From c078dbb92fd45c95c10ccaf360ae58e1a685cbb3 Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:43:14 +0100 Subject: [PATCH 01/57] Snap to Point draws no Smart Guides labels or lines with Smart Guides off With Smart Guides off, View > Snap to Point still pulls points onto anchors and ruler guides, but it showed the magenta "anchor" label while drawing, since its targets kept the default guide style. Its targets now draw no labels or lines. --- crates/tools/src/guides/targets.rs | 6 ++++-- crates/tools/src/guides/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 41b9f517ee..f2a451f110 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -568,8 +568,10 @@ impl Targets { } /// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and - /// unlocked, as the selection tools pick them) pull the pointer into line where they run. - pub(super) fn for_snap_to_point(self, cx: &ToolContext) -> Self { + /// unlocked, as the selection tools pick them) pull the pointer into line where they run. It + /// snaps without Smart Guides, so it draws none of their labels and lines. + pub(super) fn for_snap_to_point(mut self, cx: &ToolContext) -> Self { + self.style = GuideStyle { lines: false, labels: false, ..GuideStyle::of(cx) }; self.anchors_only().with_rulers(cx) } diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index a4f3bc49fd..1b6ddbaa12 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -952,3 +952,26 @@ fn a_drawn_point_lands_on_a_corner_of_a_glyphs_bounds() { assert!((q - tr).cross(dir).abs() < 1e-6, "angle {angle}: {q:?} is off the right edge"); } } + +/// With Smart Guides off, View → Snap to Point still pulls a drawn point onto an anchor, but +/// draws no Smart Guides: no "anchor" label, no lines. +#[test] +fn snap_to_point_without_smart_guides_shows_no_guides() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + // Within Snap to Point's 2 px of the anchor at (200, 200). + let at = Point::new(201.0, 199.0); + let off = ToolContext { smart_guides: false, snap_to_point: true, ..cx(&d, &s, &p) }; + let mut snap = DrawSnap::default(); + assert_eq!(snap.hover(&off, at, &[], None), Point::new(200.0, 200.0)); + assert!(snap.guides().is_empty(), "{:?}", snap.guides()); + assert_eq!(snap.press(&off, at, &[], None), Point::new(200.0, 200.0)); + assert!(snap.guides().is_empty(), "{:?}", snap.guides()); + // A dragged selection's grabbed point too. + let (q, ov) = Targets::snap_to_point(&off, &[]).unwrap().snap_point(at, 4.0); + assert_eq!((q, ov.len()), (Point::new(200.0, 200.0), 0), "{ov:?}"); + // Smart Guides on: the same point says "anchor". + let mut snap = DrawSnap::default(); + assert_eq!(snap.hover(&cx(&d, &s, &p), at, &[], None), Point::new(200.0, 200.0)); + assert_eq!(labels(snap.guides()), ["anchor"]); +} From 2f8b4ddeca6fff710d5316ab5f0be880ba94dbb8 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Thu, 12 Nov 2026 19:40:18 -0700 Subject: [PATCH 02/57] 3D: a tapered Extrude & Bevel wall is one face Extruded type with a taper showed a row of light ticks along its walls, and with a stroke a long light line down a wall (#7019). A taper only scales each wall's far edge toward the center of the bounds as it goes back: the scale is linear in depth, so without a twist each wall stays a flat trapezoid, bevel bands included. The walls were cut into 8 slices all the same, and at every joint between slices a neighbor's seam painted a light tick onto the wall. Walls are now cut into slices only for a twist, which bends them; the ticks and the long line are gone from tapered, untwisted walls. Twisted walls and Inflate's sides are sliced as before. A tapered wall now draws like an untapered one, with three effects: - A wall seen almost edge-on ends in one longer sliver instead of the last of its slices. With mitered seams that sliver's thorn is longer; round seam joins (#6980) keep it within half the seam's width. - Its seam is as wide as an untapered wall's (the width follows the face's thickness, up to 0.75 pt), wider than a slice's was. - It is sorted by one depth, like an untapered wall, so at a concave corner it can be drawn over a nearer face where the slices had happened to avoid that (#7023). Test: solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced checks that a tapered square's four walls are four flat faces, that a taper adds no faces to a circle's walls, with bevels (both sides, inside), in perspective or hollow, and that a twisted wall is still sliced. With taper slicing it fails (32 wall faces for the square's 4, 608 for the circle's 76). Docs: docs/three-d.md, docs/target-app-parity.md. --- crates/three-d/src/solid/build.rs | 5 ++-- crates/three-d/src/solid/tests.rs | 44 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + docs/three-d.md | 3 ++- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index bd4030dfc4..d8c18f292f 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -505,8 +505,9 @@ fn steps(o: &Extrude) -> Vec { } else { out.push(Step { inset: 0.0, t: 0.0, face: 1.0 }); } - // Twist and taper bend the walls: cut them into slices. - let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; + // A twist bends the walls: cut them into slices. A taper alone keeps each wall flat (its edge + // only scales toward the center as it goes back), so it needs none. + let slices = if o.twist != 0.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; let back_height = if bevel.is_some_and(|b| b.both_sides) { height } else { 0.0 }; let (t0, t1) = (height / d, 1.0 - back_height / d); for k in 0..=slices { diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index be887180b2..123462b41c 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -154,6 +154,50 @@ fn taper_shrinks_the_back_and_twist_turns_it() { assert!(open.len() > 2 * 8, "{}", open.len()); } +/// A taper only scales each wall's far edge toward the center, so the wall stays one flat face; +/// cut into slices, each joint between them showed a light tick. A twist bends the walls, which +/// are still cut into slices. +#[test] +fn a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let v = View { rotation_x: -35.0, rotation_y: 25.0, ..front() }; + // Every side facet, the ones turned away included. + let sides = |o: Extrude, v: View| -> Vec { + extrude(&p, FillRule::NonZero, b, Extrude { hidden: true, ..o }, v).unwrap().into_iter().filter(|f| f.side).collect() + }; + let o = Extrude { depth: 40.0, taper: 0.475, ..Extrude::default() }; + let walls = sides(o, v); + assert_eq!(walls.len(), 4, "one face for each of a square's four walls"); + for f in &walls { + let [ring] = &f.view[..] else { panic!("{} contours", f.view.len()) }; + let n = super::build::newell(std::slice::from_ref(ring)); + let len = n.0.hypot(n.1).hypot(n.2); + let off = |q: &V3| ((q.0 - ring[0].0) * n.0 + (q.1 - ring[0].1) * n.1 + (q.2 - ring[0].2) * n.2) / len; + assert!(ring.iter().all(|q| off(q).abs() < 1e-9), "a flat wall: {:?}", ring.iter().map(off).collect::>()); + } + // Beveled, in perspective or hollow, a taper adds no faces either. + let bevel = Bevel { width: 8.0, height: 8.0, ..Bevel::default() }; + let perspective = View { perspective: 60.0, ..v }; + for (o, v) in [ + (Extrude { bevel: Some(bevel), ..o }, v), + (Extrude { bevel: Some(Bevel { both_sides: true, ..bevel }), ..o }, v), + (Extrude { bevel: Some(Bevel { inside: true, shape: BevelShape::Step, ..bevel }), ..o }, v), + (o, perspective), + (Extrude { cap: false, ..o }, perspective), + ] { + assert_eq!(sides(o, v).len(), sides(Extrude { taper: 1.0, ..o }, v).len(), "{o:?} {v:?}"); + } + // A curved outline's walls take one band each too: a taper adds no faces to a circle's. + let (cp, cb) = shape(&circle(Point::new(50.0, 50.0), 50.0)); + let round = |o: Extrude| extrude(&cp, FillRule::NonZero, cb, Extrude { hidden: true, ..o }, v).unwrap().into_iter().filter(|f| f.side).count(); + assert_eq!(round(o), round(Extrude { taper: 1.0, ..o }), "a tapered circle's walls"); + // A twist still cuts each wall into at least 8 slices, tapered or not. + for taper in [1.0, o.taper] { + let twisted = sides(Extrude { twist: 30.0, taper, ..o }, v).len(); + assert!(twisted >= 4 * 8, "{twisted} faces for a twisted square's walls"); + } +} + #[test] fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5851d78ce..5ea962d294 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1156,6 +1156,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. + - Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). diff --git a/docs/three-d.md b/docs/three-d.md index 2eb3e6c7fe..109acc2f45 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -77,7 +77,7 @@ object is where the art was. |---|---| | Depth | 0–2,000 pt. At 0 the object is flat (the art turned like Rotate). | | Twist | 0° to 360°: the back face turned against the front, the walls following in slices. | -| Taper | 1–100 %: the back face's size against the front's. | +| Taper | 1–100 %: the back face's size against the front's. Without a twist, each wall (and each bevel band) stays flat, so it is one face, not cut into slices. | | Cap | **Solid** closes the front and back; **Hollow** leaves them open, showing the insides of the walls. | | Bevel Shape | **None**, **Classic** (a straight chamfer), **Rounded** (convex quarter round), **Cove** (concave), **Step** (two steps) or **Ogee** (an S curve). Our own cross-sections. | | Width, Height | How far the bevel reaches across the face and along the depth (0–1,000 pt each). The height is limited to the depth (half of it on each side with Bevel both sides). | @@ -418,6 +418,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| +| 2026-11-12 | Extrude & Bevel cuts its walls into slices only for a twist. A taper alone keeps each wall flat, so it is one face, without the light ticks at the joints between slices or the light line down a wall (#7019). Inflate still slices its sides for a taper. | | 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. | | 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | From 226ac437b9350811987ee58f7a621b19c2588835 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 19:55:05 -0800 Subject: [PATCH 03/57] Type: Warichu stacks selected characters on lines inside their line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Character panel menu has Warichu and Warichu Settings… with Show East Asian Options. Warichu characters are set at a percentage of their size and stacked on 1 to 6 lines inside their line: one above the other in horizontal type and side by side in vertical type. Warichu Settings sets Lines, Line Gap, Scale and Alignment. - text.setFormat and text.setRangeStyle take warichu, warichuLines, warichuGap, warichuScale and warichuAlign; ui.warichuSettings opens the dialog. - A stretch of warichu characters is one block that stays whole on one line. The canvas and every export draw the stacked lines; SVG, PDF and the .ai writers write such type as outlines, as they do ruby. Type on a path sets the characters at their own size. - The attributes are saved natively, carried by character styles and translated in every complete catalog. Docs: docs/mcp.md, docs/type-parity.md. --- crates/doc/src/lib.rs | 1 + crates/doc/src/text.rs | 5 +- crates/doc/src/text/char_style.rs | 113 ++++- crates/doc/src/text/object.rs | 36 +- crates/doc/src/text/tabs_lists.rs | 3 +- crates/engine/src/cmd/appearance.rs | 2 + crates/engine/src/cmd/fileio/native.rs | 2 +- crates/engine/src/cmd/panelcmds.rs | 46 ++- crates/engine/src/cmd/textedit.rs | 81 +++- crates/engine/src/cmd/textstyles.rs | 25 +- crates/engine/src/cmd/textstyles/tests.rs | 7 +- crates/engine/src/cmd/typecmd/area_tests.rs | 102 +++++ crates/engine/src/cmd/typecmd/block.rs | 1 + crates/eps/src/ai_editing/writer/texts.rs | 2 + crates/eps/src/legacy_ai/encoding.rs | 7 +- crates/mcp/src/ui_catalog.rs | 6 + crates/pdf/src/export/text.rs | 5 +- crates/svg/src/export/text.rs | 11 +- crates/text/src/harakat.rs | 10 + crates/text/src/justify.rs | 5 + crates/text/src/layout.rs | 21 +- crates/text/src/layout/breaking.rs | 17 +- crates/text/src/layout/driver.rs | 18 +- crates/text/src/layout/flow.rs | 64 ++- crates/text/src/layout/vertical.rs | 18 +- crates/text/src/layout/warichu.rs | 310 ++++++++++++++ crates/text/src/layout/wrapping_tests.rs | 1 + crates/text/src/shape.rs | 57 ++- crates/text/src/tests.rs | 1 + crates/text/src/tests/warichu.rs | 414 +++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/dialogs/warichu.rs | 289 +++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/state.rs | 4 +- crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/panels/character.rs | 37 +- crates/ui-egui/src/panels/character/tests.rs | 37 ++ docs/mcp.md | 38 ++ docs/type-parity.md | 1 + 66 files changed, 1872 insertions(+), 69 deletions(-) create mode 100644 crates/text/src/layout/warichu.rs create mode 100644 crates/text/src/tests/warichu.rs create mode 100644 crates/ui-egui/src/dialogs/warichu.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2a970f2686..24dbe1e881 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -115,6 +115,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::FontHeightReference; pub use text::GlyphChoice; +pub use text::WarichuAlign; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index d69da9bae2..440c379283 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -17,7 +17,10 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, ScriptMetrics}; +pub use char_style::{ + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, + WARICHU_SCALE, WarichuAlign, +}; pub use composite::{ COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, CompositeParts, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index a880a2a2bc..16b63e0809 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -145,6 +145,29 @@ pub struct CharStyle { /// #[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")] pub standard_vertical_roman: bool, + /// Warichu (Character panel menu, East Asian options): these characters are set at + /// [`Self::warichu_scale`] percent of their size and stacked on [`Self::warichu_lines`] lines + /// inside their line, one above the other in horizontal type and side by side in vertical type. + /// A stretch of marked characters is one block that takes the settings of its first character + /// and stays whole on one line. Type on a path sets them at their own size on the path. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub warichu: bool, + /// Warichu Settings › Lines: how many lines a warichu block is stacked on, within + /// [`WARICHU_LINES`]. Saved only when not 2. + #[serde(default = "two", rename = "warichuLines", skip_serializing_if = "is_two")] + pub warichu_lines: u8, + /// Warichu Settings › Line Gap, in points from 0 to [`MAX_WARICHU_GAP`]: the space between the + /// lines of a warichu block. Saved only when not 0. + #[serde(default, rename = "warichuGap", skip_serializing_if = "crate::skip::is_default")] + pub warichu_gap: f64, + /// Warichu Settings › Scale: the size of warichu characters as a percentage of their size, + /// within [`WARICHU_SCALE`]. Saved only when not 50. + #[serde(default = "fifty", rename = "warichuScale", skip_serializing_if = "is_fifty")] + pub warichu_scale: f64, + /// Warichu Settings › Alignment: how the lines of a warichu block line up along the block. + #[serde(default, rename = "warichuAlign", skip_serializing_if = "crate::skip::is_default")] + pub warichu_align: WarichuAlign, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] @@ -417,6 +440,82 @@ fn hundred() -> f64 { /// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em. pub const MAX_AKI: f64 = 1000.0; +/// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]). +pub const WARICHU_LINES: std::ops::RangeInclusive = 1..=6; + +/// The largest Warichu Settings Line Gap ([`CharStyle::warichu_gap`]), in points. +pub const MAX_WARICHU_GAP: f64 = 100.0; + +/// The Warichu Settings Scale ([`CharStyle::warichu_scale`]) range, in percent of the size. +pub const WARICHU_SCALE: std::ops::RangeInclusive = 10.0..=100.0; + +fn two() -> u8 { + 2 +} + +fn is_two(v: &u8) -> bool { + *v == 2 +} + +fn fifty() -> f64 { + 50.0 +} + +fn is_fifty(v: &f64) -> bool { + *v == 50.0 +} + +/// How the lines of a warichu block line up along the block ([`CharStyle::warichu_align`]): +/// each line set flush left (top, in vertical type), centered or flush right (bottom), or spread +/// to the block's length with the last line set flush left, centered or flush right, or spread as +/// well. Auto is the default and sets the lines as [`WarichuAlign::JustifyLeft`] does. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WarichuAlign { + #[default] + Auto, + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, +} + +impl WarichuAlign { + /// Every alignment, in the order Warichu Settings lists them. + pub const ALL: [WarichuAlign; 8] = [ + WarichuAlign::Auto, + WarichuAlign::Left, + WarichuAlign::Center, + WarichuAlign::Right, + WarichuAlign::JustifyLeft, + WarichuAlign::JustifyCenter, + WarichuAlign::JustifyRight, + WarichuAlign::JustifyAll, + ]; + + /// The name files and commands give this alignment (as it is saved). + pub fn id(self) -> &'static str { + match self { + WarichuAlign::Auto => "auto", + WarichuAlign::Left => "left", + WarichuAlign::Center => "center", + WarichuAlign::Right => "right", + WarichuAlign::JustifyLeft => "justifyLeft", + WarichuAlign::JustifyCenter => "justifyCenter", + WarichuAlign::JustifyRight => "justifyRight", + WarichuAlign::JustifyAll => "justifyAll", + } + } + + /// The alignment [`WarichuAlign::id`] names; `None` for any other name. + pub fn from_id(id: &str) -> Option { + WarichuAlign::ALL.into_iter().find(|a| a.id() == id) + } +} + pub(super) fn yes() -> bool { true } @@ -464,6 +563,11 @@ impl Default for CharStyle { tcy_up_down: 0.0, tcy_left_right: 0.0, standard_vertical_roman: false, + warichu: false, + warichu_lines: 2, + warichu_gap: 0.0, + warichu_scale: 50.0, + warichu_align: WarichuAlign::Auto, small_caps: None, digits: Digits::Default, font_height_reference: FontHeightReference::EmBox, @@ -484,7 +588,10 @@ impl CharStyle { /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right /// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to - /// [`MAX_AKI`], and one that is not a number becomes 0. + /// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale + /// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to + /// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its + /// own units. pub fn keep_dimensions_in_range(&mut self) { let default = Self::default(); let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; @@ -499,6 +606,10 @@ impl CharStyle { let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) }; self.aki_left = aki(self.aki_left); self.aki_right = aki(self.aki_right); + self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end()); + self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) }; + self.warichu_scale = + if self.warichu_scale.is_nan() { default.warichu_scale } else { self.warichu_scale.clamp(*WARICHU_SCALE.start(), *WARICHU_SCALE.end()) }; } /// Do the characters draw a stroke (a paint and a positive weight)? pub fn has_stroke(&self) -> bool { diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 68f41abd5b..dd4002dd60 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -34,10 +34,19 @@ impl TextObject { style.baseline_shift *= scale.points; style.tcy_up_down *= scale.points; style.tcy_left_right *= scale.points; + style.warichu_gap *= scale.points; style.h_scale *= scale.horizontal; - if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.tcy_up_down, style.tcy_left_right, style.h_scale] - .iter() - .all(|v| v.is_finite()) + if ![ + style.size, + style.leading.unwrap_or(0.0), + style.baseline_shift, + style.tcy_up_down, + style.tcy_left_right, + style.warichu_gap, + style.h_scale, + ] + .iter() + .all(|v| v.is_finite()) { return None; } @@ -114,6 +123,12 @@ impl TextObject { pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } + /// Does layout stack some of this type's characters as warichu ([`CharStyle::warichu`])? Not + /// on a path, which sets them as they are. Exports that write live text write such type as + /// outlines, as they do ruby: their text can't place the stacked lines. + pub fn stacks_warichu(&self) -> bool { + !matches!(self.kind, TextKind::OnPath { .. }) && self.runs.iter().any(|r| r.style.warichu && !r.text.is_empty()) + } /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. /// /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a @@ -492,8 +507,23 @@ impl crate::Document { st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); st.aki_left = num("akiLeft").unwrap_or(st.aki_left); st.aki_right = num("akiRight").unwrap_or(st.aki_right); + st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap); + st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale); st.keep_dimensions_in_range(); + // Lines is a whole number: a stored number outside the range, or with a fraction, + // becomes the nearest whole number within it. + if let Some(old) = def.attrs.get_mut("warichuLines") + && old.is_number() + && old.as_u64().is_none_or(|v| !super::WARICHU_LINES.contains(&u8::try_from(v).unwrap_or(u8::MAX))) + { + let (lo, hi) = (*super::WARICHU_LINES.start(), *super::WARICHU_LINES.end()); + let v = old.as_f64().filter(|v| v.is_finite()).map_or(2.0, |v| v.round().clamp(f64::from(lo), f64::from(hi))); + // Within 1..6 after the clamp, so the cast keeps the value. + *old = (v as u8).into(); + } let kept = [ + ("warichuGap", Some(st.warichu_gap)), + ("warichuScale", Some(st.warichu_scale)), ("size", Some(st.size)), ("h_scale", Some(st.h_scale)), ("v_scale", Some(st.v_scale)), diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 4fcaa8d078..b8531b402d 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -357,7 +357,7 @@ impl ParaList { /// The style the marker is set in: `first` (the style of the paragraph's first character) /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// Insert Aki, tate-chu-yoko, underline and strikethrough. + /// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough. pub fn marker_style(first: &CharStyle) -> CharStyle { CharStyle { baseline_shift: 0.0, @@ -370,6 +370,7 @@ impl ParaList { aki_right: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, + warichu: false, underline: false, strikethrough: false, ..first.clone() diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index fb86fa7852..cbd77af6b7 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1430,6 +1430,7 @@ fn copied_character_styles<'a>(source: &Node, targets: impl Iterator bool { (from.baseline_shift, to.baseline_shift), (from.tcy_up_down, to.tcy_up_down), (from.tcy_left_right, to.tcy_left_right), + (from.warichu_gap, to.warichu_gap), (from.h_scale, to.h_scale), ] .iter() diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 4e2e9a85fd..382f06bc5f 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -133,7 +133,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "outlineText", ty: "boolean", default: "false", - description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines", + description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, warichu, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines", }, ]; diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 1ab5707d58..3cd8d938bb 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -465,6 +465,23 @@ fn list_level_param(p: &Value, cmd: &str) -> Result> { } } +/// Set the Warichu and Warichu Settings of `st` that are given: Warichu on or off, Lines, Line Gap +/// (in the type's own units), Scale and Alignment. +fn set_warichu( + st: &mut vectorcraft_doc::CharStyle, + on: Option, + lines: Option, + gap: Option, + scale: Option, + align: Option, +) { + st.warichu = on.unwrap_or(st.warichu); + st.warichu_lines = lines.unwrap_or(st.warichu_lines); + st.warichu_gap = gap.unwrap_or(st.warichu_gap); + st.warichu_scale = scale.unwrap_or(st.warichu_scale); + st.warichu_align = align.unwrap_or(st.warichu_align); +} + fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; if p.get("paragraphIndents").is_some() { @@ -479,6 +496,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "allCaps", "proportionalMetrics", "tateChuYoko", + "warichu", "standardVerticalRoman", "noBreak", "kashidas", @@ -571,6 +589,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "tateChuYoko", "tcyUpDown", "tcyLeftRight", + "warichu", + "warichuLines", + "warichuGap", + "warichuScale", + "warichuAlign", "akiLeft", "akiRight", "standardVerticalRoman", @@ -617,6 +640,10 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let aki_left = super::textedit::aki_param(p, "akiLeft", C)?; let aki_right = super::textedit::aki_param(p, "akiRight", C)?; + let warichu_lines = super::textedit::warichu_lines_param(p, C)?; + let warichu_gap = super::textedit::warichu_number_param(p, "warichuGap", C)?; + let warichu_scale = super::textedit::warichu_number_param(p, "warichuScale", C)?; + let warichu_align = super::textedit::warichu_align_param(p, C)?; let mojikumi = match p.get("mojikumi") { None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { @@ -717,9 +744,13 @@ fn set_format(s: &mut Session, p: &Value) -> Result { // A paragraph whose Minimum, Desired and Maximum would no longer be in order. let mut out_of_order: Option<&str> = None; let range = super::typecmd::TextRange::parse(p, C)?; - // The character attributes, given the baseline shift, horizontal scale and Tate-chu-yoko - // offsets in the type's own units. - let chars = |st: &mut vectorcraft_doc::CharStyle, baseline_shift: Option, h_scale: Option, tcy: (Option, Option)| { + // The character attributes, given the baseline shift, horizontal scale, Tate-chu-yoko offsets + // and warichu Line Gap in the type's own units. + let chars = |st: &mut vectorcraft_doc::CharStyle, + baseline_shift: Option, + h_scale: Option, + tcy: (Option, Option), + gap: Option| { if let Some(k) = kerning { st.kerning = k; } @@ -777,6 +808,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = tcy.1 { st.tcy_left_right = v; } + set_warichu(st, flag("warichu"), warichu_lines, gap, warichu_scale, warichu_align); if let Some(v) = aki_left { st.aki_left = v; } @@ -842,7 +874,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?; let local = |v: Option| v.map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose(); let tcy = (local(tcy_up_down)?, local(tcy_left_right)?); - super::typecmd::style_chars(t, range, |st| chars(st, baseline_shift, h_scale, tcy)); + let gap = + warichu_gap.map(|v| super::typecmd::local_type_value(t, v, (0.0, vectorcraft_doc::text::MAX_WARICHU_GAP), false, C)).transpose()?; + super::typecmd::style_chars(t, range, |st| chars(st, baseline_shift, h_scale, tcy, gap)); let span = super::typecmd::para_span(range, t); let paras = |para: &mut vectorcraft_doc::ParaStyle, out_of_order: &mut Option<&'static str>| { if let Some(v) = num("leftIndent") { @@ -951,7 +985,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); - changed |= super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + changed |= super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right), warichu_gap)); if let Some(paras) = para_change { // The kept attributes are checked too: they may differ from the labels'. changed |= super::graph::capture_label_paragraphs(d, &ids, &|para| { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 6fc8b12edc..0fd7c7d97d 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -459,6 +459,12 @@ pub(crate) struct CharChange { /// Tate-chu-yoko Settings › Up/Down and Left/Right, in points. tcy_up_down: Option, tcy_left_right: Option, + warichu: Option, + /// Warichu Settings › Lines, Line Gap (points), Scale (percent) and Alignment. + warichu_lines: Option, + warichu_gap: Option, + warichu_scale: Option, + warichu_align: Option, /// Insert Aki Left and Right, in 1/1000 em. aki_left: Option, aki_right: Option, @@ -495,6 +501,41 @@ pub(crate) fn tcy_offset_param(p: &Value, key: &str, cmd: &str) -> Result