From 44dd908c95e0d9338282c6785583a00a0169d7df Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:32:07 +0000 Subject: [PATCH 01/83] PDF scan: note each font resource dictionary once per page walk --- crates/pdf/src/import_scan.rs | 19 ++++++++++++++++--- crates/pdf/src/tests/import_fidelity.rs | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 9263bcd5a3..4f4e35dd3e 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -455,12 +455,15 @@ fn blank_tail(mut bytes: &[u8]) -> bool { } } -struct Walker<'w, 'p> { +pub(crate) struct Walker<'w, 'p> { ocgs: &'w mut Ocgs, /// The groups that are off for the interpreter (none once [`all_on`] turned them on). off: HashSet, xref: &'p craft_pdf::syntax::xref::XRef, - fonts: &'w mut FontNames, + pub(crate) fonts: &'w mut FontNames, + /// The font dictionaries ([`Dict::cache_key`]) whose fonts were noted: a form drawn again with + /// the same resources isn't read for its fonts again. + pub(crate) font_dicts: HashSet, visible: Vec, ops: usize, max_ops: usize, @@ -632,6 +635,9 @@ impl Walker<'_, '_> { } fn note_fonts(&mut self, res: &Resources<'_>) { + if !self.font_dicts.insert(res.fonts.cache_key()) { + return; + } for key in res.fonts.keys() { if let Some(f) = res.fonts.get::>(key.as_ref()) && let Some(name) = font_name(&f) @@ -1049,13 +1055,20 @@ pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: & if scan.cut { Err(incomplete_content()) } else { Ok(scan) } } -fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames, max_form_bytes: usize) -> Walker<'w, 'p> { +pub(crate) fn walk_page<'w, 'p>( + page: &Page<'p>, + ocgs: &'w mut Ocgs, + all_on: bool, + fonts: &'w mut FontNames, + max_form_bytes: usize, +) -> Walker<'w, 'p> { let off = if all_on { HashSet::new() } else { ocgs.off.clone() }; let mut w = Walker { ocgs, off, xref: page.xref(), fonts, + font_dicts: HashSet::new(), visible: vec![], ops: 0, max_ops: max_ops(), diff --git a/crates/pdf/src/tests/import_fidelity.rs b/crates/pdf/src/tests/import_fidelity.rs index e60f3e98c5..d66c8bd86d 100644 --- a/crates/pdf/src/tests/import_fidelity.rs +++ b/crates/pdf/src/tests/import_fidelity.rs @@ -474,6 +474,22 @@ fn a_composite_font_is_noted_by_its_cid_font_name() { assert_eq!(noted("<< /Type /Font /Subtype /Type1 /BaseFont /Vctest-Identity-H >>"), ["Vctest-Identity-H"]); } +/// A form drawn again and again without resources of its own reads the page's font dictionary once, +/// not at every drawing; the fonts are noted all the same. +#[test] +fn a_form_drawn_many_times_reads_its_font_resources_once() { + let form_no = first_extra(1); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", "0 0 1 rg 50 50 30 30 re f"); + let resources = format!("/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X0 {form_no} 0 R >>"); + let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, &format!("BT /F1 24 Tf 10 30 Td (Hi) Tj ET {}", "/X0 Do ".repeat(500))) }; + let pdf = craft_pdf::syntax::Pdf::new(pdf_with(&[page], &[&form], None)).unwrap(); + let mut fonts = crate::import_scan::FontNames::new(); + let mut ocgs = crate::import_scan::Ocgs::read(&pdf); + let walk = crate::import_scan::walk_page(&pdf.pages()[0], &mut ocgs, false, &mut fonts, pdf.decode_limits().max_decoded_stream()); + assert_eq!(walk.font_dicts.len(), 1); + assert_eq!(walk.fonts.len(), 1); +} + /// A value of the page's font dictionary that the reader can't parse (a name with a broken `#` /// escape, a number with a stray `-`) is left out when the page's fonts are noted, and the fonts /// after it are noted. From 796e2a7ebc91709fb2303e66df1de657145ae339 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 11:00:51 -0800 Subject: [PATCH 02/83] Simplify: a closed subpath whose refits enclose no area keeps its outline Follows #6614. When a closed subpath's refit in two and in three parts is still lines that enclose no area, for example an S curve closed by a straight edge whose cut points all lie on that edge, Simplify now splits each segment in 2, 4, 8 and so on (up to 1,024 pieces, 65,536 in all) until every piece is within the tolerance of the line between its ends and those lines enclose an area. With Convert to Straight Lines those lines are the result; without it the subpath stays as it is. A subpath whose points are all in line keeps the refit's lines, so its extra anchors are still removed. Lines count as enclosing no area when every point is within 1e-6 pt of one line. --- crates/pathops/src/edit.rs | 40 +++++++++++++++++++++++++++---- crates/pathops/tests/it/ops.rs | 44 +++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index ced1db16e2..a82503391c 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use kurbo::{CubicBez, ParamCurve, PathSeg, Point, Rect, Vec2}; +use kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Rect, Vec2}; use vectorcraft_geom::path::measured_arclen; use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; @@ -85,9 +85,10 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { } /// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. A closed subpath -/// whose refit is one segment, or two lines that enclose no area, is refitted in two parts, cut at -/// the point farthest from its start, or in three when two still give that; one of a single segment -/// stays as it is. +/// whose refit is one segment, or lines that enclose no area, is refitted in two parts, cut at the +/// point farthest from its start, or in three when two still give that. When three parts still give +/// no area, straight lines follow the subpath within the tolerance, and without straight lines it +/// stays as it is, as does one of a single segment; one that is all in line keeps the refit. fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let mut segs = subpath_segs(sp); let n = segs.len(); @@ -105,7 +106,12 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let across = if sp.closed && opts.smooth > 0.0 { unit(end_tangent(&segs[n - 1].c) + start_tangent(&segs[0].c)) } else { None }; let across = across.filter(|_| (0..n).all(|i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) <= opts.corner_angle_deg)); let mut result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across); - let flat = |r: &[Seg]| r.len() < 2 || r.len() == 2 && r.iter().all(|s| s.line); + let in_line = |pts: &[Point]| { + let Some(&a) = pts.first() else { return true }; + let far = pts.iter().map(|&p| p - a).max_by(|u, v| u.hypot2().total_cmp(&v.hypot2())).unwrap_or_default(); + pts.iter().all(|&p| far.cross(p - a).abs() <= 1e-6 * far.hypot()) + }; + let flat = |r: &[Seg]| r.len() < 2 || r.iter().all(|s| s.line) && in_line(&r.iter().map(|s| s.c.p0).collect::>()); if sp.closed && flat(&result) { // Each segment in two, and the loop cut where they meet: first at the point farthest from its // start, then also at the point farthest from the line between those two. @@ -131,6 +137,30 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { if flat(&result) && k != j { result = refit(&[0, j.min(k), j.max(k), m]); } + let ctrl: Vec = segs.iter().flat_map(|s| [s.c.p0, s.c.p1, s.c.p2]).collect(); + if flat(&result) && !in_line(&ctrl) { + // Each segment split in 2, 4, 8 and so on, up to 1,024 pieces and 65,536 in all, until every + // piece is within the tolerance of the line between its ends and those lines enclose an + // area: with straight lines they are the result, and without them the loop stays as it is. + // A loop whose points are all in line keeps the lines of the refit. + let near = |c: &CubicBez| [c.p1, c.p2].iter().all(|&p| Line::new(c.p0, c.p3).nearest(p, 1e-9).distance_sq.sqrt() <= opts.tolerance); + for k in 1..=10u32 { + let parts = 1u32 << k; + if segs.len().saturating_mul(parts as usize) > 1 << 16 { + break; + } + let at = |i: u32| f64::from(i) / f64::from(parts); + let pieces: Vec = segs.iter().flat_map(|s| (0..parts).map(move |i| s.c.subsegment(at(i)..at(i + 1)))).collect(); + let lines: Vec = pieces.iter().map(|c| Seg::line(c.p0, c.p3)).collect(); + if pieces.iter().all(near) && !flat(&lines) { + if !opts.straight_lines { + return Some(sp.clone()); + } + result = lines; + break; + } + } + } } let s = segs_to_subpath(&result, sp.closed)?; Some(if opts.straight_lines { s } else { no_worse(sp, s) }) diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 01e89efe08..9a6fb57138 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -424,17 +424,59 @@ fn simplify_keeps_a_closed_subpath_that_refits_to_one_curve() { ], true, )); + // A straight edge and an S curve whose midpoints all lie on that edge. + let s_curve = |h: f64, x: f64| { + PathData::single(SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(0.0, h)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(x, -h), Point::new(100.0, 0.0)), + ], + true, + )) + }; + let (s_low, s_high) = (s_curve(3.0, 0.0), s_curve(13.5, 100.0)); for straight_lines in [false, true] { for tolerance in [4.0, 10.0, 32.0] { let opts = SimplifyOptions { tolerance, straight_lines, ..Default::default() }; - for path in [&compound, &lone, &bow] { + for path in [&compound, &lone, &bow, &s_low] { let s = simplify_with(path, &opts); assert_eq!(s.subpaths.len(), path.subpaths.len(), "{tolerance} pt, straight lines {straight_lines}: {s:?}"); assert!(s.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 2 && sp.area().abs() > 1.0), "{s:?}"); assert!(max_deviation(path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(path, &s)); + let lines = s.subpaths.iter().all(|sp| (0..sp.segment_count()).all(|i| sp.segment_is_line(i))); + assert!(lines || !straight_lines, "{tolerance} pt: {s:?}"); } } } + // Lobes on both sides of the edge, 3.9 pt from it, stay within the tolerance. + for straight_lines in [false, true] { + let s = simplify_with(&s_high, &SimplifyOptions { tolerance: 4.0, straight_lines, ..Default::default() }); + assert!(max_deviation(&s_high, &s) <= 4.0 + 1e-6, "straight lines {straight_lines}: {}", max_deviation(&s_high, &s)); + } + // Curves whose handles lie on the line between their anchors become lines. + let thin = PathData::single(SubPath::new( + vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(30.0, 0.0)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(70.0, 0.0), Point::new(80.0, 0.0)), + ], + true, + )); + let s = simplify_with(&thin, &SimplifyOptions { tolerance: 4.0, straight_lines: true, ..Default::default() }); + assert!(s.subpaths.iter().all(|sp| (0..sp.segment_count()).all(|i| sp.segment_is_line(i))), "{s:?}"); + // Anchors in line on a closed path are still removed. + let in_line = PathData::single(SubPath::new([0.0, 25.0, 50.0, 100.0].map(|x| Anchor::corner(Point::new(x, 0.0))).to_vec(), true)); + let s = simplify_with(&in_line, &SimplifyOptions { tolerance: 4.0, ..Default::default() }); + assert!(s.subpaths[0].anchors.len() < 4, "{s:?}"); + let many = PathData::single(SubPath::new((0..5000).map(|i| Anchor::corner(Point::new(f64::from(i), 0.0))).collect(), true)); + for tolerance in [4.0, f64::INFINITY, f64::NAN] { + for path in [&in_line, &thin, &many] { + simplify_with(path, &SimplifyOptions { tolerance, straight_lines: true, ..Default::default() }); + } + } + // A long, thin rectangle keeps its corners. + let long = rect(0.0, 0.0, 1e9, 0.5); + let s = simplify_with(&long, &SimplifyOptions { tolerance: 0.1, straight_lines: true, ..Default::default() }); + assert!(max_deviation(&long, &s) <= 0.1 + 1e-6, "{s:?}"); } /// The Simplify slider's levels: 1 pt at 50, doubled for every 10 levels down and halved for every From 6723d09328c0ecbaa5b8f081ea510202d46283a6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 11:02:36 -0800 Subject: [PATCH 03/83] Plug-ins: the approval dialog takes OK only once its question was shown for half a second The plug-in approval dialog's OK stays disabled until the question has been on screen in the focused window for 0.5 s. A click, Enter or Space before then, a press begun before then, and a direct confirm install nothing and leave the dialog open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click lands is not approved by that click. Cancel and Escape work at once. --- crates/ui-egui/src/dialogs/plugin.rs | 87 ++++++++++- crates/ui-egui/src/io/open.rs | 2 +- crates/ui-egui/src/tests/automation.rs | 4 + crates/ui-egui/src/tests/plugin_runs.rs | 1 + crates/ui-egui/src/tests/plugins.rs | 183 ++++++++++++++++++++++++ docs/mcp.md | 4 +- docs/plugins.md | 7 +- docs/target-app-parity.md | 1 + 8 files changed, 280 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 76f71f3b4e..203f30e615 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -16,7 +16,9 @@ //! //! Dialog `pluginInstall` asks a person to approve a plug-in's permissions or heavy budget. What it //! shows and installs is held by the app, not in its fields; a control request or synthetic input -//! can't confirm it. Enter confirms it only when OK has the keyboard focus. +//! can't confirm it. Enter confirms it only when OK has the keyboard focus. OK takes no click, Enter +//! or Space until the question has been on screen in the focused window for [`APPROVAL_DELAY`], and +//! that time starts over for every new question and whenever the window loses the focus. use std::sync::Arc; @@ -329,6 +331,69 @@ pub(crate) struct PendingInstall { pub params: Value, /// What OK approves: the module the question is about. pub approval: InstallApproval, + /// How long the question has been on screen; [`ask_install`] starts it over. + pub shown: Shown, +} + +/// How long, in seconds, the approval's question stays on screen in the focused window, on every +/// frame and unchanged, before its OK takes a click, Enter or Space. +pub(crate) const APPROVAL_DELAY: f64 = 0.5; + +/// How long the approval's question has been on screen, from the frames that drew it. +#[derive(Default)] +pub(crate) struct Shown { + /// The time of the first frame of the current showing: drawn, visible, in the focused window, + /// right after a frame that drew it too. `None` while it isn't shown. + since: Option, + /// The pass number and time of the last frame that drew it. + pass: Option, + last: Option, + /// A pointer button went down before OK took input, and hasn't been let go of. + pressed_early: bool, + /// OK takes input: shown for [`APPROVAL_DELAY`], with no press from before that held. + ready: bool, +} + +impl Shown { + /// Notes a frame that draws the question in `ui` → whether OK is enabled on it. The showing + /// starts over when the frame before didn't draw it, when time went back, and on any frame + /// that is a measuring pass, is invisible or comes while the window doesn't have the focus. + fn drawn(&mut self, ui: &egui::Ui) -> bool { + let pass = ui.ctx().cumulative_pass_nr(); + let (now, focused, pressed) = ui.input(|i| (i.time, i.focused, i.pointer.any_pressed())); + let seen = focused && ui.is_visible() && !ui.is_sizing_pass() && now.is_finite(); + let follows = self.pass.is_some_and(|p| p.checked_add(1) == Some(pass)) && self.last.is_some_and(|t| t <= now); + if !seen || !follows { + self.since = None; + } + if seen && self.since.is_none() { + self.since = Some(now); + } + (self.pass, self.last) = (Some(pass), Some(now)); + let enabled = self.since.is_some_and(|s| now - s >= APPROVAL_DELAY); + if pressed { + self.pressed_early = !enabled; + } + self.ready = enabled && !self.pressed_early; + if let Some(s) = self.since.filter(|_| !enabled) { + ui.ctx().request_repaint_after_secs((s + APPROVAL_DELAY - now) as f32); + } + enabled + } + + /// After the frame's OK: a press from before OK took input stops counting once every pointer + /// button is up. + fn released(&mut self, ui: &egui::Ui) { + if !ui.input(|i| i.pointer.any_down()) { + self.pressed_early = false; + } + } + + /// Whether OK takes input: the question was shown for [`APPROVAL_DELAY`] on the last frame + /// that drew it. + pub(crate) fn ready(&self) -> bool { + self.ready + } } /// No OK in the shared footer: the OK in its body is a `Sense::click` button, which Enter clicks @@ -342,15 +407,20 @@ pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { ..DialogSpec::FORM }; -/// Asks a person whether to install what `pending` describes. → `{pending}`. -pub(crate) fn ask_install(app: &mut VectorcraftApp, pending: PendingInstall) -> Value { +/// Asks a person whether to install what `pending` describes, its question not yet shown, even when +/// it reads as the one it replaces. → `{pending}`. +pub(crate) fn ask_install(app: &mut VectorcraftApp, mut pending: PendingInstall) -> Value { + pending.shown = Shown::default(); app.pending_plugin_install = Some(pending); app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({}))); json!({ "pending": INSTALL_KIND }) } +/// The question, the lines under it, and OK, which is disabled until the question has been shown +/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> DialogAction { - let Some(pending) = app.pending_plugin_install.as_ref() else { return DialogAction::Cancel }; + let Some(pending) = app.pending_plugin_install.as_mut() else { return DialogAction::Cancel }; + let enabled = pending.shown.drawn(ui); ui.label(egui::RichText::new(&pending.message).strong()); ui.add_space(8.0); ui.label(egui::RichText::new(&pending.detail).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); @@ -358,9 +428,10 @@ fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> let mut action = DialogAction::None; let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::primary_button_enabled(ui, "OK", true).clicked() { + if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && pending.shown.ready() { action = DialogAction::Confirm; } + pending.shown.released(ui); ui.add_space(8.0); if widgets::secondary_button(ui, "Cancel").clicked() { action = DialogAction::Cancel; @@ -370,11 +441,15 @@ fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> } /// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the -/// dialog stays open. +/// dialog stays open. Before the question has been shown for [`APPROVAL_DELAY`] it installs nothing +/// and the dialog stays open. → `{pending}`. fn install_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { if app.automation_is_acting() { return Err("Only the person at the keyboard can approve installing a plug-in".into()); } + if app.pending_plugin_install.as_ref().is_some_and(|p| !p.shown.ready()) { + return Ok(json!({ "pending": INSTALL_KIND })); + } app.ui.dialog = None; let Some(pending) = app.pending_plugin_install.take() else { return Ok(Value::Null) }; app.session.approve_plugin_install(pending.approval); diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 50f13e695e..88375f3a85 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -279,7 +279,7 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result; + +/// The dialog layer of `app` in a window, each frame drawn at the time [`at`] gives it. Building it +/// draws frames at times of its own, so a test opens the dialog after. +fn window(app: VectorcraftApp) -> Window { + let mut fonts = false; + egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + theme::install_fonts(ui.ctx()); + return; + } + dialogs::show(app, ui.ctx()); + }, + app, + ) +} + +/// Draws a frame of `h` at `time` seconds. +fn at(h: &mut Window, time: f64) { + h.input_mut().time = Some(time); + h.step(); +} + +/// Whether the approval's OK is enabled, as a screen reader reads it. +fn ok_enabled(h: &Window) -> bool { + use egui_kittest::kittest::{NodeT, Queryable}; + !h.get_by_label("OK").accesskit_node().is_disabled() +} + +/// Clicks the approval's OK as a person does, on a frame at `time` seconds. +fn click_ok(h: &mut Window, time: f64) { + use egui_kittest::kittest::Queryable; + h.get_by_label("OK").click(); + at(h, time); +} + +/// The approval is open and plug-in `id` neither installed nor granted. +fn waiting(app: &VectorcraftApp, id: &str) -> bool { + app.ui.dialog.as_ref().is_some_and(|d| d.kind == dialogs::plugin::INSTALL_KIND) + && vectorcraft_plugins::registry::get(id).is_none() + && vectorcraft_plugins::registry::granted(id).is_none() +} + fn red_rect(app: &mut VectorcraftApp) -> vectorcraft_doc::NodeId { let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); @@ -163,6 +225,7 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { // File › Open asks too, and OK installs it with what it lists granted. io::open_bytes(&mut app, "tidy.wasm", &bytes, None).unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND)); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); @@ -195,6 +258,7 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); assert_ne!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(again_hash), "Cancel records no grant"); app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none(), "one question"); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); @@ -209,11 +273,128 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "Cancel keeps the one installed"); // OK keeps the `replace` it was asked with: `false` leaves the installed one in place. app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more), "replace": false})).unwrap(); + show_approval(&mut app); let _ = dialogs::confirm(&mut app); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// The approval's OK takes no click, Enter or Space, and a confirm installs nothing, until its +/// question has been on screen for [`dialogs::plugin::APPROVAL_DELAY`]; then a click installs it. +/// Cancel works on the first frame. +#[test] +fn the_install_approval_takes_ok_only_once_its_question_was_shown_for_a_moment() { + use egui_kittest::kittest::Queryable; + let id = "test.ui.approval.delay"; + let module = json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Delay", r#"["doc.read"]"#))}); + let mut h = window(app()); + h.state_mut().run("ui.installPlugin", module.clone()).unwrap(); + assert_eq!(dialogs::confirm(h.state_mut()).unwrap(), json!({"pending": dialogs::plugin::INSTALL_KIND}), "not drawn yet"); + assert!(waiting(h.state(), id)); + at(&mut h, 10.0); + at(&mut h, 10.0); + assert!(!ok_enabled(&h), "disabled when it first shows"); + click_ok(&mut h, 10.1); + assert!(waiting(h.state(), id), "a click"); + for (key, time) in [(egui::Key::Enter, 10.2), (egui::Key::Space, 10.3)] { + h.get_by_label("OK").focus(); + at(&mut h, time); + h.key_press(key); + at(&mut h, time + 0.05); + assert!(waiting(h.state(), id), "{key:?}"); + } + at(&mut h, 10.49); + assert!(!ok_enabled(&h), "still disabled just before the delay"); + assert_eq!(dialogs::confirm(h.state_mut()).unwrap()["pending"], dialogs::plugin::INSTALL_KIND, "a confirm"); + assert!(waiting(h.state(), id)); + at(&mut h, 10.5); + assert!(ok_enabled(&h), "enabled once shown for the delay"); + click_ok(&mut h, 10.6); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + assert!(vectorcraft_plugins::registry::get(id).is_some()); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); + + h.state_mut().run("ui.installPlugin", module).unwrap(); + at(&mut h, 20.0); + at(&mut h, 20.0); + h.get_by_label("Cancel").click(); + at(&mut h, 20.0); + assert!(h.state().ui.dialog.is_none() && h.state().pending_plugin_install.is_none(), "Cancel doesn't wait"); + assert!(vectorcraft_plugins::registry::get(id).is_none()); +} + +/// The wait starts over for each question: one that takes the place of the question shown just +/// before a click lands, which the click doesn't approve; the same question asked again; and the +/// question shown when the window gets the focus back. +#[test] +fn the_install_approval_waits_again_for_a_new_question_and_after_the_focus_comes_back() { + let (a, b) = ("test.ui.approval.swap.a", "test.ui.approval.swap.b"); + let module = |id: &str| json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Same", r#"["doc.read"]"#))}); + let mut h = window(app()); + h.state_mut().run("ui.installPlugin", module(a)).unwrap(); + at(&mut h, 10.0); + at(&mut h, 10.0); + at(&mut h, 10.6); + assert!(ok_enabled(&h)); + // Between two frames, a request closes the question and asks about another module. + dialogs::cancel(h.state_mut()); + h.state_mut().run("ui.installPlugin", module(b)).unwrap(); + click_ok(&mut h, 10.7); + assert!(waiting(h.state(), b) && vectorcraft_plugins::registry::get(a).is_none(), "the click approves neither"); + assert!(!ok_enabled(&h)); + at(&mut h, 11.19); + assert!(!ok_enabled(&h)); + at(&mut h, 11.2); + assert!(ok_enabled(&h), "the new question waits its own delay"); + // The same question again, from the same bytes. + dialogs::cancel(h.state_mut()); + h.state_mut().run("ui.installPlugin", module(b)).unwrap(); + at(&mut h, 11.3); + assert!(!ok_enabled(&h), "asked again, it waits again"); + at(&mut h, 11.8); + assert!(ok_enabled(&h)); + // The window loses the focus and gets it back. + h.input_mut().focused = false; + at(&mut h, 11.9); + assert!(!ok_enabled(&h), "not while the window doesn't have the focus"); + assert_eq!(dialogs::confirm(h.state_mut()).unwrap()["pending"], dialogs::plugin::INSTALL_KIND); + h.input_mut().focused = true; + at(&mut h, 12.0); + at(&mut h, 12.49); + assert!(!ok_enabled(&h), "back in front, it waits again"); + at(&mut h, 12.5); + click_ok(&mut h, 12.6); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + assert!(vectorcraft_plugins::registry::get(b).is_some() && vectorcraft_plugins::registry::get(a).is_none()); + h.state_mut().run("plugin.remove", json!({"id": b})).unwrap(); +} + +/// A press on OK that began before OK took input approves nothing when it is let go after; a new +/// click does. +#[test] +fn a_press_on_the_install_approval_from_before_ok_took_input_approves_nothing() { + use egui_kittest::kittest::Queryable; + let id = "test.ui.approval.held"; + let mut h = window(app()); + let module = json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "Held", r#"["doc.read"]"#))}); + h.state_mut().run("ui.installPlugin", module).unwrap(); + at(&mut h, 10.0); + at(&mut h, 10.0); + let pos = h.get_by_label("OK").rect().center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + h.input_mut().events.extend([egui::Event::PointerMoved(pos), button(true)]); + at(&mut h, 10.1); + at(&mut h, 10.6); + assert!(ok_enabled(&h)); + h.input_mut().events.push(button(false)); + at(&mut h, 10.7); + assert!(waiting(h.state(), id), "the press began before OK took input"); + click_ok(&mut h, 10.8); + assert!(h.state().ui.dialog.is_none(), "{}", h.state().ui.status); + assert!(vectorcraft_plugins::registry::get(id).is_some()); + h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); +} + /// A module with other bytes than the one installed with its id asks before taking its place: in /// the ordinary confirm when it needs nothing that only a person can approve, and in the approval /// dialog, with its id and file hash, when it also asks for a heavy budget. @@ -246,6 +427,7 @@ fn installing_a_plugin_over_another_module_asks_first() { let pending = app.pending_plugin_install.as_ref().unwrap(); assert!(pending.detail.ends_with(&format!("\n\nID: {id}\nFile SHA-256: {heavy_hash}\n\n{takes_place}")), "{}", pending.detail); assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed"); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(heavy_hash)); app.run("plugin.remove", json!({"id": id})).unwrap(); @@ -404,6 +586,7 @@ fn installing_a_plugin_that_reports_progress_says_so() { assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND); let detail = app.pending_plugin_install.as_ref().unwrap().detail.clone(); assert!(detail.ends_with("• Read the objects it is given\n• Show its progress, see Cancel and write log lines"), "{detail}"); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2)); app.run("plugin.remove", json!({"id": id})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 5d4c5cdbd8..2ba77e5b87 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7443,7 +7443,9 @@ runs it where it is called, so the call may take minutes; `notifications/cancell A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) needs a person's approval too, and `plugin.install` refuses `grant: [permission]` from a request. With a desktop app connected, `ui.installPlugin` opens the approval dialog and returns `{pending: "pluginInstall"}`; `answer_dialog`, -`ui.dialog.confirm` and injected input can't confirm it. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` +`ui.dialog.confirm` and injected input can't confirm it. Its OK takes input only once the question has been on screen +in the focused window for half a second, counted again for every new question and after the window gets the focus +back. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with `--connect`, `--automation-read-root` or `--automation-write-root`). A server confined to folders has no plug-in store, so it reads no `grants.json`, and a module that lists permissions or asks for a heavy budget does not install diff --git a/docs/plugins.md b/docs/plugins.md index 06fd89f28a..2e6e792625 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -212,7 +212,12 @@ capability model proposed for PhotoCraft plug-ins names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected input (`ui.key`, `ui.click`) are refused and the dialog stays open. Enter confirms it only when OK has the keyboard focus. `ui.dialog.set` can't - change what it installs. + change what it installs. OK stays disabled until the question has been on screen in the focused window for half a + second (VectorCraft's own value, from the delay the security guidance at + https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md + gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second + starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel + and Escape work at once. - `plugin.install` refuses the module, naming the permissions it needs, unless this module already has a recorded grant (`plugin.inspect`'s `needsApproval` tells it beforehand). A request's `grant` is refused too: `plugin.install` takes it only with the approval the dialog gives. A `vectorcraft-cli mcp` or `run` started with `--allow-plugin-grants` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9599a6373d..80186e7bce 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1652,6 +1652,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. +- **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. From f1c17603bb3464b41fc8af1335c31440acc9f106 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 10:43:05 -0800 Subject: [PATCH 04/83] Scripts: #targetengine runs a script in a named engine that keeps its globals A script whose first lines name an engine with `#targetengine name` (or `//@targetengine name`) runs in that engine, and a later script that names it sees the globals and functions earlier runs left, until the engine is dropped or the session ends. A script without the line, and `#targetengine main`, still get a new engine for each run. - A session keeps at most 8 named engines, each with a 64 MiB heap. A run that goes past the heap ends with the heap limit, and the engine stays usable. Names are 1 to 64 bytes. The line comes once, in the script itself, before its code and its #include lines. - An uncaught error, a limit or script.stop puts the documents back and keeps the globals the run set. An internal error drops the engine. A run stopped at a dialog or left paused ends where it was before the engine is kept. An error in a function an earlier run defined is reported without a line and column. - `vc`, `$`, `alert`, `confirm` and `prompt` are made once for each engine and act for the run that calls them, including copies an earlier run kept in a global. `vc.args` gives a read-only copy of the calling run's `args`, and what a run sets on `$` lasts for that run only. - `script.engines` lists the named engines with their heap, and `script.engine.drop {name}` drops one. Scripts cannot run either. - The engines stay on the thread that made them. A session used on another thread starts there with no named engines, and the old ones are dropped on their own thread. - The web build has no named engines: a run that names one other than `main` ends with an error before any of it runs. - `Realm::end` ends a run where it is, `Realm::error_source` counts from the realm's last script, and `HostItem::FrozenGetter` gives a frozen value each time it is read. - The script checker lists `#targetengine` as a supported directive. Docs: docs/mcp.md, docs/target-app-parity.md, docs/gaps.md. --- crates/engine/src/cmd/scripts.rs | 36 +- crates/engine/src/lib.rs | 2 + crates/engine/src/script/dialogs.rs | 8 +- crates/engine/src/script/dollar.rs | 79 +++-- crates/engine/src/script/include.rs | 120 +++++-- crates/engine/src/script/mod.rs | 393 ++++++++++++++++++++-- crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/script_engines.rs | 391 +++++++++++++++++++++ crates/engine/src/tests/script_include.rs | 2 +- crates/engine/src/tests/script_run.rs | 2 +- crates/js/src/host.rs | 14 +- crates/js/src/host_tests.rs | 48 +++ crates/js/src/realm.rs | 31 +- crates/scriptcheck/src/catalog.rs | 2 +- crates/scriptcheck/src/tests.rs | 2 +- docs/gaps.md | 5 +- docs/mcp.md | 50 ++- docs/target-app-parity.md | 3 +- 19 files changed, 1082 insertions(+), 108 deletions(-) create mode 100644 crates/engine/src/tests/script_engines.rs diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 493ba68db7..a9a5d0fe60 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 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?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), 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; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; 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 on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #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, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), 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), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, 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", + "{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?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), 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), $.includePath the #includepath list and $.engineName the engine's name (main without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; 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 on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; not on the web); #engine is 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, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), 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), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, 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 ), @@ -45,6 +45,24 @@ pub fn specs() -> Vec { always, stop ), + cmd!( + query "script.engines", + "List Script Engines", + [], + None, + "{} the named engines (#targetengine) this session keeps, in the order they were made: a named engine keeps its globals between the runs that name it until it is dropped or the session ends; main, the engine of a script without #targetengine, is new for each run and is not listed; a session keeps at most 8, each with at most 64 MiB of heap (VectorCraft's own limits; none on the web) → {engines: [{name, heapBytes: its heap after its last run, running: a run has it now}], limit: 8, heapLimit: 67108864}", + always, + engines + ), + cmd!( + query "script.engine.drop", + "Drop Script Engine", + [], + None, + "{name: a named engine script.engines lists} drop the engine and the globals its runs left; the next run that names it gets a new one; main, an engine no run made and the engine of the running script are refused → {dropped: true, name}", + always, + engine_drop + ), cmd!( query "script.console", "Script Console", @@ -175,6 +193,22 @@ fn stop(s: &mut Session, _: &Value) -> Result { Ok(json!({"stopped": s.script_request_stop()})) } +fn engines(s: &mut Session, _: &Value) -> Result { + let list: Vec = + s.script_engines().into_iter().map(|(name, heap, running)| json!({"name": name, "heapBytes": heap, "running": running})).collect(); + Ok(json!({"engines": list, "limit": script::MAX_ENGINES, "heapLimit": script::ENGINE_HEAP_BYTES})) +} + +fn engine_drop(s: &mut Session, p: &Value) -> Result { + const C: &str = "script.engine.drop"; + let name = match p.get("name") { + Some(Value::String(name)) if !name.is_empty() => name.clone(), + _ => return Err(bad(C, "`name` must be the name of a script engine")), + }; + s.drop_script_engine(&name)?; + Ok(json!({"dropped": true, "name": name})) +} + fn console_lines(s: &mut Session, _: &Value) -> Result { Ok(s.script_console().to_json()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..7d98e65375 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -258,6 +258,8 @@ pub struct Session { pub recovery: cmd::recovery::Recovery, /// The running script's job, while there is one: the mutation lease ([`script`]). pub(crate) script_key: Option, + /// The named script engines this session keeps ([`script`]). + pub(crate) script_engines: script::EnginesKey, /// Stops the running script at its next instruction; the same flag for every run. pub(crate) script_stop: vectorcraft_js::Interrupt, /// The report of the run that ended last. diff --git a/crates/engine/src/script/dialogs.rs b/crates/engine/src/script/dialogs.rs index 1faf624f16..a674396f4b 100644 --- a/crates/engine/src/script/dialogs.rs +++ b/crates/engine/src/script/dialogs.rs @@ -290,12 +290,12 @@ impl Dialogs { } } -/// Adds `alert`, `confirm` and `prompt` to the realm. -pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { +/// Adds `alert`, `confirm` and `prompt` to the realm, for the run `link` holds. +pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> { use Arg::{Bool, Text, TextOrUndefined}; let call = |kind: DialogKind| -> HostFunction { - let h = host.clone(); - Rc::new(move |args| open(&h, kind, &args)) + let l = link.clone(); + Rc::new(move |args| open(&*super::current(&l)?, kind, &args)) }; realm.define_host_function("alert", vec![Text, TextOrUndefined, Bool], call(DialogKind::Alert))?; realm.define_host_function("confirm", vec![Text, Bool, TextOrUndefined], call(DialogKind::Confirm))?; diff --git a/crates/engine/src/script/dollar.rs b/crates/engine/src/script/dollar.rs index 3e6b005844..fcf6dbf542 100644 --- a/crates/engine/src/script/dollar.rs +++ b/crates/engine/src/script/dollar.rs @@ -4,14 +4,15 @@ //! the locale the global `localize` reads //! (). //! -//! Every value here belongs to one run: a run starts with the locale of the interface, the -//! environment values of [`ENV_NAMES`] read from the process, localization off and a timer at zero, -//! and what a script changes ends with its run. `$.setenv` never changes the process's environment. +//! Every value here belongs to one run, in a named engine too ([`RunState`]): a run starts with the +//! locale of the interface, the environment values of [`ENV_NAMES`] read from the process, +//! localization off and a timer at zero, and what a script changes ends with its run. `$.setenv` +//! never changes the process's environment. //! //! These are VectorCraft's own values and rules: `$.appEncoding` is always UTF-8; `$.build` and -//! `$.version` give VectorCraft's version, `$.engineName` is `VectorCraft` and `$.buildDate` is -//! `null`; `$.flags` takes only 512, which turns localization on; `$.level` and `$.memCache` are 0 -//! and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the +//! `$.version` give VectorCraft's version, `$.engineName` is the name `#targetengine` gave, or +//! `VectorCraft` without one, and `$.buildDate` is `null`; `$.flags` takes only 512, which turns +//! localization on; `$.level` and `$.memCache` are 0 and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the //! realm reads scripts as strict mode code; `$.bp` and `$.colorPicker` throw, since scripts have no //! debugger and cannot show a dialog; `$.gc` does nothing, since the interpreter collects its own //! garbage; `$.getenv` and `$.setenv` know only [`ENV_NAMES`], with values of at most @@ -25,7 +26,7 @@ use std::time::Duration; use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem}; -use super::{Host, function}; +use super::{Link, current, function}; /// The longest `$.sleep`, in milliseconds. pub const MAX_SLEEP_MS: f64 = 1_000.0; @@ -36,12 +37,34 @@ pub const MAX_ENV_BYTES: usize = 64; /// The value of `$.flags` that turns localization on, the only one a script may set. pub const LOCALIZE_FLAG: f64 = 512.0; -/// What `$` knows of a run. +/// What `$` knows of its realm. pub(crate) struct RunInfo { - /// The locale the run starts with, such as `de_DE`. - pub locale: String, /// The realm reads scripts as strict mode code. pub strict: bool, + /// The name `#targetengine` gave the realm's engine. + pub engine: Option, +} + +/// The values of `$` that belong to one run, which the run's host holds. +pub(crate) struct RunState { + /// The locale the run starts with, such as `de_DE`. + locale: String, + env: RefCell)>>, + /// The time since the run started at which `$.hiresTimer` was last read. + timer: Cell, +} + +impl RunState { + pub(crate) fn new(locale: &str) -> RunState { + RunState { locale: locale.to_string(), env: RefCell::new(process_env()), timer: Cell::new(Duration::ZERO) } + } +} + +/// Sets `localization` as a run starts: `locale`, this system's platform and localization off. +pub(super) fn start_run(localization: &Localization, locale: &str) { + localization.set_enabled(false); + localization.set_locale(locale); + localization.set_platform(platform()); } /// The locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language, @@ -148,14 +171,10 @@ fn text(s: impl Into) -> Data { Data::String(s.into()) } -/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. -pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { +/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. They +/// act for the run that `link` holds when they are called. +pub(super) fn members(link: &Link, info: RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let version = env!("CARGO_PKG_VERSION"); - let host_locale = info.locale.clone(); - localization.set_locale(&info.locale); - localization.set_platform(platform()); - let env = Rc::new(RefCell::new(process_env())); - let timer = Rc::new(Cell::new(Duration::ZERO)); let getter = |f: Rc Data>| HostItem::Getter(function(move |_| Ok(f()))); let property = |get: Rc Data>, set: Rc Result<(), HostError>>| HostItem::Property { get: function(move |_| Ok(get())), @@ -167,13 +186,13 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc host_locale.clone(), - Data::String(t) if t.is_empty() => host_locale.clone(), + Data::Null | Data::Undefined => current(&run)?.dollar.locale.clone(), + Data::String(t) if t.is_empty() => current(&run)?.dollar.locale.clone(), Data::String(t) => script_locale(&t).ok_or_else(|| HostError::Range(BAD_LOCALE.to_string()))?, _ => return Err(HostError::Type(BAD_LOCALE.to_string())), }; @@ -224,31 +243,34 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc, info: RunInfo, localization: &Rc *n, Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))), @@ -282,7 +305,7 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc, pub strict: Option, + pub engine: Option<(String, u32, u32)>, +} + +/// What the preprocessor lines of a file have named so far: its `#script` name and its engine, and +/// whether code or an `#include` line has come before. +#[derive(Default)] +struct Named { + name: Option, + engine: Option<(String, u32, u32)>, + code_seen: bool, +} + +/// Are `units` only blanks and comments? +fn only_blanks_and_comments(units: &[u16]) -> bool { + let mut i = 0; + while let Some(&u) = units.get(i) { + match u { + 0x2F if units.get(i + 1) == Some(&0x2F) => { + while units.get(i).is_some_and(|&c| !matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029)) { + i += 1; + } + } + 0x2F if units.get(i + 1) == Some(&0x2A) => { + let Some(end) = units.get(i + 2..).and_then(|rest| rest.windows(2).position(|w| w == [0x2A, 0x2F])) else { return false }; + i += end + 4; + } + 0xFEFF => i += 1, + u if char::from_u32(u32::from(u)).is_some_and(char::is_whitespace) => i += 1, + _ => return false, + } + } + true +} + +/// The engine name of a `#targetengine` line's value `value` (as written, quotes included), or what +/// is wrong with it. +fn engine_name(value: &str) -> Result { + let name = match value.chars().next() { + None => return Err("needs a name"), + Some(q @ ('"' | '\'')) => { + let inner = value.get(1..).and_then(|v| v.strip_suffix(q)).ok_or("needs a closing quote after the name")?; + if inner.contains(q) { + return Err("takes one name"); + } + if inner.chars().any(char::is_control) { + return Err("needs a name without control characters"); + } + inner + } + _ => { + let mut chars = value.chars(); + let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_'); + if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err("needs a name of letters, digits and _, or a quoted name"); + } + value + } + }; + if name.is_empty() { + return Err("needs a name"); + } + if name.len() > MAX_ENGINE_NAME_BYTES { + return Err("needs a name of at most 64 bytes"); + } + Ok(name.to_string()) } /// A file being run: its name for people and the folder its relative paths start from (none for a @@ -342,8 +415,8 @@ impl Files { /// 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(), strict: None }; - let mut name = None; - self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?; + let mut named = Named::default(); + self.file(&mut out, shown, dir.as_deref(), text, script, &mut named)?; let source = String::from_utf16(&out.units).map_err(|_| Failure { kind: FailKind::Syntax, message: "the script is not valid text".into(), @@ -352,20 +425,12 @@ impl Files { line: 0, column: 0, })?; - Ok(Expanded { source, map: out.map, name, strict: out.strict }) + Ok(Expanded { source, map: out.map, name: named.name, strict: out.strict, engine: named.engine }) } /// 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> { + fn file(&mut self, out: &mut Out, shown: Arc, dir: Option<&Path>, text: &str, script: bool, named: &mut Named) -> Result<(), Failure> { let index = out.map.files.len(); out.map.files.push(shown.clone()); let units: Vec = text.encode_utf16().collect(); @@ -385,7 +450,11 @@ impl Files { 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()); + let before = units.get(cursor..start).unwrap_or_default(); + if !named.code_seen && !only_blanks_and_comments(before) { + named.code_seen = true; + } + out.emit(before); cursor = end; let label = format!("{}{}", d.form, d.name); if d.value.len() > MAX_VALUE_BYTES { @@ -397,11 +466,12 @@ impl Files { if value.is_empty() { return Err(at(FailKind::Syntax, format!("{label} needs a file name"))); } + named.code_seen = true; 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); + let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, named); self.chain.pop(); self.levels -= 1; r?; @@ -416,7 +486,7 @@ impl Files { return Err(at(FailKind::Syntax, format!("{label} needs a name"))); } if script && index == 0 { - *name = Some(value.to_string()); + named.name = Some(value.to_string()); } } Some(DirectiveName::Target) => { @@ -429,7 +499,19 @@ impl Files { "on" => out.strict = Some(true), _ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))), }, - Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))), + Some(DirectiveName::TargetEngine) => { + if !script || index > 0 { + return Err(at(FailKind::Syntax, format!("{label} may only be in the script itself, not in a file it runs"))); + } + if named.engine.is_some() { + return Err(at(FailKind::Syntax, format!("the script has more than one {label} line"))); + } + if named.code_seen { + return Err(at(FailKind::Syntax, format!("{label} must come before the script's code and #include lines"))); + } + let engine = engine_name(&d.value).map_err(|why| at(FailKind::Syntax, format!("{label} {why}")))?; + named.engine = Some((engine, d.line, column)); + } None if d.name.eq_ignore_ascii_case("engine") => { return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))); } diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 213cff53fd..b3067b2caa 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -36,6 +36,13 @@ //! //! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the //! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`. +//! +//! A script with `#targetengine name` runs in that named engine, which the session keeps with the +//! globals its runs leave, at most [`MAX_ENGINES`] of them with [`ENGINE_HEAP_BYTES`] of heap each +//! (VectorCraft's own limits). A script without the line, or with `main`, gets a new realm for each +//! run. An engine keeps no document and no run: its host functions reach the run through a link +//! that holds the run's host only while the run goes on. An error, a limit or a stop puts the +//! documents back and keeps the engine's globals; an internal error drops the engine. pub mod console; pub mod dialogs; @@ -193,6 +200,150 @@ fn with_job(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.id).map(f)) } +/// The most named engines (`#targetengine`) a session keeps. +pub const MAX_ENGINES: usize = 8; +/// The heap a named engine may use, with what it keeps between runs. +pub const ENGINE_HEAP_BYTES: usize = 64 << 20; +/// The engine of a script without `#targetengine`, which `#targetengine main` also names: a new +/// one for each run, never kept. +pub const MAIN_ENGINE: &str = "main"; + +/// The run a script's host functions act for. A named engine keeps its functions between runs, so +/// they find the run through this link, which holds the run's [`Host`] only while the run goes on. +type Link = Rc>>>; + +/// The host of the run `link` holds, or an error when no run holds it. +fn current(link: &Link) -> std::result::Result, HostError> { + link.borrow().clone().ok_or_else(|| HostError::Error(NO_RUN.to_string())) +} + +/// A named engine: its interpreter, with the globals its runs left (`None` while a run has it), +/// the link its host functions use, and its heap after its last run. +struct Engine { + name: String, + realm: Option, + link: Link, + heap: usize, +} + +/// The named engines of one thread's sessions, by their key, each in the order they were made. +struct ThreadEngines { + thread: std::thread::ThreadId, + by_key: HashMap>, +} + +impl ThreadEngines { + fn new() -> Self { + let thread = std::thread::current().id(); + released().insert(thread, Vec::new()); + Self { thread, by_key: HashMap::new() } + } +} + +impl Drop for ThreadEngines { + fn drop(&mut self) { + released().remove(&self.thread); + } +} + +thread_local! { + /// The named engines of this thread's sessions. They are held here, and not in their session, + /// because they hold interpreters, which stay on this thread. + static ENGINES: RefCell = RefCell::new(ThreadEngines::new()); +} + +static NEXT_ENGINES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// For each thread that holds engines, the keys of the ones sessions let go of on another thread. +/// The thread drops them the next time it uses its engines; a thread that has ended has no entry, +/// since its engines ended with it. +static RELEASED_ENGINES: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +fn released() -> std::sync::MutexGuard<'static, HashMap>> { + RELEASED_ENGINES.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Lets the engines with key `id` go, for `thread`, which holds them, to drop. +fn release_engines(thread: std::thread::ThreadId, id: u64) { + if let Some(ids) = released().get_mut(&thread) { + ids.push(id); + } +} + +/// A session's hold on its named engines: their key, and the thread that holds them once a run +/// made one. Dropping it drops them. The engines stay on that thread, so a session used on another +/// thread lets them go and starts there with none. +#[derive(Debug)] +pub struct EnginesKey(std::sync::Mutex<(u64, Option)>); + +impl EnginesKey { + pub(crate) fn new() -> Self { + Self(std::sync::Mutex::new((NEXT_ENGINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed), None))) + } + + /// The key of the session's engines on this thread. + fn here(&self) -> u64 { + let mut key = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let thread = std::thread::current().id(); + match key.1 { + Some(t) if t != thread => { + release_engines(t, key.0); + *key = (NEXT_ENGINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed), Some(thread)); + } + Some(_) => {} + None => key.1 = Some(thread), + } + key.0 + } +} + +impl Drop for EnginesKey { + fn drop(&mut self) { + let (id, thread) = *self.0.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(t) = thread.filter(|t| *t != std::thread::current().id()) { + release_engines(t, id); + return; + } + let gone = ENGINES.try_with(|engines| engines.try_borrow_mut().ok().and_then(|mut e| e.by_key.remove(&id))); + drop(gone); + } +} + +fn with_engines(key: &EnginesKey, f: impl FnOnce(&mut Vec) -> T) -> T { + let id = key.here(); + ENGINES.with(|engines| { + let gone: Vec> = { + let mut here = engines.borrow_mut(); + let ids = released().get_mut(&here.thread).map(std::mem::take).unwrap_or_default(); + ids.iter().filter_map(|id| here.by_key.remove(id)).collect() + }; + drop(gone); + f(engines.borrow_mut().by_key.entry(id).or_default()) + }) +} + +/// Tests: the named engines this thread holds, for every session. +#[cfg(test)] +pub(crate) fn engines_on_this_thread() -> usize { + ENGINES.with(|engines| engines.borrow().by_key.values().map(Vec::len).sum()) +} + +/// Tests: whether engines can still be let go for `thread`, which holds an entry while it lives. +#[cfg(test)] +pub(crate) fn releases_kept_for(thread: std::thread::ThreadId) -> bool { + released().contains_key(&thread) +} + +/// The heap of a named engine. +fn engine_heap() -> usize { + #[cfg(test)] + if let Some(n) = TEST_ENGINE_HEAP.with(Cell::get) { + return n; + } + ENGINE_HEAP_BYTES +} + /// The state a script changes that a rollback puts back. struct World { docs: Vec, @@ -517,6 +668,10 @@ struct Host { can_wait: Rc>, /// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one). eval_strict: Cell>, + /// The values of `$` that belong to the run. + dollar: dollar::RunState, + /// The run's `args`, which `vc.args` gives, and their size as `vc.args` charges it. + args: RefCell<(Data, u64)>, } /// A run's host calls by kind, as its report gives them in `hostCallCounts`. @@ -693,6 +848,10 @@ impl Host { struct Job { realm: Realm, host: Rc, + /// The link the realm's host functions find `host` through. + link: Link, + /// The named engine the realm belongs to, which gets it back when the run ends. + engine: Option, /// Between slices the working copy; during the last slice's commit the state before the run. world: World, /// The documents open when the run began, by [`DocState::uid`]. @@ -1005,10 +1164,11 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) { /// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file /// being run and `$.includePath` the `#includepath` list. Its other members are /// [`dollar::members`]. -fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { +fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let writer = |end: bool| { - let h = host.clone(); + let l = link.clone(); HostItem::TextFunction(function(move |a| { + let h = current(&l)?; let text: String = a.iter().map(|d| if let Data::String(s) = d { s.as_str() } else { "" }).collect(); h.charge_call(text.len() as u64 + 2)?; bump(&h.counts.console_writes); @@ -1017,21 +1177,24 @@ fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc String| { - let h = host.clone(); + let l = link.clone(); HostItem::Getter(function(move |_| { + let h = current(&l)?; 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(); - let strict = host.clone(); + let (l, end, strict) = (link.clone(), link.clone(), link.clone()); HostItem::Evaluator { - source: function(move |a| eval_file(&h, a)), - end: Rc::new(move || end.files.borrow_mut().end_eval()), - strict_writes: Rc::new(move || strict.eval_strict.get()), + source: function(move |a| eval_file(&*current(&l)?, a)), + end: Rc::new(move || { + if let Ok(h) = current(&end) { + h.files.borrow_mut().end_eval(); + } + }), + strict_writes: Rc::new(move || current(&strict).ok().and_then(|h| h.eval_strict.get())), } }; let mut items = vec![ @@ -1041,7 +1204,7 @@ fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc) -> std::result::Result + 'sta Rc::new(f) } -fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { +/// The `vc` object, made once for each realm: its members act for the run `link` holds when they +/// are called. +fn vc_object(link: &Link) -> Vec<(String, HostItem)> { + let args = { + let l = link.clone(); + function(move |_| { + let h = current(&l)?; + let (args, size) = &*h.args.borrow(); + h.charge_call(*size)?; + Ok(args.clone()) + }) + }; let run = { - let h = host.clone(); - function(move |a| vc_run(&h, a)) + let l = link.clone(); + function(move |a| vc_run(¤t(&l)?, a)) }; let document = { - let h = host.clone(); + let l = link.clone(); function(move |_| { + let h = current(&l)?; h.charge_call(64)?; bump(&h.counts.document_reads); vc_document(&h) }) }; let commands = { - let h = host.clone(); + let l = link.clone(); let list = Rc::new(vc_commands()); let size = data_size(&list); function(move |_| { + let h = current(&l)?; h.charge_call(size)?; bump(&h.counts.command_list_reads); Ok((*list).clone()) }) }; let log = { - let h = host.clone(); + let l = link.clone(); function(move |a| { + let h = current(&l)?; h.charge_call(data_size(a.first().unwrap_or(&Data::Undefined)))?; bump(&h.counts.vc_log); vc_log(&h, a) @@ -1125,7 +1302,7 @@ fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { ("document".into(), HostItem::Getter(document)), ("commands".into(), HostItem::Getter(commands)), ("log".into(), HostItem::Function(log)), - ("args".into(), HostItem::Value(args)), + ("args".into(), HostItem::FrozenGetter(args)), ( "api".into(), HostItem::Object(vec![("version".into(), HostItem::Value(Data::String(API_VERSION.into()))), ("has".into(), HostItem::Function(has))]), @@ -1188,13 +1365,16 @@ fn located(host: &Host, tag: u32, mark: Option, e: &RunError) -> (RunError, if e.line == 0 { return (e.clone(), None); } + // An error in code from another source, such as a function an earlier run of a named engine + // defined, has no place in this run's files, so it is given without one. + let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, 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), + None => unplaced(), }, - _ => (e.clone(), None), + _ => unplaced(), } } @@ -1214,6 +1394,95 @@ impl Session { self.script_key.is_some() } + /// The named engine of the running script. + fn running_engine(&self) -> Option { + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.engine.clone())).flatten() + } + + /// The named engines (`#targetengine`) this session keeps, in the order they were made: each + /// one's name, its heap after its last run in bytes, and whether a run has it now. + pub fn script_engines(&self) -> Vec<(String, usize, bool)> { + with_engines(&self.script_engines, |engines| engines.iter().map(|e| (e.name.clone(), e.heap, e.realm.is_none())).collect()) + } + + /// Drops the named engine `name` and the globals its runs left; a later run that names it gets a + /// new one. + pub fn drop_script_engine(&mut self, name: &str) -> Result<()> { + if name == MAIN_ENGINE { + return Err(EngineError::Other(DROP_MAIN.to_string())); + } + if self.running_engine().as_deref() == Some(name) { + return Err(EngineError::Busy); + } + let gone = with_engines(&self.script_engines, |engines| { + let at = engines.iter().position(|e| e.name == name)?; + Some(engines.remove(at)) + }); + match gone { + Some(engine) => { + drop(engine); + Ok(()) + } + None => Err(EngineError::Other(format!("there is no script engine named {name:?}"))), + } + } + + /// The realm of the named engine `name` for a run, with the link its host functions use: the + /// realm its last run left, or `None` for an engine made now. → the kind and text of the + /// failure when the engine cannot be had. + fn take_engine(&mut self, name: &str) -> std::result::Result<(Option, Link), (include::FailKind, String)> { + if cfg!(target_arch = "wasm32") { + return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string())); + } + with_engines(&self.script_engines, |engines| { + if let Some(e) = engines.iter_mut().find(|e| e.name == name) { + let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?; + return Ok((Some(realm), e.link.clone())); + } + if engines.len() >= MAX_ENGINES { + return Err(( + include::FailKind::Limit, + format!("a session keeps at most {MAX_ENGINES} script engines; drop one with script.engine.drop"), + )); + } + let link: Link = Rc::new(RefCell::new(None)); + engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 }); + Ok((None, link)) + }) + } + + /// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer + /// holds the run, and the engine keeps `realm`, or is dropped without one. + fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option) { + let host = link.borrow_mut().take(); + drop(host); + let Some(name) = engine else { return }; + let mut realm = realm; + let heap = realm.as_mut().map(Realm::collect_garbage); + let gone = with_engines(&self.script_engines, |engines| { + let at = engines.iter().position(|e| e.name == name)?; + match (realm.take(), engines.get_mut(at)) { + (Some(r), Some(e)) => { + e.realm = Some(r); + e.heap = heap.unwrap_or(0); + None + } + _ => Some(engines.remove(at)), + } + }); + drop(gone); + drop(realm); + } + + /// The end of `job`: its engine gets its realm back, with a run that was stopped where it was + /// ended, unless the run ended with an internal error, which drops the engine. + fn end_job(&self, job: Job, internal: bool) { + let Job { mut realm, link, engine, host, .. } = job; + drop(host); + realm.end(); + self.return_engine(engine.as_deref(), &link, (!internal).then_some(realm)); + } + /// The flag that stops the script running in this session from any thread, at its next /// instruction. pub fn script_interrupt(&self) -> Interrupt { @@ -1326,6 +1595,13 @@ impl Session { let line = console::ConsoleLine { run: key.run, name: key.name.clone(), entry: console::Entry::End(ending) }; self.script_console.push(line, false); drop(key); + // The realm of the job's named engine went with the job, so the engine is dropped. + let lost: Vec = with_engines(&self.script_engines, |engines| { + let (lost, kept) = std::mem::take(engines).into_iter().partition(|e| e.realm.is_none()); + *engines = kept; + lost + }); + drop(lost); raise_ids(self); self.script_stop.clear(); self.reset_tool(); @@ -1363,10 +1639,40 @@ impl Session { 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() }; + // The engine named by `#targetengine`, other than `main`: its realm, kept from its last run, + // or none for an engine this run makes. + let named = match &expanded { + Ok(include::Expanded { engine: Some((engine, line, column)), .. }) if engine != MAIN_ENGINE => Some((engine.clone(), *line, *column)), + _ => None, + }; + let (expanded, kept) = match named { + None => (expanded, None), + Some((engine, line, column)) => match self.take_engine(&engine) { + Ok(kept) => (expanded, Some((engine, kept))), + Err((kind, message)) => { + let file = Arc::from(files.file_name()); + (Err(include::Failure { kind, message, index: 0, file, line, column }), None) + } + }, + }; + let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES }; + let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() }; + let (engine, realm, link) = match kept { + None => (None, None, Rc::new(RefCell::new(None))), + Some((engine, (realm, link))) => (Some(engine), realm, link), + }; + // A realm made here gets the script extensions, `$` and the dialogs; a kept one has them + // from its first run, and they act for the run that calls them. + let fresh = realm.is_none(); let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() }; let strict = options.strict; - let mut realm = Realm::with_options(options, limits); + let mut realm = match realm { + Some(mut realm) => { + realm.set_limits(limits); + realm + } + None => Realm::with_options(options, limits), + }; realm.share_interrupt(&self.script_stop); realm.set_data_limits(DataLimits::default()); self.script_runs += 1; @@ -1402,28 +1708,46 @@ impl Session { wake_at: Cell::new(None), can_wait: realm.wait_flag(), eval_strict: Cell::new(None), + dollar: dollar::RunState::new(&request.locale), + args: RefCell::new((Data::Undefined, 0)), }); - 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") })?; + *link.borrow_mut() = Some(host.clone()); + let args = match to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) { + Ok(args) => args, + Err(place) => { + self.return_engine(engine.as_deref(), &link, (!fresh).then_some(realm)); + return Err(EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") }); + } + }; + let size = data_size(&args); + *host.args.borrow_mut() = (args, size); let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start }; self.script_console.push(line, false); - let info = dollar::RunInfo { locale: request.locale, strict }; + let engine_name = match &expanded { + Ok(include::Expanded { engine: Some((name, ..)), .. }) => Some(name.clone()), + _ => None, + }; + let info = dollar::RunInfo { strict, engine: engine_name }; let localization = realm.localization(); + dollar::start_run(&localization, &request.locale); let started = match expanded { Err(f) => Err(preprocessor_error(f)), Ok(expanded) => { *host.script_map.borrow_mut() = expanded.map; realm.set_strict_writes(expanded.strict == Some(true)); - realm - .add_script_extensions() - .and_then(|()| realm.define_host_object("vc", vc_object(&host, args))) - .and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization))) - .and_then(|()| dialogs::install(&mut realm, &host)) + (if fresh { realm.add_script_extensions() } else { Ok(()) }) + .and_then(|()| if fresh { realm.define_host_object("vc", vc_object(&link)) } else { Ok(()) }) + .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) + .and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) }) .and_then(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) } }; if let Err((e, file)) = started { + // An engine this run made is not kept: only a run that starts makes one. An internal + // error drops a kept one. + let keep = !fresh && e.kind != RunErrorKind::Internal; + self.return_engine(engine.as_deref(), &link, keep.then_some(realm)); 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); @@ -1432,7 +1756,7 @@ impl Session { } let scope = crate::file_access::ScopeHandle::capture(); let (run, line_name) = (host.run, host.name.clone()); - let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope }; + let job = Job { realm, host, link, engine, 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, run, name: line_name }); @@ -1497,7 +1821,7 @@ impl Session { let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), 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); + self.end_job(job, e.kind == RunErrorKind::Internal); raise_ids(self); self.script_stop.clear(); self.reset_tool(); @@ -1579,6 +1903,7 @@ impl Session { "hostCalls": host.calls.get(), }); close_run(self, host, &mut report, console::Ending::Finished); + self.end_job(job, false); self.script_report = Some(report.clone()); Ok(report) } @@ -1617,6 +1942,10 @@ const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the scr 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"; +const NO_RUN: &str = "this function's script run has ended"; +const DROP_MAIN: &str = "the main script engine is new for each run and is never kept, so it cannot be dropped"; +const NO_ENGINES_ON_WEB: &str = "named script engines (#targetengine) are not available on the web"; +const ENGINE_BUSY: &str = "the script engine is in use by another run"; /// 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 { @@ -1782,6 +2111,8 @@ 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 heap of a named engine in place of [`ENGINE_HEAP_BYTES`]. + pub(crate) static TEST_ENGINE_HEAP: 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) }; /// Tests: the time since a run started, in place of its clock's. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index e34eb711bd..5fee850a24 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -134,6 +134,7 @@ impl Session { global_edit_options: Default::default(), keyboard_text: false, script_key: None, + script_engines: crate::script::EnginesKey::new(), script_stop: vectorcraft_js::Interrupt::new(), script_report: None, script_console: Default::default(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9c6de6a89e..c5b40a4384 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -153,6 +153,7 @@ mod scalestrokes; mod script_console; mod script_dialogs; mod script_dollar; +mod script_engines; mod script_folder; mod script_include; mod script_run; diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs new file mode 100644 index 0000000000..56fe4bc262 --- /dev/null +++ b/crates/engine/src/tests/script_engines.rs @@ -0,0 +1,391 @@ +//! `#targetengine`: named engines whose globals last between the runs that name them, their names +//! and places, their limits, `script.engines` and `script.engine.drop`. + +use std::path::PathBuf; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for}; +use crate::*; + +fn run(s: &mut Session, source: &str) -> Result { + s.execute("script.run", &json!({"source": source, "name": "t.js"})) +} + +fn log(s: &mut Session, source: &str) -> Value { + run(s, source).unwrap()["log"].clone() +} + +/// A session with one document holding one rectangle. +fn one_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s +} + +fn objects(s: &Session) -> usize { + s.documents()[0].doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +fn names(s: &mut Session) -> Vec { + let r = s.execute("script.engines", &json!({})).unwrap(); + r["engines"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect() +} + +const RECT: &str = "{x: 100, y: 0, width: 5, height: 5}"; + +#[test] +fn a_named_engine_keeps_its_globals_and_functions_between_runs() { + let mut s = Session::new(); + assert_eq!(log(&mut s, "#targetengine \"session\"\nvar n = 41;\nfunction f() { return n + 1; }\nvc.log($.engineName);"), json!(["session"])); + assert_eq!(log(&mut s, "//@targetengine session\nvc.log(f()); n = 1;"), json!([42])); + assert_eq!(log(&mut s, "#targetengine 'session'\nvc.log(n);"), json!([1]), "single quotes name the same engine"); + assert_eq!(log(&mut s, "#targetengine Session\nvc.log(typeof n);"), json!(["undefined"]), "names are matched as written"); + assert_eq!(log(&mut s, "#targetengine other\nvc.log(typeof f);"), json!(["undefined"])); + assert_eq!(log(&mut s, "vc.log(typeof n, $.engineName);\nvc.log($.engineName);"), json!(["undefined", "VectorCraft"])); + // `main` is new for each run, as a script without the line is. + log(&mut s, "#targetengine main\nvar m = 1;"); + assert_eq!(log(&mut s, "#targetengine main\nvc.log(typeof m, $.engineName);\nvc.log($.engineName);"), json!(["undefined", "main"])); + assert_eq!(names(&mut s), ["session", "Session", "other"]); + assert_eq!(log(&mut s, "#targetengine \"Ünï cödé 1\"\nvc.log($.engineName);"), json!(["Ünï cödé 1"])); + let r = s.execute("script.run", &json!({"source": "#targetengine session\n$.engineName = 'x'; vc.log($.engineName);"})).unwrap(); + assert_eq!(r["log"], json!(["session"]), "$.engineName is read-only"); +} + +#[test] +fn engine_names_and_places_are_checked_before_anything_runs() { + let mut s = Session::new(); + let long = "a".repeat(65); + let control = "#targetengine \"a\u{1}b\""; + let cases: Vec<(String, &str, u64)> = vec![ + ("#targetengine".into(), "#targetengine needs a name", 1), + ("#targetengine \"\"".into(), "#targetengine needs a name", 1), + ("#targetengine a b".into(), "#targetengine needs a name of letters, digits and _", 1), + ("#targetengine 9a".into(), "#targetengine needs a name of letters, digits and _", 1), + ("#targetengine \"a\" \"b\"".into(), "#targetengine takes one name", 1), + (format!("#targetengine \"{long}\""), "#targetengine needs a name of at most 64 bytes", 1), + (format!("#targetengine {long}"), "#targetengine needs a name of at most 64 bytes", 1), + (control.into(), "#targetengine needs a name without control characters", 1), + ("#targetengine a\n//@targetengine a".into(), "the script has more than one //@targetengine line", 2), + ("var x;\n#targetengine a".into(), "#targetengine must come before the script's code and #include lines", 2), + ("/* a */ 1;\n#targetengine a".into(), "#targetengine must come before the script's code and #include lines", 2), + ]; + for (source, message, line) in &cases { + let e = run(&mut s, &format!("{source}\nvc.log(1);")).unwrap_err().to_string(); + assert!(e.contains(message), "{source}: {e}"); + assert_eq!(s.script_report().unwrap()["line"], *line, "{source}"); + assert_eq!(s.script_report().unwrap()["log"], json!([]), "{source}"); + } + assert!(names(&mut s).is_empty(), "no failed line made an engine"); + let ok = format!( + "// a comment\n/* and\n another */\n#target vectorcraft\n#script \"Named\"\n #targetengine \"{}\"\nvc.log($.engineName.length);", + "b".repeat(64) + ); + assert_eq!(log(&mut s, &ok), json!([64])); + // A script that does not start: an engine it would make is not kept. + assert!(run(&mut s, "#targetengine broken\nvar = 1;").is_err()); + assert_eq!(names(&mut s), ["b".repeat(64)]); +} + +#[test] +fn only_the_script_itself_names_its_engine() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("lib.jsxinc"), "#targetengine lib\nvar a = 1;").unwrap(); + let main = base.join("main.jsx"); + let mut s = Session::new(); + for text in ["#include \"lib.jsxinc\"\n", "$.evalFile('lib.jsxinc');\n"] { + std::fs::write(&main, text).unwrap(); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("#targetengine may only be in the script itself, not in a file it runs"), "{text}: {e}"); + assert!(s.script_report().unwrap()["file"].as_str().unwrap().ends_with("lib.jsxinc"), "{text}"); + } + std::fs::write(base.join("ok.jsxinc"), "var a = 1;").unwrap(); + std::fs::write(&main, "#include \"ok.jsxinc\"\n#targetengine e\n").unwrap(); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("#targetengine must come before the script's code and #include lines"), "{e}"); + assert_eq!(s.script_report().unwrap()["line"], 2); + // An error in a file $.evalFile runs is at that file's place in a kept engine's later runs too. + std::fs::write(base.join("bad.jsxinc"), "var b;\nthrow new Error('here');").unwrap(); + std::fs::write(&main, "#targetengine e\n$.evalFile('ok.jsxinc');").unwrap(); + s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap(); + std::fs::write(&main, "#targetengine e\n$.evalFile('bad.jsxinc');").unwrap(); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err()); + let report = s.script_report().unwrap().clone(); + assert!(report["file"].as_str().is_some_and(|f| f.ends_with("bad.jsxinc")), "{report}"); + assert_eq!(report["line"], 2, "{report}"); + assert_eq!(names(&mut s), ["e"]); + let _ = std::fs::remove_dir_all(base); + // An error in a function an earlier run defined has no place in the run that calls it. + run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap(); + assert!(run(&mut s, "#targetengine e\nbad();").is_err()); + let report = s.script_report().unwrap().clone(); + assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}"); +} + +/// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to +/// that run's script, and as `$.evalFile` does in a run given as text. +#[test] +fn a_kept_eval_file_reads_in_the_calling_run_scope() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-scope-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for dir in ["a", "b"] { + std::fs::create_dir_all(base.join(dir)).unwrap(); + std::fs::write(base.join(dir).join("lib.jsxinc"), format!("'{dir}'")).unwrap(); + } + let mut s = Session::new(); + let a = base.join("a").join("main.jsx"); + std::fs::write(&a, "#targetengine e\nvar ev = $.evalFile;\nvc.log(ev('lib.jsxinc'));").unwrap(); + assert_eq!(s.execute("script.run", &json!({"path": a.to_string_lossy()})).unwrap()["log"], json!(["a"])); + let b = base.join("b").join("main.jsx"); + std::fs::write(&b, "#targetengine e\nvc.log(ev('lib.jsxinc'));").unwrap(); + assert_eq!(s.execute("script.run", &json!({"path": b.to_string_lossy()})).unwrap()["log"], json!(["b"]), "the kept function reads beside b"); + let kept = run(&mut s, "#targetengine e\nvc.log(ev('lib.jsxinc'));").map_err(|e| e.to_string()); + let direct = run(&mut s, "vc.log($.evalFile('lib.jsxinc'));").map_err(|e| e.to_string()); + let _ = std::fs::remove_dir_all(base); + assert!(direct.is_err(), "{direct:?}"); + assert_eq!(kept.unwrap_err().split(": ").last(), direct.unwrap_err().split(": ").last(), "a run given as text reads no file beside a"); +} + +#[test] +fn a_failed_or_stopped_run_keeps_the_globals_it_set_and_puts_the_documents_back() { + let mut s = one_doc(); + let undo = s.documents()[0].history.undo.len(); + let e = run(&mut s, &format!("#targetengine e\nvar n = 1;\nvc.run('shape.rectangle', {RECT});\nthrow new Error('boom');")); + assert!(e.is_err()); + assert_eq!((objects(&s), s.documents()[0].history.undo.len()), (1, undo), "the documents went back"); + assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([1])); + // A stop. + s.script_frames = true; + assert_eq!(run(&mut s, &format!("#targetengine e\nn = 10;\nvc.run('shape.rectangle', {RECT});\nwhile (true) {{}}")).unwrap()["running"], true); + assert!(s.script_pump().is_none()); + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert!(s.script_pump().unwrap().is_err()); + s.script_frames = false; + assert_eq!(objects(&s), 1); + // An error in another engine leaves this one as it was. + assert!(run(&mut s, "#targetengine f\nvar n = 99;\nthrow 1;").is_err()); + assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([10])); + // A run that ends well is one undo step. + run(&mut s, &format!("#targetengine e\nvc.run('shape.rectangle', {RECT});")).unwrap(); + assert_eq!((objects(&s), s.documents()[0].history.undo.len()), (2, undo + 1)); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script"); +} + +/// A run stopped at a dialog, or left paused, ends where it was: the engine keeps its globals and +/// the next run starts afresh. +#[test] +fn a_run_stopped_at_a_dialog_or_left_paused_ends_where_it_was() { + let mut s = Session::new(); + s.script_frames = true; + run(&mut s, "#targetengine e\nvar n = 20;\nfunction f() { var big = new Array(4000001).join('x'); alert('x'); n = big.length; }\nf();").unwrap(); + for _ in 0..1_000 { + if s.script_dialog().is_some() { + break; + } + assert!(s.script_pump().is_none()); + } + assert!(s.script_dialog().is_some()); + s.execute("script.stop", &json!({})).unwrap(); + assert!(s.script_pump().unwrap().is_err()); + let heap = s.execute("script.engines", &json!({})).unwrap()["engines"][0]["heapBytes"].as_u64().unwrap(); + assert!(heap < 4 << 20, "the stopped run's frames, and the string they held, are gone: {heap} bytes"); + run(&mut s, "#targetengine e\nn += 1;\nwhile (true) {}").unwrap(); + assert!(s.script_pump().is_none()); + assert!(s.script_abandon()); + s.script_frames = false; + for _ in 0..3 { + assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([21])); + } +} + +#[test] +fn a_kept_host_function_acts_for_the_run_that_calls_it() { + let mut s = one_doc(); + run(&mut s, "#targetengine e\nvar oldRun = vc.run, oldVc = vc, oldWrite = $.writeln, oldAlert = alert;").unwrap(); + // The functions an earlier run kept work in this run's transaction, which goes back. + let e = run(&mut s, &format!("#targetengine e\noldRun('shape.rectangle', {RECT});\noldVc.log('kept');\nthrow new Error('x');")); + assert!(e.is_err()); + assert_eq!(objects(&s), 1, "the kept vc.run's change went back with the run"); + assert_eq!(s.script_report().unwrap()["log"], json!(["kept"])); + let r = s + .execute( + "script.run", + &json!({"source": format!("#targetengine e\noldRun('shape.rectangle', {RECT});\noldWrite('out');\noldAlert('hi');"), "answers": [{"kind": "alert", "value": "ok"}]}), + ) + .unwrap(); + assert_eq!((objects(&s), r["dialogCount"].as_u64()), (2, Some(1))); + assert!(r["console"]["lines"].as_array().unwrap().iter().any(|l| l == "out"), "{r}"); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script"); + let r = s + .execute( + "script.run", + &json!({"source": "#targetengine e\nvc.log([oldVc.args.n, vc === oldVc, Object.isFrozen(vc.args)]);", "args": {"n": 2}}), + ) + .unwrap(); + assert_eq!(r["log"], json!([[2, true, true]]), "a kept vc gives the calling run's args, frozen"); +} + +/// The values a run sets on `$` in a named engine end with that run, as in a run without one, and +/// a `$` an earlier run kept gives the values of the run that reads it. +#[test] +fn the_values_of_the_dollar_object_belong_to_each_run_of_a_named_engine() { + let mut s = Session::new(); + let read = "vc.log([$.getenv('OS'), $.getenv('LANG'), $.localize, $.flags, $.locale]);"; + let fresh = log(&mut s, read); + let set = "$.setenv('OS', 'set_here');\n$.setenv('LANG', 'set_here');\n$.localize = true;\n$.locale = 'fr_FR';"; + run(&mut s, &format!("#targetengine e\nvar d = $;\n{set}\n{read}")).unwrap(); + assert_eq!(log(&mut s, &format!("#targetengine e\n{read}")), fresh); + let os = fresh[0][0].clone(); + assert_eq!(log(&mut s, "#targetengine e\nvc.log([d === $, d.engineName, d.getenv('OS'), d.localize]);"), json!([[true, "e", os, false]])); + assert_eq!(log(&mut s, "#targetengine e\nd.setenv('OS', 'again');\nvc.log(d.getenv('OS'));"), json!(["again"])); + assert_eq!(log(&mut s, &format!("#targetengine e\n{read}")), fresh); +} + +#[test] +fn an_engine_does_not_keep_a_closed_document() { + let mut s = one_doc(); + let name = s.documents()[0].title(); + run(&mut s, "#targetengine e\nvar snap = vc.document;\nvar read = function () { return vc.document; };").unwrap(); + let weak = Arc::downgrade(&s.documents()[0].doc); + s.execute("file.close", &json!({})).unwrap(); + assert!(weak.upgrade().is_none(), "nothing holds the closed document"); + assert_eq!(log(&mut s, "#targetengine e\nvc.log(read());\nvc.log(snap.name);"), json!([null, name])); +} + +#[test] +fn a_session_keeps_eight_engines_and_drops_them_on_request() { + let mut s = Session::new(); + for i in 0..8 { + run(&mut s, &format!("#targetengine e{i}\nvar v = {i};")).unwrap(); + } + let e = run(&mut s, "#targetengine e8\nvc.log(1);").unwrap_err().to_string(); + assert!(e.contains("a session keeps at most 8 script engines"), "{e}"); + assert_eq!((s.script_report().unwrap()["kind"].as_str(), s.script_report().unwrap()["log"].clone()), (Some("limit"), json!([]))); + assert_eq!(log(&mut s, "#targetengine e3\nvc.log(v);"), json!([3]), "the eight still run"); + let list = s.execute("script.engines", &json!({})).unwrap(); + assert_eq!((list["limit"].as_u64(), list["heapLimit"].as_u64()), (Some(8), Some(64 << 20))); + for (i, e) in list["engines"].as_array().unwrap().iter().enumerate() { + assert_eq!(e["name"], format!("e{i}")); + assert_eq!(e["running"], false); + assert!(e["heapBytes"].as_u64().unwrap() > 0, "{e}"); + } + assert_eq!(s.execute("script.engine.drop", &json!({"name": "e2"})).unwrap(), json!({"dropped": true, "name": "e2"})); + assert_eq!(names(&mut s), ["e0", "e1", "e3", "e4", "e5", "e6", "e7"]); + run(&mut s, "#targetengine e8\nvar v = 8;").unwrap(); + assert!(run(&mut s, "#targetengine e2\n1").unwrap_err().to_string().contains("at most 8")); + s.execute("script.engine.drop", &json!({"name": "e8"})).unwrap(); + assert_eq!(log(&mut s, "#targetengine e2\nvc.log(typeof v);"), json!(["undefined"]), "a dropped engine comes back new"); + for (params, message) in [ + (json!({"name": "main"}), "the main script engine is new for each run"), + (json!({"name": "nope"}), "there is no script engine named \"nope\""), + (json!({}), "`name` must be the name of a script engine"), + (json!({"name": 3}), "`name` must be the name of a script engine"), + ] { + let e = s.execute("script.engine.drop", ¶ms).unwrap_err().to_string(); + assert!(e.contains(message), "{params}: {e}"); + } + // The running script's engine cannot be dropped; another one can. + s.script_frames = true; + assert_eq!(run(&mut s, "#targetengine e0\nwhile (true) {}").unwrap()["running"], true); + assert!(s.script_pump().is_none()); + assert!(matches!(s.execute("script.engine.drop", &json!({"name": "e0"})), Err(EngineError::Busy))); + let list = s.execute("script.engines", &json!({})).unwrap(); + assert_eq!(list["engines"][0]["running"], true); + s.execute("script.engine.drop", &json!({"name": "e1"})).unwrap(); + s.execute("script.stop", &json!({})).unwrap(); + assert!(s.script_pump().unwrap().is_err()); + assert_eq!(names(&mut s), ["e0", "e3", "e4", "e5", "e6", "e7", "e2"]); + // A script cannot drop or list engines itself. + s.script_frames = false; + let e = run(&mut s, "vc.run('script.engine.drop', {name: 'e0'});").unwrap_err().to_string(); + assert!(e.contains("a script cannot run `script.engine.drop`"), "{e}"); +} + +#[test] +fn a_named_engine_has_its_own_heap_limit_and_stays_usable() { + TEST_ENGINE_HEAP.with(|h| h.set(Some(8 << 20))); + let mut s = Session::new(); + let big = run(&mut s, "#targetengine big\nvar marker = 'set', keep = [];\nfor (var i = 0; i < 10000000; i++) keep.push('item ' + i);"); + // The next run starts on the nearly full heap even with large `args`, and can free memory. + let big_args = json!({"big": "x".repeat(1 << 19)}); + let free = json!({"source": "#targetengine big\nkeep = null;\nvc.log(marker);", "name": "t.js", "args": big_args}); + let kept = s.execute("script.run", &free).unwrap()["log"].clone(); + let after = s.execute("script.engines", &json!({})).unwrap(); + TEST_ENGINE_HEAP.with(|h| h.set(None)); + assert!(big.unwrap_err().to_string().contains("Heap")); + assert_eq!(kept, json!(["set"]), "the globals set before the limit stay, and the engine still runs"); + assert!(after["engines"][0]["heapBytes"].as_u64().unwrap() < 8 << 20, "{after}"); + // A run without #targetengine keeps the larger heap of one run. + assert_eq!(log(&mut s, "var keep = [];\nfor (var i = 0; i < 300000; i++) keep.push('item ' + i);\nvc.log(keep.length);"), json!([300000])); +} + +/// The engines stay on the thread that made them: a session used on another thread starts there +/// with none, and the engines it left, or that a session dropped elsewhere held, are dropped on +/// their own thread when it next uses its engines. +#[test] +fn a_session_moved_to_another_thread_starts_with_no_engines_and_lets_the_old_ones_go() { + let mut s = Session::new(); + run(&mut s, "#targetengine e\nvar kept = 1;").unwrap(); + assert_eq!(engines_on_this_thread(), 1); + let (mut s, there, listed, thread) = std::thread::spawn(move || { + let there = log(&mut s, "#targetengine e\nvc.log(typeof kept);"); + let listed = names(&mut s); + (s, there, listed, std::thread::current().id()) + }) + .join() + .unwrap(); + assert_eq!((there, listed), (json!(["undefined"]), vec!["e".to_string()])); + assert!(!releases_kept_for(thread), "a thread that ended keeps no list of engines to let go"); + // Back here, the first engine has been let go: the run gets a new one. + assert_eq!(log(&mut s, "#targetengine e\nvc.log(typeof kept);"), json!(["undefined"])); + assert_eq!(engines_on_this_thread(), 1); + assert!(!releases_kept_for(thread), "letting go of the ended thread's engines adds nothing for it"); + std::thread::spawn(move || drop(s)).join().unwrap(); + let mut other = Session::new(); + assert!(names(&mut other).is_empty()); + assert_eq!(engines_on_this_thread(), 0, "the engine of the session dropped on another thread is dropped here"); +} + +#[test] +fn an_internal_error_drops_the_engine() { + let mut s = Session::new(); + assert_eq!(log(&mut s, "#targetengine p\nvar a = 1;\nvc.log(a);"), json!([1])); + assert_eq!(log(&mut s, "#targetengine p\nvc.log(a);"), json!([1])); + s.script_frames = true; + run(&mut s, "#targetengine p\nwhile (true) {}").unwrap(); + PANIC_IN_SLICE.with(|p| p.set(true)); + let r = s.script_pump().unwrap(); + PANIC_IN_SLICE.with(|p| p.set(false)); + assert!(r.is_err()); + assert!(names(&mut s).is_empty()); + s.script_frames = false; + assert_eq!(log(&mut s, "#targetengine p\nvc.log(typeof a);"), json!(["undefined"])); +} + +/// A run whose job is gone took its engine's realm with it: the engine is dropped, so the next run +/// that names it starts it new and other engines stay. +#[test] +fn a_run_whose_job_is_gone_drops_its_engine() { + for abandon in [true, false] { + let mut s = Session::new(); + run(&mut s, "#targetengine kept\nvar k = 1;").unwrap(); + run(&mut s, "#targetengine lost\nvar a = 1;").unwrap(); + s.script_frames = true; + run(&mut s, "#targetengine lost\nwhile (true) {}").unwrap(); + assert!(s.script_pump().is_none()); + assert!(crate::script::forget_job(&s)); + if abandon { + assert!(s.script_abandon()); + } else { + assert!(s.script_pump().unwrap().is_err()); + } + s.script_frames = false; + assert_eq!(names(&mut s), ["kept"], "abandon: {abandon}"); + assert_eq!(log(&mut s, "#targetengine lost\nvc.log(typeof a);"), json!(["undefined"]), "abandon: {abandon}"); + assert_eq!(log(&mut s, "#targetengine kept\nvc.log(k);"), json!([1]), "abandon: {abandon}"); + } +} diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index 0c9ab45b20..0d1932ed55 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -192,7 +192,7 @@ fn script_target_other_app_does_not_route() { assert_eq!(r["log"], json!([1])); for (source, message) in [ ("#target\nvc.log(1);", "#target needs a value"), - ("#targetengine \"session\"", "unsupported script directive: #targetengine"), + ("#targetengine \"session\"", "#targetengine must come before the script's code and #include lines"), ("#engine \"main\"", "unsupported script directive: #engine"), ("#strict maybe", "#strict takes on or off"), ("#frobnicate 1", "unknown script directive: #frobnicate"), diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 8294aa638a..3359f272f9 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, "#targetengine x\n1").unwrap_err().to_string().contains("unsupported script directive: #targetengine")); + assert!(run(&mut s, "#targetengine 9x\n1").unwrap_err().to_string().contains("#targetengine needs a name of letters, digits and _")); assert_eq!(s.script_report().unwrap()["line"], 1); } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 4ec8f2c721..edb46f596e 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -85,11 +85,13 @@ pub enum Arg { Bool, } -/// How the arguments of a host function are read. +/// How the arguments of a host function are read, and its result given. #[derive(Clone)] pub(crate) enum ArgRead { /// Each as [`Data`]. Data, + /// Each as [`Data`], with the result given frozen, as [`HostItem::Value`] gives its value. + Frozen, /// Each as the string `String(value)` gives. Text, /// One by one as the list says; those past its end are not read. @@ -109,6 +111,9 @@ pub enum HostItem { Converted(Vec, HostFunction), /// A property whose value the function gives each time it is read. Getter(HostFunction), + /// A property whose value the function gives each time it is read, frozen as + /// [`HostItem::Value`] gives its value. + FrozenGetter(HostFunction), /// A property whose value `get` gives each time it is read, and that `set` is given each value /// assigned to it, as its one argument; an error from `set` is thrown to the script. Property { @@ -359,7 +364,7 @@ impl Interp { let result = f(data); self.can_wait.set(false); match result { - Ok(d) => self.write_data(d, 0, false), + Ok(d) => self.write_data(d, 0, matches!(how, ArgRead::Frozen)), Err(HostError::Wait) if can_wait => { self.waiting = true; self.pause.set(true); @@ -412,14 +417,14 @@ impl Interp { let mut data = Vec::with_capacity(args.len()); // A conversion can run the script's own `toString`, which may collect garbage. let mark = self.temp_roots.len(); - if !matches!(how, ArgRead::Data) { + if !matches!(how, ArgRead::Data | ArgRead::Frozen) { self.temp_roots.extend(args.iter().cloned()); } let mut read = Ok(()); for (i, a) in args.iter().enumerate() { reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); let d = match how { - ArgRead::Data => self.read_data(a, &mut reading), + ArgRead::Data | ArgRead::Frozen => self.read_data(a, &mut reading), ArgRead::Text => self.read_text(a, &mut reading), ArgRead::Each(list) => match list.get(i) { Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined), @@ -507,6 +512,7 @@ impl Interp { let prop = match item { HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE }, + HostItem::FrozenGetter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Frozen)), set: None, flags: ENUMERABLE }, HostItem::Property { get, set } => Prop::Accessor { get: Some(self.host_function(get, &full, ArgRead::Data)), set: Some(self.host_function(set, &full, ArgRead::Data)), diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 48aaa96106..44aea36ade 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -58,6 +58,23 @@ fn state_survives_a_pause_and_a_collection_between_slices() { assert_eq!(finish(&mut r, 50).0.unwrap(), JsValue::Number(1500.0)); } +/// A paused run that is ended leaves no frames behind: the realm starts the next run with its +/// globals, as many times as it is ended. +#[test] +fn an_ended_run_leaves_the_realm_ready_for_the_next() { + let mut r = realm(u64::MAX); + for _ in 0..3 { + r.start("var kept = 'yes'; function deep(n) { return n ? deep(n - 1) : (function () { while (true) {} })(); } deep(50);").unwrap(); + assert_eq!(r.advance(500).unwrap(), Progress::Paused); + r.end(); + assert!(!r.is_running()); + } + assert!(r.interp().frames.is_empty() && r.interp().stack.is_empty()); + r.start("kept").unwrap(); + assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("yes".into())); + r.end(); +} + #[test] fn the_fuel_limit_covers_every_slice_together() { let mut r = realm(5_000); @@ -223,6 +240,18 @@ fn a_getter_is_read_each_time() { assert_eq!(r.run("host.n + host.n").unwrap(), JsValue::Number(3.0)); } +/// A frozen getter gives a new value each time it is read, frozen to its leaves. +#[test] +fn a_frozen_getter_gives_a_frozen_value_each_time() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Ok(Data::Object(vec![("a".into(), Data::Array(vec![Data::Number(1.0)]))]))); + let g = f.clone(); + r.define_host_object("host", vec![("v".into(), HostItem::FrozenGetter(f)), ("open".into(), HostItem::Getter(g))]).unwrap(); + let source = "var v = host.v; v.b = 2; try { v.a.push(2); } catch (e) {} \ + [Object.isFrozen(v), Object.isFrozen(v.a), v.b === undefined, v.a.length, host.v === host.v, Object.isFrozen(host.open)].join()"; + assert_eq!(r.run(source).unwrap(), JsValue::String("true,true,true,1,false,false".into())); +} + #[test] fn a_text_function_takes_each_argument_as_string_gives_it() { let mut r = realm(1_000_000); @@ -408,6 +437,25 @@ fn a_converted_function_reads_each_argument_as_its_list_says() { assert_eq!(out, JsValue::String("function 1".into()), "a script may replace the function"); } +#[test] +fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unknown() { + let mut r = realm(10_000_000); + let source: HostFunction = Rc::new(|_| Ok(Data::String("\nthrow new Error('in eval')".into()))); + let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + r.define_host_object("host", vec![("load".into(), load)]).unwrap(); + r.run("function late() { throw new Error('late'); }").unwrap(); + assert!(r.run("host.load()").is_err()); + assert_eq!(r.error_source(), 1, "the first source the evaluator gave in this run"); + assert!(r.run("\nlate()").is_err()); + assert_eq!(r.error_source(), UNKNOWN_SOURCE, "an earlier script defined late"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!(r.error_source(), 0); + assert!(r.run("host.load()").is_err()); + assert_eq!(r.error_source(), 1, "counted again from this run's script"); + assert!(r.run("var = 1").is_err()); + assert_eq!(r.error_source(), 0, "a syntax error is in the script"); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 1c2bb6ecf1..23fec090dc 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -148,6 +148,10 @@ impl From for RunError { pub struct Realm { interp: Interp, unbroken_fuel: u64, + /// The source tag of the script the last [`Realm::run`] or [`Realm::start`] compiled. Each + /// script gets a new one, and the sources an evaluator gives during its run get the ones after + /// it, so code that an earlier script defined keeps a tag of its own. + script_tag: u32, } impl Default for Realm { @@ -165,7 +169,7 @@ impl Realm { /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it /// counted together. pub fn with_options(options: Options, limits: Limits) -> Self { - Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL } + Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: 0 } } /// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default @@ -237,13 +241,15 @@ impl Realm { interp.finally_marks.clear(); interp.error_mark = None; let strict = interp.options.strict; - interp.source_tag = 0; + interp.evaluated = interp.evaluated.saturating_add(1).min(UNKNOWN_SOURCE - 1); + self.script_tag = interp.evaluated; + interp.source_tag = self.script_tag; 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)) => { - interp.error_tag = 0; + interp.error_tag = self.script_tag; Err(e.into()) } Err(CompileError::Internal(m)) => Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), @@ -325,6 +331,15 @@ impl Realm { !self.interp.frames.is_empty() } + /// Ends a run begun with [`Realm::start`] where it is, paused or waiting for a host function's + /// result, as an interrupt would: its stacks are emptied, so the realm can start another run. + /// The globals the run set stay. + pub fn end(&mut self) { + if self.is_running() || self.interp.waiting { + let _ = self.outcome(Err(Abrupt::Limit(LimitKind::Interrupted))); + } + } + /// The flag a host function sets to end the current slice at the next point it can pause. pub fn pause_flag(&self) -> std::rc::Rc> { self.interp.pause.clone() @@ -367,11 +382,15 @@ 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 + /// Which source the last error's `line` and `column` are in: 0 for the script last 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. + /// [`crate::HostItem::Evaluator`] gave since then, and [`UNKNOWN_SOURCE`] otherwise, which + /// includes code from an earlier script and from the sources given during its run. pub fn error_source(&self) -> u32 { - self.interp.error_tag + match self.interp.error_tag { + UNKNOWN_SOURCE => UNKNOWN_SOURCE, + tag => tag.checked_sub(self.script_tag).unwrap_or(UNKNOWN_SOURCE), + } } /// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no diff --git a/crates/scriptcheck/src/catalog.rs b/crates/scriptcheck/src/catalog.rs index 7ee71c9eea..8954736d25 100644 --- a/crates/scriptcheck/src/catalog.rs +++ b/crates/scriptcheck/src/catalog.rs @@ -152,7 +152,7 @@ features! { DirectiveInclude "directive.include" Language, DirectiveOther "directive.other" Language, DirectiveTarget "directive.target" Language, - DirectiveTargetengine "directive.targetengine" HostOnly, + DirectiveTargetengine "directive.targetengine" Language, Dollar "dollar" Language, DollarEnv "dollar.env" Files, DollarEvalFile "dollar.evalFile" Files, diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index e7ead63a4f..00d3b8727c 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -209,7 +209,7 @@ fn directives_are_read_at_the_start_of_a_line_in_both_forms() { let includes: Vec<_> = r.includes.iter().map(|i| (i.value.as_str(), i.line, i.status)).collect(); assert_eq!(includes, [("lib/a.jsxinc", 3, IncludeStatus::NotRead)]); assert_eq!(problems(&r), [("unknownDirective", Some(8), Some("#pragma")), ("unknownDirective", Some(9), Some("#!"))]); - assert_eq!(r.needs, [Need::Language, Need::HostOnly]); + assert_eq!(r.needs, [Need::Language], "VectorCraft runs #targetengine"); } #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 19376c4bf2..b10f0f0a9e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -687,8 +687,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G6 Automation and scripting -- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is - open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, +- **Missing:** the document object model for scripts and the `#engine` script directive (#1183 is + open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, `#targetengine` + runs a script in a named engine whose globals last between runs, File › Scripts lists the scripts of the Scripts folder, and Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and export, and full agent control over MCP are done. diff --git a/docs/mcp.md b/docs/mcp.md index 9988286204..f592404bf5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1492,10 +1492,10 @@ top-level `import` or `export`, such as a React `.jsx` file), `notText` (a NUL b control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, the reply holds these lists: - `needs` lists what the script needs from the app that runs it: `language` (the script language's own objects - and directives, such as `$`, `alert`, `UnitValue` and `#target`), `dom` (the reference app's document model), - `files`, `menuCommands`, `dialogs`, and `hostOnly` (features only the reference app has: palettes, - `#targetengine`, BridgeTalk, sockets, native code, XMP, the XML object and E4X, operator overloading, - reflection, actions and `selectTool`). + and directives, such as `$`, `alert`, `UnitValue`, `#target` and `#targetengine`), `dom` (the reference app's + document model), `files`, `menuCommands`, `dialogs`, and `hostOnly` (features only the reference app has: + palettes, BridgeTalk, sockets, native code, XMP, the XML object and E4X, operator overloading, reflection, actions + and `selectTool`). - `directives` lists the `#include`, `#includepath`, `#script`, `#strict`, `#target` and `#targetengine` lines, and `targets` splits each `#target` line's application specifier into `app`, `instance`, `version` and `locale`. A directive is read where it starts a line, written `#name` or `//@name`. @@ -1577,10 +1577,11 @@ app through the global object `vc`, and writes to the Script Console and runs ot 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 `;`. + script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`. `$.engineName` is + the name `#targetengine` gave the engine the script runs in (`VectorCraft` without the line) and is read-only. - `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and `prompt(message, preset[, title])` open a dialog, described below. `vc.api.has("dialogs")` is true. -- `vc.args` is the `args` object. +- `vc.args` gives a read-only copy of the `args` object each time it is read. - `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 @@ -1610,8 +1611,12 @@ VectorCraft reads them by these rules of its own: and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`. -- `#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. +- `#targetengine name` and `//@targetengine name` run the script in the named engine. The help page above describes + the line: the engine it names lasts for the application session, and a later script that names it sees the globals + and functions earlier ones left. A script without the line, and `#targetengine main`, get a new engine for each + run, as before. The rest are VectorCraft's own rules, described under Script engines below. +- `#engine` ends 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 @@ -1689,6 +1694,35 @@ change anything outside the documents: `script.*` except `script.check`, `plugin an error. With Data Recovery off for complex documents, a run in the web build does not start while a complex document has unsaved changes. +### Script engines + +A named engine (`#targetengine`) keeps its globals and functions between the runs that name it, until it is dropped +or the session ends. These are VectorCraft's own rules: + +- The name is either a name of ASCII letters, digits and `_` that does not start with a digit, or a name in single or + double quotes of 1 to 64 bytes of UTF-8 without control characters. Names are matched as written: `Session` and + `session` are two engines. `main` is the engine of a script without the line, which is new for each run. +- Only the script itself has the line, once, before its code and its `#include` lines; blanks, comments and the other + preprocessor lines may come before it. A second line, a late line, a bad name, or the line in an included file or a + file `$.evalFile` runs ends the run at that line before any of it runs, and makes no engine. +- A session keeps at most 8 named engines. A run that names a ninth ends with a limit error before any of it runs, + and the other eight are kept. Each named engine has a heap of 64 MiB, which holds what it keeps between runs; a run + that goes past it ends with the heap limit, and the engine stays usable. +- The documents go back after an uncaught error, a limit or `script.stop` as they do for any run, and the engine keeps + the globals the run set before it ended. An internal error drops the engine. An error in a function an earlier run + defined is reported without a line and column. +- `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a + global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine + holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, + `$.localize`, `$.flags`, `$.setenv` values and `$.hiresTimer`) lasts for that run only, as in a run without the line. +- `script.engines {}` lists the named engines in the order they were made: `{engines: [{name, heapBytes, running}], + limit: 8, heapLimit: 67108864}`, where `heapBytes` is the engine's heap after its last run. +- `script.engine.drop {name}` drops a named engine and its globals, and returns `{dropped: true, name}`. The next run + that names it gets a new engine. `main`, a name with no engine, and the engine of the running script are refused; + the last one ends with the busy error. +- A script cannot run `script.engines` or `script.engine.drop`. +- The web build has no named engines: a run that names one other than `main` ends with an error before any of it runs. + VectorCraft sets these limits itself: | Limit | Value | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 07d626e5a4..574186b3ac 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -735,7 +735,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.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, `#strict on` turns on strict write checks (below), and `#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`). + - 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, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is 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`). + - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. From 8102e9e6e831c6f8fa55992e236bd6e25e4b472c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 11:41:02 -0800 Subject: [PATCH 05/83] i18n: plug-in limit messages are translated in every complete-message catalog The instruction, time and memory budgets and the output size a plug-in run can exceed had @msg rows only in hi and sv. Every other complete-message catalog now has them, next to the other plug-in budget rows, and every complete-message catalog has a row for the error of a plug-in removal that leaves the plug-in's grant in the plug-in store. The message scan now reads a message that an error or a status takes from a &str constant (Error::Limit(TIME_BUDGET.into()), crate::runtime::SELECTION_TOO_LARGE), so the untranslated-strings report lists such messages. Tests: message_scan_reads_messages_held_in_constants and plugin_limit_errors_are_translated. --- crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tests.rs | 137 +++++++++++++++++++++++++--- crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + docs/target-app-parity.md | 1 + 24 files changed, 229 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4af4313326..63c7221bc3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6093,6 +6093,11 @@ digits Malayalam Malaiàlam @msg `inputs` needs the permission `doc.read` `inputs` requereix el permís `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) pressupost de memòria (l’objecte {_1} necessita {size} bytes d’entrada, en queden {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) pressupost d’objectes (més de {MAX_OBJECTS} objectes de només lectura) +@msg instruction budget pressupost d’instruccions +@msg time budget pressupost de temps +@msg memory budget (the selection is too large to hand over) pressupost de memòria (la selecció és massa gran per passar-la) +@msg output size ({what} is {len} bytes, limit {max}) mida de la sortida ({what} fa {len} bytes, límit {max}) +@msg the plug-in's grant can't be removed from the plug-in store el permís del complement no es pot treure del magatzem de complements @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix @msg unknown permission `{shown}` permís desconegut `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» demana els permisos {asked}: una persona els ha d’aprovar a l’aplicació d’escriptori, o passa `grant` en una sessió iniciada amb --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 821d4a90c0..adffc014c8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6056,6 +6056,11 @@ axis Y Axis osa Y @msg `inputs` needs the permission `doc.read` `inputs` vyžaduje oprávnění `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) rozpočet paměti (objekt {_1} potřebuje {size} bajtů vstupu, zbývá {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) rozpočet objektů (více než {MAX_OBJECTS} objektů jen pro čtení) +@msg instruction budget rozpočet instrukcí +@msg time budget časový rozpočet +@msg memory budget (the selection is too large to hand over) rozpočet paměti (výběr je příliš velký na předání) +@msg output size ({what} is {len} bytes, limit {max}) velikost výstupu ({what} má {len} bajtů, limit {max}) +@msg the plug-in's grant can't be removed from the plug-in store oprávnění zásuvného modulu nelze odebrat z úložiště zásuvných modulů @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje @msg unknown permission `{shown}` neznámé oprávnění `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ žádá o oprávnění {asked}: musí je schválit člověk v desktopové aplikaci, nebo předejte `grant` v relaci spuštěné s --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f7bc97c90a..83ef9bb915 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5639,6 +5639,11 @@ axis Y Axis Y-Achse @msg `inputs` needs the permission `doc.read` `inputs` erfordert die Berechtigung `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) Speicherbudget (Objekt {_1} benötigt {size} Byte Eingabe, {left} verbleiben) @msg object budget (more than {MAX_OBJECTS} read-only objects) Objektbudget (mehr als {MAX_OBJECTS} schreibgeschützte Objekte) +@msg instruction budget Anweisungsbudget +@msg time budget Zeitbudget +@msg memory budget (the selection is too large to hand over) Speicherbudget (die Auswahl ist zu groß zum Übergeben) +@msg output size ({what} is {len} bytes, limit {max}) Ausgabegröße ({what} ist {len} Byte groß, Grenze {max}) +@msg the plug-in's grant can't be removed from the plug-in store die Berechtigung des Plug-ins kann nicht aus dem Plug-in-Speicher entfernt werden @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht @msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ verlangt die Berechtigungen {asked}: Eine Person muss sie in der Desktop-App genehmigen, oder übergeben Sie `grant` in einer mit --allow-plugin-grants gestarteten Sitzung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e1189bc985..a06f21d46f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6007,6 +6007,11 @@ axis Y Axis Άξονας Y @msg `inputs` needs the permission `doc.read` το `inputs` απαιτεί το δικαίωμα `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) προϋπολογισμός μνήμης (το αντικείμενο {_1} χρειάζεται {size} byte εισόδου, απομένουν {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) προϋπολογισμός αντικειμένων (περισσότερα από {MAX_OBJECTS} αντικείμενα μόνο για ανάγνωση) +@msg instruction budget προϋπολογισμός εντολών +@msg time budget χρονικός προϋπολογισμός +@msg memory budget (the selection is too large to hand over) προϋπολογισμός μνήμης (η επιλογή είναι πολύ μεγάλη για να παραδοθεί) +@msg output size ({what} is {len} bytes, limit {max}) μέγεθος εξόδου ({what}: {len} byte, όριο {max}) +@msg the plug-in's grant can't be removed from the plug-in store η άδεια του πρόσθετου δεν μπορεί να αφαιρεθεί από τον χώρο αποθήκευσης πρόσθετων @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί @msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants το «{name}» ζητά τα δικαιώματα {asked}: πρέπει να τα εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `grant` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 24f5e0e39b..2effb34a30 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5998,6 +5998,11 @@ axis Y Axis Eje Y @msg `inputs` needs the permission `doc.read` `inputs` requiere el permiso `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) presupuesto de memoria (el objeto {_1} necesita {size} bytes de entrada, quedan {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) presupuesto de objetos (más de {MAX_OBJECTS} objetos de solo lectura) +@msg instruction budget presupuesto de instrucciones +@msg time budget presupuesto de tiempo +@msg memory budget (the selection is too large to hand over) presupuesto de memoria (la selección es demasiado grande para entregarla) +@msg output size ({what} is {len} bytes, limit {max}) tamaño de salida ({what} ocupa {len} bytes, límite {max}) +@msg the plug-in's grant can't be removed from the plug-in store el permiso del plugin no se puede quitar del almacén de plugins @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede @msg unknown permission `{shown}` permiso desconocido `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” pide los permisos {asked}: una persona debe aprobarlos en la aplicación de escritorio, o pasa `grant` en una sesión iniciada con --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 64b3f2b6fa..6f7d04cb7b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6086,6 +6086,11 @@ axis Y Axis Y-akseli @msg `inputs` needs the permission `doc.read` `inputs` edellyttää käyttöoikeutta `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) muistibudjetti (objekti {_1} tarvitsee {size} tavua syötettä, jäljellä {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) objektibudjetti (yli {MAX_OBJECTS} vain luku -objektia) +@msg instruction budget käskybudjetti +@msg time budget aikabudjetti +@msg memory budget (the selection is too large to hand over) muistibudjetti (valinta on liian suuri luovutettavaksi) +@msg output size ({what} is {len} bytes, limit {max}) tulosteen koko ({what}: {len} tavua, raja {max}) +@msg the plug-in's grant can't be removed from the plug-in store laajennuksen käyttöoikeutta ei voi poistaa laajennusvarastosta @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä @msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” pyytää käyttöoikeuksia {asked}: ihmisen on hyväksyttävä ne työpöytäsovelluksessa, tai anna `grant` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dad99bcb41..f12ea23182 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6052,6 +6052,11 @@ axis Y Axis Axe Y @msg `inputs` needs the permission `doc.read` `inputs` nécessite l’autorisation `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) budget mémoire (l’objet {_1} nécessite {size} octets d’entrée, il en reste {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) budget d’objets (plus de {MAX_OBJECTS} objets en lecture seule) +@msg instruction budget budget d’instructions +@msg time budget budget de temps +@msg memory budget (the selection is too large to hand over) budget mémoire (la sélection est trop grande pour être transmise) +@msg output size ({what} is {len} bytes, limit {max}) taille de sortie ({what} fait {len} octets, limite {max}) +@msg the plug-in's grant can't be removed from the plug-in store l’autorisation du plug-in ne peut pas être retirée du magasin de plug-ins @msg unknown permission `{shown}` autorisation inconnue `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants « {name} » demande les autorisations {asked} : une personne doit les approuver dans l’application de bureau, ou passez `grant` dans une session démarrée avec --allow-plugin-grants @msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` et `allowHeavy` ne prennent effet que dans une session démarrée avec --allow-plugin-grants : approuvez le module externe dans l’application de bureau diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9408584703..dfacf8974e 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5364,6 +5364,7 @@ antiAlias Sharp शार्प @msg `inputs` needs the permission `doc.read` `inputs` के लिए अनुमति `doc.read` आवश्यक है @msg memory budget (object {_1} needs {size} bytes of input, {left} left) मेमोरी बजट (ऑब्जेक्ट {_1} को इनपुट के {size} बाइट चाहिए, {left} शेष हैं) @msg object budget (more than {MAX_OBJECTS} read-only objects) ऑब्जेक्ट बजट ({MAX_OBJECTS} से अधिक केवल-पठन ऑब्जेक्ट) +@msg the plug-in's grant can't be removed from the plug-in store प्लग-इन की अनुमति प्लग-इन स्टोर से हटाई नहीं जा सकती @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता @msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” अनुमतियां {asked} मांगता है: इन्हें डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `grant` दें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8f37efc8c1..3b4fbd30b2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6039,6 +6039,11 @@ axis Y Axis Y tengely @msg `inputs` needs the permission `doc.read` az `inputs` használatához a(z) `doc.read` engedély szükséges @msg memory budget (object {_1} needs {size} bytes of input, {left} left) memóriakeret (a(z) {_1} objektumnak {size} bájt bemenet kell, {left} maradt) @msg object budget (more than {MAX_OBJECTS} read-only objects) objektumkeret (több mint {MAX_OBJECTS} csak olvasható objektum) +@msg instruction budget utasításkeret +@msg time budget időkeret +@msg memory budget (the selection is too large to hand over) memóriakeret (a kijelölés túl nagy az átadáshoz) +@msg output size ({what} is {len} bytes, limit {max}) kimenet mérete ({what}: {len} bájt, korlát {max}) +@msg the plug-in's grant can't be removed from the plug-in store a bővítmény engedélye nem távolítható el a bővítménytárból @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg @msg unknown permission `{shown}` ismeretlen engedély: `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants a(z) „{name}” a következő engedélyeket kéri: {asked}; egy személynek jóvá kell hagynia őket az asztali alkalmazásban, vagy add meg a `grant` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 551fd25e4d..32b41b249f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5673,6 +5673,11 @@ axis Y Axis Asse Y @msg `inputs` needs the permission `doc.read` `inputs` richiede l’autorizzazione `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) budget di memoria (l’oggetto {_1} richiede {size} byte di input, ne restano {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) budget di oggetti (più di {MAX_OBJECTS} oggetti di sola lettura) +@msg instruction budget budget di istruzioni +@msg time budget budget di tempo +@msg memory budget (the selection is too large to hand over) budget di memoria (la selezione è troppo grande per essere passata) +@msg output size ({what} is {len} bytes, limit {max}) dimensione dell’output ({what} è di {len} byte, limite {max}) +@msg the plug-in's grant can't be removed from the plug-in store il permesso del plug-in non può essere rimosso dall’archivio dei plug-in @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede @msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” chiede le autorizzazioni {asked}: una persona deve approvarle nell’app desktop, oppure passa `grant` in una sessione avviata con --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 50a503e7f1..61bf8c0d07 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5738,6 +5738,11 @@ axis Y Axis Y 軸 @msg `inputs` needs the permission `doc.read` `inputs` には権限 `doc.read` が必要です @msg memory budget (object {_1} needs {size} bytes of input, {left} left) メモリ予算 (オブジェクト {_1} には {size} バイトの入力が必要ですが、残りは {left} です) @msg object budget (more than {MAX_OBJECTS} read-only objects) オブジェクト予算 ({MAX_OBJECTS} 個を超える読み取り専用オブジェクト) +@msg instruction budget 命令予算 +@msg time budget 時間予算 +@msg memory budget (the selection is too large to hand over) メモリ予算 (選択範囲が大きすぎて渡せません) +@msg output size ({what} is {len} bytes, limit {max}) 出力サイズ ({what} は {len} バイト、上限 {max}) +@msg the plug-in's grant can't be removed from the plug-in store プラグインの許可をプラグインストアから削除できません @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません @msg unknown permission `{shown}` 不明な権限 `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants 「{name}」は権限 {asked} を求めています。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `grant` を指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a9aa3f2c30..e567dba4d4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6007,6 +6007,11 @@ axis Y Axis Y ღერძი @msg `inputs` needs the permission `doc.read` `inputs`-ს სჭირდება ნებართვა `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) მეხსიერების ბიუჯეტი (ობიექტს {_1} სჭირდება შეყვანის {size} ბაიტი, დარჩენილია {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) ობიექტების ბიუჯეტი ({MAX_OBJECTS}-ზე მეტი მხოლოდ წასაკითხი ობიექტი) +@msg instruction budget ინსტრუქციების ბიუჯეტი +@msg time budget დროის ბიუჯეტი +@msg memory budget (the selection is too large to hand over) მეხსიერების ბიუჯეტი (მონიშნული ზედმეტად დიდია გადასაცემად) +@msg output size ({what} is {len} bytes, limit {max}) გამოტანის ზომა ({what}: {len} ბაიტი, ლიმიტი {max}) +@msg the plug-in's grant can't be removed from the plug-in store პლაგინის ნებართვის წაშლა პლაგინების საცავიდან შეუძლებელია @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს @msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ ითხოვს ნებართვებს {asked}: ისინი დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `grant` --allow-plugin-grants-ით გაშვებულ სესიაში diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e51fd6c3d1..2dfcf3f976 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5634,6 +5634,11 @@ axis Y Axis Y축 @msg `inputs` needs the permission `doc.read` `inputs`에는 `doc.read` 권한이 필요합니다 @msg memory budget (object {_1} needs {size} bytes of input, {left} left) 메모리 예산(개체 {_1}에 입력 {size}바이트가 필요하지만 {left}바이트 남음) @msg object budget (more than {MAX_OBJECTS} read-only objects) 개체 예산(읽기 전용 개체 {MAX_OBJECTS}개 초과) +@msg instruction budget 명령어 예산 +@msg time budget 시간 예산 +@msg memory budget (the selection is too large to hand over) 메모리 예산(선택 항목이 너무 커서 넘길 수 없음) +@msg output size ({what} is {len} bytes, limit {max}) 출력 크기({what}: {len}바이트, 제한 {max}) +@msg the plug-in's grant can't be removed from the plug-in store 플러그인 저장소에서 플러그인의 권한을 제거할 수 없습니다 @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 @msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}”은(는) {asked} 권한을 요청합니다. 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `grant`를 전달하세요 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0760b61ec6..c13b992b8a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5910,6 +5910,11 @@ axis Y Axis Oś Y @msg `inputs` needs the permission `doc.read` `inputs` wymaga uprawnienia `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) budżet pamięci (obiekt {_1} wymaga {size} bajtów danych wejściowych, pozostało {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) budżet obiektów (ponad {MAX_OBJECTS} obiektów tylko do odczytu) +@msg instruction budget budżet instrukcji +@msg time budget budżet czasu +@msg memory budget (the selection is too large to hand over) budżet pamięci (zaznaczenie jest zbyt duże, aby je przekazać) +@msg output size ({what} is {len} bytes, limit {max}) rozmiar wyjścia ({what} ma {len} bajtów, limit {max}) +@msg the plug-in's grant can't be removed from the plug-in store nie można usunąć uprawnienia wtyczki z magazynu wtyczek @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje @msg unknown permission `{shown}` nieznane uprawnienie `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}” prosi o uprawnienia {asked}: musi je zatwierdzić osoba w aplikacji desktopowej albo przekaż `grant` w sesji uruchomionej z --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1e37e7f551..041f58bb3f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5971,6 +5971,11 @@ axis Y Axis Eixo Y @msg `inputs` needs the permission `doc.read` `inputs` exige a permissão `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) orçamento de memória (o objeto {_1} precisa de {size} bytes de entrada, restam {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) orçamento de objetos (mais de {MAX_OBJECTS} objetos somente leitura) +@msg instruction budget orçamento de instruções +@msg time budget orçamento de tempo +@msg memory budget (the selection is too large to hand over) orçamento de memória (a seleção é grande demais para ser entregue) +@msg output size ({what} is {len} bytes, limit {max}) tamanho da saída ({what} tem {len} bytes, limite {max}) +@msg the plug-in's grant can't be removed from the plug-in store a permissão do plug-in não pode ser removida do armazenamento de plug-ins @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede @msg unknown permission `{shown}` permissão desconhecida `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” pede as permissões {asked}: uma pessoa precisa aprová-las no aplicativo de desktop, ou passe `grant` em uma sessão iniciada com --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index de1d31fd14..017f158c6c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5667,6 +5667,11 @@ axis Y Axis Ось Y @msg `inputs` needs the permission `doc.read` для `inputs` нужно разрешение `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) бюджет памяти (объекту {_1} нужно {size} байт входных данных, осталось {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет объектов (более {MAX_OBJECTS} объектов только для чтения) +@msg instruction budget бюджет инструкций +@msg time budget бюджет времени +@msg memory budget (the selection is too large to hand over) бюджет памяти (выделение слишком велико для передачи) +@msg output size ({what} is {len} bytes, limit {max}) размер вывода ({what}: {len} байт, предел {max}) +@msg the plug-in's grant can't be removed from the plug-in store разрешение модуля нельзя удалить из хранилища модулей @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт @msg unknown permission `{shown}` неизвестное разрешение `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» запрашивает разрешения {asked}: их должен одобрить человек в настольном приложении, либо передайте `grant` в сеансе, запущенном с --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index a3939998ec..8c213d520a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6079,6 +6079,7 @@ axis Y Axis Y-axel @msg `inputs` needs the permission `doc.read` `inputs` kräver behörigheten `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) minnesbudget (objekt {_1} behöver {size} byte indata, {left} återstår) @msg object budget (more than {MAX_OBJECTS} read-only objects) objektbudget (fler än {MAX_OBJECTS} skrivskyddade objekt) +@msg the plug-in's grant can't be removed from the plug-in store pluginets behörighet kan inte tas bort ur pluginlagret @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” begär behörigheterna {asked}: en person måste godkänna dem i skrivbordsappen, eller skicka `grant` i en session som startats med --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 79cbd7a648..1b8110bddf 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1878,7 +1878,7 @@ fn width(p: &Value, cmd: &str, key: &str) -> Result { } "##; let engine: Vec<_> = ERROR_HELPERS.iter().filter(|(c, _, _)| *c == "engine").map(|&(_, call, before)| (call, before)).collect(); - let found = source_messages(code, &engine); + let found = source_messages(code, &engine, &Default::default()); for want in [ "`{key}` must be a number", "the width can't be negative", @@ -1897,7 +1897,7 @@ fn width(p: &Value, cmd: &str, key: &str) -> Result { } let named = |m: &String| m.contains("not an error") || m.contains("not.an.error") || m.contains("not.a.reason"); assert!(!found.iter().any(named), "{found:#?}"); - let elsewhere = source_messages(code, &[]); + let elsewhere = source_messages(code, &[], &Default::default()); let in_variants = ["nested", "manifest", "nothing to clear", "panel is locked"]; assert!(elsewhere.len() == 4 && elsewhere.iter().all(|m| in_variants.iter().any(|w| m.contains(w))), "{elsewhere:#?}"); // A message read this way matches its row: `{key}` keeps its name. @@ -1905,6 +1905,76 @@ fn width(p: &Value, cmd: &str, key: &str) -> Result { assert_eq!(c.message("`width` must be a number").map(|m| m.1), Some(vec![("key", "width")])); } +/// The scan reads a message that an error or a status takes from a `&str` constant, by its name or +/// by its path, with `.into()`, `.to_string()` or nothing after it, and the constant declared in +/// another file. It does not read a constant that no error takes, a call of a name that a constant +/// also has, or a constant in a comment. +#[test] +fn message_scan_reads_messages_held_in_constants() { + let declared = r#" +pub(crate) const TOO_LARGE: &str = "the selection is too large to hand over"; +pub const SLOW: &'static str = "time budget"; +const UNUSED: &str = "not an error: a constant no error takes"; +const fn NOT_A_STR() -> u32 { 0 } +// const COMMENTED: &str = "not an error: a constant in a comment"; +"#; + let code = r#" +fn run() -> Result<()> { + let _ = Error::Limit(crate::runtime::TOO_LARGE.into()); + let _ = Err(Error::Limit(SLOW.to_string())); + let _ = Err(COMMENTED); + let _ = Err(SLOW(3)); + Ok(()) +} +"#; + let mut consts = std::collections::BTreeMap::new(); + for (name, value) in str_consts(declared) { + consts.entry(name).or_insert_with(Vec::new).push(value); + } + assert_eq!(consts.keys().collect::>(), ["SLOW", "TOO_LARGE", "UNUSED"]); + let found: std::collections::BTreeSet = source_messages(code, &[], &consts).into_iter().collect(); + assert_eq!(found.iter().collect::>(), ["the selection is too large to hand over", "time budget"]); + assert!(source_messages(code, &[], &Default::default()).is_empty()); +} + +/// The limits a plug-in run can exceed, and the error of a removal that leaves the plug-in's grant +/// in the plug-in store, are translated in every language that translates every message, as the +/// status bar shows them. +#[test] +fn plugin_limit_errors_are_translated() { + use vectorcraft_plugins::Error; + let scanned = message_literals(); + let grant = "the plug-in's grant can't be removed from the plug-in store"; + let templates = [ + "instruction budget", + "time budget", + "memory budget (the selection is too large to hand over)", + "output size ({what} is {len} bytes, limit {max})", + grant, + ]; + for template in templates { + assert!(scanned.contains(template), "{template}"); + } + let limits = [ + "instruction budget", + "time budget", + "memory budget (the selection is too large to hand over)", + "output size (the result is 9000 bytes, limit 4096)", + ]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for limit in limits { + let shown = message(lang, limit); + assert_ne!(shown, limit, "{code}"); + let whole = message(lang, &Error::Limit(limit.into()).to_string()); + assert!(whole.contains(&shown) && !whole.contains("exceeded"), "{code}: {whole}"); + } + let shown = message(lang, limits[3]); + assert!(shown.contains("9000") && shown.contains("4096") && !shown.contains("output size"), "{code}: {shown}"); + assert_ne!(message(lang, grant), grant, "{code}"); + } +} + /// The warnings of an Image Trace with keepColors, which the status bar shows and the scan above /// doesn't read, are translated in each language of [`COMPLETE_MESSAGES`], with the colors kept. #[test] @@ -2545,6 +2615,7 @@ const NOT_MESSAGES: &[&str] = &["quit", "cancelled"]; pub fn message_literals() -> std::collections::BTreeSet { let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let mut out = std::collections::BTreeSet::new(); + let mut files = vec![]; let mut stack: Vec<_> = MESSAGE_CRATES.iter().map(|c| crates.join(c).join("src")).collect(); while let Some(d) = stack.pop() { for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { @@ -2558,22 +2629,61 @@ pub fn message_literals() -> std::collections::BTreeSet { // Control replies are English protocol data, never status-bar messages // (see i18n's module contract). Engine messages are scanned in their own crates. let text = std::fs::read_to_string(&path).unwrap_or_default(); - let helpers: Vec<_> = - ERROR_HELPERS.iter().filter(|(c, _, _)| path.starts_with(crates.join(c))).map(|&(_, call, before)| (call, before)).collect(); - out.extend(source_messages(without_test_module(&text), &helpers)); + files.push((path, text)); } } } + let mut consts = std::collections::BTreeMap::new(); + for (_, text) in &files { + for (name, value) in str_consts(without_test_module(text)) { + consts.entry(name).or_insert_with(Vec::new).push(value); + } + } + for (path, text) in &files { + let helpers: Vec<_> = + ERROR_HELPERS.iter().filter(|(c, _, _)| path.starts_with(crates.join(c))).map(|&(_, call, before)| (call, before)).collect(); + out.extend(source_messages(without_test_module(text), &helpers, &consts)); + } out } +/// The `&str` constants `code` declares outside its comments (`const NAME: &str = "…";`), with +/// their text, escapes resolved. +fn str_consts(code: &str) -> Vec<(String, String)> { + let code = &without_comments(code); + code.match_indices("const ") + .filter(|&(at, _)| !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) + .filter_map(|(at, m)| { + let rest = &code[at + m.len()..]; + let (name, rest) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')?); + let rest = rest.trim_start().strip_prefix(':')?.trim_start(); + let rest = rest.strip_prefix("&'static str").or_else(|| rest.strip_prefix("&str"))?.trim_start(); + let value = string_literal(rest.strip_prefix('=')?.trim_start().strip_prefix('"')?)?; + Some((name.to_string(), value)) + }) + .collect() +} + +/// The text of the constant that `rest` starts with, by name or by path (`TIME_BUDGET`, +/// `crate::runtime::SELECTION_TOO_LARGE.into()`), when `consts` holds it: every text, when crates +/// declare constants of that name with different texts. +fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap>) -> &'a [String] { + let path = &rest[..rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != ':').unwrap_or(rest.len())]; + let after = &rest[path.len()..]; + let used = [")", ",", ".into()", ".to_string()", ".to_owned()"].iter().any(|end| after.starts_with(end)); + let name = path.rsplit("::").next().unwrap_or(""); + let upper = name.starts_with(|c: char| c.is_ascii_uppercase()) && name.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'); + if used && upper { consts.get(name).map_or(&[], Vec::as_slice) } else { &[] } +} + /// The message literals in `code` outside its comments, as catalog templates (see /// [`message_literals`]): the literal after each of [`MESSAGE_MARKERS`], the message argument of an /// error enum's variant (the first, as in `PsError::Limit(…)` and `Error::Manifest(…)`, or the one /// [`LATER_MESSAGE_VARIANTS`] gives; not [`NAME_VARIANTS`]), /// and the message argument of each call in `helpers` (a call and the number of arguments before -/// its message), a literal or a `format!` string. -fn source_messages(code: &str, helpers: &[(&str, usize)]) -> Vec { +/// its message), a literal, a `format!` string or the name of one of `consts` (see +/// [`str_consts`]). +fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collections::BTreeMap>) -> Vec { let code = &without_comments(code); let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..])); let in_variants = code.match_indices("Error::").filter_map(|(at, m)| { @@ -2592,10 +2702,15 @@ fn source_messages(code: &str, helpers: &[(&str, usize)]) -> Vec { for rest in after_markers.chain(in_variants).chain(in_helpers) { let rest = rest.trim_start(); let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start); - let Some(lit) = rest.strip_prefix('"').and_then(string_literal) else { continue }; - let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); - if has_word && !NOT_MESSAGES.contains(&lit.as_str()) { - out.push(as_template(&lit)); + let lits = match rest.strip_prefix('"') { + Some(quoted) => string_literal(quoted).into_iter().collect(), + None => named_const(rest, consts).to_vec(), + }; + for lit in lits { + let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); + if has_word && !NOT_MESSAGES.contains(&lit.as_str()) { + out.push(as_template(&lit)); + } } } out diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 17f25cf774..a601ee6c5b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6012,6 +6012,11 @@ axis Y Axis Y ekseni @msg `inputs` needs the permission `doc.read` `inputs` için `doc.read` izni gerekir @msg memory budget (object {_1} needs {size} bytes of input, {left} left) bellek bütçesi (nesne {_1} için {size} bayt girdi gerekiyor, {left} kaldı) @msg object budget (more than {MAX_OBJECTS} read-only objects) nesne bütçesi ({MAX_OBJECTS} adetten fazla salt okunur nesne) +@msg instruction budget talimat bütçesi +@msg time budget zaman bütçesi +@msg memory budget (the selection is too large to hand over) bellek bütçesi (seçim aktarılamayacak kadar büyük) +@msg output size ({what} is {len} bytes, limit {max}) çıktı boyutu ({what}: {len} bayt, sınır {max}) +@msg the plug-in's grant can't be removed from the plug-in store eklentinin izni eklenti deposundan kaldırılamıyor @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez @msg unknown permission `{shown}` bilinmeyen izin `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” {asked} izinlerini istiyor: masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `grant` geçirin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f9412ed977..8ee30242ec 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5653,6 +5653,11 @@ axis Y Axis Вісь Y @msg `inputs` needs the permission `doc.read` для `inputs` потрібен дозвіл `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) бюджет пам’яті (об’єкту {_1} потрібно {size} байтів вхідних даних, залишилося {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет об’єктів (понад {MAX_OBJECTS} об’єктів лише для читання) +@msg instruction budget бюджет інструкцій +@msg time budget бюджет часу +@msg memory budget (the selection is too large to hand over) бюджет пам’яті (виділення завелике для передавання) +@msg output size ({what} is {len} bytes, limit {max}) розмір виводу ({what}: {len} байтів, межа {max}) +@msg the plug-in's grant can't be removed from the plug-in store дозвіл модуля не вдається вилучити зі сховища модулів @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає @msg unknown permission `{shown}` невідомий дозвіл `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» запитує дозволи {asked}: їх має схвалити людина в настільному застосунку, або передайте `grant` у сеансі, запущеному з --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e26e818526..a261983606 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5654,6 +5654,11 @@ axis Y Axis Trục Y @msg `inputs` needs the permission `doc.read` `inputs` cần quyền `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) ngân sách bộ nhớ (đối tượng {_1} cần {size} byte đầu vào, còn lại {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) ngân sách đối tượng (hơn {MAX_OBJECTS} đối tượng chỉ đọc) +@msg instruction budget ngân sách lệnh +@msg time budget ngân sách thời gian +@msg memory budget (the selection is too large to hand over) ngân sách bộ nhớ (vùng chọn quá lớn để chuyển giao) +@msg output size ({what} is {len} bytes, limit {max}) kích thước đầu ra ({what} là {len} byte, giới hạn {max}) +@msg the plug-in's grant can't be removed from the plug-in store không thể xóa quyền của plug-in khỏi kho plug-in @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó @msg unknown permission `{shown}` quyền không xác định `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” yêu cầu các quyền {asked}: cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `grant` trong phiên khởi động bằng --allow-plugin-grants diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 855001a82b..e34d048d4f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5894,6 +5894,11 @@ axis Y Axis Y 轴 @msg `inputs` needs the permission `doc.read` `inputs` 需要权限 `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) 内存预算(对象 {_1} 需要 {size} 字节的输入,剩余 {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) 对象预算(超过 {MAX_OBJECTS} 个只读对象) +@msg instruction budget 指令预算 +@msg time budget 时间预算 +@msg memory budget (the selection is too large to hand over) 内存预算(所选内容太大,无法传递) +@msg output size ({what} is {len} bytes, limit {max}) 输出大小({what} 为 {len} 字节,上限 {max}) +@msg the plug-in's grant can't be removed from the plug-in store 无法从增效工具存储中移除该增效工具的授权 @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 @msg unknown permission `{shown}` 未知权限 `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}”需要权限 {asked}:需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `grant` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2002c06ef4..5997dfb376 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5691,6 +5691,11 @@ axis Y Axis Y 軸 @msg `inputs` needs the permission `doc.read` `inputs` 需要權限 `doc.read` @msg memory budget (object {_1} needs {size} bytes of input, {left} left) 記憶體預算(物件 {_1} 需要 {size} 位元組的輸入,剩餘 {left}) @msg object budget (more than {MAX_OBJECTS} read-only objects) 物件預算(超過 {MAX_OBJECTS} 個唯讀物件) +@msg instruction budget 指令預算 +@msg time budget 時間預算 +@msg memory budget (the selection is too large to hand over) 記憶體預算(所選內容太大,無法傳遞) +@msg output size ({what} is {len} bytes, limit {max}) 輸出大小({what} 為 {len} 位元組,上限 {max}) +@msg the plug-in's grant can't be removed from the plug-in store 無法從增效模組存放區移除該增效模組的授權 @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 @msg unknown permission `{shown}` 未知權限 `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants 「{name}」需要權限 {asked}:需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `grant` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 21081966b2..5dcc013daf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -785,6 +785,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). + - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). From 3d7803289c4240eab36404b28b70f3a6d840b549 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 11:57:46 -0800 Subject: [PATCH 06/83] Toolbars: a + button on a new toolbar adds and removes tools from a menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A toolbar made with New Toolbar was filled only by dragging tools from its All Tools drawer, which the ••• button opens. It now also has a + button (Add Tool…) above the ••• button. Its menu lists every tool under the drawer's headings; a click adds the tool at the end of that toolbar (toolbar.addTool with toolbar), and the tools already in it are dimmed. Under Remove Tool the menu lists the toolbar's tools, and a click takes one out (toolbar.removeTool). The button takes the keyboard focus, and the menu scrolls inside the window like the other long menus. The drawer and its drags work as before. Add Tool… and Remove Tool have rows in every catalog. --- crates/ui-egui/src/custom_toolbars.rs | 66 ++++++++++++++++-- crates/ui-egui/src/edit_toolbar.rs | 2 +- crates/ui-egui/src/edit_toolbar/tests.rs | 88 +++++++++++++++++++++++- 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 + docs/control-protocol.md | 5 +- docs/target-app-parity.md | 1 + 32 files changed, 207 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index e5008644b9..2cc23df53f 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -1,8 +1,10 @@ //! Window › Toolbars › New Toolbar (#1759): toolbars the user makes and names. New Toolbar asks //! for a name and makes a blank toolbar that floats on its own; the ••• button at its foot opens the //! All Tools drawer beside it, and tools dragged from the drawer fill it (`edit_toolbar`, with the -//! `toolbar` param of its commands). Its title bar moves it, its × hides it and its double arrow -//! shows its tools in one column or two. Each toolbar is listed under Window › Toolbars, where its +//! `toolbar` param of its commands). The + button above it opens Add Tool…, a menu of every tool +//! where a click adds the tool at the end, and whose Remove Tool items take tools out: VectorCraft's +//! own way to fill a toolbar without a drag. Its title bar moves it, its × hides it and its double +//! arrow shows its tools in one column or two. Each toolbar is listed under Window › Toolbars, where its //! item shows or hides it, and is kept with the workspace it was made in (`Workspace::toolbars`). //! Window › Toolbars › Manage Toolbars (also in the drawer's menu) renames, copies and deletes them. //! @@ -553,6 +555,10 @@ enum Action { /// A group's flyout: its tools, its button, and the toolbar's name. Flyout(Vec<&'static str>, egui::Rect, String), EditToolbar(String), + /// A tool picked in Add Tool…'s menu, added at the end of the toolbar named. + AddTool(String, &'static str), + /// A tool picked under Remove Tool in Add Tool…'s menu, taken out of the toolbar named. + RemoveTool(String, &'static str), /// The title bar's ×. Hide(String), /// The title bar's double arrow: two columns (true) or one. @@ -561,8 +567,8 @@ enum Action { /// The toolbars made with New Toolbar that show, each floating where its title bar was dragged: the /// title bar's × hides it and its double arrow lays the tools out in one column or two; then its -/// tool buttons (a group's flyout opens as the Tools panel's do) and the ••• button that opens the -/// All Tools drawer beside it. +/// tool buttons (a group's flyout opens as the Tools panel's do), the + button whose menu adds and +/// removes tools, and the ••• button that opens the All Tools drawer beside it. pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { // Tab hides them, also when Window › Tools shows the Tools panel again. if app.ui.tab_hid.is_some() || app.ui.custom_toolbars.iter().all(|b| !b.shown) { @@ -660,9 +666,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); } ui.add_space(4.0); + // Add Tool…: a menu of the tools, which adds one to this toolbar or takes one out. + let add = ui.vertical_centered(|ui| widgets::icon_button(ui, "plus", "Add Tool…", false, 26.0)).inner; // Edit Toolbar: the All Tools drawer opens beside this toolbar, by this button. - ui.vertical_centered(|ui| widgets::icon_button(ui, "ellipsis", "Edit Toolbar", edit_this, 26.0)).inner + (add, ui.vertical_centered(|ui| widgets::icon_button(ui, "ellipsis", "Edit Toolbar", edit_this, 26.0)).inner) }); + let (add, edit) = edit; + add_menu(&add, &t, &bar, &mut actions); if edit.clicked() { actions.push(Action::EditToolbar(bar.name.clone())); } @@ -721,6 +731,16 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.status(e); } } + Action::AddTool(name, tool) => { + if let Err(e) = app.run("toolbar.addTool", json!({ "tool": tool, "toolbar": name })) { + app.status(e); + } + } + Action::RemoveTool(name, tool) => { + if let Err(e) = app.run("toolbar.removeTool", json!({ "tool": tool, "toolbar": name })) { + app.status(e); + } + } Action::Hide(name) => { if let Err(e) = app.run("window.toolbar.custom", json!({ "name": name, "show": false })) { app.status(e); @@ -793,6 +813,42 @@ fn slot_button(ui: &mut egui::Ui, t: &Tokens, p: &Press, slot: &[String], action Some(rect) } +/// The area of Add Tool…'s menu on the toolbar named `name`. +pub(crate) fn add_menu_id(name: &str) -> egui::Id { + egui::Id::unique(("custom-toolbar-add", key(name))) +} + +/// Add Tool…'s menu, which the + button on the toolbar `bar` opens: under Remove Tool the tools in +/// the toolbar, where a click takes one out; then every tool under the All Tools drawer's headings, +/// where a click adds one at the end (dimmed: in the toolbar already). It scrolls past the window's +/// bottom bars, as other long menus do ([`widgets::menu_scroll`]). +fn add_menu(button: &egui::Response, t: &Tokens, bar: &CustomToolbar, actions: &mut Vec) { + egui::Popup::menu(button).id(add_menu_id(&bar.name)).show(|ui| { + ui.set_min_width(200.0); + widgets::menu_scroll(ui, |ui| { + let held: Vec<&'static str> = bar.slots.iter().flatten().filter_map(|id| tool_info(id).map(|t| t.id)).collect(); + let heading = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).size(12.0).color(t.text_strong)); + if !held.is_empty() { + heading(ui, &format!("{}:", tl!("Remove Tool"))); + for tool in held.iter().filter_map(|id| tool_info(id)) { + if widgets::menu_item(ui, tool.label, true, false) { + actions.push(Action::RemoveTool(bar.name.clone(), tool.id)); + } + } + ui.separator(); + } + for (cat, tools) in crate::edit_toolbar::sections() { + heading(ui, &format!("{}:", tl!(cat))); + for tool in tools { + if widgets::menu_item(ui, tool.label, !held.contains(&tool.id), false) { + actions.push(Action::AddTool(bar.name.clone(), tool.id)); + } + } + } + }); + }); +} + /// Remember `id` as the tool shown by each slot of a toolbar made with New Toolbar that holds it, /// however it was chosen (`toolbar::remember` calls it). pub(crate) fn remember(ui: &mut UiState, id: &str) { diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index 2d6fede3bf..6f84623087 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -416,7 +416,7 @@ fn heading(id: &str) -> &'static str { } /// The headings in order, each with its tools in the toolbar's order. -fn sections() -> &'static [(&'static str, Vec<&'static ToolInfo>)] { +pub(crate) fn sections() -> &'static [(&'static str, Vec<&'static ToolInfo>)] { static SECTIONS: OnceLock)>> = OnceLock::new(); SECTIONS.get_or_init(|| { let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect(); diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index b467f05552..02ec0762bd 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -381,7 +381,7 @@ fn a_new_toolbar_fills_from_the_drawer_beside_it() { frame(&mut app, &ctx, 0.1, vec![]); let area = || ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).expect("the toolbar is shown"); let bar = area(); - let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().find(|r| bar.contains(r.center())).expect("its ••• button"); + let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().rfind(|r| bar.contains(r.center())).expect("its ••• button"); // Open beside the Tools panel's toolbar, the drawer moves to this one on its ••• click. app.run("window.editToolbar", json!({})).unwrap(); frame(&mut app, &ctx, 0.5, vec![]); @@ -439,6 +439,90 @@ fn a_new_toolbar_fills_from_the_drawer_beside_it() { assert!(!ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, crate::custom_toolbars::area_id("Mine"))))); } +/// A toolbar made with New Toolbar has an Add Tool… button (+, above the ••• button) that the +/// keyboard reaches. Its menu lists the tools under the drawer's headings: a tool picked there is +/// added at the end of this toolbar alone, without the drawer, and is dimmed in the menu from then +/// on; picked under Remove Tool, it is taken out again. +#[test] +fn the_add_tool_menu_fills_a_new_toolbar_without_a_drag() { + let mut app = app(); + let ctx = ctx(); + let shipped = slots(&app); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let plus = || { + let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).expect("the toolbar is shown"); + let buttons: Vec = + crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().filter(|r| bar.contains(r.center())).collect(); + assert_eq!(buttons.len(), 2, "the + and ••• buttons: {buttons:?}"); + buttons[0] + }; + let button = plus(); + let sense = ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.rect == button).map(|w| w.sense)); + assert!(sense.is_some_and(|s| s.is_focusable()), "Tab stops on the + button: {sense:?}"); + // The menu at `time`, opened with a click on the + button unless it is open. + let open = |app: &mut VectorcraftApp, time: f64| { + let shown = frame(app, &ctx, time, vec![]); + if shown.iter().any(|(s, ..)| s == "Select:") { + return shown; + } + click(app, &ctx, time + 0.2, plus().center()); + // The menu is sized in its first frame and drawn from the second. + frame(app, &ctx, time + 0.5, vec![]); + frame(app, &ctx, time + 0.6, vec![]) + }; + // The items named `name` that the menu shows (it scrolls), top first. + let items = |shown: &[(String, Pos2, Color32)], name: &str| { + let menu = ctx.memory(|m| m.area_rect(crate::custom_toolbars::add_menu_id("Mine"))).expect("the menu is open"); + let mut at: Vec<(Pos2, Color32)> = shown.iter().filter(|(s, p, _)| s.trim() == name && menu.contains(*p)).map(|(_, p, c)| (*p, *c)).collect(); + at.sort_by(|a, b| a.0.y.total_cmp(&b.0.y)); + at + }; + let mine = |app: &VectorcraftApp| crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots.clone(); + let shown = open(&mut app, 9.0); + assert!(items(&shown, "Remove Tool:").is_empty(), "nothing to take out of a blank toolbar"); + let lasso = items(&shown, "Lasso Tool"); + assert_eq!(lasso.len(), 1, "listed once, under Select"); + click(&mut app, &ctx, 10.0, lasso[0].0); + assert_eq!(mine(&app), [["lasso"]]); + assert_eq!(slots(&app), shipped, "the Tools panel's toolbar is untouched"); + assert!(!app.ui.tools_drawer, "no drawer opened"); + let list = app.run("window.toolbar.list", json!({})).unwrap(); + assert_eq!(list, json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]]}])); + // A second tool goes after the first. + let shown = open(&mut app, 19.0); + click(&mut app, &ctx, 20.0, items(&shown, "Magic Wand Tool")[0].0); + assert_eq!(mine(&app), [["lasso"], ["magicWand"]]); + // A tool in the toolbar is listed under Remove Tool, and dimmed under its heading, where a click + // adds nothing. + let shown = open(&mut app, 29.0); + assert_eq!(items(&shown, "Remove Tool:").len(), 1); + let lasso = items(&shown, "Lasso Tool"); + assert_eq!(lasso.len(), 2, "under Remove Tool, then under Select"); + let enabled = |at: Pos2| { + let layer = egui::LayerId::new(egui::Order::Foreground, crate::custom_toolbars::add_menu_id("Mine")); + ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .filter(|(l, _)| **l == layer) + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.contains(at)) + .last() + .map(|w| w.enabled) + }) + }; + assert_eq!([enabled(lasso[0].0), enabled(lasso[1].0)], [Some(true), Some(false)], "dimmed under Select"); + click(&mut app, &ctx, 30.0, lasso[1].0); + assert_eq!(mine(&app), [["lasso"], ["magicWand"]]); + // Under Remove Tool, a click takes it out. + let shown = open(&mut app, 39.0); + click(&mut app, &ctx, 40.0, items(&shown, "Lasso Tool")[0].0); + assert_eq!(mine(&app), [["magicWand"]]); + assert_eq!(slots(&app), shipped); +} + /// New Toolbar… in the drawer's menu opens the New Toolbar dialog, which asks for a name. #[test] fn new_toolbar_in_the_drawer_menu_asks_for_a_name() { @@ -477,7 +561,7 @@ fn the_drawer_moves_between_new_toolbars() { frame(&mut app, &ctx, 0.1, vec![]); let edit_of = |name: &str| { let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id(name))).unwrap(); - crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().find(|r| bar.contains(r.center())).unwrap() + crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().rfind(|r| bar.contains(r.center())).unwrap() }; let (a, b) = (edit_of("A"), edit_of("B")); for (k, (button, name)) in [(b, "B"), (a, "A"), (b, "B")].into_iter().enumerate() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..6eed65dc54 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3514,6 +3514,8 @@ digits Malayalam Малаялам Toolbar: Single/Double Column Лента с инструменти: Една/Две колони Toolbar: Add Tool Лента с инструменти: Добавяне на инструмент Toolbar: Remove Tool Лента с инструменти: Премахване на инструмент + Add Tool… Добавяне на инструмент… + Remove Tool Премахване на инструмент Toolbar: Reset Лента с инструменти: Нулиране Toolbars Ленти с инструменти Tools Инструменти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..87ae9c03d1 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1148,6 +1148,8 @@ lighting Light Svjetlo Toolbar: Single/Double Column Traka s alatima: jedna/dvije kolone Toolbar: Add Tool Traka s alatima: dodaj alat Toolbar: Remove Tool Traka s alatima: ukloni alat + Add Tool… Dodaj alat… + Remove Tool Ukloni alat Toolbar: Reset Traka s alatima: vrati na početno Two Point Perspective Preset 1 Predložak perspektive s dvije tačke 1 Two Point Perspective Preset 2 Predložak perspektive s dvije tačke 2 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..8f74b2a824 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4528,6 +4528,8 @@ digits Malayalam Malaiàlam Toolbar: Add Tool Barra d’eines: afegeix una eina Toolbar: Advanced / Basic Barra d’eines: avançada / bàsica Toolbar: Remove Tool Barra d’eines: suprimeix una eina + Add Tool… Afegeix una eina… + Remove Tool Suprimeix una eina Toolbar: Reset Barra d’eines: restableix Toolbar: Show Controls Barra d’eines: mostra els controls Toolbar: Single/Double Column Barra d’eines: columna simple o doble diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..5834575e2b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1167,6 +1167,8 @@ lighting Light Světlo Toolbar: Single/Double Column Panel nástrojů: jeden / dva sloupce Toolbar: Add Tool Panel nástrojů: přidat nástroj Toolbar: Remove Tool Panel nástrojů: odebrat nástroj + Add Tool… Přidat nástroj… + Remove Tool Odebrat nástroj Toolbar: Reset Panel nástrojů: obnovit Two Point Perspective Preset 1 Přednastavení dvoubodové perspektivy 1 Two Point Perspective Preset 2 Přednastavení dvoubodové perspektivy 2 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..b6cde3487e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1083,6 +1083,8 @@ lighting Light Lys Toolbar: Single/Double Column Værktøjslinje: Enkelt/dobbelt kolonne Toolbar: Add Tool Værktøjslinje: Tilføj værktøj Toolbar: Remove Tool Værktøjslinje: Fjern værktøj + Add Tool… Tilføj værktøj… + Remove Tool Fjern værktøj Toolbar: Reset Værktøjslinje: Nulstil Two Point Perspective Preset 1 2-punkts perspektivforudindstilling 1 Two Point Perspective Preset 2 2-punkts perspektivforudindstilling 2 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..e3e902fb8b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3547,6 +3547,8 @@ effect Points Punkte Toolbar: Single/Double Column Werkzeugleiste: ein-/zweispaltig Toolbar: Add Tool Werkzeugleiste: Werkzeug hinzufügen Toolbar: Remove Tool Werkzeugleiste: Werkzeug entfernen + Add Tool… Werkzeug hinzufügen… + Remove Tool Werkzeug entfernen Toolbar: Reset Werkzeugleiste: Zurücksetzen Toolbars Werkzeugleisten Tools Werkzeuge diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..bf7a812528 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3960,6 +3960,8 @@ digits Malayalam Μαλαγιάλαμ Toolbar: Single/Double Column Γραμμή εργαλείων: Μονή/Διπλή στήλη Toolbar: Add Tool Γραμμή εργαλείων: Προσθήκη εργαλείου Toolbar: Remove Tool Γραμμή εργαλείων: Αφαίρεση εργαλείου + Add Tool… Προσθήκη εργαλείου… + Remove Tool Αφαίρεση εργαλείου Toolbar: Reset Γραμμή εργαλείων: Επαναφορά Top in Front Επάνω μπροστά Top-Justified Tab Στηλοθέτης επάνω στοίχισης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..2023316c89 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3602,6 +3602,8 @@ digits Malayalam Malayálam Toolbar: Single/Double Column Barra de herramientas: columna simple/doble Toolbar: Add Tool Barra de herramientas: añadir herramienta Toolbar: Remove Tool Barra de herramientas: quitar herramienta + Add Tool… Añadir herramienta… + Remove Tool Quitar herramienta Toolbar: Reset Barra de herramientas: restablecer Toolbars Barras de herramientas Tools Herramientas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..9fd1811098 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1145,6 +1145,8 @@ lighting Light Valo Toolbar: Single/Double Column Työkalurivi: yksi/kaksi saraketta Toolbar: Add Tool Työkalurivi: lisää työkalu Toolbar: Remove Tool Työkalurivi: poista työkalu + Add Tool… Lisää työkalu… + Remove Tool Poista työkalu Toolbar: Reset Työkalurivi: palauta Two Point Perspective Preset 1 Kahden pisteen perspektiivin esiasetus 1 Two Point Perspective Preset 2 Kahden pisteen perspektiivin esiasetus 2 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..e091e292cf 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3593,6 +3593,8 @@ digits Malayalam Malayalam Toolbar: Single/Double Column Barre d’outils : une/deux colonnes Toolbar: Add Tool Barre d’outils : ajouter un outil Toolbar: Remove Tool Barre d’outils : retirer un outil + Add Tool… Ajouter un outil… + Remove Tool Retirer un outil Toolbar: Reset Barre d’outils : réinitialiser Toolbars Barres d’outils Tools Outils diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..006b688f6f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4912,6 +4912,8 @@ antiAlias Sharp शार्प Toolbar: Add Tool टूलबार: ऐड टूल Toolbar: Advanced / Basic टूलबार: उन्नत / बुनियादी Toolbar: Remove Tool टूलबार: टूल निकालें + Add Tool… टूल जोड़ें… + Remove Tool टूल निकालें Toolbar: Reset टूलबार: रीसेट करें Toolbar: Show Controls टूलबार: शो कंट्रोल Toolbar: Single/Double Column टूलबार: सिंगल / डबल कॉलम diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..66b0bdcb9b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4286,6 +4286,8 @@ mark-style Japanese Japán Toolbar: Add Tool Eszköztár: eszköz hozzáadása Toolbar: Advanced / Basic Eszköztár: speciális / alap Toolbar: Remove Tool Eszköztár: eszköz eltávolítása + Add Tool… Eszköz hozzáadása… + Remove Tool Eszköz eltávolítása Toolbar: Reset Eszköztár: visszaállítás Toolbar: Single/Double Column Eszköztár: egy/két oszlop Toolbars Eszköztárak diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..083ef97b3c 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1083,6 +1083,8 @@ lighting Light Cahaya Toolbar: Single/Double Column Bilah alat: kolom tunggal/ganda Toolbar: Add Tool Bilah alat: tambahkan alat Toolbar: Remove Tool Bilah alat: hapus alat + Add Tool… Tambahkan alat… + Remove Tool Hapus alat Toolbar: Reset Bilah alat: setel ulang Two Point Perspective Preset 1 Preset perspektif dua titik 1 Two Point Perspective Preset 2 Preset perspektif dua titik 2 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..eb1b985fee 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3639,6 +3639,8 @@ digits Malayalam Malayalam Toolbar: Single/Double Column Barra degli strumenti: colonna singola/doppia Toolbar: Add Tool Barra degli strumenti: aggiungi strumento Toolbar: Remove Tool Barra degli strumenti: rimuovi strumento + Add Tool… Aggiungi strumento… + Remove Tool Rimuovi strumento Toolbar: Reset Barra degli strumenti: ripristina Toolbars Barre degli strumenti Tools Strumenti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..94e63ccd12 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3323,6 +3323,8 @@ mark-style Japanese 日本式 Toolbar: Single/Double Column ツールバー: 1 列/2 列 Toolbar: Add Tool ツールバー: ツールを追加 Toolbar: Remove Tool ツールバー: ツールを削除 + Add Tool… ツールを追加… + Remove Tool ツールを削除 Toolbar: Reset ツールバー: リセット Top: 上: Total Width: 合計幅: diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..9b50357d91 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1041,6 +1041,8 @@ lighting Light სინათლე Toolbar: Single/Double Column ხელსაწყოთა ზოლი: ერთი/ორი სვეტი Toolbar: Add Tool ხელსაწყოთა ზოლი: ხელსაწყოს დამატება Toolbar: Remove Tool ხელსაწყოთა ზოლი: ხელსაწყოს ამოშლა + Add Tool… ხელსაწყოს დამატება… + Remove Tool ხელსაწყოს ამოშლა Toolbar: Reset ხელსაწყოთა ზოლი: ჩამოყრა Two Point Perspective Preset 1 ორწერტილიანი პერსპექტივის პრესეტი 1 Two Point Perspective Preset 2 ორწერტილიანი პერსპექტივის პრესეტი 2 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..237dd902e9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3312,6 +3312,8 @@ mark-style Japanese 일본식 Toolbar: Single/Double Column 도구 막대: 한 줄/두 줄 Toolbar: Add Tool 도구 막대: 도구 추가 Toolbar: Remove Tool 도구 막대: 도구 제거 + Add Tool… 도구 추가… + Remove Tool 도구 제거 Toolbar: Reset 도구 막대: 재설정 Top: 위쪽: Total Width: 전체 너비: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..37481b34b3 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1097,6 +1097,8 @@ lighting Light Licht Toolbar: Single/Double Column Werkbalk: Enkele/dubbele kolom Toolbar: Add Tool Werkbalk: gereedschap toevoegen Toolbar: Remove Tool Werkbalk: gereedschap verwijderen + Add Tool… Gereedschap toevoegen… + Remove Tool Gereedschap verwijderen Toolbar: Reset Werkbalk: herstellen Two Point Perspective Preset 1 Voorinstelling perspectief met twee vluchtpunten 1 Two Point Perspective Preset 2 Voorinstelling perspectief met twee vluchtpunten 2 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..1966d82df8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1102,6 +1102,8 @@ lighting Light Światło Toolbar: Single/Double Column Pasek narzędzi: jedna/dwie kolumny Toolbar: Add Tool Pasek narzędzi: dodaj narzędzie Toolbar: Remove Tool Pasek narzędzi: usuń narzędzie + Add Tool… Dodaj narzędzie… + Remove Tool Usuń narzędzie Toolbar: Reset Pasek narzędzi: resetuj Two Point Perspective Preset 1 Ustawienie perspektywy dwupunktowej 1 Two Point Perspective Preset 2 Ustawienie perspektywy dwupunktowej 2 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..d2942d2699 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5037,6 +5037,8 @@ axis Both Ambos Tip on the end point Ponta no ponto final Toolbar: Add Tool Barra de Ferramentas: Adicionar Ferramenta Toolbar: Remove Tool Barra de Ferramentas: Remover Ferramenta + Add Tool… Adicionar Ferramenta… + Remove Tool Remover Ferramenta Toolbar: Reset Barra de Ferramentas: Redefinir Top Superior Top in Front Superior na Frente diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..ffc00d6d14 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3633,6 +3633,8 @@ digits Malayalam Малаялам Toolbar: Single/Double Column Панель инструментов: один/два столбца Toolbar: Add Tool Панель инструментов: добавить инструмент Toolbar: Remove Tool Панель инструментов: убрать инструмент + Add Tool… Добавить инструмент… + Remove Tool Убрать инструмент Toolbar: Reset Панель инструментов: сбросить Toolbars Панели инструментов Tools Инструменты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..23ce029a41 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3523,6 +3523,8 @@ digits Malayalam Malayalam Toolbar: Single/Double Column Verktygsfält: En/två kolumner Toolbar: Add Tool Verktygsfält: Lägg till verktyg Toolbar: Remove Tool Verktygsfält: Ta bort verktyg + Add Tool… Lägg till verktyg… + Remove Tool Ta bort verktyg Toolbar: Reset Verktygsfält: Återställ Toolbars Verktygsfält Tools Verktyg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..9770a85ba7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3412,6 +3412,8 @@ digits Malayalam Malayalam Toolbar: Single/Double Column Araç Çubuğu: Tek/Çift Sütun Toolbar: Add Tool Araç Çubuğu: Araç Ekle Toolbar: Remove Tool Araç Çubuğu: Aracı Kaldır + Add Tool… Araç Ekle… + Remove Tool Aracı Kaldır Toolbar: Reset Araç Çubuğu: Sıfırla Toolbars Araç çubukları Tools Araçlar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..dd18ed066c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3632,6 +3632,8 @@ effect Points Точки Toolbar: Single/Double Column Панель інструментів: один/два стовпці Toolbar: Add Tool Панель інструментів: додати інструмент Toolbar: Remove Tool Панель інструментів: вилучити інструмент + Add Tool… Додати інструмент… + Remove Tool Вилучити інструмент Toolbar: Reset Панель інструментів: скинути Toolbars Панелі інструментів Tools Інструменти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..075b8572a8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3988,6 +3988,8 @@ mark-style Japanese Kiểu Nhật Toolbar: Add Tool Thanh công cụ: Thêm công cụ Toolbar: Advanced / Basic Thanh công cụ: Nâng cao / Cơ bản Toolbar: Remove Tool Thanh công cụ: Gỡ công cụ + Add Tool… Thêm công cụ… + Remove Tool Gỡ công cụ Toolbar: Reset Thanh công cụ: Đặt lại Toolbar: Single/Double Column Thanh công cụ: Một/Hai cột Toolbars Thanh công cụ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..166d363e6e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3465,6 +3465,8 @@ digits Malayalam 马拉雅拉姆数字 Toolbar: Single/Double Column 工具列:单列/双列 Toolbar: Add Tool 工具栏:添加工具 Toolbar: Remove Tool 工具栏:移除工具 + Add Tool… 添加工具… + Remove Tool 移除工具 Toolbar: Reset 工具栏:重置 Toolbars 工具栏 Tools 工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..30ba39b10a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4581,6 +4581,8 @@ digits Malayalam 馬來亞拉姆數字 Toolbar: Single/Double Column 工具列:單欄/雙欄 Toolbar: Add Tool 工具列:新增工具 Toolbar: Remove Tool 工具列:移除工具 + Add Tool… 新增工具… + Remove Tool 移除工具 Toolbar: Reset 工具列:重設 Toolbars 工具列 Tools 工具 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 04715474d0..966384142e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -396,7 +396,10 @@ the Tools panel alone, and Tab hides these toolbars with the other panels. Windo panel without them. A toolbar shown with its item or made with New Toolbar after Tab shows alone, and the other toolbars Tab hid are turned off. A toolbar's ••• button (`window.editToolbar {toolbar}`) opens the All Tools drawer beside it, and the drawer's drags and -`toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` with `toolbar` edit it; the drawer has no Show section +`toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` with `toolbar` edit it. The + button above the ••• +button (Add Tool…) opens a menu of every tool under the drawer's headings: a click on a tool runs `toolbar.addTool +{tool, toolbar}`, which puts it at the end, and the tools under its Remove Tool heading run `toolbar.removeTool +{tool, toolbar}`; the tools already in the toolbar are dimmed under their headings. The drawer has no Show section there. `window.floatTools {tool, toolbar}` floats one of its groups, and a group torn off it shows and hides with it. `window.toolbar.list` returns the toolbars (`ui.custom_toolbars` in `ui.inspect`: name, slots, shown flag, two-column flag, top-left corner, the groups torn off it). They are kept with the workspace they were made in: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..9b523dc652 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -782,6 +782,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A menu in a floating panel's OS window stops at that window's bottom edge and scrolls past it, and is never taller than the window (#5347; `a_long_panel_menu_scrolls_inside_a_short_native_window`). A panel's long submenus (Digits, Stylistic Sets, Save Selection, Move to Group), the libraries menu, the Color Guide's Limit to Library menu and the Layers panel's Filter popup scroll the same way. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). + - A toolbar made with New Toolbar has a + button (Add Tool…) above its ••• button. A click on it, or Enter while it has the keyboard focus, opens a menu of every tool under the All Tools drawer's headings, without the drawer: a tool picked there goes at the end of that toolbar, and the tools already in it are dimmed. Under Remove Tool, the menu lists the toolbar's tools, and a click takes one out. This menu is VectorCraft's own way to fill a toolbar without a drag (`toolbar.addTool` and `toolbar.removeTool` with `toolbar`; `the_add_tool_menu_fills_a_new_toolbar_without_a_drag`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). From 825620eb6cd17a4298b4fa9041013abe3d35190c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 11:26:33 -0800 Subject: [PATCH 07/83] Clipboard: Linux and BSD reads wait 2 s at most for the app that copied arboard's Wayland data-control read waits for the app that owns the clipboard to send its data with no time limit, and its X11 read waits up to 4 s for each format it tries. These reads ran on the UI thread: a Copy or Cut checking what the clipboard held, a paste, and the paste check once its background thread is gone. A clipboard owner that never sent its data froze the window. On Linux and the BSDs, arboard's reads now run on a worker thread with a clipboard handle of its own. Text and file lists wait 2 s at most and a bitmap 10 s; the core-protocol fallback's text reads already wait 2 s. While a read is unanswered, later reads fail at once. Writes stay on the UI thread's handle. The core-protocol fallback's worker is now generic and built on every platform. On X11, closing the clipboard within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands the copy to the clipboard manager. On data-control and the core protocol, closing does not wait for an unanswered read. --- Cargo.lock | 1 + apps/vectorcraft/Cargo.toml | 4 + apps/vectorcraft/src/clipboard.rs | 505 +++++++++++++++++++++++------- docs/development.md | 2 +- 4 files changed, 396 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3ec5a0b91..556a5c0e98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6353,6 +6353,7 @@ dependencies = [ "winit", "winresource", "winsafe", + "wl-clipboard-rs", "x11rb", "zbus", ] diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index dfd9b16719..047c4530c7 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -113,6 +113,10 @@ wayland-backend = { version = "0.3", features = ["client_system"] } # exactly: review the diff before bumping. [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies] zbus = "=5.19.0" +# Tells whether arboard reads through the compositor's data-control protocol or through X11, the +# same check arboard makes (src/clipboard.rs). MIT OR Apache-2.0, already in the build through +# arboard. +wl-clipboard-rs = "0.9" [build-dependencies] # Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index b9be306898..bf3562ac0b 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -12,7 +12,9 @@ //! copied), falling back to X11 where the compositor lacks the protocol, and where there is no //! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection, //! text only. A clipboard that can't be opened is tried again after a wait that grows to a -//! minute, not each time Paste asks. +//! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app +//! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at +//! once. //! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS, //! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). @@ -332,11 +334,182 @@ mod win { } } +/// Reads made on a worker thread and waited for a bounded time: a clipboard owner that never sends +/// its data holds the worker, not the caller. On Linux and the BSDs arboard's reads go through one, +/// and on Linux the Wayland core protocol's reads through another. Built everywhere so it is +/// tested everywhere. +#[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")), + allow(dead_code) +)] +mod loads { + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + /// The longest a read waits for the clipboard's owner to send its data. + pub(super) const LOAD_WAIT: Duration = Duration::from_secs(2); + /// What a read says once its worker has stopped. + pub(super) const GONE: &str = "the clipboard's reader stopped"; + + /// Requests of type `R` answered with `A` on a worker thread. + pub(super) struct Loads { + requests: mpsc::Sender, + answers: mpsc::Receiver, + /// When a read that timed out and hasn't answered yet was sent: until it answers, reads + /// don't queue behind it. + pending: Option, + /// How long after it was sent dropping `Loads` waits for an unanswered read to end. + finish: Duration, + } + + /// When dropping `Loads` stops waiting for its worker: `finish` after the unanswered read + /// `pending` was sent (at once if that time has passed), and with no read unanswered + /// [`LOAD_WAIT`] from `now` at most (an idle worker ends at once). + fn stop_by(pending: Option, now: Instant, finish: Duration) -> Instant { + pending.map_or(now + LOAD_WAIT, |sent| sent + finish) + } + + impl Loads { + /// A worker thread named `name` that answers each request with `load`; dropping the result + /// waits up to `finish` after an unanswered read was sent for that read to end. None when the + /// worker can't start. + pub(super) fn spawn(name: &str, finish: Duration, mut load: impl FnMut(R) -> A + Send + 'static) -> Option { + let (requests, asked) = mpsc::channel::(); + let (answer, answers) = mpsc::channel(); + let work = move || { + while let Ok(request) = asked.recv() { + if answer.send(load(request)).is_err() { + break; + } + } + // `load` and the clipboard it holds go first: dropping `Loads` waits for `answer`. + drop(load); + drop(answer); + }; + std::thread::Builder::new().name(name.into()).spawn(work).ok()?; + Some(Self { requests, answers, pending: None, finish }) + } + + /// The answer to `request`, or why there is none, after `wait` at most. + pub(super) fn get(&mut self, request: R, wait: Duration) -> Result { + if self.pending.is_some() { + match self.answers.try_recv() { + // The late answer of the read that timed out: stale by now. + Ok(_) => self.pending = None, + Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()), + Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()), + } + } + let sent = Instant::now(); + self.requests.send(request).map_err(|_| GONE.to_string())?; + match self.answers.recv_timeout(wait) { + Ok(answer) => Ok(answer), + Err(mpsc::RecvTimeoutError::Timeout) => { + self.pending = Some(sent); + Err(format!("the clipboard's owner didn't send its data within {wait:?}")) + } + Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()), + } + } + } + + impl Drop for Loads { + /// Stops the worker and waits until it has let go of what its `load` holds, until + /// [`stop_by`] at most: on X11 the last arboard handle dropped hands what the app copied to + /// the clipboard manager, and the app's own handle must be that one. + fn drop(&mut self) { + let (stop, _) = mpsc::channel(); + drop(std::mem::replace(&mut self.requests, stop)); + let end = stop_by(self.pending, Instant::now(), self.finish); + while self.answers.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {} + } + } + + #[cfg(test)] + mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn a_read_that_answers_in_time_gives_the_text() { + let mut loads = Loads::spawn("test", Duration::ZERO, |()| "".to_string()).unwrap(); + assert_eq!(loads.get((), Duration::from_secs(5)), Ok("".to_string())); + assert_eq!(loads.get((), Duration::from_secs(5)), Ok("".to_string())); + } + + #[test] + fn an_owner_that_never_answers_holds_the_worker_not_the_caller() { + let (release, gate) = mpsc::channel::<()>(); + let mut calls = 0; + let mut loads = Loads::spawn("test", Duration::ZERO, move |()| { + calls += 1; + if calls == 1 { + // The first owner sends nothing until released. + let _ = gate.recv(); + return "stale".to_string(); + } + "fresh".to_string() + }) + .unwrap(); + assert!(loads.get((), Duration::ZERO).is_err(), "the first read times out"); + // While it is unanswered, a read doesn't queue behind it or wait at all. + assert!(loads.get((), Duration::from_secs(600)).is_err()); + release.send(()).unwrap(); + // Once the late answer is in, it is dropped and a new read is made. + let text = loop { + match loads.get((), Duration::from_secs(600)) { + Ok(text) => break text, + Err(why) => { + // The late answer isn't in yet. + assert_ne!(why, GONE); + std::thread::yield_now(); + } + } + }; + assert_eq!(text, "fresh"); + } + + #[test] + fn a_read_after_the_worker_stops_says_so() { + let mut loads = Loads::spawn("test", Duration::ZERO, |()| String::new()).unwrap(); + // The worker ends when its answers can't be sent: drop them by hand. + let (_, dead) = mpsc::channel(); + drop(std::mem::replace(&mut loads.answers, dead)); + assert_eq!(loads.get((), Duration::from_millis(50)), Err(GONE.to_string())); + } + + #[test] + fn dropping_the_reads_waits_until_the_worker_lets_go_of_its_clipboard() { + let clipboard = Arc::new(()); + let held = clipboard.clone(); + let mut loads = Loads::spawn("test", Duration::ZERO, move |()| Arc::strong_count(&held)).unwrap(); + assert_eq!(loads.get((), Duration::from_secs(600)), Ok(2)); + drop(loads); + assert_eq!(Arc::strong_count(&clipboard), 1, "the worker's handle is gone before the app's"); + } + + #[test] + fn dropping_the_reads_waits_for_an_unanswered_read_up_to_its_limit() { + let now = Instant::now(); + let limit = Duration::from_secs(25); + // No read unanswered: the idle worker ends at once, LOAD_WAIT is only the bound. + assert_eq!(stop_by(None, now, limit), now + LOAD_WAIT); + // A read sent 2 s ago that timed out: wait for it until 25 s after it was sent. + assert_eq!(stop_by(Some(now - Duration::from_secs(2)), now, limit), now + Duration::from_secs(23)); + // Its limit already over, or no limit (data-control, the core protocol): no wait. + assert!(stop_by(Some(now - Duration::from_secs(30)), now, limit) <= now); + assert_eq!(stop_by(Some(now), now, Duration::ZERO), now); + } + } +} + /// arboard, or on a Wayland compositor without its data-control protocol the core protocol's text /// clipboard: one format at a time. Built everywhere so it is checked everywhere. #[cfg_attr(windows, allow(dead_code))] mod portable { use std::borrow::Cow; + use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -349,9 +522,156 @@ mod portable { Image(usize, usize), } + /// A read of arboard's clipboard. + #[derive(Clone, Copy)] + enum Request { + Text, + Image, + Files, + } + + /// What a [`Request`] read. + enum Answer { + Text(Result), + Image(Result, String>), + Files(Vec), + } + + /// What a read says when it gets the answer to another request. + const MIXED: &str = "the clipboard answered another read"; + + impl Answer { + /// The answer to `request` when it couldn't be read: `why`, or no files. + fn failed(request: Request, why: String) -> Self { + match request { + Request::Text => Self::Text(Err(why)), + Request::Image => Self::Image(Err(why)), + Request::Files => Self::Files(vec![]), + } + } + + fn text(self) -> Result { + match self { + Self::Text(text) => text, + _ => Err(MIXED.into()), + } + } + + fn image(self) -> Result, String> { + match self { + Self::Image(image) => image, + _ => Err(MIXED.into()), + } + } + + fn files(self) -> Vec { + match self { + Self::Files(files) => files, + _ => vec![], + } + } + } + + /// `request` read through `cb`. + fn answer(cb: &mut arboard::Clipboard, request: Request) -> Answer { + match request { + Request::Text => Answer::Text(cb.get_text().map_err(|e| e.to_string())), + Request::Image => Answer::Image(cb.get_image().map_err(|e| e.to_string())), + Request::Files => Answer::Files(cb.get().file_list().unwrap_or_default()), + } + } + + /// `request` read on `reads`' worker, waiting `wait` at most. + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")), + allow(dead_code) + )] + fn read_within(reads: &mut super::loads::Loads, request: Request, wait: Duration) -> Answer { + reads.get(request, wait).unwrap_or_else(|why| Answer::failed(request, why)) + } + + /// The longest a bitmap read of arboard's waits for the app that copied: the bitmap's transfer + /// and its PNG decoding happen within the read. Text and file lists wait + /// [`LOAD_WAIT`](super::loads::LOAD_WAIT). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + const IMAGE_WAIT: Duration = Duration::from_secs(10); + + /// How long a read of `request` waits for the app that copied. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn wait_for(request: Request) -> Duration { + match request { + Request::Image => IMAGE_WAIT, + Request::Text | Request::Files => super::loads::LOAD_WAIT, + } + } + + /// The longest an X11 read of arboard's lasts while the owner sends nothing: a text read tries + /// six formats and waits up to 4 s for each, and a second more covers the rest of the read. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + const X11_READ_LIMIT: Duration = Duration::from_secs(25); + + /// Whether arboard's handles use the compositor's data-control protocol rather than X11. arboard + /// 3.7 makes the same check when it opens: data-control where `WAYLAND_DISPLAY` is set and + /// wl-clipboard-rs finds the protocol, X11 otherwise. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn on_data_control() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() && wl_clipboard_rs::utils::is_primary_selection_supported().is_ok() + } + + /// How long after an unanswered read was sent dropping the reads waits for it to end: on X11 + /// the last handle dropped hands what the app copied to a clipboard manager, and on + /// data-control, where a read may never end, nothing is handed over. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn finish(data_control: bool) -> Duration { + if data_control { Duration::ZERO } else { X11_READ_LIMIT } + } + + /// arboard's clipboard, with `C` for writes (arboard's handle; a stand-in in tests). On Linux + /// and the BSDs its reads run on a worker with a handle of its own and wait + /// [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, a bitmap 10 s: arboard's Wayland + /// data-control read waits for the app that copied to send its data with no limit, and its X11 + /// read waits up to 4 s for each format it tries. + struct Arboard { + /// Dropped before `cb`, and waits for its worker's handle to go, on X11 up to + /// [`X11_READ_LIMIT`] after an unanswered read was sent: there the last handle dropped + /// hands what the app copied to the clipboard manager. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + reads: super::loads::Loads, + /// Writes, and elsewhere reads. + cb: C, + } + + impl Arboard { + fn open() -> Result { + let cb = arboard::Clipboard::new().map_err(|e| e.to_string())?; + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + { + let mut reader = arboard::Clipboard::new().map_err(|e| e.to_string())?; + let reads = super::loads::Loads::spawn("clipboard-read", finish(on_data_control()), move |request| answer(&mut reader, request)) + .ok_or("the clipboard's reader didn't start")?; + Ok(Self { reads, cb }) + } + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] + Ok(Self { cb }) + } + + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] + fn read(&mut self, request: Request) -> Answer { + answer(&mut self.cb, request) + } + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + impl Arboard { + /// Reads on the worker; the write handle `C` is not used. + fn read(&mut self, request: Request) -> Answer { + read_within(&mut self.reads, request, wait_for(request)) + } + } + /// An open clipboard connection. enum Backend { - Arboard(arboard::Clipboard), + Arboard(Arboard), /// The compositor's core data-device protocol on the window's connection (text only). #[cfg(target_os = "linux")] Wayland(super::wayland::TextClipboard), @@ -366,7 +686,7 @@ mod portable { /// Wayland connection. Err: why each failed. fn open(window: Option<&Arc>) -> Result { first_open( - || arboard::Clipboard::new().map(Self::Arboard).map_err(|e| e.to_string()), + || Arboard::open().map(Self::Arboard), || { #[cfg(target_os = "linux")] return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window"); @@ -389,7 +709,7 @@ mod portable { fn get_text(&mut self) -> Result { match self { - Self::Arboard(cb) => cb.get_text().map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.read(Request::Text).text(), #[cfg(target_os = "linux")] Self::Wayland(cb) => cb.get_text(), } @@ -397,7 +717,7 @@ mod portable { fn set_text(&mut self, text: &str) -> Result<(), String> { match self { - Self::Arboard(cb) => cb.set_text(text).map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.cb.set_text(text).map_err(|e| e.to_string()), #[cfg(target_os = "linux")] Self::Wayland(cb) => { cb.set_text(text); @@ -408,7 +728,7 @@ mod portable { fn get_image(&mut self) -> Result, String> { match self { - Self::Arboard(cb) => cb.get_image().map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.read(Request::Image).image(), #[cfg(target_os = "linux")] Self::Wayland(_) => Err(TEXT_ONLY.into()), } @@ -416,7 +736,7 @@ mod portable { fn set_image(&mut self, img: arboard::ImageData<'_>) -> Result<(), String> { match self { - Self::Arboard(cb) => cb.set_image(img).map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.cb.set_image(img).map_err(|e| e.to_string()), #[cfg(target_os = "linux")] Self::Wayland(_) => Err(TEXT_ONLY.into()), } @@ -424,7 +744,7 @@ mod portable { fn clear(&mut self) -> Result<(), String> { match self { - Self::Arboard(cb) => cb.clear().map_err(|e| e.to_string()), + Self::Arboard(cb) => cb.cb.clear().map_err(|e| e.to_string()), #[cfg(target_os = "linux")] Self::Wayland(cb) => { cb.set_text(""); @@ -434,9 +754,9 @@ mod portable { } /// Files copied in a file manager (none where the backend can't read a file list). - fn file_list(&mut self) -> Vec { + fn file_list(&mut self) -> Vec { match self { - Self::Arboard(cb) => cb.get().file_list().unwrap_or_default(), + Self::Arboard(cb) => cb.read(Request::Files).files(), #[cfg(target_os = "linux")] Self::Wayland(_) => vec![], } @@ -642,6 +962,7 @@ mod portable { mod tests { use std::cell::Cell; + use super::super::loads::Loads; use super::*; /// A clock that starts now and moves only when told. @@ -763,6 +1084,59 @@ mod portable { ); } + #[test] + fn reads_return_while_the_clipboard_owner_sends_nothing() { + let (release, gate) = std::sync::mpsc::channel::<()>(); + // The app that copied never sends its data until released. + let mut reads = Loads::spawn("test", Duration::ZERO, move |request| { + let _ = gate.recv(); + Answer::failed(request, "late".into()) + }) + .unwrap(); + // Copy's look at the text, Paste's file list and its bitmap: none waits for the owner. + assert!(read_within(&mut reads, Request::Text, Duration::ZERO).text().is_err()); + assert!(read_within(&mut reads, Request::Files, Duration::from_secs(600)).files().is_empty()); + assert!(read_within(&mut reads, Request::Image, Duration::from_secs(600)).image().is_err()); + drop(release); + } + + #[test] + fn each_read_takes_its_own_answer() { + let mut reads = Loads::spawn("test", Duration::ZERO, |request| match request { + Request::Text => Answer::Text(Ok("".into())), + Request::Image => Answer::Image(Ok(arboard::ImageData { width: 2, height: 1, bytes: Cow::Owned(vec![0; 8]) })), + Request::Files => Answer::Files(vec![PathBuf::from("art.svg")]), + }) + .unwrap(); + let wait = Duration::from_secs(600); + assert_eq!(read_within(&mut reads, Request::Text, wait).text(), Ok("".to_string())); + assert_eq!(read_within(&mut reads, Request::Image, wait).image().map(|i| (i.width, i.height)), Ok((2, 1))); + assert_eq!(read_within(&mut reads, Request::Files, wait).files(), [PathBuf::from("art.svg")]); + // The answer to another read is not taken for this one. + assert_eq!(Answer::Files(vec![]).text(), Err(MIXED.to_string())); + assert!(Answer::Text(Ok(String::new())).image().is_err()); + assert!(Answer::Text(Ok("art.svg".into())).files().is_empty()); + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn arboards_reads_run_on_its_reader_and_wait_by_kind() { + let reads = Loads::spawn("test", Duration::ZERO, |request| match request { + Request::Text => Answer::Text(Ok("read on the worker".into())), + _ => Answer::failed(request, "not asked".into()), + }) + .unwrap(); + // `()` for writes: a read made through the write handle wouldn't build. + let mut cb = Arboard { reads, cb: () }; + assert_eq!(cb.read(Request::Text).text(), Ok("read on the worker".to_string())); + assert_eq!(wait_for(Request::Text), super::super::loads::LOAD_WAIT); + assert_eq!(wait_for(Request::Files), super::super::loads::LOAD_WAIT); + assert_eq!(wait_for(Request::Image), Duration::from_secs(10)); + // X11 waits for an unanswered read when the clipboard closes; data-control doesn't. + assert_eq!(finish(false), X11_READ_LIMIT); + assert_eq!(finish(true), Duration::ZERO); + } + #[test] fn one_outage_is_logged_once_across_handles() { static LOGGED: AtomicBool = AtomicBool::new(false); @@ -787,19 +1161,15 @@ mod portable { #[cfg(target_os = "linux")] mod wayland { use std::sync::Arc; - use std::sync::mpsc; use std::time::Duration; - /// The longest a read waits for the selection's owner to send its text. - const LOAD_WAIT: Duration = Duration::from_secs(2); - /// What a read says once its worker has stopped. - const GONE: &str = "the clipboard's reader stopped"; + use super::loads::{LOAD_WAIT, Loads}; pub struct TextClipboard { /// Writes: a store only hands the text to smithay-clipboard's thread. cb: smithay_clipboard::Clipboard, /// Reads, on a worker with a second clipboard on the same connection. - loads: Loads, + loads: Loads<(), Result>, /// Held as long as the clipboards: their connection is the window's. _window: Arc, } @@ -830,115 +1200,20 @@ mod wayland { let loader = Loader { cb: reader, _window: window.clone() }; // `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names, // and the worker must hold the window as long as its clipboard. - let loads = Loads::spawn(move || loader.load())?; + // Dropping doesn't wait for an unanswered read: only arboard's X11 handles hand what the + // app copied on when the last one goes. + let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |()| loader.load())?; Some(Self { cb, loads, _window: window }) } pub fn get_text(&mut self) -> Result { - self.loads.get(LOAD_WAIT) + self.loads.get((), LOAD_WAIT)? } pub fn set_text(&self, text: &str) { self.cb.store(text); } } - - /// Reads made on a worker thread and waited for a bounded time: a selection owner that never - /// sends its data holds the worker, not the caller. - struct Loads { - requests: mpsc::Sender<()>, - answers: mpsc::Receiver>, - /// A read that timed out and hasn't answered yet: until it does, reads don't queue behind it. - pending: bool, - } - - impl Loads { - fn spawn(mut load: impl FnMut() -> Result + Send + 'static) -> Option { - let (requests, asked) = mpsc::channel::<()>(); - let (answer, answers) = mpsc::channel(); - let work = move || { - while asked.recv().is_ok() { - if answer.send(load()).is_err() { - break; - } - } - }; - std::thread::Builder::new().name("wayland-clipboard-read".into()).spawn(work).ok()?; - Some(Self { requests, answers, pending: false }) - } - - /// The text, or why there is none, after `wait` at most. - fn get(&mut self, wait: Duration) -> Result { - if self.pending { - match self.answers.try_recv() { - // The late answer of the read that timed out: stale by now. - Ok(_) => self.pending = false, - Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()), - Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()), - } - } - self.requests.send(()).map_err(|_| GONE.to_string())?; - match self.answers.recv_timeout(wait) { - Ok(text) => text, - Err(mpsc::RecvTimeoutError::Timeout) => { - self.pending = true; - Err(format!("the clipboard's owner didn't send its text within {wait:?}")) - } - Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()), - } - } - } - - #[cfg(test)] - mod tests { - use std::time::Instant; - - use super::*; - - #[test] - fn a_read_that_answers_in_time_gives_the_text() { - let mut loads = Loads::spawn(|| Ok("".into())).unwrap(); - assert_eq!(loads.get(Duration::from_secs(5)), Ok("".to_string())); - assert_eq!(loads.get(Duration::from_secs(5)), Ok("".to_string())); - } - - #[test] - fn an_owner_that_never_answers_holds_the_worker_not_the_caller() { - let (release, gate) = mpsc::channel::<()>(); - let mut calls = 0; - let mut loads = Loads::spawn(move || { - calls += 1; - if calls == 1 { - // The first owner sends nothing until released. - let _ = gate.recv(); - return Ok("stale".into()); - } - Ok("fresh".into()) - }) - .unwrap(); - let start = Instant::now(); - assert!(loads.get(Duration::from_millis(50)).is_err(), "the first read times out"); - // While it is unanswered, a read doesn't queue behind it or wait at all. - assert!(loads.get(Duration::from_secs(60)).is_err()); - assert!(start.elapsed() < Duration::from_secs(30), "no read waited past its bound"); - release.send(()).unwrap(); - // Once the late answer is in, it is dropped and a new read is made. - let text = (0..200).find_map(|_| { - std::thread::sleep(Duration::from_millis(10)); - loads.get(Duration::from_secs(5)).ok() - }); - assert_eq!(text.as_deref(), Some("fresh")); - } - - #[test] - fn a_read_after_the_worker_stops_says_so() { - let mut loads = Loads::spawn(|| Ok(String::new())).unwrap(); - // The worker ends when its answers can't be sent: drop them by hand. - let (_, dead) = mpsc::channel(); - drop(std::mem::replace(&mut loads.answers, dead)); - assert_eq!(loads.get(Duration::from_millis(50)), Err(GONE.to_string())); - } - } } /// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the diff --git a/docs/development.md b/docs/development.md index c6523bd035..78e620f7e3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -123,7 +123,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: - **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and a Paste waits 2 s at most: an owner that never sends its data costs one Paste 2 s, and later reads return at once until it answers. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. -- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. +- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most, a bitmap 10 s: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. - **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, the app runs under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer: Preferences › Performance › Linux Display Server's Automatic (the default) and X11 (XWayland) When a Pen Is Attached (`x11WithPen`) open the window through XWayland when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and log `pen found: opening the window through XWayland`, as PhotoCraft does (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps the window on Wayland). For a pen attached after the app started, either run the app under XWayland (Preferences › Performance › Linux Display Server › X11 (XWayland); `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way. From 96e20d548d4d643854e53875e62c324ca0bf98ba Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 12:18:18 -0800 Subject: [PATCH 08/83] Plug-ins: a light filter that reports progress runs in the background with Cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A light filter of plug-in API level 2 with the `progress` permission ran on the UI thread, so its progress reports and `vc_cancelled` never reached a progress window. It now runs in the background under its light budget, as a heavy filter does, when it runs at once from Object › Plug-ins or its shortcut, or when OK runs it with Preview off. The progress window shows its share and Cancel stops it. With Preview on, OK keeps the previewed run. A filter that lists a `commandAllowlist` still runs on the UI thread. Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md. --- crates/mcp/src/ui_catalog.rs | 6 +- crates/ui-egui/src/dialogs/plugin.rs | 35 +++++-- crates/ui-egui/src/menus/ui_commands.rs | 6 +- crates/ui-egui/src/plugin_runs.rs | 6 +- crates/ui-egui/src/tests/plugin_runs.rs | 127 +++++++++++++++++++++++- docs/mcp.md | 4 +- docs/plugins.md | 6 +- docs/target-app-parity.md | 4 +- 8 files changed, 172 insertions(+), 22 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e15244580d..e58f00b8e5 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1493,7 +1493,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "plugin.dialog", "Plug-in…", "", - "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", + "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step). A heavy filter, and a filter that lists `progress` and no commandAllowlist, run in the background when they run at once or OK runs them without a kept preview → {background: true, status} (see plugin.cancelRuns)", ), ( "ui.installPlugin", @@ -1505,13 +1505,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "plugin.cancelRuns", "Cancel Plug-in Run", "", - "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", + "{} stop every filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", ), ( "plugin.hideRuns", "Continue in Background", "", - "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", + "{} close the progress window of the filters running in the background; they go on, shown in the status bar → {hidden: n}", ), ( "imageTrace.cancelRuns", diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 76f71f3b4e..9ee608a12c 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -6,7 +6,10 @@ //! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live //! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs //! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes: -//! it runs in the background ([`crate::plugin_runs`]). A filter or live effect with commands of +//! it runs in the background ([`crate::plugin_runs`]). A light filter that reports its progress +//! (plug-in API level 2 with the `progress` permission) and calls no commands runs in the +//! background too, with its progress and Cancel, when it runs at once or when OK runs it without a +//! kept preview ([`in_background`]). A filter or live effect with commands of //! its own is a submenu named after it, holding its item and then its commands; a command with //! parameters opens the same dialog for them ([`open_command`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its @@ -200,12 +203,19 @@ fn params(d: &Dialog) -> Value { } } -/// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background when it is -/// heavy. +/// Does filter `m` run in the background ([`crate::plugin_runs`]) rather than on the UI thread? A +/// heavy one does, and so does a light one that reports its progress (the `progress` permission) +/// unless it calls commands, which run only in the session. +fn in_background(m: &Manifest) -> bool { + m.budget == Budget::Heavy || (m.permissions().contains(&permissions::Permission::Progress) && m.command_allowlist.is_empty()) +} + +/// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background +/// ([`in_background`]). fn run(app: &mut VectorcraftApp, plugin: &Plugin, p: Value) -> Result { - match plugin.manifest().budget { - Budget::Light => app.run(CMD, p), - Budget::Heavy => crate::plugin_runs::start(app, p), + match in_background(plugin.manifest()) { + false => app.run(CMD, p), + true => crate::plugin_runs::start(app, p), } } @@ -234,11 +244,18 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } -/// OK: a filter keeps its previewed run as one undo step; a heavy one, which has no preview, starts -/// in the background ([`run`]) and the dialog closes. +/// OK: a filter keeps its previewed run as one undo step; a heavy one, which has no preview, and a +/// light one that runs in the background ([`in_background`]) with Preview off, start in the +/// background ([`run`]) and the dialog closes. A preview still shown when Preview was just turned +/// off is rolled back first. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let own_run = d.str("__command").is_empty(); + let kept = |plugin: &Plugin| plugin.manifest().budget == Budget::Light && d.bool("preview") && app.session.in_interaction(); match registry::get(&d.str("__plugin")) { - Some(plugin) if plugin.manifest().budget == Budget::Heavy => { + Some(plugin) if own_run && in_background(plugin.manifest()) && !kept(&plugin) => { + if d.fields.contains_key(form::PREVIEWED) && app.session.in_interaction() { + let _ = app.session.cancel_interaction(); + } let r = run(app, &plugin, params(d)); if r.is_ok() { app.ui.dialog = None; diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index a96f13e67c..9cfd4d8951 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1494,7 +1494,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "plugin.dialog", "Plug-in…", "", - "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", + "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step). A heavy filter, and a filter that lists `progress` and no commandAllowlist, run in the background when they run at once or OK runs them without a kept preview → {background: true, status} (see plugin.cancelRuns)", ), ( "ui.installPlugin", @@ -1506,13 +1506,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "plugin.cancelRuns", "Cancel Plug-in Run", "", - "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", + "{} stop every filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", ), ( "plugin.hideRuns", "Continue in Background", "", - "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", + "{} close the progress window of the filters running in the background; they go on, shown in the status bar → {hidden: n}", ), ( "imageTrace.cancelRuns", diff --git a/crates/ui-egui/src/plugin_runs.rs b/crates/ui-egui/src/plugin_runs.rs index a1711ee8f8..b80f15a9db 100644 --- a/crates/ui-egui/src/plugin_runs.rs +++ b/crates/ui-egui/src/plugin_runs.rs @@ -13,7 +13,9 @@ //! A filter of plug-in API level 2 may report how far it has got (`vc_progress`): the progress //! window then shows a bar with the share done. Its Cancel also makes the filter's `vc_cancelled` //! return 1, so a filter that checks it stops at once; one that doesn't stops at the next slice of -//! its budget. +//! its budget. A light filter that may report its progress (the `progress` permission) and calls +//! no commands runs here too, under its light budget, so its progress and Cancel show while it +//! computes. use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; @@ -152,7 +154,7 @@ fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { } } -/// The progress window's Cancel (also Esc): every heavy filter running stops (at once when it checks +/// The progress window's Cancel (also Esc): every filter running in the background stops (at once when it checks /// `vc_cancelled`, else at the next slice of its budget) and changes nothing → `{cancelled: n}`. pub fn cancel_all(app: &mut VectorcraftApp) -> Value { let mut n = 0; diff --git a/crates/ui-egui/src/tests/plugin_runs.rs b/crates/ui-egui/src/tests/plugin_runs.rs index c3bd9df302..555aedd050 100644 --- a/crates/ui-egui/src/tests/plugin_runs.rs +++ b/crates/ui-egui/src/tests/plugin_runs.rs @@ -1,6 +1,7 @@ //! Heavy plug-in filters in the app ([`crate::plugin_runs`]): installing one asks first, its dialog //! has no Preview, and it runs off the UI thread, its result applied as one undo step once it is -//! done, unless the document changed meanwhile. Cancel stops it; its progress window shows it. +//! done, unless the document changed meanwhile. Cancel stops it; its progress window shows it. A +//! light filter that reports its progress runs there too. use std::time::{Duration, Instant}; @@ -214,6 +215,130 @@ fn a_filter_that_reports_progress_shows_a_bar_and_sees_cancel() { assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string()); } +/// The triangle the light test filters return. +const TRIANGLE: &str = r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[30,0]},{"p":[15,20]}]}]}}]}"#; + +/// Installs a light filter `id` of plug-in API level 2 with `params` that returns [`TRIANGLE`], +/// granted as the user would grant it. With `progress` it lists the `progress` permission, and its +/// `vc_run` first runs `first`, which may report its progress. +fn install_light(app: &mut VectorcraftApp, id: &str, progress: bool, params: &str, first: &str) { + let may: &[&str] = if progress { &["doc.read", "doc.write", "progress"] } else { &["doc.read", "doc.write"] }; + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{},"params":{params}}}"#, json!(may)); + // Without `progress` the module may not import the host functions. + let src = if progress { tpl::reporting(&manifest, first, TRIANGLE) } else { tpl::returning(&manifest, TRIANGLE) }; + let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); + app.run("plugin.install", json!({"dataBase64": data, "grant": may})).unwrap(); +} + +/// Applies the runs' results as the frames would, until none is left (each run ends: it returns, +/// or its budget stops it). +fn finish_runs(app: &mut VectorcraftApp) { + while !app.plugin_runs.running.is_empty() { + plugin_runs::poll(app); + std::thread::yield_now(); + } +} + +/// A light filter that may report its progress runs off the UI thread when it runs at once, as a +/// heavy one does: the status bar says so, and its result is one undo step named after it. +#[test] +fn a_light_filter_that_reports_progress_runs_off_the_ui_thread() { + let mut app = app(); + let id = "test.ui.light.progress.run"; + install_light(&mut app, id, true, "{}", "(call $vc_progress (i64.const 1) (i64.const 2))"); + let undo = app.session.doc().unwrap().history.undo.len(); + let r = app.run("plugin.dialog", json!({"id": id})).unwrap(); + assert_eq!(r["background"], true, "{r}"); + assert_eq!(app.ui.status, format!("Running “Test {id}”…")); + finish_runs(&mut app); + let st = app.session.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + assert_eq!(st.history.undo.last().unwrap().label, format!("Test {id}")); + assert_eq!(st.selection.objects.len(), 1, "the triangle it added, selected"); +} + +/// A light filter that reports its progress shows it in the progress window, and Cancel there +/// makes its `vc_cancelled` return 1: it stops, changing nothing. +#[test] +fn a_light_filter_s_progress_window_shows_its_share_and_cancel_stops_it() { + let mut app = app(); + let id = "test.ui.light.progress.cancel"; + let first = "(call $vc_progress (i64.const 250) (i64.const 1000)) (block $stop (loop $l (br_if $stop (call $vc_cancelled)) (br $l)))"; + install_light(&mut app, id, true, "{}", first); + let doc = app.session.doc().unwrap().doc.clone(); + assert_eq!(app.run("plugin.dialog", json!({"id": id})).unwrap()["background"], true); + // Waits for the run's thread to report the share (a handshake: the thread never stops before + // it is cancelled or its budget runs out, and a run that ended leaves the loop). + loop { + plugin_runs::poll(&mut app); + match app.plugin_runs.running.first() { + Some(r) if r.progress.counts() != (250, 1000) => std::thread::yield_now(), + _ => break, + } + } + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + progress(&mut app, &ctx, 0.0); + progress(&mut app, &ctx, 1.0); + let shown = progress(&mut app, &ctx, 1.1); + assert!(shown.contains(&format!("Running “Test {id}”…")) && shown.contains("25 %") && shown.contains("Cancel"), "{shown}"); + assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1); + finish_runs(&mut app); + assert_eq!(app.session.doc().unwrap().doc, doc); + assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string()); +} + +/// In the dialog of a light filter that reports its progress, OK with Preview off runs it in the +/// background, also right after Preview was turned off; with Preview on, OK keeps the previewed +/// run, as for any light filter. +#[test] +fn ok_runs_a_light_progress_filter_in_the_background_without_a_preview() { + let mut app = app(); + let id = "test.ui.light.progress.dialog"; + install_light(&mut app, id, true, r#"{"size":{"type":"number","min":1,"max":9,"default":3}}"#, ""); + let undo = app.session.doc().unwrap().history.undo.len(); + app.run("plugin.dialog", json!({"id": id})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + frame(&mut app); + assert!(!app.session.in_interaction(), "nothing previewed"); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.plugin_runs.running.len(), 1, "running in the background"); + finish_runs(&mut app); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Preview on: OK keeps the previewed run, and nothing runs in the background. + app.run("plugin.dialog", json!({"id": id})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("preview"), Some(&json!(true))); + frame(&mut app); + assert!(app.session.in_interaction(), "previewed"); + dialogs::confirm(&mut app).unwrap(); + assert!(app.plugin_runs.running.is_empty()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + // Preview turned off after a preview and OK before the next frame: the preview is rolled back + // and the run goes to the background. + app.run("plugin.dialog", json!({"id": id})).unwrap(); + frame(&mut app); + assert!(app.session.in_interaction(), "previewed"); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false)); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.in_interaction(), "the preview was rolled back"); + assert_eq!(app.plugin_runs.running.len(), 1, "running in the background"); + finish_runs(&mut app); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 3); +} + +/// A light filter without the `progress` permission still runs at once, on the UI thread. +#[test] +fn a_light_filter_that_does_not_report_progress_runs_at_once() { + let mut app = app(); + let id = "test.ui.light.plain"; + install_light(&mut app, id, false, "{}", ""); + let r = app.run("plugin.dialog", json!({"id": id})).unwrap(); + assert!(r.get("background").is_none(), "{r}"); + assert!(app.plugin_runs.running.is_empty()); + assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1, "the triangle, added at once"); +} + /// `CRAFT_UI_HEAVY_FILTER_WASM=` writes a heavy test filter to install in the app /// (`order-paths-test-filter.wasm`): it computes until it is cancelled or its budget runs out, so /// its progress window stays up for a picture. diff --git a/docs/mcp.md b/docs/mcp.md index 287ecbca63..788bb5bf13 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7464,7 +7464,9 @@ grant in the store. A stored module that is disabled, or not read in Safe Mode, filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log -under its id. One that lists `settings` or `cache` keeps settings or a cache of its own in the `Plug-ins` folder +under its id. With a desktop app connected, `plugin.dialog` runs such a filter (one without a `commandAllowlist`) in the +background when it runs at once or OK runs it with Preview off, and returns `{background: true, status}`; +`plugin.cancelRuns` stops it. One that lists `settings` or `cache` keeps settings or a cache of its own in the `Plug-ins` folder (VectorCraft's own caps: 256 settings and 1 MiB, 512 MiB of cache): `plugin.list` and `plugin.info` give the cache's size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns `{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that diff --git a/docs/plugins.md b/docs/plugins.md index 06fd89f28a..aba041fe4d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -336,7 +336,11 @@ still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; t and one the manifest doesn't allow (an `api` below 2, or without the import's permission) refuse the module, and the error names the import and the permission. - A filter's or an exporter's progress is shown as a share: a heavy filter's progress window and the export's progress - window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. A plug-in that checks it can + window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. In the desktop app a light + filter that lists `progress` runs in the background as a heavy one does, under its light budget, when it runs at once + from Object › Plug-ins or its shortcut, or when OK runs it with Preview off; with Preview on, its dialog keeps the + previewed run. A filter that also lists a `commandAllowlist` runs where it is called, since its command calls need + the document. A plug-in that checks `vc_cancelled` can stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that ignores it is still stopped by the instruction and time budgets. With a known total, a report is passed on only when it reaches another tenth of a percent; with an unknown total, each change of `done` is passed on. After 2,000 reports diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9599a6373d..43f93efe95 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1655,7 +1655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. -- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. +- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. @@ -1935,7 +1935,7 @@ Edit › Keyboard Shortcuts lists every item of the Effect menu under its menu p A live-effect plug-in installed while the app runs gets its Effect › Plug-ins row in Edit › Keyboard Shortcuts, in `shortcuts.list` and in a Keyboard Shortcuts dialog that is already open, and its key runs it. A removed plug-in's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed, opening the effect's dialog if the new one has options and applying the effect if it has none. Tests: `shortcuts::tests::a_live_effect_plugin_installed_later_takes_a_shortcut_of_its_own`, `shortcut_editor::tests::an_open_dialog_follows_live_effect_plugins`. -Each installed filter plug-in's Object › Plug-ins item has a row of its own (stored as `plugin:`, since every filter runs `plugin.dialog {id}`), with no default shortcut, also when two filters share a name. The menu shows its shortcut, and pressing it does what a click on the item does: it opens the filter's dialog, or runs a filter without parameters at once as one undo step (a heavy filter starts in the background). A removed filter's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed. With no document open the key does nothing, as the dimmed item does. The command palette has one result per filter, and Import Shortcut Set offers each filter as a target without matching a row to it by name. Plug-in names stay as the plug-in gives them; the menu path is translated. Tests: `shortcuts::tests::each_filter_plugin_takes_a_shortcut_of_its_own`, `shortcuts::tests::a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded`, `shortcuts::tests::a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item`, `shortcuts::tests::a_filter_plugin_row_translates_its_path_and_not_its_name`, `palette::tests::each_filter_plugin_is_a_result_of_its_own`, `dialogs::tests_shortcut_import::an_imported_shortcut_reaches_a_filter_plugin_picked_as_its_target`. +Each installed filter plug-in's Object › Plug-ins item has a row of its own (stored as `plugin:`, since every filter runs `plugin.dialog {id}`), with no default shortcut, also when two filters share a name. The menu shows its shortcut, and pressing it does what a click on the item does: it opens the filter's dialog, or runs a filter without parameters at once as one undo step (a heavy filter, and a light one that lists `progress` and no `commandAllowlist`, starts in the background). A removed filter's row goes away and its key runs nothing; the shortcut stays saved and applies again when a plug-in with the same id is installed. With no document open the key does nothing, as the dimmed item does. The command palette has one result per filter, and Import Shortcut Set offers each filter as a target without matching a row to it by name. Plug-in names stay as the plug-in gives them; the menu path is translated. Tests: `shortcuts::tests::each_filter_plugin_takes_a_shortcut_of_its_own`, `shortcuts::tests::a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded`, `shortcuts::tests::a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item`, `shortcuts::tests::a_filter_plugin_row_translates_its_path_and_not_its_name`, `palette::tests::each_filter_plugin_is_a_result_of_its_own`, `dialogs::tests_shortcut_import::an_imported_shortcut_reaches_a_filter_plugin_picked_as_its_target`. ## Live Corners: arrow keys change the corner kind while dragging (2026-11-01) From f04aa61fc1e3f899822c0f230c399450a30ab602 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 12:19:32 -0800 Subject: [PATCH 09/83] Scripts: a return from a finally block drops the discarded error's throw site A `return` inside a finally block discards the pending exception, but the saved throw site for it stayed in the list of collection roots. The code it pointed to then stayed live after the function was gone, which could stop a script with the heap limit when that code came from `Function` or `eval`. Returning from a frame now drops the saved sites for the slots it leaves. Follows #6653. --- crates/js/src/interp.rs | 2 ++ crates/js/src/interp_tests.rs | 41 ++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b04702be58..d9a351c774 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -779,6 +779,8 @@ impl Interp { fn return_value(&mut self, v: Value) -> JsResult> { let Some(frame) = self.frames.pop() else { return internal("return without a frame") }; self.stack.truncate(frame.base); + // A `return` in a finally block leaves the block, and the value takes its exception's slot. + self.drop_left_finally_sites(frame.base); let v = match frame.construct { Some(o) if !matches!(v, Value::Object(_)) => Value::Object(o), _ => v, diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index d2a12b5fb0..9d673dc78e 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use proptest::prelude::*; use crate::bytecode::{Code, Handler, Op, Source, verify}; -use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; +use crate::{JsValue, LimitKind, Limits, Options, Progress, Realm, RunError, RunErrorKind}; fn run(src: &str) -> JsValue { match Realm::new().run(src) { @@ -357,6 +357,45 @@ fn function_source_text_counts_toward_the_heap() { assert_eq!(realm.run("(function f(a) { return a; }).toString()"), Ok(JsValue::String("function f(a) { return a; }".into()))); } +#[test] +fn a_function_that_returns_from_a_finally_block_does_not_keep_its_code() { + // The function's source holds a 256 Ki-unit comment. Its result stays on the stack while the + // second call runs, after the function was dropped. A collection then leaves its code out of + // the live total, as it does for a function whose finally block ran without an exception. + let live = |body: &str| { + let mut r = small(Limits::default()); + let src = format!( + "var s = 'x'; for (var i = 0; i < 18; i++) s += s;\n\ + var f = Function('/*' + s + '*/ try {{ {body} }} finally {{ return 0; }}'); s = null;\n\ + f() + (f = null, (function () {{ var a = 0; for (var i = 0; i < 50; i++) a += i; return a; }})())" + ); + if let Err(e) = r.start(&src) { + panic!("{e:?}"); + } + let mut bytes = None; + loop { + match r.advance(1) { + Ok(Progress::Paused) => { + let i = r.interp(); + if i.frames.len() == 2 && i.stack.len() > 1 { + i.collect_garbage(); + bytes = Some(i.heap.bytes); + } + } + Ok(Progress::Done(v)) => { + assert_eq!(v, JsValue::Number(1225.0)); + break; + } + Err(e) => panic!("{e:?}"), + } + } + let Some(bytes) = bytes else { panic!("the second call never paused") }; + bytes + }; + let (plain, thrown) = (live(""), live("throw 1;")); + assert!(thrown < plain + 4096, "{thrown} > {plain}"); +} + #[test] fn for_in_names_take_fuel_and_heap_under_later_limits() { // The first run, under the default limits, builds an object with many properties. Later runs From 3c495a7b611d14169b85b2e80f4e9676c3a20e8f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:22:05 +0000 Subject: [PATCH 10/83] Fonts: the file search for an uncovered character stops at a budget between scans --- crates/text/src/fontdb.rs | 11 +++++++++++ crates/text/src/fontdb/db.rs | 1 + crates/text/src/fontdb/lookup.rs | 7 +++++++ crates/text/src/fontdb/names.rs | 8 ++++++++ 4 files changed, 27 insertions(+) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index a95ac2dc5d..bad992e513 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -192,6 +192,17 @@ mod alias_tests { font } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_file_search_for_uncovered_characters_stops_at_its_budget() { + let db = FontDb::with_font_dirs(vec![]); + db.sys.lock().unwrap().file_searches = names::MAX_FILE_SEARCHES; + assert!(!db.system_fallback('\u{E000}')); + let sys = db.sys.lock().unwrap(); + assert_eq!(sys.file_searches, names::MAX_FILE_SEARCHES); + assert!(!sys.misses.contains(&'\u{E000}'), "an unsearched character is not a remembered miss"); + } + #[test] fn a_collection_lists_at_most_max_faces() { let mut font = name_font(&[(3, 0x0409, 1, "Fam"), (3, 0x0409, 2, "Regular")]); diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 902a3e5aae..4ef17c40c6 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -456,6 +456,7 @@ impl FontDb { let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); sys.scans = sys.scans.wrapping_add(1); sys.misses.clear(); + sys.file_searches = 0; } self.changed(); n diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index 71e8e7ab88..18af244ed1 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -501,6 +501,13 @@ impl FontDb { return true; } } + { + let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if sys.file_searches >= super::names::MAX_FILE_SEARCHES { + return false; + } + sys.file_searches += 1; + } let mut paths: Vec = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|cf| cf.path.clone())).collect(); paths.sort(); paths.dedup(); diff --git a/crates/text/src/fontdb/names.rs b/crates/text/src/fontdb/names.rs index 6ed1b44a2c..7531b130ec 100644 --- a/crates/text/src/fontdb/names.rs +++ b/crates/text/src/fontdb/names.rs @@ -7,8 +7,16 @@ pub(super) struct SysFallback { pub(super) misses: std::collections::HashSet, /// Counts the scans of the font folders: a search that a scan overtook doesn't record its miss. pub(super) scans: u64, + /// Characters that went on to read every cataloged font file (and found nothing, or a face). + /// Each such search rereads the catalog, so only [`MAX_FILE_SEARCHES`] are made per scan. + pub(super) file_searches: u32, } +/// The most characters searched for in the font files themselves between two scans of the font +/// folders. Real text has a few uncovered characters; a document of thousands of distinct +/// private-use code points would reread the whole catalog for each. +pub(super) const MAX_FILE_SEARCHES: u32 = 1024; + /// Fonts made for Thai, tried first (when installed) for Thai the font in use lacks: Windows /// (Leelawadee UI; Tahoma before Windows 8), macOS (Thonburi, Sukhumvit Set) and Linux (Noto, /// TLWG). From 21e6524f0627ea1d75f8cfb6a87094493ff65eb0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:24:43 +0000 Subject: [PATCH 11/83] Text: colour layers stop drawing outlines once over budget, one frame's baseline search has a total step budget, a glyph outline keeps at most a million elements --- crates/text/src/colr.rs | 17 +++++++++--- crates/text/src/fontdb/glyph_pen.rs | 40 +++++++++++++++++++++++++++++ crates/text/src/layout/breaking.rs | 36 ++++++++++++++++++++++++++ crates/text/src/layout/flow.rs | 1 + 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs index 9045a4f550..a3c6bf6e80 100644 --- a/crates/text/src/colr.rs +++ b/crates/text/src/colr.rs @@ -169,6 +169,17 @@ impl<'a> Tree<'a> { } } + /// Glyph `gid`'s outline in the current transform; empty once the glyph is over budget, so + /// the layers that follow cost nothing (a compact layer list can name one big outline often). + fn layer_outline(&mut self, gid: GlyphId) -> BezPath { + if self.too_large { + return BezPath::new(); + } + let mut path = outline(self.font, self.location, gid); + path.apply_affine(self.transform()); + path + } + fn transform(&self) -> Affine { self.transforms.last().copied().unwrap_or(FLIP) } @@ -359,8 +370,7 @@ impl ColorPainter for Tree<'_> { } fn push_clip_glyph(&mut self, glyph_id: GlyphId) { - let mut clip = outline(self.font, self.location, glyph_id); - clip.apply_affine(self.transform()); + let clip = self.layer_outline(glyph_id); self.charge(&clip); self.open(Open { clip: Some(clip), blend: None, children: vec![], flooded: false }); } @@ -392,8 +402,7 @@ impl ColorPainter for Tree<'_> { /// The glyph's outline filled, the brush moved by `brush_transform` (the glyph stays). fn fill_glyph(&mut self, glyph_id: GlyphId, brush_transform: Option, brush: Brush<'_>) { - let mut path = outline(self.font, self.location, glyph_id); - path.apply_affine(self.transform()); + let path = self.layer_outline(glyph_id); let brush_xf = self.transform() * brush_transform.as_ref().map_or(Affine::IDENTITY, affine); self.transforms.push(brush_xf); self.fill_path(&path, &brush); diff --git a/crates/text/src/fontdb/glyph_pen.rs b/crates/text/src/fontdb/glyph_pen.rs index 7f02517037..fdbb27e3a7 100644 --- a/crates/text/src/fontdb/glyph_pen.rs +++ b/crates/text/src/fontdb/glyph_pen.rs @@ -7,23 +7,63 @@ pub(super) fn svg_drawn(face: &FontFace, font: &skrifa::FontRef<'_>, gid: u32) - face.svg_glyphs && !crate::colr::has_colour_glyph(font, gid) } +/// The most path elements one glyph outline keeps. Real outlines have hundreds; a variable +/// composite glyph can repeat a big base outline into millions. Elements past it are dropped. +pub(crate) const MAX_OUTLINE_ELEMENTS: usize = 1 << 20; + /// A pen drawing outlines y down, as text space has them. pub(crate) struct FlipPen(pub(crate) BezPath); +impl FlipPen { + fn full(&self) -> bool { + self.0.elements().len() >= MAX_OUTLINE_ELEMENTS + } +} + impl OutlinePen for FlipPen { fn move_to(&mut self, x: f32, y: f32) { + if self.full() { + return; + } self.0.move_to((x as f64, -y as f64)); } fn line_to(&mut self, x: f32, y: f32) { + if self.full() { + return; + } self.0.line_to((x as f64, -y as f64)); } fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + if self.full() { + return; + } self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + if self.full() { + return; + } self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); } fn close(&mut self) { + if self.full() { + return; + } self.0.close_path(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_pen_keeps_at_most_the_element_cap() { + let mut pen = FlipPen(BezPath::new()); + pen.move_to(0.0, 0.0); + for i in 0..MAX_OUTLINE_ELEMENTS + 10 { + pen.line_to(i as f32, 0.0); + } + assert_eq!(pen.0.elements().len(), MAX_OUTLINE_ELEMENTS); + } +} diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 87fa8db4df..a753dba779 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -63,12 +63,19 @@ pub(super) struct Pen<'r> { pub(super) next_top: Option, /// The narrowest word no line from here down is wide enough for (see [`Pen::place`]). pub(super) no_room_for: f64, + /// Baseline steps still allowed over all the frame's cells and placements (shared by this pen's + /// copies): see [`MAX_TOTAL_STEPS`]. + pub(super) steps_left: std::rc::Rc>, } /// The largest number of steps layout takes to move a line down a frame cell to find room for it: /// in a cell taller than this many leads, a step is the cell's height divided by this. const MAX_STEPS: usize = 100_000; +/// The most baseline steps one frame's layout takes in all, over every cell: a cell may take +/// [`MAX_STEPS`], but rows and columns of tall, narrow-shaped cells would multiply that. +pub(super) const MAX_TOTAL_STEPS: usize = 10_000_000; + impl Pen<'_> { /// Baseline and horizontal span for a line with estimated metrics `est` and indents that starts /// with a word `word` wide; `None` = the frame is full (overflow). @@ -110,6 +117,12 @@ impl Pen<'_> { // height when that is more, in at most `MAX_STEPS` steps. let step = est.lead.max(1.0).max(r.cell.height() / MAX_STEPS as f64); for _ in 0..=MAX_STEPS { + let left = self.steps_left.get(); + if left == 0 { + // Out of steps: the frame is full as far as this text goes. + return None; + } + self.steps_left.set(left - 1); // A baseline a step no longer moves (past a gutter of 1e300, say) won't reach a line. if baseline + est.desc > r.bottom() + 0.01 || baseline + step <= baseline || baseline.is_nan() { break; @@ -732,3 +745,26 @@ pub(super) fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&Hyphenatio v.dedup_by_key(|b| b.end); v } + +#[cfg(test)] +#[test] +fn a_pen_out_of_baseline_steps_finds_no_room() { + let frame = kurbo::Shape::to_path(&kurbo::Rect::new(0.0, 0.0, 400.0, 400.0), 0.1); + let regions = Region::cells(&frame, &super::LayoutOptions::default(), &[]); + let est = Metrics { asc: 10.0, desc: 3.0, lead: 14.0, cap: 7.0, xh: 5.0, top: 10.0 }; + let pen = |steps| Pen { + regions: Some(®ions), + ri: 0, + prev: None, + pending: 0.0, + fb: FirstBaseline::Ascent, + fb_min: 0.0, + queued: vec![], + model: LeadingModel::default(), + next_top: None, + no_room_for: f64::INFINITY, + steps_left: std::rc::Rc::new(std::cell::Cell::new(steps)), + }; + assert!(pen(MAX_TOTAL_STEPS).place(est, 0.0, 0.0, 0.0).is_some()); + assert!(pen(0).place(est, 0.0, 0.0, 0.0).is_none()); +} diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 56c71c2f4b..670384b1d7 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -150,6 +150,7 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, reg model: t.para_at(0).leading_model, next_top: None, no_room_for: f64::INFINITY, + steps_left: std::rc::Rc::new(std::cell::Cell::new(super::breaking::MAX_TOTAL_STEPS)), }; let markers = list_markers(t, paras); for (pi, pr) in paras.iter().enumerate() { From 1348ec0003ecc5c225ebaa561f261307bfafd50b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 12:35:29 -0800 Subject: [PATCH 12/83] Guides: the ruler guide export test leaves out the PDF's dates exports_and_fit_all_are_the_same_with_ruler_guides compared PDF exports byte for byte, but each export carries the time it was written in /ModDate and the XMP packet. Two exports in different seconds differed, so the test failed now and then in CI. The test now drops the dated lines from the PDF before comparing, as undated in the envelope tests does. SVG, PNG and PSD are still compared whole. --- crates/engine/tests/it/ruler_guide_nodes.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/engine/tests/it/ruler_guide_nodes.rs b/crates/engine/tests/it/ruler_guide_nodes.rs index 95625d65d1..950da2f065 100644 --- a/crates/engine/tests/it/ruler_guide_nodes.rs +++ b/crates/engine/tests/it/ruler_guide_nodes.rs @@ -257,14 +257,25 @@ fn guides_made_while_editing_a_mask_a_pattern_or_a_symbol_stay() { assert!(!in_symbol, "the guide is not part of the symbol"); } +/// `bytes` without the lines that depend on when the file was written: the PDF dates, and the XMP +/// packet (one line, with its dates and IDs) and trailer `/ID`. +fn undated(bytes: &[u8]) -> Vec { + let stamps: [&[u8]; 5] = [b"CreationDate", b"ModDate", b"xmp:CreateDate", b"xmp:ModifyDate", b"/ID["]; + let dated = |line: &&[u8]| stamps.iter().any(|s| line.windows(s.len()).any(|w| w == *s)); + bytes.split(|b| *b == b'\n').filter(|l| !dated(l)).flat_map(|l| l.iter().copied().chain(*b"\n")).collect() +} + /// The bytes of each export format (PDF without the embedded native document, which keeps the -/// guides), and the box Fit All in Window fits (the art and every artboard). -fn exports(s: &mut Session) -> (Vec<(String, String)>, Option) { +/// guides, and without its dates), and the box Fit All in Window fits (the art and every artboard). +fn exports(s: &mut Session) -> (Vec<(String, Vec)>, Option) { let files = [json!({"format": "svg"}), json!({"format": "pdf", "preserveEditing": false}), json!({"format": "png"}), json!({"format": "psd"})] .iter() .map(|p| { let out = s.execute("document.export", p).unwrap(); - (p["format"].as_str().unwrap().to_string(), out["dataBase64"].as_str().unwrap().to_string()) + let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap(); + let format = p["format"].as_str().unwrap().to_string(); + let bytes = if format == "pdf" { undated(&bytes) } else { bytes }; + (format, bytes) }) .collect(); let d = &s.doc().unwrap().doc; From 543034987eb6d2e31f73a6b373234c40e9802263 Mon Sep 17 00:00:00 2001 From: jmverges Date: Wed, 11 Nov 2026 19:33:22 +0000 Subject: [PATCH 13/83] Envelopes: type's strokes paint at their own weight along the bent outlines (#6644) With Envelope Options > Distort Appearance on, VectorCraft turned the strokes of type in an envelope into filled outlines and bent them with the art, so a stroke got thinner where the grid squeezes the type and thicker where it stretches it. The drawn art of users' .ai files keeps those strokes as stroked paths at the type's own weights, and the ring they paint is as wide wherever the grid squeezes or stretches the type. An envelope of stroked type in such a grid therefore didn't draw as its drawn art and kept it instead of opening live. Type's strokes (its characters' and those on its own appearance) now paint along the bent glyph outlines at their own weight, with Distort Appearance on or off, also in an envelope inside an envelope: - doc: `map_node` maps the groups that hold type's glyph outlines with `Hooks::type_appearance` in place of `Hooks::appearance`, for type in the envelope's contents and for type that a live object in them (an inner envelope) outlined (`Warper::type_outlines`). Such a group's opacity mask is mapped with the ordinary hooks, so it bends as other art does. Groups marked as type's outlines in the contents themselves (art an earlier Expand made) stay plain art. - effects: `bake_type_appearance`, `bake_appearance` with strokes kept as strokes: geometry effects on type's fills and strokes still bake in and bend. - doc: `max_outset` counts type's character strokes as type's own visual bounds do (`char_stroke_outset`). It left them out, though they already painted past an envelope's grid where the glyphs reach its edge; this also grows the visual bounds of blends and repeats of stroked type and the reach of symbol art holding it. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. The canvas, hit testing, effect bounds, Expand, every export and the .ai save take the evaluation from the same hooks. Expand of an envelope of stroked type gives the bent outlines with the strokes on them. This changes how VectorCraft's own envelopes of stroked type draw. Tests: envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope, a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope, type_strokes_paint_at_their_own_weight_in_an_envelope_inside_an_envelope, a_paths_stroke_still_bends_in_a_squeezed_envelope, an_opacity_mask_on_type_bends_as_other_art, the_visual_bounds_of_an_envelope_hold_its_types_strokes, the_visual_bounds_of_a_repeat_of_stroked_type_grow_by_half_the_stroke, expanding_an_envelope_of_stroked_type_keeps_the_stroke_at_its_weight, bake::tests::type_pieces_bake_their_geometry_effects_and_keep_their_strokes and tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/live/envelope.rs | 5 +- crates/doc/src/live/expand.rs | 35 +++- crates/effects/src/bake.rs | 29 ++- crates/effects/src/bake/tests.rs | 21 ++ crates/effects/src/lib.rs | 2 +- crates/effects/src/live.rs | 10 +- crates/engine/src/tests/envelope_distort.rs | 207 ++++++++++++++++++++ crates/eps/src/import/tests_native.rs | 100 +++++++++- docs/file-format-parity.md | 3 +- docs/mcp.md | 10 +- docs/target-app-parity.md | 6 +- docs/type-parity.md | 3 +- 12 files changed, 406 insertions(+), 25 deletions(-) diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 8f9b197048..8abe961841 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -78,7 +78,9 @@ pub struct EnvelopeOptions { /// Rasters: Preserve Shape Using. pub preserve_shape: PreserveShape, /// Distort Appearance: strokes and live effects are applied before the distortion, so they - /// bend with the art (off: they apply to the distorted paths). + /// bend with the art (off: they apply to the distorted paths). Type's strokes are the exception: + /// they paint along its distorted glyph outlines at their own weight either way, as `.ai` files + /// draw them (#6644). pub distort_appearance: bool, /// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art. pub distort_linear_gradients: bool, @@ -638,6 +640,7 @@ pub(super) fn expand_envelope( hooks, options, depth: 0, + type_outlines: false, }; content.iter().map(|c| map_node(c, &w)).collect() } diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 1cc8ba0863..8675dcc011 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -21,6 +21,11 @@ pub struct Hooks<'a> { /// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None` /// when it has none to bake). pub appearance: Outliner<'a>, + /// Distort Appearance for the pieces of type's glyph outlines: their geometry effects baked + /// as [`Hooks::appearance`] bakes them, their strokes left as strokes, which then paint along + /// the bent outlines at their own weight (#6644). `None` when a piece has nothing to bake, and + /// without this hook nothing in type's outlines is baked. + pub type_appearance: Outliner<'a>, /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). pub pattern: Option>, /// The document whose symbols' art counts toward the instance budget of a Repeat of their @@ -61,6 +66,12 @@ pub(super) struct Warper<'a> { pub(super) options: EnvelopeOptions, /// How many hooks the art being mapped came through. pub(super) depth: u32, + /// Are groups marked as type's glyph outlines ([`crate::NodeExtras::type_outlines`]) in the art + /// being mapped type that live evaluation outlined: the type of the envelope's contents, or of + /// a live object in them, such as an envelope inside the envelope? Their strokes then paint + /// along the bent outlines at their own weight (#6644). Groups so marked in the contents as + /// they are, art an earlier Expand made of type, are plain art. + pub(super) type_outlines: bool, } impl Warper<'_> { @@ -117,6 +128,13 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { *live = None; } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + // Type's glyph outlines: with Distort Appearance too, their strokes paint along the + // bent outlines at their own weight, as `.ai` files draw them (#6644). Their pieces are + // baked by `type_appearance`, which keeps strokes as strokes; their geometry effects, + // gradients and pattern fills follow the options as a path's do. The group's opacity + // mask, mapped above, bends as other art does. + let typed = Warper { hooks: Hooks { appearance: w.hooks.type_appearance, ..w.hooks }, ..*w }; + let w = if w.type_outlines && n.extras().type_outlines { &typed } else { w }; for c in children.iter_mut() { *c = Arc::new(map_node(c, w)); } @@ -124,14 +142,17 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { NodeKind::Mesh(m) => m.map(w.f), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { if let Some(d) = &deeper { - return map_node(&expanded_group_hooks(n, w.hooks), d); + // The type's outlines in its art are type (an envelope inside the envelope, #6644). + return map_node(&expanded_group_hooks(n, w.hooks), &Warper { type_outlines: true, ..*d }); } } NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => { if let Some(d) = &deeper && let Some(o) = w.hooks.outline.and_then(|h| h(n)) { - return map_node(&carry_look(n, o), d); + // Type's outlines are type (#6644, the group arm above). + let d = Warper { type_outlines: d.type_outlines || matches!(n.kind, NodeKind::Text(_)), ..*d }; + return map_node(&carry_look(n, o), &d); } } NodeKind::Image(im) => { @@ -439,8 +460,11 @@ pub fn subtree_has_live(n: &Node) -> bool { any } -/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the -/// outsets in the art of the symbols its instances show count too +/// Largest stroke outset in a subtree (for visual bounds of envelopes, blends and repeats, and the +/// reach of symbol art), type's character strokes included as type's own visual bounds measure them +/// ([`crate::TextObject::char_stroke_outset`]): in an envelope they paint at their own weight +/// around the bent glyphs, past its grid where the glyphs reach its edge (#6644). With +/// `symbols`, the outsets in the art of the symbols its instances show count too /// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made /// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad /// does. @@ -449,6 +473,9 @@ pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxe for n in nodes { n.walk(&mut |c| { o = o.max(c.appearance.outset()); + if let NodeKind::Text(t) = &c.kind { + o = o.max(t.char_stroke_outset()); + } if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind) && let Some(art) = s.art(symbol) { diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 5d27ac60af..795fc69bdf 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -552,6 +552,21 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to /// outline nor a geometry effect, so bending first changes nothing. pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { + bake_appearance_as(doc, n, true) +} + +/// [`bake_appearance`] for the glyph outlines of type in an envelope with Distort Appearance: +/// geometry effects are applied as it applies them, and fills, gradients and patterns are left as +/// it leaves them, but every stroke stays a stroke on its piece instead of becoming its filled +/// outline. The envelope then bends the piece's path, and the stroke paints along the bent outline +/// at its own weight, as `.ai` files draw the strokes of type in an envelope (#6644). `None` when +/// it has no geometry effect, so bending first changes nothing. +pub fn bake_type_appearance(doc: Option<&Document>, n: &Node) -> Option { + bake_appearance_as(doc, n, false) +} + +/// [`bake_appearance`], or with `outline_strokes` false [`bake_type_appearance`]. +fn bake_appearance_as(doc: Option<&Document>, n: &Node, outline_strokes: bool) -> Option { if let Some(m) = crate::revolve_art_in(doc, n).or_else(|| crate::solid_art_in(doc, n)) { return Some(m); } @@ -564,10 +579,10 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { if crate::has_transform_art(n) && let Some(mut m) = reshape(doc, n, None) { - bake_appearance_pieces(doc, &mut m); + bake_appearance_pieces(doc, &mut m, outline_strokes); return Some(m); } - let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); + let outlined = |i: &AppearanceItem| outline_strokes && paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))); if !geometric && !n.appearance.items.iter().any(outlined) { return None; @@ -585,7 +600,7 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { let (ig, mut it) = apply_item(item, carried, &g, &ctx); clear_item_effects(&mut it); pieces.push(match &it { - AppearanceItem::Stroke(st) if st.brush.is_none() => { + AppearanceItem::Stroke(st) if outline_strokes && st.brush.is_none() => { let outline = crate::stroke::outline_region(&ig, rule, st); match st.path_gradient() { // A gradient along or across the stroke: its gradient meshes clipped to the outline. @@ -618,16 +633,16 @@ pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { Some(m) } -/// [`bake_appearance`] every path and compound path in `n` that it changes, in place. -fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node) { +/// [`bake_appearance_as`] every path and compound path in `n` that it changes, in place. +fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes: bool) { if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { - if let Some(m) = bake_appearance(doc, n) { + if let Some(m) = bake_appearance_as(doc, n, outline_strokes) { *n = m; } return; } for c in n.children_mut().into_iter().flatten() { - bake_appearance_pieces(doc, Arc::make_mut(c)); + bake_appearance_pieces(doc, Arc::make_mut(c), outline_strokes); } } diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 4751b137c9..2bcc026340 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -575,6 +575,27 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline assert_eq!(ch[1].opacity, 0.5); } +/// Distort Appearance on the pieces of type's glyph outlines (#6644): a stroke's geometry effect +/// still bakes into its piece, but the stroke stays a stroke at its own weight; a piece with no +/// geometry effect is left as it is, its stroke on it (Distort Appearance on a path outlines it). +#[test] +fn type_pieces_bake_their_geometry_effects_and_keep_their_strokes() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + assert!(bake_type_appearance(None, &n).is_none(), "nothing to bake"); + assert!(bake_appearance(None, &n).is_some(), "a path's stroke is outlined"); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.width = 4.0; + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + } + let m = bake_type_appearance(None, &n).unwrap(); + let ch = m.children().unwrap(); + assert_eq!(ch.len(), 2); + let Some(AppearanceItem::Stroke(st)) = ch[1].appearance.items.first() else { panic!("still a stroke: {:?}", ch[1].appearance) }; + assert!(st.width == 4.0 && st.effects.is_empty(), "{st:?}"); + assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5, "the stroke's offset is baked into its path"); + assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6, "the fill keeps the square"); +} + /// A 100 pt square at the origin painted by `fill` and `stroke` (`width` pt), with the Transform /// effect `params`. fn transformed(fill: Paint, stroke: Paint, width: f64, params: serde_json::Value) -> Node { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ae8b7d9e90..67b4b464fd 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -68,7 +68,7 @@ pub use adjust::{ ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment, is_channel, }; -pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake}; +pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; pub use clip::clip_outline; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index 03b335f010..2c02f886c0 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -2,8 +2,9 @@ //! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself //! ([`Hooks`]): type inside an envelope distorts as its glyph outlines, run by run //! ([`outline_text`]); symbol instances as their symbol's art; with Distort Appearance strokes and -//! geometry effects bend with the art ([`bake_appearance`]); with Distort Pattern Fills the pattern -//! tiles do. +//! geometry effects bend with the art ([`bake_appearance`]), except type's strokes, which paint +//! along its bent outlines at their own weight ([`bake_type_appearance`], #6644); with Distort +//! Pattern Fills the pattern tiles do. use std::sync::Arc; @@ -11,7 +12,7 @@ use vectorcraft_doc::live::{self, Hooks, Outliner}; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{Affine, Rect}; -use crate::{bake_appearance, outline_text}; +use crate::{bake_appearance, bake_type_appearance, outline_text}; /// The text outliner live evaluation uses: type as its glyph outlines, one path per run in the /// run's paint ([`outline_text`]). @@ -44,7 +45,8 @@ fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { let outline = |n: &Node| outline_object(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); let appearance = |n: &Node| bake_appearance(doc, n); - f(Hooks { outline: Some(&outline), appearance: Some(&appearance), pattern: Some(&tiles), symbols: doc }) + let type_appearance = |n: &Node| bake_type_appearance(doc, n); + f(Hooks { outline: Some(&outline), appearance: Some(&appearance), type_appearance: Some(&type_appearance), pattern: Some(&tiles), symbols: doc }) } /// One level of evaluation of live object `n` ([`live::expand_live_hooks`]). diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index b42b1da027..8a1e46c68a 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -405,3 +405,210 @@ fn new_envelopes_distort_appearance_and_old_ones_keep_their_look() { let e = arch(&mut s, 50.0, json!({})); assert!(matches!(node(&s, e).kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::NEW)); } + +/// What strokes the object in [`squeezed`]. +#[derive(Clone, Copy)] +enum Stroked { + /// Nothing. + No, + /// The characters of type, or a path's own stroke. + Own, + /// A stroke on type's own appearance (Appearance panel), the characters unstroked. + Appearance, +} + +/// The weight of the strokes in [`squeezed`]. +const SQUEEZED_WEIGHT: f64 = 8.0; + +/// `make`'s object, stroked by `stroked` with a red stroke [`SQUEEZED_WEIGHT`] wide, in a one-patch +/// mesh envelope with Distort Appearance on whose right column is moved in to squeeze the object to +/// half its width (a horizontal squeeze, the same everywhere): the session, the envelope and the +/// height of the envelope's middle. +fn squeezed(make: fn(&mut Session) -> NodeId, stroked: Stroked) -> (Session, NodeId, f64) { + use vectorcraft_doc::live::EnvelopeKind; + let mut s = session(); + let a = make(&mut s); + sel(&mut s, &[a]); + match stroked { + Stroked::No => {} + Stroked::Own => { + s.execute("paint.setStroke", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": SQUEEZED_WEIGHT})).unwrap(); + } + Stroked::Appearance => { + s.edit("appearance stroke", |d, _| { + let n = d.node_mut(a).unwrap(); + let mut appearance = (*n.appearance).clone(); + appearance + .items + .push(AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), SQUEEZED_WEIGHT))); + n.appearance = appearance.into(); + Ok(()) + }) + .unwrap(); + } + } + let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap()); + let NodeKind::Envelope { options, .. } = &node(&s, e).kind else { panic!("an envelope") }; + assert!(options.distort_appearance, "new envelopes distort appearance"); + let mut middle = 0.0; + s.edit("squeeze", |d, _| { + let NodeKind::Envelope { kind, .. } = &mut d.node_mut(e).unwrap().kind else { panic!("an envelope") }; + let EnvelopeKind::Mesh { points, handles, .. } = &mut **kind else { panic!("a mesh") }; + let (left, right) = (points[0].x, points[1].x); + points[1].x = left + (right - left) / 2.0; + points[3].x = points[1].x; + handles.clear(); + middle = (points[0].y + points[2].y) / 2.0; + Ok(()) + }) + .unwrap(); + (s, e, middle) +} + +/// Where the ink of the page of `s` starts and ends across height `y`, as the canvas draws it. +fn ink_across(s: &Session, y: f64) -> (f64, f64) { + let scale = 8.0; + let region = Rect::new(0.0, y - 0.5, 800.0, y + 0.5); + let r = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, scale, true); + let row = (r.height / 2) as usize; + let inked: Vec = (0..r.width as usize).filter(|i| r.pixels[(row * r.width as usize + i) * 4..][..3].iter().any(|c| *c < 128)).collect(); + let (first, last) = (inked.first().unwrap(), inked.last().unwrap()); + (*first as f64 / scale, (last + 1) as f64 / scale) +} + +/// How wide the ink is across the middle of `make`'s object in [`squeezed`]. +fn squeezed_ink(make: fn(&mut Session) -> NodeId, stroked: Stroked) -> f64 { + let (s, _, middle) = squeezed(make, stroked); + let (first, last) = ink_across(&s, middle); + last - first +} + +/// A capital I set 300 points tall at (100, 400), filled black. +fn tall_i(s: &mut Session) -> NodeId { + id_of(&s.execute("text.create", &json!({"x": 100, "y": 400, "text": "I", "size": 300})).unwrap()) +} + +/// With Distort Appearance on, the characters' stroke of type in an envelope paints along the bent +/// glyph outlines at its own weight, as `.ai` files draw it (#6644): squeezed to half its width, the +/// stem of an I still has half the weight of stroke on each side, not a quarter. +#[test] +fn type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope() { + let (plain, stroked) = (squeezed_ink(tall_i, Stroked::No), squeezed_ink(tall_i, Stroked::Own)); + let past = stroked - plain; + assert!((past - SQUEEZED_WEIGHT).abs() < 0.5, "the stroke reaches {past} pt past the squeezed stem, both sides together ({plain} → {stroked})"); +} + +/// A stroke on type's own appearance paints at its own weight too (#6644). +#[test] +fn a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope() { + let (plain, stroked) = (squeezed_ink(tall_i, Stroked::No), squeezed_ink(tall_i, Stroked::Appearance)); + let past = stroked - plain; + assert!((past - SQUEEZED_WEIGHT).abs() < 0.5, "the stroke reaches {past} pt past the squeezed stem, both sides together ({plain} → {stroked})"); +} + +/// A path's stroke still bends with the art: squeezed to half its width, it reaches half as far past +/// the path's left and right sides. +#[test] +fn a_paths_stroke_still_bends_in_a_squeezed_envelope() { + let (plain, stroked) = (squeezed_ink(rect, Stroked::No), squeezed_ink(rect, Stroked::Own)); + let past = stroked - plain; + assert!( + (past - SQUEEZED_WEIGHT / 2.0).abs() < 0.5, + "the stroke reaches {past} pt past the squeezed rectangle, both sides together ({plain} → {stroked})" + ); +} + +/// The type's stroke paints past the envelope's grid, where the glyphs reach its edge: the +/// envelope's visual bounds hold it (#6644). +#[test] +fn the_visual_bounds_of_an_envelope_hold_its_types_strokes() { + let (s, e, middle) = squeezed(tall_i, Stroked::Own); + let n = node(&s, e); + let (visual, grid) = (n.visual_bounds().unwrap(), n.geometric_bounds().unwrap()); + let (first, last) = ink_across(&s, middle); + assert!(first < grid.x0 - 1.0, "the stroke paints past the grid: {first} vs {grid:?}"); + // The ink is measured to whole pixels (an eighth of a point); miter spikes don't count, as in + // type's own visual bounds. + let pixel = 0.125; + assert!(visual.x0 <= first + pixel && visual.x1 >= last - pixel, "{visual:?} holds the ink from {first} to {last}"); + assert!(first - visual.x0 < 1.0 && visual.x1 - last < 1.0, "{visual:?} reaches no further than the ink from {first} to {last}"); +} + +/// A repeat of stroked type reaches past its copies by as far as the characters' strokes paint +/// past their glyphs: half the weight of a centred stroke, as type's own visual bounds (#6644). +#[test] +fn the_visual_bounds_of_a_repeat_of_stroked_type_grow_by_half_the_stroke() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "I", "size": 100})).unwrap()); + s.execute("paint.setStroke", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [t.0], "weight": SQUEEZED_WEIGHT})).unwrap(); + let r = id_of(&s.execute("object.repeat.grid", &json!({"ids": [t.0], "rows": 2, "cols": 2})).unwrap()); + let n = node(&s, r); + let (visual, geometric) = (n.visual_bounds().unwrap(), n.geometric_bounds().unwrap()); + let grown = [geometric.x0 - visual.x0, geometric.y0 - visual.y0, visual.x1 - geometric.x1, visual.y1 - geometric.y1]; + assert!(grown.iter().all(|g| (g - SQUEEZED_WEIGHT / 2.0).abs() < 1e-6), "{grown:?}"); +} + +/// An I stroked by `stroked` in a one-patch mesh envelope that leaves it as it is: the envelope. +fn enveloped_i(s: &mut Session, stroked: bool) -> NodeId { + let t = tall_i(s); + if stroked { + s.execute("paint.setStroke", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [t.0], "weight": SQUEEZED_WEIGHT})).unwrap(); + } + sel(s, &[t]); + id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap()) +} + +/// Type's stroke in an envelope inside an envelope also paints at its own weight: the outer +/// envelope squeezes the inner one's type to half its width, and the stem still has half the weight +/// of stroke on each side (#6644). +#[test] +fn type_strokes_paint_at_their_own_weight_in_an_envelope_inside_an_envelope() { + let (plain, stroked) = (squeezed_ink(|s| enveloped_i(s, false), Stroked::No), squeezed_ink(|s| enveloped_i(s, true), Stroked::No)); + let past = stroked - plain; + assert!((past - SQUEEZED_WEIGHT).abs() < 0.5, "the stroke reaches {past} pt past the squeezed stem, both sides together ({plain} → {stroked})"); +} + +/// An opacity mask on type bends as other art does (#6644): with Distort Appearance its stroke +/// becomes its bent outline, as on a path, while the type's own stroke stays a stroke. +#[test] +fn an_opacity_mask_on_type_bends_as_other_art() { + let mut s = session(); + let t = tall_i(&mut s); + s.execute("paint.setStroke", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [t.0], "weight": SQUEEZED_WEIGHT})).unwrap(); + let m = id_of(&s.execute("shape.rectangle", &json!({"x": 90, "y": 150, "width": 120, "height": 300})).unwrap()); + s.execute("paint.setStroke", &json!({"ids": [m.0], "color": "#ffffff"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [m.0], "weight": SQUEEZED_WEIGHT})).unwrap(); + sel(&mut s, &[t, m]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + assert!(node(&s, t).extras().mask.is_some(), "the type has the mask"); + sel(&mut s, &[t]); + let e = arch(&mut s, 60.0, json!({"distortAppearance": true})); + let (mut masks, mut kept) = (vec![], 0); + for n in &evaluated(&s, e) { + n.walk(&mut |c| { + kept += c.appearance.painted_strokes().count(); + masks.extend(c.extras().mask.as_ref().map(|m| m.art.clone())); + }); + } + assert_eq!(masks.len(), 1, "the mask goes with the type"); + let mut in_mask = 0; + masks[0].walk(&mut |c| in_mask += c.appearance.painted_strokes().count()); + assert_eq!(in_mask, 0, "the mask's stroke became its bent outline"); + assert_eq!(kept, 1, "the type's stroke stays a stroke"); +} + +/// Expand turns an envelope of stroked type into its bent glyph outlines with the stroke on them, +/// at its own weight, as it draws (#6644). +#[test] +fn expanding_an_envelope_of_stroked_type_keeps_the_stroke_at_its_weight() { + let (mut s, e, _) = squeezed(tall_i, Stroked::Own); + sel(&mut s, &[e]); + s.execute("object.envelope.expand", &json!({})).unwrap(); + let mut weights = vec![]; + node(&s, e).walk(&mut |c| weights.extend(c.appearance.painted_strokes().map(|st| st.width))); + assert_eq!(weights, [SQUEEZED_WEIGHT]); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 45a7a92c8c..8f64280367 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5796,7 +5796,8 @@ fn a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its /// A warp of stroked type with Distort Appearance whose drawn art maps the type's bounds with its /// strokes onto the grid the file saves (not its glyphs' bounds alone) opens live as a mesh envelope -/// mapping from that box (#3179). +/// mapping from that box (#3179). The drawn art strokes the bent outlines at the stroke's own weight, +/// as files draw it (#6644). #[test] fn a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does() { use vectorcraft_doc::live::{envelope_content_boxes, envelope_stroke_margin}; @@ -5816,7 +5817,7 @@ fn a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_ vectorcraft_doc::NodeId(1_000_000), NodeKind::Envelope { content, kind: Box::new(rising_kind()), fidelity: 50.0, editing: false, options, frame: Box::default() }, ); - let (drawn, page) = drawn_envelope(&shaped); + let (drawn, page) = drawn_stroked_envelope(&shaped); let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn, ""))), &doc); let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); @@ -5833,6 +5834,101 @@ fn a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_ assert_eq!(margin, Some(options.bounds_margin)); } +/// The drawn art of `envelope` (on the page axes), an envelope of type stroked by its characters, +/// as the drawn art of `.ai` files strokes type in an envelope: the bent glyph outlines the envelope +/// draws of its type unstroked, each filled in its type's color, then each stroked with the type's +/// stroke (its weight, color, join and miter limit) whatever the grid does to it, and the page's +/// PostScript for them (#6644). +fn drawn_stroked_envelope(envelope: &Node) -> (String, String) { + let NodeKind::Envelope { content, .. } = &envelope.kind else { panic!("an envelope") }; + let Some(NodeKind::Text(t)) = content.first().map(|c| &c.kind) else { panic!("type") }; + let stroke = t.runs[0].style.stroke_layer(); + let mut unstroked = envelope.clone(); + if let NodeKind::Envelope { content, .. } = &mut unstroked.kind { + for c in content.iter_mut() { + if let NodeKind::Text(t) = &mut std::sync::Arc::make_mut(c).kind { + t.runs.iter_mut().for_each(|r| r.style.stroke = vectorcraft_color::Paint::None); + } + } + } + let (fills, fill_page) = drawn_envelope(&unstroked); + let [r, g, b] = stroke.paint.color().map_or([0.0; 3], |c| c.to_rgb()); + let join = match stroke.join { + vectorcraft_doc::LineJoin::Miter => 0, + vectorcraft_doc::LineJoin::Round => 1, + vectorcraft_doc::LineJoin::Bevel => 2, + }; + let (w, limit) = (stroke.width, stroke.miter_limit); + let mut art = format!("{fills}0 0 0 0 {r} {g} {b} XA\n{w} w\n{join} j\n{limit} M\n"); + let mut page = format!("{fill_page}{r} {g} {b} setrgbcolor {w} setlinewidth {join} setlinejoin {limit} setmiterlimit\n"); + let pt = |p: kurbo::Point| { + let (x, y) = to_art(p); + format!("{x} {y}") + }; + vectorcraft_effects::expand_live_deep(None, &unstroked).walk(&mut |n| { + let NodeKind::Path { path, .. } = &n.kind else { return }; + for el in path.to_bezpath().elements() { + match *el { + kurbo::PathEl::MoveTo(p) => { + art += &format!("{} m\n", pt(p)); + page += &format!("newpath {} moveto ", pt(p)); + } + kurbo::PathEl::LineTo(p) => { + art += &format!("{} L\n", pt(p)); + page += &format!("{} lineto ", pt(p)); + } + kurbo::PathEl::CurveTo(a, c, p) => { + art += &format!("{} {} {} C\n", pt(a), pt(c), pt(p)); + page += &format!("{} {} {} curveto ", pt(a), pt(c), pt(p)); + } + kurbo::PathEl::QuadTo(..) => {} + kurbo::PathEl::ClosePath => { + art += "s\n"; + page += "closepath stroke\n"; + } + } + } + }); + (art, page) +} + +/// An envelope of type with a character stroke, Distort Appearance on, whose drawn art strokes the +/// bent glyph outlines at the stroke's own weight (as `.ai` files draw the strokes of type in an +/// envelope, however much the grid squeezes or stretches it) opens live: VectorCraft draws the +/// stroke that way too (#6644). +#[test] +fn an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live() { + let Some(family) = installed_family() else { return }; + // "FLUX" stroked 1.5 points wide in black with round joins. + let stroked = "/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> /54 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> /57 true /62 1 /63 1.5"; + let doc = type_envelope_text(&family, None).replace("/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >>", stroked); + let mut text = made_envelope_type(&doc); + text.visible = true; + assert!( + matches!(&text.kind, NodeKind::Text(t) if t.runs[0].style.stroke_width == 1.5 && t.runs[0].style.stroke_join == vectorcraft_doc::LineJoin::Round), + "{text:?}" + ); + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { + content: vec![std::sync::Arc::new(text)], + kind: Box::new(rising_kind()), + fidelity: 50.0, + editing: false, + options: vectorcraft_doc::live::EnvelopeOptions::NEW, + frame: Box::default(), + }, + ); + let (drawn, page) = drawn_stroked_envelope(&shaped); + let art = + native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); +} + /// A warp of type whose editing data saves no grid it can be shaped by keeps its drawn art and its /// type, hidden, with the note, when its own warp draws apart from that art (#3179). #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d42e770df0..79785b2b00 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: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.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, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | | 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..75f5dfe80c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7922,8 +7922,14 @@ laid-out box, and so does type inside any other object, such as a blend, a repea VectorCraft's own rule, and envelopes of type in saved files redraw by it. Everything inside bends with the envelope: images as a raster mesh warp (cut into pieces by Fidelity, each a clipped image under its own affine map; SVG shares the pixels between the pieces), symbol instances as their symbol's art, -and with Distort Appearance strokes (as their filled outlines) and geometry effects; with it, Distort Linear Gradients -bends linear gradients and Distort Pattern Fills the pattern tiles. A transformed envelope keeps its warp in its own +and with Distort Appearance the strokes of paths (as their filled outlines) and geometry effects; with it, Distort +Linear Gradients bends linear gradients and Distort Pattern Fills the pattern tiles. Type's strokes (its characters' +and those on its own appearance) don't bend, with Distort Appearance or without: they paint along the bent glyph +outlines at their own weight, as `.ai` files draw type in an envelope, so a squeezed or stretched envelope keeps them +as wide as they are, also in an envelope inside an envelope (#6644). Their geometry effects and an opacity mask on +the type still bend, and Expand gives the bent outlines with the strokes on them, which Object › Expand or Outline +Stroke outlines later. Before #6644, VectorCraft bent type's strokes too, so envelopes of stroked type made earlier +now draw their strokes at their own weight. A transformed envelope keeps its warp in its own frame (`kind.frame` in `document.inspect`; rotating a warp envelope turns the warp with it), and move and scale keep it square to the page. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..a5d874c65e 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 (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously 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). > @@ -1233,6 +1233,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. Smooth Color counts the steps of each pair of keys from their color change, or from the distance between their visible bounding-box edges when their colors match or they hold gradients or patterns ([Blend Options](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)); the step counts, their caps and the colors compared are VectorCraft's own. Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)). + With Envelope Options › Distort Appearance on, type's strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight instead of bending with the art, as the drawn art of users' `.ai` files strokes type in an envelope. This is VectorCraft's own rule from those files: the public page names applied effects and graphic styles, not strokes. This holds in an envelope inside an envelope too. Paths' strokes, geometry effects (type's included), opacity masks (on type too), linear gradients and pattern fills bend as before; Expand gives type's bent outlines with their strokes on them. The visual bounds of envelopes, blends and repeats, and the reach of symbol art, count type's character strokes as type's own visual bounds do (half the weight of a centred stroke); they left them out, though those strokes already painted past an envelope's grid. Envelopes of stroked type made in VectorCraft before this draw their strokes at their own weight now (#6644; `crates/engine/src/tests/envelope_distort.rs`, `crates/effects/src/bake/tests.rs`). The four polynomial distributions are original approximations of the public description, not measured numeric parity. See [Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). A blend in a `.ai` or EPS opened from its editing data keeps a Specified Distance spacing with its distance ([Blend Options](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-options-overview.html)) where VectorCraft lays out as many steps between each pair of keys as the file draws, in the same places and colors. A Smooth Color blend opens live only where VectorCraft's own Smooth Color step count equals the drawn steps, and VectorCraft often counts a different number. A blend that VectorCraft doesn't lay out as drawn keeps its drawn steps with a note (`import::ai::tests::a_blend_keeps_its_spacing`, `a_blend_of_three_keys_keeps_its_distance_between_each_pair`, `a_blend_whose_spacing_doesnt_lay_out_its_steps_keeps_them`). @@ -1438,7 +1439,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 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. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. + 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. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art. 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). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version. @@ -1981,6 +1982,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 702ecd5048..7d7af40c51 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Type in envelopes: its strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them; with Distort Appearance they bent with the art as their filled outlines. The same holds in an envelope inside an envelope. Geometry effects on type's fills and strokes, and an opacity mask on type, still bend; Expand gives the bent outlines with the strokes on them. The visual bounds of envelopes, blends and repeats count type's character strokes as type's own visual bounds do. This changes how VectorCraft's own envelopes of stroked type draw (#6644). Score unchanged (estimated) | | 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. | | 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. | From f64438122f617648ef6d8786599a64bf46507895 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:48:51 +0000 Subject: [PATCH 14/83] Fonts: system_fallback visible to the module's tests --- crates/text/src/fontdb/lookup.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index 18af244ed1..fa91e97875 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -482,7 +482,7 @@ impl FontDb { /// Load a system font covering `c` (preferred fallback families first, then any cataloged /// file under 40 MB). Returns true if one was loaded. Misses are remembered. #[cfg(not(target_arch = "wasm32"))] - fn system_fallback(&self, c: char) -> bool { + pub(super) fn system_fallback(&self, c: char) -> bool { if c.is_control() || c.is_whitespace() { return false; } From 56142af888730e315b17a2f4a9ceb638f793b3be Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:54:53 +0000 Subject: [PATCH 15/83] Fonts: the budget test starts after the first scan --- crates/text/src/fontdb.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index bad992e513..12d27bd119 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -196,6 +196,8 @@ mod alias_tests { #[test] fn the_file_search_for_uncovered_characters_stops_at_its_budget() { let db = FontDb::with_font_dirs(vec![]); + // The first search scans the font folders, which starts the count again. + db.system_fallback('\u{E001}'); db.sys.lock().unwrap().file_searches = names::MAX_FILE_SEARCHES; assert!(!db.system_fallback('\u{E000}')); let sys = db.sys.lock().unwrap(); From 8e8b43f045b9945d0824cbcb1fec6b4bcc7c5dd1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:02:52 -0500 Subject: [PATCH 16/83] ci: use StepSecurity's maintained actions (#6658) Each third-party action that StepSecurity publishes a secure drop-in replacement for now uses that replacement, pinned to a commit with its version as a comment. Versions follow what we used: the newest StepSecurity release in the same major, or the closest major they publish. dtolnay/rust-toolchain refs that named a toolchain (stable, nightly, 1.76) become the replacement's `toolchain` input. --- .github/workflows/affinity-corpus.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/affinity-corpus.yml b/.github/workflows/affinity-corpus.yml index 41ac5a00d7..b3e0178f56 100644 --- a/.github/workflows/affinity-corpus.yml +++ b/.github/workflows/affinity-corpus.yml @@ -30,7 +30,7 @@ jobs: egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0 with: toolchain: "1.95" - name: Fetch pinned and checksum-verified corpus From da8c37aa8c8b0fddd649b08943dce01d977c7fe4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:06:08 +0000 Subject: [PATCH 17/83] PDF: note fonts once per walk, place repeated rasters without converting again, check image headers before embedding --- crates/pdf/src/export/images.rs | 22 ++++++++++++++++++++++ crates/pdf/src/import/builder.rs | 1 + crates/pdf/src/import/device.rs | 7 +++++++ crates/pdf/src/import/state.rs | 3 +++ crates/pdf/src/tests/colorize.rs | 18 ++++++++++++++++++ 5 files changed, 51 insertions(+) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 390fcc3445..2143ecba61 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -254,10 +254,20 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { Some(png) } +/// Most pixels an image may have to be embedded: twice what the app reads (64 million), so every image +/// it holds is still written. +const MAX_EMBED_PIXELS: u64 = 1 << 27; + /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), /// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a /// sample is written at 8 bits ([`png_8_bit`]). pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { + // The header's size first: the writer's decoders allocate what it declares, and the encoded + // bytes of an image that is far larger than any the app reads may be a few kilobytes. + let size = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions(); + if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > MAX_EMBED_PIXELS) { + return None; + } let bytes = blob.bytes.as_ref().clone(); let direct = match blob.mime.as_str() { "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), @@ -281,6 +291,18 @@ mod tests { use vectorcraft_doc::{Document, ImageBlob, ImageObject}; use vectorcraft_geom::Affine; + #[test] + fn an_image_whose_header_declares_far_more_pixels_than_the_app_reads_is_left_out() { + // A real 8 × 8 PNG whose IHDR (width, height at 16..24) claims 65535 × 65535, with its CRC fixed. + let mut png = vec![]; + image::DynamicImage::ImageRgb8(image::RgbImage::new(8, 8)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png[16..24].copy_from_slice(&[0, 0, 0xFF, 0xFF, 0, 0, 0xFF, 0xFF]); + let crc = + !png[12..29].iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 })); + png[29..33].copy_from_slice(&crc.to_be_bytes()); + assert!(super::embed(&ImageBlob::png(png), false, true).is_none()); + } + #[test] fn colorized_image_caches_rejected_images() { let mut doc = Document::new(10.0, 10.0); diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 61acbfc8ea..945c082a26 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -26,6 +26,7 @@ impl<'p> Builder<'p> { actual: Default::default(), images: HashMap::new(), image_keys: HashMap::new(), + raster_scales: HashMap::new(), cmyk_keys: HashSet::new(), separation_paints: HashMap::new(), opaque: HashSet::new(), diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index 2fb6bb9c80..c21e51dbb5 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -285,6 +285,12 @@ impl<'a> Device<'a> for Builder<'_> { self.add_image(key, || Some((ImageBlob::new("image/jpeg", bytes), w, h)), transform); return; } + // Drawn before: the image is there already, so its pixels aren't converted again. + if let Some(sf) = self.raster_scales.get(&key).copied().filter(|_| self.image_keys.contains_key(&key)) { + let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); + self.add_image(key, || None, xf); + return; + } let mut decoded: Option = None; r.with_rgba( |img, alpha| { @@ -303,6 +309,7 @@ impl<'a> Device<'a> for Builder<'_> { self.warn("an image could not be decoded and was skipped"); return; }; + self.raster_scales.insert(key, sf); let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); } diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 93a84ae688..419952b017 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -85,6 +85,9 @@ pub(super) struct Builder<'p> { pub(super) actual: super::actual_text::ActualText, pub(super) images: HashMap, pub(super) image_keys: HashMap, + /// The scale factors of each decoded raster by its key: drawn again, it is placed from them + /// without its pixels being decoded and converted again. + pub(super) raster_scales: HashMap, /// The images kept with their CMYK samples ([`crate::import_image`]). pub(super) cmyk_keys: HashSet, /// Images in a Separation space, by key: the paint of their ink, or `None` when they open in diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 97388ea313..f81fc17514 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -493,3 +493,21 @@ fn a_duotone_devicen_image_still_opens_as_rgb() { assert_eq!(blobs.len(), 1); assert!(!blobs[0].0.is_cmyk() && !blobs[0].1, "RGB, not colorized"); } + +/// A raster drawn again is placed from what the first drawing found, as the same image at its own +/// size, without its pixels being converted again. +#[test] +fn a_raster_drawn_twice_is_one_image_placed_twice() { + let content = "q 80 0 0 10 10 50 cm /Im1 Do Q q 40 0 0 10 10 20 cm /Im1 Do Q"; + let r = separation_report("/DeviceRGB", 2, 8, "", "FF0000 00FF00", &[], content); + let mut placed = vec![]; + r.document.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + placed.push((im.key.clone(), im.xf.as_coeffs()[0])); + } + }); + assert_eq!(placed.len(), 2, "{placed:?}"); + assert_eq!(placed[0].0, placed[1].0); + assert!(placed[0].1 > placed[1].1 * 1.9 && placed[0].1 < placed[1].1 * 2.1, "{placed:?}"); + assert_eq!(r.document.images.len(), 1); +} From 566f720868c180c862311fb174cefe91e81e7abd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:11:34 -0500 Subject: [PATCH 18/83] SVG import: references follow an id's first element, one read per linked file, bounded blank lines (#5409) * fix(svg): first element of an id, shared linked file reads, bounded blank lines * address review: cache failed linked-image reads but give each href its own placeholder and link * address review: charge only blank lines to the line-break budget * address review: a collapsed line break keeps its measured leading in tiny type * address review: blank lines use the same scaled leading tolerance --- crates/svg/src/import/depth.rs | 19 +++++++++++++- crates/svg/src/import/files.rs | 28 +++++++++++++++----- crates/svg/src/import/text.rs | 20 ++++++++++++--- crates/svg/tests/it/import_details.rs | 37 +++++++++++++++++++++++++++ crates/svg/tests/it/import_text.rs | 19 ++++++++++++++ crates/svg/tests/it/roundtrip.rs | 16 ++++++++++++ 6 files changed, 129 insertions(+), 10 deletions(-) diff --git a/crates/svg/src/import/depth.rs b/crates/svg/src/import/depth.rs index 2cdccc9dad..0586deaf6f 100644 --- a/crates/svg/src/import/depth.rs +++ b/crates/svg/src/import/depth.rs @@ -220,7 +220,14 @@ fn prescan_with(svg: &str, most: usize) -> Prescan<'_> { /// `most`. Without recursion. pub(super) fn height(xml: &roxmltree::Document, most: usize) -> Option { let elements = || xml.descendants().filter(|n| n.is_element()); - let ids: HashMap<&str, XNode> = elements().filter_map(|n| Some((n.attribute("id")?, n))).collect(); + // The first element of an id, as usvg resolves references: a later one of the same id would + // hide a deep target behind a shallow one. + let mut ids: HashMap<&str, XNode> = HashMap::new(); + for n in elements() { + if let Some(id) = n.attribute("id") { + ids.entry(id).or_insert(n); + } + } let css = Styles::new(xml); // How deep each element's own tree goes, itself included; 0 while it is being worked out: a // reference back to it then is a loop, which usvg leaves out. @@ -394,6 +401,16 @@ mod tests { assert!(!deep(&svg(&format!("{defs}{art}")))); } + #[test] + fn a_reference_follows_the_first_element_of_its_id() { + // A chain of ``s as deep as the cap, each link's id given again later to an empty group: + // usvg follows the first, so the chain is deep. + let chain: String = (1..MAX_ELEMENT_DEPTH).map(|i| format!("", i - 1)).collect(); + let shallow: String = (1..MAX_ELEMENT_DEPTH).map(|i| format!("")).collect(); + let top = format!("", MAX_ELEMENT_DEPTH - 1); + assert!(deep(&svg(&format!("{chain}{shallow}{top}")))); + } + #[test] fn a_loop_of_references_is_not_deep() { assert!(!deep(&svg(""))); diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index 9de3bebcdf..1a4c06f0e6 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -49,11 +49,12 @@ pub(in crate::import) struct Files { /// How usvg holds the bytes of a raster of one format (`ImageKind::PNG`…). type RasterKind = fn(Arc>) -> ImageKind; +#[derive(Clone)] enum Source { /// A raster file of the kind usvg reads it as. Raster(RasterKind, Arc>), /// An SVG file's text (unpacked when it is SVGZ), parsed by usvg as an image. - Svg(String), + Svg(Arc), } /// A linked raster image (or the placeholder of a missing one). @@ -133,14 +134,29 @@ impl Files { pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec) -> Self { let mut files = Files::default(); let mut placeholder = None; + // What each file read gave, by the file: hrefs spelled differently (`a.png`, `./a.png`, + // `%61.png`) share one read and one copy of its bytes. + let mut by_file: HashMap> = HashMap::new(); for n in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "image") { let Some(h) = href(n).filter(|h| !h.trim_start().starts_with("data:")) else { continue }; if !files.sources.contains_key(h) { let (path, file) = target(h, opts.folder); - let read = file.and_then(|file| opts.read.and_then(|read| read(&file))); - let source = match read { - Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"), - None => Err("not found"), + // A failed read is cached too, but each href gets its own placeholder: its own link + // path (an Arc shared between hrefs would share the first href's link) and warning. + let cached = file.as_ref().and_then(|f| by_file.get(f)).cloned(); + let source = match cached { + Some(source) => source, + None => { + let read = file.clone().and_then(|file| opts.read.and_then(|read| read(&file))); + let source = match read { + Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"), + None => Err("not found"), + }; + if let Some(file) = file { + by_file.insert(file, source.clone()); + } + source + } }; let source = source.or_else(|why| { warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked")); @@ -164,7 +180,7 @@ impl Files { let Ok(format) = image::guess_format(&bytes) else { // Unpacked once, here, within what the SVGZ images may unpack to. let text = crate::text_within(&bytes, &self.unpacked).ok()?; - return text.contains(" impl Iterator { v.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()) @@ -1155,6 +1159,12 @@ fn justify_of(anchor: f64) -> Justify { } } +/// How far a line's leading may differ from its spacing and still count as it: 0.01 absolute, but +/// never more than a thousandth of the spacing itself (tiny type has tiny gaps). +fn leading_tolerance(spacing: f64) -> f64 { + 0.01_f64.min(spacing.abs() * 1e-3) +} + /// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the /// justification, the x of the first line's left (or anchor) edge and, when no character is /// placed on its own, each line's first character and anchor x. @@ -1175,6 +1185,7 @@ fn lines( // Line starts (first character, anchor x) and absolutely placed characters (index, x). let mut lines = vec![(0, x0)]; let mut targets: Vec<(usize, f64)> = vec![]; + let mut added = 0usize; for i in 1..n { let ny = y[i].unwrap_or(cur) + dy[i]; let lead = cells[i].style.effective_leading(); @@ -1183,7 +1194,9 @@ fn lines( // Whole multiples of the auto leading add blank lines (not without limit: a far-off y // mustn't ask for billions); other spacing is one break. let count = (gap / lead).round().clamp(1.0, MAX_LINE_GAP); - let count = if (gap / count - lead).abs() <= lead * 0.05 { count } else { 1.0 }; + // The first newline just ends the previous line: only the others are blank lines. + let count = if (gap / count - lead).abs() <= lead * 0.05 && added + (count as usize - 1) <= MAX_LINE_BREAKS { count } else { 1.0 }; + added += count as usize - 1; breaks.push(Break { at: i, count: count as usize, leading: gap / count }); lines.push((i, ax + dx[i])); (base, cur) = (ny, ny); @@ -1199,7 +1212,8 @@ fn lines( // A line's characters take its spacing as leading unless it is their auto leading. for (k, b) in breaks.iter().enumerate() { let end = breaks.get(k + 1).map_or(n, |nb| nb.at); - if cells[b.at..end].iter().any(|c| (c.style.effective_leading() - b.leading).abs() > 0.01) { + let tolerance = leading_tolerance(b.leading); + if cells[b.at..end].iter().any(|c| (c.style.effective_leading() - b.leading).abs() > tolerance) { for c in &mut cells[b.at..end] { c.style.leading = Some(b.leading); } @@ -1372,7 +1386,7 @@ fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec) { let key = (prev.fill, prev.stroke); push(&mut out, '\n', prev.final_style(), key); let mut blank = prev.final_style(); - if (blank.effective_leading() - b.leading).abs() > 0.01 { + if (blank.effective_leading() - b.leading).abs() > leading_tolerance(b.leading) { blank.leading = Some(b.leading); } for _ in 1..b.count { diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 10418f3441..d6983a1ce2 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -88,6 +88,23 @@ fn a_linked_png_resolves_against_the_svg_folder_and_stays_linked() { assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.width() - 80.0).abs() < 1e-6, "{b:?}"); } +#[test] +fn hrefs_spelling_one_file_differently_read_it_once() { + let dir = folder("spellings"); + std::fs::write(dir.join("img").join("a.png"), png(4, 4)).unwrap(); + let reads = std::sync::atomic::AtomicUsize::new(0); + let counting = |path: &str| { + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + read(path) + }; + let folder = dir.to_string_lossy(); + let body = r#""#; + let (d, w) = import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&counting) }).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(images(&d).len(), 3); + assert_eq!(reads.into_inner(), 1, "one read for the three spellings"); +} + #[test] fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() { let dir = folder("missing"); @@ -139,6 +156,26 @@ fn files_with_the_same_pixels_have_keys_of_their_own() { assert_eq!(*d.images[&b.key].bytes, bytes); } +#[test] +fn hrefs_spelling_one_missing_file_differently_each_warn_and_keep_their_own_link() { + let dir = folder("missing_twice"); + let reads = std::sync::atomic::AtomicUsize::new(0); + let counting = |path: &str| { + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + read(path) + }; + let folder = dir.to_string_lossy(); + let body = r#""#; + let (d, w) = import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&counting) }).unwrap(); + assert_eq!(w.iter().filter(|w| w.contains("gone.png") && w.contains("not found")).count(), 2, "{w:?}"); + assert_eq!(reads.into_inner(), 1, "the failed read is cached"); + let found = images(&d); + assert_eq!(found.len(), 2); + for (_, im) in found { + assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join("gone.png")); + } +} + #[test] fn svg_images_import_as_vector_art() { let dir = folder("svgimage"); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 35289a0c1d..8b0d0338c6 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -277,6 +277,25 @@ fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); } +#[test] +fn a_small_line_gap_in_tiny_type_keeps_its_leading() { + // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. + let d = open(r#"OneTwo"#); + let t = only_text(&d); + let two = t.runs.iter().find(|r| r.text.contains("Two")).unwrap().style.leading; + assert!(two.is_some_and(|l| close(l, 0.0085, 1e-9)), "{two:?}"); +} + +#[test] +fn blank_lines_in_tiny_type_keep_their_own_spacing() { + // B sits 0.0085 below A (kept as its leading); C is three 0.006 lines below B, so the two blank + // lines between them must be 0.006 apart, not carry B's 0.0085. + let d = open(r#"ABC"#); + let t = only_text(&d); + let blank = t.runs.iter().find(|r| r.text == "\n\n").expect("a run of the two blank lines"); + assert!(blank.style.leading.is_some_and(|l| close(l, 0.006, 1e-9)), "{:?}", blank.style.leading); +} + #[test] fn tspan_lines_dx_and_baseline_shift() { let d = open( diff --git a/crates/svg/tests/it/roundtrip.rs b/crates/svg/tests/it/roundtrip.rs index b13ecf160b..127e47846c 100644 --- a/crates/svg/tests/it/roundtrip.rs +++ b/crates/svg/tests/it/roundtrip.rs @@ -909,6 +909,22 @@ fn import_tspan_far_below_is_bounded() { assert!(breaks <= 10_002, "{breaks} line breaks"); } +#[test] +fn import_many_tspans_far_apart_are_bounded_together() { + // Each tspan 9,999 lines (auto leading 14.4 pt) below the last: each within the limit of one gap, + // two hundred of them were two million line breaks. + let spans: String = (1..=200).map(|i| format!(r#"{i}"#, 20.0 + 14.4 * 9_999.0 * i as f64)).collect(); + let d = import_art(&format!( + r#"a{spans}"# + )); + let a = art(&d); + let NodeKind::Text(t) = &a[0].kind else { panic!() }; + let text = t.plain_text(); + assert!(text.ends_with("200"), "{}", &text[text.len().saturating_sub(20)..]); + let breaks = text.matches('\n').count(); + assert!(breaks <= 10_200, "{breaks} line breaks"); +} + #[test] fn import_tspan_on_the_same_baseline_stays_on_the_line() { // Styled runs positioned on the line's own baseline (as Illustrator writes them, with x and y on From 3edc1640fd1bc89f67db67337651a73a96195cc3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:17:39 -0800 Subject: [PATCH 19/83] Simplify: a flat closed loop keeps its outline when splitting reaches its limit Follows #6651. When the refits of a closed subpath enclose no area, Simplify splits its curves until straight lines between the pieces enclose one. That split stopped at 65,536 pieces in all, and a loop with many anchors then came back as the flat refit, with no outline. - Only curved segments are split; line segments stay one piece each. - When no split within the limits encloses an area, the loop stays as it is. With Convert to Straight Lines, each curved segment becomes the lines between its samples instead. --- crates/pathops/src/edit.rs | 44 +++++++++++++++++++++++++++------- crates/pathops/tests/it/ops.rs | 36 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index a82503391c..da8e5e4d8f 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -88,7 +88,9 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { /// whose refit is one segment, or lines that enclose no area, is refitted in two parts, cut at the /// point farthest from its start, or in three when two still give that. When three parts still give /// no area, straight lines follow the subpath within the tolerance, and without straight lines it -/// stays as it is, as does one of a single segment; one that is all in line keeps the refit. +/// stays as it is, as does one of a single segment. When no lines within the limits on pieces enclose +/// an area, it stays as it is, or with straight lines follows the lines between samples of each +/// curved segment; one that is all in line keeps the refit. fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let mut segs = subpath_segs(sp); let n = segs.len(); @@ -139,27 +141,51 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { } let ctrl: Vec = segs.iter().flat_map(|s| [s.c.p0, s.c.p1, s.c.p2]).collect(); if flat(&result) && !in_line(&ctrl) { - // Each segment split in 2, 4, 8 and so on, up to 1,024 pieces and 65,536 in all, until every - // piece is within the tolerance of the line between its ends and those lines enclose an - // area: with straight lines they are the result, and without them the loop stays as it is. - // A loop whose points are all in line keeps the lines of the refit. + // Each curved segment split in 2, 4, 8 and so on, up to 1,024 pieces and 65,536 pieces in all + // with the line segments, until every piece is within the tolerance of the line between its + // ends and those lines enclose an area: with straight lines they are the result, and without + // them the loop stays as it is. When no split within those limits encloses an area, the loop + // stays as it is, or with straight lines each curved segment becomes the lines between its + // samples, up to `MAX_RUN_RESULT` lines in all. A loop whose points are all in line keeps the + // lines of the refit. let near = |c: &CubicBez| [c.p1, c.p2].iter().all(|&p| Line::new(c.p0, c.p3).nearest(p, 1e-9).distance_sq.sqrt() <= opts.tolerance); + let curved = segs.iter().filter(|s| !s.line).count(); for k in 1..=10u32 { let parts = 1u32 << k; - if segs.len().saturating_mul(parts as usize) > 1 << 16 { + if curved.saturating_mul(parts as usize).saturating_add(n - curved) > 1 << 16 { break; } let at = |i: u32| f64::from(i) / f64::from(parts); - let pieces: Vec = segs.iter().flat_map(|s| (0..parts).map(move |i| s.c.subsegment(at(i)..at(i + 1)))).collect(); + let pieces: Vec = segs + .iter() + .flat_map(|s| { + let count = if s.line { 1 } else { parts }; + (0..count).map(move |i| if s.line { s.c } else { s.c.subsegment(at(i)..at(i + 1)) }) + }) + .collect(); let lines: Vec = pieces.iter().map(|c| Seg::line(c.p0, c.p3)).collect(); if pieces.iter().all(near) && !flat(&lines) { if !opts.straight_lines { return Some(sp.clone()); } - result = lines; - break; + return segs_to_subpath(&lines, true); } } + if !opts.straight_lines { + return Some(sp.clone()); + } + let room = (MAX_RUN_RESULT / curved.max(1)).min(MAX_SEGMENT_SAMPLES); + let mut lines: Vec = Vec::new(); + for s in &segs { + if s.line { + lines.push(Seg::line(s.c.p0, s.c.p3)); + continue; + } + let mut pts = Vec::new(); + sample(&s.c, sample_steps(&s.c, opts.tolerance.max(1e-6)).min(room).max(1), &mut pts, true); + lines.extend(pts.windows(2).map(|p| Seg::line(p[0], p[1]))); + } + return segs_to_subpath(&lines, true); } } let s = segs_to_subpath(&result, sp.closed)?; diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 9a6fb57138..81e7f83ce7 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -479,6 +479,42 @@ fn simplify_keeps_a_closed_subpath_that_refits_to_one_curve() { assert!(max_deviation(&long, &s) <= 0.1 + 1e-6, "{s:?}"); } +/// An S curve closed by a straight edge of 20,000 anchors, whose curve shows only when it is split in +/// four, keeps its outline within the tolerance. With corner anchors on the edge, only the curve is +/// split. With smooth anchors whose handles lie along the edge, four pieces of every segment pass the +/// limit on pieces: the loop stays as it is, or with straight lines follows lines between samples. +#[test] +fn simplify_keeps_a_flat_loop_with_many_anchors_on_its_edge() { + let n = 20_000; + let step = 100.0 / f64::from(n); + let curve_anchors = vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(0.0, 3.0)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(0.0, -3.0), Point::new(100.0, 0.0)), + ]; + let curve = PathData::single(SubPath::new(curve_anchors.clone(), false)); + for smooth_edge in [false, true] { + let mut anchors = curve_anchors.clone(); + anchors.extend((1..n).map(|i| { + let p = Point::new(100.0 - f64::from(i) * step, 0.0); + if smooth_edge { + Anchor::with_handles(p, Point::new(p.x + step / 3.0, 0.0), Point::new(p.x - step / 3.0, 0.0)) + } else { + Anchor::corner(p) + } + })); + let path = PathData::single(SubPath::new(anchors, true)); + for straight_lines in [false, true] { + let s = simplify_with(&path, &SimplifyOptions { tolerance: 4.0, straight_lines, ..Default::default() }); + let at = format!("smooth edge {smooth_edge}, straight lines {straight_lines}"); + assert_eq!(s.subpaths.len(), 1, "{at}"); + let sp = &s.subpaths[0]; + assert!(sp.closed && sp.area().abs() > 1.0, "{at}: area {}", sp.area()); + assert!(max_deviation(&curve, &s) <= 4.0 + 1e-6, "{at}: {}", max_deviation(&curve, &s)); + assert!(!straight_lines || (0..sp.segment_count()).all(|i| sp.segment_is_line(i)), "{at}: a curve is left"); + } + } +} + /// The Simplify slider's levels: 1 pt at 50, doubled for every 10 levels down and halved for every /// 10 up; Auto-Simplify takes the lowest level whose tolerance is at most 1/400 of the size, and /// level 100 for a size under 12.5 pt. From 590b579a49df1622fdbb93319d3cc879a42c7564 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:17:48 -0800 Subject: [PATCH 20/83] Clipboard: quitting waits for the paste check so copied data reaches the clipboard manager On X11 the last clipboard handle dropped hands what the app copied to the clipboard manager. When the paste check's thread was still waiting on a clipboard owner that had not answered, quitting dropped the app's own handle first and waited 250 ms at most for the check's handle, so the process could exit before the copy reached the clipboard manager. - VectorcraftApp declares clipboard_probe before services, so the paste check stops and lets go of its handle before the app's handle drops. - Dropping the paste check waits up to 5 s for its thread, longer than arboard's 4 s X11 read wait. - New test quitting_drops_the_probes_clipboard_before_the_apps. --- crates/ui-egui/src/clipboard_probe.rs | 11 ++-- crates/ui-egui/src/lib.rs | 9 ++-- crates/ui-egui/src/tests/sysclip_probe.rs | 65 ++++++++++++++++++++++- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/clipboard_probe.rs b/crates/ui-egui/src/clipboard_probe.rs index 902024bac2..d492893ce0 100644 --- a/crates/ui-egui/src/clipboard_probe.rs +++ b/crates/ui-egui/src/clipboard_probe.rs @@ -14,8 +14,11 @@ use crate::sysclip::ClipboardProbeFactory; /// How often the worker looks at the clipboard while the UI wants to know. const PROBE_INTERVAL: Duration = Duration::from_millis(250); -/// How long dropping the probe waits for the worker to let go of its clipboard handle. -const STOP_WAIT: Duration = Duration::from_millis(250); +/// How long dropping the probe (when the app quits) waits for the worker to let go of its clipboard +/// handle (VectorCraft's own bound). arboard's X11 reads wait up to 4 s for each text format they +/// ask for; when a look is still waiting after this bound, the app quits without waiting longer, +/// and what it copied may not reach the clipboard manager. +const STOP_WAIT: Duration = Duration::from_secs(5); /// The background clipboard check ([`crate::VectorcraftApp`]'s `clipboard_probe`). pub(crate) struct Probe { @@ -89,8 +92,8 @@ impl Probe { } impl Drop for Probe { - /// Wake the worker to stop and wait (a moment at most: it may be stuck on an owner that never - /// answers) until it has let go of its clipboard handle. + /// Wake the worker to stop and wait ([`STOP_WAIT`] at most: it may be stuck on an owner that + /// never answers) until it has let go of its clipboard handle. fn drop(&mut self) { self.wants = None; let end = Instant::now() + STOP_WAIT; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 644125b545..e636f0520a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -133,6 +133,12 @@ pub struct VectorcraftApp { /// [`VectorcraftApp::sync_views`], which puts the views back with their documents after /// `document.move` reorders them (#5663). pub(crate) view_uids: Vec, + /// The thread that checks the system clipboard for `system_paste` instead + /// ([`Services::clipboard_probe`]). It is declared before `services` so that it is dropped + /// first: on X11 the last clipboard handle alive hands what the app copied to the clipboard + /// manager, and that must be the app's own handle in `services`, dropped on the UI thread + /// before the process exits. + clipboard_probe: Option, pub services: Services, pub canvas: CanvasCache, pub perf: Perf, @@ -234,9 +240,6 @@ pub struct VectorcraftApp { pub(crate) clipboard_image_request: Option, /// When `system_paste` was last checked in line (app time, s; at most once per frame). system_paste_at: f64, - /// The thread that checks the system clipboard for `system_paste` instead - /// ([`Services::clipboard_probe`]). - clipboard_probe: Option, /// File dialogs shown off the UI thread and what runs again with their answers. pub(crate) picks: picks::Picks, /// The screen color sampler shown and not answered yet, and where its color goes. diff --git a/crates/ui-egui/src/tests/sysclip_probe.rs b/crates/ui-egui/src/tests/sysclip_probe.rs index 48e4f2e348..05cab31bf7 100644 --- a/crates/ui-egui/src/tests/sysclip_probe.rs +++ b/crates/ui-egui/src/tests/sysclip_probe.rs @@ -14,11 +14,13 @@ use vectorcraft_engine::cmd::clipboard::Flavour; use crate::tests_sysclip::{copy_rect, run}; use crate::{Services, SystemClipboard, VectorcraftApp, menus}; -/// What the clipboard handles saw: the thread each `has()` ran on, and how many handles are alive. +/// What the clipboard handles saw: the thread each `has()` ran on, how many handles are alive, and +/// the thread each handle was dropped on, in order. #[derive(Default)] struct Recorder { threads: Mutex>>, alive: AtomicUsize, + drops: Mutex>>, } impl Recorder { @@ -44,10 +46,42 @@ impl Slow { impl Drop for Slow { fn drop(&mut self) { + self.rec.drops.lock().unwrap().push(std::thread::current().name().map(str::to_owned)); self.rec.alive.fetch_sub(1, Ordering::SeqCst); } } +/// The probe's clipboard: its `has()` tells `looking` it started, then waits until `answer` sends +/// (an owner that answers only once the app is quitting). +struct Held { + rec: Arc, + looking: std::sync::mpsc::Sender<()>, + answer: std::sync::mpsc::Receiver<()>, +} + +impl Drop for Held { + fn drop(&mut self) { + self.rec.drops.lock().unwrap().push(std::thread::current().name().map(str::to_owned)); + } +} + +impl SystemClipboard for Held { + fn write(&mut self, _flavours: &[Flavour]) -> Result<(), String> { + Ok(()) + } + fn holds_ours(&mut self) -> bool { + false + } + fn read(&mut self, _mimes: &[&'static str]) -> Option { + None + } + fn has(&mut self, _mimes: &[&'static str]) -> bool { + let _ = self.looking.send(()); + let _ = self.answer.recv(); + false + } +} + impl SystemClipboard for Slow { fn write(&mut self, _flavours: &[Flavour]) -> Result<(), String> { Ok(()) @@ -143,6 +177,35 @@ fn the_probe_rests_after_a_copy_and_stops_with_the_app() { assert_eq!(rec.alive.load(Ordering::SeqCst), 0, "the probe's thread kept its clipboard"); } +#[test] +fn quitting_drops_the_probes_clipboard_before_the_apps() { + let rec = Arc::new(Recorder::default()); + let (looking, looked) = std::sync::mpsc::channel(); + let (answer, answered) = std::sync::mpsc::channel::<()>(); + let make = { + let rec = Arc::clone(&rec); + move || -> Box { Box::new(Held { rec, looking, answer: answered }) } + }; + let services = Services { + system_clipboard: Some(Slow::boxed(&rec, Duration::ZERO, Some(false))), + clipboard_probe: Some(Box::new(make)), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + frame(&mut app); + frame(&mut app); + // The probe's thread is waiting on the clipboard's owner when the user quits. + looked.recv_timeout(Duration::from_secs(60)).expect("the probe never looked"); + drop(answer); + drop(app); + // On X11 the last handle dropped hands what the app copied to the clipboard manager: it must be + // the app's own, dropped on the UI thread before the process exits. + let drops = rec.drops.lock().unwrap().clone(); + let ui = std::thread::current().name().map(str::to_owned); + assert_eq!(drops, vec![Some("vectorcraft-clip".to_owned()), ui], "handles dropped on: {drops:?}"); +} + #[test] fn without_its_thread_the_check_runs_in_line_again() { let rec = Arc::new(Recorder::default()); From 5ce23377158eb1642d0a9967cafbf81112f11613 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:20:45 -0800 Subject: [PATCH 21/83] Tests: malformed id cases check and restore isolation mode and the shown mask malformed_ids_are_refused_by_commands_that_default_to_the_selection put back the document, the selection and the undo history after a case that changed them, but not isolation mode or the opacity mask view. If object.isolate entered isolation mode while refusing a bad id, the test did not report it, and the later transparency.editOpacityMask and transparency.viewOpacityMask cases failed with "exit isolation mode first" instead. Each case now also compares isolation mode and the shown mask before and after the command, and puts them back when they changed, so a failure is reported against the command that caused it. --- crates/engine/src/tests.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ca0501c8f7..c19a0a72d2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4245,8 +4245,8 @@ fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() { /// #785: commands whose `ids` or `id` the shared check doesn't read (it reads `id` where it stands /// for `ids` of one) refuse one that isn't an object id. They don't act on the selection, the -/// current layer or a new object instead, and the document, the selection and the undo history -/// stay as they are. +/// current layer or a new object instead, and the document, the selection, the undo history, +/// isolation mode and the shown opacity mask stay as they are. #[test] fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() { let mut s = session(); @@ -4294,17 +4294,19 @@ fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() { if let Some(p) = p.as_object_mut() { p.insert(key.into(), bad.clone()); } - let (doc, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), s.doc().unwrap().history.undo.len()); + let st = s.doc().unwrap(); + let (doc, selection, undo, modes) = (st.doc.clone(), st.selection.clone(), st.history.undo.len(), (st.isolation, st.mask_view)); let r = s.execute(cmd, &p); let named = |e: &str| e.contains("object id") || e.contains("step reference") || e.contains(&format!("`{key}` must be")); let refused = r.as_ref().err().map(|e| e.to_string()).is_some_and(|e| named(&e)); - let same = s.doc().unwrap().doc == doc && s.doc().unwrap().selection == selection && s.doc().unwrap().history.undo.len() == undo; + let st = s.doc().unwrap(); + let same = st.doc == doc && st.selection == selection && st.history.undo.len() == undo && (st.isolation, st.mask_view) == modes; if !(refused && same) { wrong.push(format!("{cmd} {p}: {r:?}, document unchanged: {same}")); } if !same { let st = s.doc_mut().unwrap(); - (st.doc, st.selection) = (doc, selection); + (st.doc, st.selection, (st.isolation, st.mask_view)) = (doc, selection, modes); st.history.undo.truncate(undo); } } From be270420602199454ca23d34687dfaadb132ecae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:32:57 -0800 Subject: [PATCH 22/83] Guides: sublayer guides survive layer effects and the canvas check - A layer with a Pathfinder effect, or a Transform effect that copies its art, keeps the ruler guides of its sublayers on the layer, hidden and locked as their sublayers were, instead of losing them with the art. - Opening a file counts the ruler guides of sublayers removed beyond the canvas. - Opening a file moves misplaced ruler guides above the art before the canvas check, so a guide saved before a clipping path beyond the canvas no longer makes other art the clipping path. Follows #6632. --- crates/doc/src/canvas_range.rs | 46 ++++++++++++++++++++++++++----- crates/effects/src/group.rs | 38 ++++++++++++++++++++++++- crates/effects/src/group/tests.rs | 41 +++++++++++++++++++++++++++ crates/format/src/lib.rs | 38 +++++++++++++++++++++++-- 4 files changed, 152 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index e4f267a8b6..491b79f158 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -15,7 +15,8 @@ pub struct BeyondCanvas { /// Objects removed whole (a layer or group keeps its children within the canvas). pub objects: usize, pub artboards: usize, - /// Guides beyond the canvas, and the artboard guides of the artboards removed. + /// Guides beyond the canvas, the artboard guides of the artboards removed, and the layer + /// guides of the layers removed. pub guides: usize, /// The ruler origin was beyond the canvas and is back at the origin. pub ruler_origin: bool, @@ -100,11 +101,20 @@ fn layers_in(n: &Node, layers: &mut Vec) { } } +/// How many ruler guides the layers in `n` hold, if it is a layer or group. +fn guides_in(n: &Node) -> usize { + match clipping(n) { + None => 0, + Some(_) => n.children().into_iter().flatten().map(|c| if c.is_ruler_guide() { 1 } else { guides_in(c) }).sum(), + } +} + /// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its /// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything /// else goes whole. Only containers holding such an object are copied. Recursion follows the -/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`. -fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec) { +/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and +/// the ruler guides they held to `guides`. +fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec, guides: &mut usize) { if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) { return; } @@ -114,10 +124,11 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut return true; } if clipping(c).is_some() && !clips_beyond(c) { - drop_children_beyond(c, dropped, layers); + drop_children_beyond(c, dropped, layers, guides); return true; } layers_in(c, layers); + *guides += guides_in(c); *dropped += 1; false }); @@ -159,13 +170,14 @@ impl Document { for c in children.iter() { layers_in(c, &mut layers); } - // The layer's own ruler guides stay; only its art goes. + // The layer's own ruler guides stay; only its art goes, with its sublayers' guides. gone.objects += children.iter().filter(|c| !c.is_ruler_guide()).count(); + gone.guides += children.iter().filter(|c| !c.is_ruler_guide()).map(|c| guides_in(c)).sum::(); children.retain(|c| c.is_ruler_guide()); *clip = false; } } else { - drop_children_beyond(layer, &mut gone.objects, &mut layers); + drop_children_beyond(layer, &mut gone.objects, &mut layers, &mut gone.guides); } } if gone.objects > 0 { @@ -184,7 +196,7 @@ impl Document { let guides = self.saved_guides.len(); self.saved_guides .retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); - gone.guides = guides - self.saved_guides.len(); + gone.guides += guides - self.saved_guides.len(); let out = |n: &Node| match n.kind { NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), _ => false, @@ -324,6 +336,21 @@ mod tests { let layer = d2.layers[0].id.0; assert_eq!(d2.drop_beyond_canvas().objects, 1); assert_eq!(ids(&d2), [layer, 10]); + // The guides of a sublayer emptied out of a top-level layer go with it, counted. + let probe = Document::new(1.0, 1.0); + let sub = sublayer(&probe, 40, vec![rect(41, 0.0)], false); + let mut d3 = doc(vec![rect(10, 1e17), sub]); + if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut d3.layers[0]).kind { + *clip = true; + } + d3.saved_guides = + vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d3.layers[0].id), ..Guide::new(true, 5.0) }]; + let mut nodes = d3.clone(); + assert_eq!(nodes.guides_into_nodes(), 2); + let gone = d3.drop_beyond_canvas(); + assert_eq!((gone.objects, gone.guides), (2, 1), "the clipping path, the sublayer and its guide; the layer's own guide stays"); + assert_eq!(nodes.drop_beyond_canvas(), gone); + assert_eq!(nodes.ruler_guides().len(), 1); assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default()); } @@ -353,8 +380,13 @@ mod tests { ]; d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); + // The same guides as nodes, as a document has them once read. + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 2); let gone = d.drop_beyond_canvas(); assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide"); + assert_eq!(nodes.drop_beyond_canvas(), gone, "a guide node goes with its sublayer, counted as a saved one is"); + assert_eq!(nodes.ruler_guides().len(), 1); assert_eq!(d.saved_guides.len(), 1, "the guide of a layer that stays, stays"); assert_eq!(d.saved_selections[0].objects, [NodeId(10)]); assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 99e9d9e6fe..73abbffb2c 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -719,6 +719,37 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], Some(g) } +/// Whether a sublayer among the members of `n`, or among its groups' members, holds a ruler guide. +fn sublayer_guides(n: &Node) -> bool { + n.children().is_some_and(|ch| ch.iter().any(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c)))) +} + +/// Move the ruler guides of the sublayers among the members of `n`, and among its groups' +/// members, to `out`, in order. A guide taken from a hidden or locked sublayer, or from one inside +/// such a sublayer, is hidden or locked itself. Only containers holding one are copied. +fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, hidden: bool, locked: bool) { + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut().filter(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))) { + let c = Arc::make_mut(c); + let (hidden, locked) = if c.is_layer() { (hidden || !c.visible, locked || c.locked) } else { (hidden, locked) }; + if c.is_layer() + && let Some(lc) = c.children_mut() + { + let (taken, art): (Vec<_>, Vec<_>) = std::mem::take(lc).into_iter().partition(|g| g.is_ruler_guide()); + *lc = art; + out.extend(taken.into_iter().map(|mut g| { + if hidden || locked { + let g = Arc::make_mut(&mut g); + g.visible &= !hidden; + g.locked |= locked; + } + g + })); + } + take_sublayer_guides(c, out, hidden, locked); + } +} + /// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects /// reshaping all its members as one piece (the whole art's bounds as their reference box), and its /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below @@ -739,7 +770,12 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } let mut m = n.clone(); // A layer's ruler guides take no part: they go back above the evaluated art at the end. - let guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); + let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); + // So do its sublayers' guides, which would otherwise go with the art the effects replace or + // copy: they stay on this layer, hidden and locked as their sublayers were. + if m.is_layer() && (pathfinder || !fx.is_empty()) { + take_sublayer_guides(&mut m, &mut guides, false, false); + } if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); let result = pathfinder_children(doc, &m, Some(&hook)).unwrap_or_default(); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index bf702b2732..ef242c5dc1 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -862,3 +862,44 @@ fn a_layers_ruler_guides_stay_last_through_its_effects() { painted.appearance.items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items; assert_eq!(tail(&evaluate_container(None, &painted).unwrap()), guides); } + +/// The ruler guides of a sublayer in a layer with a Pathfinder effect, or a Transform effect that +/// copies its art, stay on a layer of the evaluated art, once, hidden and locked as the sublayer is. +#[test] +fn a_sublayers_ruler_guides_survive_its_layers_effects() { + use vectorcraft_doc::LayerColor; + let layer = |effect: &str, params: serde_json::Value, shown: bool| { + let mut sub = Node::layer(NodeId(11), "Sub", LayerColor::Preset(1)); + (sub.visible, sub.locked) = (shown, !shown); + if let Some(ch) = sub.children_mut() { + *ch = vec![ + sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), + Arc::new(Node::new(NodeId(20), NodeKind::RulerGuide { vertical: true, pos: 3.0, artboard: None })), + ]; + } + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(sub)]; + } + l.appearance.effects.push(Effect { id: effect.into(), params, visible: true }); + l + }; + // The guides found where ruler guides are looked for: directly in a layer or sublayer. + fn listed(n: &Node, out: &mut Vec<(NodeId, bool, bool)>) { + for c in n.children().into_iter().flatten() { + if c.is_ruler_guide() { + out.push((c.id, c.visible, c.locked)); + } else if c.is_layer() { + listed(c, out); + } + } + } + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + for shown in [true, false] { + let art = evaluate_container(None, &layer(effect, params.clone(), shown)).unwrap(); + let mut found = vec![]; + listed(&art, &mut found); + assert_eq!(found, [(NodeId(20), shown, !shown)], "{effect}: the sublayer's guide, once, on a layer"); + } + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 38545b8292..cd64fcbb50 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -594,6 +594,10 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND); let mut depth_notes = vec![]; doc.limit_depth_noting(&mut depth_notes); + // Untrusted: ruler guide nodes only directly in layers, above their art (after the depth limit: + // the walk recurses; before the canvas check, which takes a layer's first member as its + // clipping path). + doc.tidy_ruler_guides(); // Untrusted: nothing beyond the canvas, where no edit may leave anything (after the depth // limit: the walk recurses). A file whose artboards all lie there has no page to show (#3575). let had_artboards = !doc.artboards.is_empty(); @@ -605,9 +609,6 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result ))); } depth_notes.extend(beyond.note()); - // Untrusted: ruler guide nodes only directly in layers, above their art (after the depth limit: - // the walk recurses). - doc.tidy_ruler_guides(); // The saved ruler guides become nodes on their layers (after the guides beyond the canvas are // dropped, and after `fix_next_id`: new ids start above the file's). doc.guides_into_nodes(); @@ -1385,4 +1386,35 @@ mod tests { assert_eq!(base64_decode("QUI="), Some(vec![0x41, 0x42])); assert_eq!(base64_decode("QUJD"), Some(vec![0x41, 0x42, 0x43])); } + + /// A layer clipping by a path beyond the canvas is emptied on open and stops clipping, also + /// when the file puts a ruler guide before the clipping path: no other object becomes it. + #[test] + fn a_guide_before_a_clipping_path_beyond_the_canvas_does_not_change_the_clipping_path() { + let opened = |guide_first: bool| { + let mut d = Document::new(100.0, 100.0); + let rect = |d: &mut Document, y: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, y, 10.0, y + 10.0)), Appearance::default_art())) + }; + let (far, near) = (rect(&mut d, 1e17), rect(&mut d, 0.0)); + if let NodeKind::Layer { children, clip, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind { + *children = vec![far, near]; + *clip = true; + } + // Saving writes ruler guides to the saved list: the guide node is put in the file. + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let guide = serde_json::json!({"id": 50, "kind": {"type": "rulerguide", "vertical": true, "pos": 5.0}}); + let kids = v["document"]["layers"][0]["kind"]["children"].as_array_mut().unwrap(); + if guide_first { + kids.insert(0, guide) + } else { + kids.push(guide) + } + let d = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let NodeKind::Layer { children, clip, .. } = &d.layers[0].kind else { panic!("a layer") }; + (*clip, children.iter().map(|c| c.is_ruler_guide()).collect::>()) + }; + assert_eq!(opened(false), (false, vec![true]), "the clipping path and what it clipped go; the guide stays"); + assert_eq!(opened(true), opened(false)); + } } From dc98b59444753786f56a10b774dd2c2c2113c970 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:45:46 -0800 Subject: [PATCH 23/83] PDF: the in-points option leaves PDF/A pages within the standard's size limit longPagesInPoints now applies only to files without a standard. PDF/A-2 limits page boundaries to 14,400 units (ISO 19005-2, 6.1.13), so a PDF/A file with a page longer than 200 in keeps its UserUnit whether the option is on or off. The Save PDF dialog disables the checkbox for PDF/A as it does for PDF/X. Follows #6643. --- crates/engine/src/cmd/fileio/pdf.rs | 4 +-- crates/pdf/src/export/entry.rs | 15 ++++++--- crates/pdf/src/settings.rs | 5 +-- crates/pdf/src/tests/marks.rs | 23 ++++++++++++++ crates/ui-egui/src/dialogs/save_pdf.rs | 5 +-- crates/ui-egui/src/dialogs/save_pdf/tests.rs | 33 ++++++++++++++++++++ docs/mcp.md | 5 ++- docs/target-app-parity.md | 2 +- 8 files changed, 80 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 365edbedbf..a18af48af1 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -99,7 +99,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "longPagesInPoints", ty: "boolean", default: "false", - description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)", }, FormatOption { name: "createLayers", @@ -128,7 +128,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, 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), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5; apps that keep to the limit cut it to 200 in), 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), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), 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 ), diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index fc48485216..d6fb01a07c 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -192,10 +192,17 @@ const MIN_PAGE_SIDE: f64 = 3.0; const MAX_PDF14_REAL: f64 = 32_767.0; /// Pages with a side longer than [`MAX_PAGE_SIDE`] are written with a `UserUnit`: in PDF 1.6 and -/// later files that aren't PDF/X files, unless `long_pages_in_points` is on. Other files have +/// later files that aren't PDF/X files, unless `long_pages_in_points` applies. Other files have /// every page in points. fn writes_user_units(set: &PdfSettings) -> bool { - set.compatibility.has_user_unit() && !set.standard.is_pdfx() && !set.long_pages_in_points + set.compatibility.has_user_unit() && !set.standard.is_pdfx() && !long_pages_in_points(set) +} + +/// `long_pages_in_points` is on in a file without a standard. PDF/X files write long pages in +/// points anyway, and PDF/A files keep the `UserUnit`, which keeps their page boundaries within +/// 14,400 units (ISO 19005-2, 6.1.13). +fn long_pages_in_points(set: &PdfSettings) -> bool { + set.long_pages_in_points && set.standard == Standard::None } /// The `UserUnit` (PDF 1.6) of a page `w` × `h` points large: 1 when both sides are at most @@ -221,7 +228,7 @@ pub fn check_page_sizes(doc: &Document, opts: &PdfOptions) -> Result<(), PdfErro /// The settings `set` is written with when one of `pages` has a side longer than /// [`MAX_PAGE_SIDE`]: a PDF 1.5 file that isn't a PDF/X file is written as PDF 1.6, which has a -/// page's `UserUnit`, unless `long_pages_in_points` is on (`None`: `set` as it is). Other files +/// page's `UserUnit`, unless `long_pages_in_points` applies (`None`: `set` as it is). Other files /// without a `UserUnit` have the page in points. The writer puts a whole number as an integer and fails a PDF 1.3 or 1.4 file that has /// a real number past [`MAX_PDF14_REAL`]: a page whose side is past it and not a whole number of /// points is refused here, before anything is drawn ([`page_too_long`]). @@ -229,7 +236,7 @@ fn for_long_pages(set: &PdfSettings, pages: &[(usize, PageBoxes)]) -> Result MAX_PAGE_SIDE) { return Ok(None); } - let raised = (set.compatibility == Compatibility::Pdf15 && !set.standard.is_pdfx() && !set.long_pages_in_points) + let raised = (set.compatibility == Compatibility::Pdf15 && !set.standard.is_pdfx() && !long_pages_in_points(set)) .then(|| PdfSettings { compatibility: Compatibility::Pdf16, ..set.clone() }); // The writer's test for a whole number, on the `f32` it writes. let real = |side: f64| side > MAX_PDF14_REAL && (side as f32) as i32 as f32 != side as f32; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index b4b25baebf..710037a7c1 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -237,8 +237,9 @@ pub struct PdfSettings { /// `create_layers` is on). pub include_non_printing: bool, /// Write a page with a side longer than 14,400 pt (200 in) at its size in points, without a - /// `UserUnit`, at every PDF version (off: PDF 1.6 and later files that aren't PDF/X files - /// write it with a `UserUnit`, and PDF 1.5 files are written as PDF 1.6 for it). + /// `UserUnit`, at every PDF version, in files without a standard (off, and in PDF/A files: + /// PDF 1.6 and later files that aren't PDF/X files write it with a `UserUnit`, and PDF 1.5 + /// files are written as PDF 1.6 for it). pub long_pages_in_points: bool, pub compression: CompressionSettings, pub marks: MarkSettings, diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index c829ed8d5a..47ee3dfa79 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -477,6 +477,29 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_ assert_eq!(report(&d, with(&x4)).unwrap().bytes, report(&d, x4).unwrap().bytes); } +/// PDF/A-2 limits page boundaries to 14,400 units (ISO 19005-2, 6.1.13): with +/// `long_pages_in_points`, a PDF/A file gives the same file as without it at every version its +/// standard allows, and a 10 m page keeps its `UserUnit` from PDF 1.5 on (PDF 1.5 is written as +/// PDF 1.6 for it). +#[test] +fn pdfa2b_long_page_stays_within_14400_units() { + let d = sized(TEN_M, TEN_M / 10.0); + for s in Standard::ALL.iter().copied().filter(|s| *s != Standard::None && !s.is_pdfx()) { + for c in Compatibility::ALL.iter().copied().filter(|c| s.allows(*c)) { + let off = PdfSettings { standard: s, compatibility: c, ..PdfSettings::default() }; + let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; + let (on, off) = (report(&d, on).unwrap(), report(&d, off).unwrap()); + assert_eq!(on.bytes, off.bytes, "{} {}", s.label(), c.label()); + assert_eq!(on.warnings, off.warnings, "{} {}", s.label(), c.label()); + if c != Compatibility::Pdf14 { + assert_eq!(raw_numbers(&on.bytes, "/UserUnit", 1), [1.969], "{} {}", s.label(), c.label()); + let media = raw_numbers(&on.bytes, "/MediaBox", 4); + assert!(media[2] - media[0] <= 14_400.0 && media[3] - media[1] <= 14_400.0, "{} {}: {media:?}", s.label(), c.label()); + } + } + } +} + /// A page of 14,400 pt or less gives the same file with `long_pages_in_points` on or off, at every /// PDF version. #[test] diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index ba6d206074..e2f21c80cf 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -478,9 +478,10 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { let layers = standard.allows_layers() && choice::(d, "compatibility").unwrap_or_default().has_layers(); flag(ui, d, "createLayers", tl!("Create PDF layers from layers and sublayers"), layers); flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true); - // PDF/X files and PDF 1.3 and 1.4 files always write long pages in points. + // PDF/X files and PDF 1.3 and 1.4 files always write long pages in points, and PDF/A files + // keep the `UserUnit`. let compatibility = choice::(d, "compatibility").unwrap_or_default(); - let scales = !standard.is_pdfx() && !matches!(compatibility, Compatibility::Pdf13 | Compatibility::Pdf14); + let scales = standard == Standard::None && !matches!(compatibility, Compatibility::Pdf13 | Compatibility::Pdf14); flag(ui, d, "longPagesInPoints", tl!("Write pages longer than 200 in at full size in points"), scales); if editor { heading(ui, tl!("Description")); diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index 7bb471a76e..a6f4e9884c 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -323,6 +323,39 @@ fn choosing_a_pdfx_standard_sets_its_version_and_writes_the_standard() { assert_eq!((get(d, "compatibility"), get(d, "createLayers")), (&json!("1.6"), &json!(true))); } +/// "Write pages longer than 200 in at full size in points" is enabled only without a standard, at +/// PDF 1.5 and later: PDF/X files and PDF 1.3 and 1.4 files write long pages in points anyway, and +/// PDF/A files keep the `UserUnit`. +#[test] +fn the_long_pages_in_points_check_box_is_disabled_with_a_standard() { + const LABEL: &str = "Write pages longer than 200 in at full size in points"; + let (mut app, _, _) = app(); + app.run("ui.savePdfDialog", json!({})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 900.0)); + let frame = |app: &mut VectorcraftApp| { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + out + }; + for s in Standard::ALL.iter().copied() { + for c in Compatibility::ALL.iter().copied().filter(|c| s.allows(*c)) { + set_field(&mut app, "standard", json!(s.id())); + set_field(&mut app, "compatibility", json!(c.id())); + frame(&mut app); + let out = frame(&mut app); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let node = nodes.iter().find(|(_, n)| n.label() == Some(LABEL) && n.role() == egui::accesskit::Role::CheckBox); + let disabled = node.unwrap_or_else(|| panic!("no check box named {LABEL:?}")).1.is_disabled(); + let enabled = s == Standard::None && !matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14); + assert_eq!(disabled, !enabled, "{} {}", s.label(), c.label()); + } + } +} + #[test] fn create_layers_writes_pdf_layers() { let (mut app, written, _) = app(); diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..771f11ccdd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -877,7 +877,10 @@ rounded up to the next thousandth, with the page's sizes and coordinates in thos keeps PDF 1.5's encryption (128-bit RC4). PDF/X files and PDF 1.3 and 1.4 files write such a page in points, and apps that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without -a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can +a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. The option applies +only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files keep the `UserUnit`, +which keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13). The dialog disables the +checkbox when a standard is chosen. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can be at most 32,767, and whole numbers are written as integers: a page longer than 32,767 pt whose sides aren't whole numbers of points fails the export with an error naming the page before the thumbnails are drawn and the transparency is flattened, and one whose art needs a real number past 32,767 fails with the same error when the file is written. A page whose short side is 3 pt or longer but would be under 3 units at that diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..4de9e947f9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1441,7 +1441,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, 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. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. 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). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version. + 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). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard. 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. 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). From e3093034eacaac5094c1661071e51a4e1e7f74cc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:47:41 -0800 Subject: [PATCH 24/83] Scripts: opening a macOS menu reads the Scripts folder again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the macOS menu bar, File › Scripts was read only at startup and when the window became active, so a script added while the window stayed active did not appear. The native menu now reports a menu opening (Event::Opened, when AppKit begins tracking), and native_menu::run reads the folder again, as opening the in-window File menu does. Menu updates wait until tracking ends, so the new listing shows the next time the menu opens. Docs: docs/mcp.md (File › Scripts), docs/target-app-parity.md. --- apps/vectorcraft/src/mac_menu.rs | 10 +++++- crates/ui-egui/src/native_menu.rs | 22 +++++++++--- crates/ui-egui/src/tests/scripts_menu.rs | 44 ++++++++++++++++++++++++ docs/mcp.md | 7 ++-- docs/target-app-parity.md | 2 +- 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/apps/vectorcraft/src/mac_menu.rs b/apps/vectorcraft/src/mac_menu.rs index f90e4f7304..53b610a210 100644 --- a/apps/vectorcraft/src/mac_menu.rs +++ b/apps/vectorcraft/src/mac_menu.rs @@ -30,6 +30,9 @@ use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole, // AppKit can deliver window input while its system menu is still tracking (especially with // asynchronous menu presentation). Keep the native tracking state separate from egui popups. static TRACKING: AtomicBool = AtomicBool::new(false); +/// A tracking session began since the last [`Backend::drain`], which reports it as +/// [`Event::Opened`]. +static OPENED: AtomicBool = AtomicBool::new(false); pub(crate) fn is_tracking() -> bool { TRACKING.load(Ordering::Relaxed) @@ -46,9 +49,11 @@ fn observe_tracking(ctx: &egui::Context) { let block = RcBlock::new(move |_: std::ptr::NonNull| { TRACKING.store(tracking, Ordering::Relaxed); if tracking { + OPENED.store(true, Ordering::Relaxed); super::mac_cursor::release(); } - // Apply any deferred menu updates as soon as the tracking session ends. + // Report the opened menu and apply any deferred menu updates as soon as the + // tracking session ends. if !tracking { repaint.request_repaint(); } @@ -267,6 +272,9 @@ impl Backend for MacMenu { fn drain(&mut self) -> Vec { let mut out = Vec::new(); + if OPENED.swap(false, Ordering::Relaxed) { + out.push(Event::Opened); + } while let Ok(chosen) = self.rx.try_recv() { cancel_tracking(); // Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled. diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 56baee51aa..510131be0e 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -499,6 +499,9 @@ pub enum Event { /// A key equivalent pressed: the item's key with the modifiers held, to be handled as the key /// press it was. Key(KeyboardShortcut), + /// A menu of the bar opened. The Scripts folder is read again, as when the in-window File menu + /// opens; the new listing shows the next time the menu opens. + Opened, } /// A platform's native menu bar (the desktop app implements it for macOS with muda). @@ -517,6 +520,8 @@ pub struct NativeMenu { /// Hash of the state the menus were last read in ([`state_hash`]). state: Option, clicks: Vec, + /// A menu opened since the last [`run`]. + opened: bool, /// Something was chosen (AppKit may have toggled a check mark itself): read the menus again. dirty: bool, logged: bool, @@ -524,17 +529,21 @@ pub struct NativeMenu { impl NativeMenu { pub fn new(backend: Box) -> NativeMenu { - NativeMenu { backend, state: None, clicks: Vec::new(), dirty: true, logged: false } + NativeMenu { backend, state: None, clicks: Vec::new(), opened: false, dirty: true, logged: false } } - /// The key equivalents pressed since the last frame, for [`key_events`]; clicks wait for - /// [`run`]. + /// The key equivalents pressed since the last frame, for [`key_events`]; clicks and menus + /// opened wait for [`run`]. pub fn take_keys(&mut self) -> Vec { let mut keys = Vec::new(); for e in self.backend.drain() { match e { Event::Key(k) => keys.push(k), Event::Click(it) => self.clicks.push(it), + Event::Opened => { + self.opened = true; + continue; + } } self.dirty = true; } @@ -542,10 +551,15 @@ impl NativeMenu { } } -/// Run the items clicked in the native menu since the last frame, like in-window menu clicks. +/// Run the items clicked in the native menu since the last frame, like in-window menu clicks, and +/// read the Scripts folder again when a menu opened. pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) { let picking = app.file_dialog_open(); let Some(menu) = app.services.native_menu.as_mut() else { return }; + if std::mem::take(&mut menu.opened) { + crate::scripts_menu::refresh(app, Some(ctx)); + } + let Some(menu) = app.services.native_menu.as_mut() else { return }; for it in std::mem::take(&mut menu.clicks) { // A click that raced the menu going inert under a file dialog. if picking && !modal_allows(&it) { diff --git a/crates/ui-egui/src/tests/scripts_menu.rs b/crates/ui-egui/src/tests/scripts_menu.rs index 751bd528a6..7cfa5fe2b5 100644 --- a/crates/ui-egui/src/tests/scripts_menu.rs +++ b/crates/ui-egui/src/tests/scripts_menu.rs @@ -353,6 +353,50 @@ fn the_folder_is_read_at_the_first_frame_and_when_the_window_becomes_active() { let _ = std::fs::remove_dir_all(dir); } +/// A macOS menu bar that reports a menu opening at its next drain when `0` is set. +struct Opens(std::rc::Rc>); + +impl native_menu::Backend for Opens { + fn sync(&mut self, _: &native_menu::MenuBar) {} + fn drain(&mut self) -> Vec { + if self.0.replace(false) { vec![native_menu::Event::Opened] } else { Vec::new() } + } +} + +#[test] +fn a_menu_of_the_macos_menu_bar_opening_reads_the_folder_again() { + let dir = folder("native-open"); + let mut app = app(&dir); + write(&dir, "first.js", ""); + read(&app); + let opens = std::rc::Rc::new(std::cell::Cell::new(false)); + app.services.native_menu = Some(native_menu::NativeMenu::new(Box::new(Opens(opens.clone())))); + let ctx = egui::Context::default(); + let mut frame = |opened: bool| { + opens.set(opened); + assert_eq!(app.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys), Some(Vec::new())); + native_menu::run(&mut app, &ctx); + scripts_menu::wait(); + native_menu::layout(&app).bar + }; + let listed = |bar: &native_menu::MenuBar| { + let file = bar.menus.iter().find(|m| m.title == "File").unwrap(); + let scripts = file.children.iter().find_map(|n| match n { + Node::Submenu { label, children } if label == "Scripts" => Some(children.as_slice()), + _ => None, + }); + scripts.unwrap_or_default().iter().filter_map(|n| if let Node::Item(it) = n { Some(it.label.clone()) } else { None }).collect::>() + }; + // While the window stays active and no menu opens, the folder is left alone. + write(&dir, "second.js", ""); + let bar = frame(false); + assert_eq!(listed(&bar)[..1], ["first"], "{:?}", listed(&bar)); + assert!(!listed(&bar).iter().any(|l| l == "second"), "{:?}", listed(&bar)); + let bar = frame(true); + assert_eq!(listed(&bar)[..2], ["first", "second"], "{:?}", listed(&bar)); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_read_asked_for_during_another_runs_when_that_one_ends() { let dir = folder("pending"); diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..6d7629a893 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1898,9 +1898,10 @@ way. The listed scripts come first in the menu, above Check Script… and Other The rest of this section is VectorCraft's own rules: -- The folder is read when the app starts, each time its window becomes the active one, and, where the menus are in - the window (Windows, Linux and BSD), each time the File menu opens. A script added while VectorCraft runs is listed - after the next of these. A read takes names only, never a script's text, and runs off the UI thread, one read at a +- The folder is read when the app starts, each time its window becomes the active one, each time the File menu opens + where the menus are in the window (Windows, Linux and BSD), and each time a menu of the macOS menu bar opens. A + script added while VectorCraft runs is listed after the next of these; in the macOS menu bar it shows the next time + the menu opens. A read takes names only, never a script's text, and runs off the UI thread, one read at a time. - Each level is sorted by label without regard to case, with scripts and subfolders mixed. A script whose label another row of its folder has (`Tidy.js` beside `Tidy.jsx`, or beside a subfolder `Tidy`) shows its whole file name. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..fd85c208dc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -740,7 +740,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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, `#strict on` turns on strict write checks (below), and `#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`). - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. + - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 8e90c833f65d86246c971645920598ea3f1afbba Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:50:12 -0800 Subject: [PATCH 25/83] Scripts: a kept engine's next run starts after the last uncaught exception is dropped Resetting Folder.current for the next run of a named engine now drops the last run's uncaught exception and $.error first, so memory only they held no longer makes the reset fail with the heap limit on every later run. A File an earlier run left open now counts as closed in a later run: setting its encoding stores it for the next open, and reading the encoding gives the stored one. docs/mcp.md: engines stay on the thread that made them, and a #targetengine line in a file $.evalFile runs makes that call throw a SyntaxError when it runs. --- crates/engine/src/tests/script_engines.rs | 8 +++++--- crates/js/src/file_classes.rs | 25 +++++++++++++++-------- crates/js/src/host_tests.rs | 19 +++++++++++++++++ crates/js/src/realm.rs | 7 ++++++- docs/mcp.md | 8 +++++--- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 41af473004..5506cb98d1 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -151,7 +151,8 @@ fn a_kept_eval_file_reads_in_the_calling_run_scope() { } /// `File` and `Folder` objects an earlier run kept read in the folder of the run that calls them, -/// `Folder.current` starts at each run's script, and a file an earlier run left open is not open. +/// `Folder.current` starts at each run's script, and a file an earlier run left open is not open: +/// it takes the encoding it is given, as a closed file does. #[test] fn kept_file_objects_read_in_the_calling_run_folder() { let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-files-{}", std::process::id())); @@ -165,11 +166,12 @@ fn kept_file_objects_read_in_the_calling_run_folder() { std::fs::write(&a, "#targetengine e\nvar keep = new File('data.txt');\nkeep.open('r');\nvc.log([Folder.current.name, keep.read(1)]);").unwrap(); assert_eq!(s.execute("script.run", &json!({"path": a.to_string_lossy()})).unwrap()["log"], json!([["a", "A"]])); let b = base.join("b").join("main.jsx"); - let text = "#targetengine e\nvar g = new File('data.txt');\ng.open('r');\nvc.log([Folder.current.name, g.read(), keep.read(), keep.error, keep.exists]);"; + let text = "#targetengine e\nvar g = new File('data.txt');\ng.open('r');\nvc.log([Folder.current.name, g.read(), keep.read(), keep.error, keep.exists]);\n\ + var before = keep.encoding;\nkeep.encoding = 'BINARY';\nkeep.open('r');\nvc.log([before, keep.encoding]);"; std::fs::write(&b, text).unwrap(); let log = s.execute("script.run", &json!({"path": b.to_string_lossy()})).unwrap()["log"].clone(); let _ = std::fs::remove_dir_all(base); - assert_eq!(log, json!([["b", "B", "", "Script file is not open", false]])); + assert_eq!(log, json!([["b", "B", "", "Script file is not open", false], ["UTF-8", "BINARY"]])); } #[test] diff --git a/crates/js/src/file_classes.rs b/crates/js/src/file_classes.rs index 4eba59a90f..715834fd52 100644 --- a/crates/js/src/file_classes.rs +++ b/crates/js/src/file_classes.rs @@ -31,6 +31,8 @@ pub(crate) struct Classes { folder_proto: ObjId, host: ObjId, current: Value, + /// The count of [`reset_current`] calls: a file is open only in the run that opened it. + run: f64, } impl Classes { @@ -43,6 +45,7 @@ impl Classes { const PATH: &str = "__vcPath"; const ERROR: &str = "__vcError"; const HANDLE: &str = "__vcHandle"; +const RUN: &str = "__vcRun"; const ENCODING: &str = "__vcEncoding"; const LINE_FEED: &str = "__vcLineFeed"; @@ -132,12 +135,13 @@ fn parsed(i: &mut Interp, this: &Value, what: &str, name: &str) -> JsResult JsResult { let line_feed = host_value(i, "lineFeed")?; i.rooted(&[fs.clone(), line_feed.clone()], |i| { - i.reserve_heap(OBJECT_BYTES + 5 * PROP_BYTES)?; + i.reserve_heap(OBJECT_BYTES + 6 * PROP_BYTES)?; let o = i.heap.alloc_object(Object::new(Class::Ordinary, Some(proto))); i.rooted(&[Value::Object(o)], |i| { set_slot(i, o, PATH, fs.clone())?; set_slot(i, o, ERROR, Value::str(""))?; set_slot(i, o, HANDLE, Value::Number(-1.0))?; + set_slot(i, o, RUN, Value::Number(-1.0))?; set_slot(i, o, ENCODING, Value::str(""))?; set_slot(i, o, LINE_FEED, line_feed.clone())?; Ok(Value::Object(o)) @@ -189,8 +193,7 @@ fn text_result(i: &mut Interp, o: ObjId, r: Value, failed: Value) -> JsResult JsResult> { - let h = slot(i, o, HANDLE)?; - if matches!(h, Value::Number(n) if n >= 0.0) { + if let Some(h) = is_open(i, o)? { return Ok(Some(h)); } let m = message(i, "notOpen")?; @@ -198,9 +201,11 @@ fn opened(i: &mut Interp, o: ObjId) -> JsResult> { Ok(None) } +/// The id of the object's open file, when this run opened it. fn is_open(i: &Interp, o: ObjId) -> JsResult> { let h = slot(i, o, HANDLE)?; - Ok(matches!(h, Value::Number(n) if n >= 0.0).then_some(h)) + let this_run = matches!(slot(i, o, RUN)?, Value::Number(n) if n == classes(i)?.run); + Ok((this_run && matches!(h, Value::Number(n) if n >= 0.0)).then_some(h)) } /// Closes the object's open file, if it has one. @@ -528,6 +533,8 @@ fn open(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult return Ok(Value::Bool(false)); } set_slot(i, o, HANDLE, id)?; + let run = classes(i)?.run; + set_slot(i, o, RUN, Value::Number(run))?; Ok(Value::Bool(true)) } @@ -759,11 +766,11 @@ fn folder_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { Ok(()) } -/// Sets `Folder.current` back to the host's `current`, when the realm has `File` and `Folder`. +/// Sets `Folder.current` back to the host's `current`, when the realm has `File` and `Folder`, and +/// begins a new run, in which the files earlier runs opened are not open. pub(crate) fn reset_current(i: &mut Interp) -> JsResult<()> { - if i.files.is_none() { - return Ok(()); - } + let Some(c) = i.files.as_mut() else { return Ok(()) }; + c.run += 1.0; let start = host(i, "current", &[])?; if let Some(c) = i.files.as_mut() { c.current = start; @@ -780,7 +787,7 @@ pub(crate) fn install(i: &mut Interp, items: Vec<(String, HostItem)>) -> JsResul i.reserve_heap(2 * OBJECT_BYTES)?; let file_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); let folder_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); - i.files = Some(Classes { file_proto, folder_proto, host: host_obj, current: Value::str("") }); + i.files = Some(Classes { file_proto, folder_proto, host: host_obj, current: Value::str(""), run: 0.0 }); let start = host(i, "current", &[])?; if let Some(c) = i.files.as_mut() { c.current = start; diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index d3c8a7a461..6fc6c4e04f 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -75,6 +75,25 @@ fn an_ended_run_leaves_the_realm_ready_for_the_next() { r.end(); } +/// Setting `Folder.current` back for the next run drops what only the last run's uncaught +/// exception held, so a full heap from that exception does not stop the next run. +#[test] +fn resetting_folder_current_drops_the_last_uncaught_exception() { + let mut r = realm(10_000_000); + let len = Rc::new(Cell::new(4)); + let n = len.clone(); + let current: HostFunction = Rc::new(move |_| Ok(Data::String("d".repeat(n.get())))); + r.define_file_classes(vec![("current".into(), HostItem::Function(current))]).unwrap(); + assert!(r.run("throw (function () { var a = []; for (var i = 0; i < 2000; i++) a.push('item ' + i); return a; })();").is_err()); + let interp = r.interp(); + interp.collect_garbage(); + interp.limits.heap_bytes = interp.heap.bytes; + len.set(200); + assert_eq!(r.reset_folder_current(), Ok(())); + r.set_limits(Limits::default()); + assert_eq!(r.run("typeof Folder"), Ok(JsValue::String("function".into()))); +} + #[test] fn the_fuel_limit_covers_every_slice_together() { let mut r = realm(5_000); diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 9d514e5b4d..f274c42ead 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -436,8 +436,13 @@ impl Realm { } /// Sets `Folder.current` back to the host's `current`, for the next run of a realm that has - /// `File` and `Folder` ([`Realm::define_file_classes`]); a realm without them is left as it is. + /// `File` and `Folder` ([`Realm::define_file_classes`]); a realm without them keeps its + /// `Folder.current`. The files an earlier run opened are no longer open. The last run's uncaught + /// exception and `$.error` are dropped first, as [`Realm::start`] drops them, so memory that only + /// they held is free for the reset. pub fn reset_folder_current(&mut self) -> Result<(), RunError> { + self.interp.last_thrown = Value::Undefined; + self.interp.last_error = Value::Null; match crate::file_classes::reset_current(&mut self.interp) { Ok(()) => Ok(()), Err(e) => self.outcome(Err(e)).map(drop), diff --git a/docs/mcp.md b/docs/mcp.md index 3a2c247178..c48bcd8eb0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1723,14 +1723,16 @@ has unsaved changes. ### Script engines A named engine (`#targetengine`) keeps its globals and functions between the runs that name it, until it is dropped -or the session ends. These are VectorCraft's own rules: +or the session ends. Engines stay on the thread that made them: a session used on another thread starts there with +no engines, and the engines it left are dropped when their own thread next uses engines. These are VectorCraft's own rules: - The name is either a name of ASCII letters, digits and `_` that does not start with a digit, or a name in single or double quotes of 1 to 64 bytes of UTF-8 without control characters. Names are matched as written: `Session` and `session` are two engines. `main` is the engine of a script without the line, which is new for each run. - Only the script itself has the line, once, before its code and its `#include` lines; blanks, comments and the other - preprocessor lines may come before it. A second line, a late line, a bad name, or the line in an included file or a - file `$.evalFile` runs ends the run at that line before any of it runs, and makes no engine. + preprocessor lines may come before it. A second line, a late line, a bad name, or the line in an included file ends + the run at that line before any of it runs, and makes no engine. In a file `$.evalFile` runs, the line makes that + call throw a `SyntaxError` when the call runs. - A session keeps at most 8 named engines. A run that names a ninth ends with a limit error before any of it runs, and the other eight are kept. Each named engine has a heap of 64 MiB, which holds what it keeps between runs; a run that goes past it ends with the heap limit, and the engine stays usable. From 0bf35ac543c3c997490cf7dbacde242244223349 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:50:10 -0800 Subject: [PATCH 26/83] Docs: each progress line appears once in the shipped-features evidence list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Evidence: what ships today, by area" list in docs/target-app-parity.md carried three lines twice, each copy byte-identical: Window › Arrange › Tile, the renderer's path cache for moved anchors, and the plug-in instruction budget at a bulk memory instruction. One copy of each is removed. The budget line stays next to the bulk memory fuzz entry; the other two stay at their first place. --- docs/target-app-parity.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..498a2cc08d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -660,7 +660,6 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Fade Region Outside Artboard:** Artboard Options' Global checkboxes Fade Region Outside Artboard and Update While Dragging ([user guide, Artboard options](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)) are application preferences (`fadeRegionOutsideArtboard`, `artboardUpdateWhileDragging`), both on by default. While the Artboard tool is active the canvas draws everything outside the artboards, the pasteboard and the art on it, with each color channel at 60% of its value (VectorCraft's own amount), or only outside the box around all the artboards when crossing artboards would leave more than 4,096 rectangles in view; with Update While Dragging off it draws as usual during an artboard move or resize. Presentation Mode, exports and print do not change. Regression tests: `canvas::tests` (`the_artboard_tool_fades_the_pasteboard`, `presentation_mode_draws_no_fade`, `the_fade_darkens_art_on_the_pasteboard_and_not_on_the_artboard`, `outside_rects_cover_the_bounds_minus_the_boards`, `outside_rects_of_a_row_of_boards_stay_few`, `the_fade_of_crossing_boards_stays_bounded`, `update_while_dragging_keeps_or_drops_the_fade_during_an_artboard_drag`, `the_pasteboard_fade_leaves_exports_unchanged`), `dialogs/tests.rs` (`artboard_options_global_checkboxes_set_the_preferences`, `artboard_options_error_leaves_the_global_preferences_unchanged`). - **Saved views (G7):** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): up to 25 views per document, saved with it, renamed or deleted in Edit Views, chosen from the bottom of the View menu). Each view keeps the zoom, centre and rotation; the View menu lists all 25 (`view.goto1`–`view.goto25`), and Edit Views… lists them to rename or delete, applied together as one undo step. A file's views are checked when it opens (at most 25, unique names up to 255 characters, a finite centre and a positive zoom). Tests: `saved_views_round_trip_and_from_a_file_are_tidied` (`crates/format/tests/prop_format.rs`), `crates/engine/src/cmd/views.rs`, `crates/ui-egui/src/dialogs/edit_views.rs` and `every_saved_view_is_listed_at_the_bottom_of_the_view_menu` (`crates/ui-egui/src/menus.rs`). - **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. -- **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. - **Window › Arrange › Float in Window, Float All in Windows and Cascade (G7):** documents float in windows of their own, out of the tab strip ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). Float in Window (`window.arrange.floatInWindow`) floats the current document, Float All in Windows (`window.arrange.floatAllInWindows`) every tabbed one, and Cascade (`window.arrange.cascade`) floats every document and stacks the windows from the upper left to the lower right ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). On the desktop each window is an OS window; on the web and on Wayland it is a window inside the app's window. The window of the current document holds the canvas; the others draw their documents as Tile's panes do, and a press in one makes its document the current one. Closing a window closes its document, asking to save unsaved changes. Consolidate All Windows brings every document back under the tabs, and Tile brings them back into its grid. The cascade's 30 pt step, the windows' size and when the items are enabled (Cascade with two documents or more, Float in Window for a tabbed current document, Float All in Windows while a document is tabbed, Consolidate All Windows while documents are tiled or floating) are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`. @@ -1548,7 +1547,6 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). - The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). When an edit changes a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve), the canvas draws again where its art was and is, measured by that art: type and groups were measured by their letters or members, so a shallower or turned solid left pieces of the old one on the canvas. When the canvas draws the part on its own thread, it measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type, where they cropped it to the letters, and Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once unless the object has brushed strokes: 30 extruded type objects measured over 30 artboards take about 2 ms in a release build, where measuring their letters took under 1 ms. A document's first frame builds every 3D object's solid, off the view too, to know whether it reaches into the view. When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times). Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million. @@ -1644,7 +1642,6 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.rs`. Scores and hours unchanged. - **Importer plug-ins (G17):** a WebAssembly plug-in of kind `importer` adds a read-only file format, as [the help page](https://ai-sdk-2020.84.nz/html/struct_a_i_file_format_suite) describes for file format plug-ins that read: File › Open lists its files under "All readable files" and its own filter, and File › Open With lists each importer by name. `document.open` uses it for a file whose extension no built-in format reads, by content or extension (the importer whose id sorts first, when several list the extension), or the one `importer` names, also for an extension a built-in format reads; a damaged file of a built-in format reports that format's error. The plug-in is given at most 128 MiB of the file and `_context` (`mode: "import"`, `extension`, `name`), and returns paths and compound paths in the filters' object JSON, checked as a filter's output is; they become a new document whose artboard is their bounds, which Save asks a name for. A decline, invalid output, a trap or Cancel opens nothing. The routing rule, the limit and Open With are VectorCraft's own. Regressions: `crates/engine/tests/plugin_import.rs`, `crates/plugins/src/import.rs`, `crates/ui-egui/src/dialogs/tests_plugin_import.rs`. Scores and hours unchanged. -- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged. From fc767dad097a60648b8dc58089e0c4d8d070213e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:53:20 -0800 Subject: [PATCH 27/83] Type: Dynamic Text sets each line of area type to the frame width Type > Dynamic Text (type.dynamicText) now also works on area type as Block. Each line as typed (each paragraph, and each forced line break in one) is set to the frame's width, less the inset, the column gutters and the paragraph's indents, by changing its font size only. Lines are not broken again. The first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, and the line's other characters keep their proportions. The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step. - Word Gap (0 to 1,000 % of a word space, default 100) sets every paragraph's Desired word spacing; Line Gap (-1,296 to 1,296 pt, default 0) is added to each character's size for its leading. Both are VectorCraft's own ranges and defaults, saved in the area options only when they differ from the defaults. - Area Type Options gains a Block fit (text.areaOptions fit "block", with wordGap and lineGap), and the Properties panel's Paragraph section shows a Dynamic Text checkbox with Word Gap and Line Gap rows. - Block replaces Auto Size and Shrink Text to Fit. Off keeps the sizes. Threaded area type is refused, as for type on a path. - Empty lines keep their size, a frame with no width changes nothing, vertical type fits each line to the frame's height, and right-to-left lines and list markers are fitted like other lines. A refit runs at most four extra layouts. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/text.rs | 11 +- crates/doc/src/text/area.rs | 25 ++ crates/engine/src/cmd/create.rs | 17 +- crates/engine/src/cmd/textedit.rs | 4 +- crates/engine/src/cmd/typecmd.rs | 102 +++-- crates/engine/src/cmd/typecmd/block.rs | 309 ++++++++++++++ crates/engine/src/cmd/typecmd/block_tests.rs | 385 ++++++++++++++++++ crates/engine/src/cmd/typemenu.rs | 115 ++++-- crates/engine/src/inspect.rs | 7 +- crates/engine/src/tests/dynamic_text.rs | 10 +- crates/ui-egui/src/dialogs/command.rs | 6 +- crates/ui-egui/src/i18n/hi.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/menus/state.rs | 18 +- crates/ui-egui/src/menus/tests.rs | 11 +- crates/ui-egui/src/panels/properties.rs | 3 + .../src/panels/properties/dynamic_text.rs | 84 ++++ crates/ui-egui/src/panels/properties/tests.rs | 35 ++ docs/mcp.md | 55 ++- docs/target-app-parity.md | 1 + 20 files changed, 1100 insertions(+), 102 deletions(-) create mode 100644 crates/engine/src/cmd/typecmd/block.rs create mode 100644 crates/engine/src/cmd/typecmd/block_tests.rs create mode 100644 crates/ui-egui/src/panels/properties/dynamic_text.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0be38d261e..05f9c29041 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -77,10 +77,13 @@ pub struct TextObject { /// them up round the inside. #[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")] pub path_spacing: f64, - /// Type › Dynamic Text (type on a path only): the layout scales the text by one factor so it - /// fills the span between its start and end brackets, and fits it again after each edit. The - /// stored sizes keep their proportions; the layout's `fit_scale` is the factor drawn. Off for new - /// type and for documents from before it, and saved only when on. + /// Type › Dynamic Text. On type on a path, the layout scales the text by one factor so it + /// fills the span between its start and end brackets, and fits it again after each edit; the + /// stored sizes keep their proportions, and the layout's `fit_scale` is the factor drawn. On + /// area type it is Block: after each edit the engine sets each line as typed to the frame's + /// width by its font size and writes the sizes into the characters, with the gaps of + /// [`AreaOptions::word_gap`] and [`AreaOptions::line_gap`]. Point type does not use it. Off for + /// new type and for documents from before it, and saved only when on. #[serde(default, rename = "dynamicText", skip_serializing_if = "crate::skip::is_default")] pub dynamic_text: bool, /// Wrap objects above this area type, resolved by the engine after each edit (text space). diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 7bb742f483..1901a23c8a 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -167,6 +167,14 @@ pub struct AreaOptions { /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). #[serde(skip_serializing_if = "crate::skip::is_default")] pub fit: AreaFit, + /// Dynamic Text's Block ([`super::TextObject::dynamic_text`] on area type): every paragraph's + /// Desired word spacing, in percent of a word space (100, the default, is left out when saved). + #[serde(skip_serializing_if = "AreaOptions::is_default_word_gap")] + pub word_gap: f64, + /// Dynamic Text's Block: the points added to each character's size to give its leading (0, the + /// default, is left out when saved). + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub line_gap: f64, } impl Default for AreaOptions { @@ -182,6 +190,8 @@ impl Default for AreaOptions { vertical_align: VerticalAlign::Top, flow: TextFlow::Columns, fit: AreaFit::None, + word_gap: Self::DEFAULT_WORD_GAP, + line_gap: 0.0, } } } @@ -189,6 +199,21 @@ impl Default for AreaOptions { impl AreaOptions { /// The most rows, and the most columns, area type can have. pub const MAX_ROWS_COLUMNS: usize = 100; + /// Block's word gap range (percent of a word space), the Justification word spacing range. + pub const WORD_GAP: (f64, f64) = (0.0, 1000.0); + /// Block's word gap new type starts with: the font's own word space. + pub const DEFAULT_WORD_GAP: f64 = 100.0; + /// Block's line gap range in points. + pub const LINE_GAP: (f64, f64) = (-1296.0, 1296.0); + fn is_default_word_gap(v: &f64) -> bool { + *v == Self::DEFAULT_WORD_GAP + } + /// Block's gaps kept within their ranges; a gap that is not a number gives its default. + pub fn block_gaps(&self) -> (f64, f64) { + let word = if self.word_gap.is_finite() { self.word_gap.clamp(Self::WORD_GAP.0, Self::WORD_GAP.1) } else { Self::DEFAULT_WORD_GAP }; + let line = if self.line_gap.is_finite() { self.line_gap.clamp(Self::LINE_GAP.0, Self::LINE_GAP.1) } else { 0.0 }; + (word, line) + } } /// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 973c82af53..f265c3ff3d 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -752,7 +752,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let w = f64_or(a, "width", 200.0); let h = f64_or(a, "height", 100.0); t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) }; - t.area.fit = new_area_fit(s, a, "text.create")?; + new_area_fit(s, a, "text.create", &mut t)?; } t.system_layout = s.doc()?.doc.setup.system_layout; if bool_or(p, "placeholder", false) { @@ -827,11 +827,16 @@ pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut Ok(()) } -/// The fit new area type gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes -/// them), else Auto Size when the "Auto Size New Area Type" preference is on. -pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result { +/// The fit new area type `t` gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes +/// them, `block` turning Dynamic Text on), else Auto Size when the "Auto Size New Area Type" +/// preference is on; and Block's `wordGap` and `lineGap`. +pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) -> Result<()> { let default = if s.prefs.auto_size_area_type { vectorcraft_doc::AreaFit::AutoHeight } else { vectorcraft_doc::AreaFit::None }; - Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default)) + let (word_gap, line_gap) = super::typecmd::block_gaps(p, c)?; + (t.area.fit, t.dynamic_text) = super::typecmd::fit_param(p, default, false, c)?.unwrap_or((default, false)); + t.area.word_gap = word_gap.unwrap_or(t.area.word_gap); + t.area.line_gap = line_gap.unwrap_or(t.area.line_gap); + Ok(()) } /// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 21ed6d8023..00f1dd8adb 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -1637,7 +1637,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { }; super::create::new_type_alignment(s, p, C, &mut t)?; if !on_path { - t.area.fit = super::create::new_area_fit(s, p, C)?; + super::create::new_area_fit(s, p, C, &mut t)?; } t.system_layout = s.doc()?.doc.setup.system_layout; if bool_or(p, "placeholder", false) { diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6d29550fa0..33dad39d93 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Area Type Options…", ["Type"], None, - "{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options (each frame takes the options given over its own and keeps the rest; fit resolves against each frame's own; options that change nothing add no undo step); width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", + "{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText|block, fitMinPercent?: 10..100, wordGap?: % 0..1000, lineGap?: pt -1296..1296, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options (each frame takes the options given over its own and keeps the rest; fit resolves against each frame's own; options that change nothing add no undo step); width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit; fit block is Dynamic Text's Block, as type.dynamicText sets it (in place of Auto Size and Shrink Text to Fit; threaded frames are refused; another fit turns it off, keeping the sizes it wrote), and wordGap and lineGap set its gaps (kept while Block is off; out of range is an error) (none given: query) → the first object's options, plus wordGap and lineGap, overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", has_selection, area_options ), @@ -304,11 +304,13 @@ fn ruby(s: &mut Session, p: &Value) -> Result { /// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing /// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type -/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo -/// step as the edit. +/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text, and Dynamic Text's Block +/// (`dynamic_text` on area type, [`block::block_fit`]) sizes each line to the frame: part of the +/// same undo step as the edit. pub(crate) fn refresh_bounds(t: &mut TextObject) { // Edits that changed the paragraph count keep one paragraph style per paragraph. t.normalize_paras(); + block::block_fit(t); auto_height(t); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); @@ -320,6 +322,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { /// ([`vectorcraft_text::take_kept_layout`]) instead of laying the object out again. pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { t.normalize_paras(); + block::block_fit(t); auto_height(t); let stamp = vectorcraft_text::LayoutStamp::now(); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); @@ -1026,7 +1029,7 @@ pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObje } /// Area type's text, if `n` is area type. -fn area_text(n: Option<&Node>) -> Option<&TextObject> { +pub(crate) fn area_text(n: Option<&Node>) -> Option<&TextObject> { match n.map(|n| &n.kind) { Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t), _ => None, @@ -1059,10 +1062,15 @@ fn size_area(t: &mut TextObject, w: Option, h: Option) -> bool { reshape_area_with(t, |t| t.transform_area(a)) } -/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText or `{"shrinkText": {"minPercent"}}`, -/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent` -/// while the fit isn't Shrink Text). -pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result> { +/// A fit `p` asks for: the Area Type Options fit, and whether Dynamic Text's Block +/// ([`TextObject::dynamic_text`]) is on. Block's fit is [`AreaFit::None`]: it replaces Auto Size +/// and Shrink Text to Fit. +pub(crate) type FitChoice = (AreaFit, bool); + +/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText|block or `{"shrinkText": {"minPercent"}}`, +/// `fitMinPercent`), starting from `cur` (`block`: Block is on). None when `p` sets neither (or only +/// `fitMinPercent` while the fit isn't Shrink Text). +pub(crate) fn fit_param(p: &Value, cur: AreaFit, block: bool, c: &str) -> Result> { check_numbers(p, &["fitMinPercent"], c)?; let min = p.get("fitMinPercent").and_then(Value::as_f64); let cur_min = match cur { @@ -1071,21 +1079,48 @@ pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result match (cur, min) { - (AreaFit::ShrinkText { .. }, Some(m)) => AreaFit::ShrinkText { min_percent: m }, + (AreaFit::ShrinkText { .. }, Some(m)) if !block => AreaFit::ShrinkText { min_percent: m }, _ => return Ok(None), }, + Some(Value::String(id)) if is_block_id(id) => return Ok(Some((AreaFit::None, true))), Some(Value::String(id)) => { - AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText, got {id}")))? + AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText|block, got {id}")))? } Some(v) => match serde_json::from_value::(v.clone()).map_err(|e| bad(c, format!("bad fit: {e}")))? { AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: min.unwrap_or(min_percent) }, f => f, }, }; - Ok(Some(match fit { - AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) }, - f => f, - })) + Ok(Some(( + match fit { + AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) }, + f => f, + }, + false, + ))) +} + +/// Whether a `fit` id names Dynamic Text's Block (case, spaces, dashes and underscores ignored). +fn is_block_id(id: &str) -> bool { + matches!(id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str(), "block" | "dynamictext") +} + +/// The `fit` id area type reports: `block` while Dynamic Text's Block is on, else its fit's. +pub(crate) fn fit_id(t: &TextObject) -> &'static str { + if t.dynamic_text { "block" } else { t.area.fit.id() } +} + +/// Dynamic Text's Block gaps `p` gives: `wordGap` (% of a word space, 0..1000) and `lineGap` (pt, +/// -1296..1296). A value that is not a number, or is outside its range, is an error. +pub(crate) fn block_gaps(p: &Value, c: &str) -> Result<(Option, Option)> { + let gap = |key: &str, (lo, hi): (f64, f64)| -> Result> { + match number_given(p, key, c)? { + Some(v) if !(v.is_finite() && (lo..=hi).contains(&v)) => Err(bad(c, format!("`{key}` must be from {lo} to {hi}, not {v}"))), + v => Ok(v), + } + }; + use vectorcraft_doc::AreaOptions as O; + Ok((gap("wordGap", O::WORD_GAP)?, gap("lineGap", O::LINE_GAP)?)) } /// Area type's options as `text.areaOptions` reports them: the Area Type Options with `fit` as @@ -1095,7 +1130,7 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result { let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); if let Some(o) = v.as_object_mut() { - o.insert("fit".into(), json!(t.area.fit.id())); + o.insert("fit".into(), json!(fit_id(t))); o.insert("flow".into(), json!(t.area.flow)); // The gutter between rows, the gutter's when the rows have none of their own. o.insert("rowGutter".into(), json!(t.area.row_gutter.unwrap_or(t.area.gutter))); @@ -1104,6 +1139,9 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result { _ => AreaFit::DEFAULT_MIN_PERCENT, }; o.insert("fitMinPercent".into(), json!(min)); + let (word_gap, line_gap) = t.area.block_gaps(); + o.insert("wordGap".into(), json!(word_gap)); + o.insert("lineGap".into(), json!(line_gap)); if let Some((w, h, _)) = area_size(t) { o.insert("width".into(), json!(w)); o.insert("height".into(), json!(h)); @@ -1132,8 +1170,9 @@ fn area_options(s: &mut Session, p: &Value) -> Result { // Checked once up front (its errors don't depend on the frame); each frame resolves `fit` // against its own setting below. let cur = area_text(s.doc()?.doc.node(first)).map(|t| t.area.clone()).unwrap_or_default(); - fit_param(p, cur.fit, C)?; - let fit_given = p.get("fit").is_some_and(|v| !v.is_null()) || p.get("fitMinPercent").is_some_and(|v| !v.is_null()); + fit_param(p, cur.fit, false, C)?; + let (word_gap, line_gap) = block_gaps(p, C)?; + let fit_given = ["fit", "fitMinPercent", "wordGap", "lineGap"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())); let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(1.0, 100_000.0)); let (w, h) = (size("width"), size("height")); let given: Vec<(&String, &Value)> = p.as_object().into_iter().flatten().filter(|(k, _)| AREA_KEYS.contains(&k.as_str())).collect(); @@ -1142,7 +1181,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result { } // Each area type takes the options given over its own (#3759): setting one option on several // frames leaves their other options as they were. - let merged = |own: &vectorcraft_doc::AreaOptions| -> Result { + let merged = |own: &vectorcraft_doc::AreaOptions, block: bool| -> Result<(vectorcraft_doc::AreaOptions, bool)> { let mut v = serde_json::to_value(own).map_err(|e| EngineError::Other(e.to_string()))?; if let Some(o) = v.as_object_mut() { for (k, val) in &given { @@ -1162,17 +1201,29 @@ fn area_options(s: &mut Session, p: &Value) -> Result { opts.inset = finite(opts.inset).clamp(0.0, 10_000.0); opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0); // Each frame's fit from its own (`fitMinPercent` alone changes only Shrink Text frames). - opts.fit = fit_param(p, own.fit, C)?.unwrap_or(own.fit); - Ok(opts) + let (fit, block) = fit_param(p, own.fit, block, C)?.unwrap_or((own.fit, block)); + opts.fit = fit; + opts.word_gap = word_gap.unwrap_or(own.word_gap); + opts.line_gap = line_gap.unwrap_or(own.line_gap); + Ok((opts, block)) }; let d = &s.doc()?.doc; - let planned: Vec<(NodeId, vectorcraft_doc::AreaOptions)> = ids + let planned: Vec<(NodeId, (vectorcraft_doc::AreaOptions, bool))> = ids .iter() - .filter_map(|id| area_text(d.node(*id)).map(|t| (*id, &t.area))) - .map(|(id, own)| merged(own).map(|o| (id, o))) + .filter_map(|id| area_text(d.node(*id)).map(|t| (*id, t))) + .map(|(id, t)| merged(&t.area, t.dynamic_text).map(|o| (id, o))) .collect::>()?; - let apply = |t: &mut TextObject, opts: &vectorcraft_doc::AreaOptions| { + // Dynamic Text's Block is refused for threaded frames, as `type.dynamicText` refuses it. + let frames: Vec = + planned.iter().filter(|(id, (_, block))| *block && !super::threads::story_of(d, *id).is_empty()).map(|(id, _)| id.0.to_string()).collect(); + if !frames.is_empty() { + let frames = frames.join(", "); + return Err(bad(C, format!("area type in a thread cannot use Dynamic Text: {frames}"))); + } + let apply = |t: &mut TextObject, (opts, block): &(vectorcraft_doc::AreaOptions, bool)| { t.area = opts.clone(); + // Block off keeps the sizes it wrote into the characters. + t.dynamic_text = *block; // Auto Size sets the height (Illustrator's dialog turns the Height field off). let h = if opts.fit == AreaFit::AutoHeight { None } else { h }; // A resized frame has laid its text out again; otherwise the new options do here. @@ -1313,6 +1364,9 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod area_tests; +mod block; +#[cfg(test)] +mod block_tests; /// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. /// The third value is where the first line ends in the plain text. diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs new file mode 100644 index 0000000000..a78f5254cb --- /dev/null +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -0,0 +1,309 @@ +//! Dynamic Text's Block for area type ([`TextObject::dynamic_text`] on area type): each line as +//! typed is set to the frame's width by changing its font size, after every edit +//! ([`super::refresh_bounds`]). + +use vectorcraft_doc::{TextKind, TextObject, TextRun}; + +/// The font size range a line's first character is fitted within, in document points (the +/// Character panel's). +const SIZES: (f64, f64) = (0.1, 1296.0); + +/// Most area layouts a refit runs to correct lines that still wrap after the first fit. +pub(crate) const BLOCK_PASSES: usize = 4; + +/// One line as typed: `start..content` is its text, `content..end` the `\n` or U+2028 that ends it +/// (empty for the last line). `para` is the index of its paragraph, and `para_start` whether it +/// starts that paragraph. +#[derive(Clone, Copy, Debug)] +struct TypedLine { + start: usize, + content: usize, + end: usize, + para: usize, + para_start: bool, +} + +/// The lines as typed: the text split after each `\n` and U+2028. +fn typed_lines(text: &str) -> Vec { + let mut out = vec![]; + let (mut start, mut para, mut para_start) = (0, 0, true); + for (i, c) in text.char_indices() { + if c == '\n' || c == '\u{2028}' { + let end = i + c.len_utf8(); + out.push(TypedLine { start, content: i, end, para, para_start }); + start = end; + para_start = c == '\n'; + if c == '\n' { + para += 1; + } + } + } + out.push(TypedLine { start, content: text.len(), end: text.len(), para, para_start }); + out +} + +/// The stored size of the character at `byte` (the run that holds it, not the one before it). +pub(crate) fn size_at(runs: &[TextRun], byte: usize) -> f64 { + let mut off = 0; + for r in runs { + let end = off + r.text.len(); + if (off..end).contains(&byte) { + return r.style.size; + } + off = end; + } + vectorcraft_text::edit::style_at(runs, byte).size +} + +/// The stored size of each line's first character, in one pass over the runs. +fn first_sizes(runs: &[TextRun], lines: &[TypedLine]) -> Vec { + let mut out = Vec::with_capacity(lines.len()); + let (mut ri, mut off) = (0, 0); + for l in lines { + while let Some(r) = runs.get(ri) { + if l.start < off + r.text.len() { + break; + } + off += r.text.len(); + ri += 1; + } + out.push(runs.get(ri).map_or_else(|| size_at(runs, l.start), |r| r.style.size)); + } + out +} + +/// Each line's content width (trailing spaces left out) by its first byte, with `t` laid out as +/// point type, where no line wraps. +fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collections::HashMap { + let mut probe = t.clone(); + probe.kind = TextKind::Point; + let laid = vectorcraft_text::layout(db, &probe); + let mut out = std::collections::HashMap::with_capacity(laid.lines.len()); + for m in &laid.lines { + out.entry(m.start).or_insert(m.x1 - m.x0); + } + out +} + +/// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing +/// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down +/// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its +/// size rounded down to tenths, and None when that is its size, so fitting again changes nothing. +/// Room taken entirely by indents gives the smallest size. +fn refit(size: f64, width: f64, avail: f64, slope: f64) -> Option { + if width <= avail + 1e-6 && width + 0.1 * slope > avail { + let rounded = tenths(size); + return ((rounded - size).abs() > 1e-9).then_some(rounded); + } + Some(tenths(size + (avail.max(0.0) - width) / slope)) +} + +/// A size in document points rounded down to a whole number of tenths and kept within [`SIZES`]. +fn tenths(points: f64) -> f64 { + ((points * 10.0 + 1e-6).floor() / 10.0).clamp(SIZES.0, SIZES.1) +} + +/// Fit area type with Dynamic Text (Block) on: every paragraph's Desired word spacing becomes the word gap, +/// then each line as typed gets the size (its first character's, in steps of 0.1 pt within 0.1 to +/// 1,296 pt, the line's other characters keeping their proportions to it) at which its width +/// equals the frame's width less the inset, the gutters and the paragraph's indents, and each +/// character's leading is its size plus the line gap. An empty line or one of only spaces keeps +/// its size. Nothing changes when the frame has no width along the lines or the type's transform +/// has no size. A line that still wraps at its first fit is made smaller, in at most +/// [`BLOCK_PASSES`] more layouts. True when the text changed. +pub(crate) fn block_fit(t: &mut TextObject) -> bool { + if !t.dynamic_text { + return false; + } + let TextKind::Area { frame } = &t.kind else { return false }; + let (word_gap, line_gap) = t.area.block_gaps(); + let Some(b) = frame.bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return false }; + let Some(points) = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12) else { return false }; + let before = (t.runs.clone(), t.para.clone(), t.paras.clone()); + + // The word gap first: it changes the lines' widths. + let mut paras = t.paragraph_styles(); + for p in &mut paras { + let [min, _, max] = p.justification.word_spacing; + p.justification.word_spacing = [min.min(word_gap), word_gap, max.max(word_gap)]; + } + if paras != t.paragraph_styles() { + t.set_paragraph_styles(paras); + } + + // The room along a line in one cell of the frame, before indents. + let a = &t.area; + let (along, cells, gutter) = if t.vertical { (b.height(), a.rows, a.row_gutter.unwrap_or(a.gutter)) } else { (b.width(), a.columns, a.gutter) }; + let cells = cells.max(1) as f64; + let cell = (along - 2.0 * a.inset.max(0.0) - gutter.max(0.0) * (cells - 1.0)) / cells; + if !(cell.is_finite() && cell > 1e-9) { + return (t.runs.clone(), t.para.clone(), t.paras.clone()) != before; + } + + let db = vectorcraft_text::FontDb::global(); + let text = t.plain_text(); + let lines = typed_lines(&text); + let widths = line_widths(db, t); + let sizes = first_sizes(&t.runs, &lines); + // Per line: (factor applied to its sizes, the room it fills, the last size measured and its + // width), or None to keep its size. + let mut fits: Vec> = lines + .iter() + .zip(&sizes) + .map(|(l, size)| { + if l.content == l.start { + return None; + } + let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; + let para = t.para_at(l.para); + let first = if l.para_start { para.first_line_indent } else { 0.0 }; + let avail = cell - para.left_indent - para.right_indent - first; + let size = size * points; + if !(size.is_finite() && size > 0.0) { + return None; + } + Some((refit(size, width, avail, width / size).map_or(1.0, |n| n / size), avail, size, width)) + }) + .collect(); + + let gap = line_gap / points; + let min_leading = SIZES.0 / points; + // Always from the sizes the refit started with, so each pass sets a line's size once: the runs + // are split where the lines end and each piece scaled by its line's factor, in one pass. + let apply = |t: &mut TextObject, from: &[TextRun], fits: &[Option<(f64, f64, f64, f64)>]| { + let mut out = Vec::with_capacity(from.len() + lines.len()); + let (mut off, mut li) = (0, 0); + for r in from { + let end = off + r.text.len(); + let mut at = off; + loop { + while lines.get(li).is_some_and(|l| l.end <= at && li + 1 < lines.len()) { + li += 1; + } + let stop = lines.get(li).map_or(end, |l| l.end.clamp(at, end)); + let stop = if stop == at { end } else { stop }; + let f = fits.get(li).copied().flatten().map_or(1.0, |(f, ..)| f); + let mut piece = TextRun { text: r.text.get(at - off..stop - off).unwrap_or_default().to_string(), ..r.clone() }; + if at > off { + piece.style.glyph_alternate = None; + } + let st = &mut piece.style; + st.size *= f; + st.baseline_shift *= f; + st.tcy_up_down *= f; + st.tcy_left_right *= f; + st.leading = Some((st.size + gap).max(min_leading)); + if let Some(art) = &mut piece.inline { + art.baseline_shift *= f; + } + out.push(piece); + at = stop; + if at >= end { + break; + } + } + off = end; + } + vectorcraft_text::edit::normalize(&mut out); + t.runs = out; + }; + let start = t.runs.clone(); + apply(t, &start, &fits); + + // Each pass measures the lines again: a line whose width did not follow its size (a tab to a + // fixed stop) is fitted again from its measured width, and when every line measures right, a + // line the frame's shape or a list marker still wraps is made smaller, by what the line the + // layout gave it holds. + // Per line: the smallest size measured too wide, which a later pass never goes back to. + let mut too_wide = vec![f64::INFINITY; lines.len()]; + for _ in 0..BLOCK_PASSES { + let now_widths = line_widths(db, t); + let mut again = false; + for (((l, fit), size), wide) in lines.iter().zip(fits.iter_mut()).zip(&sizes).zip(too_wide.iter_mut()) { + let Some((f, avail, last_size, last_width)) = fit else { continue }; + let size = size * points; + let now = size * *f; + let Some(width) = now_widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9) else { continue }; + if width > *avail + 1e-6 { + *wide = wide.min(now); + } + // The slope between the last two sizes measured, where they differ: a line whose width + // has a part that stays as the size changes is fitted in one step. + let slope = Some((width - *last_width) / (now - *last_size)) + .filter(|s| (now - *last_size).abs() > 1e-9 && s.is_finite() && *s > 1e-9) + .unwrap_or(width / now); + (*last_size, *last_width) = (now, width); + let next = refit(now, width, *avail, slope).map(|n| if n >= *wide - 1e-9 { tenths(*wide - 0.1) } else { n }); + if let Some(next) = next.filter(|n| (n - now).abs() > 1e-9) { + *f = next / size; + again = true; + } + } + if !again { + let laid = vectorcraft_text::layout(db, t); + // Per typed line: how many laid-out lines it took, and the room of its first. + let mut parts: Vec<(usize, f64)> = vec![(0, 0.0); lines.len()]; + for m in &laid.lines { + let i = lines.partition_point(|l| l.start <= m.start).saturating_sub(1); + if let Some(p) = parts.get_mut(i) { + if p.0 == 0 { + p.1 = m.avail.1 - m.avail.0; + } + p.0 += 1; + } + } + for (((l, fit), (count, room)), size) in lines.iter().zip(fits.iter_mut()).zip(&parts).zip(&sizes) { + let Some((f, ..)) = fit else { continue }; + if *count < 2 { + continue; + } + let size = size * points; + let now = size * *f; + if now <= SIZES.0 + 1e-9 { + continue; + } + let width = now_widths.get(&l.start).copied().unwrap_or(f64::INFINITY); + let smaller = if room.is_finite() && *room > 0.0 && *room < width { tenths(now * room / width) } else { now }; + *f = smaller.min(tenths(now - 0.1)) / size; + again = true; + } + } + if !again { + break; + } + apply(t, &start, &fits); + } + (t.runs.clone(), t.para.clone(), t.paras.clone()) != before +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_lines_split_after_returns_and_forced_breaks() { + let l = typed_lines("ab\ncd\u{2028}e\n"); + let spans: Vec<_> = l.iter().map(|l| (l.start, l.content, l.end, l.para, l.para_start)).collect(); + assert_eq!(spans, vec![(0, 2, 3, 0, true), (3, 5, 8, 1, true), (8, 9, 10, 1, false), (10, 10, 10, 2, true)]); + assert_eq!(typed_lines("").len(), 1); + } + + #[test] + fn a_fitting_line_keeps_its_size_in_tenths() { + // 100 pt wide at 12 pt, 100.5 pt of room: a tenth larger would not fit. + assert_eq!(refit(12.0, 100.0, 100.5, 100.0 / 12.0), None); + assert_eq!(refit(12.34, 100.0, 100.5, 100.0 / 12.34), Some(12.3), "a size between tenths is rounded down"); + // Room for twice the width: twice the size. + assert_eq!(refit(12.0, 100.0, 200.0, 100.0 / 12.0), Some(24.0)); + // A part of the width that stays (a slope below width / size) needs more size. + assert_eq!(refit(12.0, 100.0, 150.0, 5.0), Some(22.0)); + } + + #[test] + fn sizes_round_down_to_tenths_within_the_range() { + assert_eq!(tenths(23.99), 23.9); + assert_eq!(tenths(24.0 - 1e-9), 24.0); + assert_eq!(tenths(0.01), 0.1); + assert_eq!(tenths(5000.0), 1296.0); + } +} diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs new file mode 100644 index 0000000000..1028fc0017 --- /dev/null +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -0,0 +1,385 @@ +//! Dynamic Text's Block for area type: `type.dynamicText`, Area Type Options › Block and the refit +//! after edits. + +use serde_json::{Value, json}; +use vectorcraft_doc::{AreaFit, NodeId, NodeKind, TextKind, TextObject}; + +use crate::Session; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn area(s: &mut Session, w: f64, h: f64, text: &str) -> u64 { + let id = s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": {"width": w, "height": h}, "text": text})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + id +} + +fn text(s: &Session, id: u64) -> TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The size of the character at `byte`. +fn size_at(t: &TextObject, byte: usize) -> f64 { + super::block::size_at(&t.runs, byte) +} + +/// Each laid-out line's (start byte, content width, room): the room is the frame's span less the +/// indents. +fn lines(t: &TextObject) -> Vec<(usize, f64, f64)> { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.lines.iter().map(|l| (l.start, l.x1 - l.x0, l.avail.1 - l.avail.0)).collect() +} + +/// Every typed line is one laid-out line whose width is within one 0.1 pt size step of its room. +fn assert_block(t: &TextObject) { + let typed: Vec = std::iter::once(0).chain(t.plain_text().match_indices(['\n', '\u{2028}']).map(|(i, c)| i + c.len())).collect(); + let laid = lines(t); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), typed, "one laid-out line per typed line: {laid:?}"); + for &(start, width, room) in &laid { + let size = size_at(t, start); + if width <= 1e-9 || size >= 1296.0 - 1e-9 || size <= 0.1 + 1e-9 { + continue; + } + assert!(width <= room + 0.01, "line at {start} is wider than its room: {width} > {room}"); + assert!(width >= room * (size - 0.1) / size - 0.01, "line at {start} at {size} pt is short of its room: {width} < {room}"); + } +} + +#[test] +fn block_sets_each_typed_line_to_the_frame_width_by_its_size() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line\nmid one"); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r, json!({"on": true, "ids": [id], "fitScale": 1.0, "wordGap": 100.0, "lineGap": 0.0})); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(t.area.fit, AreaFit::None); + assert_block(&t); + let (big, long, mid) = (size_at(&t, 0), size_at(&t, 4), size_at(&t, 31)); + assert!(big > mid && mid > long, "a shorter line is set larger: {big} {mid} {long}"); + for size in [big, long, mid] { + assert!(((size * 10.0).round() - size * 10.0).abs() < 1e-6, "{size} is a whole number of tenths"); + } + // Each character's leading is its size (line gap 0). + assert!(t.runs.iter().all(|r| r.style.leading == Some(r.style.size)), "{:?}", t.runs); + // Inspect reports it. + let doc = s.execute("document.inspect", &json!({})).unwrap(); + let node = doc["layers"][0]["children"].as_array().unwrap().iter().find(|k| k["id"] == id).unwrap().clone(); + assert_eq!((node["dynamicText"].as_bool(), node["fit"].as_str()), (Some(true), Some("block"))); +} + +#[test] +fn block_keeps_the_proportions_inside_a_line_and_fits_again_after_edits() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "one two three\nfour"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 24})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_block(&t); + let ratio = size_at(&t, 0) / size_at(&t, 4); + assert!((ratio - 2.0).abs() < 0.02, "the first word stays twice the size of the rest: {ratio}"); + let four = size_at(&t, 14); + // Typing into the first line makes it smaller, in the edit's own undo step; the second line stays. + let first = size_at(&t, 4); + let n = undo_len(&s); + s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": " and more words"})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, id); + assert_block(&t); + assert!(size_at(&t, 4) < first, "{} < {first}", size_at(&t, 4)); + assert_eq!(size_at(&t, t.plain_text().find("four").unwrap()), four); + // A narrower frame sets every line smaller. + s.execute("text.areaOptions", &json!({"width": 150})).unwrap(); + let narrow = text(&s, id); + assert_block(&narrow); + let f = size_at(&narrow, narrow.plain_text().find("four").unwrap()) / four; + assert!((f - 0.5).abs() < 0.02, "half the width, half the size: {f}"); + // Undo brings the wider frame's sizes back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id).runs, t.runs); +} + +#[test] +fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() { + let mut s = session(); + // The tab goes to a stop that stays where it is as the size changes. + let id = area(&mut s, 300.0, 400.0, "Fit me\nand\tme too"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let fitted = text(&s, id).runs; + let laid = lines(&text(&s, id)); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), vec![0, 7], "{laid:?}"); + assert!(laid.iter().all(|l| l.1 <= l.2 + 0.01 && l.1 > l.2 * 0.95), "each line fills its room: {laid:?}"); + let n = undo_len(&s); + assert_eq!(s.execute("type.dynamicText", &json!({"on": true})).unwrap()["on"], true); + assert_eq!(undo_len(&s), n, "already on: no undo step"); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["on"], false); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, id); + assert!(!t.dynamic_text); + assert_eq!(t.runs, fitted, "off keeps the sizes it was drawn at"); + // Edits no longer fit the lines. + s.execute("text.areaOptions", &json!({"width": 100})).unwrap(); + assert_eq!(text(&s, id).runs, fitted); + assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["on"], false); + assert_eq!(undo_len(&s), n + 2, "already off: no undo step"); +} + +#[test] +fn bad_params_and_other_type_are_refused() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me"); + for bad in [ + json!({"on": "yes"}), + json!({"on": 1}), + json!({"wordGap": "wide"}), + json!({"wordGap": -1}), + json!({"wordGap": 1001}), + json!({"lineGap": 1297}), + json!({"lineGap": -1297}), + json!({"lineGap": true}), + ] { + assert!(s.execute("type.dynamicText", &bad).is_err(), "{bad}"); + } + assert!(!text(&s, id).dynamic_text); + let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [point]})).unwrap(); + let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string(); + assert!(e.contains("select type on a path or area type"), "{e}"); + // With point type selected too, only the area type changes. + s.execute("select.set", &json!({"ids": [id, point]})).unwrap(); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([id])); + assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text); +} + +#[test] +fn gaps_set_word_spacing_and_leading() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "two words\nthree more words"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let plain = text(&s, id); + let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 6})).unwrap(); + assert_eq!((r["on"].as_bool(), r["wordGap"].as_f64(), r["lineGap"].as_f64()), (Some(true), Some(300.0), Some(6.0))); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!((t.area.word_gap, t.area.line_gap), (300.0, 6.0)); + assert_eq!(t.para_at(0).justification.word_spacing, [80.0, 300.0, 300.0]); + assert!(t.runs.iter().all(|r| (r.style.leading.unwrap() - r.style.size - 6.0).abs() < 1e-9), "{:?}", t.runs); + assert_block(&t); + assert!(size_at(&t, 0) < size_at(&plain, 0), "wider word spaces leave the letters less room"); + // A gap alone on a frame without Block turns it on. + let other = area(&mut s, 200.0, 200.0, "x y"); + let r = s.execute("type.dynamicText", &json!({"lineGap": -2})).unwrap(); + assert_eq!(r["on"], true); + let t = text(&s, other); + assert!(t.dynamic_text); + assert_eq!((t.area.word_gap, t.area.line_gap), (100.0, -2.0)); +} + +#[test] +fn area_type_options_offer_block_with_its_gaps() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me\nand me too"); + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["wordGap"].as_f64(), q["lineGap"].as_f64()), (Some(100.0), Some(0.0))); + // Gaps alone are kept for Block, without turning it on or changing the text. + let (n, runs) = (undo_len(&s), text(&s, id).runs); + s.execute("text.areaOptions", &json!({"wordGap": 200})).unwrap(); + let t = text(&s, id); + assert_eq!((undo_len(&s), t.dynamic_text, t.area.word_gap), (n + 1, false, 200.0)); + assert_eq!(t.runs, runs); + let r = s.execute("text.areaOptions", &json!({"fit": "block", "lineGap": 4})).unwrap(); + assert_eq!((r["fit"].as_str(), r["wordGap"].as_f64(), r["lineGap"].as_f64()), (Some("block"), Some(200.0), Some(4.0))); + assert!(text(&s, id).dynamic_text); + assert_block(&text(&s, id)); + // The reply passed back (as the dialog does) changes nothing; naming Block again keeps the gaps. + let n = undo_len(&s); + assert_eq!(s.execute("text.areaOptions", &r).unwrap(), r); + s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap(); + assert_eq!(undo_len(&s), n); + s.execute("text.areaOptions", &json!({"lineGap": 2})).unwrap(); + let t = text(&s, id); + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 200.0, 2.0)); + assert!(s.execute("text.areaOptions", &json!({"lineGap": 5000})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"wordGap": 2000})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"wordGap": "wide"})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"fit": "squash"})).unwrap_err().to_string().contains("shrinkText|block")); + // Shrink Text to Fit replaces Block, keeping the sizes. + let runs = text(&s, id).runs; + s.execute("text.areaOptions", &json!({"fit": "shrinkText"})).unwrap(); + let t = text(&s, id); + assert!(!t.dynamic_text); + assert_eq!(t.runs, runs); + // Block replaces Shrink Text to Fit and Auto Size. + s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap(); + assert_eq!(text(&s, id).area.fit, AreaFit::None); + // New area type can start with it. + let made = s + .execute( + "text.create", + &json!({"x": 10, "y": 300, "size": 12, "area": {"width": 200, "height": 200, "fit": "block", "wordGap": 150}, "text": "made"}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let t = text(&s, made); + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 150.0, 0.0)); + assert_block(&t); +} + +#[test] +fn block_round_trips_through_the_native_format() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me\nand me too"); + s.execute("type.dynamicText", &json!({"wordGap": 120, "lineGap": 3})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let bytes = vectorcraft_format::save(&doc, false); + let back = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 120.0, 3.0)); + assert_eq!(t.runs, text(&s, id).runs, "the fitted sizes are saved in the characters"); + // Saved as `dynamicText` with the gaps beside the area options; missing gaps read as their + // defaults, which are left out when saved. + let saved = serde_json::to_value(&**t).unwrap(); + assert_eq!((&saved["dynamicText"], &saved["area"]["wordGap"], &saved["area"]["lineGap"]), (&json!(true), &json!(120.0), &json!(3.0))); + let plain = serde_json::from_value::(json!({})).unwrap(); + assert_eq!((plain.word_gap, plain.line_gap), (100.0, 0.0)); + let plain: Value = serde_json::to_value(&plain).unwrap(); + assert!(plain.get("wordGap").is_none() && plain.get("lineGap").is_none(), "{plain}"); + // Turned off, it opens off with the sizes it had. + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(!t.dynamic_text); + assert_eq!(t.runs, text(&s, id).runs); +} + +#[test] +fn edge_cases_fit_without_failing() { + let mut s = session(); + // Empty text, an empty line and a line of spaces keep their sizes. + let empty = area(&mut s, 300.0, 400.0, ""); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, empty), 0), 12.0); + let gaps = area(&mut s, 300.0, 400.0, "top\n\n \nbottom line"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, gaps); + assert_eq!((size_at(&t, 4), size_at(&t, 5)), (12.0, 12.0)); + assert_block(&t); + // One word. + let word = area(&mut s, 300.0, 400.0, "Word"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_block(&text(&s, word)); + // A wide frame stops at 1,296 pt; a very long line in a narrow frame stops at 0.1 pt and wraps. + let wide = area(&mut s, 100_000.0, 400.0, "i"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, wide), 0), 1296.0); + let narrow = area(&mut s, 1.0, 400.0, &"long words ".repeat(40)); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, narrow); + assert_eq!(size_at(&t, 0), 0.1); + assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "at 0.1 pt the line still does not fit"); + // A frame inset so far that it has no width keeps the sizes. + let inset = area(&mut s, 50.0, 400.0, "Inset"); + s.execute("text.areaOptions", &json!({"inset": 30})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, inset), 0), 12.0); +} + +#[test] +fn block_fits_columns_lists_vertical_and_right_to_left_type() { + let mut s = session(); + // Two columns: each line is set to a column's width. + let cols = area(&mut s, 400.0, 600.0, "left column\nsecond"); + s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 20})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, cols); + assert_block(&t); + assert!(lines(&t).iter().all(|l| (l.2 - 190.0).abs() < 1e-6), "{:?}", lines(&t)); + // A bulleted list: each item stays one line, the marker and its tab taking their room before + // the text (the room the layout reports takes in the marker's too). + let list = area(&mut s, 300.0, 400.0, "first item\nsecond item here"); + s.execute("text.setFormat", &json!({"list": "bullet"})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, list); + let laid = lines(&t); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), vec![0, 11], "{laid:?}"); + assert!(laid.iter().all(|l| l.1 <= l.2), "{laid:?}"); + assert!(size_at(&t, 0) > size_at(&t, 11), "the shorter item is larger"); + // Right-to-left text. + let rtl = area(&mut s, 300.0, 400.0, "שלום עולם\nטקסט ארוך יותר בשורה"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_block(&text(&s, rtl)); + // Vertical type: each column of text is set to the frame's height. + let v = s + .execute( + "text.create", + &json!({"x": 40, "y": 40, "size": 12, "vertical": true, "area": {"width": 300, "height": 200}, "text": "縦書き\nもっと長い縦書きの行"}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [v]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, v); + assert!(t.vertical); + assert_block(&t); + assert!(lines(&t).iter().all(|l| (l.2 - 200.0).abs() < 1e-6), "{:?}", lines(&t)); +} + +#[test] +fn threaded_frames_are_refused_and_threading_turns_block_off() { + let mut s = session(); + let a = area(&mut s, 200.0, 300.0, "Fit me\nand me too"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let fitted = text(&s, a).runs; + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 40, "width": 200, "height": 300})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let n = undo_len(&s); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, a); + assert!(!t.dynamic_text, "threading turns Block off"); + assert_eq!(t.runs, fitted, "the text keeps its sizes"); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let e = s.execute("type.dynamicText", &json!({"on": true})).unwrap_err().to_string(); + assert!(e.contains("area type in a thread") && e.contains(&a.to_string()), "{e}"); + let runs = text(&s, a).runs; + let e = s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap_err().to_string(); + assert!(e.contains("area type in a thread") && e.contains(&a.to_string()), "{e}"); + assert_eq!(text(&s, a).runs, runs, "Area Type Options refuses it too, changing nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(text(&s, a).dynamic_text, "undo brings it back with the thread gone"); + assert!(matches!(text(&s, a).kind, TextKind::Area { .. })); +} + +#[test] +fn long_text_fits_in_a_bounded_number_of_layouts() { + let mut s = session(); + let story: String = (0..300).map(|i| format!("line number {i} {}\n", "x".repeat(i % 17))).collect(); + let id = area(&mut s, 300.0, 100_000.0, &story); + let before = vectorcraft_text::test_fonts::layouts_on_this_thread(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before; + assert_block(&text(&s, id)); + // The command fits the object to compare and to apply (each a measure, at most two layouts a + // pass and the caches), plus the reply's own. + let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4; + assert!(used <= bound, "{used} layouts"); +} diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index deaac8acc4..c3db479332 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -1,13 +1,13 @@ //! Type menu long tail (Change Case, Smart Punctuation, Convert To Area/Point Type, Type on a Path -//! Options, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) and the text -//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). +//! Options, Dynamic Text, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) +//! and the text commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). use serde_json::{Value, json}; -use vectorcraft_doc::{CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; use vectorcraft_geom::{Affine, Rect, shapes}; use super::object::choice; -use super::typecmd::refresh_bounds; +use super::typecmd::{area_text, block_gaps, refresh_bounds}; use super::{ CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, switch_or, targets, text_param, @@ -65,8 +65,8 @@ pub fn specs() -> Vec { "Dynamic Text", ["Type"], None, - "{ids?|id?, on?: bool} turn Dynamic Text on or off for the selected type on a path (on omitted: the opposite of the first object's setting). On, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. Off, the text keeps the sizes it was drawn at. Point type and area type in the selection are left as they are; a selection holding type on a path in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When every object already has the setting, nothing changes and no undo step is recorded → {on, ids, fitScale (the first object's factor, 1 when off)}", - has_free_path_type, + "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and every paragraph's Desired word spacing is wordGap (the gaps are area type's, and are kept while Block is off). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", + has_dynamic_type, dynamic_text ), cmd!( @@ -520,26 +520,40 @@ fn path_options(s: &mut Session, p: &Value) -> Result { options(s) } -/// Whether type on a path `id` is a frame of a thread. +/// Whether type `id` is a frame of a thread. fn threaded(d: &Document, id: NodeId) -> bool { !super::threads::story_of(d, id).is_empty() } -/// `type.dynamicText` is enabled for a selection holding type on a path, none of it in a thread. -fn has_free_path_type(s: &Session) -> std::result::Result<(), String> { +/// Type on a path or area type: the type Dynamic Text works on. +fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> { + path_text(n).or_else(|| area_text(n)) +} + +/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it +/// in a thread. +fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; let d = &st.doc; let selected: Vec = st.selection.objects.to_vec(); - let paths: Vec = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect(); - if paths.is_empty() { - return Err("select type on a path".into()); + let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect(); + if types.is_empty() { + return Err("select type on a path or area type".into()); } + threaded_refusal(d, &types).map_or(Ok(()), Err) +} + +/// Why Dynamic Text is refused for `ids`: the frames among them that are in a thread, type on a +/// path first; None when none is. +fn threaded_refusal(d: &Document, ids: &[NodeId]) -> Option { + let (paths, areas): (Vec, Vec) = ids.iter().copied().partition(|id| path_text(d.node(*id)).is_some()); let frames = threaded_ids(d, &paths); if !frames.is_empty() { - return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); + return Some(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); } - Ok(()) + let frames = threaded_ids(d, &areas); + (!frames.is_empty()).then(|| format!("area type in a thread cannot use Dynamic Text: {frames}")) } /// The ids among `ids` of frames of threads, as a list for a message; empty when there are none. @@ -547,32 +561,57 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String { ids.iter().filter(|id| threaded(d, **id)).map(|id| id.0.to_string()).collect::>().join(", ") } -/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting) on the -/// selected type on a path, in one undo step, and none when every object already has it. Turning -/// it off keeps the sizes the text was drawn at ([`bake_dynamic_text`]). +/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on +/// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in +/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn +/// at ([`bake_dynamic_text`]). fn dynamic_text(s: &mut Session, p: &Value) -> Result { const C: &str = "type.dynamicText"; - let (ids, on, changing) = { + let (word_gap, line_gap) = block_gaps(p, C)?; + let (ids, on, planned) = { let d = &s.doc()?.doc; - let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect(); - let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?; - let frames = threaded_ids(d, &ids); - if !frames.is_empty() { - return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}"))); + let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect(); + let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?; + if let Some(why) = threaded_refusal(d, &ids) { + return Err(bad(C, why)); } - let on = switch_or(p, "on", !path_text(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; - let changing: Vec = ids.iter().copied().filter(|id| path_text(d.node(*id)).is_some_and(|t| t.dynamic_text != on)).collect(); - (ids, on, changing) + let gap_given = word_gap.is_some() || line_gap.is_some(); + let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; + // Each object as the command leaves it, and only those it changes. + let mut planned: Vec<(NodeId, TextObject)> = vec![]; + for id in &ids { + let Some(t) = dynamic_type(d.node(*id)) else { continue }; + let mut after = t.clone(); + if matches!(t.kind, TextKind::Area { .. }) { + after.area.word_gap = word_gap.unwrap_or(t.area.word_gap); + after.area.line_gap = line_gap.unwrap_or(t.area.line_gap); + } + match (on, t.dynamic_text) { + (true, true) => refresh_bounds(&mut after), + (true, false) => { + after.dynamic_text = true; + // Block replaces Auto Size and Shrink Text to Fit. + if matches!(t.kind, TextKind::Area { .. }) { + after.area.fit = AreaFit::None; + } + refresh_bounds(&mut after); + } + (false, true) => { + bake_dynamic_text(&mut after); + } + (false, false) => {} + } + if after != *t { + planned.push((*id, after)); + } + } + (ids, on, planned) }; - if !changing.is_empty() { + if !planned.is_empty() { s.edit("Dynamic Text", |d, _| { - for id in &changing { - let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - if on { - t.dynamic_text = true; - refresh_bounds(t); - } else { - bake_dynamic_text(t); + for (id, after) in &planned { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + **t = after.clone(); } } Ok(()) @@ -581,7 +620,13 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let fit = ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale); - Ok(json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit})) + let mut reply = json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit}); + if let Some(t) = ids.iter().find_map(|id| area_text(d.node(*id))) { + let (w, l) = t.area.block_gaps(); + reply["wordGap"] = json!(w); + reply["lineGap"] = json!(l); + } + Ok(reply) } /// Turn Dynamic Text off on `t`, keeping the sizes its layout draws it at: every run's size, diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 347f6734e7..9bad62a6c8 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -99,14 +99,15 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) -> NodeKind::Text(t) => { v["text"] = json!(t.plain_text()); // Area type and type on a path: does the text fit (the red overflow "+")? Area type - // also reports its fit and Shrink Text's factor (1 unshrunk), and type on a path its - // Dynamic Text setting and factor (1 when off). + // also reports its fit (`block` for Dynamic Text's Block) and Shrink Text's factor (1 + // unshrunk), and type on a path its factor (1 when off); both report Dynamic Text. if !matches!(t.kind, vectorcraft_doc::TextKind::Point) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); v["overflow"] = json!(lay.overflow); if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) { - v["fit"] = json!(t.area.fit.id()); + v["fit"] = json!(crate::cmd::typecmd::fit_id(t)); v["fitScale"] = json!(lay.fit_scale); + v["dynamicText"] = json!(t.dynamic_text); } else { v["dynamicText"] = json!(t.dynamic_text); v["fitScale"] = json!(lay.fit_scale); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 80ad1f1af9..3512db9c59 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -88,14 +88,10 @@ fn dynamic_text_refuses_bad_params_and_other_type() { assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on")); assert_eq!(undo_len(&s), before); assert!(!text(&s, id).dynamic_text); - // Point type and area type alone: refused. + // Point type alone: refused (area type takes Block, `typecmd::block_tests`). let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); - let area = - s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap()["id"].as_u64().unwrap(); - for other in [point, area] { - s.execute("select.set", &json!({"ids": [other]})).unwrap(); - assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path")); - } + s.execute("select.set", &json!({"ids": [point]})).unwrap(); + assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path or area type")); let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [empty]})).unwrap(); assert!(s.execute("type.dynamicText", &json!({})).is_err()); diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 0a722cbcf8..3acd7e6c0e 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -102,7 +102,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "shape.flare" => &["diameter", "pathLength"], "perspective.grid.set" => &["cell", "distance"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], - "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin"], + "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], "object.pathfinder.trap" => &["thickness"], _ => &[], @@ -156,8 +156,8 @@ const RASTERIZE_CMYK: form::Choices = &[("CMYK", "cmyk"), ("Grayscale", "graysca /// Type on a Path Options › Align to Path. const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")]; -/// Area Type Options › Fit. -const AREA_FIT: form::Choices = &[("None", "none"), ("Auto Size", "autoHeight"), ("Shrink Text to Fit", "shrinkText")]; +/// Area Type Options › Fit (Block is Dynamic Text's, as `type.dynamicText` sets it). +const AREA_FIT: form::Choices = &[("None", "none"), ("Auto Size", "autoHeight"), ("Shrink Text to Fit", "shrinkText"), ("Block", "block")]; /// Area Type Options › First Baseline. const FIRST_BASELINE: form::Choices = diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..73a18cb18b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6371,7 +6371,7 @@ antiAlias Sharp शार्प @msg files must be {name, dataBase64} files: फ़ाइलों को {name, dataBase64} होना चाहिए @msg finish editing the pattern first पहले पैटर्न को संपादित करना @msg First measured segment no longer exists पहले मापा खंड अब मौजूद नहीं है -@msg fit must be none|autoHeight|shrinkText, got {id} fit को none|autoHeight|shrinkText होना चाहिए, {id} मिला +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit को none|autoHeight|shrinkText|block होना चाहिए, {id} मिला @msg Flare click coordinates must be finite numbers फ्लेयर क्लिक निर्देशांक परिमित संख्या होना चाहिए @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. फ्लैट लोगो ने {_1} × {_2} पिक्सेल पर इमेज का पता लगाया, जो {_3} × {_4} से नीचे है। @msg Flatten Transparency: {e} Flatten transparency: {e} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..24dcb30bb4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -7406,7 +7406,7 @@ digits Hindi Hindi @msg features must be a list of tags features måste vara en lista med taggar @msg fidelity must be 0..100 fidelity måste vara 0..100 @msg files must be {name, dataBase64} files måste vara {name, dataBase64} -@msg fit must be none|autoHeight|shrinkText, got {id} fit måste vara none|autoHeight|shrinkText, fick {id} +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit måste vara none|autoHeight|shrinkText|block, fick {id} @msg Flare click coordinates must be finite numbers Klickkoordinaterna för Ljusreflex måste vara ändliga tal @msg flattener: {e} utplattning: {e} @msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low eller en sparad (se flattener.presets.list) diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 2e45a88483..9cbeda6043 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -16,16 +16,16 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { let v = &app.ui.view; // An item that can be on or off is `Some` whatever the document and selection (off with none): // the macOS menu bar can't change an item's kind without rebuilding. - // The first type object (of type on a path, with `onpath`) among the selected objects and the - // objects inside them, in the order the type commands act on them. - let selected_text = |onpath: bool| { + // The first type object of a kind `kinds` takes among the selected objects and the objects + // inside them, in the order the type commands act on them. + let selected_text = |kinds: fn(&vectorcraft_engine::doc::TextKind) -> bool| { let st = app.session.active()?; let mut found = None; for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { n.walk(&mut |c| { if found.is_none() && let vectorcraft_engine::doc::NodeKind::Text(t) = &c.kind - && (!onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. })) + && kinds(&t.kind) { found = Some(t); } @@ -41,17 +41,17 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical") } "type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true), - // Dynamic Text of the first selected type on a path, which the item turns the other way. - "type.dynamicText" => selected_text(true).is_some_and(|t| t.dynamic_text), + // Dynamic Text of the first selected type on a path or area type, which the item turns the + // other way. + "type.dynamicText" => selected_text(|k| !matches!(k, vectorcraft_engine::doc::TextKind::Point)).is_some_and(|t| t.dynamic_text), "view.outline" => app.outline_view(), "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, "view.snapToPixel" => v.snap_to_pixel, "view.snapToGlyph" => v.snap_to_glyph, // Type on a Path effect of the selected path type. - "type.pathOptions" if p.get("effect").is_some() => { - selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())) - } + "type.pathOptions" if p.get("effect").is_some() => selected_text(|k| matches!(k, vectorcraft_engine::doc::TextKind::OnPath { .. })) + .is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())), "view.smartGuides" => v.smart_guides, "edit.autoSpellCheck" => v.auto_spell_check, "view.grid" => v.grid, diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index a449fb0361..8591c4d89d 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -313,8 +313,8 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() { assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); } -/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path that is in no -/// thread, and is checked while the selected type on a path has it on. +/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path and area type that +/// are in no thread, and is checked while the selected type has it on. #[test] fn dynamic_text_is_a_checked_type_menu_item() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); @@ -349,6 +349,13 @@ fn dynamic_text_is_a_checked_type_menu_item() { app.run("text.thread.create", json!({})).unwrap(); app.run("select.set", json!({"ids": [t]})).unwrap(); assert!(!enabled(&app, id), "type on a path in a thread"); + // Area type takes Block, and the item is checked while it is on. + let area = app.run("text.create", json!({"x": 10, "y": 400, "text": "Block", "area": {"width": 100, "height": 50}})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [area]})).unwrap(); + assert!(enabled(&app, id), "area type"); + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true)); } /// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3dd7da7d6e..6dcd4a516f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1014,9 +1014,12 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) { }); } super::paragraph::hyphenate_check(app, ui, ¶); + // Area type: Dynamic Text's Block and its gaps. + dynamic_text::rows(app, ui); } } +pub(crate) mod dynamic_text; pub(crate) mod vertical_align; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/properties/dynamic_text.rs b/crates/ui-egui/src/panels/properties/dynamic_text.rs new file mode 100644 index 0000000000..7caf9e41f5 --- /dev/null +++ b/crates/ui-egui/src/panels/properties/dynamic_text.rs @@ -0,0 +1,84 @@ +//! Dynamic Text in the Properties panel's Paragraph section: the Dynamic Text checkbox for the +//! selected area type (Block, `type.dynamicText`) and, while it is on, its Word Gap and Line Gap +//! rows ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) +//! describes Block and its spacing between words and between lines). + +use egui::Ui; +use serde_json::{Value, json}; +use vectorcraft_doc::{AreaOptions, NodeKind, TextKind}; + +use crate::VectorcraftApp; +use crate::widgets; + +/// What the selected area type shows: whether Block is on in every frame (`None` when they +/// differ), the gaps every frame shares (`None` when they differ), and whether every frame can +/// take it (none is threaded). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Shown { + pub(crate) on: Option, + pub(crate) word_gap: Option, + pub(crate) line_gap: Option, + pub(crate) enabled: bool, +} + +/// Dynamic Text of the selected area type (and area type in selected groups); `None` when the +/// selection holds no area type, or holds other type too (the command refuses it). +pub(crate) fn shown(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let threaded = |id| st.doc.text_threads.iter().any(|t| t.len() > 1 && t.contains(&id)); + let mut frames = vec![]; + let mut other = false; + for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| { + let NodeKind::Text(t) = &c.kind else { return }; + if matches!(t.kind, TextKind::Area { .. }) { + let (word, line) = t.area.block_gaps(); + frames.push((t.dynamic_text, word, line, threaded(c.id))); + } else { + other = true; + } + }); + } + let &(on, word, line, _) = frames.first().filter(|_| !other)?; + Some(Shown { + on: frames.iter().all(|f| f.0 == on).then_some(on), + word_gap: frames.iter().all(|f| f.1 == word).then_some(word), + line_gap: frames.iter().all(|f| f.2 == line).then_some(line), + enabled: frames.iter().all(|f| !f.3), + }) +} + +fn run(app: &mut VectorcraftApp, p: Value) { + if let Err(e) = app.run("type.dynamicText", p) { + app.status(e); + } +} + +/// The checkbox, and the gap rows while every selected frame has Block on. Nothing shows without +/// area type. Returns whether anything was drawn. +pub(crate) fn rows(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { + let Some(s) = shown(app) else { return false }; + if widgets::check(ui, tl!("Dynamic Text"), s.on == Some(true), s.enabled) { + run(app, json!({ "on": s.on != Some(true) })); + } + if s.on != Some(true) { + return true; + } + let fw = super::super::field_width(ui); + let (lo, hi) = AreaOptions::WORD_GAP; + ui.horizontal(|ui| { + ui.label(tl!("Word Gap")); + if let Some(v) = widgets::spin_plain_named(ui, "props-dt-word", Some(tl!("Word Gap")), s.word_gap, "%", 0, fw, 10.0, lo, &[]) { + run(app, json!({ "wordGap": v.clamp(lo, hi) })); + } + }); + let (lo, hi) = AreaOptions::LINE_GAP; + let unit = app.session.general_unit(); + ui.horizontal(|ui| { + ui.label(tl!("Line Gap")); + if let Some(v) = widgets::spin_field_named(ui, "props-dt-line", Some(tl!("Line Gap")), s.line_gap, unit, fw, 1.0, lo, &[]) { + run(app, json!({ "lineGap": v.clamp(lo, hi) })); + } + }); + true +} diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index a9853d3173..5ed020c326 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1946,3 +1946,38 @@ fn vertical_alignment_buttons_align_the_selected_area_type() { app.run("select.set", json!({"ids": [a]})).unwrap(); assert_eq!(vertical_align::shown(&app).map(|s| s.enabled), Some(false)); } + +/// With area type selected, the Paragraph section shows a Dynamic Text checkbox, and Word Gap and +/// Line Gap while Block is on in every selected frame. Point type in the selection shows none; +/// threaded frames turn the checkbox off. +#[test] +fn dynamic_text_rows_follow_the_selected_area_type() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 500, "height": 400})).unwrap(); + let make = |app: &mut VectorcraftApp, x: f64, y: f64| { + app.run("text.create", json!({"x": x, "y": y, "text": "Panel\ntext", "area": {"width": 100, "height": 120}})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (make(&mut app, 10.0, 10.0), make(&mut app, 200.0, 10.0)); + let point = app.run("text.create", json!({"x": 10, "y": 350, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, point]})).unwrap(); + assert_eq!(dynamic_text::shown(&app), None, "point type selected too: no rows"); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let off = dynamic_text::Shown { on: Some(false), word_gap: Some(100.0), line_gap: Some(0.0), enabled: true }; + assert_eq!(dynamic_text::shown(&app), Some(off)); + let ctx = egui::Context::default(); + let texts = panel_frame(&mut app, &ctx, vec![], type_sections); + assert!(texts.iter().any(|(t, _)| t == "Dynamic Text") && !texts.iter().any(|(t, _)| t == "Word Gap"), "{texts:?}"); + app.run("type.dynamicText", json!({"on": true, "lineGap": 2})).unwrap(); + let on = dynamic_text::Shown { on: Some(true), word_gap: Some(100.0), line_gap: Some(2.0), enabled: true }; + assert_eq!(dynamic_text::shown(&app), Some(on)); + let texts = panel_frame(&mut app, &ctx, vec![], type_sections); + assert!(texts.iter().any(|(t, _)| t == "Word Gap") && texts.iter().any(|(t, _)| t == "Line Gap"), "{texts:?}"); + // Differing gaps and settings show as mixed. + app.run("type.dynamicText", json!({"ids": [b], "wordGap": 200})).unwrap(); + assert_eq!(dynamic_text::shown(&app).map(|s| (s.on, s.word_gap, s.line_gap)), Some((Some(true), None, Some(2.0)))); + app.run("type.dynamicText", json!({"ids": [b], "on": false})).unwrap(); + assert_eq!(dynamic_text::shown(&app).map(|s| (s.on, s.word_gap)), Some((None, None))); + // A threaded frame turns the checkbox off. + app.run("text.thread.create", json!({})).unwrap(); + assert_eq!(dynamic_text::shown(&app).map(|s| (s.on, s.enabled)), Some((Some(false), false))); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..44da10f748 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6477,12 +6477,17 @@ Native files save it only when it is `rows`, so files from before it open by col sizes don't change: the scaling happens at layout time, so rendering and every export see it. The factor is found by bisection over the first run's size in steps of 0.1 pt (its scaled size is a whole number of tenths of a point, so the result is deterministic); at `fitMinPercent` the text may still overflow. +- `block`: Dynamic Text's Block, as `type.dynamicText` turns it on (see below), in place of Auto Size and Shrink Text + to Fit; another fit turns it off. `wordGap` (0–1,000 % of a word space, default 100) and `lineGap` (-1,296 to 1,296 + pt, default 0) set its gaps, and are kept while Block is off. The file stores `"fit": "autoHeight"` or `"fit": {"shrinkText": {"minPercent": 40}}` in the object's `area`; the -command also accepts that object form. The query (and every reply) of `text.areaOptions` reports `fit` as its id, -`fitMinPercent`, `overflow` (the text doesn't fit its frame) and `fitScale` (Shrink Text's factor, 1 unshrunk). +command also accepts that object form. Block is stored as the object's `"dynamicText": true`, with `wordGap` and +`lineGap` in its `area` when they differ from their defaults. The query (and every reply) of `text.areaOptions` +reports `fit` as its id (`block` while Block is on), `fitMinPercent`, `wordGap`, `lineGap`, `overflow` (the text +doesn't fit its frame) and `fitScale` (Shrink Text's factor, 1 unshrunk). `document.inspect` (and `document.node {summary: true}`) reports `overflow` for area type and type on a path (text past -the end of the path), and `fit` and `fitScale` for area type. +the end of the path), and `fit`, `fitScale` and `dynamicText` (Block is on) for area type. Given for several area types (`ids`, or a selection of several), `text.areaOptions` changes only the options given on each one: each frame keeps its other options (it used to take the first frame's), and `fitMinPercent` alone changes only @@ -6507,6 +6512,45 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print {"name":"run_command","arguments":{"command":"text.areaOptions","params":{"fit":"shrinkText","fitMinPercent":60}}} ``` +### Dynamic Text: Block + +`type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text) turns Block on or off for the +selected area type, as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type +Options › Block is the same setting, and the Properties panel's Paragraph section has a Dynamic Text checkbox, which +shows Word Gap and Line Gap while it is on. The [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) +describes Block as text that scales to fill a rectangular box, with settings for the spacing between words and between +lines. The rest are VectorCraft's own rules: + +- Each line as typed (each paragraph, and each forced line break, Shift+Enter, in one) is one line; lines are not + broken again. Each line is set to the frame's width, less the inset, the gutters between columns and the paragraph's + left, right and first-line indents, by its font size only (tracking and scaling stay): the first character's size in + steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it and their + baseline shifts following. Vertical type sets each line to the frame's height (less the gutters between rows). +- `wordGap` is every paragraph's Desired word spacing (Justification…), and each character's leading is its size + plus `lineGap`. +- The sizes are written into the characters, so every export and the Character panel see them. After every edit to + the text or the frame (typing, `text.editRange`, character and paragraph changes, `text.areaOptions {width}`, a + frame resize) the lines are fitted again, in the edit's own undo step. The lines are then measured again, in at + most four passes: a line whose width does not follow its size (a tab to a fixed stop) is sized again from what it + measures, and a line that still wraps (a frame that is not a rectangle) is made smaller. +- An empty line, or a line of spaces, keeps its size. At 1,296 pt a line can be narrower than the frame; at 0.1 pt it + can still wrap. A frame with no room along its lines changes nothing. Right-to-left lines are fitted as left-to-right + ones are. +- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Point type in the selection + is left as it is. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` + refuses it too); threading a frame turns Block off, keeping its sizes. Without `on`, it toggles from the first + object's setting, or turns on when a gap is given. A value that is not a boolean, or a gap outside its range, is an + error. When nothing changes, no undo step is recorded. +- Saved and opened, the native file keeps Block on with its gaps and the sizes in the characters; with Block off it + keeps the sizes. + +The reply is `{on, ids, fitScale, wordGap, lineGap}` (`fitScale` as for type on a path, 1 when the first object is area +type; the gaps are the first area type's). + +```json +{"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true,"wordGap":120,"lineGap":2}}} +``` + ## Threading text from the in and out ports As in the reference app, selected area type and type on a path show an in port and an out port: area type's on the @@ -7314,12 +7358,13 @@ and tate-chu-yoko offsets by one factor, so runs keep their proportions; Type on scaled. The first character's size, in document points, moves in steps of 0.1 pt within 0.1 to 1,296 pt; text that still runs past the end bracket at 0.1 pt overflows. Reshaping or scaling the path fits the text again. The stored sizes do not change: turning Dynamic Text off writes the sizes the text was drawn at into its characters, so it keeps -its look, in one undo step. Point type and area type in the selection are left as they are. Type on a path in a +its look, in one undo step. Point type in the selection is left as it is, and area type in it takes Block +([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked -while the first selected type on a path has it on. `document.inspect` reports `dynamicText` and `fitScale` for type +while the first selected type on a path or area type has it on. `document.inspect` reports `dynamicText` and `fitScale` for type on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says so in a warning. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..cd3f49ef69 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1159,6 +1159,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). + Dynamic Text on area type is Block (Type › Dynamic Text, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, and threaded frames cannot use it (`crates/engine/src/cmd/typecmd/block_tests.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. From 283d127e2a0287d2b68182d3b2dcdc91ea4d6ad5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:57:54 -0800 Subject: [PATCH 28/83] JavaScript: characters assigned in Unicode 17 are identifier characters vectorcraft-js and Check Script took identifier categories from unicode-general-category 1.1.0, which carries Unicode 16.0, so letters and marks assigned in Unicode 17.0 (U+088F at the start of a name, U+1ACF after its first character) were refused. Both lexers now read general categories from unicode-properties 0.1.4 (Unicode 17.0), which Cargo.lock already holds. The version is exact because 0.1.5 adds the Unicode-3.0 license, which is not on the license allowlist. The Other_ID_Start, Other_ID_Continue and Pattern_Syntax lists are the same in PropList 17.0. The exhaustive identifier test now expects the DerivedCoreProperties 17.0 totals (145,916 ID_Start, 149,240 ID_Continue), and new tests read U+088F and U+1ACF written out and as escapes in both lexers. --- Cargo.lock | 4 +-- .../tests/it/js_lexer_vs_scriptcheck.rs | 10 +++--- crates/js/Cargo.toml | 4 ++- crates/js/src/chars.rs | 8 ++--- crates/js/src/tests.rs | 33 +++++++++++++++---- crates/scriptcheck/Cargo.toml | 4 ++- crates/scriptcheck/src/lex.rs | 15 ++++----- crates/scriptcheck/src/tests.rs | 7 +++- docs/target-app-parity.md | 4 +-- 9 files changed, 59 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d018aa027f..b11347dd89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6591,7 +6591,7 @@ dependencies = [ "chrono", "getrandom 0.4.3", "proptest", - "unicode-general-category", + "unicode-properties", ] [[package]] @@ -6727,7 +6727,7 @@ name = "vectorcraft-scriptcheck" version = "0.24.0" dependencies = [ "proptest", - "unicode-general-category", + "unicode-properties", ] [[package]] diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs index c9b5ac4665..ba029dd3b1 100644 --- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -89,11 +89,12 @@ fn both_lexers_find_the_same_unclosed_token_on_the_same_line() { } /// Characters on both sides of the identifier rule: `Other_ID_Start`, `Other_ID_Continue`, connector -/// punctuation, marks, digits and letters of other scripts, and a superscript digit, a -/// `Pattern_Syntax` letter and punctuation that no name holds. +/// punctuation, marks, digits and letters of other scripts, a letter and a mark Unicode 17.0 +/// assigned, and a superscript digit, a `Pattern_Syntax` letter and punctuation that no name holds. const NAME_CHARS: &[char] = &[ - '\u{AA}', '\u{B2}', '\u{B7}', '\u{D7}', '\u{300}', '\u{387}', '\u{660}', '\u{1369}', '\u{1885}', '\u{19DA}', '\u{200C}', '\u{200D}', '\u{203F}', - '\u{2040}', '\u{2118}', '\u{212E}', '\u{2160}', '\u{2E2F}', '\u{3005}', '\u{309B}', '\u{30FB}', '\u{FE33}', '\u{FF10}', '\u{FF65}', + '\u{AA}', '\u{B2}', '\u{B7}', '\u{D7}', '\u{300}', '\u{387}', '\u{660}', '\u{88F}', '\u{1369}', '\u{1885}', '\u{19DA}', '\u{1ACF}', '\u{200C}', + '\u{200D}', '\u{203F}', '\u{2040}', '\u{2118}', '\u{212E}', '\u{2160}', '\u{2E2F}', '\u{3005}', '\u{309B}', '\u{30FB}', '\u{FE33}', '\u{FF10}', + '\u{FF65}', ]; /// Whether the parser reads `src` as one identifier spelling `name`. @@ -111,6 +112,7 @@ fn checker_member(src: &str, name: &str) -> bool { #[test] fn both_lexers_take_the_same_characters_into_names() { assert!(checker_member("a\u{2118}b", "a\u{2118}b") && one_identifier("a\u{2118}b", "a\u{2118}b")); + assert!(checker_member("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}") && one_identifier("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}")); for &c in NAME_CHARS { let escape = format!("\\u{:04X}", u32::from(c)); for (src, name) in [ diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index 610c8f9bd6..c12871583a 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -11,7 +11,9 @@ workspace = true [dependencies] chrono = { workspace = true } -unicode-general-category = "1" +# Unicode 17.0 general categories for identifiers (chars.rs). 0.1.5 adds the Unicode-3.0 license, +# which is not on the license allowlist, so the version is exact. +unicode-properties = { version = "=0.1.4", default-features = false, features = ["general-category"] } # The seed of Math.random (interp.rs). getrandom = "0.4" diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs index a7b2c1374c..61b9a7f318 100644 --- a/crates/js/src/chars.rs +++ b/crates/js/src/chars.rs @@ -4,7 +4,7 @@ use std::cmp::Ordering; -use unicode_general_category::{GeneralCategory as G, get_general_category}; +use unicode_properties::{GeneralCategory as G, UnicodeGeneralCategory}; /// A line terminator: LF, CR, U+2028 or U+2029 (section 7.3). pub fn is_line_terminator(c: u32) -> bool { @@ -20,7 +20,7 @@ pub fn is_whitespace(c: u32) -> bool { /// A character an identifier may start with: a Unicode `ID_Start` character, `$` or `_` /// (the Names and Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, Lm, Lo and Nl) /// and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode Standard Annex -/// #31). The categories come from Unicode 16.0. `\u` escapes are decoded before this check. +/// #31). The categories come from Unicode 17.0. `\u` escapes are decoded before this check. pub fn is_id_start(c: u32) -> bool { match c { 0x24 | 0x5F => true, @@ -58,7 +58,7 @@ fn is_other_id_start(c: u32) -> bool { matches!(c, 0x1885 | 0x1886 | 0x2118 | 0x212E | 0x309B | 0x309C) } -/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their +/// `Other_ID_Continue` (Unicode PropList 17.0): characters kept in `ID_Continue` though their /// category is not a letter, mark, digit or connector. fn is_other_id_continue(c: u32) -> bool { matches!(c, 0xB7 | 0x387 | 0x1369..=0x1371 | 0x19DA | 0x200C | 0x200D | 0x30FB | 0xFF65) @@ -110,7 +110,7 @@ fn is_pattern_syntax(c: u32) -> bool { /// The Unicode general category of a code point; `None` for a lone surrogate. fn category(c: u32) -> Option { - char::from_u32(c).map(get_general_category) + char::from_u32(c).map(char::general_category) } /// The value of a hexadecimal digit. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 8c24b5d0bb..d2d4537439 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -424,6 +424,27 @@ fn identifiers_take_the_other_id_start_and_continue_characters() { } } +#[test] +fn identifiers_take_the_characters_unicode_17_assigned() { + // U+088F (a letter, Lo) starts a name; U+1ACF (a combining mark, Mn) may follow the first + // character but cannot start a name. Unicode 17.0 assigned both. + for (src, name) in [ + ("var \u{88F} = 1;", "\u{88F}"), + ("var \\u088F = 1;", "\u{88F}"), + (r"var \u{88F} = 1;", "\u{88F}"), + ("var _\u{1ACF} = 1;", "_\u{1ACF}"), + ("var _\\u1ACF = 1;", "_\u{1ACF}"), + (r"var _\u{1ACF} = 1;", "_\u{1ACF}"), + ] { + let p = ok(src); + let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!("{src:?}") }; + assert_eq!(decls[0].name.name, name, "{src:?}"); + } + assert!(err("var \u{1ACF} = 1;").message.contains("unexpected character")); + assert!(err("var \\u1ACF = 1;").message.contains("identifier escape")); + assert!(err(r"var \u{1ACF} = 1;").message.contains("identifier escape")); +} + #[test] fn identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes() { // U+2E2F is a letter by category but Pattern_Syntax; U+2010, U+00B6 and U+2190 are @@ -480,12 +501,12 @@ fn no_pattern_syntax_or_white_space_character_is_an_identifier_character() { } #[test] -fn identifier_characters_are_unicode_16_id_start_and_id_continue() { +fn identifier_characters_are_unicode_17_id_start_and_id_continue() { use crate::chars::{is_id_part, is_id_start}; - use unicode_general_category::{GeneralCategory as G, get_general_category}; + use unicode_properties::{GeneralCategory as G, UnicodeGeneralCategory}; // Unicode Standard Annex #31: ID_Start is the categories Lu, Ll, Lt, Lm, Lo and Nl plus // Other_ID_Start; ID_Continue adds Mn, Mc, Nd, Pc and Other_ID_Continue; both leave out - // Pattern_Syntax and Pattern_White_Space. Other_ID_Start and Other_ID_Continue as PropList 16.0 + // Pattern_Syntax and Pattern_White_Space. Other_ID_Start and Other_ID_Continue as PropList 17.0 // lists them. let other_start = [0x1885..=0x1886, 0x2118..=0x2118, 0x212E..=0x212E, 0x309B..=0x309C]; let other_continue = [0xB7..=0xB7, 0x387..=0x387, 0x1369..=0x1371, 0x19DA..=0x19DA, 0x200C..=0x200D, 0x30FB..=0x30FB, 0xFF65..=0xFF65]; @@ -498,7 +519,7 @@ fn identifier_characters_are_unicode_16_id_start_and_id_continue() { let (mut id_starts, mut id_continues) = (0, 0); for c in (0..=0x10_FFFF_u32).filter(|c| !(0xD800..=0xDFFF).contains(c)) { let Some(ch) = char::from_u32(c) else { panic!("U+{c:04X}") }; - let g = get_general_category(ch); + let g = ch.general_category(); let letter = matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber); let id_start = (letter || other_start.iter().any(|r| r.contains(&c))) && !excluded[c as usize]; let continuing = matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation); @@ -509,8 +530,8 @@ fn identifier_characters_are_unicode_16_id_start_and_id_continue() { assert_eq!(is_id_start(c), id_start || c == 0x24 || c == 0x5F, "start U+{c:04X}"); assert_eq!(is_id_part(c), id_continue || c == 0x24, "part U+{c:04X}"); } - // The totals of ID_Start and ID_Continue in DerivedCoreProperties 16.0. - assert_eq!((id_starts, id_continues), (141_269, 144_541)); + // The totals of ID_Start and ID_Continue in DerivedCoreProperties 17.0. + assert_eq!((id_starts, id_continues), (145_916, 149_240)); } #[test] diff --git a/crates/scriptcheck/Cargo.toml b/crates/scriptcheck/Cargo.toml index 2e9dac687c..35df44d84c 100644 --- a/crates/scriptcheck/Cargo.toml +++ b/crates/scriptcheck/Cargo.toml @@ -10,7 +10,9 @@ rust-version.workspace = true workspace = true [dependencies] -unicode-general-category = "1" +# Unicode 17.0 general categories for names (lex.rs). 0.1.5 adds the Unicode-3.0 license, which is +# not on the license allowlist, so the version is exact. +unicode-properties = { version = "=0.1.4", default-features = false, features = ["general-category"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index 92d8c72e09..715a0582b0 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -10,7 +10,7 @@ use std::cmp::Ordering; -use unicode_general_category::{GeneralCategory as G, get_general_category}; +use unicode_properties::{GeneralCategory as G, UnicodeGeneralCategory}; use crate::catalog; use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE}; @@ -137,7 +137,7 @@ fn is_blank(c: char) -> bool { /// A character a name may start with: a Unicode `ID_Start` character, `$` or `_` (the Names and /// Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, /// Lm, Lo and Nl) and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode -/// Standard Annex #31). The categories come from Unicode 16.0. +/// Standard Annex #31). The categories come from Unicode 17.0. pub(crate) fn ident_start(c: char) -> bool { match c { '$' | '_' => true, @@ -145,7 +145,7 @@ pub(crate) fn ident_start(c: char) -> bool { _ if is_other_id_start(c) => true, _ => { matches!( - get_general_category(c), + c.general_category(), G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber ) && !is_pattern_syntax(c) } @@ -162,10 +162,7 @@ pub(crate) fn ident_part(c: char) -> bool { _ if ident_start(c) => true, _ if c.is_ascii() => false, _ if is_other_id_continue(c) => true, - _ => { - matches!(get_general_category(c), G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation) - && !is_pattern_syntax(c) - } + _ => matches!(c.general_category(), G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation) && !is_pattern_syntax(c), } } @@ -179,13 +176,13 @@ pub(crate) fn escaped(s: &str) -> Option { .and_then(char::from_u32) } -/// `Other_ID_Start` (Unicode PropList 16.0): characters kept in `ID_Start` though their category is +/// `Other_ID_Start` (Unicode PropList 17.0): characters kept in `ID_Start` though their category is /// not a letter. fn is_other_id_start(c: char) -> bool { matches!(c, '\u{1885}' | '\u{1886}' | '\u{2118}' | '\u{212E}' | '\u{309B}' | '\u{309C}') } -/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their +/// `Other_ID_Continue` (Unicode PropList 17.0): characters kept in `ID_Continue` though their /// category is not a letter, mark, digit or connector. fn is_other_id_continue(c: char) -> bool { matches!(c, '\u{B7}' | '\u{387}' | '\u{1369}'..='\u{1371}' | '\u{19DA}' | '\u{200C}' | '\u{200D}' | '\u{30FB}' | '\u{FF65}') diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index c7eed0c1a6..49b04b6bb2 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -278,12 +278,14 @@ fn unescaped(src: &str) -> String { fn names_are_made_of_the_unicode_identifier_characters() { // U+2118 (Other_ID_Start) starts a name, and U+203F (connector punctuation), U+00B7 // (Other_ID_Continue) and the zero width non-joiner and joiner continue one. - for name in ["\u{2118}a", "a\u{203F}b", "a\u{B7}b", "a\u{200C}b", "a\u{200D}b", "_\u{2118}", "$_a1", "\u{E9}t\u{E9}"] { + // Unicode 17.0 assigned the letter U+088F and the combining mark U+1ACF. + for name in ["\u{2118}a", "a\u{203F}b", "a\u{B7}b", "a\u{200C}b", "a\u{200D}b", "_\u{2118}", "$_a1", "\u{E9}t\u{E9}", "\u{88F}", "_\u{1ACF}"] { assert_eq!(tokens(name), [(T::Ident, name)], "{name:?}"); } for (src, want) in [ // Characters that only continue a name, and digits, don't start one. ("\u{203F}a", [(T::Other, "\u{203F}"), (T::Ident, "a")]), + ("\u{1ACF}a", [(T::Other, "\u{1ACF}"), (T::Ident, "a")]), ("\u{B7}a", [(T::Other, "\u{B7}"), (T::Ident, "a")]), ("\u{200C}a", [(T::Other, "\u{200C}"), (T::Ident, "a")]), ("0a", [(T::Number, "0"), (T::Ident, "a")]), @@ -318,8 +320,11 @@ fn an_escape_stands_for_a_character_only_where_a_name_may_hold_it() { ("a\\u200Db", "a\u{200D}b"), ("a\\u0030", "a0"), ("\\u0024\\u005F", "$_"), + ("\\u088F", "\u{88F}"), + ("_\\u1ACF", "_\u{1ACF}"), // Not at the start of a name. ("\\u0030abc", "\\u0030abc"), + ("\\u1ACFa", "\\u1ACFa"), ("\\u203Fa", "\\u203Fa"), ("\\u00B7a", "\\u00B7a"), // In no name. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..65438f19c5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -749,9 +749,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 16.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_16_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). + - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 17.0, so U+088F starts an identifier and U+1ACF may follow its first character; characters assigned after Unicode 17.0 are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_17_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_characters_unicode_17_assigned`, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. From 0923d862a171d4d491dfc8f9df63822128d60f8a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 07:57:49 -0800 Subject: [PATCH 29/83] =?UTF-8?q?PSD=20import:=20File=20=E2=80=BA=20Open?= =?UTF-8?q?=20converts=20a=20PSD=20file's=20layers=20to=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a .psd or .psb used to give one merged image. document.open now reads the layer records and builds the document from them. The PSD import options page (https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) says the option that converts layers to objects keeps as much of the layer structure as it can, merges the layers of a clipping mask into one layer and discards hidden layers that require merging, and that Import Hidden Layers brings hidden layers in. document.open does the same, with hiddenLayers: true for the hidden layers; layers: false keeps the merged image. VectorCraft's own rules: - Each layer becomes a layer holding its pixels as an image, with its name, visibility, opacity and blend mode. A group becomes a layer of sublayers; a Pass Through group is not isolated. - Clipped layers show where the layer below has pixels. That layer's fill opacity fades only its own pixels. Layers clipped to an adjustment layer, or to a fill that stores no pixels and isn't drawn, show over the whole canvas. - A layer mask becomes the layer's opacity mask, its default color and invert flag applied; a disabled mask is kept, switched off. - Fill opacity multiplies the layer's opacity. - Layers and groups in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, whether or not they hold transparent pixels; Color Dodge, Color Burn and Difference keep their mode. A merged Dissolve layer's pixels are opaque or transparent, in a pattern of each layer's own. - A solid color fill layer that stores no pixels and has no vector mask comes in as its color (RGB, CMYK, Grayscale, Lab or HSB). - A layer name stored only as a Pascal string, without its Unicode name, is read as Mac Roman. - Adjustment layers, fill layers that aren't drawn, layer effects that are switched on, knockouts and vector masks are not applied; the warnings count them. - A merge whose canvases need more memory than allowed leaves the layer alone, in the Normal mode or unclipped, with a warning. This changes what opening a layered PSD does. Batch Trace Folder passes layers: false, so a PSD is still traced as its merged image. A file whose layers can't be read, or whose layers and merges need more memory than allowed, opens as the merged image with a warning. Lengths, counts, nesting, decoded pixels and the canvases merges make are bounded. Zipped 32-bit data with prediction is now read, in layers and in the merged image. import_fuzz gains a case that mutates the PSD and PSB samples and opens them with their layers. The PSD reader's new error messages have rows in every catalog. Docs: docs/mcp.md, docs/target-app-parity.md, docs/file-format-parity.md, README. --- README.md | 2 +- .../src/cmd/addons/document/batch_trace.rs | 3 +- .../engine/src/cmd/addons/document/tests.rs | 28 + crates/engine/src/cmd/fileio.rs | 3 + crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/formats.rs | 3 +- crates/engine/src/cmd/fileio/load.rs | 10 + crates/engine/src/cmd/fileio/pdfimport.rs | 28 +- crates/engine/src/cmd/fileio/psdlayers.rs | 1529 +++++++++++++++++ crates/engine/src/cmd/fileio/psdread.rs | 209 ++- .../engine/src/cmd/fileio/tests_psdlayers.rs | 1017 +++++++++++ crates/engine/src/cmd/fileio/tests_psdread.rs | 25 + crates/engine/src/cmd/place/text.rs | 2 +- crates/engine/tests/import_fuzz.rs | 21 + crates/ui-egui/src/i18n/bg.tsv | 8 +- crates/ui-egui/src/i18n/bs.tsv | 8 +- crates/ui-egui/src/i18n/ca.tsv | 8 +- crates/ui-egui/src/i18n/cs.tsv | 8 +- crates/ui-egui/src/i18n/da.tsv | 8 +- crates/ui-egui/src/i18n/de.tsv | 10 +- crates/ui-egui/src/i18n/el.tsv | 8 +- crates/ui-egui/src/i18n/es.tsv | 8 +- crates/ui-egui/src/i18n/fi.tsv | 8 +- crates/ui-egui/src/i18n/fr.tsv | 8 +- crates/ui-egui/src/i18n/hi.tsv | 8 +- crates/ui-egui/src/i18n/hu.tsv | 8 +- crates/ui-egui/src/i18n/id.tsv | 8 +- crates/ui-egui/src/i18n/it.tsv | 8 +- crates/ui-egui/src/i18n/ja.tsv | 8 +- crates/ui-egui/src/i18n/ka.tsv | 8 +- crates/ui-egui/src/i18n/ko.tsv | 8 +- crates/ui-egui/src/i18n/nl.tsv | 8 +- crates/ui-egui/src/i18n/pl.tsv | 8 +- crates/ui-egui/src/i18n/pt-br.tsv | 8 +- crates/ui-egui/src/i18n/ru.tsv | 8 +- crates/ui-egui/src/i18n/sv.tsv | 8 +- crates/ui-egui/src/i18n/tr.tsv | 8 +- crates/ui-egui/src/i18n/uk.tsv | 8 +- crates/ui-egui/src/i18n/vi.tsv | 8 +- crates/ui-egui/src/i18n/zh-hans.tsv | 8 +- crates/ui-egui/src/i18n/zh-hant.tsv | 8 +- docs/file-format-parity.md | 4 +- docs/mcp.md | 61 +- docs/target-app-parity.md | 1 + 44 files changed, 3048 insertions(+), 118 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/psdlayers.rs create mode 100644 crates/engine/src/cmd/fileio/tests_psdlayers.rs diff --git a/README.md b/README.md index dc7a1cfa5f..7e49943a31 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu (92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and -files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package). +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out, PSD/PSB opened with their layers and placed as their merged image, JPEG XL in, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master diff --git a/crates/engine/src/cmd/addons/document/batch_trace.rs b/crates/engine/src/cmd/addons/document/batch_trace.rs index 61e59fc54f..084bd5ca27 100644 --- a/crates/engine/src/cmd/addons/document/batch_trace.rs +++ b/crates/engine/src/cmd/addons/document/batch_trace.rs @@ -113,7 +113,8 @@ pub(super) fn batch_trace_folder(s: &mut Session, p: &Value) -> Result { for path in &files { let opened_before = s.documents().len(); let one = |s: &mut Session| -> Result { - s.execute("document.open", &json!({ "path": path }))?; + // A PSD or PSB is traced as its merged image, not as the first of its layers. + s.execute("document.open", &json!({ "path": path, "layers": false }))?; s.execute("select.all", &json!({}))?; s.execute(trace, &json!({ "preset": preset }))?; let stem = fileio::file_stem(path); diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs index b0fbb41422..43e86caf52 100644 --- a/crates/engine/src/cmd/addons/document/tests.rs +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -333,6 +333,34 @@ fn batch_trace_folder_traces_and_saves_each_image() { let _ = std::fs::remove_dir_all(&dir); } +/// A layered PSD is traced as its merged image: a white layer under a black square gives the +/// square's shape, not a trace of the white layer alone. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_trace_folder_traces_a_layered_psd_as_its_merged_image() { + const C: &str = "addon.document.batchTraceFolder"; + let dir = folder("batch-trace-psd"); + let (src, out) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&src).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 24, "height": 24})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 24, "height": 24})).unwrap(); + s.execute("layer.new", &json!({"name": "Square"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 6, "y": 6, "width": 12, "height": 12})).unwrap(); + let psd = s.execute("document.export", &json!({"format": "psd"})).unwrap(); + std::fs::write(src.join("two.psd"), vectorcraft_format::base64_decode(psd["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + let r = + s.execute(C, &json!({"folder": src, "destinationFolder": out, "format": "svg", "preset": "Black and White Logo", "expand": true})).unwrap(); + assert_eq!(r["traced"].as_array().unwrap().len(), 1, "{r}"); + let svg = std::fs::read_to_string(out.join("two.svg")).unwrap(); + assert!(svg.contains("fill=\"#000000\""), "the black square is traced: {svg}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Batch Trace Folder rejects optional values with the wrong type before opening images. #[cfg(not(target_arch = "wasm32"))] #[test] diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 04a443ed4c..eaa417f4ef 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -50,6 +50,7 @@ mod pdfimport; pub mod pngtext; pub mod ppi; pub mod progress; +mod psdlayers; pub mod psdread; mod pxrread; mod save; @@ -218,6 +219,8 @@ mod tests_pdfx; #[cfg(test)] mod tests_psd; #[cfg(test)] +mod tests_psdlayers; +#[cfg(test)] mod tests_psdread; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 81e8168d06..13df0d5eb1 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 23aeb83bb0..78ca6149a2 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -493,7 +493,8 @@ pub const FORMATS: &[Format] = &[ // Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a // vector app for a raster editor's documents. reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false), - // Photoshop's large document format: read like a PSD (its merged image), never written. + // The large document form of PSD: read like a PSD (its layers, or its merged image), never + // written. reader("psb", "PSB", &["psb"], "image/x-psb", true), // PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't. reader("ps", "PostScript", &["ps"], "application/postscript", false), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 58fff3cad1..5f8b70187e 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -492,6 +492,16 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result preview_only = i.preview_only; (i.doc, i.warnings, false) } + // A PSD or PSB file's layers become layers (`layers`, default on); without layers, or with + // `layers: false`, its merged image opens. Layers that can't be read leave the merged image. + "psd" | "psb" if opts.layers => { + let cms = vectorcraft_color::cms::active(); + match super::psdlayers::import(bytes, opts.hidden_layers, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)) { + Ok(Some((doc, warnings))) => (doc, warnings, false), + Ok(None) => (raster_doc(&title, bytes)?, vec![], false), + Err(e) => (raster_doc(&title, bytes)?, vec![format!("the layers can't be read ({e}): the file opened as its merged image")], false), + } + } _ if format.raster => (raster_doc(&title, bytes)?, vec![], false), _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 2a67cf4a45..2a0dede17f 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -28,8 +28,10 @@ pub struct LoadOptions { /// What a PDF's text becomes. pub text_as: TextAs, /// A PDF's optional content groups become layers (else one layer per page, without the art - /// that is off). + /// that is off), and a PSD or PSB file's layers become layers (else its merged image opens). pub layers: bool, + /// A PSD or PSB file's hidden layers come in, hidden (`hiddenLayers`; else they are left out). + pub hidden_layers: bool, /// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]). pub dxf: vectorcraft_cad::ImportOptions, /// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its @@ -57,6 +59,7 @@ impl Default for LoadOptions { grays: Grays::default(), text_as: TextAs::default(), layers: true, + hidden_layers: false, dxf: Default::default(), editing_data: true, trust_editing_data: false, @@ -69,7 +72,8 @@ impl Default for LoadOptions { impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`, - /// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`), + /// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false), + /// `editingData` (true | false | `always`), /// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF /// import options). pub fn from_params(cmd: &str, p: &Value) -> Result { @@ -95,6 +99,10 @@ impl LoadOptions { None => true, Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?, }; + let hidden_layers = match p.get("hiddenLayers").filter(|v| !v.is_null()) { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "hiddenLayers must be true or false"))?, + }; let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) { None => (true, false), Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true), @@ -108,7 +116,21 @@ impl LoadOptions { .ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?, }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; - Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, legacy_names, native: true }) + Ok(Self { + pages, + crop, + password, + color_mode, + grays, + text_as, + layers, + hidden_layers, + dxf, + editing_data, + trust_editing_data, + legacy_names, + native: true, + }) } /// Does the document read only part of the file, or read it differently (a page range, another diff --git a/crates/engine/src/cmd/fileio/psdlayers.rs b/crates/engine/src/cmd/fileio/psdlayers.rs new file mode 100644 index 0000000000..c3c45029a0 --- /dev/null +++ b/crates/engine/src/cmd/fileio/psdlayers.rs @@ -0,0 +1,1529 @@ +//! File › Open of a PSD or PSB file with its layers as objects. The import option that converts +//! layers to objects +//! () +//! keeps as much of the layer structure as it can, merges the layers of a clipping mask into one +//! layer, and discards hidden layers that need merging; the rest of what this module does is +//! VectorCraft's own. Read from the layer records of the public PSD file format specification +//! (). +//! +//! Each layer becomes a layer of the document, with the layer's name, visibility, opacity and blend +//! mode, holding its pixels as an image at their place on the canvas. A layer group becomes a layer +//! whose sublayers are the group's layers (a pass-through group lets them blend with what is below +//! it; any other group is isolated). A layer mask becomes the layer's opacity mask: a gray image +//! whose outside is hidden or shown as the mask's default color says, kept and switched off when +//! the mask is disabled. A layer's opacity is multiplied by its fill opacity, except in a layer +//! that others are clipped to, whose fill opacity fades only its own pixels. A solid color fill +//! layer that stores no pixels and has no vector mask comes in as an image of its color over the +//! canvas (an RGB, CMYK, Grayscale, Lab or HSB color); type, shape and other fill layers come in as +//! the pixels the file stores for them. +//! +//! Layers clipped to the layer below them are merged into it as one image (named after them all, +//! joined with " + "), shown where the layer below has pixels. Layers clipped to an adjustment +//! layer, or to a fill layer drawn over the whole canvas, show over the whole canvas. A layer in a +//! blend mode VectorCraft doesn't have (Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear +//! Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color, Lighter Color) is merged with the +//! layers below it in its group. A Dissolve layer's partly transparent pixels are merged as opaque +//! or transparent, opaque at the share of its pixels their transparency gives, in a fixed pattern +//! that differs from layer to layer. When a merge's canvases would need more memory than allowed, +//! the layer comes in alone: in the Normal mode, or unclipped. +//! Hidden layers are left out unless `hidden` asks for them. A hidden layer that would need a merge +//! (one in a mode VectorCraft lacks, one that layers are clipped to, one clipped to another, or +//! one below a merged layer) is always left out, with the layers clipped to it. Adjustment layers, +//! fill layers that store no pixels and whose fill isn't read or whose shape is a vector mask, +//! layer effects that are switched on, knockouts and vector masks aren't applied; the warnings +//! count them and the hidden layers left out. +//! +//! The file is untrusted: every count, length and size is checked before anything is allocated, +//! groups nest at most [`MAX_DEPTH`] deep (layers deeper than a document may nest merge into the +//! group around them, with a warning), each layer's pixels, like the canvases one step of a +//! merge holds together, must fit the memory allowed, and all the pixels decoded and the canvases +//! the merges make must fit [`TOTAL_DECODED`] times that. + +use std::cell::Cell; +use std::sync::Arc; + +use image::RgbaImage; +use vectorcraft_color::BlendMode; +use vectorcraft_color::blend::{blend_rgb, composite}; +use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; +use vectorcraft_doc::{Document, ImageObject, LAYER_COLORS, LayerColor, Node, NodeKind, OpacityMask}; +use vectorcraft_geom::Affine; + +use super::load::{RasterImage, as_png}; +use super::psdread::{Header, Reader, byte, header, layer_channel, layer_info, long_key, row_bytes, sample, to_rgb}; +use crate::cmd::place::text::mac_roman; + +/// Most layer groups inside one another. +pub(super) const MAX_DEPTH: usize = 64; +/// The pixels all layers and masks decode to, merges' second readings included, and the canvases +/// the merges make may take this many times the memory allowed for one image. +const TOTAL_DECODED: u64 = 8; +/// Most channels one layer has (colors, transparency and masks), as many as a document may. +const MAX_CHANNELS: usize = 56; +/// The fewest bytes a layer record takes. +const MIN_RECORD: usize = 34; +const DAMAGED: &str = "the PSD file's layer records are damaged"; + +/// The keys of the tagged blocks that make a layer an adjustment layer. +const ADJUSTMENTS: [&[u8; 4]; 18] = [ + b"levl", b"curv", b"brit", b"blnc", b"hue ", b"hue2", b"selc", b"mixr", b"grdm", b"phfl", b"expA", b"vibA", b"thrs", b"nvrt", b"post", b"clrL", + b"blwh", b"CgEd", +]; + +/// A layer's blend mode. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Blend { + Mode(BlendMode), + /// A group's Pass Through. + Pass, + /// A mode VectorCraft doesn't have: the layer is merged with the layers below it. + Other(Other), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Other { + Dissolve, + LinearBurn, + LinearDodge, + VividLight, + LinearLight, + PinLight, + HardMix, + Subtract, + Divide, + DarkerColor, + LighterColor, +} + +impl Other { + fn label(self) -> &'static str { + match self { + Other::Dissolve => "Dissolve", + Other::LinearBurn => "Linear Burn", + Other::LinearDodge => "Linear Dodge", + Other::VividLight => "Vivid Light", + Other::LinearLight => "Linear Light", + Other::PinLight => "Pin Light", + Other::HardMix => "Hard Mix", + Other::Subtract => "Subtract", + Other::Divide => "Divide", + Other::DarkerColor => "Darker Color", + Other::LighterColor => "Lighter Color", + } + } +} + +/// The blend mode a record's key names (an unknown key: Normal). +fn blend(key: &[u8; 4]) -> Blend { + use BlendMode as M; + let mode = match key { + b"pass" => return Blend::Pass, + b"diss" => return Blend::Other(Other::Dissolve), + b"lbrn" => return Blend::Other(Other::LinearBurn), + b"lddg" => return Blend::Other(Other::LinearDodge), + b"vLit" => return Blend::Other(Other::VividLight), + b"lLit" => return Blend::Other(Other::LinearLight), + b"pLit" => return Blend::Other(Other::PinLight), + b"hMix" => return Blend::Other(Other::HardMix), + b"fsub" => return Blend::Other(Other::Subtract), + b"fdiv" => return Blend::Other(Other::Divide), + b"dkCl" => return Blend::Other(Other::DarkerColor), + b"lgCl" => return Blend::Other(Other::LighterColor), + b"dark" => M::Darken, + b"mul " => M::Multiply, + b"idiv" => M::ColorBurn, + b"lite" => M::Lighten, + b"scrn" => M::Screen, + b"div " => M::ColorDodge, + b"over" => M::Overlay, + b"sLit" => M::SoftLight, + b"hLit" => M::HardLight, + b"diff" => M::Difference, + b"smud" => M::Exclusion, + b"hue " => M::Hue, + b"sat " => M::Saturation, + b"colr" => M::Color, + b"lum " => M::Luminosity, + _ => M::Normal, + }; + Blend::Mode(mode) +} + +/// A rectangle of the canvas, in pixels: top, left, bottom, right. +#[derive(Clone, Copy, Debug, Default)] +struct Bounds { + top: i32, + left: i32, + bottom: i32, + right: i32, +} + +impl Bounds { + fn read(r: &mut Reader<'_>) -> Result { + let mut v = || r.u32().map(|v| v as i32); + Ok(Self { top: v()?, left: v()?, bottom: v()?, right: v()? }) + } + /// Width and height (0 when empty or inverted). + fn size(self) -> (usize, usize) { + let side = |a: i32, b: i32| usize::try_from(i64::from(b) - i64::from(a)).unwrap_or(0); + (side(self.left, self.right), side(self.top, self.bottom)) + } +} + +/// What a layer holds besides plain pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Content { + Pixels, + /// A type layer: its pixels come in. + Type, + /// An adjustment layer: not applied. + Adjustment, + /// A solid color fill layer: its stored pixels, or its color over the whole canvas when it + /// stores none (nothing when the color can't be read, or when its shape is a vector mask). + Solid(Option), + /// A gradient or pattern fill layer: its stored pixels, if any. + Fill, +} + +/// A solid color fill layer's color. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Paint { + /// sRGB, 0..1. + Rgb([f32; 3]), + /// Ink amounts, 0..1. + Cmyk([f32; 4]), +} + +/// A layer mask's place and its color outside it. +#[derive(Clone, Copy, Debug)] +struct MaskInfo { + bounds: Bounds, + default: u8, + disabled: bool, + /// The mask's values, and its default color, are read inverted. + inverted: bool, +} + +/// One layer record: a layer, or a group's opening or closing divider. +struct Record<'a> { + name: String, + bounds: Bounds, + /// Channel id (−1 transparency, −2 the layer mask, 0.. the colors) and its data, compression + /// method first. + channels: Vec<(i16, &'a [u8])>, + blend: Blend, + /// The layer's opacity, 0..1. + opacity: f32, + /// Its fill opacity, 0..1. + fill: f32, + clipped: bool, + hidden: bool, + mask: Option, + /// The section divider type: 0 a layer, 1 or 2 a group (open or closed in the panel), 3 the + /// end of a group (the record below its layers). + divider: u32, + content: Content, + effects: bool, + vector_mask: bool, + knockout: bool, +} + +/// The layer records of `info` (the layer count, the records, then their channels' data). +fn records(info: &[u8], psb: bool) -> Result>, String> { + let mut r = Reader::new(info); + let count = usize::from((r.u16()? as i16).unsigned_abs()); + if count.saturating_mul(MIN_RECORD) > info.len() { + return Err(DAMAGED.into()); + } + let mut heads = Vec::with_capacity(count); + for _ in 0..count { + heads.push(record(&mut r, psb)?); + } + let mut out = Vec::with_capacity(count); + for (mut rec, lengths) in heads { + rec.channels = lengths.into_iter().map(|(id, n)| r.take(n).map(|d| (id, d))).collect::>()?; + out.push(rec); + } + Ok(out) +} + +/// One record, with the lengths of its channels' data (which follows every record). +fn record<'a>(r: &mut Reader<'a>, psb: bool) -> Result<(Record<'a>, Vec<(i16, usize)>), String> { + let bounds = Bounds::read(r)?; + let n = usize::from(r.u16()?); + if n > MAX_CHANNELS { + return Err(DAMAGED.into()); + } + let mut lengths = Vec::with_capacity(n); + for _ in 0..n { + let id = r.u16()? as i16; + lengths.push((id, r.length(psb)?)); + } + if &r.array::<4>()? != b"8BIM" { + return Err(DAMAGED.into()); + } + let blend = blend(&r.array()?); + let opacity = f32::from(r.u8()?) / 255.0; + let clipped = r.u8()? != 0; + // Bit 1: hidden. + let hidden = r.u8()? & 2 != 0; + r.u8()?; + let extra = r.section(false)?; + let mut rec = Record { + name: String::new(), + bounds, + channels: vec![], + blend, + opacity, + fill: 1.0, + clipped, + hidden, + mask: None, + divider: 0, + content: Content::Pixels, + effects: false, + vector_mask: false, + knockout: false, + }; + rec.read_extra(extra, psb)?; + Ok((rec, lengths)) +} + +impl Record<'_> { + /// The layer mask, blending ranges, name and tagged blocks after the record's fixed part. A + /// damaged mask, blending ranges or name is an error; a damaged tagged block keeps what was + /// read before it. + fn read_extra(&mut self, extra: &[u8], psb: bool) -> Result<(), String> { + let damaged = |_| DAMAGED.to_string(); + let mut e = Reader::new(extra); + let mask = e.section(false).map_err(damaged)?; + self.mask = match mask_info(mask) { + Some(info) => Some(info), + None if mask.is_empty() => None, + None => return Err(DAMAGED.into()), + }; + // The blending ranges. + e.section(false).map_err(damaged)?; + // A Pascal string padded to a multiple of 4 bytes, length byte included, in the system's + // encoding (its Unicode name follows in `luni`), read as Mac Roman as psd-tools (MIT) reads + // it: , `psd/layer_and_mask.py`. + let n = usize::from(e.u8().map_err(damaged)?); + let name = e.take(n).map_err(damaged)?; + self.name = name.iter().map(|&b| mac_roman(b)).collect(); + e.take((4 - (n + 1) % 4) % 4).map_err(damaged)?; + // Effects in a descriptor (`lfx2`, `lmfx`) say whether they are switched on; the older + // `lrFX` block is counted whenever it is there. + let (mut effects, mut old_effects) = (None, false); + while e.rest().len() >= 12 { + let (Ok(signature), Ok(key)) = (e.array::<4>(), e.array::<4>()) else { break }; + if !matches!(&signature, b"8BIM" | b"8B64") { + break; + } + let Ok(data) = e.section(psb && long_key(&key)) else { break }; + match &key { + b"luni" => { + if let Some(name) = unicode(data) { + self.name = name; + } + } + b"lsct" | b"lsdk" => { + if let Some(t) = data.get(..4) { + self.divider = u32::from_be_bytes([t[0], t[1], t[2], t[3]]); + } + if data.get(4..8) == Some(b"8BIM") + && let Some(Ok(key)) = data.get(8..12).map(<[u8; 4]>::try_from) + { + self.blend = blend(&key); + } + } + b"iOpa" => self.fill = data.first().map_or(1.0, |&v| f32::from(v) / 255.0), + b"TySh" | b"tySh" => self.content = Content::Type, + b"SoCo" => self.content = Content::Solid(solid_color(data)), + b"GdFl" | b"PtFl" => self.content = Content::Fill, + b"lfx2" | b"lmfx" => effects = Some(effects == Some(true) || effects_on(data)), + b"lrFX" => old_effects = true, + b"vmsk" | b"vsms" => self.vector_mask = true, + // Shallow or deep knockout. + b"knko" => self.knockout = data.first().is_some_and(|&k| k != 0), + k if ADJUSTMENTS.contains(&k) => self.content = Content::Adjustment, + _ => {} + } + // Some writers pad blocks further: skip to the next signature. + for _ in 0..3 { + if e.rest().is_empty() || e.rest().starts_with(b"8BIM") || e.rest().starts_with(b"8B64") { + break; + } + let _ = e.take(1); + } + } + self.effects = effects.unwrap_or(old_effects); + Ok(()) + } + + /// Does the record store no pixels (bounds of no area, or no color channel)? + fn stores_nothing(&self) -> bool { + let (w, h) = self.bounds.size(); + w == 0 || h == 0 || !self.channels.iter().any(|(id, _)| *id >= 0) + } + + /// Is the layer a fill drawn from its color over the whole canvas (a solid color fill that + /// stores no pixels and has no vector mask)? + fn color_over_canvas(&self) -> bool { + matches!(self.content, Content::Solid(Some(_))) && self.stores_nothing() && !self.vector_mask + } + + /// Does the layer cover the whole canvas without drawing (an adjustment layer, or a fill that + /// stores no pixels, has no vector mask and isn't drawn from its color)? Layers clipped to it + /// show over the whole canvas. + fn covers_without_drawing(&self) -> bool { + match self.content { + Content::Adjustment => true, + Content::Solid(None) | Content::Fill => self.stores_nothing() && !self.vector_mask, + _ => false, + } + } + + /// Is the layer a fill that stores no pixels and whose shape is a vector mask, so that it + /// isn't drawn? + fn undrawn_shape(&self) -> bool { + matches!(self.content, Content::Solid(_) | Content::Fill) && self.stores_nothing() && self.vector_mask + } +} + +/// Does an effects descriptor (`lfx2`, `lmfx`) have its master switch on and an effect switched on? +/// Read as psd-tools (MIT) reads them, a missing switch being off: `masterFXSwitch` and each +/// effect's `enab` (, `api/effects.py`). +fn effects_on(data: &[u8]) -> bool { + // A key, then its type `bool`, then one byte. + let values = |key: &'static [u8]| data.windows(key.len() + 1).filter(move |w| w.starts_with(key)).filter_map(|w| w.last().map(|&v| v != 0)); + values(b"masterFXSwitchbool").any(|on| on) && values(b"enabbool").any(|on| on) +} + +/// The layer mask data's place, default color, disabled flag (bit 1) and invert flag (bit 2). +fn mask_info(data: &[u8]) -> Option { + let mut r = Reader::new(data); + let bounds = Bounds::read(&mut r).ok()?; + let default = r.u8().ok()?; + let flags = r.u8().ok()?; + Some(MaskInfo { bounds, default, disabled: flags & 2 != 0, inverted: flags & 4 != 0 }) +} + +/// A `luni` block's name: a count of UTF-16 units, then the units. +fn unicode(data: &[u8]) -> Option { + let mut r = Reader::new(data); + let n = usize::try_from(r.u32().ok()?).ok()?; + let units: Vec = r.take(n.checked_mul(2)?).ok()?.as_chunks::<2>().0.iter().map(|u| u16::from_be_bytes(*u)).collect(); + Some(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()) +} + +/// The color of a solid color fill layer's descriptor, from the values after its color keys: red, +/// green and blue (0..255, or 0..1 in the `redFloat` keys), cyan, magenta, yellow and black (0..100 +/// percent ink), gray (0..100 percent black), Lab (L 0..100, a and b) or hue (degrees), saturation +/// and brightness (0..100). `None` for a color in another model. +fn solid_color(data: &[u8]) -> Option { + // A key, then `doub` and a double, or `UntF`, a unit and a double. + let value = |key: &[u8]| -> Option { + let at = data.windows(key.len()).position(|w| w == key)? + key.len(); + let at = match data.get(at..at + 4)? { + b"doub" => at + 4, + b"UntF" => at + 8, + _ => return None, + }; + let v = f64::from_be_bytes(data.get(at..at + 8)?.try_into().ok()?); + v.is_finite().then_some(v) + }; + let unit = |v: f64| v.clamp(0.0, 1.0) as f32; + let rgb = |keys: [&[u8]; 3], scale: f64| -> Option<[f32; 3]> { + Some([unit(value(keys[0])? / scale), unit(value(keys[1])? / scale), unit(value(keys[2])? / scale)]) + }; + if let Some(c) = rgb([b"Rd ", b"Grn ", b"Bl "], 255.0).or_else(|| rgb([b"redFloat", b"greenFloat", b"blueFloat"], 1.0)) { + return Some(Paint::Rgb(c)); + } + if let (Some(c), Some(m), Some(y), Some(k)) = (value(b"Cyn "), value(b"Mgnt"), value(b"Ylw "), value(b"Blck")) { + return Some(Paint::Cmyk([c, m, y, k].map(|v| unit(v / 100.0)))); + } + if let Some(g) = value(b"Gry ") { + return Some(Paint::Rgb([unit(1.0 - g / 100.0); 3])); + } + if let (Some(l), Some(a), Some(b)) = (value(b"Lmnc"), value(b"A "), value(b"B ")) { + let [r, g, b] = lab_to_srgb(Lab { l: l as f32, a: a as f32, b: b as f32 }); + return Some(Paint::Rgb([r, g, b].map(|v| v.clamp(0.0, 1.0)))); + } + let (h, s, b) = (value(b"H ")?, value(b"Strt")?, value(b"Brgh")?); + Some(Paint::Rgb(hsb(h, unit(s / 100.0), unit(b / 100.0)))) +} + +/// Hue `h` (degrees), saturation `s` and brightness `b` (0..1) as RGB. +fn hsb(h: f64, s: f32, b: f32) -> [f32; 3] { + let h = (h.rem_euclid(360.0) / 60.0) as f32; + let f = |n: f32| { + let k = (n + h) % 6.0; + b - b * s * k.min(4.0 - k).clamp(0.0, 1.0) + }; + [f(5.0), f(3.0), f(1.0)] +} + +/// The layer tree: records in the order the file stores them, bottom first. +enum Item { + Layer(usize), + /// A group's record and its layers. + Group(usize, Vec), +} + +fn tree(records: &[Record<'_>]) -> Result, String> { + let mut stack: Vec> = vec![vec![]]; + for (i, rec) in records.iter().enumerate() { + match rec.divider { + 3 => { + if stack.len() > MAX_DEPTH { + return Err("the PSD file's layer groups are nested too deeply".into()); + } + stack.push(vec![]); + } + 1 | 2 => { + // A group without its end divider holds nothing. + let children = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { vec![] }; + if let Some(top) = stack.last_mut() { + top.push(Item::Group(i, children)); + } + } + _ => { + if let Some(top) = stack.last_mut() { + top.push(Item::Layer(i)); + } + } + } + } + // Groups the file never closes: their layers join the group around them. + while stack.len() > 1 { + let inner = stack.pop().unwrap_or_default(); + if let Some(top) = stack.last_mut() { + top.extend(inner); + } + } + Ok(stack.pop().unwrap_or_default()) +} + +/// Straight RGBA, 0..1, at `left`, `top` on the canvas. +#[derive(Clone, Default)] +struct Canvas { + left: i64, + top: i64, + width: usize, + height: usize, + px: Vec<[f32; 4]>, +} + +impl Canvas { + fn from_image(left: i64, top: i64, img: &RgbaImage) -> Self { + let px = img.pixels().map(|p| p.0.map(|v| f32::from(v) / 255.0)).collect(); + Self { left, top, width: img.width() as usize, height: img.height() as usize, px } + } + fn get(&self, x: i64, y: i64) -> Option<[f32; 4]> { + let (x, y) = (usize::try_from(x - self.left).ok()?, usize::try_from(y - self.top).ok()?); + if x >= self.width || y >= self.height { + return None; + } + self.px.get(y * self.width + x).copied() + } + /// The canvas as an image, cropped to its painted pixels; `None` when nothing is painted. + fn to_image(&self) -> Option<(i64, i64, RgbaImage)> { + let painted = |x: usize, y: usize| self.px.get(y * self.width + x).is_some_and(|p| p[3] > 0.0); + let rows: Vec = (0..self.height).filter(|&y| (0..self.width).any(|x| painted(x, y))).collect(); + let (&y0, &y1) = (rows.first()?, rows.last()?); + let cols: Vec = (0..self.width).filter(|&x| (y0..=y1).any(|y| painted(x, y))).collect(); + let (&x0, &x1) = (cols.first()?, cols.last()?); + let img = RgbaImage::from_fn((x1 - x0 + 1) as u32, (y1 - y0 + 1) as u32, |x, y| { + let p = self.px.get((y0 + y as usize) * self.width + x0 + x as usize).copied().unwrap_or_default(); + image::Rgba(p.map(byte)) + }); + Some((self.left + x0 as i64, self.top + y0 as i64, img)) + } +} + +/// The blend function of a mode VectorCraft doesn't have, on straight RGB. +fn blend_other(mode: Other, b: [f32; 3], s: [f32; 3]) -> [f32; 3] { + let each = |f: fn(f32, f32) -> f32| [0, 1, 2].map(|i| f(b[i].clamp(0.0, 1.0), s[i].clamp(0.0, 1.0)).clamp(0.0, 1.0)); + // Darker and Lighter Color compare the total of the channel values. + let total = |c: [f32; 3]| c[0] + c[1] + c[2]; + let burn = |b: f32, s: f32| blend_rgb(BlendMode::ColorBurn, [b; 3], [s; 3])[0]; + let dodge = |b: f32, s: f32| blend_rgb(BlendMode::ColorDodge, [b; 3], [s; 3])[0]; + match mode { + // Dissolve's pixels are opaque or transparent (`coverage`); the opaque ones replace. + Other::Dissolve => s, + Other::LinearBurn => each(|b, s| b + s - 1.0), + Other::LinearDodge => each(|b, s| b + s), + Other::VividLight => [0, 1, 2].map(|i| { + let (b, s) = (b[i].clamp(0.0, 1.0), s[i].clamp(0.0, 1.0)); + if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) } + }), + Other::LinearLight => each(|b, s| b + 2.0 * s - 1.0), + Other::PinLight => each(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }), + Other::HardMix => each(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }), + Other::Subtract => each(|b, s| b - s), + Other::Divide => each(|b, s| if s <= 0.0 { if b <= 0.0 { 0.0 } else { 1.0 } } else { b / s }), + Other::DarkerColor => { + if total(s) < total(b) { + s + } else { + b + } + } + Other::LighterColor => { + if total(s) > total(b) { + s + } else { + b + } + } + } +} + +/// Source-over compositing of straight `s` onto straight `b` in `mode`, as +/// [`vectorcraft_color::blend::composite`] does for the modes it has. +fn over(mode: Blend, b: [f32; 4], s: [f32; 4]) -> [f32; 4] { + let other = match mode { + Blend::Mode(m) => return composite(m, b, s), + Blend::Pass => return composite(BlendMode::Normal, b, s), + Blend::Other(o) => o, + }; + let (ab, a) = (b[3].clamp(0.0, 1.0), s[3].clamp(0.0, 1.0)); + let ao = a + ab * (1.0 - a); + if ao <= 0.0 { + return [0.0; 4]; + } + let mixed = blend_other(other, [b[0], b[1], b[2]], [s[0], s[1], s[2]]); + let c = |i: usize| ((1.0 - ab) * s[i] + ab * mixed[i]) * a + (1.0 - a) * ab * b[i]; + [c(0) / ao, c(1) / ao, c(2) / ao, ao] +} + +/// What one entry of a layer list becomes: a layer of the document. +struct Unit<'i> { + name: String, + visible: bool, + opacity: f32, + blend: Blend, + /// The record whose layer mask the layer takes. + mask_of: Option, + /// The record the layer comes from (each record has its own Dissolve pattern). + record: usize, + body: Body<'i>, + /// While layers are clipped to it: how its merged canvas is shaped. + clip: Option, +} + +impl Unit<'_> { + /// Is it what a merge of layers in modes VectorCraft lacks made: one image, visible, at full + /// opacity in the Normal mode, without a mask? + fn is_merge(&self) -> bool { + matches!(self.body, Body::Merged(_)) + && self.clip.is_none() + && self.mask_of.is_none() + && self.visible + && self.opacity >= 1.0 + && self.blend == Blend::Mode(BlendMode::Normal) + } +} + +/// The shape of a layer merged with the layers clipped to it. +enum Clip { + /// The layer's own transparency at each pixel of its canvas; the canvas holds its pixels at its + /// fill opacity, the clipped layers composited over them. + Shape(Vec), + /// The whole canvas of the file (a layer that covers it without drawing): the canvas holds the + /// clipped layers alone. + Whole, +} + +enum Body<'i> { + /// A layer record's pixels. + Layer(usize), + /// A group's layers. + Group(&'i [Item]), + /// Layers merged into one image. + Merged(Canvas), +} + +/// A unit's body drawn alone. +enum Drawn { + /// It draws nothing. + Nothing, + Canvas(Canvas), + /// Its canvas would need more memory than allowed. + TooLarge, +} + +/// What the warnings say. +#[derive(Default)] +struct Notes { + types: usize, + adjustments: usize, + fills: usize, + effects: usize, + vector_masks: usize, + knockouts: usize, + /// Hidden layers left out, and layers clipped to them. + hidden: usize, + merged: Vec<&'static str>, + unmerged: usize, + /// Clipped layers left unclipped: the merge needs more memory than allowed. + unclipped: usize, + /// Layers clipped to a fill that isn't drawn. + clipped_out: usize, +} + +/// The counts a merge that can't be made gives back. +#[derive(Clone, Copy)] +struct Counts { + skipped: usize, + merged: usize, + unmerged: usize, + unclipped: usize, + clipped_out: usize, +} + +/// `n` things: "1 layer was", "2 layers were". +fn count(n: usize, one: &str, many: &str) -> String { + if n == 1 { format!("1 {one} was") } else { format!("{n} {many} were") } +} + +impl Notes { + fn warnings(self) -> Vec { + let mut out = vec![]; + if self.types > 0 { + let n = if self.types == 1 { "1 type layer".to_string() } else { format!("{} type layers", self.types) }; + out.push(format!("{n} came in as pixels: the text isn't editable")); + } + if self.adjustments > 0 { + out.push(format!("{} not applied", count(self.adjustments, "adjustment layer", "adjustment layers"))); + } + if self.fills > 0 { + out.push(format!( + "{} not drawn (a gradient or pattern fill, a color that can't be read, or a shape that stores no pixels)", + count(self.fills, "fill layer", "fill layers") + )); + } + let layers = |n: usize| if n == 1 { "1 layer".to_string() } else { format!("{n} layers") }; + if self.effects > 0 { + out.push(format!("the layer effects of {} weren't drawn", layers(self.effects))); + } + if self.vector_masks > 0 { + out.push(format!("the vector masks of {} weren't applied", layers(self.vector_masks))); + } + if self.knockouts > 0 { + out.push(format!("the knockout of {} wasn't applied", layers(self.knockouts))); + } + if self.hidden > 0 { + out.push(format!("{} left out (hidden layers and the layers clipped to them)", count(self.hidden, "layer", "layers"))); + } + if self.clipped_out == 1 { + out.push("1 layer clipped to a fill layer that wasn't drawn was left out".into()); + } else if self.clipped_out > 1 { + out.push(format!("{} layers clipped to fill layers that weren't drawn were left out", self.clipped_out)); + } + if !self.merged.is_empty() { + out.push(format!("layers in blend modes VectorCraft doesn't have ({}) were merged with the layers below them", self.merged.join(", "))); + } + if self.unmerged > 0 { + out.push(format!( + "{} in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed", + layers(self.unmerged) + )); + } + if self.unclipped > 0 { + out.push(format!("{} clipped to the layer below came in unclipped: the merge needs more memory than allowed", layers(self.unclipped))); + } + out + } + + fn counts(&self, skipped: usize) -> Counts { + Counts { skipped, merged: self.merged.len(), unmerged: self.unmerged, unclipped: self.unclipped, clipped_out: self.clipped_out } + } +} + +struct Builder<'a, F> { + h: &'a Header<'a>, + records: &'a [Record<'a>], + /// Hidden layers come in, hidden. + hidden: bool, + max_alloc: u64, + /// Bytes held by the copies of what is below the pass-through groups being merged. + held: u64, + cmyk: F, + doc: Document, + /// Canvas pixels to points. + to_doc: Affine, + ppi: Option<(f64, f64)>, + layers: usize, + notes: Notes, + /// The records [`Notes`] counted. + noted: Vec, + /// The records counted as hidden layers left out. + skipped: Vec, + /// Those records, in the order they were counted. + skip_log: Vec, + /// Bytes of layer and mask pixels decoded, and of canvases the merges made, so far. + spent: Cell, +} + +impl [f32; 3]> Builder<'_, F> { + fn record(&self, i: usize) -> Result<&Record<'_>, String> { + self.records.get(i).ok_or_else(|| DAMAGED.to_string()) + } + + /// Refuse to allocate `bytes` past the memory allowed. + fn fits(&self, bytes: u64) -> Result<(), String> { + if bytes > self.max_alloc { Err("a layer of the PSD file is too large to read".into()) } else { Ok(()) } + } + + /// Count `bytes` of decoded pixels, or of a canvas a merge makes, against the whole import's + /// allowance, [`TOTAL_DECODED`] times the memory allowed for one image. + fn spend(&self, bytes: u64) -> Result<(), String> { + let total = self.spent.get().saturating_add(bytes); + self.spent.set(total); + if total > self.max_alloc.saturating_mul(TOTAL_DECODED) { + return Err("the PSD file's layers take more memory to read than allowed".into()); + } + Ok(()) + } + + /// Record `i`'s pixels as straight RGBA, at their place on the canvas; `None` when it has none. + fn image(&self, i: usize) -> Result, String> { + let rec = self.record(i)?; + let h = self.h; + let max_side = if h.psb { 300_000 } else { 30_000 }; + let (w, rows) = match rec.content { + Content::Adjustment => return Ok(None), + // A fill layer that stores no pixels (a shape layer stores its shape's; one that + // doesn't, with only its vector mask, isn't drawn). + Content::Solid(Some(paint)) if rec.color_over_canvas() => { + let (w, rows) = (h.width as usize, h.height as usize); + self.fits(4 * w as u64 * rows as u64)?; + self.spend(4 * w as u64 * rows as u64)?; + let rgb = match paint { + Paint::Rgb(rgb) => rgb, + Paint::Cmyk(ink) => (self.cmyk)(ink), + }; + let px = image::Rgba([byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), 255]); + return Ok(Some((0, 0, RgbaImage::from_pixel(h.width, h.height, px)))); + } + Content::Pixels | Content::Type | Content::Solid(_) | Content::Fill => rec.bounds.size(), + }; + if rec.stores_nothing() { + return Ok(None); + } + if w > max_side || rows > max_side { + return Err(DAMAGED.into()); + } + let colors = h.mode.colors(); + let row = row_bytes(w, h.depth); + self.fits(((colors as u64 + 1) * row as u64).saturating_add(4 * w as u64).saturating_mul(rows as u64))?; + self.spend(4 * w as u64 * rows as u64)?; + let plane = |id: i16| -> Result>, String> { + match rec.channels.iter().find(|(c, _)| *c == id) { + Some((_, data)) => layer_channel(data, rows, row, h.psb, h.depth).map(Some), + None => Ok(None), + } + }; + let planes = (0..colors as i16).map(plane).collect::, _>>()?; + let alpha = plane(-1)?; + let linear = h.depth == 32; + let img = RgbaImage::from_fn(w as u32, rows as u32, |x, y| { + let (x, y) = (x as usize, y as usize); + let at = |p: &Option>, linear: bool, missing: f32| { + p.as_ref().and_then(|p| p.get(y * row..(y + 1) * row)).map_or(missing, |r| sample(r, x, h.depth, linear)) + }; + let rgb = to_rgb(h, |c| planes.get(c).map_or(0.0, |p| at(p, linear, 0.0)), &self.cmyk); + image::Rgba([byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), byte(at(&alpha, false, 1.0))]) + }); + Ok(Some((i64::from(rec.bounds.left), i64::from(rec.bounds.top), img))) + } + + /// Record `i`'s layer mask as gray values at their place, with the value outside them; `None` + /// when it has none, or it is disabled and not `even_disabled`. + fn mask(&self, i: usize, even_disabled: bool) -> Result, String> { + let rec = self.record(i)?; + let Some(m) = rec.mask.filter(|m| even_disabled || !m.disabled) else { return Ok(None) }; + let Some((_, data)) = rec.channels.iter().find(|(c, _)| *c == -2) else { return Ok(None) }; + let (w, rows) = m.bounds.size(); + let max_side = if self.h.psb { 300_000 } else { 30_000 }; + if w > max_side || rows > max_side { + return Err(DAMAGED.into()); + } + let row = row_bytes(w, self.h.depth); + self.fits((row as u64 + w as u64).saturating_mul(rows as u64))?; + self.spend(w as u64 * rows as u64)?; + let plane = layer_channel(data, rows, row, self.h.psb, self.h.depth)?; + let read = |v: u8| if m.inverted { 255 - v } else { v }; + let img = image::GrayImage::from_fn(w as u32, rows as u32, |x, y| { + let r = plane.get(y as usize * row..(y as usize + 1) * row).unwrap_or_default(); + image::Luma([read(byte(sample(r, x as usize, self.h.depth, false)))]) + }); + Ok(Some((i64::from(m.bounds.left), i64::from(m.bounds.top), img, read(m.default)))) + } + + /// Multiply `c`'s transparency by record `i`'s layer mask. + fn apply_mask(&self, c: &mut Canvas, i: usize) -> Result<(), String> { + let Some((left, top, m, default)) = self.mask(i, false)? else { return Ok(()) }; + let (w, h) = (i64::from(m.width()), i64::from(m.height())); + for (k, p) in c.px.iter_mut().enumerate() { + let (x, y) = (c.left + (k % c.width.max(1)) as i64, c.top + (k / c.width.max(1)) as i64); + let inside = (left..left + w).contains(&x) && (top..top + h).contains(&y); + let v = if inside { m.get_pixel((x - left) as u32, (y - top) as u32).0[0] } else { default }; + p[3] *= f32::from(v) / 255.0; + } + Ok(()) + } + + /// What `items` (a layer list, bottom first) become, before the modes VectorCraft lacks are + /// merged: layers clipped to the one below are merged into it, and hidden ones are left out + /// unless `keep_hidden`. Hidden layers a clip would merge are always left out. + fn units<'i>(&mut self, items: &'i [Item], keep_hidden: bool) -> Result>, String> { + let mut out: Vec> = vec![]; + // The layer the next clipped layers clip to: its index in `out`, or `None` when it was left + // out (its clipped layers go with it). + let mut base: Option> = None; + // The next clipped layers come in unclipped: a merge with their base didn't fit. + let mut unclipped = false; + for item in items { + let (i, body) = match item { + Item::Layer(i) => (*i, Body::Layer(*i)), + Item::Group(i, children) => (*i, Body::Group(children)), + }; + let rec = self.record(i)?; + let (name, hidden, clipped, blend) = (rec.name.clone(), rec.hidden, rec.clipped, rec.blend); + let opacity = rec.opacity * rec.fill; + if clipped && let Some(b) = base { + match b { + // Clipped to a layer left out, or hidden itself: left out. + None => self.skip(i), + Some(_) if hidden => self.skip(i), + // A hidden base (with `keep_hidden`) would need the merge: it is left out with + // the layers clipped to it. + Some(b) if out.get(b).is_some_and(|u| !u.visible) => { + if b < out.len() { + let u = out.remove(b); + self.skip(u.record); + } + self.skip(i); + base = Some(None); + } + Some(b) => { + // Counted even when it draws nothing (an adjustment layer). + self.note(i)?; + if !unclipped && self.clip(&mut out, b, &body, i, &name, blend, opacity)? { + continue; + } + // The merge doesn't fit: this layer and the next ones clipped to the same + // base come in as layers of their own. + unclipped = true; + self.notes.unclipped += 1; + out.push(Unit { name, visible: true, opacity, blend, mask_of: Some(i), record: i, body, clip: None }); + } + } + continue; + } + unclipped = false; + if hidden && !keep_hidden { + self.skip(i); + base = Some(None); + continue; + } + self.note(i)?; + out.push(Unit { name, visible: !hidden, opacity, blend, mask_of: Some(i), record: i, body, clip: None }); + base = Some(Some(out.len() - 1)); + } + for u in &mut out { + self.finish_clip(u); + } + Ok(out) + } + + /// Merge the layer of record `i` (`body`, named `name`, drawn alone with its layer mask, at + /// `opacity` in `blend`) into unit `b` of `out`, which it is clipped to. The base's own layer + /// mask stays its opacity mask, which hides the clipped layers with it. `false`, with `out` as + /// it was, when the canvases would need more memory than allowed. + #[allow(clippy::too_many_arguments)] + fn clip(&mut self, out: &mut [Unit<'_>], b: usize, body: &Body<'_>, i: usize, name: &str, blend: Blend, opacity: f32) -> Result { + let Some(u) = out.get(b) else { return Ok(true) }; + let (whole, undrawn, fill, base_opacity) = { + let r = self.record(u.record)?; + let layer = matches!(u.body, Body::Layer(_)); + (layer && r.covers_without_drawing(), layer && r.undrawn_shape(), r.fill, r.opacity) + }; + // A fill whose shape is a vector mask isn't drawn, and nothing clipped to it is either. + if undrawn && u.clip.is_none() { + self.notes.clipped_out += 1; + return Ok(true); + } + let layer = match self.draw_body(body, Some(i))? { + Drawn::Nothing => return Ok(true), + Drawn::TooLarge => return Ok(false), + Drawn::Canvas(c) => c, + }; + let room = self.room(); + let layer_bytes = 16 * layer.px.len() as u64; + let Some(u) = out.get_mut(b) else { return Ok(true) }; + if u.clip.is_none() { + let (canvas, clip) = if whole { + (Canvas::default(), Clip::Whole) + } else { + match self.draw_body(&u.body, None)? { + Drawn::TooLarge => return Ok(false), + // A base that draws nothing shows nothing of its clipped layers either. + Drawn::Nothing => (Canvas::default(), Clip::Shape(vec![])), + Drawn::Canvas(mut c) => { + // Its transparency, 4 bytes a pixel, is kept beside the canvas. + if (20 * c.px.len() as u64).saturating_add(layer_bytes) > room { + return Ok(false); + } + self.spend(4 * c.px.len() as u64)?; + let shape = c.px.iter().map(|p| p[3]).collect(); + for p in &mut c.px { + p[3] = fill; + } + (c, Clip::Shape(shape)) + } + } + }; + u.body = Body::Merged(canvas); + u.clip = Some(clip); + u.opacity = base_opacity; + } + let Body::Merged(c) = &mut u.body else { return Ok(true) }; + match &u.clip { + Some(Clip::Shape(_)) => { + if (20 * c.px.len() as u64).saturating_add(layer_bytes) > room { + return Ok(false); + } + clip_onto(c, &layer, blend, opacity, i); + } + _ => { + let mut acc = (!c.px.is_empty()).then(|| std::mem::take(c)); + let laid = blend_onto(&mut acc, &layer, blend, opacity, i, room, &|n| self.spend(n)); + *c = acc.unwrap_or_default(); + if !laid? { + return Ok(false); + } + } + } + u.name.push_str(" + "); + u.name.push_str(name); + Ok(true) + } + + /// Unit `u`'s merged canvas, once every layer clipped to it is in: transparent outside its + /// shape. + fn finish_clip(&self, u: &mut Unit<'_>) { + let Some(clip) = u.clip.take() else { return }; + let Body::Merged(c) = &mut u.body else { return }; + match clip { + Clip::Shape(shape) => c.px.iter_mut().zip(shape).for_each(|(p, a)| p[3] *= a), + Clip::Whole => { + let (w, h, width) = (i64::from(self.h.width), i64::from(self.h.height), c.width.max(1)); + for (k, p) in c.px.iter_mut().enumerate() { + let (x, y) = (c.left + (k % width) as i64, c.top + (k / width) as i64); + if !(0..w).contains(&x) || !(0..h).contains(&y) { + p[3] = 0.0; + } + } + } + } + } + + /// Count what record `i` holds that doesn't come in. + fn note(&mut self, i: usize) -> Result<(), String> { + // Once, though a merge may draw it again. + match self.noted.get_mut(i) { + Some(true) => return Ok(()), + Some(n) => *n = true, + None => {} + } + let (content, empty, effects, vector_mask, knockout) = { + let rec = self.record(i)?; + (rec.content, rec.stores_nothing(), rec.effects, rec.vector_mask, rec.knockout) + }; + match content { + Content::Type => self.notes.types += 1, + Content::Adjustment => self.notes.adjustments += 1, + // A fill drawn from neither its pixels nor its color. + Content::Fill | Content::Solid(None) if empty => self.notes.fills += 1, + Content::Solid(Some(_)) if empty && vector_mask => self.notes.fills += 1, + _ => {} + } + self.notes.effects += usize::from(effects); + self.notes.vector_masks += usize::from(vector_mask); + self.notes.knockouts += usize::from(knockout); + Ok(()) + } + + /// Count record `i` as a hidden layer left out (once). + fn skip(&mut self, i: usize) { + if let Some(s) = self.skipped.get_mut(i) + && !*s + { + *s = true; + self.notes.hidden += 1; + self.skip_log.push(i); + } + } + + /// `items`' units with the layers in blend modes VectorCraft lacks merged with those below. + fn merged<'i>(&mut self, items: &'i [Item], keep_hidden: bool) -> Result>, String> { + let mut out: Vec> = vec![]; + for mut u in self.units(items, keep_hidden)? { + let Blend::Other(other) = u.blend else { + out.push(u); + continue; + }; + // A hidden layer that would need the merge is left out. + if !u.visible { + self.skip(u.record); + continue; + } + // A merge that can't be made counts nothing its groups would have counted. + let saved = self.notes.counts(self.skip_log.len()); + let made = match out.as_mut_slice() { + // Onto the previous merge alone: composited onto its canvas, in place. + [prev] if prev.is_merge() => { + let mut acc = match &mut prev.body { + Body::Merged(c) => Some(std::mem::take(c)), + _ => None, + }; + let made = self.composite(&mut acc, &u); + if let Body::Merged(c) = &mut prev.body { + *c = acc.unwrap_or_default(); + } + if made? { + prev.name.push_str(" + "); + prev.name.push_str(&u.name); + true + } else { + false + } + } + _ => match self.merge(&out, &u)? { + Some(canvas) => { + let mut name = String::new(); + for (k, b) in out.iter_mut().filter(|b| b.visible).enumerate() { + if k == 0 { + name = std::mem::take(&mut b.name); + } else { + name.push_str(" + "); + name.push_str(&b.name); + } + } + if out.iter().any(|b| b.visible) { + name.push_str(" + "); + } + name.push_str(&u.name); + // Hidden layers below are left out with the rest. + for b in out.iter().filter(|b| !b.visible).map(|b| b.record).collect::>() { + self.skip(b); + } + out.clear(); + out.push(Unit { + name, + visible: true, + opacity: 1.0, + blend: Blend::Mode(BlendMode::Normal), + mask_of: None, + record: u.record, + body: Body::Merged(canvas), + clip: None, + }); + true + } + None => false, + }, + }; + if made { + if !self.notes.merged.contains(&other.label()) { + self.notes.merged.push(other.label()); + } + } else { + self.restore(saved); + self.notes.unmerged += 1; + u.blend = Blend::Mode(BlendMode::Normal); + out.push(u); + } + } + Ok(out) + } + + /// The counts as they were before a merge that couldn't be made. + fn restore(&mut self, c: Counts) { + for i in self.skip_log.split_off(c.skipped) { + if let Some(s) = self.skipped.get_mut(i) { + *s = false; + self.notes.hidden = self.notes.hidden.saturating_sub(1); + } + } + self.notes.merged.truncate(c.merged); + self.notes.unmerged = c.unmerged; + self.notes.unclipped = c.unclipped; + self.notes.clipped_out = c.clipped_out; + } + + /// The visible units `below` with `top` composited over them; `None` when the canvas would need + /// more memory than allowed. + fn merge(&mut self, below: &[Unit<'_>], top: &Unit<'_>) -> Result, String> { + let mut acc: Option = None; + for u in below.iter().filter(|u| u.visible).chain([top]) { + if !self.composite(&mut acc, u)? { + return Ok(None); + } + } + Ok(Some(acc.unwrap_or_default())) + } + + /// The bytes a merge step may still take: the memory allowed, less the copies of what is below + /// the pass-through groups being merged. + fn room(&self) -> u64 { + self.max_alloc.saturating_sub(self.held) + } + + /// Unit `u` composited onto `acc`. A pass-through group lays its layers onto `acc` one by one, + /// so that they blend with what is below it; at less than full opacity or with a layer mask, the + /// result is then faded back toward `acc` as it was by the opacity and the mask. Anything else + /// is drawn alone first. `false` when the canvas would need more memory than allowed. + fn composite(&mut self, acc: &mut Option, u: &Unit<'_>) -> Result { + if let (Body::Group(items), Blend::Pass) = (&u.body, u.blend) { + let mask = match u.mask_of { + Some(i) if self.has_mask(Some(i))? => self.mask(i, false)?, + _ => None, + }; + if u.opacity >= 1.0 && mask.is_none() { + return self.lay(acc, items); + } + let bytes = acc.as_ref().map_or(0, |c| 16 * c.px.len() as u64); + if bytes > self.room() { + return Ok(false); + } + self.spend(bytes)?; + let below = acc.clone(); + self.held = self.held.saturating_add(bytes); + let laid = self.lay(acc, items); + self.held = self.held.saturating_sub(bytes); + if !laid? { + return Ok(false); + } + if let Some(after) = acc.as_mut() { + fade(after, below.as_ref(), u.opacity, mask.as_ref()); + } + return Ok(true); + } + let c = match self.draw_body(&u.body, u.mask_of)? { + Drawn::Nothing => return Ok(true), + Drawn::Canvas(c) => c, + Drawn::TooLarge => return Ok(false), + }; + blend_onto(acc, &c, u.blend, u.opacity, u.record, self.room(), &|n| self.spend(n)) + } + + /// `items`' layers composited onto `acc` one by one; `false` when a canvas would need more + /// memory than allowed. + fn lay(&mut self, acc: &mut Option, items: &[Item]) -> Result { + for c in self.merged(items, false)? { + if !self.composite(acc, &c)? { + return Ok(false); + } + } + Ok(true) + } + + /// Does record `mask_of` have a layer mask that is switched on? + fn has_mask(&self, mask_of: Option) -> Result { + let Some(i) = mask_of else { return Ok(false) }; + let rec = self.record(i)?; + Ok(rec.mask.is_some_and(|m| !m.disabled) && rec.channels.iter().any(|(c, _)| *c == -2)) + } + + /// A unit's body drawn alone, with the layer mask of record `mask_of`, before its own opacity + /// and blend mode. Its canvas counts against the import's allowance. + fn draw_body(&mut self, body: &Body<'_>, mask_of: Option) -> Result { + let mut c = match body { + Body::Merged(c) => { + let bytes = 16 * c.px.len() as u64; + if bytes > self.room() { + return Ok(Drawn::TooLarge); + } + self.spend(bytes)?; + c.clone() + } + Body::Layer(i) => match self.image(*i)? { + Some((left, top, img)) => { + let bytes = 16 * u64::from(img.width()) * u64::from(img.height()); + if bytes > self.room() { + return Ok(Drawn::TooLarge); + } + self.spend(bytes)?; + Canvas::from_image(left, top, &img) + } + None => return Ok(Drawn::Nothing), + }, + Body::Group(items) => { + let mut acc: Option = None; + if !self.lay(&mut acc, items)? { + return Ok(Drawn::TooLarge); + } + let Some(acc) = acc else { return Ok(Drawn::Nothing) }; + acc + } + }; + if let Some(i) = mask_of { + self.apply_mask(&mut c, i)?; + } + Ok(Drawn::Canvas(c)) + } + + /// A new layer of the document named `name`, colored as the next new layer would be. + fn layer(&mut self, name: &str) -> Node { + let n = Node::layer(self.doc.alloc_id(), name, LayerColor::Preset((self.layers % LAYER_COLORS.len()) as u8)); + self.layers += 1; + n + } + + /// `img` at `left`, `top` on the canvas as an image of the document named `name`. + fn image_node(&mut self, name: &str, left: i64, top: i64, img: RgbaImage) -> Result { + let RasterImage { key, blob, width, height, .. } = as_png(img, None, self.ppi).map_err(|e| e.to_string())?; + let xf = self.to_doc * Affine::translate((left as f64, top as f64)); + let mut n = Node::new( + self.doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })), + ); + n.name = Some(name.to_string()); + self.doc.images.insert(key, blob); + Ok(n) + } + + /// Layers of the document for `items`, bottom first. + fn list(&mut self, items: &[Item]) -> Result>, String> { + let mut out = vec![]; + for u in self.merged(items, self.hidden)? { + if let Some(n) = self.node(u)? { + out.push(Arc::new(n)); + } + } + Ok(out) + } + + /// The layer of the document unit `u` becomes; `None` for a layer that draws nothing (an + /// adjustment layer, or one without pixels). + fn node(&mut self, u: Unit<'_>) -> Result, String> { + let children = match &u.body { + Body::Layer(i) => match self.image(*i)? { + Some((left, top, img)) => vec![Arc::new(self.image_node(&u.name, left, top, img)?)], + None => return Ok(None), + }, + Body::Merged(c) => match c.to_image() { + Some((left, top, img)) => vec![Arc::new(self.image_node(&u.name, left, top, img)?)], + None => return Ok(None), + }, + Body::Group(items) => self.list(items)?, + }; + let mut layer = self.layer(&u.name); + layer.visible = u.visible; + layer.opacity = u.opacity.clamp(0.0, 1.0); + (layer.blend, layer.isolate) = match u.blend { + Blend::Mode(m) => (m, matches!(u.body, Body::Group(_))), + Blend::Pass | Blend::Other(_) => (BlendMode::Normal, false), + }; + if let Some(c) = layer.children_mut() { + *c = children; + } + if let Some(i) = u.mask_of { + layer.extras_mut().mask = self.opacity_mask(i)?.map(Box::new); + } + Ok(Some(layer)) + } + + /// Record `i`'s layer mask as an opacity mask: a gray image, outside which the layer is hidden + /// or shown as the mask's default color says (a mask of no pixels hides all of it, or none). + fn opacity_mask(&mut self, i: usize) -> Result, String> { + let disabled = self.record(i)?.mask.is_some_and(|m| m.disabled); + let Some((left, top, gray, default)) = self.mask(i, true)? else { return Ok(None) }; + let (art, clip) = if gray.width() == 0 || gray.height() == 0 { + if default == 255 { + return Ok(None); + } + let black = RgbaImage::from_pixel(1, 1, image::Rgba([0, 0, 0, 255])); + (self.image_node("Layer Mask", left, top, black)?, true) + } else { + self.fits(5 * u64::from(gray.width()) * u64::from(gray.height()))?; + let rgba = RgbaImage::from_fn(gray.width(), gray.height(), |x, y| { + let v = gray.get_pixel(x, y).0[0]; + image::Rgba([v, v, v, 255]) + }); + (self.image_node("Layer Mask", left, top, rgba)?, default == 0) + }; + let mut mask = OpacityMask::new(art, clip); + mask.disabled = disabled; + Ok(Some(mask)) + } +} + +/// The coverage of a pixel at `x`, `y` of the canvas with `alpha`, 0..1, in `blend`: in Dissolve +/// fully opaque or fully transparent, opaque at a share of the pixels equal to `alpha`, chosen by a +/// fixed hash of the pixel's place and of `record`, so that each layer has its own pattern; `alpha` +/// in every other mode. +fn coverage(blend: Blend, alpha: f32, x: i64, y: i64, record: usize) -> f32 { + if !matches!(blend, Blend::Other(Other::Dissolve)) { + return alpha; + } + let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) + ^ (record as u64).wrapping_mul(0x1656_67B1_9E37_79F9); + h ^= h >> 31; + h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); + h ^= h >> 29; + // The top 24 bits, 0..1. + let r = (h >> 40) as f32 / (1u64 << 24) as f32; + if r < alpha { 1.0 } else { 0.0 } +} + +/// `layer` (of record `record`) composited onto `base`, the canvas of the layer it is clipped to, +/// in `blend` at `opacity`, where the two overlap. +fn clip_onto(base: &mut Canvas, layer: &Canvas, blend: Blend, opacity: f32, record: usize) { + let (x0, x1) = (base.left.max(layer.left), (base.left + base.width as i64).min(layer.left + layer.width as i64)); + let (y0, y1) = (base.top.max(layer.top), (base.top + base.height as i64).min(layer.top + layer.height as i64)); + for y in y0..y1 { + for x in x0..x1 { + let Some(mut s) = layer.get(x, y) else { continue }; + let k = (y - base.top) as usize * base.width + (x - base.left) as usize; + let Some(b) = base.px.get_mut(k) else { continue }; + s[3] = coverage(blend, s[3] * opacity, x, y, record); + *b = over(blend, *b, s); + } + } +} + +/// `after` faded back toward `before` (transparent where it has no pixels) by `opacity` times the +/// value of `mask` (its place, its gray values and the value outside it) at each pixel, in +/// premultiplied color. +fn fade(after: &mut Canvas, before: Option<&Canvas>, opacity: f32, mask: Option<&(i64, i64, image::GrayImage, u8)>) { + let width = after.width.max(1); + for (k, p) in after.px.iter_mut().enumerate() { + let (x, y) = (after.left + (k % width) as i64, after.top + (k / width) as i64); + let m = mask.map_or(255, |(left, top, m, default)| { + let inside = (*left..*left + i64::from(m.width())).contains(&x) && (*top..*top + i64::from(m.height())).contains(&y); + if inside { m.get_pixel((x - left) as u32, (y - top) as u32).0[0] } else { *default } + }); + let t = opacity * f32::from(m) / 255.0; + let b = before.and_then(|c| c.get(x, y)).unwrap_or([0.0; 4]); + let alpha = (1.0 - t) * b[3] + t * p[3]; + if alpha <= 0.0 { + *p = [0.0; 4]; + continue; + } + let mix = |i: usize| ((1.0 - t) * b[3] * b[i] + t * p[3] * p[i]) / alpha; + *p = [mix(0), mix(1), mix(2), alpha]; + } +} + +/// `src` (of record `record`) at `opacity` composited onto `acc` (nothing when `None`) in `blend`, +/// on a canvas covering both (`acc` itself when it covers `src`; a larger one is counted with +/// `spend`). `false`, with `acc` as it was, when the canvases held, `acc` and `src` and a larger +/// canvas when one is needed, would take more than `max_alloc` bytes. +fn blend_onto( + acc: &mut Option, + src: &Canvas, + blend: Blend, + opacity: f32, + record: usize, + max_alloc: u64, + spend: &dyn Fn(u64) -> Result<(), String>, +) -> Result { + let (mut left, mut top, mut right, mut bottom) = (src.left, src.top, src.left + src.width as i64, src.top + src.height as i64); + if let Some(a) = acc.as_ref() { + left = left.min(a.left); + top = top.min(a.top); + right = right.max(a.left + a.width as i64); + bottom = bottom.max(a.top + a.height as i64); + } + let (Ok(width), Ok(height)) = (usize::try_from(right - left), usize::try_from(bottom - top)) else { return Ok(false) }; + let held = (src.px.len() as u64).saturating_add(acc.as_ref().map_or(0, |a| a.px.len() as u64)); + let covers = acc.as_ref().is_some_and(|a| (a.left, a.top, a.width, a.height) == (left, top, width, height)); + if covers { + if held.saturating_mul(16) > max_alloc { + return Ok(false); + } + } else { + let bytes = (width as u64).saturating_mul(height as u64).saturating_mul(16); + if bytes.saturating_add(held.saturating_mul(16)) > max_alloc { + return Ok(false); + } + spend(bytes)?; + let mut out = Canvas { left, top, width, height, px: vec![[0.0; 4]; width * height] }; + if let Some(a) = acc.take() { + for (y, row) in a.px.chunks(a.width.max(1)).enumerate() { + let at = (a.top - top) as usize + y; + let start = at * width + (a.left - left) as usize; + if let Some(dst) = out.px.get_mut(start..start + row.len()) { + dst.copy_from_slice(row); + } + } + } + *acc = Some(out); + } + let Some(out) = acc.as_mut() else { return Ok(false) }; + // Only the source's rows and columns change. + let (sw, x0, y0) = (src.width.max(1), (src.left - left) as usize, (src.top - top) as usize); + for (k, s) in src.px.iter().enumerate() { + let Some(p) = out.px.get_mut((y0 + k / sw) * width + x0 + k % sw) else { continue }; + let a = coverage(blend, s[3] * opacity, src.left + (k % sw) as i64, src.top + (k / sw) as i64, record); + *p = over(blend, *p, [s[0], s[1], s[2], a]); + } + Ok(true) +} + +/// PSD or PSB file `bytes` with its layers as layers of the document (see the module +/// documentation), with warnings for what doesn't come in. `None` when the file has no layers (its +/// merged image is all of it). CMYK converts through `cmyk` (ink amounts 0..1 → sRGB). +pub(super) fn import( + bytes: &[u8], + hidden: bool, + max_alloc: u64, + cmyk: impl Fn([f32; 4]) -> [f32; 3], +) -> Result)>, String> { + let mut r = Reader::new(bytes); + let h = header(&mut r)?; + // Layer info that holds only the count (which says whether the merged image has transparency) + // has no layers either. + let Some(info) = layer_info(h.layers, h.psb).filter(|info| info.len() > 2) else { return Ok(None) }; + let records = records(info, h.psb)?; + if records.is_empty() { + return Ok(None); + } + let items = tree(&records)?; + let ppi = super::ppi::resolution(bytes).filter(|(x, y)| x.is_finite() && y.is_finite() && *x > 0.0 && *y > 0.0); + let (sx, sy) = crate::cmd::place::pt_per_px(ppi); + let doc = Document::new(f64::from(h.width) * sx, f64::from(h.height) * sy); + let mut b = Builder { + h: &h, + records: &records, + hidden, + max_alloc, + held: 0, + cmyk, + doc, + to_doc: Affine::scale_non_uniform(sx, sy), + ppi, + layers: 0, + notes: Notes::default(), + noted: vec![false; records.len()], + skipped: vec![false; records.len()], + skip_log: vec![], + spent: Cell::new(0), + }; + let layers = b.list(&items)?; + let Builder { mut doc, notes, .. } = b; + if !layers.is_empty() { + doc.layers = layers; + } + // Groups nest up to `MAX_DEPTH`, deeper than a document may: what is too deep merges into + // the group around it, with a warning. + let mut warnings = notes.warnings(); + doc.limit_depth_noting(&mut warnings); + Ok(Some((doc, warnings))) +} diff --git a/crates/engine/src/cmd/fileio/psdread.rs b/crates/engine/src/cmd/fileio/psdread.rs index 84ac8203aa..93e7b61f17 100644 --- a/crates/engine/src/cmd/fileio/psdread.rs +++ b/crates/engine/src/cmd/fileio/psdread.rs @@ -1,7 +1,8 @@ //! Reading Photoshop documents (PSD, and PSB, the large document format) for File › Open and //! File › Place (#918): the merged image, the flattened picture every PSD carries after its layers, //! as a linked PSD shows. Written from Adobe's public "Photoshop File Formats Specification" -//! (). Layers aren't read. +//! (). File › Open reads the +//! layers with [`super::psdlayers`], which shares this reader. //! //! Every mode Photoshop saves a merged image in is read: Bitmap, Grayscale, Duotone (as its //! grayscale data), Indexed, RGB, CMYK and Lab, at 1, 8, 16 and 32 bits per channel, stored raw, @@ -16,7 +17,7 @@ use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; /// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Mode { +pub(super) enum Mode { Bitmap, Gray, Indexed, @@ -27,7 +28,7 @@ enum Mode { impl Mode { /// The channels holding colour (an alpha channel may follow them). - fn colors(self) -> usize { + pub(super) fn colors(self) -> usize { match self { Mode::Bitmap | Mode::Gray | Mode::Indexed => 1, Mode::Rgb | Mode::Lab => 3, @@ -46,68 +47,68 @@ pub fn is_psb(bytes: &[u8]) -> bool { is_psd(bytes) && bytes.get(4..6) == Some(&[0, 2]) } -const TRUNCATED: &str = "the PSD file is cut short"; +pub(super) const TRUNCATED: &str = "the PSD file is cut short"; const DAMAGED: &str = "the PSD file's header is damaged"; /// Big-endian reads through `b`, each checked against its end. -struct Reader<'a> { +pub(super) struct Reader<'a> { b: &'a [u8], at: usize, } impl<'a> Reader<'a> { - fn new(b: &'a [u8]) -> Self { + pub(super) fn new(b: &'a [u8]) -> Self { Self { b, at: 0 } } - fn take(&mut self, n: usize) -> Result<&'a [u8], String> { + pub(super) fn take(&mut self, n: usize) -> Result<&'a [u8], String> { let s = self.at.checked_add(n).and_then(|end| self.b.get(self.at..end)).ok_or(TRUNCATED)?; self.at += n; Ok(s) } - fn array(&mut self) -> Result<[u8; N], String> { + pub(super) fn array(&mut self) -> Result<[u8; N], String> { self.take(N)?.try_into().map_err(|_| TRUNCATED.to_string()) } - fn u8(&mut self) -> Result { + pub(super) fn u8(&mut self) -> Result { self.array::<1>().map(|[v]| v) } - fn u16(&mut self) -> Result { + pub(super) fn u16(&mut self) -> Result { self.array().map(u16::from_be_bytes) } - fn u32(&mut self) -> Result { + pub(super) fn u32(&mut self) -> Result { self.array().map(u32::from_be_bytes) } /// A section length: 4 bytes in a PSD, 8 in a PSB. - fn length(&mut self, psb: bool) -> Result { + pub(super) fn length(&mut self, psb: bool) -> Result { let n = if psb { self.array().map(u64::from_be_bytes)? } else { u64::from(self.u32()?) }; usize::try_from(n).map_err(|_| TRUNCATED.to_string()) } /// A section: its length, then that many bytes. - fn section(&mut self, psb: bool) -> Result<&'a [u8], String> { + pub(super) fn section(&mut self, psb: bool) -> Result<&'a [u8], String> { let n = self.length(psb)?; self.take(n) } - fn rest(&self) -> &'a [u8] { + pub(super) fn rest(&self) -> &'a [u8] { self.b.get(self.at..).unwrap_or_default() } } /// The fixed-size header and the sections before the image data. -struct Header<'a> { - psb: bool, +pub(super) struct Header<'a> { + pub(super) psb: bool, channels: usize, - width: u32, - height: u32, - depth: u16, - mode: Mode, + pub(super) width: u32, + pub(super) height: u32, + pub(super) depth: u16, + pub(super) mode: Mode, /// Colour mode data: an indexed image's palette (256 reds, then greens, then blues). palette: &'a [u8], /// The image resources. resources: &'a [u8], /// The layer and mask information. - layers: &'a [u8], + pub(super) layers: &'a [u8], } -fn header<'a>(r: &mut Reader<'a>) -> Result, String> { +pub(super) fn header<'a>(r: &mut Reader<'a>) -> Result, String> { if !is_psd(r.b) { return Err("not a PSD file".into()); } @@ -183,41 +184,45 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { /// count is negative: in the layer info, or for 16- and 32-bit documents (whose layer info is /// empty) in their `Lr16` / `Lr32` block. fn merged_alpha(layers: &[u8], psb: bool) -> bool { - let count_negative = |info: &[u8]| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0); + layer_info(layers, psb).is_some_and(|info| info.get(..2).is_some_and(|c| i16::from_be_bytes([c[0], c[1]]) < 0)) +} + +/// Do tagged blocks with this key give an 8-byte length in a PSB? +pub(super) fn long_key(key: &[u8; 4]) -> bool { + matches!(key, b"Lr16" | b"Lr32" | b"Layr" | b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD") +} + +/// The layer info: the layer count, the layer records and their channels. It is the first part of +/// the layer and mask information, or for 16- and 32-bit documents (whose first part is empty) the +/// `Lr16` / `Lr32` block after it. `None` when the file has neither. +pub(super) fn layer_info(layers: &[u8], psb: bool) -> Option<&[u8]> { let mut r = Reader::new(layers); - let Ok(info) = r.section(psb) else { return false }; + let info = r.section(psb).ok()?; if !info.is_empty() { - return count_negative(info); + return Some(info); } // The global layer mask info, then tagged blocks: signature, key, length (8 bytes for the // layer blocks of a PSB), data padded to 4 bytes. - if r.section(false).is_err() { - return false; - } + r.section(false).ok()?; while r.rest().len() >= 12 { - let (Ok(_), Ok(key)) = (r.take(4), r.array::<4>()) else { return false }; - let layer_block = matches!(&key, b"Lr16" | b"Lr32" | b"Layr"); - let long = layer_block || matches!(&key, b"LMsk" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD"); - let Ok(data) = r.section(psb && long) else { - return false; - }; - if layer_block { - return count_negative(data); - } - if r.take((4 - data.len() % 4) % 4).is_err() { - return false; + r.take(4).ok()?; + let key = r.array::<4>().ok()?; + let data = r.section(psb && long_key(&key)).ok()?; + if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { + return Some(data); } + r.take((4 - data.len() % 4) % 4).ok()?; } - false + None } /// The most bytes one channel row may take (a PSB at 32 bits). -fn row_bytes(width: usize, depth: u16) -> usize { +pub(super) fn row_bytes(width: usize, depth: u16) -> usize { if depth == 1 { width.div_ceil(8) } else { width * usize::from(depth / 8) } } /// Undo PackBits: `src` into `dst` (a row; a short or overlong run leaves the rest as it is). -fn unpack_bits(mut src: &[u8], dst: &mut [u8]) { +pub(super) fn unpack_bits(mut src: &[u8], dst: &mut [u8]) { let mut at = 0; while let Some((&n, rest)) = src.split_first() { src = rest; @@ -269,32 +274,77 @@ fn channel_data(data: &[u8], h: &Header<'_>, channels: usize, row: usize) -> Res } Ok(out) } - 2 | 3 => { - let mut out = Vec::new(); - flate2::read::ZlibDecoder::new(r.rest()) - .take(wanted as u64) - .read_to_end(&mut out) - .map_err(|e| format!("the PSD file's image data can't be read: {e}"))?; - if out.len() < wanted { - return Err(TRUNCATED.into()); - } - if method == 3 { - // Each row stores the difference from the sample before it. - match h.depth { - 8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))), - 16 => out.chunks_exact_mut(row).for_each(|r| { - let mut prev = 0u16; - for s in r.as_chunks_mut::<2>().0 { - prev = prev.wrapping_add(u16::from_be_bytes(*s)); - *s = prev.to_be_bytes(); - } - }), - _ => return Err("zipped 32-bit PSD image data with prediction can't be read".into()), + 2 | 3 => inflate(r.rest(), wanted, method == 3, h.depth, row), + m => Err(format!("the PSD file's image data uses an unknown compression ({m})")), + } +} + +/// `wanted` bytes of zipped image data, rows of `row` bytes; with `predicted`, each row stores the +/// difference from the sample before it. In 32-bit data a row stores the first bytes of its samples, +/// then their second bytes, and so on, and the differences run byte by byte along the whole row (as +/// psd-tools, MIT, reads it: , `compression/__init__.py`). +fn inflate(src: &[u8], wanted: usize, predicted: bool, depth: u16, row: usize) -> Result, String> { + let mut out = Vec::new(); + flate2::read::ZlibDecoder::new(src) + .take(wanted as u64) + .read_to_end(&mut out) + .map_err(|e| format!("the PSD file's image data can't be read: {e}"))?; + if out.len() < wanted { + return Err(TRUNCATED.into()); + } + if predicted && row > 0 { + match depth { + 8 => out.chunks_exact_mut(row).for_each(|r| (1..r.len()).for_each(|i| r[i] = r[i].wrapping_add(r[i - 1]))), + 16 => out.chunks_exact_mut(row).for_each(|r| { + let mut prev = 0u16; + for s in r.as_chunks_mut::<2>().0 { + prev = prev.wrapping_add(u16::from_be_bytes(*s)); + *s = prev.to_be_bytes(); } + }), + 32 => { + let w = row / 4; + let mut planes = vec![0u8; row]; + for r in out.chunks_exact_mut(row) { + let mut prev = 0u8; + for (dst, &b) in planes.iter_mut().zip(r.iter()) { + prev = prev.wrapping_add(b); + *dst = prev; + } + for (x, s) in r.as_chunks_mut::<4>().0.iter_mut().enumerate() { + for (k, v) in s.iter_mut().enumerate() { + *v = planes.get(k * w + x).copied().unwrap_or(0); + } + } + } + } + // 1-bit. + _ => return Err("zipped 1-bit PSD image data with prediction can't be read".into()), + } + } + Ok(out) +} + +/// One channel of a layer: its compression method, then `rows` rows of `row` bytes (PackBits rows +/// after their byte counts, 4 bytes each in a PSB). +pub(super) fn layer_channel(data: &[u8], rows: usize, row: usize, psb: bool, depth: u16) -> Result, String> { + let mut r = Reader::new(data); + let method = r.u16()?; + let wanted = rows.checked_mul(row).ok_or(TRUNCATED)?; + match method { + 0 => r.take(wanted).map(<[u8]>::to_vec), + 1 => { + let size = if psb { 4 } else { 2 }; + let counts = r.take(rows.checked_mul(size).ok_or(TRUNCATED)?)?; + let mut out = vec![0; wanted]; + for (dst, n) in out.chunks_exact_mut(row.max(1)).zip(counts.chunks_exact(size)) { + let n = n.iter().fold(0usize, |n, &b| n << 8 | usize::from(b)); + unpack_bits(r.take(n)?, dst); } Ok(out) } - m => Err(format!("the PSD file's image data uses an unknown compression ({m})")), + 2 | 3 => inflate(r.rest(), wanted, method == 3, depth, row), + m => Err(format!("the PSD file's layer data uses an unknown compression ({m})")), } } @@ -304,7 +354,7 @@ fn srgb_encode(v: f32) -> f32 { } /// Sample `x` of a channel row, 0..1. -fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { +pub(super) fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { match depth { 1 => { // Bitmap: a set bit is black. @@ -321,10 +371,25 @@ fn sample(row: &[u8], x: usize, depth: u16, linear: bool) -> f32 { } } -fn byte(v: f32) -> u8 { +pub(super) fn byte(v: f32) -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } +/// The sRGB color of a pixel whose color channels `v` gives (0..1, as stored), in `h`'s mode. +pub(super) fn to_rgb(h: &Header<'_>, v: impl Fn(usize) -> f32, cmyk: &impl Fn([f32; 4]) -> [f32; 3]) -> [f32; 3] { + match h.mode { + Mode::Bitmap | Mode::Gray => [v(0); 3], + Mode::Indexed => { + let i = usize::from(byte(v(0))); + [0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0)) + } + Mode::Rgb => [v(0), v(1), v(2)], + // Inks are stored as 1 − amount. + Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))), + Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }), + } +} + /// A Photoshop document's merged image as straight RGBA. CMYK converts through `cmyk` (ink /// amounts 0..1 → sRGB). Fails, without allocating, when the image would need more than /// `max_alloc` bytes. @@ -348,17 +413,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64, cmyk: impl Fn([f32; 4]) -> [f32; 3]) let planes: Vec<&[u8]> = (0..channels).map(|c| data.get((c * rows + y) * row..(c * rows + y + 1) * row).unwrap_or_default()).collect(); for (x, px) in line.enumerate() { let v = |c: usize| planes.get(c).map_or(0.0, |p| sample(p, x, h.depth, linear && c < colors)); - let rgb = match h.mode { - Mode::Bitmap | Mode::Gray => [v(0); 3], - Mode::Indexed => { - let i = usize::from(byte(v(0))); - [0, 256, 512].map(|at| h.palette.get(at + i).map_or(0.0, |&c| f32::from(c) / 255.0)) - } - Mode::Rgb => [v(0), v(1), v(2)], - // Inks are stored as 1 − amount. - Mode::Cmyk => cmyk([0, 1, 2, 3].map(|c| 1.0 - v(c))), - Mode::Lab => lab_to_srgb(Lab { l: v(0) * 100.0, a: v(1) * 255.0 - 128.0, b: v(2) * 255.0 - 128.0 }), - }; + let rgb = to_rgb(&h, v, &cmyk); let a = if alpha { v(colors) } else { 1.0 }; // The merged image is stored on white: take the white back out. let straight = |c: f32| if a <= 0.0 { 0.0 } else { (c - (1.0 - a)) / a }; diff --git a/crates/engine/src/cmd/fileio/tests_psdlayers.rs b/crates/engine/src/cmd/fileio/tests_psdlayers.rs new file mode 100644 index 0000000000..31f670d7ff --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_psdlayers.rs @@ -0,0 +1,1017 @@ +//! Opening PSD and PSB files with their layers as layers (`document.open`'s `layers` and +//! `hiddenLayers`): names, visibility, opacity, blend modes, groups, layer masks, clipped layers +//! and blend modes VectorCraft lacks merged, 16-bit and PSB files, VectorCraft's own PSD export +//! read back, and damaged files read without a panic. + +use serde_json::{Value, json}; +use vectorcraft_color::BlendMode; +use vectorcraft_doc::{Node, NodeKind}; + +use super::*; + +/// One layer record of a synthetic file. +#[derive(Clone)] +struct L { + name: &'static str, + /// Top, left, bottom, right. + rect: [i32; 4], + /// Red, green, blue and alpha, the same for every pixel. + rgba: [u8; 4], + blend: &'static [u8; 4], + opacity: u8, + clipped: bool, + hidden: bool, + divider: Option, + /// Rect, default color, flags and the mask's pixels. + mask: Option<([i32; 4], u8, u8, Vec)>, + /// Extra tagged blocks. + blocks: Vec<([u8; 4], Vec)>, + /// Zipped channels: with prediction when `true`. + zip: Option, + /// The record lists no channels, whatever its rect. + bare: bool, + /// The Pascal name's bytes, with no Unicode name block. + legacy: Option<&'static [u8]>, +} + +fn layer(name: &'static str, rect: [i32; 4], rgba: [u8; 4]) -> L { + L { + name, + rect, + rgba, + blend: b"norm", + opacity: 255, + clipped: false, + hidden: false, + divider: None, + mask: None, + blocks: vec![], + zip: None, + bare: false, + legacy: None, + } +} + +fn group_end() -> L { + L { divider: Some(3), ..layer("", [0; 4], [0; 4]) } +} + +fn group(name: &'static str, blend: &'static [u8; 4]) -> L { + L { divider: Some(1), blend, ..layer(name, [0; 4], [0; 4]) } +} + +/// A tagged block, its length padded to even. +fn block(key: &[u8; 4], data: &[u8], psb: bool) -> Vec { + let mut data = data.to_vec(); + data.resize(data.len().next_multiple_of(2), 0); + let mut b = b"8BIM".to_vec(); + b.extend(key); + if psb && matches!(key, b"Lr16" | b"Lr32") { + b.extend((data.len() as u64).to_be_bytes()); + } else { + b.extend((data.len() as u32).to_be_bytes()); + } + b.extend(data); + b +} + +/// One row of a zipped channel with prediction: each sample stored as the difference from the one +/// before it, and in 32 bits the row's first bytes, then its second bytes and so on, each byte the +/// difference from the byte before it. +fn predict(row: &[u8], depth: u16) -> Vec { + let bytewise = |b: &[u8]| -> Vec { b.iter().scan(0u8, |prev, &v| Some(v.wrapping_sub(std::mem::replace(prev, v)))).collect() }; + match depth { + 16 => { + let mut prev = 0u16; + row.chunks(2) + .flat_map(|s| { + let v = u16::from_be_bytes([s[0], s[1]]); + v.wrapping_sub(std::mem::replace(&mut prev, v)).to_be_bytes() + }) + .collect() + } + 32 => bytewise(&(0..4).flat_map(|k| row.chunks(4).map(move |s| s[k])).collect::>()), + _ => bytewise(row), + } +} + +/// A channel of `n` samples of `v` (in 32 bits, `v` / 255 as a float) at `depth`, raw, zipped +/// (with prediction when `zip` is `Some(true)`) or PackBits rows of `width` when `rle`. +fn channel(v: u8, n: usize, width: usize, depth: u16, rle: bool, psb: bool, zip: Option) -> Vec { + let sample = if depth == 32 { (f32::from(v) / 255.0).to_be_bytes().to_vec() } else { vec![v; usize::from(depth / 8)] }; + let row: Vec = std::iter::repeat_n(sample.as_slice(), width).flatten().copied().collect(); + if let Some(predicted) = zip { + let rows = n.checked_div(width).unwrap_or(0); + let one = if predicted { predict(&row, depth) } else { row }; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + for _ in 0..rows { + std::io::Write::write_all(&mut z, &one).unwrap(); + } + let mut d = vec![0, if predicted { 3 } else { 2 }]; + d.extend(z.finish().unwrap()); + return d; + } + if !rle || n == 0 { + let mut d = vec![0, 0]; + d.extend(std::iter::repeat_n(sample.as_slice(), n).flatten()); + return d; + } + // Each row as literal runs of at most 128 bytes. + let mut packed = vec![]; + for run in row.chunks(128) { + packed.push((run.len() - 1) as u8); + packed.extend(run); + } + let rows = n / width; + let mut d = vec![0, 1]; + for _ in 0..rows { + if psb { + d.extend((packed.len() as u32).to_be_bytes()); + } else { + d.extend((packed.len() as u16).to_be_bytes()); + } + } + for _ in 0..rows { + d.extend(&packed); + } + d +} + +/// The layer info: the count (negative: the merged image has transparency), the records, then +/// their channels. +fn layer_info(layers: &[L], depth: u16, rle: bool, psb: bool) -> Vec { + let mut records: Vec = vec![]; + let mut data: Vec = vec![]; + for l in layers { + let [t, lf, b, r] = l.rect; + let (w, h) = ((r - lf).max(0) as usize, (b - t).max(0) as usize); + let mut channels: Vec<(i16, Vec)> = [(-1, l.rgba[3]), (0, l.rgba[0]), (1, l.rgba[1]), (2, l.rgba[2])] + .iter() + .filter(|_| !l.bare) + .map(|&(id, v)| (id, channel(v, w * h, w, depth, rle, psb, l.zip))) + .collect(); + let mut mask = vec![]; + if let Some(([mt, ml, mb, mr], default, flags, pixels)) = &l.mask { + let mut d = vec![0, 0]; + for &p in pixels { + d.extend(std::iter::repeat_n(p, usize::from(depth / 8))); + } + channels.push((-2, d)); + for v in [mt, ml, mb, mr] { + mask.extend(v.to_be_bytes()); + } + mask.extend([*default, *flags, 0, 0]); + } + for v in l.rect { + records.extend(v.to_be_bytes()); + } + records.extend((channels.len() as u16).to_be_bytes()); + for (id, d) in &channels { + records.extend(id.to_be_bytes()); + if psb { + records.extend((d.len() as u64).to_be_bytes()); + } else { + records.extend((d.len() as u32).to_be_bytes()); + } + data.extend(d); + } + records.extend(b"8BIM"); + records.extend(l.blend); + records.extend([l.opacity, u8::from(l.clipped), 0x08 | if l.hidden { 2 } else { 0 }, 0]); + let mut extra = (mask.len() as u32).to_be_bytes().to_vec(); + extra.extend(mask); + extra.extend(0u32.to_be_bytes()); + let ascii = l.legacy.unwrap_or(l.name.as_bytes()); + extra.push(ascii.len() as u8); + extra.extend(ascii); + // The Pascal string padded to a multiple of 4 (what comes before it is too). + extra.resize(extra.len().next_multiple_of(4), 0); + if l.legacy.is_none() { + let units: Vec = l.name.encode_utf16().collect(); + let mut luni = (units.len() as u32).to_be_bytes().to_vec(); + luni.extend(units.iter().flat_map(|u| u.to_be_bytes())); + extra.extend(block(b"luni", &luni, psb)); + } + if let Some(kind) = l.divider { + extra.extend(block(b"lsct", &[&kind.to_be_bytes()[..], b"8BIM", l.blend].concat(), psb)); + } + for (key, d) in &l.blocks { + extra.extend(block(key, d, psb)); + } + records.extend((extra.len() as u32).to_be_bytes()); + records.extend(extra); + } + let mut info = (-(layers.len() as i16)).to_be_bytes().to_vec(); + info.extend(records); + info.extend(data); + info.resize(info.len().next_multiple_of(2), 0); + info +} + +/// An RGB document of `w` × `h` pixels at 72 ppi holding `layers` (bottom first). 16- and 32-bit +/// documents keep their layers in an `Lr16` or `Lr32` block. +fn file(w: u32, h: u32, layers: &[L], depth: u16, rle: bool, psb: bool) -> Vec { + let info = layer_info(layers, depth, rle, psb); + let length = |n: usize, out: &mut Vec| { + if psb { + out.extend((n as u64).to_be_bytes()); + } else { + out.extend((n as u32).to_be_bytes()); + } + }; + let mut section = vec![]; + if depth == 8 { + length(info.len(), &mut section); + section.extend(info); + section.extend(0u32.to_be_bytes()); + } else { + length(0, &mut section); + section.extend(0u32.to_be_bytes()); + section.extend(block(if depth == 32 { b"Lr32" } else { b"Lr16" }, &info, psb)); + } + let mut f = b"8BPS".to_vec(); + f.extend(if psb { 2u16 } else { 1 }.to_be_bytes()); + f.extend([0; 6]); + f.extend(4u16.to_be_bytes()); + f.extend(h.to_be_bytes()); + f.extend(w.to_be_bytes()); + f.extend(depth.to_be_bytes()); + f.extend(3u16.to_be_bytes()); + f.extend(0u32.to_be_bytes()); + f.extend(0u32.to_be_bytes()); + length(section.len(), &mut f); + f.extend(section); + // The merged image: raw, mid gray, opaque. + f.extend([0, 0]); + let n = (w * h) as usize * usize::from(depth / 8); + f.extend(std::iter::repeat_n(128, n * 3)); + f.extend(std::iter::repeat_n(255, n)); + f +} + +/// The warning for one hidden layer left out. +const HIDDEN_1: &str = "1 layer was left out (hidden layers and the layers clipped to them)"; + +fn open(f: &[u8], p: Value) -> (Session, Value) { + let mut s = Session::new(); + let r = s.execute("document.open", &merge(json!({"name": "layers.psd", "dataBase64": vectorcraft_format::base64_encode(f)}), p)).unwrap(); + (s, r) +} + +fn name(n: &Node) -> &str { + n.name.as_deref().unwrap_or("") +} + +/// The layer's one image, named as the layer: its place (points) and its pixels. +fn image_of(s: &Session, l: &Node) -> ((f64, f64), image::RgbaImage) { + let Some([c]) = l.children().map(Vec::as_slice) else { panic!("{} holds {:?}", name(l), l.children().map(Vec::len)) }; + assert_eq!(name(c), name(l)); + pixels_of(s, c) +} + +/// An image's place (points) and its pixels. +fn pixels_of(s: &Session, c: &Node) -> ((f64, f64), image::RgbaImage) { + let NodeKind::Image(img) = &c.kind else { panic!("{} isn't an image", name(c)) }; + let blob = &s.doc().unwrap().doc.images[&img.key]; + let px = image::load_from_memory(&blob.bytes).unwrap().to_rgba8(); + assert_eq!((px.width(), px.height()), (img.width, img.height)); + let o = img.xf * kurbo::Point::ZERO; + ((o.x, o.y), px) +} + +/// Bottom, a pass-through group of a multiply layer at 50 % and a hidden layer, a screen group, and +/// a layer at 40 % fill opacity. +fn stack() -> Vec { + let mut fill = layer("Faded", [1, 1, 3, 3], [0, 0, 255, 255]); + fill.blocks.push((*b"iOpa", vec![102])); + vec![ + layer("Bottom", [0, 0, 8, 8], [255, 0, 0, 255]), + group_end(), + L { blend: b"mul ", opacity: 128, ..layer("Inner A", [0, 0, 4, 4], [0, 255, 0, 255]) }, + L { hidden: true, ..layer("Inner B", [4, 4, 8, 8], [0, 0, 255, 255]) }, + group("Folder", b"pass"), + group_end(), + layer("Lit", [2, 2, 6, 6], [10, 20, 30, 128]), + group("Screens", b"scrn"), + fill, + ] +} + +#[test] +fn layers_open_as_layers_with_their_names_visibility_opacity_and_blend_modes() { + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({})); + assert_eq!((&r["format"], &r["warnings"]), (&json!("psd"), &json!([HIDDEN_1])), "{r}"); + let d = &s.doc().unwrap().doc; + assert!((d.artboards[0].rect.width() - 8.0).abs() < 1e-9 && (d.artboards[0].rect.height() - 8.0).abs() < 1e-9); + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom", "Folder", "Screens", "Faded"], "bottom first"); + // The bottom layer's image covers the canvas in its color. + let ((x, y), px) = image_of(&s, &d.layers[0]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(3, 3).0), ((0.0, 0.0), (8, 8), [255, 0, 0, 255])); + // A pass-through group: a layer of sublayers that blend with what is below; its hidden layer + // is left out. + let folder = &d.layers[1]; + assert!(matches!(folder.kind, NodeKind::Layer { .. }) && !folder.isolate && folder.blend == BlendMode::Normal); + let inner: Vec<&str> = folder.children().unwrap().iter().map(|c| name(c)).collect(); + assert_eq!(inner, ["Inner A"]); + let a = &folder.children().unwrap()[0]; + assert!(a.blend == BlendMode::Multiply && (a.opacity - 128.0 / 255.0).abs() < 1e-6 && a.visible); + // Another group mode: isolated, in its mode; a layer keeps its transparency and place. + let screens = &d.layers[2]; + assert!(screens.isolate && screens.blend == BlendMode::Screen); + let ((x, y), px) = image_of(&s, &screens.children().unwrap()[0]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(0, 0).0), ((2.0, 2.0), (4, 4), [10, 20, 30, 128])); + // Fill opacity multiplies the opacity. + assert!((d.layers[3].opacity - 102.0 / 255.0).abs() < 1e-6, "{}", d.layers[3].opacity); + // Hidden layers come in hidden when asked for. + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({"hiddenLayers": true})); + assert_eq!(r["warnings"], json!([]), "{r}"); + let folder = &s.doc().unwrap().doc.layers[1]; + let inner: Vec<(&str, bool)> = folder.children().unwrap().iter().map(|c| (name(c), c.visible)).collect(); + assert_eq!(inner, [("Inner A", true), ("Inner B", false)]); + // `layers: false` opens the merged image, as before. + let (s, r) = open(&file(8, 8, &stack(), 8, false, false), json!({"layers": false})); + let d = &s.doc().unwrap().doc; + assert_eq!((d.layers.len(), d.layers[0].children().map(Vec::len)), (1, Some(1)), "{r}"); + assert_eq!(pixels_of(&s, &d.layers[0].children().unwrap()[0]).1.get_pixel(0, 0).0, [128, 128, 128, 255]); + let bad = Session::new().execute("document.open", &json!({"name": "a.psd", "dataBase64": "AAAA", "hiddenLayers": "yes"})); + assert!(bad.unwrap_err().to_string().contains("hiddenLayers must be true or false")); +} + +#[test] +fn sixteen_bit_packbits_and_psb_layers_read() { + for (depth, rle, psb) in [(16, false, false), (8, true, false), (8, true, true), (16, true, true)] { + let (s, r) = open(&file(8, 8, &stack(), depth, rle, psb), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom", "Folder", "Screens", "Faded"], "{depth} {rle} {psb}: {r}"); + let px = image_of(&s, &d.layers[2].children().unwrap()[0]).1; + assert_eq!(px.get_pixel(1, 1).0, [10, 20, 30, 128], "{depth} {rle} {psb}"); + } +} + +/// Zipped channels, with and without prediction, at 8, 16 and 32 bits (32-bit colors are linear, +/// so the test uses 0 and 1, which sRGB's curve keeps). +#[test] +fn zipped_and_32_bit_layers_read() { + for depth in [8, 16, 32] { + for zip in [None, Some(false), Some(true)] { + if depth != 32 && zip.is_none() { + continue; + } + let layers = [L { zip, ..layer("Back", [0, 0, 4, 4], [0, 0, 255, 255]) }, L { zip, ..layer("Front", [1, 1, 3, 4], [255, 0, 255, 128]) }]; + let (s, r) = open(&file(4, 4, &layers, depth, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Back", "Front"], "{depth} {zip:?}: {r}"); + let ((x, y), px) = image_of(&s, &d.layers[1]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(2, 1).0), ((1.0, 1.0), (3, 2), [255, 0, 255, 128]), "{depth} {zip:?}"); + } + } +} + +/// A layer without a Unicode name takes the name its Pascal string holds, read as Mac Roman. +#[test] +fn names_without_a_unicode_name_read_as_mac_roman() { + let l = L { legacy: Some(b"Caf\x8E \xD2Old\xD3"), ..layer("", [0, 0, 1, 1], [0, 0, 0, 255]) }; + let (s, r) = open(&file(1, 1, &[l], 8, false, false), json!({})); + assert_eq!(name(&s.doc().unwrap().doc.layers[0]), "Café “Old”", "{r}"); +} + +#[test] +fn layer_masks_become_opacity_masks() { + let mut masked = layer("Masked", [0, 0, 8, 8], [255, 255, 255, 255]); + masked.mask = Some(([2, 2, 4, 4], 0, 0, vec![255, 128, 0, 255])); + let mut shown = layer("Shown outside", [0, 0, 8, 8], [0, 0, 0, 255]); + shown.mask = Some(([0, 0, 1, 2], 255, 0, vec![0, 0])); + let mut off = layer("Off", [0, 0, 8, 8], [0, 0, 0, 255]); + off.mask = Some(([0, 0, 1, 1], 0, 2, vec![0])); + // Flag bit 2: the mask's values and its default color are read inverted. + let mut inverted = layer("Inverted", [0, 0, 8, 8], [0, 0, 0, 255]); + inverted.mask = Some(([0, 0, 1, 2], 0, 4, vec![255, 64])); + let (s, _) = open(&file(8, 8, &[masked, shown, off, inverted], 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let mask = |i: usize| d.layers[i].extras().mask.as_deref().expect("a mask"); + let m = mask(0); + assert!(m.clip && !m.disabled, "outside a mask with default 0 is hidden"); + let ((x, y), px) = pixels_of(&s, &m.art); + assert_eq!(((x, y), px.dimensions()), ((2.0, 2.0), (2, 2))); + assert_eq!([px.get_pixel(0, 0).0, px.get_pixel(1, 0).0, px.get_pixel(0, 1).0], [[255, 255, 255, 255], [128, 128, 128, 255], [0, 0, 0, 255]]); + assert!(!mask(1).clip, "outside a mask with default 255 shows"); + assert!(mask(2).disabled, "a disabled mask is kept, switched off"); + let m = mask(3); + assert!(!m.clip, "an inverted default of 0 shows outside the mask"); + let px = pixels_of(&s, &m.art).1; + assert_eq!([px.get_pixel(0, 0).0, px.get_pixel(1, 0).0], [[0, 0, 0, 255], [191, 191, 191, 255]]); +} + +#[test] +fn clipped_layers_merge_into_the_layer_below() { + let base = L { opacity: 200, ..layer("Base", [4, 4, 8, 8], [255, 255, 0, 255]) }; + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 8, 6], [0, 0, 255, 255]) }; + let hidden = L { clipped: true, hidden: true, ..layer("Gone", [0, 0, 8, 8], [0, 255, 0, 255]) }; + let (s, r) = + open(&file(8, 8, &[layer("Under", [0, 0, 2, 2], [1, 2, 3, 255]), base, clipped, hidden], 8, false, false), json!({"hiddenLayers": true})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Base + Clipped"], "{r}"); + assert_eq!(r["warnings"], json!([HIDDEN_1]), "a hidden clipped layer is left out: {r}"); + // The base keeps its place, transparency and opacity; the clipped layer shows only on it. + let merged = &d.layers[1]; + assert!((merged.opacity - 200.0 / 255.0).abs() < 1e-6); + let ((x, y), px) = image_of(&s, merged); + assert_eq!(((x, y), px.dimensions()), ((4.0, 4.0), (4, 4))); + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(3, 3).0), ([0, 0, 255, 255], [255, 255, 0, 255])); + // A base's layer mask stays the merged layer's opacity mask, over the unmasked pixels. + let mut masked = layer("Masked", [0, 0, 4, 4], [255, 255, 0, 255]); + masked.mask = Some(([0, 0, 1, 1], 0, 0, vec![255])); + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[masked, clipped], 8, false, false), json!({})); + let merged = &s.doc().unwrap().doc.layers[0]; + assert_eq!(name(merged), "Masked + Clipped", "{r}"); + assert!(merged.extras().mask.as_deref().is_some_and(|m| m.clip && !m.disabled), "{r}"); + let px = image_of(&s, merged).1; + assert_eq!((px.dimensions(), px.get_pixel(3, 3).0), ((4, 4), [0, 0, 255, 255])); + // A hidden base would need the merge: even with hidden layers asked for, it is left out with + // the layers clipped to it. + let hidden_base = L { hidden: true, ..layer("Hidden base", [0, 0, 4, 4], [255, 255, 0, 255]) }; + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let layers = [layer("Under", [0, 0, 2, 2], [1, 2, 3, 255]), hidden_base, clipped, layer("Over", [0, 0, 1, 1], [9, 9, 9, 255])]; + for hidden in [false, true] { + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({"hiddenLayers": hidden})); + let names: Vec<&str> = s.doc().unwrap().doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Over"], "{hidden}: {r}"); + assert_eq!(r["warnings"], json!(["2 layers were left out (hidden layers and the layers clipped to them)"]), "{hidden}: {r}"); + } +} + +/// A masked base merged with its clipped layers, then with a layer in a mode VectorCraft lacks, +/// still hides what its mask hides. +#[test] +fn a_merged_base_keeps_its_mask_in_a_later_merge() { + let mut masked = layer("Masked", [0, 0, 4, 4], [255, 0, 0, 255]); + masked.mask = Some(([0, 0, 1, 1], 0, 0, vec![255])); + let clipped = L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }; + let dodge = L { blend: b"lddg", ..layer("Dodge", [3, 3, 4, 4], [0, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 4, 4], [0, 255, 0, 255]), masked, clipped, dodge]; + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + assert_eq!(name(&d.layers[0]), "Bottom + Masked + Clipped + Dodge", "{r}"); + let px = image_of(&s, &d.layers[0]).1; + // Inside the mask the clipped layer shows; outside it, the layer below. + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(2, 2).0), ([0, 0, 255, 255], [0, 255, 0, 255])); +} + +#[test] +fn layers_in_modes_vectorcraft_lacks_merge_with_the_layers_below() { + let dodge = L { blend: b"lddg", ..layer("Dodge", [0, 0, 2, 2], [100, 100, 100, 255]) }; + let hidden = L { hidden: true, ..layer("Hidden", [0, 0, 8, 8], [0, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), hidden, dodge, layer("Top", [6, 6, 8, 8], [0, 0, 0, 255])]; + let (s, r) = open(&file(8, 8, &layers, 8, false, false), json!({"hiddenLayers": true})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Dodge", "Top"], "hidden layers a merge takes in are left out: {r}"); + let (_, px) = image_of(&s, &d.layers[0]); + // Linear Dodge adds: 100 + 100, 50 + 100, 0 + 100. + assert_eq!((px.get_pixel(0, 0).0, px.get_pixel(5, 5).0), ([200, 150, 100, 255], [100, 50, 0, 255])); + assert!(r["warnings"].to_string().contains("Linear Dodge"), "{r}"); + // Darker Color keeps the color whose channels add up to less: (0, 0, 200) over (100, 50, 0) + // has the larger total but the lower luminance, so the base stays. + let darker = L { blend: b"dkCl", ..layer("Darker", [0, 0, 2, 2], [0, 0, 200, 255]) }; + let (s, _) = open(&file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), darker], 8, false, false), json!({})); + let (_, px) = image_of(&s, &s.doc().unwrap().doc.layers[0]); + assert_eq!(px.get_pixel(0, 0).0, [100, 50, 0, 255]); + // A hidden layer in such a mode would need the merge: left out, hidden layers asked for or not. + let hidden_dodge = L { hidden: true, blend: b"lddg", ..layer("Hidden dodge", [0, 0, 2, 2], [100, 100, 100, 255]) }; + let (s, r) = open(&file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [100, 50, 0, 255]), hidden_dodge], 8, false, false), json!({"hiddenLayers": true})); + let names: Vec<&str> = s.doc().unwrap().doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!((names, &r["warnings"]), (vec!["Bottom"], &json!([HIDDEN_1])), "{r}"); +} + +/// A merge whose canvases would need more memory than allowed leaves the layer in the Normal mode, +/// with a warning. +#[test] +fn a_merge_past_the_memory_allowed_leaves_the_layer_normal() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let layers = [layer("Bottom", [0, 0, 2, 2], [100, 50, 0, 255]), L { blend: b"lddg", ..layer("Dodge", [2, 2, 4, 4], [100, 100, 100, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + // Each 2 × 2 layer fits 128 bytes (16 a pixel), and so does the first canvas of the merge with + // the layer beside it; the 4 × 4 canvas of both doesn't. + let (doc, warnings) = psdlayers::import(&f, false, 128, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Bottom", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + // With room for it, the merge is made. + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Dodge"]); + // A merge onto a canvas that already covers the layer holds both: two 4 × 4 canvases take 512 + // bytes, past 300, although the canvas of the two 1 × 1 layers below fits (288 bytes). + let layers = [ + layer("Bottom", [0, 0, 1, 1], [100, 50, 0, 255]), + layer("Corner", [3, 3, 4, 4], [0, 50, 100, 255]), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 4, 4], [100, 100, 100, 255]) }, + ]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 300, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom", "Corner", "Dodge"]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Corner + Dodge"]); + // A group below whose layers each fit, but whose canvas (4 × 4 with both 1 × 1 layers held, 288 + // bytes) doesn't fit 100: the layers stay, and the layer comes in Normal. + let layers = [ + group_end(), + layer("Left", [0, 0, 1, 1], [100, 50, 0, 255]), + layer("Right", [3, 3, 4, 4], [0, 50, 100, 255]), + group("Pair", b"norm"), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [100, 100, 100, 255]) }, + ]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 100, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Pair", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(doc.layers[0].children().unwrap().iter().map(|c| name(c)).collect::>(), ["Left", "Right"]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Pair + Dodge"]); + // A layer below whose pixels fit (128 bytes) but whose canvas (16 bytes a pixel, 256) doesn't + // fit 200: the same. + let layers = [layer("Big", [0, 0, 4, 4], [100, 50, 0, 255]), L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [100, 100, 100, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 200, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Big", BlendMode::Normal), ("Dodge", BlendMode::Normal)]); + assert_eq!(warnings, ["1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed"]); + // A clipped layer whose merge doesn't fit (the base's canvas and its transparency, 20 bytes a + // pixel, beside the clipped layer's canvas, 16: 576 bytes, past 300) comes in unclipped. + let layers = [layer("Base", [0, 0, 4, 4], [255, 0, 0, 255]), L { clipped: true, ..layer("Clipped", [0, 0, 4, 4], [0, 0, 255, 255]) }]; + let f = file(4, 4, &layers, 8, false, false); + let (doc, warnings) = psdlayers::import(&f, false, 300, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Base", "Clipped"]); + assert_eq!(warnings, ["1 layer clipped to the layer below came in unclipped: the merge needs more memory than allowed"]); + let (doc, _) = psdlayers::import(&f, false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!(doc.layers.iter().map(|l| name(l)).collect::>(), ["Base + Clipped"]); +} + +/// A merge that can't be made counts nothing that the merges inside it counted: here the merge +/// in a group at half opacity fails inside the outer merge's attempt (the copy of what is below the +/// group takes the room) and succeeds when the group comes in. +#[test] +fn a_merge_that_cant_be_made_counts_only_itself() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let layers = [ + layer("Under", [0, 0, 2, 2], [100, 50, 0, 255]), + group_end(), + layer("Inner", [0, 0, 2, 2], [10, 10, 10, 255]), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 2, 2], [20, 20, 20, 255]) }, + L { opacity: 128, ..group("Pass", b"pass") }, + L { blend: b"diss", ..layer("Top", [0, 0, 2, 2], [0, 0, 0, 255]) }, + ]; + let (doc, warnings) = psdlayers::import(&file(2, 2, &layers, 8, false, false), false, 150, cms).unwrap().unwrap(); + let got: Vec<(&str, BlendMode)> = doc.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Under", BlendMode::Normal), ("Pass", BlendMode::Normal), ("Top", BlendMode::Normal)]); + assert_eq!(doc.layers[1].children().unwrap().iter().map(|c| name(c)).collect::>(), ["Inner + Dodge"]); + assert_eq!( + warnings, + [ + "layers in blend modes VectorCraft doesn't have (Linear Dodge) were merged with the layers below them", + "1 layer in blend modes VectorCraft doesn't have came in as Normal: the merge needs more memory than allowed", + ] + ); +} + +/// Layers in modes VectorCraft lacks, one after another, merge onto the canvas the first merge made +/// without copying it: 300 of them over a 64 × 64 canvas (64 KiB) open within an allowance that a +/// copy for each would pass. Merges that need a copy of that canvas each, past the allowance, open +/// the merged image instead. +#[test] +fn merges_onto_a_merge_reuse_its_canvas() { + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let dodge = |rect: [i32; 4]| L { blend: b"lddg", ..layer("d", rect, [1, 1, 1, 255]) }; + let mut layers = vec![layer("Bottom", [0, 0, 1, 1], [0, 0, 0, 255]), dodge([63, 63, 64, 64])]; + layers.extend((0..300).map(|_| dodge([1, 1, 2, 2]))); + let (doc, warnings) = psdlayers::import(&file(64, 64, &layers, 8, false, false), false, 1 << 20, cms).unwrap().unwrap(); + assert_eq!((doc.layers.len(), warnings.len()), (1, 1), "{warnings:?}"); + assert!(name(&doc.layers[0]).starts_with("Bottom + d + d") && name(&doc.layers[0]).matches(" + d").count() == 301); + // A Normal layer between each pair: every merge copies the 64 KiB canvas, and 150 of them pass + // eight times the allowance of 1 MiB. + let mut layers = vec![layer("Bottom", [0, 0, 1, 1], [0, 0, 0, 255]), dodge([63, 63, 64, 64])]; + for _ in 0..150 { + layers.extend([layer("n", [1, 1, 2, 2], [1, 1, 1, 255]), dodge([2, 2, 3, 3])]); + } + let e = psdlayers::import(&file(64, 64, &layers, 8, false, false), false, 1 << 20, cms).err(); + assert!(e.as_deref().is_some_and(|e| e.contains("take more memory to read than allowed")), "{e:?}"); +} + +/// A Dissolve layer at half opacity merges as pixels of its own color or of the color below, +/// about half of each, and none in between. +#[test] +fn dissolve_layers_merge_as_opaque_or_transparent_pixels() { + let dissolve = L { blend: b"diss", opacity: 128, ..layer("Dissolve", [0, 0, 16, 16], [255, 0, 0, 255]) }; + let layers = [layer("Bottom", [0, 0, 16, 16], [255, 255, 255, 255]), dissolve]; + let (s, r) = open(&file(16, 16, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Dissolve"], "{r}"); + let (_, px) = image_of(&s, &d.layers[0]); + let red = px.pixels().filter(|p| p.0 == [255, 0, 0, 255]).count(); + let white = px.pixels().filter(|p| p.0 == [255, 255, 255, 255]).count(); + assert_eq!(red + white, 256, "every pixel is the layer's color or the color below"); + assert!((96..=160).contains(&red), "{red} of 256 pixels are the layer's"); + // A second Dissolve layer at half opacity over the first has its own pattern: about a quarter + // of the pixels still show the first. + let blue = L { blend: b"diss", opacity: 128, ..layer("Blue", [0, 0, 16, 16], [0, 0, 255, 255]) }; + let layers = [layers[0].clone(), layers[1].clone(), blue]; + let (s, r) = open(&file(16, 16, &layers, 8, false, false), json!({})); + let (_, px) = image_of(&s, &s.doc().unwrap().doc.layers[0]); + let red = px.pixels().filter(|p| p.0 == [255, 0, 0, 255]).count(); + assert!((32..=96).contains(&red), "{red} of 256 pixels show the lower layer: {r}"); +} + +#[test] +fn what_doesnt_come_in_is_said() { + let mut text = layer("Words", [0, 0, 2, 2], [0, 0, 0, 255]); + text.blocks.push((*b"TySh", vec![0; 4])); + let mut adjust = layer("Levels", [0, 0, 0, 0], [0; 4]); + adjust.blocks.push((*b"levl", vec![0; 4])); + let mut fx = layer("Shadowed", [0, 0, 2, 2], [0, 0, 0, 255]); + fx.blocks.push((*b"lfx2", effects(true, true))); + fx.blocks.push((*b"knko", vec![1, 0, 0, 0])); + // Effects with the master switch off, or none switched on, aren't counted. + let mut fx_off = layer("Switched off", [0, 0, 1, 1], [0, 0, 0, 255]); + fx_off.blocks.push((*b"lfx2", effects(false, true))); + let mut fx_none = layer("None on", [0, 0, 1, 1], [0, 0, 0, 255]); + fx_none.blocks.push((*b"lfx2", effects(true, false))); + // An adjustment layer clipped to the layer below draws nothing, and is still counted. + let mut clipped_adjust = L { clipped: true, ..layer("Curves", [0, 0, 0, 0], [0; 4]) }; + clipped_adjust.blocks.push((*b"curv", vec![0; 4])); + let mut solid = layer("Fill", [0, 0, 0, 0], [0; 4]); + let mut soco = vec![0; 8]; + for (key, v) in [(b"Rd doub", 255.0f64), (b"Grn doub", 0.0), (b"Bl doub", 51.0)] { + soco.extend(key); + soco.extend(v.to_be_bytes()); + } + solid.blocks.push((*b"SoCo", soco.clone())); + // A shape layer: a solid color fill with a vector mask and the shape's pixels stored. + let mut shape = layer("Shape", [1, 1, 3, 2], [255, 0, 51, 255]); + shape.blocks.push((*b"SoCo", soco.clone())); + shape.blocks.push((*b"vmsk", vec![0; 8])); + // One with its vector mask only, no pixels: not drawn, rather than its color over the canvas, + // and the layer clipped to it isn't either. + let mut outline = layer("Outline", [0, 0, 0, 0], [0; 4]); + outline.blocks.push((*b"SoCo", soco)); + outline.blocks.push((*b"vsms", vec![0; 8])); + let on_outline = L { clipped: true, ..layer("On outline", [0, 0, 2, 2], [1, 1, 1, 255]) }; + let layers = [solid, text, clipped_adjust, adjust, fx, fx_off, fx_none, shape, outline, on_outline]; + let (s, r) = open(&file(4, 4, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Fill", "Words", "Shadowed", "Switched off", "None on", "Shape"], "an adjustment layer draws nothing"); + assert_eq!(image_of(&s, &d.layers[0]).1.get_pixel(3, 3).0, [255, 0, 51, 255], "a solid color fill covers the canvas"); + let ((x, y), px) = image_of(&s, &d.layers[5]); + assert_eq!(((x, y), px.dimensions()), ((1.0, 1.0), (1, 2)), "a shape layer comes in as the pixels it stores"); + let w = r["warnings"].to_string(); + for said in [ + "1 type layer came in as pixels", + "2 adjustment layers were not applied", + "1 fill layer was not drawn", + "the layer effects of 1 layer weren't drawn", + "the vector masks of 2 layers", + "the knockout of 1 layer wasn't applied", + "1 layer clipped to a fill layer that wasn't drawn was left out", + ] { + assert!(w.contains(said), "{said}: {w}"); + } +} + +/// An effects descriptor (`lfx2`) with its master switch and one effect's `enab` set: each key after +/// its length (0 for a 4-character key), then `bool` and a byte. +fn effects(master: bool, enabled: bool) -> Vec { + let mut d = vec![0; 8]; + d.extend(14u32.to_be_bytes()); + d.extend(b"masterFXSwitchbool"); + d.push(u8::from(master)); + d.extend(0u32.to_be_bytes()); + d.extend(b"enabbool"); + d.push(u8::from(enabled)); + d +} + +/// A layer's fill opacity fades only its own pixels when layers are clipped to it: they still show +/// in its shape, and the merged layer keeps the layer's opacity. +#[test] +fn a_clipping_base_fill_opacity_fades_only_its_own_pixels() { + for (fill, size, corner) in [(0u8, (4, 2), None), (128, (4, 4), Some([255, 0, 0, 128]))] { + let mut base = L { opacity: 200, ..layer("Base", [0, 0, 4, 4], [255, 0, 0, 255]) }; + base.blocks.push((*b"iOpa", vec![fill])); + let photo = L { clipped: true, ..layer("Photo", [0, 0, 2, 4], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[base, photo], 8, false, false), json!({})); + let merged = &s.doc().unwrap().doc.layers[0]; + assert_eq!(name(merged), "Base + Photo", "{r}"); + assert!((merged.opacity - 200.0 / 255.0).abs() < 1e-6, "{fill}: {}", merged.opacity); + let ((x, y), px) = image_of(&s, merged); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(0, 0).0), ((0.0, 0.0), size, [0, 0, 255, 255]), "{fill}"); + if let Some(c) = corner { + assert!(px.get_pixel(3, 3).0.iter().zip(c).all(|(&a, b)| a.abs_diff(b) <= 1), "{fill}: {:?}", px.get_pixel(3, 3)); + } + } +} + +/// Layers clipped to an adjustment layer show over the whole canvas of the file, through the +/// adjustment layer's mask. +#[test] +fn layers_clipped_to_an_adjustment_layer_show_over_the_canvas() { + let mut curves = layer("Curves", [0, 0, 0, 0], [0; 4]); + curves.blocks.push((*b"curv", vec![0; 4])); + curves.mask = Some(([0, 0, 1, 1], 255, 0, vec![0])); + let photo = L { clipped: true, ..layer("Photo", [-1, -1, 2, 5], [0, 0, 255, 255]) }; + let (s, r) = open(&file(4, 4, &[layer("Under", [0, 0, 4, 4], [255, 0, 0, 255]), curves, photo], 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Under", "Curves + Photo"], "{r}"); + // Cut to the canvas: 4 × 2 at its corner. + let ((x, y), px) = image_of(&s, &d.layers[1]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(1, 1).0), ((0.0, 0.0), (4, 2), [0, 0, 255, 255])); + assert!(d.layers[1].extras().mask.is_some(), "the adjustment layer's mask stays"); + assert!(r["warnings"].to_string().contains("1 adjustment layer was not applied"), "{r}"); +} + +/// Each blend mode VectorCraft lacks merges with the layer below by its formula, from the public +/// descriptions: +/// (Vivid Light, Pin Light, Hard Mix, Darker and Lighter Color) and +/// (Linear Burn, Linear Dodge, Linear Light, Subtract, +/// Divide). Color Dodge, Color Burn and Difference keep their mode. +#[test] +fn every_mode_vectorcraft_lacks_merges_by_its_formula() { + let b = [200u8, 100, 30]; + let s = [60u8, 180, 128]; + let burn = |b: f64, c: f64| if c <= 0.0 { if b >= 1.0 { 1.0 } else { 0.0 } } else { 1.0 - ((1.0 - b) / c).min(1.0) }; + let dodge = |b: f64, c: f64| if c >= 1.0 { if b <= 0.0 { 0.0 } else { 1.0 } } else { (b / (1.0 - c)).min(1.0) }; + let each = |f: &dyn Fn(f64, f64) -> f64| -> [u8; 3] { + [0, 1, 2].map(|i| (f(f64::from(b[i]) / 255.0, f64::from(s[i]) / 255.0).clamp(0.0, 1.0) * 255.0).round() as u8) + }; + let total = |c: [u8; 3]| c.iter().map(|&v| u32::from(v)).sum::(); + let cases: [(&[u8; 4], [u8; 3]); 10] = [ + (b"lbrn", each(&|b, s| b + s - 1.0)), + (b"lddg", each(&|b, s| b + s)), + (b"vLit", each(&|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) })), + (b"lLit", each(&|b, s| b + 2.0 * s - 1.0)), + (b"pLit", each(&|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) })), + (b"hMix", each(&|b, s| if (b + s) * 255.0 >= 254.999 { 1.0 } else { 0.0 })), + (b"fsub", each(&|b, s| b - s)), + (b"fdiv", each(&|b, s| if s <= 0.0 { 1.0 } else { b / s })), + (b"dkCl", if total(s) < total(b) { s } else { b }), + (b"lgCl", if total(s) > total(b) { s } else { b }), + ]; + for (key, want) in cases { + let top = L { blend: key, ..layer("Top", [0, 0, 1, 1], [s[0], s[1], s[2], 255]) }; + let (st, r) = open(&file(1, 1, &[layer("Bottom", [0, 0, 1, 1], [b[0], b[1], b[2], 255]), top], 8, false, false), json!({})); + let d = &st.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Top"], "{key:?}: {r}"); + let got = image_of(&st, &d.layers[0]).1.get_pixel(0, 0).0; + assert!( + got[..3].iter().zip(want).all(|(&g, w)| g.abs_diff(w) <= 1) && got[3] == 255, + "{}: {got:?}, want {want:?}", + String::from_utf8_lossy(key) + ); + } + for (key, mode) in [(b"div ", BlendMode::ColorDodge), (b"idiv", BlendMode::ColorBurn), (b"diff", BlendMode::Difference)] { + let top = L { blend: key, ..layer("Top", [0, 0, 1, 1], [s[0], s[1], s[2], 255]) }; + let (st, r) = open(&file(1, 1, &[layer("Bottom", [0, 0, 1, 1], [b[0], b[1], b[2], 255]), top], 8, false, false), json!({})); + let d = &st.doc().unwrap().doc; + let got: Vec<(&str, BlendMode)> = d.layers.iter().map(|l| (name(l), l.blend)).collect(); + assert_eq!(got, [("Bottom", BlendMode::Normal), ("Top", mode)], "{r}"); + } +} + +/// A descriptor's numbers: each key (a 4-character key after a length of 0, a longer one after its +/// length), then `doub` and the value, or for `H `, `UntF`, `#Ang` and the value. +fn descriptor(values: &[(&[u8], f64)]) -> Vec { + let mut d = vec![0; 8]; + for (key, v) in values { + d.extend(if key.len() == 4 { 0u32 } else { key.len() as u32 }.to_be_bytes()); + d.extend(*key); + d.extend(if *key == b"H " { &b"UntF#Ang"[..] } else { b"doub" }); + d.extend(v.to_be_bytes()); + } + d +} + +/// Solid color fill layers that store no pixels come in as their color in every color model a +/// descriptor uses; one whose color can't be read, and a gradient fill that stores no pixels, are +/// counted; a gradient fill that stores pixels comes in as them. +#[test] +fn solid_color_fills_read_every_color_model() { + let fill = |name: &'static str, values: &[(&[u8], f64)]| { + let mut l = layer(name, [0, 0, 0, 0], [0; 4]); + l.blocks.push((*b"SoCo", descriptor(values))); + l + }; + let mut gradient = layer("Gradient", [0, 0, 0, 0], [0; 4]); + gradient.blocks.push((*b"GdFl", vec![0; 8])); + let mut gradient_shape = layer("Gradient shape", [1, 1, 2, 2], [7, 8, 9, 255]); + gradient_shape.blocks.push((*b"GdFl", vec![0; 8])); + // Bounds, but no channels: it stores no pixels either. + let mut pattern = L { bare: true, ..layer("Pattern", [0, 0, 4, 4], [0; 4]) }; + pattern.blocks.push((*b"PtFl", vec![0; 8])); + let layers = [ + fill("Float", &[(b"redFloat", 0.0), (b"greenFloat", 0.0), (b"blueFloat", 1.0)]), + fill("Cmyk", &[(b"Cyn ", 0.0), (b"Mgnt", 100.0), (b"Ylw ", 100.0), (b"Blck", 0.0)]), + fill("Gray", &[(b"Gry ", 25.0)]), + fill("Lab", &[(b"Lmnc", 100.0), (b"A ", 0.0), (b"B ", 0.0)]), + fill("Hsb", &[(b"H ", 120.0), (b"Strt", 100.0), (b"Brgh", 100.0)]), + fill("Unread", &[(b"Bk ", 1.0)]), + gradient, + gradient_shape, + pattern, + ]; + // A naive CMYK conversion, so that the colors don't depend on the color settings. + let naive = |c: [f32; 4]| [0, 1, 2].map(|i| (1.0 - c[i]) * (1.0 - c[3])); + let (doc, warnings) = psdlayers::import(&file(4, 4, &layers, 8, false, false), false, 1 << 20, naive).unwrap().unwrap(); + let names: Vec<&str> = doc.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Float", "Cmyk", "Gray", "Lab", "Hsb", "Gradient shape"]); + let mut s = Session::new(); + s.add_document(doc, None); + let d = &s.doc().unwrap().doc; + let colors: Vec<[u8; 4]> = d.layers[..5].iter().map(|l| image_of(&s, l).1.get_pixel(2, 2).0).collect(); + assert_eq!(colors[..3], [[0, 0, 255, 255], [255, 0, 0, 255], [191, 191, 191, 255]]); + assert!(colors[3].iter().all(|&v| v >= 253), "Lab 100, 0, 0 is white: {:?}", colors[3]); + assert_eq!(colors[4], [0, 255, 0, 255]); + assert_eq!(image_of(&s, &d.layers[5]).1.get_pixel(0, 0).0, [7, 8, 9, 255]); + assert_eq!(warnings, ["3 fill layers were not drawn (a gradient or pattern fill, a color that can't be read, or a shape that stores no pixels)"]); +} + +/// VectorCraft's own PSD export opens with the layers it wrote: groups as layers of sublayers, +/// hidden ones hidden, with their opacity and blend mode. +#[test] +fn psd_exports_open_with_their_layers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 30, "height": 40})).unwrap(); + s.execute("layer.new", &json!({"name": "Blue"})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 20, "height": 40})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50, "blend": "Multiply"})).unwrap(); + s.execute("layer.new", &json!({"name": "Off"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let off = s.doc().unwrap().doc.layers.last().unwrap().id; + s.execute("layer.setProps", &json!({"id": off.0, "visible": false})).unwrap(); + for (options, top) in [ + (json!({"hiddenLayers": true}), vec![("Layer 1", true), ("Blue", true), ("Off", false)]), + (json!({"maxEditability": true}), vec![("Layer 1", true), ("Blue", true)]), + ] { + let r = s.execute("document.export", &merge(json!({"format": "psd"}), options.clone())).unwrap(); + let f = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let (back, r) = open(&f, json!({"hiddenLayers": true})); + let d = &back.doc().unwrap().doc; + let got: Vec<(&str, bool)> = d.layers.iter().map(|l| (name(l), l.visible)).collect(); + assert_eq!(got, top, "{options}: {r}"); + if options["maxEditability"] == true { + // Each layer a group: its objects become sublayers, the rectangle's opacity and blend mode + // on its own. + let blue = d.layers[1].children().unwrap(); + assert_eq!(blue.len(), 1, "{options}"); + assert!(blue[0].blend == BlendMode::Multiply && (blue[0].opacity - 0.5).abs() < 0.01, "{options}"); + } + } +} + +#[test] +fn files_without_layers_or_with_damaged_layers_open_as_their_merged_image() { + // No layer records: the merged image, without a warning. + let flat = { + let mut f = file(2, 2, &[layer("x", [0, 0, 2, 2], [9, 9, 9, 255])], 8, false, false); + // The layer and mask section emptied: 4 length bytes of 0, after the header and two + // empty sections. + let at = 26 + 8; + let len = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) as usize; + f.splice(at..at + 4 + len, 0u32.to_be_bytes()); + f + }; + let (_, r) = open(&flat, json!({})); + assert_eq!(r["warnings"], json!([]), "{r}"); + // Layer info of just the count (negative: the merged image has transparency) has no layers. + let count_only = { + let mut f = flat.clone(); + let at = 26 + 8; + f.splice(at..at + 4, [&10u32.to_be_bytes()[..], &2u32.to_be_bytes(), &(-1i16).to_be_bytes(), &[0, 0, 0, 0]].concat()); + f + }; + let (s, r) = open(&count_only, json!({})); + assert_eq!((&r["warnings"], s.doc().unwrap().doc.layers[0].children().map(Vec::len)), (&json!([]), Some(1)), "{r}"); + // A channel cut short: the merged image, with a warning. + let mut cut = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false); + let at = cut.windows(4).position(|w| w == [0, 0, 0, 18]).unwrap(); + cut[at..at + 4].copy_from_slice(&1000u32.to_be_bytes()); + let (s, r) = open(&cut, json!({})); + assert!(r["warnings"][0].as_str().unwrap().starts_with("the layers can't be read"), "{r}"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().map(Vec::len), Some(1)); + // Groups nested past the limit. + let mut deep: Vec = (0..=psdlayers::MAX_DEPTH).map(|_| group_end()).collect(); + deep.push(layer("x", [0, 0, 1, 1], [0, 0, 0, 255])); + let (_, r) = open(&file(2, 2, &deep, 8, false, false), json!({})); + assert!(r["warnings"][0].as_str().unwrap().contains("nested too deeply"), "{r}"); + // Groups within the limit but deeper than a document may nest: the deepest merge into the + // group around them, with the warning. + let mut deep: Vec = (0..40).map(|_| group_end()).collect(); + deep.push(layer("x", [0, 0, 1, 1], [0, 0, 0, 255])); + deep.extend((0..40).map(|_| group("g", b"norm"))); + let (s, r) = open(&file(2, 2, &deep, 8, false, false), json!({})); + assert_eq!(r["warnings"], json!([vectorcraft_doc::depth::merged_note()]), "{r}"); + fn depth(n: &Node) -> usize { + 1 + n.children().map_or(0, |c| c.iter().map(|c| depth(c)).max().unwrap_or(0)) + } + assert!(s.doc().unwrap().doc.layers.iter().map(|l| depth(l)).max().unwrap() <= vectorcraft_doc::MAX_DEPTH); +} + +/// A record whose layer mask or name is cut short opens the merged image with a warning, rather +/// than a layer without its mask or name. +#[test] +fn records_with_a_damaged_mask_or_name_open_as_the_merged_image() { + let good = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false); + // The record's extra data: an empty mask section, empty blending ranges, then the name. + let at = good.windows(10).position(|w| w == [0, 0, 0, 0, 0, 0, 0, 0, 1, b'x']).unwrap(); + // A mask section of 4 bytes, too short for a mask's place, default color and flags. + let mut short_mask = good.clone(); + short_mask[at + 3] = 4; + // A name longer than the record's extra data. + let mut long_name = good.clone(); + long_name[at + 8] = 0xff; + for f in [short_mask, long_name] { + let (s, r) = open(&f, json!({})); + assert!(r["warnings"][0].as_str().unwrap().starts_with("the layers can't be read"), "{r}"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().map(Vec::len), Some(1), "{r}"); + } +} + +#[test] +fn damaged_layers_never_panic() { + let mut masked = layer("Masked", [0, 0, 3, 3], [1, 2, 3, 255]); + masked.mask = Some(([1, 1, 2, 2], 0, 0, vec![200])); + let layers = [ + layer("A", [0, 0, 3, 3], [255, 0, 0, 255]), + group_end(), + masked, + L { clipped: true, ..layer("C", [0, 0, 3, 3], [0, 0, 255, 255]) }, + L { blend: b"lbrn", ..layer("D", [0, 0, 2, 2], [9, 9, 9, 255]) }, + group("G", b"pass"), + ]; + for (rle, psb) in [(false, false), (true, true)] { + let good = file(3, 3, &layers, 8, rle, psb); + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + assert!(psdlayers::import(&good, true, 1 << 30, cms).unwrap().is_some()); + for i in 0..good.len() { + for v in [0, 1, 0x7f, 0x80, 0xff] { + let mut f = good.clone(); + f[i] = v; + let _ = psdlayers::import(&f, true, 1 << 30, cms); + } + } + for n in 0..good.len() { + let _ = psdlayers::import(&good[..n], true, 1 << 30, cms); + } + // A layer past the memory allowed is refused, not read. + assert!(psdlayers::import(&good, true, 64, cms).unwrap_err().contains("too large")); + } + // Many layers that each fit the memory allowed for one image, but not all together (8 bytes a + // pixel to read an 8 × 8 layer; 4 a pixel decoded, against 8 times the allowance). + let cms = |c: [f32; 4]| [c[0], c[1], c[2]]; + let many: Vec = (0..20).map(|_| layer("x", [0, 0, 8, 8], [1, 2, 3, 255])).collect(); + assert!(psdlayers::import(&file(8, 8, &many[..16], 8, false, false), false, 512, cms).unwrap().is_some()); + let e = psdlayers::import(&file(8, 8, &many, 8, false, false), false, 512, cms).err(); + assert!(e.as_deref().is_some_and(|e| e.contains("take more memory to read than allowed")), "{e:?}"); +} + +/// A layer in a mode VectorCraft lacks above a pass-through group merges with the group's layers +/// blending onto what is below the group, as they do without the merge. +#[test] +fn merges_keep_pass_through_groups_blending_with_what_is_below() { + let layers = [ + layer("Bottom", [0, 0, 2, 2], [100, 200, 50, 255]), + group_end(), + L { blend: b"mul ", ..layer("Multiply", [0, 0, 2, 2], [128, 128, 128, 255]) }, + group("Pass", b"pass"), + L { blend: b"lddg", ..layer("Dodge", [0, 0, 1, 1], [0, 0, 0, 255]) }, + ]; + let (s, r) = open(&file(2, 2, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + let names: Vec<&str> = d.layers.iter().map(|l| name(l)).collect(); + assert_eq!(names, ["Bottom + Pass + Dodge"], "{r}"); + // 100, 200 and 50 times 128/255. + assert_eq!(image_of(&s, &d.layers[0]).1.get_pixel(1, 1).0, [50, 100, 25, 255]); + // At half opacity, or with a layer mask, the group's layers still blend with what is below + // it, and the result fades back toward it: halfway from (100, 200, 50) to (50, 100, 25). + let mut masked = group("Pass", b"pass"); + masked.mask = Some(([0, 0, 2, 2], 0, 0, vec![128, 128, 128, 128])); + for top in [L { opacity: 128, ..group("Pass", b"pass") }, masked] { + let mut layers = layers.clone(); + layers[3] = top; + let (s, r) = open(&file(2, 2, &layers, 8, false, false), json!({})); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| name(l)).collect::>(), ["Bottom + Pass + Dodge"], "{r}"); + let px = image_of(&s, &d.layers[0]).1.get_pixel(1, 1).0; + assert!(px.iter().zip([75, 150, 38, 255]).all(|(&a, b)| a.abs_diff(b) <= 1), "{px:?}"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_psdread.rs b/crates/engine/src/cmd/fileio/tests_psdread.rs index d411496b02..08a46f0e10 100644 --- a/crates/engine/src/cmd/fileio/tests_psdread.rs +++ b/crates/engine/src/cmd/fileio/tests_psdread.rs @@ -141,6 +141,31 @@ fn packbits_zip_and_psb_files_read() { zip.extend(z.finish().unwrap()); let px = pixels(&psd(1, 1, (3, 1), 16, 1, &[], &[], &[], &zip)); assert_eq!(px.iter().map(|p| p[0]).collect::>(), [16, 32, 48]); + // Zipped with prediction, 32 bits: the row's first bytes, then its second bytes and so on, + // each byte stored as the difference from the byte before it. + let samples = [0.0f32, 1.0, 0.0].map(f32::to_be_bytes); + let planes: Vec = (0..4).flat_map(|k| samples.iter().map(move |s| s[k])).collect(); + let deltas: Vec = planes.iter().scan(0u8, |prev, &b| Some(b.wrapping_sub(std::mem::replace(prev, b)))).collect(); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, &deltas).unwrap(); + let mut zip = vec![0, 3]; + zip.extend(z.finish().unwrap()); + let px = pixels(&psd(1, 1, (3, 1), 32, 1, &[], &[], &[], &zip)); + assert_eq!(px.iter().map(|p| p[0]).collect::>(), [0, 255, 0]); + // The same layout as another implementation writes it: psd-tools 1.26.1 (MIT, + // ), `compression.compress(data, + // Compression.ZIP_WITH_PREDICTION, 3, 2, 32)` of the samples 0, 1, 0.25 and 0.5, 0.125, 1, + // reads as the same samples stored raw. + let theirs = [ + 0x78, 0x9c, 0x63, 0xb0, 0xff, 0x7f, 0xa8, 0x81, 0xa1, 0x81, 0x01, 0x04, 0xec, 0xff, 0x33, 0x1e, 0x64, 0x68, 0x80, 0xb0, 0x01, 0x59, 0xd1, + 0x06, 0x01, + ]; + let mut zip = vec![0, 3]; + zip.extend(theirs); + let samples: Vec = [0.0f32, 1.0, 0.25, 0.5, 0.125, 1.0].iter().flat_map(|v| v.to_be_bytes()).collect(); + let stored = pixels(&psd(1, 1, (3, 2), 32, 1, &[], &[], &[], &raw(&[&samples]))); + assert_eq!(pixels(&psd(1, 1, (3, 2), 32, 1, &[], &[], &[], &zip)), stored); + assert_eq!(stored.iter().map(|p| p[0]).collect::>()[..2], [0, 255]); } #[test] diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index ab5562b5f9..25f5f36dbd 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -108,7 +108,7 @@ pub(in crate::cmd) fn windows_1252(b: u8) -> char { } /// Byte `b` of Mac Roman. -pub(super) fn mac_roman(b: u8) -> char { +pub(in crate::cmd) fn mac_roman(b: u8) -> char { const HIGH: [char; 128] = [ 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', // 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', // diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f729ed394c..586f177ae9 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1807,6 +1807,27 @@ proptest! { }); prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); } + + /// Mutated copies of the PSD and PSB samples never crash opening them with their layers, hidden + /// ones too: the PSD's layer records, channel lengths and data, and the PSB's layer count. + /// (Masks, clipping and groups are covered by `tests_psdlayers::damaged_layers_never_panic`.) + #[test] + fn mutated_layered_psds_open_without_panics(which in 6usize..8, cut in 0usize..1_000_000, edits in prop::collection::vec((0usize..1_000_000, any::()), 0..12)) { + let (name, mut bytes) = raster_samples().swap_remove(which); + let len = bytes.len(); + for (at, b) in edits { + if let Some(x) = bytes.get_mut(at % len) { + *x = b; + } + } + bytes.truncate((cut % (len + 1)).max(8)); + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "hiddenLayers": true}); + let _ = no_panic(s.execute("document.open", &p)); + }); + prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); + } } /// 1-bit TIFFs compressed with CCITT Group 3: modified Huffman (Compression 2) in three strips, T.4 diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..9209c0e57c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4775,7 +4775,13 @@ axis Both И двете @msg the PSD file has an unknown colour mode ({m}) PSD файлът има непознат цветови режим ({m}) @msg the PSD file's image data uses an unknown compression ({m}) образните данни на PSD файла използват непознато компресиране ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD изображението е твърде голямо за четене ({_1} × {_2} пиксела) -@msg zipped 32-bit PSD image data with prediction can't be read архивирани (zipped) 32-битови PSD образни данни с предсказване не могат да бъдат прочетени +@msg zipped 1-bit PSD image data with prediction can't be read архивирани (zipped) 1-битови PSD образни данни с предсказване не могат да бъдат прочетени +@msg the PSD file's layer data uses an unknown compression ({m}) данните на слоевете на PSD файла използват непознато компресиране ({m}) +@msg the PSD file's layer records are damaged записите на слоевете на PSD файла са повредени +@msg the PSD file's layer groups are nested too deeply групите слоеве на PSD файла са вложени твърде дълбоко +@msg a layer of the PSD file is too large to read слой на PSD файла е твърде голям за четене +@msg the PSD file's layers take more memory to read than allowed слоевете на PSD файла изискват повече памет за четене от позволеното +@msg the layers can't be read ({e}): the file opened as its merged image слоевете не могат да бъдат прочетени ({e}): файлът е отворен като обединеното си изображение @msg {depth}-bit PSD files in this colour mode can't be read {depth}-битови PSD файлове в този цветови режим не могат да бъдат прочетени @msg the PSD file is cut short PSD файлът е непълен @msg the PSD file's header is damaged заглавната част (header) на PSD файла е повредена diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..6b538ee673 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5143,7 +5143,13 @@ imageTrace Outline Obris @msg the HEIC or HEIF file holds a kind of image that can't be read datoteka HEIC ili HEIF sadrži vrstu slike koju nije moguće pročitati @msg HEIC and HEIF images can't be opened in this build HEIC i HEIF slike nije moguće otvoriti u ovoj verziji programa @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD slika je prevelika za čitanje ({_1} × {_2} piksela) -@msg zipped 32-bit PSD image data with prediction can't be read zipovani 32-bitni PSD podaci slike s predviđanjem ne mogu se pročitati +@msg zipped 1-bit PSD image data with prediction can't be read zipovani 1-bitni PSD podaci slike s predviđanjem ne mogu se pročitati +@msg the PSD file's layer data uses an unknown compression ({m}) podaci slojeva PSD datoteke koriste nepoznatu kompresiju ({m}) +@msg the PSD file's layer records are damaged zapisi slojeva PSD datoteke su oštećeni +@msg the PSD file's layer groups are nested too deeply grupe slojeva PSD datoteke su previše duboko ugniježđene +@msg a layer of the PSD file is too large to read sloj PSD datoteke je prevelik za čitanje +@msg the PSD file's layers take more memory to read than allowed slojevi PSD datoteke zahtijevaju više memorije za čitanje nego što je dozvoljeno +@msg the layers can't be read ({e}): the file opened as its merged image slojevi se ne mogu pročitati ({e}): datoteka je otvorena kao spojena slika @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitne PSD datoteke u ovom načinu boja ne mogu se pročitati @msg the PSD file is cut short PSD datoteka je skraćena @msg the PSD file's header is damaged zaglavlje PSD datoteke je oštećeno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..aff3376fe2 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6001,7 +6001,13 @@ digits Malayalam Malaiàlam @msg x and y must be provided together cal proporcionar x i y alhora @msg x and y must both be numbers x i y han de ser tots dos nombres @msg Your unsaved changes are kept in this browser: reload the page to get them back. Els canvis no desats es conserven en aquest navegador: torna a carregar la pàgina per recuperar-los. -@msg zipped 32-bit PSD image data with prediction can't be read les dades d’imatge PSD de 32 bits comprimides amb predicció no es poden llegir +@msg zipped 1-bit PSD image data with prediction can't be read les dades d’imatge PSD d’1 bit comprimides amb predicció no es poden llegir +@msg the PSD file's layer data uses an unknown compression ({m}) les dades de capa del fitxer PSD utilitzen una compressió desconeguda ({m}) +@msg the PSD file's layer records are damaged els registres de capes del fitxer PSD estan malmesos +@msg the PSD file's layer groups are nested too deeply els grups de capes del fitxer PSD estan imbricats massa profundament +@msg a layer of the PSD file is too large to read una capa del fitxer PSD és massa gran per llegir-la +@msg the PSD file's layers take more memory to read than allowed les capes del fitxer PSD necessiten més memòria de la permesa per llegir-les +@msg the layers can't be read ({e}): the file opened as its merged image no es poden llegir les capes ({e}): el fitxer s’ha obert com a imatge combinada @msg zone must be a string zone ha de ser una cadena @msg zone must be tab, center, left, right, top or bottom zone ha de ser tab, center, left, right, top o bottom @msg {_1} failed: {e} {_1} ha fallat: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..e9dfef5ac8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5241,7 +5241,13 @@ imageTrace Outline Obrys @msg the HEIC or HEIF file holds a kind of image that can't be read soubor HEIC nebo HEIF obsahuje druh obrázku, který nelze přečíst @msg HEIC and HEIF images can't be opened in this build obrázky HEIC a HEIF nelze v tomto sestavení otevřít @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD je příliš velký na přečtení ({_1} × {_2} obrazových bodů) -@msg zipped 32-bit PSD image data with prediction can't be read zazipovaná 32bitová obrazová data PSD s predikcí nelze přečíst +@msg zipped 1-bit PSD image data with prediction can't be read zazipovaná 1bitová obrazová data PSD s predikcí nelze přečíst +@msg the PSD file's layer data uses an unknown compression ({m}) data vrstev souboru PSD používají neznámou kompresi ({m}) +@msg the PSD file's layer records are damaged záznamy vrstev souboru PSD jsou poškozené +@msg the PSD file's layer groups are nested too deeply skupiny vrstev souboru PSD jsou vnořené příliš hluboko +@msg a layer of the PSD file is too large to read vrstva souboru PSD je příliš velká na přečtení +@msg the PSD file's layers take more memory to read than allowed vrstvy souboru PSD potřebují ke čtení víc paměti, než je povoleno +@msg the layers can't be read ({e}): the file opened as its merged image vrstvy nelze přečíst ({e}): soubor se otevřel jako sloučený obraz @msg {depth}-bit PSD files in this colour mode can't be read {depth}bitové soubory PSD v tomto barevném režimu nelze přečíst @msg the PSD file is cut short soubor PSD je useknutý @msg the PSD file's header is damaged hlavička souboru PSD je poškozená diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..324c90c41b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4822,7 +4822,13 @@ imageTrace Outline Kontur @msg the PSD file has an unknown colour mode ({m}) PSD-filen har en ukendt farvetilstand ({m}) @msg the PSD file's image data uses an unknown compression ({m}) PSD-filens billeddata bruger en ukendt komprimering ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-billedet er for stort til at læse ({_1} × {_2} pixels) -@msg zipped 32-bit PSD image data with prediction can't be read zippet 32-bit PSD-billeddata med prædiktion kan ikke læses +@msg zipped 1-bit PSD image data with prediction can't be read zippet 1-bit PSD-billeddata med prædiktion kan ikke læses +@msg the PSD file's layer data uses an unknown compression ({m}) PSD-filens lagdata bruger en ukendt komprimering ({m}) +@msg the PSD file's layer records are damaged PSD-filens lagposter er beskadigede +@msg the PSD file's layer groups are nested too deeply PSD-filens laggrupper er indlejret for dybt +@msg a layer of the PSD file is too large to read et lag i PSD-filen er for stort til at læse +@msg the PSD file's layers take more memory to read than allowed PSD-filens lag kræver mere hukommelse at læse end tilladt +@msg the layers can't be read ({e}): the file opened as its merged image lagene kan ikke læses ({e}): filen blev åbnet som sit samlede billede @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bit PSD-filer i denne farvetilstand kan ikke læses @msg the PSD file is cut short PSD-filen er afkortet @msg the PSD file's header is damaged PSD-filens header er beskadiget diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..1f7cccb4d1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4964,7 +4964,7 @@ theme Appearance Erscheinungsbild The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Die Hauptfarbe wird zur Konturfarbe. Die anderen Farben wandern ebenso weit um den Farbkreis wie die Hauptfarbe und behalten ihre Sättigung und Helligkeit. RGB: RGB: CMYK: CMYK: -# PSD files (opened and placed as their merged image). +# PSD files. @msg not a PSD file keine PSD-Datei @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode PSD-Dateien im Mehrkanalmodus können nicht gelesen werden: Speichern Sie die Datei im RGB-, CMYK- oder Graustufenmodus @msg the PSD file has an unknown colour mode ({m}) die PSD-Datei hat einen unbekannten Farbmodus ({m}) @@ -4984,7 +4984,13 @@ theme Appearance Erscheinungsbild @msg the HEIC or HEIF file holds a kind of image that can't be read die HEIC- oder HEIF-Datei enthält eine Bildart, die nicht gelesen werden kann @msg HEIC and HEIF images can't be opened in this build HEIC- und HEIF-Bilder können in diesem Build nicht geöffnet werden @msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel) -@msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden +@msg zipped 1-bit PSD image data with prediction can't be read ZIP-komprimierte 1-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden +@msg the PSD file's layer data uses an unknown compression ({m}) die Ebenendaten der PSD-Datei verwenden eine unbekannte Komprimierung ({m}) +@msg the PSD file's layer records are damaged die Ebenendatensätze der PSD-Datei sind beschädigt +@msg the PSD file's layer groups are nested too deeply die Ebenengruppen der PSD-Datei sind zu tief verschachtelt +@msg a layer of the PSD file is too large to read eine Ebene der PSD-Datei ist zu groß zum Lesen +@msg the PSD file's layers take more memory to read than allowed die Ebenen der PSD-Datei brauchen zum Lesen mehr Speicher als erlaubt +@msg the layers can't be read ({e}): the file opened as its merged image die Ebenen können nicht gelesen werden ({e}): die Datei wurde als ihr zusammengefügtes Bild geöffnet @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden @msg the JPEG XL file is cut short die JPEG-XL-Datei ist unvollständig @msg the JPEG XL container is too large to read der JPEG-XL-Container ist zu groß zum Lesen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..7a3ef85082 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4920,7 +4920,13 @@ digits Malayalam Μαλαγιάλαμ @msg the HEIC or HEIF file holds a kind of image that can't be read το αρχείο HEIC ή HEIF περιέχει είδος εικόνας που δεν είναι δυνατό να διαβαστεί @msg HEIC and HEIF images can't be opened in this build οι εικόνες HEIC και HEIF δεν μπορούν να ανοίξουν σε αυτή την έκδοση @msg the PSD image is too large to read ({_1} × {_2} pixels) η εικόνα PSD είναι πολύ μεγάλη για ανάγνωση ({_1} × {_2} εικονοστοιχεία) -@msg zipped 32-bit PSD image data with prediction can't be read τα συμπιεσμένα δεδομένα εικόνας PSD 32 bit με πρόβλεψη δεν διαβάζονται +@msg zipped 1-bit PSD image data with prediction can't be read τα συμπιεσμένα δεδομένα εικόνας PSD 1 bit με πρόβλεψη δεν διαβάζονται +@msg the PSD file's layer data uses an unknown compression ({m}) τα δεδομένα επιπέδων του αρχείου PSD χρησιμοποιούν άγνωστη συμπίεση ({m}) +@msg the PSD file's layer records are damaged οι εγγραφές επιπέδων του αρχείου PSD είναι κατεστραμμένες +@msg the PSD file's layer groups are nested too deeply οι ομάδες επιπέδων του αρχείου PSD είναι ένθετες σε υπερβολικό βάθος +@msg a layer of the PSD file is too large to read ένα επίπεδο του αρχείου PSD είναι πολύ μεγάλο για ανάγνωση +@msg the PSD file's layers take more memory to read than allowed τα επίπεδα του αρχείου PSD χρειάζονται περισσότερη μνήμη για ανάγνωση από όση επιτρέπεται +@msg the layers can't be read ({e}): the file opened as its merged image τα επίπεδα δεν μπορούν να διαβαστούν ({e}): το αρχείο άνοιξε ως η συγχωνευμένη εικόνα του @msg {depth}-bit PSD files in this colour mode can't be read τα αρχεία PSD {depth} bit δεν διαβάζονται σε αυτήν τη λειτουργία χρώματος @msg the PSD file is cut short το αρχείο PSD είναι περικομμένο @msg the PSD file's header is damaged η κεφαλίδα του αρχείου PSD είναι κατεστραμμένη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..914846df7f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4959,7 +4959,13 @@ axis Both Ambos @msg the HEIC or HEIF file holds a kind of image that can't be read el archivo HEIC o HEIF contiene un tipo de imagen que no se puede leer @msg HEIC and HEIF images can't be opened in this build las imágenes HEIC y HEIF no se pueden abrir en esta compilación @msg the PSD image is too large to read ({_1} × {_2} pixels) la imagen PSD es demasiado grande para leerla ({_1} × {_2} píxeles) -@msg zipped 32-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 32 bits comprimidos en ZIP con predicción +@msg zipped 1-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 1 bit comprimidos en ZIP con predicción +@msg the PSD file's layer data uses an unknown compression ({m}) los datos de capa del archivo PSD usan una compresión desconocida ({m}) +@msg the PSD file's layer records are damaged los registros de capas del archivo PSD están dañados +@msg the PSD file's layer groups are nested too deeply los grupos de capas del archivo PSD están anidados a demasiada profundidad +@msg a layer of the PSD file is too large to read una capa del archivo PSD es demasiado grande para leerla +@msg the PSD file's layers take more memory to read than allowed las capas del archivo PSD necesitan más memoria de la permitida para leerlas +@msg the layers can't be read ({e}): the file opened as its merged image no se pueden leer las capas ({e}): el archivo se abrió como su imagen combinada @msg {depth}-bit PSD files in this colour mode can't be read los archivos PSD de {depth} bits en este modo de color no se pueden leer @msg the PSD file is cut short el archivo PSD está incompleto @msg the PSD file's header is damaged el encabezado del archivo PSD está dañado diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..dfe917ba87 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5143,7 +5143,13 @@ imageTrace Outline Ääriviiva @msg the HEIC or HEIF file holds a kind of image that can't be read tässä HEIC- tai HEIF-tiedostossa on kuvatyyppi, jota ei voi lukea @msg HEIC and HEIF images can't be opened in this build HEIC- ja HEIF-kuvia ei voi avata tässä versiossa @msg the PSD image is too large to read ({_1} × {_2} pixels) tämä PSD-kuva on liian suuri luettavaksi ({_1} × {_2} pikseliä) -@msg zipped 32-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 32-bittistä PSD-kuvadataa ei voi lukea +@msg zipped 1-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 1-bittistä PSD-kuvadataa ei voi lukea +@msg the PSD file's layer data uses an unknown compression ({m}) tämän PSD-tiedoston tasodata käyttää tuntematonta pakkausta ({m}) +@msg the PSD file's layer records are damaged tämän PSD-tiedoston tasotietueet ovat vahingoittuneet +@msg the PSD file's layer groups are nested too deeply tämän PSD-tiedoston tasoryhmät ovat sisäkkäin liian syvällä +@msg a layer of the PSD file is too large to read tämän PSD-tiedoston taso on liian suuri luettavaksi +@msg the PSD file's layers take more memory to read than allowed tämän PSD-tiedoston tasojen lukeminen vaatii enemmän muistia kuin sallitaan +@msg the layers can't be read ({e}): the file opened as its merged image tasoja ei voi lukea ({e}): tiedosto avattiin yhdistettynä kuvanaan @msg {depth}-bit PSD files in this colour mode can't be read tässä väritilassa olevia {depth}-bittisiä PSD-tiedostoja ei voi lukea @msg the PSD file is cut short tämä PSD-tiedosto on katkennut @msg the PSD file's header is damaged tämän PSD-tiedoston otsake on vaurioitunut diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..209e9d6adc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4966,7 +4966,13 @@ axis Both Les deux @msg the HEIC or HEIF file holds a kind of image that can't be read le fichier HEIC ou HEIF contient un type d'image qui ne peut pas être lu @msg HEIC and HEIF images can't be opened in this build les images HEIC et HEIF ne peuvent pas être ouvertes dans cette version @msg the PSD image is too large to read ({_1} × {_2} pixels) l'image PSD est trop grande pour être lue ({_1} × {_2} pixels) -@msg zipped 32-bit PSD image data with prediction can't be read les données d'image PSD 32 bits compressées en ZIP avec prédiction ne peuvent pas être lues +@msg zipped 1-bit PSD image data with prediction can't be read les données d'image PSD 1 bit compressées en ZIP avec prédiction ne peuvent pas être lues +@msg the PSD file's layer data uses an unknown compression ({m}) les données de calque du fichier PSD utilisent une compression inconnue ({m}) +@msg the PSD file's layer records are damaged les enregistrements de calques du fichier PSD sont endommagés +@msg the PSD file's layer groups are nested too deeply les groupes de calques du fichier PSD sont imbriqués trop profondément +@msg a layer of the PSD file is too large to read un calque du fichier PSD est trop grand pour être lu +@msg the PSD file's layers take more memory to read than allowed les calques du fichier PSD demandent plus de mémoire que permis pour être lus +@msg the layers can't be read ({e}): the file opened as its merged image les calques ne peuvent pas être lus ({e}) : le fichier a été ouvert comme son image fusionnée @msg {depth}-bit PSD files in this colour mode can't be read les fichiers PSD {depth} bits dans ce mode colorimétrique ne peuvent pas être lus @msg the PSD file is cut short le fichier PSD est tronqué @msg the PSD file's header is damaged l'en-tête du fichier PSD est endommagé diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..fdf5934e68 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8000,7 +8000,13 @@ antiAlias Sharp शार्प @msg x and y must both be numbers x और y दोनों संख्या होना चाहिए @msg You must choose a side pattern. आपको एक साइड पैटर्न चुनना होगा। @msg Your unsaved changes are kept in this browser: reload the page to get them back. इस ब्राउज़र में आपके बिना सेव किए गए बदलाव को रखा गया है: उन्हें वापस पाने के लिए पेज को फिर से लोड करें। -@msg zipped 32-bit PSD image data with prediction can't be read भविष्यवाणी के साथ 32-bit PSD इमेज डेटा zipped नहीं किया जा सकता +@msg zipped 1-bit PSD image data with prediction can't be read भविष्यवाणी के साथ zipped 1-bit PSD इमेज डेटा पढ़ा नहीं जा सकता +@msg the PSD file's layer data uses an unknown compression ({m}) PSD फ़ाइल का लेयर डेटा एक अज्ञात संपीड़न ({m}) का उपयोग करता है +@msg the PSD file's layer records are damaged PSD फ़ाइल के लेयर रिकॉर्ड क्षतिग्रस्त हैं +@msg the PSD file's layer groups are nested too deeply PSD फ़ाइल के लेयर समूह बहुत गहराई तक नेस्ट किए गए हैं +@msg a layer of the PSD file is too large to read PSD फ़ाइल की एक लेयर पढ़ने के लिए बहुत बड़ी है +@msg the PSD file's layers take more memory to read than allowed PSD फ़ाइल की लेयर पढ़ने के लिए अनुमति से अधिक मेमोरी चाहिए +@msg the layers can't be read ({e}): the file opened as its merged image लेयर पढ़ी नहीं जा सकतीं ({e}): फ़ाइल अपनी मर्ज की गई इमेज के रूप में खुली @msg zone must be a string zone: क्षेत्र एक स्ट्रिंग होना चाहिए @msg zone must be tab, center, left, right, top or bottom zone को tab, center, left, right, top या bottom होना चाहिए @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone: जोन को एक एंकर की आवश्यकता होती है: एंकर के साथ एक पैनल के बगल में, या साइड (स्टार्ट या एंड) के साथ एक नए डॉक कॉलम में diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..ba6eb51ad5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5566,7 +5566,13 @@ imageTrace Outline Körvonal @msg x and y must be provided together az x és az y csak együtt adható meg @msg x and y must both be numbers az x és az y is szám kell legyen @msg Your unsaved changes are kept in this browser: reload the page to get them back. A nem mentett módosításaidat ez a böngésző megőrizte: töltsd újra az oldalt a visszakapásukhoz. -@msg zipped 32-bit PSD image data with prediction can't be read a predikcióval tömörített 32 bites PSD-képadatok nem olvashatók +@msg zipped 1-bit PSD image data with prediction can't be read a predikcióval tömörített 1 bites PSD-képadatok nem olvashatók +@msg the PSD file's layer data uses an unknown compression ({m}) a PSD-fájl rétegadatai ismeretlen tömörítést használnak ({m}) +@msg the PSD file's layer records are damaged a PSD-fájl rétegrekordjai sérültek +@msg the PSD file's layer groups are nested too deeply a PSD-fájl rétegcsoportjai túl mélyen vannak egymásba ágyazva +@msg a layer of the PSD file is too large to read a PSD-fájl egyik rétege túl nagy a beolvasáshoz +@msg the PSD file's layers take more memory to read than allowed a PSD-fájl rétegeinek beolvasása több memóriát igényel a megengedettnél +@msg the layers can't be read ({e}): the file opened as its merged image a rétegek nem olvashatók ({e}): a fájl az egyesített képeként nyílt meg @msg zone must be a string a zone értéke szöveg lehet @msg zone must be tab, center, left, right, top or bottom a zone értéke tab, center, left, right, top vagy bottom lehet @msg {_1} failed: {e} sikertelen: {_1}: {e} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..bb79cffbc1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4822,7 +4822,13 @@ imageTrace Outline Kontur @msg the PSD file has an unknown colour mode ({m}) berkas PSD memiliki mode warna yang tidak dikenal ({m}) @msg the PSD file's image data uses an unknown compression ({m}) data gambar berkas PSD menggunakan kompresi yang tidak dikenal ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) gambar PSD terlalu besar untuk dibaca ({_1} × {_2} piksel) -@msg zipped 32-bit PSD image data with prediction can't be read data gambar PSD 32-bit terkompresi zip dengan prediksi tidak dapat dibaca +@msg zipped 1-bit PSD image data with prediction can't be read data gambar PSD 1-bit terkompresi zip dengan prediksi tidak dapat dibaca +@msg the PSD file's layer data uses an unknown compression ({m}) data lapisan berkas PSD menggunakan kompresi yang tidak dikenal ({m}) +@msg the PSD file's layer records are damaged catatan lapisan berkas PSD rusak +@msg the PSD file's layer groups are nested too deeply grup lapisan berkas PSD bersarang terlalu dalam +@msg a layer of the PSD file is too large to read sebuah lapisan berkas PSD terlalu besar untuk dibaca +@msg the PSD file's layers take more memory to read than allowed lapisan berkas PSD memerlukan lebih banyak memori untuk dibaca daripada yang diizinkan +@msg the layers can't be read ({e}): the file opened as its merged image lapisan tidak dapat dibaca ({e}): berkas dibuka sebagai gambar gabungannya @msg {depth}-bit PSD files in this colour mode can't be read berkas PSD {depth}-bit dalam mode warna ini tidak dapat dibaca @msg the PSD file is cut short berkas PSD terpotong @msg the PSD file's header is damaged header berkas PSD rusak diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..3f23f9a5be 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5002,7 +5002,13 @@ axis Both Entrambi @msg the HEIC or HEIF file holds a kind of image that can't be read il file HEIC o HEIF contiene un tipo di immagine che non può essere letto @msg HEIC and HEIF images can't be opened in this build le immagini HEIC e HEIF non possono essere aperte in questa build @msg the PSD image is too large to read ({_1} × {_2} pixels) l'immagine PSD è troppo grande per essere letta ({_1} × {_2} pixel) -@msg zipped 32-bit PSD image data with prediction can't be read i dati immagine PSD a 32 bit compressi ZIP con previsione non possono essere letti +@msg zipped 1-bit PSD image data with prediction can't be read i dati immagine PSD a 1 bit compressi ZIP con previsione non possono essere letti +@msg the PSD file's layer data uses an unknown compression ({m}) i dati dei livelli del file PSD usano una compressione sconosciuta ({m}) +@msg the PSD file's layer records are damaged i record dei livelli del file PSD sono danneggiati +@msg the PSD file's layer groups are nested too deeply i gruppi di livelli del file PSD sono annidati troppo in profondità +@msg a layer of the PSD file is too large to read un livello del file PSD è troppo grande per essere letto +@msg the PSD file's layers take more memory to read than allowed i livelli del file PSD richiedono più memoria del consentito per essere letti +@msg the layers can't be read ({e}): the file opened as its merged image i livelli non possono essere letti ({e}): il file è stato aperto come immagine unita @msg {depth}-bit PSD files in this colour mode can't be read i file PSD a {depth} bit in questo metodo colore non possono essere letti @msg the PSD file is cut short il file PSD è troncato @msg the PSD file's header is damaged l'intestazione del file PSD è danneggiata diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..211ef27832 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5043,7 +5043,13 @@ swatch Bright Violet ビビッドバイオレット @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC または HEIF ファイルには読み込めない種類の画像が含まれています @msg HEIC and HEIF images can't be opened in this build このビルドでは HEIC と HEIF の画像を開けません @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 画像が大きすぎて読み込めません({_1} × {_2} ピクセル) -@msg zipped 32-bit PSD image data with prediction can't be read 予測付き ZIP 圧縮の 32 bit PSD 画像データは読み込めません +@msg zipped 1-bit PSD image data with prediction can't be read 予測付き ZIP 圧縮の 1 bit PSD 画像データは読み込めません +@msg the PSD file's layer data uses an unknown compression ({m}) PSD ファイルのレイヤーデータの圧縮方式が不明です({m}) +@msg the PSD file's layer records are damaged PSD ファイルのレイヤーレコードが破損しています +@msg the PSD file's layer groups are nested too deeply PSD ファイルのレイヤーグループの入れ子が深すぎます +@msg a layer of the PSD file is too large to read PSD ファイルのレイヤーが大きすぎて読み込めません +@msg the PSD file's layers take more memory to read than allowed PSD ファイルのレイヤーの読み込みに許容量を超えるメモリーが必要です +@msg the layers can't be read ({e}): the file opened as its merged image レイヤーを読み込めません({e}):ファイルは統合画像として開かれました @msg {depth}-bit PSD files in this colour mode can't be read このカラーモードの {depth} bit PSD ファイルは読み込めません @msg the PSD file is cut short PSD ファイルが途中で切れています @msg the PSD file's header is damaged PSD ファイルのヘッダーが破損しています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..d2be9115b1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4658,7 +4658,13 @@ font-technology Variable ცვლადი @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC ან HEIF ფაილი შეიცავს გამოსახულების ტიპს, რომლის წაკითხვაც შეუძლებელია @msg HEIC and HEIF images can't be opened in this build ამ ვერსიაში HEIC და HEIF გამოსახულებების გახსნა შეუძლებელია @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD გამოსახულება ზედმეტად დიდია წასაკითხად ({_1} × {_2} პიქსელი) -@msg zipped 32-bit PSD image data with prediction can't be read zip-ით შეკუმშული 32-ბიტიანი PSD გამოსახულების მონაცემების წაკითხვა პროგნოზირებით ვერ ხერხდება +@msg zipped 1-bit PSD image data with prediction can't be read zip-ით შეკუმშული 1-ბიტიანი PSD გამოსახულების მონაცემების წაკითხვა პროგნოზირებით ვერ ხერხდება +@msg the PSD file's layer data uses an unknown compression ({m}) PSD ფაილის შრეების მონაცემები უცნობ შეკუმშვას იყენებს ({m}) +@msg the PSD file's layer records are damaged PSD ფაილის შრეების ჩანაწერები დაზიანებულია +@msg the PSD file's layer groups are nested too deeply PSD ფაილის შრეების ჯგუფები ზედმეტად ღრმადაა ჩადგმული +@msg a layer of the PSD file is too large to read PSD ფაილის შრე ზედმეტად დიდია წასაკითხად +@msg the PSD file's layers take more memory to read than allowed PSD ფაილის შრეების წასაკითხად დაშვებულზე მეტი მეხსიერებაა საჭირო +@msg the layers can't be read ({e}): the file opened as its merged image შრეების წაკითხვა ვერ ხერხდება ({e}): ფაილი გაიხსნა როგორც მისი გაერთიანებული გამოსახულება @msg {depth}-bit PSD files in this colour mode can't be read ამ ფერის რეჟიმში {depth}-ბიტიანი PSD ფაილების წაკითხვა ვერ ხერხდება @msg the PSD file is cut short PSD ფაილი შეკვეცილია @msg the PSD file's header is damaged PSD ფაილის სათაური დაზიანებულია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..2edafef8e9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4970,7 +4970,13 @@ theme Appearance 외관 @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 또는 HEIF 파일에 읽을 수 없는 종류의 이미지가 있습니다 @msg HEIC and HEIF images can't be opened in this build 이 빌드에서는 HEIC 및 HEIF 이미지를 열 수 없습니다 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 이미지가 너무 커서 읽을 수 없습니다({_1} × {_2}픽셀) -@msg zipped 32-bit PSD image data with prediction can't be read 예측이 적용된 ZIP 압축 32비트 PSD 이미지 데이터는 읽을 수 없습니다 +@msg zipped 1-bit PSD image data with prediction can't be read 예측이 적용된 ZIP 압축 1비트 PSD 이미지 데이터는 읽을 수 없습니다 +@msg the PSD file's layer data uses an unknown compression ({m}) PSD 파일의 레이어 데이터가 알 수 없는 압축({m})을 사용합니다 +@msg the PSD file's layer records are damaged PSD 파일의 레이어 레코드가 손상되었습니다 +@msg the PSD file's layer groups are nested too deeply PSD 파일의 레이어 그룹이 너무 깊이 중첩되어 있습니다 +@msg a layer of the PSD file is too large to read PSD 파일의 레이어가 너무 커서 읽을 수 없습니다 +@msg the PSD file's layers take more memory to read than allowed PSD 파일의 레이어를 읽는 데 허용된 것보다 많은 메모리가 필요합니다 +@msg the layers can't be read ({e}): the file opened as its merged image 레이어를 읽을 수 없습니다({e}): 파일이 병합된 이미지로 열렸습니다 @msg {depth}-bit PSD files in this colour mode can't be read 이 색상 모드의 {depth}비트 PSD 파일은 읽을 수 없습니다 @msg the PSD file is cut short PSD 파일이 잘려 있습니다 @msg the PSD file's header is damaged PSD 파일의 헤더가 손상되었습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..c80b77d6c6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4834,7 +4834,13 @@ imageTrace Outline Contour @msg the PSD file has an unknown colour mode ({m}) het PSD-bestand heeft een onbekende kleurmodus ({m}) @msg the PSD file's image data uses an unknown compression ({m}) de afbeeldingsgegevens van het PSD-bestand gebruiken een onbekende compressie ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) de PSD-afbeelding is te groot om te lezen ({_1} × {_2} pixels) -@msg zipped 32-bit PSD image data with prediction can't be read gezipte 32-bits PSD-afbeeldingsgegevens met voorspelling kunnen niet worden gelezen +@msg zipped 1-bit PSD image data with prediction can't be read gezipte 1-bits PSD-afbeeldingsgegevens met voorspelling kunnen niet worden gelezen +@msg the PSD file's layer data uses an unknown compression ({m}) de laaggegevens van het PSD-bestand gebruiken een onbekende compressie ({m}) +@msg the PSD file's layer records are damaged de laagrecords van het PSD-bestand zijn beschadigd +@msg the PSD file's layer groups are nested too deeply de laaggroepen van het PSD-bestand zijn te diep genest +@msg a layer of the PSD file is too large to read een laag van het PSD-bestand is te groot om te lezen +@msg the PSD file's layers take more memory to read than allowed de lagen van het PSD-bestand hebben meer geheugen nodig om te lezen dan toegestaan +@msg the layers can't be read ({e}): the file opened as its merged image de lagen kunnen niet worden gelezen ({e}): het bestand is geopend als zijn samengevoegde afbeelding @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bits PSD-bestanden in deze kleurmodus kunnen niet worden gelezen @msg the PSD file is cut short het PSD-bestand is afgekapt @msg the PSD file's header is damaged de header van het PSD-bestand is beschadigd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..f43cf163a7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5067,7 +5067,13 @@ imageTrace Outline Kontur @msg the HEIC or HEIF file holds a kind of image that can't be read plik HEIC lub HEIF zawiera rodzaj obrazu, którego nie można odczytać @msg HEIC and HEIF images can't be opened in this build obrazów HEIC i HEIF nie można otworzyć w tej wersji @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD jest zbyt duży, aby go odczytać ({_1} × {_2} pikseli) -@msg zipped 32-bit PSD image data with prediction can't be read nie można odczytać spakowanych 32-bitowych danych obrazu PSD z predykcją +@msg zipped 1-bit PSD image data with prediction can't be read nie można odczytać spakowanych 1-bitowych danych obrazu PSD z predykcją +@msg the PSD file's layer data uses an unknown compression ({m}) dane warstw pliku PSD używają nieznanej kompresji ({m}) +@msg the PSD file's layer records are damaged rekordy warstw pliku PSD są uszkodzone +@msg the PSD file's layer groups are nested too deeply grupy warstw pliku PSD są zagnieżdżone zbyt głęboko +@msg a layer of the PSD file is too large to read warstwa pliku PSD jest zbyt duża, aby ją odczytać +@msg the PSD file's layers take more memory to read than allowed odczytanie warstw pliku PSD wymaga więcej pamięci, niż jest dozwolone +@msg the layers can't be read ({e}): the file opened as its merged image nie można odczytać warstw ({e}): plik otwarto jako jego scalony obraz @msg {depth}-bit PSD files in this colour mode can't be read nie można odczytać {depth}-bitowych plików PSD w tym trybie koloru @msg the PSD file is cut short plik PSD jest ucięty @msg the PSD file's header is damaged nagłówek pliku PSD jest uszkodzony diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..6206fac3f1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4467,7 +4467,13 @@ axis Both Ambos @msg the HEIC or HEIF file holds a kind of image that can't be read o arquivo HEIC ou HEIF contém um tipo de imagem que não pode ser lido @msg HEIC and HEIF images can't be opened in this build imagens HEIC e HEIF não podem ser abertas nesta versão @msg the PSD image is too large to read ({_1} × {_2} pixels) a imagem PSD é grande demais para ser lida ({_1} × {_2} pixels) -@msg zipped 32-bit PSD image data with prediction can't be read dados de imagem PSD de 32 bits compactados em zip com previsão não podem ser lidos +@msg zipped 1-bit PSD image data with prediction can't be read dados de imagem PSD de 1 bit compactados em zip com previsão não podem ser lidos +@msg the PSD file's layer data uses an unknown compression ({m}) os dados de camada do arquivo PSD usam uma compactação desconhecida ({m}) +@msg the PSD file's layer records are damaged os registros de camadas do arquivo PSD estão danificados +@msg the PSD file's layer groups are nested too deeply os grupos de camadas do arquivo PSD estão aninhados profundamente demais +@msg a layer of the PSD file is too large to read uma camada do arquivo PSD é grande demais para ser lida +@msg the PSD file's layers take more memory to read than allowed as camadas do arquivo PSD precisam de mais memória do que o permitido para serem lidas +@msg the layers can't be read ({e}): the file opened as its merged image as camadas não podem ser lidas ({e}): o arquivo foi aberto como sua imagem mesclada @msg {depth}-bit PSD files in this colour mode can't be read arquivos PSD de {depth} bits neste modo de cor não podem ser lidos @msg the PSD file is cut short o arquivo PSD está incompleto @msg the PSD file's header is damaged o cabeçalho do arquivo PSD está danificado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..8d0a6dc466 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4998,7 +4998,13 @@ axis Both Оба @msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC или HEIF содержит изображение такого типа, которое не удается прочитать @msg HEIC and HEIF images can't be opened in this build изображения HEIC и HEIF нельзя открыть в этой сборке @msg the PSD image is too large to read ({_1} × {_2} pixels) изображение PSD слишком велико для чтения ({_1} × {_2} пикс.) -@msg zipped 32-bit PSD image data with prediction can't be read 32-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются +@msg zipped 1-bit PSD image data with prediction can't be read 1-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются +@msg the PSD file's layer data uses an unknown compression ({m}) данные слоёв файла PSD используют неизвестное сжатие ({m}) +@msg the PSD file's layer records are damaged записи слоёв файла PSD повреждены +@msg the PSD file's layer groups are nested too deeply группы слоёв файла PSD вложены слишком глубоко +@msg a layer of the PSD file is too large to read слой файла PSD слишком велик для чтения +@msg the PSD file's layers take more memory to read than allowed для чтения слоёв файла PSD нужно больше памяти, чем разрешено +@msg the layers can't be read ({e}): the file opened as its merged image не удаётся прочитать слои ({e}): файл открыт как сведённое изображение @msg {depth}-bit PSD files in this colour mode can't be read {depth}-битные файлы PSD в этом цветовом режиме не читаются @msg the PSD file is cut short файл PSD обрезан @msg the PSD file's header is damaged заголовок файла PSD повреждён diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..0a04f632a2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4875,7 +4875,13 @@ axis Both Båda @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC- eller HEIF-filen innehåller en typ av bild som inte kan läsas @msg HEIC and HEIF images can't be opened in this build HEIC- och HEIF-bilder kan inte öppnas i den här versionen @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-bilden är för stor för att läsas ({_1} × {_2} pixlar) -@msg zipped 32-bit PSD image data with prediction can't be read zippad 32-bitars PSD-bilddata med prediktion kan inte läsas +@msg zipped 1-bit PSD image data with prediction can't be read zippad 1-bitars PSD-bilddata med prediktion kan inte läsas +@msg the PSD file's layer data uses an unknown compression ({m}) PSD-filens lagerdata använder en okänd komprimering ({m}) +@msg the PSD file's layer records are damaged PSD-filens lagerposter är skadade +@msg the PSD file's layer groups are nested too deeply PSD-filens lagergrupper är kapslade för djupt +@msg a layer of the PSD file is too large to read ett lager i PSD-filen är för stort för att läsas +@msg the PSD file's layers take more memory to read than allowed PSD-filens lager kräver mer minne för att läsas än vad som tillåts +@msg the layers can't be read ({e}): the file opened as its merged image lagren kan inte läsas ({e}): filen öppnades som sin sammanslagna bild @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitars PSD-filer i det här färgläget kan inte läsas @msg the PSD file is cut short PSD-filen är avkortad @msg the PSD file's header is damaged PSD-filens huvud är skadat diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..d723f8ed3b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4704,7 +4704,13 @@ axis Both Her İkisi @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC veya HEIF dosyası okunamayan türde bir görüntü içeriyor @msg HEIC and HEIF images can't be opened in this build HEIC ve HEIF görüntüleri bu derlemede açılamıyor @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD görüntüsü okunamayacak kadar büyük ({_1} × {_2} piksel) -@msg zipped 32-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 32 bit PSD görüntü verileri okunamıyor +@msg zipped 1-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 1 bit PSD görüntü verileri okunamıyor +@msg the PSD file's layer data uses an unknown compression ({m}) PSD dosyasının katman verileri bilinmeyen bir sıkıştırma kullanıyor ({m}) +@msg the PSD file's layer records are damaged PSD dosyasının katman kayıtları hasarlı +@msg the PSD file's layer groups are nested too deeply PSD dosyasının katman grupları çok derin iç içe geçmiş +@msg a layer of the PSD file is too large to read PSD dosyasının bir katmanı okunamayacak kadar büyük +@msg the PSD file's layers take more memory to read than allowed PSD dosyasının katmanlarını okumak izin verilenden fazla bellek gerektiriyor +@msg the layers can't be read ({e}): the file opened as its merged image katmanlar okunamıyor ({e}): dosya birleştirilmiş görüntüsü olarak açıldı @msg {depth}-bit PSD files in this colour mode can't be read Bu renk modunda {depth}-bit PSD dosyaları okunamaz @msg the PSD file is cut short PSD dosyası kısa kesildi @msg the PSD file's header is damaged PSD dosyasının başlığı hasarlı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..6983f0d06e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4986,7 +4986,13 @@ axis Both Обидві @msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC або HEIF містить зображення такого типу, яке не вдається прочитати @msg HEIC and HEIF images can't be opened in this build зображення HEIC і HEIF не можна відкрити в цій збірці @msg the PSD image is too large to read ({_1} × {_2} pixels) зображення PSD завелике для читання ({_1} × {_2} пікс.) -@msg zipped 32-bit PSD image data with prediction can't be read 32-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються +@msg zipped 1-bit PSD image data with prediction can't be read 1-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються +@msg the PSD file's layer data uses an unknown compression ({m}) дані шарів файлу PSD використовують невідоме стиснення ({m}) +@msg the PSD file's layer records are damaged записи шарів файлу PSD пошкоджено +@msg the PSD file's layer groups are nested too deeply групи шарів файлу PSD вкладено надто глибоко +@msg a layer of the PSD file is too large to read шар файлу PSD завеликий для читання +@msg the PSD file's layers take more memory to read than allowed для читання шарів файлу PSD потрібно більше пам’яті, ніж дозволено +@msg the layers can't be read ({e}): the file opened as its merged image не вдається прочитати шари ({e}): файл відкрито як зведене зображення @msg {depth}-bit PSD files in this colour mode can't be read {depth}-бітні файли PSD у цьому колірному режимі не читаються @msg the PSD file is cut short файл PSD обрізаний @msg the PSD file's header is damaged заголовок файлу PSD пошкоджено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..c977136b58 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5234,7 +5234,13 @@ theme Appearance Giao diện @msg x and y must be provided together x và y phải được cung cấp cùng nhau @msg x and y must both be numbers x và y đều phải là số @msg Your unsaved changes are kept in this browser: reload the page to get them back. Các thay đổi chưa lưu được giữ trong trình duyệt này: tải lại trang để lấy lại. -@msg zipped 32-bit PSD image data with prediction can't be read dữ liệu ảnh PSD 32-bit nén với dự đoán không thể đọc được +@msg zipped 1-bit PSD image data with prediction can't be read dữ liệu ảnh PSD 1-bit nén với dự đoán không thể đọc được +@msg the PSD file's layer data uses an unknown compression ({m}) dữ liệu lớp của tệp PSD dùng nén không xác định ({m}) +@msg the PSD file's layer records are damaged bản ghi lớp của tệp PSD bị hỏng +@msg the PSD file's layer groups are nested too deeply các nhóm lớp của tệp PSD lồng nhau quá sâu +@msg a layer of the PSD file is too large to read một lớp của tệp PSD quá lớn để đọc +@msg the PSD file's layers take more memory to read than allowed các lớp của tệp PSD cần nhiều bộ nhớ để đọc hơn mức cho phép +@msg the layers can't be read ({e}): the file opened as its merged image không thể đọc các lớp ({e}): tệp được mở dưới dạng ảnh đã gộp @msg zone must be a string zone phải là chuỗi @msg zone must be tab, center, left, right, top or bottom zone phải là tab, center, left, right, top hoặc bottom @msg {_1} failed: {e} {_1} thất bại: {e} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..09ad05970f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4747,7 +4747,13 @@ imageTrace Outline 轮廓 @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 文件包含无法读取的图像类型 @msg HEIC and HEIF images can't be opened in this build 此版本无法打开 HEIC 和 HEIF 图像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 图像过大,无法读取 ({_1} × {_2} 像素) -@msg zipped 32-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 32 位 PSD 图像数据 +@msg zipped 1-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 1 位 PSD 图像数据 +@msg the PSD file's layer data uses an unknown compression ({m}) PSD 文件的图层数据使用了未知的压缩方式 ({m}) +@msg the PSD file's layer records are damaged PSD 文件的图层记录已损坏 +@msg the PSD file's layer groups are nested too deeply PSD 文件的图层组嵌套过深 +@msg a layer of the PSD file is too large to read PSD 文件的某个图层过大,无法读取 +@msg the PSD file's layers take more memory to read than allowed 读取 PSD 文件的图层所需内存超过允许值 +@msg the layers can't be read ({e}): the file opened as its merged image 无法读取图层 ({e}):文件已作为合并图像打开 @msg {depth}-bit PSD files in this colour mode can't be read 无法读取此颜色模式下的 {depth} 位 PSD 文件 @msg the PSD file is cut short PSD 文件不完整 @msg the PSD file's header is damaged PSD 文件头已损坏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..55641591b5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2556,7 +2556,13 @@ file-filter Templates 範本 @msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 檔案包含無法讀取的影像類型 @msg HEIC and HEIF images can't be opened in this build 此版本無法開啟 HEIC 和 HEIF 影像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 影像過大,無法讀取 ({_1} × {_2} 像素) -@msg zipped 32-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 32 位元 PSD 影像資料 +@msg zipped 1-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 1 位元 PSD 影像資料 +@msg the PSD file's layer data uses an unknown compression ({m}) PSD 檔案的圖層資料使用了不明的壓縮方式 ({m}) +@msg the PSD file's layer records are damaged PSD 檔案的圖層記錄已損毀 +@msg the PSD file's layer groups are nested too deeply PSD 檔案的圖層群組巢狀過深 +@msg a layer of the PSD file is too large to read PSD 檔案的某個圖層過大,無法讀取 +@msg the PSD file's layers take more memory to read than allowed 讀取 PSD 檔案的圖層所需記憶體超過允許值 +@msg the layers can't be read ({e}): the file opened as its merged image 無法讀取圖層 ({e}):檔案已以合併影像開啟 @msg {depth}-bit PSD files in this colour mode can't be read 無法讀取此色彩模式的 {depth} 位元 PSD 檔案 @msg the PSD file is cut short PSD 檔案不完整 @msg the PSD file's header is damaged PSD 檔頭已損毀 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d42e770df0..16576600e5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -293,8 +293,8 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | -| PSD | open, place, export | yes (merged image, every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | -| PSB | open | yes (merged image) | no | | +| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | +| PSB | open | yes (layers as layers, or the merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; container Exif resolution sizes opened and placed images; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..15c4fbb112 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -490,7 +490,7 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -6198,12 +6198,67 @@ appearance of their own or knockout stay one pixel layer. Hidden layers and obje `hiddenLayers: true` writes them as hidden layers. `layers: false` writes one flat image, on white where nothing is drawn. Files are at most 30000 pixels a side. -Photoshop documents (`.psd`, and `.psb`, the large document format) open and place as their merged image, the -flattened picture every Photoshop file carries (layers aren't read): Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK +PSD files (`.psd`, and `.psb`, the large document format) place as their merged image, the +flattened picture every PSD file carries: Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK (converted with the colour settings' CMYK) and Lab, at 1, 8, 16 and 32 bits per channel, sized by their resolution. A document with transparency keeps it. `file.place {path}` links the file like any image (`links.update` reads it again when it changes); Multichannel files are refused. +`document.open` converts a PSD or PSB file's layers to objects. In +[PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html), +the option that converts layers to objects keeps as much of the layer structure as it can, merges the layers of a +clipping mask into one layer and discards hidden layers that require merging; the option that flattens the layers +imports one image, and Import Hidden Layers brings hidden layers in. `document.open` does the same, with +`layers: false` for the single image and `hiddenLayers: true` for the hidden layers. The page also merges +adjustment layers, layers with a knockout option, and layers that hold transparent pixels in some blend modes with +the layers below them. `document.open` applies neither adjustments nor knockouts, and it merges a layer by its blend +mode alone, as the rules below say. + +The rest are VectorCraft's own rules: + +- Each layer becomes a layer of the document holding its pixels as an image, with the layer's name, visibility, + opacity and blend mode. +- A layer group becomes a layer of sublayers. A Pass Through group is not isolated; a group in any other mode is. +- Layers clipped to the layer below are merged into it as one image, shown where that layer has pixels. Layers + clipped to an adjustment layer, or to a fill layer that stores no pixels and isn't drawn, show over the whole + canvas, through that layer's mask. +- Hidden layers are left out unless `hiddenLayers: true`, which brings them in hidden. A hidden layer that would + need a merge is always left out, with the layers clipped to it. The `warnings` count the layers left out. +- `layers: false` opens the merged image. +- A layer or group in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, + Divide, Darker Color or Lighter Color is merged with the visible layers below it in its group, whether or not it + holds transparent pixels. Layers in Color Dodge, Color Burn and Difference keep their mode. In such a merge, the + layers of a Pass Through group blend with the layers below the group, at the group's opacity and through its layer + mask. +- A Dissolve layer's partly transparent pixels are merged as opaque or transparent, opaque at the share of its + pixels their transparency gives, in a fixed pattern that differs from layer to layer. +- A merged layer is named after the layers it holds, joined with ` + `. +- A layer name the file stores only as a one-byte Pascal string, without its Unicode name, is read as Mac Roman. +- A layer's opacity is multiplied by its fill opacity, in every blend mode. A layer that others are clipped to keeps + its own opacity, and its fill opacity fades only its own pixels. +- A layer mask becomes the layer's opacity mask: a gray image that hides the layer outside it when the mask's + default color is black and shows it when white. A disabled mask is kept, switched off. +- Type and shape layers, and gradient and pattern fill layers that store pixels, come in as the pixels the file + stores. A solid color fill layer that stores no pixels and has no vector mask comes in as an image of its color + (RGB, CMYK, Grayscale, Lab or HSB) over the canvas. One whose shape is a vector mask isn't drawn, and the layers + clipped to it are left out. +- Adjustment layers, fill layers that store no pixels and whose fill isn't read, layer effects that are switched on, + knockouts and vector masks are not applied; the `warnings` count them. +- Layer channels may be raw, PackBits or zipped (with or without prediction) at 8, 16 and 32 bits. +- A file without layers opens as its merged image. A file whose layers can't be read opens as its merged image + with a warning, and when the merged image can't be read either, the file is refused with an error. + +Each layer's pixels must fit the memory allowed for one image, and the pixels of all the layers and masks, with the +canvases the merges make, must together fit eight times that; a file past either limit opens as its merged image, +with a warning. The canvases one step of a merge holds at once must also fit the memory allowed for one image; when +they don't, the layer that needed the merge comes in alone, with a warning: in the Normal mode, or as a layer of its +own instead of clipped. File › Open in the app has no PSD import options dialog: it converts the layers and leaves +hidden layers out. + +```json +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/poster.psd","hiddenLayers":true}}} +``` + JPEG XL images (`.jxl`, a bare codestream or the ISOBMFF container) open and place as their first frame, with the orientation their header gives, decoded with jxl-oxide and stored as PNG. 16-bit and floating-point samples become 8 bits, and alpha is kept. Colors are converted to sRGB in XYB-encoded images and when the header names the color space diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..3bee725c35 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1462,6 +1462,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). A spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4), is written as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - BMP files named `.dib` or `.rle` (run-length encoded bitmaps included), Photoshop files named `.pdd` and PNG files named `.pns` open and place, linked or embedded, as BMP, PSD and PNG files, in any letter case: the Open and Place dialogs list them with their format, the Links panel names their format, and Edit Original opens them. Exports write only `.bmp`, `.psd` and `.png` (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/cmd/fileio/tests_psdread.rs`, `crates/engine/src/tests/links.rs`). + - File › Open converts a PSD or PSB file's layers to objects, keeping the layer structure, merging clipped layers into one layer and leaving out hidden layers that would need a merge, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for the option that converts layers to objects; hidden layers come in with `hiddenLayers`, as Import Hidden Layers brings them in, and `layers: false` opens the merged image. VectorCraft's own rules: each layer becomes a layer holding its pixels with its name, visibility, opacity and blend mode, groups become layers of sublayers, layers in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, layer masks become opacity masks, fill opacity multiplies the opacity (in a layer that others are clipped to, it fades only that layer's own pixels), and adjustment layers, layer effects, knockouts and vector masks aren't applied and are counted in the warnings. 8-, 16- and 32-bit files, PSB, raw, PackBits and zipped channels. There is no PSD import options dialog yet (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). From e63e341a95bfe2aa7ac7445dcbd08bac3b3e4a64 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:03:16 -0800 Subject: [PATCH 30/83] Docs: the Check Script escape example shows the escape The example of an escape that does not start a name read `0abc`, the character, where the four-digit escape of 0 followed by abc was meant. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 65438f19c5..07eb2bcbd1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -749,7 +749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `\u0030abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 17.0, so U+088F starts an identifier and U+1ACF may follow its first character; characters assigned after Unicode 17.0 are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_17_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_characters_unicode_17_assigned`, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. From db6b55ab0a8da708b54e321e48785529b28c64ac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:03:21 -0800 Subject: [PATCH 31/83] Scripts: a return looks only at the last saved finally site The saved throw sites of running finally blocks are kept in rising slot order, so leaving a frame or a block now removes entries from the end of the list and stops at the first one below the slot. Before, every return looked at every saved site, so a call made inside many nested finally blocks took time in proportion to their count. --- crates/js/src/interp.rs | 17 +++++++++++++++-- crates/js/src/interp_tests.rs | 13 +++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index d9a351c774..af17e6586b 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -300,6 +300,12 @@ pub(crate) const MAX_APPLY_ARGS: usize = 1 << 16; /// The estimated bytes of a name's entries in the list and the set `for-in` builds. const KEY_ENTRY_BYTES: usize = 32; +#[cfg(test)] +thread_local! { + /// Tests: the [`Interp::finally_sites`] entries that leaving a block or a frame has looked at. + pub(crate) static SITE_CHECKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// The estimated bytes of the index names `0` to `len - 1` while `for-in` lists them. fn index_keys_bytes(len: usize) -> usize { let mut total = 0usize; @@ -614,9 +620,16 @@ impl Interp { } /// Removes the [`Self::finally_sites`] entries whose stack slot is at or above `len`: those of - /// `finally` blocks that were left. + /// `finally` blocks that were left. The slots rise along the list, so only its end is looked at. fn drop_left_finally_sites(&mut self, len: usize) { - self.finally_sites.retain(|&(_, slot)| slot < len); + while let Some(&(_, slot)) = self.finally_sites.last() { + #[cfg(test)] + SITE_CHECKS.with(|c| c.set(c.get() + 1)); + if slot < len { + break; + } + self.finally_sites.pop(); + } } pub(crate) fn collect_garbage(&mut self) { diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 9d673dc78e..28157d6eea 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -396,6 +396,19 @@ fn a_function_that_returns_from_a_finally_block_does_not_keep_its_code() { assert!(thrown < plain + 4096, "{thrown} > {plain}"); } +#[test] +fn a_return_looks_only_at_the_finally_sites_of_its_own_frame() { + // 200 finally blocks run at once, each for its own exception, and the innermost calls a + // function 1,000 times. Each return looks at the last saved site only, not at all 200. + let src = "function g() { return 1; }\n\ + function f(n) { try { throw n; } finally { if (n) { f(n - 1); } else { for (var i = 0; i < 1000; i++) g(); } } }\n\ + try { f(199); } catch (e) { e; }"; + crate::interp::SITE_CHECKS.with(|c| c.set(0)); + assert_eq!(small(Limits::default()).run(src), Ok(JsValue::Number(0.0))); + let checks = crate::interp::SITE_CHECKS.with(std::cell::Cell::get); + assert!(checks < 10_000, "{checks} saved sites looked at"); +} + #[test] fn for_in_names_take_fuel_and_heap_under_later_limits() { // The first run, under the default limits, builds an object with many properties. Later runs From 7f3a046b184661e8e9386acf3706f9a1eeb1d1c6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:04:49 +0100 Subject: [PATCH 32/83] CI: nightly release dry run (#6678) release.yml also runs nightly on main with craft-common-actions' dry-run: every release build except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits (riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release day, as it did on 11 Nov 2026. --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eee66ab3be..fd836de6c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,10 @@ name: Release on: + # A nightly dry run on main: every release build except macOS, nothing signed or released + # (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day. + schedule: + - cron: "52 2 * * *" push: branches: [release] workflow_dispatch: @@ -77,6 +81,7 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: + dry-run: ${{ github.event_name == 'schedule' }} app-name: vectorcraft app-title: VectorCraft craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 From 7a9a6bd98e4ba5b75d4348d8cbc8ded3fe6d2704 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:23:56 -0800 Subject: [PATCH 33/83] Guides: a sublayer guide in a hidden or locked group keeps that state When a layer effect moves a sublayer's ruler guides onto the layer, a guide inside a hidden or locked group now stays hidden or locked, as it does for a hidden or locked sublayer. --- crates/effects/src/group.rs | 4 ++-- crates/effects/src/group/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 73abbffb2c..57f2fc8613 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -726,12 +726,12 @@ fn sublayer_guides(n: &Node) -> bool { /// Move the ruler guides of the sublayers among the members of `n`, and among its groups' /// members, to `out`, in order. A guide taken from a hidden or locked sublayer, or from one inside -/// such a sublayer, is hidden or locked itself. Only containers holding one are copied. +/// such a sublayer or group, is hidden or locked itself. Only containers holding one are copied. fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, hidden: bool, locked: bool) { let Some(ch) = n.children_mut() else { return }; for c in ch.iter_mut().filter(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))) { let c = Arc::make_mut(c); - let (hidden, locked) = if c.is_layer() { (hidden || !c.visible, locked || c.locked) } else { (hidden, locked) }; + let (hidden, locked) = (hidden || !c.visible, locked || c.locked); if c.is_layer() && let Some(lc) = c.children_mut() { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index ef242c5dc1..16e893ce99 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -903,3 +903,29 @@ fn a_sublayers_ruler_guides_survive_its_layers_effects() { } } } + +/// A sublayer's ruler guide inside a hidden or locked group stays hidden or locked when its layer's +/// Pathfinder effect moves it onto the layer, as the group made it. +#[test] +fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() { + use vectorcraft_doc::LayerColor; + for (hidden, locked) in [(true, false), (false, true), (false, false)] { + let mut sub = Node::layer(NodeId(11), "Sub", LayerColor::Preset(1)); + if let Some(ch) = sub.children_mut() { + *ch = vec![ + sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), + Arc::new(Node::new(NodeId(20), NodeKind::RulerGuide { vertical: true, pos: 3.0, artboard: None })), + ]; + } + let mut group = Node::group(NodeId(12), vec![Arc::new(sub)]); + (group.visible, group.locked) = (!hidden, locked); + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(group)]; + } + l.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); + let art = evaluate_container(None, &l).unwrap(); + let guides: Vec<_> = art.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).map(|c| (c.id, c.visible, c.locked)).collect(); + assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}"); + } +} From 736f73125f3e8a1e47f62c8e4fe222eaafb630ca Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:24:03 -0800 Subject: [PATCH 34/83] Plug-ins: OK after a failed preview runs the filter in the background A light filter that reports its progress kept a failed preview's interaction open, so OK ran it again on the UI thread without progress or Cancel. A failed preview is now rolled back and the run goes to the background. --- crates/ui-egui/src/dialogs/plugin.rs | 10 ++++++---- crates/ui-egui/src/tests/plugin_runs.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 9ee608a12c..eee33b460f 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -245,12 +245,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } /// OK: a filter keeps its previewed run as one undo step; a heavy one, which has no preview, and a -/// light one that runs in the background ([`in_background`]) with Preview off, start in the -/// background ([`run`]) and the dialog closes. A preview still shown when Preview was just turned -/// off is rolled back first. +/// light one that runs in the background ([`in_background`]) with Preview off or a failed preview, +/// start in the background ([`run`]) and the dialog closes. A preview still shown when Preview was +/// just turned off, or a preview that failed, is rolled back first. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let own_run = d.str("__command").is_empty(); - let kept = |plugin: &Plugin| plugin.manifest().budget == Budget::Light && d.bool("preview") && app.session.in_interaction(); + let kept = |plugin: &Plugin| { + plugin.manifest().budget == Budget::Light && d.bool("preview") && app.session.in_interaction() && !d.fields.contains_key(form::REFUSED) + }; match registry::get(&d.str("__plugin")) { Some(plugin) if own_run && in_background(plugin.manifest()) && !kept(&plugin) => { if d.fields.contains_key(form::PREVIEWED) && app.session.in_interaction() { diff --git a/crates/ui-egui/src/tests/plugin_runs.rs b/crates/ui-egui/src/tests/plugin_runs.rs index 555aedd050..30df214db6 100644 --- a/crates/ui-egui/src/tests/plugin_runs.rs +++ b/crates/ui-egui/src/tests/plugin_runs.rs @@ -327,6 +327,26 @@ fn ok_runs_a_light_progress_filter_in_the_background_without_a_preview() { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 3); } +/// In the dialog of a light filter that reports its progress, a failed preview is not kept: OK +/// rolls it back and runs the filter in the background instead of again on the UI thread. +#[test] +fn ok_after_a_failed_preview_runs_a_light_progress_filter_in_the_background() { + let mut app = app(); + let id = "test.ui.light.progress.fails"; + install_light(&mut app, id, true, r#"{"size":{"type":"number","min":1,"max":9,"default":3}}"#, "(unreachable)"); + let doc = app.session.doc().unwrap().doc.clone(); + app.run("plugin.dialog", json!({"id": id})).unwrap(); + frame(&mut app); + assert!(app.session.in_interaction(), "the preview's interaction"); + assert!(app.ui.dialog.as_ref().unwrap().fields.contains_key("__refused"), "the preview failed"); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(!app.session.in_interaction(), "the failed preview was rolled back"); + assert_eq!(app.plugin_runs.running.len(), 1, "running in the background"); + finish_runs(&mut app); + assert_eq!(app.session.doc().unwrap().doc, doc); +} + /// A light filter without the `progress` permission still runs at once, on the UI thread. #[test] fn a_light_filter_that_does_not_report_progress_runs_at_once() { From 0d600449d12636bf60dc654be2af09e9b965a98d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:31:33 -0800 Subject: [PATCH 35/83] Simplify: the samples of a flat loop count its line segments in the limit When a flat closed loop becomes the lines between samples of its curved segments, the line segments it keeps now count toward the 2,097,152-line limit before the rest is shared among the curved segments. When that leaves no line for some curved segment, the loop stays as it is. --- crates/pathops/src/edit.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index da8e5e4d8f..17b231a5dc 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -146,8 +146,9 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { // ends and those lines enclose an area: with straight lines they are the result, and without // them the loop stays as it is. When no split within those limits encloses an area, the loop // stays as it is, or with straight lines each curved segment becomes the lines between its - // samples, up to `MAX_RUN_RESULT` lines in all. A loop whose points are all in line keeps the - // lines of the refit. + // samples, up to `MAX_RUN_RESULT` lines in all with the line segments; when that leaves no + // line for some curved segment, the loop stays as it is. A loop whose points are all in line + // keeps the lines of the refit. let near = |c: &CubicBez| [c.p1, c.p2].iter().all(|&p| Line::new(c.p0, c.p3).nearest(p, 1e-9).distance_sq.sqrt() <= opts.tolerance); let curved = segs.iter().filter(|s| !s.line).count(); for k in 1..=10u32 { @@ -174,7 +175,9 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { if !opts.straight_lines { return Some(sp.clone()); } - let room = (MAX_RUN_RESULT / curved.max(1)).min(MAX_SEGMENT_SAMPLES); + let Some(room) = sample_room(curved, n - curved) else { + return Some(sp.clone()); + }; let mut lines: Vec = Vec::new(); for s in &segs { if s.line { @@ -192,6 +195,17 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { Some(if opts.straight_lines { s } else { no_worse(sp, s) }) } +/// Most lines between samples of each of `curved` segments, so that they and the `kept` line segments +/// make at most [`MAX_RUN_RESULT`] lines, and each has at most [`MAX_SEGMENT_SAMPLES`]. `None` when +/// that leaves no line for some curved segment. +fn sample_room(curved: usize, kept: usize) -> Option { + let left = MAX_RUN_RESULT.checked_sub(kept)?; + if curved > left { + return None; + } + Some((left / curved.max(1)).min(MAX_SEGMENT_SAMPLES)) +} + /// How far a segment may be from the lines between its samples, as a share of the tolerance. const SAMPLE_GAP: f64 = 0.005; @@ -1156,6 +1170,18 @@ mod tests { assert!(work <= 400 * pts.len(), "{work} steps for {} samples", pts.len()); } + #[test] + fn the_samples_of_a_flat_loop_and_its_line_segments_stay_within_the_result_limit() { + for (curved, kept) in [(1, 0), (32, 1), (40, 1_000_000), (1_000, MAX_RUN_RESULT - 1_000), (3, MAX_RUN_RESULT - 7)] { + let room = sample_room(curved, kept).unwrap_or_else(|| panic!("{curved} curved, {kept} kept: no room")); + assert!((1..=MAX_SEGMENT_SAMPLES).contains(&room), "{curved} curved, {kept} kept: room {room}"); + assert!(curved * room + kept <= MAX_RUN_RESULT, "{curved} curved, {kept} kept: room {room}"); + } + assert_eq!(sample_room(32, 0), Some(MAX_SEGMENT_SAMPLES)); + assert_eq!(sample_room(2, MAX_RUN_RESULT - 1), None); + assert_eq!(sample_room(1, MAX_RUN_RESULT + 5), None); + } + #[test] fn smoothing_points_whose_arc_length_overflows_returns_every_point() { let pts: Vec = (0..40).map(|i| Point::new(if i % 2 == 0 { -1e308 } else { 1e308 }, f64::from(i))).collect(); From 09e0a4e31a38fd123742cc02955701ed29341ced Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:36:36 -0800 Subject: [PATCH 36/83] =?UTF-8?q?Toolbars:=20test=20that=20Enter=20opens?= =?UTF-8?q?=20Add=20Tool=E2=80=A6=20and=20adds=20the=20focused=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test now focuses the + button, opens its menu with Enter, and adds a tool from the menu with Enter, as the docs describe, instead of checking only that the button takes the focus. --- crates/ui-egui/src/edit_toolbar/tests.rs | 25 +++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index 02ec0762bd..e47561094e 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -440,7 +440,8 @@ fn a_new_toolbar_fills_from_the_drawer_beside_it() { } /// A toolbar made with New Toolbar has an Add Tool… button (+, above the ••• button) that the -/// keyboard reaches. Its menu lists the tools under the drawer's headings: a tool picked there is +/// keyboard reaches and opens with Enter. Its menu lists the tools under the drawer's headings: a +/// tool picked there with a click or with Enter is /// added at the end of this toolbar alone, without the drawer, and is dimmed in the menu from then /// on; picked under Remove Tool, it is taken out again. #[test] @@ -480,11 +481,29 @@ fn the_add_tool_menu_fills_a_new_toolbar_without_a_drag() { at }; let mine = |app: &VectorcraftApp| crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots.clone(); - let shown = open(&mut app, 9.0); + // The keyboard: Enter on the focused + button opens the menu, and Enter on a tool adds it. + let focusable_at = |p: Pos2| { + let id = ctx.viewport(|vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.contains(p) && w.sense.is_focusable()).last().map(|w| w.id) + }); + id.unwrap_or_else(|| panic!("nothing focusable at {p:?}")) + }; + let enter = |app: &mut VectorcraftApp, time: f64, at: Pos2| { + let id = focusable_at(at); + ctx.memory_mut(|m| m.request_focus(id)); + frame(app, &ctx, time, vec![]); + for (k, pressed) in [true, false].into_iter().enumerate() { + let key = Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(app, &ctx, time + 0.1 * (k + 1) as f64, vec![key]); + } + frame(app, &ctx, time + 0.5, vec![]); + frame(app, &ctx, time + 0.6, vec![]) + }; + let shown = enter(&mut app, 9.0, plus().center()); assert!(items(&shown, "Remove Tool:").is_empty(), "nothing to take out of a blank toolbar"); let lasso = items(&shown, "Lasso Tool"); assert_eq!(lasso.len(), 1, "listed once, under Select"); - click(&mut app, &ctx, 10.0, lasso[0].0); + enter(&mut app, 10.0, lasso[0].0); assert_eq!(mine(&app), [["lasso"]]); assert_eq!(slots(&app), shipped, "the Tools panel's toolbar is untouched"); assert!(!app.ui.tools_drawer, "no drawer opened"); From 07bccb19a7c1181786b9529527a84f92c1cc46ce Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:36:41 -0800 Subject: [PATCH 37/83] Swatches: New Color Group, Delete, Move and Merge take named swatches in one pass New Color Group looked up and removed each named swatch with a scan of every swatch list, so making a group from artwork linked to many global swatches took time quadratic in their number. Delete Swatch, Move and Merge did the same, and checked each paint's link against the name list. take_swatches now finds every named swatch in one pass and removes them together, in the order and with the same choice of swatch as removing them one at a time. The commands look names up in one name map and check links against sets. With 16,000 named swatches in a release build, New Color Group went from 175.9 ms to 16.3 ms; 4,000 went from 11.6 ms to 3.6 ms. --- crates/engine/src/cmd/swatch.rs | 97 ++++++++++++++++++++------- crates/engine/src/tests/swatchcmds.rs | 29 ++++++++ 2 files changed, 103 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 2421441b91..eed8ee1f89 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -1,6 +1,6 @@ //! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting. -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent}; @@ -222,9 +222,10 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { /// The `names` list and `name` parameters, without repeats; at least one is required. pub(super) fn names_param(p: &Value, cmd: &str) -> Result> { + let mut seen: HashSet = HashSet::new(); let mut names: Vec = vec![]; for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) { - if !names.contains(&n) { + if seen.insert(n.clone()) { names.push(n); } } @@ -234,17 +235,73 @@ pub(super) fn names_param(p: &Value, cmd: &str) -> Result> { Ok(names) } +/// Take swatches `names` out of the document in that order, as [`Document::remove_swatch`] for +/// each name in turn would, in one pass over the swatches: a name takes the first swatch of that +/// name still there (ungrouped first, then each colour group's) when `take` accepts it, and +/// nothing when `take` refuses it or no swatch has the name. +fn take_swatches(d: &mut Document, names: &[String], take: impl Fn(&Swatch) -> bool) -> Vec { + let wanted: HashSet<&str> = names.iter().map(String::as_str).collect(); + // Each wanted name's swatches in lookup order: (list, index, accepted), where list 0 holds the + // ungrouped swatches and list g + 1 colour group g's. + let mut places: HashMap<&str, VecDeque<(usize, usize, bool)>> = HashMap::new(); + let lists = std::iter::once(&d.swatches[..]).chain(d.swatch_groups.iter().map(|g| &g.swatches[..])); + for (l, list) in lists.enumerate() { + for (i, w) in list.iter().enumerate() { + if let Some(n) = wanted.get(w.name.as_str()) { + places.entry(n).or_default().push_back((l, i, take(w))); + } + } + } + let mut picked: Vec<(usize, usize)> = vec![]; + for n in names { + if let Some(q) = places.get_mut(n.as_str()) + && let Some(&(l, i, true)) = q.front() + { + q.pop_front(); + picked.push((l, i)); + } + } + if picked.is_empty() { + return vec![]; + } + let hit: HashSet<(usize, usize)> = picked.iter().copied().collect(); + let hit_lists: HashSet = picked.iter().map(|&(l, _)| l).collect(); + let mut taken: HashMap<(usize, usize), Swatch> = HashMap::with_capacity(picked.len()); + let mut split = |l: usize, list: &mut Vec| { + for (i, w) in std::mem::take(list).into_iter().enumerate() { + if hit.contains(&(l, i)) { + taken.insert((l, i), w); + } else { + list.push(w); + } + } + }; + // Both lists are copied on write, so each is only written when a swatch leaves it. + if hit_lists.contains(&0) { + split(0, &mut d.swatches); + } + if hit_lists.iter().any(|&l| l > 0) { + for (g, group) in d.swatch_groups.iter_mut().enumerate() { + if hit_lists.contains(&(g + 1)) { + split(g + 1, &mut group.swatches); + } + } + } + picked.iter().filter_map(|k| taken.remove(k)).collect() +} + fn swatch_delete(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.delete"; let names = names_param(p, C)?; check_switches(p, &["unlink"], C)?; let d = &s.doc()?.doc; + let by_name = swatches_by_name(d); // Global swatches going away (named ones and those in named groups): their links are dropped. - let mut global: Vec = vec![]; + let mut global: HashSet = HashSet::new(); for n in &names { let swatches: Vec<&Swatch> = match d.swatch_groups.iter().find(|g| g.name == *n) { Some(g) => g.swatches.iter().collect(), - None => vec![d.swatch(n).ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?], + None => vec![by_name.get(n.as_str()).copied().ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?], }; if swatches.iter().any(|w| w.is_reserved()) { return Err(bad(C, format!("`{n}` can't be deleted"))); @@ -258,10 +315,9 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { |_: &mut Color, link: &mut Option, _: &mut f32| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some(); let colorized_ok = super::paint::check_colorized(d).is_ok(); let unlinked = s.edit("Delete Swatch", |d, _| { - d.swatch_groups.retain(|g| !names.contains(&g.name)); - for n in &names { - d.remove_swatch(n); - } + let named: HashSet<&str> = names.iter().map(String::as_str).collect(); + d.swatch_groups.retain(|g| !named.contains(g.name.as_str())); + take_swatches(d, &names, |_| true); let unlinked = map_swatch_links(d, &mut unlink); super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; Ok(unlinked) @@ -419,15 +475,9 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { new.extend(art.into_iter().map(|c| (c, to_global))); } let name = d.free_swatch_name(requested.as_deref().unwrap_or("Color Group")); - let mut group = SwatchGroup { name: name.clone(), swatches: vec![] }; // Colour groups hold solid colours only. - for n in &names { - if d.swatch(n).is_some_and(|sw| matches!(sw.paint, Paint::Solid { .. })) - && let Some(sw) = d.remove_swatch(n) - { - group.swatches.push(sw); - } - } + let swatches = take_swatches(d, &names, |sw| matches!(sw.paint, Paint::Solid { .. })); + let mut group = SwatchGroup { name: name.clone(), swatches }; // The global swatches made from the art's colours, which its unlinked paints link to. let mut links: Vec<(Color, String)> = vec![]; let mut free = d.free_swatch_names(); @@ -483,8 +533,9 @@ fn swatch_move(s: &mut Session, p: &Value) -> Result { insert_before(&mut d.swatch_groups, items, at.as_deref(), |g| &g.name); return Ok(()); } + let by_name = swatches_by_name(d); for n in &names { - let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?; + let sw = by_name.get(n.as_str()).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?; if sw.is_reserved() { return Err(bad(C, format!("`{n}` can't be moved"))); } @@ -503,7 +554,7 @@ fn swatch_move(s: &mut Session, p: &Value) -> Result { } } let at = anchor(list(d, dest), to, &names, |w| &w.name); - let items: Vec = names.iter().filter_map(|n| d.remove_swatch(n)).collect(); + let items = take_swatches(d, &names, |_| true); insert_before(list(d, dest), items, at.as_deref(), |w| &w.name); Ok(()) })?; @@ -955,8 +1006,9 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.merge"; let names = names_param(p, C)?; let d = &s.doc()?.doc; + let by_name = swatches_by_name(d); for n in &names { - let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?; + let sw = by_name.get(n.as_str()).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?; if sw.is_reserved() { return Err(bad(C, format!("`{n}` can't be merged"))); } @@ -970,9 +1022,10 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { } let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?; let (color, link) = (d.linked_color(kept.paint.color().unwrap_or_default(), kept.spot), kept.global.then(|| keep.clone())); + let merged_names: HashSet<&str> = merged.iter().map(String::as_str).collect(); // Tints keep their tint (of the kept colour). let mut relink = |c: &mut Color, l: &mut Option, t: &mut f32| { - if !l.as_ref().is_some_and(|l| merged.contains(l)) { + if !l.as_ref().is_some_and(|l| merged_names.contains(l.as_str())) { return false; } *c = color.tinted(*t); @@ -981,9 +1034,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { }; let colorized_ok = super::paint::check_colorized(d).is_ok(); let relinked = s.edit("Merge Swatches", |d, _| { - for n in merged { - d.remove_swatch(n); - } + take_swatches(d, merged, |_| true); let relinked = map_swatch_links(d, &mut relink); super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; Ok(relinked) diff --git a/crates/engine/src/tests/swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs index cbf595d6d8..af059e5e33 100644 --- a/crates/engine/src/tests/swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -355,6 +355,35 @@ fn new_color_group_from_artwork_lists_each_color_once_with_free_names() { assert_eq!(*doc(&s), before); } +/// New Color Group takes the named solid swatches in the order given. A name listed twice takes +/// the next swatch of that name, found ungrouped first and then in each colour group, and the +/// lists the swatches leave keep their order. Gradients and names no swatch has take nothing. +#[test] +fn new_color_group_takes_named_swatches_in_order_from_every_list() { + let mut s = session(); + let twin = vectorcraft_color::Swatch { name: "Red".into(), paint: Paint::solid(Color::rgb(0.5, 0.0, 0.0)), global: false, spot: false }; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc) + .swatch_groups + .iter_mut() + .find(|g| g.name == "Brights") + .unwrap() + .swatches + .push(twin.clone()); + let red = doc(&s).swatch("Red").cloned().unwrap(); + let mut ungrouped = names(&s, None); + let mut brights = names(&s, Some("Brights")); + let r = run(&mut s, "swatch.newGroup", json!({"name": "Mine", "swatches": ["Red", "Orange", "Sunset", "Red", "Missing", "Bright Red"]})); + assert_eq!(r["swatches"], json!(["Red", "Orange", "Red", "Bright Red"])); + let group = &doc(&s).swatch_groups.iter().find(|g| g.name == "Mine").unwrap().swatches; + assert_eq!(group[0], red); + assert_eq!(group[2], twin); + ungrouped.retain(|n| n != "Red" && n != "Orange"); + brights.retain(|n| n != "Red" && n != "Bright Red"); + assert_eq!(names(&s, None), ungrouped); + assert_eq!(names(&s, Some("Brights")), brights); + assert!(doc(&s).swatch("Sunset").is_some()); +} + #[test] fn edit_color_group_numbers_extra_colors_after_the_names_it_keeps() { let mut s = session(); From 883c24a3a8d64081d0c2a091e24dd55a55b927e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:39:25 -0800 Subject: [PATCH 38/83] Simplify: lines between samples of a flat loop stay within the tolerance When the room left in the result limit is too small for the lines of a curved segment to stay within the tolerance, the loop stays as it is instead of taking fewer lines that can pass it. --- crates/pathops/src/edit.rs | 43 +++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 17b231a5dc..d8d121ecb9 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -90,7 +90,8 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { /// no area, straight lines follow the subpath within the tolerance, and without straight lines it /// stays as it is, as does one of a single segment. When no lines within the limits on pieces enclose /// an area, it stays as it is, or with straight lines follows the lines between samples of each -/// curved segment; one that is all in line keeps the refit. +/// curved segment when the limits leave room for lines within the tolerance; one that is all in line +/// keeps the refit. fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let mut segs = subpath_segs(sp); let n = segs.len(); @@ -146,9 +147,9 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { // ends and those lines enclose an area: with straight lines they are the result, and without // them the loop stays as it is. When no split within those limits encloses an area, the loop // stays as it is, or with straight lines each curved segment becomes the lines between its - // samples, up to `MAX_RUN_RESULT` lines in all with the line segments; when that leaves no - // line for some curved segment, the loop stays as it is. A loop whose points are all in line - // keeps the lines of the refit. + // samples, up to `MAX_RUN_RESULT` lines in all with the line segments; when those do not keep + // some curved segment within the tolerance, the loop stays as it is. A loop whose points are + // all in line keeps the lines of the refit. let near = |c: &CubicBez| [c.p1, c.p2].iter().all(|&p| Line::new(c.p0, c.p3).nearest(p, 1e-9).distance_sq.sqrt() <= opts.tolerance); let curved = segs.iter().filter(|s| !s.line).count(); for k in 1..=10u32 { @@ -184,8 +185,11 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { lines.push(Seg::line(s.c.p0, s.c.p3)); continue; } + let Some(steps) = steps_within(&s.c, opts.tolerance.max(1e-6), room) else { + return Some(sp.clone()); + }; let mut pts = Vec::new(); - sample(&s.c, sample_steps(&s.c, opts.tolerance.max(1e-6)).min(room).max(1), &mut pts, true); + sample(&s.c, steps, &mut pts, true); lines.extend(pts.windows(2).map(|p| Seg::line(p[0], p[1]))); } return segs_to_subpath(&lines, true); @@ -206,6 +210,14 @@ fn sample_room(curved: usize, kept: usize) -> Option { Some((left / curved.max(1)).min(MAX_SEGMENT_SAMPLES)) } +/// How many evenly spaced lines along `c`, at most `room`, follow it: [`sample_steps`] when that +/// fits, or fewer, but never fewer than keep `c` within `tol` of them. `None` when those do not fit +/// in `room`. +fn steps_within(c: &CubicBez, tol: f64, room: usize) -> Option { + let need = line_steps(c, tol).max(1); + (need <= room).then(|| sample_steps(c, tol).min(room).max(need)) +} + /// How far a segment may be from the lines between its samples, as a share of the tolerance. const SAMPLE_GAP: f64 = 0.005; @@ -1182,6 +1194,27 @@ mod tests { assert_eq!(sample_room(1, MAX_RUN_RESULT + 5), None); } + #[test] + fn lines_with_too_little_room_for_the_tolerance_are_not_used() { + let c = CubicBez::new((0.0, 0.0), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0)); + let tol = 0.01; + let need = line_steps(&c, tol); + assert!(need > 4, "{need}"); + assert_eq!(steps_within(&c, tol, need - 1), None); + for room in [need, need + 3, MAX_SEGMENT_SAMPLES] { + let steps = steps_within(&c, tol, room).unwrap_or_else(|| panic!("room {room}: no steps")); + assert!((need..=room).contains(&steps), "room {room}: {steps} steps"); + let mut pts = Vec::new(); + sample(&c, steps, &mut pts, true); + let lines: Vec = pts.windows(2).map(|p| Line::new(p[0], p[1])).collect(); + for i in 0..=1000 { + let p = c.eval(f64::from(i) / 1000.0); + let gap = lines.iter().map(|l| l.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); + assert!(gap <= tol + 1e-9, "room {room}, {steps} steps: {gap} at {i}"); + } + } + } + #[test] fn smoothing_points_whose_arc_length_overflows_returns_every_point() { let pts: Vec = (0..40).map(|i| Point::new(if i % 2 == 0 { -1e308 } else { 1e308 }, f64::from(i))).collect(); From 880b58729b242fd04bcb75cad84e7f57e987c598 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:40:14 -0800 Subject: [PATCH 39/83] Panels: a panel floated while the floating panels are hidden opens hidden The floating panels' windows hide while a dialog, a menu, a tool flyout or the All Tools drawer is open, but the commands went out only when that state changed. A panel floated meanwhile opened shown at its topmost level, over the dialog or drawer. A window that floats while the others are hidden now gets the hide commands in its first frame. --- crates/ui-egui/src/panel_docking.rs | 24 ++++++++++----- .../ui-egui/src/panel_docking/native_tests.rs | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 3447cd1e15..ef502761df 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1238,18 +1238,28 @@ fn palette_window_state(modal: bool, popup: bool, active: bool) -> (egui::Window /// Sends the palette windows of `layout` the level and visibility [`palette_window_state`] gives /// when they change. craft-ui varies a palette's level through the viewport builder, which egui /// applies when the window opens and not to one already on screen, so the commands go explicitly to -/// the windows there are. +/// the windows there are. A window that opens while the others are hidden (a panel floated under a +/// dialog, a menu, a flyout or the drawer) opens shown at its palette level, so it gets the commands +/// in its first frame. #[cfg(not(target_arch = "wasm32"))] fn palette_window_levels(ctx: &egui::Context, layout: &Layout, modal: bool, popup: bool) { let active = ctx.input(|i| i.focused || i.raw.viewports.values().any(|v| v.focused == Some(true))); let key = area_id().with("palette-levels-state"); - if ctx.data(|d| d.get_temp::<(bool, bool, bool)>(key)) == Some((modal, popup, active)) { - return; - } - ctx.data_mut(|d| d.insert_temp(key, (modal, popup, active))); - + let state = (modal, popup, active); + let windows: Vec = layout.floating.iter().map(|window| window.id).collect(); + let sent = ctx.data(|d| d.get_temp::<((bool, bool, bool), Vec)>(key)); let (level, visible) = palette_window_state(modal, popup, active); - for window in &layout.floating { + // The windows that already have this state's commands. + let known: &[u64] = match &sent { + Some((was, ids)) if *was == state && !visible => ids, + Some((was, _)) if *was == state => &windows, + _ => &[], + }; + let fresh: Vec = windows.iter().copied().filter(|id| !known.contains(id)).collect(); + if sent.as_ref().is_none_or(|(was, ids)| *was != state || *ids != windows) { + ctx.data_mut(|d| d.insert_temp(key, (state, windows))); + } + for window in layout.floating.iter().filter(|window| fresh.contains(&window.id)) { let viewport = craft_ui::docking::DockArea::::window_viewport(area_id(), window.id); ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::WindowLevel(level)); ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Visible(visible)); diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index db1e6be36c..2f1d0d536c 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -842,6 +842,36 @@ fn a_torn_off_strip_or_a_new_toolbar_leaves_the_palette_windows_shown() { assert_eq!(take_palette_commands(&h, viewport).0, [false], "still shown: the drawer hides them again"); } +/// A panel floated while the palette windows are hidden opens hidden too, and shows with the others +/// once the drawer or the dialog over them closes. +#[test] +fn a_panel_floated_while_the_palette_windows_are_hidden_opens_hidden() { + let mut h = Harness::new(); + let navigator = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, navigator); + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, navigator).0, [false], "hidden under the drawer"); + let layers = h.float("layers"); + h.host.borrow_mut().focus = ViewportId::ROOT; + assert!(h.app.ui.tools_drawer, "the drawer stays open"); + let (shown, levels) = take_palette_commands(&h, layers); + assert_eq!(shown, [false], "the new palette window is hidden under the drawer"); + assert_eq!(levels, [egui::WindowLevel::Normal], "and out of the top level"); + h.app.run("window.editToolbar", json!({"open": false})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, layers).0, [true], "shown when the drawer closes"); + + h.app.ui.palette_open = true; + h.frames(2); + assert_eq!(take_palette_commands(&h, layers).0, [false], "hidden under the command palette"); + let swatches = h.float("swatches"); + h.host.borrow_mut().focus = ViewportId::ROOT; + assert_eq!(take_palette_commands(&h, swatches).0, [false], "a panel floated under it is hidden too"); +} + /// The frame in which a click elsewhere closes a flyout or the drawer shows the palette windows, as /// no later frame need come while the app is idle. #[test] From f4bbf33b51ec10d76d78b343d54a2910aac2e097 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 22:46:07 +0000 Subject: [PATCH 40/83] Trace: refuse a GIF frame larger than the pixel ceiling before decoding --- crates/trace/src/raster.rs | 36 +++++++++++++++++++++++++++++++++++- crates/trace/src/tests.rs | 12 ++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index fc7cd10f67..3e61c58953 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -56,6 +56,37 @@ pub struct Raster { pub rgba: Vec, } +/// The pixels the first frame of GIF `bytes` declares in its image descriptor, which the decoder +/// allocates for before it crops the frame to the logical screen the dimension check reads. `None` +/// when the bytes aren't a GIF or hold no frame header. +fn gif_first_frame_pixels(bytes: &[u8]) -> Option { + if !bytes.starts_with(b"GIF8") { + return None; + } + let flags = *bytes.get(10)?; + let mut at = 13 + if flags & 0x80 != 0 { 3 << ((flags & 7) + 1) } else { 0 }; + loop { + match *bytes.get(at)? { + // An extension: its data sub-blocks end at a zero length. + 0x21 => { + at += 2; + loop { + let len = usize::from(*bytes.get(at)?); + at += 1 + len; + if len == 0 { + break; + } + } + } + 0x2C => { + let d = bytes.get(at + 5..at + 9)?; + return Some(u64::from(u16::from_le_bytes([d[0], d[1]])) * u64::from(u16::from_le_bytes([d[2], d[3]]))); + } + _ => return None, + } + } +} + impl Raster { /// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other /// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not @@ -85,7 +116,7 @@ impl Raster { if w == 0 || h == 0 { return Err(TraceError::Empty); } - if u64::from(w) * u64::from(h) > MAX_PIXELS { + if u64::from(w) * u64::from(h) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); } let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); @@ -112,6 +143,9 @@ impl Raster { } // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. // A deeper image (16 bits a channel) that needs more fails with a decode error. + if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + } let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; let mut limits = image::Limits::default(); limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 19f2081751..4e138dfd5b 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -2908,3 +2908,15 @@ fn noise_keeps_a_kept_rare_colours_regions_but_not_its_specks() { assert_eq!(a, b); assert!(a.iter().all(|&v| v == 0)); } + +/// A GIF whose logical screen is one pixel but whose frame declares 65535 × 65535 is refused before +/// the decoder allocates for the frame. +#[test] +fn a_gif_frame_larger_than_its_screen_is_refused_before_decoding() { + let mut gif = b"GIF89a".to_vec(); + gif.extend([1, 0, 1, 0, 0, 0, 0]); // screen 1 × 1, no color table + gif.extend([0x2C, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0]); // frame at 0,0 of 65535 × 65535 + gif.extend([2, 1, 0, 0, 0x3B]); + assert!(matches!(Raster::decode(&gif), Err(TraceError::TooLarge { .. })), "{:?}", Raster::decode(&gif).err()); + assert!(matches!(Raster::decode_to_fit(&gif), Err(TraceError::TooLarge { .. }))); +} From 7bd559cb1dbaec61f0014cc8b7567f09ec9a35f4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:47:18 -0800 Subject: [PATCH 41/83] Docs: PDF/A files at PDF 1.4 write long pages in points A PDF/A file at PDF 1.4 has no UserUnit, so a page longer than 14,400 pt is written in points with or without longPagesInPoints. The param text, docs/mcp.md and the exporter comment now say that only PDF/A files at PDF 1.5 and later keep the UserUnit. --- crates/engine/src/cmd/fileio/pdf.rs | 4 ++-- crates/pdf/src/export/entry.rs | 5 +++-- docs/mcp.md | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index a18af48af1..2e42ab94e6 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -99,7 +99,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "longPagesInPoints", ty: "boolean", default: "false", - description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files from 1.5 on keep the UserUnit)", }, FormatOption { name: "createLayers", @@ -128,7 +128,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, 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), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), 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), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files from 1.5 on keep the UserUnit); apps that keep to the limit cut it to 200 in), 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 ), diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index d6fb01a07c..a53394f0f0 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -199,8 +199,9 @@ fn writes_user_units(set: &PdfSettings) -> bool { } /// `long_pages_in_points` is on in a file without a standard. PDF/X files write long pages in -/// points anyway, and PDF/A files keep the `UserUnit`, which keeps their page boundaries within -/// 14,400 units (ISO 19005-2, 6.1.13). +/// points anyway, and PDF/A files from PDF 1.5 on keep the `UserUnit`, which keeps their page +/// boundaries within 14,400 units (ISO 19005-2, 6.1.13). A PDF/A file at PDF 1.4 has no +/// `UserUnit` and writes long pages in points either way. fn long_pages_in_points(set: &PdfSettings) -> bool { set.long_pages_in_points && set.standard == Standard::None } diff --git a/docs/mcp.md b/docs/mcp.md index 771f11ccdd..4a875123ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -878,8 +878,10 @@ keeps PDF 1.5's encryption (128-bit RC4). PDF/X files and PDF 1.3 and 1.4 files that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. The option applies -only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files keep the `UserUnit`, -which keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13). The dialog disables the +only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files at PDF 1.5 and later keep +the `UserUnit`, which keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13). A PDF/A +file at PDF 1.4 writes such a page in points, as other PDF 1.4 files do, so its page boundaries are longer than that +limit. The dialog disables the checkbox when a standard is chosen. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can be at most 32,767, and whole numbers are written as integers: a page longer than 32,767 pt whose sides aren't whole numbers of points fails the export with an error naming the page before the thumbnails are drawn and the transparency is From 3fda7ad83e5773b30314c39e57bd8f1df8585a1e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:51:21 -0800 Subject: [PATCH 42/83] Actions: Batch shows the data set File Name row only where it applies The Batch dialog showed File Name for every destination when Play was Data Sets, though only Destination Folder writes a file named from it. The row now shows after Destination Folder for Data Sets with a folder destination, and the dialog sends datasetFileNaming only then. The chosen value stays in the dialog while another destination is picked. action.batch and its default for an omitted datasetFileNaming are unchanged. --- crates/ui-egui/src/dialogs/action_batch.rs | 71 +++++++++++++++++----- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/dialogs/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index 4e8a3bfae8..61ef75c5e1 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -66,14 +66,6 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "includeSubdirs", "Include All Subdirectories"); } else { ui.label(egui::RichText::new(tl!("Uses every data set of the open document.")).size(11.5).color(t.text_dim)); - form::choice( - ui, - d, - "datasetFileNaming", - "File Name:", - (LABEL, 200.0), - &[("fileNumber", "File + Number"), ("fileDataSetName", "File + Data Set Name"), ("dataSetName", "Data Set Name")], - ); } ui.add_space(8.0); @@ -86,6 +78,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); } + if names_data_set_files(d) { + form::choice( + ui, + d, + "datasetFileNaming", + "File Name:", + (LABEL, 200.0), + &[("fileNumber", "File + Number"), ("fileDataSetName", "File + Data Set Name"), ("dataSetName", "Data Set Name")], + ); + } ui.add_space(6.0); widgets::subheader(ui, tl!("Overrides")); form::check(ui, d, "overrideOpen", "Override Action \"Open\" Commands"); @@ -160,7 +162,8 @@ fn params(d: &Dialog, action_name: &str, commands: Vec) -> Value { }); if source == "folder" { params["folder"] = json!(d.str("folder")); - } else if source == "datasets" { + } + if names_data_set_files(d) { params["datasetFileNaming"] = json!(d.str("datasetFileNaming")); } if destination == "folder" { @@ -169,14 +172,22 @@ fn params(d: &Dialog, action_name: &str, commands: Vec) -> Value { params } +/// Whether the File Name choice names the batch's output: a Data Sets source with a +/// destination folder. With None or Save and Close the dialog sends no folder, so no file +/// takes the data set's name. +fn names_data_set_files(d: &Dialog) -> bool { + d.str("source") == "datasets" && d.str("destination") == "folder" +} + #[cfg(test)] mod tests { use super::*; use vectorcraft_engine::Session; - fn shown(source: &str) -> Vec { + fn shown(source: &str, destination: &str) -> Vec { let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); d.fields["source"] = json!(source); + d.fields["destination"] = json!(destination); render(&mut d) } @@ -205,11 +216,35 @@ mod tests { labels } + /// The File Name row's caption and its selected choice (the dropdown shows only that one). + const NAMING: [&str; 2] = ["File Name:", "File + Number"]; + #[test] - fn file_name_choice_shows_only_for_data_sets() { - assert!(shown("datasets").iter().any(|label| label == "File Name:")); - assert!(shown("datasets").iter().any(|label| label == "File + Number")); - assert!(!shown("folder").iter().any(|label| label == "File Name:")); + fn file_name_choice_shows_only_for_data_sets_into_a_folder() { + for source in ["folder", "datasets"] { + for destination in ["none", "saveAndClose", "folder"] { + let labels = shown(source, destination); + let expected = source == "datasets" && destination == "folder"; + for text in NAMING { + assert_eq!(labels.iter().any(|label| label == text), expected, "{source} to {destination}, {text}: {labels:?}"); + } + } + } + } + + #[test] + fn file_name_choice_comes_back_after_another_destination() { + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); + d.fields["source"] = json!("datasets"); + d.fields["datasetFileNaming"] = json!("dataSetName"); + for (destination, shown) in [("folder", true), ("none", false), ("saveAndClose", false), ("folder", true)] { + d.fields["destination"] = json!(destination); + let labels = render(&mut d); + assert_eq!(labels.iter().any(|label| label == "File Name:"), shown, "{destination}: {labels:?}"); + assert_eq!(labels.iter().any(|label| label == "Data Set Name"), shown, "{destination}: {labels:?}"); + assert_eq!(d.str("datasetFileNaming"), "dataSetName", "{destination}"); + } + assert_eq!(params(&d, "Test", vec![])["datasetFileNaming"], "dataSetName"); } /// Batch plays an action recorded with a key its command no longer reads, as Play does. @@ -240,6 +275,14 @@ mod tests { d.fields["datasetFileNaming"] = json!(choice); assert_eq!(params(&d, "Test", vec![])["datasetFileNaming"], choice); } + d.fields["datasetFileNaming"] = json!("dataSetName"); + for destination in ["none", "saveAndClose"] { + d.fields["destination"] = json!(destination); + let p = params(&d, "Test", vec![]); + assert!(p.get("datasetFileNaming").is_none(), "{destination}: {p}"); + assert!(p.get("destinationFolder").is_none(), "{destination}: {p}"); + } + d.fields["destination"] = json!("folder"); d.fields["source"] = json!("folder"); assert!(params(&d, "Test", vec![]).get("datasetFileNaming").is_none()); } diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..7df361b4eb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4443,7 +4443,8 @@ Save for Office Documents included) that writes a file and that the batch does n warning. For a Data Sets source, `datasetFileNaming?: fileNumber | fileDataSetName | dataSetName` chooses File + Number (`source001`), File + Data Set Name (`source_Alpha`), or Data Set Name (`Alpha`). An omitted parameter keeps File + Data Set Name -for existing callers; the dialog starts with File + Number. A folder source ignores this parameter. The folder +for existing callers; the dialog starts with File + Number, and shows and sends the choice only when Destination is +Folder. A folder source ignores this parameter. The folder destination and overridden Save/Export paths use the chosen stem with the output format's extension. An empty data set name under Data Set Name falls back to the document stem plus its three-digit ordinal. Batch writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds the next free `-2`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..a48091a270 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -715,6 +715,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). + - The Batch dialog shows its data set File Name row only when Destination is Folder, and keeps the choice while another destination is picked. With None or Save and Close it does not send `datasetFileNaming`. - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set, unless a recorded Save step that Override Save points into the destination folder first makes each data set's file the document's own. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. Unless a recorded Save or Save As step writes a file and makes it the document's own first, `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. From 634b9c7ff86c9fe8c00f1cf52b7f5fd18dd6767d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:55:46 -0800 Subject: [PATCH 43/83] Objects on Path: the Move All widget slides every attached object along the path With one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below the first object. Dragging it moves every object along the path together, keeping the distances between them (object.objectsOnPath.options with start), in one undo step; Escape cancels it, and the first object's distance along the path shows next to the pointer. On an open path the widget shows only when the distance from the first object to the last is shorter than the path, and the objects stop at its ends; on a closed path they go on round across the first anchor. Where Auto or a Space value the open path is too short for sets the distance, the drag also sets spacing to the distance as drawn. --- crates/tools/src/cursors.toml | 1 + crates/tools/src/objects_on_path.rs | 448 ++++++++++++++++++++++++-- crates/tools/src/select.rs | 27 +- crates/ui-egui/src/canvas/overlays.rs | 16 +- crates/ui-egui/src/canvas/tests.rs | 39 +++ docs/mcp.md | 12 + docs/target-app-parity.md | 3 +- 7 files changed, 514 insertions(+), 32 deletions(-) diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index c5b0f52327..13e98ec4ee 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -102,6 +102,7 @@ shown_when = [ "The Artboard tool inside an artboard.", "The Gradient tool over the annotator's handles and during most gradient drags.", "The Perspective Grid and Perspective Selection over plane widgets and handles.", + "Selection and Objects on Path over an Objects on Path group's Move All widget, and while dragging it.", ] hotspot = "The centre." look = "A four-way move cursor." diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index fe5a8b9215..c2f90d0bef 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -20,13 +20,22 @@ //! one. The distance shows next to the pointer in the document's units, the drag is previewed and //! kept as one undo step on release, and Escape cancels it. //! +//! The Move All widget: with one Objects on Path group selected whose objects have room to move +//! along its path, the same tools show a diamond beyond the group's first object, below it. +//! Dragging it along the path moves every object along it as far, keeping the distances between +//! them (`object.objectsOnPath.options` with `start`). On an open path, the widget shows only when +//! the distance from the first object to the last is shorter than the path, and the objects stop +//! at its ends; on a closed path they go on round across its first anchor. The first object's +//! distance along the path shows next to the pointer in the document's units, the drag is +//! previewed and kept as one undo step on release, and Escape cancels it. +//! //! Behavior: , //! . use serde_json::{Value, json}; use vectorcraft_doc::blend::Spine; -use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop}; -use vectorcraft_doc::{Document, NodeId, Selection}; +use vectorcraft_doc::objects_on_path::{CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop}; +use vectorcraft_doc::{Document, Node, NodeId, Selection}; use vectorcraft_geom::{BezPath, Point, Vec2}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -51,6 +60,14 @@ pub const SPACE_HALF_PX: f32 = 3.5; /// How near (screen px) the pointer must be to hit the Space widget. const SPACE_HIT_PX: f64 = 6.0; +/// The Move All widget's center sits this far (screen px) beyond the first object, on the side its +/// bottom faces. +const MOVE_ALL_OFFSET_PX: f64 = 16.0; +/// Half the diagonal of the Move All widget's diamond (screen px), as the canvas draws it. +pub const MOVE_ALL_HALF_PX: f32 = 4.5; +/// How near (screen px) the pointer must be to hit the Move All widget. +const MOVE_ALL_HIT_PX: f64 = 6.0; + /// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with /// its record, its children and its path: only when the selection is that one editable group, on /// a path with length, and no anchors are Direct-Selected. @@ -73,6 +90,30 @@ fn one_group<'a>( Some((*group, rec, children.as_slice(), path)) } +/// The direction the top of the object `m` faces: the -y direction of the frame it is drawn in +/// (the document's y axis points down), so a reflected or sheared frame turns it with the art's +/// top. A unit vector in the document's axes. +fn top(m: &Member) -> Vec2 { + let [_, _, a2, a3, _, _] = m.frame.as_coeffs(); + let len = a2.hypot(a3); + if len.is_finite() && len > 1e-12 { + Vec2::new(-a2 / len, -a3 / len) + } else { + let (sin, cos) = m.angle.sin_cos(); + Vec2::new(sin, -cos) + } +} + +/// The point `gap` (points) beyond the bounds of the object `m` (one of `children`), from its +/// attachment point `from` in the direction `dir`. `None` when it is not finite. +fn beside(doc: &Document, children: &[std::sync::Arc], m: &Member, from: Point, dir: Vec2, gap: f64) -> Option { + let b = children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?; + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; + let reach = corners.iter().map(|c| (*c - from).dot(dir)).fold(0.0, f64::max); + let at = from + dir * (reach + gap); + (at.x.is_finite() && at.y.is_finite() && from.x.is_finite() && from.y.is_finite()).then_some(at) +} + /// The Rotate All widget of a selected Objects on Path group. #[derive(Clone, Copy, Debug, PartialEq)] pub struct RotateAllWidget { @@ -93,23 +134,8 @@ impl RotateAllWidget { let (group, rec, children, path) = one_group(doc, selection, zoom)?; let first = rec.members.first()?; let (pivot, _) = stop(path, rec.members.len(), rec.spacing, rec.start, 0)?; - let art = children.iter().find(|c| c.id == first.id)?; - let b = art.geometric_bounds_with(&doc.symbol_boxes())?; - // The object's top is the -y direction of the frame it is drawn in (the document's y axis - // points down), so a reflected or sheared frame moves the widget with the art's top. - let [_, _, a2, a3, _, _] = first.frame.as_coeffs(); - let len = a2.hypot(a3); - let up = if len.is_finite() && len > 1e-12 { - Vec2::new(-a2 / len, -a3 / len) - } else { - let (sin, cos) = first.angle.sin_cos(); - Vec2::new(sin, -cos) - }; - let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; - let reach = corners.iter().map(|c| (*c - pivot).dot(up)).fold(0.0, f64::max); - let at = pivot + up * (reach + ROTATE_ALL_OFFSET_PX / zoom); - let finite = at.x.is_finite() && at.y.is_finite() && pivot.x.is_finite() && pivot.y.is_finite(); - finite.then_some(Self { group, pivot, at, rotate: rec.rotate }) + let at = beside(doc, children, first, pivot, top(first), ROTATE_ALL_OFFSET_PX / zoom)?; + Some(Self { group, pivot, at, rotate: rec.rotate }) } /// The widget of `cx` when `p` is within reach of it. @@ -394,6 +420,160 @@ impl SpaceDrag { } } +/// The Move All widget of a selected Objects on Path group. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MoveAllWidget { + pub group: NodeId, + /// The first object's attachment point, in the document. + pub first: Point, + /// The widget's center in the document. + pub at: Point, + /// The length of the path (points). + pub len: f64, + /// Is the path closed? + pub closed: bool, + /// Where the first object attaches along the path (points from its first anchor), as drawn. + pub start: f64, + /// The farthest along an open path the first object can attach with the others still on it + /// (points); the path's length on a closed path. + pub most: f64, + /// The Space value a drag sets with the start: the distance between the objects as drawn, when + /// the saved value would draw them apart differently from another start (Auto, or a Space value + /// an open path is too short for). `None` keeps the saved value. + pub gap: Option, + /// The group's start, as saved. + pub saved: f64, +} + +impl MoveAllWidget { + /// The widget of the selection at `zoom` (screen px per document point): only when the + /// selection is one editable Objects on Path group with at least one object on a path with + /// length, no anchors are Direct-Selected, and the objects have room to move: on an open path, + /// the distance from the first object to the last is shorter than the path. It sits beyond the + /// first object's bounds, on the side its bottom faces. + pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option { + let (group, rec, children, path) = one_group(doc, selection, zoom)?; + let count = rec.members.len(); + let first = rec.members.first()?; + let (len, closed) = path_length(path)?; + let (start, gap) = layout(len, closed, count, rec.spacing, rec.start); + let most = if closed { len } else { len - gap * count.saturating_sub(1) as f64 }; + if !closed && (!most.is_finite() || most <= 1e-9) { + return None; + } + // On an open path, Auto and a Space value the path is too short for draw the objects apart + // by what is left of the path after the start, so another start would change the distance. + let keeps = closed || count < 2 || rec.spacing.is_some_and(|g| (g - gap).abs() < 1e-9); + let (at_first, _) = stop(path, count, rec.spacing, rec.start, 0)?; + let at = beside(doc, children, first, at_first, -top(first), MOVE_ALL_OFFSET_PX / zoom)?; + let finite = [len, start, most, rec.start].iter().all(|v| v.is_finite()); + finite.then_some(Self { group, first: at_first, at, len, closed, start, most, gap: (!keeps).then_some(gap), saved: rec.start }) + } + + /// The widget of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, p: Point) -> Option { + Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(MOVE_ALL_HIT_PX)) + } +} + +/// Is `p` over the Move All widget? +pub fn over_move_all(cx: &ToolContext, p: Point) -> bool { + MoveAllWidget::at_point(cx, p).is_some() +} + +/// Dragging the Move All widget: the distance the pointer moves along the path moves every object +/// along it as far, keeping the distances between them. On an open path the objects stop at its +/// ends, and on a closed path they go on round across its first anchor. +#[derive(Clone, Debug)] +pub struct MoveAllDrag { + widget: MoveAllWidget, + start: Point, + began: bool, + /// The distance along the path to the point nearest the pointer at the last drag (points). + last: f64, + /// Where the drag puts the first object along the path (points from its first anchor). + to: f64, + at: Point, +} + +impl MoveAllDrag { + /// The drag has left the click: the objects are moving. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on the Move All widget. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = MoveAllWidget::at_point(cx, ev.pos)?; + let (_, last) = group_spine(cx.doc, widget.group)?.nearest(ev.pos, Some(widget.start))?; + last.is_finite().then_some(Self { widget, start: ev.pos, began: false, last, to: widget.start, at: ev.pos }) + } + + /// The pointer moved to `p`: move the first object as far along the path as the point nearest + /// the pointer moved, the shorter way round a closed path. On an open path it stays between + /// the first anchor and [`MoveAllWidget::most`], so moving back from beyond an end moves the + /// objects back at once. + fn follow(&mut self, doc: &Document, p: Point) { + let w = &self.widget; + let Some((_, t)) = group_spine(doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return }; + let step = if w.closed { (t - self.last + w.len / 2.0).rem_euclid(w.len) - w.len / 2.0 } else { t - self.last }; + if !t.is_finite() || !step.is_finite() { + return; + } + self.last = t; + self.to = if w.closed { + let s = (self.to + step).rem_euclid(w.len); + if s.is_finite() && s < w.len { s } else { 0.0 } + } else { + (self.to + step).clamp(0.0, w.most) + }; + } + + /// The pointer moved to `p`. + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + let mut out = vec![]; + if !self.began { + if p.distance(self.start) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Objects on Path Options".into())); + } + self.follow(cx.doc, p); + self.at = p; + let w = &self.widget; + // Where the objects stay as they are drawn, the group keeps its saved values. + let params = if (self.to - w.start).abs() < 1e-9 { + json!({ "ids": [w.group.0], "start": w.saved }) + } else if let Some(gap) = w.gap { + json!({ "ids": [w.group.0], "start": self.to, "spacing": gap }) + } else { + json!({ "ids": [w.group.0], "start": self.to }) + }; + out.push(Action::Preview("object.objectsOnPath.options".into(), params)); + out + } + + pub fn finish(self) -> Vec { + if self.began { vec![Action::Commit] } else { vec![] } + } + + /// The first object's distance along the path next to the pointer, in the document's units. + pub fn overlays(&self, cx: &ToolContext) -> Vec { + if !self.began || !cx.measurement_labels { + return vec![]; + } + vec![Overlay::Measure { p: self.at, text: cx.len(self.to) }] + } +} + +/// The path of the Objects on Path group `g` of `doc`, to measure along. +fn group_spine(doc: &Document, g: NodeId) -> Option { + let n = doc.node(g)?; + let rec = n.extras().objects_on_path.as_deref()?; + Spine::new(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?) +} + /// The path a click at `p` attaches the selected objects to: the path object hit there, when it can /// take objects, is in no Objects on Path group, is not a clipping path or part of a compound path, /// blend, envelope, repeat or Intertwine group ([`vectorcraft_doc::objects_on_path::held`]), and @@ -420,6 +600,8 @@ pub struct ObjectsOnPathTool { rotating: Option, /// The Space widget drag, while the button is down after a press on the widget. spacing: Option, + /// The Move All widget drag, while the button is down after a press on the widget. + moving: Option, /// The reference point of each object attached next (0-8, row by row from the top left). pivot: u8, /// The angle of each object attached next, in degrees counter-clockwise from upright on the path. @@ -428,7 +610,7 @@ pub struct ObjectsOnPathTool { impl Default for ObjectsOnPathTool { fn default() -> Self { - Self { hover: None, press: None, rotating: None, spacing: None, pivot: CENTER, rotate: 0.0 } + Self { hover: None, press: None, rotating: None, spacing: None, moving: None, pivot: CENTER, rotate: 0.0 } } } @@ -453,9 +635,16 @@ impl Tool for ObjectsOnPathTool { _ => vec![], }; } + if let Some(d) = &mut self.moving { + return match ev.kind { + PointerKind::Drag => d.drag(cx, p), + PointerKind::Up => self.moving.take().map(MoveAllDrag::finish).unwrap_or_default(), + _ => vec![], + }; + } match ev.kind { PointerKind::Move => { - self.hover = if over_rotate_all(cx, p) || over_space(cx, p) { None } else { target(cx, p) }; + self.hover = if over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) { None } else { target(cx, p) }; vec![] } PointerKind::Down => { @@ -469,6 +658,11 @@ impl Tool for ObjectsOnPathTool { self.hover = None; return vec![]; } + if let Some(d) = MoveAllDrag::hit(cx, ev) { + self.moving = Some(d); + self.hover = None; + return vec![]; + } self.press = Some(p); vec![] } @@ -492,14 +686,20 @@ impl Tool for ObjectsOnPathTool { if let Some(d) = &self.spacing { o.extend(d.overlays(cx)); } + if let Some(d) = &self.moving { + o.extend(d.overlays(cx)); + } o } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { - if self.rotating.is_some() || (self.spacing.is_none() && over_rotate_all(cx, p)) { + let idle = self.spacing.is_none() && self.moving.is_none(); + if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) { Cursor::Rotate - } else if self.spacing.is_some() || over_space(cx, p) { + } else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) { Cursor::ArrowHollow + } else if self.moving.is_some() || over_move_all(cx, p) { + Cursor::Move } else if target(cx, p).is_some() { Cursor::ObjectsOnPath } else { @@ -508,14 +708,16 @@ impl Tool for ObjectsOnPathTool { } /// Escape while the button is down cancels the click, so the release attaches nothing, or the - /// Rotate All or Space drag, so the objects go back to the Rotate or Space they had. + /// Rotate All, Space or Move All drag, so the objects go back to the Rotate, Space or place + /// they had. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.press = None; self.hover = None; let rotated = self.rotating.take().is_some_and(|d| d.adjusting()); let spaced = self.spacing.take().is_some_and(|d| d.adjusting()); - if rotated || spaced { + let moved = self.moving.take().is_some_and(|d| d.adjusting()); + if rotated || spaced || moved { return vec![Action::Cancel]; } } @@ -545,11 +747,13 @@ impl Tool for ObjectsOnPathTool { } fn busy(&self) -> bool { - self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() + self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() } fn transforming(&self) -> bool { - self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) || self.spacing.as_ref().is_some_and(SpaceDrag::adjusting) + self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) + || self.spacing.as_ref().is_some_and(SpaceDrag::adjusting) + || self.moving.as_ref().is_some_and(MoveAllDrag::adjusting) } } @@ -1056,6 +1260,196 @@ mod tests { assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 1000.0, 100.0))), Some((400.0, 0.0)), "half way round at most"); } + /// The start a Move All drag's last action previews, to 1/1,000,000 pt, and whether it also + /// sets a Space value. + fn moved(acts: &[Action]) -> Option<(f64, Option)> { + let round = |v: f64| (v * 1e6).round() / 1e6 + 0.0; + match acts.last() { + Some(Action::Preview(cmd, p)) if cmd == "object.objectsOnPath.options" && p.get("rotate").is_none() => { + Some((round(p["start"].as_f64()?), p.get("spacing").and_then(Value::as_f64).map(round))) + } + _ => None, + } + } + + /// A document holding an Objects on Path group of one 20 × 20 square on the line from (0, 300) + /// to (400, 300), and a selection of the group. + fn single_doc() -> (Document, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (line, a, g) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = Node::path(line, shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), Appearance::default_art()); + let a = Node::path(a, shapes::rectangle(Rect::new(50.0, 50.0, 70.0, 70.0)), Appearance::default_art()); + let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &[&a], CENTER, 0.0); + let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a)], clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes()); + d.insert(Some(l), 0, group).unwrap(); + let mut sel = Selection::default(); + sel.set([g]); + (d, sel) + } + + #[test] + fn the_move_all_widget_sits_below_the_first_object_when_the_objects_have_room() { + // Space 100 from 50: the first square spans 290 to 310 down the page, and the widget is + // 16 px below its bottom. The two objects take 100 pt of the 400 pt path, so the first + // one can go from 0 to 300 along it. + let (d, g, sel) = spaced_doc(Some(100.0), 50.0); + let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap(); + assert_eq!(w.group, g); + assert!(near(w.first, Point::new(50.0, 300.0)) && near(w.at, Point::new(50.0, 326.0)), "{w:?}"); + assert!(w.start == 50.0 && (w.most - 300.0).abs() < 1e-9 && w.gap.is_none() && !w.closed, "{w:?}"); + assert!(near(MoveAllWidget::of(&d, &sel, 2.0).unwrap().at, Point::new(50.0, 318.0))); + // Auto spreads the objects from one end of an open path to the other: no room to move. + let (d, _, sel) = group_doc(0.0); + assert!(MoveAllWidget::of(&d, &sel, 1.0).is_none(), "Auto"); + // Nor with a Space value that reaches the end. + let (d, _, sel) = spaced_doc(Some(400.0), 0.0); + assert!(MoveAllWidget::of(&d, &sel, 1.0).is_none(), "the whole path"); + // On a closed path the objects always have room, Auto included. + let (d, sel) = path_doc(shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), None); + let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap(); + assert!(w.closed && (w.most - 800.0).abs() < 1e-9 && w.gap.is_none(), "{w:?}"); + // One object on an open path can move along all of it. + let (d, sel) = single_doc(); + let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap(); + assert!(w.start == 0.0 && (w.most - 400.0).abs() < 1e-9 && w.gap.is_none(), "{w:?}"); + // No widget for an object in the group, or for a locked group. + let (d, _, sel) = spaced_doc(Some(100.0), 50.0); + let children: Vec = d.node(sel.objects[0]).and_then(|n| n.children()).unwrap().iter().map(|c| c.id).collect(); + let mut one = Selection::default(); + one.set([children[1]]); + assert!(MoveAllWidget::of(&d, &one, 1.0).is_none()); + let (mut d, g, sel) = spaced_doc(Some(100.0), 50.0); + if let Some(n) = d.node_mut(g) { + n.locked = true; + } + assert!(MoveAllWidget::of(&d, &sel, 1.0).is_none()); + } + + /// Dragging the widget 80 pt along the path moves both objects 80 pt, keeping the Space, with + /// the Selection tool and with the Objects on Path tool, previewed while dragging and kept as + /// one undo step on release. On an open path the objects stop at its ends, and coming back + /// moves them back at once. + #[test] + fn dragging_the_move_all_widget_moves_every_object_along_the_path() { + let (d, g, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let at = MoveAllWidget::of(&d, &sel, 1.0).unwrap().at; + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + let id = t.id(); + assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::Move, "{id}"); + assert!(t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)).is_empty(), "{id}"); + let a = t.pointer(&c, &ev(PointerKind::Drag, at.x + 80.0, at.y + 10.0)); + assert_eq!(a.first(), Some(&Action::Begin("Objects on Path Options".into())), "{id}"); + assert!(matches!(a.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{a:?}"); + assert_eq!(moved(&a), Some((130.0, None)), "{id}: only the start changes"); + assert!(t.transforming() && t.cursor(&c, at, Mods::default()) == Cursor::Move, "{id}"); + let label = c.len(130.0); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if *text == label)), "{id}"); + // Shift and Alt change nothing. + let mods = Mods { shift: true, alt: true, ..Mods::default() }; + assert_eq!(moved(&t.pointer(&c, &PointerEvent { mods, ..ev(PointerKind::Drag, at.x + 90.0, at.y) })), Some((140.0, None)), "{id}"); + // No further than where the last object reaches the end, and back at once. + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, at.x + 900.0, at.y))), Some((300.0, None)), "{id}"); + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, at.x + 880.0, at.y))), Some((280.0, None)), "{id}"); + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, at.x - 900.0, at.y))), Some((0.0, None)), "{id}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)), vec![Action::Commit], "{id}"); + assert!(!t.busy(), "{id}"); + // A click on the widget that does not move changes nothing. + assert!(t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at.x + 1.0, at.y)).is_empty(), "{id}"); + } + } + + /// Where the saved Space would draw the objects apart differently from another start (Auto, + /// or a value the path is too short for), the drag sets the distance as drawn with the start; + /// back where it began, the group keeps its saved values. + #[test] + fn a_move_all_drag_keeps_the_distance_the_objects_are_drawn_at() { + for (spacing, what) in [(Some(1000.0), "a Space value too large for the path"), (None, "Auto")] { + let (d, _, sel) = spaced_doc(spacing, 100.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap(); + assert!((w.most - 100.0).abs() < 1e-9 && w.gap == Some(300.0), "{what}: {w:?}"); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y)); + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x - 40.0, w.at.y))), Some((60.0, Some(300.0))), "{what}"); + let a = t.pointer(&c, &ev(PointerKind::Drag, w.at.x, w.at.y)); + assert_eq!(moved(&a), Some((100.0, None)), "{what}: back where it began"); + } + } + + /// On an L-shaped path the drag counts the distance along the path, round the corner. + #[test] + fn a_move_all_drag_follows_the_path_round_a_corner() { + let (d, sel) = corner_doc(Some(100.0)); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap(); + assert!(near(w.at, Point::new(0.0, 326.0)) && (w.most - 600.0).abs() < 1e-9, "{w:?}"); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y)); + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 200.0, 326.0))), Some((200.0, None))); + // Up the upright leg, 200 pt from the corner. + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 426.0, 100.0))), Some((600.0, None)), "no further than the end"); + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 426.0, 200.0))), Some((500.0, None))); + } + + /// On a closed path the objects go on round across the first anchor, both ways. + #[test] + fn a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor() { + // The square's outline is 800 pt round, from (100, 100) to the right. + let (d, sel) = path_doc(shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), Some(100.0)); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap(); + assert!(near(w.first, Point::new(100.0, 100.0)) && near(w.at, Point::new(100.0, 126.0)), "{w:?}"); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y)); + // Down the left side, back before the first anchor. + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 100.0, 150.0))), Some((776.0, None))); + // Round the corner onto the top, ahead of it again. + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 130.0, 100.0))), Some((56.0, None))); + } + + /// One object moves along all of an open path. + #[test] + fn a_move_all_drag_moves_a_single_object_to_the_end() { + let (d, sel) = single_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap(); + let mut t = crate::select::SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y)); + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x + 150.0, w.at.y))), Some((150.0, None))); + assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x + 600.0, w.at.y))), Some((400.0, None))); + } + + /// Escape during a Move All drag cancels it: the readout is removed, and the release keeps + /// nothing. + #[test] + fn escape_cancels_a_move_all_drag() { + let (d, _, sel) = spaced_doc(Some(100.0), 50.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let at = MoveAllWidget::of(&d, &sel, 1.0).unwrap().at; + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)); + assert!(moved(&t.pointer(&c, &ev(PointerKind::Drag, at.x + 40.0, at.y))).is_some()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id()); + assert!(!t.busy() && !t.transforming(), "{}", t.id()); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, at.x, at.y)).is_empty(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id()); + } + } + /// Escape during a Space drag cancels it: the readout is removed, and the release keeps /// nothing. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8dbdb461ea..1658346127 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -47,7 +47,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{RotateAllDrag, SpaceDrag, over_rotate_all, over_space}; +use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -133,6 +133,8 @@ enum State { RotateAll(RotateAllDrag), /// Dragging an Objects on Path group's Space widget. Space(SpaceDrag), + /// Dragging an Objects on Path group's Move All widget. + MoveAll(MoveAllDrag), /// Move or rotate a live mirror axis. Mirror(crate::mirror::MirrorDrag), /// Drag a grid repeat's row, column or corner handle or a spacing slider. @@ -751,7 +753,7 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All or Space widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. @@ -842,6 +844,7 @@ impl Tool for SelectionTool { State::Pie(d) => d.adjusting(), State::RotateAll(d) => d.adjusting(), State::Space(d) => d.adjusting(), + State::MoveAll(d) => d.adjusting(), State::Bracket(b) => b.adjusting(), State::Mirror(d) => d.adjusting(), State::GridRepeat(d) => d.adjusting(), @@ -1030,6 +1033,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::MoveAll(mut d)) => { + let out = d.drag(cx, p); + self.state = State::MoveAll(d); + out + } + (PointerKind::Up, State::MoveAll(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::Bracket(mut b)) => { let out = b.drag(cx, p, m.cmd); self.state = State::Bracket(b); @@ -1094,6 +1106,7 @@ impl Tool for SelectionTool { State::Pie(d) => o.extend(d.overlays(cx)), State::RotateAll(d) => o.extend(d.overlays(cx)), State::Space(d) => o.extend(d.overlays(cx)), + State::MoveAll(d) => o.extend(d.overlays(cx)), State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } @@ -1145,6 +1158,7 @@ impl Tool for SelectionTool { State::Pie(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, State::Space(_) => return Cursor::ArrowHollow, + State::MoveAll(_) => return Cursor::Move, State::Bracket(_) => return Cursor::PathBracket, State::Loaded { .. } => return Cursor::LoadedText, _ => {} @@ -1170,6 +1184,9 @@ impl Tool for SelectionTool { if over_space(cx, p) { return Cursor::ArrowHollow; } + if over_move_all(cx, p) { + return Cursor::Move; + } if over_bracket(cx, p) { return Cursor::PathBracket; } @@ -1519,7 +1536,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All and Space widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1537,6 +1554,10 @@ impl SelectionTool { self.state = State::Space(d); return Some(vec![]); } + if let Some(d) = MoveAllDrag::hit(cx, ev) { + self.state = State::MoveAll(d); + return Some(vec![]); + } if let Some(b) = BracketDrag::hit(cx, ev) { self.state = State::Bracket(b); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index fcda53def0..8570b87448 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -238,7 +238,7 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } -/// The Rotate All and Space widgets (Selection and Objects on Path tools, one Objects on Path +/// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path /// group selected). The Rotate All widget is joined by a line to the first object's attachment /// point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { @@ -253,6 +253,7 @@ fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_d p.circle_stroke(c, ROTATE_ALL_RADIUS_PX, Stroke::new(1.0, color)); } space_widget(p, xf, doc, selection); + move_all_widget(p, xf, doc, selection); } /// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square @@ -267,6 +268,19 @@ fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, sel p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); } +/// The Move All widget (beside the Rotate All widget, when the objects have room to move along the +/// path): a diamond below the first object joined by a line to its attachment point. It stays while +/// dragged. +fn move_all_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { + use vectorcraft_tools::objects_on_path::{MOVE_ALL_HALF_PX, MoveAllWidget}; + let Some(w) = MoveAllWidget::of(doc, selection, xf.zoom) else { return }; + let (on, c, color) = (xf.to_screen(w.first), xf.to_screen(w.at), c32(doc.layer_color(w.group))); + let h = MOVE_ALL_HALF_PX; + p.line_segment([on, c], Stroke::new(1.0, color)); + let corners = vec![c + vec2(0.0, -h), c + vec2(h, 0.0), c + vec2(0.0, h), c + vec2(-h, 0.0)]; + p.add(Shape::convex_polygon(corners, Color32::WHITE, Stroke::new(1.0, color))); +} + /// Record where the Type tool's IME candidate window goes this pass, if it has an editor. /// /// The descriptor itself is observed once at the end of the pass (`crate::ime::end_of_pass`), diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index efe59c127e..1e83c87ec6 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4806,6 +4806,45 @@ fn a_selected_objects_on_path_group_shows_its_space_widget() { assert_eq!(widgets(&mut app), 0, "one object"); } +/// A selected Objects on Path group whose objects have room to move along the path shows its Move +/// All widget, a white diamond below its first object, with the Selection and Objects on Path tools; +/// objects spread evenly to both ends of an open path have no room, and show none. +#[test] +fn a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap()); + let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()); + let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap()); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let g = id(app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap()); + let ctx = egui::Context::default(); + // A diamond: its top and bottom corners one above the other, its full height apart. + let diamond = |d: &egui::epaint::PathShape| { + let h = 2.0 * vectorcraft_tools::objects_on_path::MOVE_ALL_HALF_PX; + let [top, _, bottom, _] = d.points[..] else { return false }; + d.closed && d.fill == Color32::WHITE && top.x == bottom.x && (bottom.y - top.y - h).abs() < 1e-3 + }; + let widgets = |app: &mut VectorcraftApp| shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(d) if diamond(d))).count(); + app.select_tool("selection"); + assert_eq!(widgets(&mut app), 0, "Auto: the objects reach both ends"); + app.session.execute("object.objectsOnPath.options", &json!({"ids": [g], "spacing": 60})).unwrap(); + assert_eq!(widgets(&mut app), 1); + app.select_tool("objectsOnPath"); + assert_eq!(widgets(&mut app), 1); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app), 0); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(widgets(&mut app), 0, "an object in the group, not the group"); + // One object can move along the path whatever its Space. + app.session.execute("object.objectsOnPath.options", &json!({"ids": [g], "spacing": "auto"})).unwrap(); + app.run("edit.clear", json!({"ids": [b]})).unwrap(); + app.run("select.set", json!({"ids": [g]})).unwrap(); + assert_eq!(widgets(&mut app), 1, "one object"); +} + /// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..2a26c8523c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8160,6 +8160,18 @@ than the end. With Alt (Option on macOS) held, the objects spread from both ends open path, or of the first anchor of a closed path, up to the whole path. With measurement labels on, the distance between the objects shows next to the pointer in the document's units. Escape cancels the drag. +When the objects have room to move along the path, the same tools show the Move All widget, a diamond below the +group's first object joined by a line to its attachment point. On an open path that is when the distance from the first +object to the last is shorter than the path: one object, or a Space value that leaves part of the path free, but not +Auto from the first anchor, which spreads the objects to both ends. On a closed path the widget always shows. Dragging +it moves every object along the path as far as the pointer moves along the path, keeping the distances between them, +and previews `object.objectsOnPath.options {ids: [group], start}` and keeps it as one undo step on release. On an open +path the objects stop where the first one reaches the first anchor or the last one reaches the end, and moving the +pointer back moves them back at once; on a closed path they go on round across the first anchor. Where Auto or a Space +value the open path is too short for sets the distance, the drag also sets `spacing` to the distance as drawn, so the +objects stay that far apart. Shift and Alt do not change the drag. With measurement labels on, the first object's +distance along the path shows next to the pointer in the document's units. Escape cancels the drag. + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..825d066a6e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1251,8 +1251,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Move All and Select/Move widgets and Detach are missing. + Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. + Objects on Path Move All widget: with one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below its first object; dragging it along the path, round its bends, moves every object along the path together in one undo step, keeping the distances between them (`object.objectsOnPath.options {start}`). On an open path the widget shows only when the distance from the first object to the last is shorter than the path, so not for objects spread evenly from end to end, and the objects stop at the path's ends; on a closed path they go on round across its first anchor. The first object's distance along the path shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::{dragging_the_move_all_widget_moves_every_object_along_the_path, a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room` in `crates/ui-egui`. Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. From 78f412f0a34ce73d98335a5d7cd601265fac35c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:57:01 -0800 Subject: [PATCH 44/83] Clipboard: a paste's bitmap read waits for the owner off the UI thread A paste on Linux and the BSDs read the bitmap on the clipboard worker but waited up to 10 s for it on the UI thread, so a bitmap from an app that never sent its data froze the window long enough for the compositor to offer to close it. The paste now sends the bitmap read to the worker and returns at once; its background thread waits up to 10 s for the answer and converts it to PNG. While that read is open, other reads fail at once instead of queuing behind it. Each read gets its own answer channel, so a late answer is dropped. The check of whether the clipboard still holds the app's own bitmap waits 2 s at most, as text does. --- apps/vectorcraft/src/clipboard.rs | 197 +++++++++++++++++++++++------- docs/development.md | 2 +- 2 files changed, 151 insertions(+), 48 deletions(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index bf3562ac0b..1d8eb5aff3 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -343,6 +343,8 @@ mod win { allow(dead_code) )] mod loads { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc; use std::time::{Duration, Instant}; @@ -353,11 +355,15 @@ mod loads { /// Requests of type `R` answered with `A` on a worker thread. pub(super) struct Loads { - requests: mpsc::Sender, - answers: mpsc::Receiver, - /// When a read that timed out and hasn't answered yet was sent: until it answers, reads - /// don't queue behind it. - pending: Option, + /// Each request goes with the channel its answer is sent on. + requests: mpsc::Sender<(R, mpsc::Sender)>, + /// Requests sent and not answered yet, counted down by the worker: while one is open, + /// reads don't queue behind it. + open: Arc, + /// When the last request was sent. + sent: Option, + /// Disconnected once the worker has let go of what its `load` holds. + done: mpsc::Receiver<()>, /// How long after it was sent dropping `Loads` waits for an unanswered read to end. finish: Duration, } @@ -374,42 +380,55 @@ mod loads { /// waits up to `finish` after an unanswered read was sent for that read to end. None when the /// worker can't start. pub(super) fn spawn(name: &str, finish: Duration, mut load: impl FnMut(R) -> A + Send + 'static) -> Option { - let (requests, asked) = mpsc::channel::(); - let (answer, answers) = mpsc::channel(); + let (requests, asked) = mpsc::channel::<(R, mpsc::Sender)>(); + let (alive, done) = mpsc::channel::<()>(); + let open = Arc::new(AtomicUsize::new(0)); + let answered = open.clone(); let work = move || { - while let Ok(request) = asked.recv() { - if answer.send(load(request)).is_err() { - break; - } + while let Ok((request, reply)) = asked.recv() { + let answer = load(request); + // Counted down before the answer goes, so its reader can ask again at once. + answered.fetch_sub(1, Ordering::SeqCst); + // A read that timed out no longer waits: its late answer is dropped. + let _ = reply.send(answer); } - // `load` and the clipboard it holds go first: dropping `Loads` waits for `answer`. + // `load` and the clipboard it holds go first: dropping `Loads` waits for `alive`. drop(load); - drop(answer); + drop(alive); }; std::thread::Builder::new().name(name.into()).spawn(work).ok()?; - Some(Self { requests, answers, pending: None, finish }) + Some(Self { requests, open, sent: None, done, finish }) + } + + /// `request` sent to the worker; its answer comes on the channel returned, for + /// [`wait_for_answer`] here or on another thread. Err when an earlier request is still + /// unanswered or the worker has stopped. + pub(super) fn ask(&mut self, request: R) -> Result, String> { + if self.open.load(Ordering::SeqCst) > 0 { + return Err("the clipboard's owner hasn't answered an earlier read".into()); + } + let (reply, answer) = mpsc::channel(); + self.open.fetch_add(1, Ordering::SeqCst); + self.sent = Some(Instant::now()); + if self.requests.send((request, reply)).is_err() { + self.open.fetch_sub(1, Ordering::SeqCst); + return Err(GONE.into()); + } + Ok(answer) } /// The answer to `request`, or why there is none, after `wait` at most. pub(super) fn get(&mut self, request: R, wait: Duration) -> Result { - if self.pending.is_some() { - match self.answers.try_recv() { - // The late answer of the read that timed out: stale by now. - Ok(_) => self.pending = None, - Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()), - Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()), - } - } - let sent = Instant::now(); - self.requests.send(request).map_err(|_| GONE.to_string())?; - match self.answers.recv_timeout(wait) { - Ok(answer) => Ok(answer), - Err(mpsc::RecvTimeoutError::Timeout) => { - self.pending = Some(sent); - Err(format!("the clipboard's owner didn't send its data within {wait:?}")) - } - Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()), - } + wait_for_answer(&self.ask(request)?, wait) + } + } + + /// The answer that comes on `answer` ([`Loads::ask`]), or why there is none, after `wait` at most. + pub(super) fn wait_for_answer(answer: &mpsc::Receiver, wait: Duration) -> Result { + match answer.recv_timeout(wait) { + Ok(answer) => Ok(answer), + Err(mpsc::RecvTimeoutError::Timeout) => Err(format!("the clipboard's owner didn't send its data within {wait:?}")), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()), } } @@ -420,8 +439,10 @@ mod loads { fn drop(&mut self) { let (stop, _) = mpsc::channel(); drop(std::mem::replace(&mut self.requests, stop)); - let end = stop_by(self.pending, Instant::now(), self.finish); - while self.answers.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {} + let pending = self.sent.filter(|_| self.open.load(Ordering::SeqCst) > 0); + let end = stop_by(pending, Instant::now(), self.finish); + // Nothing is sent on `done`: this ends when the worker drops `alive`, or at `end`. + let _ = self.done.recv_timeout(end.saturating_duration_since(Instant::now())); } } @@ -473,12 +494,32 @@ mod loads { #[test] fn a_read_after_the_worker_stops_says_so() { let mut loads = Loads::spawn("test", Duration::ZERO, |()| String::new()).unwrap(); - // The worker ends when its answers can't be sent: drop them by hand. - let (_, dead) = mpsc::channel(); - drop(std::mem::replace(&mut loads.answers, dead)); + // The worker ends when its requests stop: cut them off by hand. + let (dead, _) = mpsc::channel(); + drop(std::mem::replace(&mut loads.requests, dead)); assert_eq!(loads.get((), Duration::from_millis(50)), Err(GONE.to_string())); } + #[test] + fn an_answer_waited_for_on_another_thread_holds_back_the_next_read() { + let (release, gate) = mpsc::channel::<()>(); + let mut loads = Loads::spawn("test", Duration::ZERO, move |n: u32| { + if n == 1 { + let _ = gate.recv(); + } + n + }) + .unwrap(); + let answer = loads.ask(1).unwrap(); + // The caller doesn't wait: the answer is waited for wherever the channel goes. + let waiter = std::thread::spawn(move || wait_for_answer(&answer, Duration::from_secs(600))); + // Until it comes, a read fails at once instead of queuing behind it. + assert!(loads.get(2, Duration::from_secs(600)).is_err()); + release.send(()).unwrap(); + assert_eq!(waiter.join().ok(), Some(Ok(1))); + assert_eq!(loads.get(3, Duration::from_secs(600)), Ok(3)); + } + #[test] fn dropping_the_reads_waits_until_the_worker_lets_go_of_its_clipboard() { let clipboard = Arc::new(()); @@ -590,9 +631,9 @@ mod portable { reads.get(request, wait).unwrap_or_else(|why| Answer::failed(request, why)) } - /// The longest a bitmap read of arboard's waits for the app that copied: the bitmap's transfer - /// and its PNG decoding happen within the read. Text and file lists wait - /// [`LOAD_WAIT`](super::loads::LOAD_WAIT). + /// The longest a paste's bitmap read of arboard's waits for the app that copied, off the UI + /// thread: the bitmap's transfer and its PNG decoding happen within the read. Text, file lists + /// and the UI thread's bitmap check wait [`LOAD_WAIT`](super::loads::LOAD_WAIT). #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] const IMAGE_WAIT: Duration = Duration::from_secs(10); @@ -628,7 +669,7 @@ mod portable { /// arboard's clipboard, with `C` for writes (arboard's handle; a stand-in in tests). On Linux /// and the BSDs its reads run on a worker with a handle of its own and wait - /// [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, a bitmap 10 s: arboard's Wayland + /// [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, a paste's bitmap 10 s off the UI thread: arboard's Wayland /// data-control read waits for the app that copied to send its data with no limit, and its X11 /// read waits up to 4 s for each format it tries. struct Arboard { @@ -667,8 +708,24 @@ mod portable { fn read(&mut self, request: Request) -> Answer { read_within(&mut self.reads, request, wait_for(request)) } + + /// A bitmap read on the worker that waits [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, for + /// a check on the UI thread. + fn image_now(&mut self) -> Result, String> { + read_within(&mut self.reads, Request::Image, super::loads::LOAD_WAIT).image() + } + + /// A bitmap read sent to the worker now; the work returned waits [`IMAGE_WAIT`] at most for + /// it, so the caller can wait off the UI thread. Err when the read can't be sent. + fn image_later(&mut self) -> Result { + let answer = self.reads.ask(Request::Image)?; + Ok(Box::new(move || super::loads::wait_for_answer(&answer, IMAGE_WAIT).and_then(Answer::image))) + } } + /// A bitmap read whose answer is waited for when it runs. + type ImageWork = Box Result, String> + Send>; + /// An open clipboard connection. enum Backend { Arboard(Arboard), @@ -726,14 +783,35 @@ mod portable { } } - fn get_image(&mut self) -> Result, String> { + /// The bitmap, for a check on the UI thread: on Linux and the BSDs it waits + /// [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, as text does. + fn image_now(&mut self) -> Result, String> { match self { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + Self::Arboard(cb) => cb.image_now(), + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] Self::Arboard(cb) => cb.read(Request::Image).image(), #[cfg(target_os = "linux")] Self::Wayland(_) => Err(TEXT_ONLY.into()), } } + /// The bitmap as work for another thread: on Linux and the BSDs the wait for the app + /// that copied happens when the work runs, elsewhere the bitmap is read here. + fn image_later(&mut self) -> Result { + match self { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + Self::Arboard(cb) => cb.image_later(), + #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] + Self::Arboard(cb) => { + let image = cb.read(Request::Image).image()?; + Ok(Box::new(move || Ok(image))) + } + #[cfg(target_os = "linux")] + Self::Wayland(_) => Err(TEXT_ONLY.into()), + } + } + fn set_image(&mut self, img: arboard::ImageData<'_>) -> Result<(), String> { match self { Self::Arboard(cb) => cb.cb.set_image(img).map_err(|e| e.to_string()), @@ -913,7 +991,7 @@ mod portable { let Some(cb) = self.cb.opened() else { return false }; match &self.ours { Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t), - Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)), + Some(Ours::Image(w, h)) => cb.image_now().is_ok_and(|now| (now.width, now.height) == (*w, *h)), None => false, } } @@ -936,10 +1014,12 @@ mod portable { match *m { TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })), BITMAP => { - let img = cb.get_image().ok()?; - let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); - let bytes = img.bytes.into_owned(); - Some(ClipboardRead::Later(Box::new(move || png_flavour(&image::RgbaImage::from_raw(width, height, bytes)?)))) + let read = cb.image_later().ok()?; + Some(ClipboardRead::Later(Box::new(move || { + let img = read().ok()?; + let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); + png_flavour(&image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?) + }))) } _ => None, } @@ -1137,6 +1217,29 @@ mod portable { assert_eq!(finish(true), Duration::ZERO); } + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn a_bitmap_paste_waits_for_the_owner_off_the_ui_thread() { + let (release, gate) = std::sync::mpsc::channel::<()>(); + // The app that copied sends its bitmap only when released. + let reads = Loads::spawn("test", Duration::ZERO, move |request| match request { + Request::Image => { + let _ = gate.recv(); + Answer::Image(Ok(arboard::ImageData { width: 2, height: 1, bytes: Cow::Owned(vec![0; 8]) })) + } + _ => Answer::failed(request, "not asked".into()), + }) + .unwrap(); + let mut cb = Arboard { reads, cb: () }; + // The read is sent and the UI thread goes on without its answer. + let work = cb.image_later().unwrap(); + // A check on the UI thread meanwhile fails at once instead of waiting behind it. + assert!(cb.read(Request::Text).text().is_err()); + let paste = std::thread::spawn(work); + release.send(()).unwrap(); + assert_eq!(paste.join().ok().and_then(Result::ok).map(|i| (i.width, i.height)), Some((2, 1))); + } + #[test] fn one_outage_is_logged_once_across_handles() { static LOGGED: AtomicBool = AtomicBool::new(false); diff --git a/docs/development.md b/docs/development.md index 78e620f7e3..766b493c53 100644 --- a/docs/development.md +++ b/docs/development.md @@ -123,7 +123,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: - **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and a Paste waits 2 s at most: an owner that never sends its data costs one Paste 2 s, and later reads return at once until it answers. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. -- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most, a bitmap 10 s: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. +- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. - **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, the app runs under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer: Preferences › Performance › Linux Display Server's Automatic (the default) and X11 (XWayland) When a Pen Is Attached (`x11WithPen`) open the window through XWayland when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and log `pen found: opening the window through XWayland`, as PhotoCraft does (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps the window on Wayland). For a pen attached after the app started, either run the app under XWayland (Preferences › Performance › Linux Display Server › X11 (XWayland); `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way. From 1618686224669a104b3a971f86651122f0db0d16 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:57:19 -0800 Subject: [PATCH 45/83] Path ops: rejoining split curves tests only the input curves around the run exact_merge looked at every input curve on each merge attempt, so tidying the output of a sweep cost the number of input curves for each joint it tried to remove. It now takes its candidates from Tidy::curves_around, the bounds index that repair already uses, and tests only the curves whose bounds hold both ends of the run. A new test cuts one curve of a 1,000-curve row in three and checks that the merge rebuilds it while testing at most four curves; before the change it tested 1,002. --- crates/pathops/src/boolean.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index e12c9fb395..60c2a8b296 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -22,6 +22,8 @@ thread_local! { pub(crate) static CONTOUR_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; /// The sweeps [`normalize_bez`] made on this thread. static NORMALIZE_SWEEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The input curves [`exact_merge`] tested on this thread. + static EXACT_MERGE_CURVES: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Default curve-refit tolerance (document points) used to merge split curve pieces back together. @@ -663,16 +665,17 @@ fn try_merge(run: &[Seg], tidy: &Tidy) -> Option { } /// If every piece of `run` lies on one input cubic, return the exact sub-curve spanning the run. +/// Only the input curves whose bounds hold both ends of the run are tested +/// ([`Tidy::curves_around`]). fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option { use kurbo::{ParamCurve, ParamCurveNearest}; let a = run[0].c.p0; let b = run[run.len() - 1].c.p3; let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs()); let tol = 1e-6 * scale; - for (r, c) in &tidy.curves { - if !r.contains(a) || !r.contains(b) { - continue; - } + for c in tidy.curves_around(a, b) { + #[cfg(test)] + EXACT_MERGE_CURVES.with(|n| n.set(n.get() + 1)); let na = c.nearest(a, 1e-12); let nb = c.nearest(b, 1e-12); if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol { @@ -1366,6 +1369,29 @@ mod tests { assert_eq!(tidy.repair(&lens), lens); } + #[test] + fn rejoining_a_split_curve_tests_only_the_curves_around_it() { + // A row of 1,000 short curves; one of them is cut in three, as the sweep cuts a curve. + let mut row = BezPath::new(); + row.move_to((0.0, 0.0)); + for k in 0..1000 { + let x = f64::from(k); + row.curve_to((x + 0.3, 1.0), (x + 0.7, 1.0), (x + 1.0, 0.0)); + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&row]); + let c = CubicBez::new((500.0, 0.0), (500.3, 1.0), (500.7, 1.0), (501.0, 0.0)); + let pieces: Vec = [0.0..0.3, 0.3..0.6, 0.6..1.0].into_iter().map(|t| Seg { c: c.subsegment(t), line: false }).collect(); + EXACT_MERGE_CURVES.with(|n| n.set(0)); + let merged = tidy_segments(pieces, false, &tidy); + let tested = EXACT_MERGE_CURVES.with(std::cell::Cell::get); + assert_eq!(merged.len(), 1); + for (got, want) in [(merged[0].c.p1, c.p1), (merged[0].c.p2, c.p2)] { + assert!(got.distance(want) < 1e-9, "{got:?} {want:?}"); + } + // Each merge attempt tests the curves whose bounds hold both ends of the run, not all 1,000. + assert!(tested <= 4, "{tested} curves tested"); + } + #[test] fn arrangement_new_budgeted_fails_on_complexity_and_open_paths() { let mut bp = BezPath::new(); From a28e15499c4453ed347e0725ca560b50dcfb168c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 12:56:41 -0800 Subject: [PATCH 46/83] Data Recovery: saving no longer fails when another running copy tidies the new area When a second running copy listed the recovery folder, it could lock an area that this copy had just made but not yet locked, take it for a crashed copy's empty area and remove it. Locking the area then failed, and the recovery save reported "No such file or directory". On macOS the lock could also fail with "Invalid argument" on the removed lock file. Taking a new area now counts it as busy, and tries the next name, when its folder or lock file is gone by the time it is locked, when its lock file is there but cannot be opened (some file systems keep a removed file that another copy still has open until it is closed), or when the lock file that was locked is no longer the one in the folder. Tidying an empty area, and quitting, now remove the area while it is still locked, and remove its folder once more after letting it go if the folder is empty. --- crates/engine/src/cmd/recovery.rs | 108 ++++++++++++--- crates/engine/src/tests/recovery.rs | 198 ++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 4cf070c6ae..ffd3badf0d 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -111,8 +111,13 @@ pub trait RecoveryStore: Send + Sync { fn announced(&self, _area: &str) -> Option { None } - /// Tidy away area `area` once it holds no copies and nobody holds it (its lock file and folder). + /// Tidy away area `area` once it holds no copies (its lock file and folder). The caller holds + /// the area until this returns. fn remove_area(&self, _area: &str) {} + /// Remove area `area`'s folder if it is empty, after [`RecoveryStore::remove_area`] and after the + /// caller let the area go. On some file systems a file that is open is removed only when it is + /// closed, so `remove_area` leaves the folder there while the area is held. + fn remove_empty_area(&self, _area: &str) {} /// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone). fn now(&self) -> Option { vectorcraft_doc::metadata::now_unix() @@ -295,6 +300,72 @@ impl FolderStore { .filter(|n| Self::plain(n)) .collect()) } + + /// Lock area folder `dir`, which was just made. Another app tidies an empty area away + /// (`tidy`) by removing its lock file and folder while it holds the lock. So when the folder + /// or the lock file is gone by now, or the lock taken is on a lock file the folder no longer + /// has, that app had the area: it is [`Lock::Busy`], and the caller tries another one. + pub(crate) fn lock_area(dir: &std::path::Path) -> std::result::Result { + if let Err(e) = Self::own_area(dir) { + let gone = std::fs::symlink_metadata(dir).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound); + return if gone { Ok(Lock::Busy) } else { Err(e) }; + } + let path = dir.join(LOCK_FILE); + match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) { + Ok(file) => Self::take(file, &path), + Err(e) => Self::open_failed(e, &path), + } + } + + /// Opening lock file `path` failed with `e`. When the folder is gone by now, another app had the + /// area: it is [`Lock::Busy`]. So it is when the lock file is there but can't be opened: some + /// file systems keep a removed file that another app still has open until that app closes it, + /// and refuse to open it meanwhile. A lock file that can't be made is an error. + pub(crate) fn open_failed(e: std::io::Error, path: &std::path::Path) -> std::result::Result { + match e.kind() { + std::io::ErrorKind::NotFound => Ok(Lock::Busy), + std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy), + _ => Err(format!("{}: {e}", path.display())), + } + } + + /// Lock `file`, opened as lock file `path`. The lock holds the area only while `path` still + /// names that file. + pub(crate) fn take(file: std::fs::File, path: &std::path::Path) -> std::result::Result { + match file.try_lock() { + Ok(()) if Self::still_named(&file, path) => Ok(Lock::Held(Box::new(file))), + Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), + // The lock file was removed meanwhile, and locking it failed instead. + Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy), + Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), + } + } + + /// Does `path` still name `file`? On Unix the two must be the same file (device and inode); on + /// Windows they must have the same creation time; elsewhere `path` must exist. + fn still_named(file: &std::fs::File, path: &std::path::Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + match (file.metadata(), std::fs::symlink_metadata(path)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + match (file.metadata(), std::fs::symlink_metadata(path)) { + (Ok(a), Ok(b)) => a.creation_time() == b.creation_time(), + _ => false, + } + } + #[cfg(not(any(unix, windows)))] + { + let _ = file; + std::fs::symlink_metadata(path).is_ok() + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -342,29 +413,23 @@ impl RecoveryStore for FolderStore { fn lock(&self, area: &str) -> std::result::Result { let dir = self.area(area)?; Self::create_area(&dir)?; - Self::own_area(&dir)?; - let path = dir.join(LOCK_FILE); - let file = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&path) - .map_err(|e| format!("{}: {e}", path.display()))?; - match file.try_lock() { - Ok(()) => Ok(Lock::Held(Box::new(file))), - Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), - Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), - } + Self::lock_area(&dir) } fn remove_area(&self, area: &str) { // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty - // (copies came back meanwhile) stays. + // (copies came back meanwhile) stays. The caller still holds the lock here. if let Ok(dir) = self.area(area) { let _ = std::fs::remove_file(dir.join(LOCK_FILE)); let _ = std::fs::remove_dir(&dir); } } + fn remove_empty_area(&self, area: &str) { + // Best effort, as above. An app that made the folder again meanwhile and has not locked it + // yet finds it gone and takes another area (`lock_area`); one that has keeps it. + if let Ok(dir) = self.area(area) { + let _ = std::fs::remove_dir(&dir); + } + } } /// The area this session keeps its copies in, while it holds it. @@ -527,8 +592,10 @@ pub fn release(s: &mut Session) { for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { let _ = store.remove(name); } - drop(hold); + // Held until it is gone, as in `tidy`. store.remove_area(&area); + drop(hold); + store.remove_empty_area(&area); } /// Let go of this session's area but leave its copies, as a crash would, so the next app started @@ -967,7 +1034,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } } -/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). +/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The +/// area is removed while it is still held, so an app taking a new area of that name meanwhile +/// finds it held or gone and takes the next name. fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec) { let names = store.list().unwrap_or_default(); let mine: Vec<&String> = names.iter().filter(|n| n.split_once('/').is_some_and(|(a, _)| a == area)).collect(); @@ -977,8 +1046,9 @@ fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec) { for n in mine { let _ = store.remove(n); } - drop(hold); store.remove_area(area); + drop(hold); + store.remove_empty_area(area); } /// The copies of documents open now in this session. diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 9fe2d846ce..ce76b612cf 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -483,6 +483,204 @@ fn the_folder_store_reads_no_linked_entries() { let _ = std::fs::remove_dir_all(&base); } +/// A store in memory that notes each removal of an area (its lock file and folder, then its empty +/// folder) and whether the area was still held then. +#[derive(Default)] +struct Tidying { + inner: MemoryStore, + removals: std::sync::Mutex>, +} + +impl Tidying { + fn note(&self, what: &'static str, area: &str) { + let held = matches!(self.inner.lock(area), Ok(cmd::recovery::Lock::Busy)); + self.removals.lock().unwrap().push((what, held)); + } +} + +impl RecoveryStore for Tidying { + fn list(&self) -> std::result::Result, String> { + self.inner.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.inner.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.inner.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.inner.remove(name) + } + fn location(&self) -> String { + self.inner.location() + } + fn lock(&self, area: &str) -> std::result::Result { + self.inner.lock(area) + } + fn remove_area(&self, area: &str) { + self.note("area", area); + } + fn remove_empty_area(&self, area: &str) { + self.note("empty folder", area); + } + fn now(&self) -> Option { + self.inner.now() + } +} + +/// An app tidying an area away (a crashed app's, or its own when it quits) removes it while it +/// still holds it, so another app taking a new area of that name can't lock it in between. Once it +/// let the area go, it removes the folder again if that is empty. +#[test] +fn areas_are_removed_while_still_held() { + let store = Arc::new(Tidying::default()); + let app = || { + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s + }; + let mut a = app(); + a.execute("file.recovery.save", &json!({})).unwrap(); + drop(a); + let mut b = app(); + assert_eq!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().len(), 1); + b.execute("file.recovery.save", &json!({})).unwrap(); + b.execute("file.close", &json!({})).unwrap(); + cmd::recovery::forget_all(&mut b); + let tidy = [("area", true), ("empty folder", false)]; + assert_eq!(*store.removals.lock().unwrap(), [tidy, tidy].concat(), "discard's tidy, then quitting"); +} + +/// Another app tidied an empty area away between this app making it and locking it: the area is +/// busy, not held and not an error. +#[test] +fn an_area_tidied_away_before_it_is_locked_is_busy() { + use cmd::recovery::Lock; + let base = std::env::temp_dir().join(format!("vc-recovery-tidied-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let store = FolderStore::new(&base); + drop(store.lock("5-1").unwrap()); + let area = base.join("5-1"); + let path = area.join(".lock"); + let open = |create: bool| std::fs::OpenOptions::new().read(true).write(true).create(create).truncate(false).open(&path).unwrap(); + // The lock file was opened, then removed before it was locked. + let opened = open(false); + std::fs::remove_file(&path).unwrap(); + assert!(matches!(FolderStore::take(opened, &path), Ok(Lock::Busy))); + // The folder went before it was checked or its lock file opened. + std::fs::remove_dir(&area).unwrap(); + assert!(matches!(FolderStore::lock_area(&area), Ok(Lock::Busy))); + // A lock file made again under the same name is not the one opened. + #[cfg(unix)] + { + std::fs::create_dir_all(&area).unwrap(); + let opened = open(true); + std::fs::remove_file(&path).unwrap(); + std::fs::write(&path, b"").unwrap(); + assert!(matches!(FolderStore::take(opened, &path), Ok(Lock::Busy))); + assert!(matches!(FolderStore::take(open(false), &path), Ok(Lock::Held(_)))); + } + let _ = std::fs::remove_dir_all(&base); +} + +/// Opening an area's lock file failed. When the folder went after it was checked, or the lock file +/// is there but can't be opened (a removed lock file that another app still has open, on file +/// systems that keep it until it is closed), the area is busy. A lock file that can't be made is an +/// error. +#[test] +fn a_lock_file_that_is_gone_or_cannot_be_opened_is_busy() { + use cmd::recovery::Lock; + use std::io::ErrorKind::{NotFound, PermissionDenied}; + let base = std::env::temp_dir().join(format!("vc-recovery-unopened-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let path = base.join(".lock"); + let failed = |kind: std::io::ErrorKind| FolderStore::open_failed(kind.into(), &path); + assert!(matches!(failed(NotFound), Ok(Lock::Busy))); + assert!(failed(PermissionDenied).is_err()); + std::fs::write(&path, b"").unwrap(); + assert!(matches!(failed(PermissionDenied), Ok(Lock::Busy))); + #[cfg(unix)] + if !rustix::process::geteuid().is_root() { + use std::os::unix::fs::PermissionsExt as _; + let mode = |p: &std::path::Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap(); + // A lock file that is there but can't be opened. + let area = base.join("5-1"); + std::fs::create_dir(&area).unwrap(); + std::fs::write(area.join(".lock"), b"").unwrap(); + mode(&area.join(".lock"), 0o400); + assert!(matches!(FolderStore::lock_area(&area), Ok(Lock::Busy))); + // A folder in which no lock file can be made. + let area = base.join("5-2"); + std::fs::create_dir(&area).unwrap(); + mode(&area, 0o500); + let r = FolderStore::lock_area(&area); + mode(&area, 0o700); + assert!(r.is_err()); + } + let _ = std::fs::remove_dir_all(&base); +} + +/// A folder store in which another app tidies the first area away as soon as it is made. +struct TidiedFirst { + inner: FolderStore, + dir: std::path::PathBuf, + first: std::sync::atomic::AtomicBool, +} + +impl RecoveryStore for TidiedFirst { + fn list(&self) -> std::result::Result, String> { + self.inner.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.inner.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.inner.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.inner.remove(name) + } + fn location(&self) -> String { + self.inner.location() + } + fn areas(&self) -> std::result::Result, String> { + self.inner.areas() + } + fn lock(&self, area: &str) -> std::result::Result { + if !self.first.swap(false, std::sync::atomic::Ordering::SeqCst) { + return self.inner.lock(area); + } + let dir = self.dir.join(area); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + FolderStore::lock_area(&dir) + } + fn remove_area(&self, area: &str) { + self.inner.remove_area(area); + } +} + +#[test] +fn a_recovery_save_takes_the_next_area_when_the_first_is_tidied_away() { + let dir = std::env::temp_dir().join(format!("vc-recovery-next-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = Arc::new(TidiedFirst { inner: FolderStore::new(&dir), dir: dir.clone(), first: true.into() }); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let area = s.recovery.own_area().unwrap().to_string(); + assert!(area.ends_with("-2"), "{area}"); + assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1").as_str(), "{r}"); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn saving_reverting_or_closing_removes_the_copy() { let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id())); From a5bfa524dc3dd58fbcd728a8363588b7addb7dca Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:45:58 -0800 Subject: [PATCH 47/83] Type: Mojikumi spacing at line ends counts when lines break With Line-end Punctuation Half Width, a closing bracket, comma or full stop ending a line is set half width, but both composers measured it at full width. A closing bracket that fit only at half width went to the next line with the character before it, and so did a comma or full stop with Burasagari None. The single-line and every-line composers now measure each candidate line with its last character set as narrow as Mojikumi sets it, using the same rule the line is drawn with. A mark that already lost its half to the punctuation after it and a line ending in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends. Kinsoku Shori Type measures the pushed-in and pushed-out lines the same way. Right-to-left lines and Mojikumi None break as before. A test font sets ideographs and Japanese punctuation one em wide, so the layout tests run without system fonts. --- crates/text/src/composer.rs | 15 ++- crates/text/src/layout/breaking.rs | 46 +++++++-- crates/text/src/layout/flow.rs | 86 +++++++++++++--- crates/text/src/layout/wrapping_tests.rs | 121 ++++++++++++++++++++--- crates/text/src/test_fonts.rs | 35 +++++++ crates/text/src/tests/vertical.rs | 70 +++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 340 insertions(+), 34 deletions(-) diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index dbfa374317..bac0edbe92 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -116,7 +116,9 @@ fn adjustment_ratio(natural: f64, width: f64, stretch: f64, shrink: f64, last: b /// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`; /// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets -/// an opening bracket there flush with the line's start); `cands` are the break opportunities +/// an opening bracket there flush with the line's start); `end_credit(t)` is the width a line whose +/// last character is glyph `t - 1` gives back (Mojikumi sets a closing mark there half width), at +/// most that glyph's advance, and nothing at a hyphenation point; `cands` are the break opportunities /// in increasing order (the paragraph end is added); `flex` holds how far each glyph may stretch /// and shrink in a justified line. /// Returns `(end, hyphen)` per line, or `None` if no set of breaks fits within the tolerance @@ -125,6 +127,7 @@ pub(crate) fn compose( g: &[SGlyph], width: &dyn Fn(usize) -> f64, start_credit: &dyn Fn(usize) -> f64, + end_credit: &dyn Fn(usize) -> f64, cands: &[Breakpoint], flex: &[Flex], p: &Params, @@ -153,6 +156,11 @@ pub(crate) fn compose( } b }; + let line_end_credit = |t: usize| { + let credit = end_credit(t); + let most = t.checked_sub(1).and_then(|k| g.get(k)).map_or(0.0, |gl| gl.adv.max(0.0)); + if credit.is_finite() { credit.clamp(0.0, most) } else { 0.0 } + }; let mut all: Vec = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect(); all.push(Breakpoint { end: n, hyphen: 0.0 }); let (adj_demerits, hyphen_penalty) = @@ -170,7 +178,10 @@ pub(crate) fn compose( let Some(a) = nodes.get(ai) else { continue }; let t = trim(bp.end, a.pos); let credit = if a.pos > 0 { start_credit(a.pos) } else { 0.0 }; - let natural = w[t] - w[a.pos] - credit + bp.hyphen; + // A line that ends with a hyphen ends with it, not with its last character. At most the + // last glyph's advance, so a longer line from the same node is never narrower. + let end = if hyphen || t <= a.pos { 0.0 } else { line_end_credit(t) }; + let natural = w[t] - w[a.pos] - credit - end + bp.hyphen; let lw = width(a.line); // Adjustment ratio, badness and whether the line ends the active node's reach. let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue }; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 87fa8db4df..f319e92867 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -274,6 +274,8 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { /// keeps the line from breaking before the character that overflows, `kinsoku_type` decides /// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical /// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type. +/// `line_end[k]` is how much narrower a line ending with glyph `k` is set (Mojikumi); a cluster +/// that fits only that narrower stays on the line when the line may end after it (empty: none). #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -288,6 +290,7 @@ pub(super) fn break_line( kinsoku_type: KinsokuShoriType, hanging: bool, vertical: bool, + line_end: &[f64], ) -> (usize, bool) { if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { @@ -329,7 +332,11 @@ pub(super) fn break_line( if gl.ch == '\u{2028}' { return (cluster_end, false); } - if j > first && !gl.is_space() && x + cluster_width > width + EPS { + // Measured as the line is set when it ends with this cluster: only the cluster's last glyph + // is narrowed, and only where the line may end. + let end_credit = + Some(line_end_credit(g, line_end, cluster_end)).filter(|&c| c > 0.0 && may_end_after(g, cluster_end, kinsoku)).unwrap_or(0.0); + if j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { let mut end = cluster_end; @@ -338,8 +345,8 @@ pub(super) fn break_line( } return (end, false); } - if let Some(end) = - push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging, vertical) + let pushed_out = last_break.map(|b| b.0); + if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, pushed_out, kinsoku, kinsoku_type, hanging, vertical, line_end) { return (end, false); } @@ -482,6 +489,30 @@ pub(super) fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f (left, right, left_glyphs, right_glyphs) } +/// How much narrower a line whose last character is glyph `end - 1` is set: `line_end` of that +/// glyph ([`break_line`]), at most its advance, and 0 when it is not finite. +fn line_end_credit(g: &[SGlyph], line_end: &[f64], end: usize) -> f64 { + let Some(k) = end.checked_sub(1) else { return 0.0 }; + match (line_end.get(k), g.get(k)) { + (Some(&credit), Some(gl)) if credit.is_finite() && credit > 0.0 => credit.min(gl.adv.max(0.0)), + _ => 0.0, + } +} + +/// May [`break_line`] end a line after glyph `end - 1` (and the spaces after it)? At the paragraph's +/// end or a forced line break, and where it records a break without a hyphen. +fn may_end_after(g: &[SGlyph], end: usize, kinsoku: Kinsoku) -> bool { + let mut e = end; + while g.get(e).is_some_and(SGlyph::is_space) { + e += 1; + } + if e >= g.len() || g[e].ch == '\u{2028}' { + return true; + } + let Some(last) = e.checked_sub(1).and_then(|k| g.get(k)) else { return false }; + last.break_after() && last.len > 0 && !last.is_soft_hyphen() && kinsoku_allows(g, e - 1, kinsoku) && !splits_no_break(g, e) +} + /// Would a line ending at glyph index `end` split a No Break run? fn splits_no_break(g: &[SGlyph], end: usize) -> bool { end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break) @@ -526,7 +557,8 @@ const PUSH_IN_RUN: usize = 16; /// neither does a line holding a tab or right-to-left text, nor one where the characters taken in /// reach a space after a character that may not end a line. With `hanging` (Optical Margin /// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not -/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). +/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). Both lines are +/// measured with their last character set as narrow as `line_end` sets it ([`break_line`]). #[allow(clippy::too_many_arguments)] fn push_in( g: &[SGlyph], @@ -540,6 +572,7 @@ fn push_in( kinsoku_type: KinsokuShoriType, hanging: bool, vertical: bool, + line_end: &[f64], ) -> Option { if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly { return None; @@ -591,7 +624,7 @@ fn push_in( || (a + 3 == last && punct(last - 2) && quote(last - 1))) }; let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); - if x - width > room + EPS { + if x - line_end_credit(g, line_end, last) - width > room + EPS { return None; } if kinsoku_type == KinsokuShoriType::PushOutFirst { @@ -599,7 +632,8 @@ fn push_in( while kept > i && g[kept - 1].is_space() { kept -= 1; } - let short = width - g[i..kept].iter().map(|h| h.adv).sum::(); + let set = if kept > i { line_end_credit(g, line_end, kept) } else { 0.0 }; + let short = width - (g[i..kept].iter().map(|h| h.adv).sum::() - set); if short <= squeeze_em(before, vertical) + EPS { return None; } diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 56c71c2f4b..5617902f1d 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -23,11 +23,14 @@ const MAX_COMPOSE_GLYPHS: usize = 200_000; /// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged, /// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the /// greedy single-line composer. With `continued`, the paragraph continues from the frame before, -/// and its first composed line is not its first line. +/// and its first composed line is not its first line. `line_end` is how much narrower a line +/// ending with each glyph is set ([`ParagraphData::line_end`]). +#[allow(clippy::too_many_arguments)] fn compose_para( cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], + line_end: &[f64], para: &ParaStyle, pen: &Pen<'_>, rtl: bool, @@ -109,7 +112,10 @@ fn compose_para( let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) }; - compose(sg, &width, &start_credit, &cands, flex, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(loose))) + // …and its last character is set narrower, as the line loop sets it. + let end_credit = |end: usize| end.checked_sub(1).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0); + let pass = |p: &Params| compose(sg, &width, &start_credit, &end_credit, &cands, flex, p); + pass(¶ms(strict)).or_else(|| pass(¶ms(loose))) } /// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers @@ -183,6 +189,10 @@ struct ParagraphData<'a, 'text> { sg: Vec, compressed: Vec, wakan: Vec, + /// How much narrower a line ending with each glyph is set ([`line_end_trim`]), for measuring + /// where lines break; empty when the paragraph's lines are measured at full width (Mojikumi + /// None, or right-to-left text). + line_end: Vec, flex: Vec, marker: usize, pm: Metrics, @@ -253,7 +263,13 @@ fn prepare_paragraph<'a, 'text>( if pi > 0 { pen.pending += para.space_before; } - let composed = compose_para(cx, &sg, &flex, para, pen, rtl, continued); + // A line fits when it fits as it is set: with its last character narrowed by Mojikumi. + let line_end = if para.mojikumi == Mojikumi::LineEndHalf && !rtl { + (0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, k)).collect() + } else { + vec![] + }; + let composed = compose_para(cx, &sg, &flex, &line_end, para, pen, rtl, continued); // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the // Hyphen Limit counts the lines in a row that end with a hyphen. let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; @@ -269,6 +285,7 @@ fn prepare_paragraph<'a, 'text>( sg, compressed, wakan, + line_end, flex, marker, pm, @@ -284,6 +301,8 @@ struct PlacementContext<'a> { para: &'a ParaStyle, pr: &'a Range, sg: &'a mut [SGlyph], + /// [`ParagraphData::line_end`]. + line_end: &'a [f64], continued: bool, rtl: bool, mirror: bool, @@ -312,8 +331,23 @@ struct LinePosition { } fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option { - let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line, hanging } = - context; + let PlacementContext { + para, + pr, + sg, + line_end, + continued, + rtl, + mirror, + pm, + composed, + hyphen_run, + hyphen_limit, + zone, + index: i, + para_line, + hanging, + } = context; let n = sg.len(); let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; let first_line = *para_line == 0 && !*continued; @@ -345,8 +379,23 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { - let line = - |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical); + let line = |ty| { + break_line( + cx.text, + sg, + *i, + width, + ¶.tabs, + ind_l, + hyphenate, + para.burasagari, + para.kinsoku, + ty, + *hanging, + cx.vertical, + line_end, + ) + }; let (end, hyph) = line(para.kinsoku_type); // The line pushed characters in where it ends after the line that pushes out. let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0; @@ -439,6 +488,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut para: paragraph.para, pr: ¶graph.pr, sg: &mut paragraph.sg, + line_end: ¶graph.line_end, continued: paragraph.continued, rtl: paragraph.rtl, mirror: paragraph.mirror, @@ -473,6 +523,18 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut true } +/// Mojikumi (Line-end Punctuation Half Width): how much narrower a line ending with glyph `k` of +/// `sg` is set. A closing bracket, comma or full stop ending the line is set half width, unless it +/// already lost that half to the punctuation after it (`compressed`), and no Japanese–Latin space +/// (`wakan`) is left after the line's last character. Never more than the glyph's advance, and 0 +/// for an advance or a space that is not finite. +pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], k: usize) -> f64 { + let Some(g) = sg.get(k) else { return 0.0 }; + let half = if compressed.get(k).copied().unwrap_or(false) { None } else { punct_half(g, Punct::Closing) }; + let trim = half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0); + if trim.is_finite() && g.adv.is_finite() { trim.min(g.adv).max(0.0) } else { 0.0 } +} + /// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it /// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or /// stay out of justification, and whether it is the paragraph's last line. @@ -488,12 +550,10 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData trimmed -= 1; } let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1])); - // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and - // no Japanese–Latin space is left at the end of a line. - let end_trim = trimmed.checked_sub(1).filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf).map_or(0.0, |k| { - let half = if paragraph.compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; - half.unwrap_or(0.0) + paragraph.wakan.get(k).copied().unwrap_or(0.0) - }); + let end_trim = trimmed + .checked_sub(1) + .filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf) + .map_or(0.0, |k| line_end_trim(sg, ¶graph.compressed, ¶graph.wakan, k)); let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte); let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 920e802b7a..123d043ff9 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -92,14 +92,15 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() { KinsokuShoriType::PushOutOnly, false, false, + &[], ); assert_eq!((end, hyphen), (4, true), "{tag}"); let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 }; - let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], ¶ms).expect("every-line composition"); + let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms).expect("every-line composition"); assert!(lines.iter().any(|(_, hyphen)| *hyphen), "{tag}"); assert!(lines.iter().all(|(end, _)| end % 2 == 0), "{tag}: {lines:?}"); let limited = Params { hyphen_limit: 1, ..params }; - if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], &limited) { + if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], &limited) { assert!(lines.windows(2).all(|pair| !pair[0].1 || !pair[1].1), "{tag}: {lines:?}"); } } @@ -164,7 +165,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); let (end, hyphenated) = - break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -178,12 +179,12 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); let (first_end, _) = - break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); let (second_end, hyphenated) = - break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -239,7 +240,7 @@ fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); let (end, hyphen) = - break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -248,7 +249,7 @@ fn no_break_keeps_marked_characters_on_one_line() { g.no_break = true; } let (end, hyphen) = - break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); + break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -257,9 +258,11 @@ fn no_break_keeps_marked_characters_on_one_line() { let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); + let (end, _) = + break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); + let (end, _) = + break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); @@ -286,7 +289,7 @@ fn full_width(text: &str) -> Vec { fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { - break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false).0 + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false, &[]).0 }; let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); let text = "一二三四五六七八九十一二三四五」六"; @@ -351,7 +354,8 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { let mixed = "一二三א五六七八九十一二三四五」六"; let mut glyphs = full_width(mixed); glyphs[3].level = unicode_bidi::Level::rtl(); - let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; + let mixed_end = + |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0; assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); assert_eq!(mixed_end(PushOutOnly), 14); @@ -362,7 +366,8 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { for g in &mut glyphs { g.sy *= 2.0; } - let tall_end = |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; + let tall_end = + |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0; assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」"); // No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to @@ -371,7 +376,97 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { glyphs[1].adv = 5.0; let half = glyphs[1].clone(); glyphs.insert(2, half); - let split_end = |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false).0; + let split_end = + |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[]).0; assert_eq!(split_end(54.0), 5, "五 is pushed out with 」"); assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } + +/// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed +/// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last +/// glyph of the last cluster counts, and only where the line may end. +#[test] +fn greedy_wrap_counts_the_narrower_line_end() { + use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; + let text = "一二三」四五六"; + let glyphs = full_width(text); + let mut credit = vec![0.0; glyphs.len()]; + credit[3] = 5.0; + let end = |glyphs: &[SGlyph], width: f64, burasagari: Burasagari, line_end: &[f64]| { + break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end).0 + }; + assert_eq!(end(&glyphs, 35.0, Burasagari::None, &credit), 4, "」 half width fits"); + assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[]), 2, "」 full width does not: 三 goes with it"); + assert_eq!(end(&glyphs, 34.0, Burasagari::None, &credit), 2, "half width is still too wide"); + // A credit that isn't finite counts for nothing, and one larger than the glyph for its advance. + assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[0.0, 0.0, 0.0, f64::INFINITY]), 2); + assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[0.0, 0.0, 0.0, f64::NAN]), 2); + assert_eq!(end(&glyphs, 30.0, Burasagari::None, &[0.0, 0.0, 0.0, 100.0]), 4, "at most 10 pt off"); + assert_eq!(end(&glyphs, 29.0, Burasagari::None, &[0.0, 0.0, 0.0, 100.0]), 2); + + // The line may not end after 三 (、 follows it): its credit does not keep it on the line, so 、 + // does not hang after a line already wider than the measure. + let comma = "一二三、"; + let glyphs = full_width(comma); + let end = |line_end: &[f64]| { + break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end).0 + }; + assert_eq!(end(&[0.0, 0.0, 5.0, 0.0]), 2); + + // Kinsoku Shori Type measures both lines as they are set. Pushing 。 in leaves the line 5 pt + // wider than the measure with 。 half width, which its five gaps close. + let pushed = "一二三四」。六"; + let glyphs = full_width(pushed); + let end = |ty: KinsokuShoriType, line_end: &[f64]| { + break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end).0 + }; + let stop = [0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 0.0]; + assert_eq!(end(PushInFirst, &stop), 6, "。 is pushed in"); + assert_eq!(end(PushInFirst, &[]), 3, "full width, the gaps can't close enough"); + // Push Out First: pushing out to after 」, set half width, leaves the line 15 pt short, more + // than an em, so 四 is pushed in. + let pushed = format!("{}二」「四", "一".repeat(10)); + let glyphs = full_width(&pushed); + let end = |line_end: &[f64]| { + break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end).0 + }; + let mut bracket = vec![0.0; glyphs.len()]; + bracket[11] = 5.0; + assert_eq!(end(&bracket), 14, "四 is pushed in"); + assert_eq!(end(&[]), 12, "full width, pushing out leaves the line one em short: 「四 go on"); + + // 三 is two glyphs of one cluster: the credit of its first glyph does not count. + let text = "一二三"; + let mut glyphs = full_width(text); + glyphs[2].adv = 5.0; + let half = glyphs[2].clone(); + glyphs.insert(3, half); + let end = + |line_end: &[f64]| break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end).0; + assert_eq!(end(&[0.0, 0.0, 4.0, 0.0]), 2, "the cluster stays whole and goes to the next line"); + assert_eq!(end(&[0.0, 0.0, 0.0, 4.0]), 4, "its last glyph's credit keeps it on the line"); +} + +/// Mojikumi: the every-line composer measures each candidate line with its last character narrowed +/// (`end_credit`), never by more than that glyph's advance, and a line ending in a hyphen ends with +/// the hyphen. +#[test] +fn composition_counts_the_narrower_line_end() { + let text = "一二三」四五六"; + let glyphs = full_width(text); + let cands = candidates(text, &glyphs, None, Kinsoku::Hard); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let lines = + |width: f64, end_credit: &dyn Fn(usize) -> f64, cands: &[Breakpoint]| compose(&glyphs, &|_| width, &|_| 0.0, end_credit, cands, &[], ¶ms); + let half = |t: usize| if t == 4 { 5.0 } else { 0.0 }; + assert_eq!(lines(35.0, &half, &cands), Some(vec![(4, false), (7, false)])); + let solid = lines(35.0, &|_| 0.0, &cands).expect("composed"); + assert!(solid.iter().all(|&(end, _)| end != 4), "」 full width does not end the first line: {solid:?}"); + // At most the glyph's advance; a credit that isn't finite counts for nothing. + assert_eq!(lines(30.0, &|t| if t == 4 { 100.0 } else { 0.0 }, &cands).and_then(|l| l.first().copied()), Some((4, false))); + assert!(lines(35.0, &|_| f64::NAN, &cands).is_some_and(|l| l.iter().all(|&(end, _)| end != 4))); + // No credit for a line ending in a hyphen. + let hyphenated = [Breakpoint { end: 4, hyphen: 1.0 }]; + assert_eq!(lines(38.0, &half, &hyphenated), None); + assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)])); +} diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index aab7f0e1f0..1300920ad1 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -414,6 +414,41 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option> { Some(builder.build()) } +/// The family of [`full_width_font`]. +pub const FULL_WIDTH_FAMILY: &str = "Fullwidthtest Sans"; + +/// The ideographs and Japanese punctuation [`full_width_font`] sets one em wide. +pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。"; + +/// A test-only cmap over bundled Source Sans 3: [`FULL_WIDTH_CHARS`] use the one-em-wide glyph of +/// U+2318, so Japanese composition is tested without OS fonts; Latin letters and the space keep +/// their own glyphs. +pub fn full_width_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let wide = write_fonts::types::GlyphId::new(charmap.map('\u{2318}')?.to_u32()); + let latin = ('A'..='Z').chain('a'..='z').chain([' ']); + let mut mappings: Vec<(char, write_fonts::types::GlyphId)> = FULL_WIDTH_CHARS.chars().map(|c| (c, wide)).collect(); + for c in latin { + mappings.push((c, write_fonts::types::GlyphId::new(charmap.map(c)?.to_u32()))); + } + let cmap = Cmap::from_mappings(mappings).ok()?; + let name = name_table(&[(1, FULL_WIDTH_FAMILY), (2, "Regular"), (4, "Fullwidthtest Sans Regular"), (6, "FullwidthtestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if tag != Tag::new(b"GSUB") && !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`half_form_font`]. pub const HALF_FORM_FAMILY: &str = "Halftest Sans"; diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 2bc1c2ff70..52061213a2 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -1075,6 +1075,76 @@ fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() { } } +/// Mojikumi: with Line-end Punctuation Half Width, a line is measured as it is set. A closing +/// bracket, comma or full stop that fits only at half width ends the line in a measure of three +/// and a half ems, with both composers, horizontal and vertical; with Mojikumi None it goes to the +/// next line with the character before it. A closing mark that already lost its half to the +/// punctuation after it takes nothing more off, and the space between Japanese and Latin text +/// counts only where the line ends. Uses a test font whose ideographs and punctuation are one em. +#[test] +fn mojikumi_line_end_half_width_counts_when_a_line_breaks() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + let lay = |text: &str, m: Mojikumi, composer: Composer, burasagari: Burasagari, vertical_type: bool, measure: f64| { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + t.vertical = vertical_type; + t.para.mojikumi = m; + t.para.composer = composer; + t.para.burasagari = burasagari; + t.para.kinsoku = Kinsoku::Hard; + // Push Out Only lets the every-line composer take the paragraph. + if composer == Composer::EveryLine { + t.para.kinsoku_type = KinsokuShoriType::PushOutOnly; + } + let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, measure) } else { Rect::new(0.0, 0.0, measure, 200.0) }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + layout(&db, &t) + }; + let ends = |l: &TextLayout, text: &str| l.lines.iter().map(|line| text[line.start..line.end].to_string()).collect::>(); + let widest = |l: &TextLayout| { + l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::()).fold(0.0, f64::max) + }; + assert!((lay("一", Mojikumi::None, Composer::SingleLine, Burasagari::None, false, 70.0).glyphs[0].advance - 20.0).abs() < 1e-9, "one em"); + for vertical_type in [false, true] { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let at = format!("vertical {vertical_type} {composer:?}"); + let marks = [("一二三」四五六", "一二三」"), ("一二三。四五六", "一二三。"), ("一二三、四五六", "一二三、")]; + for (text, first) in marks { + let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0); + assert_eq!(ends(&l, text), [first, "四五六"], "{at}: {text}"); + assert!((l.glyphs[3].advance - 10.0).abs() < 1e-9, "{at}: {text}: the mark ends the line half width"); + assert!(widest(&l) <= 70.0 + 1e-9, "{at}: {text}: {}", widest(&l)); + // Standard burasagari: the mark fits inside, so it doesn't hang. + let standard = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::Standard, vertical_type, 70.0); + assert_eq!(ends(&standard, text), [first, "四五六"], "{at}: {text} standard"); + assert!(widest(&standard) <= 70.0 + 1e-9, "{at}: {text} standard"); + // Full width it doesn't fit: kinsoku takes 三 to the next line with it. + let solid = lay(text, Mojikumi::None, composer, Burasagari::None, vertical_type, 70.0); + assert_eq!(ends(&solid, text).first().map(String::as_str), Some("一二"), "{at}: {text} none"); + } + // 」 lost its half to 。 after it: the line would be four ems, so 三」。 go on. + let text = "一二三」。四五"; + let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0); + assert_eq!(ends(&l, text).first().map(String::as_str), Some("一二"), "{at}: compressed"); + assert!(widest(&l) <= 70.0 + 1e-9, "{at}: compressed"); + } + } + // The quarter em between 三 and A goes where the line ends there: 一二三 fits three ems and a + // tenth, with or without the space at its end. + for composer in [Composer::SingleLine, Composer::EveryLine] { + let text = "一二三ABC"; + let on = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 62.0); + assert_eq!(ends(&on, text).first().map(String::as_str), Some("一二三"), "{composer:?}"); + assert!((on.glyphs[2].advance - 20.0).abs() < 1e-9, "{composer:?}: no space at the line's end"); + let wide = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 200.0); + assert!((wide.glyphs[2].advance - 25.0).abs() < 1e-9, "{composer:?}: the space inside the line"); + } +} + /// Burasagari: in a measure of exactly five ems, a comma that would be the sixth character goes to /// the next line with the character before it (None) or hangs outside the line (Standard, Forced); /// a full stop that is the fifth character stays inside (None, Standard) or hangs while the four diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..e2d7c73167 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1188,6 +1188,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 (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). + With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. 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, 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. From 253c60a23ea39811e75c0c403b1844e7034408bc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:17:12 -0800 Subject: [PATCH 48/83] Type: a line's first word is measured as set when looking for room In a frame that is not a rectangle, or with text wrap, layout moves a line down to the first span wide enough for its first word. The word was measured at full width before Mojikumi set an opening bracket at the line's start flush, and with its last character at full width, so a word that fit as set moved down to a wider line. It also ended at any break opportunity, so an opening bracket counted without the character kinsoku keeps with it and could be placed alone on a line too narrow for that character. - word_width ends the word only where break_line may end a line: at a cluster end that kinsoku allows, not inside a No Break run, whose spaces count. It leaves out the spaces at the word's end and narrows its last glyph by the Mojikumi line-end credit. - place_flow_line measures the word after the opening-bracket trim, and the Every-line Composer's first-line placement takes off the same start credit, so the composed widths match the placed lines. --- crates/text/src/layout/breaking.rs | 38 ++++++++--- crates/text/src/layout/flow.rs | 23 +++---- crates/text/src/tests/vertical.rs | 100 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 141 insertions(+), 22 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index f319e92867..f5e7b1d45f 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -240,29 +240,47 @@ pub(super) fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, out } -/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces -/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it. -/// A list marker at `i` (glyphs of length 0) counts whole, with the word after it. -pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { +/// Width of the word starting at glyph `i`: its advances up to the first place a line may break +/// (spaces left out, the hyphen of a soft hyphen in), the least room a line needs not to break +/// inside it. As in [`break_line`], kinsoku keeps a character that may not end a line with the one +/// after it, a No Break run (its spaces inside it included) is one word, and a cluster is never +/// split. A list marker at `i` (glyphs of length 0) counts whole, with the word after it. A word +/// that ends where a line may end is measured as the line is set: without the spaces at its end, +/// and with its last glyph as narrow as `line_end` sets it; one that ends at a soft hyphen or past +/// the measured glyphs is not. +pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kinsoku) -> f64 { /// Glyphs measured at most: a word wrapped over many lines is measured again from each line's /// start, and one this long is broken inside whatever cell it meets. const MAX_GLYPHS: usize = 1024; let rest = g.get(i..).unwrap_or_default(); let marker = rest.iter().take_while(|gl| gl.len == 0).count(); let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum(); - for gl in rest.iter().skip(marker).take(MAX_GLYPHS) { - if gl.is_space() { - break; + let start = i + marker; + let mut end = start; + // The spaces at the end of what is measured so far (inside a No Break run), and the end of its + // last glyph that is not a space. + let (mut trailing, mut ink) = (0.0, start); + let set = |w: f64, trailing: f64, ink: usize| w - trailing - line_end_credit(g, line_end, ink); + while let Some(gl) = g.get(end).filter(|_| end - start < MAX_GLYPHS) { + if gl.is_space() && (end == start || !splits_no_break(g, end)) { + return set(w, trailing, ink); } if gl.is_soft_hyphen() { return w + hyphen_glyph(gl).adv; } w += gl.adv; - if gl.break_after() { - break; + end += 1; + if gl.is_space() { + trailing += gl.adv; + } else { + (trailing, ink) = (0.0, end); + } + let cluster_ends = g.get(end).is_none_or(|next| next.byte != gl.byte); + if gl.break_after() && cluster_ends && kinsoku_allows(g, end - 1, kinsoku) && !splits_no_break(g, end) { + return set(w, trailing, ink); } } - w + if end >= g.len() { set(w, trailing, ink) } else { w } } /// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 5617902f1d..05ec2e0e76 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -71,6 +71,12 @@ fn compose_para( return None; } let total: f64 = sg.iter().map(|g| g.adv).sum(); + // The same rule as the line loop below: an opening bracket starting a wrapped line gives up + // the space before it. + let start_credit = |i: usize| { + let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; + sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) + }; let mut sim = pen.clone(); let mut widths = vec![]; let mut acc = 0.0; @@ -80,8 +86,9 @@ fn compose_para( let first = widths.is_empty(); let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 }; // Only the first line's first word is known here. Later lines are placed as the composed - // lines need: each fits its width, so its first word does too. - let word = if first { word_width(sg, 0) } else { 0.0 }; + // lines need: each fits its width, so its first word does too. It is measured as the line + // loop measures it, with Mojikumi's narrower start and end. + let word = if first { word_width(sg, 0, line_end, para.kinsoku) - start_credit(0) } else { 0.0 }; let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break }; let w = x1 - x0 - ind_l - para.right_indent; widths.push(w); @@ -106,13 +113,7 @@ fn compose_para( // stop at four times its stretch and fall back to the single-line composer; the documented // default range (word spacing up to 133%) is narrow enough that most paragraphs would. let (strict, loose) = (1.0, f64::INFINITY); - // The same rule as the line loop below: an opening bracket starting a wrapped line gives up - // the space before it. - let start_credit = |i: usize| { - let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; - sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) - }; - // …and its last character is set narrower, as the line loop sets it. + // A line's last character is set narrower, as the line loop sets it. let end_credit = |end: usize| end.checked_sub(1).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0); let pass = |p: &Params| compose(sg, &width, &start_credit, &end_credit, &cands, flex, p); pass(¶ms(strict)).or_else(|| pass(¶ms(loose))) @@ -352,8 +353,6 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; let first_line = *para_line == 0 && !*continued; let (ind_l, ind_r) = line_indents(para, first_line, *mirror); - // Measured before Mojikumi narrows an opening bracket, as the composer measures it. - let word = word_width(sg, *i); // Mojikumi: an opening bracket starting a line is set flush with the line's start (the // space before it goes), and the line has that much more room. At a paragraph's start // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). @@ -366,6 +365,8 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC glyph.adv -= half; glyph.lead += half; } + // The line's first word, as narrow as the line sets it at both ends. + let word = word_width(sg, *i, line_end, para.kinsoku); // Place, break, then settle the baseline on the line's real metrics (moving on to the // next row/column if it no longer fits). loop { diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 52061213a2..e76aee72f0 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -1145,6 +1145,106 @@ fn mojikumi_line_end_half_width_counts_when_a_line_breaks() { } } +/// Mojikumi: in a frame that is narrow at the top, and in a frame a wrap object narrows at the top, +/// a line's first word is measured as the line sets it. A word ending with a closing bracket that +/// fits the narrow lines only with the bracket half width, and an opening bracket that fits them +/// only flush with the line's start, start on the first line, with both composers. An opening +/// bracket that kinsoku keeps with the character after it, and a No Break run, are measured whole. +#[test] +fn mojikumi_narrows_a_lines_first_word_when_looking_for_room() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::{Composer, Kinsoku, KinsokuShoriType, Mojikumi, TextWrap, WrapShape}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + // The top 40 pt of the frame are `top` wide, the rest 200: an L-shaped frame, or a rectangle + // with a wrap object over the top right. + let lay = |text: &str, styled: fn(&mut TextObject), composer: Composer, top: f64, wrapped: bool| { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + styled(&mut t); + t.para.mojikumi = Mojikumi::LineEndHalf; + t.para.composer = composer; + t.para.kinsoku = Kinsoku::Hard; + t.para.kinsoku_type = KinsokuShoriType::PushOutOnly; + let frame = if wrapped { + t.wrap = vec![WrapShape { + path: PathData::from_bezpath(&Rect::new(top, -10.0, 210.0, 40.0).to_path(0.1)), + wrap: TextWrap { offset: 0.0, invert: false }, + }]; + Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1) + } else { + let mut p = kurbo::BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((top, 0.0)); + p.line_to((top, 40.0)); + p.line_to((200.0, 40.0)); + p.line_to((200.0, 200.0)); + p.line_to((0.0, 200.0)); + p.close_path(); + p + }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + layout(&db, &t) + }; + let plain: fn(&mut TextObject) = |_| {}; + let wide_bracket: fn(&mut TextObject) = |t| edit::style_range(&mut t.runs, 0, '「'.len_utf8(), |s| s.h_scale = 250.0); + let no_break: fn(&mut TextObject) = |t| { + let n = t.plain_text().len(); + edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true); + }; + let no_break_but_the_last_word: fn(&mut TextObject) = |t| { + let n = t.plain_text().rfind(' ').map_or(0, |k| k + 1); + edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true); + }; + // "ABCD」" is `full` wide inside a line; ending one, 」 is half width. + let closing = "ABCD」EFGH"; + let open = lay(closing, plain, Composer::SingleLine, 200.0, false); + let full: f64 = open.glyphs[..5].iter().map(|g| g.advance).sum(); + assert!((open.glyphs[4].advance - 20.0).abs() < 1e-9, "」 is one em inside a line"); + for wrapped in [false, true] { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let at = format!("wrapped {wrapped} {composer:?}"); + let top = full - 5.0; + let l = lay(closing, plain, composer, top, wrapped); + let first = l.lines.first().expect("a line"); + assert_eq!(&closing[first.start..first.end], "ABCD」", "{at}"); + assert!(first.baseline < 40.0, "{at}: the word starts on the narrow lines: {}", first.baseline); + let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum(); + assert!(set <= top + 1e-9, "{at}: {set} in {top}"); + // 「 is 50 pt at 250%, 25 pt flush with the line's start: 「一 is 45 pt. + let opening = "「一二三四五六"; + let l = lay(opening, wide_bracket, composer, 47.0, wrapped); + let first = l.lines.first().expect("a line"); + assert_eq!(&opening[first.start..first.end], "「一", "{at}"); + assert!(first.baseline < 40.0, "{at}: 「 starts on the narrow lines: {}", first.baseline); + let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum(); + assert!(set <= 47.0 + 1e-9, "{at}: {set}"); + // Kinsoku keeps 「 with 一: in 40 pt they don't fit, and the line starts below, whole. + let l = lay(opening, wide_bracket, composer, 40.0, wrapped); + let first = l.lines.first().expect("a line"); + assert!(first.baseline > 40.0 && &opening[first.start..first.end] != "「", "{at}: kinsoku: {:?}", placed_lines(&l, opening)); + // A No Break run is one word, its space included: it doesn't fit the narrow lines. + let run_text = "ABCD EFGH"; + let run: f64 = lay(run_text, no_break, composer, 200.0, false).glyphs.iter().map(|g| g.advance).sum(); + let l = lay(run_text, no_break, composer, run - 5.0, wrapped); + let first = l.lines.first().expect("a line"); + assert!(first.baseline > 40.0 && &run_text[first.start..first.end] == run_text, "{at}: no break: {:?}", placed_lines(&l, run_text)); + // …but the space ending a No Break run is not set at the line's end: the run fits a + // line 2 pt wider than it is without that space. + let ending = "ABCD EFGH IJ"; + let l = lay(ending, no_break_but_the_last_word, composer, run + 2.0, wrapped); + let first = l.lines.first().expect("a line"); + assert!(first.baseline < 40.0 && ending[first.start..first.end].trim_end() == run_text, "{at}: {:?}", placed_lines(&l, ending)); + } + } +} + +/// Each line's baseline and text. +fn placed_lines(l: &TextLayout, text: &str) -> Vec<(f64, String)> { + l.lines.iter().map(|line| (line.baseline, text[line.start..line.end].to_string())).collect() +} + /// Burasagari: in a measure of exactly five ems, a comma that would be the sixth character goes to /// the next line with the character before it (None) or hangs outside the line (Standard, Forced); /// a full stop that is the fifth character stays inside (None, Standard) or hangs while the four diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2d7c73167..90ed2fc30b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1188,7 +1188,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 (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). - With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. + With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. 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, 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. From 7114de8ae5df8309d76469f7b689f65a9ee6637f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:23:08 -0800 Subject: [PATCH 49/83] Scripts: a missing file outside the script's folder is refused like an existing one A `File` or `Folder` for a missing path got "does not exist" even when the path led outside the script's folder, through a folder link or, on a file system where case matters, through a folder whose name differs from the script's folder only in case, while an existing file there was refused. A missing path is now refused unless the nearest folder above it that exists lies inside a granted folder. --- crates/engine/src/file_access.rs | 12 +++++++++++- crates/engine/src/tests/script_io.rs | 28 ++++++++++++++++++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 27ebc9c40d..7114e6f13e 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -463,7 +463,10 @@ impl ScriptScope { let real = match std::fs::canonicalize(path) { Ok(real) => real, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing }); + if std::fs::symlink_metadata(path).is_ok() || !self.missing_inside(path) { + return Err(ScriptRead::Refused); + } + return Err(ScriptRead::Missing); } Err(e) => return Err(ScriptRead::Failed(e.to_string())), }; @@ -473,6 +476,13 @@ impl ScriptScope { Ok(real) } + /// Is the missing `path` inside a granted folder: does the nearest folder above it that exists + /// lie inside one? On a file system where case matters, `MAIN/x` beside the granted `main` is + /// outside, whether `MAIN` exists or not. + fn missing_inside(&self, path: &Path) -> bool { + path.ancestors().skip(1).find_map(|a| std::fs::canonicalize(a).ok()).is_some_and(|real| self.roots.iter().any(|r| inside(&real, r))) + } + /// What the file or folder at `path` is, located as [`ScriptScope::locate`] locates it. pub(crate) fn stat(&self, path: &Path) -> Result { let real = self.locate(path)?; diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index aae4810dad..7f3af701e0 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -153,6 +153,9 @@ fn script_file_link_escape_refused() { Folder.current.getFiles().map(function (f) { return f.name; }), new Folder('near').alias, new Folder('near').resolve().name]);", ); assert_eq!(log, json!([[false, null, DENIED, null, false, false, DENIED, ["inner", "main.jsx", "near", "own.txt"], true, "inner"]])); + // A missing file behind the link is refused like an existing one, so nothing tells them apart. + let log = logged(&base, "var n = new File('away/nope.txt'); vc.log([n.exists, n.open('r'), n.error]);"); + assert_eq!(log, json!([[false, false, DENIED]])); #[cfg(unix)] let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); #[cfg(windows)] @@ -176,6 +179,31 @@ fn script_file_link_escape_refused() { let _ = std::fs::remove_dir_all(base); } +/// A path whose names differ from the script's folder only in case is that folder where case does +/// not matter, and outside it where case matters, whether or not the file is there. +#[test] +fn script_file_case_variant_folder() { + let base = folder("case"); + write(&base.join("main/own.txt"), b"own"); + let upper = base.join("MAIN"); + let source = |name: &str| { + let p = format!("{:?}", upper.join(name).to_string_lossy()); + format!("var f = new File({p}); vc.log([f.exists, f.open('r'), f.error]);") + }; + if upper.exists() { + // Case does not matter here: `MAIN` is the script's folder. + assert_eq!(logged(&base, &source("own.txt")), json!([[true, true, ""]])); + assert_eq!(logged(&base, &source("nope.txt")), json!([[false, false, "Script file does not exist"]])); + } else { + let denied = json!([[false, false, DENIED]]); + assert_eq!(logged(&base, &source("nope.txt")), denied, "MAIN does not exist"); + write(&upper.join("secret.txt"), b"secret"); + assert_eq!(logged(&base, &source("secret.txt")), denied); + assert_eq!(logged(&base, &source("nope.txt")), denied, "MAIN exists beside main"); + } + let _ = std::fs::remove_dir_all(base); +} + #[test] fn script_file_oversize_refused_before_read() { let base = folder("oversize"); diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..7f75de7c33 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1837,7 +1837,8 @@ VectorCraft reads files by these rules of its own: line, the files must also be inside them. A path outside, a Windows network path (`\\host\share`) and a device path (`\\?\`, `\\.\`) are refused before anything is looked up: `exists` is `false`, the dates and `getFiles` give `null`, and `error` is `Script file access denied`. A path that leads out through a link or a junction is refused too, and - `getFiles` leaves such an entry out. + `getFiles` leaves such an entry out. A missing file is refused the same way unless the nearest folder above it that + exists is inside the script's folder. - `open` reads the whole file. A file larger than 8 MiB is refused from its size before any of it is read, with `Script file exceeds 8 MiB`, and a run that opens more than 32 MiB in all ends with an error. A run has at most 256 files open at once; another `open` returns `false` with `Script has 256 files open` until one is closed. From aa73329a4039285b291f77d971cc1107cc21993c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:24:12 -0800 Subject: [PATCH 50/83] Document Info: Gradient Objects lists the objects painted with gradients The gradients category was titled Gradient Swatches and listed every gradient swatch, used or not. It is now Gradient Objects: one row per object filled or stroked with a gradient, named as the object is, with the gradients it uses (a linked swatch by name, else Linear gradient, Radial gradient or Freeform gradient). document.info adds gradientObjects and gradientNames, and Selection Only, Save and the Package report use the same rows. Gradient objects follow the rules Pattern Objects uses for paint that draws: hidden fills and strokes, strokes with no width, guides, compound path members' own paint, image paint without a shape and type that draws nothing are left out, and type whose characters carry a gradient is included. objects.gradients now counts the listed objects, so it no longer counts hidden or zero-width gradient paint and no longer misses type whose characters use a gradient. The duplicate Document Info Save line in docs/target-app-parity.md is removed. --- crates/engine/src/cmd/docinfo.rs | 268 ++++++++++++++++++++------ crates/engine/src/tests/package.rs | 2 +- crates/ui-egui/src/panels/doc_info.rs | 16 +- docs/mcp.md | 6 +- docs/target-app-parity.md | 2 +- 5 files changed, 226 insertions(+), 68 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index cb1bb86fbd..7499e14943 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", has_doc, info ), @@ -42,7 +42,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [ ("brushes", "Brushes"), ("spotColors", "Spot Colors"), ("patterns", "Pattern Objects"), - ("gradients", "Gradient Swatches"), + ("gradients", "Gradient Objects"), ("symbols", "Symbols"), ("fonts", "Fonts"), ("fontDetails", "Font Details"), @@ -131,7 +131,8 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S /// The Document Info categories of `d` (only `category` when given) from `info` (what /// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the -/// rows of its objects filled or stroked with a pattern. +/// rows of its objects filled or stroked with a pattern and with a gradient. +#[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, @@ -139,6 +140,7 @@ fn sections( images: &[ImageRow], repeats: &[(String, String)], pattern_objects: &[(String, String)], + gradient_objects: &[(String, String)], category: Option<&str>, ) -> Vec
{ let names = |k: &str| -> Vec<(String, String)> { @@ -183,7 +185,7 @@ fn sections( "brushes" => names("brushNames"), "spotColors" => names("spotColors"), "patterns" => pattern_objects.to_vec(), - "gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(), + "gradients" => gradient_objects.to_vec(), "symbols" => names("symbols"), "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), "fontDetails" => { @@ -254,19 +256,37 @@ pub fn report(s: &mut Session, selection_only: bool) -> Result { Ok(v["text"].as_str().unwrap_or_default().to_string()) } -/// The type object `n` holds and the runs its layout draws, when it uses a pattern: the runs with a -/// glyph outline (list markers and ruby included) or an underline or strikethrough bar. Spaces and -/// line breaks draw no outline. A type object's characters carry their own fill and stroke, and the -/// type object's own fills and strokes paint on its characters, so only those runs paint. The type -/// is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the renderer lays it out. +/// The pattern paint `p` uses: the pattern's name. +fn pattern_label(p: &Paint) -> Option { + match p { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + } +} + +/// The gradient paint `p` uses: the name of the gradient swatch it is linked to, else its kind +/// ("Linear gradient", "Radial gradient" or "Freeform gradient"). +fn gradient_label(p: &Paint) -> Option { + match p { + Paint::Gradient(g) => Some(g.swatch.clone().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("{} gradient", g.gradient.kind.label()))), + _ => None, + } +} + +/// The type object `n` holds and the runs its layout draws, when it uses a pattern or a gradient: +/// the runs with a glyph outline (list markers and ruby included) or an underline or strikethrough +/// bar. Spaces and line breaks draw no outline. A type object's characters carry their own fill and +/// stroke, and the type object's own fills and strokes paint on its characters, so only those runs +/// paint. The type is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the +/// renderer lays it out. fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { let NodeKind::Text(t) = &n.kind else { return None }; - let is_pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)); let own = n.appearance.items.iter().any(|i| match i { - vectorcraft_doc::AppearanceItem::Fill(f) => is_pattern(&f.paint), - vectorcraft_doc::AppearanceItem::Stroke(s) => is_pattern(&s.paint), + vectorcraft_doc::AppearanceItem::Fill(f) => is_listed(&f.paint), + vectorcraft_doc::AppearanceItem::Stroke(s) => is_listed(&s.paint), }); - if !own && !t.runs.iter().any(|r| is_pattern(&r.style.fill) || is_pattern(&r.style.stroke)) { + if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) { return None; } let db = vectorcraft_text::FontDb::global(); @@ -277,42 +297,38 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T Some((t, painted)) } -/// The patterns `n`'s own fills and strokes paint, each once. A hidden fill or stroke and a stroke -/// with no width paint nothing, and neither does any fill or stroke of a guide (drawn in the guide -/// color), of a compound path's member (`members`: the compound path's own fills and strokes paint -/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint -/// only where an effect gives them a shape. -fn own_patterns<'a>( +/// The patterns or gradients (`label`: [`pattern_label`] or [`gradient_label`]) that `n`'s own +/// fills and strokes paint, each once. A hidden fill or stroke and a stroke with no width paint +/// nothing, and neither does any fill or stroke of a guide (drawn in the guide color), of a compound +/// path's member (`members`: the compound path's own fills and strokes paint it) or of type whose +/// layout draws nothing (`painted_runs`). An image's fills and strokes paint only where an effect +/// gives them a shape. +fn own_paints( d: &Document, - n: &'a Node, + n: &Node, members: &HashSet<*const Node>, painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet)>, -) -> Vec<&'a str> { + label: fn(&Paint) -> Option, +) -> Vec { if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) || painted_runs.is_some_and(|(_, r)| r.is_empty()) + || !n.appearance.items.iter().any(|i| label(i.paint()).is_some()) { return vec![]; } - // The patterns an image's shaped fills and strokes paint (their effects can transform the - // pattern, so they are matched by name). - let shaped: Option> = matches!(n.kind, NodeKind::Image(_)).then(|| { - vectorcraft_render::effects::image_paint(Some(d), n) - .into_iter() - .filter_map(|(_, _, item)| match item.paint() { - Paint::Pattern { pattern, .. } => Some(pattern.clone()), - _ => None, - }) - .collect() - }); - let mut out: Vec<&str> = vec![]; + // The patterns or gradients an image's shaped fills and strokes paint (their effects can + // transform the paint, so they are matched by label). + let shaped: Option> = matches!(n.kind, NodeKind::Image(_)) + .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().filter_map(|(_, _, item)| label(item.paint())).collect()); + let mut out: Vec = vec![]; for item in &n.appearance.items { - let Paint::Pattern { pattern, .. } = item.paint() else { continue }; + let Some(name) = label(item.paint()) else { continue }; let paints = item.visible() && !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) - && shaped.as_ref().is_none_or(|s| s.contains(pattern)); - if paints && !out.contains(&pattern.as_str()) { - out.push(pattern); + && shaped.as_ref().is_none_or(|s| s.contains(&name)); + if paints && !out.contains(&name) { + out.push(name); } } out @@ -354,6 +370,11 @@ fn info(s: &mut Session, p: &Value) -> Result { // `pattern_objects` and `pattern_rows` hold the `patternObjects` entries and the rows of the objects filled or stroked with a pattern, in walk order. let mut pattern_objects: Vec = vec![]; let mut pattern_rows: Vec<(String, String)> = vec![]; + // `gradient_objects`, `gradient_rows` and `gradients` hold the `gradientObjects` entries, the rows of the objects filled or stroked + // with a gradient, in walk order, and the gradients they use. + let mut gradient_objects: Vec = vec![]; + let mut gradient_rows: Vec<(String, String)> = vec![]; + let mut gradients = BTreeSet::new(); // The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once. // The set holds each object's address, so two objects that share an id (as a damaged file can hold) count as two. let mut seen: HashSet<*const Node> = HashSet::new(); @@ -412,27 +433,17 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - // The paint kinds this object's fills and strokes use, each once. - let mut kinds = BTreeSet::new(); - // The patterns this object's fills and strokes use, each once. - let mut used_patterns: Vec<&str> = vec![]; - let painted_runs = painted_runs(d, n); - for pattern in own_patterns(d, n, &members, painted_runs.as_ref()) { - patterns.insert(pattern.to_string()); - used_patterns.push(pattern); - } for item in &n.appearance.items { - let paint = item.paint(); - // Objects counts an object with a pattern when Pattern Objects lists it, below. - if let Paint::Gradient(_) = paint { - kinds.insert("gradients"); - } - if let Paint::Solid { swatch: Some(name), .. } = paint + if let Paint::Solid { swatch: Some(name), .. } = item.paint() && d.swatch(name).is_some_and(|s| s.spot) { spot.insert(name.clone()); } } + // The patterns and the gradients this object's fills and strokes paint, each once. + let painted_runs = painted_runs(d, n); + let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), pattern_label); + let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), gradient_label); // The characters' own paints: an inline graphic draws no outline and does not take its // run's paint (a list marker set in its run does), and a stroke with no width paints // nothing. @@ -440,23 +451,32 @@ fn info(s: &mut Session, p: &Value) -> Result { for run in painted.iter().filter_map(|i| t.runs.get(*i)) { let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); for paint in std::iter::once(&run.style.fill).chain(stroke) { - if let Paint::Pattern { pattern, .. } = paint { - patterns.insert(pattern.clone()); - if !used_patterns.contains(&pattern.as_str()) { - used_patterns.push(pattern); + for (used, label) in + [(&mut used_patterns, pattern_label as fn(&Paint) -> Option), (&mut used_gradients, gradient_label)] + { + if let Some(name) = label(paint) + && !used.contains(&name) + { + used.push(name); } } } } } + // Objects counts the objects that Pattern Objects and Gradient Objects list. if !used_patterns.is_empty() { let name = n.display_name(); + patterns.extend(used_patterns.iter().cloned()); + pattern_rows.push((name.clone(), used_patterns.join(", "))); pattern_objects.push(json!({ "id": n.id.0, "name": name, "patterns": used_patterns })); - pattern_rows.push((name, used_patterns.join(", "))); - kinds.insert("patterns"); + *counts.entry("patterns").or_default() += 1; } - for k in kinds { - *counts.entry(k).or_default() += 1; + if !used_gradients.is_empty() { + let name = n.display_name(); + gradients.extend(used_gradients.iter().cloned()); + gradient_rows.push((name.clone(), used_gradients.join(", "))); + gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients })); + *counts.entry("gradients").or_default() += 1; } if n.extras().mask.is_some() { *counts.entry("opacityMasks").or_default() += 1; @@ -506,10 +526,12 @@ fn info(s: &mut Session, p: &Value) -> Result { "patterns": d.patterns.len(), "patternNames": patterns, "patternObjects": pattern_objects, + "gradientNames": gradients, + "gradientObjects": gradient_objects, }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -583,12 +605,14 @@ mod tests { &json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}}), ) .unwrap(); - s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); let i = s.execute("document.info", &json!({})).unwrap(); + // The type's characters take the current gradient fill, so it is a gradient object too. assert_eq!( (i["objects"]["paths"].as_u64(), i["objects"]["textObjects"].as_u64(), i["objects"]["gradients"].as_u64()), - (Some(1), Some(1), Some(1)) + (Some(1), Some(1), Some(2)) ); + assert_eq!((i["gradientObjects"][0]["id"].clone(), i["gradientObjects"][1]["id"].clone()), (r.clone(), t)); assert_eq!(i["fonts"][0], "Source Sans 3 Regular"); assert_eq!(i["document"]["artboards"][0]["width"], 200.0); s.execute("select.set", &json!({"ids": [r]})).unwrap(); @@ -842,6 +866,124 @@ mod tests { assert!(!text.contains("Unused"), "{text}"); } + /// Document Info › Gradient Objects lists each object filled or stroked with a gradient once, + /// with the gradients it uses (a linked swatch by name, else the gradient's kind), one in a + /// group and a type object whose characters use a gradient included; a gradient swatch no art + /// uses adds no row, paint that draws nothing is left out by the rules Pattern Objects follows, + /// and Objects counts the listed objects. With Selection Only it lists the objects in the + /// selection, each once, and the report writes the same rows. + #[test] + fn info_lists_the_gradient_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}); + s.execute("swatch.new", &json!({"name": "Ramp", "gradient": ramp("linear")})).unwrap(); + s.execute("swatch.new", &json!({"name": "Spare", "gradient": ramp("radial")})).unwrap(); + let (a, b, c, f) = (square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0), square(&mut s, 0.0)); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Ramp"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "gradient": ramp("linear")})).unwrap(); + s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b], "gradient": ramp("radial")})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [f], "gradient": ramp("freeform")})).unwrap(); + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + // A type object whose characters are filled with the gradient. + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Ramp"})).unwrap(); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + // A hidden gradient fill and a gradient stroke with no width paint nothing, so neither object is listed. + let (d, e) = (square(&mut s, 100.0), square(&mut s, 200.0)); + s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Ramp"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [e], "gradient": ramp("linear")})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // Type with no characters, or of only spaces and a line break, paints nothing, and neither + // does a guide made from a path with a gradient. + let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Ramp"})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap(); + let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Ramp"})).unwrap(); + let guide = square(&mut s, 300.0); + s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Ramp"})).unwrap(); + s.execute("select.set", &json!({"ids": [guide]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + // Nor does a compound path's member's own gradient fill, or an image's gradient fill + // without an effect that gives it a shape. + let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap(); + let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone(); + let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance); + let pic = square(&mut s, 360.0); + s.execute("select.set", &json!({"ids": [pic]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let image = s.doc().unwrap().selection.objects[0]; + s.edit("own paint", |d, _| { + d.node_mut(member).unwrap().appearance = paint.clone(); + d.node_mut(image).unwrap().appearance = paint; + Ok(()) + }) + .unwrap(); + // Underlined spaces draw a bar in the gradient, so that type object is listed. + let bar = s.execute("text.create", &json!({"x": 0, "y": 340, "text": " "})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [bar], "swatch": "Ramp"})).unwrap(); + s.execute("select.set", &json!({"ids": [bar]})).unwrap(); + s.execute("text.setFormat", &json!({"underline": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": bar, "name": "Bar"})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!( + i["gradientObjects"], + json!([ + {"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"]}, + {"id": b, "name": "", "gradients": ["Radial gradient"]}, + {"id": f, "name": "", "gradients": ["Freeform gradient"]}, + {"id": t, "name": "Type", "gradients": ["Ramp"]}, + {"id": bar, "name": "Bar", "gradients": ["Ramp"]}, + ]) + ); + assert_eq!(i["gradientNames"], json!(["Freeform gradient", "Linear gradient", "Radial gradient", "Ramp"])); + // Objects counts the objects that Gradient Objects lists. + assert_eq!(i["objects"]["gradients"].as_u64(), Some(5), "{}", i["objects"]); + s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["gradients"].is_null() && i["gradientObjects"] == json!([]), "{}", i["objects"]); + let i = s.execute("document.info", &json!({"category": "gradients"})).unwrap(); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("gradients"), Some("Gradient Objects"))); + assert_eq!( + i["sections"][0]["rows"], + json!([ + ["Both", "Ramp, Linear gradient"], + ["", "Radial gradient"], + ["", "Freeform gradient"], + ["Type", "Ramp"], + ["Bar", "Ramp"] + ]) + ); + // A selection that holds the group and the object inside it lists that object once. + for ids in [json!([group, b]), json!([b, group])] { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "gradients"})).unwrap(); + assert_eq!(i["sections"][0]["rows"], json!([["", "Radial gradient"]]), "{ids}"); + assert_eq!(i["objects"]["gradients"].as_u64(), Some(1), "{ids}"); + } + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "gradients"})).unwrap(); + assert_eq!((i["sections"][0]["rows"].clone(), i["gradientNames"].clone()), (json!([["Type", "Ramp"]]), json!(["Ramp"]))); + let text = s.execute("document.info", &json!({"category": "gradients", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!( + text.contains("\nGRADIENT OBJECTS\nBoth: Ramp, Linear gradient\n: Radial gradient\n: Freeform gradient\nType: Ramp\nBar: Ramp\n"), + "{text}" + ); + assert!(!text.contains("Spare"), "{text}"); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index e15258bec6..aeecc9fcc2 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -393,7 +393,7 @@ fn the_document_info_report_has_every_category() { "BRUSHES", "SPOT COLORS", "PATTERN OBJECTS", - "GRADIENT SWATCHES", + "GRADIENT OBJECTS", "SYMBOLS", "FONTS", "FONT DETAILS", diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index f6becb3911..4d6bf05ec1 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -1,6 +1,6 @@ //! Document Info panel: the document's setup and what it contains (objects by kind, graphic -//! styles, brushes, spot colors, patterns, gradients, symbols, fonts and their files, linked and embedded -//! images, repeat objects), for the whole document or the selection only. The flyout picks one category (or all) +//! styles, brushes, spot colors, objects painted with patterns and with gradients, symbols, fonts and their files, +//! linked and embedded images, repeat objects), for the whole document or the selection only. The flyout picks one category (or all) //! and saves the text report (`docInfo.save`, the report File → Package writes too). use egui::Ui; @@ -191,9 +191,20 @@ mod tests { menu_text.contains("Font Details") && menu_text.contains("Brushes") && menu_text.contains("Repeat Objects") + && menu_text.contains("Gradient Objects") && menu_text.contains("Save…"), "{menu_text}" ); + // Gradient Objects lists the objects painted with a gradient by name. + let r = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + let ramp = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}); + app.session.execute("paint.setFill", &json!({"ids": [r], "gradient": ramp})).unwrap(); + app.session.execute("layer.setProps", &json!({"id": r, "name": "Glow"})).unwrap(); + let gradients = crate::tests_labels::painted_text(&mut app, |app, ui| { + set_pstate(ui.ctx(), "docinfo-category", "gradients".to_string()); + show(app, ui); + }); + assert!(gradients.contains("Gradient Objects (1)") && gradients.contains("Glow") && gradients.contains("Radial gradient"), "{gradients}"); let filtered = crate::tests_labels::painted_text(&mut app, |app, ui| { set_pstate(ui.ctx(), "docinfo-category", "fonts".to_string()); show(app, ui); @@ -210,6 +221,7 @@ mod tests { assert_eq!(r["path"], "/tmp/info.txt"); let report = String::from_utf8(written.borrow().clone()).unwrap(); assert!(report.starts_with("Document Info: ") && report.contains("\nFONT DETAILS\n") && report.contains("\nREPEAT OBJECTS\n"), "{report}"); + assert!(report.contains("\nGRADIENT OBJECTS\nGlow: Radial gradient\n"), "{report}"); } /// The report the panel menu's Save… writes with category `category` shown ("" for All diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..9604427327 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5751,7 +5751,11 @@ whose drawn characters use a pattern included, hidden fills and strokes, strokes member's own fills and strokes, an image's fills and strokes that no Convert to Shape or Outline Object effect gives a shape, and characters that draw nothing (spaces and line breaks without an underline or strikethrough) left out, with the patterns it uses, which the patterns category lists as Pattern Objects and `objects.patterns` counts; with `selectionOnly`, those in the selection), -`patternNames` (the patterns those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left +`patternNames` (the patterns those objects use), `gradientObjects` (`[{id, name, gradients}]`: every object filled or +stroked with a gradient, by the same rules as `patternObjects`, with the gradients it uses: the name of the gradient +swatch a gradient is linked to, else `Linear gradient`, `Radial gradient` or `Freeform gradient`; the gradients category +lists them as Gradient Objects and `objects.gradients` counts them; gradient swatches no art uses are not listed; with +`selectionOnly`, those in the selection), `gradientNames` (the gradients those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left out, which objects lists as Path Length in the document's units, say for a cut line or an LED strip along the letters); `format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?, selectionOnly?, category?}`: only `category` when given, else every category) and the package report write. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..fdaf038e16 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1310,8 +1310,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). + Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine). Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. - Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes. With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). From afe2a71e59a3d09942dcb6fa40241e2cb8ada9ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:03:07 -0800 Subject: [PATCH 51/83] Tests: the empty-params command sweeps leave plug-in caches alone every_command_survives_empty_params and read_only_commands_read_the_instance_of_a_picked_object run every command with {}. That includes plugin.clearCache, which without an id empties the cache of every plug-in installed in the process. When either sweep ran during plugin_storage_is_capped_and_survives_a_damaged_settings_file (called from plugins_keep_settings_and_a_cache_of_their_own_in_the_store), it deleted that test's cache entries Tile and tile. The test's st.clear_cache() == MAX_CACHE_BYTES + 3 assertion after the MAX_CACHE_SCAN folders then failed with 536870912 bytes freed. Both sweeps now leave plugin.clearCache out. tests/plugin_clear_cache.rs already runs it with {} in a test binary of its own. A new test runs the other plug-in commands with {} in the selection states that every_command_survives_empty_params uses, and checks that an installed plug-in's cache keeps its 3 bytes. --- crates/engine/src/lib.rs | 9 ++++++ crates/engine/src/tests.rs | 2 +- crates/engine/src/tests/dynamic_symbols.rs | 2 +- crates/engine/src/tests/plugins.rs | 34 ++++++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..c3eeaa2777 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -347,6 +347,15 @@ pub(crate) fn adding_fonts() -> std::sync::MutexGuard<'static, ()> { FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } +/// Whether the tests that run every command with `{}` run command `id`. `plugin.clearCache` is +/// left out: without an id it empties the cache of every plug-in installed in the process, which +/// includes the caches that the plug-in storage tests check while they run. +/// `tests/plugin_clear_cache.rs` runs it with `{}` in a test binary of its own. +#[cfg(test)] +pub(crate) fn runs_with_empty_params(id: &str) -> bool { + id != "plugin.clearCache" +} + #[cfg(test)] use tests::{ adjust as tests_adjust, clip as tests_clip, colormgmt as tests_colormgmt, expand as tests_expand, large_edits as tests_large_edits, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ca0501c8f7..2cf0031a34 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3160,7 +3160,7 @@ fn every_command_survives_empty_params() { // The view toggles among them flip the process-wide proof view. let _proof_view = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); for sel in [false, true] { - for c in command_specs() { + for c in command_specs().iter().filter(|c| crate::runs_with_empty_params(c.id)) { let mut s = session(); let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); rect(&mut s, 20.0, 0.0, 10.0, 10.0); diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index f4ae97bb14..278d979ef5 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -1309,7 +1309,7 @@ fn a_lent_selection_tool_moves_the_instance_of_a_picked_object() { #[test] fn read_only_commands_read_the_instance_of_a_picked_object() { let mut failures = Vec::new(); - for spec in crate::cmd::command_specs().iter().filter(|c| !c.journal) { + for spec in crate::cmd::command_specs().iter().filter(|c| !c.journal && crate::runs_with_empty_params(c.id)) { let mut s = session(); let inst = pair(&mut s, "dynamic"); s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a4be6e554f..dc52243e3a 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -3242,6 +3242,40 @@ fn removing_an_id_that_is_not_installed_deletes_its_storage() { assert!(s.execute("plugin.remove", &json!({"id": id})).is_err(), "nothing left to remove"); } +/// The `plugin.*` commands that the tests running every command with `{}` run +/// ([`crate::runs_with_empty_params`]) leave the cache of an installed plug-in as it is, so +/// those tests can run beside the ones here that check a cache. Each command runs with `{}` in a +/// document with two rectangles, one or both selected, as `every_command_survives_empty_params` +/// runs it. +#[test] +fn plugin_commands_run_with_empty_params_keep_other_plugins_caches() { + let folder = crate::tests_links::Folder::new("plugin-storage-empty-params"); + let id = "test.engine.storageemptyparams"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + let mut ran = vec![]; + for all in [false, true] { + for c in crate::cmd::command_specs().iter().filter(|c| c.id.starts_with("plugin.") && crate::runs_with_empty_params(c.id)) { + let mut other = session(); + let a = rect(&mut other, 10.0, "#ff0000"); + rect(&mut other, 40.0, "#0000ff"); + if all { + run(&mut other, "select.all", json!({})); + } else { + run(&mut other, "select.set", json!({"ids": [a.0]})); + } + let _ = other.execute(c.id, &json!({})); + ran.push(c.id); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3), "after {}", c.id); + } + } + assert!(ran.contains(&"plugin.list"), "{ran:?}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// The settings file and the cache are checked: a file that doesn't read is moved aside and the /// settings start empty; a write past a cap fails and changes nothing; cache entries whose names /// differ only in case are two files; a removed plug-in's storage takes no more writes. From e6add18663a01ce563f19e2698e6084eff6840b4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:02:23 -0800 Subject: [PATCH 52/83] Fix #6634: Pen closing drag no longer gives the first anchor symmetric handles Closing a path by dragging on its first anchor made that anchor smooth with symmetric handles, so the first segment bent along with the closing one. The drag now sets the first anchor's incoming handle to the mirror of the pointer. Its outgoing handle stays where it is on a corner, so the first segment keeps its shape, and on a smooth anchor it turns to stay in line at its own length. Alt makes the anchor a corner and leaves the outgoing handle where it was when Alt went down. A click on the first anchor closes the path with the incoming handle retracted and the anchor a corner, so the closing segment comes in straight. The Smart Pen's closing drag follows the same rule. path.close's `in` now places the handle as path.setHandle does, and a new `out` param places the outgoing handle and makes the anchor a corner. --- crates/engine/src/cmd/addons/pen/tests.rs | 51 ++++++++++ crates/engine/src/cmd/path.rs | 26 +++-- crates/engine/src/tests/draw2.rs | 110 ++++++++++++++++++++- crates/tools/src/addons/smart_pen.rs | 19 ++-- crates/tools/src/addons/smart_pen/tests.rs | 8 +- crates/tools/src/pen.rs | 64 +++++++++--- crates/tools/src/pen/tests.rs | 73 +++++++++----- docs/mcp.md | 11 ++- docs/target-app-parity.md | 2 +- 9 files changed, 305 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/cmd/addons/pen/tests.rs b/crates/engine/src/cmd/addons/pen/tests.rs index 1bb9f3345e..b314b8db32 100644 --- a/crates/engine/src/cmd/addons/pen/tests.rs +++ b/crates/engine/src/cmd/addons/pen/tests.rs @@ -285,3 +285,54 @@ fn the_tool_draws_a_path_one_step_per_anchor_and_closes_it() { assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3); assert_eq!(steps(&s), n + 8); } + +/// #6634: the tool's closing drag on the first anchor sets that anchor's incoming handle to the +/// mirror of the pointer and does not give it symmetric handles: a corner keeps its outgoing +/// handle, a smooth anchor turns it to stay in line at its own length, and Alt pressed mid-drag +/// keeps it where the drag had turned it. +#[test] +fn the_tools_closing_drag_does_not_make_the_first_anchor_symmetric() { + // An open path whose first anchor, at (50, 50), has the handles `first`, drawn on from its last + // anchor; the closing gesture is then up to the caller. + let draw = |first: Value| { + let mut s = session(); + let mut a = json!({"x": 50, "y": 50}); + a.as_object_mut().unwrap().extend(first.as_object().unwrap().clone()); + let anchors = json!([a, {"x": 150, "y": 50}, {"x": 150, "y": 150}]); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap()); + s.select_tool("smartPen", view()).unwrap(); + gesture(&mut s, &[(150.0, 150.0)], Mods::default()); + (s, id) + }; + let first = |s: &Session, id: NodeId| { + let sp = &path(s, id).subpaths[0]; + assert!(sp.closed, "{sp:?}"); + sp.anchors[0] + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + + // A corner: the first segment keeps its shape. + let (mut s, id) = draw(json!({"out": [80, 30]})); + gesture(&mut s, &[(50.0, 50.0), (50.0, 100.0)], Mods::default()); + let a = first(&s, id); + assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(50.0, 0.0), Point::new(80.0, 30.0), AnchorKind::Corner), "{a:?}"); + + // A smooth anchor: the outgoing handle turns opposite the incoming one, keeping its length 30. + let (mut s, id) = draw(json!({"in": [20, 50], "out": [80, 50]})); + assert_eq!(path(&s, id).subpaths[0].anchors[0].kind, AnchorKind::Smooth); + gesture(&mut s, &[(50.0, 50.0), (50.0, 100.0)], Mods::default()); + let a = first(&s, id); + assert_eq!((a.h_in, a.kind), (Point::new(50.0, 0.0), AnchorKind::Smooth), "{a:?}"); + assert!(near(a.h_out, Point::new(50.0, 80.0)), "{a:?}"); + + // Alt pressed mid-drag: a corner, its outgoing handle where the drag had turned it. + let (mut s, id) = draw(json!({"in": [20, 50], "out": [80, 50]})); + let alt = Mods { alt: true, ..Mods::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), view()).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 50.0, 100.0), view()).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 80.0, 100.0).with_mods(alt), view()).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 100.0).with_mods(alt), view()).unwrap(); + let a = first(&s, id); + assert_eq!((a.h_in, a.kind), (Point::new(20.0, 0.0), AnchorKind::Corner), "{a:?}"); + assert!(near(a.h_out, Point::new(50.0, 80.0)), "{a:?}"); +} diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 712ca8a179..91da35e51b 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Close Path", [], None, - "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor), independent?: bool}", + "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}", has_doc, close ), @@ -202,14 +202,18 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { ok() } -/// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor. +/// Close the active open subpath of `id`, optionally setting the handles of the first anchor. The +/// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only +/// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape. fn close(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); - let h_in = match p.get("in").filter(|h| !h.is_null()) { - None => None, - Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?), + let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) { + None => Ok(None), + Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))), }; + let h_in = handle("in")?; + let h_out = handle("out")?; let independent = switch_or(p, "independent", false, "path.close")?; s.edit("Close Path", |d, sel| { let path = path_mut(d, id)?; @@ -218,11 +222,13 @@ fn close(s: &mut Session, p: &Value) -> Result { None => path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?, }; sp.closed = true; - if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) { - first.h_in = h; - if !independent { - first.h_out = first.p - (h - first.p); - first.kind = AnchorKind::Smooth; + if let Some(first) = sp.anchors.first_mut() { + if let Some(h) = h_in { + first.set_handle(false, h, independent); + } + if let Some(h) = h_out { + first.h_out = h; + first.kind = AnchorKind::Corner; } } let top = compound_or_self(d, id); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index fcf9b63636..ea10df64b5 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -3146,7 +3146,7 @@ fn shift_reshapes_the_closing_segment_of_a_rotated_square_and_undoes() { fn pen_drag_from_the_first_anchor_closes_the_path() { // #501: closing a path by dragging out of its first anchor flickered between a close and a new // path, and released at the wrong moment left the shape open. The press decides: the drag - // shapes the closing curve, symmetric, and the release closes the path wherever it ends. + // shapes the closing curve, and the release closes the path wherever it ends. let none = Mods::default(); let v = view(); let mut s = session(); @@ -3167,13 +3167,119 @@ fn pen_drag_from_the_first_anchor_closes_the_path() { let sp = &path(&s, id).subpaths[0]; assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}"); let a = sp.anchors[0]; - assert_eq!((a.h_in, a.h_out), (Point::new(40.0, 348.0), Point::new(160.0, 252.0)), "symmetric"); + assert_eq!((a.h_in, a.h_out), (Point::new(40.0, 348.0), Point::new(100.0, 300.0)), "only the closing segment curves"); assert_eq!(paths(&s).len(), 1); // The next click starts a new path. gesture(&mut s, &[(500.0, 500.0)], none); assert_eq!(paths(&s).len(), 2); } +/// #6634: closing a path by dragging on its first anchor does not give that anchor symmetric +/// handles. Its incoming handle mirrors the pointer; its outgoing handle stays where it is on a +/// corner, so the first segment keeps its shape, and on a smooth anchor turns to stay in line at +/// its own length. Alt keeps the outgoing handle where it was when Alt went down, Shift keeps the +/// drag to 45° steps, and a click leaves the anchor a corner with the closing segment straight +/// into it. Each close is one undo step. +#[test] +fn pen_closing_drag_does_not_make_the_first_anchor_symmetric() { + use vectorcraft_geom::AnchorKind; + let (none, alt, shift) = (Mods::default(), Mods { alt: true, ..Mods::default() }, Mods { shift: true, ..Mods::default() }); + let v = view(); + // Draws a path from (100, 300), dragged out to `start` when given (a smooth anchor), through + // (200, 200) and (300, 300), and returns it with its first anchor as drawn. + let draw = |s: &mut Session, start: Option<(f64, f64)>| { + s.select_tool("pen", v).unwrap(); + let first = match start { + Some(out) => vec![(100.0, 300.0), out], + None => vec![(100.0, 300.0)], + }; + gesture(s, &first, none); + gesture(s, &[(200.0, 200.0)], none); + gesture(s, &[(300.0, 300.0)], none); + let id = s.doc().unwrap().selection.objects[0]; + let anchor = path(s, id).subpaths[0].anchors[0]; + (id, anchor) + }; + let first = |s: &Session, id: NodeId| { + let sp = &path(s, id).subpaths[0]; + assert!(sp.closed, "{sp:?}"); + sp.anchors[0] + }; + + // A corner: the first segment stays straight however the drag turns. + let mut s = session(); + let (id, before) = draw(&mut s, None); + assert_eq!(gesture(&mut s, &[(100.0, 300.0), (80.0, 250.0), (60.0, 200.0)], none), 1, "one undo step"); + let a = first(&s, id); + assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(140.0, 400.0), before.h_out, AnchorKind::Corner), "{a:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!path(&s, id).subpaths[0].closed, "the undo opens the path again"); + + // A smooth anchor: the outgoing handle turns opposite the incoming one, keeping its length 50. + let mut s = session(); + let (id, before) = draw(&mut s, Some((150.0, 300.0))); + assert_eq!((before.h_out, before.kind), (Point::new(150.0, 300.0), AnchorKind::Smooth)); + gesture(&mut s, &[(100.0, 300.0), (100.0, 250.0), (100.0, 200.0)], none); + let a = first(&s, id); + assert_eq!((a.h_in, a.kind), (Point::new(100.0, 400.0), AnchorKind::Smooth), "{a:?}"); + assert!(near(a.h_out, Point::new(100.0, 250.0)), "in line, at its own length: {a:?}"); + + // Alt held from the press: a corner, its outgoing handle where it was. + let mut s = session(); + let (id, before) = draw(&mut s, Some((150.0, 300.0))); + gesture(&mut s, &[(100.0, 300.0), (100.0, 250.0), (100.0, 200.0)], alt); + let a = first(&s, id); + assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(100.0, 400.0), before.h_out, AnchorKind::Corner), "{a:?}"); + + // Alt pressed mid-drag: the outgoing handle stays where the drag had turned it. + let mut s = session(); + let (id, _) = draw(&mut s, Some((150.0, 300.0))); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 100.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 130.0, 200.0).with_mods(alt), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 130.0, 200.0).with_mods(alt), v).unwrap(); + let a = first(&s, id); + assert_eq!((a.h_in, a.kind), (Point::new(70.0, 400.0), AnchorKind::Corner), "{a:?}"); + assert!(near(a.h_out, Point::new(100.0, 250.0)), "{a:?}"); + + // Shift: the drag keeps to 45° steps (straight up here), at the pointer's distance from the + // anchor, mirrored into the incoming handle. + let mut s = session(); + let (id, before) = draw(&mut s, None); + gesture(&mut s, &[(100.0, 300.0), (95.0, 250.0), (90.0, 200.0)], shift); + let a = first(&s, id); + assert!(near(a.h_in, Point::new(100.0, 300.0 + 10f64.hypot(100.0))) && a.h_out == before.h_out, "{a:?}"); + + // A click: the closing segment comes straight in, and a smooth anchor keeps its outgoing + // handle and becomes a corner. + let mut s = session(); + let (id, before) = draw(&mut s, Some((150.0, 300.0))); + assert_eq!(gesture(&mut s, &[(100.0, 300.0)], none), 1); + let a = first(&s, id); + assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(100.0, 300.0), before.h_out, AnchorKind::Corner), "{a:?}"); +} + +/// Close Path's `in` moves the first anchor's incoming handle as Move Direction Handle does, and +/// `out` places its outgoing handle, making it a corner; `out` that is not [x, y] is refused. +#[test] +fn close_path_places_the_first_anchors_handles() { + use vectorcraft_geom::AnchorKind; + let mut s = session(); + let id = curve(&mut s); + let a = path(&s, id).subpaths[0].anchors[0]; + assert!(a.has_out() && a.kind == AnchorKind::Corner, "{a:?}"); + for h in [json!("1, 2"), json!([1]), json!([1, "2"])] { + refuses_with(&mut s, "path.close", &json!({"id": id.0, "out": h}), "`out` must be [x, y]"); + } + s.execute("path.close", &json!({"id": id.0, "in": [60.0, 140.0]})).unwrap(); + let b = path(&s, id).subpaths[0].anchors[0]; + assert_eq!((b.h_in, b.h_out, b.kind), (Point::new(60.0, 140.0), a.h_out, AnchorKind::Corner), "a corner keeps its outgoing handle"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("path.close", &json!({"id": id.0, "in": [60.0, 140.0], "out": [120.0, 60.0]})).unwrap(); + let b = path(&s, id).subpaths[0].anchors[0]; + assert_eq!((b.h_in, b.h_out, b.kind), (Point::new(60.0, 140.0), Point::new(120.0, 60.0), AnchorKind::Corner)); +} + #[test] fn pen_with_alt_converts_the_anchors_of_the_path_being_drawn() { // #504: while drawing, Alt-click on a smooth anchor of the path makes it a corner and Alt-drag diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index 68c19cc676..ee7440dde6 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -6,7 +6,8 @@ //! tangent); a drag makes a smooth anchor (Alt held breaks the outgoing handle off, Shift keeps it //! to 45° steps, Space moves the anchor); Tab switches the segment into the anchor being placed, or //! the last one drawn, between straight and curved. A rubber band shows the next segment. A click -//! on the first anchor closes the path, on an end of another open path joins the two, and away +//! on the first anchor closes the path (a drag from it shapes the closing segment as the Pen's +//! closing drag does), on an end of another open path joins the two, and away //! from paths, with no path being drawn, starts a new one. Enter or Esc ends the path. //! //! Editing the shown paths' points: a click selects a point (an end of an open path is then drawn @@ -30,7 +31,7 @@ use vectorcraft_pathops::addons::pen::{leave_handle, straighten_segment}; use crate::direct::hit_handle; use crate::draw2::{anchor_in, hit_segment, segment_in, shown_paths}; use crate::guides::{DrawSnap, Leave}; -use crate::pen::{active_path, continue_from, open_end_at}; +use crate::pen::{active_path, close_drag, continue_from, first_anchor, open_end_at}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// The tool's id. @@ -82,11 +83,13 @@ struct Place { #[derive(Clone, Copy, Debug, PartialEq)] enum Target { Place(Place), - /// Closing the path being drawn on its first anchor, at `first`. + /// Closing the path being drawn on its first anchor, at `first`; `anchor` holds that anchor as + /// the press found it and its outgoing handle as the drag last left it. Close { id: NodeId, si: usize, first: Point, + anchor: Option<(Anchor, Point)>, }, /// An anchor of a shown path at `p`; `n` anchors in its subpath, `closed` or not. Point { @@ -285,7 +288,8 @@ impl SmartPenTool { let press = |what| Press { at: ev.pos, mods: ev.mods, dragged: false, begun: false, what }; match self.hit(cx, ev.pos) { Hit::Close(id, si, first) => { - self.press = Some(Press { begun: true, ..press(Target::Close { id, si, first }) }); + let anchor = first_anchor(cx, id, si).map(|a| (a, a.h_out)); + self.press = Some(Press { begun: true, ..press(Target::Close { id, si, first, anchor }) }); let seg = if self.curved { "curved" } else { "straight" }; vec![ Action::Begin("Close Path".into()), @@ -362,10 +366,11 @@ impl SmartPenTool { pr.what = Target::Place(pl); ("", self.place_preview(&pl, true)) } - Target::Close { id, si, first } => { + Target::Close { id, si, first, mut anchor } => { let out = Self::handle_to(cx, first, ev.pos, first, shift, false); - let h_in = first - (out - first); - ("", Action::Preview("path.close".into(), json!({"id": id.0, "subpath": si, "in": xy(h_in), "independent": alt}))) + let act = close_drag(id, Some(si), first - (out - first), alt, anchor.as_mut()); + pr.what = Target::Close { id, si, first, anchor }; + ("", act) } Target::Point { id, si, ai, p, .. } if pr.mods.alt => { let h = Self::handle_to(cx, p, ev.pos, p, shift, false); diff --git a/crates/tools/src/addons/smart_pen/tests.rs b/crates/tools/src/addons/smart_pen/tests.rs index 0f6bd5c912..a7f792c22f 100644 --- a/crates/tools/src/addons/smart_pen/tests.rs +++ b/crates/tools/src/addons/smart_pen/tests.rs @@ -157,7 +157,13 @@ fn a_press_on_the_first_anchor_closes_the_path() { // A drag shapes the closing curve. let (c, params) = preview(&t.pointer(&cx, &ev(PointerKind::Drag, 130.0, 100.0, NO))); assert_eq!((c.as_str(), params["in"].clone()), ("path.close", json!([70.0, 100.0]))); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 130.0, 100.0, NO)), vec![Action::Commit]); + // Alt pressed mid-drag makes the anchor a corner and keeps its outgoing handle where it was. + let (_, params) = preview(&t.pointer(&cx, &ev(PointerKind::Drag, 130.0, 120.0, ALT))); + assert_eq!( + (params["in"].clone(), params["out"].clone(), params["independent"].clone()), + (json!([70.0, 80.0]), json!([100.0, 100.0]), json!(true)) + ); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 130.0, 120.0, ALT)), vec![Action::Commit]); assert!(!t.drawing, "the path is finished"); } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index b70a160d8a..31aabfb38f 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -3,7 +3,11 @@ //! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt breaks //! them: the incoming handle stays where it was when Alt went down and only the outgoing one //! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°. -//! A press on the first anchor closes the path, the drag shaping the closing curve. Clicking the +//! A press on the first anchor closes the path, the drag shaping the closing curve: the anchor's +//! incoming handle mirrors the pointer (a click leaves it retracted and the anchor a corner, so the +//! closing segment comes in straight). Its outgoing handle stays where it is on a corner, so the first segment keeps its +//! shape, and on a smooth anchor turns to stay in line at its own length; Alt makes the anchor a +//! corner, leaving the outgoing handle where it was when Alt went down. Clicking the //! last one retracts its outgoing handle, so the next segment leaves it as a corner; dragging from //! it pulls a new one out on its own. //! Alt held over a handle end, an anchor or a segment of a selected path (the one being drawn too) @@ -27,7 +31,7 @@ use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind}; -use vectorcraft_geom::{BezPath, Point, Vec2}; +use vectorcraft_geom::{Anchor, BezPath, Point, Vec2}; use crate::direct::hit_handle; use crate::draw2::{AnchorTool, editable_paths, shown_paths}; @@ -47,6 +51,9 @@ pub struct PenTool { hover: Option, /// The incoming handle of the anchor being dragged out, as last previewed. in_h: Point, + /// The first anchor, as it was when a press on it began closing the path, and its outgoing + /// handle as the closing drag last left it: Alt pressed mid-drag keeps the handle there. + first: Option<(Anchor, Point)>, /// The pointer at the last button-down or drag: Space held moves the anchor as far as it moves. last: Point, /// Where the button went down: the drag threshold is measured from it. @@ -289,9 +296,7 @@ impl Tool for PenTool { // Pick at the raw pointer before snapping or constraining a new point. // A one-anchor path doesn't close on itself: its anchor is the last one too. if picked == Some(Endpoint::First) { - self.drag = Some(Place { at: first, path: Some(id), subpath: Some(si), closing: true }); - let params = json!({"id": id.0, "subpath": si}); - return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)]; + return self.begin_close(cx, id, si, first); } // Alt uses Anchor Point behavior at the terminal anchor too. if let Some(acts) = self.alt_convert(cx, ev) { @@ -307,9 +312,7 @@ impl Tool for PenTool { if raw_other.is_none() && p != ev.pos { let snapped = pick_endpoint(closing_end, last, p, tol, Endpoint::First); if snapped == Some(Endpoint::First) { - self.drag = Some(Place { at: first, path: Some(id), subpath: Some(si), closing: true }); - let params = json!({"id": id.0, "subpath": si}); - return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)]; + return self.begin_close(cx, id, si, first); } if snapped == Some(Endpoint::Last) && let Some((si, ai)) = end @@ -410,11 +413,7 @@ impl Tool for PenTool { )]; }; if closing { - let mut params = json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}); - if let Some(si) = subpath { - params["subpath"] = json!(si); - } - return vec![Action::Preview("path.close".into(), params)]; + return vec![close_drag(id, subpath, a - (out_h - a), alt, self.first.as_mut())]; } // Alt pressed mid-drag keeps the curve already shaped into the anchor: only the // outgoing handle follows. Held from the start, the incoming handle stays retracted. @@ -436,6 +435,7 @@ impl Tool for PenTool { } let Some(place) = self.drag.take() else { return vec![] }; if place.closing { + self.first = None; self.stop(); } vec![Action::Commit] @@ -453,6 +453,7 @@ impl Tool for PenTool { /// close it was making: the path being drawn goes on with the next click. fn drop_press(&mut self, _cx: &ToolContext) -> Vec { self.drag = None; + self.first = None; self.handle = None; self.convert = None; self.snap.clear(); @@ -464,6 +465,7 @@ impl Tool for PenTool { self.stop(); self.snap.clear(); let placing = self.drag.take().is_some(); + self.first = None; let handling = self.handle.take().is_some(); // Finish the preview as it stands; clearing state alone leaves its transaction open. let mut acts = self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default(); @@ -577,6 +579,16 @@ impl PenTool { (self.drawing, self.path, self.subpath) = (false, None, None); } + /// A press on the first anchor (at `at`) of subpath `si` of the path being drawn: close it, the + /// anchor a corner with its incoming handle retracted until a drag pulls it out, as a click on + /// the last anchor retracts its outgoing one ([`set_out_handle`]). + fn begin_close(&mut self, cx: &ToolContext, id: NodeId, si: usize, at: Point) -> Vec { + self.drag = Some(Place { at, path: Some(id), subpath: Some(si), closing: true }); + self.first = first_anchor(cx, id, si).map(|a| (a, a.h_out)); + let params = json!({"id": id.0, "subpath": si, "in": [at.x, at.y], "independent": true}); + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)] + } + /// Hand an Alt press on a selected path's handle, anchor or segment to the Anchor Point tool. fn alt_convert(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { if !alt_converts(cx, ev.pos, ev.mods) { @@ -592,6 +604,32 @@ impl PenTool { } } +/// The first anchor of subpath `si` of path `id`. +pub(crate) fn first_anchor(cx: &ToolContext, id: NodeId, si: usize) -> Option { + cx.doc.node(id)?.path_data()?.subpaths.get(si)?.anchors.first().copied() +} + +/// The preview of a closing drag that puts the first anchor's incoming handle at `in_h`. `first` +/// holds that anchor as it was when the press began closing the path, and its outgoing handle as +/// the drag last left it: a smooth anchor turns that handle to stay in line, and Alt (`alt`), which +/// makes the anchor a corner, keeps it there. +pub(crate) fn close_drag(id: NodeId, subpath: Option, in_h: Point, alt: bool, first: Option<&mut (Anchor, Point)>) -> Action { + let mut params = json!({"id": id.0, "in": [in_h.x, in_h.y], "independent": alt}); + if let Some((anchor, out)) = first { + if alt { + params["out"] = json!([out.x, out.y]); + } else { + let mut a = *anchor; + a.set_handle(false, in_h, false); + *out = a.h_out; + } + } + if let Some(si) = subpath { + params["subpath"] = json!(si); + } + Action::Preview("path.close".into(), params) +} + /// Go on drawing subpath `si` of open path `id` from its last anchor, or from its first /// (`at_first`, the subpath reversed so that it is the last): the path (its compound path) is /// selected. diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 340a2ea6ed..984f34646a 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -2,6 +2,13 @@ use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; +/// What a press on the first anchor, at `first`, of subpath 0 of `id` does: the path closes with +/// the anchor a corner with its incoming handle retracted. +fn close_press(id: NodeId, first: Point) -> Vec { + let params = json!({"id": id.0, "subpath": 0, "in": [first.x, first.y], "independent": true}); + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)] +} + #[test] fn press_inside_drag_threshold_places_a_corner() { let (d, s) = (vectorcraft_doc::Document::new(500.0, 500.0), Selection::default()); @@ -131,10 +138,7 @@ fn snapped_first_endpoint_closes_active_path_after_raw_miss() { assert!(open_end_at(&cx, raw, tol, Some((id, 0))).is_none()); assert!(open_end_at(&cx, first, tol, Some((id, 0))).is_none()); - assert_eq!( - pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] - ); + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), close_press(id, first)); let place = pen.drag.unwrap(); assert!(place.closing); assert_eq!((place.path, place.at), (Some(id), first)); @@ -349,10 +353,7 @@ fn terminal_anchor_conversion_precedes_grid_snapping() { assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenContinue); assert!(click(&mut t, &cx, 63.0, 303.0, Mods::default()).is_empty()); assert_eq!(t.active(&cx).map(|a| a.0), Some(id)); - assert_eq!( - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] - ); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)), close_press(id, Point::new(13.0, 303.0))); } #[test] @@ -401,7 +402,7 @@ fn overlapping_endpoint_tolerances_pick_the_nearest_end() { let cursor = if first_end { Cursor::PenClose } else { Cursor::PenConvert }; let actual_cursor = pen.cursor(&cx, at, mods); let expected = if first_end { - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] + close_press(id, first) } else { vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)] }; @@ -459,10 +460,7 @@ fn coincident_endpoints_keep_the_prior_tie_choice() { let cx = ToolContext { zoom, smart_guides: false, snap_to_point: false, ..cx(&d, &selection, &paint) }; let mut drawing = PenTool { drawing: true, ..PenTool::default() }; assert_eq!(drawing.cursor(&cx, at, Mods::default()), Cursor::PenClose); - assert_eq!( - click(&mut drawing, &cx, at.x, at.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] - ); + assert_eq!(click(&mut drawing, &cx, at.x, at.y, Mods::default()), close_press(id, at)); let mut resuming = PenTool::default(); assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue); assert!(click(&mut resuming, &cx, at.x, at.y, Mods::default()).is_empty()); @@ -516,10 +514,7 @@ fn enlarged_anchor_radius_is_used_for_endpoints_and_alt_conversion() { let mut closing = PenTool { drawing: true, ..PenTool::default() }; let first = Point::new(10.0 - 6.0 / zoom, 300.0); assert_eq!(closing.cursor(&cx, first, Mods::default()), Cursor::PenClose); - assert_eq!( - click(&mut closing, &cx, first.x, first.y, Mods::default()), - vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))] - ); + assert_eq!(click(&mut closing, &cx, first.x, first.y, Mods::default()), close_press(id, Point::new(10.0, 300.0))); let mut alt_pen = PenTool { drawing: true, ..PenTool::default() }; let alt = Mods { alt: true, ..Mods::default() }; assert!(alt_pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(alt)).is_empty()); @@ -577,7 +572,8 @@ fn ending_during_a_pen_gesture_commits_and_clears_it() { /// #501: a press on the first anchor closes the path, however far the drag then goes: the /// closing preview leaves the path closed (so not one the pen extends), and each move still -/// previews the close with symmetric handles (Alt breaks them), released as a close. +/// previews the close with the incoming handle mirroring the pointer (Alt keeps the outgoing one +/// where it is), released as a close. #[test] fn a_drag_from_the_first_anchor_always_closes() { let pts = [Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(200.0, 400.0)]; @@ -585,22 +581,53 @@ fn a_drag_from_the_first_anchor_always_closes() { let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0)); - assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0, "subpath": 0}))]); + assert_eq!(a, close_press(id, pts[0])); // What the tool sees once the preview applied: the path closed. d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; let c = cx(&d, &s, &p); - let close = |x: f64, y: f64, alt: bool| { - vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt, "subpath": 0}))] + let close = |x: f64, y: f64| { + vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": false, "subpath": 0}))] }; for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] { - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})"); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y), "at ({x}, {y})"); } let alt = Mods { alt: true, ..Mods::default() }; - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 250.0).with_mods(alt)), close(170.0, 250.0, true)); + let alt_close = json!({"id": id.0, "in": [30.0, 350.0], "out": [100.0, 300.0], "independent": true, "subpath": 0}); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 250.0).with_mods(alt)), + vec![Action::Preview("path.close".into(), alt_close)] + ); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 250.0)), vec![Action::Commit]); assert!(!t.busy() && t.active(&c).is_none(), "the path is done"); } +/// #6634: on a smooth first anchor the closing drag turns the outgoing handle in line with the +/// incoming one; Alt pressed mid-drag sends the outgoing handle where the drag had turned it. +#[test] +fn alt_mid_close_keeps_the_turned_outgoing_handle() { + use vectorcraft_geom::{Anchor, SubPath}; + let anchors = vec![Anchor::smooth(Point::new(100.0, 300.0), Point::new(150.0, 300.0)), Anchor::corner(Point::new(200.0, 200.0))]; + let (mut d, id, s) = drawing(SubPath { anchors, closed: false }); + let p = paint(); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), close_press(id, Point::new(100.0, 300.0))); + d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; + let c = cx(&d, &s, &p); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 100.0, 200.0)), + vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [100.0, 400.0], "independent": false, "subpath": 0}))] + ); + let alt = Mods { alt: true, ..Mods::default() }; + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 200.0).with_mods(alt)), + vec![Action::Preview( + "path.close".into(), + json!({"id": id.0, "in": [70.0, 400.0], "out": [100.0, 250.0], "independent": true, "subpath": 0}) + )] + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 200.0)), vec![Action::Commit]); +} + #[test] fn a_one_anchor_path_does_not_close_on_itself() { let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false)); diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..938ecce098 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3987,6 +3987,13 @@ an `anchor` or `segment` in the same way. The document does not change and no un 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, with the new anchor as an end of both that moves with them. Undo restores the path and the selection in one step. +`path.close {id, subpath?, in?, out?, independent?}` closes an open subpath (by default the last open one). `in` places +the first anchor's incoming handle as `path.setHandle` places a handle: a smooth anchor turns its outgoing handle to +stay in line and keeps that handle's length, and a corner keeps its outgoing handle where it is. With +`independent: true` the anchor becomes a corner first, so its outgoing handle does not move. `out`, placed after `in`, +is the first anchor's outgoing handle, and makes the anchor a corner. A Pen press on the first anchor sends `in` at +the anchor with `independent: true`. The Pen's and the Smart Pen's closing drags send the mirror of the pointer as +`in`, and once Alt is held they also send `out` at the outgoing handle's position when Alt went down. `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`. @@ -4035,8 +4042,8 @@ an `x` without a `y`, or a `y` without an `x`, with "x and y must be given toget `path.blob` refuse a point in `points` whose x or y is not a number, such as `["10", 10]` or a point written as a string, with "x and y must both be numbers". All of them except `path.curvature` refuse a pen pressure, tilt or rotation that is neither a number nor null with `` `pressure` must be a number, not "0.5" ``, naming the key -(`pressure`, `tiltX`, `tiltY` or `rotation`). `path.close` refuses an `in` that is not `[x, y]` -with `` `in` must be [x, y] ``. +(`pressure`, `tiltX`, `tiltY` or `rotation`). `path.close` refuses an `in` or `out` that is not `[x, y]` +with `` `in` must be [x, y] ``, naming the key. These commands refuse a string, a number or another value that is not true or false with `` `closed` must be true or false ``, naming the key; the document does not change and no undo step is recorded: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..296ba0cff0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -956,7 +956,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with (kept with the UI state; Copy never is), and its Copy button, left of Cancel, transforms a copy of each object as the [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html) help page has it; the Copy checkbox is gone (#1732; the remembered values follow a maintainer comment on the issue, not the help page). Its Scale section has the Relative and Absolute radio buttons the same page describes: percentages of each object's size, or the width and height it becomes (`object.transformEach` `relative`, which Transform Again repeats); Absolute measures each object's own box, and the scales act along its axes about that box's reference point, so a turned shape keeps its shape and objects of different sizes come out alike; the fields show the width and height the selected objects share and read "Mixed" when they differ or a side is 0 (a line's), each object measured by its own box, rotated with it, as the Transform panel and the Control bar show it (sizes they show alike count as the same), and a size typed into them sets every object to it (a side left Mixed keeps its size), our choices where the page leaves them open; a live Repeat scales its spacing by the geometric mean of the two factors, so its Absolute size is approximate. The link beside the two scales keeps them in proportion, as the shape dialogs' Width and Height link does (`__constrain`, kept with the dialog's values; where it sits is our choice, the public pages don't establish it). The panel lists Scale, Move and Rotate, then the Options in two columns (Transform Objects, Transform Patterns, Scale Strokes & Effects, Scale Corners; Reflect X, Reflect Y, Random), the Reference Point, and Preview at the left of the button row, level with Copy, Cancel and OK. Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). - - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). A drag's preview (the Pen's, the Anchor Point tool's, Direct Selection's handles and anchors) follows every position the pointer is seen at (#834): the canvas sends the tool a drag whenever the pointer's document position differs from the last one it sent, not only when egui's frame delta is non-zero (zero when a compositor coalesces motion and in the frame the pointer comes back into the window after leaving it), and a move that arrives in the release's own frame shapes the preview before the release commits it, once the press has dragged. With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). + - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve and the release closes the path wherever it ends (#501). The first anchor's incoming handle mirrors the pointer, and a click leaves it retracted and the anchor a corner. Its outgoing handle stays where it is on a corner, so the first segment keeps its shape, and on a smooth anchor it turns to stay in line at its own length; Alt makes the anchor a corner and leaves the outgoing handle where it was when Alt went down. The Smart Pen's closing drag does the same (fixes #6634; `path.close`'s `in` and `out`; behavior reference: [draw with the Pen, Curvature, or Pencil tool](https://web.archive.org/web/20220614224656/https://helpx.adobe.com/illustrator/using/drawing-pen-curvature-or-pencil.html), where a click or drag on the first anchor closes the path and Alt breaks the pairing of the closing anchor's handles; which handles the drag sets are our choices). Moving the closing anchor with Space held, which that page also describes, is still missing. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). A drag's preview (the Pen's, the Anchor Point tool's, Direct Selection's handles and anchors) follows every position the pointer is seen at (#834): the canvas sends the tool a drag whenever the pointer's document position differs from the last one it sent, not only when egui's frame delta is non-zero (zero when a compositor coalesces motion and in the frame the pointer comes back into the window after leaving it), and a move that arrives in the release's own frame shapes the preview before the release commits it, once the press has dragged. With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). - **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character. 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). From 019ff2b278881e76e804caa158d749ad9e30dd7f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:34:17 -0800 Subject: [PATCH 53/83] i18n: Hindi and Turkish messages keep the command ids they name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen @msg rows in hi.tsv and one in tr.tsv translated or dropped the command id the English names outside backticks. For example, "missing `id` (see plugin.list)" read "(information.list)" in Hindi, and flattener.presets.list became "düzleştirici.presets.list" in Turkish. The rows now keep each id as written. Several of the Hindi rows also said the opposite of the English ("a profile is called" for "no profile is called") and are corrected. A new test checks every catalog: each command id a status or error message names (dot-separated lowercase words outside backticks) appears unchanged in its translation. --- crates/ui-egui/src/i18n/hi.tsv | 34 ++++++++++---------- crates/ui-egui/src/i18n/tests.rs | 55 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 2 +- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..710767b0e1 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5577,7 +5577,7 @@ antiAlias Sharp शार्प @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` को {min} और {max} ({x}) के बीच पूरी संख्या होना चाहिए। @msg `{key}` must be a whole number of at least 0, not {n} `{key}` को कम से कम 0 की पूरी संख्या होना चाहिए, नहीं {n} @msg `{key}` must be an appearance item index (paint order) or null `{key}` एक उपस्थिति आइटम सूचकांक (पेंट ऑर्डर) या null होना चाहिए -@msg `{key}` must be an array of asset ids (see assets.list) `{key}` को परिसंपत्ति ids की एक सरणी होना चाहिए (देखें परिसंपत्तियों.list) +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` को परिसंपत्ति ids की एक सरणी होना चाहिए (assets.list देखें) @msg `{key}` must be an array of these names: {_1} `{key}` इन नामों की एक सरणी होना चाहिए: {_1} @msg `{key}` must be between {min} and {max} (got {x}) `{key}` को {min} और {max} ({x}) के बीच होना चाहिए। @msg `{key}` must be from {_1} to {shift_max} `{key}` को {_1} से {shift_max} होना चाहिए @@ -5649,7 +5649,7 @@ antiAlias Sharp शार्प @msg `artboards` must be a list of artboard indices `artboards` को आर्टबोर्ड इंडेक्स की सूची होना चाहिए @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all या 1, 3-5 जैसी सीमा दें, {v} नहीं @msg `aspect` must be a number (%) `aspect` को संख्या (%) होना चाहिए -@msg `asset` is the asset id (see assets.list) `asset` परिसंपत्ति आईडी है (aset.list देखें) +@msg `asset` is the asset id (see assets.list) `asset` परिसंपत्ति आईडी है (assets.list देखें) @msg `at` must be [x, y] `at` होना चाहिए [x, y] @msg `at` must be a byte offset `at` to be a byte ऑफसेट @msg `autoLeading` must be a number `autoLeading` को संख्या होना चाहिए @@ -6169,7 +6169,7 @@ antiAlias Sharp शार्प @msg closed must be a boolean closed: बंद होना चाहिए एक boolean @msg closing needs three anchors closing: बंद करने की जरूरत है तीन एंकर @msg collapsed must be true or false collapsed को true या false होना चाहिए -@msg color.profile: no RGB or CMYK profile is called “{profile}” Color.profile: कोई RGB या CMYK प्रोफ़ाइल को "{profile}" कहा जाता है। +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: "{profile}" नाम की कोई RGB या CMYK प्रोफ़ाइल नहीं है @msg colorMode must be rgb or cmyk, not `{m}` colorMode को rgb या cmyk होना चाहिए, `{m}` नहीं @msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel को {own} (डॉक्यूमेंट का मोड), grayscale या bitmap होना चाहिए @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}`, "#ff8800" जैसा रंग नहीं है @@ -6497,11 +6497,11 @@ antiAlias Sharp शार्प @msg highlight two or more rows in one layer or group एक लेयर या समूह में दो या अधिक पंक्तियों को हाइलाइट करें @msg highlight two or more rows to merge दो या अधिक पंक्तियों को मर्ज करने के लिए हाइलाइट करें @msg HTML Text applies to object slices of type only एचटीएमएल टेक्स्ट केवल टाइप के ऑब्जेक्ट स्लाइस पर लागू होता है -@msg icon docks a panel in the icon column: use window.panel.dock आइकन कॉलम में एक पैनल डॉक करता है: विंडो का उपयोग करें.पैनल.dock +@msg icon docks a panel in the icon column: use window.panel.dock icon पैनल को आइकन कॉलम में डॉक करता है: window.panel.dock का उपयोग करें @msg icon must be true or false icon को true या false होना चाहिए @msg icon only docks a panel in the icon column, with no anchor आइकन केवल आइकन कॉलम में एक पैनल को डॉक करता है, जिसमें कोई एंकर नहीं होता है @msg id {value} is a layer, not an object Id {value} एक लेयर है, कोई ऑब्जेक्ट नहीं है -@msg id {value} is a layer, not an object; move layers with layer.move Id {value} एक लेयर है, कोई ऑब्जेक्ट नहीं है; लेयर के साथ लेयर्स को स्थानांतरित करें। +@msg id {value} is a layer, not an object; move layers with layer.move id {value} एक लेयर है, ऑब्जेक्ट नहीं; लेयर्स को layer.move से स्थानांतरित करें @msg id must be a dimension id id: आईडी एक आयाम आईडी होना चाहिए @msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) ऑब्जेक्ट्स को XML ID से पहचानें: नाम एक वैध XML ID होना चाहिए (एक पत्र, अंडरस्कोर या कॉलोन के साथ शुरू; कोई स्थान नहीं) @msg ids must contain 1–10000 object ids ids में 1–10000 ऑब्जेक्ट ids होना चाहिए @@ -6610,7 +6610,7 @@ antiAlias Sharp शार्प @msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "auto", या "optical" होना चाहिए @msg keywords must be a list of words or a comma-separated string keywords: कीवर्ड शब्दों की एक सूची होना चाहिए या एक अल्पसंख्यक स्ट्रिंग होना चाहिए @msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords: कीवर्ड नियंत्रण वर्णों के बिना, अधिकांश {MAX_KEYWORD} वर्णों पर होना चाहिए -@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind: टाइप 1, 2 या 3 होना चाहिए, या प्रीसेट `name` देना चाहिए (see पर्सपेक्टिव.presets.list) +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind 1, 2 या 3 होना चाहिए, या प्रीसेट `name` दें (perspective.presets.list देखें) @msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind को graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle या paraStyle होना चाहिए @msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: नॉकआउट को "on", "off", "neutral" या एक बोल, न {v} होना चाहिए @msg lang must be one of {_1} lang: लैंग को {_1} में से एक होना चाहिए @@ -6675,7 +6675,7 @@ antiAlias Sharp शार्प @msg missing `gid` (a glyph id, or null for the default glyph) लापता `gid` (a glyph id, या डिफ़ॉल्ट glyph के लिए null) @msg missing `group` लापता `group` @msg missing `id` लापता `id` -@msg missing `id` (see plugin.list) अनुपलब्ध `id` (information.list) +@msg missing `id` (see plugin.list) अनुपलब्ध `id` (plugin.list देखें) @msg missing `id`: give one, or select one type object लापता `id`: एक देना, या एक प्रकार का ऑब्जेक्ट चुनें @msg missing `index` (a point index, or null to clear) लापता `index` (a बिंदु सूचकांक, या स्पष्ट करने के लिए null) @msg missing `index` (a stop index, or null to clear) लापता `index` (a स्टॉप इंडेक्स, या स्पष्ट करने के लिए null) @@ -6790,7 +6790,7 @@ antiAlias Sharp शार्प @msg no artboards named नामित कोई आर्टबोर्ड नहीं @msg no artwork कोई आर्टवर्क नहीं @msg no asset {id} कोई परिसंपत्ति {id} -@msg no asset {id} (see assets.list) कोई परिसंपत्ति {id} (Ession.list) नहीं है। +@msg no asset {id} (see assets.list) कोई परिसंपत्ति {id} नहीं है (assets.list देखें) @msg no assets to export निर्यात करने के लिए कोई संपत्ति नहीं @msg no assets yet: collect art for export first (assets.add) अभी तक कोई संपत्ति नहीं: पहले निर्यात के लिए कला इकट्ठा (assets.add) @msg no blend options कोई मिश्रण ऑप्शन्स नहीं @@ -6887,7 +6887,7 @@ antiAlias Sharp शार्प @msg no pattern named `{choice}` `{choice}` नाम का कोई पैटर्न नहीं @msg no pattern swatch `{name}` कोई पैटर्न नहीं `{name}` @msg no PDF preset named `{n}` कोई PDF प्रीसेट नहीं है जिसका नाम `{n}` है -@msg no perspective grid preset named `{b}` (see perspective.presets.list) कोई पर्सपेक्टिव ग्रिड प्रीसेट जिसका नाम `{b}` है (see पर्सपेक्टिव.presets.list) +@msg no perspective grid preset named `{b}` (see perspective.presets.list) `{b}` नाम का कोई पर्सपेक्टिव ग्रिड प्रीसेट नहीं है (perspective.presets.list देखें) @msg no perspective grid preset named `{n}` कोई पर्सपेक्टिव ग्रिड प्रीसेट जिसका नाम `{n}` है @msg no perspective grid preset named `{name}` कोई पर्सपेक्टिव ग्रिड प्रीसेट जिसका नाम `{name}` है @msg no plug-in {0} is installed कोई प्लग-इन {0} स्थापित नहीं है @@ -6940,7 +6940,7 @@ antiAlias Sharp शार्प @msg no selection bounds कोई सिलेक्शन सीमा नहीं @msg no series {i} कोई श्रृंखला {i} @msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again कोई समान ऑब्जेक्ट नहीं पाया (घूर्णित या परिलक्षित प्रतियां मिलान नहीं की जाती हैं); वैश्विक संपादन ऑप्शन्स बदलें और फिर से प्रयास करें -@msg no slice {n} in the image (see slice.list) इमेज में कोई स्लाइस {n} नहीं ( स्लाइसलिस्ट देखें) +@msg no slice {n} in the image (see slice.list) इमेज में कोई स्लाइस {n} नहीं है (slice.list देखें) @msg no slice selected सिलेक्टेड कोई स्लाइस नहीं @msg no source art कोई स्रोत कला @msg no stop {i} (the gradient has {n}) नहीं रोक {i} (ग्रेडेंट {n} है) @@ -6950,7 +6950,7 @@ antiAlias Sharp शार्प @msg no such action step ऐसी कोई कार्रवाई नहीं @msg no such anchor ऐसा कोई एंकर नहीं @msg no such artboard ऐसा कोई आर्टबोर्ड नहीं -@msg no such assets (see assets.list) ऐसी कोई संपत्ति नहीं (देखें परिसंपत्तियां)। +@msg no such assets (see assets.list) ऐसी कोई परिसंपत्ति नहीं है (assets.list देखें) @msg no such color ऐसा कोई रंग नहीं @msg no such document ऐसा कोई डॉक्यूमेंट नहीं @msg no such frame anchor, or the frame would reach past the canvas इस तरह के फ्रेम एंकर नहीं, या फ्रेम कैनवास तक पहुंच जाएगा @@ -7130,7 +7130,7 @@ antiAlias Sharp शार्प @msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first केवल इस Affinity फ़ाइल के एम्बेडेड प्रीव्यू को पढ़ा जा सकता है: फ़ाइल का उपयोग करें > इसे अपनी चेतावनी के साथ देखने के लिए ओपन करें, या पहले Affinity से SVG या PDF निर्यात करें @msg onto must name a floating panel onto: पर एक फ्लोटिंग पैनल का नाम होना चाहिए @msg onto must name a panel floating in another group onto: किसी अन्य समूह में फ्लोटिंग पैनल का नाम होना चाहिए -@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock ऊपर, नीचे, आइकन और एक स्तंभ स्थान खिड़की से संबंधित है। पैनल.फ्लोट और विंडो.पैनल.डॉक +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon और column स्थान window.panel.float और window.panel.dock के लिए हैं @msg onto, above, below, icon and a column place take no anchor, side, zone or before ऊपर, नीचे, आइकन और एक स्तंभ स्थान पर कोई एंकर, साइड, जोन या पहले नहीं होता है @msg onto, above, below, icon and a column place take no x, y, width or height ऊपर, नीचे, आइकन और एक स्तंभ स्थान पर कोई एक्स, वाई, चौड़ाई या ऊंचाई नहीं होती है @msg open a document to look for its linked files अपनी लिंक्ड फ़ाइलों को देखने के लिए एक डॉक्यूमेंट ओपन करें @@ -7144,10 +7144,10 @@ antiAlias Sharp शार्प @msg outlineText {v}: true or false outlineText {v}: true या false @msg output intent profile: {e} उत्पादन आशय प्रोफ़ाइल: {e} @msg output size ({what} is {len} bytes, limit {max}) उत्पादन का आकार ({what} {len} बाइट्स है, {max} को सीमित करें) -@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile उत्पादन: {_1} फ़ाइलें CMYK हैं, और "{name}" एक RGB प्रोफ़ाइल है -@msg output.destination: {e} उत्पादन.destination: {e} -@msg output.destination: no RGB or CMYK profile is called “{name}” आउटपुट: कोई RGB या CMYK प्रोफ़ाइल को "{name}" कहा जाता है। -@msg output.inks: no ink is set to print आउटपुट.ink: प्रिंट करने के लिए कोई स्याही सेट नहीं है +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} फ़ाइलें CMYK हैं, और "{name}" एक RGB प्रोफ़ाइल है +@msg output.destination: {e} output.destination: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: "{name}" नाम की कोई RGB या CMYK प्रोफ़ाइल नहीं है +@msg output.inks: no ink is set to print output.inks: प्रिंट करने के लिए कोई स्याही सेट नहीं है @msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: “{name}” ग्रे प्रोफ़ाइल है, जो {std} आउटपुट इंटेंट नहीं हो सकती @msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: {std} फ़ाइलें CMYK में प्रिंट होती हैं और “{name}” RGB प्रोफ़ाइल है @msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: “{name}” नाम की प्रोफ़ाइल नहीं है और {std} आउटपुट इंटेंट अपनी प्रोफ़ाइल एम्बेड करता है @@ -7483,7 +7483,7 @@ antiAlias Sharp शार्प @msg text.{key} `{v}`: use "{_1}" or "{_2}" Text.{key} `{v}`: "{_1}" या "{_2}" का उपयोग करें। @msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces को 2 से 100 के बीच पूर्णांक होना चाहिए @msg textureImage: {e} textureImage: {e} -@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: एक इमेज फ़ाइल के {path} या {name, dataBase64}, या इफ़ेक्ट के रूप में बनावट देते हैं। +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {path} या {name, dataBase64} दें, या effect.list द्वारा दिया गया टेक्सचर दें @msg that anchor can't slide: an end of an open path has nowhere to go कि एंकर स्लाइड नहीं कर सकते: एक खुले रास्ते का अंत कहीं नहीं जाना है @msg that document is no longer open यह डॉक्यूमेंट अब खुला नहीं है @msg that makes more than {MAX_PIECES} slices जो {MAX_PIECES} स्लाइस से अधिक बनाता है diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 79cbd7a648..baca06cacf 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2510,6 +2510,61 @@ fn translated_messages_keep_the_keys_and_values_their_english_names() { assert!(missing.is_empty(), "{} rows drop a key or value their English names:\n{}", missing.len(), missing.join("\n")); } +/// The command ids that `s` names outside its backticked spans, such as `assets.list` or +/// `window.panel.dock`: words of two or more dot-separated parts, each an ASCII letter or digit +/// run that starts with a lowercase letter. +fn command_ids(s: &str) -> Vec<&str> { + let mut ids = Vec::new(); + for part in s.split('`').step_by(2) { + for word in part.split(|c: char| !(c.is_ascii_alphanumeric() || c == '.' || c == '_')) { + let word = word.trim_end_matches('.'); + let segments: Vec<&str> = word.split('.').collect(); + let dotted = segments.len() > 1 + && segments + .iter() + .all(|seg| seg.chars().next().is_some_and(|c| c.is_ascii_lowercase()) && seg.chars().all(|c| c.is_ascii_alphanumeric())); + if dotted && word != "e.g" && word != "i.e" { + ids.push(word); + } + } + } + ids +} + +#[test] +fn command_ids_finds_dotted_ids_outside_backticks() { + assert_eq!(command_ids("no slice {n} in the image (see slice.list)"), ["slice.list"]); + assert_eq!(command_ids("use window.panel.dock."), ["window.panel.dock"]); + assert_eq!(command_ids("output.destination: {e}"), ["output.destination"]); + assert_eq!(command_ids("(assets.list.old देखें)"), ["assets.list.old"]); + assert_eq!(command_ids("see `plugin.list`, e.g. 1.5 or {name}.svg"), Vec::<&str>::new()); +} + +/// A translated status or error message keeps each command id its English text names without +/// backticks, spelled as the English spells it, since that is the id a script or the command +/// palette runs. +#[test] +fn translated_messages_keep_the_command_ids_their_english_names() { + let mut missing = Vec::new(); + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (ctx, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg" || ctx == "@msgplural") { + let plural = ctx == "@msgplural"; + let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() }; + let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] }; + for form in forms { + let kept: Vec<&str> = command_ids(form).into_iter().chain(backticked(form)).collect(); + for id in command_ids(english) { + if !kept.contains(&id) { + missing.push(format!("{}: {id} in {src:?} -> {form:?}", l.code)); + } + } + } + } + } + assert!(missing.is_empty(), "{} rows drop a command id their English names:\n{}", missing.len(), missing.join("\n")); +} + /// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists /// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = &[ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..a9736063fe 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4471,7 +4471,7 @@ axis Both Her İkisi @msg `prefs.import` runs on its own, not as a batch step `prefs.import` tek başına çalışır, toplu iş adımı olarak değil @msg unknown preset (one of {_1}) bilinmeyen ön ayar ({_1}'den biri) @msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) bilinmeyen ön ayar `{name}` (yüksek, orta, düşük veya kaydedilmiş bir ayar: bkz. flattener.presets.list) -@msg unknown preset `{name}` (see flattener.presets.list) bilinmeyen ön ayar `{name}` (bkz. düzleştirici.presets.list) +@msg unknown preset `{name}` (see flattener.presets.list) bilinmeyen ön ayar `{name}` (bkz. flattener.presets.list) @msg unknown preset `{name}` (see webExport.presets.list) bilinmeyen ön ayar `{name}` (bkz. webExport.presets.list) @msg unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets) bilinmeyen ön ayar `{name}`: yerleşik ya da kayıtlı bir ön ayar değil (bkz. imageTrace.presets) @msg unknown side `{other}` (left|right|top|bottom) bilinmeyen taraf `{other}` (sol|sağ|üst|alt) From b967408a09acdb7d15142bcfe7b46a338017336d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:39:28 -0800 Subject: [PATCH 54/83] Vector Halftone: a parameter of the wrong type is refused object.vectorHalftone read frequency, angle, invert, clip and keepOriginal with readers that fall back to the default for a value of the wrong type, so {"frequency": "60"} drew a 20 lpi screen. shape and mode given as a non-string also fell back to circle and mono. Each of these is now an error and the document is left as it was. A null value, color included, still stands for the default. --- crates/engine/src/cmd/halftone.rs | 14 +++++++------- crates/engine/src/tests/halftone.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs index 0c1d36d35d..b54e92932c 100644 --- a/crates/engine/src/cmd/halftone.rs +++ b/crates/engine/src/cmd/halftone.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::Node; use vectorcraft_render::effects::addons::halftone::{DotShape, Halftone, art_outline, dot_nodes}; use super::edit::selected_roots; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, color_value, f64_or, has_selection, str_param}; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, color_value, has_selection, number_or, switch_or, text_param}; pub fn specs() -> Vec { vec![cmd!( @@ -27,28 +27,28 @@ pub fn specs() -> Vec { fn halftone(s: &mut Session, p: &Value) -> Result { const C: &str = "object.vectorHalftone"; - let shape = match str_param(p, "shape") { + let shape = match text_param(p, "shape", C)? { Some(v) => DotShape::parse(v).ok_or_else(|| bad(C, format!("unknown shape `{v}` (circle|ellipse|square|diamond|line)")))?, None => DotShape::Circle, }; - let frequency = f64_or(p, "frequency", 20.0); + let frequency = number_or(p, "frequency", 20.0, C)?; if !(1.0..=300.0).contains(&frequency) { return Err(bad(C, "frequency must be between 1 and 300 lines per inch")); } - let angle = f64_or(p, "angle", 45.0); + let angle = number_or(p, "angle", 45.0, C)?; if !angle.is_finite() { return Err(bad(C, "invalid angle")); } - let mono = match str_param(p, "mode").unwrap_or("mono") { + let mono = match text_param(p, "mode", C)?.unwrap_or("mono") { "mono" => true, "cmyk" => false, other => return Err(bad(C, format!("unknown mode `{other}` (mono|cmyk)"))), }; let color = match p.get("color") { + None | Some(Value::Null) => Color::BLACK, Some(v) => color_value(v).ok_or_else(|| bad(C, "invalid color"))?, - None => Color::BLACK, }; - let (invert, clip, keep) = (bool_or(p, "invert", false), bool_or(p, "clip", true), bool_or(p, "keepOriginal", false)); + let (invert, clip, keep) = (switch_or(p, "invert", false, C)?, switch_or(p, "clip", true, C)?, switch_or(p, "keepOriginal", false, C)?); let ht = Halftone { shape, frequency, angle, mono, color, from_art: false, invert, clip }; let roots = selected_roots(s)?; diff --git a/crates/engine/src/tests/halftone.rs b/crates/engine/src/tests/halftone.rs index caef57ddb4..d836502baa 100644 --- a/crates/engine/src/tests/halftone.rs +++ b/crates/engine/src/tests/halftone.rs @@ -65,3 +65,29 @@ fn halftone_options() { assert!(s.execute("object.vectorHalftone", &bad).is_err(), "{bad}"); } } + +#[test] +fn halftone_refuses_a_param_of_the_wrong_type() { + for bad in [ + json!({"frequency": "60"}), + json!({"angle": "30"}), + json!({"shape": 1}), + json!({"mode": true}), + json!({"invert": "true"}), + json!({"clip": "false"}), + json!({"keepOriginal": 1}), + json!({"color": 0}), + ] { + let mut s = session(); + let id = red_rect(&mut s); + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("object.vectorHalftone", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().doc, before, "{bad} changed the document"); + assert!(s.doc().unwrap().doc.node(id).is_some()); + } + // Null stands for an omitted value. + let mut s = session(); + red_rect(&mut s); + let p = json!({"frequency": null, "angle": null, "shape": null, "mode": null, "color": null, "invert": null, "clip": null, "keepOriginal": null}); + assert!(s.execute("object.vectorHalftone", &p).is_ok()); +} From b33c004fb02cec0859648b6af2029daae329c6cf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:47:06 -0800 Subject: [PATCH 55/83] Tabs: indent drags on threaded frames follow the story's order and set a split paragraph once With the Selection tool on several frames of one threaded story, the Tabs panel listed the paragraphs to change in the order the frames were selected, and listed each frame's paragraphs separately. A marker drag over mixed indents then ran its per-paragraph steps out of story order, so a step that made text reflow could move a later step's paragraph, and a paragraph that runs on from one frame into the next got two steps. After Type tool Select All the split paragraph also got two. The panel now lists each paragraph of a story once, in story order, by its last piece with text in a selected frame. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets it. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/panels/tabs.rs | 87 +++++++++++++--- crates/ui-egui/src/panels/tabs/tests.rs | 133 ++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 206 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 57a99e433b..7e58a218a5 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -4,9 +4,11 @@ //! same distance. A stop placed or dragged on the ruler lands on the nearest whole point. With Snap //! to Unit on, or Shift held, it lands on the ruler's nearest tick. +use std::collections::HashSet; + use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind, ParaStyle, TabAlign, TabStop, TextKind}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject}; use super::{first_selected, pstate, set_pstate}; use crate::VectorcraftApp; @@ -64,40 +66,95 @@ struct IndentTarget { level: f64, } +fn indent_target(id: NodeId, text: &TextObject, i: usize, start: usize) -> IndentTarget { + let para = text.para_at(i); + let left = para.laid_out_left_indent(); + IndentTarget { id: id.0, start, left, first_line: para.first_line_indent, level: left - para.left_indent } +} + fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &mut Vec) { let NodeKind::Text(text) = &node.kind else { return }; let plain = text.plain_text(); let mut starts = vec![0]; starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); - targets.extend(range.filter_map(|i| { - let para = text.para_at(i); - let left = para.laid_out_left_indent(); - Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left, first_line: para.first_line_indent, level: left - para.left_indent }) - })); + targets.extend(range.filter_map(|i| Some(indent_target(node.id, text, i, *starts.get(i)?)))); } -/// Paragraphs touched by the Type selection (every paragraph of every frame after Select All in -/// threaded text), or all paragraphs of selected text objects. +/// One target for each paragraph of `story` (its frames in thread order), in story order. Where +/// one frame ends and the next begins, the last paragraph of the one and the first of the next are +/// pieces of one story paragraph: a paragraph that runs on into the next frame, or an empty piece +/// after a frame's last paragraph break. Such a paragraph is targeted once, by its last piece with +/// text in a frame that `selected` takes, the piece whose style the story keeps when it flows again. +/// A paragraph whose text lies only in frames that `selected` leaves out is not targeted. An empty +/// paragraph takes its style from its last piece when the story flows again, so it is targeted by +/// that piece when `selected` takes its frame, and otherwise not. +fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(NodeId) -> bool, targets: &mut Vec) { + /// The part of a story paragraph in one frame: paragraph `index` there, starting at byte `start`. + struct Piece<'a> { + id: NodeId, + text: &'a TextObject, + index: usize, + start: usize, + has_text: bool, + } + // Each story paragraph's pieces, in thread order. + let mut paragraphs: Vec>> = vec![]; + for frame in story.iter().filter_map(|id| doc.node(*id)) { + let NodeKind::Text(text) = &frame.kind else { continue }; + let plain = text.plain_text(); + let mut start = 0; + for (index, part) in plain.split('\n').enumerate() { + let piece = Piece { id: frame.id, text, index, start, has_text: !part.is_empty() }; + start += part.len() + 1; + match paragraphs.last_mut() { + Some(last) if index == 0 => last.push(piece), + _ => paragraphs.push(vec![piece]), + } + } + } + for pieces in ¶graphs { + let pick = if pieces.iter().any(|p| p.has_text) { + pieces.iter().rev().find(|p| p.has_text && selected(p.id)) + } else { + pieces.last().filter(|p| selected(p.id)) + }; + if let Some(p) = pick { + targets.push(indent_target(p.id, p.text, p.index, p.start)); + } + } +} + +/// Paragraphs touched by the Type selection (every paragraph of the story after Select All in +/// threaded text), or all paragraphs of selected text objects. Each paragraph of a threaded story +/// is listed once, in story order ([`append_story_targets`]). fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { let mut targets = Vec::new(); if let Some((_, a, b)) = super::character::text_editing(app) { if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) { - for frame in story.iter().filter_map(|id| st.doc.node(*id)) { - if let NodeKind::Text(text) = &frame.kind { - append_indent_targets(frame, 0..text.paragraph_count(), &mut targets); - } - } + append_story_targets(&st.doc, &story, |_| true, &mut targets); } else if let NodeKind::Text(text) = &node.kind { append_indent_targets(node, text.paragraphs_in(a, b), &mut targets); } } else if let Some(st) = app.session.active() { + // The selected text objects and the text in selected groups, each once. + let mut frames = Vec::new(); + let mut taken = HashSet::new(); for selected in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { selected.walk(&mut |child| { - if let NodeKind::Text(text) = &child.kind { - append_indent_targets(child, 0..text.paragraph_count(), &mut targets); + if matches!(child.kind, NodeKind::Text(_)) && taken.insert(child.id) { + frames.push(child.id); } }); } + let mut done = HashSet::new(); + for id in frames { + if done.contains(&id) { + continue; + } + let story = st.doc.story_of(id); + done.extend(story.iter().copied()); + append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets); + } } targets } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index bc71e34080..9e96866ab2 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -538,6 +538,139 @@ fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame( } } +/// A story of twelve paragraphs that wrap, threaded through three frames: the first two frames each +/// end inside a paragraph, which runs on into the next frame. → the app and the frames in thread +/// order. +fn split_story_app() -> (VectorcraftApp, [u64; 3]) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 1000})).unwrap(); + // Each paragraph takes more lines than a short frame holds. + let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3); + let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap(); + for id in [b, c] { + assert!(text_of(&app, id).first_para_continues, "frame {id} starts inside a paragraph: {:?}", text_of(&app, id).plain_text()); + } + (app, [a, b, c]) +} + +fn text_of(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject { + match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// Each paragraph of the story of `frames` by its first word, with its left indent and first-line +/// position, counted once; each piece of a paragraph split between frames has the same values. +fn story_paragraphs(app: &VectorcraftApp, frames: &[u64]) -> Vec<(String, f64, f64)> { + let mut out: Vec<(String, f64, f64)> = vec![]; + for (k, id) in frames.iter().enumerate() { + let t = text_of(app, *id); + for (i, text) in t.plain_text().split('\n').enumerate() { + let p = t.para_at(i); + let values = (p.left_indent, p.left_indent + p.first_line_indent); + if i == 0 && k > 0 && t.first_para_continues { + let last = out.last().unwrap(); + assert_eq!((last.1, last.2), values, "both pieces of {} agree", last.0); + } else if !text.is_empty() { + out.push((text.split(' ').next().unwrap_or_default().to_string(), values.0, values.1)); + } + } + } + out +} + +/// Where each target is in the story: its frame's place in `frames` and its start there. +fn story_positions(targets: &[IndentTarget], frames: &[u64]) -> Vec<(usize, usize)> { + targets.iter().map(|p| (frames.iter().position(|f| *f == p.id).unwrap(), p.start)).collect() +} + +/// With the Selection tool on every frame of a threaded story, selected last frame first, the +/// indent markers list each paragraph of the story once, in the story's order, and a marker drag +/// over mixed indents sets every paragraph in one undo step, the two split paragraphs included. +#[test] +fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once() { + let (mut app, [a, b, c]) = split_story_app(); + app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [c, b, a]})).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect::>(), vec![c, b, a]); + let before = story_paragraphs(&app, &[a, b, c]); + assert_eq!(before.len(), 12, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + let positions = story_positions(&targets, &[a, b, c]); + assert_eq!(positions.len(), 12, "one target for each paragraph of the story: {positions:?}"); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "the targets follow the story: {positions:?}"); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 60.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let after = story_paragraphs(&app, &[a, b, c]); + assert_eq!(after.iter().map(|p| &p.0).collect::>(), before.iter().map(|p| &p.0).collect::>()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 60.0), "{} keeps its left indent and starts at 60 pt: {after:?}", now.0); + } +} + +/// After Select All in threaded text, a paragraph split between two frames is one target. +#[test] +fn indent_markers_after_select_all_list_a_split_paragraph_once() { + let (mut app, [a, b, c]) = split_story_app(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + let ids = [a, b, c].map(vectorcraft_doc::NodeId); + assert_eq!(super::super::character::text_story(&app), Some(ids.to_vec())); + let (node, _, _) = current_para(&app).unwrap(); + let positions = story_positions(&indent_targets(&app, &node), &[a, b, c]); + assert_eq!(positions.len(), 12, "{positions:?}"); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); +} + +/// With the Selection tool on some frames of a threaded story, only the paragraphs with text in +/// those frames are listed, each once: a selected frame that starts inside a paragraph lists that +/// paragraph, and so does a selected frame that ends inside one. Text in a selected group is +/// listed with the story's frames, and an empty text object keeps its one paragraph. +#[test] +fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { + let (mut app, [a, b, c]) = split_story_app(); + let count = |app: &VectorcraftApp, id: u64| text_of(app, id).paragraph_count(); + let targets_of = |app: &mut VectorcraftApp, ids: Vec| { + app.run("select.set", json!({"ids": ids})).unwrap(); + let (node, _, _) = current_para(app).unwrap(); + story_positions(&indent_targets(app, &node), &[a, b, c]) + }; + // The continuing frame alone: its first paragraph is the one the frame before ends inside. + let only_b = targets_of(&mut app, vec![b]); + assert_eq!(only_b.len(), count(&app, b), "{only_b:?}"); + assert_eq!(only_b.first(), Some(&(1, 0)), "{only_b:?}"); + let only_a = targets_of(&mut app, vec![a]); + assert_eq!(only_a.len(), count(&app, a), "{only_a:?}"); + // Two frames that share a paragraph list it once, by the later frame's piece. + let both = targets_of(&mut app, vec![c, a, b]); + assert_eq!(both.len(), count(&app, a) + count(&app, b) + count(&app, c) - 2, "{both:?}"); + assert!(both.windows(2).all(|w| w[0] < w[1]), "{both:?}"); + // The first and last frames of the story, and a grouped empty text object besides. + let empty = app.run("text.create", json!({"x": 10, "y": 900, "text": "", "area": {"width": 100, "height": 40}})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [empty, c]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group, a]})).unwrap(); + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().clone(); + let targets = indent_targets(&app, &node); + let ids: Vec = targets.iter().map(|p| p.id).collect(); + assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}"); + let story: Vec = targets.into_iter().filter(|p| p.id != empty).collect(); + let positions = story_positions(&story, &[a, b, c]); + assert_eq!(positions.len(), count(&app, a) + count(&app, c), "{positions:?}"); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); +} + #[test] fn lower_marker_bounds_cover_each_touched_first_line() { let targets = [ diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..fc81643b62 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -640,6 +640,7 @@ since 2026-10-01. - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. - **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. A drag that leaves every stop where it was records no undo step. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing`, `command_drag_stops_at_the_largest_position_and_keeps_the_spacing` and `a_drag_that_moves_no_stop_records_no_undo_step`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time. +- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat` for each paragraph of the story, from its last paragraph to its first, whatever order the frames were selected in with the Selection tool; a paragraph that runs on from one frame into the next gets one step, after Type tool Select All too. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets that paragraph. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_after_select_all_list_a_split_paragraph_once` and `indent_markers_on_some_threaded_frames_list_their_paragraphs_once`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. From b89a6e8ba153d01a4b8a9f98d43033a3d06528eb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:49:16 -0800 Subject: [PATCH 56/83] Type: Block's word gap leaves the paragraphs' word spacing as set The word gap is now the Desired word spacing the layout uses for area type with Block on (TextObject::layout_justification), with Minimum and Maximum moved out to it when it lies outside them. The paragraphs keep their own Justification values, so a wide gap no longer leaves a 1,000 % Maximum behind, and turning Block off brings back the paragraph's own Desired value. The SVG writer reads the same values. - The Contextual Task Bar offers Dynamic Text for area type outside a thread, beside Convert To Point Type. - type.dynamicText with wordGap or lineGap and no area type in the selection is an error instead of being ignored. - Docs: docs/mcp.md, params text, docs/target-app-parity.md. --- crates/doc/src/text/area.rs | 5 ++- crates/doc/src/text/object.rs | 15 ++++++- crates/engine/src/cmd/typecmd/block.rs | 33 +++++++-------- crates/engine/src/cmd/typecmd/block_tests.rs | 42 +++++++++++++++++++- crates/engine/src/cmd/typemenu.rs | 5 ++- crates/svg/src/export/text.rs | 4 +- crates/text/src/layout/flow.rs | 5 ++- crates/ui-egui/src/canvas/task_bar.rs | 15 ++++--- crates/ui-egui/src/tests/taskbar.rs | 24 +++++++++++ docs/mcp.md | 9 +++-- docs/target-app-parity.md | 2 +- 11 files changed, 119 insertions(+), 40 deletions(-) diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 1901a23c8a..848add8f53 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -167,8 +167,9 @@ pub struct AreaOptions { /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). #[serde(skip_serializing_if = "crate::skip::is_default")] pub fit: AreaFit, - /// Dynamic Text's Block ([`super::TextObject::dynamic_text`] on area type): every paragraph's - /// Desired word spacing, in percent of a word space (100, the default, is left out when saved). + /// Dynamic Text's Block ([`super::TextObject::dynamic_text`] on area type): the Desired word + /// spacing the layout sets every paragraph with ([`super::TextObject::layout_justification`]), in + /// percent of a word space (100, the default, is left out when saved). #[serde(skip_serializing_if = "AreaOptions::is_default_word_gap")] pub word_gap: f64, /// Dynamic Text's Block: the points added to each character's size to give its leading (0, the diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 1092b871f2..d46b45b1f7 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -1,6 +1,6 @@ use super::area::{AreaOptions, PathAlign, PathEffect}; use super::char_style::CharStyle; -use super::para_style::ParaStyle; +use super::para_style::{JustificationOptions, ParaStyle}; use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end}; use super::tabs_lists::{ListCounts, ParaList}; use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; @@ -170,6 +170,19 @@ impl TextObject { pub fn para_at(&self, i: usize) -> &ParaStyle { self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) } + /// The Justification… options the layout sets `para` with: its own, except that with Dynamic + /// Text's Block on area type ([`Self::dynamic_text`]) the Desired word spacing is the word gap + /// ([`AreaOptions::block_gaps`]), and Minimum and Maximum move out to it when it lies outside + /// them. The paragraph keeps its own values, which apply again when Block is off. + pub fn layout_justification(&self, para: &ParaStyle) -> JustificationOptions { + let mut j = para.justification.clone(); + if self.dynamic_text && matches!(self.kind, TextKind::Area { .. }) { + let gap = self.area.block_gaps().0; + let [min, _, max] = j.word_spacing; + j.word_spacing = [min.min(gap), gap, max.max(gap)]; + } + j + } /// Every paragraph's attributes, one per paragraph. pub fn paragraph_styles(&self) -> Vec { (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs index a78f5254cb..06453f4983 100644 --- a/crates/engine/src/cmd/typecmd/block.rs +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -8,7 +8,8 @@ use vectorcraft_doc::{TextKind, TextObject, TextRun}; /// Character panel's). const SIZES: (f64, f64) = (0.1, 1296.0); -/// Most area layouts a refit runs to correct lines that still wrap after the first fit. +/// Most passes a refit runs to correct lines that still wrap after the first fit; each pass measures +/// the lines and runs at most one more area layout. pub(crate) const BLOCK_PASSES: usize = 4; /// One line as typed: `start..content` is its text, `content..end` the `\n` or U+2028 that ends it @@ -73,9 +74,12 @@ fn first_sizes(runs: &[TextRun], lines: &[TypedLine]) -> Vec { } /// Each line's content width (trailing spaces left out) by its first byte, with `t` laid out as -/// point type, where no line wraps. +/// point type, where no line wraps, with the word spacing `t`'s layout uses. fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collections::HashMap { let mut probe = t.clone(); + for p in probe.para_styles_mut() { + p.justification = t.layout_justification(p); + } probe.kind = TextKind::Point; let laid = vectorcraft_text::layout(db, &probe); let mut out = std::collections::HashMap::with_capacity(laid.lines.len()); @@ -103,33 +107,24 @@ fn tenths(points: f64) -> f64 { ((points * 10.0 + 1e-6).floor() / 10.0).clamp(SIZES.0, SIZES.1) } -/// Fit area type with Dynamic Text (Block) on: every paragraph's Desired word spacing becomes the word gap, -/// then each line as typed gets the size (its first character's, in steps of 0.1 pt within 0.1 to +/// Fit area type with Dynamic Text (Block) on: with the word gap as every paragraph's Desired word +/// spacing ([`TextObject::layout_justification`]; the paragraphs keep their own values), each line +/// as typed gets the size (its first character's, in steps of 0.1 pt within 0.1 to /// 1,296 pt, the line's other characters keeping their proportions to it) at which its width /// equals the frame's width less the inset, the gutters and the paragraph's indents, and each /// character's leading is its size plus the line gap. An empty line or one of only spaces keeps /// its size. Nothing changes when the frame has no width along the lines or the type's transform /// has no size. A line that still wraps at its first fit is made smaller, in at most -/// [`BLOCK_PASSES`] more layouts. True when the text changed. +/// [`BLOCK_PASSES`] more passes. True when the text changed. pub(crate) fn block_fit(t: &mut TextObject) -> bool { if !t.dynamic_text { return false; } let TextKind::Area { frame } = &t.kind else { return false }; - let (word_gap, line_gap) = t.area.block_gaps(); + let (_, line_gap) = t.area.block_gaps(); let Some(b) = frame.bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return false }; let Some(points) = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12) else { return false }; - let before = (t.runs.clone(), t.para.clone(), t.paras.clone()); - - // The word gap first: it changes the lines' widths. - let mut paras = t.paragraph_styles(); - for p in &mut paras { - let [min, _, max] = p.justification.word_spacing; - p.justification.word_spacing = [min.min(word_gap), word_gap, max.max(word_gap)]; - } - if paras != t.paragraph_styles() { - t.set_paragraph_styles(paras); - } + let before = t.runs.clone(); // The room along a line in one cell of the frame, before indents. let a = &t.area; @@ -137,7 +132,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool { let cells = cells.max(1) as f64; let cell = (along - 2.0 * a.inset.max(0.0) - gutter.max(0.0) * (cells - 1.0)) / cells; if !(cell.is_finite() && cell > 1e-9) { - return (t.runs.clone(), t.para.clone(), t.paras.clone()) != before; + return t.runs != before; } let db = vectorcraft_text::FontDb::global(); @@ -273,7 +268,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool { } apply(t, &start, &fits); } - (t.runs.clone(), t.para.clone(), t.paras.clone()) != before + t.runs != before } #[cfg(test)] diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index 1028fc0017..2dda356146 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -166,6 +166,17 @@ fn bad_params_and_other_type_are_refused() { let r = s.execute("type.dynamicText", &json!({})).unwrap(); assert_eq!(r["ids"], json!([id])); assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text); + // A gap with only type on a path selected is an error, and nothing changes. + let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap(); + let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [along]})).unwrap(); + let n = undo_len(&s); + for gap in [json!({"wordGap": 300}), json!({"on": false, "lineGap": 2})] { + let e = s.execute("type.dynamicText", &gap).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{e}"); + } + assert_eq!(undo_len(&s), n); + assert!(!text(&s, along).dynamic_text); } #[test] @@ -179,10 +190,39 @@ fn gaps_set_word_spacing_and_leading() { let t = text(&s, id); assert!(t.dynamic_text); assert_eq!((t.area.word_gap, t.area.line_gap), (300.0, 6.0)); - assert_eq!(t.para_at(0).justification.word_spacing, [80.0, 300.0, 300.0]); + // The layout uses the gap as the Desired word spacing; the paragraph keeps its own. + assert_eq!(t.para_at(0).justification.word_spacing, [80.0, 100.0, 133.0]); + assert_eq!(t.layout_justification(t.para_at(0)).word_spacing, [80.0, 300.0, 300.0]); assert!(t.runs.iter().all(|r| (r.style.leading.unwrap() - r.style.size - 6.0).abs() < 1e-9), "{:?}", t.runs); assert_block(&t); assert!(size_at(&t, 0) < size_at(&plain, 0), "wider word spaces leave the letters less room"); + // (own, used by the layout) word spacing of the first paragraph. + let spacing = |s: &Session| { + let t = text(s, id); + (t.para_at(0).justification.word_spacing, t.layout_justification(t.para_at(0)).word_spacing) + }; + // Minimum and Maximum move out only as far as the current gap needs, and the paragraph's own + // values apply again when Block is off. + s.execute("type.dynamicText", &json!({"wordGap": 1000})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 1000.0, 1000.0])); + s.execute("type.dynamicText", &json!({"wordGap": 100})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 100.0, 133.0])); + s.execute("type.dynamicText", &json!({"wordGap": 0})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [0.0, 0.0, 133.0])); + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 100.0, 133.0])); + // A paragraph's own values, outside the default range and equal to Desired too, stay through + // gap changes and refits after edits. + s.execute("text.setFormat", &json!({"wordSpacing": [20, 20, 150]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true, "wordGap": 20})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 20.0, 150.0])); + s.execute("type.dynamicText", &json!({"wordGap": 1000})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 1000.0, 1000.0])); + s.execute("type.dynamicText", &json!({"wordGap": 100})).unwrap(); + s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 0, "insert": "a "})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 100.0, 150.0])); + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 20.0, 150.0])); // A gap alone on a frame without Block turns it on. let other = area(&mut s, 200.0, 200.0, "x y"); let r = s.execute("type.dynamicText", &json!({"lineGap": -2})).unwrap(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index c3db479332..03681c556a 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { "Dynamic Text", ["Type"], None, - "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and every paragraph's Desired word spacing is wordGap (the gaps are area type's, and are kept while Block is off). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", + "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", has_dynamic_type, dynamic_text ), @@ -576,6 +576,9 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { return Err(bad(C, why)); } let gap_given = word_gap.is_some() || line_gap.is_some(); + if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) { + return Err(bad(C, "wordGap and lineGap need area type in the selection")); + } let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; // Each object as the command leaves it, and only those it changes. let mut planned: Vec<(NodeId, TextObject)> = vec![]; diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index b12811338b..645ba3d751 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -693,8 +693,8 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. // Justification…'s Desired word and letter spacing and glyph scaling move characters from - // where a reader draws them, on every line. - let spaced = !fewer && !style.justification.desired_is_default(); + // where a reader draws them, on every line (Block's word gap among them). + let spaced = !fewer && !t.layout_justification(style).desired_is_default(); let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) { Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) } else { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 56c71c2f4b..e3bddbac93 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -217,8 +217,9 @@ fn prepare_paragraph<'a, 'text>( // …and a quarter em between Japanese and Latin text. let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how - // far a justified line may move each one from it. - let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + // far a justified line may move each one from it (Dynamic Text's Block on area type sets the + // Desired word spacing to its word gap). + let mut flex = apply_desired(&mut sg, &t.layout_justification(para), !cx.vertical); // Threaded text: a first paragraph that continues from the frame before has its first // line, first-line indent and list marker there. let continued = pi == 0 && t.first_para_continues; diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index eb038f0790..a7a7f0a45c 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -151,14 +151,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { match kind { TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), - TextKind::OnPath { .. } => { - items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())); - // Dynamic Text turns on or off, as the Type menu's item does; left out for type in a thread - // (). - if crate::menus::enabled(app, "type.dynamicText") { - items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); - } - } + TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), + } + // Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does; + // left out for point type and type in a thread + // (). + if crate::menus::enabled(app, "type.dynamicText") { + items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); } } else if is_image { // Offset Path and Simplify don't apply to an image. diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 53042baa69..d42be49ff9 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -208,6 +208,30 @@ fn type_on_a_path_offers_dynamic_text() { assert!(!has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Area type offers Dynamic Text beside Convert To Point Type, which turns Block on and off; area +/// type in a thread does not. +#[test] +fn area_type_offers_dynamic_text() { + let mut app = app(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "area": {"width": 200, "height": 100}, "text": "Fit me"})).unwrap()); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + select(&mut app, &[t]); + let dynamic = |app: &VectorcraftApp| matches!(kind(app, t), NodeKind::Text(ref t) if t.dynamic_text); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Point Type") && has(&texts, "Dynamic Text"), "{texts:?}"); + click(&mut app, "Dynamic Text"); + assert!(dynamic(&app)); + click(&mut app, "Dynamic Text"); + assert!(!dynamic(&app)); + // Threaded, it is not offered. + let frame = id(app.run("shape.rectangle", json!({"x": 60, "y": 300, "width": 200, "height": 100})).unwrap()); + select(&mut app, &[t, frame]); + app.run("text.thread.create", json!({})).unwrap(); + select(&mut app, &[t]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Point Type") && !has(&texts, "Dynamic Text"), "{texts:?}"); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { diff --git a/docs/mcp.md b/docs/mcp.md index 44da10f748..c2bd7bbb64 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6514,7 +6514,8 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print ### Dynamic Text: Block -`type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text) turns Block on or off for the +`type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text, and the Contextual Task Bar for +area type outside a thread) turns Block on or off for the selected area type, as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type Options › Block is the same setting, and the Properties panel's Paragraph section has a Dynamic Text checkbox, which shows Word Gap and Line Gap while it is on. The [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) @@ -6526,8 +6527,10 @@ lines. The rest are VectorCraft's own rules: left, right and first-line indents, by its font size only (tracking and scaling stay): the first character's size in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it and their baseline shifts following. Vertical type sets each line to the frame's height (less the gutters between rows). -- `wordGap` is every paragraph's Desired word spacing (Justification…), and each character's leading is its size - plus `lineGap`. +- The layout sets every paragraph with `wordGap` as its Desired word spacing (Justification…), and its Minimum or + Maximum word spacing moves out to `wordGap` when `wordGap` lies outside it. The paragraphs keep their own + Justification… values, which apply again when Block is off. Each character's leading is its size plus `lineGap`. A + gap given with no area type in the selection is an error. - The sizes are written into the characters, so every export and the Character panel see them. After every edit to the text or the frame (typing, `text.editRange`, character and paragraph changes, `text.areaOptions {width}`, a frame resize) the lines are fitted again, in the edit's own undo step. The lines are then measured again, in at diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cd3f49ef69..bab61d841a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1159,7 +1159,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). - Dynamic Text on area type is Block (Type › Dynamic Text, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, and threaded frames cannot use it (`crates/engine/src/cmd/typecmd/block_tests.rs`). + Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, and threaded frames cannot use it (`crates/engine/src/cmd/typecmd/block_tests.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. From 14c8385df36fc74707c5deeef22b3ef46a37f86e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:40:54 -0800 Subject: [PATCH 57/83] Clipboard: a paste after an unanswered read waits for its answer When the check whether the clipboard still holds what VectorCraft copied, or one of a paste's own reads, gets no answer within its 2 s, the read is kept. The paste then waits for that answer on its background thread, up to 10 s after the read was sent, instead of failing its other reads at once and pasting the internal clipboard. When the answer is what VectorCraft copied, or none comes, the paste takes the internal clipboard; otherwise the clipboard is read as a paste reads it, with that answer standing in for its read. An answer that came before the paste is dropped and the clipboard is read again. --- apps/vectorcraft/src/clipboard.rs | 449 +++++++++++++++++++++++++----- docs/development.md | 2 +- 2 files changed, 388 insertions(+), 63 deletions(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 1d8eb5aff3..b5b8137101 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -14,7 +14,7 @@ //! text only. A clipboard that can't be opened is tried again after a wait that grows to a //! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app //! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at -//! once. +//! once; a paste then waits for that read's answer off the UI thread. //! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS, //! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). @@ -343,9 +343,8 @@ mod win { allow(dead_code) )] mod loads { - use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::mpsc; + use std::sync::{Arc, Mutex, PoisonError, Weak, mpsc}; use std::time::{Duration, Instant}; /// The longest a read waits for the clipboard's owner to send its data. @@ -355,17 +354,72 @@ mod loads { /// Requests of type `R` answered with `A` on a worker thread. pub(super) struct Loads { + /// The worker's requests; the worker stops once this is dropped. + line: Arc>, + /// Disconnected once the worker has let go of what its `load` holds. + done: mpsc::Receiver<()>, + /// How long after it was sent dropping `Loads` waits for an unanswered read to end. + finish: Duration, + } + + /// Where requests go to a [`Loads`] worker. + struct Line { /// Each request goes with the channel its answer is sent on. requests: mpsc::Sender<(R, mpsc::Sender)>, /// Requests sent and not answered yet, counted down by the worker: while one is open, /// reads don't queue behind it. open: Arc, - /// When the last request was sent. - sent: Option, - /// Disconnected once the worker has let go of what its `load` holds. - done: mpsc::Receiver<()>, - /// How long after it was sent dropping `Loads` waits for an unanswered read to end. - finish: Duration, + /// When the last request was sent, and whether the `Loads` has stopped taking requests: + /// requests are sent with this locked. + sent: Mutex, + } + + #[derive(Default)] + struct Sent { + at: Option, + closed: bool, + } + + impl Line { + /// A line whose worker has stopped. + fn dead() -> Arc { + Arc::new(Self { requests: mpsc::channel().0, open: Arc::default(), sent: Mutex::default() }) + } + + /// `request` sent to the worker ([`Loads::ask`]). + fn ask(&self, request: R) -> Result, String> { + let mut sent = self.sent.lock().unwrap_or_else(PoisonError::into_inner); + if sent.closed { + return Err(GONE.into()); + } + if self.open.compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst).is_err() { + return Err("the clipboard's owner hasn't answered an earlier read".into()); + } + let (reply, answer) = mpsc::channel(); + sent.at = Some(Instant::now()); + if self.requests.send((request, reply)).is_err() { + self.open.fetch_sub(1, Ordering::SeqCst); + return Err(GONE.into()); + } + Ok(answer) + } + + /// Stops taking requests → when the one still unanswered was sent. + fn close(&self) -> Option { + let mut sent = self.sent.lock().unwrap_or_else(PoisonError::into_inner); + sent.closed = true; + sent.at.filter(|_| self.open.load(Ordering::SeqCst) > 0) + } + } + + /// Sends requests to a [`Loads`] worker from another thread while the `Loads` is open. + pub(super) struct Asker(Weak>); + + impl Asker { + /// [`Loads::ask`]; Err also once the `Loads` has been dropped. + pub(super) fn ask(&self, request: R) -> Result, String> { + self.0.upgrade().ok_or_else(|| GONE.to_string())?.ask(request) + } } /// When dropping `Loads` stops waiting for its worker: `finish` after the unanswered read @@ -397,24 +451,24 @@ mod loads { drop(alive); }; std::thread::Builder::new().name(name.into()).spawn(work).ok()?; - Some(Self { requests, open, sent: None, done, finish }) + Some(Self { line: Arc::new(Line { requests, open, sent: Mutex::default() }), done, finish }) } /// `request` sent to the worker; its answer comes on the channel returned, for /// [`wait_for_answer`] here or on another thread. Err when an earlier request is still /// unanswered or the worker has stopped. pub(super) fn ask(&mut self, request: R) -> Result, String> { - if self.open.load(Ordering::SeqCst) > 0 { - return Err("the clipboard's owner hasn't answered an earlier read".into()); - } - let (reply, answer) = mpsc::channel(); - self.open.fetch_add(1, Ordering::SeqCst); - self.sent = Some(Instant::now()); - if self.requests.send((request, reply)).is_err() { - self.open.fetch_sub(1, Ordering::SeqCst); - return Err(GONE.into()); - } - Ok(answer) + self.line.ask(request) + } + + /// Whether a request sent is still unanswered. + pub(super) fn waiting(&self) -> bool { + self.line.open.load(Ordering::SeqCst) > 0 + } + + /// A handle that sends requests from another thread until this is dropped. + pub(super) fn asker(&self) -> Asker { + Asker(Arc::downgrade(&self.line)) } /// The answer to `request`, or why there is none, after `wait` at most. @@ -437,9 +491,9 @@ mod loads { /// [`stop_by`] at most: on X11 the last arboard handle dropped hands what the app copied to /// the clipboard manager, and the app's own handle must be that one. fn drop(&mut self) { - let (stop, _) = mpsc::channel(); - drop(std::mem::replace(&mut self.requests, stop)); - let pending = self.sent.filter(|_| self.open.load(Ordering::SeqCst) > 0); + // No `Asker` sends after this, and one that holds the line lets go once it has sent. + let pending = self.line.close(); + drop(std::mem::replace(&mut self.line, Line::dead())); let end = stop_by(pending, Instant::now(), self.finish); // Nothing is sent on `done`: this ends when the worker drops `alive`, or at `end`. let _ = self.done.recv_timeout(end.saturating_duration_since(Instant::now())); @@ -495,11 +549,23 @@ mod loads { fn a_read_after_the_worker_stops_says_so() { let mut loads = Loads::spawn("test", Duration::ZERO, |()| String::new()).unwrap(); // The worker ends when its requests stop: cut them off by hand. - let (dead, _) = mpsc::channel(); - drop(std::mem::replace(&mut loads.requests, dead)); + drop(std::mem::replace(&mut loads.line, Line::dead())); assert_eq!(loads.get((), Duration::from_millis(50)), Err(GONE.to_string())); } + #[test] + fn another_threads_reads_stop_with_the_reads() { + let loads = Loads::spawn("test", Duration::ZERO, |()| 1).unwrap(); + let asker = loads.asker(); + assert_eq!(asker.ask(()).and_then(|a| wait_for_answer(&a, Duration::from_secs(600))), Ok(1)); + drop(loads); + assert_eq!(asker.ask(()).err(), Some(GONE.to_string())); + // Closed while an asker still holds the line: nothing more is sent. + let line = Line::<(), ()>::dead(); + assert_eq!(line.close(), None); + assert_eq!(line.ask(()).err(), Some(GONE.to_string())); + } + #[test] fn an_answer_waited_for_on_another_thread_holds_back_the_next_read() { let (release, gate) = mpsc::channel::<()>(); @@ -558,13 +624,14 @@ mod portable { use super::{BITMAP, ClipboardRead, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of}; /// What our last write put on the clipboard. + #[derive(Clone)] enum Ours { Text(String), Image(usize, usize), } /// A read of arboard's clipboard. - #[derive(Clone, Copy)] + #[derive(Clone, Copy, PartialEq, Eq)] enum Request { Text, Image, @@ -591,6 +658,19 @@ mod portable { } } + /// The request this answers. + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")), + allow(dead_code) + )] + fn request(&self) -> Request { + match self { + Self::Text(_) => Request::Text, + Self::Image(_) => Request::Image, + Self::Files(_) => Request::Files, + } + } + fn text(self) -> Result { match self { Self::Text(text) => text, @@ -623,17 +703,15 @@ mod portable { } /// `request` read on `reads`' worker, waiting `wait` at most. - #[cfg_attr( - not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")), - allow(dead_code) - )] + #[cfg(test)] fn read_within(reads: &mut super::loads::Loads, request: Request, wait: Duration) -> Answer { reads.get(request, wait).unwrap_or_else(|why| Answer::failed(request, why)) } /// The longest a paste's bitmap read of arboard's waits for the app that copied, off the UI - /// thread: the bitmap's transfer and its PNG decoding happen within the read. Text, file lists - /// and the UI thread's bitmap check wait [`LOAD_WAIT`](super::loads::LOAD_WAIT). + /// thread: the bitmap's transfer and its PNG decoding happen within the read. A paste waits as + /// long, from when it was sent, for a read that got no answer in its wait. Text, file lists and + /// the UI thread's bitmap check wait [`LOAD_WAIT`](super::loads::LOAD_WAIT). #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] const IMAGE_WAIT: Duration = Duration::from_secs(10); @@ -678,10 +756,85 @@ mod portable { /// hands what the app copied to the clipboard manager. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] reads: super::loads::Loads, + /// The last read that got no answer in its wait, until a paste takes it or the next read + /// is sent. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + unanswered: Option, /// Writes, and elsewhere reads. cb: C, } + /// A read on arboard's worker that got no answer in its wait. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + struct Unanswered { + request: Request, + answer: std::sync::mpsc::Receiver, + sent: Instant, + /// The worker, for the paste's other reads. + reads: super::loads::Asker, + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + impl Unanswered { + /// Whether a paste of one of `mimes` can take this read's answer: files paste as any of + /// them. + fn fits(&self, mimes: &[&'static str]) -> bool { + match self.request { + Request::Text => mimes.contains(&TEXT), + Request::Image => mimes.contains(&BITMAP), + Request::Files => true, + } + } + + /// What a paste of one of `mimes` takes, once this read's answer comes, waiting for it until + /// [`IMAGE_WAIT`] after it was sent, as long as a paste's bitmap read waits. `None`, so the + /// paste takes the internal clipboard, when the answer is what `ours` put on the clipboard + /// or doesn't come. Otherwise the clipboard is read as [`Clipboard::read_now_or_later`] and + /// the paste read it, this answer standing in for the read it answers: copied files, else + /// text, else the bitmap, and the bitmap when the text is blank or only names a picture + /// (`adopt_system_clipboard_or_defer` in `vectorcraft-ui-egui`). + fn paste(self, ours: Option<&Ours>, mimes: &[&'static str]) -> Option { + let wait = (self.sent + IMAGE_WAIT).saturating_duration_since(Instant::now()); + let answer = super::loads::wait_for_answer(&self.answer, wait).ok()?; + let held = match (&answer, ours) { + (Answer::Text(Ok(now)), Some(Ours::Text(t))) => now == t, + (Answer::Image(Ok(now)), Some(Ours::Image(w, h))) => (now.width, now.height) == (*w, *h), + _ => false, + }; + if held { + return None; + } + let mut answer = Some(answer); + let mut read = |request: Request| match answer.take_if(|a| a.request() == request) { + Some(answer) => answer, + None => self + .reads + .ask(request) + .and_then(|a| super::loads::wait_for_answer(&a, wait_for(request))) + .unwrap_or_else(|why| Answer::failed(request, why)), + }; + if let Some(f) = copied_file(read(Request::Files).files(), mimes) { + return Some(f); + } + let text = if mimes.contains(&TEXT) { read(Request::Text).text().ok().filter(|t| !t.is_empty()) } else { None }; + let bitmap = |read: &mut dyn FnMut(Request) -> Answer| image_flavour(read(Request::Image).image().ok()?); + match text { + Some(t) if t.trim().is_empty() || vectorcraft_engine::cmd::clipboard::names_a_picture(&t) => { + bitmap(&mut read).or(Some(Flavour { mime: TEXT, data: t.into_bytes() })) + } + Some(t) => Some(Flavour { mime: TEXT, data: t.into_bytes() }), + None if mimes.contains(&BITMAP) => bitmap(&mut read), + None => None, + } + } + } + + /// arboard's bitmap as a PNG flavour. + fn image_flavour(img: arboard::ImageData<'static>) -> Option { + let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); + png_flavour(&image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?) + } + impl Arboard { fn open() -> Result { let cb = arboard::Clipboard::new().map_err(|e| e.to_string())?; @@ -690,7 +843,7 @@ mod portable { let mut reader = arboard::Clipboard::new().map_err(|e| e.to_string())?; let reads = super::loads::Loads::spawn("clipboard-read", finish(on_data_control()), move |request| answer(&mut reader, request)) .ok_or("the clipboard's reader didn't start")?; - Ok(Self { reads, cb }) + Ok(Self { reads, unanswered: None, cb }) } #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] Ok(Self { cb }) @@ -706,21 +859,56 @@ mod portable { impl Arboard { /// Reads on the worker; the write handle `C` is not used. fn read(&mut self, request: Request) -> Answer { - read_within(&mut self.reads, request, wait_for(request)) + self.read_waiting(request, wait_for(request)) } /// A bitmap read on the worker that waits [`LOAD_WAIT`](super::loads::LOAD_WAIT) at most, for /// a check on the UI thread. fn image_now(&mut self) -> Result, String> { - read_within(&mut self.reads, Request::Image, super::loads::LOAD_WAIT).image() + self.read_waiting(Request::Image, super::loads::LOAD_WAIT).image() + } + + /// `request` read on the worker, waiting `wait` at most. A read that gets no answer in that + /// time is kept ([`Self::unanswered`]) for a paste to wait for off the UI thread. + fn read_waiting(&mut self, request: Request, wait: Duration) -> Answer { + let sent = Instant::now(); + let answer = match self.ask(request) { + Ok(answer) => answer, + Err(why) => return Answer::failed(request, why), + }; + match super::loads::wait_for_answer(&answer, wait) { + Ok(answer) => answer, + Err(why) => { + if why != super::loads::GONE { + self.unanswered = Some(Unanswered { request, answer, sent, reads: self.reads.asker() }); + } + Answer::failed(request, why) + } + } + } + + /// `request` sent to the worker. A read sent means the one kept unanswered has been + /// answered, and its answer is dropped. + fn ask(&mut self, request: Request) -> Result, String> { + let answer = self.reads.ask(request)?; + self.unanswered = None; + Ok(answer) } /// A bitmap read sent to the worker now; the work returned waits [`IMAGE_WAIT`] at most for /// it, so the caller can wait off the UI thread. Err when the read can't be sent. fn image_later(&mut self) -> Result { - let answer = self.reads.ask(Request::Image)?; + let answer = self.ask(Request::Image)?; Ok(Box::new(move || super::loads::wait_for_answer(&answer, IMAGE_WAIT).and_then(Answer::image))) } + + /// The read that got no answer in its wait, while its answer has still not come and a paste + /// of one of `mimes` can take it: what the clipboard holds is not known until it comes. Once + /// it has come the answer may be old, and it is dropped: a new read is made. + fn unanswered(&mut self, mimes: &[&'static str]) -> Option { + let unanswered = self.unanswered.take_if(|u| u.fits(mimes))?; + self.reads.waiting().then_some(unanswered) + } } /// A bitmap read whose answer is waited for when it runs. @@ -831,6 +1019,27 @@ mod portable { } } + /// arboard's read that got no answer in its wait, when a paste of one of `mimes` can take + /// its answer ([`Arboard::unanswered`]). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn unanswered(&mut self, mimes: &[&'static str]) -> Option { + match self { + Self::Arboard(cb) => cb.unanswered(mimes), + #[cfg(target_os = "linux")] + Self::Wayland(_) => None, + } + } + + /// Drops arboard's read that got no answer in its wait: a paste no longer takes its answer. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn forget_unanswered(&mut self) { + match self { + Self::Arboard(cb) => cb.unanswered = None, + #[cfg(target_os = "linux")] + Self::Wayland(_) => {} + } + } + /// Files copied in a file manager (none where the backend can't read a file list). fn file_list(&mut self) -> Vec { match self { @@ -958,6 +1167,38 @@ mod portable { let window = self.window.as_ref(); self.cb.get(asker, Instant::now(), || Backend::open(window), Backend::name).ok_or_else(|| "no system clipboard".to_string()) } + + /// The work that pastes the answer to arboard's read that got no answer in its wait + /// ([`Unanswered::paste`]), while that answer has not come. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn paste_unanswered(&mut self, mimes: &[&'static str]) -> Option { + let ours = self.ours.clone(); + let unanswered = self.cb(Asker::User).ok()?.unanswered(mimes)?; + let mimes = mimes.to_vec(); + Some(ClipboardRead::Later(Box::new(move || unanswered.paste(ours.as_ref(), &mimes)))) + } + + /// Text and copied files are read here, and work that reads arboard's image comes back. + fn read_now_or_later(&mut self, mimes: &[&'static str]) -> Option { + let cb = self.cb(Asker::User).ok()?; + if let Some(f) = copied_file(cb.file_list(), mimes) { + return Some(ClipboardRead::Now(f)); + } + mimes.iter().find_map(|m| { + #[cfg(target_os = "macos")] + if let Some(f) = super::mac::read(m) { + return Some(ClipboardRead::Now(f)); + } + match *m { + TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })), + BITMAP => { + let read = cb.image_later().ok()?; + Some(ClipboardRead::Later(Box::new(move || image_flavour(read().ok()?)))) + } + _ => None, + } + }) + } } impl SystemClipboard for Clipboard { @@ -983,6 +1224,9 @@ mod portable { None } }; + // What a read got late was on the clipboard before this write. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + cb.forget_unanswered(); self.ours = ours; Ok(()) } @@ -997,33 +1241,25 @@ mod portable { } fn read(&mut self, mimes: &[&'static str]) -> Option { - self.read_deferred(mimes)?.flavour() + self.read_now_or_later(mimes)?.flavour() } - /// arboard's image is read here and encoded as PNG by the work that comes back. + /// arboard's image is read here and encoded as PNG by the work that comes back. On Linux and + /// the BSDs, after a read that got no answer in its wait (such as the check whether the + /// clipboard still holds what was copied here, or one of the reads here), the work waits for + /// that read's answer instead and pastes from it, or nothing when it is what was copied here. fn read_deferred(&mut self, mimes: &[&'static str]) -> Option { - let cb = self.cb(Asker::User).ok()?; - if let Some(f) = copied_file(cb.file_list(), mimes) { - return Some(ClipboardRead::Now(f)); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + if let Some(read) = self.paste_unanswered(mimes) { + return Some(read); } - mimes.iter().find_map(|m| { - #[cfg(target_os = "macos")] - if let Some(f) = super::mac::read(m) { - return Some(ClipboardRead::Now(f)); - } - match *m { - TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })), - BITMAP => { - let read = cb.image_later().ok()?; - Some(ClipboardRead::Later(Box::new(move || { - let img = read().ok()?; - let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); - png_flavour(&image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?) - }))) - } - _ => None, - } - }) + let read = self.read_now_or_later(mimes); + // One of these reads got no answer in its wait, and the ones after it failed at once. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + if read.is_none() { + return self.paste_unanswered(mimes); + } + read } /// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no @@ -1207,7 +1443,7 @@ mod portable { }) .unwrap(); // `()` for writes: a read made through the write handle wouldn't build. - let mut cb = Arboard { reads, cb: () }; + let mut cb = Arboard { reads, unanswered: None, cb: () }; assert_eq!(cb.read(Request::Text).text(), Ok("read on the worker".to_string())); assert_eq!(wait_for(Request::Text), super::super::loads::LOAD_WAIT); assert_eq!(wait_for(Request::Files), super::super::loads::LOAD_WAIT); @@ -1230,7 +1466,7 @@ mod portable { _ => Answer::failed(request, "not asked".into()), }) .unwrap(); - let mut cb = Arboard { reads, cb: () }; + let mut cb = Arboard { reads, unanswered: None, cb: () }; // The read is sent and the UI thread goes on without its answer. let work = cb.image_later().unwrap(); // A check on the UI thread meanwhile fails at once instead of waiting behind it. @@ -1240,6 +1476,95 @@ mod portable { assert_eq!(paste.join().ok().and_then(Result::ok).map(|i| (i.width, i.height)), Some((2, 1))); } + /// An arboard clipboard whose owner answers each read with `answer` only once the sender + /// returned is used or dropped. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn slow_owner(answer: impl Fn(Request) -> Answer + Send + 'static) -> (Arboard<()>, std::sync::mpsc::Sender<()>) { + let (release, gate) = std::sync::mpsc::channel::<()>(); + let reads = Loads::spawn("test", Duration::ZERO, move |request| { + let _ = gate.recv(); + answer(request) + }) + .unwrap(); + (Arboard { reads, unanswered: None, cb: () }, release) + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn a_paste_after_an_unanswered_ownership_check_waits_for_its_answer() { + let image = |width: usize| Answer::Image(Ok(arboard::ImageData { width, height: 1, bytes: Cow::Owned(vec![0; width * 4]) })); + // A 2×1 bitmap was copied here; the clipboard's owner sends a `width`×1 one late. + let paste_after_check = |width: usize| { + let (mut cb, release) = slow_owner(move |_| image(width)); + // The check whether the clipboard still holds what was copied here gets no answer + // in its wait (2 s on the UI thread, none here). + assert!(cb.read_waiting(Request::Image, Duration::ZERO).image().is_err()); + // The paste's own reads fail at once while the owner hasn't answered. + assert!(cb.read(Request::Files).files().is_empty()); + assert!(cb.read(Request::Text).text().is_err()); + assert!(cb.image_later().is_err()); + // The paste waits for the check's answer off the UI thread instead. + let unanswered = cb.unanswered(&vectorcraft_engine::cmd::clipboard::PASTE_ORDER).expect("the check's read is kept for the paste"); + let paste = std::thread::spawn(move || unanswered.paste(Some(&Ours::Image(2, 1)), &[BITMAP])); + drop(release); + paste.join().ok().flatten() + }; + // Another app's bitmap is pasted, not the art copied here. + let pasted = paste_after_check(3).expect("the new bitmap is pasted"); + assert_eq!((pasted.mime, decode_png(&pasted.data).map(|i| i.dimensions())), (PNG, Some((3, 1)))); + // The bitmap copied here: the paste takes the internal clipboard. + assert!(paste_after_check(2).is_none()); + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn a_late_answer_to_an_ownership_check_is_pasted_only_while_it_is_awaited() { + // Text was copied here; the clipboard's owner answers the check with `text` late, and + // has a 3×1 bitmap. + let paste_after_check = |text: &'static str| { + let (mut cb, release) = slow_owner(move |request| match request { + Request::Text => Answer::Text(Ok(text.into())), + Request::Image => Answer::Image(Ok(arboard::ImageData { width: 3, height: 1, bytes: Cow::Owned(vec![0; 12]) })), + Request::Files => Answer::Files(vec![]), + }); + assert!(cb.read_waiting(Request::Text, Duration::ZERO).text().is_err()); + // A paste of a bitmap alone can't take a text read's answer: the read stays kept. + assert!(cb.unanswered(&[BITMAP]).is_none()); + let unanswered = cb.unanswered(&vectorcraft_engine::cmd::clipboard::PASTE_ORDER).expect("the check's read is kept for the paste"); + let ours = Ours::Text("copied here".into()); + let paste = std::thread::spawn(move || unanswered.paste(Some(&ours), &vectorcraft_engine::cmd::clipboard::PASTE_ORDER)); + drop(release); + paste.join().ok().flatten() + }; + let pasted = paste_after_check("copied elsewhere").expect("the other app's text is pasted"); + assert_eq!((pasted.mime, pasted.data), (TEXT, b"copied elsewhere".to_vec())); + // Text that only names a picture goes with it: the picture is pasted, as at once. + let pasted = paste_after_check("").expect("the picture is pasted"); + assert_eq!((pasted.mime, decode_png(&pasted.data).map(|i| i.dimensions())), (PNG, Some((3, 1)))); + // The text copied here: the paste takes the internal clipboard. + assert!(paste_after_check("copied here").is_none()); + + // A paste's own file-list read that gets no answer: once it comes, the text is read. + let (mut cb, release) = slow_owner(|request| match request { + Request::Text => Answer::Text(Ok("late text".into())), + _ => Answer::failed(request, "none".into()), + }); + assert!(cb.read_waiting(Request::Files, Duration::ZERO).files().is_empty()); + let unanswered = cb.unanswered(&[TEXT]).expect("the file-list read is kept for the paste"); + let paste = std::thread::spawn(move || unanswered.paste(None, &[TEXT])); + drop(release); + assert_eq!(paste.join().ok().flatten().map(|f| f.data), Some(b"late text".to_vec())); + + // An answer that came before the paste may be old: it is dropped, not pasted. + let (mut cb, release) = slow_owner(|request| Answer::failed(request, "not asked".into())); + assert!(cb.read_waiting(Request::Text, Duration::ZERO).text().is_err()); + drop(release); + while cb.reads.waiting() { + std::thread::yield_now(); + } + assert!(cb.unanswered(&[TEXT]).is_none()); + } + #[test] fn one_outage_is_logged_once_across_handles() { static LOGGED: AtomicBool = AtomicBool::new(false); diff --git a/docs/development.md b/docs/development.md index 766b493c53..3cbbc45f99 100644 --- a/docs/development.md +++ b/docs/development.md @@ -123,7 +123,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: - **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and a Paste waits 2 s at most: an owner that never sends its data costs one Paste 2 s, and later reads return at once until it answers. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. -- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. +- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. - **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, the app runs under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer: Preferences › Performance › Linux Display Server's Automatic (the default) and X11 (XWayland) When a Pen Is Attached (`x11WithPen`) open the window through XWayland when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and log `pen found: opening the window through XWayland`, as PhotoCraft does (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps the window on Wayland). For a pen attached after the app started, either run the app under XWayland (Preferences › Performance › Linux Display Server › X11 (XWayland); `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way. From ef7fbaac31e14b59a1597324488f37fdb3175fcf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:51:11 -0800 Subject: [PATCH 58/83] Plug-ins: a failed rename back of a removed plug-in's settings is reported and undone later When plugin.remove could not remove the grant and then could not rename the plug-in's settings folder back from .removed, the failure was only logged: the plug-in got empty settings, and the next removal deleted the .removed folder with the settings in it. - The error now names the .removed folder, the plug-in folder and the reason, and the plug-in gets no storage until the settings are back. - The next load, install or plugin.remove of the plug-in renames a kept folder back while the plug-in has no folder of its own. When it has one, that folder is used and the kept one stays until plugin.remove deletes both. A removal while a kept .removed folder exists renames the plug-in folder to .removing. - Before deleting a renamed folder, the store writes an empty .removed.delete file and removes it once the folder is gone, so what a failed deletion leaves is not moved back. When that file can't be written, nothing is deleted. - A .removed folder without a .delete file, including one an earlier version left, counts as kept settings: a new install of the id needs replace: true, and plugin.remove of the id deletes it. --- crates/engine/src/cmd/plugin.rs | 2 +- crates/engine/src/store.rs | 178 +++++++++++++++++++++++++---- crates/engine/src/tests/plugins.rs | 119 +++++++++++++++++++ docs/mcp.md | 8 +- docs/plugins.md | 17 ++- docs/target-app-parity.md | 1 + 6 files changed, 291 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index a5b71d538b..e8e57301e8 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -1096,7 +1096,7 @@ pub fn specs() -> Vec { "Remove Plug-in", [], None, - "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", + "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed; when the settings can't be moved back either, the error names the folder they stay in, and the next load, install or removal of the plug-in moves them back); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", always, remove ), diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 01ebc46526..9a96f96212 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -190,6 +190,12 @@ fn lock_disabled(_: &Path) -> Option { thread_local! { /// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume. pub(crate) static PUT_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: moving a plug-in's settings and cache back from `.removed` ([`move_back`]) + /// fails on this thread, as when another program holds a file in the folder. + pub(crate) static MOVE_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: writing the `.delete` file beside a folder about to be deleted ([`delete_aside`]) + /// fails on this thread, as on a full volume. + pub(crate) static MARKER_WRITE_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted. @@ -590,26 +596,34 @@ impl Session { } /// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without - /// one, nowhere (its storage calls then fail as unavailable). + /// one, nowhere (its storage calls then fail as unavailable). Settings and cache that a removal + /// kept are moved back first ([`plugin_storage`]). pub(crate) fn attach_plugin_storage(&self, id: &str) { vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id)); } - /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A - /// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls - /// can't reach it and removing the plug-in leaves it. + /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`, or one + /// that a removal kept ([`aside_paths`], [`is_kept`])? A folder behind a `storage` folder + /// that is a link is not counted: the plug-in's storage calls can't reach it and removing the + /// plug-in leaves it. pub(crate) fn has_plugin_storage(&self, id: &str) -> bool { - self.plugin_dir - .as_deref() - .is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok()) + self.plugin_dir.as_deref().is_some_and(|dir| { + let path = plugin_data_dir(dir, id); + !is_link(&dir.join(PLUGIN_DATA_DIR)) && (std::fs::symlink_metadata(&path).is_ok() || aside_paths(&path).iter().any(|a| is_kept(a))) + }) } /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. - /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it - /// as it was, when the rename does; what the deletion then leaves stays under that name, with a - /// log record. A later call for the id tries to delete it again. `forget` runs once the folder is - /// out of the way, before anything is deleted: when it returns `false`, the folder is renamed - /// back and this fails. + /// Settings and cache that an earlier removal kept ([`is_kept`]) are first moved back when the + /// folder is missing ([`restore_removed`]); what earlier deletions left is deleted. The folder is + /// then renamed out of the way ([`aside_paths`]: `.removed`, or `.removing` while + /// `.removed` holds kept settings), and that fails, leaving it as it was, when the rename + /// does. `forget` runs once the folder is out of the way, before anything is deleted: when it + /// returns `false`, the folder is renamed back and this fails, and the kept settings stay. When + /// that rename fails too, the error also names both paths and the reason, and the next attach or + /// removal for the id moves the folder back. Once `forget` returns `true`, the folder and the + /// kept settings are deleted ([`delete_aside`]); what that deletion leaves stays, with a log + /// record, and a later call for the id tries to delete it again. pub(crate) fn remove_plugin_storage(&self, id: &str, forget: impl FnOnce() -> bool) -> Result<(), StorageError> { let not_forgotten = || StorageError::Io(GRANT_NOT_REMOVED.to_string()); vectorcraft_plugins::registry::set_storage(id, None); @@ -636,28 +650,45 @@ impl Session { close(); return Ok(()); } - let mut aside = path.clone().into_os_string(); - aside.push(".removed"); - let aside = PathBuf::from(aside); - let _ = std::fs::remove_dir_all(&aside); - match std::fs::rename(&path, &aside) { + let asides = aside_paths(&path); + if std::fs::symlink_metadata(&path).is_err() + && let Err(e) = restore_removed(&path) + { + log::warn!("can't move the settings and cache of {} back: {e}", path.display()); + } + for aside in asides.iter().filter(|a| !is_kept(a)) { + delete_aside(aside); + } + let target = if std::fs::symlink_metadata(&path).is_err() { + asides.iter().rev().find(|a| is_kept(a)).unwrap_or(&asides[0]) + } else if !is_kept(&asides[0]) { + &asides[0] + } else if std::fs::symlink_metadata(&asides[1]).is_err() { + &asides[1] + } else { + return Err(StorageError::Io(format!("{}: {} and {} are in the way", path.display(), asides[0].display(), asides[1].display()))); + }; + match std::fs::rename(&path, target) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))), Ok(()) => {} } if !forget() { - if std::fs::symlink_metadata(&aside).is_ok() - && let Err(e) = std::fs::rename(&aside, &path) + if std::fs::symlink_metadata(target).is_ok() + && let Err(e) = move_back(target, &path) { - log::warn!("can't move {} back: {e}", aside.display()); + log::warn!("can't move {} back: {e}", target.display()); + return Err(StorageError::Io(format!( + "{GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {} to {}: {e}", + target.display(), + path.display() + ))); } return Err(not_forgotten()); } close(); - if std::fs::symlink_metadata(&aside).is_ok() - && let Err(e) = std::fs::remove_dir_all(&aside) - { - log::warn!("can't delete {}: {e}", aside.display()); + for aside in &asides { + delete_aside(aside); } Ok(()) } @@ -688,10 +719,107 @@ pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id))) } -/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`. +/// `path` with `suffix` added to its last part. +fn suffixed(path: &Path, suffix: &str) -> PathBuf { + let mut named = path.as_os_str().to_os_string(); + named.push(suffix); + PathBuf::from(named) +} + +/// Where [`Session::remove_plugin_storage`] moves the plug-in folder `path` out of the way: +/// `.removed`, and `.removing` while `.removed` holds settings and cache an +/// earlier removal kept ([`is_kept`]). The second one, when it is there, is the newer. +fn aside_paths(path: &Path) -> [PathBuf; 2] { + [suffixed(path, ".removed"), suffixed(path, ".removing")] +} + +/// `