Browse / github.com/storytold/gridcraft / patch
fix(ui): Persian/Arabic system fonts and script-only word order
fix(ui): Persian/Arabic system fonts and script-only word order ## What this changes Persian and Arabic cells currently render as tofu boxes or in the wrong word order on Linux when the workbook font (usually Calibri) has no Arabic glyphs: - **Font discovery:** the installed-font scan now follows fontconfig’s `<dir>` and `<include>` configuration, including directory includes, `~`, `prefix="xdg"` and `FONTCONFIG_FILE`. It is still a plain filesystem scan (no new dependency, no `fc-list`/`fc-match` process), and malformed or missing configuration is ignored. This means a font is found where fontconfig points, not only under the fixed distribution paths. -
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- vergoboy (imported from pull request #2714, open)
- Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb- Size
- +712 −43 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
040bdc63fb82c9527155f1c0
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/ui-egui/src/fonts.rs crates/ui-egui/src/formula_bar.rs crates/ui-egui/src/grid/editor.rs crates/ui-egui/src/grid/quadrant.rs crates/ui-egui/src/theme.rs crates/ui-egui/src/theme/font_fallback.rs crates/ui-egui/src/theme/font_setup.rs crates/ui-egui/src/theme/tests.rs crates/ui-egui/tests/persian.rs docs/gaps.md docs/localization-parity.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (Linux font discovery follows fontconfig; Arabic/Persian fallback, shaping and script-only word order) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---|+| 2026-10-27 | Linux font discovery follows fontconfig's `<dir>` and `<include>` entries instead of only fixed paths, so a Persian/Arabic font is found wherever it is installed. The Arabic-script fallback now includes Vazirmatn, Noto Sans/Naskh/Kufi Arabic, Shabnam, Sahel, Amiri and the platform faces; HarfRust shapes and joins Persian/Arabic, and an Arabic-only cell whose chosen font lacks Arabic leads with the script face so “سلام دنیا” keeps its word order. Bidi, mixed Latin/Arabic order, wrapped RTL lines and RTL alignment remain unsupported | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |@@ -10,7 +10,7 @@ //! Native only: on wasm there are no system fonts, every function here reports "nothing", and the //! font list is the standard [`crate::ribbon::FONTS`] set. -use std::collections::HashSet;+use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::OnceLock; @@ -41,6 +41,9 @@ pub struct Face { pub weight: f32, /// Whether the style name reads as italic or oblique. pub italic: bool,+ /// Whether the face maps a common Arabic letter. A cell that names this family can then shape+ /// Persian/Arabic in one face instead of splitting the line at a fallback boundary.+ pub arabic: bool, } /// The font folders of the platform (the system's and the user's). Empty on wasm.@@ -70,11 +73,200 @@ pub fn system_font_dirs() -> Vec<PathBuf> { dirs.push(h.join(".fonts")); dirs.push(h.join(".local/share/fonts")); }+ // Folders fontconfig is configured with: a distribution need not keep every font under+ // the standard paths above, and a user's own folder may live anywhere. Parsing+ // fontconfig's configuration keeps discovery dynamic instead of guessing layouts.+ dirs.extend(fontconfig_dirs()); } dirs } } +/// Font folders from fontconfig's configuration, so a font installed where fontconfig points is+/// found even when it is not under a distribution's default path (a user's font folder, a custom+/// prefix, ChromeOS's Linux container). Follows `<include>` elements (a directory include pulls in+/// its `.conf` files) and expands `~` and `prefix="xdg"`. A missing or malformed file contributes+/// nothing, never a panic; empty on wasm.+pub fn fontconfig_dirs() -> Vec<PathBuf> {+ #[cfg(target_arch = "wasm32")]+ {+ Vec::new()+ }+ #[cfg(not(target_arch = "wasm32"))]+ {+ let mut out: Vec<PathBuf> = Vec::new();+ let mut seen_dirs: HashSet<PathBuf> = HashSet::new();+ let mut seen_files: HashSet<PathBuf> = HashSet::new();+ for file in fontconfig_config_files() {+ parse_fontconfig_file(&file, &mut out, &mut seen_dirs, &mut seen_files, 0);+ }+ out+ }+}++/// The fontconfig configuration files, system first then user, as fontconfig reads them. The+/// `FONTCONFIG_FILE` environment variable overrides the main configuration file when set.+#[cfg(not(target_arch = "wasm32"))]+fn fontconfig_config_files() -> Vec<PathBuf> {+ let home = std::env::var_os("HOME").map(PathBuf::from);+ let mut files: Vec<PathBuf> = Vec::new();+ match std::env::var_os("FONTCONFIG_FILE") {+ Some(f) => files.push(PathBuf::from(f)),+ None => files.push(PathBuf::from("/etc/fonts/fonts.conf")),+ }+ files.push(PathBuf::from("/etc/fonts/local.conf"));+ if let Some(config) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home.as_ref().map(|h| h.join(".config"))) {+ files.push(config.join("fontconfig/fonts.conf"));+ }+ if let Some(h) = &home {+ files.push(h.join(".fonts.conf")); // the pre-XDG location+ }+ files+}++/// How deep `<include>`s are followed before giving up, so a cycle can't spin forever.+#[cfg(not(target_arch = "wasm32"))]+const MAX_FONTCONFIG_DEPTH: usize = 16;++/// Read one fontconfig file: collect its `<dir>` folders and recurse into its `<include>`s. An+/// include of a directory pulls in that directory's `.conf` files, as fontconfig does.+#[cfg(not(target_arch = "wasm32"))]+fn parse_fontconfig_file(+ path: &std::path::Path,+ out: &mut Vec<PathBuf>,+ seen_dirs: &mut HashSet<PathBuf>,+ seen_files: &mut HashSet<PathBuf>,+ depth: usize,+) {+ if depth > MAX_FONTCONFIG_DEPTH {+ return;+ }+ let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());+ if !seen_files.insert(key) {+ return;+ }+ if path.is_dir() {+ let mut entries: Vec<PathBuf> = std::fs::read_dir(path)+ .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("conf"))).collect())+ .unwrap_or_default();+ entries.sort();+ for entry in entries {+ parse_fontconfig_file(&entry, out, seen_dirs, seen_files, depth + 1);+ }+ return;+ }+ let Ok(text) = std::fs::read_to_string(path) else { return };+ let base = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();+ for (attrs, inner) in xml_elements(&text, "include") {+ let value = decode_xml_entities(inner).trim().to_string();+ if value.is_empty() {+ continue;+ }+ let target = resolve_fontconfig_path(&value, xml_attr(&attrs, "prefix").as_deref(), &base);+ parse_fontconfig_file(&target, out, seen_dirs, seen_files, depth + 1);+ }+ for (attrs, inner) in xml_elements(&text, "dir") {+ let value = decode_xml_entities(inner).trim().to_string();+ if value.is_empty() {+ continue;+ }+ let dir = resolve_fontconfig_path(&value, xml_attr(&attrs, "prefix").as_deref(), &base);+ if dir.is_dir() {+ let key = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());+ if seen_dirs.insert(key) {+ out.push(dir);+ }+ }+ }+}++/// Resolve a fontconfig path value: `~` is the home folder, `prefix="xdg"` means+/// `$XDG_DATA_HOME` (defaulting to `~/.local/share`), `prefix="cwd"` the working folder, and any+/// other relative value is relative to the file that named it.+#[cfg(not(target_arch = "wasm32"))]+fn resolve_fontconfig_path(value: &str, prefix: Option<&str>, base: &std::path::Path) -> PathBuf {+ let home = std::env::var_os("HOME").map(PathBuf::from);+ if let Some(rest) = value.strip_prefix("~/") {+ return home.map(|h| h.join(rest)).unwrap_or_else(|| PathBuf::from(value));+ }+ if value == "~" {+ return home.unwrap_or_else(|| PathBuf::from(value));+ }+ let path = PathBuf::from(value);+ if path.is_absolute() {+ return path;+ }+ match prefix {+ Some("xdg") => std::env::var_os("XDG_DATA_HOME")+ .map(PathBuf::from)+ .or_else(|| home.map(|h| h.join(".local/share")))+ .map(|d| d.join(value))+ .unwrap_or_else(|| base.join(value)),+ Some("cwd") => std::env::current_dir().map(|d| d.join(value)).unwrap_or_else(|_| base.join(value)),+ _ => base.join(value),+ }+}++/// The attributes and text content of every `<name>` element in `text`, in document order. Not a+/// full XML parser: fontconfig's configuration only uses simple elements, and malformed input is+/// ignored rather than rejected.+#[cfg(not(target_arch = "wasm32"))]+fn xml_elements<'a>(text: &'a str, name: &str) -> Vec<(String, &'a str)> {+ let open = format!("<{name}");+ let close = format!("</{name}>");+ let mut out: Vec<(String, &str)> = Vec::new();+ let mut rest = text;+ while let Some(i) = rest.find(&open) {+ rest = &rest[i + open.len()..];+ // The tag name must end here: `<dirty>` is not `<dir>`.+ if !rest.starts_with(|c: char| c.is_whitespace() || c == '>' || c == '/') {+ continue;+ }+ let Some(gt) = rest.find('>') else { break };+ let attrs = rest[..gt].to_string();+ let self_closing = attrs.trim_end().ends_with('/');+ rest = &rest[gt + 1..];+ if self_closing {+ out.push((attrs, ""));+ continue;+ }+ let Some(end) = rest.find(&close) else { break };+ out.push((attrs, &rest[..end]));+ rest = &rest[end + close.len()..];+ }+ out+}++/// The value of the XML attribute `name` in `attrs`, if quoted with `"` or `'`.+#[cfg(not(target_arch = "wasm32"))]+fn xml_attr(attrs: &str, name: &str) -> Option<String> {+ let key = format!("{name}=");+ let mut rest = attrs;+ while let Some(i) = rest.find(&key) {+ if rest[..i].chars().next_back().is_none_or(char::is_whitespace) {+ let after = &rest[i + key.len()..];+ let quote = after.chars().next()?;+ if quote == '"' || quote == '\'' {+ let value = &after[quote.len_utf8()..];+ if let Some(end) = value.find(quote) {+ return Some(value[..end].to_string());+ }+ }+ }+ rest = &rest[i + key.len()..];+ }+ None+}++/// Decode the XML entities that can appear in a fontconfig path. Anything unknown is left as is.+#[cfg(not(target_arch = "wasm32"))]+fn decode_xml_entities(text: &str) -> String {+ if !text.contains('&') {+ return text.to_string();+ }+ text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")+}+ /// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300). #[cfg(not(target_arch = "wasm32"))] fn style_weight(style: &str) -> f32 {@@ -111,10 +303,10 @@ fn style_italic(style: &str) -> bool { s.contains("italic") || s.contains("oblique") } -/// Every face in `data` as `(index, family, names, style)`, where `names` holds every family name-/// (see [`Face::names`]). Damaged data yields nothing, never a panic.+/// Every face in `data` as `(index, family, names, style, arabic)`, where `names` holds every+/// family name (see [`Face::names`]). Damaged data yields nothing, never a panic. #[cfg(not(target_arch = "wasm32"))]-fn file_faces(data: &[u8]) -> Vec<(u32, String, Vec<String>, String)> {+fn file_faces(data: &[u8]) -> Vec<(u32, String, Vec<String>, String, bool)> { use skrifa::raw::FileRef; use skrifa::{MetadataProvider, string::StringId}; let count = match FileRef::new(data) {@@ -141,7 +333,10 @@ fn file_faces(data: &[u8]) -> Vec<(u32, String, Vec<String>, String)> { } } let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());- Some((i, family, names, style))+ // A representative Arabic letter, enough to tell an Arabic-capable face from a Latin-only+ // one without trusting its family name.+ let arabic = f.charmap().map('\u{0633}').is_some();+ Some((i, family, names, style, arabic)) }) .collect() }@@ -173,8 +368,8 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> { continue; } let Ok(data) = std::fs::read(&path) else { continue };- for (index, family, names, style) in file_faces(&data) {- out.push(Face { family, names, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) });+ for (index, family, names, style, arabic) in file_faces(&data) {+ out.push(Face { family, names, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style), arabic }); } } }@@ -224,6 +419,30 @@ pub fn faces_of(family: &str) -> Vec<Face> { catalog_now().map(|cat| faces_named(cat, family)).unwrap_or_default() } +/// One lookup per family and weight: cell painting asks this for every visible Arabic cell, while+/// the catalog scan is linear in installed faces.+static ARABIC_FAMILIES: OnceLock<std::sync::RwLock<HashMap<(String, bool, bool), bool>>> = OnceLock::new();++fn arabic_families() -> &'static std::sync::RwLock<HashMap<(String, bool, bool), bool>> {+ ARABIC_FAMILIES.get_or_init(|| std::sync::RwLock::new(HashMap::new()))+}++/// Whether the face the family would use for this weight/slant maps Arabic letters. `false` until+/// the background scan has found the family, and when that primary face has no Arabic coverage (its+/// regular sibling does not help: a space would resolve to the primary face and split the line).+pub fn family_supports_arabic(name: &str, bold: bool, italic: bool) -> bool {+ if catalog_now().is_none() {+ return false;+ }+ let key = (name.to_string(), bold, italic);+ if let Some(answer) = arabic_families().read().unwrap_or_else(|e| e.into_inner()).get(&key) {+ return *answer;+ }+ let answer = pick_face(&faces_of(name), bold, italic).is_some_and(|f| f.arabic);+ arabic_families().write().unwrap_or_else(|e| e.into_inner()).insert(key, answer);+ answer+}+ /// The faces in `cat` that go by `wanted`. When two families claim one name the winner is fixed, /// not left to the scan's directory order: a family whose own (English) name it is wins, else the /// family of the first claiming face by path and index. Only the faces that carry the name are@@ -461,7 +680,7 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let path = dir.join("Ubuntu-Light.ttf"); std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write");- let face = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into()], path, index: 0, weight: 300.0, italic: false };+ let face = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into()], path, index: 0, weight: 300.0, italic: false, arabic: false }; let mut fonts = egui::FontDefinitions::default(); let bound = bind_faces(&mut fonts, "Ubuntu", std::slice::from_ref(&face));@@ -512,7 +731,15 @@ mod tests { const KO: u16 = 0x0412; fn face(family: &str, names: &[&str], path: &str, weight: f32) -> Face {- Face { family: family.into(), names: names.iter().map(|s| s.to_string()).collect(), path: path.into(), index: 0, weight, italic: false }+ Face {+ family: family.into(),+ names: names.iter().map(|s| s.to_string()).collect(),+ path: path.into(),+ index: 0,+ weight,+ italic: false,+ arabic: false,+ } } #[cfg(not(target_arch = "wasm32"))]@@ -522,14 +749,14 @@ mod tests { let font = font_with_names(&[(EN, 1, "Malgun Gothic"), (EN, 2, "Regular"), (KO, 1, "맑은 고딕"), (KO, 2, "보통")]); let faces = file_faces(&font); assert_eq!(faces.len(), 1);- let (_, family, names, style) = &faces[0];+ let (_, family, names, style, _) = &faces[0]; assert_eq!(family, "Malgun Gothic", "the font list keeps the English name"); assert_eq!(names, &["Malgun Gothic", "맑은 고딕"]); assert_eq!(style, "Regular"); // Both name IDs are kept: a legacy family name (ID 1) names a single face (#1606). let font = font_with_names(&[(EN, 1, "Noto Sans KR Medium"), (EN, 2, "Regular"), (EN, 16, "Noto Sans KR"), (EN, 17, "Medium")]);- let (_, family, names, _) = &file_faces(&font)[0];+ let (_, family, names, _, _) = &file_faces(&font)[0]; assert_eq!(family, "Noto Sans KR"); assert_eq!(names, &["Noto Sans KR", "Noto Sans KR Medium"]); }@@ -539,7 +766,7 @@ mod tests { fn a_name_table_full_of_names_is_capped() { let texts: Vec<String> = (0..100).map(|i| format!("Family {i}")).collect(); let records: Vec<(u16, u16, &str)> = texts.iter().enumerate().map(|(i, t)| (0x0400 + i as u16, 1, t.as_str())).collect();- let (_, _, names, _) = &file_faces(&font_with_names(&records))[0];+ let (_, _, names, _, _) = &file_faces(&font_with_names(&records))[0]; assert_eq!(names.len(), MAX_FAMILY_NAMES); } @@ -599,7 +826,15 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let path = dir.join("Ubuntu-Light.ttf"); std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("write");- let f = Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into(), "우분투".into()], path, index: 0, weight: 300.0, italic: false };+ let f = Face {+ family: "Ubuntu".into(),+ names: vec!["Ubuntu".into(), "우분투".into()],+ path,+ index: 0,+ weight: 300.0,+ italic: false,+ arabic: false,+ }; let mut fonts = egui::FontDefinitions::default(); let before = fonts.font_data.len();@@ -611,6 +846,40 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(not(target_arch = "wasm32"))]+ #[test]+ fn fontconfig_configuration_adds_directories_and_follows_includes() {+ let base = std::env::temp_dir().join(format!("gridcraft-fontconfig-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&base);+ for d in ["fonts", "relative", "conf.d/fonts"] {+ std::fs::create_dir_all(base.join(d)).expect("temp dir");+ }+ // A directory include pulls in its .conf files, and a relative <dir> is relative to the+ // file that names it. A cycle (second.conf includes the root again) must terminate.+ std::fs::write(base.join("conf.d/10-extra.conf"), "<fontconfig><dir>fonts</dir></fontconfig>").expect("write");+ std::fs::write(+ base.join("root.conf"),+ "<fontconfig><include>conf.d</include><include>second.conf</include><dir>relative</dir><dir>missing</dir></fontconfig>",+ )+ .expect("write");+ std::fs::write(base.join("second.conf"), "<fontconfig><include>root.conf</include></fontconfig>").expect("write");++ let mut out: Vec<PathBuf> = Vec::new();+ let mut seen_dirs: HashSet<PathBuf> = HashSet::new();+ let mut seen_files: HashSet<PathBuf> = HashSet::new();+ parse_fontconfig_file(&base.join("root.conf"), &mut out, &mut seen_dirs, &mut seen_files, 0);+ assert!(out.iter().any(|p| p.ends_with("conf.d/fonts")), "directory include: {out:?}");+ assert!(out.iter().any(|p| p.ends_with("relative")), "relative dir: {out:?}");+ assert!(!out.iter().any(|p| p.ends_with("missing")), "a non-directory is not offered: {out:?}");++ // Malformed input contributes nothing and never panics.+ std::fs::write(base.join("broken.conf"), "<fontconfig><dir>unclosed").expect("write");+ let mut out = Vec::new();+ parse_fontconfig_file(&base.join("broken.conf"), &mut out, &mut HashSet::new(), &mut HashSet::new(), 0);+ assert!(out.is_empty());+ let _ = std::fs::remove_dir_all(&base);+ }+ /// On a Windows machine with Malgun Gothic, the real file carries its Korean name. #[cfg(windows)] #[test]@@ -618,6 +887,6 @@ mod tests { let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows")); let Ok(data) = std::fs::read(root.join("Fonts").join("malgun.ttf")) else { return }; let faces = file_faces(&data);- assert!(faces.iter().any(|(_, family, names, _)| family == "Malgun Gothic" && names.iter().any(|n| n == "맑은 고딕")), "{faces:?}");+ assert!(faces.iter().any(|(_, family, names, _, _)| family == "Malgun Gothic" && names.iter().any(|n| n == "맑은 고딕")), "{faces:?}"); } }@@ -111,13 +111,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let id = egui::Id::unique("gridcraft.formula_bar"); // A spill child shows its anchor's formula muted and read-only. let read_only = active_spill_anchor(app).is_some();- let info = if app.editor.is_some() {- editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height())+ let info = if let Some(edit) = app.editor.as_ref() {+ let edit_font = theme::font_for_text(font.clone(), &edit.text);+ editor_widget(app, &mut child, id, edit_font, true, inner.width(), inner.height()) } else { // Show the active cell's content; clicking starts editing in the bar. let text = active_input(app); let color = if read_only { t.text_disabled } else { t.text };- let galley = child.painter().layout(text, font.clone(), color, inner.width());+ let text_font = theme::font_for_text(font.clone(), &text);+ let galley = child.painter().layout(text, text_font, color, inner.width()); let lines = galley.rows.len(); // Each cell's view starts at its top: the offset is kept per cell, not per bar. let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active.row, d.selection.active.col));@@ -28,7 +28,7 @@ pub(super) fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, s }; let st = wb.styles.get(sh.style_id(cell)).clone(); let size = st.font.size * 96.0 / 72.0 * geo.z;- let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic));+ let font = FontId::new(size, theme::cell_family_for_text(&st.font.name, st.font.bold, st.font.italic, &ed.text)); let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; // Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar // is the one that stops at five lines).@@ -231,6 +231,8 @@ pub(crate) fn paint_quadrant( if text.is_empty() { continue; }+ // Arabic-only text leads with an Arabic face so egui does not split it at the spaces.+ let font = FontId::new(size, theme::cell_family_for_text(&st.font.name, st.font.bold, st.font.italic, &text)); let color = ncolor.unwrap_or(lk.text_color); let halign = match st.align.h { HAlign::General => match v {@@ -25,12 +25,13 @@ mod font_setup; pub use font_fallback::symbol_face_installed; #[cfg(test)]-use font_fallback::{cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, symbols_more, thai};+use font_fallback::{arabic, cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, symbols_more, thai}; use font_setup::pick; pub use font_setup::{- CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MONO, SERIF, UI, UI_BOLD, available_text, cell_family, cell_text_width,- chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required, font_definitions,- font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font,+ CELL, CELL_ARABIC, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MONO, SERIF, UI, UI_BOLD, available_text, cell_family,+ cell_family_for_text, cell_text_width, chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required,+ font_definitions, font_definitions_for_language, font_definitions_with, font_for_text, pinned_font_definitions, symbol_fallback, ui_bold,+ ui_font, }; #[cfg(test)] use font_setup::{candidates, craft_font_faces, try_load};@@ -54,6 +54,7 @@ pub(super) fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-lao", lao()),+ ("sys-arabic", arabic()), ("sys-hebrew", hebrew()), ("sys-symbols", symbols()), ("sys-symbols-2", symbols_more()),@@ -241,6 +242,70 @@ pub(super) fn hebrew() -> Vec<(String, u32)> { ) } +/// Arabic-script faces (Arabic, Persian, Urdu). Windows ships Segoe UI, Tahoma and Arial; macOS has+/// Geeza Pro and Noto Sans Arabic on newer versions; Linux usually has Noto Sans Arabic, Vazirmatn+/// or DejaVu Sans. The installed-font catalog comes first, so a face fontconfig found in a custom+/// folder is used too; the fixed paths follow for machines whose scan has not finished yet.+#[cfg(not(target_arch = "wasm32"))]+pub(super) fn arabic() -> Vec<(String, u32)> {+ let mut v: Vec<(String, u32)> = Vec::new();+ // Prefer a face whose family name says it is designed for Arabic/Persian, then the broad+ // Noto/DejaVu faces. One regular face per family; load_fallback stops at the first that parses.+ for family in [+ "Vazirmatn",+ "Noto Naskh Arabic",+ "Noto Sans Arabic",+ "Noto Kufi Arabic",+ "Noto Naskh Arabic UI",+ "Noto Sans Arabic UI",+ "Shabnam",+ "Sahel",+ "Amiri",+ "Noto Sans",+ "Segoe UI",+ "Tahoma",+ "Arial",+ "Geeza Pro",+ "DejaVu Sans",+ ] {+ if let Some(face) = crate::fonts::faces_of(family).into_iter().find(|f| !f.italic && (f.weight - 400.0).abs() < 1.0) {+ v.push((face.path.to_string_lossy().into_owned(), face.index));+ }+ }+ let w = windows_fonts();+ for f in ["segoeui.ttf", "tahoma.ttf", "arial.ttf", "times.ttf"] {+ v.push((format!("{w}\\{f}"), 0));+ }+ for p in [+ "/System/Library/Fonts/GeezaPro.ttc",+ "/System/Library/Fonts/Supplemental/Geeza Pro.ttc",+ "/System/Library/Fonts/Supplemental/NotoSansArabic-Regular.ttf",+ "/System/Library/Fonts/Supplemental/Arial.ttf",+ "/System/Library/Fonts/Supplemental/Times New Roman.ttf",+ ] {+ v.push((p.into(), 0));+ }+ for d in [+ "/run/host/fonts",+ "/run/host/local-fonts",+ "/run/host/user-fonts",+ "/usr/share/fonts/truetype/noto",+ "/usr/share/fonts/opentype/noto",+ "/usr/share/fonts/truetype",+ "/usr/share/fonts",+ "/usr/local/share/fonts",+ ] {+ v.push((format!("{d}/NotoSansArabic-Regular.ttf"), 0));+ v.push((format!("{d}/Vazirmatn-Regular.ttf"), 0));+ v.push((format!("{d}/vazirmatn/Vazirmatn-Regular.ttf"), 0));+ v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0));+ }+ // Fedora's variable Noto Sans Arabic (and the mac supplemental folder path above) covers+ // distributions that package only the variable font.+ v.push(("/usr/share/fonts/google-noto-vf/NotoSansArabic[wght].ttf".into(), 0));+ v+}+ /// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's /// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), /// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover@@ -35,6 +35,10 @@ pub const CELL_ITALIC: &str = "cell-italic"; pub const CELL_BOLD_ITALIC: &str = "cell-bold-italic"; +/// Cell text in an Arabic-script face first: a whole Persian/Arabic sentence shares one face, so a+/// space cannot split it into separately shaped runs (egui has no bidi reordering between runs).+pub const CELL_ARABIC: &str = "cell-arabic";+ pub const SERIF: &str = "serif"; pub const MONO: &str = "mono";@@ -81,16 +85,67 @@ pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily { ) } +/// The family for cell text: the workbook's own font when it carries Arabic glyphs, else a+/// script-leading family for Arabic-only text. egui assigns each character to the first family+/// that has it; with an Arabic-only line led by the Latin font, the spaces belong to Latin and the+/// Arabic words become separate runs, which egui draws left to right (#2384).+pub fn cell_family_for_text(name: &str, bold: bool, italic: bool, text: &str) -> FontFamily {+ if is_arabic_only(text) {+ let role = crate::fonts::role_key(name, bold, italic);+ if crate::fonts::is_bound(&role) && crate::fonts::family_supports_arabic(name, bold, italic) {+ return FontFamily::Name(role.into());+ }+ if crate::fonts::is_bound(CELL_ARABIC) {+ return FontFamily::Name(CELL_ARABIC.into());+ }+ }+ cell_family(name, bold, italic)+}++/// [`FontId`] for a single line of interface text (formula bar, in-cell editor), in the Arabic+/// script-leading family when the line is Arabic-only.+pub fn font_for_text(font: FontId, text: &str) -> FontId {+ if is_arabic_only(text) && crate::fonts::is_bound(CELL_ARABIC) { FontId::new(font.size, FontFamily::Name(CELL_ARABIC.into())) } else { font }+}++/// Whether `text` is made only of Arabic-script letters, Arabic punctuation/digits and neutral+/// separators. Mixed Latin/Arabic text is deliberately not redirected: egui cannot reorder its+/// runs, and forcing the whole line into an Arabic face would change the Latin glyph shapes too.+fn is_arabic_only(text: &str) -> bool {+ let mut arabic = false;+ for ch in text.chars() {+ if ch.is_whitespace() || ch.is_ascii_digit() || ch.is_ascii_punctuation() {+ continue;+ }+ let c = ch as u32;+ let neutral = matches!(c, 0x200C..=0x200F | 0x202A..=0x202E | 0x2066..=0x2069 | 0x00AB | 0x00BB | 0x061C);+ if neutral {+ continue;+ }+ if (0x0600..=0x06FF).contains(&c)+ || (0x0750..=0x077F).contains(&c)+ || (0x08A0..=0x08FF).contains(&c)+ || (0xFB50..=0xFDFF).contains(&c)+ || (0xFE70..=0xFEFF).contains(&c)+ {+ arabic = true;+ } else {+ return false;+ }+ }+ arabic+}+ /// Width in points, at 100% zoom, of one line of cell text drawn as the grid draws it: in the-/// face [`cell_family`] picks, which on a machine without the named font is a wider or narrower-/// substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the+/// face [`cell_family_for_text`] picks, which on a machine without the named font is a wider or+/// narrower substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the /// next frame) falls back to the engine's estimate rather than letting egui panic on it. /// /// `exact` lays the text out (a galley kept until the end of the frame); otherwise the glyph /// advances are summed without kerning, which is cheap and never narrower. This takes the /// Context lock (`fonts_mut`): never call it, or run a command, while an egui lock is held. pub fn cell_text_width(ctx: &egui::Context, text: &str, font: &gridcraft_engine::model::Font, exact: bool) -> f32 {- let family = cell_family(&font.name, font.bold, font.italic);+ let family = cell_family_for_text(&font.name, font.bold, font.italic, text); let id = FontId::new(font.size * 96.0 / 72.0, family); ctx.fonts_mut(|f| { f.families().contains(&id.family).then(|| {@@ -464,6 +519,23 @@ fn font_definitions_impl(language: crate::i18n::Language, used: &[String], syste // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still // leads, and the barred Noto CJK collection never enters. append_craft_faces(&mut fonts);+ // An Arabic-first chain for Arabic-only cell text. A system Arabic face is preferred, then the+ // permitted craft-fonts Arabic face, so the same rule works on the web and on hosts without a+ // system Arabic font. Without either, the role families behave as before.+ let craft = craft_font_faces();+ let arabic = fallback.iter().find(|k| k.as_str() == "sys-arabic").cloned();+ let arabic = arabic.or_else(|| craft.iter().find(|(_, f)| f.scripts.contains(&"Arab")).map(|(k, _)| k.clone()));+ if let Some(arabic) = arabic {+ let mut chain: Vec<String> = Vec::new();+ for key in std::iter::once(&arabic).chain(base_prop.iter()).chain(fallback.iter()).chain(craft.iter().map(|(k, _)| k)) {+ if !chain.contains(key) {+ chain.push(key.clone());+ }+ }+ chain.retain(|k| fonts.font_data.contains_key(k));+ fonts.families.insert(FontFamily::Name(CELL_ARABIC.into()), chain);+ bound.push(CELL_ARABIC.to_string());+ } drop_unparseable_fonts(&mut fonts); // Only families egui still has data for count as bound (a dropped face can empty a chain). bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty()));@@ -558,6 +630,18 @@ mod font_binding_tests { [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect() } + #[test]+ fn arabic_only_cell_text_uses_the_arabic_family_when_it_is_bound() {+ let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());+ crate::fonts::set_bound(always_bound().into_iter().chain([CELL_ARABIC.to_string()]));+ assert_eq!(cell_family_for_text("Calibri", false, false, "سلام دنیا"), FontFamily::Name(CELL_ARABIC.into()));+ assert_eq!(cell_family_for_text("Calibri", false, false, "سلام."), FontFamily::Name(CELL_ARABIC.into()));+ assert_eq!(font_for_text(ui_font(14.0), "سلام دنیا").family, FontFamily::Name(CELL_ARABIC.into()));+ // Mixed text and Latin-only text keep their ordinary role (egui cannot reorder mixed runs).+ assert_eq!(cell_family_for_text("Calibri", false, false, "GridCraft فارسی"), FontFamily::Name(CELL.into()));+ assert_eq!(font_for_text(ui_font(14.0), "GridCraft"), ui_font(14.0));+ }+ #[test] fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() { let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());@@ -121,11 +121,11 @@ fn language_reorders_only_the_han_faces() { let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::<Vec<_>>(); assert_eq!( keys(HanOrder::Japanese),- ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]+ ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-arabic", "sys-hebrew", "sys-symbols", "sys-symbols-2"] ); assert_eq!( keys(HanOrder::Chinese),- ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]+ ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-arabic", "sys-hebrew", "sys-symbols", "sys-symbols-2"] ); assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] {@@ -183,7 +183,7 @@ fn fallback_covers_its_script() { // have a glyph for every non-space character in the sample. A script runs when a system // face or the optional craft-fonts input provides one; the printed skip is a recorded // gate, and a present face that is not wired in fails.- let cases: [(&str, Vec<(String, u32)>, &str); 10] = [+ let cases: [(&str, Vec<(String, u32)>, &str); 11] = [ ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), // Chinese uses a system Han face or the native-only permitted LXGW craft face; the // barred Noto CJK collection never supplies it.@@ -191,6 +191,7 @@ fn fallback_covers_its_script() { ("sys-cjk-hangul", cjk_hangul(), "한글"), ("sys-thai", thai(), "ทดสอบ"), ("sys-lao", lao(), "ພາສາລາວ"),+ ("sys-arabic", arabic(), "مرحبا"), ("sys-hebrew", hebrew(), "עברית"), ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), ("craft-noto-sans-georgian", Vec::new(), "ქართული"),new file mode 100644@@ -0,0 +1,231 @@+//! Persian and Arabic (right-to-left) text must render with real glyphs, not tofu boxes, in both+//! the interface and the cell fonts, and a workbook must keep it through an XLSX round-trip.+//!+//! A separate test binary: it runs the process-wide installed-font scan, whose faces bind from the+//! next frame and would leak into the other integration tests (see `it/main.rs`).++use egui::{Color32, FontFamily, FontId};+use gridcraft_engine::Session;+use gridcraft_engine::core::{CellRef, Value};+use gridcraft_ui_egui::{fonts, theme};+use serde_json::json;+use std::sync::{Mutex, MutexGuard};++/// `font_definitions*` records the bound role families process-wide. Tests in this binary build+/// different font sets, so serialize them; the app has one font set and never hits this race.+static FONT_LOCK: Mutex<()> = Mutex::new(());++fn lock_fonts() -> MutexGuard<'static, ()> {+ FONT_LOCK.lock().unwrap_or_else(|e| e.into_inner())+}++/// One sample per goal: a Persian phrase, mixed Persian/English, Arabic with punctuation, and a+/// Persian sentence holding a Latin URL and Latin digits (all common in real sheets).+const PERSIAN: &str = "سلام دنیا";+const MIXED: &str = "GridCraft فارسی 123";+const ARABIC_PUNCT: &str = "مرحبا، كيف حالك؟ (١٢٣)";+const PERSIAN_URL: &str = "وبسایت https://example.com؟ 42";++/// A context with the same font set the app installs, built after the scan so dynamically-found+/// faces (not just the hardcoded paths) are bound.+fn context() -> egui::Context {+ let _ = fonts::catalog(); // finish the scan before building the font set+ let ctx = egui::Context::default();+ ctx.set_fonts(theme::font_definitions());+ let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {});+ out.textures_delta.clear(); // nothing consumes the atlas in a headless test+ ctx+}++/// Every drawn glyph of `text` in `family`.+fn drawn(ctx: &egui::Context, family: &FontFamily, text: &str) -> Vec<egui::epaint::text::Glyph> {+ let font = FontId::new(14.0, family.clone());+ let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font, Color32::BLACK));+ galley.rows.iter().flat_map(|r| r.glyphs.iter().copied()).collect()+}++/// Whether the font set has an Arabic-script face for the fallback chain: a system face or the+/// permitted craft-fonts Arabic face. Without one there is nothing to shape with, and the tests+/// skip instead of failing a minimal install (the CI gate builds craft fonts).+fn arabic_face_loaded() -> bool {+ let fonts = theme::font_definitions();+ fonts.font_data.contains_key("sys-arabic") || fonts.families.contains_key(&FontFamily::Name(theme::CELL_ARABIC.into()))+}++/// The characters of `text` that have no glyph in `family`. Fonts may draw a missing character+/// as a zero-width component (egui 0.37's replacement glyph), so ask the font chain directly.+fn tofu_chars(ctx: &egui::Context, family: &FontFamily, text: &str) -> String {+ let font = FontId::new(14.0, family.clone());+ ctx.fonts_mut(|f| text.chars().filter(|ch| *ch != ' ' && !f.has_glyph(&font, *ch)).collect())+}++#[test]+fn persian_renders_real_glyphs_in_the_interface_and_cell_fonts() {+ let _g = lock_fonts();+ let ctx = context();+ if !arabic_face_loaded() {+ eprintln!("skipped: no Arabic-script font installed");+ return;+ }+ if !fonts::faces_of("Vazirmatn").is_empty() || !fonts::faces_of("Noto Sans Arabic").is_empty() {+ // The scan found an Arabic-script face; the fallback must have loaded one of them.+ assert!(+ theme::font_definitions().font_data.contains_key("sys-arabic"),+ "an Arabic font is installed but no Arabic-script fallback face was bound"+ );+ }+ for family in [FontFamily::Proportional, FontFamily::Name(theme::CELL.into())] {+ for sample in [PERSIAN, MIXED, ARABIC_PUNCT, PERSIAN_URL] {+ let missing = tofu_chars(&ctx, &family, sample);+ assert!(missing.is_empty(), "{sample:?} renders as boxes in {family:?}: {missing:?}");+ }+ }+}++#[test]+fn bold_and_italic_cell_roles_still_have_arabic() {+ let _g = lock_fonts();+ if !arabic_face_loaded() {+ eprintln!("skipped: no Arabic-script font installed");+ return;+ }+ // Bold cells use a different role chain; it must carry `sys-arabic` too, or a bold Persian cell+ // would fall to tofu even though the regular one renders.+ let ctx = context();+ for role in [theme::CELL_BOLD, theme::CELL_ITALIC, theme::CELL_BOLD_ITALIC] {+ let family = FontFamily::Name(role.into());+ let missing = tofu_chars(&ctx, &family, PERSIAN);+ assert!(missing.is_empty(), "bold/italic {role} renders Persian as boxes: {missing:?}");+ }+}++#[test]+fn mixed_latin_and_persian_keeps_the_persian_shaped_and_the_latin_readable() {+ let _g = lock_fonts();+ let ctx = context();+ if !arabic_face_loaded() {+ eprintln!("skipped: no Arabic-script font installed");+ return;+ }+ let family = FontFamily::Name(theme::CELL.into());+ // Every character (Latin, Persian, digits, space) has a real glyph.+ assert!(tofu_chars(&ctx, &family, MIXED).is_empty());+ // The galley is wider than its Latin part alone, so the Persian is actually laid out.+ let width = |text: &str| ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), FontId::new(14.0, family.clone()), Color32::BLACK)).size().x;+ let (full, latin) = (width(MIXED), width("GridCraft 123"));+ assert!(full > latin, "mixed text ({full}) must be wider than its Latin part ({latin})");+}++/// Within one shaping run, HarfRust lays a right-to-left script's glyphs out in visual order: the+/// last logical character comes first, drawn at the left. That is what makes the letters join and+/// read right to left. epaint has no bidi reordering *between* runs (`TODO(emilk): heed bidi+/// characters`): a space can be owned by a different face, splitting a sentence into one run per+/// word, and those runs are concatenated in logical order. So this checks a single word only; the+/// limitation is recorded in `docs/localization-parity.md`.+#[test]+fn a_right_to_left_word_is_shaped_in_visual_order() {+ let _g = lock_fonts();+ let ctx = context();+ if !arabic_face_loaded() {+ eprintln!("skipped: no Arabic-script font installed");+ return;+ }+ let family = FontFamily::Name(theme::CELL.into());+ let glyphs = drawn(&ctx, &family, "اب");+ let at = |ch: char| glyphs.iter().find(|g| g.chr == ch && g.advance_width > 0.0).map(|g| g.pos.x);+ let (Some(alef), Some(beh)) = (at('ا'), at('ب')) else {+ panic!("both letters should be shaped: {glyphs:?}");+ };+ assert!(beh < alef, "the last logical letter (ب) must be left of the first (ا), got ا={alef}, ب={beh}");+}++/// A Persian sentence is two right-to-left runs if a space belongs to a Latin face. The Arabic cell+/// family leads with the script face instead, so the whole sentence is one run and its words are in+/// reading order. (Mixed Latin/Arabic still has no bidi; see `docs/localization-parity.md`.)+#[test]+fn a_persian_sentence_keeps_its_word_order_in_the_arabic_cell_family() {+ let _g = lock_fonts();+ let ctx = context();+ if !arabic_face_loaded() || !fonts::is_bound(theme::CELL_ARABIC) {+ eprintln!("skipped: no Arabic-script font installed");+ return;+ }+ assert_eq!(+ theme::cell_family_for_text("No Such Font", false, false, PERSIAN),+ FontFamily::Name(theme::CELL_ARABIC.into()),+ "a font without Arabic must use the script-leading family"+ );+ let glyphs = drawn(&ctx, &FontFamily::Name(theme::CELL_ARABIC.into()), PERSIAN);+ let at = |ch: char| glyphs.iter().find(|g| g.chr == ch && g.advance_width > 0.0).map(|g| g.pos.x);+ let (first_word, second_word) = (at('س'), at('د'));+ let (Some(salam), Some(donya)) = (first_word, second_word) else {+ panic!("both words should be shaped: {glyphs:?}");+ };+ assert!(donya < salam, "دنیا must be left of سلام in a right-to-left sentence");+}++#[test]+fn an_installed_arabic_font_keeps_its_own_family_for_arabic_cells() {+ let _g = lock_fonts();+ let _ = fonts::catalog();+ let installed = ["Vazirmatn", "Noto Naskh Arabic", "Noto Sans Arabic"].into_iter().find(|name| !fonts::faces_of(name).is_empty());+ let Some(name) = installed else {+ eprintln!("skipped: no named Arabic-script font installed");+ return;+ };+ let ctx = egui::Context::default();+ ctx.set_fonts(theme::font_definitions_with(gridcraft_ui_egui::i18n::Language::En, &[name.to_string()]));+ let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});+ out.textures_delta.clear();+ let family = theme::cell_family_for_text(name, false, false, PERSIAN);+ let FontFamily::Name(role) = &family else { panic!("an installed font must name its own family, got {family:?}") };+ assert!(fonts::is_bound(role), "the chosen Arabic font's role must be bound");+ assert_ne!(&**role, theme::CELL_ARABIC, "a font with Arabic coverage keeps its own typeface");+ assert!(tofu_chars(&ctx, &family, PERSIAN).is_empty(), "the chosen font {name} must draw Persian");+}++#[test]+fn a_workbook_keeps_persian_and_its_font_through_an_xlsx_round_trip() {+ let _g = lock_fonts();+ let _ = fonts::catalog();+ if !arabic_face_loaded() {+ eprintln!("skipped: no Arabic-script font installed");+ return;+ }+ let mut session = Session::new();+ session.new_workbook();+ session.execute("range.setValues", json!({"range": "A1:A2", "values": [[PERSIAN], [MIXED]]})).expect("set values");+ // A Persian font selected for the cell, as a user would, when it is installed.+ let vazirmatn = !fonts::faces_of("Vazirmatn").is_empty();+ if vazirmatn {+ session.execute("home.fontName", json!({"range": "A1", "name": "Vazirmatn"})).expect("set font");+ }+ let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).expect("save");+ let base64 = saved.get("base64").and_then(|b| b.as_str()).expect("base64");+ let name = saved.get("name").and_then(|n| n.as_str()).unwrap_or("Book.xlsx");+ session.execute("file.open", json!({"name": name, "base64": base64})).expect("reopen");++ let (font, family_name) = {+ let doc = session.active().expect("document");+ let sh = doc.wb.active().expect("sheet");+ assert_eq!(sh.value(CellRef::new(0, 0)), Value::Text(PERSIAN.into()), "A1 survives the round-trip");+ assert_eq!(sh.value(CellRef::new(1, 0)), Value::Text(MIXED.into()), "A2 survives the round-trip");+ let st = doc.wb.styles.get(sh.style_id(CellRef::new(0, 0)));+ (st.font.clone(), st.font.name.clone())+ };+ if vazirmatn {+ assert_eq!(font.name, "Vazirmatn", "the cell font survives the round-trip");+ }+ // Build the reopened workbook's font set and render the cell text in its own family.+ let used = vec![font.name.clone()];+ let ctx = egui::Context::default();+ ctx.set_fonts(theme::font_definitions_with(gridcraft_ui_egui::i18n::Language::En, &used));+ let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});+ out.textures_delta.clear();+ let family = theme::cell_family(&family_name, font.bold, font.italic);+ if let FontFamily::Name(role) = &family {+ assert!(fonts::is_bound(role), "the cell font role {role:?} must be bound to font data");+ }+ let missing = tofu_chars(&ctx, &family, PERSIAN);+ assert!(missing.is_empty(), "the reopened cell's Persian renders as boxes: {missing:?}");+}@@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (§5: fontconfig discovery, Arabic/Persian fallback and script-only word order; bidi remains) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open@@ -53,10 +53,15 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md) ### 5. Text in non-Latin scripts doesn't render or export (L)-- **Missing:** CJK font fallback, complex shaping (Arabic, Devanagari, Thai), bidi, non-Latin PDF.-- **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32); #142- (Chinese text) awaits confirmation; #54 (symbols as boxes on Fedora); no shaping for Arabic or- Devanagari; PDF writer uses 14 standard fonts only.+- **Missing:** bidi (mixed and wrapped right-to-left runs, RTL alignment), Devanagari/Thai shaping,+ non-Latin PDF. Arabic/Persian shaping and font fallback now work.+- **Evidence:** system-font fallback for CJK, Thai, Hebrew and Arabic landed (#38 and the 2026-10-27+ Arabic work); the scan reads fontconfig `<dir>`/`<include>` entries, so installed Vazirmatn, Noto+ Naskh Arabic, Shabnam or Sahel faces are found outside the standard paths; HarfRust shapes and+ joins Arabic/Persian, and an Arabic-only cell leads with its script face so its words stay in+ reading order. Mixed Latin/Arabic runs, wrapped RTL lines and RTL alignment still use logical+ order; #142 (Chinese text) awaits confirmation; #54 (symbols as boxes on Fedora); PDF writer+ uses 14 standard fonts only. - **Impact:** blocks most of the world's users even with an English UI. - **Estimate:** 12–20 h. [localization-parity.md](localization-parity.md) @@ -183,6 +188,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | §5: fontconfig `<dir>`/`<include>` discovery finds Arabic/Persian faces outside fixed paths; the script fallback shapes and joins Arabic-only cell text in reading order. Bidi, mixed Latin/Arabic order, wrapped RTL lines and RTL alignment remain | | 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | | 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) |@@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Linux font discovery follows fontconfig; Arabic/Persian fallback finds installed faces and shapes a script-only cell in reading order; bidi and mixed-run order remain) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats,@@ -44,10 +44,16 @@ right-to-left sheets and full complex-script support. Part of - **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32; #142 is still open pending confirmation). Native CJK fallback also probes Flatpak's read-only `/run/host/fonts`, `/run/host/local-fonts` and `/run/host/user-fonts` layouts, so installed- Noto CJK and WenQuanYi faces can render Chinese without a broad filesystem grant. This does- not change the installed-font catalogue or add bundled fonts. Lao falls back to Lao Sangam MN / Lao MN (macOS),- Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). There is no complex-text shaping: Arabic and- Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left**+ Noto CJK and WenQuanYi faces can render Chinese without a broad filesystem grant; that probe+ adds no bundled fonts. The general font scan now also follows fontconfig's `<dir>`/`<include>`+ configuration, so the installed-font catalogue includes folders fontconfig names. Lao falls back to Lao Sangam MN / Lao MN (macOS),+ Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). Arabic-script+ fonts (Vazirmatn, Noto Sans/Naskh/Kufi Arabic, Shabnam, Sahel, Amiri…) are found by family+ wherever the font scan reached, including folders only fontconfig names; HarfRust shapes and+ joins Arabic/Persian, and an Arabic-script-only cell whose chosen font lacks Arabic leads with+ that script face so its words stay in reading order. There is still no bidirectional layout:+ mixed Latin/Arabic runs, wrapping and Excel's right-to-left alignment are not reordered, and+ Devanagari/Thai shaping is not covered. **Sheet Right-to-Left** (`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`, `.xls` WINDOW2 fRightToLeft) mirrors the grid (#146): column A and the row headers on the right, the vertical scrollbar on the left; hit-testing, arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box@@ -72,7 +78,7 @@ separators, number/date formats, machine translation with agent review, and scri | Simplified Chinese (Mandarin) | zh-Hans | 266 (~12%) | no | CJK system-font fallback (#38); IME unverified; PDF can't export | no | menus only | 8–13 h | | Spanish | es | 0 (0%) | no | Latin: yes | no | none | 6–10 h | | Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h |-| Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h |+| Arabic | ar | 0 (0%) | no | Arabic/Persian shaping and script-only word order; **no bidi/mixed-run order, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h | | French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h | | Portuguese | pt-BR | 943 (~43%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h | | Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h |@@ -102,14 +108,15 @@ language (none so far) and of localized function names. 2. Locale-aware formulas: localized function-name tables and separators at the formula-bar boundary only (files keep en-US invariant names), locale number and date formats, list separators in CSV.-3. Text shaping and fallback: a shaping engine for grid, editor and PDF text (Arabic, Devanagari,- Thai), system font fallback for CJK, bidirectional runs, right-to-left sheet tabs and printing;- embedded subset fonts in PDF.+3. Text shaping and fallback: Arabic-script shaping and font fallback exist; add bidi (mixed and+ wrapped RTL runs), Devanagari/Thai shaping, PDF text (embedded subset fonts), right-to-left+ sheet tabs and printing. ## Revision history | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Linux font discovery reads fontconfig's `<dir>` and `<include>` entries, so an Arabic/Persian font is found wherever it is installed (not only at one distribution path). The Arabic-script fallback (Vazirmatn, Noto Sans/Naskh/Kufi Arabic, Shabnam, Sahel, Amiri…) renders and joins Persian/Arabic; an Arabic-only cell whose chosen font lacks Arabic uses a script-leading family, keeping “سلام دنیا” in reading order where a Latin-owned space used to split it into separately ordered runs. Bidi, mixed Latin/Arabic order, wrapped RTL lines and RTL alignment remain unsupported and are documented | | 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |