mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # crates/ui-egui/src/i18n.rs # crates/ui-egui/src/i18n/LICENSE-translations.txt # crates/ui-egui/src/i18n/live_tests.rs # crates/ui-egui/src/i18n/system.rs # docs/localization-parity.md
This commit is contained in:
@@ -23,6 +23,7 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn start() {
|
||||
}
|
||||
let unsaved = Arc::new(AtomicBool::new(false));
|
||||
guard_unload(unsaved.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
|
||||
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets, ui_language: "" }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -224,7 +224,10 @@ fn load_font_manifest(served: Served, ctx: egui::Context) {
|
||||
served_fonts::add_fonts(&fonts);
|
||||
// Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets
|
||||
// the Arabic font before typing); the rest is fetched when text or a font menu needs it.
|
||||
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str));
|
||||
// Also the interface language's: `serve_fonts` asked for it once already, maybe before
|
||||
// this manifest arrived, when no fallback was registered yet.
|
||||
let ui_language = photocraft_ui_egui::i18n::current().code();
|
||||
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str).chain([ui_language]));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
@@ -270,6 +273,8 @@ struct WebShell {
|
||||
served: Served,
|
||||
database: Option<web_sys::IdbDatabase>,
|
||||
presets: crate::preset_bridge::Bridge,
|
||||
/// The interface language whose served script font was last asked for.
|
||||
ui_language: &'static str,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
@@ -294,6 +299,12 @@ impl WebShell {
|
||||
/// Fetches the served families asked for (picked in a font menu, or needed by a layout) and
|
||||
/// installs the files that arrived.
|
||||
fn serve_fonts(&mut self, ctx: &egui::Context) {
|
||||
// The interface language's script font draws its labels (Arabic, Japanese…).
|
||||
let ui_language = photocraft_ui_egui::i18n::current().code();
|
||||
if ui_language != self.ui_language {
|
||||
self.ui_language = ui_language;
|
||||
served_fonts::request_for_languages([ui_language]);
|
||||
}
|
||||
let requested = served_fonts::take_requests();
|
||||
let (fetch, arrived) = {
|
||||
let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
@@ -320,7 +331,7 @@ impl WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
served_fonts::install(&mut self.app, arrived);
|
||||
served_fonts::install(&mut self.app, ctx, arrived);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +286,11 @@ pub fn request_for_languages<'a>(languages: impl IntoIterator<Item = &'a str>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The served fallback family for the script `language` (a BCP 47 tag) is written in, if any.
|
||||
pub fn fallback_for_language(language: &str) -> Option<String> {
|
||||
fallback_for(&state().fallbacks, language_script(language)?)
|
||||
}
|
||||
|
||||
/// Families the host can serve, installed or not (for font menus).
|
||||
pub fn families() -> Vec<String> {
|
||||
state().families.clone()
|
||||
|
||||
@@ -155,6 +155,9 @@ impl PhotocraftApp {
|
||||
/// open in name order ([`natural_cmp`]: `File_2` before `File_10`), and their tabs keep that
|
||||
/// order however long each takes to open in the background (#3248). Returns how many opened.
|
||||
pub fn open_paths(&mut self, paths: &[String]) -> usize {
|
||||
// The command line's files open before the first frame, which otherwise applies the
|
||||
// interface language: their status lines and notices would come out in English.
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.session.prefs().interface.language));
|
||||
let mut paths: Vec<String> = paths.iter().map(|p| absolute(p)).collect();
|
||||
paths.sort_by(|a, b| natural_cmp(a, b));
|
||||
let batch = crate::jobs_ui::begin_batch(self);
|
||||
|
||||
@@ -818,6 +818,28 @@ fn natural_order_counts_numbers_by_value() {
|
||||
assert_eq!(names, ["A.psd", "b.psd", "file_1.psd", "File_03.psd", "File_9.psd", "File_10.psd", "File_100.psd"]);
|
||||
}
|
||||
|
||||
/// Files on the command line open before the first frame applies the interface language: their
|
||||
/// status line follows the preference, not the English default.
|
||||
#[test]
|
||||
fn open_paths_reports_in_the_interface_language_before_the_first_frame() {
|
||||
let pattern = |i: u8| {
|
||||
let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||||
surface.fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[f32::from(i) / 3.0, 0.5, 0.2, 1.0]);
|
||||
let mut p = photocraft_doc::Pattern::new("Launch", surface, 4, 4);
|
||||
p.id = format!("6f1c2c1e-0000-4000-8000-00000000000{i}");
|
||||
p
|
||||
};
|
||||
let bytes = photocraft_io::pattern_map::write_pat(&[pattern(1), pattern(2), pattern(3)]).unwrap();
|
||||
let (dir, paths) = files("launch-language", &["launch.pat"], &bytes);
|
||||
let (mut app, _) = app_with(Vec::new());
|
||||
app.session.edit_prefs(|p| p.interface.language = "ar".into());
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
assert_eq!(app.open_paths(&paths), 1);
|
||||
assert_eq!(app.ui.status, "تم استيراد 3 نقشات من launch");
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3248: several files opened at once (File › Open, the command line, Finder) open in name order.
|
||||
#[test]
|
||||
fn open_paths_opens_a_set_in_name_order() {
|
||||
|
||||
@@ -115,6 +115,18 @@ fn plural_polish(n: u64) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Arabic (CLDR): 0 → zero, 1 → one, 2 → two, n % 100 in 3–10 → few, in 11–99 → many, else other.
|
||||
fn plural_arabic(n: u64) -> usize {
|
||||
match (n, n % 100) {
|
||||
(0, _) => 0,
|
||||
(1, _) => 1,
|
||||
(2, _) => 2,
|
||||
(_, 3..=10) => 3,
|
||||
(_, 11..=99) => 4,
|
||||
_ => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// A catalog file as packed by build.rs (`pack_catalogs`; the binary would otherwise carry ~2.2 MB
|
||||
/// of translations, which the web build's size gate can't afford). See [`unpack`].
|
||||
macro_rules! catalog {
|
||||
@@ -187,7 +199,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 23] = [
|
||||
pub static LANGUAGES: [LangInfo; 24] = [
|
||||
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -222,6 +234,9 @@ pub static LANGUAGES: [LangInfo; 23] = [
|
||||
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Hindi; `hi`, `hi-IN` locales resolve here.
|
||||
LangInfo { code: "hi", name: "हिन्दी", source: catalog!("hi"), plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Arabic (Modern Standard); `ar-SA`, `ar-EG` and the other regional locales resolve here. The
|
||||
// chrome mirrors right to left (`dir_for_code`), and egui lays each label out right to left.
|
||||
LangInfo { code: "ar", name: "العربية", source: catalog!("ar"), plural: plural_arabic, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Swedish; `sv-SE` and `sv-FI` locales resolve here.
|
||||
LangInfo { code: "sv", name: "Svenska", source: catalog!("sv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
@@ -41,6 +41,10 @@ PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
Russian translations: PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
Arabic translations: PhotoCraft contributors, combined from the catalogs contributed by @azizr12,
|
||||
@hicbest, @hasan-sdaif, @NibrasAz7, @vfggf95565, @syrrevshin4202-prog and @test-bit-sys and
|
||||
reviewed with Claude (Anthropic) as an AI contributor; original translations of PhotoCraft's
|
||||
English labels, and no proprietary translation resources were used.
|
||||
Swedish translations: PhotoCraft contributors, combined from the catalogs contributed by @kler and
|
||||
@davvarn and reviewed with Claude (Anthropic) as an AI contributor; original translations of
|
||||
PhotoCraft's English labels, and no proprietary translation resources were used.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -451,3 +451,44 @@ fn czech_shortcut_conflicts_keep_warning_state_and_shortcut_codes() {
|
||||
assert_eq!(fields["overrides"]["file.new"], json!("Cmd+O"));
|
||||
});
|
||||
}
|
||||
|
||||
/// The catalog stays in logical order: egui puts a right-to-left label into display order itself,
|
||||
/// so the first word of an Arabic label lands to the right of its last, and a Latin brand name at
|
||||
/// the end of the sentence lands at the left.
|
||||
#[test]
|
||||
fn arabic_labels_are_laid_out_right_to_left() {
|
||||
with_language(Lang::EN, || {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "ar"})).unwrap();
|
||||
h.run_steps(4);
|
||||
let x_of = |text: &str, c: char| {
|
||||
let galley = h.ctx.fonts_mut(|f| f.layout_no_wrap(text.to_string(), egui::FontId::proportional(13.0), egui::Color32::WHITE));
|
||||
galley.rows.iter().flat_map(|r| r.glyphs.iter()).find(|g| g.chr == c).map(|g| g.pos.x).expect("glyph")
|
||||
};
|
||||
let label = tr(lang("ar"), "No properties");
|
||||
assert_eq!(label, "لا توجد خصائص");
|
||||
assert!(x_of(label, 'ل') > x_of(label, 'ص'), "{label}: first word at the right");
|
||||
let join = tr(lang("ar"), "Join us on Discord");
|
||||
assert!(x_of(join, 'D') < x_of(join, 'ن'), "{join}: Discord at the left");
|
||||
});
|
||||
}
|
||||
|
||||
/// #5159 on egui 0.37: user names are handed to egui as typed, and egui orders the words. An
|
||||
/// Arabic layer name reads from the right; the title line keeps "— PhotoCraft" at the right of a
|
||||
/// right-to-left document name.
|
||||
#[test]
|
||||
fn arabic_names_are_laid_out_right_to_left() {
|
||||
with_language(Lang::EN, || {
|
||||
let h = harness();
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let x_in = |galley: &egui::Galley, c: char| galley.rows.iter().flat_map(|r| r.glyphs.iter()).find(|g| g.chr == c).map(|g| g.pos.x).expect("glyph");
|
||||
let name = "لاشي مكتوب هنا";
|
||||
let galley = h.ctx.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), egui::Color32::WHITE));
|
||||
assert!(x_in(&galley, 'ل') > x_in(&galley, 'ه'), "{name}: first word at the right");
|
||||
let title = "لاشي مكتوب.png — PhotoCraft";
|
||||
let job = crate::panels::title_bar::title_job(title, font, egui::Color32::WHITE);
|
||||
let galley = h.ctx.fonts_mut(|f| f.layout_job(job));
|
||||
assert!(x_in(&galley, 'ل') > x_in(&galley, 'م'), "{title}: the name's first word right of its second");
|
||||
assert!(x_in(&galley, 'P') > x_in(&galley, 'ل'), "{title}: PhotoCraft stays at the right");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ mod tests {
|
||||
(vec!["zh-Hant-HK", "zh-CN"], "zh-hant"),
|
||||
(vec!["zh-Hans-CN", "zh-TW"], "zh-hans"),
|
||||
(vec!["en-US", "ru-RU"], "en"),
|
||||
(vec!["fi-FI", "ar-SA"], "ar"),
|
||||
(vec!["sw-KE", "fi-FI"], "en"),
|
||||
(vec!["de-AT", "en-US"], "de"),
|
||||
(vec!["pt-PT"], "pt-br"),
|
||||
|
||||
@@ -209,6 +209,38 @@ fn ukrainian_plural_rule_handles_teens_and_compound_counts() {
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 0, 1, 1, 2, 2, 0, 2, 2, 2, 0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_resolves_locales_and_preferences() {
|
||||
let ar = Lang::from_code("ar").expect("ar registered");
|
||||
assert_eq!(ar.name(), "العربية");
|
||||
for tag in ["ar", "AR", "ar-SA", "ar_EG", "ar_EG.UTF-8", "ar-AE", "ar-MA"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(ar), "{tag}");
|
||||
assert_eq!(Lang::from_pref(tag), ar, "{tag}");
|
||||
}
|
||||
assert_eq!(dir_for_code("ar"), Dir::Rtl);
|
||||
assert_eq!(tr(ar, "File"), "ملف");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_plural_rule_has_six_forms() {
|
||||
let ar = Lang::from_code("ar").expect("ar registered");
|
||||
let counts = [0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111, 199, 1000, u64::MAX];
|
||||
let forms: Vec<usize> = counts.into_iter().map(ar.0.plural).collect();
|
||||
assert_eq!(forms, [0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 3, 4, 4, 5, 4]);
|
||||
assert_eq!(trn(ar, 2, "{n} layer", "{n} layers"), "2 طبقتان");
|
||||
assert_eq!(trn(ar, 5, "{n} layer", "{n} layers"), "5 طبقات");
|
||||
}
|
||||
|
||||
/// The preset-import result (`preset_files_ui::open`) picks its plural pair at run time, so the
|
||||
/// literal-key coverage check can't see these rows.
|
||||
#[test]
|
||||
fn arabic_preset_import_results_are_translated() {
|
||||
let ar = Lang::from_code("ar").expect("ar registered");
|
||||
assert_eq!(trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), "تم استيراد 3 نقشات من {file}");
|
||||
assert_eq!(trn(ar, 2, "Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), "تم استيراد 2 تدرجين من {file}");
|
||||
assert_eq!(trn(ar, 1, "Imported {n} brush from {file}", "Imported {n} brushes from {file}"), "تم استيراد 1 فرشاة من {file}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serbian_resolves_locales_and_preferences() {
|
||||
let sr = Lang::from_code("sr").expect("sr registered");
|
||||
|
||||
@@ -57,7 +57,7 @@ pub mod smart_ui;
|
||||
mod status_bar;
|
||||
pub mod swatches_ui;
|
||||
pub(super) mod timeline_ui;
|
||||
mod title_bar;
|
||||
pub(crate) mod title_bar;
|
||||
pub(super) mod titlebar;
|
||||
mod toolbar;
|
||||
pub mod type_panels_ui;
|
||||
|
||||
@@ -127,14 +127,14 @@ pub fn truncated(painter: &Painter, text: &str, font: FontId, color: egui::Color
|
||||
}
|
||||
painter.layout_job(job)
|
||||
};
|
||||
// A right-to-left name (Arabic, Hebrew) too long for the row is cut at its logical end and
|
||||
// then put in display order, so its start stays and "…" is at the left (`bidi_text::elide`).
|
||||
// A right-to-left name (Arabic, Hebrew) too long for the row is cut at its logical end, so its
|
||||
// start stays and "…" is at the left (`bidi_text::elide`).
|
||||
if let Some(shown) = crate::bidi_text::elide(text, |s| layout(s, false).size().x <= max_width) {
|
||||
return Some(layout(&shown, false));
|
||||
}
|
||||
// Right-to-left names in display order: egui lays words out left to right. Left-to-right
|
||||
// names are cut at their right end by egui.
|
||||
Some(layout(&crate::bidi_text::visual(text), true))
|
||||
// egui puts a right-to-left name into display order itself; left-to-right names are cut at
|
||||
// their right end by egui.
|
||||
Some(layout(text, true))
|
||||
}
|
||||
|
||||
/// Paint `l`'s right-hand indicators in `row` and handle the effects triangle. Returns the
|
||||
|
||||
@@ -132,18 +132,15 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if system {
|
||||
return;
|
||||
}
|
||||
// A right-to-left document name in display order (the OS title keeps the logical text),
|
||||
// in a left-to-right line: "— PhotoCraft" stays after the name, at the right.
|
||||
let title = crate::bidi_text::visual_ltr(&title).into_owned();
|
||||
let font = theme::medium(13.0);
|
||||
let galley = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text_dim);
|
||||
let galley = ui.painter().layout_job(title_job(&title, font.clone(), t.text_dim));
|
||||
if let Some(x) = title_x(full.center().x, free.0, free.1, galley.size().x) {
|
||||
ui.painter().galley(egui::pos2(x, full.center().y - galley.size().y / 2.0), galley, t.text_dim);
|
||||
} else if free.1 - free.0 > 80.0 {
|
||||
// Too narrow for the whole name: show the start of it, elided, in the free gap
|
||||
// next to the menus.
|
||||
let avail = free.1 - free.0 - 2.0 * TITLE_GAP;
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(title, font, t.text_dim);
|
||||
let mut job = title_job(&title, font, t.text_dim);
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(avail);
|
||||
let g = ui.painter().layout_job(job);
|
||||
let x = if rtl { free.1 - TITLE_GAP - g.size().x } else { free.0 + TITLE_GAP };
|
||||
@@ -185,6 +182,14 @@ pub fn sync_window_title(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-app title line. It reads left to right as a whole, so a right-to-left document name keeps
|
||||
/// "— PhotoCraft" after it, at the right; egui orders the name's own words.
|
||||
pub(crate) fn title_job(title: &str, font: egui::FontId, color: egui::Color32) -> egui::text::LayoutJob {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(title.to_owned(), font, color);
|
||||
job.direction = egui::epaint::text::TextDirection::LeftToRight;
|
||||
job
|
||||
}
|
||||
|
||||
/// Minimum space kept between the window title and the menus or controls beside it.
|
||||
const TITLE_GAP: f32 = 16.0;
|
||||
|
||||
|
||||
@@ -1,31 +1,11 @@
|
||||
//! Right-to-left text in labels that egui draws (#5159).
|
||||
//!
|
||||
//! egui shapes text in runs of one font face and places the runs left to right. An Arabic or
|
||||
//! Hebrew word is one run, which the shaper draws right to left, but the space between two words
|
||||
//! comes from another font, so a phrase came out with every word correct and the word order
|
||||
//! reversed ("THIS IS A TEXT" read as "TEXT A IS THIS"). [`visual`] puts a label into display
|
||||
//! order with the Unicode Bidirectional Algorithm before egui lays it out: words of a
|
||||
//! right-to-left script stay in logical order (the shaper reverses them itself), and everything
|
||||
//! else in a right-to-left run is reversed around them, with brackets mirrored.
|
||||
//!
|
||||
//! For display only: never store the result or put it in an editable field.
|
||||
//! egui lays text out with the Unicode Bidirectional Algorithm: each paragraph takes the direction
|
||||
//! of its first strong letter, and a right-to-left run is placed right to left. Labels, names and
|
||||
//! translations are therefore handed to egui in logical order, as typed. What egui can't know is
|
||||
//! where a right-to-left name may be cut: [`elide`] keeps the *start* of the name.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use unicode_bidi::{BidiClass, BidiInfo, Level, bidi_class};
|
||||
|
||||
/// `text` in the order egui should lay it out from left to right. Text without right-to-left
|
||||
/// characters comes back unchanged, without allocating.
|
||||
pub fn visual(text: &str) -> Cow<'_, str> {
|
||||
visual_at(text, None)
|
||||
}
|
||||
|
||||
/// [`visual`] for a line that reads left to right as a whole even when it starts with a
|
||||
/// right-to-left name, such as the title bar's "*صورة.psd — PhotoCraft": with the paragraph
|
||||
/// direction taken from the name, the "— PhotoCraft" tail moved to the left.
|
||||
pub fn visual_ltr(text: &str) -> Cow<'_, str> {
|
||||
visual_at(text, Some(Level::ltr()))
|
||||
}
|
||||
use unicode_bidi::{BidiClass, bidi_class};
|
||||
|
||||
/// Whether `text` reads right to left as a paragraph: its first strong letter is Hebrew, Arabic…
|
||||
pub fn is_rtl_paragraph(text: &str) -> bool {
|
||||
@@ -38,20 +18,19 @@ pub fn is_rtl_paragraph(text: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `text` in display order, cut with "…" where it doesn't fit (`fits` measures a display-order
|
||||
/// string). A right-to-left name is cut in logical order before it is reordered, so its *start*
|
||||
/// stays and the "…" lands at the left, the end of a right-to-left line; cutting after the
|
||||
/// reordering lost the start of the name instead. `None`: `text` fits as it is (or a
|
||||
/// left-to-right one, which the caller elides at its right end as usual).
|
||||
/// A right-to-left `text` cut at its logical end with "…" where it doesn't fit (`fits` measures a
|
||||
/// candidate). The start of the name stays, and egui puts the "…" at the left, the end of a
|
||||
/// right-to-left line. `None`: `text` fits as it is (or a left-to-right one, which the caller
|
||||
/// elides at its right end as usual).
|
||||
pub fn elide(text: &str, fits: impl Fn(&str) -> bool) -> Option<String> {
|
||||
if !is_rtl_paragraph(text) || fits(&visual(text)) {
|
||||
if !is_rtl_paragraph(text) || fits(text) {
|
||||
return None;
|
||||
}
|
||||
// The longest logical prefix (whole characters) that fits with "…" after it.
|
||||
let ends: Vec<usize> = text.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(text.len())).collect();
|
||||
let cut = |n: usize| -> String {
|
||||
let end = if n == 0 { 0 } else { ends.get(n - 1).copied().unwrap_or(text.len()) };
|
||||
visual(&format!("{}…", text.get(..end).unwrap_or_default().trim_end())).into_owned()
|
||||
format!("{}…", text.get(..end).unwrap_or_default().trim_end())
|
||||
};
|
||||
let (mut lo, mut hi) = (0usize, ends.len());
|
||||
while lo < hi {
|
||||
@@ -61,143 +40,21 @@ pub fn elide(text: &str, fits: impl Fn(&str) -> bool) -> Option<String> {
|
||||
Some(cut(lo))
|
||||
}
|
||||
|
||||
fn visual_at(text: &str, level: Option<Level>) -> Cow<'_, str> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
let info = BidiInfo::new(text, level);
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for para in &info.paragraphs {
|
||||
let (levels, runs) = info.visual_runs(para, para.range.clone());
|
||||
for run in runs {
|
||||
let Some(slice) = text.get(run.clone()) else { continue };
|
||||
if levels.get(run.start).is_some_and(|level| level.is_rtl()) {
|
||||
push_reversed(slice, &mut out);
|
||||
} else {
|
||||
out.push_str(slice);
|
||||
}
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// A strong right-to-left letter, or any character of a right-to-left script block (Arabic
|
||||
/// punctuation such as "،" belongs with the word it follows: the same font shapes both).
|
||||
fn is_rtl(c: char) -> bool {
|
||||
matches!(bidi_class(c), BidiClass::R | BidiClass::AL) || matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}')
|
||||
}
|
||||
|
||||
/// Characters that stay inside a right-to-left word: its letters, combining marks, and joiners.
|
||||
fn keeps_with_word(c: char) -> bool {
|
||||
is_rtl(c) || matches!(bidi_class(c), BidiClass::NSM | BidiClass::BN)
|
||||
}
|
||||
|
||||
/// Append a right-to-left run in left-to-right display order: right-to-left words stay whole and
|
||||
/// in logical order, every other character is reversed around them, and brackets are mirrored.
|
||||
fn push_reversed(run: &str, out: &mut String) {
|
||||
let mut units: Vec<&str> = Vec::new();
|
||||
let mut word: Option<usize> = None;
|
||||
for (i, c) in run.char_indices() {
|
||||
if keeps_with_word(c) {
|
||||
word.get_or_insert(i);
|
||||
continue;
|
||||
}
|
||||
if let Some(start) = word.take()
|
||||
&& let Some(w) = run.get(start..i)
|
||||
{
|
||||
units.push(w);
|
||||
}
|
||||
if let Some(single) = run.get(i..i + c.len_utf8()) {
|
||||
units.push(single);
|
||||
}
|
||||
}
|
||||
if let Some(start) = word
|
||||
&& let Some(w) = run.get(start..)
|
||||
{
|
||||
units.push(w);
|
||||
}
|
||||
for unit in units.into_iter().rev() {
|
||||
let mut chars = unit.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(c), None) if !keeps_with_word(c) => out.push(mirror(c)),
|
||||
_ => out.push_str(unit),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirrored form of a bracket shown in right-to-left text.
|
||||
fn mirror(c: char) -> char {
|
||||
match c {
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'<' => '>',
|
||||
'>' => '<',
|
||||
'«' => '»',
|
||||
'»' => '«',
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn left_to_right_text_is_untouched_and_not_copied() {
|
||||
for text in ["PhotoCraft", "Layer 1", "", "Background copy (2)", "日本語のレイヤー"] {
|
||||
assert!(matches!(visual(text), Cow::Borrowed(t) if t == text), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5159: the reported layer name. Each word stays as typed; the word order flips so that
|
||||
/// egui, placing words left to right, shows the first word at the right.
|
||||
#[test]
|
||||
fn arabic_words_are_placed_right_to_left() {
|
||||
assert_eq!(visual("لاشي مكتوب هنا"), "هنا مكتوب لاشي");
|
||||
assert_eq!(visual("שלום עולם"), "עולם שלום");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_and_latin_keep_their_own_order() {
|
||||
// A number inside right-to-left text reads left to right, at the start of the line.
|
||||
assert_eq!(visual("طبقة 2"), "2 طبقة");
|
||||
assert_eq!(visual("طبقة 12"), "12 طبقة");
|
||||
// A left-to-right name with an Arabic word at the end is left as it is.
|
||||
assert_eq!(visual("Layer 1 نص"), "Layer 1 نص");
|
||||
// Latin inside an Arabic name stays readable.
|
||||
assert_eq!(visual("نص PhotoCraft هنا"), "هنا PhotoCraft نص");
|
||||
}
|
||||
|
||||
/// #5385 review: the title bar's line is left to right as a whole, so a right-to-left
|
||||
/// document name keeps "— PhotoCraft" after it, at the right.
|
||||
#[test]
|
||||
fn the_title_line_stays_left_to_right_around_a_right_to_left_name() {
|
||||
assert_eq!(visual_ltr("*صورة.psd — PhotoCraft"), "*صورة.psd — PhotoCraft");
|
||||
assert_eq!(visual_ltr("لاشي مكتوب.png — PhotoCraft"), "مكتوب لاشي.png — PhotoCraft");
|
||||
assert!(matches!(visual_ltr("Untitled-1 — PhotoCraft"), Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
/// #5385 review: a right-to-left name that doesn't fit keeps its first words and ends with
|
||||
/// "…" at the left; a left-to-right or fitting name is left to the caller.
|
||||
/// "…"; a left-to-right or fitting name is left to the caller.
|
||||
#[test]
|
||||
fn right_to_left_names_are_cut_at_their_logical_end() {
|
||||
let fits = |n: usize| move |s: &str| s.chars().count() <= n;
|
||||
// Cut anywhere, as egui elides: the first word and the start of the second.
|
||||
assert_eq!(elide("لاشي مكتوب هنا", fits(8)).as_deref(), Some("…مك لاشي"));
|
||||
assert_eq!(elide("לשלום עולם גדול", fits(11)).as_deref(), Some("…עולם לשלום"));
|
||||
assert_eq!(elide("لاشي مكتوب هنا", fits(8)).as_deref(), Some("لاشي مك…"));
|
||||
assert_eq!(elide("לשלום עולם גדול", fits(11)).as_deref(), Some("לשלום עולם…"));
|
||||
assert_eq!(elide("لاشي مكتوب هنا", fits(40)), None, "fits");
|
||||
assert_eq!(elide("Layer copy 12", fits(4)), None, "left to right: elided by egui at its end");
|
||||
assert_eq!(elide("نص", fits(0)).as_deref(), Some("…"), "nothing fits: just the ellipsis");
|
||||
assert!(is_rtl_paragraph("12 نص") && !is_rtl_paragraph("Layer نص") && !is_rtl_paragraph("123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brackets_mirror_and_arabic_punctuation_stays_with_its_word() {
|
||||
assert_eq!(visual("نص (1)"), "(1) نص");
|
||||
assert_eq!(visual("مرحبا، عالم"), "عالم مرحبا،");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,4 +1014,4 @@ pub(crate) fn set_craft_font_scale(ctx: &egui::Context, scale: f32) {
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests;
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -433,7 +433,7 @@ fn two_face_ttc(first: &[u8], second: &[u8]) -> Vec<u8> {
|
||||
|
||||
/// A minimal TrueType font mapping the `ranges` (inclusive) to box glyphs, so tests have a
|
||||
/// font for a script without committing font files.
|
||||
fn synthetic_font(ranges: &[(u32, u32)]) -> Vec<u8> {
|
||||
pub(crate) fn synthetic_font(ranges: &[(u32, u32)]) -> Vec<u8> {
|
||||
let be16 = |v: &mut Vec<u8>, x: i32| v.extend((x as u16).to_be_bytes());
|
||||
let be32 = |v: &mut Vec<u8>, x: u32| v.extend(x.to_be_bytes());
|
||||
let chars: u32 = ranges.iter().map(|(a, b)| b - a + 1).sum();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Right-to-left shell layout: the direction rules, and where the mirrored chrome lands in a
|
||||
//! running shell. No right-to-left catalog is registered yet, so the shell tests force the
|
||||
//! direction with `direction_scope`.
|
||||
//! running shell. The shell tests force the direction with `direction_scope`, so they don't
|
||||
//! depend on a right-to-left language being selected.
|
||||
|
||||
use super::*;
|
||||
use crate::PhotocraftApp;
|
||||
@@ -17,9 +17,10 @@ fn right_to_left_scripts_by_language_code() {
|
||||
for code in ["en", "ja", "zh-hans", "pt-br", "ku", "arn", ""] {
|
||||
assert_eq!(i18n::dir_for_code(code), Dir::Ltr, "{code}");
|
||||
}
|
||||
// No right-to-left catalog is registered yet, so the shell stays as it was.
|
||||
// Arabic is the one right-to-left catalog; every other registered language stays left to right.
|
||||
for lang in Lang::all() {
|
||||
assert_eq!(lang.dir(), Dir::Ltr, "{}", lang.code());
|
||||
let want = if lang.code() == "ar" { Dir::Rtl } else { Dir::Ltr };
|
||||
assert_eq!(lang.dir(), want, "{}", lang.code());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! Fonts a host serves next to the app, for the platform shell that fetches them (the web build;
|
||||
//! see `photocraft_text::served`): reading the manifest, the families to fetch, and installing
|
||||
//! what arrived, which registers the files and re-renders the type layers drawn without them.
|
||||
//! A served script fallback (Arabic, Japanese…) also joins the UI's font stack: the web build has
|
||||
//! no system fonts, so it is what draws an Arabic or Japanese interface.
|
||||
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
|
||||
use egui::{FontData, FontFamily};
|
||||
|
||||
use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer};
|
||||
pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests};
|
||||
@@ -12,12 +17,73 @@ use crate::PhotocraftApp;
|
||||
/// Layers whose document was busy with a background job when their font arrived.
|
||||
static PENDING: Mutex<Vec<(DocId, LayerId)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Served script fallbacks in the UI's font stack, by egui font name, unscaled: kept so the stack
|
||||
/// `theme::install_fonts` rebuilds on a language change includes them again.
|
||||
static UI_FACES: Mutex<Vec<(String, Arc<FontData>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The served faces for the end of the UI's font stack (see [`UI_FACES`]), the interface
|
||||
/// language's own first: a Japanese and a Simplified Chinese face both cover Han, and egui draws a
|
||||
/// character with the first face in the stack that has it, so a Chinese interface must not get
|
||||
/// the Japanese face only because it arrived first.
|
||||
pub fn ui_faces() -> Vec<(String, Arc<FontData>)> {
|
||||
let mut faces = UI_FACES.lock().unwrap_or_else(PoisonError::into_inner).clone();
|
||||
if let Some(own) = ui_language_face() {
|
||||
faces.sort_by_key(|(n, _)| *n != own);
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
/// The egui font name of the interface language's served script fallback, if the host serves one.
|
||||
fn ui_language_face() -> Option<String> {
|
||||
photocraft_text::served::fallback_for_language(crate::i18n::current().code()).map(|f| face_name(&f))
|
||||
}
|
||||
|
||||
fn face_name(family: &str) -> String {
|
||||
format!("served-{family}")
|
||||
}
|
||||
|
||||
/// Puts a delivered script fallback at the end of every UI font family, once. A file egui cannot
|
||||
/// read is left out: egui panics on a configured font it cannot parse.
|
||||
fn add_ui_face(ctx: &egui::Context, family: &str, bytes: &[u8]) {
|
||||
if !photocraft_text::cjk::font_face_is_readable(bytes, 0) {
|
||||
log::warn!("the served font {family:?} is not a readable font file; the interface does not use it");
|
||||
return;
|
||||
}
|
||||
let name = face_name(family);
|
||||
let mut data = {
|
||||
let mut faces = UI_FACES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if faces.iter().any(|(n, _)| *n == name) {
|
||||
return;
|
||||
}
|
||||
let data = FontData::from_owned(bytes.to_vec());
|
||||
faces.push((name.clone(), Arc::new(data.clone())));
|
||||
data
|
||||
};
|
||||
// The interface language's face arriving after another served face must go ahead of it, which
|
||||
// appending cannot do: rebuild the stack, as a language change does.
|
||||
let others = ctx.fonts(|f| f.definitions().font_data.keys().any(|n| n.starts_with("served-")));
|
||||
if others && ui_language_face().as_ref() == Some(&name) {
|
||||
crate::theme::install_fonts(ctx);
|
||||
return;
|
||||
}
|
||||
crate::theme::size_ui_font(ctx, &name, &mut data);
|
||||
let mut families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
|
||||
for f in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
if !families.contains(&f) {
|
||||
families.push(f);
|
||||
}
|
||||
}
|
||||
let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect();
|
||||
ctx.add_font(FontInsert { name, data, families });
|
||||
}
|
||||
|
||||
/// Registers delivered font files (`(family, bytes)`, the family as the manifest lists it) and
|
||||
/// re-renders the type layers that use those families, or whose text falls back to them (a script
|
||||
/// fallback, e.g. Arabic set in Inter), and show our own layout. A PSD layer still
|
||||
/// showing Photoshop's pixels (not edited yet) keeps them, as it does when its font is installed
|
||||
/// from the start. Call it every frame: it also retries the layers of a document that was busy.
|
||||
pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec<u8>)>) {
|
||||
/// from the start. A script fallback also joins the UI's font stack. Call it every frame: it also
|
||||
/// retries the layers of a document that was busy.
|
||||
pub fn install(app: &mut PhotocraftApp, ctx: &egui::Context, delivered: Vec<(String, Vec<u8>)>) {
|
||||
let mut layers = std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
if !delivered.is_empty() {
|
||||
let families: Vec<String> = delivered.iter().map(|(f, _)| f.clone()).collect();
|
||||
@@ -34,6 +100,12 @@ pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec<u8>)>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let fallbacks = photocraft_text::served::script_fallback_families();
|
||||
for (family, bytes) in &delivered {
|
||||
if fallbacks.contains(family) {
|
||||
add_ui_face(ctx, family, bytes);
|
||||
}
|
||||
}
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
for (family, bytes) in delivered {
|
||||
let added = eng.fonts.register_font_data(bytes);
|
||||
@@ -89,4 +161,97 @@ mod tests {
|
||||
assert!(!redraws_for(&layer("hello"), &[thai.into()]));
|
||||
assert!(redraws_for(&layer("hello"), &["Inter".into()]), "and a layer still redraws for its own family");
|
||||
}
|
||||
|
||||
/// #10006: the web build has no system fonts, so a served Arabic fallback is what draws an
|
||||
/// Arabic interface, also after a language change rebuilds the UI's font stack.
|
||||
#[test]
|
||||
fn a_served_script_fallback_draws_the_ui_and_survives_a_font_rebuild() {
|
||||
// The web build's sources: no system fonts, no embedded craft fonts.
|
||||
let web = || crate::cjk_fonts::Sources {
|
||||
locale: || None,
|
||||
files: |_| Vec::new(),
|
||||
last_resort: Vec::new,
|
||||
pending: || false,
|
||||
scripts: |_| Vec::new(),
|
||||
covers: |_, _| None,
|
||||
embedded: crate::cjk_fonts::no_embedded,
|
||||
eager_craft_fonts: false,
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts_with(&ctx, web());
|
||||
ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic);
|
||||
// Font changes take effect on the next pass: one empty pass, then draw Arabic.
|
||||
let draw = |ctx: &egui::Context, text: &str| {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
ui.label(text);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
};
|
||||
draw(&ctx, "File");
|
||||
add_ui_face(&ctx, "Served Test Arabic", &crate::cjk_fonts::tests::synthetic_font(&[(0x0600, 0x06FF)]));
|
||||
draw(&ctx, "File");
|
||||
draw(&ctx, "ملف");
|
||||
// A language change rebuilds the stack; the served face comes back with it.
|
||||
crate::theme::install_fonts_with(&ctx, web());
|
||||
draw(&ctx, "File");
|
||||
draw(&ctx, "ملف");
|
||||
}
|
||||
|
||||
/// #10006: a host serving a broken file as a script fallback must not stop the interface;
|
||||
/// egui panics on a configured font it cannot parse.
|
||||
#[test]
|
||||
fn an_unreadable_served_font_stays_out_of_the_ui() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let draw = |ctx: &egui::Context| {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
ui.label("File ملف");
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
};
|
||||
draw(&ctx);
|
||||
add_ui_face(&ctx, "Served Test Broken", b"<html>not found</html>");
|
||||
draw(&ctx);
|
||||
crate::theme::install_fonts(&ctx);
|
||||
draw(&ctx);
|
||||
assert!(!ui_faces().iter().any(|(n, _)| n == "served-Served Test Broken"));
|
||||
}
|
||||
|
||||
/// #10006: a Japanese and a Simplified Chinese face both cover Han. A Chinese interface draws
|
||||
/// it with the Chinese one, even when the Japanese face arrived first.
|
||||
#[test]
|
||||
fn the_interface_languages_served_face_comes_first_in_the_ui_stack() {
|
||||
let (jpan, hans) = ("Served Test Jpan", "Served Test Hans");
|
||||
photocraft_text::served::add_fonts(&parse_manifest(&format!("{jpan} | Regular | fonts/j/J.ttf | Jpan\n{hans} | Regular | fonts/h/H.ttf | Hans\n")).0);
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_code("zh-hans").expect("Simplified Chinese"));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let draw = |ctx: &egui::Context| {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
ui.label("文件");
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
};
|
||||
let order = |ctx: &egui::Context| {
|
||||
let stack = ctx.fonts(|f| f.definitions().families[&FontFamily::Proportional].clone());
|
||||
let at = |family: &str| stack.iter().position(|n| *n == format!("served-{family}"));
|
||||
(at(hans), at(jpan))
|
||||
};
|
||||
draw(&ctx);
|
||||
let han = crate::cjk_fonts::tests::synthetic_font(&[(0x4E00, 0x9FFF)]);
|
||||
add_ui_face(&ctx, jpan, &han);
|
||||
draw(&ctx);
|
||||
add_ui_face(&ctx, hans, &han);
|
||||
draw(&ctx);
|
||||
let (h, j) = order(&ctx);
|
||||
assert!(h.is_some() && h < j, "the Chinese face goes ahead of the Japanese one: {h:?} {j:?}");
|
||||
crate::theme::install_fonts(&ctx);
|
||||
draw(&ctx);
|
||||
let (h, j) = order(&ctx);
|
||||
assert!(h.is_some() && h < j, "also after a rebuild: {h:?} {j:?}");
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,6 +878,13 @@ pub fn install_fonts_with(ctx: &egui::Context, cjk: crate::cjk_fonts::Sources) {
|
||||
// family stack ahead of the Japanese or Korean face, and a locale without a Traditional Chinese
|
||||
// craft face leaves Han to the system font. (A single linear per-family chain still decides a
|
||||
// glyph once a broad face is discovered; see `cjk_fonts`.)
|
||||
// Served script fallbacks the web build has received (Arabic, Japanese…), after everything else.
|
||||
for (name, data) in crate::served_fonts::ui_faces() {
|
||||
fonts.font_data.insert(name.clone(), data);
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
fonts.families.entry(family).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
// Named weights fall back to the default stack for missing glyphs.
|
||||
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
for (fam, primary) in [("medium", medium), ("semibold", semibold)] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Swedish UI catalog; previously minor: Arabic paragraph direction buttons, #6793) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-29 · **Change:** minor (Swedish UI catalog; previously minor: Arabic UI catalog) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
|
||||
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
|
||||
@@ -24,7 +24,7 @@ Vietnamese UI; Arabic and Hebrew users get English "Middle East" builds with the
|
||||
composer, and its text engine handles RTL, Devanagari and Indic scripts.
|
||||
|
||||
**Summary.** 7 of the 12 key languages at 100% of measured keys (status `partial` because of the
|
||||
engine messages), 3 absent (Hindi, Arabic, Vietnamese), plus 8 other languages. Weighted by
|
||||
engine messages), 2 absent (Hindi, Vietnamese), plus 8 other languages. Weighted by
|
||||
speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorded only for Italian,
|
||||
2026-10-12).
|
||||
|
||||
@@ -36,7 +36,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde
|
||||
| Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h |
|
||||
| Spanish | `es` | 1,844 / 1,844 (100%) | 4,010 | as above; complete dynamic UI key set | Latin | no | partial | 6–10 h |
|
||||
| Hindi | `hi` | partial | 448 | Top menus, toolbox tools, camera raw, dialogs, plurals | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | partial | 15–20 h |
|
||||
| Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction buttons but no visual RTL caret (TYPE-218-2 partial), layer names as tofu (#1408, #1693) | no | none | 40–60 h |
|
||||
| Arabic | `ar` | 2,175 / 2,175 (100%, 2026-10-28) | 3,410 | as above; combined from the community catalogs with one term per concept | Arabic shaping and bidi in the UI (egui 0.37) and the text engine, Arabic fallback font; RTL shell layout but not inside panels (DIST-220-4), paragraph direction buttons but no visual RTL caret (TYPE-218-2 partial) | no | partial | 15–25 h |
|
||||
| French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h |
|
||||
| Portuguese (Brazil) | `pt-br` | 1,608 / 1,608 (100%) | 2,788 | as above | Latin; every `pt` locale uses it | no | partial | 6–10 h |
|
||||
| Indonesian | `id` | 1,608 / 1,608 (100%) | 2,764 | as above | Latin | no | partial | 6–10 h |
|
||||
@@ -72,9 +72,9 @@ engine localization or native review is complete.
|
||||
- Engine error, status and parameter-help messages are English in every language.
|
||||
- Right-to-left UI layout is partial (2026-10-10): the shell mirrors for Arabic, Hebrew, Persian,
|
||||
Urdu and other right-to-left codes (`direction.rs`), but panel interiors, dialogs, menu rows,
|
||||
submenus and document tabs stay left to right. UI labels also lack an Arabic fallback font, and
|
||||
egui 0.36 joins letters within a word but orders a line's words left to right (no bidi).
|
||||
- Web build: no browser-locale detection, no CJK web fonts.
|
||||
submenus and document tabs stay left to right. Word order within a label is right: egui 0.37
|
||||
lays text out with the Unicode Bidirectional Algorithm.
|
||||
- Web build: no browser-locale detection. It has no system fonts: Arabic, CJK and other non-Latin interface labels are drawn with the host's served script fonts (`packaging/web/README.md` › Fonts), and show as boxes on a host that serves none.
|
||||
- WebAssembly plug-ins disappear from Filter › Plug-ins when the UI isn't English (#2540).
|
||||
- A user couldn't find the language option (#2532): it is in Preferences › Interface.
|
||||
|
||||
@@ -94,4 +94,5 @@ engine localization or native review is complete.
|
||||
| 2026-10-13 | minor | Italian: the `Screen` blend mode read *Schermo* (the display) and is now *Scherma*; the Dodge/Burn/Sponge tool names were already Adobe's (*Scherma*, *Brucia*, *Spugna*) and stay. Pinned by a test |
|
||||
| 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) |
|
||||
| 2026-10-22 | minor | Arabic row: paragraph direction has Auto / Left-to-right / Right-to-left buttons in the Paragraph panel and Type options bar (#6793); the visual RTL caret is still missing (TYPE-218-2 partial) |
|
||||
| 2026-10-28 | minor | Swedish (`sv`) UI catalog: every measured key plus the dynamic labels, combined from #5985 and #9536 |
|
||||
| 2026-10-28 | minor | Arabic (`ar`) UI catalog: every measured key, six plural forms; layer names and the title bar no longer reversed on egui 0.37 |
|
||||
| 2026-10-29 | minor | Swedish (`sv`) UI catalog: every measured key plus the dynamic labels, combined from #5985 and #9536 |
|
||||
|
||||
@@ -105,13 +105,15 @@ title, options and status bars read from the right (`crates/ui-egui/src/directio
|
||||
direction follows the language code, so a catalog needs no extra setting.
|
||||
|
||||
Not mirrored yet: panel interiors, dialogs, menu rows (shortcuts stay on the right), submenus
|
||||
(they open to the right), document tabs and the canvas. Before an Arabic catalog reads
|
||||
correctly, the UI also needs an Arabic fallback font (the bundled UI fonts have no Arabic
|
||||
glyphs) and bidirectional word order: egui 0.36 shapes each right-to-left word with HarfRust,
|
||||
so letters join, but lays a line's words out left to right, and its caret indexing is off in
|
||||
right-to-left text (emilk/egui#8576, #8577). Document text is laid out by the text engine.
|
||||
(they open to the right), document tabs and the canvas. egui 0.37 lays text out with the Unicode
|
||||
Bidirectional Algorithm (each paragraph takes the direction of its first strong letter), so
|
||||
catalogs, layer names and document names are handed to it in logical order, as typed; never
|
||||
pre-reorder a label. The title line forces a left-to-right paragraph so "— PhotoCraft" stays at
|
||||
the right of a right-to-left document name, and `bidi_text::elide` cuts a right-to-left name at
|
||||
its logical end. Document text is laid out by the text engine.
|
||||
|
||||
No right-to-left catalog ships yet. To check the mirrored layout with any language, launch with
|
||||
Arabic (`ar`) is the first right-to-left catalog: Modern Standard Arabic, Western digits, the six
|
||||
CLDR plural forms (zero|one|two|few|many|other). To check the mirrored layout with any language, launch with
|
||||
`PHOTOCRAFT_UI_DIRECTION=rtl` (or `ltr` to force left-to-right); it is read once per process
|
||||
and has no effect on the web build:
|
||||
|
||||
|
||||
+1
-1
@@ -293,7 +293,7 @@ Never-crash attribute on 30 of 30 crate roots.
|
||||
| DIST-220-1 | Help > Check for Updates plus an opt-out periodic release-feed check on all desktops | partial | [#220](https://github.com/storytold/photocraft/issues/220) | 2026-10-22 (#5485): Help > Check for Updates and an opt-out check once per launch (updates_cmds + the desktop HTTPS transport; none inside Flatpak, where flatpak update owns updates). Missing: a periodic recheck while the app stays open |
|
||||
| DIST-220-2 | Windows ARM64 (aarch64-pc-windows-msvc) in the release matrix, signed like x64 | done | [#220](https://github.com/storytold/photocraft/issues/220) | release.yml has { arch: arm64, target: aarch64-pc-windows-msvc } in the Windows matrix, and .github/workflows/windows-arm64.yml packages that MSI, installs it on a Windows 11 ARM64 runner, checks the installed binaries are ARM64 and runs the CLI natively before uninstalling. Signing takes the same path as x64: the secrets are optional and a missing one produces an unsigned artifact and a warning (see DIST-2) |
|
||||
| DIST-220-3 | In-app bug report with reviewable diagnostics that opens a prefilled issue | missing | [#220](https://github.com/storytold/photocraft/issues/220) | No bug report dialog in crates |
|
||||
| DIST-220-4 | Translation coverage per language reported by xtask; RTL layout for Arabic and Hebrew | partial | [#220](https://github.com/storytold/photocraft/issues/220) | `cargo xtask i18n-coverage` reports per-language coverage (#427); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs |
|
||||
| DIST-220-4 | Translation coverage per language reported by xtask; RTL layout for Arabic and Hebrew | partial | [#220](https://github.com/storytold/photocraft/issues/220) | `cargo xtask i18n-coverage` reports per-language coverage (#427); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs; Arabic (`ar`) is the first RTL catalog |
|
||||
| DIST-220-5 | Linux AppImage x86_64 + aarch64 ship; Flatpak kept; Flathub later (#173) | partial | [#220](https://github.com/storytold/photocraft/issues/220) | AppImage and Flatpak ship (release.yml:162-216); Flathub not done |
|
||||
|
||||
## Open bugs
|
||||
|
||||
@@ -66,7 +66,7 @@ id = "DIST-220-4"
|
||||
target = "Translation coverage per language reported by xtask; RTL layout for Arabic and Hebrew"
|
||||
status = "partial"
|
||||
issue = 220
|
||||
note = "`cargo xtask i18n-coverage` reports per-language coverage (#427); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs"
|
||||
note = "`cargo xtask i18n-coverage` reports per-language coverage (#427); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs; Arabic (`ar`) is the first RTL catalog"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-220-5"
|
||||
|
||||
Reference in New Issue
Block a user