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 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:13:29 -04:00
1 parent becb109cc1
commit d74f7a4108
11 files changed
+1125 -43

No files matched your search

Generated
+8
View File
@@ -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"
+4
View File
@@ -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
+20
View File
@@ -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
+2
View File
@@ -11,3 +11,5 @@ workspace = true
[dependencies]
cadcraft-geom = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
+465
View File
@@ -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<bool> {
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<bool>) -> Option<Level> {
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<usize> {
let mut v: Vec<usize> = 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<bool>) -> Vec<(Range<usize>, 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<bool>) -> Vec<u8> {
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<usize> {
let lv: Vec<Level> = 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<char> = 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<Option<Form>> {
let jt: Vec<Jt> = 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<char> {
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<char> {
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<char> = 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<char> = "سلام".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<usize> = 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<char> = "می\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<char> = "ب\u{200D}".chars().collect();
assert_eq!(joining_forms(&z).first(), Some(&Some(Form::Initial)));
// Marks are transparent: بَب joins across the fatha.
let m: Vec<char> = "ب\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<char> = "قیمت 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<char> = 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);
}
}
+303
View File
@@ -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<Shaped> {
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<char> = 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<usize> = 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<usize>, primary: Option<&Face>) -> Vec<(Src, Range<usize>)> {
let mut pieces: Vec<(Src, Range<usize>)> = 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<usize>, rtl: bool) -> Vec<Glyph> {
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<f64> = 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(&ltr)).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<Face> {
ttf::fallback_for('\u{0633}').filter(|f| f.has_glyph('\u{06CC}') && f.has_glyph('\u{067E}'))
}
fn nominal(f: &Face, c: char) -> Option<u32> {
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<char> = "سلام".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<usize> = 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<char> = "می\u{200C}روم".chars().collect();
let without: Vec<char> = "میروم".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<f64> = 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);
}
}
+69 -17
View File
@@ -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<f64> = None;
let mut over_start: Option<f64> = 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<Vec2> = 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<Vec2> = 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);
+124 -12
View File
@@ -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<Seg>,
fmt: usize,
x: f64,
w: f64,
at: usize,
}
#[derive(Default)]
struct Line {
items: Vec<(Seg, usize, f64)>,
items: Vec<Item>,
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<bool>) {
if !crate::bidi::has_rtl(&self.text) {
return;
}
let levels = crate::bidi::char_levels(&self.text, base);
let item_levels: Vec<u8> = 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<Option<Item>> = 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<usize> = None;
let mut last_fmt = 0usize;
let finish = |lines: &mut Vec<Line>, line: &mut Line, f: &Fmt| {
let mut para = 0usize;
let finish = |lines: &mut Vec<Line>, 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<Option<bool>> = 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<Vec2> {
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<Vec2> = 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);
+121 -8
View File
@@ -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<FontRef<'_>> {
pub(crate) fn font(&self) -> Option<FontRef<'_>> {
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<usize> {
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<usize> {
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<GlyphOutline>>;
type GlyphCache = HashMap<(usize, usize, u32, GlyphKey), Arc<GlyphOutline>>;
fn glyph_cache() -> &'static Mutex<GlyphCache> {
static C: OnceLock<Mutex<GlyphCache>> = 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<GlyphOutline> {
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<GlyphOutline> {
outline(face, GlyphKey::Id(gid))
}
fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
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<Shaped> {
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<f64> {
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
+1 -1
View File
@@ -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
+8 -5
View File
@@ -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 |