From d74f7a410804f15616c54a8d11412df6c615e862 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:13:29 +0200 Subject: [PATCH] Right-to-left drawing text: bidi and Arabic-script shaping (Persian, Arabic, Urdu) (#315) Squash of PR #315 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 + Cargo.toml | 4 + README.md | 20 ++ crates/fonts/Cargo.toml | 2 + crates/fonts/src/bidi.rs | 465 ++++++++++++++++++++++++++++++++++++ crates/fonts/src/complex.rs | 303 +++++++++++++++++++++++ crates/fonts/src/lib.rs | 86 +++++-- crates/fonts/src/mtext.rs | 136 ++++++++++- crates/fonts/src/ttf.rs | 129 +++++++++- docs/gaps.md | 2 +- docs/localization-parity.md | 13 +- 11 files changed, 1125 insertions(+), 43 deletions(-) create mode 100644 crates/fonts/src/bidi.rs create mode 100644 crates/fonts/src/complex.rs diff --git a/Cargo.lock b/Cargo.lock index dc91a81..e469ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -631,7 +631,9 @@ name = "cadcraft-fonts" version = "0.5.0" dependencies = [ "cadcraft-geom", + "harfrust", "skrifa 0.47.0", + "unicode-bidi", ] [[package]] @@ -3731,6 +3733,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 10b81ab..0703540 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ rayon = "1" # rendering / formats tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] } skrifa = "0.47" +# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses) +# and the Unicode Bidirectional Algorithm for right-to-left drawing text. +harfrust = "0.12" +unicode-bidi = "0.3.18" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # ui diff --git a/README.md b/README.md index c37a800..ba34e02 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,26 @@ run) an environment variable, steer this: `.` clears either one. +**Your own fonts.** Any `.ttf`, `.otf` or `.ttc` font works: install it in the system font folder +(Windows `C:\Windows\Fonts` or per-user fonts, macOS `/Library/Fonts` or `~/Library/Fonts`, +Linux and the BSDs `/usr/share/fonts`, `/usr/local/share/fonts`, `~/.local/share/fonts` or +`~/.fonts`) and name it in a text style (`STYLE`, or `style {"name":"FA","font":"Vazirmatn"}`) +by file name (`Vazirmatn-Regular.ttf`) or family name (`Vazirmatn`), or inline in MTEXT with +`\fVazirmatn;`. Restart CADCraft after installing a font: the font folders are scanned once. + +**Right-to-left text (Persian, Arabic, Urdu, Hebrew).** TEXT and MTEXT lines are laid out with the +Unicode Bidirectional Algorithm (the paragraph direction comes from its first letter, so numbers +and Latin words inside Persian text keep their own order), and Arabic-script text is shaped with +OpenType (contextual letter forms, lam-alef and other ligatures, ZWNJ / ZWJ, mark positioning). +Justification (left, centre, right, MTEXT attachment and wrapping) still refers to the text's box. +Our stroke font has no Arabic letters, so Persian in a stroke-font style is drawn from an +installed font that has them: Vazirmatn, Noto Naskh Arabic, Noto Sans Arabic, Segoe UI, Tahoma, +Arial and DejaVu Sans are looked for first. To choose the font, install a free Persian font such +as [Vazirmatn](https://github.com/rastikerdar/vazirmatn) (SIL Open Font License) and either use it +in the text style or set `FONTFALLBACK` (`CADCRAFT_FONTFALLBACK="Vazirmatn"`). Formatting changes +inside a word (an MTEXT colour change in the middle of a Persian word) break the joining at that +point. + ### Logs The desktop app writes its `log` records to standard error and to `logs/cadcraft.log` in the diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index e088012..d2bdddf 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -11,3 +11,5 @@ workspace = true [dependencies] cadcraft-geom = { workspace = true } skrifa = { workspace = true } +harfrust = { workspace = true } +unicode-bidi = { workspace = true } diff --git a/crates/fonts/src/bidi.rs b/crates/fonts/src/bidi.rs new file mode 100644 index 0000000..1ca5a90 --- /dev/null +++ b/crates/fonts/src/bidi.rs @@ -0,0 +1,465 @@ +//! Right-to-left text at the code-point level, independent of any font: +//! - the Unicode Bidirectional Algorithm (`unicode-bidi`) for one line: visual runs, per-character +//! embedding levels, mirrored brackets; +//! - Arabic-script joining (Arabic, Persian, Urdu): each letter's contextual form (isolated, +//! initial, medial, final), with ZWJ / tatweel joining and ZWNJ breaking, and the lam-alef +//! ligatures, mapped to Unicode presentation forms. Fonts with OpenType Arabic features are +//! shaped by `harfrust` instead (the `complex` module); the presentation forms serve fonts +//! without them (older fonts that only map the presentation-form code points). + +use std::ops::Range; + +use unicode_bidi::{BidiClass, Level, ParagraphBidiInfo, bidi_class}; + +/// Whether `c` is a strong right-to-left character or an explicit right-to-left control. +pub(crate) fn is_rtl(c: char) -> bool { + matches!(bidi_class(c), BidiClass::R | BidiClass::AL | BidiClass::RLE | BidiClass::RLO | BidiClass::RLI) +} + +/// Whether a line needs the right-to-left path (bidi reordering and shaping). +pub fn has_rtl(s: &str) -> bool { + s.chars().any(is_rtl) +} + +/// Invisible formatting characters: joiners, directional marks, embeddings and isolates. They +/// take no space and draw nothing (a shaper still sees the joiners). +pub(crate) fn is_format_control(c: char) -> bool { + matches!(c, '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2069}' | '\u{FEFF}' | '\u{061C}') +} + +/// A combining mark (drawn on the preceding character). +pub(crate) fn is_mark(c: char) -> bool { + bidi_class(c) == BidiClass::NSM +} + +/// Base direction of a paragraph from its first strong character (rules P2/P3): `Some(true)` +/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character. +pub fn base_rtl(s: &str) -> Option { + s.chars().find_map(|c| match bidi_class(c) { + BidiClass::L => Some(false), + BidiClass::R | BidiClass::AL => Some(true), + _ => None, + }) +} + +fn para_level(base: Option) -> Option { + base.map(|rtl| if rtl { Level::rtl() } else { Level::ltr() }) +} + +/// Map character indices of `text` to byte offsets (one extra entry for the end). +fn byte_starts(text: &str) -> Vec { + let mut v: Vec = text.char_indices().map(|(b, _)| b).collect(); + v.push(text.len()); + v +} + +/// The visual runs of one line of `chars`: character-index ranges in left-to-right display order, +/// each flagged right-to-left or not. The paragraph direction comes from the first strong +/// character (or `base` when given). Characters inside a right-to-left run are still in logical +/// order: the caller lays them out from right to left. +pub fn visual_runs(chars: &[char], base: Option) -> Vec<(Range, bool)> { + if chars.is_empty() { + return Vec::new(); + } + let text: String = chars.iter().collect(); + let starts = byte_starts(&text); + let char_at = |byte: usize| starts.partition_point(|b| *b < byte); + let info = ParagraphBidiInfo::new(&text, para_level(base)); + let (levels, runs) = info.visual_runs(0..text.len()); + runs.into_iter() + .filter(|r| !r.is_empty()) + .map(|r| { + let rtl = levels.get(r.start).is_some_and(|l| l.is_rtl()); + (char_at(r.start)..char_at(r.end), rtl) + }) + .collect() +} + +/// Resolved embedding level of every character of one line (after rule L1), for reordering +/// pieces of the line with [`reorder`]. +pub fn char_levels(text: &str, base: Option) -> Vec { + if text.is_empty() { + return Vec::new(); + } + let info = ParagraphBidiInfo::new(text, para_level(base)); + info.reordered_levels_per_char(0..text.len()).into_iter().map(|l| l.number()).collect() +} + +/// Display order (rule L2) of items with the given embedding levels: indices into `levels`, +/// left to right. +pub fn reorder(levels: &[u8]) -> Vec { + let lv: Vec = levels.iter().map(|l| Level::new(*l).unwrap_or_else(|_| Level::ltr())).collect(); + ParagraphBidiInfo::reorder_visual(&lv) +} + +/// The mirrored glyph of a paired bracket in right-to-left text (rule L4, common pairs). +pub fn mirror(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + '‹' => '›', + '›' => '‹', + '≤' => '≥', + '≥' => '≤', + _ => c, + } +} + +/// A line in display order, code point by code point: right-to-left runs reversed and their +/// brackets mirrored. Shows what the layout draws, before glyph shaping. +pub fn visual_order(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len()); + for (r, rtl) in visual_runs(&chars, None) { + let run = chars.get(r).unwrap_or_default(); + if rtl { + out.extend(run.iter().rev().map(|c| mirror(*c))); + } else { + out.extend(run.iter()); + } + } + out +} + +/// Arabic joining type (Unicode `ArabicShaping.txt` classes, the letters in common use). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Jt { + /// Non-joining (also ZWNJ, spaces, Latin). + U, + /// Right-joining: joins only the preceding letter (alef, dal, reh, waw …). + R, + /// Dual-joining. + D, + /// Join-causing (ZWJ, tatweel). + C, + /// Transparent (combining marks). + T, +} + +fn joining_type(c: char) -> Jt { + let u = u32::from(c); + match u { + 0x0640 | 0x200D => Jt::C, + 0x0622..=0x0625 + | 0x0627 + | 0x0629 + | 0x062F..=0x0632 + | 0x0648 + | 0x0671..=0x0673 + | 0x0675..=0x0677 + | 0x0688..=0x0699 + | 0x06C0 + | 0x06C3..=0x06CB + | 0x06CD + | 0x06CF + | 0x06D2 + | 0x06D3 + | 0x06D5 + | 0x06EE + | 0x06EF + | 0x0759..=0x075B + | 0x076B + | 0x076C + | 0x0771 + | 0x0773 + | 0x0774 + | 0x0778 + | 0x0779 => Jt::R, + 0x0620 + | 0x0626 + | 0x0628 + | 0x062A..=0x062E + | 0x0633..=0x063F + | 0x0641..=0x0647 + | 0x0649 + | 0x064A + | 0x066E + | 0x066F + | 0x0678..=0x0687 + | 0x069A..=0x06BF + | 0x06C1 + | 0x06C2 + | 0x06CC + | 0x06CE + | 0x06D0 + | 0x06D1 + | 0x06FA..=0x06FC + | 0x06FF + | 0x0750..=0x0758 + | 0x075C..=0x076A + | 0x076D..=0x0770 + | 0x0772 + | 0x0775..=0x0777 + | 0x077A..=0x077F => Jt::D, + _ if is_mark(c) => Jt::T, + _ => Jt::U, + } +} + +/// Contextual form of a joining letter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Form { + Isolated, + Final, + Initial, + Medial, +} + +/// The contextual form of every character of a logical-order run (`None` for characters that +/// don't join: spaces, digits, Latin, marks, ZWNJ). +pub fn joining_forms(chars: &[char]) -> Vec> { + let jt: Vec = chars.iter().map(|c| joining_type(*c)).collect(); + // Neighbour joining types, skipping transparent marks. + let prev = |i: usize| jt.get(..i).and_then(|s| s.iter().rev().find(|t| **t != Jt::T)).copied().unwrap_or(Jt::U); + let next = |i: usize| jt.get(i + 1..).and_then(|s| s.iter().find(|t| **t != Jt::T)).copied().unwrap_or(Jt::U); + jt.iter() + .enumerate() + .map(|(i, t)| { + if !matches!(t, Jt::R | Jt::D) { + return None; + } + let joins_prev = matches!(prev(i), Jt::D | Jt::C); + let joins_next = *t == Jt::D && matches!(next(i), Jt::R | Jt::D | Jt::C); + Some(match (joins_prev, joins_next) { + (true, true) => Form::Medial, + (true, false) => Form::Final, + (false, true) => Form::Initial, + (false, false) => Form::Isolated, + }) + }) + .collect() +} + +/// Presentation forms of the basic Arabic letters (block FE70–FEFF, which starts at U+FE80): +/// each letter takes 1 (hamza), 2 (isolated, final) or 4 (isolated, final, initial, medial) +/// consecutive code points, in this order. +const FORMS_B: &[(u32, u32)] = &[ + (0x0621, 1), + (0x0622, 2), + (0x0623, 2), + (0x0624, 2), + (0x0625, 2), + (0x0626, 4), + (0x0627, 2), + (0x0628, 4), + (0x0629, 2), + (0x062A, 4), + (0x062B, 4), + (0x062C, 4), + (0x062D, 4), + (0x062E, 4), + (0x062F, 2), + (0x0630, 2), + (0x0631, 2), + (0x0632, 2), + (0x0633, 4), + (0x0634, 4), + (0x0635, 4), + (0x0636, 4), + (0x0637, 4), + (0x0638, 4), + (0x0639, 4), + (0x063A, 4), + (0x0641, 4), + (0x0642, 4), + (0x0643, 4), + (0x0644, 4), + (0x0645, 4), + (0x0646, 4), + (0x0647, 4), + (0x0648, 2), + (0x0649, 2), + (0x064A, 4), +]; + +/// Presentation forms of Persian and Urdu letters (block FB50–FDFF): letter, first form, count. +const FORMS_A: &[(u32, u32, u32)] = &[ + (0x0671, 0xFB50, 2), // alef wasla + (0x067E, 0xFB56, 4), // peh پ + (0x0679, 0xFB66, 4), // tteh + (0x0686, 0xFB7A, 4), // tcheh چ + (0x0688, 0xFB88, 2), // ddal + (0x0698, 0xFB8A, 2), // jeh ژ + (0x0691, 0xFB8C, 2), // rreh + (0x06A9, 0xFB8E, 4), // keheh ک + (0x06AF, 0xFB92, 4), // gaf گ + (0x06BA, 0xFB9E, 2), // noon ghunna + (0x06C0, 0xFBA4, 2), // heh with yeh above + (0x06C1, 0xFBA6, 4), // heh goal + (0x06BE, 0xFBAA, 4), // heh doachashmee + (0x06D2, 0xFBAE, 2), // yeh barree + (0x06CC, 0xFBFC, 4), // farsi yeh ی +]; + +/// The presentation-form code point of `c` in `form`, when Unicode has one. +pub fn presentation_form(c: char, form: Form) -> Option { + let u = u32::from(c); + let slot = match form { + Form::Isolated => 0, + Form::Final => 1, + Form::Initial => 2, + Form::Medial => 3, + }; + let mut at = 0xFE80; + let mut found = None; + for (base, n) in FORMS_B { + if *base == u { + found = Some((at, *n)); + break; + } + at += n; + } + let (first, n) = found.or_else(|| FORMS_A.iter().find(|(b, _, _)| *b == u).map(|(_, f, n)| (*f, *n)))?; + // A letter with only isolated and final forms keeps them in initial / medial position. + let slot = if slot >= n { slot % 2 } else { slot }; + if slot >= n { + return None; + } + char::from_u32(first + slot) +} + +/// Lam-alef ligature for lam followed by an alef, isolated or final. +fn lam_alef(alef: char, final_form: bool) -> Option { + let first = match alef { + '\u{0622}' => 0xFEF5, + '\u{0623}' => 0xFEF7, + '\u{0625}' => 0xFEF9, + '\u{0627}' => 0xFEFB, + _ => return None, + }; + char::from_u32(first + u32::from(final_form)) +} + +/// Arabic joining with Unicode presentation forms, for a logical-order run: each joining letter +/// becomes its contextual form and lam + alef becomes one ligature. Returns the output code +/// points with the index of the source character each comes from (a ligature takes the lam's). +/// Joiners stay in place (a shaper hides them); `keep` decides whether a form may be used (a font +/// lacking it keeps the base letter). +pub fn presentation_forms(chars: &[char], keep: impl Fn(char) -> bool) -> Vec<(char, usize)> { + let forms = joining_forms(chars); + let mut out = Vec::with_capacity(chars.len()); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let form = forms.get(i).copied().flatten(); + if c == '\u{0644}' + && let Some(f) = form + && let Some(&a) = chars.get(i + 1) + && let Some(lig) = lam_alef(a, matches!(f, Form::Medial | Form::Final)) + && keep(lig) + { + out.push((lig, i)); + i += 2; + continue; + } + let shaped = form.and_then(|f| presentation_form(c, f)).filter(|p| keep(*p)).unwrap_or(c); + out.push((shaped, i)); + i += 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn forms(s: &str) -> String { + let chars: Vec = s.chars().collect(); + presentation_forms(&chars, |_| true).into_iter().map(|(c, _)| c).collect() + } + + #[test] + fn salam_takes_contextual_forms_and_the_lam_alef_ligature() { + // س initial, ل + ا ligature (final, since lam joins seen), م isolated (alef doesn't join + // forward). + let chars: Vec = "سلام".chars().collect(); + assert_eq!(joining_forms(&chars), vec![Some(Form::Initial), Some(Form::Medial), Some(Form::Final), Some(Form::Isolated)]); + assert_eq!(forms("سلام"), "\u{FEB3}\u{FEFC}\u{FEE1}"); + let src: Vec = presentation_forms(&chars, |_| true).into_iter().map(|(_, i)| i).collect(); + assert_eq!(src, vec![0, 1, 3]); + // A word-initial lam-alef is the isolated ligature. + assert_eq!(forms("لا"), "\u{FEFB}"); + } + + #[test] + fn persian_letters_join() { + // پ چ ژ گ ی ک: initial, medial, final and isolated Persian forms. + assert_eq!(forms("پی"), "\u{FB58}\u{FBFD}"); + assert_eq!(forms("چک"), "\u{FB7C}\u{FB8F}"); + assert_eq!(forms("گژ"), "\u{FB94}\u{FB8B}"); + assert_eq!(forms("کیک"), "\u{FB90}\u{FBFF}\u{FB8F}"); + // Jeh is right-joining: it never joins the next letter. + assert_eq!(forms("ژی"), "\u{FB8A}\u{FBFC}"); + } + + #[test] + fn zwnj_breaks_and_zwj_forces_joining() { + // می‌روم ("I go"): ZWNJ after meem keeps it from joining yeh. + let chars: Vec = "می\u{200C}روم".chars().collect(); + let f = joining_forms(&chars); + assert_eq!(f.get(1), Some(&Some(Form::Final)), "yeh before ZWNJ is final"); + assert_eq!(f.get(2), Some(&None), "ZWNJ itself does not join"); + assert_eq!(f.get(3), Some(&Some(Form::Isolated)), "reh after ZWNJ starts afresh"); + // ZWJ makes an isolated letter join forward. + let z: Vec = "ب\u{200D}".chars().collect(); + assert_eq!(joining_forms(&z).first(), Some(&Some(Form::Initial))); + // Marks are transparent: بَب joins across the fatha. + let m: Vec = "ب\u{064E}ب".chars().collect(); + assert_eq!(joining_forms(&m), vec![Some(Form::Initial), None, Some(Form::Final)]); + } + + #[test] + fn mixed_line_visual_order() { + // "Price 25 toman": a right-to-left paragraph; the number stays left-to-right. + let v = visual_order("قیمت 25 تومان"); + assert_eq!(v, "ناموت 25 تمیق"); + let chars: Vec = "قیمت 25 تومان".chars().collect(); + let runs = visual_runs(&chars, None); + assert_eq!(runs, vec![(7..13, true), (5..7, false), (0..5, true)]); + // Latin inside Farsi, and Farsi inside a Latin paragraph. + assert_eq!(visual_order("فایل CAD باز"), "زاب CAD لیاف"); + assert_eq!(visual_order("open فایل now"), "open لیاف now"); + // Brackets mirror in right-to-left runs. + assert_eq!(visual_order("(سلام)"), "(مالس)"); + } + + #[test] + fn levels_and_reorder() { + assert!(has_rtl("abc سلام") && !has_rtl("abc 123")); + assert_eq!(base_rtl(" 123 سلام abc"), Some(true)); + assert_eq!(base_rtl("123"), None); + let lv = char_levels("ab سل", None); + assert_eq!(lv, vec![0, 0, 0, 1, 1]); + assert_eq!(reorder(&[1, 1, 2, 1]), vec![3, 2, 1, 0]); + assert_eq!(reorder(&[0, 1, 1, 0]), vec![0, 2, 1, 3]); + assert!(visual_runs(&[], None).is_empty() && char_levels("", None).is_empty()); + } + + #[test] + fn hostile_input_never_panics() { + for s in [ + "a\nسلام\u{2029}b\r", + "\u{202E}\u{202E}\u{202E}abc", + "\u{2067}\u{2069}\u{2069}", + "\u{0644}", + "\u{0644}\u{200D}", + "\u{064E}\u{064E}", + "\u{FEFF}", + ] { + let _ = visual_order(s); + let chars: Vec = s.chars().collect(); + let _ = presentation_forms(&chars, |_| true); + let _ = char_levels(s, Some(true)); + } + let long: String = "سلام abc 123 ".repeat(2000); + assert_eq!(visual_order(&long).chars().count(), long.chars().count()); + assert_eq!(reorder(&[200, 0, 255]).len(), 3); + } +} diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs new file mode 100644 index 0000000..b479590 --- /dev/null +++ b/crates/fonts/src/complex.rs @@ -0,0 +1,303 @@ +//! Lines with right-to-left text: the line is split into bidi runs in display order +//! ([`crate::bidi::visual_runs`]), each run into pieces by the font that draws them (the text's +//! font, a fallback font for characters it lacks, or the stroke font), and each font piece is +//! shaped with `harfrust` (OpenType GSUB/GPOS: Arabic contextual forms, lam-alef and other +//! ligatures, mark positioning, mirroring) before its glyphs are placed. Shaping happens per +//! piece in the font that draws it, so Arabic in a stroke-font style is joined by the fallback +//! font. Fonts without OpenType Arabic features get Unicode presentation forms instead. + +use std::ops::Range; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use cadcraft_geom::Vec2; + +use crate::ttf::{self, Face}; +use crate::{Shaped, bidi, stroke}; + +/// Longest line (characters) laid out this way; longer lines keep one glyph per character. +const MAX_CHARS: usize = 10_000; + +/// What draws a piece of a run. +#[derive(Clone)] +enum Src { + /// The built-in stroke font. + Stroke, + /// No font has the character: a missing-glyph box. + Missing, + /// An invisible formatting character outside any shaped piece. + Hidden, + /// A TrueType/OpenType face (the text's font or a fallback). + Face(Face), +} + +impl Src { + fn same(&self, o: &Src) -> bool { + match (self, o) { + (Src::Stroke, Src::Stroke) | (Src::Missing, Src::Missing) | (Src::Hidden, Src::Hidden) => true, + (Src::Face(a), Src::Face(b)) => a.same(b), + _ => false, + } + } + fn face(&self) -> Option<&Face> { + match self { + Src::Face(f) => Some(f), + _ => None, + } + } +} + +/// Shape one line containing right-to-left text in `primary` (None: the stroke font), with the +/// same conventions as the left-to-right paths: baseline at y = 0, x from 0, text height = cap +/// height, `%%` codes decoded. `None` when the line is too long (the caller then lays it out one +/// glyph per character) or shaping failed. +pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { + let chars = crate::decode_controls(s); + if chars.is_empty() || chars.len() > MAX_CHARS { + return None; + } + // A last-resort guard around the bidi and shaping libraries: a panic in them must not take + // the drawing down with it (the line then falls back to one glyph per character). + catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok() +} + +fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped { + let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; + let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; + let shear = oblique.tan().clamp(-10.0, 10.0); + let sc = h / stroke::CAP; + let cs: Vec = chars.iter().map(|c| c.0).collect(); + let flags = |i: usize| chars.get(i).map(|c| (c.1, c.2)).unwrap_or((false, false)); + let mut out = Shaped::default(); + let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new(); + let mut x = 0.0; + // Whether the rightmost glyph so far is a stroke-font glyph. + let mut stroke_last = false; + for (run, rtl) in bidi::visual_runs(&cs, None) { + let mut pieces = itemize(&cs, run, primary); + if rtl { + pieces.reverse(); + } + for (src, r) in pieces { + match &src { + Src::Hidden => {} + Src::Stroke | Src::Missing => { + let idx: Vec = if rtl { r.rev().collect() } else { r.collect() }; + for i in idx { + let c = cs.get(i).copied().unwrap_or(' '); + let c = if rtl { bidi::mirror(c) } else { c }; + match (&src, stroke::glyph(c)) { + (Src::Stroke, Some((_, spec))) => crate::draw_stroke_glyph(&mut out, spec, x, sc, wf, shear), + (Src::Missing, _) => crate::missing_glyph_box(&mut out, x, h, sc * wf), + _ => {} + } + let adv = crate::char_advance(c) * h * wf; + let (u, o) = flags(i); + spans.push((x, x + adv, u, o)); + x += adv; + stroke_last = true; + } + } + Src::Face(face) => { + for g in shape_run(face, &cs, r, rtl) { + let outline = ttf::glyph_by_id(face, g.gid); + let k = h / outline.cap * wf; + let dy = g.dy * h / outline.cap; + let mut contours = ttf::place_glyph(&outline, x + g.dx * k, h, wf, shear); + for p in contours.iter_mut().flatten() { + *p += Vec2::new(dy * shear, dy); + } + if !contours.is_empty() { + out.glyphs.push(contours); + } + let adv = g.advance * k; + let (u, o) = flags(g.cluster); + spans.push((x, x + adv, u, o)); + x += adv; + stroke_last = false; + } + } + } + } + } + out.strokes.extend(crate::decorations(&spans, h)); + // A stroke-font glyph at the right end: the width leaves out its trailing gap, like + // `shape_stroke`. + out.width = if stroke_last { (x - stroke::GAP * sc * wf).max(0.0) } else { x }; + out +} + +/// Split a logical-order run into pieces by what draws each character. Joiners and combining +/// marks stay in the piece before them (the shaper needs them in context); other characters +/// prefer the previous fallback face when it has them, so one word stays in one font. +fn itemize(cs: &[char], run: Range, primary: Option<&Face>) -> Vec<(Src, Range)> { + let mut pieces: Vec<(Src, Range)> = Vec::new(); + for i in run { + let c = cs.get(i).copied().unwrap_or(' '); + let prev = pieces.last().and_then(|(s, _)| s.face()).cloned(); + let src = match prev { + Some(f) if bidi::is_format_control(c) || bidi::is_mark(c) => Src::Face(f), + None if bidi::is_format_control(c) => Src::Hidden, + prev => resolve(c, primary, prev.as_ref()), + }; + match pieces.last_mut() { + Some((s, r)) if s.same(&src) => r.end = i + 1, + _ => pieces.push((src, i..i + 1)), + } + } + pieces +} + +/// What draws `c`: the text's face when it has the character, else the previous fallback face, +/// else the first fallback font that has it (FONTFALLBACK, FONTALT, installed fonts). +fn resolve(c: char, primary: Option<&Face>, prev: Option<&Face>) -> Src { + if let Some(p) = primary + && (c.is_whitespace() || p.has_glyph(c)) + { + return Src::Face(p.clone()); + } + if primary.is_none() && (c.is_whitespace() || stroke::glyph(c).is_some()) { + return Src::Stroke; + } + if let Some(f) = prev.filter(|f| f.has_glyph(c)) { + return Src::Face(f.clone()); + } + match (ttf::fallback_for(c), primary) { + (Some(f), _) => Src::Face(f), + // The text's font draws its own missing-glyph shape. + (None, Some(p)) => Src::Face(p.clone()), + (None, None) => Src::Missing, + } +} + +/// A shaped glyph: id, source character index, advance and offset in font units at 1000 per em. +struct Glyph { + gid: u32, + cluster: usize, + advance: f64, + dx: f64, + dy: f64, +} + +/// Shape characters `r` of `cs` (one direction, one face) into glyphs in display order. +fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec { + let run = cs.get(r.clone()).unwrap_or_default(); + let Ok(font) = harfrust::FontRef::from_index(&face.bytes, face.index) else { return Vec::new() }; + let data = harfrust::ShaperData::new(&font); + let shaper = data.shaper(&font).build(); + let mut buf = harfrust::UnicodeBuffer::new(); + let arabic = run.iter().any(|c| matches!(*c, '\u{0600}'..='\u{08FF}')); + if arabic && !face.has_joining_features() { + for (c, i) in bidi::presentation_forms(run, |p| face.has_glyph(p)) { + buf.add(c, u32::try_from(r.start + i).unwrap_or(u32::MAX)); + } + } else { + for (i, c) in run.iter().enumerate() { + buf.add(*c, u32::try_from(r.start + i).unwrap_or(u32::MAX)); + } + } + buf.set_direction(if rtl { harfrust::Direction::RightToLeft } else { harfrust::Direction::LeftToRight }); + buf.guess_segment_properties(); + let shaped = shaper.shape(buf, harfrust::ShapeOptions::new()); + let k = 1000.0 / f64::from(shaper.units_per_em().max(1)); + shaped + .glyph_infos() + .iter() + .zip(shaped.glyph_positions()) + .map(|(gi, gp)| Glyph { + gid: gi.glyph_id, + cluster: usize::try_from(gi.cluster).unwrap_or(usize::MAX), + advance: f64::from(gp.x_advance) * k, + dx: f64::from(gp.x_offset) * k, + dy: f64::from(gp.y_offset) * k, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stroke_font_rtl_lines_keep_latin_and_digits_in_the_stroke_font() { + // With or without installed fonts: the Persian words are fallback glyphs or + // missing-glyph boxes, the digits stay stroke glyphs, in the middle of the line (between + // the two words), and the measured width matches the shaped one. + let s = "قیمت 25 تومان"; + let sh = shape(None, s, 1.0, 1.0, 0.0).unwrap_or_default(); + assert!(sh.width > 0.0); + assert!((crate::line_width(s, 1.0, 1.0) - sh.width).abs() < 1e-9); + let digits: Vec = sh.strokes.iter().filter(|st| !(st.len() == 5 && st.first() == st.last())).flatten().map(|p| p.x).collect(); + assert!(!digits.is_empty()); + let (lo, hi) = (digits.iter().cloned().fold(f64::MAX, f64::min), digits.iter().cloned().fold(f64::MIN, f64::max)); + assert!(lo > 0.2 * sh.width && hi < 0.8 * sh.width, "digits between the words: {lo}..{hi} of {}", sh.width); + } + + #[test] + fn rtl_order_of_stroke_glyphs() { + // Hebrew letters are R; in an RTL line the Latin word keeps its order and sits where the + // bidi algorithm puts it: "abc" first visually when it ends the logical line. + let rtl = crate::shape_stroke("\u{05D0} abc", 1.0, 1.0, 0.0); + let ltr = crate::shape_stroke("abc", 1.0, 1.0, 0.0); + let x0 = |s: &Shaped| s.strokes.iter().flatten().map(|p| p.x).fold(f64::MAX, f64::min); + // "abc" is laid out at the left edge in both. + assert!((x0(&rtl) - x0(<r)).abs() < 1e-9); + assert!(rtl.width > ltr.width); + } + + #[test] + fn invisible_controls_take_no_space() { + let a = crate::line_width("AB", 1.0, 1.0); + assert!((crate::line_width("A\u{200C}B", 1.0, 1.0) - a).abs() < 1e-9); + assert!((crate::line_width("A\u{200F}B", 1.0, 1.0) - a).abs() < 1e-9); + let sh = crate::shape_stroke("A\u{200F}B", 1.0, 1.0, 0.0); + assert!(!sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last()), "no boxes for marks"); + } + + /// An installed font that shapes Arabic (Persian), or `None` (the test then skips). + fn arabic_face() -> Option { + ttf::fallback_for('\u{0633}').filter(|f| f.has_glyph('\u{06CC}') && f.has_glyph('\u{067E}')) + } + + fn nominal(f: &Face, c: char) -> Option { + use skrifa::MetadataProvider; + f.font().and_then(|font| font.charmap().map(c)).map(|g| g.to_u32()) + } + + #[test] + fn persian_is_shaped_by_an_installed_font_when_available() { + let Some(face) = arabic_face() else { return }; + let cs: Vec = "سلام".chars().collect(); + let gs = shape_run(&face, &cs, 0..cs.len(), true); + // Display order is right to left: clusters come out descending. + let clusters: Vec = gs.iter().map(|g| g.cluster).collect(); + assert!(clusters.windows(2).all(|w| w[0] >= w[1]), "{clusters:?}"); + // Seen is word-initial: not its isolated (nominal) glyph. + let seen = gs.iter().find(|g| g.cluster == 0).map(|g| g.gid); + assert!(seen.is_some() && seen != nominal(&face, '\u{0633}'), "initial form"); + // ZWNJ: in "می‌روم" yeh is final, in "میروم" medial. + let with: Vec = "می\u{200C}روم".chars().collect(); + let without: Vec = "میروم".chars().collect(); + let yeh = |cs: &[char]| shape_run(&face, cs, 0..cs.len(), true).into_iter().find(|g| g.cluster == 1).map(|g| g.gid); + assert!(yeh(&with).is_some() && yeh(&with) != yeh(&without), "ZWNJ breaks the join"); + // In a stroke-font style the line draws glyph outlines from the fallback font, no boxes. + let sh = crate::shape_stroke("قیمت 25 تومان", 1.0, 1.0, 0.0); + assert!(sh.glyphs.len() >= 6, "{}", sh.glyphs.len()); + assert!(!sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last()), "no missing-glyph boxes"); + // The ASCII digits are stroke glyphs in the middle of the line. + let stroke_x: Vec = sh.strokes.iter().flatten().map(|p| p.x).collect(); + let (lo, hi) = (stroke_x.iter().cloned().fold(f64::MAX, f64::min), stroke_x.iter().cloned().fold(f64::MIN, f64::max)); + assert!(lo > 0.2 * sh.width && hi < 0.8 * sh.width, "digits between the words: {lo}..{hi} of {}", sh.width); + assert!((crate::line_width("قیمت 25 تومان", 1.0, 1.0) - sh.width).abs() < 1e-9); + } + + #[test] + fn hostile_rtl_lines_never_panic() { + for s in ["\u{202E}%%u\u{05D0}%%", "\u{2067}\u{0644}\u{0627}\u{2069}%%o", "\u{064E}\u{064E}\u{05D0}", "\u{05D0}\u{E000}\u{10FFFF}"] { + let _ = crate::shape_stroke(s, f64::NAN, -1.0, f64::INFINITY); + let _ = crate::line_width(s, 0.0, 0.0); + } + let long = "\u{05D0}b ".repeat(MAX_CHARS); + assert!(shape(None, &long, 1.0, 1.0, 0.0).is_none()); + assert!(crate::shape_stroke(&long, 1.0, 1.0, 0.0).width > 0.0); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 92d012e..0254cd2 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -5,9 +5,16 @@ //! per-character fallback to installed fonts for characters the chosen font lacks. Handles the //! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting //! (paragraphs, stacking, height changes), word wrap and attachment points. +//! +//! Right-to-left lines (Persian, Arabic, Urdu, Hebrew) go through [`bidi`] (the Unicode +//! Bidirectional Algorithm, Arabic joining) and `complex` (OpenType shaping with `harfrust`): +//! each run is shaped in the font that draws it before glyphs are placed, so the fallback font +//! drawing Arabic in a stroke-font style joins its letters too. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod bidi; +mod complex; mod mtext; mod stroke; pub mod ttf; @@ -80,6 +87,9 @@ pub fn char_advance(c: char) -> f64 { /// Advance of `c` in the stroke font at height `h` and width factor `wf`; characters the /// stroke font lacks take a fallback font's advance when one has them. fn stroke_advance(c: char, h: f64, wf: f64) -> f64 { + if bidi::is_format_control(c) { + return 0.0; + } if stroke::glyph(c).is_none() && !c.is_whitespace() && let Some(g) = ttf::fallback_glyph(c) @@ -101,21 +111,18 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh let mut x = 0.0; let mut under_start: Option = None; let mut over_start: Option = None; + if bidi::has_rtl(s) + && let Some(sh) = complex::shape(None, s, h, wf, oblique) + { + return sh; + } for (c, under, over) in decode_controls(s) { + if bidi::is_format_control(c) { + continue; + } let mut adv = char_advance(c) * h * wf; match stroke::glyph(c) { - Some((_, spec)) => { - for st in stroke::strokes(spec) { - let pts: Vec = st.iter().map(|(gx, gy)| Vec2::new(x + (gx * sc * wf) + gy * sc * shear, gy * sc)).collect(); - if pts.len() == 1 { - if let Some(p) = pts.first() { - out.strokes.push(vec![*p, *p + Vec2::new(sc * 0.2, 0.0)]); - } - } else { - out.strokes.push(pts); - } - } - } + Some((_, spec)) => draw_stroke_glyph(&mut out, spec, x, sc, wf, shear), None if !c.is_whitespace() => match ttf::fallback_glyph(c) { Some(g) => { let contours = ttf::place_glyph(&g, x, h, wf, shear); @@ -124,11 +131,7 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh } adv = g.advance * h / g.cap * wf; } - None => { - // Unknown glyph: a small box, like a missing-glyph rectangle. - let w = 4.0 * sc * wf; - out.strokes.push(vec![Vec2::new(x, 0.0), Vec2::new(x + w, 0.0), Vec2::new(x + w, h), Vec2::new(x, h), Vec2::new(x, 0.0)]); - } + None => missing_glyph_box(&mut out, x, h, sc * wf), }, None => {} } @@ -162,6 +165,27 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh out } +/// Draw stroke-font glyph `spec` with its left edge at `x` (`sc` = text height / cap height). +fn draw_stroke_glyph(out: &mut Shaped, spec: &str, x: f64, sc: f64, wf: f64, shear: f64) { + for st in stroke::strokes(spec) { + let pts: Vec = st.iter().map(|(gx, gy)| Vec2::new(x + (gx * sc * wf) + gy * sc * shear, gy * sc)).collect(); + if pts.len() == 1 { + if let Some(p) = pts.first() { + out.strokes.push(vec![*p, *p + Vec2::new(sc * 0.2, 0.0)]); + } + } else { + out.strokes.push(pts); + } + } +} + +/// A character no font has: a small box, like a missing-glyph rectangle (`k` = text height / +/// cap height × width factor). +fn missing_glyph_box(out: &mut Shaped, x: f64, h: f64, k: f64) { + let w = 4.0 * k; + out.strokes.push(vec![Vec2::new(x, 0.0), Vec2::new(x + w, 0.0), Vec2::new(x + w, h), Vec2::new(x, h), Vec2::new(x, 0.0)]); +} + /// Lay out a single line at `height`, with `width_factor` and `oblique` (radians), in the /// stroke font (fallback glyphs become closed outlines). pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run { @@ -173,6 +197,13 @@ pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run /// Width of a line without building strokes. pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 { + if bidi::has_rtl(s) { + let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; + let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; + if let Some(sh) = complex::shape(None, s, h, wf, 0.0) { + return sh.width; + } + } let n: f64 = decode_controls(s).iter().map(|(c, _, _)| stroke_advance(*c, height, width_factor)).sum(); (n - stroke::GAP / stroke::CAP * height * width_factor).max(0.0) } @@ -479,6 +510,27 @@ mod tests { assert!((bb.center().x - 10.0).abs() < 1e-9); } + #[test] + fn rtl_text_justification_works_on_the_text_box() { + // Right- and centre-justified right-to-left TEXT: the box sits on the alignment point + // and every glyph stays inside it. + for (h, want) in [(Align::Right, 10.0), (Align::Center, 10.0 - 0.5), (Align::Left, 0.0)] { + let p = TextParams { h, align_pt: Some(Vec2::new(10.0, 0.0)), ..TextParams::new(Vec2::ZERO, 1.0) }; + let (sh, bb) = place(&TextFont::Stroke, "قیمت 25 تومان", &p); + let edge = match h { + Align::Right => bb.max.x, + Align::Center => bb.center().x - 0.5, + _ => bb.min.x, + }; + assert!((edge - want).abs() < 1e-9, "{h:?}: {edge}"); + // An installed Arabic-script font may overhang its advance a little (ink outside + // the box is normal); a tenth of the height keeps this independent of the machine. + for q in sh.outlines().iter().flatten() { + assert!(q.x >= bb.min.x - 0.1 && q.x <= bb.max.x + 0.1, "{h:?}: {} outside {:?}", q.x, bb); + } + } + } + #[test] fn fit_spans_both_points() { let (_, bb) = place_text("ABC", Vec2::ZERO, Some(Vec2::new(10.0, 0.0)), 1.0, 0.0, 1.0, 0.0, Align::Fit, VAlign::Baseline); diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index 1fabdf6..e7a6c6f 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -459,12 +459,48 @@ fn seg_plain(seg: &Seg) -> String { } } +/// One laid-out piece of a line: a segment (`None`: the space between two words), its format, +/// x offset, width and the character offset of its text in [`Line::text`]. +struct Item { + seg: Option, + fmt: usize, + x: f64, + w: f64, + at: usize, +} + #[derive(Default)] struct Line { - items: Vec<(Seg, usize, f64)>, + items: Vec, width: f64, max_h: f64, text: String, + /// Index of the paragraph the line belongs to. + para: usize, +} + +impl Line { + /// Put the items in display order for right-to-left text: the Unicode Bidirectional + /// Algorithm over the line's text (paragraph direction `base`), pieces reordered by the level + /// of their first character, x offsets recomputed. Lines without right-to-left text are + /// left as they are. + fn reorder_bidi(&mut self, base: Option) { + if !crate::bidi::has_rtl(&self.text) { + return; + } + let levels = crate::bidi::char_levels(&self.text, base); + let item_levels: Vec = self.items.iter().map(|it| levels.get(it.at).copied().unwrap_or(0)).collect(); + let order = crate::bidi::reorder(&item_levels); + let mut items: Vec> = std::mem::take(&mut self.items).into_iter().map(Some).collect(); + let mut x = 0.0; + for i in order { + if let Some(mut it) = items.get_mut(i).and_then(Option::take) { + it.x = x; + x += it.w; + self.items.push(it); + } + } + } } /// Lay out MTEXT with the stroke font. `attach` 1..=9 (TL, TC, TR, ML, MC, MR, BL, BC, BR). @@ -486,10 +522,12 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { let mut line = Line::default(); let mut pending_space: Option = None; let mut last_fmt = 0usize; - let finish = |lines: &mut Vec, line: &mut Line, f: &Fmt| { + let mut para = 0usize; + let finish = |lines: &mut Vec, line: &mut Line, f: &Fmt, para: usize| { if line.items.is_empty() { line.max_h = f.height; } + line.para = para; lines.push(std::mem::take(line)); }; for a in &atoms { @@ -502,33 +540,53 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { } Atom::Para(f) => { pending_space = None; - finish(&mut lines, &mut line, fmt(*f)); + finish(&mut lines, &mut line, fmt(*f), para); + para += 1; last_fmt = *f; } Atom::Word(segs) => { let ww: f64 = segs.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum(); let sw = pending_space.map(|f| text_width(&fmt(f).font, " ", fmt(f).height, fmt(f).wf)).unwrap_or(0.0); if width > 0.0 && !line.items.is_empty() && line.width + sw + ww > width + 1e-9 { - finish(&mut lines, &mut line, fmt(last_fmt)); + finish(&mut lines, &mut line, fmt(last_fmt), para); } else if !line.items.is_empty() || pending_space.is_some() { - line.width += sw; - if pending_space.is_some() { + if let Some(f) = pending_space { + let at = line.text.chars().count(); + line.items.push(Item { seg: None, fmt: f, x: line.width, w: sw, at }); line.text.push(' '); } + line.width += sw; } pending_space = None; for (sg, f) in segs { let fw = seg_width(sg, fmt(*f)); line.max_h = line.max_h.max(fmt(*f).height); + let at = line.text.chars().count(); line.text.push_str(&seg_plain(sg)); - line.items.push((sg.clone(), *f, line.width)); + line.items.push(Item { seg: Some(sg.clone()), fmt: *f, x: line.width, w: fw, at }); line.width += fw; last_fmt = *f; } } } } - finish(&mut lines, &mut line, fmt(last_fmt)); + finish(&mut lines, &mut line, fmt(last_fmt), para); + // Right-to-left text: each line in display order, its paragraph's direction from the + // paragraph's first strong character. + let mut bases: Vec> = Vec::new(); + for l in &lines { + if bases.len() <= l.para { + bases.resize(l.para + 1, None); + } + if let Some(b) = bases.get_mut(l.para) + && b.is_none() + { + *b = crate::bidi::base_rtl(&l.text); + } + } + for l in &mut lines { + l.reorder_bidi(bases.get(l.para).copied().flatten()); + } let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 }; // Baselines below the top of the first line. let mut baselines = Vec::with_capacity(lines.len()); @@ -556,9 +614,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { 1 => -l.width / 2.0, _ => -l.width, }; - for (sg, f, x) in &l.items { - let fm = fmt(*f); - let mut sh = seg_shape(sg, fm, *x); + for it in &l.items { + let Some(sg) = &it.seg else { continue }; + let fm = fmt(it.fmt); + let mut sh = seg_shape(sg, fm, it.x); let off = Vec2::new(x0, top + base_y); sh.map(|q| xf(q + off)); match out.pieces.last_mut() { @@ -683,9 +742,62 @@ mod tests { assert!(l.pieces.iter().all(|p| p.shaped.glyphs.is_empty())); } + /// Stroke-font geometry that isn't a missing-glyph box (Latin letters here). + fn latin_points(l: &MTextLayout) -> Vec { + l.pieces.iter().flat_map(|p| p.shaped.strokes.iter()).filter(|s| !(s.len() == 5 && s.first() == s.last())).flatten().copied().collect() + } + + #[test] + fn rtl_paragraph_lays_words_out_right_to_left() { + // A Persian paragraph: the first word is rightmost, the Latin word at the end of the + // sentence is leftmost (without any installed font the Persian letters are boxes). + let l = lay("سلام دنیا abc", 0.0); + let xs = latin_points(&l); + assert!(!xs.is_empty()); + let w = l.bounds.width(); + assert!(xs.iter().all(|p| p.x < w / 2.0), "abc on the left of a right-to-left line"); + assert_eq!(l.lines, vec!["سلام دنیا abc"], "plain text stays in logical order"); + // The same words in a left-to-right paragraph keep abc first. + let l = lay("abc سلام دنیا", 0.0); + assert!(latin_points(&l).iter().all(|p| p.x < l.bounds.width() / 2.0)); + assert!((l.bounds.width() - w).abs() < 1e-9, "same width either way"); + } + + #[test] + fn rtl_wrapping_keeps_the_column_and_attachment() { + // Top-right attachment, wrapped to a 6-unit column: every line ends at the insertion + // point's x and nothing leaves the column. + let text = "قیمت 25 تومان و abc def"; + let l = layout_mtext(text, Vec2::ZERO, 1.0, 6.0, 3, 0.0, 1.0); + assert!(l.lines.len() >= 2, "{:?}", l.lines); + assert!(l.bounds.max.x.abs() < 1e-9 && (l.bounds.width() - 6.0).abs() < 1e-9); + let pts: Vec = l.strokes.iter().flatten().copied().collect(); + // Allow an installed font's overhang (a tenth of the height), so this holds on any machine. + assert!(pts.iter().all(|p| p.x <= 0.1 && p.x >= -6.0 - 0.1), "inside the column"); + // Centred: the line box is centred on the insertion point. + let c = layout_mtext(text, Vec2::ZERO, 1.0, 0.0, 2, 0.0, 1.0); + assert!(c.bounds.center().x.abs() < 1e-9); + } + #[test] fn hostile_contents_do_not_panic() { - for s in ["\\", "\\S", "\\S/;", "{{{{", "}}}}", "\\H;", "\\H-1;x", "\\Hx;", "\\C999;a", "\\f;", "\\W0;a", "\\Q90;a", "\\P\\P\\P"] { + for s in [ + "\u{202E}{\\C1;سل}ام\\P\u{2067}", + "\\S\u{05D0}/ب;", + "\\", + "\\S", + "\\S/;", + "{{{{", + "}}}}", + "\\H;", + "\\H-1;x", + "\\Hx;", + "\\C999;a", + "\\f;", + "\\W0;a", + "\\Q90;a", + "\\P\\P\\P", + ] { let _ = lay(s, 1.0); } let long = "word ".repeat(5000); diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 6935878..bfcd2b3 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -22,7 +22,8 @@ use cadcraft_geom::Vec2; use skrifa::instance::{LocationRef, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; use skrifa::raw::FileRef; -use skrifa::{FontRef, MetadataProvider}; +use skrifa::raw::types::Tag; +use skrifa::{FontRef, GlyphId, MetadataProvider}; use crate::{Run, Shaped}; @@ -46,9 +47,23 @@ impl Face { FontRef::from_index(&bytes, index).ok()?; Some(Face { bytes, index }) } - fn font(&self) -> Option> { + pub(crate) fn font(&self) -> Option> { FontRef::from_index(&self.bytes, self.index).ok() } + /// Whether two handles are the same face of the same loaded file. + pub(crate) fn same(&self, other: &Face) -> bool { + Arc::ptr_eq(&self.bytes, &other.bytes) && self.index == other.index + } + /// Whether the face has OpenType Arabic joining features (`init` / `medi` / `fina`). Faces + /// without them get Unicode presentation forms instead ([`crate::bidi::presentation_forms`]). + pub(crate) fn has_joining_features(&self) -> bool { + use skrifa::raw::TableProvider; + let tags = [Tag::new(b"init"), Tag::new(b"medi"), Tag::new(b"fina")]; + self.font() + .and_then(|f| f.gsub().ok()) + .and_then(|g| g.feature_list().ok()) + .is_some_and(|l| l.feature_records().iter().any(|r| tags.contains(&r.feature_tag()))) + } /// Whether the face maps `c` to a glyph. pub fn has_glyph(&self, c: char) -> bool { self.font().is_some_and(|f| f.charmap().map(c).is_some()) @@ -110,6 +125,37 @@ const PREFERRED_FALLBACK: &[&str] = &[ "segoeui", "arial", ]; +/// Fonts known to cover a right-to-left script, tried before the general search for its +/// characters (the general search loads a bounded number of files, and wide-coverage CJK fonts +/// come first in it). Matched as substrings of the normalised file stem. +const ARABIC_FALLBACK: &[&str] = &[ + "vazirmatn", + "vazir", + "notonaskharabic", + "notosansarabic", + "notokufiarabic", + "amiri", + "sahel", + "samim", + "geezapro", + "segoeui", + "tahoma", + "arial", + "dejavusans", +]; +const HEBREW_FALLBACK: &[&str] = &["notosanshebrew", "notoserifhebrew", "arial", "tahoma", "segoeui", "dejavusans"]; +/// Script-specific fallback files loaded per character, at most. +const MAX_SCRIPT_FILES: usize = 4; + +/// Script-specific fallback font names for `c`, if any. +fn script_fallback(c: char) -> &'static [&'static str] { + match c { + '\u{0600}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => ARABIC_FALLBACK, + '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => HEBREW_FALLBACK, + _ => &[], + } +} + const WEIGHT_WORDS: &[&str] = &["thin", "light", "medium", "semibold", "bold", "black", "heavy", "italic", "oblique", "mono", "condensed"]; #[derive(Default)] @@ -290,14 +336,47 @@ impl Db { } } + /// Index in `fallback.faces` of a face of a font known to cover `c`'s script (loaded and + /// added on demand), if one is installed and has `c`. + fn search_script_fallback(&mut self, c: char) -> Option { + let names = script_fallback(c); + if names.is_empty() { + return None; + } + let mut stems: Vec<(usize, usize, String)> = self + .paths + .keys() + .chain(self.files.keys()) + .filter_map(|s| { + let n = norm(s); + names.iter().position(|p| n.contains(p)).map(|pref| (weight_penalty(s, ""), pref, s.clone())) + }) + .collect(); + stems.sort(); + stems.dedup_by(|a, b| a.2 == b.2); + for (_, _, stem) in stems.into_iter().take(MAX_SCRIPT_FILES) { + let Some(bytes) = self.file(&stem) else { continue }; + let Some(face) = faces(&bytes).into_iter().find(|f| f.has_glyph(c)) else { continue }; + if let Some(i) = self.fallback.faces.iter().position(|f| f.same(&face)) { + return Some(i); + } + if self.fallback.faces.len() >= MAX_FALLBACK_FACES { + return None; + } + self.fallback.faces.push(face); + return Some(self.fallback.faces.len() - 1); + } + None + } + /// Index in `fallback.faces` of the first face that has `c`. fn search_fallback(&mut self, c: char) -> Option { if let Some(r) = self.fallback.by_char.get(&c) { return *r; } - let mut found = None; + let mut found = self.search_script_fallback(c); let mut i = 0; - while i < MAX_FALLBACK_FACES { + while found.is_none() && i < MAX_FALLBACK_FACES { if i >= self.fallback.faces.len() && !self.load_next_fallback() { break; } @@ -470,9 +549,16 @@ pub(crate) struct GlyphOutline { mapped: bool, } -/// Cached glyph outlines keyed by (font bytes address, length, face, char). Fonts live for the +/// A glyph by character (through the face's character map) or by glyph id (shaper output). +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum GlyphKey { + Char(char), + Id(u32), +} + +/// Cached glyph outlines keyed by (font bytes address, length, face, glyph). Fonts live for the /// whole process in the font table, so the address identifies them. -type GlyphCache = HashMap<(usize, usize, u32, char), Arc>; +type GlyphCache = HashMap<(usize, usize, u32, GlyphKey), Arc>; fn glyph_cache() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); @@ -555,12 +641,25 @@ fn cap_height(f: &FontRef) -> f64 { /// The outline of `c` in `face` (its missing-glyph shape when unmapped; empty when the face /// doesn't parse). pub(crate) fn glyph(face: &Face, c: char) -> Arc { - let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, c); + outline(face, GlyphKey::Char(c)) +} + +/// The outline of glyph `gid` of `face` (as a shaper returns it); its advance is the font's +/// nominal one. +pub(crate) fn glyph_by_id(face: &Face, gid: u32) -> Arc { + outline(face, GlyphKey::Id(gid)) +} + +fn outline(face: &Face, k: GlyphKey) -> Arc { + let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k); if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) { return g.clone(); } let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) }; - let mapped = f.charmap().map(c); + let mapped = match k { + GlyphKey::Char(c) => f.charmap().map(c), + GlyphKey::Id(id) => Some(GlyphId::new(id)), + }; let gid = mapped.unwrap_or_default(); let mut pen = Pen { contours: Vec::new(), cur: Vec::new() }; if let Some(g) = f.outline_glyphs().get(gid) { @@ -613,6 +712,11 @@ pub(crate) fn place_glyph(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64) /// Characters the face lacks come from a fallback font, scaled to its own cap height. pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { face.font()?; + if crate::bidi::has_rtl(s) + && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, oblique) + { + return Some(sh); + } let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let shear = oblique.tan().clamp(-10.0, 10.0); @@ -620,6 +724,9 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) let mut spans = Vec::new(); let mut x = 0.0; for (c, under, over) in crate::decode_controls(s) { + if crate::bidi::is_format_control(c) { + continue; + } let g = glyph_or_fallback(face, c); if !c.is_whitespace() { let contours = place_glyph(&g, x, h, wf, shear); @@ -639,10 +746,16 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) /// Width of one line set in a TrueType face. pub fn width(face: &Face, s: &str, height: f64, width_factor: f64) -> Option { face.font()?; + if crate::bidi::has_rtl(s) + && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, 0.0) + { + return Some(sh.width); + } let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; Some( crate::decode_controls(s) .iter() + .filter(|(c, _, _)| !crate::bidi::is_format_control(*c)) .map(|(c, _, _)| { let g = glyph_or_fallback(face, *c); g.advance * height / g.cap * wf diff --git a/docs/gaps.md b/docs/gaps.md index ea92874..72bc5b2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -114,7 +114,7 @@ Detail: [localization-parity.md](localization-parity.md). |---|---|---| | L1 | Catalog covers the UI (936 rows, #36) but not engine diagnostics, CLI/MCP replies or command history | 4–6 | | L2 | AutoCAD for Mac's 7 other languages (zh-Hans, es, fr, ja, de, ko, it) plus pt, hi, ar, id, vi from the standard's list | 40–60 + native review | -| L3 | Complex-script shaping (Arabic, Devanagari) and bidi/RTL in drawing text and UI | 30–45 | +| L3 | Complex-script shaping for Devanagari and other Indic scripts in drawing text; RTL (mirrored) UI. Arabic-script shaping and bidi in drawing text landed (#287) | 20–30 | | L4 | IME fixes on every platform (PRs #110–#119); vertical text styles | 6–10 | ## Stability diff --git a/docs/localization-parity.md b/docs/localization-parity.md index dfa286a..76e2c05 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-10 · **Change:** minor (Ukrainian catalog landed, #36: first string catalog) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (right-to-left drawing text and Arabic-script shaping, #287) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How far CADCraft's interface and drawing text are from AutoCAD's languages. Summarised in [ROADMAP.md](../ROADMAP.md#languages); work items in [gaps.md](gaps.md#localization). @@ -31,9 +31,11 @@ shaping and right-to-left text through TrueType, vertical SHX text styles. and `crates/ui-egui` (grep, 2026-10-10). - **Drawing text:** TrueType/OpenType/`.ttc` through `skrifa` with per-character font fallback and FONTALT, so CJK characters render (#173). DXF text is written with `\U+` escapes so - non-ASCII survives in R2000 files (#169). Glyphs are placed one per code point: there is **no - shaping** (Arabic joining, Devanagari conjuncts), **no bidi** (right-to-left order) and **no - vertical text**. SHX fonts are not read; our stroke font stands in. + non-ASCII survives in R2000 files (#169). Lines with right-to-left text get the Unicode + Bidirectional Algorithm and OpenType shaping with `harfrust` (Arabic, Persian and Urdu joining, + lam-alef, ZWNJ, marks; Hebrew), in TEXT and MTEXT including wrapping (#287). Left-to-right lines + are still placed one glyph per code point: **no shaping** for Devanagari and other Indic + scripts, and **no vertical text**. SHX fonts are not read; our stroke font stands in. - **Text input:** egui/winit IME. Several upstream IME fixes are queued as PRs (#110–#119: macOS Korean, emoji picker, Windows caret placement, X11 and Wayland), so CJK input is not yet reliable on every platform. @@ -52,7 +54,7 @@ plus native review by a human; extending the catalog to the remaining English-on | Simplified Chinese | zh-Hans | 0 (0%) | 0% | CJK glyphs render via fallback; IME fixes pending (#110–#119); no vertical text | no | none | 8–12 h (includes extending the catalog, 4–6 h) | | Spanish | es | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h | | Hindi | hi | 0 (0%) | 0% | Devanagari: no shaping | no | none | 3–5 h + 15–25 h shaping (shared with Arabic) | -| Arabic | ar | 0 (0%) | 0% | No shaping, no RTL layout or bidi text | no | none | 3–5 h + shaping + 15–25 h RTL UI | +| Arabic | ar | 0 (0%) | 0% | Drawing text shaped and laid out right to left (#287); the UI is not mirrored | no | none | 3–5 h + 15–25 h RTL UI | | French | fr | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h | | Portuguese | pt | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h | | Indonesian | id | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h | @@ -74,5 +76,6 @@ each language, which needs humans. Translations parallelise fully once the catal | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Right-to-left drawing text: bidi and Arabic-script shaping in TEXT and MTEXT (#287) | | 2026-10-10 | minor | Ukrainian catalog landed on main (#36): catalog infrastructure exists, Ukrainian partial; total hours 70–110 → 60–100 | | 2026-10-10 | major | First localization measurement against AutoCAD 2027 for Mac's bundle localizations |