diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 08567cda..259512b8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, }; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index f5c0b735..12ce76d5 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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::(old).unwrap().digits, Digits::Default); + assert_eq!(serde_json::from_value::(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()); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index ce207c47..561ae395 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), 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 { "leadingModel", "charAlign", "proportionalMetrics", + "digits", "burasagari", "composer", ]; @@ -247,6 +248,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }; 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 { 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| { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index b1a3c347..364693d4 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", 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>, char_align: Option, proportional_metrics: Option, + digits: Option, +} + +/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in. +pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result> { + 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; + } } } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 38f2c3a1..5230c5c5 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -887,6 +887,40 @@ mod area_tests { assert!(!serde_json::from_value::(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::(), "صفحة 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; diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 7fc8c327..2605109e 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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); diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index 94f4dee5..3735e7e6 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -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) }; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index efef50c3..08e88988 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -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, 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, 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 = if upper { c.to_uppercase().collect() } else { vec![c] }; for u in chars { let g = cmap.map(u).unwrap_or_default(); diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index 3aac854c..998011cc 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -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::>(); + let clusters = |l: &TextLayout| l.glyphs.iter().map(|g| (g.byte, g.len)).collect::>(); + // 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))); +} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8736fc42..581190ff 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 49b75cbe..e29be0ce 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b191982b..35dc70cd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2739d92d..0f66bc6b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 819e2993..fcf35a19 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -38,6 +38,10 @@ ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 Proportional Metrics プロポーショナルメトリクス + Digits 数字 + Arabic アラビア数字 + Hindi ヒンディー数字 + Farsi ペルシャ数字 Favorites お気に入り Flip 反転 Japanese 日本語 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d80f76d..f4f83d24 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 55bedd5f..cd90ad67 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2050,6 +2050,10 @@ Proof Setup Настройка цветопробы Properties Свойства Proportional Metrics Пропорциональные метрики + Digits Цифры + Arabic Арабские + Hindi Хинди + Farsi Фарси Proportional Oldstyle Пропорциональные старого стиля Pucker & Bloat… Сморщивание и раздувание… Pucker Tool Инструмент «Сморщивание» diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 49925c68..d4d21919 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2046,6 +2046,10 @@ Proof Setup Налаштування кольоропроби Properties Властивості Proportional Metrics Пропорційні метрики + Digits Цифри + Arabic Арабські + Hindi Гінді + Farsi Фарсі Proportional Oldstyle Пропорційні цифри старого стилю Pucker & Bloat… Зморщування та роздування … Pucker Tool Інструмент «Зморщування» diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 405a95f1..da0845a9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1978,6 +1978,10 @@ Proof Setup 校样设置 Properties 属性 Proportional Metrics 比例度量 + Digits 数字 + Arabic 阿拉伯数字 + Hindi 印地数字 + Farsi 波斯数字 Proportional Oldstyle 比例旧式数字 Pucker & Bloat… 缩拢与膨胀… Pucker Tool 收缩工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index dc528af0..eb91ad05 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2032,6 +2032,10 @@ Proof Setup 校樣設定 Properties 內容 Proportional Metrics 比例度量 + Digits 數字 + Arabic 阿拉伯數字 + Hindi 印地數字 + Farsi 波斯數字 Proportional Oldstyle 比例舊式數字 Pucker & Bloat… 縮攏與膨脹… Pucker Tool 縮攏工具 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 51935b8f..8da9cef6 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -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)); + } } diff --git a/docs/gaps.md b/docs/gaps.md index 5d77cc5f..d6fffd47 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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. | diff --git a/docs/mcp.md b/docs/mcp.md index 149a8eef..6868103c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ca00b9c..4411ba66 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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) | diff --git a/docs/type-parity.md b/docs/type-parity.md index a308eb3c..a35dc039 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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) |