diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs new file mode 100644 index 000000000..7e47d3726 --- /dev/null +++ b/crates/text/src/harakat.rs @@ -0,0 +1,149 @@ +//! Arabic marks (harakat) styled apart from their letters. +//! +//! A designer colours the harakat apart from the letters (or hides them with no fill) without +//! breaking the letters' joining or the marks' attachment: runs that differ only in paint +//! ([`paint_only`]) are not a shaping boundary at a harakat ([`paint_only_mark_boundary`]), so the +//! letter and its marks are still shaped together, glyph for glyph as before. When the character- +//! level shaping pass matches the monotone pass ([`own_chars`]), a glyph may take a later +//! in-cluster character's run only when it is positively a mark: the face's GDEF GlyphClassDef +//! names it class 3 (mark) and that character is a harakat. Anything else—including fonts with no +//! GDEF, AAT/`morx` faces where the class is unavailable, ligatures, and multi-component +//! glyphs—keeps the shaping cluster's first character. Conservative means a mark may fail to take +//! its colour, but a base letter must never take a mark's colour. AAT/`morx` fonts may still leave +//! a mark on a ligature (for example Geeza Pro's Allah ligature) painted as its letter. Renderers +//! and exporters read the glyph's run as they always did, so they need no change. The text itself +//! is never changed. + +use skrifa::GlyphId; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::gdef::GlyphClassDef; +use vectorcraft_doc::CharStyle; + +use crate::fontdb::FontFace; + +/// Is `c` an Arabic combining mark: harakat (fatha, damma, kasra, tanwin, shadda, sukun), the +/// superscript alef, Quranic annotation marks, and the extended-A marks? +pub(crate) fn is_harakat(c: char) -> bool { + matches!( + c as u32, + 0x0610..=0x061A + | 0x064B..=0x065F + | 0x0670 + | 0x06D6..=0x06DC + | 0x06DF..=0x06E4 + | 0x06E7..=0x06E8 + | 0x06EA..=0x06ED + | 0x08D3..=0x08FF + ) +} + +/// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the +/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper +/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike` +/// (which also ignores baseline shift, tracking, underline, …). +pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { + let mut b = b.clone(); + b.fill = a.fill.clone(); + b.stroke = a.stroke.clone(); + b.stroke_width = a.stroke_width; + b.overprint_fill = a.overprint_fill; + b.overprint_stroke = a.overprint_stroke; + b.stroke_cap = a.stroke_cap; + b.stroke_join = a.stroke_join; + b.stroke_miter_limit = a.stroke_miter_limit; + b.stroke_dash = a.stroke_dash.clone(); + b.stroke_align = a.stroke_align; + b.style_name = a.style_name.clone(); + *a == b +} + +/// Is the run boundary at byte `at` of `text`, between styles `before` and `after`, one that +/// stays inside a shaping cluster: they differ only in paint and a harakat is on either side? +pub(crate) fn paint_only_mark_boundary(text: &str, at: usize, before: &CharStyle, after: &CharStyle) -> bool { + let next = text.get(at..).and_then(|s| s.chars().next()); + let previous = text.get(..at).and_then(|s| s.chars().next_back()); + (next.is_some_and(is_harakat) || previous.is_some_and(is_harakat)) && paint_only(before, after) +} + +/// The byte ranges of the harakat in `text[range]`, each run of consecutive harakat as one range. +/// The range is normalised as [`crate::edit::style_range`] takes one: its ends clamped to the +/// text, each moved back to the start of the character it falls in, and put in order. +#[cfg(test)] +pub(crate) fn harakat_ranges(text: &str, range: std::ops::Range) -> Vec> { + let (mut start, mut end) = (crate::edit::floor_char(text, range.start), crate::edit::floor_char(text, range.end)); + if start > end { + std::mem::swap(&mut start, &mut end); + } + let Some(part) = text.get(start..end) else { + return vec![]; + }; + let mut out: Vec> = vec![]; + for (i, c) in part.char_indices().filter(|(_, c)| is_harakat(*c)) { + let (a, b) = (start + i, start + i + c.len_utf8()); + match out.last_mut() { + Some(last) if last.end == a => last.end = b, + _ => out.push(a..b), + } + } + out +} + +/// Does `face`'s GDEF GlyphClassDef name glyph `gid` as class 3 (mark)? False when the face has +/// no GDEF, no class def, or the class is unavailable (typical of AAT/`morx` paths). +pub(crate) fn is_gdef_mark(face: &FontFace, gid: u32) -> bool { + let Some(font) = face.skrifa() else { + return false; + }; + let Ok(gdef) = font.gdef() else { + return false; + }; + let Some(Ok(classes)) = gdef.glyph_class_def() else { + return false; + }; + GlyphClassDef::new(classes.get(GlyphId::new(gid))) == GlyphClassDef::Mark +} + +/// Each glyph's own character, from the shaping `by_char` at character cluster level of the +/// glyphs `raw` (glyph id, cluster, x advance, x and y offset; `by_char` with the y advance after +/// the x advance), the segment ending at byte `end`, and `text` for character identity. +/// `is_gdef_mark(gid)` is true only when the face's GDEF GlyphClassDef names that glyph class 3. +/// Empty unless `by_char` sets the same glyphs the same way (glyph id, advance and offsets, glyph +/// by glyph): repeated glyph ids alone could hide a changed correspondence. A glyph whose character +/// lies outside its own shaping cluster of `raw` (from its cluster value up to the next larger one, +/// else `end`) gets its cluster's first character instead. A later in-cluster character is kept +/// only when the glyph is a GDEF mark and that character is a harakat; otherwise the cluster start +/// is kept (conservative: a mark may miss its colour; a base must never take a mark's). `raw`'s +/// clusters are never changed. These are HarfBuzz shaping clusters, which can span more than one +/// grapheme (a ligature). +pub(crate) fn own_chars( + raw: &[(u32, u32, i32, i32, i32)], + by_char: &[(u32, u32, i32, i32, i32, i32)], + end: usize, + text: &str, + is_gdef_mark: impl Fn(u32) -> bool, +) -> Vec { + let same = by_char.len() == raw.len() && by_char.iter().zip(raw).all(|(c, r)| c.0 == r.0 && c.2 == r.2 && c.4 == r.3 && c.5 == r.4); + if !same { + return vec![]; + } + let mut starts: Vec = raw.iter().map(|g| g.1).collect(); + starts.sort_unstable(); + starts.dedup(); + let cluster_end = |cl: u32| starts.get(starts.partition_point(|&c| c <= cl)).map_or(end, |&c| c as usize); + by_char + .iter() + .zip(raw) + .map(|(c, r)| { + let in_cluster = r.1 <= c.1 && (c.1 as usize) < cluster_end(r.1); + if !in_cluster { + return r.1; + } + if c.1 == r.1 { + return c.1; + } + // Later in-cluster character: only a positively identified mark glyph on a harakat. + let mark_char = text.get(c.1 as usize..).and_then(|s| s.chars().next()).is_some_and(is_harakat); + if is_gdef_mark(r.0) && mark_char { c.1 } else { r.1 } + }) + .collect() +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c36514eef..d79fe7c6b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -430,11 +430,22 @@ impl Ctx<'_> { }); } + /// Source-run index covering byte `b` (the cluster start for a letter with its marks). + fn run_index_at(&self, b: usize) -> Option { + let find = |b: usize| { + let i = self.runs.partition_point(|(r, _)| r.end <= b); + self.runs.get(i).filter(|(r, _)| r.start <= b).map(|_| i) + }; + find(b).or_else(|| b.checked_sub(1).and_then(find)) + } + /// Draw a kashida `len` points long from `at` (the pen on the baseline) on line `line`, as /// tatweels in the face, size and style of glyph `g`. Short, Medium and Long kashidas are one /// tatweel stretched along the baseline; a Stylistic one is tatweels side by side. Each tatweel /// is a glyph at `g`'s byte offset that stands for no character (length 0) and does not - /// advance: `g`'s advance takes the kashida in. + /// advance: `g`'s advance takes the kashida in. Paint comes from the base letter's run at the + /// cluster start (`g.byte`), not from a trailing mark that may be the last glyph of the + /// cluster when harakat are coloured or hidden apart from their letters. fn emit_kashida(&mut self, g: &SGlyph, kind: Kashida, len: f64, at: Point, line: usize) { let Some((gid, t)) = kashida::tatweel(g) else { return }; let mut k = g.clone(); @@ -448,6 +459,10 @@ impl Ctx<'_> { k.ch = kashida::TATWEEL; k.inline = None; k.tcy = None; + // Base letter's run: `g` may be a mark (last in the shaping cluster) with mark paint. + if let Some(run) = self.run_index_at(g.byte) { + k.run = run; + } if kind == Kashida::Stylistic { let n = (len / t).round().clamp(1.0, kashida::MAX_TATWEELS) as usize; for m in 0..n { @@ -462,3 +477,163 @@ impl Ctx<'_> { #[cfg(test)] mod wrapping_tests; + +/// Forced mark-last kashida emitter: the absorbing glyph carries a mark run while the cluster +/// start is a letter run; the tatweel must take the letter run. Reverting the base-run assignment +/// in [`Ctx::emit_kashida`] fails these. +#[cfg(test)] +mod emit_kashida_mark_last { + use super::*; + use std::sync::Arc; + use vectorcraft_doc::color::{Color, Paint}; + + fn arabic_face() -> Option> { + let db = FontDb::global(); + ["Noto Sans Arabic", "Arial", "Tahoma", "Times New Roman"] + .into_iter() + .filter_map(|name| db.face(name, "Regular")) + .find(|face| face.covers('ب') && face.covers('\u{064E}') && face.covers('\u{0640}')) + } + + fn force_mark_last(mark_fill: Paint) { + let Some(face) = arabic_face() else { + eprintln!("SKIPPED: emit_kashida_mark_last; no Arabic face with tatweel"); + return; + }; + let Some((tat_gid, _)) = kashida::tatweel(&SGlyph { + face: Arc::clone(&face), + gid: 0, + byte: 0, + len: 2, + run: 0, + adv: 10.0, + dx: 0.0, + dy: 0.0, + kern_shift: 0.0, + sx: face.upem.recip() * 48.0, + sy: face.upem.recip() * 48.0, + bshift: 0.0, + rotation: 0.0, + ascent: 40.0, + descent: 10.0, + leading: 0.0, + cap: 30.0, + xh: 20.0, + ch: 'ب', + tcy: None, + inline: None, + word_end: false, + lead: 0.0, + proportional: false, + vpal: None, + level: Level::rtl(), + no_break: false, + vertical_roman: false, + cursive: true, + hyphen_language: crate::hyphen::Language::English, + }) else { + eprintln!("SKIPPED: emit_kashida_mark_last; face has no tatweel"); + return; + }; + let letter = CharStyle { font_family: face.family.clone(), size: 48.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let mut mark = letter.clone(); + mark.fill = mark_fill.clone(); + // "بَ": letter run 0 covers 0..2, mark run 1 covers 2..4. Cluster starts at byte 0. + let text = "بَ"; + let opts = LayoutOptions::default(); + let db = FontDb::global(); + let mut cx = Ctx { + on_path: false, + db, + text, + runs: vec![(0..2, &letter), (2..4, &mark)], + inlines: vec![None, None], + default: letter.clone(), + opts: &opts, + vertical: false, + system_layout: false, + fractional_widths: true, + upright_cluster: None, + out: TextLayout::default(), + }; + // Last visual glyph of the cluster: a mark that wrongly keeps the mark run (run 1). + let mut g = SGlyph { + face: Arc::clone(&face), + gid: face.glyph_for('\u{064E}'), + byte: 0, + len: 4, + run: 1, + adv: 0.0, + dx: 0.0, + dy: 0.0, + kern_shift: 0.0, + sx: face.upem.recip() * 48.0, + sy: face.upem.recip() * 48.0, + bshift: 0.0, + rotation: 0.0, + ascent: 40.0, + descent: 10.0, + leading: 0.0, + cap: 30.0, + xh: 20.0, + ch: '\u{064E}', + tcy: None, + inline: None, + word_end: false, + lead: 0.0, + proportional: false, + vpal: None, + level: Level::rtl(), + no_break: false, + vertical_roman: false, + cursive: true, + hyphen_language: crate::hyphen::Language::English, + }; + assert_ne!(g.run, 0, "precondition: absorbing glyph has mark run ≠ cluster-start run"); + assert_eq!(cx.style_at(g.byte).fill, letter.fill, "precondition: cluster start is letter paint"); + assert_eq!(mark.fill, mark_fill, "precondition: mark paint differs"); + cx.emit_kashida(&g, Kashida::Long, 20.0, Point::ZERO, 0); + let tatweels: Vec<_> = cx.out.glyphs.iter().filter(|pg| pg.gid == tat_gid && pg.len == 0).collect(); + assert!(!tatweels.is_empty(), "tatweel emitted"); + for t in tatweels { + assert_eq!(t.run, 0, "tatweel must use base letter run, not mark run {}", g.run); + let fill = cx.runs.get(t.run).map(|(_, s)| &s.fill).expect("run"); + assert_eq!(fill, &letter.fill, "tatweel paint is the letter's"); + assert_ne!(fill, &mark_fill, "tatweel must not inherit mark paint"); + } + // Without the base-run assignment, emit_kashida would keep g.run == 1. + g.run = 1; + let mut broken = Ctx { + on_path: false, + db, + text, + runs: vec![(0..2, &letter), (2..4, &mark)], + inlines: vec![None, None], + default: letter.clone(), + opts: &opts, + vertical: false, + system_layout: false, + fractional_widths: true, + upright_cluster: None, + out: TextLayout::default(), + }; + // Simulate the pre-fix path: clone run from the mark glyph only. + let mut k = g.clone(); + k.gid = tat_gid; + k.len = 0; + k.ch = kashida::TATWEEL; + // k.run stays 1 — what emit_kashida did before the fix. + broken.emit(&k, Affine::IDENTITY, Point::ZERO, 0.0, 0.0, 0); + assert_eq!(broken.out.glyphs[0].run, 1, "sanity: unrepaired path keeps mark run"); + } + + #[test] + fn coloured_mark_last_tatweel_uses_letter_run() { + force_mark_last(Paint::solid(Color::rgb8(200, 30, 30))); + } + + #[test] + fn hidden_mark_last_tatweel_uses_letter_run() { + force_mark_last(Paint::None); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9763ea4b6..71f46acec 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -19,6 +19,7 @@ pub mod embed; mod features; mod fontconfig; mod fontdb; +mod harakat; pub mod hyphen; mod justify; mod kashida; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 1a6716c5f..06a14d3df 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -8,7 +8,7 @@ use unicode_bidi::Level; use unicode_script::{Script, UnicodeScript}; use unicode_segmentation::UnicodeSegmentation; -use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; +use harfrust::{BufferClusterLevel, Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; use kurbo::Shape; use skrifa::MetadataProvider; use skrifa::charmap::Charmap; @@ -976,6 +976,10 @@ struct Shaped { raw: Vec<(u32, u32, i32, i32, i32)>, proportional: Vec, vpal: Vec>, + /// Each glyph's own character (cluster at character level), when harakat are styled apart + /// from their letters and the character-level pass matches ([`crate::harakat`]); empty + /// otherwise (glyphs keep their shaping-cluster start). + chars: Vec, } fn is_mark(c: char) -> bool { @@ -1042,6 +1046,11 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe if !manual_ends.is_empty() { return shape_apart(manual_ends, out); } + // Harakat styled apart from their letters (paint only): shaped with them, each glyph then + // takes its own character's run ([`crate::harakat`]). + let styled_marks = + boundaries().any(|b| run_at(b - 1).1 != run_at(b).1 && crate::harakat::paint_only_mark_boundary(text, b, run_at(b - 1).1, run_at(b).1)); + let char_level = std::cell::Cell::new(false); // Shape the segment as if all of it were in `st`, with the features `more` on top. let shape_in = |st: &CharStyle, more: &[Feature]| { @@ -1052,7 +1061,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) .and_then(|l| l.parse::().ok()); let mut shaped = Shaped::default(); - let Shaped { raw, proportional, vpal } = &mut shaped; + let Shaped { raw, proportional, vpal, chars } = &mut shaped; let hb_shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // gid, cluster, x and y advance, x and y offset. @@ -1072,6 +1081,9 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } buf.set_direction(direction); buf.guess_segment_properties(); + if char_level.get() { + buf.set_cluster_level(BufferClusterLevel::Characters); + } // The characters' language picks the font's forms for it (`locl`: Turkish keeps fi // apart and the dot on its i). if let Some(l) = &language { @@ -1100,6 +1112,14 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe *raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect(); let raw = &*raw; let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(raw).all(|(a, b)| a.0 == b.0); + // The same shaping at character level names each glyph's own character (kept only when + // it sets the same glyphs alike, each character in its glyph's cluster). + if styled_marks { + char_level.set(true); + let by_char = shape(&feats.resolve(st), along); + char_level.set(false); + *chars = crate::harakat::own_chars(raw, &by_char, range.end, text, |gid| crate::harakat::is_gdef_mark(face, gid)); + } if feats.proportional(st) { // `palt` is positioning only: the same glyphs set on their full widths tell which // advances it changed (kerning and the rest change both alike). @@ -1154,7 +1174,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe shaped }; let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) }; - let Shaped { raw, proportional, vpal } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { + let Shaped { raw, proportional, vpal, chars } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { // The pair entering Optical keeps the left character's font kerning. Subsequent // Optical pairs use outline spacing, so font pair features stop at its second character. let optical_off = if first == range.start { @@ -1214,7 +1234,11 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cluster_starts.dedup(); // A glyph is drawn in one style: where a cluster would take in characters of runs in different // styles, the runs are shaped apart, as runs that do not shape alike are. - let inside_clusters: Vec = boundaries().filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1).collect(); + // Except at harakat styled apart only in paint ([`crate::harakat`]). + let inside_clusters: Vec = boundaries() + .filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1) + .filter(|&b| !crate::harakat::paint_only_mark_boundary(text, b, run_at(b - 1).1, run_at(b).1)) + .collect(); if !inside_clusters.is_empty() { return shape_apart(inside_clusters, out); } @@ -1274,6 +1298,11 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() { let cl = cl as usize; let (run, gst) = run_at(cl); + // A glyph of a harakat styled apart takes its own character's run (paint only). + let run = match chars.get(gi).map(|&c| run_at(c as usize)) { + Some((own, own_st)) if crate::harakat::paint_only(gst, own_st) => own, + _ => run, + }; let end = cluster_end(cl); let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; let ch = first_char(cl); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 031d35bdc..483d0754d 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -13,6 +13,7 @@ mod devanagari; mod embed; mod fit; mod hang; +mod harakat; mod inline; mod joiners; mod justify; diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs new file mode 100644 index 000000000..4f9c31833 --- /dev/null +++ b/crates/text/src/tests/harakat.rs @@ -0,0 +1,497 @@ +//! Arabic marks (harakat) styled apart from their letters ([`crate::harakat`]). +//! Layout cases need an Arabic face: craft-fonts' Noto Sans Arabic when the build has +//! `CRAFT_FONTS_DIR`, else an installed OpenType Arabic face. Without either they skip. + +use std::sync::Arc; + +use kurbo::{Affine, Point, Rect, Shape}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::text::Kashida; +use vectorcraft_doc::{CharStyle, Justify, TextKind, TextObject}; +use vectorcraft_geom::PathData; + +use crate::harakat::{harakat_ranges, is_gdef_mark, is_harakat, own_chars, paint_only, paint_only_mark_boundary}; +use crate::{FontDb, FontFace, PositionedGlyph, TextLayout, layout}; + +const FAMILY: &str = "Noto Sans Arabic"; +const VOCALIZED: &str = "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ"; +/// Short vocalized word used to probe whether a face's shaped mark glyphs are GDEF class 3. +const GDEF_PROBE: &str = "بَبَب"; + +/// An OpenType Arabic face covering harakat whose shaped fixture glyphs include GDEF mark +/// (class 3) glyphs. Strict colour/ownership layout tests need that: without GDEF, production +/// correctly keeps the letter's paint and those assertions would fail. Fonts with coverage and +/// GSUB but no mark classes are skipped here; synthetic `own_chars` cases and +/// `installed_fonts_keep_their_glyphs_with_coloured_harakat` still cover the no-GDEF fallback. +fn arabic_face(test: &str) -> Option> { + let opentype = |face: &FontFace| { + use skrifa::raw::TableProvider; + face.skrifa().is_some_and(|f| f.gsub().is_ok()) + }; + let db = FontDb::global(); + let face = ["Noto Sans Arabic", "Arial", "Geeza Pro", "SF Arabic", "Tahoma", "Times New Roman"] + .into_iter() + .filter_map(|name| db.face(name, "Regular")) + .find(|face| { + if !(face.covers('ب') && face.covers('\u{064E}') && face.covers('\u{0640}') && opentype(face)) { + return false; + } + // Shape the probe: every zero-advance authored glyph must be a GDEF mark, and at least + // one must appear, so strict colour assertions match production ownership. + let shaped = layout(db, &text_with_harakat(GDEF_PROBE, &face.family, None)); + let marks: Vec<_> = shaped.glyphs.iter().filter(|g| g.len > 0 && g.advance.abs() < 1e-6).collect(); + !marks.is_empty() && marks.iter().all(|g| is_gdef_mark(face, g.gid)) + }); + match &face { + Some(face) => eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {} (GDEF marks)", face.family, face.style), + None => { + eprintln!("SKIPPED: {test}; no OpenType Arabic font with harakat and GDEF mark classes (set CRAFT_FONTS_DIR or install one)") + } + } + face +} + +fn red() -> Paint { + Paint::solid(Color::rgb8(200, 30, 30)) +} + +fn style(family: &str) -> CharStyle { + CharStyle { font_family: family.into(), size: 48.0, ..CharStyle::default() } +} + +/// `text` in `family`, its harakat filled with `fill` (`None`: as the letters). +fn text_with_harakat(text: &str, family: &str, fill: Option) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, style(family)); + if let Some(fill) = fill { + for r in harakat_ranges(text, 0..text.len()) { + crate::edit::style_range(&mut t.runs, r.start, r.end, |st| st.fill = fill.clone()); + } + } + t +} + +/// One glyph's geometry, bit for bit: face, glyph id, cluster, pen position, transform, advance. +type Geometry = (u32, u32, usize, usize, [u64; 2], [u64; 6], u64); + +fn geometry(l: &TextLayout) -> Vec { + l.glyphs + .iter() + .map(|g| { + (g.font_id, g.gid, g.byte, g.len, [g.origin.x.to_bits(), g.origin.y.to_bits()], g.xf.as_coeffs().map(f64::to_bits), g.advance.to_bits()) + }) + .collect() +} + +fn painted(t: &TextObject, l: &TextLayout, fill: &Paint) -> usize { + l.glyphs.iter().filter(|g| run_of(t, g).style.fill == *fill).count() +} + +fn run_of<'a>(t: &'a TextObject, g: &PositionedGlyph) -> &'a vectorcraft_doc::TextRun { + t.runs.get(g.run).unwrap_or_else(|| panic!("glyph {} at byte {} names missing run {}", g.gid, g.byte, g.run)) +} + +/// Expected fill for an authored glyph from the source runs that cover its shaping-cluster bytes, +/// independent of the glyph's assigned `run` index: a harakat in the cluster span wins when the +/// glyph looks like a mark (no advance before kashida, or run text is harakat-only is not used +/// here). For ownership checks we map by whether every authored character in `[byte, byte+len)` +/// that is a harakat should paint the glyph when the glyph has zero advance; letters use the +/// fill of the first non-harakat character in the span (the cluster start). +fn expected_fill(t: &TextObject, text: &str, g: &PositionedGlyph) -> Paint { + let end = (g.byte + g.len).min(text.len()); + let span = text.get(g.byte..end).unwrap_or(""); + let letter_fill = style_fill_at(t, g.byte); + if g.advance.abs() < 1e-6 && span.chars().any(is_harakat) { + // Mark glyph: first harakat in the cluster span. + let off = span.char_indices().find(|(_, c)| is_harakat(*c)).map(|(i, _)| g.byte + i).unwrap_or(g.byte); + return style_fill_at(t, off); + } + letter_fill +} + +fn style_fill_at(t: &TextObject, byte: usize) -> Paint { + let mut off = 0; + for r in &t.runs { + let end = off + r.text.len(); + if byte < end || (byte == end && byte == off) { + return r.style.fill.clone(); + } + off = end; + } + t.runs.last().map(|r| r.style.fill.clone()).unwrap_or_else(|| Paint::solid(Color::BLACK)) +} + +#[test] +fn paint_only_differences_are_told_apart() { + let a = style(FAMILY); + let mut b = a.clone(); + b.fill = red(); + b.stroke = red(); + b.stroke_width = 2.0; + assert!(paint_only(&a, &b) && paint_only(&a, &a)); + let mut c = a.clone(); + c.size = 12.0; + assert!(!paint_only(&a, &c)); + let mut d = b.clone(); + d.baseline_shift = 3.0; + assert!(!paint_only(&a, &d), "a raised harakat is not paint only"); + assert!(paint_only_mark_boundary("بِ", "ب".len(), &a, &b)); + assert!(!paint_only_mark_boundary("بب", "ب".len(), &a, &b), "between two letters"); + assert!(!paint_only_mark_boundary("e\u{301}", 1, &a, &b), "a Latin accent keeps the existing rule"); + assert_eq!(harakat_ranges("بِسْمِ", 0.."بِسْمِ".len()), [2..4, 6..8, 10..12]); + assert_eq!(harakat_ranges("اللَّهِ", 0.."اللَّهِ".len()), [6..10, 12..14], "shadda and fatha together"); +} + +/// A range is normalised as `edit::style_range` takes one: clamped, each end moved back to the +/// start of its character, put in order. Never a silent empty result for a usable range. +#[test] +fn harakat_ranges_normalise_their_range() { + let word = "بِسْمِ"; // ب 0..2, kasra 2..4, س 4..6, sukun 6..8, م 8..10, kasra 10..12 + let all = harakat_ranges(word, 0..word.len()); + assert_eq!(harakat_ranges(word, word.len()..0), all, "reversed"); + assert_eq!(harakat_ranges(word, 0..usize::MAX), all, "past the end"); + assert!(harakat_ranges(word, usize::MAX..usize::MAX).is_empty(), "wholly past the end"); + assert_eq!(harakat_ranges(word, 1..word.len()), all, "a start inside ب moves back to it"); + assert_eq!(harakat_ranges(word, 0..7), [std::ops::Range { start: 2, end: 4 }], "an end inside the sukun moves back before it"); + assert_eq!(harakat_ranges(word, 3..9), [2..4, 6..8], "both ends inside characters"); + assert_eq!(harakat_ranges(word, std::ops::Range { start: 9, end: 3 }), [2..4, 6..8], "reversed, inside characters"); + assert!(harakat_ranges(word, 4..4).is_empty(), "empty"); +} + +/// The character-level pass names each glyph's own character only when it sets the same glyphs +/// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone +/// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character; +/// everything else stays at the cluster start (conservative). +#[test] +fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() { + // بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and + // initial beh (cluster 0). Bytes: ب0 ف2 ب4 ف6 ب8. Glyph id 20 is the fatha (GDEF mark). + const TEXT: &str = "بَبَب"; + let marks = |gid: u32| gid == 20; + let raw = [(10, 8, 500, 0, 0), (20, 4, 0, -30, 400), (11, 4, 300, 0, 0), (20, 0, 0, -40, 420), (12, 0, 450, 0, 0)]; + let by_char = |own: [u32; 5]| raw.iter().zip(own).map(|(g, c)| (g.0, c, g.2, 0, g.3, g.4)).collect::>(); + let ok = by_char([8, 6, 4, 2, 0]); + assert_eq!(own_chars(&raw, &ok, 10, TEXT, marks), [8, 6, 4, 2, 0]); + // The fathas' characters swapped (the same glyph ids): each is outside its glyph's cluster, + // so both keep their cluster's (their letter's) character, never the other fatha's. + assert_eq!(own_chars(&raw, &by_char([8, 2, 4, 6, 0]), 10, TEXT, marks), [8, 4, 4, 0, 0]); + // A character before its glyph's cluster, or past the segment's end: the cluster's own. + assert_eq!(own_chars(&raw, &by_char([8, 3, 4, 2, 0]), 10, TEXT, marks), [8, 4, 4, 2, 0]); + assert_eq!(own_chars(&raw, &by_char([9, 6, 4, 2, 0]), 9, TEXT, marks), [8, 6, 4, 2, 0]); + // A base letter named as its mark keeps the letter (not a GDEF mark). + assert_eq!(own_chars(&raw, &by_char([8, 6, 6, 2, 0]), 10, TEXT, marks), [8, 6, 4, 2, 0]); + // Without GDEF mark classification every later owner falls back to the cluster start. + assert_eq!(own_chars(&raw, &ok, 10, TEXT, |_| false), [8, 4, 4, 0, 0]); + // Wide mark (advance 500) beside a narrower base (300): mark keeps 2; base keeps 0. + // Review counterexample: advance must not decide ownership. + let wide = [(20, 0, 500, 0, 0), (11, 0, 300, 0, 0)]; + let wide_by = [(20, 2, 500, 0, 0, 0), (11, 2, 300, 0, 0, 0)]; + assert_eq!(own_chars(&wide, &wide_by, 4, "بَ", marks), [2, 0], "wide GDEF mark keeps harakat owner"); + // Equal advances: same rule. + let equal = [(20, 0, 300, 0, 0), (11, 0, 300, 0, 0)]; + let equal_by = [(20, 2, 300, 0, 0, 0), (11, 2, 300, 0, 0, 0)]; + assert_eq!(own_chars(&equal, &equal_by, 4, "بَ", marks), [2, 0], "equal advances"); + // Multi-component glyph (GDEF class 4, not mark) named as a later harakat stays at cluster start. + let multi = [(40, 0, 100, 0, 0), (11, 0, 300, 0, 0)]; + let multi_by = [(40, 2, 100, 0, 0, 0), (11, 0, 300, 0, 0, 0)]; + assert_eq!(own_chars(&multi, &multi_by, 4, "بَ", marks), [0, 0], "component is not a mark"); + // Single-glyph ligature (one output for letter+mark) named as the mark stays at cluster start: + // one glyph can represent a ligature containing a base, not a lone mark. + let liga = [(50, 0, 400, 0, 0)]; + let liga_by = [(50, 2, 400, 0, 0, 0)]; + assert_eq!(own_chars(&liga, &liga_by, 4, "بَ", |_| false), [0], "ligature without GDEF mark class"); + assert_eq!(own_chars(&liga, &liga_by, 4, "بَ", |gid| gid == 50), [2], "only if GDEF names it mark"); + // Sole advancing glyph without GDEF mark proof stays at the cluster start. + let advancing = [(20, 0, 100, 0, 0)]; + let advancing_by = [(20, 2, 100, 0, 0, 0)]; + assert_eq!(own_chars(&advancing, &advancing_by, 4, "بَ", |_| false), [0]); + assert_eq!(own_chars(&advancing, &advancing_by, 4, "بَ", marks), [2]); + // The same ids set otherwise (an advance or an offset changed, another glyph, one short): + // nothing is borrowed. + let mut moved = ok.clone(); + moved[1].5 += 1; + assert!(own_chars(&raw, &moved, 10, TEXT, marks).is_empty(), "a mark moved"); + let mut wider = ok.clone(); + wider[2].2 += 1; + assert!(own_chars(&raw, &wider, 10, TEXT, marks).is_empty(), "an advance changed"); + let mut other = ok.clone(); + other[0].0 = 13; + assert!(own_chars(&raw, &other, 10, TEXT, marks).is_empty(), "another glyph"); + assert!(own_chars(&raw, &ok[1..], 10, TEXT, marks).is_empty(), "a glyph short"); +} + +/// A word whose letters and marks repeat the same glyph ids (بَبَب): each fatha takes its own +/// colour, on its own letter, and every glyph is set as without colour. +#[test] +fn repeated_glyphs_take_their_own_harakat_colour() { + let Some(face) = arabic_face("repeated_glyphs_take_their_own_harakat_colour") else { + return; + }; + const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8 + let db = FontDb::global(); + let green = Paint::solid(Color::rgb8(30, 140, 70)); + let plain = text_with_harakat(WORD, &face.family, None); + let mut coloured = plain.clone(); + crate::edit::style_range(&mut coloured.runs, 2, 4, |st| st.fill = green.clone()); + crate::edit::style_range(&mut coloured.runs, 6, 8, |st| st.fill = red()); + let (a, b) = (layout(db, &plain), layout(db, &coloured)); + assert_eq!(geometry(&a), geometry(&b), "joining and mark positions unchanged"); + assert_authored_ownership(&coloured, WORD, &b, "repeated"); + let fill = |g: &PositionedGlyph| run_of(&coloured, g).style.fill.clone(); + let marks: Vec<&PositionedGlyph> = b.glyphs.iter().filter(|g| g.advance.abs() < 1e-6 && !g.outline.is_empty()).collect(); + assert_eq!(marks.len(), 2, "two fathas"); + assert_eq!(marks[0].gid, marks[1].gid, "the same glyph id twice"); + for m in &marks { + let want = if m.byte == 0 { green.clone() } else { red() }; + assert!(m.byte == 0 || m.byte == 4, "a fatha in its letter's cluster: {}", m.byte); + assert_eq!(fill(m), want, "the fatha of the letter at {} keeps its own colour", m.byte); + } + let letters = plain.runs[0].style.fill.clone(); + assert!(b.glyphs.iter().filter(|g| g.advance.abs() >= 1e-6).all(|g| fill(g) == letters), "letters keep theirs"); +} + +/// Coloured harakat: the letters keep their joining and the marks their attachment, glyph for +/// glyph and bit for bit; every mark glyph (and only the marks) is drawn in the harakat's run. +#[test] +fn harakat_coloured_apart_keep_every_glyph_and_take_their_own_colour() { + let Some(face) = arabic_face("harakat_coloured_apart_keep_every_glyph_and_take_their_own_colour") else { + return; + }; + let db = FontDb::global(); + let family = face.family.as_str(); + let plain = text_with_harakat(VOCALIZED, family, None); + let coloured = text_with_harakat(VOCALIZED, family, Some(red())); + assert!(coloured.runs.len() > 1); + let (a, b) = (layout(db, &plain), layout(db, &coloured)); + assert_eq!(geometry(&a), geometry(&b), "joining and mark positions unchanged"); + let marks = b.glyphs.iter().filter(|g| g.advance.abs() < 1e-6 && !g.outline.is_empty()).count(); + assert!(marks > 0, "vocalized text has mark glyphs"); + assert_eq!(painted(&coloured, &b, &red()), marks); + assert_authored_ownership(&coloured, VOCALIZED, &b, "coloured"); + let hidden = text_with_harakat(VOCALIZED, family, Some(Paint::None)); + let h = layout(db, &hidden); + assert_eq!(geometry(&a), geometry(&h)); + assert_eq!(painted(&hidden, &h, &Paint::None), marks); + assert_authored_ownership(&hidden, VOCALIZED, &h, "hidden"); + let mut sized = text_with_harakat(VOCALIZED, family, None); + let first = harakat_ranges(VOCALIZED, 0..VOCALIZED.len())[0].clone(); + crate::edit::style_range(&mut sized.runs, first.start, first.end, |st| st.size = 30.0); + assert_ne!(geometry(&layout(db, &sized)), geometry(&a)); +} + +/// Justified kashida lines with coloured or hidden harakat keep authored glyph ownership, and +/// inserted tatweels take the base letter's paint—not a trailing mark's. +#[test] +fn coloured_harakat_keep_kashida() { + let Some(face) = arabic_face("coloured_harakat_keep_kashida") else { + return; + }; + let db = FontDb::global(); + let family = face.family.as_str(); + let justified = |t: &mut TextObject, width: f64| { + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 2000.0).to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t.para.justify = Justify::JustifyAll; + t.para.kashida = Kashida::Long; + }; + const PARTLY: &str = "بِسْمِ اللَّهِ الرحمن الرحيم الحمد لله رب العالمين"; + let tat = face.glyph_for('\u{0640}'); + let mut tatweels = 0; + for width in [200.0, 260.0, 300.0, 420.0, 600.0] { + let mut plain = text_with_harakat(PARTLY, family, None); + let mut coloured = text_with_harakat(PARTLY, family, Some(red())); + let before = layout(db, &coloured); + assert_authored_ownership(&coloured, PARTLY, &before, &format!("before kashida {width}")); + justified(&mut plain, width); + justified(&mut coloured, width); + let (a, b) = (layout(db, &plain), layout(db, &coloured)); + tatweels += a.glyphs.iter().filter(|g| g.gid == tat && g.len == 0).count(); + assert_eq!(geometry(&a), geometry(&b), "{width}"); + assert_kashida_ownership(&coloured, &before, &b, &red(), &format!("coloured {width}"), false); + } + assert!(tatweels > 0, "the text takes kashida"); + let mut stretched = 0; + for width in [200.0, 260.0, 300.0, 420.0, 600.0] { + let mut plain = text_with_harakat(FULLY, family, None); + let mut coloured = text_with_harakat(FULLY, family, Some(red())); + let mut hidden = text_with_harakat(FULLY, family, Some(Paint::None)); + let before_c = layout(db, &coloured); + let before_h = layout(db, &hidden); + assert_authored_ownership(&coloured, FULLY, &before_c, &format!("full before {width}")); + justified(&mut plain, width); + justified(&mut coloured, width); + justified(&mut hidden, width); + let (a, b, h) = (layout(db, &plain), layout(db, &coloured), layout(db, &hidden)); + assert_eq!(geometry(&a), geometry(&b), "{width}"); + assert_eq!(geometry(&a), geometry(&h), "hidden {width}"); + stretched += stretched_words_with_harakat(FULLY, &b, tat); + assert_kashida_ownership(&coloured, &before_c, &b, &red(), &format!("full coloured {width}"), false); + assert_kashida_ownership(&hidden, &before_h, &h, &Paint::None, &format!("full hidden {width}"), false); + } + assert!(stretched > 0, "words with coloured harakat take kashida"); +} + +/// Harakat on every word. +const FULLY: &str = "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ الْحَمْدُ لِلَّهِ رَبِّ الْعَالَمِينَ الرَّحْمَنِ الرَّحِيمِ مَالِكِ يَوْمِ الدِّينِ"; + +/// The words of `text` with harakat that `l` stretched with a tatweel (glyph `tat`). +fn stretched_words_with_harakat(text: &str, l: &TextLayout, tat: u32) -> usize { + let mut words: Vec = l + .glyphs + .iter() + .filter(|g| g.gid == tat && g.len == 0) + .map(|g| text[..g.byte].rfind(' ').map_or(0, |i| i + 1)) + .filter(|&start| text[start..].split(' ').next().is_some_and(|w| w.chars().any(is_harakat))) + .collect(); + words.dedup(); + words.len() +} + +/// Authored glyphs paint as the independent expected fill from source runs; run indices must exist. +fn assert_authored_ownership(t: &TextObject, text: &str, l: &TextLayout, what: &str) { + for g in &l.glyphs { + if g.len == 0 { + continue; + } + let actual = &run_of(t, g).style.fill; + let want = expected_fill(t, text, g); + assert_eq!(actual, &want, "{what}: glyph {} at {} len {} adv {}", g.gid, g.byte, g.len, g.advance); + } +} + +/// Compare authored glyph run indices before/after Insert Kashidas (matched by byte and gid; +/// layout order can differ between point and justified area), and assert every inserted tatweel +/// uses the base letter's paint at the cluster start—not `mark_fill`. When `require_mark_last`, +/// at least one tatweel must follow a same-cluster glyph whose run is the mark paint (so the +/// emitter fix is exercised: reverting `emit_kashida`'s base-run assignment fails). +fn assert_kashida_ownership( + coloured: &TextObject, + before: &TextLayout, + with_kashida: &TextLayout, + mark_fill: &Paint, + what: &str, + require_mark_last: bool, +) -> usize { + let mut before_runs: std::collections::HashMap<(usize, u32), Vec> = std::collections::HashMap::new(); + for g in before.glyphs.iter().filter(|g| g.len > 0) { + before_runs.entry((g.byte, g.gid)).or_default().push(g.run); + } + let mut after_n = 0; + for g in with_kashida.glyphs.iter().filter(|g| g.len > 0) { + after_n += 1; + let runs = + before_runs.get_mut(&(g.byte, g.gid)).unwrap_or_else(|| panic!("{what}: authored glyph {} at {} missing before kashida", g.gid, g.byte)); + let want_run = runs.pop().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte)); + assert_eq!(g.run, want_run, "{what}: authored run at {} gid {}", g.byte, g.gid); + let want_fill = coloured.runs.get(want_run).map(|r| &r.style.fill).unwrap_or_else(|| panic!("{what}: missing run {want_run}")); + assert_eq!(&run_of(coloured, g).style.fill, want_fill, "{what}: authored fill at {}", g.byte); + } + assert_eq!(after_n, before.glyphs.iter().filter(|g| g.len > 0).count(), "{what}: authored glyph count"); + assert!(before_runs.values().all(|v| v.is_empty()), "{what}: unjustified authored glyphs left unmatched"); + let mut mark_last = 0; + for (i, g) in with_kashida.glyphs.iter().enumerate().filter(|(_, g)| g.len == 0) { + let letter_fill = style_fill_at(coloured, g.byte); + let fill = &run_of(coloured, g).style.fill; + assert_eq!(fill, &letter_fill, "{what}: tatweel at {} must use base-letter paint", g.byte); + assert_ne!(fill, mark_fill, "{what}: tatweel must not inherit mark paint at {}", g.byte); + // The glyph that absorbed the kashida (last of the cluster in visual order) is emitted + // immediately before the tatweel and shares its cluster byte. + if let Some(prev) = i.checked_sub(1).and_then(|j| with_kashida.glyphs.get(j)) + && prev.byte == g.byte + && prev.len > 0 + && &run_of(coloured, prev).style.fill == mark_fill + && letter_fill != *mark_fill + { + mark_last += 1; + } + } + if require_mark_last { + assert!(mark_last > 0, "{what}: need a mark-last insertion (last visual glyph has mark paint ≠ letter) to exercise emit_kashida"); + } + mark_last +} + +/// Installed OpenType Arabic faces (when present): coloured harakat change no glyph, position or +/// advance; letter gids from the bare text must not take harakat paint. Glyphs absent from that +/// bare set may take either the harakat colour or the letter colour (AAT/`morx` ligature fallback +/// keeps the letter)—this test does not prove every mark owns its character. Selected AAT faces +/// below record an explicit letter-fill allowance for non-letter gids; they may also paint no +/// glyph in the harakat colour when GDEF is unavailable. +#[test] +fn installed_fonts_keep_their_glyphs_with_coloured_harakat() { + const FONTS: &[&str] = &[ + "Geeza Pro", + "SF Arabic", + "Al Nile", + "Baghdad", + "Damascus", + "KufiStandardGK", + "DecoType Naskh", + "Diwan Thuluth", + "Farah", + "Noto Nastaliq Urdu", + "Arial", + "Times New Roman", + "Tahoma", + "Noto Naskh Arabic", + "Noto Kufi Arabic", + "Noto Sans Arabic", + "Amiri", + "Aref Ruqaa", + "IBM Plex Sans Arabic", + ]; + let db = FontDb::with_font_dirs(crate::system_font_dirs()); + let harakat = VOCALIZED.chars().filter(|c| is_harakat(*c)).count(); + let mut rows = vec![]; + for &family in FONTS { + if !db.has_family(family) { + continue; + } + let Some(face) = db.face(family, "Regular") else { + continue; + }; + let plain = text_with_harakat(VOCALIZED, &face.family, None); + let coloured = text_with_harakat(VOCALIZED, &face.family, Some(red())); + let bare: String = VOCALIZED.chars().filter(|c| !is_harakat(*c)).collect(); + let (a, b) = (layout(&db, &plain), layout(&db, &coloured)); + assert_eq!(geometry(&a), geometry(&b), "{family}: coloured harakat changed the glyphs"); + let letter_fill = style_fill_at(&coloured, 0); + let letter_gids: std::collections::HashSet = + layout(&db, &text_with_harakat(&bare, &face.family, None)).glyphs.iter().map(|g| g.gid).collect(); + // AAT faces where a ligature mark may keep the letter's colour (explicit allowance, not a + // per-glyph ownership proof). + let aat_letter_ok = matches!(family, "Geeza Pro" | "Al Nile" | "Baghdad" | "Damascus" | "KufiStandardGK" | "DecoType Naskh"); + for g in b.glyphs.iter().filter(|g| g.len > 0) { + let actual = &run_of(&coloured, g).style.fill; + if letter_gids.contains(&g.gid) { + // A glyph the bare letters also draw: must not take harakat paint. + assert_ne!(actual, &red(), "{family}: letter gid {} at {} took harakat paint", g.gid, g.byte); + } else if aat_letter_ok { + assert!( + actual == &red() || actual == &letter_fill, + "{family}: non-letter gid {} at {} may be harakat or letter paint (AAT), got {actual:?}", + g.gid, + g.byte + ); + } else { + // OpenType with GDEF: prefer harakat paint; letter paint still allowed when the + // character-level pass falls back (does not prove every mark owns its character). + assert!( + actual == &red() || actual == &letter_fill, + "{family}: non-letter at {} should be harakat or letter paint, got {actual:?}", + g.byte + ); + } + } + let red_glyphs = painted(&coloured, &b, &red()); + // Without GDEF (or when the character-level pass falls back), every glyph may keep the + // letter's paint—conservative and allowed. This is not every-mark ownership. + let all_letter = b.glyphs.iter().filter(|g| g.len > 0).all(|g| run_of(&coloured, g).style.fill == letter_fill); + assert!(red_glyphs > 0 || all_letter, "{family}: expected some harakat paint or a full letter-paint fallback (aat={aat_letter_ok})"); + rows.push(format!("{family}: {} glyphs identical; {red_glyphs} glyphs in the harakat colour ({harakat} harakat)", b.glyphs.len())); + } + if rows.is_empty() { + eprintln!("SKIPPED: installed_fonts_keep_their_glyphs_with_coloured_harakat; none of the candidate families are installed"); + } else { + eprintln!("{rows:#?}"); + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index c4246c711..11a135a6e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -127,9 +127,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..909b5b393 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -57,6 +57,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | PDF import: a DeviceN gradient of a spot ink over process plates keeps the ink and the plates, and PDF export writes it as a DeviceN shading again (#3235); scores unchanged | | 2026-11-05 | minor | Optical kerning moves each pair part of the way toward the font's own stem spacing, with a minimum gap and tighter limits, and leaves ideographs, kana, fullwidth forms and dashes on the font's spacing, so text is no longer squeezed (#5336). Scores and estimates unchanged. | | 2026-11-05 | minor | Gradient panel: Delete Stop sits at the right end of the ramp, so it shows whenever the ramp does instead of under the stop fields, where a short panel hid it (#5403); scores and estimates unchanged | +| 2026-11-07 | minor | Type: paint-only Arabic harakat keep joining forms; a matching character-level pass assigns a later in-cluster run only for GDEF mark glyphs on harakat characters (otherwise cluster start); Insert Kashidas tatweels take the base letter's paint (`tests::harakat`). Scores and estimates unchanged. | | 2026-11-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | Raster/PSD defaults honor document resolution; CLI run/convert accept --ppi, matching MCP options.ppi precedence. Synthetic regressions added; scores and hours unchanged. | @@ -186,7 +187,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; 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, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636) | +| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; 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, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | @@ -460,7 +461,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), 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 | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), 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 | -| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | +| Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types, Kashidas and paint-only harakat colour ownership (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | more built-in symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | @@ -1011,6 +1012,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. The Character panel menu's Kashidas switch (with the Indic options; `text.setFormat` / `text.setRangeStyle {kashidas}`) allows Kashidas on selected characters, on by default for new and old text. A pair of joined letters receives one only when both characters allow it; turning one off leaves other eligible pairs in the word available. Character styles and the native file keep the switch. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html) documents the menu control and the separate paragraph length choices; the default and pair rule are VectorCraft choices. + Paint-only Arabic harakat (marks whose character styles differ only in fill, stroke and related paint) stay inside one shaping cluster so letters keep their joining forms; when a second character-level shaping pass matches the monotone glyphs, a glyph may take a later in-cluster character's run only when GDEF classifies it as a mark (class 3) and that character is a harakat; otherwise it keeps the shaping cluster's first character. Insert Kashidas tatweels take the base letter's paint rather than a trailing mark's. Fonts without GDEF and some AAT/`morx` ligatures can leave a mark painted as its letter (`crates/text/src/tests/harakat.rs`). The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and list detection while typing are not built yet. The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet. The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1. diff --git a/docs/type-parity.md b/docs/type-parity.md index b36cb4892..40e5f810e 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-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; previously: Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273) · **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 @@ -57,7 +57,7 @@ against Illustrator's public documentation; **estimated** unless noted. | 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 and Indic: digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada, Malayalam) | done | `text.setFormat` / `text.setRangeStyle {digits}` (`"devanagari"`, `"bengali"`, `"gurmukhi"`, `"gujarati"`, `"odia"`, `"tamil"`, `"telugu"`, `"kannada"`, `"malayalam"` added, #4942); the text keeps the typed digits, the glyphs and SVG export follow the style; carried by character styles. Not yet: `.ai` export of the setting; Indic digits typed as such stay as typed under another system. Evidence: `text::tests::indic_digits_map_to_their_scripts_block`, `tests::bidi::indic_digits_draw_the_scripts_own_digits`, `typecmd::area_tests::indic_digits_round_trip_through_the_commands_and_the_file` | -| Middle Eastern: Kashidas | done | Paragraph Insert Kashidas sets the length for justified Arabic area type; Character-menu Kashidas allows or excludes selected characters, on by default. A kashida is inserted only between two allowed joined characters. The character switch is retained in character styles and the native file; the length is retained in paragraph styles and the native file. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html). | +| Middle Eastern: Kashidas | done | Paragraph Insert Kashidas sets the length for justified Arabic area type; Character-menu Kashidas allows or excludes selected characters, on by default. A kashida is inserted only between two allowed joined characters. The character switch is retained in character styles and the native file; the length is retained in paragraph styles and the native file. Paint-only harakat (coloured or hidden marks) stay in one shaping cluster so letters keep joining; when the character-level pass matches, a glyph may take a later in-cluster character's run only if GDEF names it a mark (class 3) and that character is a harakat—otherwise the cluster start (a mark may miss its colour; a base never takes a mark's). Inserted tatweels take the base letter's paint—not a trailing mark's. Fonts without GDEF and some AAT/`morx` ligatures (e.g. Geeza Pro Allah) can leave a mark painted as its letter. No Character-panel harakat colour command yet. Evidence: `tests::harakat`. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html). | | Middle Eastern: Middle Eastern composers, split caret | missing | Character Direction is done; editable bidirectional SVG text remains incomplete. | | 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 | @@ -87,6 +87,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. | | 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. | | 2026-11-04 | minor | Find Font's Replace With Font lists family and style rows, one click setting both, with a search over them; the list scrolls apart from the document's font list and refreshes after each replacement (#4972); scores and estimates unchanged. | +| 2026-11-07 | minor | Paint-only Arabic harakat stay in one shaping cluster; a matching character-level pass assigns a later in-cluster run only for GDEF mark glyphs on harakat characters (otherwise cluster start); Insert Kashidas tatweels take the base letter's paint (AAT/no-GDEF marks may keep the letter's colour). Evidence: `tests::harakat`. Scores and estimates unchanged. | | 2026-11-03 | minor | The OpenType panel lists the selected font's available Stylistic Sets with its custom names or numbered labels; sets can be combined and deselected. Scores and estimates unchanged. | | 2026-11-03 | minor | Character-menu Kashidas controls selected characters separately from the paragraph's Insert Kashidas length. Existing text allows Kashidas by default; scores and estimates unchanged. | | 2026-11-03 | minor | Type on a Path Options has a Preview checkbox: the type follows the fields while it is on, Cancel restores it, OK is one undo step (#5360, as public tutorials of the dialog show); scores and estimates unchanged. |