#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:
seif 2026-10-11 11:35:01 -06:00 committed by seif
parent 0800a8b0aa
commit cfe0c751e9
11 changed files with 478 additions and 20 deletions

1
Cargo.lock generated
View File

@ -2303,6 +2303,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"toml 1.1.6+spec-1.1.0", "toml 1.1.6+spec-1.1.0",
"unicode-bidi",
"web-time", "web-time",
] ]

View File

@ -33,6 +33,8 @@ serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
rayon = { workspace = true } rayon = { workspace = true }
web-time = "1" 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. # ArtCraft wordmark (PNG) for the About dialog and Home screen.
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }

View File

@ -48,6 +48,7 @@ pub mod panels;
pub mod perf; pub mod perf;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod play_ahead; pub mod play_ahead;
mod rtl;
pub mod scrub; pub mod scrub;
pub mod source_playback; pub mod source_playback;
pub mod state; pub mod state;

View File

@ -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 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 mut buf = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| c.text.clone());
let resp = ui.add( let resp = if crate::rtl::has_rtl(&buf) && !ui.memory(|m| m.has_focus(key.with("te"))) {
egui::TextEdit::multiline(&mut buf) reading_order(ui, &buf, key.with("te"))
.id(key.with("te")) } else {
.desired_rows(1) ui.add(
.desired_width(ui.available_width()) egui::TextEdit::multiline(&mut buf)
.font(Tokens::ui(12.5)) .id(key.with("te"))
.frame(egui::Frame::NONE), .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"); app.auto.add(&format!("text.captions.{}.text", c.id.0), resp.rect, "Caption text");
if resp.has_focus() { if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(key, buf.clone())); 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); 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) { fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens; let t = app.tokens;
if app.session.active_sequence().is_none() { if app.session.active_sequence().is_none() {

View File

@ -148,10 +148,11 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re
} }
if r.width() > 14.0 { if r.width() > 14.0 {
let text = filmcraft_project::plain_text(&c.text).replace('\n', " "); let text = filmcraft_project::plain_text(&c.text).replace('\n', " ");
p.with_clip_rect(r.shrink2(vec2(4.0, 0.0)).intersect(lane)).text( // in reading order; Arabic and Hebrew start at the block's right edge
pos2(r.min.x + 5.0, r.center().y), crate::rtl::paint_line(
Align2::LEFT_CENTER, &p.with_clip_rect(r.shrink2(vec2(4.0, 0.0)).intersect(lane)),
text, 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), Tokens::ui(11.0),
if tr.enabled { Color32::WHITE } else { t.text_dim }, if tr.enabled { Color32::WHITE } else { t.text_dim },
); );

415
crates/ui-egui/src/rtl.rs Normal file
View File

@ -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();
}
}

View File

@ -415,7 +415,8 @@ pub fn font_families() -> Vec<FontFamily> {
} }
/// Install the Latin UI fonts, Chinese fallback (craft-fonts or an installed face), Japanese /// 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) { pub fn install(ctx: &egui::Context, t: &Tokens) {
let mut fonts = FontDefinitions::default(); let mut fonts = FontDefinitions::default();
fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR))); 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 // glyph forms; Chinese faces still cover the hanzi Japanese fonts lack
add_craft_fonts(&mut fonts); add_craft_fonts(&mut fonts);
crate::cjk::install(&mut fonts); crate::cjk::install(&mut fonts);
crate::rtl::install(&mut fonts);
ctx.set_fonts(fonts); ctx.set_fonts(fonts);
apply_visuals(ctx, t); apply_visuals(ctx, t);
} }
@ -643,6 +645,8 @@ mod tests {
ctx.fonts_mut(|fonts| { ctx.fonts_mut(|fonts| {
let defs = fonts.definitions().clone(); let defs = fonts.definitions().clone();
for (family, stack) in &defs.families { 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()); 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:?}"); 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")); let first_japanese = stack.iter().find(|n| n.starts_with("craft:BIZ") || n.starts_with("craft:Shippori"));
@ -666,7 +670,7 @@ mod tests {
#[test] #[test]
fn works_without_craft_fonts() { fn works_without_craft_fonts() {
let ctx = installed(); 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| { ctx.fonts_mut(|fonts| {
let defs = fonts.definitions().clone(); let defs = fonts.definitions().clone();
assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n); assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n);

View File

@ -62,12 +62,13 @@ also splits captions.
## Timeline and Text panel ## Timeline and Text panel
- Caption rows: header with `C1…` badge, lock, output eye, name and format; caption blocks in - 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>`, opens the Text panel. Automation ids: `timeline.caption.<id>`,
`timeline.captionTrack.C1[.locked|.enabled]`. `timeline.captionTrack.C1[.locked|.enabled]`.
- **Text panel ▸ Captions**: search, track picker (also creates tracks of each format), add / - **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 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`, 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.*`. `text.captions.<id>.in|out|text|goto|row`, `text.captions.style.*`.

View File

@ -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 `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. 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 ## 3. Quality gates
Every commit must pass all of these: Every commit must pass all of these:

View File

@ -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 - **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). 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), 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, 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. CLI and MCP stay English. No language has a recorded native-speaker review.

View File

@ -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 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 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 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; is supported. The interface (egui) shapes each font run with harfrust and has Arabic and Hebrew
the title engine (`crates/text`, harfrust + bidi) does, but Arabic titles are reported broken (#395). 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 ## 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 | | 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 | | 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) | | 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 | | 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 | | 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 | | Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + review |