From 621e1a6640dff383fd9205483454b93273c0d02e Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:23:54 +0900 Subject: [PATCH] =?UTF-8?q?Type:=20Proportional=20Metrics=20=E2=80=94=20fu?= =?UTF-8?q?ll-width=20glyphs=20on=20the=20font's=20proportional=20widths,?= =?UTF-8?q?=20`palt`=20(#966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A character attribute (Character panel menu with the East Asian options; `text.setFormat` / `text.setRangeStyle {proportionalMetrics}`), saved only when on. In horizontal type it applies `palt`; vertical type sets as before until the vertical shaping change (`vpal`) follows. Line-end Punctuation Half Width takes nothing more off the punctuation `palt` re-spaced (the flush opening bracket, consecutive punctuation, the line-end half width): the segment is shaped once more without `palt`, and the glyphs whose advance differs are marked. Punctuation the font leaves full width is trimmed as before. SVG writes `"palt" 1` with the character's other features. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/text.rs | 6 +++ crates/engine/src/cmd/panelcmds.rs | 6 ++- crates/engine/src/cmd/textedit.rs | 8 ++- crates/engine/src/cmd/typecmd.rs | 28 +++++++++++ crates/svg/src/css.rs | 22 +++++---- crates/svg/tests/text_layout.rs | 14 ++++++ crates/text/src/features.rs | 17 +++++++ crates/text/src/layout.rs | 4 +- crates/text/src/shape.rs | 68 +++++++++++++++++--------- crates/text/src/tests_typo.rs | 45 +++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/character.rs | 18 +++++-- docs/mcp.md | 13 +++++ docs/target-app-parity.md | 2 +- 24 files changed, 220 insertions(+), 43 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 42ffe80b0..73b5e00ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 7ed8247b9..012e64b2b 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -95,6 +95,11 @@ pub struct CharStyle { /// their line line up with it. #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] pub char_align: CharAlign, + /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the + /// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before + /// for now. + #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] + pub proportional_metrics: bool, } /// Where a character smaller than the largest on its line lines up with it: on the Roman @@ -205,6 +210,7 @@ impl Default for CharStyle { stroke_dash: None, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, + proportional_metrics: false, small_caps: None, } } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index fe1c4d642..62204f755 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), 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), 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\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), 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`; horizontal type), 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\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -215,6 +215,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "direction", "leadingModel", "charAlign", + "proportionalMetrics", "burasagari", "composer", ]; @@ -314,6 +315,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = char_align { st.char_align = v; } + if let Some(v) = flag("proportionalMetrics") { + st.proportional_metrics = v; + } }); let span = super::typecmd::para_span(range, t); t.edit_paras(span, |para| { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 3a2593ac3..8d6ec5482 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, 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\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} 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}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, 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\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type)} 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}", has_doc, set_range_style ), @@ -208,6 +208,7 @@ pub(crate) struct CharChange { position: Option, small_caps: Option>, char_align: Option, + proportional_metrics: Option, } /// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). @@ -291,6 +292,7 @@ impl CharChange { position, small_caps, char_align: char_align_param(p, cmd)?, + proportional_metrics: flag("proportionalMetrics"), }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -319,6 +321,7 @@ impl CharChange { && self.position.is_none() && self.small_caps.is_none() && self.char_align.is_none() + && self.proportional_metrics.is_none() } fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { @@ -405,6 +408,9 @@ impl CharChange { if let Some(v) = self.char_align { st.char_align = v; } + if let Some(v) = self.proportional_metrics { + st.proportional_metrics = v; + } } } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index f75120a66..38f2c3a1c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -859,6 +859,34 @@ mod area_tests { } } + /// Proportional Metrics (#966): a character attribute, saved only when on. + #[test] + fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.proportional_metrics); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.proportional_metrics)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.proportional_metrics); + // Characters selected with the Type tool: only the range takes it (雅 is 3 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect(); + assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]); + // Styles written without it (documents from before it) read as off. + let old = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert!(old.get("proportionalMetrics").is_none()); + assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); + } + #[test] fn new_type_is_composed_with_line_end_half_width_punctuation() { use vectorcraft_doc::Mojikumi; diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index f98092b65..8775a97d8 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -109,15 +109,19 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { // Manual kerning replaces the font's pair kerning. p.push(("font-kerning", "none".into())); } - if !st.features.is_empty() { - let v: Vec = st - .features - .iter() - .map(|t| match t.strip_prefix('-') { - Some(off) => format!("\"{off}\" 0"), - None => format!("\"{t}\" 1"), - }) - .collect(); + let mut v: Vec = st + .features + .iter() + .map(|t| match t.strip_prefix('-') { + Some(off) => format!("\"{off}\" 0"), + None => format!("\"{t}\" 1"), + }) + .collect(); + // Proportional Metrics: `palt` (SVG writes vertical type as outlines). + if st.proportional_metrics { + v.push("\"palt\" 1".into()); + } + if !v.is_empty() { p.push(("font-feature-settings", v.join(", "))); } match (st.underline, st.strikethrough) { diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index c6587ab27..94f4dee5f 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -105,6 +105,20 @@ fn rich_text_writes_spacing_shift_and_scale() { assert_eq!(attr(tags[1], "y"), attr(tags[0], "y")); } +/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own. +#[test] +fn proportional_metrics_write_palt() { + let proportional = CharStyle { proportional_metrics: true, ..style(12.0) }; + let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) }; + let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 3, "{svg}"); + assert!(!tags[0].contains("palt"), "{}", tags[0]); + assert!(tags[1].contains("font-feature-settings:"palt" 1"), "{}", tags[1]); + assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); +} + #[test] fn fewer_tspans_writes_one_per_line() { let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) }; diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 1cf826302..88bf919d5 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -160,6 +160,23 @@ impl OtFeatures { // and digits itself (they would lie upside down, and tate-chu-yoko break). v.push(f(b"vert", true)); } + if self.proportional(st) { + v.push(f(b"palt", true)); + } + v + } + + /// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in + /// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before. + pub(crate) fn proportional(&self, st: &CharStyle) -> bool { + st.proportional_metrics && !self.vertical + } + + /// The harfrust features for text in style `st` with full-width glyphs on their full widths: + /// [`Self::resolve`] without `palt`. + pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec { + let mut v = self.resolve(st); + v.retain(|x| x.tag != Tag::new(b"palt")); v } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0ebf2e141..338820588 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -387,7 +387,8 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) { /// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its /// advance an em, give or take a tenth), the space that mojikumi can take off. fn punct_half(g: &SGlyph, kind: Punct) -> Option { - if punct(g.ch)? != kind || g.tcy.is_some() { + // A glyph Proportional Metrics re-spaced has no empty half left to take off (#966). + if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional { return None; } let em = g.face.units_per_em(); @@ -1176,6 +1177,7 @@ mod wrapping_tests { tcy: None, inline: None, lead: 0.0, + proportional: false, level: unicode_bidi::Level::ltr(), }) .collect() diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index f82f447cd..328fcac17 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -49,6 +49,9 @@ pub(crate) struct SGlyph { /// Japanese composition: the space taken off before the glyph (an opening bracket after /// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line. pub lead: f64, + /// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width, + /// and Japanese composition takes no more space off it (#966). + pub proportional: bool, /// Resolved Unicode bidi embedding level (logical source order). pub level: Level, /// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph. @@ -117,6 +120,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph { h.len = 0; h.ch = '-'; h.inline = None; + h.proportional = false; h } @@ -422,6 +426,7 @@ fn inline_glyph(face: &Arc, st: &CharStyle, art: &InlineArt, run: usiz ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR), tcy: None, lead: 0.0, + proportional: false, // U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in // RTL text like any other glyph once the line is reordered. level, @@ -470,34 +475,48 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' '); let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff + // Proportional Metrics: which glyphs `palt` gave another advance than their full width. + let mut proportional = vec![]; let shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); - let mut buf = UnicodeBuffer::new(); - for (i, c) in text_seg.char_indices() { - let cl = (range.start + i) as u32; - if upper { - for u in c.to_uppercase() { - buf.add(u, cl); + let shape = |feats: &[Feature]| { + let mut buf = UnicodeBuffer::new(); + for (i, c) in text_seg.char_indices() { + let cl = (range.start + i) as u32; + if upper { + for u in c.to_uppercase() { + buf.add(u, cl); + } + } else { + buf.add(c, cl); } - } else { - buf.add(c, cl); } - } - buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }); - buf.guess_segment_properties(); - // Harfrust keeps this Unicode context for joining/positional shaping, but does not emit - // glyphs for it. This retains context at real style/font segmentation boundaries without - // shaping across those boundaries or claiming cross-style ligatures/mark attachment. - if let Some(pre) = text.get(context.start..range.start) { - buf.set_pre_context(pre); - } - if let Some(post) = text.get(range.end..context.end) { - buf.set_post_context(post); - } - let feats: Vec = feats.resolve(st); - let gb = shaper.shape(buf, ShapeOptions::new().features(&feats)); - for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { - raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)); + buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }); + buf.guess_segment_properties(); + // Harfrust keeps this Unicode context for joining/positional shaping, but does not emit + // glyphs for it. This retains context at real style/font segmentation boundaries without + // shaping across those boundaries or claiming cross-style ligatures/mark attachment. + if let Some(pre) = text.get(context.start..range.start) { + buf.set_pre_context(pre); + } + if let Some(post) = text.get(range.end..context.end) { + buf.set_post_context(post); + } + let gb = shaper.shape(buf, ShapeOptions::new().features(feats)); + gb.glyph_infos() + .iter() + .zip(gb.glyph_positions()) + .map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)) + .collect::>() + }; + raw = shape(&feats.resolve(st)); + if feats.proportional(st) { + // `palt` is positioning only: the same glyphs set on their full widths tell which + // advances it changed (kerning and the rest change both alike). + let full = shape(&feats.resolve_fixed_width(st)); + if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) { + proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect(); + } } }); if shaped.is_none() { @@ -553,6 +572,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe ch, tcy: None, lead: 0.0, + proportional: proportional.get(gi).copied().unwrap_or(false), level: *level, inline: None, }); diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index 4897e53be..5bf85d8a0 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -749,3 +749,48 @@ fn layout_10k_area_text_is_fast() { assert!(per < budget, "{justify:?} {composer:?}: {per} ms"); } } + +/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional +/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt` +/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a +/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as +/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not +/// 〘〙 (skipped without it). +#[test] +fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() { + use vectorcraft_doc::Mojikumi; + if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() }; + // Right-aligned in a frame ten ems wide, so a line's end trim moves the line. + let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| { + let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right); + t.vertical = vertical; + t.para.mojikumi = m; + layout(db(), &t) + }; + let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::>(); + let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::>(); + let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01); + let off = adv(&lay("「あ漢」", false, Mojikumi::None, false)); + let on = adv(&lay("「あ漢」", true, Mojikumi::None, false)); + assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}"); + assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}"); + assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}"); + // Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's + // start (「), between punctuation (」、) or at the line's end (」). + for text in ["「あ」、漢", "漢「あ」"] { + let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false)); + assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none)); + assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none)); + } + // Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end. + let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false)); + assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half)); + let moved = x0(&half)[1] - x0(&none)[1]; + assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}"); + // Vertical type sets as before. + assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true)))); +} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d4e5e6f6e..8599f203f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2000,6 +2000,7 @@ Proof Colors Farbproof Proof Setup Proof einrichten Properties Eigenschaften + Proportional Metrics Proportionale Metrik Proportional Oldstyle Proportionale Mediävalziffern Pucker & Bloat… Zusammenziehen und aufblasen… Pucker Tool Zusammenziehen-Werkzeug diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6bb2aa3f7..e1dc04199 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2051,6 +2051,7 @@ Proof Colors Colores de prueba Proof Setup Ajuste de prueba Properties Propiedades + Proportional Metrics Métrica proporcional Proportional Oldstyle Estilo antiguo proporcional Pucker & Bloat… Fruncir y engordar… Pucker Tool Herramienta Fruncir diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 23092e6d3..41a1a44d5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2048,6 +2048,7 @@ Proof Colors Couleurs d’épreuve Proof Setup Format d’épreuve Properties Propriétés + Proportional Metrics Métrique proportionnelle Proportional Oldstyle Elzévirien proportionnel Pucker & Bloat… Contracter et dilater… Pucker Tool Outil Contraction diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c76bd87db..12eded13d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2051,6 +2051,7 @@ Proof Colors Colori di prova Proof Setup Impostazione prova Properties Proprietà + Proportional Metrics Metrica proporzionale Proportional Oldstyle Proporzionali stile antico Pucker & Bloat… Contrai e gonfia… Pucker Tool Strumento Contrai diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5835f335d..8baf8dc60 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -37,6 +37,7 @@ Em Box Top/Right 仮想ボディの上 / 右 ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 + Proportional Metrics プロポーショナルメトリクス Favorites お気に入り Flip 反転 Japanese 日本語 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 40d715df7..b5039cb26 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1721,6 +1721,7 @@ Proof Colors Provar Cores Proof Setup Configuração de Prova Properties Propriedades + Proportional Metrics Métrica Proporcional Pucker & Bloat… Encolher e Inchar… Pucker Tool Ferramenta Encolher Punctuation Space Espaço de Pontuação diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e324fd011..f218c5c3f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2047,6 +2047,7 @@ Proof Colors Цветопроба Proof Setup Настройка цветопробы Properties Свойства + Proportional Metrics Пропорциональные метрики Proportional Oldstyle Пропорциональные старого стиля Pucker & Bloat… Сморщивание и раздувание… Pucker Tool Инструмент «Сморщивание» diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c8410593f..e01e389c2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2043,6 +2043,7 @@ Proof Colors Пробні кольори Proof Setup Налаштування кольоропроби Properties Властивості + Proportional Metrics Пропорційні метрики Proportional Oldstyle Пропорційні цифри старого стилю Pucker & Bloat… Зморщування та роздування … Pucker Tool Інструмент «Зморщування» diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c4ed3cd12..e293f0b92 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1976,6 +1976,7 @@ Proof Colors 校样颜色 Proof Setup 校样设置 Properties 属性 + Proportional Metrics 比例度量 Proportional Oldstyle 比例旧式数字 Pucker & Bloat… 缩拢与膨胀… Pucker Tool 收缩工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2827e6141..784465963 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2029,6 +2029,7 @@ Proof Colors 校樣色彩 Proof Setup 校樣設定 Properties 內容 + Proportional Metrics 比例度量 Proportional Oldstyle 比例舊式數字 Pucker & Bloat… 縮攏與膨脹… Pucker Tool 縮攏工具 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 607ae8726..a255d8ca2 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -655,6 +655,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`). + let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics); + if menu_item(ui, tl!("Proportional Metrics"), has, proportional) { + format(app, json!({"proportionalMetrics": !proportional})); + } ui.separator(); } for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] { @@ -784,17 +789,20 @@ mod tests { assert!(!crate::menus::enabled(&app, "type.bold")); } - /// Character Alignment is in the panel menu with the East Asian options only (as Mojikumi Set - /// and Top-to-Top Leading are in the Paragraph panel). + /// Character Alignment and Proportional Metrics are in the panel menu with the East Asian + /// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel). #[test] fn character_alignment_shows_with_the_east_asian_options() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone(); app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); - let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Alignment"); - assert!(!shown(&mut app)); + let shown = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, menu); + (text.contains("Character Alignment"), text.contains("Proportional Metrics")) + }; + assert_eq!(shown(&mut app), (false, false)); app.session.prefs.show_east_asian_options = true; - assert!(shown(&mut app)); + assert_eq!(shown(&mut app), (true, true)); } } diff --git a/docs/mcp.md b/docs/mcp.md index 5650634fc..d74f20839 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2627,6 +2627,19 @@ without ideographs uses its em box. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"charAlign":"icfTop"}}} ``` +## Proportional Metrics + +`text.setFormat {proportionalMetrics: true}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, +proportionalMetrics}` (a range) set full-width glyphs on the proportional widths the font gives them with the +OpenType `palt` feature; glyphs the font doesn't re-space keep their full width. Line-end Punctuation Half Width then takes nothing more off the punctuation `palt` re-spaced (the opening +bracket at a line's start, consecutive punctuation, the closing mark at a line's end); punctuation left full width is +trimmed as before. Off by default and saved only when on. Horizontal type only for now: vertical type sets as before. +One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 87fd35fb0..fc9c8d072 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -420,7 +420,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type, and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (vertical type's `vpal` not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).