diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index e833d95db..95d23e261 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -5,12 +5,14 @@ use std::sync::Arc; use unicode_bidi::Level; use unicode_script::{Script, UnicodeScript}; +use unicode_segmentation::UnicodeSegmentation; use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; use skrifa::MetadataProvider; +use skrifa::charmap::Charmap; use skrifa::instance::Size; use vectorcraft_doc::text::INLINE_CHAR; -use vectorcraft_doc::{CharStyle, InlineArt}; +use vectorcraft_doc::{CharStyle, Digits, InlineArt}; use crate::features::{OtFeatures, ligatures_suppressed_by}; use crate::fontdb::{FontDb, FontFace}; @@ -393,24 +395,23 @@ pub(crate) fn shape_range( let small_caps = st.small_caps.is_some() && !st.all_caps; // Split into segments by font coverage (and case, for Small Caps). let mut seg = Segment { range: a..a, runs, source_runs, st, face: primary.clone(), small: false, level: level_at(a) }; - let mut cache: std::collections::HashMap> = std::collections::HashMap::new(); + let mut cache: std::collections::HashMap<&str, Arc> = std::collections::HashMap::new(); let mut script = Script::Common; - for (i, c) in text[a..b].char_indices() { + // A grapheme cluster (a letter and its marks, an emoji sequence) is set in one face. + for (i, cluster) in text[a..b].grapheme_indices(true) { 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 Some(c) = cluster.chars().next().map(|c| st.digits.map(c)) else { continue }; let level = level_at(i); // Whitespace the primary font doesn't map goes to the fallback face like any other // character (#1844): an ideographic space set at zero width would remove an indent. - // What the layout handles itself (tab, line and paragraph breaks) stays. - let covered = c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some()); - let face = if covered { + let face = if drawn_chars(cluster, st.digits).all(|c| in_face(pmap.as_ref(), c)) { primary.clone() - } else if let Some(f) = cache.get(&c) { + } else if let Some(f) = cache.get(cluster) { f.clone() } else { - let f = db.fallback_for_style(c, primary.id(), primary.weight, primary.italic).unwrap_or_else(|| primary.clone()); - cache.insert(c, f.clone()); + let f = fallback_face(db, &primary, pmap.as_ref(), cluster, st.digits); + cache.insert(cluster, f.clone()); f }; let small = small_caps && c.is_lowercase(); @@ -516,6 +517,32 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking) } +/// The characters of a grapheme cluster that need a glyph of their own, in the numeral system +/// `digits`: not what the layout handles itself (tab, line and paragraph breaks), joiners or +/// variation selectors. +fn drawn_chars(cluster: &str, digits: Digits) -> impl Iterator + '_ { + cluster + .chars() + .map(move |c| digits.map(c)) + .filter(|&c| !(c.is_control() || matches!(c, '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'))) +} + +/// Does the face with charmap `map` have a glyph for `c` (any face does when it has no charmap)? +fn in_face(map: Option<&Charmap<'_>>, c: char) -> bool { + map.is_none_or(|m| m.map(c).is_some()) +} + +/// The face of a grapheme cluster the primary face (charmap `pmap`) lacks a character of: the +/// first fallback face with all of them, so that a letter's marks are shaped with it and attach to +/// it (#4491); when there is none, the face its first character takes on its own. +fn fallback_face(db: &FontDb, primary: &Arc, pmap: Option<&Charmap<'_>>, cluster: &str, digits: Digits) -> Arc { + let fallback = |c| db.fallback_for_style(c, primary.id(), primary.weight, primary.italic); + drawn_chars(cluster, digits) + .find_map(|c| fallback(c).filter(|f| drawn_chars(cluster, digits).all(|c| f.covers(c)))) + .or_else(|| drawn_chars(cluster, digits).next().filter(|&c| !in_face(pmap, c)).and_then(fallback)) + .unwrap_or_else(|| primary.clone()) +} + /// The script `c` is shaped in: Japanese and Chinese text mixes Han, Hiragana, Katakana and /// Bopomofo, shaped together (splitting them would cost a shaper call per change of script). fn shaping_script(c: char) -> Script { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 79ce4f566..7ed2eb48c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -25,6 +25,7 @@ mod typo; mod variable; mod versions; mod vertical; +mod vietnamese; /// The bundled fonts only: what these tests measure doesn't change with the fonts installed. fn db() -> &'static FontDb { diff --git a/crates/text/src/tests/vietnamese.rs b/crates/text/src/tests/vietnamese.rs new file mode 100644 index 000000000..29f98d072 --- /dev/null +++ b/crates/text/src/tests/vietnamese.rs @@ -0,0 +1,123 @@ +//! Vietnamese letters with two diacritics (Ể, Ứ, Ự, Ẩ…) draw both marks on their base letter +//! (#4491), whichever form the text comes in: precomposed (NFC), decomposed (NFD), or as +//! Vietnamese keyboards type it (a precomposed vowel and a combining tone mark), in a font with +//! the precomposed letters, one that builds them from its marks (GPOS mark-to-base and +//! mark-to-mark), and one that has to borrow the marks from a fallback font. + +use kurbo::{Point, Rect, Shape}; +use skrifa::MetadataProvider; +use unicode_segmentation::UnicodeSegmentation; +use vectorcraft_doc::{CharStyle, TextObject}; +use write_fonts::FontBuilder; +use write_fonts::types::Tag; + +use crate::test_fonts::name_table; +use crate::{FontDb, TextLayout, layout}; + +/// Letters as (precomposed, decomposed, keyboard) text: the words "HỌC ĐỂ ỨNG DỤNG" and more +/// letters with two marks, capital and small. +const LETTERS: [[&str; 3]; 20] = [ + ["H", "H", "H"], + ["\u{1ECC}", "O\u{323}", "O\u{323}"], + ["C", "C", "C"], + ["\u{110}", "\u{110}", "\u{110}"], + ["\u{1EC2}", "E\u{302}\u{309}", "\u{CA}\u{309}"], + ["\u{1EE8}", "U\u{31B}\u{301}", "\u{1AF}\u{301}"], + ["N", "N", "N"], + ["G", "G", "G"], + ["D", "D", "D"], + ["\u{1EE4}", "U\u{323}", "U\u{323}"], + ["\u{1EF0}", "U\u{31B}\u{323}", "\u{1AF}\u{323}"], + ["\u{1EA8}", "A\u{302}\u{309}", "\u{C2}\u{309}"], + ["\u{1EBE}", "E\u{302}\u{301}", "\u{CA}\u{301}"], + ["\u{1ED8}", "O\u{302}\u{323}", "\u{D4}\u{323}"], + ["\u{1EDC}", "O\u{31B}\u{300}", "\u{1A0}\u{300}"], + ["\u{1EC3}", "e\u{302}\u{309}", "\u{EA}\u{309}"], + ["\u{1EE9}", "u\u{31B}\u{301}", "\u{1B0}\u{301}"], + ["\u{1EF1}", "u\u{31B}\u{323}", "\u{1B0}\u{323}"], + ["\u{1EA9}", "a\u{302}\u{309}", "\u{E2}\u{309}"], + ["\u{1EAB}", "a\u{306}\u{303}", "\u{103}\u{303}"], +]; + +const SIZE: f64 = 100.0; +const SOURCE_SANS: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"); + +fn text(form: usize) -> String { + LETTERS.iter().filter_map(|l| l.get(form).copied()).collect::>().join(" ") +} + +/// Each letter's ink, relative to the pen position of its first glyph. +fn letter_inks(db: &FontDb, family: &str, text: &str) -> Vec { + let st = CharStyle { font_family: family.into(), font_style: "Regular".into(), size: SIZE, ..CharStyle::default() }; + let l: TextLayout = layout(db, &TextObject::point(Point::ZERO, text, st)); + text.grapheme_indices(true) + .filter(|(_, g)| *g != " ") + .map(|(start, g)| { + let glyphs: Vec<_> = l.glyphs.iter().filter(|gl| (start..start + g.len()).contains(&gl.byte)).collect(); + assert!(!glyphs.is_empty(), "{g:?} has glyphs"); + let pen = glyphs[0].origin.x; + let ink = glyphs.iter().map(|gl| gl.outline.bounding_box()).reduce(|a, b| a.union(b)).unwrap(); + assert!(ink.area() > 0.0, "{g:?} draws"); + assert!(glyphs.iter().all(|gl| gl.gid != 0), "{g:?} has no missing glyph"); + ink - kurbo::Vec2::new(pen, 0.0) + }) + .collect() +} + +/// Every letter of `form` (drawn in `family` of `db`) has the ink of the precomposed letter in +/// Source Sans 3: its marks are on the letter, not beside it. +fn assert_attached(db: &FontDb, family: &str, form: usize) { + let want = letter_inks(FontDb::global(), "Source Sans 3", &text(0)); + let got = letter_inks(db, family, &text(form)); + assert_eq!(want.len(), got.len()); + for ((w, g), letter) in want.iter().zip(&got).zip(LETTERS) { + let off = [w.x0 - g.x0, w.x1 - g.x1, w.y0 - g.y0, w.y1 - g.y1].into_iter().fold(0.0_f64, |m, d| m.max(d.abs())); + assert!(off < 0.08 * SIZE, "{family}, form {form}: {:?} drawn {g:?}, precomposed {w:?}", letter[form]); + } +} + +#[test] +fn stacked_marks_sit_on_their_letter_in_every_form() { + for form in 0..3 { + assert_attached(FontDb::global(), "Source Sans 3", form); + } +} + +/// Source Sans 3 renamed `family`, with only the characters `keep` takes in its cmap. +fn source_sans_with(family: &str, keep: impl Fn(u32) -> bool) -> Vec { + let font = skrifa::FontRef::new(SOURCE_SANS).unwrap(); + let map: Vec<(u32, u16)> = font.charmap().mappings().filter(|(c, _)| keep(*c)).map(|(c, g)| (c, u16::try_from(g.to_u32()).unwrap())).collect(); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"cmap"), crate::embed::cmap(&map).unwrap()); + builder.add_raw(Tag::new(b"name"), name_table(&[(1, family), (2, "Regular"), (4, family), (6, "Viettest-Regular")]).unwrap()); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes()); + } + } + builder.build() +} + +/// A font without the precomposed letters builds them from its base letters and marks. +#[test] +fn stacked_marks_are_positioned_mark_to_mark() { + const FAMILY: &str = "Viettest Marks"; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(source_sans_with(FAMILY, |c| c < 0x1E00 && !(0xC0..0x250).contains(&c))), 1); + for form in 0..3 { + assert_attached(&db, FAMILY, form); + } +} + +/// A font with the base letters but none of the marks takes the whole letter, marks included, +/// from a font that has them. +#[test] +fn letters_whose_marks_a_font_lacks_fall_back_whole() { + const FAMILY: &str = "Viettest Bare"; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(source_sans_with(FAMILY, |c| c < 0x80)), 1); + for form in 0..3 { + assert_attached(&db, FAMILY, form); + } +} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 98a3bb161..9417f8893 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Catalan (`ca`) catalog added, complete under the tests) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Vietnamese text: stacked diacritics tested and attach in every form, #4491; previously: Catalan (`ca`) catalog added, complete under the tests) Per-language status of VectorCraft's interface. How the catalogs work and how to add a language: [`development.md` › Localisation](development.md#localisation). Part of @@ -37,7 +37,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | | German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | -| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (precomposed forms; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | +| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | **12-language score: ~82%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 100 + 100 + 100 + 98 + 100 + 100, over 12). **To `full` for all twelve: ~50–85 h**, most of it Arabic (right-to-left interface) and Hindi (interface @@ -95,6 +95,7 @@ font for the web build. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Vietnamese text: letters with two marks (Ể, Ứ, Ự) attach in precomposed, decomposed and keyboard forms, and a font lacking the marks falls back for the whole letter (#4491); scores unchanged | | 2026-10-28 | minor | Catalan (`ca`): complete catalog under the tests (menus, literals, messages; one/other plurals); every `ca-*` locale resolves here; AI-drafted, native review welcome. Added the Turkish and Indonesian rows the table was missing. Scores and estimates unchanged | | 2026-10-23 | patch | A missing translation never fails a test or a build; one untranslated report, printed by `cargo xtask ci` and `cargo xtask i18n-report`, lists the missing strings per language; scores unchanged | | 2026-10-16 | minor | Bulgarian (`bg`, #1909) and Greek (`el`, #2076) catalogs added, complete under the tests; Swedish (#2139) and Finnish (#1994) rows for strings added since; eighteen non-English catalogs | diff --git a/docs/type-parity.md b/docs/type-parity.md index b5d76d732..a99b3a2eb 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-25 · **Change:** minor (threading ports: a double-click on a threaded port breaks the thread, `text.thread.break`; previously: Ruby: Create Outlines, EPS, EMF/WMF and legacy `.ai` keep the reading where the canvas draws it; previously: OpenType SVG: images in glyph documents draw, export and outline with the glyph; previously: bitmap glyphs (emoji) go with type that effects reshape, into Expand Appearance, envelope Expand and EMF/WMF export, as images; previously: threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, #2572; scores unchanged; font menu: Font Technology › Variable lists only variable font families, G4; previously:; colour glyphs (COLR, SVG) go with type that effects reshape, into Expand Appearance and EMF/WMF export; previously:; OpenType SVG glyphs follow the `SVG ` table's coordinate rules: a root size without a `viewBox` sets up the em, a `viewBox` that isn't square is centred in it, and a glyph element nested in others is drawn on its own; previously: OpenType SVG colour fonts draw and export as vector art; hanging punctuation: Glyph Scaling leaves the hung marks at their width, Standard burasagari counts a hung line start as room, G4; a paragraph's Auto Leading also drives the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; scores unchanged; COLR colour fonts (v0 and v1) draw and export as vector art; previously: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; scores unchanged; previously: #3515: recent-font groups in font pickers; manual Tate-chu-yoko marks characters across one em of a vertical column; System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (font fallback keeps a letter and its marks in one face; Vietnamese stacked diacritics tested in every form (#4491); previously: threading ports: a double-click on a threaded port breaks the thread, `text.thread.break`; previously: Ruby: Create Outlines, EPS, EMF/WMF and legacy `.ai` keep the reading where the canvas draws it; previously: OpenType SVG: images in glyph documents draw, export and outline with the glyph; previously: bitmap glyphs (emoji) go with type that effects reshape, into Expand Appearance, envelope Expand and EMF/WMF export, as images; previously: threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, #2572; scores unchanged; font menu: Font Technology › Variable lists only variable font families, G4; previously:; colour glyphs (COLR, SVG) go with type that effects reshape, into Expand Appearance and EMF/WMF export; previously:; OpenType SVG glyphs follow the `SVG ` table's coordinate rules: a root size without a `viewBox` sets up the em, a `viewBox` that isn't square is centred in it, and a glyph element nested in others is drawn on its own; previously: OpenType SVG colour fonts draw and export as vector art; hanging punctuation: Glyph Scaling leaves the hung marks at their width, Standard burasagari counts a hung line start as room, G4; a paragraph's Auto Leading also drives the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; scores unchanged; COLR colour fonts (v0 and v1) draw and export as vector art; previously: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; scores unchanged; previously: #3515: recent-font groups in font pickers; manual Tate-chu-yoko marks characters across one em of a vertical column; System Layout: Character panel menu sets whole-point advances for every text object in the document (off by default); 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 @@ -32,7 +32,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | -| Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic | +| Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic; a letter and its combining marks (a grapheme cluster) are drawn in one face, the first that has all of them, so marks the text's font lacks attach to their letter instead of drawing as missing glyphs (#4491). Vietnamese letters with two marks (Ể, Ứ, Ự) sit on their letter whether typed precomposed, decomposed or as a vowel plus a tone mark, built from GPOS mark-to-mark in fonts without the precomposed letters. Evidence: `tests::vietnamese` | | Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, or ruby; in envelopes they warp as images do, and a Warp effect moves them without bending them. COLR (v0 and v1) done: a glyph's layers as vector art (solid colours, linear and radial gradients, clip glyphs and boxes, blend modes, transforms, palette 0, the foreground colour from the run's fill, variable fonts at the face's instance), transformed with the type; drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF (Type 3 glyphs), and Create Outlines makes groups of editable paths. Paints documents have no form for draw the glyph as an image (256 px per em): sweep gradients, Porter-Duff compositing, radial gradients from a circle that isn't the centre, repeating or reflecting gradients. Not ruby. COLR and SVG glyphs also go with type that geometry effects reshape (on the canvas and in exports), into Expand Appearance and EMF/WMF export, as their art, and bitmap glyphs as images. OpenType SVG done: a glyph's SVG document (its element `glyph`) is read by the SVG importer into vector art in font units, `currentColor` in the run's fill (and the legacy `context-fill` and `context-stroke` in its fill and stroke), a `viewBox` mapped onto the em square, and then drawn, exported and outlined as COLR glyphs are; in PDF real text such glyphs are text that draws nothing, with their art drawn where they sit. Images in glyph documents are in the art, holding their pixels until a document takes them in; text, scripts and embedded content are left out, as the table rules them out. Not CPAL palette variables. Evidence: `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::svg_glyph_images_draw_and_outline`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`, `tests_colourfonts::expand_appearance_keeps_the_colour_glyphs_of_type_with_effects`, `render::tests_colrglyphs::colour_glyphs_go_with_type_that_a_geometry_effect_reshapes`, `tests_bitmaps`, `tests_colr`, `render::tests_bitmapglyphs`, `render::tests_colrglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*` and `colr_glyphs_*`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups` | | Every-line and Single-line composers | done | tuned by eye, never compared side by side | | Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right paragraphs, and at the brackets of type on a path whose first paragraph runs left to right; right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, do not hang | @@ -70,6 +70,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Font fallback keeps a letter and its combining marks in one face; Vietnamese stacked diacritics (Ể, Ứ, Ự) attach in precomposed, decomposed and keyboard forms (#4491); scores and hours unchanged | | 2026-10-25 | minor | Threading ports: a double-click on a threaded port with the Selection tool breaks the thread there (`text.thread.break`); the story flows into the frames before the break; scores unchanged | | 2026-10-24 | minor | Ruby: Type › Create Outlines, EPS, EMF/WMF, legacy `.ai` and type that effects, Expand Appearance and flattening outline keep the reading's glyphs where the canvas draws them (they were dropped), stacked with their run as the canvas paints it (Create Outlines puts each run's reading right after that run's glyphs, below later runs); legacy `.ai` writes type with ruby as outlines; scores unchanged | | 2026-10-24 | minor | Colour fonts: images in OpenType SVG glyph documents draw, export and outline with the glyph, holding their pixels until a document takes them in; the SVG table's rules are now all followed but palette variables; scores unchanged |