diff --git a/ROADMAP.md b/ROADMAP.md index 9cc33a8c6..c57a7ab64 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -128,6 +128,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged. + - 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. - 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729). diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c17306216..0817b1cd6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -119,8 +119,8 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, - JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, - SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, + JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, + RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index a3aad9243..957dea76d 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1235,6 +1235,10 @@ fn one() -> f64 { 1.0 } +fn fifty() -> f64 { + 50.0 +} + impl InlineArt { pub fn new(symbol: impl Into) -> Self { Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None } @@ -1300,6 +1304,68 @@ fn reverse_from_end(path: &mut PathData) { } } +/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in +/// vertical type (the Japanese convention), or below (left in vertical type). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RubyPosition { + #[default] + Above, + Below, +} + +/// How ruby text lines up over its base text when the two are different widths. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RubyAlign { + /// Centred over the base text. + #[default] + Center, + /// Starting at the base text's start (left in horizontal type, top in vertical). + Start, + /// Ending at the base text's end (right in horizontal type, bottom in vertical). + End, +} + +/// Ruby (furigana, phonetic guide): a reading set over a range of the text. +/// +/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as +/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text; +/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and +/// allowed to overhang neighbouring characters when the reading is longer than its base. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RubySpan { + /// Byte offset of the first base character in the plain text. + pub start: usize, + /// Byte offset just past the last base character. + pub end: usize, + /// The reading, set in the base characters' font (its size comes from `size_percent`). + pub text: String, + #[serde(default)] + pub position: RubyPosition, + #[serde(default)] + pub align: RubyAlign, + /// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual). + #[serde(default = "fifty")] + pub size_percent: f64, + /// Let a reading wider than its base hang over the neighbouring characters instead of being + /// squeezed to the base's width. + #[serde(default = "yes")] + pub overhang: bool, +} + +impl RubySpan { + /// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging). + pub fn new(start: usize, end: usize, text: impl Into) -> Self { + Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true } + } + /// `size_percent` made finite and usable: 1–100% (untrusted input). + pub fn safe_size_percent(&self) -> f64 { + if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 } + } +} + /// A text object. `runs` split into paragraphs at `\n`. /// /// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every @@ -1315,6 +1381,10 @@ pub struct TextObject { /// Maps text space (origin = first baseline start for point type) to the document. pub xf: Affine, pub runs: Vec, + /// Ruby (furigana / phonetic guide) over byte ranges of the plain text; empty = none. Kept on + /// the object, so the runs (and their styles) stay the text itself. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ruby: Vec, #[serde(default)] pub para: ParaStyle, /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. @@ -1438,6 +1508,7 @@ impl TextObject { kind: TextKind::Point, xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun::new(text, style)], + ruby: Vec::new(), para: ParaStyle::default(), paras: Vec::new(), area: AreaOptions::default(), @@ -1454,6 +1525,54 @@ impl TextObject { pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } + /// 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 + /// range past the text or an inverted one would lay out nothing (or panic in a silly place). + pub fn normalize_ruby(&mut self) { + let len: usize = self.runs.iter().map(|r| r.text.len()).sum(); + for r in &mut self.ruby { + r.start = r.start.min(len); + r.end = r.end.min(len); + if r.start > r.end { + std::mem::swap(&mut r.start, &mut r.end); + } + r.text = r.text.trim().to_string(); + } + self.ruby.retain(|r| r.start < r.end && !r.text.is_empty()); + self.ruby.sort_by_key(|r| (r.start, r.end)); + } + /// The ruby span covering byte `byte` of the plain text, if any. + pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> { + self.ruby.iter().find(|r| r.start <= byte && byte < r.end) + } + /// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered. + pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator { + let (a, b) = (start.min(end), start.max(end)); + self.ruby.iter().filter(move |r| r.start < b && a < r.end) + } + /// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with + /// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into + /// keep their start and end up where the replaced part ended. Call it after every edit of the + /// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word. + pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) { + let (start, end) = (start.min(end), start.max(end)); + let delta = inserted as i64 - (end.saturating_sub(start)) as i64; + let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize }; + for r in &mut self.ruby { + if r.end <= start { + continue; + } + if r.start >= end { + r.start = shift(r.start); + r.end = shift(r.end); + } else { + r.start = r.start.min(start); + r.end = shift(r.end).max(r.start); + } + } + self.normalize_ruby(); + } /// Number of paragraphs (`\n`-separated; empty text has one). pub fn paragraph_count(&self) -> usize { 1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::() @@ -1744,12 +1863,57 @@ impl crate::Document { } } } + + /// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches + /// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art + /// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry + /// ranges from outside: a range past the text, an inverted one or an empty reading would lay + /// out nothing. It recurses through the art: call it on a document no deeper than + /// [`crate::depth::MAX_DEPTH`]. + pub fn fix_ruby(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + t.normalize_ruby(); + } + }; + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + } } #[cfg(test)] mod tests { use super::*; + /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. + #[test] + fn ruby_is_saved_and_missing_ruby_reads_as_none() { + let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default()); + t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }]; + let json = serde_json::to_string(&t).unwrap(); + assert!(json.contains("かんじ")); + let back: TextObject = serde_json::from_str(&json).unwrap(); + assert_eq!(back.ruby, t.ruby); + assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang); + // Old files have no `ruby` key: none. + let mut old: serde_json::Value = serde_json::from_str(&json).unwrap(); + old.as_object_mut().unwrap().remove("ruby"); + assert!(serde_json::from_value::(old).unwrap().ruby.is_empty()); + // A damaged file's range past the text and its empty reading are clamped away. + let mut damaged = t.clone(); + damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")]; + damaged.normalize_ruby(); + assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6)); + } + /// Digits map every digit of the three sets into the chosen one, leave the rest alone, and /// are saved only when set. #[test] diff --git a/crates/engine/src/cmd/inline.rs b/crates/engine/src/cmd/inline.rs index 7c6fa20ed..68538a5cd 100644 --- a/crates/engine/src/cmd/inline.rs +++ b/crates/engine/src/cmd/inline.rs @@ -89,6 +89,8 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let style = edit::insertion_style(&t.runs, a, b); let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]); + // The inline character is one plain-text byte range: ruby over it follows the text. + t.ruby_after_replace(a, b, vectorcraft_doc::text::INLINE_CHAR.len_utf8()); refresh_bounds(t); Ok(caret) })?; diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 378e71290..9294b0bf6 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -209,6 +209,8 @@ fn edit_range(s: &mut Session, p: &Value) -> Result { Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r), None => edit::replace_range(&mut t.runs, a, b, &insert), }; + // Ruby over the edited text moves with it (its ranges are plain-text bytes). + t.ruby_after_replace(a, b, inserted.len()); refresh_bounds(t); Ok(caret) })?; @@ -849,6 +851,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { kind, xf: Affine::IDENTITY, runs: vec![TextRun { text, style, inline: None }], + ruby: Vec::new(), para: super::create::new_type_para(), paras: Vec::new(), area: Default::default(), @@ -1070,6 +1073,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { kind: TextKind::Point, xf: Affine::IDENTITY, runs: head.clone(), + // Ruby over the counted part; the layout clamps it to `head`. + ruby: t.ruby.clone(), para: Default::default(), paras: Vec::new(), area: Default::default(), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index ef0799e9b..4c5577741 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject}; use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; @@ -106,6 +106,15 @@ pub fn specs() -> Vec { has_doc, set_style ), + cmd!( + "type.ruby", + "Ruby…", + [], + None, + "{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}", + has_selection, + ruby + ), ] } @@ -169,6 +178,67 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result { Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::>()})) } +/// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one +/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby +/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text). +fn ruby(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.ruby"; + let ids = text_targets(s, p, C)?; + let range = TextRange::parse(p, C)?; + let remove = p.get("remove").and_then(Value::as_bool).unwrap_or(false); + let reading = str_param(p, "text").map(str::trim).filter(|t| !t.is_empty()).map(str::to_string); + if !remove && reading.is_none() { + return Err(bad(C, "give `text` (the reading) or `remove`")); + } + let position = match str_param(p, "position").map(str::to_ascii_lowercase).as_deref() { + None | Some("above") | Some("right") => RubyPosition::Above, + Some("below") | Some("left") => RubyPosition::Below, + Some(_) => return Err(bad(C, "`position` is above or below")), + }; + let align = match str_param(p, "align").map(str::to_ascii_lowercase).as_deref() { + None | Some("center") | Some("centre") => RubyAlign::Center, + Some("start") | Some("left") | Some("top") => RubyAlign::Start, + Some("end") | Some("right") | Some("bottom") => RubyAlign::End, + Some(_) => return Err(bad(C, "`align` is start, center or end")), + }; + let size = p.get("size").and_then(Value::as_f64).unwrap_or(50.0); + if !size.is_finite() || !(1.0..=100.0).contains(&size) { + return Err(bad(C, "`size` is a percentage of the base text's size, 1 to 100")); + } + let overhang = p.get("overhang").and_then(Value::as_bool).unwrap_or(true); + let mut first: Option = None; + s.edit("Ruby", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { + return Err(EngineError::NoNode(*id)); + }; + let (a, b) = match range { + Some(r) => r.bytes(t), + None => (0, vectorcraft_text::edit::runs_len(&t.runs)), + }; + // The ruby over the range goes; a new reading takes its place. + t.ruby.retain(|r| r.end <= a || r.start >= b); + if let Some(text) = &reading + && a < b + { + let mut span = RubySpan::new(a, b, text.clone()); + span.position = position; + span.align = align; + span.size_percent = size; + span.overhang = overhang; + t.ruby.push(span); + } + t.normalize_ruby(); + refresh_bounds(t); + if first.is_none() { + first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null)); + } + } + Ok(()) + })?; + Ok(json!({"ids": ids.iter().map(|id| id.0).collect::>(), "ruby": first.unwrap_or(Value::Null)})) +} + /// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing /// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type /// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo @@ -580,6 +650,8 @@ fn set_text(s: &mut Session, p: &Value) -> Result { // Every paragraph of the new text takes the first paragraph's attributes. t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text); t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style, inline: None }]; + // The text is new: the ruby of the old one is gone. + t.ruby.clear(); refresh_bounds(t); } Ok(()) @@ -1772,3 +1844,116 @@ mod headline_tests { assert!(avail - w < 5.0, "{w} of {avail}"); } } + +#[cfg(test)] +mod ruby_tests { + use super::*; + + fn session_with_text() -> (Session, u64) { + 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": 10, "text": "漢字です", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + (s, id) + } + + fn text_of(s: &Session, id: u64) -> &TextObject { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text object") }; + t + } + + #[test] + fn ruby_command_sets_reports_removes_and_survives_undo() { + let (mut s, id) = session_with_text(); + let r = s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap(); + assert_eq!(r["ruby"][0]["start"], 0); + assert_eq!(r["ruby"][0]["end"], 6); + assert_eq!(r["ruby"][0]["text"], "かんじ"); + let t = text_of(&s, id); + assert_eq!(t.ruby.len(), 1); + assert_eq!(t.ruby[0].position, RubyPosition::Above); + assert_eq!(t.ruby[0].align, RubyAlign::Center); + assert!(t.ruby[0].overhang); + assert!(t.cached_bounds.unwrap().height() > 20.0, "bounds cover the ruby"); + // Options, and a new reading over the range replaces the old one. + s.execute("type.ruby", &json!({"text": "かん", "start": 0, "end": 3, "position": "below", "align": "end", "size": 80, "overhang": false})) + .unwrap(); + let t = text_of(&s, id); + assert_eq!(t.ruby.len(), 1); + assert_eq!(t.ruby[0].position, RubyPosition::Below); + assert_eq!(t.ruby[0].align, RubyAlign::End); + assert!((t.ruby[0].size_percent - 80.0).abs() < 1e-9 && !t.ruby[0].overhang); + // Remove takes the ruby off the range; Undo brings it back. + let r = s.execute("type.ruby", &json!({"remove": true, "start": 0, "end": 6})).unwrap(); + assert_eq!(r["ruby"].as_array().map(Vec::len), Some(0)); + assert!(text_of(&s, id).ruby.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_of(&s, id).ruby.len(), 1); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text_of(&s, id).ruby.is_empty()); + // Bad input changes nothing. + for p in [ + json!({"start": 0, "end": 6}), + json!({"text": "あ", "size": 0}), + json!({"text": "あ", "size": 101}), + json!({"text": "あ", "position": "sideways"}), + json!({"text": "あ", "align": "lean"}), + ] { + assert!(s.execute("type.ruby", &p).is_err(), "{p}"); + } + assert!(text_of(&s, id).ruby.is_empty()); + } + + #[test] + fn typed_text_before_a_ruby_moves_it() { + let (mut s, id) = session_with_text(); + s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap(); + s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 0, "insert": "あ"})).unwrap(); + let t = text_of(&s, id); + assert_eq!((t.ruby[0].start, t.ruby[0].end), (3, 9), "the ruby follows the text"); + assert_eq!(t.plain_text(), "あ漢字です"); + // Setting all the text drops the old ruby (it isn't the same text any more). + s.execute("text.setText", &json!({"id": id, "text": "新しい"})).unwrap(); + assert!(text_of(&s, id).ruby.is_empty()); + } + + /// Ruby is written to SVG and PDF with its text: SVG outlines the type where ruby is present + /// (a reader's `` can't place the reading), PDF differs when the ruby is there. + #[test] + fn ruby_reaches_svg_and_pdf_exports() { + let (mut s, _) = session_with_text(); + let export = |s: &mut Session, format: &str| -> Vec { + let r = s.execute("document.export", &json!({"format": format})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + }; + s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap(); + let svg_ruby = String::from_utf8(export(&mut s, "svg")).unwrap(); + let pdf_ruby = export(&mut s, "pdf"); + assert!(!svg_ruby.contains(" Result { // Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph // spacing, and its tab stops, each within its range (after the depth limit: the walk recurses). doc.keep_type_values_in_range(); + // Untrusted: ruby ranges over the type's text, clamped to it (after the depth limit too). + doc.fix_ruby(); // Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); // Untrusted: the superscript, subscript and small caps proportions of the type, the graph diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a31f8c219..0f8a561a3 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1533,11 +1533,13 @@ impl Exporter<'_> { // pushed for each fill and stroke of the characters, inside its form when the fill or // stroke overprints. let text_space = xf(t.xf); - let plain = (!self.outline_text).then(|| t.plain_text()); + // Ruby can't be real text in one run (it isn't characters of the base text): the type's + // characters go out as outlines where ruby is present, ruby included. + let plain = (!self.outline_text && t.ruby.is_empty()).then(|| t.plain_text()); let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t); for (i, run) in t.runs.iter().enumerate() { let mut bp = BezPath::new(); - for g in layout.glyphs.iter().filter(|g| g.run == i) { + for g in layout.glyphs.iter().filter(|g| g.run == i).chain(layout.ruby.iter().filter(|g| g.run == i)) { bp.extend(g.outline.iter()); } // Underline and strikethrough bars are paths, with real text too (#847). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b9bd3bcc1..f71957999 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2480,6 +2480,10 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell }); } } + // Ruby (furigana) glyphs: painted with their base text's run. + for g in &layout.ruby { + push(&mut all, g.run, &g.outline); + } // Underline and strikethrough bars, painted as their run's type is (#847). for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { push(&mut all, run, &bar); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ebd7f1fb9..f66f7c024 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1787,7 +1787,8 @@ impl Writer<'_> { let resolved = doc.inline_resolved(t); let t = &*resolved; let inline = t.runs.iter().any(|r| r.inline.is_some()); - let outlined = self.opts.outline_text || t.vertical || (inline && matches!(t.kind, TextKind::OnPath { .. })); + // Ruby is written as outlines with its base text: a reader's can't place it. + let outlined = self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })); let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); // Live type with an inside or outside character stroke writes that stroke apart from the @@ -1842,9 +1843,16 @@ impl Writer<'_> { let db = vectorcraft_text::FontDb::global(); let lay = vectorcraft_text::layout(db, t); let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; - // Underline and strikethrough bars join their run's outlines. + // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted + // with the run of the text they annotate. let bars = vectorcraft_text::decorations(&lay, db, t); - for (run, outline) in lay.glyphs.iter().map(|g| (g.run, &g.outline)).chain(bars.iter().map(|(r, b)| (*r, b))) { + for (run, outline) in lay + .glyphs + .iter() + .map(|g| (g.run, &g.outline)) + .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) + .chain(bars.iter().map(|(r, b)| (*r, b))) + { match runs.iter_mut().find(|(r, _)| *r == run) { Some((_, bp)) => bp.extend(outline.iter()), None => runs.push((run, outline.clone())), diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 68ee4bbf5..5bf9f30bf 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -7,7 +7,7 @@ use unicode_bidi::{BidiInfo, Level}; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{ Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, - SingleWordJustify, TextKind, TextObject, + RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, }; use vectorcraft_geom::{ArcPath, PathData}; @@ -421,6 +421,12 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout } TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path), } + // Ruby (furigana): placed over its base text in line space, before vertical type turns the + // lines into columns, so the same pass puts it above (right in vertical type) and widens the + // lines it sits on. Type on a path has no above/below; its ruby is left out for now. + if !is_on_path && !t.ruby.is_empty() { + ruby_layout(&mut cx, t); + } if vertical && !is_on_path { // Glyph origins, outlines and transforms to text space (lines stay in line space). for g in &mut cx.out.glyphs { @@ -429,6 +435,14 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout g.origin = line_xf * g.origin; g.angle += std::f64::consts::FRAC_PI_2; } + // Ruby glyphs are placed in line space too (upright, along the column, in the vertical + // pass); the same turn takes them to text space. + for g in &mut cx.out.ruby { + g.outline.apply_affine(line_xf); + g.xf = line_xf * g.xf; + g.origin = line_xf * g.origin; + g.angle += std::f64::consts::FRAC_PI_2; + } for i in &mut cx.out.inlines { i.xf = line_xf * i.xf; i.bounds = line_xf.transform_rect_bbox(i.bounds); @@ -666,6 +680,12 @@ fn finish_bounds(out: &mut TextLayout) { add(g.outline.bounding_box()); } } + // Ruby: covered by its line's ascent, but its ink can overhang sideways past the line. + for g in &out.ruby { + if !g.outline.elements().is_empty() { + add(g.outline.bounding_box()); + } + } for i in &out.inlines { add(i.bounds); } @@ -2492,3 +2512,290 @@ fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range .collect(); unicode_bidi::BidiInfo::reorder_visual(&cluster_levels).into_iter().filter_map(|i| clusters.get(i)).flat_map(|c| c.clone()).collect() } + +/// Ruby (furigana): lays each reading out over its base text in line space, grows the line's +/// ascent/descent so the ruby fits, and moves the lines after it on in the block direction. +/// +/// Positions are measured from the line's baseline. Vertical type uses the same geometry — ruby +/// "above" the baseline is right of the column once the lines are turned — with each reading +/// character set upright along the column (a quarter turn that the final vertical turn undoes). +/// A reading over a line break is split between the lines, by the base characters' share of it. +/// A reading wider than its base hangs over the neighbours (`overhang`), or is squeezed to the +/// base's width. Type on a path is left out (it has no above/below). +fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { + use std::collections::BTreeMap; + + let db = cx.db; + let text_len = cx.text.len(); + let n_lines = cx.out.lines.len(); + if n_lines == 0 || cx.out.glyphs.is_empty() { + return; + } + let base_baselines: Vec = cx.out.lines.iter().map(|l| l.baseline).collect(); + let old_ascent: Vec = cx.out.lines.iter().map(|l| l.ascent).collect(); + let old_descent: Vec = cx.out.lines.iter().map(|l| l.descent).collect(); + // One piece of base text on one line, with the part of the reading that belongs to it. + struct Piece { + line: usize, + x0: f64, + x1: f64, + run: usize, + byte: usize, + reading: String, + style: CharStyle, + position: RubyPosition, + align: RubyAlign, + base_size: f64, + overhang: bool, + } + let mut pieces: Vec = Vec::new(); + for span in &t.ruby { + let (start, end) = (span.start.min(text_len), span.end.min(text_len)); + if start >= end || span.text.trim().is_empty() { + continue; + } + // The base glyphs of the span, by line; a glyph's cluster is its byte range (`len`, or + // one byte for generated glyphs). + let mut by_line: BTreeMap> = BTreeMap::new(); + for (i, g) in cx.out.glyphs.iter().enumerate() { + let b = g.byte.saturating_add(g.len.max(1)); + if g.byte < end && b > start { + by_line.entry(g.line).or_default().push(i); + } + } + let total: usize = by_line.values().map(Vec::len).sum(); + if total == 0 { + continue; + } + let reading: Vec = span.text.trim().chars().collect(); + let ratio = span.safe_size_percent() / 100.0; + // Split the reading by each line's share of the base glyphs: cumulative fractions keep + // every character once and in order. + let mut taken = 0; + let mut done = 0; + let lines: Vec = by_line.keys().copied().collect(); + for line in &lines { + let count = by_line.get(line).map_or(0, Vec::len); + done += count; + let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; + let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); + taken = upto; + if part.is_empty() { + continue; + } + let glyphs = by_line.get(line).map(Vec::as_slice).unwrap_or_default(); + let mut x0 = f64::INFINITY; + let mut x1 = f64::NEG_INFINITY; + let mut base_size: f64 = 0.0; + let mut run = 0; + let mut byte = start; + for (j, &gi) in glyphs.iter().enumerate() { + let Some(g) = cx.out.glyphs.get(gi) else { continue }; + x0 = x0.min(g.origin.x); + x1 = x1.max(g.origin.x + g.advance); + base_size = base_size.max(cx.style_at(g.byte).size); + if j == 0 { + run = g.run; + byte = g.byte; + } + } + if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { + continue; + } + let mut style = cx.style_at(byte).clone(); + style.size = base_size * ratio; + style.leading = None; + style.baseline_shift = 0.0; + style.tracking = 0.0; + style.kerning = None; + pieces.push(Piece { + line: *line, + x0, + x1, + run, + byte, + reading: part, + style, + position: span.position, + align: span.align, + base_size, + overhang: span.overhang, + }); + } + } + // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how + // far above/below the baseline its ink reaches. + let mut placed: Vec<(usize, Vec, f64, f64)> = Vec::new(); // (line, glyphs, ascent, descent) + for piece in &pieces { + let Some(baseline) = base_baselines.get(piece.line).copied() else { continue }; + let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); + let lay = layout(db, &mini); + let Some(first_line) = lay.lines.first() else { continue }; + let mini_base = first_line.baseline; + let mini_asc = first_line.ascent; + let mini_desc = first_line.descent; + let base_w = (piece.x1 - piece.x0).max(0.0); + // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the + // base's em box bottom (the line's descent below the baseline). + let ruby_y = if piece.position == RubyPosition::Above { + baseline - piece.base_size - mini_desc + } else { + baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + mini_asc + }; + let dest = |width: f64| match piece.align { + RubyAlign::Center => piece.x0 + (base_w - width) * 0.5, + RubyAlign::Start => piece.x0, + RubyAlign::End => piece.x1 - width, + }; + let mut glyphs: Vec = Vec::new(); + if cx.vertical { + // Each reading character in its own cell along the column, upright once the lines turn. + let cell = (mini_asc + mini_desc).max(piece.style.size * 0.5); + let mut clusters: Vec> = Vec::new(); + for g in &lay.glyphs { + match clusters.last_mut() { + Some(c) if c.last().is_some_and(|p| p.byte == g.byte && p.len == g.len) => c.push(g), + _ => clusters.push(vec![g]), + } + } + let mut ruby_w = clusters.len() as f64 * cell; + let mut squeeze = 1.0; + if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { + squeeze = base_w / ruby_w; + ruby_w = base_w; + } + let x = dest(ruby_w); + for (ci, cluster) in clusters.iter().enumerate() { + let anchor = Point::new(x + (ci as f64 + 0.5) * cell, ruby_y); + let mut box_: Option = None; + for g in cluster { + if g.outline.elements().is_empty() { + continue; + } + let b = g.outline.bounding_box(); + box_ = Some(box_.map_or(b, |a| a.union(b))); + } + let centre = box_.map_or(anchor, |b| Point::new((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5)); + let mut m = + Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)); + if squeeze != 1.0 { + m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; + } + for g in cluster { + glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); + } + } + } else { + // The reading set along the line: the line's extent, centred or aligned to the base. + let mut ruby_w = (first_line.x1 - first_line.x0).max(0.0); + let mut squeeze = 1.0; + if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { + squeeze = base_w / ruby_w; + ruby_w = base_w; + } + let x = dest(ruby_w); + let mut m = Affine::translate((x - first_line.x0, ruby_y - mini_base)); + if squeeze != 1.0 { + m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; + } + for g in &lay.glyphs { + glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); + } + } + // How far the reading reaches above and below the base line's baseline (its ink). + let mut ascent: f64 = 0.0; + let mut descent: f64 = 0.0; + for g in &glyphs { + if g.outline.elements().is_empty() { + continue; + } + let b = g.outline.bounding_box(); + ascent = ascent.max(baseline - b.y0); + descent = descent.max(b.y1 - baseline); + } + if !glyphs.is_empty() { + placed.push((piece.line, glyphs, ascent, descent)); + } + } + if placed.is_empty() { + return; + } + // Phase B: grow each line's box to fit its ruby, then move the lines on after it by what they + // need in the block direction (the first line too for area type, so the ruby stays inside the + // frame; point type's first baseline is its anchor and stays). + let mut growth = vec![0.0_f64; n_lines]; + for (line, _, ascent, descent) in &placed { + if let (Some(a), Some(d)) = (old_ascent.get(*line), old_descent.get(*line)) { + let grown = (a.max(*ascent) + d.max(*descent)) - (a + d); + if grown > 0.0 { + let g = growth.get_mut(*line); + if let Some(slot) = g { + *slot = slot.max(grown); + } + } + } + } + // A line's ruby grows above it: the line moves on in the block direction by its own growth, + // so the line before it keeps its room (for area type the first line moves too, keeping the + // ruby inside the frame; point type's first baseline is its anchor and stays). + let area = matches!(t.kind, TextKind::Area { .. }); + let mut shift = vec![0.0_f64; n_lines]; + let mut cum = 0.0; + for i in 0..n_lines { + if area || i > 0 { + cum += growth.get(i).copied().unwrap_or(0.0); + if let Some(s) = shift.get_mut(i) { + *s = cum; + } + } + } + // Grow the lines' boxes to fit their ruby, then move them on in the block direction. + for (line, _, ascent, descent) in &placed { + if let Some(l) = cx.out.lines.get_mut(*line) { + l.ascent = l.ascent.max(*ascent); + l.descent = l.descent.max(*descent); + } + } + for (i, line) in cx.out.lines.iter_mut().enumerate() { + line.baseline += shift.get(i).copied().unwrap_or(0.0); + } + for g in &mut cx.out.glyphs { + let s = shift.get(g.line).copied().unwrap_or(0.0); + if s != 0.0 { + g.origin.y += s; + g.outline.apply_affine(Affine::translate((0.0, s))); + g.xf = Affine::translate((0.0, s)) * g.xf; + } + } + for (line, glyphs, _, _) in placed { + let s = shift.get(line).copied().unwrap_or(0.0); + for mut g in glyphs { + if s != 0.0 { + g.origin.y += s; + g.outline.apply_affine(Affine::translate((0.0, s))); + } + g.line = line; + cx.out.ruby.push(g); + } + } +} + +/// A ruby glyph from one of a reading's laid-out glyphs, moved by `m` into the base text's line +/// space. It is not a character of the text: `len` 0, the base text's `run` for fill and stroke. +fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usize) -> PositionedGlyph { + PositionedGlyph { + outline: m * g.outline.clone(), + run, + byte, + origin: m * g.origin, + advance: 0.0, + len: 0, + angle: 0.0, + line, + font_id: g.font_id, + gid: g.gid, + xf: Affine::IDENTITY, + rtl: false, + scale: 1.0, + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 85c6eaff8..12ae8a57c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -180,6 +180,10 @@ pub struct TextLayout { /// turn clockwise for vertical type (point type also centres its first column on the anchor). pub line_xf: Affine, pub glyphs: Vec, + /// Ruby (furigana) glyphs, laid out over their base text: geometry like [`Self::glyphs`], but + /// they are not characters of the text (their `len` is 0 and their `run` is the base text's + /// run, for the fill and stroke). Empty when the type has no ruby. + pub ruby: Vec, pub lines: Vec, /// Ink/advance bounds in text space. pub bounds: Rect, @@ -203,6 +207,7 @@ impl Default for TextLayout { vertical: false, line_xf: Affine::IDENTITY, glyphs: Vec::new(), + ruby: Vec::new(), lines: Vec::new(), bounds: Rect::ZERO, overflow: false, @@ -237,7 +242,7 @@ impl TextLayout { /// All glyph outlines combined (e.g. for Create Outlines). pub fn to_bezpath(&self) -> BezPath { let mut p = BezPath::new(); - for g in &self.glyphs { + for g in self.glyphs.iter().chain(&self.ruby) { p.extend(g.outline.iter()); } p @@ -605,6 +610,8 @@ mod tests_joiners; #[cfg(test)] mod tests_justify; #[cfg(test)] +mod tests_ruby; +#[cfg(test)] mod tests_scripts; #[cfg(test)] mod tests_snap; diff --git a/crates/text/src/tests_ruby.rs b/crates/text/src/tests_ruby.rs new file mode 100644 index 000000000..630ea9cfe --- /dev/null +++ b/crates/text/src/tests_ruby.rs @@ -0,0 +1,219 @@ +//! Ruby (furigana) tests: geometry over the base text in horizontal and vertical type, line +//! growth, overhang/squeeze, splitting over a line break, and the byte ranges following edits. + +use super::*; +use kurbo::{Affine, Point, Rect, Shape}; +use vectorcraft_doc::{CharStyle, RubyAlign, RubyPosition, RubySpan, TextKind}; +use vectorcraft_geom::PathData; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +/// An outline's bounds, or `None` for a glyph with no ink (a space). +fn ink(g: &PositionedGlyph) -> Option { + (!g.outline.elements().is_empty()).then(|| g.outline.bounding_box()) +} + +fn ruby_ink(lay: &TextLayout) -> Rect { + union_rect(lay.ruby.iter()).expect("ruby glyphs with ink") +} + +fn union_rect<'a>(glyphs: impl Iterator) -> Option { + let mut b: Option = None; + for g in glyphs { + if let Some(r) = ink(g) { + b = Some(b.map_or(r, |a| a.union(r))); + } + } + b +} + +/// A point text object with `text`, and ruby `reading` over its first `base` bytes. +fn ruby_text(text: &str, base: usize, reading: &str, size: f64) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, style(size)); + t.ruby = vec![RubySpan::new(0, base, reading)]; + t +} + +#[test] +fn horizontal_ruby_sits_above_the_base_and_grows_the_line() { + let base = TextObject::point(Point::ZERO, "漢字", style(20.0)); + let plain = layout(db(), &base); + assert!(plain.ruby.is_empty()); + let base_glyphs: Vec<_> = plain.glyphs.iter().map(|g| (g.font_id, g.gid, g.origin, g.advance)).collect(); + + let t = ruby_text("漢字", 6, "かんじ", 20.0); + let lay = layout(db(), &t); + assert_eq!(lay.ruby.len(), 3, "one glyph per kana"); + assert!(lay.ruby.iter().all(|g| g.run == 0 && g.line == 0 && g.len == 0)); + // The base text itself is unchanged: same glyphs, same places. + let now: Vec<_> = lay.glyphs.iter().map(|g| (g.font_id, g.gid, g.origin, g.advance)).collect(); + assert_eq!(now, base_glyphs); + // The reading is above the base's ink and inside the line's ascent. + let above = ruby_ink(&lay); + let below = union_rect(lay.glyphs.iter()).expect("base ink"); + assert!(above.y1 <= below.y0 + 0.5, "ruby bottom {} above base top {}", above.y1, below.y0); + assert!(above.y0 < below.y0, "ruby rises above the base"); + assert!(lay.lines[0].ascent > plain.lines[0].ascent + 0.2 * 20.0, "the line grew: {} vs {}", lay.lines[0].ascent, plain.lines[0].ascent); + // Centred over the base text by default. + let centre = (above.x0 + above.x1) * 0.5; + let base_centre = (below.x0 + below.x1) * 0.5; + assert!((centre - base_centre).abs() < 1.0, "{centre} vs {base_centre}"); + // Above the base's em box, not touching its ink: a visible gap of a few points. + assert!(above.y1 <= below.y0 - 1.0, "a gap between ruby and base ink: {} vs {}", above.y1, below.y0); +} + +#[test] +fn ruby_below_grows_the_descent() { + let mut t = ruby_text("漢字", 6, "かんじ", 20.0); + t.ruby[0].position = RubyPosition::Below; + let lay = layout(db(), &t); + let below = union_rect(lay.glyphs.iter()).expect("base ink"); + let ruby = ruby_ink(&lay); + assert!(ruby.y0 >= below.y1 - 0.5, "ruby top {} below base bottom {}", ruby.y0, below.y1); + let plain = layout(db(), &TextObject::point(Point::ZERO, "漢字", style(20.0))); + assert!(lay.lines[0].descent > plain.lines[0].descent + 0.2 * 20.0); +} + +#[test] +fn ruby_is_half_the_base_size_by_default_and_size_follows_the_span() { + let half = ruby_text("漢", 3, "かん", 20.0); + let mut whole = half.clone(); + whole.ruby[0].size_percent = 100.0; + let half = ruby_ink(&layout(db(), &half)); + let whole = ruby_ink(&layout(db(), &whole)); + assert!(half.height() < whole.height() * 0.75, "{} vs {}", half.height(), whole.height()); +} + +#[test] +fn a_reading_wider_than_its_base_overhangs_or_is_squeezed() { + // One base character, a three-kana reading: wider than the base at the default half size. + let mut t = ruby_text("漢", 3, "かんじ", 20.0); + let wide = ruby_ink(&layout(db(), &t)); + // The measure is the base character's advance (its em), not its ink. + let base_lay = layout(db(), &TextObject::point(Point::ZERO, "漢", style(20.0))); + let base = (base_lay.lines[0].x1 - base_lay.lines[0].x0).max(0.0); + assert!(wide.width() > base, "overhanging ruby {} vs base {}", wide.width(), base); + // Without overhang the reading is squeezed to the base's width. + t.ruby[0].overhang = false; + let narrow = ruby_ink(&layout(db(), &t)); + assert!(narrow.width() <= base + 0.5, "squeezed ruby {} vs base {}", narrow.width(), base); +} + +#[test] +fn ruby_aligns_start_centre_and_end_over_its_base() { + let mut t = ruby_text("漢字", 6, "かん", 20.0); + let base = union_rect(layout(db(), &TextObject::point(Point::ZERO, "漢字", style(20.0))).glyphs.iter()).expect("base ink"); + for (align, check) in [ + (RubyAlign::Start, (|r: Rect, b: Rect| (r.x0 - b.x0).abs() < 0.5) as fn(Rect, Rect) -> bool), + (RubyAlign::Center, |r: Rect, b: Rect| (((r.x0 + r.x1) - (b.x0 + b.x1)) * 0.5).abs() < 0.5), + (RubyAlign::End, |r: Rect, b: Rect| (r.x1 - b.x1).abs() < 0.5), + ] { + t.ruby[0].align = align; + let ruby = ruby_ink(&layout(db(), &t)); + assert!(check(ruby, base), "{align:?}: {ruby:?} over {base:?}"); + } +} + +#[test] +fn vertical_ruby_is_upright_to_the_right_of_the_column() { + let mut t = ruby_text("漢字", 6, "かんじ", 20.0); + t.vertical = true; + let lay = layout(db(), &t); + assert!(lay.vertical); + assert!(!lay.ruby.is_empty()); + // The base columns sit around the origin; the reading is to their right (larger x). + let base = union_rect(lay.glyphs.iter()).expect("base ink"); + let ruby = ruby_ink(&lay); + assert!(ruby.x0 > base.x0, "ruby right of the column: {ruby:?} vs {base:?}"); + assert!(ruby.x0 >= base.x1 - 1.0, "ruby outside the column: {ruby:?} vs {base:?}"); + // Upright kana are taller than wide (they would be lying on their side if not). + for g in &lay.ruby { + if let Some(b) = ink(g) { + assert!(b.width() <= b.height() + 1.0, "upright ruby glyph {b:?}"); + } + } + // Down the column: the reading's glyphs have distinct y positions. + let ys: Vec = lay.ruby.iter().filter_map(|g| ink(g).map(|b| b.center().y)).collect(); + assert!(ys.windows(2).any(|w| w[0] != w[1]), "the reading runs down the column: {ys:?}"); +} + +#[test] +fn ruby_moves_the_lines_after_it_down() { + // A narrow area type: "漢字" wraps to two lines; ruby on either line needs room. + let frame = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 24.0, 200.0).to_path(0.1)) }; + let mut plain = TextObject::point(Point::ZERO, "漢字", style(20.0)); + plain.kind = frame.clone(); + plain.xf = Affine::IDENTITY; + let plain = layout(db(), &plain); + assert_eq!(plain.lines.len(), 2, "two lines to compare"); + // Ruby over the second line needs room above it between the two lines: the line moves down. + let mut t = plain_text_with(frame.clone()); + t.ruby = vec![RubySpan::new(3, 6, "じ")]; + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2); + let gap_plain = plain.lines[1].baseline - plain.lines[0].baseline; + let gap_ruby = lay.lines[1].baseline - lay.lines[0].baseline; + assert!(gap_ruby > gap_plain + 2.0, "the ruby pushes the line on: {gap_ruby} vs {gap_plain}"); + // Ruby over the first line stays inside the frame: area type moves the first line down too. + let mut first = plain_text_with(frame.clone()); + first.ruby = vec![RubySpan::new(0, 3, "かん")]; + let lay = layout(db(), &first); + assert!(lay.lines[0].baseline > plain.lines[0].baseline + 2.0, "{} vs {}", lay.lines[0].baseline, plain.lines[0].baseline); +} + +fn plain_text_with(frame: TextKind) -> TextObject { + let mut t = TextObject::point(Point::ZERO, "漢字", style(20.0)); + t.kind = frame; + t.xf = Affine::IDENTITY; + t +} + +#[test] +fn a_reading_over_a_line_break_is_split_between_the_lines() { + let frame = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 24.0, 200.0).to_path(0.1)) }; + let mut t = plain_text_with(frame); + t.ruby = vec![RubySpan::new(0, 6, "かんじ")]; + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2); + assert!(!lay.ruby.is_empty()); + let mut lines: Vec = lay.ruby.iter().map(|g| g.line).collect(); + lines.sort_unstable(); + lines.dedup(); + assert_eq!(lines, vec![0, 1], "part of the reading on each line: {lines:?}"); +} + +#[test] +fn ruby_is_not_laid_out_for_type_on_a_path() { + let mut t = TextObject::point(Point::ZERO, "漢字", style(20.0)); + t.kind = + TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new(Point::ZERO, Point::new(200.0, 0.0)).to_path(0.1)), start: 0.0, end: None }; + t.ruby = vec![RubySpan::new(0, 6, "かんじ")]; + let lay = layout(db(), &t); + assert!(lay.ruby.is_empty(), "no above/below on a path yet"); + assert_eq!(lay.glyphs.len(), 2, "the base text is laid out as before"); +} + +#[test] +fn ruby_ranges_follow_edits_of_the_text() { + let mut t = TextObject::point(Point::ZERO, "漢字です", style(20.0)); + t.ruby = vec![RubySpan::new(0, 6, "かんじ")]; + // Typing two bytes before the base moves the ruby. + t.ruby_after_replace(0, 0, 2); + assert_eq!((t.ruby[0].start, t.ruby[0].end), (2, 8)); + // Deleting the first base character shrinks it. + t.ruby_after_replace(2, 5, 0); + assert_eq!((t.ruby[0].start, t.ruby[0].end), (2, 5)); + // Editing outside leaves it alone. + t.ruby_after_replace(8, 9, 1); + assert_eq!((t.ruby[0].start, t.ruby[0].end), (2, 5)); + // An emptied reading no longer lays anything out. + t.ruby[0].text.clear(); + t.normalize_ruby(); + assert!(t.ruby.is_empty()); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d6298b59d..45f0e5e34 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5301,3 +5301,5 @@ theme Appearance Външен вид Note Paper Хартия за писма Water Paper… Мокра хартия… Water Paper Мокра хартия + Ruby… Руби… + Ruby Руби diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 582baefa5..4f77cafbc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5344,3 +5344,5 @@ mark-style Japanese Japonské Note Paper Dopisní papír Water Paper… Mokrý papír… Water Paper Mokrý papír + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6d2a0cdf1..a573310ff 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5317,3 +5317,5 @@ swatch Bright Violet Kraftig violet Note Paper Notatpapir Water Paper… Vådt papir… Water Paper Vådt papir + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b4da4d05a..7a7daf42e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4926,3 +4926,5 @@ effect Soft Weich Note Paper Prägepapier Water Paper… Feuchtes Papier… Water Paper Feuchtes Papier + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c870e76fe..2b9d09b38 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5296,3 +5296,5 @@ effect Points Σημεία Note Paper Χαρτί σημειώσεων Water Paper… Βρεγμένο χαρτί… Water Paper Βρεγμένο χαρτί + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4279bc943..a36fe7ce9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5286,3 +5286,5 @@ mark-style Japanese Japonesas Note Paper Papel con relieve Water Paper… Papel mojado… Water Paper Papel mojado + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 227a8a734..780f89ac2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5375,3 +5375,5 @@ swatch Bright Violet Kirkas violetti Note Paper Kirjepaperi Water Paper… Märkä paperi… Water Paper Märkä paperi + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index eee7edb57..39741ab78 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5343,3 +5343,5 @@ theme Appearance Apparence Note Paper Papier gaufré Water Paper… Papier humide… Water Paper Papier humide + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 599308f19..e4809a47d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5330,3 +5330,5 @@ imageTrace Outline Körvonal Note Paper Jegyzetpapír Water Paper… Nedves papír… Water Paper Nedves papír + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3bcf968d5..70616f7b8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5317,3 +5317,5 @@ swatch Bright Violet Violet cerah Note Paper Kertas Catatan Water Paper… Kertas Basah… Water Paper Kertas Basah + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6684eb727..dcf9a8b00 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4959,3 +4959,5 @@ lighting Light Luce Note Paper Carta da lettere Water Paper… Carta bagnata… Water Paper Carta bagnata + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6649f3d2f..96071c6e0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5006,3 +5006,5 @@ lighting Light ライト Kaku #2 角形2号 Kaku #3 角形3号 Yonroku-ban 四六判 + Ruby… ルビ… + Ruby ルビ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index fd1c2d020..e3a7d9656 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5254,3 +5254,5 @@ imageTrace Outline კონტური # Artboard tool hints (#2497) on the canvas to create one ( ტილოზე — ახლის შესაქმნელად ( selects several, or draws one inside an artboard) რამდენიმეს მონიშნავს ან ახალს სამუშაო დაფის შიგნით ხატავს) + Ruby… რუბი… + Ruby რუბი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 506b0a43d..d00dbdec1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4922,3 +4922,5 @@ lighting Light 광원 Note Paper 메모지 Water Paper… 물 종이… Water Paper 물 종이 + Ruby… 루비… + Ruby 루비 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0576068a9..d595a0dfa 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5359,3 +5359,5 @@ swatch Bright Violet Felviolet Note Paper Notitiepapier Water Paper… Nat papier… Water Paper Nat papier + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8233439c3..d1baf5469 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5196,3 +5196,5 @@ mark-style Japanese Japońskie Note Paper Papier listowy Water Paper… Mokry papier… Water Paper Mokry papier + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 71009d6c7..f4ed36f83 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5261,3 +5261,5 @@ mark-style Japanese Japonesas Note Paper Papel de carta Water Paper… Papel molhado… Water Paper Papel molhado + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4d10209a6..562872175 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4955,3 +4955,5 @@ lighting Light Свет Note Paper Почтовая бумага Water Paper… Мокрая бумага… Water Paper Мокрая бумага + Ruby… Руби… + Ruby Руби diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index eb2bb95e7..14136b0aa 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5300,3 +5300,5 @@ theme Appearance Utseende Note Paper Anteckningspapper Water Paper… Vått papper… Water Paper Vått papper + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2793f3e76..6e1e0430c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4939,3 +4939,5 @@ lighting Light Світло Note Paper Поштовий папір Water Paper… Мокрий папір… Water Paper Мокрий папір + Ruby… Рубі… + Ruby Рубі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e31082a56..3c227bd8d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4939,3 +4939,5 @@ theme Appearance Giao diện Note Paper Giấy ghi chú Water Paper… Giấy ướt… Water Paper Giấy ướt + Ruby… Ruby… + Ruby Ruby diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0b3ce01a7..f143747cf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5183,3 +5183,5 @@ lighting Light 光源 Note Paper 便条纸 Water Paper… 水彩画纸… Water Paper 水彩画纸 + Ruby… 注音… + Ruby 注音 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 39d0971af..b44c2548b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4978,3 +4978,5 @@ lighting Light 光源 Note Paper 便條紙 Water Paper… 水彩畫紙… Water Paper 水彩畫紙 + Ruby… 注音… + Ruby 注音 diff --git a/docs/gaps.md b/docs/gaps.md index 922e91736..8dda09bbe 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -207,8 +207,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** Paragraph panel menu › Roman Hanging Punctuation (#2756), per paragraph; estimate unchanged. - **Done:** Paragraph panel menu › Justification… (#2755): word and letter spacing, glyph scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification per paragraph, used by both composers; estimate unchanged. +- **Done:** Ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above or below (right of the + column in vertical type), aligned, sized, overhanging or squeezed; saved natively, laid out with line growth, drawn + on the canvas and in SVG/PDF (outlines where ruby is present); estimate unchanged. - **Missing:** CJK vertical - composition (ruby, mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern + composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern features beyond bidi and digit types (Character Direction, kashidas, composers), variable font axes, spell check with an open dictionary, Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2fe9ec4e..d2cf1ffab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth and drawn, with SVG/PDF outlines where ruby is present; scores and hours unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -82,6 +82,8 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 49% | 10 Photoshop-style filters (7 Artistic, 2 Sketch, 1 more), the Effect Gallery dialog, Load Texture | 8–14 | measured count 47/57; texture redraw distances optimized (#1257), pixels verified; depth estimated | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208) | +| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated | @@ -345,6 +347,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko; No Break is done), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Fractional Widths is done; System Layout remains a panel stub | 14–19 | +| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type done (9-slice guides remain) | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | @@ -384,7 +387,7 @@ since 2026-10-01. |---|---|---| | **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | | **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) | -| **Large missing subsystems** | 0–20% | 3D & Materials (~25%, initial 3D types without materials), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | +| **Large missing subsystems** | 0–20% | 3D & Materials (~25%, initial 3D types without materials), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; mojikumi open), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | @@ -916,6 +919,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | Properties: one selected clip group has Edit Clipping Path and Edit Contents at the top of the panel (public doc cited) (G1). Scores and hours unchanged. | | 2026-10-21 | minor | Print: the Print dialog's Media list carries the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5, which keep their ISO 216 sizes, plus はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; the JSON id or the dialog label names a paper, any case and spacing. Print score and hours unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 1fb1e3da4..c068d24b3 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Fractional Widths: Character panel menu turns whole-point advances on or off per type object, on by default; scores unchanged; type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), start/centre/end aligned, sized and overhanging or squeezed; saved natively, laid out with its line growing, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged; previously:; Fractional Widths: Character panel menu turns whole-point advances on or off per type object, on by default; scores unchanged; type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -54,6 +54,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: input methods (marked text, candidate window) | done | | | CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing | | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 | +| CJK: ruby, manual tate-chu-yoko | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present); manual tate-chu-yoko missing | #633 | | No Break | done | Character panel menu (`text.setFormat` / `text.setRangeStyle {noBreak}`): the marked characters stay on one line, including across a space; a run that does not fit after earlier text moves together, and a run longer than the line stays together. Evidence: `layout::wrapping_tests::no_break_keeps_marked_characters_on_one_line`, `tests::no_break_keeps_marked_words_on_one_line`, `typecmd::no_break_is_a_character_attribute_saved_only_when_on` | | CJK: ruby, manual tate-chu-yoko | missing | #633 | | Inline symbols in text | beyond Illustrator | not scored | @@ -65,6 +66,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right/left of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores unchanged | | 2026-10-21 | minor | Fractional Widths: Character panel menu turns whole-point advances on or off per type object (on by default); System Layout remains a stub; scores and hours unchanged | | 2026-10-21 | minor | Type on a path threads, mixed with area type in one thread: its text that doesn't fit flows on into the next frame, and its red out port, just past its end bracket, loads the Selection tool with that text as area type's does; a new frame threaded from it at a point is type on a path of its shape; scores unchanged | | 2026-10-21 | minor | No Break: marked characters stay on one line; scores unchanged |