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 <noreply@anthropic.com>
This commit is contained in:
parent
becb109cc1
commit
d74f7a4108
|
|
@ -631,7 +631,9 @@ name = "cadcraft-fonts"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-geom",
|
"cadcraft-geom",
|
||||||
|
"harfrust",
|
||||||
"skrifa 0.47.0",
|
"skrifa 0.47.0",
|
||||||
|
"unicode-bidi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3731,6 +3733,12 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "unicode-general-category"
|
name = "unicode-general-category"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ rayon = "1"
|
||||||
# rendering / formats
|
# rendering / formats
|
||||||
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
|
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
|
||||||
skrifa = "0.47"
|
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"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||||
|
|
||||||
# ui
|
# ui
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -152,6 +152,26 @@ run) an environment variable, steer this:
|
||||||
|
|
||||||
`.` clears either one.
|
`.` 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
|
### Logs
|
||||||
|
|
||||||
The desktop app writes its `log` records to standard error and to `logs/cadcraft.log` in the
|
The desktop app writes its `log` records to standard error and to `logs/cadcraft.log` in the
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,5 @@ workspace = true
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cadcraft-geom = { workspace = true }
|
cadcraft-geom = { workspace = true }
|
||||||
skrifa = { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,16 @@
|
||||||
//! per-character fallback to installed fonts for characters the chosen font lacks. Handles the
|
//! per-character fallback to installed fonts for characters the chosen font lacks. Handles the
|
||||||
//! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting
|
//! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting
|
||||||
//! (paragraphs, stacking, height changes), word wrap and attachment points.
|
//! (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)]
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod bidi;
|
||||||
|
mod complex;
|
||||||
mod mtext;
|
mod mtext;
|
||||||
mod stroke;
|
mod stroke;
|
||||||
pub mod ttf;
|
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
|
/// 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.
|
/// stroke font lacks take a fallback font's advance when one has them.
|
||||||
fn stroke_advance(c: char, h: f64, wf: f64) -> f64 {
|
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()
|
if stroke::glyph(c).is_none()
|
||||||
&& !c.is_whitespace()
|
&& !c.is_whitespace()
|
||||||
&& let Some(g) = ttf::fallback_glyph(c)
|
&& 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 x = 0.0;
|
||||||
let mut under_start: Option<f64> = None;
|
let mut under_start: Option<f64> = None;
|
||||||
let mut over_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) {
|
for (c, under, over) in decode_controls(s) {
|
||||||
|
if bidi::is_format_control(c) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut adv = char_advance(c) * h * wf;
|
let mut adv = char_advance(c) * h * wf;
|
||||||
match stroke::glyph(c) {
|
match stroke::glyph(c) {
|
||||||
Some((_, spec)) => {
|
Some((_, spec)) => draw_stroke_glyph(&mut out, spec, x, sc, wf, shear),
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None if !c.is_whitespace() => match ttf::fallback_glyph(c) {
|
None if !c.is_whitespace() => match ttf::fallback_glyph(c) {
|
||||||
Some(g) => {
|
Some(g) => {
|
||||||
let contours = ttf::place_glyph(&g, x, h, wf, shear);
|
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;
|
adv = g.advance * h / g.cap * wf;
|
||||||
}
|
}
|
||||||
None => {
|
None => missing_glyph_box(&mut out, x, h, sc * wf),
|
||||||
// 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 => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +165,27 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh
|
||||||
out
|
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
|
/// Lay out a single line at `height`, with `width_factor` and `oblique` (radians), in the
|
||||||
/// stroke font (fallback glyphs become closed outlines).
|
/// stroke font (fallback glyphs become closed outlines).
|
||||||
pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run {
|
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.
|
/// Width of a line without building strokes.
|
||||||
pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 {
|
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();
|
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)
|
(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);
|
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]
|
#[test]
|
||||||
fn fit_spans_both_points() {
|
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);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
#[derive(Default)]
|
||||||
struct Line {
|
struct Line {
|
||||||
items: Vec<(Seg, usize, f64)>,
|
items: Vec<Item>,
|
||||||
width: f64,
|
width: f64,
|
||||||
max_h: f64,
|
max_h: f64,
|
||||||
text: String,
|
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).
|
/// 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 line = Line::default();
|
||||||
let mut pending_space: Option<usize> = None;
|
let mut pending_space: Option<usize> = None;
|
||||||
let mut last_fmt = 0usize;
|
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() {
|
if line.items.is_empty() {
|
||||||
line.max_h = f.height;
|
line.max_h = f.height;
|
||||||
}
|
}
|
||||||
|
line.para = para;
|
||||||
lines.push(std::mem::take(line));
|
lines.push(std::mem::take(line));
|
||||||
};
|
};
|
||||||
for a in &atoms {
|
for a in &atoms {
|
||||||
|
|
@ -502,33 +540,53 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||||
}
|
}
|
||||||
Atom::Para(f) => {
|
Atom::Para(f) => {
|
||||||
pending_space = None;
|
pending_space = None;
|
||||||
finish(&mut lines, &mut line, fmt(*f));
|
finish(&mut lines, &mut line, fmt(*f), para);
|
||||||
|
para += 1;
|
||||||
last_fmt = *f;
|
last_fmt = *f;
|
||||||
}
|
}
|
||||||
Atom::Word(segs) => {
|
Atom::Word(segs) => {
|
||||||
let ww: f64 = segs.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum();
|
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);
|
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 {
|
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() {
|
} else if !line.items.is_empty() || pending_space.is_some() {
|
||||||
line.width += sw;
|
if let Some(f) = pending_space {
|
||||||
if pending_space.is_some() {
|
let at = line.text.chars().count();
|
||||||
|
line.items.push(Item { seg: None, fmt: f, x: line.width, w: sw, at });
|
||||||
line.text.push(' ');
|
line.text.push(' ');
|
||||||
}
|
}
|
||||||
|
line.width += sw;
|
||||||
}
|
}
|
||||||
pending_space = None;
|
pending_space = None;
|
||||||
for (sg, f) in segs {
|
for (sg, f) in segs {
|
||||||
let fw = seg_width(sg, fmt(*f));
|
let fw = seg_width(sg, fmt(*f));
|
||||||
line.max_h = line.max_h.max(fmt(*f).height);
|
line.max_h = line.max_h.max(fmt(*f).height);
|
||||||
|
let at = line.text.chars().count();
|
||||||
line.text.push_str(&seg_plain(sg));
|
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;
|
line.width += fw;
|
||||||
last_fmt = *f;
|
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 };
|
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.
|
// Baselines below the top of the first line.
|
||||||
let mut baselines = Vec::with_capacity(lines.len());
|
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,
|
1 => -l.width / 2.0,
|
||||||
_ => -l.width,
|
_ => -l.width,
|
||||||
};
|
};
|
||||||
for (sg, f, x) in &l.items {
|
for it in &l.items {
|
||||||
let fm = fmt(*f);
|
let Some(sg) = &it.seg else { continue };
|
||||||
let mut sh = seg_shape(sg, fm, *x);
|
let fm = fmt(it.fmt);
|
||||||
|
let mut sh = seg_shape(sg, fm, it.x);
|
||||||
let off = Vec2::new(x0, top + base_y);
|
let off = Vec2::new(x0, top + base_y);
|
||||||
sh.map(|q| xf(q + off));
|
sh.map(|q| xf(q + off));
|
||||||
match out.pieces.last_mut() {
|
match out.pieces.last_mut() {
|
||||||
|
|
@ -683,9 +742,62 @@ mod tests {
|
||||||
assert!(l.pieces.iter().all(|p| p.shaped.glyphs.is_empty()));
|
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]
|
#[test]
|
||||||
fn hostile_contents_do_not_panic() {
|
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 _ = lay(s, 1.0);
|
||||||
}
|
}
|
||||||
let long = "word ".repeat(5000);
|
let long = "word ".repeat(5000);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ use cadcraft_geom::Vec2;
|
||||||
use skrifa::instance::{LocationRef, Size};
|
use skrifa::instance::{LocationRef, Size};
|
||||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||||
use skrifa::raw::FileRef;
|
use skrifa::raw::FileRef;
|
||||||
use skrifa::{FontRef, MetadataProvider};
|
use skrifa::raw::types::Tag;
|
||||||
|
use skrifa::{FontRef, GlyphId, MetadataProvider};
|
||||||
|
|
||||||
use crate::{Run, Shaped};
|
use crate::{Run, Shaped};
|
||||||
|
|
||||||
|
|
@ -46,9 +47,23 @@ impl Face {
|
||||||
FontRef::from_index(&bytes, index).ok()?;
|
FontRef::from_index(&bytes, index).ok()?;
|
||||||
Some(Face { bytes, index })
|
Some(Face { bytes, index })
|
||||||
}
|
}
|
||||||
fn font(&self) -> Option<FontRef<'_>> {
|
pub(crate) fn font(&self) -> Option<FontRef<'_>> {
|
||||||
FontRef::from_index(&self.bytes, self.index).ok()
|
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.
|
/// Whether the face maps `c` to a glyph.
|
||||||
pub fn has_glyph(&self, c: char) -> bool {
|
pub fn has_glyph(&self, c: char) -> bool {
|
||||||
self.font().is_some_and(|f| f.charmap().map(c).is_some())
|
self.font().is_some_and(|f| f.charmap().map(c).is_some())
|
||||||
|
|
@ -110,6 +125,37 @@ const PREFERRED_FALLBACK: &[&str] = &[
|
||||||
"segoeui",
|
"segoeui",
|
||||||
"arial",
|
"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"];
|
const WEIGHT_WORDS: &[&str] = &["thin", "light", "medium", "semibold", "bold", "black", "heavy", "italic", "oblique", "mono", "condensed"];
|
||||||
|
|
||||||
#[derive(Default)]
|
#[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`.
|
/// Index in `fallback.faces` of the first face that has `c`.
|
||||||
fn search_fallback(&mut self, c: char) -> Option<usize> {
|
fn search_fallback(&mut self, c: char) -> Option<usize> {
|
||||||
if let Some(r) = self.fallback.by_char.get(&c) {
|
if let Some(r) = self.fallback.by_char.get(&c) {
|
||||||
return *r;
|
return *r;
|
||||||
}
|
}
|
||||||
let mut found = None;
|
let mut found = self.search_script_fallback(c);
|
||||||
let mut i = 0;
|
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() {
|
if i >= self.fallback.faces.len() && !self.load_next_fallback() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -470,9 +549,16 @@ pub(crate) struct GlyphOutline {
|
||||||
mapped: bool,
|
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.
|
/// 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> {
|
fn glyph_cache() -> &'static Mutex<GlyphCache> {
|
||||||
static C: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
|
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
|
/// The outline of `c` in `face` (its missing-glyph shape when unmapped; empty when the face
|
||||||
/// doesn't parse).
|
/// doesn't parse).
|
||||||
pub(crate) fn glyph(face: &Face, c: char) -> Arc<GlyphOutline> {
|
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) {
|
if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
|
||||||
return g.clone();
|
return g.clone();
|
||||||
}
|
}
|
||||||
let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) };
|
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 gid = mapped.unwrap_or_default();
|
||||||
let mut pen = Pen { contours: Vec::new(), cur: Vec::new() };
|
let mut pen = Pen { contours: Vec::new(), cur: Vec::new() };
|
||||||
if let Some(g) = f.outline_glyphs().get(gid) {
|
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.
|
/// 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> {
|
pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option<Shaped> {
|
||||||
face.font()?;
|
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 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 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 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 spans = Vec::new();
|
||||||
let mut x = 0.0;
|
let mut x = 0.0;
|
||||||
for (c, under, over) in crate::decode_controls(s) {
|
for (c, under, over) in crate::decode_controls(s) {
|
||||||
|
if crate::bidi::is_format_control(c) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let g = glyph_or_fallback(face, c);
|
let g = glyph_or_fallback(face, c);
|
||||||
if !c.is_whitespace() {
|
if !c.is_whitespace() {
|
||||||
let contours = place_glyph(&g, x, h, wf, shear);
|
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.
|
/// Width of one line set in a TrueType face.
|
||||||
pub fn width(face: &Face, s: &str, height: f64, width_factor: f64) -> Option<f64> {
|
pub fn width(face: &Face, s: &str, height: f64, width_factor: f64) -> Option<f64> {
|
||||||
face.font()?;
|
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 };
|
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
|
||||||
Some(
|
Some(
|
||||||
crate::decode_controls(s)
|
crate::decode_controls(s)
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|(c, _, _)| !crate::bidi::is_format_control(*c))
|
||||||
.map(|(c, _, _)| {
|
.map(|(c, _, _)| {
|
||||||
let g = glyph_or_fallback(face, *c);
|
let g = glyph_or_fallback(face, *c);
|
||||||
g.advance * height / g.cap * wf
|
g.advance * height / g.cap * wf
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| L4 | IME fixes on every platform (PRs #110–#119); vertical text styles | 6–10 |
|
||||||
|
|
||||||
## Stability
|
## Stability
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Localization parity
|
# 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
|
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).
|
[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).
|
and `crates/ui-egui` (grep, 2026-10-10).
|
||||||
- **Drawing text:** TrueType/OpenType/`.ttc` through `skrifa` with per-character font fallback
|
- **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
|
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
|
non-ASCII survives in R2000 files (#169). Lines with right-to-left text get the Unicode
|
||||||
shaping** (Arabic joining, Devanagari conjuncts), **no bidi** (right-to-left order) and **no
|
Bidirectional Algorithm and OpenType shaping with `harfrust` (Arabic, Persian and Urdu joining,
|
||||||
vertical text**. SHX fonts are not read; our stroke font stands in.
|
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:
|
- **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
|
macOS Korean, emoji picker, Windows caret placement, X11 and Wayland), so CJK input is not yet
|
||||||
reliable on every platform.
|
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) |
|
| 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 |
|
| 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) |
|
| 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 |
|
| French | fr | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h |
|
||||||
| Portuguese | pt | 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 |
|
| 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 |
|
| 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 | 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 |
|
| 2026-10-10 | major | First localization measurement against AutoCAD 2027 for Mac's bundle localizations |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue