Type: ruby (ふりがな) for live text

Squashed from #3448 onto main.
This commit is contained in:
bflatastic
2026-10-22 00:10:56 +07:00
parent 2cd7f156fb
commit 14f804cdee
39 changed files with 977 additions and 15 deletions
+3 -1
View File
@@ -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).
+2 -2
View File
@@ -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};
+164
View File
@@ -1235,6 +1235,10 @@ fn one() -> f64 {
1.0
}
fn fifty() -> f64 {
50.0
}
impl InlineArt {
pub fn new(symbol: impl Into<String>) -> 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<String>) -> 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<TextRun>,
/// 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<RubySpan>,
#[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<Item = &RubySpan> {
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::<usize>()
@@ -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::<TextObject>(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]
+2
View File
@@ -89,6 +89,8 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
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)
})?;
+5
View File
@@ -209,6 +209,8 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<f64> {
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(),
+186 -1
View File
@@ -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<CommandSpec> {
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<Value> {
Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
}
/// 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<Value> {
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<Value> = 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::<Vec<_>>(), "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<Value> {
// 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 `<text>` 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<u8> {
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("<text"), "ruby type is outlined: {svg_ruby}");
assert!(!pdf_ruby.is_empty());
s.execute("type.ruby", &json!({"remove": true})).unwrap();
let svg_plain = String::from_utf8(export(&mut s, "svg")).unwrap();
let pdf_plain = export(&mut s, "pdf");
assert!(svg_plain.contains("<text"), "without ruby the type stays live text: {svg_plain}");
assert_ne!(svg_ruby, svg_plain, "the SVG carries the ruby");
assert_ne!(pdf_ruby, pdf_plain, "the PDF carries the ruby");
}
#[test]
fn ruby_survives_a_native_round_trip() {
let (mut s, id) = session_with_text();
s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6, "position": "below", "size": 40})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let bytes = vectorcraft_format::save(&doc, false);
let back = vectorcraft_format::load(&bytes).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(t.ruby.len(), 1);
assert_eq!((t.ruby[0].start, t.ruby[0].end), (0, 6));
assert_eq!(t.ruby[0].text, "かんじ");
assert_eq!(t.ruby[0].position, RubyPosition::Below);
assert!((t.ruby[0].size_percent - 40.0).abs() < 1e-9);
// The loaded type lays the reading out too.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
assert!(!lay.ruby.is_empty());
}
}
+2
View File
@@ -567,6 +567,8 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
// 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
+4 -2
View File
@@ -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).
+4
View File
@@ -2480,6 +2480,10 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> 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);
+11 -3
View File
@@ -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 <text> 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())),
+308 -1
View File
@@ -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, &paras, 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<f64> = cx.out.lines.iter().map(|l| l.baseline).collect();
let old_ascent: Vec<f64> = cx.out.lines.iter().map(|l| l.ascent).collect();
let old_descent: Vec<f64> = 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<Piece> = 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<usize, Vec<usize>> = 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<char> = 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<usize> = 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<PositionedGlyph>, 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<PositionedGlyph> = 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<&PositionedGlyph>> = 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<Rect> = 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,
}
}
+8 -1
View File
@@ -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<PositionedGlyph>,
/// 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<PositionedGlyph>,
pub lines: Vec<LineInfo>,
/// 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;
+219
View File
@@ -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<Rect> {
(!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<Item = &'a PositionedGlyph>) -> Option<Rect> {
let mut b: Option<Rect> = 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<f64> = 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<usize> = 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());
}
+2
View File
@@ -5301,3 +5301,5 @@ theme Appearance Външен вид
Note Paper Хартия за писма
Water Paper… Мокра хартия…
Water Paper Мокра хартия
Ruby… Руби…
Ruby Руби
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4926,3 +4926,5 @@ effect Soft Weich
Note Paper Prägepapier
Water Paper… Feuchtes Papier…
Water Paper Feuchtes Papier
Ruby… Ruby…
Ruby Ruby
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5296,3 +5296,5 @@ effect Points Σημεία
Note Paper Χαρτί σημειώσεων
Water Paper… Βρεγμένο χαρτί…
Water Paper Βρεγμένο χαρτί
Ruby… Ruby…
Ruby Ruby
Can't render this file because it contains an unexpected character in line 2031 and column 19.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5343,3 +5343,5 @@ theme Appearance Apparence
Note Paper Papier gaufré
Water Paper… Papier humide…
Water Paper Papier humide
Ruby… Ruby…
Ruby Ruby
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 3801 and column 19.
+2
View File
@@ -4959,3 +4959,5 @@ lighting Light Luce
Note Paper Carta da lettere
Water Paper… Carta bagnata…
Water Paper Carta bagnata
Ruby… Ruby…
Ruby Ruby
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5006,3 +5006,5 @@ lighting Light ライト
Kaku #2 角形2号
Kaku #3 角形3号
Yonroku-ban 四六判
Ruby… ルビ…
Ruby ルビ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 რუბი
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4922,3 +4922,5 @@ lighting Light 광원
Note Paper 메모지
Water Paper… 물 종이…
Water Paper 물 종이
Ruby… 루비…
Ruby 루비
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5359,3 +5359,5 @@ swatch Bright Violet Felviolet
Note Paper Notitiepapier
Water Paper… Nat papier…
Water Paper Nat papier
Ruby… Ruby…
Ruby Ruby
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4955,3 +4955,5 @@ lighting Light Свет
Note Paper Почтовая бумага
Water Paper… Мокрая бумага…
Water Paper Мокрая бумага
Ruby… Руби…
Ruby Руби
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5300,3 +5300,5 @@ theme Appearance Utseende
Note Paper Anteckningspapper
Water Paper… Vått papper…
Water Paper Vått papper
Ruby… Ruby…
Ruby Ruby
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -4939,3 +4939,5 @@ lighting Light Світло
Note Paper Поштовий папір
Water Paper… Мокрий папір…
Water Paper Мокрий папір
Ruby… Рубі…
Ruby Рубі
Can't render this file because it contains an unexpected character in line 726 and column 40.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4003 and column 10.
+2
View File
@@ -5183,3 +5183,5 @@ lighting Light 光源
Note Paper 便条纸
Water Paper… 水彩画纸…
Water Paper 水彩画纸
Ruby… 注音…
Ruby 注音
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4978,3 +4978,5 @@ lighting Light 光源
Note Paper 便條紙
Water Paper… 水彩畫紙…
Water Paper 水彩畫紙
Ruby… 注音…
Ruby 注音
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4 -1
View File
@@ -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).
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -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 |