From dc6af04dcce97266072fcf97eda734b2a4296152 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:23:03 +0300 Subject: [PATCH 01/52] fix(ai-import): read mesh corners whose handle weights are 0 Some files write a handle weight of 0 on some gradient mesh corners. The reader took only weights of 1, so the mesh and the whole file's layers were unread. A weight of 0 draws as the same cubic edge against the files' pages; other weights still leave the mesh unread. --- crates/eps/src/import/ai/mesh.rs | 24 ++++++++++++++++++++++-- docs/file-format-parity.md | 3 ++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index b33869a95..3937045db 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -245,8 +245,9 @@ impl MeshReader { _ => return None, }; let &[x, y, nx, ny, nw, px, py, pw] = geometry else { return None }; - // Non-unit rational weights need a different surface representation. - if nw != 1.0 || pw != 1.0 { + // Weights are 1, or 0 on some corners, which draw as the same cubic edges (checked + // against the files' pages). Others would need a rational surface. + if ![nw, pw].iter().all(|w| *w == 1.0 || *w == 0.0) { return None; } if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) { @@ -631,6 +632,25 @@ mod tests { assert!((first.handles[H_DOWN] - Vec2::new(0.0, 1.0)).hypot() < 1e-9, "{:?}", first.handles); } + /// A corner's handle weights are 1, or 0 on some corners, which draw as the same patch; any + /// other weight leaves the patch unread. + #[test] + fn corners_with_handle_weights_of_zero_are_read() { + let read = |nw: f64, pw: f64| { + let mut reader = MeshReader::default(); + patch(&mut reader, &[0.5, 1.0, 0.0, 0.0, 0.0, 0.3, nw, 0.3, 0.0, pw]); + reader + }; + for (nw, pw) in [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0)] { + let reader = read(nw, pw); + assert!(!reader.unread, "{nw} {pw}"); + let p = &reader.patches[0].points[0]; + assert_eq!((p.handles[H_DOWN], p.handles[H_RIGHT]), (Vec2::new(0.0, 0.3), Vec2::new(0.3, 0.0))); + } + let reader = read(0.5, 1.0); + assert!(reader.unread && reader.patches.is_empty()); + } + #[test] fn hostile_meshes_are_capped_and_checked() { let mut reader = MeshReader::default(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index af662106c..4f383bd3d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: pages cropped to the art in `UserUnit` units, and their copies' type) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai`/EPS editing data: mesh corners with handle weights of 0 are read) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh corner whose handle weights are 0 (one or both; the others are 1) is read like one whose weights are 1, so a mesh with such corners no longer leaves the whole file coming in as its page or PDF part; other weights still leave the mesh unread. Checked against the pages of two users' files: one opens from its layers 0.6% from its page, the other with 728 live meshes instead of its PDF part's 65,558 patches. Test `import::ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a PDF part whose pages are cropped to the art is recognised in a `UserUnit` page's units too (the art's box in those units, the artboard not the page in points), and its type is read from the first page only, since every such page holds all the art again: the copies on later pages were moved as the first page's and could be put into the layers' text objects. A page cropped to the art is taken only when every page is that box. Tests `import::tests_native::a_user_unit_page_cropped_to_the_art_is_read_in_its_units`, `type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`; scores unchanged | | 2026-10-30 | minor | `.ai` import: when the layers are compared with the page again with placed files at the corner of their box, the text objects the page doesn't draw are weighed with the images there, from the layers as read: before, the text an image covered at its first placement had already been left out, so a file whose image covered type only there came in without that type or as its PDF part. Test `import::tests_native::text_a_placed_file_covers_only_at_its_first_placement_is_kept`; scores unchanged | | 2026-10-30 | minor | `.ai` import: type read from the editing data's text document keeps its case setting (character attribute 12: 1 Small Caps, at Document Setup's default size, 2 All Caps); it came in as typed, so a heading set in All Caps showed in lower or mixed case where the page draws capitals. Test `import::tests_native::type_from_the_text_document_keeps_its_case_setting`; checked locally against a user's files; scores unchanged | From e5481bda9866eceec244ae013a1758054dc19937 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:42:49 +0300 Subject: [PATCH 02/52] fix(eps): a gradient mesh takes the transparency written after it In the editing data of a .ai or EPS file, a gradient mesh's own state (`Xy`) comes right after its `/End X!`, before its `Xd` and `XW`, as a group's comes after its `U`. The reader took it as the next object's, so a Multiply shadow mesh came in as Normal and painted opaque white over the art under it, while the file's page shows the shadow. An `Xy` read straight after a mesh ends now also applies to that mesh. An `Xy` after the mesh's `Xd` and `XW` stays the next object's. --- crates/eps/src/import/ai/read.rs | 3 +++ crates/eps/src/import/ai/read/art.rs | 8 ++++++-- crates/eps/src/import/ai/read/ops.rs | 12 ++++++++---- crates/eps/src/import/ai/tests.rs | 29 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cc060f3f1..abcbf0219 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -183,6 +183,8 @@ struct Reader<'a> { blend_budget: usize, /// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read. group_ended: bool, + /// A gradient mesh just ended (`/End X!`), its own state (`Xy`) not yet read. + mesh_ended: bool, /// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on. layer_state: Option<(f32, BlendMode, bool, Knockout)>, /// Painted copies of clipping paths that have a fill (drawn under what they clip). @@ -288,6 +290,7 @@ impl<'a> Reader<'a> { compound_groups: HashSet::new(), text_looks: HashSet::new(), group_ended: false, + mesh_ended: false, layer_state: None, styles: HashMap::new(), palette: super::palette::Palette::default(), diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 6bf699ce9..89df83cd4 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -235,7 +235,9 @@ impl<'a> Reader<'a> { let mut node = self.new_node(NodeKind::Mesh(one), &gs); node.opacity = gs.opacity; node.blend = gs.blend; - return self.add(node, hidden); + self.add(node, hidden)?; + self.mesh_ended = true; + return Ok(()); } Err(crate::import::ai::mesh::NotOne::Unjoined) => { self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches") @@ -249,7 +251,9 @@ impl<'a> Reader<'a> { }; node.opacity = gs.opacity; node.blend = gs.blend; - self.add(node, hidden) + self.add(node, hidden)?; + self.mesh_ended = true; + Ok(()) } /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9431061fe..d9dbab22a 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -79,6 +79,7 @@ impl<'a> Reader<'a> { self.layer_state = None; } let group_ended = std::mem::take(&mut self.group_ended); + let mesh_ended = std::mem::take(&mut self.mesh_ended); let vals = self.operands(); let vals = self.take_head(vals); // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so @@ -201,7 +202,7 @@ impl<'a> Reader<'a> { } } // Transparency and state. - "Xy" => self.transparency(&vals), + "Xy" => self.transparency(&vals, mesh_ended), // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as // the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own. "Xd" if group_ended => { @@ -388,8 +389,9 @@ impl<'a> Reader<'a> { }); } - /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too. - fn transparency(&mut self, vals: &[V]) { + /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too, and right + /// after a gradient mesh's `/End X!` (before its `Xd` and `XW`), the mesh's. + fn transparency(&mut self, vals: &[V], mesh_ended: bool) { let n = Self::nums(vals); let [mode, opacity, rest @ ..] = n.as_slice() else { return }; let gs = &mut self.gs; @@ -403,7 +405,9 @@ impl<'a> Reader<'a> { }; gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); let gs = self.gs.clone(); - if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { + if self.last_object().is_some_and(|n| { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) || (mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) + }) { if let Some(n) = self.last_object() { n.opacity = gs.opacity; n.blend = gs.blend; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9b453de24..1c3ff8acc 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -410,6 +410,35 @@ fn transparency_of_objects_and_groups() { assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the next object's state isn't the group's"); } +#[test] +fn a_gradient_mesh_takes_the_transparency_written_after_it() { + // GUARDS: a mesh's own state (`Xy`) comes right after its `/End X!`, before `Xd` and `XW`, as a + // group's does after `U`; read as the next object's, a Multiply shadow mesh painted opaque. + let corner = + |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1); + let mesh = |x: i32| { + format!( + "/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n", + corner(x, 0, (x, 10), (x + 10, 0)), + corner(x, 40, (x + 10, 40), (x, 30)), + corner(x + 40, 40, (x + 40, 30), (x + 30, 40)), + corner(x + 40, 0, (x + 30, 0), (x + 40, 10)), + ) + }; + let art_ = format!( + "0 1 0 0 0 Xy\n{}1 0.8 0 0 0 Xy\n0 0 Xd\n6 () XW\n{}0 0 Xd\n6 () XW\n2 0.7 0 0 0 Xy\n{RED}{}f\n", + mesh(0), + mesh(50), + rect_path(100.0, 0.0, 110.0, 10.0) + ); + let doc = read_ok(&layer(&art_)).doc; + let a = art(&doc); + assert!(matches!(a[0].kind, NodeKind::Mesh(_)) && matches!(a[1].kind, NodeKind::Mesh(_)), "{a:#?}"); + assert_eq!((a[0].opacity, a[0].blend), (0.8, BlendMode::Multiply), "the state after the mesh is its own"); + assert_eq!((a[1].opacity, a[1].blend), (0.8, BlendMode::Multiply), "the next mesh keeps the current state"); + assert_eq!((a[2].opacity, a[2].blend), (0.7, BlendMode::Screen), "a state after the mesh's `Xd` and `XW` is the next object's"); +} + #[test] fn gradients() { let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4f383bd3d..b85ff6dbb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai`/EPS editing data: mesh corners with handle weights of 0 are read) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai`/EPS editing data: a gradient mesh takes the transparency written after it) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh takes the transparency (`Xy`) written right after its `/End X!`, before its `Xd` and `XW`, as a group takes the one after its `U`; it was read as the next object's, so a Multiply shadow mesh painted opaque white over the art under it, where the file's page shows the shadow. A user's file now draws such a mesh as its page does. Own-fixture test in `import::ai::tests`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh corner whose handle weights are 0 (one or both; the others are 1) is read like one whose weights are 1, so a mesh with such corners no longer leaves the whole file coming in as its page or PDF part; other weights still leave the mesh unread. Checked against the pages of two users' files: one opens from its layers 0.6% from its page, the other with 728 live meshes instead of its PDF part's 65,558 patches. Test `import::ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a PDF part whose pages are cropped to the art is recognised in a `UserUnit` page's units too (the art's box in those units, the artboard not the page in points), and its type is read from the first page only, since every such page holds all the art again: the copies on later pages were moved as the first page's and could be put into the layers' text objects. A page cropped to the art is taken only when every page is that box. Tests `import::tests_native::a_user_unit_page_cropped_to_the_art_is_read_in_its_units`, `type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`; scores unchanged | | 2026-10-30 | minor | `.ai` import: when the layers are compared with the page again with placed files at the corner of their box, the text objects the page doesn't draw are weighed with the images there, from the layers as read: before, the text an image covered at its first placement had already been left out, so a file whose image covered type only there came in without that type or as its PDF part. Test `import::tests_native::text_a_placed_file_covers_only_at_its_first_placement_is_kept`; scores unchanged | From 2d4e9dc9c952d96998394746c1be16f0eb915886 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:05:16 +0300 Subject: [PATCH 03/52] EPS import: the printer's crop marks after the layers aren't art outside them An Illustrator EPS draws its crop marks after the last layer, between %AI3_BeginCrops and %AI3_EndCrops (inside userdict /AI3_noCropMarks known not { ... } if). The reader took them for art outside the layers, and the whole file came in as its page in one layer. The section is now skipped, as other non-art sections are; the crop area is %AI3_Cropmarks. --- crates/eps/src/import/ai/read/types.rs | 3 +++ crates/eps/src/import/ai/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 95423c94e..fccbc6c48 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -246,6 +246,9 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[ ("BeginSymbolList", "EndSymbolList"), ("BeginTextDocument", "EndTextDocument"), ("BeginEncoding", "EndEncoding"), + // The printer's crop marks, drawn after the layers (`userdict /AI3_noCropMarks known not { + // … } if`); the crop area they mark is `%AI3_Cropmarks`. + ("BeginCrops", "EndCrops"), ]; /// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 1c3ff8acc..813113d5b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1929,3 +1929,15 @@ fn a_blend_record_is_read_to_its_length() { let (blend, warnings) = read_blend(&blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &longer)); assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}"); } + +/// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside +/// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads +/// from its layers, without them (the crop area is `%AI3_Cropmarks`). +#[test] +fn crop_marks_after_the_layers_are_not_art() { + let crops = "%AI3_BeginCrops\nuserdict /AI3_noCropMarks known not {\n0 A\nu\n0 R\n1 1 1 1 ([Registration]) 0 XS\n\ + 0 J 0 j 0.5 w 4 M []0 d\n0 XR\n-9 -400 m\n-36 -400 L\nS\nU\n} if\n%AI3_EndCrops\n"; + let r = read_ok(&format!("{}{crops}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert_eq!(art(&r.doc).len(), 1); + assert_eq!(r.doc.layers.len(), 1); +} From 67c8d86c6ff636653638f7ac3b6b34c0592d2886 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:54:02 +0300 Subject: [PATCH 04/52] .ai import: a page's type inside placed files needs no text object A .ai whose layers hold placed (linked) PDFs and no text object came in as its PDF part, saying its layers had no text objects for its page's text. The PDF part draws each placed file, type and all, where the layers show its preview, so type that lies inside the placed files (at either of their placements) is theirs and needs no text object. Type outside them still does. --- crates/eps/src/import/native/build.rs | 38 +++++++++++++++++++- crates/eps/src/import/tests_native.rs | 51 +++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c36013cb8..92d80c2cd 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -179,7 +179,12 @@ pub(super) fn build_from( return Err("its type is to come in as outlines, which only its page has".into()); } if infos.iter().all(|i| !i.shown) && !shown.is_empty() { - return Err("its page has text, and its layers have no text objects to put it in".into()); + if !in_placed_files(&shown, &doc, &placed_elsewhere) { + return Err("its page has text, and its layers have no text objects to put it in".into()); + } + // The page's type is all inside placed files: it is theirs, which their previews show, and + // needs no text object. + shown.clear(); } // The page's type is compared with every text object of the layers: that has its limits. if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { @@ -290,6 +295,37 @@ fn same_size(r: Option, page: &Document) -> bool { (r.width() - p.width()).abs() <= 0.5 && (r.height() - p.height()).abs() <= 0.5 } +/// Does all of `objects` (the page's text objects) lie inside placed files that show in `doc`? The +/// page draws a placed file, type and all, where the layers show its preview (a linked image, see +/// `ai::Reader::end_place`), at its first placement or at the other one that `placed` lists (see +/// [`elsewhere`]). +fn in_placed_files(objects: &[Vec>], doc: &Document, placed: &[(NodeId, Affine)]) -> bool { + let inside = |d: &Document| { + let mut files = vec![]; + placed_files(&d.layers, &mut files); + !files.is_empty() + && objects + .iter() + .all(|o| extent(o).is_some_and(|r| files.iter().any(|f: &Rect| f.inflate(OUTLINE_REACH, OUTLINE_REACH).contains_rect(r)))) + }; + inside(doc) || elsewhere(doc, placed).is_some_and(|other| inside(&other)) +} + +/// Where the placed files that show in `nodes` are: the linked images the editing data's reader +/// makes of them. +fn placed_files(nodes: &[Arc], out: &mut Vec) { + for n in nodes.iter().filter(|n| n.visible) { + match &n.kind { + NodeKind::Image(im) if im.link.is_some() => out.extend(n.visual_bounds()), + _ => { + if let Some(c) = n.children() { + placed_files(c, out); + } + } + } + } +} + /// Does the editing data's first artboard `board` (in the document read from it: art space through /// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above /// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index d4699b06d..d49b89540 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -266,6 +266,57 @@ fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_p assert!(read, "{warnings:?}"); } +/// A `.ai` whose layers hold a placed PDF and no text object: its PDF part draws the placed file, +/// type and all, where the layers show the file's preview. That type is the placed file's and needs +/// no text object, so the file comes in from its layers, the placed file where its matrix takes the +/// canvas point of art space's origin or at the corner of its box (see +/// `a_placed_file_at_its_boxs_corner_matches_the_page`). Type outside the placed file still needs +/// one, and without it the file comes in as its PDF part. +#[test] +fn type_of_the_page_inside_a_placed_file_needs_no_text_object() { + // A file placed on (30, 30)–(70, 70) whose preview is 2 × 2 pixels of half-transparent black, + // as in `a_placed_file_at_its_boxs_corner_matches_the_page`; with the template box, the canvas + // point of art space's origin puts it off the page, and the box's corner on it. + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(/files/pages.pdf)`\n\ + %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", + "\x7f\0\0\0".repeat(4) + ); + let at_origin = native(&layer("Pages", true, &place)); + let at_corner = at_origin.replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); + // The PDF part: the placed file's page in 50% grey, and type at (`x`, 55). + let pdf = |x: f64| { + let mut d = Document::new(100.0, 100.0); + let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); + let page = Node::path( + d.alloc_id(), + rect, + vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }, + ); + let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(x, 55.0), "Hi", style); + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text))); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, page).unwrap(); + d.insert(Some(layer), 1, text).unwrap(); + d + }; + for data in [&at_origin, &at_corner] { + let (d, warnings, read) = crate::layered_ai(&private(data), pdf(38.0), vec![], false, false, 1.0); + assert!(read, "{warnings:?}"); + assert_eq!(names(&d), ["Pages"]); + let mut links = vec![]; + d.visit_images(|_, im| links.extend(im.link.as_ref().map(|l| l.path.clone()))); + assert_eq!(links, ["/files/pages.pdf"], "{warnings:?}"); + assert!(warnings.iter().all(|w| !w.contains("no text object")), "the type is the placed file's, not left out: {warnings:?}"); + // Type beside the placed file is a text object's, which the layers don't have. + let (_, warnings, read) = crate::layered_ai(&private(data), pdf(5.0), vec![], false, false, 1.0); + assert!(!read, "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("its layers have no text objects")), "{warnings:?}"); + } +} + /// A palette of `[None]` and White, and a color group `Inks` holding a spot color, Red ink. const PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n1 (Inks) 1 Pg\n0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\nPB\n%AI5_EndPalette\n"; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b85ff6dbb..b24f51742 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai`/EPS editing data: a gradient mesh takes the transparency written after it) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: a page's type inside placed files needs no text object) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.ai` import: a file whose layers hold placed (linked) PDFs and no text object comes in from its layers when all of its page's type lies inside the placed files (at either of their placements): the PDF part draws each placed file, type and all, where the layers show its preview, so that type needs no text object. Such a file came in as its PDF part, saying its layers had no text objects for its page's text; type outside the placed files still needs one. Own-fixture test in `import::tests_native`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh takes the transparency (`Xy`) written right after its `/End X!`, before its `Xd` and `XW`, as a group takes the one after its `U`; it was read as the next object's, so a Multiply shadow mesh painted opaque white over the art under it, where the file's page shows the shadow. A user's file now draws such a mesh as its page does. Own-fixture test in `import::ai::tests`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh corner whose handle weights are 0 (one or both; the others are 1) is read like one whose weights are 1, so a mesh with such corners no longer leaves the whole file coming in as its page or PDF part; other weights still leave the mesh unread. Checked against the pages of two users' files: one opens from its layers 0.6% from its page, the other with 728 live meshes instead of its PDF part's 65,558 patches. Test `import::ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a PDF part whose pages are cropped to the art is recognised in a `UserUnit` page's units too (the art's box in those units, the artboard not the page in points), and its type is read from the first page only, since every such page holds all the art again: the copies on later pages were moved as the first page's and could be put into the layers' text objects. A page cropped to the art is taken only when every page is that box. Tests `import::tests_native::a_user_unit_page_cropped_to_the_art_is_read_in_its_units`, `type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`; scores unchanged | From 7b4a65e23100c0571291ff2366ce7278c279bceb Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:55:25 +0300 Subject: [PATCH 05/52] fix(ai-import): put placed files where they draw closer to the page The corner placement of placed files was tried only when the first placement failed the comparison with the page. A small logo in the wrong place differs too little to fail, so it stayed there. When the corner placement draws closer to the page and still matches it, use it. --- crates/eps/src/import/native/build.rs | 17 ++++++++++++++++- crates/eps/src/import/tests_native.rs | 25 +++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 92d80c2cd..bf1611fc7 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -247,7 +247,22 @@ pub(super) fn build_from( let unseen = prune_unseen(&frame, &mut view, &mut doc); let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); match compare(&frame, &mut view, &doc) { - Ok(note) => notes.extend(unseen_note(unseen).into_iter().chain(note)), + // Placed files at the other point their data gives, where that draws closer to the + // page: a small image in the wrong place differs too little to fail. + Ok(note) => { + let closer = other.and_then(|mut other| { + let unseen = prune_unseen(&frame, &mut view, &mut other); + let closer = view.difference(&other, frame.rect).any < view.difference(&doc, frame.rect).any; + closer.then(|| compare(&frame, &mut view, &other).ok()).flatten().map(|note| (other, unseen, note)) + }); + match closer { + Some((other, unseen, note)) => { + doc = other; + notes.extend(unseen_note(unseen).into_iter().chain(note)); + } + None => notes.extend(unseen_note(unseen).into_iter().chain(note)), + } + } // Placed files at the other point their data gives, where that draws as the page. Err(why) => match other.map(|mut other| { let unseen = prune_unseen(&frame, &mut view, &mut other); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index d49b89540..5183a46a8 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1728,6 +1728,31 @@ fn a_placed_file_at_its_boxs_corner_matches_the_page() { assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); } +#[test] +fn a_small_placed_file_goes_where_it_draws_closer_to_the_page() { + // A 2 × 2 placed file (a small logo on a large page) whose box corner the matrix takes to + // (30, 30)–(32, 32), as the page draws it. The template box's canvas point puts it at + // (10, 10)–(12, 12): on the page too, and small enough that the layers pass the comparison + // there all the same. + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8002 8002 4 4 0\n(logo.png)`\n\ + %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", + "\x7f\0\0\0".repeat(4) + ); + let data = native(&layer("Jig", true, &place)).replace("%AI5_NumLayers", "%AI3_TemplateBox: 211.5 -211.5 211.5 -211.5\n%AI5_NumLayers"); + let mut page = Document::new(100.0, 100.0); + let id = page.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 32.0, 32.0)); + let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }); + let l = page.layers[0].id; + page.insert(Some(l), 0, n).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&data), page, vec![], false, false, 1.0); + assert!(read, "{w:?}"); + let b = d.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 32.0, b.y1 - 32.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); +} + #[test] fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() { const OPAQUE: &str = ""; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b24f51742..971aa3984 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: a page's type inside placed files needs no text object) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: placed files go where they draw closer to the page) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.ai` import: a placed (linked) file whose data gives two placements is put at the corner of its box when that draws closer to the page and the layers still match it, not only when the first placement fails the comparison: a small logo in the wrong place differed too little to fail, and stayed there. Test `import::tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a file whose layers hold placed (linked) PDFs and no text object comes in from its layers when all of its page's type lies inside the placed files (at either of their placements): the PDF part draws each placed file, type and all, where the layers show its preview, so that type needs no text object. Such a file came in as its PDF part, saying its layers had no text objects for its page's text; type outside the placed files still needs one. Own-fixture test in `import::tests_native`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh takes the transparency (`Xy`) written right after its `/End X!`, before its `Xd` and `XW`, as a group takes the one after its `U`; it was read as the next object's, so a Multiply shadow mesh painted opaque white over the art under it, where the file's page shows the shadow. A user's file now draws such a mesh as its page does. Own-fixture test in `import::ai::tests`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh corner whose handle weights are 0 (one or both; the others are 1) is read like one whose weights are 1, so a mesh with such corners no longer leaves the whole file coming in as its page or PDF part; other weights still leave the mesh unread. Checked against the pages of two users' files: one opens from its layers 0.6% from its page, the other with 728 live meshes instead of its PDF part's 65,558 patches. Test `import::ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`; scores unchanged | From 1a5f0fb099a1ae1b8524a0854af6dc59af1ba5e9 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:47:34 +0300 Subject: [PATCH 06/52] fix(ai-import): space type on a path round curves as the file lays it out Type on a path from the editing data's text document came in at the font's advances, while the file's own layout (and its page) closes the glyphs up round the outside of a curve and opens them up round the inside, even where the frame keeps no spacing. Round a circle the type ran on past where the page ends it. The spacing (Type on a Path Options > Spacing) that reproduces the layout is now fitted from the layout's glyph positions, advances and turns, using the text engine's own model, and set on the imported text object. Corners and straight runs are left out of the fit. --- crates/eps/src/import/ate.rs | 108 +++++++++++++++++++++++++- crates/eps/src/import/tests_native.rs | 78 +++++++++++++++++++ docs/file-format-parity.md | 3 +- 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 21198305d..875a046fc 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -281,6 +281,8 @@ pub(super) struct Story { paras: Vec<(usize, Para)>, /// `(characters, style)` in order. runs: Vec<(usize, Style)>, + /// Type on a path: the spacing its first frame's layout implies ([`path_spacing`]). + path_spacing: f64, } #[derive(Clone, Debug)] @@ -408,8 +410,11 @@ impl Texts { return None; } // The first frame's layout, when the file keeps one: where its lines start. - let (mut anchor, mut lines) = ((0.0, 0.0), vec![]); + let (mut anchor, mut lines, mut path_spacing) = ((0.0, 0.0), vec![], 0.0); if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) { + if matches!(frames.first(), Some(FrameKind::OnPath { .. })) { + path_spacing = self::path_spacing(f); + } if let Some(a) = f.get("0").and_then(|a| a.get("0")).and_then(Val::nums) && let [x, y, ..] = a.as_slice() { @@ -441,7 +446,7 @@ impl Texts { if runs.is_empty() { runs.push((text.chars().count(), self.style(None))); } - Some(Story { text, anchor, lines, frames, paras, runs }) + Some(Story { text, anchor, lines, frames, paras, runs, path_spacing }) } /// A paragraph's attributes: its own keys, else the document's. @@ -572,6 +577,50 @@ fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 { ((before + within) / total).clamp(0.0, 1.0) } +/// The spacing (Type on a Path Options › Spacing) that the layout `f` of type on a path implies. +/// The file closes its glyphs up round the outside of a curve and opens them up round the inside, +/// even where its frame keeps no spacing; the text engine spaces glyphs that way by the spacing +/// (`vectorcraft_text`'s `path_steps`): between two glyphs' centres, the path's length `s`, their +/// advances' mean `a` and how far it turns `t` (away from their tops) make `s + t × spacing = a`. +/// Fitted over every pair of the layout's glyphs; none where the path doesn't turn under them. +fn path_spacing(f: &Val) -> f64 { + let mut found = vec![]; + find_nodes(f, "G", &mut found); + let (mut sum, mut turns) = (0.0, 0.0); + for g in found { + // Each glyph's origin, its matrix (along its baseline, then towards its foot) and where it + // ends along the line; the lists end with where the line ends. + let Some(at) = g.get("21") else { continue }; + let list = |k: &str| at.get(k).and_then(Val::nums).unwrap_or_default(); + let (xs, ys, matrices, ends) = (list("1"), list("2"), list("3"), list("4")); + let glyphs: Vec<(Point, f64, kurbo::Vec2, kurbo::Vec2)> = xs + .iter() + .zip(&ys) + .zip(matrices.as_chunks::<6>().0) + .zip(ends.windows(2)) + .map(|(((x, y), [a, b, c, d, ..]), e)| { + let advance = e.get(1).zip(e.first()).map_or(0.0, |(e1, e0)| e1 - e0); + let along = kurbo::Vec2::new(*a, *b); + (Point::new(*x, *y) + along * (advance * 0.5), advance, along, kurbo::Vec2::new(*c, *d)) + }) + .collect(); + for w in glyphs.windows(2) { + let [(p, a0, along, foot), (q, a1, next, _)] = w else { continue }; + let turn = next.dot(*foot).atan2(next.dot(*along)); + // The length of the arc between the centres (the path is taken to be round there). A + // corner (a turn of more than a radian between two glyphs) isn't round: left out. + let half = turn.abs() * 0.5; + let length = if half > 1e-9 { (*q - *p).hypot() * half / half.sin() } else { (*q - *p).hypot() }; + if turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() { + sum += ((a0 + a1) * 0.5 - length) * turn; + turns += turn * turn; + } + } + } + let spacing = sum / turns; + if turns > 1e-6 && spacing.is_finite() { spacing.clamp(-1000.0, 1000.0) } else { 0.0 } +} + impl Story { /// How many bytes of text the story has. pub(super) fn len(&self) -> usize { @@ -711,6 +760,9 @@ impl Story { if paras.len() > 1 && paras.iter().any(|p| *p != t.para) { t.paras = paras; } + if matches!(kind, TextKind::OnPath { .. }) && !threaded { + t.path_spacing = self.path_spacing * scale; + } t.kind = kind; t.xf = xf; Some(Node::new(id, NodeKind::Text(Box::new(t)))) @@ -1015,9 +1067,61 @@ mod tests { caps: 0, }, )], + path_spacing: 0.0, } } + /// A layout of glyphs `advances` wide round a circle of radius `radius` (y down), from its left, + /// outside it (their feet towards its centre) or inside it, the length of the circle between + /// their centres `fraction` of their advances' mean. + fn round_layout(advances: &[f64], radius: f64, outside: bool, fraction: f64) -> Val { + let sign = if outside { 1.0 } else { -1.0 }; + let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]); + let mut angle = std::f64::consts::PI; + for (i, a) in advances.iter().enumerate() { + if i > 0 { + angle += sign * (advances[i - 1] + a) * 0.5 * fraction / radius; + } + // Outside, clockwise on the page (y down); inside, the other way round. + let along = (-angle.sin() * sign, angle.cos() * sign); + let centre = (radius * angle.cos(), radius * angle.sin()); + xs.push(centre.0 - along.0 * a * 0.5); + ys.push(centre.1 - along.1 * a * 0.5); + matrices.extend([along.0, along.1, -along.1, along.0, 0.0, 0.0]); + ends.push(ends[i] + a); + } + let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::>().join(" "); + let doc = format!( + "/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>", + nums(&xs), + nums(&ys), + nums(&matrices), + nums(&ends) + ); + read(doc.as_bytes()).unwrap().get("0").unwrap().clone() + } + + /// The file lays type on a path out closer round the outside of a curve and further apart round + /// the inside: the spacing that does that is read from its layout, as the text engine's model + /// has it (length + turn × spacing = advance). + #[test] + fn the_layout_of_type_on_a_path_gives_its_spacing() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + // Outside a circle of radius 100, 10 above the path: the centres are 100/110 of the advances apart. + let spacing = path_spacing(&round_layout(&advances, 100.0, true, 100.0 / 110.0)); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + // Inside it, 20 above the path: 100/80 of them. + let spacing = path_spacing(&round_layout(&advances, 100.0, false, 100.0 / 80.0)); + assert!((spacing - 20.0).abs() < 1e-6, "{spacing}"); + // At their advances: none. + assert!(path_spacing(&round_layout(&advances, 100.0, true, 1.0)).abs() < 1e-6); + // Corners (here a turn of about four radians from glyph to glyph) aren't taken for a curve. + assert_eq!(path_spacing(&round_layout(&advances, 5.0, true, 1.0)), 0.0); + // A straight line turns nowhere, and a layout without glyphs says nothing. + assert_eq!(path_spacing(&round_layout(&advances, 1e12, true, 0.9)), 0.0); + assert_eq!(path_spacing(&read(b"/99 /F").unwrap()), 0.0); + } + /// The leading of the text object a story comes in as. fn leading_of(story: &Story) -> Option { let node = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY)?; diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 5183a46a8..fb1c65c91 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1490,6 +1490,84 @@ fn area_type_and_type_on_a_path_come_from_the_text_document() { assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); } +/// A story of `text` on frame 0 whose layout sets glyphs `advance` wide round the outside of a +/// circle of radius `radius`, the circle's length between their centres `fraction` of an advance. +fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String { + let n = text.chars().count(); + let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![]); + for i in 0..=n { + // From the circle's left, clockwise on the canvas (y down), feet towards its centre. + let angle = std::f64::consts::PI + i as f64 * advance * fraction / radius; + let along = (-angle.sin(), angle.cos()); + xs.push(radius * angle.cos() - along.0 * advance * 0.5); + ys.push(radius * angle.sin() - along.1 * advance * 0.5); + matrices.push(format!("{} {} {} {} 0 0", along.0, along.1, -along.1, along.0)); + ends.push(i as f64 * advance); + } + let nums = |v: &[f64]| v.iter().map(|x| format!("{x}")).collect::>().join(" "); + format!( + "<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \ + /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 12.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> ] \ + /2 [ << /99 /PC /6 [ << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >> ] >> ] >> >>", + nums(&xs), + nums(&ys), + matrices.join(" "), + nums(&ends) + ) +} + +/// The file closes type up round the outside of a curve (its layout puts the glyphs' centres +/// closer than their advances), though the frame has no spacing of its own: the type comes in +/// spaced as the layout is, not at its advances, so it doesn't run on past where the page ends it. +#[test] +fn type_round_a_circle_is_spaced_as_the_file_lays_it_out() { + // A circle of radius 40 round the middle of the page, in four cubics from its right, clockwise + // on the page; the type from its left, over the top, round almost all of it. + let k = 40.0 * 0.552_284_75; + let points = [ + (90.0, 50.0), + (90.0, 50.0 - k), + (50.0 + k, 10.0), + (50.0, 10.0), + (50.0 - k, 10.0), + (10.0, 50.0 - k), + (10.0, 50.0), + (10.0, 50.0 + k), + (50.0 - k, 90.0), + (50.0, 90.0), + (50.0 + k, 90.0), + (90.0, 50.0 + k), + (90.0, 50.0), + ]; + let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect(); + let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::>().join(" "); + let frame = + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] >> >> >>", (0..4).map(segment).collect::>().join(" ")); + // Glyphs 5 apart, their centres 5 × 40/48 apart round the circle: spaced 8 above the path. + let doc = document_of(&[frame], &[round_story("abcdefgh", 5.0, 40.0, 40.0 / 48.0)]); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + assert!(matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }), "{:?}", t.kind); + assert!((t.path_spacing - 8.0).abs() < 0.05, "{}", t.path_spacing); + // Laid out, the glyphs' centres are 40/48 of their advances apart round the circle. + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let centres: Vec<(f64, f64)> = layout + .glyphs + .iter() + .map(|g| (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0, g.advance)) + .map(|(x, y, a)| (y.atan2(x), a)) + .collect(); + assert_eq!(centres.len(), 8); + for w in centres.windows(2) { + let turn = (w[1].0 - w[0].0 + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + let ratio = turn.abs() * 40.0 / ((w[0].1 + w[1].1) * 0.5); + assert!((ratio - 40.0 / 48.0).abs() < 0.01, "{ratio}"); + } +} + #[test] fn threaded_area_type_flows_through_its_frames() { let frames = [ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 971aa3984..4b487b5a4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: placed files go where they draw closer to the page) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: type on a path spaced round curves as the file lays it out) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.ai`/EPS editing data: type on a path is spaced round curves as the file's own layout spaces it, closer round the outside and further apart round the inside, though its frame keeps no spacing: the Type on a Path Options › Spacing that does that is fitted from the layout's glyph positions, where it came in at the font's advances and ran on past where the page ends it. In 8 files of a user's set with type round curves (19 stories), the fitted spacing is a fifth of the line's ascent outside a curve (17 stories) and half of it inside (2), and the 4 files whose pages were measured space their glyphs as their layouts do. Own-fixture tests in `import::tests_native` and `import::ate`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file whose data gives two placements is put at the corner of its box when that draws closer to the page and the layers still match it, not only when the first placement fails the comparison: a small logo in the wrong place differed too little to fail, and stayed there. Test `import::tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a file whose layers hold placed (linked) PDFs and no text object comes in from its layers when all of its page's type lies inside the placed files (at either of their placements): the PDF part draws each placed file, type and all, where the layers show its preview, so that type needs no text object. Such a file came in as its PDF part, saying its layers had no text objects for its page's text; type outside the placed files still needs one. Own-fixture test in `import::tests_native`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh takes the transparency (`Xy`) written right after its `/End X!`, before its `Xd` and `XW`, as a group takes the one after its `U`; it was read as the next object's, so a Multiply shadow mesh painted opaque white over the art under it, where the file's page shows the shadow. A user's file now draws such a mesh as its page does. Own-fixture test in `import::ai::tests`; scores unchanged | From a0da3faffebafc0772b5a1902d66fd55b60501e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 10:10:21 -0700 Subject: [PATCH 07/52] Swatches: a Shift-click after switching documents starts a new range The panel cleared its swatch selection when the active document or the selected art changed, but kept the Shift-click range anchor. A Shift-click in the other document then selected every swatch from a name clicked in the first document (for example White) to the clicked one. The anchor is now cleared with the selection, so the Shift-click selects only the clicked swatch. --- crates/ui-egui/src/panels/swatches/body.rs | 1 + crates/ui-egui/src/panels/swatches/tests.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs index 9147960ba..611b12d6d 100644 --- a/crates/ui-egui/src/panels/swatches/body.rs +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -159,6 +159,7 @@ pub(super) fn focus_swatch(app: &VectorcraftApp, ui: &Ui, active: Option<&str>) let reselected = last_art.as_ref().is_some_and(|l| *l != art); if reselected { set_pstate(ui.ctx(), "swatch-selected", Vec::::new()); + set_pstate(ui.ctx(), "swatch-anchor", String::new()); } let changed = reselected || last_art.is_none() || last_swatch.as_deref() != active; set_pstate(ui.ctx(), "swatch-focus", (Some(art), active.map(str::to_string))); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index 7d751fe9d..c65941370 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -133,6 +133,21 @@ fn modifier_clicks_select_several_swatches_and_groups() { assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone"); } +#[test] +fn switching_documents_clears_the_swatch_range_anchor() { + let mut app = app(); + let ctx = context(); + click_tile(&mut app, &ctx, "White", Modifiers::NONE, 0.0); + assert_eq!(selected(&ctx), ["White"]); + + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + frame(&mut app, &ctx, vec![], 1.0, show); + assert!(selected(&ctx).is_empty()); + + click_tile(&mut app, &ctx, "Black", Modifiers::SHIFT, 2.0); + assert_eq!(selected(&ctx), ["Black"]); +} + /// A color swatch or a recent color clicked in the panel becomes the Color Guide's base color /// (#3137); a modifier click only selects. #[test] From e556dacbb61f796c32219d42cfb43ca8b84ae03d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 31 Oct 2026 01:11:25 +0700 Subject: [PATCH 08/52] =?UTF-8?q?Add-ons:=20Artwork=20Cleanup=20=E2=80=94?= =?UTF-8?q?=20text=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Plug-ins › Artwork Cleanup and a hideable Text row in its panel gain text tools, each one undo step: - Break Text into Lines / Paragraphs / Words / Glyphs: point type pieces placed by the glyph of their first character, keeping their styles. - Combine Point Text: one-line point type merged into as few objects as it can, every glyph staying where it was (tracking and spaces, checked against the layout). - Split Text at Cursor, Remove Line Breaks, Remove Paragraph Breaks, Set Alignment, Keep Position, Reset Text Transform and Replace Missing Fonts. --- crates/engine/src/cmd/addons/cleanup.rs | 10 +- crates/engine/src/cmd/addons/cleanup/text.rs | 865 ++++++++++++++++++ .../src/cmd/addons/cleanup/text/tests.rs | 307 +++++++ crates/engine/tests/command_sweep.rs | 54 ++ crates/ui-egui/src/addons/cleanup.rs | 150 ++- crates/ui-egui/src/addons/cleanup/tests.rs | 108 +++ crates/ui-egui/src/i18n/bg.tsv | 44 + crates/ui-egui/src/i18n/ca.tsv | 44 + crates/ui-egui/src/i18n/cs.tsv | 44 + crates/ui-egui/src/i18n/da.tsv | 44 + crates/ui-egui/src/i18n/de.tsv | 44 + crates/ui-egui/src/i18n/el.tsv | 44 + crates/ui-egui/src/i18n/es.tsv | 44 + crates/ui-egui/src/i18n/fi.tsv | 44 + crates/ui-egui/src/i18n/fr.tsv | 44 + crates/ui-egui/src/i18n/hu.tsv | 44 + crates/ui-egui/src/i18n/id.tsv | 44 + crates/ui-egui/src/i18n/it.tsv | 44 + crates/ui-egui/src/i18n/ja.tsv | 44 + crates/ui-egui/src/i18n/ka.tsv | 44 + crates/ui-egui/src/i18n/ko.tsv | 44 + crates/ui-egui/src/i18n/nl.tsv | 44 + crates/ui-egui/src/i18n/pl.tsv | 44 + crates/ui-egui/src/i18n/pt-br.tsv | 44 + crates/ui-egui/src/i18n/ru.tsv | 44 + crates/ui-egui/src/i18n/sv.tsv | 44 + crates/ui-egui/src/i18n/tr.tsv | 44 + crates/ui-egui/src/i18n/uk.tsv | 44 + crates/ui-egui/src/i18n/vi.tsv | 44 + crates/ui-egui/src/i18n/zh-hans.tsv | 44 + crates/ui-egui/src/i18n/zh-hant.tsv | 44 + docs/mcp.md | 43 + docs/target-app-parity.md | 11 +- docs/ui-parity.md | 4 +- 34 files changed, 2637 insertions(+), 15 deletions(-) create mode 100644 crates/engine/src/cmd/addons/cleanup/text.rs create mode 100644 crates/engine/src/cmd/addons/cleanup/text/tests.rs diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index 0b29b07e2..972fb7904 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -1,10 +1,12 @@ //! Artwork Cleanup (`addon.cleanup.*`), a built-in add-on: Simplify Anchors (Keep Shape), the //! Document Health Check (check, show and fix the issues it finds), Deep Ungroup and Rejoin Path -//! Pieces. Its panel and its submenu, Object › Plug-ins › Artwork Cleanup, are the UI's. +//! Pieces, and its text tools ([`text`]). Its panel and its submenu, Object › Plug-ins › Artwork +//! Cleanup, are the UI's. mod checks; #[cfg(test)] mod tests; +mod text; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -30,7 +32,7 @@ const FIX_ROUNDS: usize = 3; const MAX_LISTED: usize = 1000; pub fn specs() -> Vec { - vec![ + let mut v = vec![ cmd!( "addon.cleanup.simplify", "Simplify Anchors (Keep Shape)…", @@ -85,7 +87,9 @@ pub fn specs() -> Vec { has_selection, rejoin ), - ] + ]; + v.extend(text::specs()); + v } /// The paths (not guides) under `roots`, compound path members included. diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs new file mode 100644 index 000000000..26a37efbf --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -0,0 +1,865 @@ +//! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point +//! Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset +//! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters' +//! styles and stay where the layout drew them: each is moved so the glyph of its first character +//! lands where it was. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, ParaStyle, RubySpan, TextKind, TextObject}; +use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit}; + +use crate::cmd::textedit::editing_range; +use crate::cmd::typecmd::refresh_bounds; +use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Most text objects one command takes. +const MAX_TEXTS: usize = 10_000; +/// Most pieces Break Text and Split Text at Cursor make at once. +const MAX_PIECES: usize = 20_000; +/// Most breaks Remove Line / Paragraph Breaks removes at once. +const MAX_BREAKS: usize = 200_000; +/// Most pieces Combine Point Text tries to join, counted over every try: past it, what it joined +/// so far is the result. +const MAX_JOIN_TRIES: usize = 200_000; +/// How far (pt) a glyph Combine Point Text sets may land from where it was. +const TOLERANCE: f64 = 0.01; +/// Combine Point Text: pieces whose baselines are within this many ems of each other share a line +/// (the difference becomes baseline shift); a gap wider than `MAX_GAP` ems, or an overlap of more +/// than `MAX_OVERLAP`, starts another object. +const SAME_LINE: f64 = 0.4; +const MAX_GAP: f64 = 10.0; +const MAX_OVERLAP: f64 = 1.0; +/// Tracking Combine Point Text may give a character (thousandths of an em). +const TRACKING: std::ops::RangeInclusive = -1000.0..=10000.0; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.cleanup.breakText", + "Break Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}", + has_selection, + break_text + ), + cmd!( + "addon.cleanup.combineText", + "Combine Point Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", + has_selection, + combine_text + ), + cmd!( + "addon.cleanup.removeBreaks", + "Remove Breaks", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool, else in all the text of the selected type, as one undo step; a break between two words becomes a space → {ids, removed}", + has_selection, + remove_breaks + ), + cmd!( + "addon.cleanup.splitText", + "Split Text at Cursor", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{} while a Type tool edits horizontal point or area type: the text before the selected characters, they and the text after become point type objects of their own (at a cursor: before and after it), each where its characters were, as one undo step; spaces at the ends of each piece go, and editing ends with the pieces selected → {ids}", + editing_text, + split_text + ), + cmd!( + "addon.cleanup.alignInPlace", + "Set Alignment, Keep Position", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}", + has_selection, + align_in_place + ), + cmd!( + "addon.cleanup.resetTextTransform", + "Reset Text Transform", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}", + has_selection, + reset_transform + ), + cmd!( + "addon.cleanup.replaceFonts", + "Replace Missing Fonts…", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}", + has_doc, + replace_fonts + ), + ] +} + +/// Enabled while a Type tool edits text. +fn editing_text(s: &Session) -> std::result::Result<(), String> { + match s.tool_options().get("editing") { + Some(v) if !v.is_null() => Ok(()), + _ => Err("Click in type with a Type tool to split it".into()), + } +} + +/// The type objects under `roots` (inside groups and layers, not live art) that can be edited. +fn texts_under(d: &Document, roots: &[NodeId]) -> Result> { + fn visit(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Text(_) => out.push(n.id), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + _ => {} + } + } + let mut out = vec![]; + roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); + let editable = d.editable(); + out.retain(|id| editable(*id)); + out.dedup(); + if out.len() > MAX_TEXTS { + return Err(EngineError::Other("Too many type objects to change at once".into())); + } + Ok(out) +} + +fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> { + match &d.node(id)?.kind { + NodeKind::Text(t) => Some(t), + _ => None, + } +} + +fn layout(t: &TextObject) -> TextLayout { + vectorcraft_text::layout(FontDb::global(), t) +} + +/// Where the layout draws the character at `byte` (the one it starts in): its glyph's pen position. +fn glyph_at(lay: &TextLayout, byte: usize) -> Option { + let start = lay.glyphs.iter().filter(|g| g.len > 0 && g.byte <= byte).map(|g| g.byte).max()?; + lay.glyphs.iter().find(|g| g.len > 0 && g.byte == start).map(|g| g.origin) +} + +/// The pen position of the glyph that starts at `byte`, if one does. +fn glyph_starting(lay: &TextLayout, byte: usize) -> Option { + lay.glyphs.iter().find(|g| g.len > 0 && g.byte == byte).map(|g| g.origin) +} + +/// The first glyph of line `l`, in text order. +fn line_start(lay: &TextLayout, l: usize) -> Option { + let line = lay.lines.get(l)?; + let g = lay.glyphs.get(line.glyph_start..line.glyph_end)?.iter().filter(|g| g.len > 0).min_by_key(|g| g.byte)?; + Some(g.origin) +} + +/// `a..b` of `text` without the white space at its ends; None when nothing else is left. +fn trimmed(text: &str, a: usize, b: usize) -> Option<(usize, usize)> { + let s = text.get(a..b)?; + let start = a + (s.len() - s.trim_start().len()); + let end = a + s.trim_end().len(); + (start < end).then_some((start, end)) +} + +/// Paragraph alignment as point type keeps it: justified lines align to their side. +fn point_justify(j: Justify) -> Justify { + match j { + Justify::JustifyLeft | Justify::JustifyAll => Justify::Left, + Justify::JustifyCenter => Justify::Center, + Justify::JustifyRight => Justify::Right, + j => j, + } +} + +#[derive(Clone, Copy, PartialEq)] +enum By { + Lines, + Paragraphs, + Words, + Glyphs, +} + +/// A type object being cut into point type pieces: its text as drawn (Shrink Text to Fit's sizes +/// taken in) and its layout. +struct Source { + t: TextObject, + lay: TextLayout, + text: String, +} + +impl Source { + /// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise. + fn of(d: &Document, id: NodeId) -> Option { + let t = text_of(d, id)?; + if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) { + return None; + } + let lay = layout(t); + // Text left out of a frame would be lost with it. + if lay.overflow { + return None; + } + let t = if (lay.fit_scale - 1.0).abs() > 1e-9 { vectorcraft_text::scaled(t, lay.fit_scale) } else { t.clone() }; + Some(Self { text: t.plain_text(), t, lay }) + } + + /// The byte ranges of the pieces `by` cuts the text into. + fn ranges(&self, by: By) -> Vec<(usize, usize)> { + let text = &self.text; + match by { + By::Paragraphs => { + let mut out = vec![]; + let mut a = 0; + for (i, _) in text.match_indices('\n') { + out.push((a, i)); + a = i + 1; + } + out.push((a, text.len())); + out + } + By::Lines => self.lay.lines.iter().map(|l| (l.start, l.end)).collect(), + By::Words => { + let mut out = vec![]; + for l in &self.lay.lines { + let Some(s) = text.get(l.start..l.end) else { continue }; + let mut word: Option = None; + for (i, c) in s.char_indices() { + match (c.is_whitespace(), word) { + (true, Some(w)) => { + out.push((l.start + w, l.start + i)); + word = None; + } + (false, None) => word = Some(i), + _ => {} + } + } + if let Some(w) = word { + out.push((l.start + w, l.end)); + } + } + out + } + By::Glyphs => { + // One piece per cluster (a ligature's characters stay together). + let mut clusters = std::collections::BTreeMap::new(); + for g in self.lay.glyphs.iter().filter(|g| g.len > 0) { + let end = clusters.entry(g.byte).or_insert(g.byte + g.len); + *end = (*end).max(g.byte + g.len); + } + clusters.into_iter().collect() + } + } + } + + /// Point type holding bytes `a..b` (white space at its ends left out) where they were drawn. + /// Lines the layout wrapped end in a forced line break; `lists` keeps bulleted paragraphs' + /// bullets where the piece starts its paragraph. + fn piece(&self, (a, b): (usize, usize), lists: bool) -> Option { + let (a, b) = trimmed(&self.text, a, b)?; + let mut p = TextObject::point(Point::ZERO, "", CharStyle::default()); + p.runs = edit::slice_runs(&self.t.runs, a, b); + p.xf = self.t.xf; + p.optical_margin = self.t.optical_margin; + p.system_layout = self.t.system_layout; + p.fractional_widths = self.t.fractional_widths; + p.ruby = + self.t.ruby.iter().filter(|r| a <= r.start && r.end <= b).map(|r| RubySpan { start: r.start - a, end: r.end - a, ..r.clone() }).collect(); + // The paragraphs it holds, with their styles; the first keeps its bullet only from its start. + let starts_para = self.text.get(..a).is_some_and(|s| s.rsplit('\n').next().is_some_and(|line| line.trim().is_empty())); + let styles = self.t.paragraph_styles(); + let mut paras: Vec = styles.get(self.t.paragraphs_in(a, b))?.to_vec(); + for (i, para) in paras.iter_mut().enumerate() { + para.justify = point_justify(para.justify); + if !(lists && (i > 0 || starts_para) && para.list.as_ref().is_some_and(|l| l.number.is_none())) { + para.list = None; + } + } + p.set_paragraph_styles(paras); + // Lines the layout wrapped end in a forced line break, in place of the spaces they ended in. + let mut wraps = vec![]; + for next in self.lay.lines.iter().skip(1).filter(|l| a < l.start && l.start < b) { + let content = self.text.get(..next.start)?.trim_end_matches([' ', '\t']); + // A line that ends in a break of its own keeps it. + if !content.ends_with(['\n', '\u{2028}']) { + wraps.push((content.len().max(a) - a, next.start - a)); + } + } + for &(s, e) in wraps.iter().rev() { + edit::replace_range(&mut p.runs, s, e, "\u{2028}"); + p.ruby_after_replace(s, e, '\u{2028}'.len_utf8()); + } + self.place(&mut p, a)?; + Some(p) + } + + /// Move `p` (cut from byte `a`) so the glyph of its first character lands where it was drawn. + /// A piece of several lines that starts inside a left-aligned line takes a first-line indent, + /// so its later lines stay where they were too. + fn place(&self, p: &mut TextObject, a: usize) -> Option<()> { + let lay = layout(p); + let mut shift: Vec2 = glyph_at(&self.lay, a)? - glyph_at(&lay, 0)?; + let first = self.lay.lines.iter().rposition(|l| l.start <= a); + if let (Some(now), Some(was)) = (line_start(&lay, 1), first.and_then(|l| line_start(&self.lay, l + 1))) { + let off = was.x - (now.x + shift.x); + let left = match p.para.justify { + Justify::Left => true, + Justify::Auto => lay.lines.first().is_some_and(|l| !l.rtl), + _ => false, + }; + if left && off.abs() > 1e-6 { + p.edit_paras(Some(0..1), |para| para.first_line_indent -= off); + shift.x += off; + } + } + p.xf = self.t.xf * Affine::translate(shift); + Some(()) + } +} + +/// Put each object of `items` (id, the node, its pieces) out of the document, its pieces in its +/// place, as one undo step labelled `label`, the pieces selected → their ids. +fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, Vec)>) -> Result> { + s.edit(label, |d, sel| { + let (mut made, mut places) = (vec![], vec![]); + for (id, mut node, pieces) in items { + // A light template: the pieces take its paint and attributes, not its text. + node.kind = NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "", CharStyle::default()))); + let nodes: Vec = pieces + .into_iter() + .map(|mut t| { + refresh_bounds(&mut t); + let mut n = d.reid(&node); + n.kind = NodeKind::Text(Box::new(t)); + n + }) + .collect(); + made.extend(nodes.iter().map(|n| n.id)); + places.push((id, nodes)); + } + d.insert_beside(places, true)?; + sel.set(made.iter().copied()); + Ok(made.iter().map(|i| i.0).collect()) + }) +} + +fn ids_json(ids: &[NodeId]) -> Vec { + ids.iter().map(|i| i.0).collect() +} + +fn break_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.breakText"; + let by = match text_param(p, "by", C)?.unwrap_or("lines") { + "lines" => By::Lines, + "paragraphs" => By::Paragraphs, + "words" => By::Words, + "glyphs" => By::Glyphs, + other => return Err(bad(C, format!("`by` must be lines, paragraphs, words or glyphs, not {other:?}"))), + }; + let d = &s.doc()?.doc; + let (mut items, mut skipped, mut count) = (vec![], vec![], 0); + for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? { + let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else { + skipped.push(id); + continue; + }; + let ranges = src.ranges(by); + count += ranges.len(); + if count > MAX_PIECES { + return Err(EngineError::Other("Too many pieces to make at once".into())); + } + let pieces: Vec = ranges.into_iter().filter_map(|r| src.piece(r, by == By::Paragraphs)).collect(); + if pieces.is_empty() { + skipped.push(id); + } else { + items.push((id, node.clone(), pieces)); + } + } + if items.is_empty() { + return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); + } + let ids = replace_with_pieces(s, "Break Text", items)?; + Ok(json!({ "ids": ids, "skipped": ids_json(&skipped) })) +} + +fn split_text(s: &mut Session, _: &Value) -> Result { + let Some((id, a, b)) = editing_range(s)? else { + return Err(EngineError::Other("Click in type with a Type tool to split it".into())); + }; + let d = &s.doc()?.doc; + let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else { + return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); + }; + let text = &src.text; + let (a, b) = (edit::floor_char(text, a.min(b)), edit::floor_char(text, a.max(b))); + let cuts: Vec<(usize, usize)> = if a == b { vec![(0, a), (a, text.len())] } else { vec![(0, a), (a, b), (b, text.len())] }; + let pieces: Vec = cuts.into_iter().filter_map(|r| src.piece(r, true)).collect(); + if pieces.len() < 2 { + return Err(EngineError::Other("Nothing to split at the cursor".into())); + } + let node = node.clone(); + let ids = replace_with_pieces(s, "Split Text at Cursor", vec![(id, node, pieces)])?; + // The text edited is gone: the pieces stay selected. + s.set_tool_option("stopEditing", &Value::Bool(true)); + Ok(json!({ "ids": ids })) +} + +/// One piece of point type Combine Point Text takes: its text with its object's scale taken into +/// its characters, and where it starts and ends along its line in the frame of its turn and shear. +struct Piece { + id: NodeId, + t: TextObject, + /// The object's turn and shear (`xf` without its move). + turn: [f64; 4], + /// Where its first character's glyph starts on its baseline, in that frame. + at: Point, + /// Where its last glyph ends along the line, in that frame. + end: f64, + /// Its first character's size. + size: f64, + /// Each character's pen position from the first's (byte, x), as it lays out alone. + pens: Vec<(usize, f64)>, +} + +impl Piece { + fn of(d: &Document, id: NodeId) -> Option<(Self, &Node)> { + let node = d.node(id)?; + let NodeKind::Text(t) = &node.kind else { return None }; + let text = t.plain_text(); + if t.vertical || !matches!(t.kind, TextKind::Point) || text.trim().is_empty() || text.contains(['\n', '\u{2028}']) { + return None; + } + let mut t = (**t).clone(); + t.scale_text_attributes(); + t.style_scale()?; + let lay = layout(&t); + let line = lay.lines.first().filter(|l| !l.rtl && lay.lines.len() == 1)?; + let first = glyph_at(&lay, 0)?; + // The first glyph of each cluster (a mark drawn over it shares its byte). + let mut seen = std::collections::HashSet::new(); + let pens = lay.glyphs.iter().filter(|g| g.len > 0 && seen.insert(g.byte)).map(|g| (g.byte, g.origin.x - first.x)).collect(); + let end = lay.glyphs.iter().map(|g| g.origin.x + g.advance).fold(first.x, f64::max) - first.x; + let [a, b, c, dd, _, _] = t.xf.as_coeffs(); + let turn = Affine::new([a, b, c, dd, 0.0, 0.0]); + let at = turn.inverse() * (t.xf * Point::new(first.x, line.baseline)); + let size = t.runs.first()?.style.size; + if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) { + return None; + } + Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node)) + } + + /// Whether `o` is turned and sheared alike. + fn turned_like(&self, o: &Piece) -> bool { + self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0)) + } +} + +/// Whether two objects look alike apart from their text (paint, opacity, blending, effects). +fn same_look(a: &Node, b: &Node) -> bool { + a.appearance == b.appearance + && a.opacity == b.opacity + && a.blend == b.blend + && a.isolate == b.isolate + && a.knockout == b.knockout + && a.extras == b.extras +} + +/// How wide a space of `style` is. +fn space_width(style: &CharStyle) -> f64 { + let probe = TextObject::point(Point::ZERO, " ", CharStyle { tracking: 0.0, ..style.clone() }); + layout(&probe).glyphs.first().map_or(style.size * 0.25, |g| g.advance) +} + +/// `chain` (pieces left to right on one line) as one point type object whose glyphs land where +/// theirs were, or the index of the first piece that can't join the ones before it so. +fn merge(chain: &[&Piece]) -> std::result::Result { + let Some(first) = chain.first() else { return Err(0) }; + let mut runs = vec![]; + // Each piece's first byte, and the byte range of the character before it (whose tracking sets + // the gap). + let (mut starts, mut before) = (vec![], vec![]); + let mut text = String::new(); + for (k, p) in chain.iter().enumerate() { + if let Some(prev) = k.checked_sub(1).and_then(|i| chain.get(i)) { + let starts_blank = p.t.plain_text().starts_with(char::is_whitespace); + let last = prev.t.runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default(); + if !text.ends_with(char::is_whitespace) && !starts_blank && p.at.x - prev.end >= 0.5 * space_width(&last) { + runs.push(vectorcraft_doc::TextRun::new(" ", last)); + text.push(' '); + } + let c = text.chars().next_back().map_or(1, char::len_utf8); + before.push((text.len() - c, text.len())); + } + starts.push(text.len()); + // Baselines a little apart: the difference is baseline shift (up is positive). + let shift = first.at.y - p.at.y; + for r in &p.t.runs { + let mut r = r.clone(); + r.style.baseline_shift += shift; + text.push_str(&r.text); + runs.push(r); + } + } + let mut m = first.t.clone(); + m.set_all_paras(ParaStyle { justify: Justify::Left, ..first.t.para.clone() }); + let [a, b, c, d] = first.turn; + let base = Affine::new([a, b, c, d, 0.0, 0.0]) * Affine::translate(first.at.to_vec2()); + m.xf = base; + m.runs = runs; + m.ruby.clear(); + // Tracking after the character before each piece, until each lands where it was. + let targets: Vec = chain.iter().map(|p| p.at.x - first.at.x).collect(); + let mut lay = layout(&m); + for _ in 0..4 { + // A piece whose first character joins the one before it (a ligature) can't keep its place. + let xs: Vec = match starts.iter().enumerate().map(|(k, b)| glyph_starting(&lay, *b).map(|g| g.x).ok_or(k)).collect() { + Ok(xs) => xs, + Err(k) => return Err(k.max(1)), + }; + let x0 = xs.first().copied().unwrap_or(0.0); + let off: Vec = targets.iter().zip(&xs).map(|(t, x)| t - (x - x0)).collect(); + if off.iter().all(|o| o.abs() <= TOLERANCE * 0.5) { + break; + } + // Moving piece k moves those after it as far. + for (k, w) in off.windows(2).enumerate() { + let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue }; + if delta.abs() > 1e-9 { + edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0); + if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) { + return Err(k + 1); + } + } + } + lay = layout(&m); + } + // Every glyph where it was, within the tolerance; else the run ends before the piece that moved. + let x0 = glyph_starting(&lay, 0).map_or(0.0, |g| g.x); + for (k, p) in chain.iter().enumerate() { + let (Some(&start), Some(&target)) = (starts.get(k), targets.get(k)) else { return Err(k.max(1)) }; + let moved = p.pens.iter().any(|(byte, x)| glyph_starting(&lay, start + byte).is_none_or(|g| (g.x - x0 - target - x).abs() > TOLERANCE)); + if moved { + return Err(k.max(1)); + } + } + m.xf = base * Affine::translate((-x0, 0.0)); + Ok(m) +} + +fn combine_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.combineText"; + let d = &s.doc()?.doc; + let mut pieces: Vec<(Piece, &Node)> = + texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); + // Lines: pieces turned alike and looking alike, by baseline, then left to right. + pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x))); + let mut lines: Vec> = vec![]; + for (piece, node) in &pieces { + let fits = |l: &Vec<(&Piece, &Node)>| { + l.first() + .is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size)) + }; + match lines.iter_mut().find(|l| fits(l)) { + Some(l) => l.push((piece, node)), + None => lines.push(vec![(piece, node)]), + } + } + // Runs along each line: a wide gap or an overlap starts another. + let mut chains: Vec> = vec![]; + for mut l in lines { + l.sort_by(|x, y| x.0.at.x.total_cmp(&y.0.at.x)); + let mut chain: Vec<&Piece> = vec![]; + for (piece, _) in l { + if let Some(prev) = chain.last() { + let gap = piece.at.x - prev.end; + if gap > MAX_GAP * prev.size || gap < -MAX_OVERLAP * prev.size { + chains.push(std::mem::take(&mut chain)); + } + } + chain.push(piece); + } + chains.push(chain); + } + // Each run as one object, or as several where a piece can't keep its place joined. + let mut made: Vec<(NodeId, TextObject, Vec)> = vec![]; + let mut tries = 0; + 'chains: for chain in chains { + let mut rest: &[&Piece] = &chain; + while rest.len() > 1 { + let mut upto = rest.len(); + while let Some(part) = rest.get(..upto) { + tries += part.len(); + if tries > MAX_JOIN_TRIES { + break 'chains; + } + match merge(part) { + Ok(m) => { + let gone = part.iter().skip(1).map(|p| p.id).collect(); + if let Some(first) = part.first() { + made.push((first.id, m, gone)); + } + break; + } + // A run of one stays as it is. + Err(k) if k <= 1 => { + upto = 1; + break; + } + Err(k) => upto = k, + } + } + rest = rest.get(upto.max(1)..).unwrap_or_default(); + } + } + if made.is_empty() { + return Err(EngineError::Other("Select two or more one-line point type objects on one baseline to combine".into())); + } + let combined: usize = made.iter().map(|m| m.2.len()).sum(); + let ids: Vec = made.iter().map(|m| m.0.0).collect(); + s.edit("Combine Point Text", |d, sel| { + let mut gone = vec![]; + for (id, mut t, others) in made { + refresh_bounds(&mut t); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(t)); + } + gone.extend(others); + } + d.remove_many(&gone)?; + sel.set(ids.iter().map(|i| NodeId(*i))); + Ok(()) + })?; + Ok(json!({ "ids": ids, "combined": combined })) +} + +/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many +/// went. +fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize { + let text = t.plain_text(); + let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b)); + let Some(range) = text.get(a..b) else { return 0 }; + let mut prev = text.get(..a).and_then(|s| s.chars().next_back()); + let mut cuts = vec![]; + for (i, c) in range.char_indices() { + let at = a + i; + if c != brk { + prev = Some(c); + continue; + } + // The next character left: past the breaks that go too. + let after = at + c.len_utf8(); + let next = text.get(after..).and_then(|rest| rest.char_indices().find(|(j, n)| *n != brk || after + j >= b).map(|(_, n)| n)); + let space = prev.is_some_and(|p| !p.is_whitespace()) && next.is_some_and(|n| !n.is_whitespace()); + if space { + prev = Some(' '); + } + cuts.push((at, space)); + } + for &(at, space) in cuts.iter().rev() { + let insert = if space { " " } else { "" }; + let end = at + brk.len_utf8(); + t.splice_paras(at, end, insert); + edit::replace_range(&mut t.runs, at, end, insert); + t.ruby_after_replace(at, end, insert.len()); + } + cuts.len() +} + +fn remove_breaks(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.removeBreaks"; + let (brk, label, none) = match text_param(p, "kind", C)? { + Some("line") => ('\u{2028}', "Remove Line Breaks", "No line breaks to remove"), + Some("paragraph") => ('\n', "Remove Paragraph Breaks", "No paragraph breaks to remove"), + Some(other) => return Err(bad(C, format!("`kind` must be \"line\" or \"paragraph\", not {other:?}"))), + None => return Err(bad(C, "missing `kind`")), + }; + // The characters a Type tool has selected, else all the text of the selected type. + let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; + let d = &s.doc()?.doc; + let targets: Vec<(NodeId, usize, usize)> = match editing { + Some((id, a, b)) => vec![(id, a.min(b), a.max(b))].into_iter().filter(|(id, ..)| d.is_editable(*id)).collect(), + None => texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().map(|id| (id, 0, usize::MAX)).collect(), + }; + let count: usize = targets + .iter() + .filter_map(|(id, a, b)| { + let text = text_of(d, *id)?.plain_text(); + let (a, b) = (edit::floor_char(&text, *a), edit::floor_char(&text, *b)); + Some(text.get(a..b)?.matches(brk).count()) + }) + .sum(); + if count == 0 { + return Err(EngineError::Other(none.into())); + } + if count > MAX_BREAKS { + return Err(EngineError::Other("Too many breaks to remove at once".into())); + } + let (removed, ids) = s.edit(label, |d, _| { + let (mut removed, mut ids) = (0, vec![]); + for (id, a, b) in targets { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + let n = strip_breaks(t, a, b, brk); + if n > 0 { + refresh_bounds(t); + removed += n; + ids.push(id.0); + } + } + Ok((removed, ids)) + })?; + Ok(json!({ "ids": ids, "removed": removed })) +} + +/// Horizontal point type among the objects `p` names. +fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result> { + let d = &s.doc()?.doc; + let ids: Vec = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)? + .into_iter() + .filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))) + .collect(); + if ids.is_empty() { + return Err(EngineError::Other("Select point type".into())); + } + Ok(ids) +} + +/// Replace the type objects of `changed` as one undo step labelled `label` → their ids. +fn set_texts(s: &mut Session, label: &str, changed: Vec<(NodeId, TextObject)>, reset_angle: bool) -> Result { + let ids: Vec = changed.iter().map(|c| c.0.0).collect(); + s.edit(label, |d, _| { + for (id, mut t) in changed { + refresh_bounds(&mut t); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(t)); + if reset_angle { + n.bbox_angle = 0.0; + } + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids })) +} + +fn align_in_place(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.alignInPlace"; + let justify = match text_param(p, "align", C)? { + Some("left") => Justify::Left, + Some("center") => Justify::Center, + Some("right") => Justify::Right, + Some(other) => return Err(bad(C, format!("`align` must be \"left\", \"center\" or \"right\", not {other:?}"))), + None => return Err(bad(C, "missing `align`")), + }; + let ids = point_texts(s, p, C)?; + let d = &s.doc()?.doc; + let mut changed = vec![]; + for id in ids { + let Some(t) = text_of(d, id).filter(|t| !t.vertical && t.para_styles().any(|para| para.justify != justify)) else { continue }; + let was = layout(t); + // The longest line stays where it is. + let Some((i, line)) = was.lines.iter().enumerate().max_by(|a, b| (a.1.x1 - a.1.x0).total_cmp(&(b.1.x1 - b.1.x0))) else { continue }; + let mut n = t.clone(); + n.edit_paras(None, |para| para.justify = justify); + let Some(now) = layout(&n).lines.get(i).map(|l| l.x0) else { continue }; + n.xf *= Affine::translate((line.x0 - now, 0.0)); + changed.push((id, n)); + } + if changed.is_empty() { + return Err(EngineError::Other("The point type is aligned that way already".into())); + } + set_texts(s, "Set Alignment, Keep Position", changed, false) +} + +fn reset_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.resetTextTransform"; + let scale = switch_or(p, "scale", false, C)?; + let ids = point_texts(s, p, C)?; + let d = &s.doc()?.doc; + let mut changed = vec![]; + for id in ids { + let Some(t) = text_of(d, id) else { continue }; + let [a, b, c, dd, _, _] = t.xf.as_coeffs(); + // The object's own axes: its width along its baseline, and its height square to it. + let (sx, det) = (a.hypot(b), a * dd - b * c); + if !(sx > 1e-12 && sx.is_finite() && det.is_finite() && det.abs() > 1e-12) { + continue; + } + let sy = det.abs() / sx; + let unscaled = |t: &TextObject| t.runs.iter().all(|r| r.style.h_scale == 100.0); + let upright = b.abs() <= 1e-9 * sx && c.abs() <= 1e-9 * sy && a > 0.0 && dd > 0.0; + if upright && (!scale || ((sx - sy).abs() <= 1e-9 * sy && unscaled(t))) { + continue; + } + let mut n = t.clone(); + let w = if scale { + n.runs.iter_mut().for_each(|r| r.style.h_scale = 100.0); + sy + } else { + sx + }; + // The centre of the text stays where it is. + let centre = t.xf * layout(t).bounds.center(); + let turn = Affine::scale_non_uniform(w, sy); + let now = turn * layout(&n).bounds.center(); + n.xf = Affine::translate(centre - now) * turn; + changed.push((id, n)); + } + if changed.is_empty() { + return Err(EngineError::Other("The point type has no transform to reset".into())); + } + set_texts(s, "Reset Text Transform", changed, true) +} + +fn replace_fonts(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.replaceFonts"; + let default = CharStyle::default().font_family; + let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); + let db = FontDb::global(); + // Any name of the family is stored as its own name. + let family = match db.resolve(wanted, "Regular") { + Some((f, m)) if m != FontMatch::Missing => f.family.clone(), + _ => return Err(bad(C, "the replacement font is not available")), + }; + let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); + let d = &s.doc()?.doc; + let mut ids = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.runs.iter().any(|r| missing(&r.style)) + { + ids.push(n.id); + } + }); + } + if ids.is_empty() { + return Err(EngineError::Other("No fonts are missing".into())); + } + let runs = s.edit("Replace Missing Fonts", |d, _| { + let mut runs = 0; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { + // The style the font has closest to the one the character had. + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + r.style.font_family = family.clone(); + r.style.font_version = None; + r.style.font_axes.clear(); + runs += 1; + } + edit::normalize(&mut t.runs); + refresh_bounds(t); + } + Ok(runs) + })?; + Ok(json!({ "runs": runs, "font": family })) +} diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs new file mode 100644 index 000000000..a2fa3225f --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -0,0 +1,307 @@ +//! Artwork Cleanup's text tools: pieces stay where their glyphs were drawn and keep their styles, +//! each command is one undo step and refuses cleanly when there is nothing to do. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Justify, NodeId, NodeKind, TextKind, TextObject}; +use vectorcraft_geom::Point; + +use crate::{EngineError, Session}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, p: Value) -> NodeId { + NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +/// Each drawn character of `ids` (spaces left out) with where its glyph starts on the page, in +/// the order of the objects and their text. +fn glyphs(s: &Session, ids: &[NodeId]) -> Vec<(char, Point)> { + let mut out = vec![]; + for id in ids { + let t = obj(s, *id); + let text = t.plain_text(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let mut gs: Vec<_> = lay.glyphs.iter().filter(|g| g.len > 0).collect(); + gs.sort_by_key(|g| g.byte); + gs.dedup_by_key(|g| g.byte); + for g in gs { + let c = text[g.byte..].chars().next().unwrap(); + if !c.is_whitespace() { + out.push((c, t.xf * g.origin)); + } + } + } + out +} + +fn assert_same_places(was: &[(char, Point)], now: &[(char, Point)]) { + assert_eq!(was.iter().map(|g| g.0).collect::(), now.iter().map(|g| g.0).collect::()); + for (a, b) in was.iter().zip(now) { + assert!((a.1 - b.1).hypot() < 0.02, "{:?} moved to {:?}", a, b); + } +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn doc_json(s: &Session) -> String { + serde_json::to_string(&s.doc().unwrap().doc.layers).unwrap() +} + +fn other(r: Result) -> String { + match r { + Err(EngineError::Other(m)) => m, + r => panic!("expected a refusal, got {r:?}"), + } +} + +#[test] +fn break_into_words_keeps_places_styles_and_is_one_step() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 100, "text": "Hello big World", "size": 24})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 9, "size": 36, "tracking": 50})).unwrap(); + let rotated = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned words", "size": 18, "rotation": 30})); + let was = glyphs(&s, &[id, rotated]); + let before = doc_json(&s); + let steps = undo_steps(&s); + select(&mut s, &[id, rotated]); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap(); + let pieces = ids(&r); + assert_eq!(pieces.len(), 5); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(s.doc().unwrap().selection.objects.len(), 5, "the pieces are selected"); + let texts: Vec = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect(); + assert_eq!(texts, ["Hello", "big", "World", "Turned", "words"]); + assert_same_places(&was, &glyphs(&s, &pieces)); + let big = obj(&s, pieces[1]); + assert!(matches!(big.kind, TextKind::Point)); + assert_eq!((big.runs[0].style.size, big.runs[0].style.tracking), (36.0, 50.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); +} + +#[test] +fn break_area_type_into_lines_paragraphs_and_glyphs() { + for (by, count) in [("lines", None), ("paragraphs", Some(2)), ("glyphs", None)] { + let mut s = session(); + let id = text( + &mut s, + json!({"x": 40, "y": 40, "text": "A paragraph long enough to wrap in its frame\nSecond one", "size": 14, "area": {"width": 140, "height": 200}}), + ); + let t = obj(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.lines.len() >= 3, "the first paragraph wraps"); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let r = s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap(); + let pieces = ids(&r); + let want = count.unwrap_or(if by == "lines" { lay.lines.len() } else { was.len() }); + assert_eq!(pieces.len(), want, "{by}"); + assert_same_places(&was, &glyphs(&s, &pieces)); + if by == "paragraphs" { + // The wrapped lines end in line breaks. + assert!(obj(&s, pieces[0]).plain_text().contains('\u{2028}')); + } + } +} + +#[test] +fn break_text_refuses_what_it_cannot_break() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + assert!(other(s.execute("addon.cleanup.breakText", &json!({}))).contains("point or area type")); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "sentences"})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); +} + +#[test] +fn combine_puts_glyph_pieces_back_together_where_they_were() { + let mut s = session(); + let id = text(&mut s, json!({"x": 50, "y": 120, "text": "Water Lily", "size": 30})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 10, "fill": "#ff0000"})).unwrap(); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": "glyphs"})).unwrap()); + assert_eq!(pieces.len(), 9); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let made = ids(&r); + assert_eq!((made.len(), r["combined"].as_u64()), (1, Some(8))); + let t = obj(&s, made[0]); + assert_eq!(t.plain_text(), "Water Lily", "a space goes back between the words"); + assert_same_places(&was, &glyphs(&s, &made)); + let red = t.runs.iter().find(|r| r.text.contains('L')).unwrap(); + assert_ne!(red.style.fill, t.runs[0].style.fill, "styles stay"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(pieces[1]).is_some(), "undo brings the pieces back"); +} + +#[test] +fn combine_joins_only_pieces_on_one_line_turned_alike() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + let c = text(&mut s, json!({"x": 50, "y": 200, "text": "Below", "size": 20})); + let d = text(&mut s, json!({"x": 300, "y": 100, "text": "Turned", "size": 20, "rotation": 45})); + let was = glyphs(&s, &[a, b]); + select(&mut s, &[a, b, c, d]); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(ids(&r), [a]); + assert_eq!(obj(&s, a).plain_text(), "Left Right"); + assert_same_places(&was, &glyphs(&s, &[a])); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(b).is_none() && doc.node(c).is_some() && doc.node(d).is_some()); + select(&mut s, &[c, d]); + assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine")); +} + +#[test] +fn remove_line_and_paragraph_breaks() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "one\u{2028}two \u{2028}three\nfour\n\nfive", "size": 12})); + select(&mut s, &[id]); + let before = doc_json(&s); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"})).unwrap(); + assert_eq!(r["removed"], 2); + assert_eq!(obj(&s, id).plain_text(), "one two three\nfour\n\nfive"); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(r["removed"], 3); + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.paragraph_count()), ("one two three four five", 1)); + assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"}))), "No line breaks to remove"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); + assert!(matches!(s.execute("addon.cleanup.removeBreaks", &json!({})), Err(EngineError::BadParams { .. }))); +} + +/// The Type tool editing `id` with bytes `anchor..caret` selected. +fn edit_text(s: &mut Session, id: NodeId, anchor: usize, caret: usize) { + s.select_tool("type", Default::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": id.0, "anchor": anchor, "caret": caret})); +} + +#[test] +fn remove_breaks_in_the_characters_a_type_tool_selected() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12})); + select(&mut s, &[id]); + edit_text(&mut s, id, 0, 2); + s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "a b\nc"); +} + +#[test] +fn split_at_the_cursor_and_around_a_selection() { + for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "Split here please", "size": 20, "justify": "center"})); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let r = s.execute("addon.cleanup.splitText", &json!({})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("Type tool")), "{r:?}"); + edit_text(&mut s, id, anchor, caret); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.splitText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let pieces = ids(&r); + let texts: Vec = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect(); + assert_eq!(texts, want); + assert_same_places(&was, &glyphs(&s, &pieces)); + assert!(s.tool_options()["editing"].is_null(), "editing ends"); + } +} + +#[test] +fn split_keeps_the_later_lines_of_a_wrapped_piece() { + let mut s = session(); + let id = text( + &mut s, + json!({"x": 40, "y": 40, "text": "First words then a long run of text that wraps in the frame", "size": 14, "area": {"width": 160, "height": 200}}), + ); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + edit_text(&mut s, id, 6, 6); + let pieces = ids(&s.execute("addon.cleanup.splitText", &json!({})).unwrap()); + assert_eq!(pieces.len(), 2); + assert_same_places(&was, &glyphs(&s, &pieces)); +} + +#[test] +fn set_alignment_keeps_the_text_in_place() { + let mut s = session(); + let id = text(&mut s, json!({"x": 100, "y": 60, "text": "Stay put", "size": 20})); + let multi = text(&mut s, json!({"x": 100, "y": 160, "text": "Short\u{2028}The longest line", "size": 20})); + let was = glyphs(&s, &[id]); + let longest: Vec<_> = glyphs(&s, &[multi]).into_iter().skip(5).collect(); + select(&mut s, &[id, multi]); + for align in ["center", "right", "left"] { + s.execute("addon.cleanup.alignInPlace", &json!({"align": align})).unwrap(); + assert_same_places(&was, &glyphs(&s, &[id])); + assert_same_places(&longest, &glyphs(&s, &[multi]).into_iter().skip(5).collect::>()); + } + assert_eq!(obj(&s, id).para.justify, Justify::Left); + assert!(other(s.execute("addon.cleanup.alignInPlace", &json!({"align": "left"}))).contains("already")); + assert!(matches!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "up"})), Err(EngineError::BadParams { .. }))); +} + +#[test] +fn reset_text_transform_takes_off_rotation_and_with_scale_the_horizontal_scale() { + let mut s = session(); + let id = text(&mut s, json!({"x": 100, "y": 100, "text": "Tilted", "size": 20, "rotation": 30})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 150})).unwrap(); + let centre = |s: &Session| { + let t = obj(s, id); + t.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds.center() + }; + let was = centre(&s); + let size = obj(&s, id).effective_char_style(&obj(&s, id).runs[0].style).unwrap().size; + select(&mut s, &[id]); + s.execute("addon.cleanup.resetTextTransform", &json!({})).unwrap(); + let t = obj(&s, id); + let [_, b, c, _, _, _] = t.xf.as_coeffs(); + assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "{:?}", t.xf); + assert!((centre(&s) - was).hypot() < 1e-6); + assert!((t.effective_char_style(&t.runs[0].style).unwrap().size - size).abs() < 1e-6, "the size stays"); + assert!(t.runs[0].style.h_scale > 100.0, "the horizontal scale stays"); + assert!(other(s.execute("addon.cleanup.resetTextTransform", &json!({}))).contains("no transform")); + s.execute("addon.cleanup.resetTextTransform", &json!({"scale": true})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.h_scale, 100.0); +} + +#[test] +fn replace_missing_fonts_with_the_default_or_a_chosen_font() { + let mut s = session(); + let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family", "style": "Bold"})); + let kept = text(&mut s, json!({"x": 40, "y": 120, "text": "Fine", "size": 20})); + let fine = obj(&s, kept); + let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap(); + assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3"))); + let t = obj(&s, gone); + assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("Source Sans 3", "Bold")); + assert_eq!(obj(&s, kept), fine); + assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing"); + let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "No Such Font Family"})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index c5f9fc9de..d02690860 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -559,6 +559,23 @@ fn structured_junk() { ("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})), ("addon.pen.nextSegment", json!({"curved": "yes"})), ("addon.pen.closeJoin", json!({"ids": [u64::MAX]})), + ("addon.cleanup.breakText", json!({"by": "glyphs", "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), + ("addon.cleanup.breakText", json!({"by": ["lines"]})), + ("addon.cleanup.breakText", json!({"by": "paragraphs"})), + ("addon.cleanup.breakText", json!({"by": "words", "ids": "all"})), + ("addon.cleanup.combineText", json!({"ids": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]})), + ("addon.cleanup.combineText", json!({"ids": [-1, 1e308]})), + ("addon.cleanup.removeBreaks", json!({"kind": "paragraph", "ids": [1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), + ("addon.cleanup.removeBreaks", json!({"kind": 3})), + ("addon.cleanup.removeBreaks", json!({"kind": "line", "ids": null})), + ("addon.cleanup.splitText", json!({"at": 1e308})), + ("addon.cleanup.alignInPlace", json!({"align": "right", "ids": [1, 2, 3, 4, 5, 6, 7, 8]})), + ("addon.cleanup.alignInPlace", json!({"align": null, "ids": [-1]})), + ("addon.cleanup.resetTextTransform", json!({"scale": "yes"})), + ("addon.cleanup.resetTextTransform", json!({"scale": true, "ids": [1, 2, 3, 4, 5, 6, 7, 8]})), + ("addon.cleanup.replaceFonts", json!({"font": ""})), + ("addon.cleanup.replaceFonts", json!({"font": ["Source Sans 3"]})), + ("addon.cleanup.replaceFonts", json!({"font": "\u{0}"})), ]; let mut failures = vec![]; for fx in Fixture::ALL { @@ -571,6 +588,43 @@ fn structured_junk() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +/// Artwork Cleanup's text tools while a Type tool edits type with junk ranges (past the end, +/// reversed, inside a character): never a panic, and the document stays sound. +#[test] +fn cleanup_text_tools_with_junk_editing_ranges() { + let ranges = [(0, u64::MAX / 4), (u64::MAX, 1), (5, 5), (1, 2), (u64::MAX / 4, u64::MAX / 4)]; + let mut failures = vec![]; + for fx in Fixture::ALL { + let mut texts = vec![]; + for l in &fx.session().doc().unwrap().doc.layers { + l.walk(&mut |n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)) { + texts.push(n.id.0); + } + }); + } + for id in texts { + for (anchor, caret) in ranges { + for (cmd, p) in [("addon.cleanup.splitText", json!({})), ("addon.cleanup.removeBreaks", json!({"kind": "paragraph"}))] { + let mut s = fx.session(); + let r = catch_quiet(|| { + let _ = s.execute("select.set", &json!({"ids": [id]})); + let _ = s.select_tool("type", Default::default()); + s.set_tool_option("textSelection", &json!({"editing": id, "anchor": anchor, "caret": caret})); + no_panic(s.execute(cmd, &p)) + }); + if let Err(msg) = r { + failures.push(format!("PANIC {cmd} {id} {anchor}..{caret} [{fx:?}]: {msg}")); + } else if let Err(e) = check_session(&s) { + failures.push(format!("{cmd} {id} {anchor}..{caret} [{fx:?}]: {e}")); + } + } + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} + /// Shape Builder Tool Options (Gap Detection) with junk values: never a panic, and the tool still /// answers with options and works on a drag afterwards. #[test] diff --git a/crates/ui-egui/src/addons/cleanup.rs b/crates/ui-egui/src/addons/cleanup.rs index 66c7f537d..d44fe7b39 100644 --- a/crates/ui-egui/src/addons/cleanup.rs +++ b/crates/ui-egui/src/addons/cleanup.rs @@ -1,7 +1,8 @@ //! Artwork Cleanup's panel and submenu (Object › Plug-ins › Artwork Cleanup). The panel runs the //! Document Health Check on the selection, or the document while nothing is selected, lists what it //! found, and shows or fixes the issues picked in the list (Fix All: every issue listed), each fix -//! one undo step. Below, the add-on's one-click tools open their options as their menu items do. +//! one undo step. Below, the add-on's one-click tools open their options as their menu items do, +//! and its text tools, which the panel can hide, act on the selected type. use egui::{Align, Layout, Ui}; use serde_json::{Value, json}; @@ -16,6 +17,8 @@ pub const ID: &str = "artworkCleanup"; /// Where the panel keeps its last check (panel state). const STATE: &str = "artwork-cleanup"; +/// Where the panel keeps whether its text tools are hidden (panel state). +const TEXT_HIDDEN: &str = "artwork-cleanup-text-hidden"; /// The one-click tools: (menu label, command, the options their dialog opens with). fn tools() -> [(&'static str, &'static str, Value); 3] { @@ -26,11 +29,92 @@ fn tools() -> [(&'static str, &'static str, Value); 3] { ] } +/// Replace Missing Fonts' options: the default type font. +fn replace_fonts() -> Value { + json!({ "font": vectorcraft_doc::CharStyle::default().font_family }) +} + +/// A row of the panel's text tools: a label above (if any), then its buttons (label, what a +/// modifier click does, command, params). +type TextRow = (Option<&'static str>, Vec<(&'static str, Option<&'static str>, &'static str, Value)>); + +fn text_rows() -> [TextRow; 4] { + let align = |a: &str| json!({ "align": a }); + let by = |b: &str| json!({ "by": b }); + [ + ( + Some("Set Alignment, Keep Position:"), + vec![ + ("Left", None, "addon.cleanup.alignInPlace", align("left")), + ("Center", None, "addon.cleanup.alignInPlace", align("center")), + ("Right", None, "addon.cleanup.alignInPlace", align("right")), + ], + ), + ( + Some("Break Text into:"), + vec![ + ("Lines", Some("Shift-click: break into paragraphs"), "addon.cleanup.breakText", by("lines")), + ("Words", None, "addon.cleanup.breakText", by("words")), + ("Glyphs", None, "addon.cleanup.breakText", by("glyphs")), + ], + ), + ( + None, + vec![ + ("Split at Cursor", None, "addon.cleanup.splitText", json!({})), + ("Reset Transform", Some("Alt-click: reset the horizontal scale too"), "addon.cleanup.resetTextTransform", json!({})), + ], + ), + (None, vec![("Combine Point Text", None, "addon.cleanup.combineText", json!({}))]), + ] +} + +/// The text tools one to a row below the others: (label, command, params). +fn text_singles() -> [(&'static str, &'static str, Value); 3] { + [ + ("Remove Line Breaks", "addon.cleanup.removeBreaks", json!({ "kind": "line" })), + ("Remove Paragraph Breaks", "addon.cleanup.removeBreaks", json!({ "kind": "paragraph" })), + ("Replace Missing Fonts…", "addon.cleanup.replaceFonts", replace_fonts()), + ] +} + +/// `p` for a click with `held`: Shift-click on Lines breaks into paragraphs, Alt-click on Reset +/// Transform resets the horizontal scale too. +fn modified(id: &str, mut p: Value, held: egui::Modifiers) -> Value { + match id { + "addon.cleanup.breakText" if held.shift && p["by"] == "lines" => p["by"] = json!("paragraphs"), + "addon.cleanup.resetTextTransform" if held.alt => p["scale"] = json!(true), + _ => {} + } + p +} + /// Object › Plug-ins › Artwork Cleanup. pub fn menu() -> Vec { let mut items = vec![Item::Cmd("Document Health Check", "window.panel", json!({ "panel": ID })), Item::Sep]; items.extend(tools().into_iter().map(|(label, id, p)| Item::Cmd(label, id, p))); - items.extend([Item::Sep, Item::Cmd("Fix All Issues", "addon.cleanup.fix", Value::Null)]); + items.extend([Item::Sep, Item::Cmd("Fix All Issues", "addon.cleanup.fix", Value::Null), Item::Sep]); + let by = |b: &str| json!({ "by": b }); + let align = |a: &str| json!({ "align": a }); + items.extend([ + Item::Cmd("Combine Point Text", "addon.cleanup.combineText", json!({})), + Item::Cmd("Break Text into Lines", "addon.cleanup.breakText", by("lines")), + Item::Cmd("Break Text into Paragraphs", "addon.cleanup.breakText", by("paragraphs")), + Item::Cmd("Break Text into Words", "addon.cleanup.breakText", by("words")), + Item::Cmd("Break Text into Glyphs", "addon.cleanup.breakText", by("glyphs")), + Item::Cmd("Split Text at Cursor", "addon.cleanup.splitText", json!({})), + Item::Sub( + "Set Alignment, Keep Position", + vec![ + Item::Cmd("Left", "addon.cleanup.alignInPlace", align("left")), + Item::Cmd("Center", "addon.cleanup.alignInPlace", align("center")), + Item::Cmd("Right", "addon.cleanup.alignInPlace", align("right")), + ], + ), + Item::Cmd("Reset Text Transform", "addon.cleanup.resetTextTransform", json!({})), + Item::Cmd("Reset Text Transform and Scale", "addon.cleanup.resetTextTransform", json!({ "scale": true })), + ]); + items.extend(text_singles().into_iter().map(|(label, id, p)| Item::Cmd(label, id, p))); items } @@ -94,7 +178,8 @@ enum Action { Check, Show(String), Fix(Vec), - Tool(usize), + /// A tool's button: (its label, command, params), run as its menu item is. + Run(&'static str, &'static str, Value), } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -121,12 +206,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } widgets::divider(ui); ui.add_enabled_ui(selected, |ui| { - for (i, (label, ..)) in tools().iter().enumerate() { + for (label, id, p) in tools() { if widgets::flat_button(ui, tl!(label), width).clicked() { - action = Some(Action::Tool(i)); + action = Some(Action::Run(label, id, p)); } } }); + let hidden: bool = pstate(ui.ctx(), TEXT_HIDDEN); + widgets::divider(ui); + if widgets::section_toggle(ui, "Text", !hidden) { + set_pstate(ui.ctx(), TEXT_HIDDEN, !hidden); + } + if !hidden { + let editing = app.session.tool_options().get("editing").is_some_and(|v| !v.is_null()); + text_tools(ui, width, selected, editing, &mut action); + } match action { Some(Action::Check) => r = check(app, None), Some(Action::Show(kind)) => { @@ -135,17 +229,53 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } Some(Action::Fix(kinds)) => r = fix(app, &r, kinds), - Some(Action::Tool(i)) => { - if let Some((label, id, p)) = tools().into_iter().nth(i) { - let (id, p) = crate::menus::click_target(label, id, &p); - crate::menus::invoke(app, &id, p); - } + Some(Action::Run(label, id, p)) => { + let (id, p) = crate::menus::click_target(label, id, &p); + crate::menus::invoke(app, &id, p); } None => {} } set_pstate(ui.ctx(), STATE, r); } +/// The text tools: rows of buttons, then one to a row. Each is enabled when its command can act: +/// Split at Cursor while a Type tool edits text, Replace Missing Fonts always, the others with a +/// selection. +fn text_tools(ui: &mut Ui, width: f32, selected: bool, editing: bool, action: &mut Option) { + let enabled = |id: &str| match id { + "addon.cleanup.splitText" => editing, + "addon.cleanup.replaceFonts" => true, + _ => selected, + }; + let mut button = |ui: &mut Ui, label: &'static str, tip: Option<&str>, id: &'static str, p: Value, w: f32| { + // Lines and words of text, not lines drawn: their own catalog rows (the button translates + // the others). + let shown = if id == "addon.cleanup.breakText" { crate::i18n::tr_ctx(crate::i18n::current(), "breakText", label) } else { label }; + let mut resp = ui.add_enabled_ui(enabled(id), |ui| widgets::flat_button(ui, shown, w)).inner; + if let Some(tip) = tip { + resp = resp.on_hover_text(tl!(tip)); + } + if resp.clicked() { + *action = Some(Action::Run(label, id, modified(id, p, ui.input(|i| i.modifiers)))); + } + }; + for (heading, buttons) in text_rows() { + if let Some(h) = heading { + widgets::subheader(ui, h); + } + ui.horizontal(|ui| { + let n = buttons.len() as f32; + let each = ((width - ui.spacing().item_spacing.x * (n - 1.0)) / n).max(40.0); + for (label, tip, id, p) in buttons { + button(ui, label, tip, id, p, each); + } + }); + } + for (label, id, p) in text_singles() { + button(ui, label, None, id, p, width); + } +} + /// The list of what the check found (click picks a row, Ctrl/Cmd- or Shift-click adds one), and /// Show, Fix Selected and Fix All. fn results(ui: &mut Ui, r: &mut Results, action: &mut Option) { diff --git a/crates/ui-egui/src/addons/cleanup/tests.rs b/crates/ui-egui/src/addons/cleanup/tests.rs index 041a3410b..edf715924 100644 --- a/crates/ui-egui/src/addons/cleanup/tests.rs +++ b/crates/ui-egui/src/addons/cleanup/tests.rs @@ -65,3 +65,111 @@ fn check_lists_what_it_found_and_fix_clears_it() { }); assert!(text.contains("No issues found."), "{text}"); } + +/// An app with the point type `text`, selected. +fn type_app(text: &str) -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 60, "text": text, "size": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + (app, id) +} + +fn texts(app: &VectorcraftApp) -> Vec { + let mut out = vec![]; + for l in &app.session.doc().unwrap().doc.layers { + l.walk(&mut |n| { + if let vectorcraft_doc::NodeKind::Text(t) = &n.kind { + out.push(t.plain_text()); + } + }); + } + out +} + +#[test] +fn the_plugins_submenu_runs_the_text_tools() { + let (mut app, _) = type_app("one two"); + let entries = menus::menu_entries(&app); + let under = |path: &[&str], label: &str| entries.iter().find(|e| e.path == path && e.label == label); + let cleanup = ["Object", "Plug-ins", "Artwork Cleanup"]; + for (label, id) in [ + ("Combine Point Text", "addon.cleanup.combineText"), + ("Break Text into Lines", "addon.cleanup.breakText"), + ("Break Text into Paragraphs", "addon.cleanup.breakText"), + ("Break Text into Glyphs", "addon.cleanup.breakText"), + ("Split Text at Cursor", "addon.cleanup.splitText"), + ("Reset Text Transform", "addon.cleanup.resetTextTransform"), + ("Reset Text Transform and Scale", "addon.cleanup.resetTextTransform"), + ("Remove Line Breaks", "addon.cleanup.removeBreaks"), + ("Remove Paragraph Breaks", "addon.cleanup.removeBreaks"), + ("Replace Missing Fonts…", "addon.cleanup.replaceFonts"), + ] { + assert_eq!(under(&cleanup, label).and_then(|e| e.command.as_deref()), Some(id), "{label}"); + } + let center = under(&["Object", "Plug-ins", "Artwork Cleanup", "Set Alignment, Keep Position"], "Center").expect("listed"); + assert_eq!((center.command.as_deref(), ¢er.params), (Some("addon.cleanup.alignInPlace"), &json!({"align": "center"}))); + // Replace Missing Fonts opens its options first, with the default type font. + let fonts = under(&cleanup, "Replace Missing Fonts…").unwrap(); + let (target, p) = menus::click_target(&fonts.label, "addon.cleanup.replaceFonts", &fonts.params); + assert_eq!((target.as_str(), &p["params"]["font"]), ("ui.paramDialog", &json!("Source Sans 3"))); + let words = under(&cleanup, "Break Text into Words").unwrap(); + menus::invoke(&mut app, "addon.cleanup.breakText", words.params.clone()); + assert_eq!(texts(&app), ["one", "two"]); + let combine = under(&cleanup, "Combine Point Text").unwrap(); + menus::invoke(&mut app, "addon.cleanup.combineText", combine.params.clone()); + assert_eq!(texts(&app), ["one two"]); +} + +/// One frame of the panel in `ctx` with `events` and `modifiers` held: the texts drawn and where. +fn frame_with(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: egui::Modifiers) -> Vec<(String, egui::Rect)> { + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let time = ctx.input(|i| i.time) + 0.1; + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| collect(&c.shape, &mut v)); + v +} + +fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + frame_with(app, ctx, events, egui::Modifiers::NONE) +} + +/// Click the text `label` the panel draws, with `modifiers` held. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str, modifiers: egui::Modifiers) { + let shown = panel_frame(app, ctx, vec![]); + let at = shown.iter().find(|(s, _)| s == label).unwrap_or_else(|| panic!("no {label:?} in {shown:?}")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + frame_with(app, ctx, events, modifiers); + } +} + +#[test] +fn the_panels_text_row_runs_the_text_tools_and_hides() { + let (mut app, _) = type_app("Hello there\nworld"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let steps = app.session.doc().unwrap().history.undo.len(); + // Shift-click on Lines breaks into paragraphs. + click(&mut app, &ctx, "Lines", egui::Modifiers::SHIFT); + assert_eq!(texts(&app), ["Hello there", "world"]); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + click(&mut app, &ctx, "Words", egui::Modifiers::NONE); + assert_eq!(texts(&app), ["Hello", "there", "world"]); + click(&mut app, &ctx, "Combine Point Text", egui::Modifiers::NONE); + assert_eq!(texts(&app), ["Hello there", "world"]); + // The row hides and shows again. + click(&mut app, &ctx, "Text", egui::Modifiers::NONE); + assert!(!panel_frame(&mut app, &ctx, vec![]).iter().any(|(s, _)| s == "Words")); + click(&mut app, &ctx, "Text", egui::Modifiers::NONE); + assert!(panel_frame(&mut app, &ctx, vec![]).iter().any(|(s, _)| s == "Words")); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2a64dfdb7..a97c6c2d4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5772,3 +5772,47 @@ anchor Points: Точки: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` или `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Корекциите на мастилата за печат работят върху CMYK документи: първо изберете Файл › Цветен режим на документа › CMYK цвят @msg `{key}` must be 0..{max} `{key}` трябва да е 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Разделяне на текст + Combine Point Text Обединяване на точков текст + Remove Breaks Премахване на прекъсвания + Split Text at Cursor Разделяне на текст при курсора + Set Alignment, Keep Position Задаване на подравняване със запазване на позицията + Reset Text Transform Нулиране на трансформацията на текста + Replace Missing Fonts… Замяна на липсващите шрифтове… + Replace Missing Fonts Замяна на липсващите шрифтове + Remove Line Breaks Премахване на прекъсванията на редове + Remove Paragraph Breaks Премахване на прекъсванията на абзаци + Break Text into Lines Разделяне на текста на редове + Break Text into Paragraphs Разделяне на текста на абзаци + Break Text into Words Разделяне на текста на думи + Break Text into Glyphs Разделяне на текста на глифи + Reset Text Transform and Scale Нулиране на трансформацията и мащаба на текста + Set Alignment, Keep Position: Подравняване със запазване на позицията: + Break Text into: Разделяне на текста на: + Split at Cursor Разделяне при курсора + Reset Transform Нулиране на трансформацията + Shift-click: break into paragraphs Shift-щракване: разделяне на абзаци + Alt-click: reset the horizontal scale too Alt-щракване: нулиране и на хоризонталния мащаб +breakText Lines Редове +breakText Words Думи +breakText Glyphs Глифи +@msg Click in type with a Type tool to split it Щракнете в текст с инструмента „Текст“, за да го разделите +@msg Too many type objects to change at once Твърде много текстови обекти за промяна наведнъж +@msg Too many pieces to make at once Твърде много части за създаване наведнъж +@msg Select horizontal point or area type whose text fits Изберете хоризонтален точков текст или текст в зона, чийто текст се побира +@msg Nothing to split at the cursor При курсора няма какво да се раздели +@msg Select two or more one-line point type objects on one baseline to combine Изберете два или повече едноредови обекта с точков текст на една базова линия, за да ги обедините +@msg No line breaks to remove Няма прекъсвания на редове за премахване +@msg No paragraph breaks to remove Няма прекъсвания на абзаци за премахване +@msg Too many breaks to remove at once Твърде много прекъсвания за премахване наведнъж +@msg Select point type Изберете точков текст +@msg The point type is aligned that way already Точковият текст вече е подравнен така +@msg The point type has no transform to reset Точковият текст няма трансформация за нулиране +@msg No fonts are missing Няма липсващи шрифтове +@msg the replacement font is not available заместващият шрифт не е наличен +@msg missing `align` липсва `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` трябва да е lines, paragraphs, words или glyphs, а не {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` трябва да е "line" или "paragraph", а не {other} +@msg `align` must be "left", "center" or "right", not {other} `align` трябва да е "left", "center" или "right", а не {other} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 473c7b8bf..7a044e61c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5873,3 +5873,47 @@ anchor Points: Punts: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indiqueu `inkLimit`, `removeWhiteOverprint` o `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Les correccions de tinta per a impressió funcionen en documents CMYK: trieu primer Fitxer › Mode de color del document › Color CMYK @msg `{key}` must be 0..{max} `{key}` ha de ser 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Divideix el text + Combine Point Text Combina el text de punt + Remove Breaks Elimina els salts + Split Text at Cursor Divideix el text al cursor + Set Alignment, Keep Position Defineix l’alineació i manté la posició + Reset Text Transform Restableix la transformació del text + Replace Missing Fonts… Reemplaça els tipus de lletra que falten… + Replace Missing Fonts Reemplaça els tipus de lletra que falten + Remove Line Breaks Elimina els salts de línia + Remove Paragraph Breaks Elimina els salts de paràgraf + Break Text into Lines Divideix el text en línies + Break Text into Paragraphs Divideix el text en paràgrafs + Break Text into Words Divideix el text en paraules + Break Text into Glyphs Divideix el text en glifs + Reset Text Transform and Scale Restableix la transformació i l’escala del text + Set Alignment, Keep Position: Defineix l’alineació i manté la posició: + Break Text into: Divideix el text en: + Split at Cursor Divideix al cursor + Reset Transform Restableix la transformació + Shift-click: break into paragraphs Maj+clic: divideix en paràgrafs + Alt-click: reset the horizontal scale too Alt-clic: restableix també l’escala horitzontal +breakText Lines Línies +breakText Words Paraules +breakText Glyphs Glifs +@msg Click in type with a Type tool to split it Feu clic en un text amb una eina de text per dividir-lo +@msg Too many type objects to change at once Massa objectes de text per canviar-los alhora +@msg Too many pieces to make at once Massa peces per crear-les alhora +@msg Select horizontal point or area type whose text fits Seleccioneu text de punt o d’àrea horitzontal el text del qual hi càpiga +@msg Nothing to split at the cursor No hi ha res a dividir al cursor +@msg Select two or more one-line point type objects on one baseline to combine Seleccioneu dos o més objectes de text de punt d’una línia sobre una mateixa línia de base per combinar-los +@msg No line breaks to remove No hi ha salts de línia per eliminar +@msg No paragraph breaks to remove No hi ha salts de paràgraf per eliminar +@msg Too many breaks to remove at once Massa salts per eliminar-los alhora +@msg Select point type Seleccioneu text de punt +@msg The point type is aligned that way already El text de punt ja està alineat així +@msg The point type has no transform to reset El text de punt no té cap transformació per restablir +@msg No fonts are missing No falta cap tipus de lletra +@msg the replacement font is not available el tipus de lletra de reemplaçament no està disponible +@msg missing `align` falta `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` ha de ser lines, paragraphs, words o glyphs, no {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` ha de ser "line" o "paragraph", no {other} +@msg `align` must be "left", "center" or "right", not {other} `align` ha de ser "left", "center" o "right", no {other} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 91be88b94..14684ace5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5882,3 +5882,47 @@ anchor Points: Body: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` zadejte `inkLimit`, `removeWhiteOverprint` nebo `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Opravy tiskových barev fungují v dokumentech CMYK: nejprve zvolte Soubor › Barevný režim dokumentu › Barvy CMYK @msg `{key}` must be 0..{max} `{key}` musí být 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Rozdělit text + Combine Point Text Sloučit bodový text + Remove Breaks Odstranit zalomení + Split Text at Cursor Rozdělit text u kurzoru + Set Alignment, Keep Position Nastavit zarovnání, zachovat polohu + Reset Text Transform Obnovit transformaci textu + Replace Missing Fonts… Nahradit chybějící písma… + Replace Missing Fonts Nahradit chybějící písma + Remove Line Breaks Odstranit zalomení řádků + Remove Paragraph Breaks Odstranit zalomení odstavců + Break Text into Lines Rozdělit text na řádky + Break Text into Paragraphs Rozdělit text na odstavce + Break Text into Words Rozdělit text na slova + Break Text into Glyphs Rozdělit text na glyfy + Reset Text Transform and Scale Obnovit transformaci a měřítko textu + Set Alignment, Keep Position: Nastavit zarovnání, zachovat polohu: + Break Text into: Rozdělit text na: + Split at Cursor Rozdělit u kurzoru + Reset Transform Obnovit transformaci + Shift-click: break into paragraphs Shift+kliknutí: rozdělit na odstavce + Alt-click: reset the horizontal scale too Alt+kliknutí: obnovit i vodorovné měřítko +breakText Lines Řádky +breakText Words Slova +breakText Glyphs Glyfy +@msg Click in type with a Type tool to split it Klikněte do textu nástrojem Text, abyste jej rozdělili +@msg Too many type objects to change at once Příliš mnoho textových objektů ke změně najednou +@msg Too many pieces to make at once Příliš mnoho částí k vytvoření najednou +@msg Select horizontal point or area type whose text fits Vyberte vodorovný bodový text nebo text v oblasti, jehož text se vejde +@msg Nothing to split at the cursor U kurzoru není co rozdělit +@msg Select two or more one-line point type objects on one baseline to combine Vyberte dva nebo více jednořádkových objektů bodového textu na jednom účaří, abyste je sloučili +@msg No line breaks to remove Žádná zalomení řádků k odstranění +@msg No paragraph breaks to remove Žádná zalomení odstavců k odstranění +@msg Too many breaks to remove at once Příliš mnoho zalomení k odstranění najednou +@msg Select point type Vyberte bodový text +@msg The point type is aligned that way already Bodový text je již takto zarovnán +@msg The point type has no transform to reset Bodový text nemá žádnou transformaci k obnovení +@msg No fonts are missing Žádná písma nechybějí +@msg the replacement font is not available náhradní písmo není k dispozici +@msg missing `align` chybí `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` musí být lines, paragraphs, words nebo glyphs, ne {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` musí být "line" nebo "paragraph", ne {other} +@msg `align` must be "left", "center" or "right", not {other} `align` musí být "left", "center" nebo "right", ne {other} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bc671274c..5b8631c20 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5787,3 +5787,47 @@ anchor Points: Punkter: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` angiv `inkLimit`, `removeWhiteOverprint` eller `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Trykfarverettelser virker på CMYK-dokumenter: vælg først Filer › Dokumentets farvetilstand › CMYK-farve @msg `{key}` must be 0..{max} `{key}` skal være 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Opdel tekst + Combine Point Text Kombiner punkttekst + Remove Breaks Fjern skift + Split Text at Cursor Opdel tekst ved markøren + Set Alignment, Keep Position Angiv justering, bevar placering + Reset Text Transform Nulstil teksttransformation + Replace Missing Fonts… Erstat manglende skrifttyper… + Replace Missing Fonts Erstat manglende skrifttyper + Remove Line Breaks Fjern linjeskift + Remove Paragraph Breaks Fjern afsnitsskift + Break Text into Lines Opdel tekst i linjer + Break Text into Paragraphs Opdel tekst i afsnit + Break Text into Words Opdel tekst i ord + Break Text into Glyphs Opdel tekst i glyffer + Reset Text Transform and Scale Nulstil teksttransformation og skalering + Set Alignment, Keep Position: Angiv justering, bevar placering: + Break Text into: Opdel tekst i: + Split at Cursor Opdel ved markøren + Reset Transform Nulstil transformation + Shift-click: break into paragraphs Skift-klik: opdel i afsnit + Alt-click: reset the horizontal scale too Alt-klik: nulstil også den vandrette skalering +breakText Lines Linjer +breakText Words Ord +breakText Glyphs Glyffer +@msg Click in type with a Type tool to split it Klik i tekst med et tekstværktøj for at opdele den +@msg Too many type objects to change at once For mange tekstobjekter til at ændre på én gang +@msg Too many pieces to make at once For mange dele til at oprette på én gang +@msg Select horizontal point or area type whose text fits Vælg vandret punkt- eller områdetekst, hvis tekst kan være der +@msg Nothing to split at the cursor Intet at opdele ved markøren +@msg Select two or more one-line point type objects on one baseline to combine Vælg to eller flere punkttekstobjekter på én linje på samme grundlinje for at kombinere dem +@msg No line breaks to remove Ingen linjeskift at fjerne +@msg No paragraph breaks to remove Ingen afsnitsskift at fjerne +@msg Too many breaks to remove at once For mange skift til at fjerne på én gang +@msg Select point type Vælg punkttekst +@msg The point type is aligned that way already Punktteksten er allerede justeret sådan +@msg The point type has no transform to reset Punktteksten har ingen transformation at nulstille +@msg No fonts are missing Ingen skrifttyper mangler +@msg the replacement font is not available erstatningsskrifttypen er ikke tilgængelig +@msg missing `align` `align` mangler +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` skal være lines, paragraphs, words eller glyphs, ikke {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` skal være "line" eller "paragraph", ikke {other} +@msg `align` must be "left", "center" or "right", not {other} `align` skal være "left", "center" eller "right", ikke {other} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a87dcbc6a..77ccc6460 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5467,3 +5467,47 @@ anchor Points: Punkte: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` geben Sie `inkLimit`, `removeWhiteOverprint` oder `overprintBlack` an @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Druckfarbkorrekturen wirken in CMYK-Dokumenten: Wählen Sie zuerst Datei › Dokumentfarbmodus › CMYK-Farbe @msg `{key}` must be 0..{max} `{key}` muss 0..{max} sein + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Text aufteilen + Combine Point Text Punkttext zusammenfügen + Remove Breaks Umbrüche entfernen + Split Text at Cursor Text an der Einfügemarke teilen + Set Alignment, Keep Position Ausrichtung festlegen, Position beibehalten + Reset Text Transform Texttransformation zurücksetzen + Replace Missing Fonts… Fehlende Schriften ersetzen… + Replace Missing Fonts Fehlende Schriften ersetzen + Remove Line Breaks Zeilenumbrüche entfernen + Remove Paragraph Breaks Absatzumbrüche entfernen + Break Text into Lines Text in Zeilen aufteilen + Break Text into Paragraphs Text in Absätze aufteilen + Break Text into Words Text in Wörter aufteilen + Break Text into Glyphs Text in Glyphen aufteilen + Reset Text Transform and Scale Texttransformation und Skalierung zurücksetzen + Set Alignment, Keep Position: Ausrichtung festlegen, Position beibehalten: + Break Text into: Text aufteilen in: + Split at Cursor An Einfügemarke teilen + Reset Transform Transformation zurücksetzen + Shift-click: break into paragraphs Shift-Klick: in Absätze aufteilen + Alt-click: reset the horizontal scale too Alt-Klick: auch die horizontale Skalierung zurücksetzen +breakText Lines Zeilen +breakText Words Wörter +breakText Glyphs Glyphen +@msg Click in type with a Type tool to split it Klicken Sie mit einem Text-Werkzeug in Text, um ihn zu teilen +@msg Too many type objects to change at once Zu viele Textobjekte, um sie auf einmal zu ändern +@msg Too many pieces to make at once Zu viele Teile, um sie auf einmal zu erstellen +@msg Select horizontal point or area type whose text fits Wählen Sie horizontalen Punkt- oder Flächentext aus, dessen Text hineinpasst +@msg Nothing to split at the cursor An der Einfügemarke gibt es nichts zu teilen +@msg Select two or more one-line point type objects on one baseline to combine Wählen Sie zwei oder mehr einzeilige Punkttextobjekte auf einer Grundlinie aus, um sie zusammenzufügen +@msg No line breaks to remove Keine Zeilenumbrüche zu entfernen +@msg No paragraph breaks to remove Keine Absatzumbrüche zu entfernen +@msg Too many breaks to remove at once Zu viele Umbrüche, um sie auf einmal zu entfernen +@msg Select point type Wählen Sie Punkttext aus +@msg The point type is aligned that way already Der Punkttext ist bereits so ausgerichtet +@msg The point type has no transform to reset Der Punkttext hat keine Transformation zum Zurücksetzen +@msg No fonts are missing Es fehlen keine Schriften +@msg the replacement font is not available die Ersatzschrift ist nicht verfügbar +@msg missing `align` `align` fehlt +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` muss lines, paragraphs, words oder glyphs sein, nicht {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` muss "line" oder "paragraph" sein, nicht {other} +@msg `align` must be "left", "center" or "right", not {other} `align` muss "left", "center" oder "right" sein, nicht {other} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8b571c11f..f500d2dc1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5834,3 +5834,47 @@ anchor Points: Σημεία: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` δώστε `inkLimit`, `removeWhiteOverprint` ή `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Οι διορθώσεις μελανιών εκτύπωσης ισχύουν σε έγγραφα CMYK: επιλέξτε πρώτα Αρχείο › Λειτουργία χρώματος εγγράφου › Χρώμα CMYK @msg `{key}` must be 0..{max} το `{key}` πρέπει να είναι 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Διαίρεση κειμένου + Combine Point Text Συνδυασμός κειμένου σημείου + Remove Breaks Αφαίρεση αλλαγών + Split Text at Cursor Διαίρεση κειμένου στον δρομέα + Set Alignment, Keep Position Ορισμός στοίχισης με διατήρηση θέσης + Reset Text Transform Επαναφορά μετασχηματισμού κειμένου + Replace Missing Fonts… Αντικατάσταση γραμματοσειρών που λείπουν… + Replace Missing Fonts Αντικατάσταση γραμματοσειρών που λείπουν + Remove Line Breaks Αφαίρεση αλλαγών γραμμής + Remove Paragraph Breaks Αφαίρεση αλλαγών παραγράφου + Break Text into Lines Διαίρεση κειμένου σε γραμμές + Break Text into Paragraphs Διαίρεση κειμένου σε παραγράφους + Break Text into Words Διαίρεση κειμένου σε λέξεις + Break Text into Glyphs Διαίρεση κειμένου σε γλύφους + Reset Text Transform and Scale Επαναφορά μετασχηματισμού και κλίμακας κειμένου + Set Alignment, Keep Position: Στοίχιση με διατήρηση θέσης: + Break Text into: Διαίρεση κειμένου σε: + Split at Cursor Διαίρεση στον δρομέα + Reset Transform Επαναφορά μετασχηματισμού + Shift-click: break into paragraphs Shift-κλικ: διαίρεση σε παραγράφους + Alt-click: reset the horizontal scale too Alt-κλικ: επαναφορά και της οριζόντιας κλίμακας +breakText Lines Γραμμές +breakText Words Λέξεις +breakText Glyphs Γλύφοι +@msg Click in type with a Type tool to split it Κάντε κλικ σε κείμενο με ένα εργαλείο κειμένου για να το διαιρέσετε +@msg Too many type objects to change at once Πάρα πολλά αντικείμενα κειμένου για αλλαγή ταυτόχρονα +@msg Too many pieces to make at once Πάρα πολλά κομμάτια για δημιουργία ταυτόχρονα +@msg Select horizontal point or area type whose text fits Επιλέξτε οριζόντιο κείμενο σημείου ή περιοχής του οποίου το κείμενο χωράει +@msg Nothing to split at the cursor Δεν υπάρχει τίποτα για διαίρεση στον δρομέα +@msg Select two or more one-line point type objects on one baseline to combine Επιλέξτε δύο ή περισσότερα μονογραμμικά αντικείμενα κειμένου σημείου στην ίδια γραμμή βάσης για να τα συνδυάσετε +@msg No line breaks to remove Δεν υπάρχουν αλλαγές γραμμής για αφαίρεση +@msg No paragraph breaks to remove Δεν υπάρχουν αλλαγές παραγράφου για αφαίρεση +@msg Too many breaks to remove at once Πάρα πολλές αλλαγές για αφαίρεση ταυτόχρονα +@msg Select point type Επιλέξτε κείμενο σημείου +@msg The point type is aligned that way already Το κείμενο σημείου είναι ήδη στοιχισμένο έτσι +@msg The point type has no transform to reset Το κείμενο σημείου δεν έχει μετασχηματισμό για επαναφορά +@msg No fonts are missing Δεν λείπει καμία γραμματοσειρά +@msg the replacement font is not available η γραμματοσειρά αντικατάστασης δεν είναι διαθέσιμη +@msg missing `align` λείπει το `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} το `by` πρέπει να είναι lines, paragraphs, words ή glyphs, όχι {other} +@msg `kind` must be "line" or "paragraph", not {other} το `kind` πρέπει να είναι "line" ή "paragraph", όχι {other} +@msg `align` must be "left", "center" or "right", not {other} το `align` πρέπει να είναι "left", "center" ή "right", όχι {other} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b84ce995a..8b7707d49 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5825,3 +5825,47 @@ anchor Points: Puntos: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indique `inkLimit`, `removeWhiteOverprint` u `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Las correcciones de tinta para impresión funcionan en documentos CMYK: elija primero Archivo › Modo de color del documento › Color CMYK @msg `{key}` must be 0..{max} `{key}` debe ser 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Dividir texto + Combine Point Text Combinar texto de punto + Remove Breaks Quitar saltos + Split Text at Cursor Dividir texto en el cursor + Set Alignment, Keep Position Definir alineación manteniendo la posición + Reset Text Transform Restablecer transformación del texto + Replace Missing Fonts… Reemplazar fuentes que faltan… + Replace Missing Fonts Reemplazar fuentes que faltan + Remove Line Breaks Quitar saltos de línea + Remove Paragraph Breaks Quitar saltos de párrafo + Break Text into Lines Dividir texto en líneas + Break Text into Paragraphs Dividir texto en párrafos + Break Text into Words Dividir texto en palabras + Break Text into Glyphs Dividir texto en glifos + Reset Text Transform and Scale Restablecer transformación y escala del texto + Set Alignment, Keep Position: Definir alineación manteniendo la posición: + Break Text into: Dividir texto en: + Split at Cursor Dividir en el cursor + Reset Transform Restablecer transformación + Shift-click: break into paragraphs Shift+clic: dividir en párrafos + Alt-click: reset the horizontal scale too Alt+clic: restablecer también la escala horizontal +breakText Lines Líneas +breakText Words Palabras +breakText Glyphs Glifos +@msg Click in type with a Type tool to split it Haga clic en un texto con una herramienta Texto para dividirlo +@msg Too many type objects to change at once Demasiados objetos de texto para cambiarlos a la vez +@msg Too many pieces to make at once Demasiadas piezas para crearlas a la vez +@msg Select horizontal point or area type whose text fits Seleccione texto de punto o de área horizontal cuyo texto quepa +@msg Nothing to split at the cursor No hay nada que dividir en el cursor +@msg Select two or more one-line point type objects on one baseline to combine Seleccione dos o más objetos de texto de punto de una línea sobre la misma línea base para combinarlos +@msg No line breaks to remove No hay saltos de línea que quitar +@msg No paragraph breaks to remove No hay saltos de párrafo que quitar +@msg Too many breaks to remove at once Demasiados saltos para quitarlos a la vez +@msg Select point type Seleccione texto de punto +@msg The point type is aligned that way already El texto de punto ya está alineado así +@msg The point type has no transform to reset El texto de punto no tiene ninguna transformación que restablecer +@msg No fonts are missing No falta ninguna fuente +@msg the replacement font is not available la fuente de reemplazo no está disponible +@msg missing `align` falta `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` debe ser lines, paragraphs, words o glyphs, no {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` debe ser "line" o "paragraph", no {other} +@msg `align` must be "left", "center" or "right", not {other} `align` debe ser "left", "center" o "right", no {other} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 47aab9377..169977ede 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5913,3 +5913,47 @@ anchor Points: Pisteet: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` anna `inkLimit`, `removeWhiteOverprint` tai `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Painovärien korjaukset toimivat CMYK-asiakirjoissa: valitse ensin Tiedosto › Asiakirjan väritila › CMYK-väri @msg `{key}` must be 0..{max} `{key}` on oltava 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Jaa teksti + Combine Point Text Yhdistä pisteteksti + Remove Breaks Poista vaihdot + Split Text at Cursor Jaa teksti kohdistimen kohdalta + Set Alignment, Keep Position Aseta tasaus, säilytä sijainti + Reset Text Transform Palauta tekstin muunnos + Replace Missing Fonts… Korvaa puuttuvat fontit… + Replace Missing Fonts Korvaa puuttuvat fontit + Remove Line Breaks Poista rivinvaihdot + Remove Paragraph Breaks Poista kappaleenvaihdot + Break Text into Lines Jaa teksti riveiksi + Break Text into Paragraphs Jaa teksti kappaleiksi + Break Text into Words Jaa teksti sanoiksi + Break Text into Glyphs Jaa teksti merkeiksi + Reset Text Transform and Scale Palauta tekstin muunnos ja skaalaus + Set Alignment, Keep Position: Aseta tasaus, säilytä sijainti: + Break Text into: Jaa teksti: + Split at Cursor Jaa kohdistimen kohdalta + Reset Transform Palauta muunnos + Shift-click: break into paragraphs Vaihto-napsautus: jaa kappaleiksi + Alt-click: reset the horizontal scale too Alt-napsautus: palauta myös vaakaskaalaus +breakText Lines Rivit +breakText Words Sanat +breakText Glyphs Merkit +@msg Click in type with a Type tool to split it Napsauta tekstiä tekstityökalulla jakaaksesi sen +@msg Too many type objects to change at once Liian monta tekstiobjektia muutettavaksi kerralla +@msg Too many pieces to make at once Liian monta osaa luotavaksi kerralla +@msg Select horizontal point or area type whose text fits Valitse vaakasuora piste- tai alueteksti, jonka teksti mahtuu +@msg Nothing to split at the cursor Kohdistimen kohdalla ei ole jaettavaa +@msg Select two or more one-line point type objects on one baseline to combine Valitse kaksi tai useampia yksirivisiä pistetekstiobjekteja samalla perusviivalla yhdistääksesi ne +@msg No line breaks to remove Ei poistettavia rivinvaihtoja +@msg No paragraph breaks to remove Ei poistettavia kappaleenvaihtoja +@msg Too many breaks to remove at once Liian monta vaihtoa poistettavaksi kerralla +@msg Select point type Valitse pisteteksti +@msg The point type is aligned that way already Pisteteksti on jo tasattu näin +@msg The point type has no transform to reset Pistetekstillä ei ole palautettavaa muunnosta +@msg No fonts are missing Fontteja ei puutu +@msg the replacement font is not available korvaava fontti ei ole käytettävissä +@msg missing `align` `align` puuttuu +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` on oltava lines, paragraphs, words tai glyphs, ei {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` on oltava "line" tai "paragraph", ei {other} +@msg `align` must be "left", "center" or "right", not {other} `align` on oltava "left", "center" tai "right", ei {other} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8e1fb1a78..d253e590a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5881,3 +5881,47 @@ anchor Points: Points : @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indiquez `inkLimit`, `removeWhiteOverprint` ou `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Les corrections d’encres pour l’impression s’appliquent aux documents CMJN : choisissez d’abord Fichier › Mode colorimétrique du document › Couleur CMJN @msg `{key}` must be 0..{max} `{key}` doit être compris entre 0 et {max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Diviser le texte + Combine Point Text Combiner le texte de point + Remove Breaks Supprimer les sauts + Split Text at Cursor Diviser le texte au curseur + Set Alignment, Keep Position Définir l’alignement en conservant la position + Reset Text Transform Réinitialiser la transformation du texte + Replace Missing Fonts… Remplacer les polices manquantes… + Replace Missing Fonts Remplacer les polices manquantes + Remove Line Breaks Supprimer les sauts de ligne + Remove Paragraph Breaks Supprimer les sauts de paragraphe + Break Text into Lines Diviser le texte en lignes + Break Text into Paragraphs Diviser le texte en paragraphes + Break Text into Words Diviser le texte en mots + Break Text into Glyphs Diviser le texte en glyphes + Reset Text Transform and Scale Réinitialiser la transformation et l’échelle du texte + Set Alignment, Keep Position: Définir l’alignement en conservant la position : + Break Text into: Diviser le texte en : + Split at Cursor Diviser au curseur + Reset Transform Réinitialiser la transformation + Shift-click: break into paragraphs Maj+clic : diviser en paragraphes + Alt-click: reset the horizontal scale too Alt+clic : réinitialiser aussi l’échelle horizontale +breakText Lines Lignes +breakText Words Mots +breakText Glyphs Glyphes +@msg Click in type with a Type tool to split it Cliquez dans du texte avec un outil Texte pour le diviser +@msg Too many type objects to change at once Trop d’objets texte à modifier en une fois +@msg Too many pieces to make at once Trop de morceaux à créer en une fois +@msg Select horizontal point or area type whose text fits Sélectionnez du texte de point ou captif horizontal dont le texte tient dans son cadre +@msg Nothing to split at the cursor Rien à diviser au curseur +@msg Select two or more one-line point type objects on one baseline to combine Sélectionnez au moins deux objets texte de point d’une ligne sur une même ligne de base pour les combiner +@msg No line breaks to remove Aucun saut de ligne à supprimer +@msg No paragraph breaks to remove Aucun saut de paragraphe à supprimer +@msg Too many breaks to remove at once Trop de sauts à supprimer en une fois +@msg Select point type Sélectionnez du texte de point +@msg The point type is aligned that way already Le texte de point est déjà aligné ainsi +@msg The point type has no transform to reset Le texte de point n’a aucune transformation à réinitialiser +@msg No fonts are missing Aucune police ne manque +@msg the replacement font is not available la police de remplacement n’est pas disponible +@msg missing `align` `align` manquant +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` doit valoir lines, paragraphs, words ou glyphs, et non {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` doit valoir "line" ou "paragraph", et non {other} +@msg `align` must be "left", "center" or "right", not {other} `align` doit valoir "left", "center" ou "right", et non {other} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fa4728ec4..aaa956be0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5869,3 +5869,47 @@ anchor Points: Pontok: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` adja meg: `inkLimit`, `removeWhiteOverprint` vagy `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first A nyomdai festékjavítások CMYK-dokumentumokon működnek: előbb válassza a Fájl › Dokumentum színmódja › CMYK-szín parancsot @msg `{key}` must be 0..{max} `{key}` értéke 0..{max} lehet + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Szöveg felosztása + Combine Point Text Pontszöveg egyesítése + Remove Breaks Törések eltávolítása + Split Text at Cursor Szöveg felosztása a kurzornál + Set Alignment, Keep Position Igazítás beállítása, helyzet megtartása + Reset Text Transform Szövegátalakítás visszaállítása + Replace Missing Fonts… Hiányzó betűtípusok cseréje… + Replace Missing Fonts Hiányzó betűtípusok cseréje + Remove Line Breaks Sortörések eltávolítása + Remove Paragraph Breaks Bekezdéstörések eltávolítása + Break Text into Lines Szöveg felosztása sorokra + Break Text into Paragraphs Szöveg felosztása bekezdésekre + Break Text into Words Szöveg felosztása szavakra + Break Text into Glyphs Szöveg felosztása glifákra + Reset Text Transform and Scale Szövegátalakítás és méretezés visszaállítása + Set Alignment, Keep Position: Igazítás beállítása, helyzet megtartása: + Break Text into: Szöveg felosztása: + Split at Cursor Felosztás a kurzornál + Reset Transform Átalakítás visszaállítása + Shift-click: break into paragraphs Shift+kattintás: felosztás bekezdésekre + Alt-click: reset the horizontal scale too Alt+kattintás: a vízszintes méretezés visszaállítása is +breakText Lines Sorok +breakText Words Szavak +breakText Glyphs Glifák +@msg Click in type with a Type tool to split it Kattintson a szövegbe egy szöveg eszközzel a felosztáshoz +@msg Too many type objects to change at once Túl sok szövegobjektum az egyszerre történő módosításhoz +@msg Too many pieces to make at once Túl sok darab az egyszerre történő létrehozáshoz +@msg Select horizontal point or area type whose text fits Jelöljön ki vízszintes pont- vagy területszöveget, amelynek szövege elfér +@msg Nothing to split at the cursor A kurzornál nincs mit felosztani +@msg Select two or more one-line point type objects on one baseline to combine Az egyesítéshez jelöljön ki legalább két egysoros pontszöveg-objektumot egy alapvonalon +@msg No line breaks to remove Nincs eltávolítandó sortörés +@msg No paragraph breaks to remove Nincs eltávolítandó bekezdéstörés +@msg Too many breaks to remove at once Túl sok törés az egyszerre történő eltávolításhoz +@msg Select point type Jelöljön ki pontszöveget +@msg The point type is aligned that way already A pontszöveg már így van igazítva +@msg The point type has no transform to reset A pontszövegnek nincs visszaállítandó átalakítása +@msg No fonts are missing Nem hiányzik betűtípus +@msg the replacement font is not available a helyettesítő betűtípus nem érhető el +@msg missing `align` hiányzik az `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} a `by` értéke lines, paragraphs, words vagy glyphs lehet, nem {other} +@msg `kind` must be "line" or "paragraph", not {other} a `kind` értéke "line" vagy "paragraph" lehet, nem {other} +@msg `align` must be "left", "center" or "right", not {other} az `align` értéke "left", "center" vagy "right" lehet, nem {other} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0907afd03..32ae389d0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5787,3 +5787,47 @@ anchor Points: Titik: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` berikan `inkLimit`, `removeWhiteOverprint`, atau `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Perbaikan Tinta Cetak bekerja pada dokumen CMYK: pilih dulu Berkas › Mode warna dokumen › Warna CMYK @msg `{key}` must be 0..{max} `{key}` harus 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Pisahkan teks + Combine Point Text Gabungkan teks titik + Remove Breaks Hapus pemisah + Split Text at Cursor Pisahkan teks di kursor + Set Alignment, Keep Position Atur perataan, pertahankan posisi + Reset Text Transform Atur ulang transformasi teks + Replace Missing Fonts… Ganti font yang hilang… + Replace Missing Fonts Ganti font yang hilang + Remove Line Breaks Hapus putus baris + Remove Paragraph Breaks Hapus putus paragraf + Break Text into Lines Pisahkan teks menjadi baris + Break Text into Paragraphs Pisahkan teks menjadi paragraf + Break Text into Words Pisahkan teks menjadi kata + Break Text into Glyphs Pisahkan teks menjadi glif + Reset Text Transform and Scale Atur ulang transformasi dan skala teks + Set Alignment, Keep Position: Atur perataan, pertahankan posisi: + Break Text into: Pisahkan teks menjadi: + Split at Cursor Pisahkan di kursor + Reset Transform Atur ulang transformasi + Shift-click: break into paragraphs Shift+klik: pisahkan menjadi paragraf + Alt-click: reset the horizontal scale too Alt+klik: atur ulang juga skala horizontal +breakText Lines Baris +breakText Words Kata +breakText Glyphs Glif +@msg Click in type with a Type tool to split it Klik di dalam teks dengan alat teks untuk memisahkannya +@msg Too many type objects to change at once Terlalu banyak objek teks untuk diubah sekaligus +@msg Too many pieces to make at once Terlalu banyak bagian untuk dibuat sekaligus +@msg Select horizontal point or area type whose text fits Pilih teks titik atau teks area horizontal yang teksnya muat +@msg Nothing to split at the cursor Tidak ada yang dapat dipisahkan di kursor +@msg Select two or more one-line point type objects on one baseline to combine Pilih dua atau lebih objek teks titik satu baris pada satu garis dasar untuk menggabungkannya +@msg No line breaks to remove Tidak ada putus baris untuk dihapus +@msg No paragraph breaks to remove Tidak ada putus paragraf untuk dihapus +@msg Too many breaks to remove at once Terlalu banyak pemisah untuk dihapus sekaligus +@msg Select point type Pilih teks titik +@msg The point type is aligned that way already Teks titik sudah diratakan seperti itu +@msg The point type has no transform to reset Teks titik tidak memiliki transformasi untuk diatur ulang +@msg No fonts are missing Tidak ada font yang hilang +@msg the replacement font is not available font pengganti tidak tersedia +@msg missing `align` `align` tidak ada +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` harus lines, paragraphs, words, atau glyphs, bukan {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` harus "line" atau "paragraph", bukan {other} +@msg `align` must be "left", "center" or "right", not {other} `align` harus "left", "center", atau "right", bukan {other} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 13b70da3c..8c07d75d5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5501,3 +5501,47 @@ anchor Points: Punti: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indica `inkLimit`, `removeWhiteOverprint` o `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Le correzioni inchiostri di stampa funzionano nei documenti CMYK: scegli prima File › Metodo colore documento › Colore CMYK @msg `{key}` must be 0..{max} `{key}` deve essere 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Dividi testo + Combine Point Text Combina testo con punto + Remove Breaks Rimuovi interruzioni + Split Text at Cursor Dividi testo al cursore + Set Alignment, Keep Position Imposta allineamento mantenendo la posizione + Reset Text Transform Reimposta trasformazione testo + Replace Missing Fonts… Sostituisci font mancanti… + Replace Missing Fonts Sostituisci font mancanti + Remove Line Breaks Rimuovi interruzioni di riga + Remove Paragraph Breaks Rimuovi interruzioni di paragrafo + Break Text into Lines Dividi testo in righe + Break Text into Paragraphs Dividi testo in paragrafi + Break Text into Words Dividi testo in parole + Break Text into Glyphs Dividi testo in glifi + Reset Text Transform and Scale Reimposta trasformazione e scala testo + Set Alignment, Keep Position: Imposta allineamento mantenendo la posizione: + Break Text into: Dividi testo in: + Split at Cursor Dividi al cursore + Reset Transform Reimposta trasformazione + Shift-click: break into paragraphs Shift+clic: dividi in paragrafi + Alt-click: reset the horizontal scale too Alt+clic: reimposta anche la scala orizzontale +breakText Lines Righe +breakText Words Parole +breakText Glyphs Glifi +@msg Click in type with a Type tool to split it Fai clic nel testo con uno strumento Testo per dividerlo +@msg Too many type objects to change at once Troppi oggetti di testo da modificare in una volta +@msg Too many pieces to make at once Troppi pezzi da creare in una volta +@msg Select horizontal point or area type whose text fits Seleziona testo con punto o in area orizzontale il cui testo ci sta +@msg Nothing to split at the cursor Niente da dividere al cursore +@msg Select two or more one-line point type objects on one baseline to combine Seleziona due o più oggetti di testo con punto su una riga sulla stessa linea di base per combinarli +@msg No line breaks to remove Nessuna interruzione di riga da rimuovere +@msg No paragraph breaks to remove Nessuna interruzione di paragrafo da rimuovere +@msg Too many breaks to remove at once Troppe interruzioni da rimuovere in una volta +@msg Select point type Seleziona testo con punto +@msg The point type is aligned that way already Il testo con punto è già allineato così +@msg The point type has no transform to reset Il testo con punto non ha trasformazioni da reimpostare +@msg No fonts are missing Non mancano font +@msg the replacement font is not available il font sostitutivo non è disponibile +@msg missing `align` `align` mancante +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` deve essere lines, paragraphs, words o glyphs, non {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` deve essere "line" o "paragraph", non {other} +@msg `align` must be "left", "center" or "right", not {other} `align` deve essere "left", "center" o "right", non {other} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 274d1eff8..97d99c32e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5570,3 +5570,47 @@ anchor Points: ポイント: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`、`removeWhiteOverprint` または `overprintBlack` を指定してください @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷インキの修正は CMYK ドキュメントで使用できます。先にファイル › ドキュメントのカラーモード › CMYK カラーを選択してください @msg `{key}` must be 0..{max} `{key}` は 0..{max} にしてください + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text テキストを分割 + Combine Point Text ポイント文字を結合 + Remove Breaks 改行を削除 + Split Text at Cursor カーソル位置でテキストを分割 + Set Alignment, Keep Position 位置を保って行揃えを設定 + Reset Text Transform テキストの変形をリセット + Replace Missing Fonts… 見つからないフォントを置換… + Replace Missing Fonts 見つからないフォントを置換 + Remove Line Breaks 強制改行を削除 + Remove Paragraph Breaks 段落の区切りを削除 + Break Text into Lines テキストを行に分割 + Break Text into Paragraphs テキストを段落に分割 + Break Text into Words テキストを単語に分割 + Break Text into Glyphs テキストを字形に分割 + Reset Text Transform and Scale テキストの変形と比率をリセット + Set Alignment, Keep Position: 位置を保って行揃えを設定: + Break Text into: テキストを分割: + Split at Cursor カーソル位置で分割 + Reset Transform 変形をリセット + Shift-click: break into paragraphs Shift+クリック:段落に分割 + Alt-click: reset the horizontal scale too Alt+クリック:水平比率もリセット +breakText Lines 行 +breakText Words 単語 +breakText Glyphs 字形 +@msg Click in type with a Type tool to split it 分割するには、文字ツールでテキスト内をクリックしてください +@msg Too many type objects to change at once 一度に変更するテキストオブジェクトが多すぎます +@msg Too many pieces to make at once 一度に作成する部分が多すぎます +@msg Select horizontal point or area type whose text fits テキストが収まっている横組みのポイント文字またはエリア内文字を選択してください +@msg Nothing to split at the cursor カーソル位置で分割するものがありません +@msg Select two or more one-line point type objects on one baseline to combine 結合するには、同じベースライン上にある 1 行のポイント文字オブジェクトを 2 つ以上選択してください +@msg No line breaks to remove 削除する強制改行がありません +@msg No paragraph breaks to remove 削除する段落の区切りがありません +@msg Too many breaks to remove at once 一度に削除する改行が多すぎます +@msg Select point type ポイント文字を選択してください +@msg The point type is aligned that way already ポイント文字はすでにその行揃えです +@msg The point type has no transform to reset ポイント文字にリセットする変形がありません +@msg No fonts are missing 見つからないフォントはありません +@msg the replacement font is not available 置換用のフォントを使用できません +@msg missing `align` `align` がありません +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` は lines、paragraphs、words、glyphs のいずれかにしてください({other} は使用できません) +@msg `kind` must be "line" or "paragraph", not {other} `kind` は "line" または "paragraph" にしてください({other} は使用できません) +@msg `align` must be "left", "center" or "right", not {other} `align` は "left"、"center"、"right" のいずれかにしてください({other} は使用できません) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 236a14bef..df3777f4f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5872,3 +5872,47 @@ anchor Points: წერტილები: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` მიუთითეთ `inkLimit`, `removeWhiteOverprint` ან `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first საბეჭდი მელნის შესწორებები მუშაობს CMYK დოკუმენტებზე: ჯერ აირჩიეთ ფაილი › დოკუმენტის ფერის რეჟიმი › CMYK ფერი @msg `{key}` must be 0..{max} `{key}` უნდა იყოს 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text ტექსტის დაყოფა + Combine Point Text წერტილის ტექსტის გაერთიანება + Remove Breaks წყვეტების წაშლა + Split Text at Cursor ტექსტის დაყოფა კურსორთან + Set Alignment, Keep Position სწორების დაყენება მდებარეობის შენარჩუნებით + Reset Text Transform ტექსტის გარდაქმნის ჩამოყრა + Replace Missing Fonts… დაკარგული შრიფტების ჩანაცვლება… + Replace Missing Fonts დაკარგული შრიფტების ჩანაცვლება + Remove Line Breaks სტრიქონის წყვეტების წაშლა + Remove Paragraph Breaks აბზაცის წყვეტების წაშლა + Break Text into Lines ტექსტის დაყოფა სტრიქონებად + Break Text into Paragraphs ტექსტის დაყოფა აბზაცებად + Break Text into Words ტექსტის დაყოფა სიტყვებად + Break Text into Glyphs ტექსტის დაყოფა გლიფებად + Reset Text Transform and Scale ტექსტის გარდაქმნისა და მასშტაბის ჩამოყრა + Set Alignment, Keep Position: სწორების დაყენება მდებარეობის შენარჩუნებით: + Break Text into: ტექსტის დაყოფა: + Split at Cursor დაყოფა კურსორთან + Reset Transform გარდაქმნის ჩამოყრა + Shift-click: break into paragraphs Shift-დაწკაპუნება: დაყოფა აბზაცებად + Alt-click: reset the horizontal scale too Alt-დაწკაპუნება: ჰორიზონტალური მასშტაბის ჩამოყრაც +breakText Lines სტრიქონები +breakText Words სიტყვები +breakText Glyphs გლიფები +@msg Click in type with a Type tool to split it დასაყოფად დააწკაპუნეთ ტექსტში ტექსტის ხელსაწყოთი +@msg Too many type objects to change at once ერთდროულად შესაცვლელად ძალიან ბევრი ტექსტური ობიექტია +@msg Too many pieces to make at once ერთდროულად შესაქმნელად ძალიან ბევრი ნაწილია +@msg Select horizontal point or area type whose text fits აირჩიეთ ჰორიზონტალური წერტილის ან არის ტექსტი, რომლის ტექსტიც ეტევა +@msg Nothing to split at the cursor კურსორთან დასაყოფი არაფერია +@msg Select two or more one-line point type objects on one baseline to combine გასაერთიანებლად აირჩიეთ ერთ საბაზისო ხაზზე მდებარე ორი ან მეტი ერთსტრიქონიანი წერტილის ტექსტის ობიექტი +@msg No line breaks to remove წასაშლელი სტრიქონის წყვეტები არ არის +@msg No paragraph breaks to remove წასაშლელი აბზაცის წყვეტები არ არის +@msg Too many breaks to remove at once ერთდროულად წასაშლელად ძალიან ბევრი წყვეტაა +@msg Select point type აირჩიეთ წერტილის ტექსტი +@msg The point type is aligned that way already წერტილის ტექსტი უკვე ასეა სწორებული +@msg The point type has no transform to reset წერტილის ტექსტს ჩამოსაყრელი გარდაქმნა არ აქვს +@msg No fonts are missing დაკარგული შრიფტები არ არის +@msg the replacement font is not available ჩამნაცვლებელი შრიფტი მიუწვდომელია +@msg missing `align` `align` აკლია +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` უნდა იყოს lines, paragraphs, words ან glyphs და არა {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` უნდა იყოს "line" ან "paragraph" და არა {other} +@msg `align` must be "left", "center" or "right", not {other} `align` უნდა იყოს "left", "center" ან "right" და არა {other} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2bc410a42..a96057355 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5464,3 +5464,47 @@ anchor Points: 포인트: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` 또는 `overprintBlack`을 지정하십시오 @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 인쇄 잉크 수정은 CMYK 문서에서 작동합니다. 먼저 파일 › 문서 색상 모드 › CMYK 색상을 선택하십시오 @msg `{key}` must be 0..{max} `{key}`은(는) 0..{max}이어야 합니다 + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text 텍스트 나누기 + Combine Point Text 포인트 문자 결합 + Remove Breaks 나누기 제거 + Split Text at Cursor 커서 위치에서 텍스트 나누기 + Set Alignment, Keep Position 위치를 유지하며 정렬 설정 + Reset Text Transform 텍스트 변형 재설정 + Replace Missing Fonts… 누락된 글꼴 바꾸기… + Replace Missing Fonts 누락된 글꼴 바꾸기 + Remove Line Breaks 강제 줄 바꿈 제거 + Remove Paragraph Breaks 단락 나누기 제거 + Break Text into Lines 텍스트를 줄로 나누기 + Break Text into Paragraphs 텍스트를 단락으로 나누기 + Break Text into Words 텍스트를 단어로 나누기 + Break Text into Glyphs 텍스트를 글리프로 나누기 + Reset Text Transform and Scale 텍스트 변형 및 비율 재설정 + Set Alignment, Keep Position: 위치를 유지하며 정렬 설정: + Break Text into: 텍스트 나누기: + Split at Cursor 커서 위치에서 나누기 + Reset Transform 변형 재설정 + Shift-click: break into paragraphs Shift-클릭: 단락으로 나누기 + Alt-click: reset the horizontal scale too Alt-클릭: 가로 비율도 재설정 +breakText Lines 줄 +breakText Words 단어 +breakText Glyphs 글리프 +@msg Click in type with a Type tool to split it 나누려면 문자 도구로 텍스트 안을 클릭하십시오 +@msg Too many type objects to change at once 한 번에 변경할 문자 오브젝트가 너무 많습니다 +@msg Too many pieces to make at once 한 번에 만들 조각이 너무 많습니다 +@msg Select horizontal point or area type whose text fits 텍스트가 모두 들어가는 가로 포인트 문자 또는 영역 문자를 선택하십시오 +@msg Nothing to split at the cursor 커서 위치에서 나눌 내용이 없습니다 +@msg Select two or more one-line point type objects on one baseline to combine 결합하려면 같은 기준선에 있는 한 줄짜리 포인트 문자 오브젝트를 두 개 이상 선택하십시오 +@msg No line breaks to remove 제거할 강제 줄 바꿈이 없습니다 +@msg No paragraph breaks to remove 제거할 단락 나누기가 없습니다 +@msg Too many breaks to remove at once 한 번에 제거할 나누기가 너무 많습니다 +@msg Select point type 포인트 문자를 선택하십시오 +@msg The point type is aligned that way already 포인트 문자가 이미 그렇게 정렬되어 있습니다 +@msg The point type has no transform to reset 포인트 문자에 재설정할 변형이 없습니다 +@msg No fonts are missing 누락된 글꼴이 없습니다 +@msg the replacement font is not available 대체 글꼴을 사용할 수 없습니다 +@msg missing `align` `align`이 없습니다 +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by`는 lines, paragraphs, words 또는 glyphs여야 합니다({other}는 사용할 수 없음) +@msg `kind` must be "line" or "paragraph", not {other} `kind`는 "line" 또는 "paragraph"여야 합니다({other}는 사용할 수 없음) +@msg `align` must be "left", "center" or "right", not {other} `align`은 "left", "center" 또는 "right"여야 합니다({other}는 사용할 수 없음) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d5c08f815..7c5f8c1bf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5831,3 +5831,47 @@ anchor Points: Punten: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` geef `inkLimit`, `removeWhiteOverprint` of `overprintBlack` op @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Drukinktcorrecties werken in CMYK-documenten: kies eerst Bestand › Documentkleurmodus › CMYK-kleur @msg `{key}` must be 0..{max} `{key}` moet 0..{max} zijn + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Tekst splitsen + Combine Point Text Punttekst combineren + Remove Breaks Einden verwijderen + Split Text at Cursor Tekst splitsen bij cursor + Set Alignment, Keep Position Uitlijning instellen, positie behouden + Reset Text Transform Teksttransformatie herstellen + Replace Missing Fonts… Ontbrekende lettertypen vervangen… + Replace Missing Fonts Ontbrekende lettertypen vervangen + Remove Line Breaks Regeleinden verwijderen + Remove Paragraph Breaks Alinea-einden verwijderen + Break Text into Lines Tekst splitsen in regels + Break Text into Paragraphs Tekst splitsen in alinea's + Break Text into Words Tekst splitsen in woorden + Break Text into Glyphs Tekst splitsen in glyphs + Reset Text Transform and Scale Teksttransformatie en schaal herstellen + Set Alignment, Keep Position: Uitlijning instellen, positie behouden: + Break Text into: Tekst splitsen in: + Split at Cursor Splitsen bij cursor + Reset Transform Transformatie herstellen + Shift-click: break into paragraphs Shift-klik: splitsen in alinea's + Alt-click: reset the horizontal scale too Alt-klik: ook de horizontale schaal herstellen +breakText Lines Regels +breakText Words Woorden +breakText Glyphs Glyphs +@msg Click in type with a Type tool to split it Klik met een Type-gereedschap in tekst om deze te splitsen +@msg Too many type objects to change at once Te veel tekstobjecten om in één keer te wijzigen +@msg Too many pieces to make at once Te veel stukken om in één keer te maken +@msg Select horizontal point or area type whose text fits Selecteer horizontale punt- of vlaktekst waarvan de tekst past +@msg Nothing to split at the cursor Niets te splitsen bij de cursor +@msg Select two or more one-line point type objects on one baseline to combine Selecteer twee of meer punttekstobjecten van één regel op één basislijn om ze te combineren +@msg No line breaks to remove Geen regeleinden om te verwijderen +@msg No paragraph breaks to remove Geen alinea-einden om te verwijderen +@msg Too many breaks to remove at once Te veel einden om in één keer te verwijderen +@msg Select point type Selecteer punttekst +@msg The point type is aligned that way already De punttekst is al zo uitgelijnd +@msg The point type has no transform to reset De punttekst heeft geen transformatie om te herstellen +@msg No fonts are missing Er ontbreken geen lettertypen +@msg the replacement font is not available het vervangende lettertype is niet beschikbaar +@msg missing `align` `align` ontbreekt +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` moet lines, paragraphs, words of glyphs zijn, niet {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` moet "line" of "paragraph" zijn, niet {other} +@msg `align` must be "left", "center" or "right", not {other} `align` moet "left", "center" of "right" zijn, niet {other} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c456c6df8..01bcb83fb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5737,3 +5737,47 @@ anchor Points: Punkty: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` podaj `inkLimit`, `removeWhiteOverprint` lub `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Poprawki farb drukarskich działają w dokumentach CMYK: najpierw wybierz Plik › Tryb koloru dokumentu › Kolor CMYK @msg `{key}` must be 0..{max} `{key}` musi wynosić 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Podziel tekst + Combine Point Text Połącz tekst punktowy + Remove Breaks Usuń podziały + Split Text at Cursor Podziel tekst w miejscu kursora + Set Alignment, Keep Position Ustaw wyrównanie, zachowaj położenie + Reset Text Transform Resetuj przekształcenie tekstu + Replace Missing Fonts… Zastąp brakujące czcionki… + Replace Missing Fonts Zastąp brakujące czcionki + Remove Line Breaks Usuń podziały wiersza + Remove Paragraph Breaks Usuń podziały akapitu + Break Text into Lines Podziel tekst na wiersze + Break Text into Paragraphs Podziel tekst na akapity + Break Text into Words Podziel tekst na słowa + Break Text into Glyphs Podziel tekst na glify + Reset Text Transform and Scale Resetuj przekształcenie i skalę tekstu + Set Alignment, Keep Position: Ustaw wyrównanie, zachowaj położenie: + Break Text into: Podziel tekst na: + Split at Cursor Podziel w miejscu kursora + Reset Transform Resetuj przekształcenie + Shift-click: break into paragraphs Kliknięcie z Shift: podziel na akapity + Alt-click: reset the horizontal scale too Kliknięcie z Alt: resetuj także skalę poziomą +breakText Lines Wiersze +breakText Words Słowa +breakText Glyphs Glify +@msg Click in type with a Type tool to split it Kliknij w tekst narzędziem Tekst, aby go podzielić +@msg Too many type objects to change at once Zbyt wiele obiektów tekstowych do zmiany naraz +@msg Too many pieces to make at once Zbyt wiele części do utworzenia naraz +@msg Select horizontal point or area type whose text fits Zaznacz poziomy tekst punktowy lub akapitowy, którego tekst się mieści +@msg Nothing to split at the cursor Nie ma nic do podzielenia w miejscu kursora +@msg Select two or more one-line point type objects on one baseline to combine Zaznacz co najmniej dwa jednowierszowe obiekty tekstu punktowego na jednej linii bazowej, aby je połączyć +@msg No line breaks to remove Brak podziałów wiersza do usunięcia +@msg No paragraph breaks to remove Brak podziałów akapitu do usunięcia +@msg Too many breaks to remove at once Zbyt wiele podziałów do usunięcia naraz +@msg Select point type Zaznacz tekst punktowy +@msg The point type is aligned that way already Tekst punktowy jest już tak wyrównany +@msg The point type has no transform to reset Tekst punktowy nie ma przekształcenia do zresetowania +@msg No fonts are missing Nie brakuje żadnych czcionek +@msg the replacement font is not available czcionka zastępcza jest niedostępna +@msg missing `align` brak `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` musi mieć wartość lines, paragraphs, words lub glyphs, a nie {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` musi mieć wartość "line" lub "paragraph", a nie {other} +@msg `align` must be "left", "center" or "right", not {other} `align` musi mieć wartość "left", "center" lub "right", a nie {other} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b95ea47bd..790926432 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5800,3 +5800,47 @@ anchor Points: Pontos: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` informe `inkLimit`, `removeWhiteOverprint` ou `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first As Correções de Tinta para Impressão funcionam em documentos CMYK: escolha antes Arquivo › Modo de Cor do Documento › Cor CMYK @msg `{key}` must be 0..{max} `{key}` deve ser 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Dividir Texto + Combine Point Text Combinar Texto de Ponto + Remove Breaks Remover Quebras + Split Text at Cursor Dividir Texto no Cursor + Set Alignment, Keep Position Definir Alinhamento, Manter Posição + Reset Text Transform Redefinir Transformação do Texto + Replace Missing Fonts… Substituir Fontes Ausentes… + Replace Missing Fonts Substituir Fontes Ausentes + Remove Line Breaks Remover Quebras de Linha + Remove Paragraph Breaks Remover Quebras de Parágrafo + Break Text into Lines Dividir Texto em Linhas + Break Text into Paragraphs Dividir Texto em Parágrafos + Break Text into Words Dividir Texto em Palavras + Break Text into Glyphs Dividir Texto em Glifos + Reset Text Transform and Scale Redefinir Transformação e Escala do Texto + Set Alignment, Keep Position: Definir Alinhamento, Manter Posição: + Break Text into: Dividir Texto em: + Split at Cursor Dividir no Cursor + Reset Transform Redefinir Transformação + Shift-click: break into paragraphs Clique com Shift: dividir em parágrafos + Alt-click: reset the horizontal scale too Alt-clique: redefinir também a escala horizontal +breakText Lines Linhas +breakText Words Palavras +breakText Glyphs Glifos +@msg Click in type with a Type tool to split it Clique em um texto com uma ferramenta Texto para dividi-lo +@msg Too many type objects to change at once Objetos de texto demais para alterar de uma vez +@msg Too many pieces to make at once Partes demais para criar de uma vez +@msg Select horizontal point or area type whose text fits Selecione texto de ponto ou de área horizontal cujo texto caiba +@msg Nothing to split at the cursor Nada a dividir no cursor +@msg Select two or more one-line point type objects on one baseline to combine Selecione dois ou mais objetos de texto de ponto de uma linha na mesma linha de base para combiná-los +@msg No line breaks to remove Nenhuma quebra de linha a remover +@msg No paragraph breaks to remove Nenhuma quebra de parágrafo a remover +@msg Too many breaks to remove at once Quebras demais para remover de uma vez +@msg Select point type Selecione texto de ponto +@msg The point type is aligned that way already O texto de ponto já está alinhado assim +@msg The point type has no transform to reset O texto de ponto não tem transformação a redefinir +@msg No fonts are missing Nenhuma fonte está ausente +@msg the replacement font is not available a fonte substituta não está disponível +@msg missing `align` `align` ausente +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` deve ser lines, paragraphs, words ou glyphs, não {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` deve ser "line" ou "paragraph", não {other} +@msg `align` must be "left", "center" or "right", not {other} `align` deve ser "left", "center" ou "right", não {other} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bbeca6300..0fb52e688 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5860,3 +5860,47 @@ anchor Points: Точки: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` или `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Исправления печатных красок работают в документах CMYK: сначала выберите Файл › Цветовой режим документа › Цвет CMYK @msg `{key}` must be 0..{max} `{key}` должен быть 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Разбить текст + Combine Point Text Объединить текст из точки + Remove Breaks Удалить разрывы + Split Text at Cursor Разбить текст у курсора + Set Alignment, Keep Position Задать выравнивание, сохранив положение + Reset Text Transform Сбросить трансформацию текста + Replace Missing Fonts… Заменить отсутствующие шрифты… + Replace Missing Fonts Заменить отсутствующие шрифты + Remove Line Breaks Удалить разрывы строк + Remove Paragraph Breaks Удалить разрывы абзацев + Break Text into Lines Разбить текст на строки + Break Text into Paragraphs Разбить текст на абзацы + Break Text into Words Разбить текст на слова + Break Text into Glyphs Разбить текст на глифы + Reset Text Transform and Scale Сбросить трансформацию и масштаб текста + Set Alignment, Keep Position: Выравнивание с сохранением положения: + Break Text into: Разбить текст на: + Split at Cursor Разбить у курсора + Reset Transform Сбросить трансформацию + Shift-click: break into paragraphs Shift+щелчок: разбить на абзацы + Alt-click: reset the horizontal scale too Alt+щелчок: сбросить и масштаб по горизонтали +breakText Lines Строки +breakText Words Слова +breakText Glyphs Глифы +@msg Click in type with a Type tool to split it Щёлкните в тексте инструментом «Текст», чтобы разбить его +@msg Too many type objects to change at once Слишком много текстовых объектов для изменения за один раз +@msg Too many pieces to make at once Слишком много частей для создания за один раз +@msg Select horizontal point or area type whose text fits Выделите горизонтальный текст из точки или в области, текст которого помещается +@msg Nothing to split at the cursor У курсора нечего разбивать +@msg Select two or more one-line point type objects on one baseline to combine Выделите два или более однострочных объекта текста из точки на одной базовой линии, чтобы объединить их +@msg No line breaks to remove Нет разрывов строк для удаления +@msg No paragraph breaks to remove Нет разрывов абзацев для удаления +@msg Too many breaks to remove at once Слишком много разрывов для удаления за один раз +@msg Select point type Выделите текст из точки +@msg The point type is aligned that way already Текст из точки уже так выровнен +@msg The point type has no transform to reset У текста из точки нет трансформации для сброса +@msg No fonts are missing Отсутствующих шрифтов нет +@msg the replacement font is not available шрифт для замены недоступен +@msg missing `align` не указан `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` должен быть lines, paragraphs, words или glyphs, а не {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` должен быть "line" или "paragraph", а не {other} +@msg `align` must be "left", "center" or "right", not {other} `align` должен быть "left", "center" или "right", а не {other} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7ba6023b4..07c4a9228 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5938,3 +5938,47 @@ shape Open Öppen @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` ange `inkLimit`, `removeWhiteOverprint` eller `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Tryckfärgskorrigeringar fungerar i CMYK-dokument: välj först Fil › Dokumentets färgläge › CMYK-färg @msg `{key}` must be 0..{max} `{key}` måste vara 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Dela upp text + Combine Point Text Kombinera punkttext + Remove Breaks Ta bort brytningar + Split Text at Cursor Dela text vid markören + Set Alignment, Keep Position Ställ in justering, behåll position + Reset Text Transform Återställ texttransformering + Replace Missing Fonts… Ersätt saknade typsnitt… + Replace Missing Fonts Ersätt saknade typsnitt + Remove Line Breaks Ta bort radbrytningar + Remove Paragraph Breaks Ta bort styckebrytningar + Break Text into Lines Dela upp text i rader + Break Text into Paragraphs Dela upp text i stycken + Break Text into Words Dela upp text i ord + Break Text into Glyphs Dela upp text i glyfer + Reset Text Transform and Scale Återställ texttransformering och skala + Set Alignment, Keep Position: Ställ in justering, behåll position: + Break Text into: Dela upp text i: + Split at Cursor Dela vid markören + Reset Transform Återställ transformering + Shift-click: break into paragraphs Shift-klicka: dela upp i stycken + Alt-click: reset the horizontal scale too Alt-klick: återställ även den horisontella skalan +breakText Lines Rader +breakText Words Ord +breakText Glyphs Glyfer +@msg Click in type with a Type tool to split it Klicka i text med ett textverktyg för att dela den +@msg Too many type objects to change at once För många textobjekt att ändra på en gång +@msg Too many pieces to make at once För många delar att skapa på en gång +@msg Select horizontal point or area type whose text fits Markera vågrät punkt- eller områdestext vars text får plats +@msg Nothing to split at the cursor Inget att dela vid markören +@msg Select two or more one-line point type objects on one baseline to combine Markera två eller fler enradiga punkttextobjekt på samma baslinje för att kombinera dem +@msg No line breaks to remove Inga radbrytningar att ta bort +@msg No paragraph breaks to remove Inga styckebrytningar att ta bort +@msg Too many breaks to remove at once För många brytningar att ta bort på en gång +@msg Select point type Markera punkttext +@msg The point type is aligned that way already Punkttexten är redan justerad så +@msg The point type has no transform to reset Punkttexten har ingen transformering att återställa +@msg No fonts are missing Inga typsnitt saknas +@msg the replacement font is not available ersättningstypsnittet är inte tillgängligt +@msg missing `align` `align` saknas +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` måste vara lines, paragraphs, words eller glyphs, inte {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` måste vara "line" eller "paragraph", inte {other} +@msg `align` must be "left", "center" or "right", not {other} `align` måste vara "left", "center" eller "right", inte {other} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f445da8ad..8dbdfe254 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5840,3 +5840,47 @@ anchor Points: Noktalar: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` veya `overprintBlack` verin @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Baskı Mürekkebi Düzeltmeleri CMYK belgelerinde çalışır: önce Dosya › Belge Renk Modu › CMYK Rengi'ni seçin @msg `{key}` must be 0..{max} `{key}` 0..{max} olmalıdır + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Metni Böl + Combine Point Text Nokta Metnini Birleştir + Remove Breaks Kesmeleri Kaldır + Split Text at Cursor Metni İmleçte Böl + Set Alignment, Keep Position Hizalamayı Ayarla, Konumu Koru + Reset Text Transform Metin Dönüşümünü Sıfırla + Replace Missing Fonts… Eksik Yazı Tiplerini Değiştir… + Replace Missing Fonts Eksik Yazı Tiplerini Değiştir + Remove Line Breaks Satır Sonlarını Kaldır + Remove Paragraph Breaks Paragraf Sonlarını Kaldır + Break Text into Lines Metni Satırlara Böl + Break Text into Paragraphs Metni Paragraflara Böl + Break Text into Words Metni Sözcüklere Böl + Break Text into Glyphs Metni Gliflere Böl + Reset Text Transform and Scale Metin Dönüşümünü ve Ölçeğini Sıfırla + Set Alignment, Keep Position: Hizalamayı Ayarla, Konumu Koru: + Break Text into: Metni Böl: + Split at Cursor İmleçte Böl + Reset Transform Dönüşümü Sıfırla + Shift-click: break into paragraphs Shift tuşuyla tıklayın: paragraflara böl + Alt-click: reset the horizontal scale too Alt tuşuyla tıklayın: yatay ölçeği de sıfırla +breakText Lines Satırlar +breakText Words Sözcükler +breakText Glyphs Glifler +@msg Click in type with a Type tool to split it Bölmek için bir Yazım Aracıyla metnin içine tıklayın +@msg Too many type objects to change at once Bir kerede değiştirilecek çok fazla metin nesnesi var +@msg Too many pieces to make at once Bir kerede oluşturulacak çok fazla parça var +@msg Select horizontal point or area type whose text fits Metni sığan yatay nokta veya alan metni seçin +@msg Nothing to split at the cursor İmleçte bölünecek bir şey yok +@msg Select two or more one-line point type objects on one baseline to combine Birleştirmek için aynı taban çizgisinde iki veya daha fazla tek satırlı nokta metni nesnesi seçin +@msg No line breaks to remove Kaldırılacak satır sonu yok +@msg No paragraph breaks to remove Kaldırılacak paragraf sonu yok +@msg Too many breaks to remove at once Bir kerede kaldırılacak çok fazla kesme var +@msg Select point type Nokta metni seçin +@msg The point type is aligned that way already Nokta metni zaten bu şekilde hizalı +@msg The point type has no transform to reset Nokta metninin sıfırlanacak dönüşümü yok +@msg No fonts are missing Eksik yazı tipi yok +@msg the replacement font is not available yedek yazı tipi kullanılamıyor +@msg missing `align` `align` eksik +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by`, lines, paragraphs, words veya glyphs olmalı, {other} değil +@msg `kind` must be "line" or "paragraph", not {other} `kind`, "line" veya "paragraph" olmalı, {other} değil +@msg `align` must be "left", "center" or "right", not {other} `align`, "left", "center" veya "right" olmalı, {other} değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 26da71aac..26ad9906a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5481,3 +5481,47 @@ anchor Points: Точки: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` або `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Виправлення друкарських фарб працюють у документах CMYK: спершу виберіть Файл › Колірний режим документа › Колір CMYK @msg `{key}` must be 0..{max} `{key}` має бути 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Розбити текст + Combine Point Text Об’єднати текст із точки + Remove Breaks Видалити розриви + Split Text at Cursor Розбити текст біля курсора + Set Alignment, Keep Position Задати вирівнювання, зберігши положення + Reset Text Transform Скинути трансформацію тексту + Replace Missing Fonts… Замінити відсутні шрифти… + Replace Missing Fonts Замінити відсутні шрифти + Remove Line Breaks Видалити розриви рядків + Remove Paragraph Breaks Видалити розриви абзаців + Break Text into Lines Розбити текст на рядки + Break Text into Paragraphs Розбити текст на абзаци + Break Text into Words Розбити текст на слова + Break Text into Glyphs Розбити текст на гліфи + Reset Text Transform and Scale Скинути трансформацію та масштаб тексту + Set Alignment, Keep Position: Вирівнювання зі збереженням положення: + Break Text into: Розбити текст на: + Split at Cursor Розбити біля курсора + Reset Transform Скинути трансформацію + Shift-click: break into paragraphs Shift+клацання: розбити на абзаци + Alt-click: reset the horizontal scale too Alt+клацання: скинути й горизонтальний масштаб +breakText Lines Рядки +breakText Words Слова +breakText Glyphs Гліфи +@msg Click in type with a Type tool to split it Клацніть у тексті інструментом «Текст», щоб розбити його +@msg Too many type objects to change at once Забагато текстових об’єктів, щоб змінити їх за раз +@msg Too many pieces to make at once Забагато частин, щоб створити їх за раз +@msg Select horizontal point or area type whose text fits Виділіть горизонтальний текст із точки або в області, текст якого вміщується +@msg Nothing to split at the cursor Біля курсора нічого розбивати +@msg Select two or more one-line point type objects on one baseline to combine Виділіть два або більше однорядкових об’єктів тексту з точки на одній базовій лінії, щоб об’єднати їх +@msg No line breaks to remove Немає розривів рядків для видалення +@msg No paragraph breaks to remove Немає розривів абзаців для видалення +@msg Too many breaks to remove at once Забагато розривів, щоб видалити їх за раз +@msg Select point type Виділіть текст із точки +@msg The point type is aligned that way already Текст із точки вже так вирівняно +@msg The point type has no transform to reset Текст із точки не має трансформації для скидання +@msg No fonts are missing Відсутніх шрифтів немає +@msg the replacement font is not available шрифт для заміни недоступний +@msg missing `align` не вказано `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` має бути lines, paragraphs, words або glyphs, а не {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` має бути "line" або "paragraph", а не {other} +@msg `align` must be "left", "center" or "right", not {other} `align` має бути "left", "center" або "right", а не {other} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f26f07db7..f58aed92b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5483,3 +5483,47 @@ anchor Points: Điểm: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` hãy cho `inkLimit`, `removeWhiteOverprint` hoặc `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Sửa mực in hoạt động trên tài liệu CMYK: trước hết chọn Tệp › Chế độ màu tài liệu › Màu CMYK @msg `{key}` must be 0..{max} `{key}` phải là 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Tách văn bản + Combine Point Text Kết hợp chữ điểm + Remove Breaks Xóa ngắt + Split Text at Cursor Tách văn bản tại con trỏ + Set Alignment, Keep Position Đặt căn lề, giữ vị trí + Reset Text Transform Đặt lại biến đổi văn bản + Replace Missing Fonts… Thay thế phông chữ thiếu… + Replace Missing Fonts Thay thế phông chữ thiếu + Remove Line Breaks Xóa xuống dòng + Remove Paragraph Breaks Xóa ngắt đoạn + Break Text into Lines Tách văn bản thành dòng + Break Text into Paragraphs Tách văn bản thành đoạn + Break Text into Words Tách văn bản thành từ + Break Text into Glyphs Tách văn bản thành glyph + Reset Text Transform and Scale Đặt lại biến đổi và tỷ lệ văn bản + Set Alignment, Keep Position: Đặt căn lề, giữ vị trí: + Break Text into: Tách văn bản thành: + Split at Cursor Tách tại con trỏ + Reset Transform Đặt lại biến đổi + Shift-click: break into paragraphs Shift-bấm: tách thành đoạn + Alt-click: reset the horizontal scale too Alt-bấm: đặt lại cả tỷ lệ ngang +breakText Lines Dòng +breakText Words Từ +breakText Glyphs Glyph +@msg Click in type with a Type tool to split it Nhấp vào văn bản bằng công cụ Chữ để tách văn bản +@msg Too many type objects to change at once Quá nhiều đối tượng chữ để thay đổi cùng lúc +@msg Too many pieces to make at once Quá nhiều phần để tạo cùng lúc +@msg Select horizontal point or area type whose text fits Chọn chữ điểm hoặc chữ vùng nằm ngang có văn bản vừa khung +@msg Nothing to split at the cursor Không có gì để tách tại con trỏ +@msg Select two or more one-line point type objects on one baseline to combine Chọn hai đối tượng chữ điểm một dòng trở lên trên cùng một đường cơ sở để kết hợp +@msg No line breaks to remove Không có xuống dòng nào để xóa +@msg No paragraph breaks to remove Không có ngắt đoạn nào để xóa +@msg Too many breaks to remove at once Quá nhiều ngắt để xóa cùng lúc +@msg Select point type Chọn chữ điểm +@msg The point type is aligned that way already Chữ điểm đã được căn như vậy +@msg The point type has no transform to reset Chữ điểm không có biến đổi nào để đặt lại +@msg No fonts are missing Không thiếu phông chữ nào +@msg the replacement font is not available phông chữ thay thế không có sẵn +@msg missing `align` thiếu `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` phải là lines, paragraphs, words hoặc glyphs, không phải {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` phải là "line" hoặc "paragraph", không phải {other} +@msg `align` must be "left", "center" or "right", not {other} `align` phải là "left", "center" hoặc "right", không phải {other} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 76378e4e8..00cd38e03 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5724,3 +5724,47 @@ anchor Points: 点: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` 请指定 `inkLimit`、`removeWhiteOverprint` 或 `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷油墨修正适用于 CMYK 文档:请先选择 文件 › 文档颜色模式 › CMYK 颜色 @msg `{key}` must be 0..{max} `{key}` 必须为 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text 拆分文本 + Combine Point Text 合并点文字 + Remove Breaks 删除换行 + Split Text at Cursor 在光标处拆分文本 + Set Alignment, Keep Position 设置对齐并保持位置 + Reset Text Transform 重置文本变换 + Replace Missing Fonts… 替换缺失的字体… + Replace Missing Fonts 替换缺失的字体 + Remove Line Breaks 删除强制换行 + Remove Paragraph Breaks 删除段落换行 + Break Text into Lines 将文本拆分为行 + Break Text into Paragraphs 将文本拆分为段落 + Break Text into Words 将文本拆分为单词 + Break Text into Glyphs 将文本拆分为字形 + Reset Text Transform and Scale 重置文本变换和缩放 + Set Alignment, Keep Position: 设置对齐并保持位置: + Break Text into: 将文本拆分为: + Split at Cursor 在光标处拆分 + Reset Transform 重置变换 + Shift-click: break into paragraphs Shift+单击:拆分为段落 + Alt-click: reset the horizontal scale too Alt+点击:同时重置水平缩放 +breakText Lines 行 +breakText Words 单词 +breakText Glyphs 字形 +@msg Click in type with a Type tool to split it 请用文字工具在文字中单击以拆分 +@msg Too many type objects to change at once 一次要更改的文字对象过多 +@msg Too many pieces to make at once 一次要创建的部分过多 +@msg Select horizontal point or area type whose text fits 请选择文本能完全容纳的横排点文字或区域文字 +@msg Nothing to split at the cursor 光标处没有可拆分的内容 +@msg Select two or more one-line point type objects on one baseline to combine 请选择同一基线上两个或更多单行点文字对象以合并 +@msg No line breaks to remove 没有要删除的强制换行 +@msg No paragraph breaks to remove 没有要删除的段落换行 +@msg Too many breaks to remove at once 一次要删除的换行过多 +@msg Select point type 请选择点文字 +@msg The point type is aligned that way already 点文字已经是这种对齐方式 +@msg The point type has no transform to reset 点文字没有可重置的变换 +@msg No fonts are missing 没有缺失的字体 +@msg the replacement font is not available 替换字体不可用 +@msg missing `align` 缺少 `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` 必须是 lines、paragraphs、words 或 glyphs,而不是 {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` 必须是 "line" 或 "paragraph",而不是 {other} +@msg `align` must be "left", "center" or "right", not {other} `align` 必须是 "left"、"center" 或 "right",而不是 {other} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 34597f4aa..67383344b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5520,3 +5520,47 @@ anchor Points: 點: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` 請指定 `inkLimit`、`removeWhiteOverprint` 或 `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷油墨修正適用於 CMYK 文件:請先選擇 檔案 › 文件色彩模式 › CMYK 色彩 @msg `{key}` must be 0..{max} `{key}` 必須為 0..{max} + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text 分割文字 + Combine Point Text 合併點文字 + Remove Breaks 移除換行 + Split Text at Cursor 在游標處分割文字 + Set Alignment, Keep Position 設定對齊並保持位置 + Reset Text Transform 重設文字變形 + Replace Missing Fonts… 取代遺失的字體… + Replace Missing Fonts 取代遺失的字體 + Remove Line Breaks 移除強制換行 + Remove Paragraph Breaks 移除段落換行 + Break Text into Lines 將文字分割為行 + Break Text into Paragraphs 將文字分割為段落 + Break Text into Words 將文字分割為單字 + Break Text into Glyphs 將文字分割為字符 + Reset Text Transform and Scale 重設文字變形與縮放 + Set Alignment, Keep Position: 設定對齊並保持位置: + Break Text into: 將文字分割為: + Split at Cursor 在游標處分割 + Reset Transform 重設變形 + Shift-click: break into paragraphs Shift-按一下:分割為段落 + Alt-click: reset the horizontal scale too Alt+點擊:一併重設水平縮放 +breakText Lines 行 +breakText Words 單字 +breakText Glyphs 字符 +@msg Click in type with a Type tool to split it 請用文字工具在文字中按一下以分割 +@msg Too many type objects to change at once 一次要變更的文字物件過多 +@msg Too many pieces to make at once 一次要建立的部分過多 +@msg Select horizontal point or area type whose text fits 請選取文字能完整容納的橫排點文字或區域文字 +@msg Nothing to split at the cursor 游標處沒有可分割的內容 +@msg Select two or more one-line point type objects on one baseline to combine 請選取同一基線上兩個或更多單行點文字物件以合併 +@msg No line breaks to remove 沒有要移除的強制換行 +@msg No paragraph breaks to remove 沒有要移除的段落換行 +@msg Too many breaks to remove at once 一次要移除的換行過多 +@msg Select point type 請選取點文字 +@msg The point type is aligned that way already 點文字已經是這種對齊方式 +@msg The point type has no transform to reset 點文字沒有可重設的變形 +@msg No fonts are missing 沒有遺失的字體 +@msg the replacement font is not available 替代字體無法使用 +@msg missing `align` 未提供 `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` 必須是 lines、paragraphs、words 或 glyphs,而不是 {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` 必須是 "line" 或 "paragraph",而不是 {other} +@msg `align` must be "left", "center" or "right", not {other} `align` 必須是 "left"、"center" 或 "right",而不是 {other} diff --git a/docs/mcp.md b/docs/mcp.md index d0568d5cd..5902163c2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5731,6 +5731,49 @@ topmost piece. {"name":"run_command","arguments":{"command":"addon.cleanup.simplify","params":{"tolerance":40,"protectCorners":true}}} ``` +### Text tools + +The add-on's text tools act on the selected type (or `ids`), each as one undo step, and refuse with an error when there +is nothing for them to do. The pieces they make are point type that keeps its characters' styles, each placed so the +glyph of its first character lands where it was drawn. + +- `addon.cleanup.breakText {by?, ids?}` turns each horizontal point or area type object into point type objects, one + per line (`by: "lines"`, the default), paragraph (`"paragraphs"`), word (`"words"`) or glyph (`"glyphs"`; a ligature + stays one piece). Spaces between the pieces go. A paragraph keeps its lines: where the layout wrapped one, the piece + has a forced line break. Justified lines close up, and a paragraph broken out whole keeps its bullet (not its + number; the other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are and are + listed in `skipped`. +- `addon.cleanup.combineText {ids?}` merges one-line horizontal point type into as few objects as it can, every glyph + staying where it was (within 0.01 pt). Pieces turned and sheared alike whose baselines lie within 0.4 em of each + other join left to right (a baseline a little off becomes baseline shift); a space goes between pieces at least half + a space apart, and tracking on the character before each piece keeps its place. A gap wider than 10 em, an overlap of + more than 1 em, another look (paint, opacity, effects) or a piece that can't keep its place once joined (a ligature + forming across the join) starts another object. Each object made takes the place and paint of its leftmost piece. The + main use is type from a PDF that arrives one glyph per object. +- `addon.cleanup.removeBreaks {kind, ids?}` removes the forced line breaks (`kind: "line"`) or paragraph returns + (`"paragraph"`) in the characters selected with a Type tool, else in all the text of the selected type. A break + between two words becomes a space; merged paragraphs take the first one's paragraph style. +- `addon.cleanup.splitText {}` works while a Type tool edits horizontal point or area type: the text before the + selected characters, those characters and the text after them become point type objects (at a bare cursor, the text + before and after it). Spaces at the ends of each piece go; a piece that starts inside a left-aligned line takes a + first-line indent, so its later lines stay where they were. Editing ends with the pieces selected. +- `addon.cleanup.alignInPlace {align, ids?}` aligns every paragraph of each horizontal point type object `left`, + `center` or `right` and moves the object so its longest line stays where it is: a single line doesn't move at all. +- `addon.cleanup.resetTextTransform {scale?, ids?}` takes the rotation, shear and reflection off point type, keeping its + size and the centre of its text. With `scale: true` the horizontal scale goes too, the object's and each character's. +- `addon.cleanup.replaceFonts {font?}` sets every character of the document's type whose font family isn't available + in `font` (by default the default type font, Source Sans 3), in that family's style closest to the one it had. + +In the panel, the Text row (which the panel can hide) runs them: Shift-click Lines to break into paragraphs, and +Alt-click Reset Transform to reset the horizontal scale too. Recovering live text from outlines, combining vertical +type and evening out horizontal scales are not there yet. + +```json +{"name":"run_command","arguments":{"command":"addon.cleanup.breakText","params":{"by":"words"}}} +{"name":"run_command","arguments":{"command":"addon.cleanup.combineText","params":{}}} +{"name":"run_command","arguments":{"command":"addon.cleanup.removeBreaks","params":{"kind":"paragraph"}}} +``` + ## Path Precision Tools add-on Path Precision Tools is a built-in add-on for exact anchor and path work: its commands are `addon.pathtools.*`, and in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e480a7525..d5071c4eb 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-10-30 · **Change:** minor (Color Effects add-on: Duotone and Print Ink Fixes; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Artwork Cleanup add-on: text tools; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1222,6 +1222,14 @@ Show, Fix Selected and Fix All (each one undo step), Deep Ungroup and Rejoin Pat alone; Fix All as one step; scopes; rejoin within the gap only) and `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu opens the panel; Check lists what it finds). Commands: `docs/mcp.md` › Artwork Cleanup add-on. +The add-on's text tools (2026-10-31) work on the shared text layout: Break Text into lines, paragraphs, words or glyphs +and Split Text at Cursor make point type pieces placed by the glyph of their first character, Combine Point Text merges +one-line point type back with every glyph where it was (tracking and spaces, checked against the layout), and Remove +Line / Paragraph Breaks, Set Alignment, Keep Position, Reset Text Transform and Replace Missing Fonts. Tests: +`cmd::addons::cleanup::text::tests` in `vectorcraft-engine` (glyphs kept in place, styles kept, one undo step, +refusals), `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu and the panel's Text row run them) and +`cleanup_text_tools_with_junk_editing_ranges` in the command sweep. + ## Path Precision Tools add-on (2026-10-30) A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Path Precision Tools, @@ -1296,6 +1304,7 @@ add-on. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): text tools; evidence added; scores unchanged. | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | | 2026-10-30 | minor | 3D Extrude & Bevel, Inflate and Rotate apply to type, which stays editable (#4715); scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e8de7ef08..f56514e57 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Color Effects add-on: Duotone and Print Ink Fixes; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Artwork Cleanup add-on: text tools; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -330,6 +330,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point); an Alt-drag away from paths selects the points inside a marquee | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | | Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored | +| Artwork Cleanup add-on text tools (VectorCraft addition): the panel's Text row, which the panel can hide, and Object › Plug-ins › Artwork Cleanup set the alignment of point type keeping its place, break type into lines (Shift-click: paragraphs), words or glyphs, split type at the cursor, reset a text transform (Alt-click: and its horizontal scale), combine point type, remove line or paragraph breaks and replace missing fonts, each one undo step | done | `addon.cleanup.*` text commands, `crates/ui-egui/src/addons/cleanup.rs` and its tests; a VectorCraft addition, so it is not scored | | Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step; Select by Color Kind selects the objects that use spot colours, carry colour effects, or are RGB, CMYK or grayscale images; Vector Halftone draws an object as halftone dots live (the dots Object › Vector Halftone makes), which Expand Appearance and the exporters write as paths; Duotone prints each colour's tone with one to four inks (a tint of a spot tone's swatch, or the CMYK mix of several), live or as a filter, its dialog a row per tone and the picked tone's curve; Print Ink Fixes (a CMYK document's ink limit, white overprint removed, black overprint set) opens its options from the submenu and the panel's Inks | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored | ## Look and feel @@ -371,6 +372,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): its text tools in the panel and Object › Plug-ins › Artwork Cleanup; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | | 2026-10-30 | minor | Path Precision Tools add-on: the Parametric Shape Tool (ten re-editable shape kinds), its panel and Convert to Parametric Shape; scores and estimates unchanged | From 414aad10bd9bfa244a3c72eef63e8172ee1cc2e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:41:20 +0000 Subject: [PATCH 09/52] chore(deps): bump the cargo-patch group across 2 directories with 4 updates Bumps the cargo-patch group with 3 updates in the / directory: [rustix](https://github.com/bytecodealliance/rustix), [clipboard-win](https://github.com/DoumanAsh/clipboard-win) and [smallvec](https://github.com/servo/rust-smallvec). Bumps the cargo-patch group with 1 update in the /crates/plugins/example directory: [serde_json](https://github.com/serde-rs/json). Updates `rustix` from 1.1.5 to 1.1.6 - [Release notes](https://github.com/bytecodealliance/rustix/releases) - [Changelog](https://github.com/bytecodealliance/rustix/blob/main/CHANGES.md) - [Commits](https://github.com/bytecodealliance/rustix/compare/v1.1.5...v1.1.6) Updates `clipboard-win` from 5.4.1 to 5.4.3 - [Commits](https://github.com/DoumanAsh/clipboard-win/commits) Updates `smallvec` from 1.16.3 to 1.16.4 - [Release notes](https://github.com/servo/rust-smallvec/releases) - [Commits](https://github.com/servo/rust-smallvec/compare/v1.16.3...v1.16.4) Updates `serde_json` from 1.0.151 to 1.0.154 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154) Updates `serde_json` from 1.0.151 to 1.0.154 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154) --- updated-dependencies: - dependency-name: clipboard-win dependency-version: 5.4.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-patch - dependency-name: rustix dependency-version: 1.1.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-patch - dependency-name: serde_json dependency-version: 1.0.154 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-patch - dependency-name: serde_json dependency-version: 1.0.154 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-patch - dependency-name: smallvec dependency-version: 1.16.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 68 +++++++++++++++---------------- crates/plugins/example/Cargo.lock | 4 +- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6bf8261a8..f0f4dfd94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -303,7 +303,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -334,7 +334,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -360,7 +360,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -665,7 +665,7 @@ checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "tracing", ] @@ -688,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.5", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-client", ] @@ -763,9 +763,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -1563,7 +1563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1888,7 +1888,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "windows-link", ] @@ -3618,7 +3618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3900,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -4140,7 +4140,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4489,15 +4489,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4556,7 +4556,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5097,9 +5097,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -5142,7 +5142,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "wayland-backend", "wayland-client", @@ -5415,10 +5415,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", - "windows-sys 0.59.0", + "rustix 1.1.6", + "windows-sys 0.61.2", ] [[package]] @@ -6199,7 +6199,7 @@ dependencies = [ "log", "proptest", "roxmltree 0.21.2", - "rustix 1.1.5", + "rustix 1.1.6", "ruzstd", "serde", "serde_json", @@ -6256,7 +6256,7 @@ version = "0.18.0" dependencies = [ "flate2", "proptest", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "thiserror 2.0.21", @@ -6517,7 +6517,7 @@ dependencies = [ "egui_kittest", "image", "log", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "toml", @@ -6810,7 +6810,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.5", + "rustix 1.1.6", "scoped-tls", "smallvec", "wayland-sys", @@ -6823,7 +6823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-scanner", ] @@ -6845,7 +6845,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "wayland-client", "xcursor", ] @@ -7183,7 +7183,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7520,7 +7520,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -7579,7 +7579,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "x11rb-protocol", ] @@ -7621,7 +7621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -7725,7 +7725,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_repr", "tracing", diff --git a/crates/plugins/example/Cargo.lock b/crates/plugins/example/Cargo.lock index b42995700..a4cd2a3ea 100644 --- a/crates/plugins/example/Cargo.lock +++ b/crates/plugins/example/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", From c1971284ceed92c351c5ebbc8a3f263e0cb7ea35 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:34:57 +0300 Subject: [PATCH 10/52] Type: required lam-alef is not an alternate in fonts that put it in liga (#394) Highlight Alternate Glyphs compares each glyph with required shaping alone, which turns liga and calt off. Older Arabic fonts (such as the legacy Arial of the Microsoft core fonts) put the mandatory lam-alef ligature in liga instead of rlig, so every lam-alef was highlighted. A lam + alef cluster (any alef form, marks allowed) is now never an alternate candidate, whatever feature the font uses. Adds a test font built in code (lam-alef under liga or rlig) and host-independent regressions. Also two equivalent one-line repairs strict Clippy needs on main: a nonminimal_bool in the Path Precision Tools inspect check and is_none() in a gradient panel test. --- crates/engine/src/cmd/addons/pathtools.rs | 2 +- crates/render/src/tests/alternate.rs | 36 ++++++++++++++- crates/render/src/text_geom.rs | 16 ++++++- crates/text/src/test_fonts.rs | 50 +++++++++++++++++++++ crates/ui-egui/src/panels/gradient/tests.rs | 2 +- docs/type-parity.md | 5 ++- 6 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 4e78680ae..47dbe7335 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -355,7 +355,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result { let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?; let (x, y) = (number_or(p, "x", f64::NAN, C)?, number_or(p, "y", f64::NAN, C)?); let tolerance = number_or(p, "tolerance", 3.0, C)?; - if !(x.is_finite() && y.is_finite()) || !(tolerance >= 0.0 && tolerance.is_finite()) { + if !(x.is_finite() && y.is_finite() && tolerance >= 0.0 && tolerance.is_finite()) { return Err(bad(C, "give `x` and `y` (and a `tolerance` of 0 or more)")); } let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "`id` is not a path"))?; diff --git a/crates/render/src/tests/alternate.rs b/crates/render/src/tests/alternate.rs index ad39ed065..8ef690330 100644 --- a/crates/render/src/tests/alternate.rs +++ b/crates/render/src/tests/alternate.rs @@ -118,7 +118,9 @@ fn arabic_joining_forms_are_not_alternates() { assert_eq!(alternates(&t), 0); } -/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either. +/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either. With an +/// older font that files it under `liga` (as the legacy Arial of Microsoft's core fonts does) it +/// is still required: [`lam_alef_in_liga_is_not_an_alternate`] checks that without an OS font. #[test] fn lam_alef_is_not_an_alternate() { // Looking up an installed font loads it, which changes the fonts. @@ -129,6 +131,38 @@ fn lam_alef_is_not_an_alternate() { } } +/// #394: a font whose lam-alef ligature is in `liga` (older Arabic fonts) or `rlig` gets no +/// highlight on it: the ligature is required by the characters, not chosen by an optional +/// feature. Built in code, so it runs whatever fonts the machine has. +#[test] +fn lam_alef_in_liga_is_not_an_alternate() { + use vectorcraft_text::test_fonts::{lam_alef_family, lam_alef_font}; + let _adding = crate::tests_fontchange::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + for feature in [b"liga", b"rlig"] { + db.add_font(lam_alef_font(feature).unwrap()); + let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: lam_alef_family(feature).into(), ..Default::default() }; + for s in ["\u{0644}\u{0627}", "\u{0644}\u{0623}", "\u{0628}\u{0644}\u{0627}", "\u{0644}\u{064E}\u{0627}"] { + let t = text(s, style.clone()); + // The font's ligature glyph stands for the pair. + assert!(glyphs(&t).iter().any(|g| g.1 > 2), "{}: {s} is shaped to one ligature glyph", lam_alef_family(feature)); + assert_eq!(alternates(&t), 0, "{}: {s}", lam_alef_family(feature)); + } + } +} + +/// Only a lam followed by an alef (marks between or after allowed) is the required lam-alef. +#[test] +fn only_lam_alef_clusters_are_the_required_ligature() { + for s in ["\u{0644}\u{0627}", "\u{0644}\u{0622}", "\u{0644}\u{0623}", "\u{0644}\u{0625}", "\u{0644}\u{064E}\u{0627}", "\u{0644}\u{0627}\u{064B}"] + { + assert!(crate::text_geom::is_lam_alef(s), "{s:?}"); + } + for s in ["", "\u{0644}", "\u{0627}\u{0644}", "\u{0644}\u{0628}", "\u{0628}\u{0644}\u{0627}", "\u{0644}\u{0627}\u{0627}", "fi", "\u{064E}"] { + assert!(!crate::text_geom::is_lam_alef(s), "{s:?}"); + } +} + /// #394: an optional feature the characters turn on (a stylistic set, swash, stylistic or /// discretionary alternate…) is highlighted on the glyphs it changes, and only on those. #[test] diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 1408b397b..bf058d75f 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -48,7 +48,8 @@ impl TextGeom { /// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features /// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping /// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the - /// lam-alef ligature are what the script needs, not alternates. Measured once. + /// lam-alef ligature are what the script needs, not alternates (the lam-alef ligature also when + /// a font files it under `liga`: [`is_lam_alef`]). Measured once. pub(crate) fn alternate_glyphs(&self, t: &TextObject) -> &BezPath { self.alternate_glyphs.get_or_init(|| { let mut out = BezPath::new(); @@ -84,6 +85,17 @@ pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_c shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid) } +/// Whether `cluster` is a lam followed by an alef (plain, with madda, or with hamza above or below), +/// with any Arabic marks between or after them: the lam-alef ligature, which the Unicode +/// Standard's Arabic chapter treats as obligatory, not a stylistic choice. Fonts made before +/// `rlig` was common put it in `liga`, which [`required_shaping`] turns off, so it is required +/// by its characters whatever feature the font files it under. +pub(crate) fn is_lam_alef(cluster: &str) -> bool { + let is_mark = |c: &char| matches!(c, '\u{0610}'..='\u{061A}' | '\u{064B}'..='\u{065F}' | '\u{0670}' | '\u{06D6}'..='\u{06DC}' | '\u{06DF}'..='\u{06E4}' | '\u{06E7}' | '\u{06E8}' | '\u{06EA}'..='\u{06ED}'); + let mut letters = cluster.chars().filter(|c| !is_mark(c)); + matches!((letters.next(), letters.next(), letters.next()), (Some('\u{0644}'), Some('\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}'), None)) +} + /// A glyph [`is_opentype_alternate`] marks, in its cluster of the text (`byte`, `len`), with its /// box. #[derive(Debug)] @@ -181,7 +193,7 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeo let cluster_chars = cluster.chars().count(); let Some(first) = cluster.chars().next() else { continue }; let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0); - if is_opentype_alternate(nominal, g.gid, cluster_chars) { + if is_opentype_alternate(nominal, g.gid, cluster_chars) && !is_lam_alef(cluster) { alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell }); } } diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 76f6603ff..449221f9f 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -343,6 +343,56 @@ pub fn half_form_font(half: bool) -> Option> { Some(builder.build()) } +/// The family of [`lam_alef_font`] whose lam-alef ligature is in `feature`. +pub fn lam_alef_family(feature: &[u8; 4]) -> &'static str { + match feature { + b"liga" => "Lamtest Liga", + b"rlig" => "Lamtest Rlig", + _ => "Lamtest Other", + } +} + +/// A test-only Arabic cmap over bundled Source Sans 3: lam uses l, alef I, alef with hamza above +/// J and beh b. The `GSUB` table's `arab` script has one ligature lookup, under `feature`, that +/// joins lam + alef into H and lam + alef with hamza above into K. Older fonts put the mandatory +/// lam-alef ligature in `liga` rather than `rlig`; this builds either, without an OS font. `None` +/// when it can't be built. +pub fn lam_alef_font(feature: &[u8; 4]) -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let mapping = [('\u{0644}', 'l'), ('\u{0627}', 'I'), ('\u{0623}', 'J'), ('\u{0628}', 'b')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let family = lam_alef_family(feature); + let full = format!("{family} Regular"); + let postscript = format!("{}-Regular", family.replace(' ', "")); + let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (6, &postscript)])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let coverage = CoverageFormat1::new(vec![gid('l')?]); + let ligatures = vec![Ligature::new(gid('H')?, vec![gid('I')?]), Ligature::new(gid('K')?, vec![gid('J')?])]; + let subst = LigatureSubstFormat1::new(coverage.into(), vec![LigatureSet::new(ligatures)]); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(feature), Feature::new(None, vec![0]))]); + let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0])), vec![]))]); + builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`pair_kerning_font`]. pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans"; diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 7d9dcbf9e..a3b7f5cfa 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -660,7 +660,7 @@ fn the_panel_menu_saves_deletes_and_hides_presets() { let mut p = Panel::new(); let texts = p.frame_of(&mut app, vec![], Modifiers::NONE, menu); let save = text_at(&texts, "Save Gradient Preset…").unwrap_or_else(|| panic!("{texts:?}")); - assert!(!app.session.active_saved_gradient_preset().is_some()); + assert!(app.session.active_saved_gradient_preset().is_none()); p.menu_click(&mut app, save); let d = app.ui.dialog.take().expect("the name dialog"); assert_eq!((d.fields["__command"].as_str(), d.fields["name"].as_str()), (Some("gradient.presets.save"), Some("Gradient Preset 1"))); diff --git a/docs/type-parity.md b/docs/type-parity.md index c4130f0e5..c0220b193 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-10-30 · **Change:** minor (Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged) · **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 @@ -28,7 +28,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on; stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | @@ -71,6 +71,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged | | 2026-10-30 | minor | Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores unchanged | | 2026-10-29 | minor | Font fallback keeps a letter and its combining marks in one face; Vietnamese stacked diacritics (Ể, Ứ, Ự) attach in precomposed, decomposed and keyboard forms (#4491); scores and hours unchanged | | 2026-10-25 | minor | Threading ports: a double-click on a threaded port with the Selection tool breaks the thread there (`text.thread.break`); the story flows into the frames before the break; scores unchanged | From ef3b5b0c95ee4c1bc5d3cccb434064ed4faa0c81 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:43:08 +0300 Subject: [PATCH 11/52] fix(eps): a layer's state after its last mesh stays the layer's An Xy right after a gradient mesh's /End X! is the mesh's only when the mesh's own 6 () XW follows. When the layer's LB follows (directly or after the layer's Xd and 7 () XW) it is the layer's, as before, and the mesh keeps the state it had. Also corrects the revision-history row that still said meshes with handle weights of 0 aren't read. --- crates/eps/src/import/ai/read.rs | 4 ++++ crates/eps/src/import/ai/read/containers.rs | 11 +++++++-- crates/eps/src/import/ai/read/ops.rs | 26 +++++++++++++++------ crates/eps/src/import/ai/tests.rs | 23 ++++++++++++++++++ docs/file-format-parity.md | 5 ++-- 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index abcbf0219..b3050abea 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -187,6 +187,9 @@ struct Reader<'a> { mesh_ended: bool, /// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on. layer_state: Option<(f32, BlendMode, bool, Knockout)>, + /// The state a mesh had before an `Xy` right after its end, which is the layer's instead when + /// the layer's `LB` follows rather than the mesh's own `6 () XW`. + mesh_undo: Option<(f32, BlendMode, bool, Knockout)>, /// Painted copies of clipping paths that have a fill (drawn under what they clip). clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). @@ -292,6 +295,7 @@ impl<'a> Reader<'a> { group_ended: false, mesh_ended: false, layer_state: None, + mesh_undo: None, styles: HashMap::new(), palette: super::palette::Palette::default(), restyled: vec![], diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 09ccd14aa..19239fa87 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -171,7 +171,14 @@ impl<'a> Reader<'a> { } pub(super) fn end_layer(&mut self) -> Result<(), String> { - let f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let mut f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let state = self.layer_state.take(); + // An `Xy` right after the layer's last mesh, then `LB`: the layer's, not the mesh's. + if let (Some(_), Some(before)) = (state, self.mesh_undo.take()) + && let Some(mesh) = f.children.last_mut().filter(|n| matches!(n.kind, NodeKind::Mesh(_))) + { + (mesh.opacity, mesh.blend, mesh.isolate, mesh.knockout) = before; + } let Kind::Layer(a) = f.kind else { return Ok(()) }; let (children, clip) = self.clip_first(f.children, &f.clips); let color = a.color.unwrap_or(LayerColor::Preset(0)); @@ -183,7 +190,7 @@ impl<'a> Reader<'a> { layer.visible = a.visible; layer.locked = a.locked; // The layer's own opacity, blend and knockout, written after its art (#2003). - if let Some((opacity, blend, isolate, knockout)) = self.layer_state.take() { + if let Some((opacity, blend, isolate, knockout)) = state { (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); } match self.frames.last_mut() { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index d9dbab22a..42d6960a6 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -77,6 +77,7 @@ impl<'a> Reader<'a> { if !matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { self.after_object = false; self.layer_state = None; + self.mesh_undo = None; } let group_ended = std::mem::take(&mut self.group_ended); let mesh_ended = std::mem::take(&mut self.mesh_ended); @@ -213,7 +214,13 @@ impl<'a> Reader<'a> { } "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), - "XW" => self.style_marker(&vals, hidden), + "XW" => { + // An object's own state block ends `6 () XW`: an `Xy` after a mesh's end was the mesh's. + if Self::nums(&vals).last() == Some(&6.0) && self.mesh_undo.take().is_some() { + self.layer_state = None; + } + self.style_marker(&vals, hidden) + } // Gradients. "Bd" => self.gradient_def = GradientDef::begin(&vals), "Bs" | "BD" => {} @@ -390,7 +397,8 @@ impl<'a> Reader<'a> { } /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too, and right - /// after a gradient mesh's `/End X!` (before its `Xd` and `XW`), the mesh's. + /// after a gradient mesh's `/End X!` (before its `Xd` and `6 () XW`), the mesh's, unless the + /// layer's `LB` follows: then it is the layer's (see `end_layer`). fn transparency(&mut self, vals: &[V], mesh_ended: bool) { let n = Self::nums(vals); let [mode, opacity, rest @ ..] = n.as_slice() else { return }; @@ -405,9 +413,14 @@ impl<'a> Reader<'a> { }; gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); let gs = self.gs.clone(); - if self.last_object().is_some_and(|n| { - matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) || (mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) - }) { + let in_layer = matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })); + if let Some(n) = self.last_object().filter(|n| mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) { + // The mesh's, or the layer's when it is the layer's last object: kept for either. + let before = (n.opacity, n.blend, n.isolate, n.knockout); + (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); + self.mesh_undo = Some(before); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + } else if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { if let Some(n) = self.last_object() { n.opacity = gs.opacity; n.blend = gs.blend; @@ -419,8 +432,7 @@ impl<'a> Reader<'a> { // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until // an operator that isn't part of the state block (or the `LB` that takes it) says the // art moves on, so a path's state before its paint isn't read as its layer's. - self.layer_state = - matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })).then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 813113d5b..12eb54ee3 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -391,6 +391,29 @@ fn a_layer_keeps_its_own_transparency() { let doc = read_ok(&layer(&art_)).doc; assert_eq!(doc.layers[0].opacity, 1.0, "no layer state written: the layer stays opaque"); assert_eq!(art(&doc)[0].opacity, 0.5, "the group's own state"); + // A layer whose last object is a gradient mesh: an `Xy` right after the mesh's end is the + // layer's when `LB` follows (directly or after the layer's `Xd` and `7 () XW`), and the mesh's + // when its own `6 () XW` follows. + let corner = + |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1); + let mesh = format!( + "0 0.8 0 0 0 Xy\n/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n", + corner(0, 0, (0, 10), (10, 0)), + corner(0, 40, (10, 40), (0, 30)), + corner(40, 40, (40, 30), (30, 40)), + corner(40, 0, (30, 0), (40, 10)), + ); + for (after, layer_state, mesh_state) in [ + ("1 0.35 0 0 0 Xy\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ("1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ("1 0.35 0 0 0 Xy\n0 0 Xd\n6 () XW\n", (1.0, BlendMode::Normal), (0.35, BlendMode::Multiply)), + ] { + let doc = read_ok(&layer(&format!("{mesh}{after}"))).doc; + let l = &doc.layers[0]; + assert!(matches!(art(&doc)[0].kind, NodeKind::Mesh(_)), "{after:?}"); + assert_eq!((l.opacity, l.blend), layer_state, "the layer's state after {after:?}"); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].blend), mesh_state, "the mesh's state after {after:?}"); + } } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4b487b5a4..536c0069b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: type on a path spaced round curves as the file lays it out) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai`/EPS editing data: a layer's state after its last mesh stays the layer's) · **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 @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.ai`/EPS editing data: an `Xy` right after a gradient mesh's `/End X!` is the mesh's only when its own `6 () XW` follows; when the layer's `LB` follows (directly or after the layer's `Xd` and `7 () XW`) it is the layer's, as before #4744, and the mesh keeps the state it had. Test `import::ai::tests::a_layer_keeps_its_own_transparency`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: type on a path is spaced round curves as the file's own layout spaces it, closer round the outside and further apart round the inside, though its frame keeps no spacing: the Type on a Path Options › Spacing that does that is fitted from the layout's glyph positions, where it came in at the font's advances and ran on past where the page ends it. In 8 files of a user's set with type round curves (19 stories), the fitted spacing is a fifth of the line's ascent outside a curve (17 stories) and half of it inside (2), and the 4 files whose pages were measured space their glyphs as their layouts do. Own-fixture tests in `import::tests_native` and `import::ate`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file whose data gives two placements is put at the corner of its box when that draws closer to the page and the layers still match it, not only when the first placement fails the comparison: a small logo in the wrong place differed too little to fail, and stayed there. Test `import::tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a file whose layers hold placed (linked) PDFs and no text object comes in from its layers when all of its page's type lies inside the placed files (at either of their placements): the PDF part draws each placed file, type and all, where the layers show its preview, so that type needs no text object. Such a file came in as its PDF part, saying its layers had no text objects for its page's text; type outside the placed files still needs one. Own-fixture test in `import::tests_native`; scores unchanged | @@ -141,7 +142,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 2026-10-30 | minor | `.ai` import: when the layers are compared with the page again with placed files at the corner of their box, the text objects the page doesn't draw are weighed with the images there, from the layers as read: before, the text an image covered at its first placement had already been left out, so a file whose image covered type only there came in without that type or as its PDF part. Test `import::tests_native::text_a_placed_file_covers_only_at_its_first_placement_is_kept`; scores unchanged | | 2026-10-30 | minor | `.ai` import: type read from the editing data's text document keeps its case setting (character attribute 12: 1 Small Caps, at Document Setup's default size, 2 All Caps); it came in as typed, so a heading set in All Caps showed in lower or mixed case where the page draws capitals. Test `import::tests_native::type_from_the_text_document_keeps_its_case_setting`; checked locally against a user's files; scores unchanged | | 2026-10-30 | minor | PDF import: a patch or triangle mesh shading of up to 16,384 patches comes in as gradient meshes (it was 4,096; Illustrator writes up to 14,355 in files users sent), and a stroke painted with one (how Illustrator writes a freeform gradient on a stroke) is the meshes clipped to the area the stroke paints, with its width, caps, joins and dashes; both came in as a flat grey. In two files of a user's set, the formerly grey area now matches a reference rendering in 99% of its pixels. Own-fixture tests in `tests::import_fidelity`; scores unchanged | -| 2026-10-30 | minor | `.ai`/EPS editing data: gradient meshes the reader marked unread, which made the whole file come in as its page or PDF part, are read: a mesh starts grey and its `/CS` is written only where it changes, before the corner it applies to (within a patch too), so each corner takes the space last set before it; named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) paint at the corner's tint and `/NChannel` inks overprint at theirs; meshes up to 128 patches a side and 16,384 patches are read (files users sent had up to 97 × 72). Ten of eleven such files from a user's set now open from their layers; meshes with handle weights other than 1 still don't. Own-fixture tests in `import::ai::mesh`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: gradient meshes the reader marked unread, which made the whole file come in as its page or PDF part, are read: a mesh starts grey and its `/CS` is written only where it changes, before the corner it applies to (within a patch too), so each corner takes the space last set before it; named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) paint at the corner's tint and `/NChannel` inks overprint at theirs; meshes up to 128 patches a side and 16,384 patches are read (files users sent had up to 97 × 72). Ten of eleven such files from a user's set now open from their layers; meshes with handle weights other than 0 or 1 still don't. Own-fixture tests in `import::ai::mesh`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file is put where its matrix takes the canvas point of art space's origin, and, when the layers then don't draw as the page, at the corner of the box it is written with, which many files use; six files of a user's set whose photos landed off their artboards now open from their layers. Own-fixture test in `import::tests_native`; scores unchanged | | 2026-10-30 | minor | SVG export: axis-aligned rectangles are written as ``, circles and ellipses drawn with four standard arcs as ``/``, straight lines as `` and other straight-segment paths as ``/``, at the written precision; a rectangle or ellipse with a dashed stroke, turned ellipses, curves and compound paths stay `` (#4652); scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file written as an object's copy on `%_` lines is read: its `%AI5_BeginPlace` and `%AI5_EndPlace` markers there were dropped, so the `~` that ends it was an unknown operator and the whole file came in as its PDF part; its preview's header, on a `%_` line too, is read. Own-fixture test in `import::ai::tests`; scores unchanged | From 86f88dfc96e90220346eb433fd3e0f073d0f77c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 11:51:23 -0700 Subject: [PATCH 12/52] Type: Alt+arrow spacing steps turn with vertical type In vertical type, Alt+Down/Up (with or without Shift) steps kerning at a caret or tracking of selected text, Alt+Left/Right steps leading, and Alt+Shift+Right/Left steps baseline shift; Cmd/Ctrl makes five steps. Horizontal type keeps its mapping. Selected type objects follow the same rule when all of them are vertical, and a selection that mixes horizontal and vertical type takes the horizontal mapping in one undo step. --- crates/engine/src/cmd/textedit.rs | 4 +- crates/tools/src/text.rs | 4 +- crates/tools/src/text/plain.rs | 24 +++++-- crates/tools/src/text/tests.rs | 59 ++++++++++++++++ crates/tools/src/text/tool.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 25 ++++--- crates/ui-egui/src/shortcuts/tests.rs | 98 +++++++++++++++++++++++++++ docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 9 files changed, 203 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 92a2758ee..ae272f93a 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Step Type", [], None, - "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", + "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", has_doc, type_step ), @@ -1132,7 +1132,7 @@ fn type_step(s: &mut Session, p: &Value) -> Result { /// Cmd/Ctrl+Alt+Q: tracking 0 and kerning Auto (0 in the Character panel) in the type objects in /// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret, -/// only the kerning of the character before it changes, as Alt+←/→ kerns there. +/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there. fn reset_tracking(s: &mut Session, p: &Value) -> Result { let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?; if !ranges.is_empty() { diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 9729985c8..e86bd4c4f 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -202,12 +202,12 @@ pub struct TypeTool { } impl TypeTool { - pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize) -> Option> { + pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize, vertical: bool) -> Option> { if !mods.alt { return None; } let id = self.editing?; - let (attribute, by) = plain::step_key(key, mods.shift, a == b)?; + let (attribute, by) = plain::step_key(key, mods.shift, a == b, vertical)?; let mut out = self.commit(); let by = if mods.cmd { by * 5.0 } else { by }; out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by}))); diff --git a/crates/tools/src/text/plain.rs b/crates/tools/src/text/plain.rs index 32db456c3..6825bfbee 100644 --- a/crates/tools/src/text/plain.rs +++ b/crates/tools/src/text/plain.rs @@ -1,10 +1,22 @@ use super::{ImeSurrounding, Range, ToolKey}; -/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). -/// `None` when `r` runs past the end of `s` or backwards. -/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking; -/// ↑/↓ leading (down opens it up); Shift+↑/↓ baseline shift. -pub fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, f64)> { +/// Map Alt+arrows to one type step. Horizontal ←/→ changes kerning at a caret or +/// tracking on a range, ↑/↓ changes leading, and Shift+↑/↓ changes baseline shift. +/// Vertical ↑/↓ (with or without Shift) changes kerning or tracking, ←/→ changes +/// leading, and Shift+→/← changes baseline shift. +pub fn step_key(key: ToolKey, shift: bool, caret: bool, vertical: bool) -> Option<(&'static str, f64)> { + let shift = shift && !(vertical && matches!(key, ToolKey::Up | ToolKey::Down)); + let key = if vertical { + match key { + ToolKey::Up => ToolKey::Left, + ToolKey::Down => ToolKey::Right, + ToolKey::Right => ToolKey::Up, + ToolKey::Left => ToolKey::Down, + other => other, + } + } else { + key + }; let sign = |up: bool| if up { 1.0 } else { -1.0 }; Some(match (key, shift) { (ToolKey::Left | ToolKey::Right, false) => (if caret { "kerning" } else { "tracking" }, sign(key == ToolKey::Right)), @@ -14,6 +26,8 @@ pub fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, }) } +/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). +/// `None` when `r` runs past the end of `s` or backwards. pub(super) fn char_range_to_bytes(s: &str, r: Range) -> Option> { let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) }; let (a, b) = (byte(r.start)?, byte(r.end)?); diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index 1502533d5..172a8613b 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -898,6 +898,65 @@ fn marked_text_is_underlined_with_the_converting_clause_thick() { assert!(!tool.composing()); } +#[test] +fn spacing_arrows_follow_each_text_objects_orientation() { + let cases = [ + (ToolKey::Down, ALT, "kerning", 1.0), + (ToolKey::Up, ALT, "kerning", -1.0), + (ToolKey::Left, ALT, "leading", 1.0), + (ToolKey::Right, ALT, "leading", -1.0), + (ToolKey::Right, SHIFT_ALT, "baselineShift", 1.0), + (ToolKey::Left, SHIFT_ALT, "baselineShift", -1.0), + (ToolKey::Down, SHIFT_ALT, "kerning", 1.0), + (ToolKey::Up, SHIFT_ALT, "kerning", -1.0), + (ToolKey::Down, Mods { cmd: true, ..ALT }, "kerning", 5.0), + (ToolKey::Up, Mods { cmd: true, ..ALT }, "kerning", -5.0), + ]; + for kind in 0..3 { + for selected in [false, true] { + let (mut d, id, mut tool) = editing("abcd"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + t.vertical = true; + let path = vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)); + t.kind = match kind { + 1 => TextKind::Area { frame: path }, + 2 => TextKind::OnPath { path, start: 0.0, end: None }, + _ => TextKind::Point, + }; + tool.caret = 2; + tool.anchor = if selected { 1 } else { 2 }; + for (key_code, mods, caret_attribute, by) in cases { + let before = (tool.anchor, tool.caret); + let actions = key(&mut tool, &d, key_code, mods); + let step = actions + .iter() + .find_map(|action| match action { + Action::Exec(command, params) if command == "type.step" => Some(params), + _ => None, + }) + .expect("spacing command"); + assert_eq!( + step["attribute"], + if selected && caret_attribute == "kerning" { "tracking" } else { caret_attribute }, + "kind {kind}, selected {selected}" + ); + assert_eq!(step["by"], by); + assert_eq!((tool.anchor, tool.caret), before); + } + } + } + let (mut d, id, mut tool) = editing("abcd"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + t.vertical = true; + key(&mut tool, &d, ToolKey::Down, SHIFT); + assert_eq!((tool.anchor, tool.caret), (0, 1)); + let (d, _, mut tool) = editing("abcd"); + let actions = key(&mut tool, &d, ToolKey::Right, ALT); + assert!(actions.iter().any( + |action| matches!(action, Action::Exec(command, params) if command == "type.step" && params["attribute"] == "kerning" && params["by"] == 1.0) + )); +} + #[test] fn arrows_follow_the_columns_of_vertical_type() { let (mut d, id) = doc_with_text("§§§\n§§§"); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index cef7f4a9b..a3e27487a 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -175,7 +175,7 @@ impl Tool for TypeTool { self.anchor = self.anchor.min(len); let (a, b) = self.sel(); // Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps). - if let Some(out) = self.step_attribute(key, mods, a, b) { + if let Some(out) = self.step_attribute(key, mods, a, b, t.vertical) { return out; } let word = mods.cmd || mods.alt; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 76d6b3967..37d9e7099 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -361,19 +361,28 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Route Alt+arrows to spacing controls when text objects are selected. fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let m = ctx.input(|i| i.modifiers); - if !m.alt - || !app.session.active().is_some_and(|st| { - st.selection.guides.is_empty() - && !st.selection.objects.is_empty() - && st.selection.objects.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_)))) - }) - { + if !m.alt { return false; } + let orientation = app.session.active().and_then(|st| { + if !st.selection.guides.is_empty() || st.selection.objects.is_empty() { + return None; + } + let mut all_vertical = true; + for id in &st.selection.objects { + let node = st.doc.node(*id)?; + let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { return None }; + all_vertical &= t.vertical; + } + Some(all_vertical) + }); + let Some(vertical) = orientation else { + return false; + }; let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)]; for (key, tool_key) in keys { if ctx.input_mut(|i| i.consume_key(m, key)) { - if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false) { + if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false, vertical) { let by = if m.command { by * 5.0 } else { by }; let _ = app.run("type.step", json!({"attribute": attribute, "by": by})); } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index e97b526f9..62686d34d 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1094,6 +1094,104 @@ fn cmd_alt_shift_period_and_comma_step_the_font_size_by_five() { assert_eq!(sizes(&mut app), [(json!("One "), json!(14.0)), (json!("Two"), json!(24.0))]); } +/// Spacing arrow keys change tracking, leading, and baseline shift for a selected vertical text object. +#[test] +fn alt_arrows_rotate_for_selected_vertical_type() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let vertical = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two", "size": 20, "vertical": true})).unwrap()["id"] + .as_u64() + .unwrap(); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [vertical]})).unwrap(); + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let cases = [ + (Key::ArrowDown, Modifiers::ALT, "tracking", 20.0), + (Key::ArrowUp, Modifiers::ALT, "tracking", 0.0), + (Key::ArrowLeft, Modifiers::ALT, "leading", 26.0), + (Key::ArrowRight, Modifiers::ALT, "leading", 24.0), + (Key::ArrowRight, Modifiers::ALT | Modifiers::SHIFT, "baseline_shift", 2.0), + (Key::ArrowLeft, Modifiers::ALT | Modifiers::SHIFT, "baseline_shift", 0.0), + (Key::ArrowDown, Modifiers::ALT | Modifiers::COMMAND, "tracking", 100.0), + (Key::ArrowUp, Modifiers::ALT | Modifiers::COMMAND, "tracking", 0.0), + ]; + for (key, modifiers, field, value) in cases { + frame(&mut app, vec![egui::Event::ModifiersChanged(modifiers), press(key, modifiers)]); + let range = app.session.execute("text.getRange", &json!({"id": vertical})).unwrap(); + assert_eq!(range["runs"][0]["style"][field], json!(value), "{key:?}"); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(vertical)]); + } +} + +#[test] +fn mixed_type_objects_take_horizontal_spacing_chords_in_both_orders() { + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + for vertical_first in [true, false] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let vertical = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One", "size": 20, "vertical": true})).unwrap()["id"] + .as_u64() + .unwrap(); + let horizontal = app.session.execute("text.create", &json!({"x": 80, "y": 50, "text": "Two", "size": 20})).unwrap()["id"].as_u64().unwrap(); + let ids = if vertical_first { [vertical, horizontal] } else { [horizontal, vertical] }; + app.select_tool("type"); + app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + let before = app.session.active().unwrap().history.undo.len(); + let original = ids.map(|id| app.session.execute("text.getRange", &json!({"id": id})).unwrap()); + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowDown, Modifiers::ALT)]); + for id in ids { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["leading"], json!(26.0)); + } + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2); + app.session.execute("edit.undo", &json!({})).unwrap(); + for (id, expected) in ids.into_iter().zip(original) { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range, expected); + } + app.session.execute("edit.redo", &json!({})).unwrap(); + for id in ids { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["leading"], json!(26.0)); + } + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT)]); + for id in ids { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["tracking"], json!(20.0)); + } + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2); + } +} + +#[test] +fn selected_vertical_area_and_path_type_use_vertical_spacing_chords() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let area_frame = app.session.execute("path.create", &json!({"d": "M10 10 L100 10 L100 100 L10 100 Z"})).unwrap()["id"].as_u64().unwrap(); + let path = app.session.execute("path.create", &json!({"d": "M150 10 L250 10"})).unwrap()["id"].as_u64().unwrap(); + let area = + app.session.execute("text.createInPath", &json!({"path": area_frame, "mode": "area", "text": "Area", "vertical": true, "size": 20})).unwrap() + ["id"] + .as_u64() + .unwrap(); + let on_path = app + .session + .execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Path", "vertical": true, "size": 20})) + .unwrap()["id"] + .as_u64() + .unwrap(); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [area, on_path]})).unwrap(); + let modifiers = Modifiers::ALT; + let press = egui::Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers }; + frame(&mut app, vec![egui::Event::ModifiersChanged(modifiers), press]); + for id in [area, on_path] { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["tracking"], json!(20.0)); + } +} + /// #3786: Alt+arrows step a selected text object's spacing instead of copying and nudging it. #[test] fn alt_arrows_step_selected_type_object() { diff --git a/docs/mcp.md b/docs/mcp.md index 5902163c2..f2e5a246b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3173,8 +3173,10 @@ Pen's second anchor at (150, 431), in line with the square's centre. Cmd+Shift+,) step the size. `type.size.increaseByFive` (Cmd+Alt+Shift+., `press_key {key: ".", mods: {cmd: true, alt: true, shift: true}}`) and `type.size.decreaseByFive` (Cmd+Alt+Shift+,) step it by five times `typeSizeIncrement`. The Type tool's Alt+arrows (`press_key {key: "Right", mods: {alt: true}}`) step kerning at - a caret or tracking of the selection (←/→), leading (↑/↓) and baseline shift (Shift+↑/↓), five steps with Cmd/Ctrl - too. `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and + a caret or tracking of the selection (←/→ in horizontal type, ↓/↑ with or without Shift in vertical type), + leading (↑/↓ in horizontal type, ←/→ in vertical type), and baseline shift (Shift+↑/↓ in horizontal type, + Shift+→/← in vertical type). Mixed selected text objects use the horizontal mapping; Cmd/Ctrl makes five steps. + `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and kerning to Auto in the type objects in `ids`, the Type tool's selected text or the selected type objects; at a caret it resets only the kerning of the character before it. `type.resetHorizontalScale` (Cmd+Shift+X, `press_key {key: "X", mods: {cmd: true, shift: true}}`) sets the horizontal scale to 100%, as the Character panel diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5071c4eb..453adabf0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -814,6 +814,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With anchors or segments of a path selected with Direct Selection, Simplify acts on just that part of the path, as the [Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) page puts it: the slider, Auto-Simplify and the dialog refit only each run of selected segments, between its end anchors, which keep their place and their handles on the side away from the run. The anchors and segments outside the runs stay exactly as they were, a closed path's run may cross its start point, separate runs on one path are refitted independently in one undo step, and the runs stay selected. A path with only lone anchors selected is still simplified whole, and `object.path.simplify {wholePaths: true}` simplifies partly selected paths whole. Tests: `crates/engine/src/tests/simplify_part.rs` and `simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path`. Object › Path › Smooth… (#4115) opens a slider under the selection from Minimum to Maximum smoothing with an Auto-Smooth button, as the [Smooth slider](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html) page describes; the canvas previews it and Enter or a click outside keeps it as one undo step (`object.path.smooth {level, auto, ids}`). The page names only the ends, so the rest is VectorCraft's own rule: each selected path, compound path members included, is refitted with curves within 0.1% (level 0) to 2.5% (level 100) of its longer side, corners kept only where it turns back and never with more anchors (a rectangle stays as it is); Auto-Smooth is level 50. Closed paths stay closed and open ones keep their end points. Tests: `crates/engine/src/tests/smooth_path.rs` and `smooth_slider_previews_auto_smooth_and_keeps_the_result`. - **Devanagari shaping:** with an OpenType Devanagari font, Ka, virama and Ssa (क्ष) form one conjunct glyph, Ra and virama before a consonant (र्क) become a reph drawn after it, the i matra (कि) is drawn before its consonant, and Ka with a nukta is drawn like the precomposed क़ (U+0958), as [Microsoft's Devanagari shaping specification](https://learn.microsoft.com/en-us/typography/script-development/devanagari) describes. Each syllable is one cluster of its source bytes, and the arrow keys stop at its ends. A click inside a syllable drawn with several glyphs puts the caret at the syllable's nearer end, in point, area and path type. Tests: `crates/text/src/tests/devanagari.rs`; they print SKIPPED when no such font is installed. +- **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. From 38a68c3b85ffedf6092f3fc728a2d095de8a5fa1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 06:35:25 -0700 Subject: [PATCH 13/52] View: Escape returns full screen modes to Normal In Full Screen Mode and Full Screen Mode with Menu Bar, an unmodified Escape that nothing earlier in the Escape chain takes (a popup, dialog, field, drawer, tool gesture, edit mode, flyout or the view rotation reset) runs view.screenMode with mode 0. Presentation Mode and the F cycle are unchanged. --- crates/ui-egui/src/shortcuts.rs | 9 ++ crates/ui-egui/src/tests/screenmode.rs | 111 +++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 121 insertions(+) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 37d9e7099..e5f930a9b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -245,6 +245,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Escape pressed twice resets a rotated view (with the Rotate View tool, once). let _ = app.run("view.rotateReset", json!({})); escape_pair = None; + } else if matches!(app.ui.screen_mode, 1 | 2) + && !egui::Popup::is_any_open(ctx) + && ctx.input(|i| { + i.events.iter().any(|e| { + matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) + }) + }) + { + let _ = app.run("view.screenMode", json!({ "mode": 0 })); } } // An Escape that ended something can start a pair too. One that closed a popup cannot. diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 0c7f2d2e8..6727d81d4 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -68,6 +68,117 @@ fn snapshot(app: &VectorcraftApp) -> impl Fn(&VectorcraftApp) -> bool + use<> { } } +fn in_screen_mode(ctx: &egui::Context, mode: u8) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 90, "y": 40, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("selection"); + frame(&mut app, ctx, vec![]); + app.run("view.screenMode", json!({"mode": mode})).unwrap(); + frame(&mut app, ctx, vec![]); + app +} + +#[test] +fn escape_leaves_both_full_screen_modes_without_editing() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let unchanged = snapshot(&app); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "mode {mode}"); + assert!(unchanged(&app), "mode {mode}: document, selection and undo history"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape in Normal mode"); + } +} + +#[test] +fn modified_escape_keeps_full_screen_mode() { + for modifiers in [egui::Modifiers::SHIFT, egui::Modifiers::CTRL, egui::Modifiers::COMMAND, egui::Modifiers::ALT] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let events = + [true, false].map(|pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + frame(&mut app, &ctx, events); + assert_eq!(app.ui.screen_mode, 2, "{modifiers:?}"); + } +} + +#[test] +fn drawer_and_isolation_take_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.tools_drawer = true; + frame(&mut app, &ctx, escape()); + assert!(!app.ui.tools_drawer); + assert_eq!(app.ui.screen_mode, mode, "drawer"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after drawer"); + + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("object.isolate", json!({})).unwrap(); + assert!(app.session.active().unwrap().isolation.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().isolation.is_none()); + assert_eq!(app.ui.screen_mode, mode, "isolation"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after isolation"); + } +} + +#[test] +fn a_tool_drag_takes_escape_before_full_screen_exits() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 50.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 120.0, 70.0), view).unwrap(); + assert!(app.session.tool_busy()); + frame(&mut app, &ctx, escape()); + assert!(!app.session.tool_busy()); + assert_eq!(app.ui.screen_mode, 2); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + +#[test] +fn a_popup_takes_escape_before_full_screen_exits() { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let pos = app.canvas_rect.unwrap().center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos), button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + frame(&mut app, &ctx, vec![]); + assert!(egui::Popup::is_any_open(&ctx)); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 2); + assert!(!egui::Popup::is_any_open(&ctx)); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + +#[test] +fn a_rotated_view_resets_before_full_screen_exits() { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + app.select_tool("rotateView"); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0); + assert_eq!(app.ui.screen_mode, 2); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + #[test] fn escape_leaves_presentation_mode() { let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 453adabf0..610b859d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -759,6 +759,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). In Presentation Mode, → and ← go to the next and the previous artboard, and a click anywhere goes to the next one, as the status bar navigator's Next and Previous do (`view.goToArtboard`): on the last artboard → and a click leave the view as it is, and so does ← on the first. The arrow keys do not move the selection there, and the tool does not receive a click, so a click selects and draws nothing (behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). Full Screen Mode and Presentation Mode put the window full screen, covering the taskbar and the window's frame, and Normal Screen Mode or Full Screen Mode with Menu Bar puts it back (#4365); a window the user made full screen stays so. + An unmodified Escape in either Full Screen Mode exits to Normal Screen Mode after any open popup, tool gesture, edit mode, drawer, flyout or view rotation has taken its Escape; a later Escape exits when that action is finished. This is a VectorCraft choice for both full screen variants, based on the public [shortcut list](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html); the list does not specify that priority or the return mode. Presentation Mode shows the active artboard fitted to the screen (fitted again as the window goes full screen) and hides the selection, guides, grids, slices and the artboards' edges and names, as [presentation mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/view-artwork-in-presentation-mode.html) describes. In Presentation Mode no key press other than ←, → and Escape runs a command or reaches the tool (tool keys, Delete, Enter, Tab, F, Shift+F and every command shortcut, the macOS menu bar's key equivalents included), a right click or a long touch opens no context menu, a tap of two or three fingers undoes or redoes nothing, and the pointer over the canvas is the default one instead of the tool's cursor. Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. From dcd4b437a510ffe93b84651eb00bd7787200d6c7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 10:41:04 -0700 Subject: [PATCH 14/52] View: Escape that ends a field, ruler or Tools move keeps full screen In Full Screen Mode and Full Screen Mode with Menu Bar, an Escape that leaves a focused text field, cancels a ruler-origin drag or cancels a floating Tools panel move no longer also returns to Normal Screen Mode; the next Escape does. Tests cover a dialog, a focused field, a tool that claims Escape, pattern edit, Global Edit, a tool flyout and the rotation reset pair in both modes. --- crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/input.rs | 3 +- crates/ui-egui/src/shortcuts.rs | 19 +++ crates/ui-egui/src/tests/screenmode.rs | 164 +++++++++++++++++++++++++ crates/ui-egui/src/tools_float.rs | 21 +++- docs/target-app-parity.md | 2 +- 6 files changed, 209 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0e5475b66..923459f89 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -500,6 +500,9 @@ impl VectorcraftApp { titlebar::resize_zones(ui); } self.ui_fonts.frame(&ctx); + if self.ui.dialog.is_none() && !self.ui.palette_open { + shortcuts::remember_text_focus(&ctx); + } scrub::end_frame(self, &ctx); native_menu::sync(self, &ctx); // Observe once, after all of this pass's input, dialogs, floating panels, palette and diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 986ec60ae..6bcfdfd3f 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once}; +use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once, tools_float}; #[cfg(not(target_arch = "wasm32"))] use super::{fileio, place}; @@ -15,6 +15,7 @@ impl VectorcraftApp { self.write_formats_before_text_input(ctx); native_menu::run(self, ctx); if self.fonts_ready { + tools_float::cancel_move_on_escape(self, ctx); shortcuts::handle(self, ctx); } // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e5f930a9b..2f40778d4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -24,6 +24,22 @@ use typing::is_shortcut_chord; pub(crate) use typing::keep_marked_text; use typing::{drop_typing_presses, type_text}; +fn text_focus_frame_id() -> egui::Id { + egui::Id::unique("shortcut-text-focus-frame") +} + +pub(crate) fn remember_text_focus(ctx: &egui::Context) { + if ctx.egui_wants_keyboard_input() { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(text_focus_frame_id(), frame)); + } +} + +fn text_focused_in_previous_frame(ctx: &egui::Context) -> bool { + let previous = ctx.cumulative_frame_nr_for(ctx.viewport_id()).checked_sub(1); + ctx.data(|d| d.get_temp::(text_focus_frame_id())) == previous +} + /// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere. /// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a /// `Cmd+=` chord also answers `+` however it is typed. @@ -247,6 +263,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { escape_pair = None; } else if matches!(app.ui.screen_mode, 1 | 2) && !egui::Popup::is_any_open(ctx) + && !text_focused_in_previous_frame(ctx) + && !crate::canvas::ruler_origin_dragging(ctx) + && !crate::tools_float::escape_cancelled_move(ctx) && ctx.input(|i| { i.events.iter().any(|e| { matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 6727d81d4..121768422 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -149,6 +149,57 @@ fn a_tool_drag_takes_escape_before_full_screen_exits() { assert_eq!(app.ui.screen_mode, 0); } +#[test] +fn a_ruler_origin_drag_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.view.rulers = true; + frame(&mut app, &ctx, vec![]); + let corner = ctx.read_response(egui::Id::unique("ruler-corner")).expect("the rulers' corner").rect.center(); + let to = app.canvas_rect.unwrap().center(); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(corner)]); + frame(&mut app, &ctx, vec![button(corner, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::ruler_origin_dragging(&ctx), "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Escape cancels the drag in mode {mode}"); + frame(&mut app, &ctx, vec![button(to, false)]); + assert!(!crate::canvas::ruler_origin_dragging(&ctx), "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after the drag in mode {mode}"); + } +} + +#[test] +fn a_floating_tools_move_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("window.panel.float", json!({"panel": "tools", "x": 120, "y": 140})).unwrap(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let original = app.ui.toolbar_pos; + let [x, y] = original.expect("the Tools panel floats"); + let area = ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-floating"))).expect("the floating Tools area"); + let title = area.min + egui::vec2(20.0, 17.0); + let to = title + egui::vec2(100.0, 80.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(title), button(title, true)]); + for step in 1..=6 { + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(title + (to - title) * (step as f32 / 6.0))]); + } + assert_ne!(app.ui.toolbar_pos, original, "the Tools panel moved in mode {mode}: saved {x} {y}, area {area:?}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Escape cancels the Tools move in mode {mode}"); + assert_eq!(app.ui.toolbar_pos, original, "Escape restores the Tools panel's position"); + frame(&mut app, &ctx, vec![button(to, false)]); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after the move in mode {mode}"); + } +} + #[test] fn a_popup_takes_escape_before_full_screen_exits() { let ctx = egui::Context::default(); @@ -179,6 +230,119 @@ fn a_rotated_view_resets_before_full_screen_exits() { assert_eq!(app.ui.screen_mode, 0); } +#[test] +fn a_dialog_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.dialog = Some(crate::state::Dialog::new("rectangle", json!({"width": 20, "height": 20}))); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, escape()); + assert!(app.ui.dialog.is_none(), "mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after the dialog in mode {mode}"); + } +} + +#[test] +fn a_focused_field_keeps_full_screen_mode() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let id = egui::Id::unique("screen-mode-field"); + let mut value = String::new(); + let mut field_frame = |app: &mut VectorcraftApp, events, focus| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + let response = ui.add(egui::TextEdit::singleline(&mut value).id(id)); + if focus { + response.request_focus(); + } + app.ui(ui); + }); + out.textures_delta.clear(); + }; + field_frame(&mut app, vec![], true); + assert!(ctx.egui_wants_keyboard_input()); + field_frame(&mut app, escape(), false); + assert_eq!(app.ui.screen_mode, mode, "focused field in mode {mode}"); + } +} + +#[test] +fn a_claimed_tool_key_keeps_full_screen_mode() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.select_tool("intertwine"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Intertwine claims Escape in mode {mode}"); + app.select_tool("selection"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after changing tools in mode {mode}"); + } +} + +#[test] +fn pattern_edit_and_global_edit_take_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + assert!(app.session.active().unwrap().doc.pattern_edit.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().doc.pattern_edit.is_none()); + assert_eq!(app.ui.screen_mode, mode, "pattern edit in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after pattern edit in mode {mode}"); + + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.globalEdit", json!({})).unwrap(); + assert!(app.session.active().unwrap().global_edit.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().global_edit.is_none()); + assert_eq!(app.ui.screen_mode, mode, "Global Edit in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after Global Edit in mode {mode}"); + } +} + +#[test] +fn a_tool_flyout_takes_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.flyout = Some(0); + frame(&mut app, &ctx, escape()); + assert!(app.ui.flyout.is_none()); + assert_eq!(app.ui.screen_mode, mode, "flyout in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after flyout in mode {mode}"); + } +} + +#[test] +fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "first Escape in mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0, "second Escape after mode {mode}"); + } +} + #[test] fn escape_leaves_presentation_mode() { let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 71101b710..25d86b69f 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -8,7 +8,7 @@ //! agents. Its position is kept with the preferences and in user workspaces, and a position saved //! on a bigger window is clamped into this one when drawn. -use egui::{CornerRadius, Id, Pos2, Rect, Stroke, Vec2, pos2, vec2}; +use egui::{CornerRadius, Id, Key, Pos2, Rect, Stroke, Vec2, pos2, vec2}; use serde_json::{Value, json}; use crate::VectorcraftApp; @@ -33,6 +33,14 @@ fn move_id() -> Id { Id::unique("tools-float-move") } +fn escape_cancel_id() -> Id { + Id::unique("tools-float-escape-cancel") +} + +pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) +} + /// The strip along the window's left edge where the Tools panel docks, as last laid out. pub(crate) fn tools_zone_id() -> Id { Id::unique("tools-float-zone") @@ -50,6 +58,13 @@ fn gesture_allowed(ctx: &egui::Context) -> bool { ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape)) } +/// Cancel a move before shortcuts route the same Escape press. +pub(crate) fn cancel_move_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) { + if ctx.input(|i| i.key_pressed(Key::Escape)) { + track(app, ctx); + } +} + fn start(app: &VectorcraftApp, ctx: &egui::Context, grab: Vec2) { let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return }; ctx.data_mut(|d| d.insert_temp(move_id(), Move { grab, from, original: app.ui.toolbar_pos })); @@ -94,6 +109,10 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { if !gesture_allowed(ctx) { app.ui.toolbar_pos = m.original; ctx.data_mut(|d| d.remove::(move_id())); + if ctx.input(|i| i.key_pressed(Key::Escape)) { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(escape_cancel_id(), frame)); + } return; } let at = ctx.input(|i| i.pointer.interact_pos()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 610b859d8..cbf1e4590 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -759,7 +759,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). In Presentation Mode, → and ← go to the next and the previous artboard, and a click anywhere goes to the next one, as the status bar navigator's Next and Previous do (`view.goToArtboard`): on the last artboard → and a click leave the view as it is, and so does ← on the first. The arrow keys do not move the selection there, and the tool does not receive a click, so a click selects and draws nothing (behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). Full Screen Mode and Presentation Mode put the window full screen, covering the taskbar and the window's frame, and Normal Screen Mode or Full Screen Mode with Menu Bar puts it back (#4365); a window the user made full screen stays so. - An unmodified Escape in either Full Screen Mode exits to Normal Screen Mode after any open popup, tool gesture, edit mode, drawer, flyout or view rotation has taken its Escape; a later Escape exits when that action is finished. This is a VectorCraft choice for both full screen variants, based on the public [shortcut list](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html); the list does not specify that priority or the return mode. + An unmodified Escape in either Full Screen Mode exits to Normal Screen Mode after any open popup, focused field, tool gesture, ruler-origin drag, floating Tools panel move, edit mode, drawer, flyout or view rotation has taken its Escape; a later Escape exits when that action is finished. This is a VectorCraft choice for both full screen variants, based on the public [shortcut list](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html); the list does not specify that priority or the return mode. Presentation Mode shows the active artboard fitted to the screen (fitted again as the window goes full screen) and hides the selection, guides, grids, slices and the artboards' edges and names, as [presentation mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/view-artwork-in-presentation-mode.html) describes. In Presentation Mode no key press other than ←, → and Escape runs a command or reaches the tool (tool keys, Delete, Enter, Tab, F, Shift+F and every command shortcut, the macOS menu bar's key equivalents included), a right click or a long touch opens no context menu, a tap of two or three fingers undoes or redoes nothing, and the pointer over the canvas is the default one instead of the tool's cursor. Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. From 030baab798577776671e6ff8d8695f862d74c671 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 11:28:52 -0700 Subject: [PATCH 15/52] View: Escape that cancels a ruler or Tools move ends nothing else An Escape press that cancels a ruler-origin drag or a floating Tools panel move no longer also closes Edit Toolbar's drawer, leaves pattern edit, isolation or Global Edit, closes the tool flyout or resets a rotated view. A later Escape does that action as usual. --- crates/ui-egui/src/shortcuts.rs | 7 +- crates/ui-egui/src/shortcuts/presentation.rs | 5 +- crates/ui-egui/src/tests/screenmode.rs | 136 +++++++++++++++++++ 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2f40778d4..d8a238ed1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -205,9 +205,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { presentation_keys(app, ctx, escape_pair); return; } + let escape_ended_gesture = crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx); let typing = ctx.egui_wants_keyboard_input(); // Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already). - if app.ui.tools_drawer && !typing && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + if app.ui.tools_drawer && !typing && !escape_ended_gesture && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { app.ui.tools_drawer = false; } let view = app.view_info(); @@ -248,7 +249,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); } - if k == Key::Escape && !busy && !claimed { + if k == Key::Escape && !busy && !claimed && !escape_ended_gesture { if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { @@ -264,8 +265,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } else if matches!(app.ui.screen_mode, 1 | 2) && !egui::Popup::is_any_open(ctx) && !text_focused_in_previous_frame(ctx) - && !crate::canvas::ruler_origin_dragging(ctx) - && !crate::tools_float::escape_cancelled_move(ctx) && ctx.input(|i| { i.events.iter().any(|e| { matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs index c93ca762e..da746cbf5 100644 --- a/crates/ui-egui/src/shortcuts/presentation.rs +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -34,13 +34,14 @@ pub(super) fn escape_press(ctx: &egui::Context) -> Option { } /// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, -/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the -/// Rotate View tool is chosen. +/// neither a ruler-origin drag nor a floating Tools move is ending, and the press is the second of +/// a pair (`second`) or the Rotate View tool is chosen. pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { app.view().is_some_and(|v| v.rotation != 0.0) && (second || app.session.tool_id() == "rotateView") && !egui::Popup::is_any_open(ctx) && !crate::canvas::ruler_origin_dragging(ctx) + && !crate::tools_float::escape_cancelled_move(ctx) } /// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 121768422..3d6cc8dd6 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -80,6 +80,80 @@ fn in_screen_mode(ctx: &egui::Context, mode: u8) -> VectorcraftApp { app } +fn begin_tools_move(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option<[f32; 2]>, egui::Pos2) { + app.run("window.panel.float", json!({"panel": "tools", "x": 120, "y": 140})).unwrap(); + frame(app, ctx, vec![]); + frame(app, ctx, vec![]); + let original = app.ui.toolbar_pos; + let area = ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-floating"))).expect("the floating Tools area"); + let title = area.min + egui::vec2(20.0, 17.0); + let to = title + egui::vec2(100.0, 80.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(title), button(title, true)]); + for step in 1..=6 { + frame(app, ctx, vec![egui::Event::PointerMoved(title + (to - title) * (step as f32 / 6.0))]); + } + assert_ne!(app.ui.toolbar_pos, original, "the Tools panel moved"); + (original, to) +} + +fn begin_ruler_origin_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { + app.ui.view.rulers = true; + frame(app, ctx, vec![]); + let corner = ctx.read_response(egui::Id::unique("ruler-corner")).expect("the rulers' corner").rect.center(); + let to = app.canvas_rect.unwrap().center(); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(corner)]); + frame(app, ctx, vec![button(corner, true)]); + frame(app, ctx, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::ruler_origin_dragging(ctx)); + to +} + +fn release_drag(app: &mut VectorcraftApp, ctx: &egui::Context, to: egui::Pos2) { + frame( + app, + ctx, + vec![egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }], + ); +} + +fn start_escape_action(app: &mut VectorcraftApp, ctx: &egui::Context, action: &str) { + match action { + "drawer" => app.ui.tools_drawer = true, + "pattern" => { + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + } + "isolation" => { + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("object.isolate", json!({})).unwrap(); + } + "global" => { + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.globalEdit", json!({})).unwrap(); + } + "flyout" => { + app.ui.flyout = Some(0); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("flyout-tools"), vec!["selection".to_string(), "directSelection".to_string()])); + } + _ => panic!("unknown Escape action"), + } + assert!(escape_action_active(app, action), "{action} starts"); +} + +fn escape_action_active(app: &VectorcraftApp, action: &str) -> bool { + match action { + "drawer" => app.ui.tools_drawer, + "pattern" => app.session.active().unwrap().doc.pattern_edit.is_some(), + "isolation" => app.session.active().unwrap().isolation.is_some(), + "global" => app.session.active().unwrap().global_edit.is_some(), + "flyout" => app.ui.flyout.is_some(), + _ => false, + } +} + #[test] fn escape_leaves_both_full_screen_modes_without_editing() { for mode in [1, 2] { @@ -200,6 +274,68 @@ fn a_floating_tools_move_takes_escape_before_full_screen_exits() { } } +#[test] +fn cancelling_tools_move_keeps_rotation_in_both_full_screen_modes() { + for mode in [1, 2] { + for tool in ["rotateView", "selection"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.select_tool(tool); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + if tool == "selection" { + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "first Escape leaves mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0); + } + let (original, to) = begin_tools_move(&mut app, &ctx); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.toolbar_pos, original, "{tool} in mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0, "{tool} in mode {mode}"); + release_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0, "later Escape resets rotation"); + } + } +} + +#[test] +fn cancelling_tools_move_keeps_the_next_escape_action() { + for mode in [0, 1, 2] { + for action in ["drawer", "pattern", "isolation", "global", "flyout"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let (original, to) = begin_tools_move(&mut app, &ctx); + start_escape_action(&mut app, &ctx, action); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.toolbar_pos, original, "move cancelled in mode {mode}, {action}"); + assert!(escape_action_active(&app, action), "Escape keeps {action} in mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}, {action}"); + release_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, escape()); + assert!(!escape_action_active(&app, action), "later Escape ends {action} in mode {mode}"); + } + } +} + +#[test] +fn cancelling_ruler_origin_drag_keeps_the_next_escape_action() { + for mode in [0, 1, 2] { + for action in ["pattern", "isolation", "flyout"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let to = begin_ruler_origin_drag(&mut app, &ctx); + start_escape_action(&mut app, &ctx, action); + frame(&mut app, &ctx, escape()); + assert!(escape_action_active(&app, action), "Escape keeps {action} in mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}, {action}"); + release_drag(&mut app, &ctx, to); + assert!(!crate::canvas::ruler_origin_dragging(&ctx)); + frame(&mut app, &ctx, escape()); + assert!(!escape_action_active(&app, action), "later Escape ends {action} in mode {mode}"); + } + } +} + #[test] fn a_popup_takes_escape_before_full_screen_exits() { let ctx = egui::Context::default(); From d792557bbe14588e79f61dc3d2698f663bba9464 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 11:53:07 -0700 Subject: [PATCH 16/52] View: Escape during a canvas pan or zoom drag keeps full screen A Hand or Space drag, a middle-button pan, a Zoom tool drag, a Rotate View drag or a touch gesture is held in the canvas's drag state, which the tool busy check does not see. Escape pressed while one is held no longer leaves Full Screen Mode, closes the Edit Toolbar drawer or ends another action; an Escape after the release does. --- crates/ui-egui/src/canvas.rs | 5 +++ crates/ui-egui/src/shortcuts.rs | 7 ++-- crates/ui-egui/src/tests/screenmode.rs | 48 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 58815f2e7..cd2a142be 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -164,6 +164,11 @@ enum Drag { Present, } +/// Whether the canvas is holding a pointer drag across frames. +pub(crate) fn dragging(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(drag_id()).is_some()) +} + /// Paint the document canvas, complete or cached artwork, navigation and tool overlays. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d8a238ed1..9f3a70515 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -205,10 +205,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { presentation_keys(app, ctx, escape_pair); return; } - let escape_ended_gesture = crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx); + let escape_held_by_drag = + crate::canvas::dragging(ctx) || crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx); let typing = ctx.egui_wants_keyboard_input(); // Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already). - if app.ui.tools_drawer && !typing && !escape_ended_gesture && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + if app.ui.tools_drawer && !typing && !escape_held_by_drag && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { app.ui.tools_drawer = false; } let view = app.view_info(); @@ -249,7 +250,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); } - if k == Key::Escape && !busy && !claimed && !escape_ended_gesture { + if k == Key::Escape && !busy && !claimed && !escape_held_by_drag { if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 3d6cc8dd6..783577ed4 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -223,6 +223,54 @@ fn a_tool_drag_takes_escape_before_full_screen_exits() { assert_eq!(app.ui.screen_mode, 0); } +fn navigation_drag_keeps_screen_mode(kind: &str) { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let button = match kind { + "hand" => { + app.select_tool("hand"); + egui::PointerButton::Primary + } + "middle" => egui::PointerButton::Middle, + "zoom_box" => { + app.select_tool("zoom"); + app.session.prefs.animated_zoom = false; + egui::PointerButton::Primary + } + _ => panic!("unknown navigation drag"), + }; + frame(&mut app, &ctx, vec![]); + let start = app.canvas_rect.unwrap().center(); + let end = start + egui::vec2(35.0, 20.0); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), pointer(start, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(end)]); + assert!(crate::canvas::dragging(&ctx), "{kind} drag began in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "{kind} drag holds Escape in mode {mode}"); + frame(&mut app, &ctx, vec![pointer(end, false)]); + assert!(!crate::canvas::dragging(&ctx), "{kind} drag ended in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after {kind} drag in mode {mode}"); + } +} + +#[test] +fn a_hand_drag_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("hand"); +} + +#[test] +fn a_middle_button_pan_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("middle"); +} + +#[test] +fn a_zoom_box_drag_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("zoom_box"); +} + #[test] fn a_ruler_origin_drag_takes_escape_before_full_screen_exits() { for mode in [1, 2] { From a535d7b890ed6854657d7a3e033b7fc95780ecf9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 07:39:07 -0700 Subject: [PATCH 17/52] Panels: a second press of a panel's function key hides the panel The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency and Symbols now run window.panel with toggle: true, as the Window menu items do. A second press hides an active panel in the expanded dock, and the next press restores it in its group. A chord the user assigns to one of these panels toggles the same way. Plain window.panel calls still only show the panel. --- crates/ui-egui/src/menus/run.rs | 4 +- crates/ui-egui/src/panel_docking/tests.rs | 118 ++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 35 ++++++- crates/ui-egui/src/shortcuts/tests.rs | 24 +++++ docs/target-app-parity.md | 1 + 5 files changed, 177 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 339682c94..f31c50c40 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -40,8 +40,8 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va /// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the /// engine. pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { - // The Window menu's panel items toggle like its other checked items (#2361); the panel's - // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. + // Window menu items and the named panel function keys toggle against the checked state; + // other commands that open panels show them. if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); if app.ui.open_panel.as_deref() == Some(panel) { diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 4bb77928b..f72a433cf 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -63,6 +63,11 @@ impl Harness { self.frame(vec![]); } + fn press(&mut self, key: egui::Key, modifiers: egui::Modifiers) { + self.frame(vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]); + self.frame(vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }]); + } + /// Press at `from` and move to `to` in steps, holding the button. fn press_and_move(&mut self, from: Pos2, to: Pos2) { self.frame(vec![Event::PointerMoved(from), button(from, true)]); @@ -497,3 +502,116 @@ fn window_menu_panels_without_a_place_open_in_their_icon_group_or_float() { h.frame(vec![]); assert!(h.tab("openType").is_some()); } + +#[test] +fn f7_hides_and_restores_the_expanded_layers_tab() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "the first press activates the tab"); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "a second press hides the active tab"); + assert!(h.app.ui.docking_hidden.contains_key("layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "the next press restores it"); + assert_eq!(main_panels(&h.layout()), ["properties", "layers", "libraries"]); + + h.app.run("window.panel.collapse", json!({"panel": "layers", "collapsed": true})).unwrap(); + let layout = h.layout(); + let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"layers".to_string())).unwrap(); + assert!(group.minimized); + h.press(egui::Key::F7, egui::Modifiers::NONE); + let layout = h.layout(); + let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"layers".to_string())).unwrap(); + assert!(!group.minimized, "the first press expands a collapsed group"); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "the following press hides the expanded group"); +} + +#[test] +fn f7_activates_a_background_tab_before_hiding_it() { + let mut h = Harness::new(); + h.app.run("window.panel.move", json!({"panel": "layers", "anchor": "properties"})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "properties"})).unwrap(); + assert!(!showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!h.layout().contains(&"layers".to_string())); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), vec!["properties".to_string(), "libraries".into(), "layers".into()]); +} + +#[test] +fn panel_function_keys_toggle_icon_flyouts_and_floating_groups() { + let mut h = Harness::new(); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes")); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel, None); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes")); + + h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); + h.frame(vec![]); + let rect = floating_window(&h.layout(), "layers").unwrap().rect; + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(floating_window(&h.layout(), "layers").is_none()); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(floating_window(&h.layout(), "layers").unwrap().rect, rect); + h.app.run("window.panel.dock", json!({"panel": "layers"})).unwrap(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "the expanded dock also toggles"); +} + +#[test] +fn other_function_keys_and_keyboard_guards_keep_their_routes() { + let mut h = Harness::new(); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + let keymap = crate::shortcut_editor::current(); + for (chord, command) in [("F8", "ui.symbolOptions"), ("F12", "file.revert")] { + let bound = crate::keymap::vc_chord(chord).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)).unwrap(); + assert_eq!(bound.id, command); + } + h.app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + h.press(egui::Key::F8, egui::Modifiers::NONE); + assert_eq!(h.app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("symbolOptions")); + crate::dialogs::confirm(&mut h.app).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc.symbols.len(), 1); + crate::menus::invoke(&mut h.app, "view.rotate", json!({})); + assert!(h.app.ui.dialog.is_some()); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "F7 does nothing while a dialog is open"); + h.app.ui.dialog = None; + + let field = egui::Id::unique("panel-shortcut-text-focus"); + h.ctx.memory_mut(|m| m.request_focus(field)); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "F7 does nothing while a text field has focus"); + h.ctx.memory_mut(|m| m.surrender_focus(field)); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "F7 toggles when the field releases focus"); + + let dir = vectorcraft_testkit::temp_dir("panel-shortcut-revert"); + let path = dir.join(format!("panel-shortcut-revert-{}.vectorcraft", std::process::id())); + let services = crate::Services { + read: Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p, data| std::fs::write(p, data).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut saved = VectorcraftApp::new(Session::new(), services); + saved.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + saved.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + saved.run("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + saved.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + assert_eq!(saved.session.doc().unwrap().doc.node_count(), 3); + let ctx = egui::Context::default(); + let key = Event::Key { key: egui::Key::F12, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + ctx.run_ui(egui::RawInput { events: vec![key], ..Default::default() }, |ui| crate::shortcuts::handle(&mut saved, ui.ctx())) + .textures_delta + .clear(); + assert_eq!(saved.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::confirm::KIND)); + crate::dialogs::confirm(&mut saved).unwrap(); + assert_eq!(saved.session.doc().unwrap().doc.node_count(), 2, "F12 reloads the saved document"); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9f3a70515..5724f4d41 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -381,7 +381,7 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { None if hit.params.is_null() => { let _ = app.run(&hit.id, json!({})); } - None => crate::menus::invoke(app, &hit.id, hit.params.clone()), + None => crate::menus::invoke(app, &hit.id, shortcut_params(hit)), } } } @@ -729,6 +729,36 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H }) } +/// Params used when a shortcut invokes its command. The named panel function keys retain their +/// toggle action when their bindings are reassigned. +fn shortcut_params(c: &CommandRef) -> serde_json::Value { + let mut params = if c.params.is_null() { json!({}) } else { c.params.clone() }; + if c.id == "window.panel" + && matches!( + params.get("panel").and_then(serde_json::Value::as_str), + Some( + "brushes" + | "color" + | "layers" + | "info" + | "gradient" + | "stroke" + | "attributes" + | "graphicStyles" + | "appearance" + | "align" + | "transform" + | "pathfinder" + | "transparency" + | "symbols" + ) + ) + { + params["toggle"] = json!(true); + } + params +} + /// Runs what a shortcut is bound to: a tool, a panel (`window.panel {panel}`) or a command. fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) { if let Some(tool) = c.id.strip_prefix("tool:") { @@ -738,8 +768,7 @@ fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) { // every artboard instead of the art. crate::panels::artboards::select_all(app); } else { - let p = if c.params.is_null() { json!({}) } else { c.params.clone() }; - crate::menus::invoke(app, &c.id, p); + crate::menus::invoke(app, &c.id, shortcut_params(c)); } } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 62686d34d..703d28f3f 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1,5 +1,29 @@ use super::*; +#[test] +fn rebound_layers_chord_toggles_and_plain_panel_command_reveals() { + let mut overrides = craft_keymap::Overrides::new(); + crate::keymap::set_override(&mut overrides, "panel:layers", "Cmd+Alt+Shift+F7"); + let keymap = crate::keymap::keymap_with(&overrides); + let old = crate::keymap::vc_chord("F7").unwrap(); + assert!(crate::keymap::lookup(&keymap, &old).is_none(), "the old F7 is free"); + let rebound = crate::keymap::vc_chord("Cmd+Alt+Shift+F7").unwrap(); + let hit = crate::keymap::lookup(&keymap, &rebound).unwrap(); + assert_eq!(hit.id, "window.panel"); + assert_eq!(hit.params["panel"], "layers"); + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel", json!({"panel": "layers"})).unwrap(); + let ctx = egui::Context::default(); + run_hit(&mut app, &ctx, &hit); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(false)); + run_hit(&mut app, &ctx, &hit); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true)); + app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true), "a plain command still reveals"); +} + /// One headless frame delivering `events` to the shortcut handler. fn frame(app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { let ctx = egui::Context::default(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cbf1e4590..4172d15fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -611,6 +611,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. + - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols hide an active panel on a second press and restore it on the next press, using the same visibility state as the Window menu. A reassigned chord keeps that behavior. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). From 78b01044fc9dd89978bdb2caf628271d857b19b3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 10:56:01 -0700 Subject: [PATCH 18/52] Panels: a panel's function key hides and restores its whole group When Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency or Symbols is in front of an expanded dock group or a floating group, its function key now hides the whole group instead of closing only that tab. The next press brings the group back in the same place with every tab and the panel in front, and Window > a hidden sibling also restores the group. A background tab still comes to the front on the first press, and in an icon column the key opens and closes the flyout. Other panel shortcuts and plain window.panel calls keep their behavior. --- crates/ui-egui/src/menus/run.rs | 3 ++ crates/ui-egui/src/panel_docking.rs | 42 ++++++++++++++++++ crates/ui-egui/src/panel_docking/tests.rs | 53 ++++++++++++++++++++++- crates/ui-egui/src/shortcuts.rs | 1 + crates/ui-egui/src/shortcuts/tests.rs | 38 ++++++++++++++++ docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 7 files changed, 137 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index f31c50c40..8eff1fc12 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -48,6 +48,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Result if !layout.contains(&panel.to_string()) && !app.ui.docking_hidden.contains_key(panel) && crate::panels::library_panel::parse(panel).is_some() { return crate::panels::library_panel::float(app, panel); } + if app.ui.docking_hidden.contains_key(panel) { + return restore_hidden_group(app, panel); + } let value = shared_command(app, "window.panel.activate", &json!({"panel": panel}))?; let layout = layout_of(&app.ui); Ok(match floating_window(&layout, panel) { @@ -345,6 +348,45 @@ fn show_panel(app: &mut VectorcraftApp, params: &Value) -> Result }) } +/// Hides the group holding an active panel and records each tab's return location. +pub(crate) fn hide_group(app: &mut VectorcraftApp, panel: &str) -> Result { + let mut txn = Transaction::begin(app)?; + let panel = panel.to_string(); + let closed = txn.apply(Action::CloseGroup { panel })?; + Ok(txn.commit(app, closed, None)) +} + +/// Reopens the hidden tabs connected to `panel` and brings it to the front. +fn restore_hidden_group(app: &mut VectorcraftApp, panel: &str) -> Result { + let mut txn = Transaction::begin(app)?; + let mut root = panel.to_string(); + for _ in 0..craft_ui::docking::MAX_PANELS { + let Some((_, location)) = txn.workspace.hidden.iter().find(|(id, _)| id == &root) else { break }; + let Some(anchor) = location.anchor.as_ref().filter(|_| matches!(location.placement, Placement::Tab { .. })) else { break }; + if !txn.workspace.hidden.iter().any(|(id, _)| id == anchor) || anchor == &root { + break; + } + root = anchor.clone(); + } + let mut members = vec![root]; + for _ in 0..craft_ui::docking::MAX_PANELS { + let next = txn.workspace.hidden.iter().find_map(|(id, location)| { + (matches!(location.placement, Placement::Tab { .. }) + && location.anchor.as_ref().is_some_and(|anchor| members.contains(anchor)) + && !members.contains(id)) + .then(|| id.clone()) + }); + let Some(next) = next else { break }; + members.push(next); + } + for member in members { + let fallback = first_dock_panel(&txn.workspace.layout, &member); + txn.workspace.set_visible(member, true, fallback, model_permissions).map_err(|e| e.to_string())?; + } + txn.apply(Action::Activate { panel: panel.to_string() })?; + Ok(txn.commit(app, Vec::new(), Some(panel.to_string()))) +} + /// Show `panel` as `window.panel` does, but leave it be when it already shows ([`showing`]): a /// command that opens a panel to work in (Edit › Color Settings…) never puts it away. pub(crate) fn reveal_panel(app: &mut VectorcraftApp, panel: &str) -> Result { diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index f72a433cf..fe25133d2 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -536,10 +536,59 @@ fn f7_activates_a_background_tab_before_hiding_it() { h.press(egui::Key::F7, egui::Modifiers::NONE); assert!(showing(&h.app.ui, "layers")); h.press(egui::Key::F7, egui::Modifiers::NONE); - assert!(!h.layout().contains(&"layers".to_string())); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); h.press(egui::Key::F7, egui::Modifiers::NONE); assert!(showing(&h.app.ui, "layers")); - assert_eq!(group_of(&h.layout(), &"layers".into()), vec!["properties".to_string(), "libraries".into(), "layers".into()]); + assert_eq!(group_of(&h.layout(), &"layers".into()), vec!["properties".to_string(), "layers".into(), "libraries".into()]); +} + +#[test] +fn f7_hides_and_restores_the_whole_expanded_group() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + h.app.run("window.panel", json!({"panel": "properties", "toggle": true})).unwrap(); + assert_eq!(group_of(&h.layout(), &"properties".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "properties")); +} + +#[test] +fn f7_hides_and_restores_a_floating_tab_group() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap(); + h.frame(vec![]); + let before = h.layout(); + let window = floating_window(&before, "layers").unwrap(); + let rect = window.rect; + assert_eq!(group_of(&before, &"layers".into()), ["properties", "layers", "libraries"]); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(floating_window(&h.layout(), "layers").is_none()); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(floating_window(&h.layout(), "layers").unwrap().rect, rect); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "layers")); +} + +#[test] +fn f7_toggles_a_multi_tab_icon_flyout() { + let mut h = Harness::new(); + h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); + h.frame(vec![]); + assert!(in_iconic_column(&h.layout(), "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel, None); + assert!(in_iconic_column(&h.layout(), "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); } #[test] diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5724f4d41..00249145d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -755,6 +755,7 @@ fn shortcut_params(c: &CommandRef) -> serde_json::Value { ) { params["toggle"] = json!(true); + params["toggleGroup"] = json!(true); } params } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 703d28f3f..a8f9b08c1 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1,5 +1,43 @@ use super::*; +#[test] +fn named_panel_default_chords_toggle_their_panels() { + let keymap = crate::keymap::keymap_with(&craft_keymap::Overrides::new()); + for (panel, chord) in [ + ("brushes", "F5"), + ("color", "F6"), + ("layers", "F7"), + ("graphicStyles", "Shift+F5"), + ("appearance", "Shift+F6"), + ("align", "Shift+F7"), + ("transform", "Shift+F8"), + ("info", "Cmd+F8"), + ("gradient", "Cmd+F9"), + ("pathfinder", "Cmd+Shift+F9"), + ("stroke", "Cmd+F10"), + ("transparency", "Cmd+Shift+F10"), + ("attributes", "Cmd+F11"), + ("symbols", "Cmd+Shift+F11"), + ] { + let chord = crate::keymap::vc_chord(chord).unwrap(); + let hit = crate::keymap::lookup(&keymap, &chord).unwrap(); + assert_eq!(hit.id, "window.panel"); + assert_eq!(hit.params["panel"], panel); + assert_eq!(shortcut_params(&hit)["toggle"], true); + assert_eq!(shortcut_params(&hit)["toggleGroup"], true); + } +} + +#[test] +fn other_panel_shortcuts_keep_plain_panel_params() { + for panel in ["character", "swatches"] { + let hit = CommandRef { id: "window.panel".into(), params: json!({"panel": panel}) }; + let params = shortcut_params(&hit); + assert!(params.get("toggle").is_none(), "{panel} gained toggle"); + assert!(params.get("toggleGroup").is_none(), "{panel} gained toggleGroup"); + } +} + #[test] fn rebound_layers_chord_toggles_and_plain_panel_command_reveals() { let mut overrides = craft_keymap::Overrides::new(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4172d15fe..55ad521f9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -611,7 +611,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. - - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols hide an active panel on a second press and restore it on the next press, using the same visibility state as the Window menu. A reassigned chord keeps that behavior. + - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f56514e57..68f5227a1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -248,7 +248,7 @@ selected objects, as the Appearance panel's Add New Fill and Add New Stroke butt ~110 distinct modified shortcuts on menu commands plus single-key tool shortcuts for the default toolbar (`crates/tools/src/catalog.rs`), a shortcut editor with chords, and saved sets. Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, and Cmd/Ctrl+L still adds a layer without the dialog. F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does. Not yet diffed item by item -against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: the panel shortcuts, +against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: other panel shortcuts, the type shortcuts (tracking/kerning/leading/baseline steps with Alt/Shift), and selection shortcuts in the Layers panel. The shortcut editor also lists the commands that are in no menu but run without parameters (Edit › Duplicate, panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1539). It lists each button of the From 7a16e302d28593e71002ddb6a3418a93d3b824ec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 12:12:31 -0700 Subject: [PATCH 19/52] Panels: a panel's function key restores only the tabs its group hid Hiding a group with a panel's function key records the tabs it closed, and the next press reopens only that set with the panel in front. A tab closed on its own before the group was hidden, or reopened and closed again while the group was hidden, stays closed. The record is saved with the workspace. --- crates/ui-egui/src/panel_docking.rs | 42 ++++++++++------------ crates/ui-egui/src/panel_docking/tests.rs | 38 +++++++++++++++++++- crates/ui-egui/src/panels/library_panel.rs | 7 ++++ crates/ui-egui/src/state.rs | 4 +++ crates/ui-egui/src/workspaces.rs | 5 +++ 5 files changed, 72 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 54f9666ad..02a95cfd5 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -353,38 +353,30 @@ pub(crate) fn hide_group(app: &mut VectorcraftApp, panel: &str) -> Result