Type: Digits — Default, Arabic, Hindi and Farsi numeral systems

Adds the Character panel menu's Digits option (with the Middle Eastern /
Indic options, beside Paragraph Direction in the Paragraph panel) as a
character attribute: CharStyle::digits, saved only when set, with
text.setFormat {digits} and text.setRangeStyle {id, start, end, digits}
("default" | "arabic" | "hindi" | "farsi"; a bad value is an error).

Shaping draws every digit of the Western, Arabic-Indic (U+0660–U+0669)
and Eastern Arabic-Indic (U+06F0–U+06F9) sets in the chosen system,
whatever was typed, before font fallback, so a font without the
Arabic-Indic digits gets a fallback for them. The text, its clusters,
the caret, selection and search stay as typed, as with All Caps; SVG
export writes the digits as drawn.

Catalog rows for Digits, Arabic, Hindi and Farsi in the ten translated
languages. Docs: docs/mcp.md (Digits), type-parity.md (digit types
done; Character Direction, kashidas, composers and the split caret
still missing), target-app-parity.md and gaps.md.

Behaviour from the public documentation (a character-level option with
the values Arabic, Hindi and Farsi in the Character panel menu):
https://helpx.adobe.com/indesign/desktop/language-and-proofing/arabic-and-hebrew/choose-digit-types.html

Tests: doc (the mapping and serialization), text (glyphs of "page 3" in
Hindi/Farsi equal the typed Arabic-Indic digits' on the typed clusters;
skips without a font covering the three sets), engine (setFormat,
setRangeStyle, undo, error), svg (digits written as drawn), ui-egui
(the menu shows with the Indic options).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zakariayacine 2026-10-11 13:48:47 +01:00 committed by bflatastic
parent d19fd80390
commit 16af3cc3fa
24 changed files with 283 additions and 11 deletions

View File

@ -87,7 +87,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
TextWrap, VerticalAlign, WrapShape,
};

View File

@ -100,6 +100,47 @@ pub struct CharStyle {
/// its proportional heights, `vpal`.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub digits: Digits,
}
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps
/// the digits that were typed; only the glyphs change (like All Caps).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Digits {
/// Each digit as typed.
#[default]
Default,
/// Western digits 0–9 (the digits Arabic typography calls Arabic).
Arabic,
/// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic.
Hindi,
/// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu.
Farsi,
}
impl Digits {
/// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c`
/// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set.
pub fn map(self, c: char) -> char {
let value = match c {
'0'..='9' => c as u32 - '0' as u32,
'\u{660}'..='\u{669}' => c as u32 - 0x660,
'\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0,
_ => return c,
};
let base = match self {
Digits::Default => return c,
Digits::Arabic => '0' as u32,
Digits::Hindi => 0x660,
Digits::Farsi => 0x6F0,
};
char::from_u32(base + value).unwrap_or(c)
}
}
/// Where a character smaller than the largest on its line lines up with it: on the Roman
@ -212,6 +253,7 @@ impl Default for CharStyle {
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
small_caps: None,
digits: Digits::Default,
}
}
}
@ -1238,6 +1280,29 @@ impl TextObject {
mod tests {
use super::*;
/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and
/// are saved only when set.
#[test]
fn digits_map_between_the_numeral_systems() {
let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() };
for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] {
assert_eq!(map(Digits::Default, s), s);
assert_eq!(map(Digits::Arabic, s), "0123456789");
assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩");
assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹");
}
// Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets.
assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔");
let st = CharStyle::default();
assert!(serde_json::to_value(&st).unwrap().get("digits").is_none());
let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() };
assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi");
// Styles written before the attribute existed (no `digits` key) read as Default.
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().digits, Digits::Default);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());

View File

@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@ -231,6 +231,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"leadingModel",
"charAlign",
"proportionalMetrics",
"digits",
"burasagari",
"composer",
];
@ -247,6 +248,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
};
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
let char_align = super::textedit::char_align_param(p, C)?;
let digits = super::textedit::digits_param(p, C)?;
let mojikumi = match p.get("mojikumi") {
None => None,
Some(v) => Some(match v.as_str() {
@ -333,6 +335,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
if let Some(v) = digits {
st.digits = v;
}
});
let span = super::typecmd::para_span(range, t);
t.edit_paras(span, |para| {

View File

@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@ -209,6 +209,20 @@ pub(crate) struct CharChange {
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
proportional_metrics: Option<bool>,
digits: Option<vectorcraft_doc::Digits>,
}
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::Digits>> {
use vectorcraft_doc::Digits;
let Some(v) = p.get("digits").filter(|v| !v.is_null()) else { return Ok(None) };
Ok(Some(match v.as_str() {
Some("default") => Digits::Default,
Some("arabic") => Digits::Arabic,
Some("hindi") => Digits::Hindi,
Some("farsi") => Digits::Farsi,
_ => return Err(bad(cmd, "`digits` must be \"default\", \"arabic\", \"hindi\" or \"farsi\"")),
}))
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
@ -293,6 +307,7 @@ impl CharChange {
small_caps,
char_align: char_align_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
digits: digits_param(p, cmd)?,
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@ -322,6 +337,7 @@ impl CharChange {
&& self.small_caps.is_none()
&& self.char_align.is_none()
&& self.proportional_metrics.is_none()
&& self.digits.is_none()
}
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
@ -411,6 +427,9 @@ impl CharChange {
if let Some(v) = self.proportional_metrics {
st.proportional_metrics = v;
}
if let Some(v) = self.digits {
st.digits = v;
}
}
}

View File

@ -887,6 +887,40 @@ mod area_tests {
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
}
/// Digits (Character panel menu, Middle Eastern options): a character attribute, saved only
/// when set; the text keeps the digits that were typed.
#[test]
fn digits_are_a_character_attribute_saved_only_when_set() {
use vectorcraft_doc::Digits;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "صفحة 12"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
assert_eq!(runs(&s)[0].style.digits, Digits::Default);
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("digits").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
for (key, want) in [("hindi", Digits::Hindi), ("farsi", Digits::Farsi), ("arabic", Digits::Arabic), ("default", Digits::Default)] {
s.execute("text.setFormat", &json!({"digits": key})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.digits == want));
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert_eq!(saved.get("digits").and_then(|v| v.as_str()), (want != Digits::Default).then_some(key));
// The text is as typed: only the glyphs change.
assert_eq!(runs(&s).iter().map(|r| r.text.as_str()).collect::<String>(), "صفحة 12");
}
s.execute("text.setFormat", &json!({"digits": "hindi"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(runs(&s)[0].style.digits, Digits::Default);
let err = s.execute("text.setFormat", &json!({"digits": "roman"})).unwrap_err().to_string();
assert!(err.contains("`digits` must be"), "{err}");
// Characters selected with the Type tool: only the range takes it (صفحة is 8 bytes).
s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 11, "digits": "farsi"})).unwrap();
let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.digits)).collect();
assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]);
}
#[test]
fn new_type_is_composed_with_line_end_half_width_punctuation() {
use vectorcraft_doc::Mojikumi;

View File

@ -1861,9 +1861,9 @@ impl Writer<'_> {
self.fonts.len() - 1
});
let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue };
// As written: a hyphen where a line breaks, capitals for All Caps.
// As written: a hyphen where a line breaks, capitals for All Caps, the style's Digits.
let src = if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}') {
for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) {
if st.all_caps {
chars.extend(c.to_uppercase());
} else {
@ -2055,7 +2055,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
continue;
}
cluster = Some(g.byte);
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').collect();
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
let piece = if run.style.all_caps { src.to_uppercase() } else { src };
let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace));
let fresh = std::mem::take(&mut after_inline);

View File

@ -119,6 +119,22 @@ fn proportional_metrics_write_palt() {
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
}
/// Digits are written as drawn: the style's numeral system, not the digits that were typed (as
/// All Caps writes capitals).
#[test]
fn digits_are_written_as_drawn() {
use vectorcraft_doc::Digits;
let hindi = CharStyle { digits: Digits::Hindi, ..style(12.0) };
let farsi = CharStyle { digits: Digits::Farsi, ..style(12.0) };
let d = area_doc(vec![run("page 3, ", style(12.0)), run("page 4, ", hindi), run("page ٥", farsi)], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let text = svg.as_str();
assert!(text.contains("page 3,"), "typed Western digits stay in Default: {svg}");
assert!(text.contains("page ٤,"), "Hindi draws 4 as ٤: {svg}");
assert!(text.contains("page ۵"), "Farsi draws ٥ as ۵: {svg}");
assert!(!text.contains("page 4") && !text.contains("page ٥"), "{svg}");
}
#[test]
fn fewer_tspans_writes_one_per_line() {
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };

View File

@ -369,6 +369,8 @@ pub(crate) fn shape_range(
let mut script = Script::Common;
for (i, c) in text[a..b].char_indices() {
let i = a + i;
// Digits are drawn in the style's numeral system: the font must cover that digit.
let c = st.digits.map(c);
let level = level_at(i);
let covered = c.is_whitespace() || c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some());
let face = if covered {
@ -542,6 +544,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
let c = st.digits.map(c);
if upper {
for u in c.to_uppercase() {
buf.add(u, cl);
@ -605,6 +608,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
let c = st.digits.map(c);
let chars: Vec<char> = if upper { c.to_uppercase().collect() } else { vec![c] };
for u in chars {
let g = cmap.map(u).unwrap_or_default();

View File

@ -362,3 +362,39 @@ fn craft_arabic_fonts_load_and_noto_sans_arabic_is_the_arabic_fallback() {
let a = db.face_covering('a').expect("some face covers 'a'");
assert!(!arabic.iter().any(|f| f.family == a.family), "Latin now falls back to {}", a.family);
}
/// Digits (Character panel menu): the style's numeral system draws every digit, whatever was
/// typed, while the text and its clusters stay as typed. Needs an installed font with the
/// Arabic-Indic digits.
#[test]
fn digits_draw_every_digit_in_the_styles_numeral_system() {
use vectorcraft_doc::Digits;
let db = FontDb::global();
let Some(face) = ["Arial", "Noto Sans Arabic", "Geeza Pro", "Segoe UI", "DejaVu Sans"]
.into_iter()
.filter_map(|name| db.face(name, "Regular"))
.find(|face| face.covers('3') && face.covers('٣') && face.covers('۳'))
else {
eprintln!("SKIPPED: digits_draw_every_digit_in_the_styles_numeral_system; no installed font covers 3, ٣ and ۳");
return;
};
eprintln!("ASSERTIONS RUN: digits_draw_every_digit_in_the_styles_numeral_system; {} {}", face.family, face.style);
let st = |digits: Digits| CharStyle { digits, ..arabic_style(&face) };
let lay = |s: &str, digits: Digits| layout(db, &TextObject::point(Point::ZERO, s, st(digits)));
let gids = |l: &TextLayout| l.glyphs.iter().map(|g| (g.gid, g.font_id)).collect::<Vec<_>>();
let clusters = |l: &TextLayout| l.glyphs.iter().map(|g| (g.byte, g.len)).collect::<Vec<_>>();
// Typed Western, drawn Arabic-Indic (Hindi) or Eastern Arabic-Indic (Farsi): the glyphs of
// the typed Arabic-Indic digits, on the typed text's clusters.
let typed = lay("page 3", Digits::Default);
let hindi = lay("page 3", Digits::Hindi);
assert_eq!(gids(&hindi), gids(&lay("page ٣", Digits::Default)));
assert_ne!(gids(&hindi), gids(&typed));
assert_eq!(clusters(&hindi), clusters(&typed));
assert_eq!(gids(&lay("page 3", Digits::Farsi)), gids(&lay("page ۳", Digits::Default)));
// Typed Arabic-Indic, drawn Western (Arabic): the Western glyphs on the 2-byte clusters.
let arabic = lay("صفحة ٣", Digits::Arabic);
assert_eq!(gids(&arabic), gids(&lay("صفحة 3", Digits::Default)));
assert_eq!(clusters(&arabic), clusters(&lay("صفحة ٣", Digits::Default)));
// Default leaves each digit as typed.
assert_ne!(gids(&lay("3 ٣ ۳", Digits::Default)), gids(&lay("3 ٣ ۳", Digits::Hindi)));
}

View File

@ -2003,6 +2003,10 @@
Proof Setup Proof einrichten
Properties Eigenschaften
Proportional Metrics Proportionale Metrik
Digits Ziffern
Arabic Arabisch
Hindi Hindi
Farsi Farsi
Proportional Oldstyle Proportionale Mediävalziffern
Pucker & Bloat… Zusammenziehen und aufblasen…
Pucker Tool Zusammenziehen-Werkzeug

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2054,6 +2054,10 @@
Proof Setup Ajuste de prueba
Properties Propiedades
Proportional Metrics Métrica proporcional
Digits Dígitos
Arabic Árabes
Hindi Hindi
Farsi Farsi
Proportional Oldstyle Estilo antiguo proporcional
Pucker & Bloat… Fruncir y engordar…
Pucker Tool Herramienta Fruncir

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2051,6 +2051,10 @@
Proof Setup Format d’épreuve
Properties Propriétés
Proportional Metrics Métrique proportionnelle
Digits Chiffres
Arabic Arabes
Hindi Hindi
Farsi Farsi
Proportional Oldstyle Elzévirien proportionnel
Pucker & Bloat… Contracter et dilater…
Pucker Tool Outil Contraction

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2054,6 +2054,10 @@
Proof Setup Impostazione prova
Properties Proprietà
Proportional Metrics Metrica proporzionale
Digits Cifre
Arabic Arabe
Hindi Hindi
Farsi Farsi
Proportional Oldstyle Proporzionali stile antico
Pucker & Bloat… Contrai e gonfia…
Pucker Tool Strumento Contrai

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -38,6 +38,10 @@
ICF Top/Right 平均字面の上 / 右
ICF Bottom/Left 平均字面の下 / 左
Proportional Metrics プロポーショナルメトリクス
Digits 数字
Arabic アラビア数字
Hindi ヒンディー数字
Farsi ペルシャ数字
Favorites お気に入り
Flip 反転
Japanese 日本語

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1723,6 +1723,10 @@
Proof Setup Configuração de Prova
Properties Propriedades
Proportional Metrics Métrica Proporcional
Digits Dígitos
Arabic Arábicos
Hindi Hindi
Farsi Farsi
Pucker & Bloat… Encolher e Inchar…
Pucker Tool Ferramenta Encolher
Punctuation Space Espaço de Pontuação

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2050,6 +2050,10 @@
Proof Setup Настройка цветопробы
Properties Свойства
Proportional Metrics Пропорциональные метрики
Digits Цифры
Arabic Арабские
Hindi Хинди
Farsi Фарси
Proportional Oldstyle Пропорциональные старого стиля
Pucker & Bloat… Сморщивание и раздувание…
Pucker Tool Инструмент «Сморщивание»

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2046,6 +2046,10 @@
Proof Setup Налаштування кольоропроби
Properties Властивості
Proportional Metrics Пропорційні метрики
Digits Цифри
Arabic Арабські
Hindi Гінді
Farsi Фарсі
Proportional Oldstyle Пропорційні цифри старого стилю
Pucker & Bloat… Зморщування та роздування …
Pucker Tool Інструмент «Зморщування»

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -1978,6 +1978,10 @@
Proof Setup 校样设置
Properties 属性
Proportional Metrics 比例度量
Digits 数字
Arabic 阿拉伯数字
Hindi 印地数字
Farsi 波斯数字
Proportional Oldstyle 比例旧式数字
Pucker & Bloat… 缩拢与膨胀…
Pucker Tool 收缩工具

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2032,6 +2032,10 @@
Proof Setup 校樣設定
Properties 內容
Proportional Metrics 比例度量
Digits 數字
Arabic 阿拉伯數字
Hindi 印地數字
Farsi 波斯數字
Proportional Oldstyle 比例舊式數字
Pucker & Bloat… 縮攏與膨脹…
Pucker Tool 縮攏工具

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -662,6 +662,27 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.separator();
}
// Digits: the numeral system the selected characters' digits are drawn in, with the Middle
// Eastern (Indic) options, as Paragraph Direction is in the Paragraph panel.
if app.session.prefs.show_indic_options {
let digits = s.as_ref().map(|s| s.digits);
ui.add_enabled_ui(has, |ui| {
ui.menu_button(format!(" {}", tl!("Digits")), |ui| {
use vectorcraft_doc::Digits;
for (label, d, key) in [
(tl!("Default"), Digits::Default, "default"),
(tl!("Arabic"), Digits::Arabic, "arabic"),
(tl!("Hindi"), Digits::Hindi, "hindi"),
(tl!("Farsi"), Digits::Farsi, "farsi"),
] {
if menu_item(ui, label, true, digits == Some(d)) {
format(app, json!({"digits": key}));
}
}
});
});
ui.separator();
}
for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] {
menu_item(ui, tl!(l), false, l == "Fractional Widths");
}
@ -805,4 +826,18 @@ mod tests {
app.session.prefs.show_east_asian_options = true;
assert_eq!(shown(&mut app), (true, true));
}
/// Digits is in the panel menu with the Middle Eastern (Indic) options only, as Paragraph
/// Direction is in the Paragraph panel.
#[test]
fn digits_show_with_the_indic_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "صفحة 3"})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Digits");
assert!(!shown(&mut app));
app.session.prefs.show_indic_options = true;
assert!(shown(&mut app));
}
}

View File

@ -97,7 +97,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical
composition (ruby, mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern
features beyond bidi, variable font axes, spell check with an open dictionary,
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).
- **Impact:** professional typography and Japanese/Chinese publishing.
@ -203,6 +203,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | G4: Middle Eastern digit types done (Character panel menu › Digits); estimate unchanged |
| 2026-10-11 | minor | G4: proportional vertical metrics (#966) done; estimate unchanged |
| 2026-10-11 | minor | G11: #893 fixed (merged regions of crossing lines left seams); #937 still open, estimate unchanged |
| 2026-10-11 | minor | #1034: corrected scaled type controls; optional group font-size preservation remains separate. Estimates unchanged at this rounding. |

View File

@ -2689,6 +2689,20 @@ undo step.
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}}
```
## Digits
`text.setFormat {digits: "default"|"arabic"|"hindi"|"farsi"}` (the selected type, or `ids`) and `text.setRangeStyle {id,
start, end, digits}` (a range) set the numeral system the characters' digits are drawn in, as the Character panel menu's
Digits does with the Middle Eastern (Indic) options shown: `arabic` draws every digit as the Western 0–9, `hindi` as the
Arabic-Indic ٠–٩ (U+0660–U+0669) and `farsi` as the Eastern Arabic-Indic ۰–۹ (U+06F0–U+06F9), whichever of the three sets
was typed; `default` leaves each digit as typed. The text keeps the digits that were typed (`text.get`, search, the
caret and selection are unchanged); only the glyphs change, as with All Caps, and SVG export writes the digits as drawn.
A digit the font lacks comes from a fallback font. Default and saved only when set. One undo step.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"digits":"hindi"}}}
```
## New type in a Japanese interface
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em

View File

@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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) · **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),
@ -305,7 +305,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, 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), 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, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
| 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), 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, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
| 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), 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 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (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 |
@ -461,6 +461,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Type core: Middle Eastern digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; `{digits}`) leave the missing list; score and hours unchanged at this rounding (estimated) |
| 2026-10-11 | minor | PDF raster-effect cap reductions are reported per original object with requested/achieved ppi; scores and hours unchanged |
| 2026-10-11 | minor | Platforms: Preferences › Performance › Linux Display Server opens the window through XWayland, where file drops work (#1151); score unchanged (estimated) |
| 2026-10-11 | minor | Drawing tools: Live Corners on corners between curved sides (#1144) leaves the missing list; score and hours unchanged at this rounding (estimated) |

View File

@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
@ -44,7 +44,8 @@ against Illustrator's public documentation; **estimated** unless noted.
| Snap to Glyph | missing | menu stub |
| Thai, Lao, Khmer, Myanmar: shaping (marks, reordering), lines broken between words, word-by-word caret and double-click | done | words found by ICU4X's line segmenter: dictionaries on the desktop, LSTM models on the web |
| Middle Eastern: bidirectional layout, Paragraph Direction | done | |
| Middle Eastern: Character Direction, digit types, kashidas, Middle Eastern composers, split caret | missing | |
| Middle Eastern: digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi) | done | `text.setFormat` / `text.setRangeStyle {digits}`; the text keeps the typed digits, the glyphs and SVG export follow the style |
| Middle Eastern: Character Direction, kashidas, Middle Eastern composers, split caret | missing | |
| 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 |
@ -55,6 +56,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Middle Eastern: digit types done (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; `text.setFormat` / `text.setRangeStyle {digits}`); Character Direction, kashidas, composers and the split caret still missing; scores unchanged at this rounding |
| 2026-10-11 | minor | Thai, Lao, Khmer and Myanmar words for the caret (Ctrl/Alt+arrows) and double-click; combining marks count as part of a word; score unchanged (estimated) |
| 2026-10-11 | minor | Font menu: a Thai script filter beside Japanese and Latin; score unchanged (estimated) |
| 2026-10-11 | minor | Thai, Lao, Khmer and Myanmar text breaks its lines between words (ICU4X line segmenter) instead of only at spaces; score unchanged (estimated) |