#395: Arabic and Hebrew in the interface: fallback fonts, captions in reading order
The interface had no face for Arabic or Hebrew, so caption text, track and media names showed missing-glyph boxes in the Text panel and on the timeline while the Program monitor drew them. crates/ui-egui/src/rtl.rs adds the craft-fonts Arab / Hebr faces, else one installed face per script (Noto Sans Arabic, SF Arabic, Segoe UI...), as the last fallbacks of every UI font family, as cjk.rs does for Chinese. egui already shapes each font run with harfrust, so the letters join, but it lays runs out left to right with no bidi pass and multi-word Arabic read backwards. rtl::Lines lays captions out in reading order: lines are broken on the logical text as egui wraps it, each line is reordered with unicode-bidi (UAX #9, as crates/text does for titles) and every right-to-left word is its own layout section for egui's shaper. Caption blocks on the timeline and the Text panel caption list use it; a caption being edited keeps egui's logical order, since TextEdit places the caret by character index. Left-to-right text is laid out exactly as before.
This commit is contained in:
parent
0800a8b0aa
commit
cfe0c751e9
|
|
@ -2303,6 +2303,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"toml 1.1.6+spec-1.1.0",
|
||||
"unicode-bidi",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ serde_json = { workspace = true }
|
|||
log = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = "1"
|
||||
# Unicode Bidirectional Algorithm for Arabic and Hebrew captions in reading order (MIT OR Apache-2.0).
|
||||
unicode-bidi = "0.3.18"
|
||||
# ArtCraft wordmark (PNG) for the About dialog and Home screen.
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ pub mod panels;
|
|||
pub mod perf;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod play_ahead;
|
||||
mod rtl;
|
||||
pub mod scrub;
|
||||
pub mod source_playback;
|
||||
pub mod state;
|
||||
|
|
|
|||
|
|
@ -233,14 +233,18 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
let key = egui::Id::new(("cap-text", c.id.0));
|
||||
let mut buf = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| c.text.clone());
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(key.with("te"))
|
||||
.desired_rows(1)
|
||||
.desired_width(ui.available_width())
|
||||
.font(Tokens::ui(12.5))
|
||||
.frame(egui::Frame::NONE),
|
||||
);
|
||||
let resp = if crate::rtl::has_rtl(&buf) && !ui.memory(|m| m.has_focus(key.with("te"))) {
|
||||
reading_order(ui, &buf, key.with("te"))
|
||||
} else {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(key.with("te"))
|
||||
.desired_rows(1)
|
||||
.desired_width(ui.available_width())
|
||||
.font(Tokens::ui(12.5))
|
||||
.frame(egui::Frame::NONE),
|
||||
)
|
||||
};
|
||||
app.auto.add(&format!("text.captions.{}.text", c.id.0), resp.rect, "Caption text");
|
||||
if resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(key, buf.clone()));
|
||||
|
|
@ -277,6 +281,22 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
run(app, ui, actions);
|
||||
}
|
||||
|
||||
/// A caption's text in reading order ([`crate::rtl::Lines`]) in place of its `TextEdit`, which
|
||||
/// shows right-to-left words in logical order. A click hands focus to the `TextEdit` (`id`), so the
|
||||
/// text is edited in logical order and shown in reading order again when the field loses focus.
|
||||
fn reading_order(ui: &mut egui::Ui, text: &str, id: egui::Id) -> egui::Response {
|
||||
let margin = vec2(4.0, 2.0); // a TextEdit's own margin, so the text does not move on focus
|
||||
let color = ui.visuals().widgets.inactive.text_color();
|
||||
let width = ui.available_width();
|
||||
let lines = crate::rtl::Lines::new(ui.painter(), text, Tokens::ui(12.5), color, (width - 2.0 * margin.x).max(0.0));
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, lines.size.y + 2.0 * margin.y), Sense::click());
|
||||
lines.paint(ui.painter(), rect.shrink2(margin), color);
|
||||
if resp.clicked() {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
resp.on_hover_cursor(egui::CursorIcon::Text)
|
||||
}
|
||||
|
||||
fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
if app.session.active_sequence().is_none() {
|
||||
|
|
|
|||
|
|
@ -148,10 +148,11 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re
|
|||
}
|
||||
if r.width() > 14.0 {
|
||||
let text = filmcraft_project::plain_text(&c.text).replace('\n', " ");
|
||||
p.with_clip_rect(r.shrink2(vec2(4.0, 0.0)).intersect(lane)).text(
|
||||
pos2(r.min.x + 5.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
text,
|
||||
// in reading order; Arabic and Hebrew start at the block's right edge
|
||||
crate::rtl::paint_line(
|
||||
&p.with_clip_rect(r.shrink2(vec2(4.0, 0.0)).intersect(lane)),
|
||||
Rect::from_min_max(pos2(r.min.x + 5.0, r.min.y), pos2(r.max.x - 5.0, r.max.y)),
|
||||
&text,
|
||||
Tokens::ui(11.0),
|
||||
if tr.enabled { Color32::WHITE } else { t.text_dim },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,415 @@
|
|||
//! Right-to-left scripts (Arabic, Hebrew) in the interface: fallback fonts and reading order.
|
||||
//!
|
||||
//! Fonts: like [`crate::cjk`], the craft-fonts faces for these scripts (`Arab`, `Hebr`) become the
|
||||
//! last fallbacks of every UI font family. A build without them looks up one installed face per
|
||||
//! script once (Noto Sans Arabic, SF Arabic, Segoe UI…) and reuses it across theme changes.
|
||||
//!
|
||||
//! Reading order: egui shapes each font run with harfrust, so a word's letters join and run right
|
||||
//! to left, but it places the runs left to right in logical order with no bidi pass: in
|
||||
//! "كلمة أخرى" the second word comes out first. [`Lines`] lays out document text (captions) in
|
||||
//! reading order for display. Lines are broken on the logical text as egui wraps it, each line's
|
||||
//! runs are put in visual order by the Unicode Bidirectional Algorithm (UAX #9, as the text engine
|
||||
//! does for titles), and every right-to-left word becomes its own layout section, which egui's
|
||||
//! shaper draws right to left. Text being edited keeps egui's logical order: `TextEdit` maps the
|
||||
//! caret by character index, so a reordered galley would put it in the wrong place.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use egui::epaint::text::{ByteIndex, LayoutJob, LayoutSection, TextFormat};
|
||||
use egui::{Color32, FontData, FontDefinitions, FontId, Galley, Painter, Rect, Vec2, pos2};
|
||||
use filmcraft_text::fonts::{CraftFont, Face};
|
||||
use unicode_bidi::{BidiClass, BidiInfo, bidi_class};
|
||||
|
||||
/// A right-to-left script: its ISO 15924 code (as in the craft-fonts manifest), letters a face
|
||||
/// must cover, and installed families to prefer, best first.
|
||||
struct Script {
|
||||
code: &'static str,
|
||||
sample: &'static str,
|
||||
preferred: &'static [&'static str],
|
||||
}
|
||||
|
||||
const SCRIPTS: &[Script] = &[
|
||||
Script {
|
||||
code: "Arab",
|
||||
sample: "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأؤإئةى،؟",
|
||||
preferred: &[
|
||||
"Noto Sans Arabic UI",
|
||||
"Noto Sans Arabic",
|
||||
"SF Arabic",
|
||||
"Geeza Pro",
|
||||
"Segoe UI",
|
||||
"Noto Naskh Arabic UI",
|
||||
"Noto Naskh Arabic",
|
||||
"Tahoma",
|
||||
"Arial",
|
||||
"DejaVu Sans",
|
||||
],
|
||||
},
|
||||
Script {
|
||||
code: "Hebr",
|
||||
sample: "אבגדהוזחטיכךלמםנןסעפףצץקרשת",
|
||||
preferred: &["Noto Sans Hebrew", "SF Hebrew", "Arial Hebrew", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans"],
|
||||
},
|
||||
];
|
||||
|
||||
/// The craft-fonts faces for right-to-left scripts (empty unless built with `CRAFT_FONTS_DIR`).
|
||||
pub(crate) fn craft_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
filmcraft_text::fonts::CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| SCRIPTS.iter().any(|x| x.code == *s)))
|
||||
}
|
||||
|
||||
/// Installed faces for the scripts craft-fonts does not cover, looked up once per process (native
|
||||
/// builds only). A face that covers several scripts (Segoe UI) is added once.
|
||||
fn system_fonts() -> &'static [(String, Arc<FontData>)] {
|
||||
static FONTS: OnceLock<Vec<(String, Arc<FontData>)>> = OnceLock::new();
|
||||
FONTS.get_or_init(|| {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Vec::new();
|
||||
}
|
||||
let missing: Vec<&Script> = SCRIPTS.iter().filter(|s| !craft_fonts().any(|f| f.scripts.contains(&s.code))).collect();
|
||||
if missing.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
filmcraft_text::fonts::scan_system();
|
||||
let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect();
|
||||
let by_weight = |f: &&Arc<Face>| f.info.weight.abs_diff(400);
|
||||
let mut chosen: Vec<&Arc<Face>> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for s in missing {
|
||||
if chosen.iter().any(|f| f.covers_text(s.sample)) {
|
||||
continue;
|
||||
}
|
||||
let covers = |f: &&Arc<Face>| f.covers_text(s.sample);
|
||||
let preferred =
|
||||
s.preferred.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name)).filter(covers).min_by_key(by_weight));
|
||||
let Some(face) = preferred.or_else(|| faces.iter().filter(covers).min_by_key(by_weight)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(data) = face.data() else {
|
||||
continue;
|
||||
};
|
||||
let font = FontData { font: Cow::Owned(data), index: face.info.index, tweak: Default::default() };
|
||||
out.push((format!("system-{}", s.code.to_lowercase()), Arc::new(font)));
|
||||
chosen.push(face);
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// Append the right-to-left faces (craft-fonts, else installed ones) after the other fallbacks of
|
||||
/// every font family. Their letters are in no other UI font, so their place among the fallbacks
|
||||
/// does not matter, and Latin text keeps the interface fonts. Installed faces come last, after
|
||||
/// the craft-fonts faces: one may be a broad face (DejaVu Sans, Segoe UI) that should not take
|
||||
/// symbols the craft-fonts faces draw.
|
||||
pub(crate) fn install(fonts: &mut FontDefinitions) {
|
||||
let mut names = Vec::new();
|
||||
for f in craft_fonts() {
|
||||
let name = format!("craft:{} {}", f.family, f.style);
|
||||
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(f.bytes)));
|
||||
names.push(name);
|
||||
}
|
||||
for (name, font) in system_fonts() {
|
||||
fonts.font_data.insert(name.clone(), font.clone());
|
||||
names.push(name.clone());
|
||||
}
|
||||
for stack in fonts.families.values_mut() {
|
||||
for name in &names {
|
||||
if !stack.contains(name) {
|
||||
stack.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is an installed right-to-left face added by [`install`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_system_face(name: &str) -> bool {
|
||||
system_fonts().iter().any(|(n, _)| n == name)
|
||||
}
|
||||
|
||||
/// Whether `text` has a right-to-left letter (Arabic, Hebrew…), the only text [`Lines`] reorders.
|
||||
pub(crate) fn has_rtl(text: &str) -> bool {
|
||||
text.chars().any(|c| matches!(bidi_class(c), BidiClass::R | BidiClass::AL))
|
||||
}
|
||||
|
||||
/// Characters that stay together, in logical order, in a right-to-left word: letters, marks and
|
||||
/// joiners. egui shapes such a word as one right-to-left run.
|
||||
fn in_word(c: char) -> bool {
|
||||
matches!(bidi_class(c), BidiClass::R | BidiClass::AL | BidiClass::NSM | BidiClass::BN)
|
||||
}
|
||||
|
||||
/// A bracket shown right to left takes its mirrored glyph (UAX #9 rule L4).
|
||||
fn mirror(c: char) -> char {
|
||||
match c {
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'<' => '>',
|
||||
'>' => '<',
|
||||
'«' => '»',
|
||||
'»' => '«',
|
||||
'‹' => '›',
|
||||
'›' => '‹',
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout sections of one line of `bidi.text` (byte range `line`, inside one paragraph), left
|
||||
/// to right. A left-to-right run stays whole, except that Arabic-Indic digits get a section each
|
||||
/// so the shaper cannot run them right to left. A right-to-left run is split into words
|
||||
/// ([`in_word`]) and single other characters (mirrored), listed in reverse: egui shapes each word
|
||||
/// right to left itself.
|
||||
fn line_sections(bidi: &BidiInfo<'_>, line: Range<usize>) -> Vec<String> {
|
||||
let text = bidi.text;
|
||||
let Some(whole) = text.get(line.clone()).filter(|s| !s.is_empty()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(para) = bidi.paragraphs.iter().find(|p| p.range.start <= line.start && line.end <= p.range.end) else {
|
||||
return vec![whole.to_owned()];
|
||||
};
|
||||
let (levels, runs) = bidi.visual_runs(para, line);
|
||||
let mut out = Vec::new();
|
||||
for run in runs {
|
||||
let Some(s) = text.get(run.clone()) else {
|
||||
continue;
|
||||
};
|
||||
if levels.get(run.start).is_some_and(|l| l.is_rtl()) {
|
||||
let mut units = Vec::new();
|
||||
let mut word = String::new();
|
||||
for c in s.chars() {
|
||||
if in_word(c) {
|
||||
word.push(c);
|
||||
continue;
|
||||
}
|
||||
if !word.is_empty() {
|
||||
units.push(std::mem::take(&mut word));
|
||||
}
|
||||
units.push(mirror(c).to_string());
|
||||
}
|
||||
if !word.is_empty() {
|
||||
units.push(word);
|
||||
}
|
||||
out.extend(units.into_iter().rev());
|
||||
} else {
|
||||
let mut plain = String::new();
|
||||
for c in s.chars() {
|
||||
if bidi_class(c) == BidiClass::AN {
|
||||
if !plain.is_empty() {
|
||||
out.push(std::mem::take(&mut plain));
|
||||
}
|
||||
out.push(c.to_string());
|
||||
} else {
|
||||
plain.push(c);
|
||||
}
|
||||
}
|
||||
if !plain.is_empty() {
|
||||
out.push(plain);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Byte ranges of the lines egui broke `text` into in `galley` (newlines left out), or None when
|
||||
/// the rows do not account for every character.
|
||||
fn line_ranges(text: &str, galley: &Galley) -> Option<Vec<Range<usize>>> {
|
||||
let bounds: Vec<usize> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
|
||||
let mut out = Vec::with_capacity(galley.rows.len());
|
||||
let mut at = 0usize;
|
||||
for row in &galley.rows {
|
||||
let end = at.checked_add(row.glyphs.len())?;
|
||||
out.push(*bounds.get(at)?..*bounds.get(end)?);
|
||||
at = end.checked_add(usize::from(row.ends_with_newline))?;
|
||||
}
|
||||
(at == bounds.len().saturating_sub(1)).then_some(out)
|
||||
}
|
||||
|
||||
/// Byte ranges of the lines of `text` between newlines.
|
||||
fn paragraph_ranges(text: &str) -> Vec<Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
for (i, c) in text.char_indices() {
|
||||
if c == '\n' {
|
||||
out.push(start..i);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(start..text.len());
|
||||
out
|
||||
}
|
||||
|
||||
/// Document text laid out for display in reading order, one galley per line.
|
||||
pub(crate) struct Lines {
|
||||
rows: Vec<Arc<Galley>>,
|
||||
/// The text starts right to left (its first strong letter is Arabic or Hebrew): lines align right.
|
||||
pub rtl: bool,
|
||||
pub size: Vec2,
|
||||
}
|
||||
|
||||
impl Lines {
|
||||
/// Lay out `text` in `font`, wrapped at `wrap_width` (`f32::INFINITY`: only at `\n`). Text
|
||||
/// without right-to-left letters is laid out as egui always does, in one galley.
|
||||
pub fn new(painter: &Painter, text: &str, font: FontId, color: Color32, wrap_width: f32) -> Self {
|
||||
let logical = painter.layout(text.to_owned(), font.clone(), color, wrap_width);
|
||||
if !has_rtl(text) {
|
||||
return Self { size: logical.size(), rows: vec![logical], rtl: false };
|
||||
}
|
||||
let bidi = BidiInfo::new(text, None);
|
||||
let rtl = bidi.paragraphs.first().is_some_and(|p| p.level.is_rtl());
|
||||
let lines = line_ranges(text, &logical).unwrap_or_else(|| paragraph_ranges(text));
|
||||
let rows: Vec<Arc<Galley>> = lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let mut job = LayoutJob::default();
|
||||
for s in line_sections(&bidi, line) {
|
||||
let start = job.text.len();
|
||||
job.text.push_str(&s);
|
||||
// pushed, not appended: `append` merges sections of equal format, and each
|
||||
// right-to-left word must be shaped on its own
|
||||
job.sections.push(LayoutSection {
|
||||
leading_space: 0.0,
|
||||
byte_range: ByteIndex(start)..ByteIndex(job.text.len()),
|
||||
format: TextFormat::simple(font.clone(), color),
|
||||
});
|
||||
}
|
||||
if job.sections.is_empty() { painter.layout_no_wrap(String::new(), font.clone(), color) } else { painter.layout_job(job) }
|
||||
})
|
||||
.collect();
|
||||
let size = rows.iter().fold(Vec2::ZERO, |s, g| Vec2::new(s.x.max(g.size().x), s.y + g.size().y));
|
||||
Self { rows, rtl, size }
|
||||
}
|
||||
|
||||
/// Paint the lines down from the top of `rect`: from its left edge, or from its right edge when
|
||||
/// [`Self::rtl`].
|
||||
pub fn paint(&self, painter: &Painter, rect: Rect, color: Color32) {
|
||||
let mut y = rect.min.y;
|
||||
for g in &self.rows {
|
||||
let x = if self.rtl { rect.max.x - g.size().x } else { rect.min.x };
|
||||
painter.galley(pos2(x, y), g.clone(), color);
|
||||
y += g.size().y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one line of document text vertically centred in `rect`, in reading order: from the left
|
||||
/// edge, or from the right edge when it reads right to left, so a clipped line still shows its start.
|
||||
pub(crate) fn paint_line(painter: &Painter, rect: Rect, text: &str, font: FontId, color: Color32) {
|
||||
let lines = Lines::new(painter, text, font, color, f32::INFINITY);
|
||||
let top = rect.center().y - lines.size.y / 2.0;
|
||||
lines.paint(painter, Rect::from_min_max(pos2(rect.min.x, top), pos2(rect.max.x, top + lines.size.y)), color);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sections of each line of `text` (split at newlines), left to right.
|
||||
fn visual(text: &str) -> Vec<Vec<String>> {
|
||||
let bidi = BidiInfo::new(text, None);
|
||||
paragraph_ranges(text).into_iter().map(|l| line_sections(&bidi, l)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_words_are_listed_last_word_first() {
|
||||
assert_eq!(visual("كلمة أخرى"), [["أخرى", " ", "كلمة"]]);
|
||||
assert_eq!(visual("שלום עולם"), [["עולם", " ", "שלום"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_and_joiners_stay_in_their_word() {
|
||||
// fatha, shadda and a zero-width non-joiner belong to the letters around them
|
||||
assert_eq!(visual("كَتَبَ مُحَمَّد"), [["مُحَمَّد", " ", "كَتَبَ"]]);
|
||||
assert_eq!(visual("می\u{200c}خواهم بود"), [["بود", " ", "می\u{200c}خواهم"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_and_numbers_inside_rtl_keep_their_order() {
|
||||
assert_eq!(visual("قال FilmCraft 0.6 جيد"), [["جيد", " ", "FilmCraft 0.6", " ", "قال"]]);
|
||||
// Arabic-Indic digits read left to right, one section each so the shaper keeps them so
|
||||
assert_eq!(visual("سنة ١٤٤٥"), [["١", "٤", "٤", "٥", " ", "سنة"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_punctuation_and_brackets() {
|
||||
assert_eq!(visual("نعم، (نص)"), [["(", "نص", ")", " ", "،", "نعم"]]);
|
||||
assert_eq!(visual("لماذا؟"), [["لماذا؟"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ltr_paragraph_with_an_rtl_phrase() {
|
||||
assert_eq!(visual("Lesson كلمة أخرى end"), [["Lesson ", "أخرى", " ", "كلمة", " end"]]);
|
||||
assert!(!has_rtl("Lesson 3: (draft)"));
|
||||
assert!(has_rtl("Lesson كلمة"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_line_is_reordered_on_its_own() {
|
||||
assert_eq!(visual("سطر أول\nسطر ثان"), [["أول", " ", "سطر"], ["ثان", " ", "سطر"]]);
|
||||
assert_eq!(visual("نص\n\nEnglish"), [vec!["نص"], vec![], vec!["English"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_ranges_give_nothing() {
|
||||
let text = "كلمة";
|
||||
let bidi = BidiInfo::new(text, None);
|
||||
assert!(line_sections(&bidi, 1..3).is_empty(), "not on a char boundary");
|
||||
assert!(line_sections(&bidi, 0..99).is_empty(), "past the end");
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
let backwards = 4..2;
|
||||
assert!(line_sections(&bidi, backwards).is_empty());
|
||||
}
|
||||
|
||||
/// With an Arabic face installed, captions lay out with real glyphs in reading order: in
|
||||
/// "كلمة أخرى" the whole second word draws left of the first, its last letter leftmost.
|
||||
#[test]
|
||||
fn lines_draw_arabic_in_reading_order() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut fonts = FontDefinitions::default();
|
||||
install(&mut fonts);
|
||||
if !craft_fonts().any(|f| f.scripts.contains(&"Arab")) && !system_fonts().iter().any(|(name, _)| name == "system-arab") {
|
||||
eprintln!("SKIPPED: no craft-fonts or installed Arabic font");
|
||||
return;
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
||||
let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING);
|
||||
let font = FontId::proportional(13.0);
|
||||
let lines = Lines::new(&painter, "كلمة أخرى", font.clone(), Color32::WHITE, f32::INFINITY);
|
||||
assert!(lines.rtl);
|
||||
let row = lines.rows.first().and_then(|g| g.rows.first()).map(|r| r.glyphs.clone()).unwrap_or_default();
|
||||
// egui shapes each word on its own (letters joined, in visual order) and pads the row with
|
||||
// empty glyphs so every char keeps a cursor position: only drawn glyphs count here
|
||||
let drawn: Vec<_> = row.iter().filter(|g| !g.uv_rect.is_nothing()).collect();
|
||||
let order = drawn.iter().map(|g| g.chr).collect::<String>();
|
||||
let xs = |word: &str| drawn.iter().filter(|g| word.contains(g.chr)).map(|g| g.pos.x).collect::<Vec<f32>>();
|
||||
let (first, second) = (xs("كلمة"), xs("أخرى"));
|
||||
assert!(!first.is_empty() && !second.is_empty(), "{order:?}");
|
||||
assert!(second.iter().fold(f32::MIN, |a, &b| a.max(b)) < first.iter().fold(f32::MAX, |a, &b| a.min(b)), "{order:?}");
|
||||
let leftmost = drawn.iter().min_by(|a, b| a.pos.x.total_cmp(&b.pos.x)).map(|g| g.chr);
|
||||
assert_eq!(leftmost, Some('ى'), "{order:?}");
|
||||
let missing = painter.layout_no_wrap("\u{fffd}".into(), font, Color32::WHITE);
|
||||
let missing_uv = missing.rows.first().and_then(|r| r.glyphs.first()).map(|g| g.uv_rect);
|
||||
assert!(drawn.iter().all(|g| Some(g.uv_rect) != missing_uv), "an Arabic letter drew as a missing glyph");
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Lines broken by wrapping are each reordered: the first line holds the first words.
|
||||
#[test]
|
||||
fn line_ranges_follow_the_rows() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
||||
let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING);
|
||||
let text = "one two three\nfour";
|
||||
let galley = painter.layout(text.to_owned(), FontId::proportional(13.0), Color32::WHITE, 40.0);
|
||||
let ranges = line_ranges(text, &galley).unwrap_or_default();
|
||||
assert!(ranges.len() >= 3, "{ranges:?}");
|
||||
let joined: Vec<&str> = ranges.iter().filter_map(|r| text.get(r.clone())).collect();
|
||||
assert_eq!(joined.concat().replace(' ', ""), "onetwothreefour");
|
||||
assert_eq!(ranges.last().and_then(|r| text.get(r.clone())), Some("four"));
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -415,7 +415,8 @@ pub fn font_families() -> Vec<FontFamily> {
|
|||
}
|
||||
|
||||
/// Install the Latin UI fonts, Chinese fallback (craft-fonts or an installed face), Japanese
|
||||
/// craft-fonts and egui visuals. Document names keep their glyphs in every interface language.
|
||||
/// craft-fonts, Arabic and Hebrew fallbacks (craft-fonts or installed faces, [`crate::rtl`]) and
|
||||
/// egui visuals. Document names keep their glyphs in every interface language.
|
||||
pub fn install(ctx: &egui::Context, t: &Tokens) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));
|
||||
|
|
@ -430,6 +431,7 @@ pub fn install(ctx: &egui::Context, t: &Tokens) {
|
|||
// glyph forms; Chinese faces still cover the hanzi Japanese fonts lack
|
||||
add_craft_fonts(&mut fonts);
|
||||
crate::cjk::install(&mut fonts);
|
||||
crate::rtl::install(&mut fonts);
|
||||
ctx.set_fonts(fonts);
|
||||
apply_visuals(ctx, t);
|
||||
}
|
||||
|
|
@ -643,6 +645,8 @@ mod tests {
|
|||
ctx.fonts_mut(|fonts| {
|
||||
let defs = fonts.definitions().clone();
|
||||
for (family, stack) in &defs.families {
|
||||
// installed right-to-left faces (Hebrew is not in craft-fonts) come after the craft run
|
||||
let stack: Vec<&String> = stack.iter().filter(|n| !crate::rtl::is_system_face(n)).collect();
|
||||
let first = stack.iter().position(|n| n.starts_with("craft:")).unwrap_or(stack.len());
|
||||
assert!(first > 0 && stack[first..].iter().all(|n| n.starts_with("craft:")), "{family:?}: {stack:?}");
|
||||
let first_japanese = stack.iter().find(|n| n.starts_with("craft:BIZ") || n.starts_with("craft:Shippori"));
|
||||
|
|
@ -666,7 +670,7 @@ mod tests {
|
|||
#[test]
|
||||
fn works_without_craft_fonts() {
|
||||
let ctx = installed();
|
||||
let n = filmcraft_text::fonts::craft_japanese().count() + crate::cjk::craft_fonts().count();
|
||||
let n = filmcraft_text::fonts::craft_japanese().count() + crate::cjk::craft_fonts().count() + crate::rtl::craft_fonts().count();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
let defs = fonts.definitions().clone();
|
||||
assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n);
|
||||
|
|
|
|||
|
|
@ -62,12 +62,13 @@ also splits captions.
|
|||
## Timeline and Text panel
|
||||
|
||||
- Caption rows: header with `C1…` badge, lock, output eye, name and format; caption blocks in
|
||||
violet showing their text. Click selects (⌘/⇧ adds), drag moves, edge drag trims, double-click
|
||||
violet showing their text (Arabic and Hebrew in reading order from the block's right edge). Click selects (⌘/⇧ adds), drag moves, edge drag trims, double-click
|
||||
opens the Text panel. Automation ids: `timeline.caption.<id>`,
|
||||
`timeline.captionTrack.C1[.locked|.enabled]`.
|
||||
- **Text panel ▸ Captions**: search, track picker (also creates tracks of each format), add /
|
||||
split / merge / delete / export buttons, the list of segments with editable in/out timecodes
|
||||
and text (committed when the field loses focus), and the track style strip (size, colour, box,
|
||||
and text (committed when the field loses focus; Arabic and Hebrew show in reading order and are
|
||||
edited in logical order while the field has focus), and the track style strip (size, colour, box,
|
||||
alignment, position). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`,
|
||||
`text.captions.<id>.in|out|text|goto|row`, `text.captions.style.*`.
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,14 @@ Web builds depend on the font faces selected by `crates/text/build.rs`; they can
|
|||
`theme::tests::chinese_names_render_in_every_theme_and_font_family` verifies Chinese glyphs in the
|
||||
timeline's proportional, monospace, medium and semibold families under all three themes.
|
||||
|
||||
Arabic and Hebrew work the same way (`crates/ui-egui/src/rtl.rs`): the `Arab` / `Hebr` craft-fonts
|
||||
faces, else one installed face per script (for example Noto Sans Arabic, SF Arabic or Segoe UI),
|
||||
are the last fallbacks of every UI font family. egui shapes each font run (letters join) but lays
|
||||
runs out left to right with no bidi pass, so captions on the timeline and in the Text panel are laid
|
||||
out in reading order by `rtl::Lines` (UAX #9 via unicode-bidi, each right-to-left word its own layout
|
||||
section). A caption being edited shows egui's logical order, since `TextEdit` places the caret by
|
||||
character index.
|
||||
|
||||
## 3. Quality gates
|
||||
|
||||
Every commit must pass all of these:
|
||||
|
|
|
|||
|
|
@ -203,7 +203,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
|
|||
|
||||
- **Have:** Spanish, Japanese, Simplified Chinese and French (#498) catalogs cover ~99.6–100% of
|
||||
the ~3,500 cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus).
|
||||
- **Missing:** Hindi, Arabic (no RTL interface layout; Arabic titles reported broken, #395),
|
||||
- **Missing:** Hindi, Arabic (no RTL interface layout or catalog; the interface has Arabic and Hebrew
|
||||
fallback fonts and shows captions in reading order, but other names and fields keep egui's
|
||||
left-to-right run order; Arabic titles reported broken, #395),
|
||||
Indonesian, German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts),
|
||||
Vietnamese, Portuguese beyond menus; Premiere also ships Italian and Russian. Engine errors,
|
||||
CLI and MCP stay English. No language has a recorded native-speaker review.
|
||||
|
|
|
|||
|
|
@ -25,8 +25,11 @@ Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts) (`CRAFT_
|
|||
official releases) or an installed system face; without one, switching to Japanese or Chinese is
|
||||
refused with a message. Simplified Chinese is picked for `zh`, `zh-CN`, `zh-SG`, `zh-Hans`, not
|
||||
Traditional locales. Ukrainian uses the bundled Cyrillic interface fonts. Vertical text in titles
|
||||
is supported. The interface (egui) does **not** shape complex scripts or lay out right to left;
|
||||
the title engine (`crates/text`, harfrust + bidi) does, but Arabic titles are reported broken (#395).
|
||||
is supported. The interface (egui) shapes each font run with harfrust and has Arabic and Hebrew
|
||||
fallback fonts (`crates/ui-egui/src/rtl.rs`), but it has no bidi pass: only captions (timeline
|
||||
blocks, the Text panel list when not being edited) are laid out in reading order, and the interface
|
||||
itself is never mirrored right to left. The title engine (`crates/text`, harfrust + bidi) lays out
|
||||
right to left, but Arabic titles are reported broken (#395).
|
||||
|
||||
## Status
|
||||
|
||||
|
|
@ -41,7 +44,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests.
|
|||
| Simplified Chinese | zh-cn | 3,485 (99.6%) | dialogs, Settings, effects, shortcuts, status; engine errors English | CJK font fallback; system IME via winit | not recorded | partial | 6–10 h + review |
|
||||
| Spanish | es | 3,496 (99.9%) | as above | Latin | not recorded | partial | 4–8 h + review |
|
||||
| Hindi | hi | 0 (0%) | — | **no Devanagari shaping in the interface** | — | none | 30–50 h (shaping in UI + catalog + fonts) |
|
||||
| Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h |
|
||||
| Arabic | ar | 0 (0%) | — | **no RTL interface layout**; fallback fonts and shaping in the interface, captions in reading order, other text in egui's left-to-right run order; titles broken (#395) | — | none | 40–70 h |
|
||||
| French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review |
|
||||
| Portuguese (Brazil) | pt-br | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review |
|
||||
| Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + review |
|
||||
|
|
|
|||
Loading…
Reference in New Issue