mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-15 06:46:45 -04:00
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:
1 parent
becb109cc1
commit
d74f7a4108
11 files changed
+1125
-43
No files matched your search
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,3 +11,5 @@ workspace = true
|
||||
[dependencies]
|
||||
cadcraft-geom = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(<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<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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
Reference in new issue
Block a user