mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 20:41:47 +00:00
Arabic font fallback in cells, the formula bar and the web (#931)
* M12.AR2: Arabic font fallback in cells and the interface (#229) * M12.AR2: reserve the preferred Arabic file only once it has loaded load_fallback treated fontconfig's Arabic file as already loaded even when load_preferred_arabic could not load it, so a system Arabic face from the same file was skipped and the chains could end up with no Arabic system face. The file is now reserved only when the preferred face is in the font set; the candidate loop moves to load_faces so a test can drive it with a fixture file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M12.AR2: say where fontconfig's Arabic choice applies fontconfig's Arabic face leads the Arabic fallbacks, so it draws Arabic where the UI or cell font has none. A UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic (#229), which is left for a follow-up. The docs and the test's doc comment no longer say the choice comes first in every chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
9 files changed
+296
-32
No files matched your search
@@ -149,6 +149,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
|
||||
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
|
||||
| 2026-10-26 | Arabic text shows in cells and the interface through a system, fontconfig or craft-fonts Arabic face; workbook fonts keep their script fallbacks |
|
||||
| 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 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
|
||||
| 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`) |
|
||||
@@ -276,6 +277,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
|
||||
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
|
||||
| 2026-10-31 | minor | Progress log: Function Library |
|
||||
| 2026-10-26 | minor | Progress log: Arabic font fallback |
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
|
||||
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
|
||||
|
||||
@@ -24,6 +24,7 @@ pub const REQUIRED_FACES: &[(&str, &str, &[&str])] = &[
|
||||
("NanumGothic", "Regular", &["Hang", "Kore"]),
|
||||
("Noto Sans Symbols", "Regular", &["Zsym"]),
|
||||
("Noto Sans Symbols 2", "Regular", &["Zsym"]),
|
||||
("Noto Sans Arabic", "Regular", &["Arab"]),
|
||||
];
|
||||
|
||||
/// The native-only Simplified Chinese face from craft-fonts' separate
|
||||
@@ -41,6 +42,7 @@ pub const WEB_ALLOWED: &[(&str, &str)] = &[
|
||||
("NanumGothic", "Regular"),
|
||||
("Noto Sans Symbols", "Regular"),
|
||||
("Noto Sans Symbols 2", "Regular"),
|
||||
("Noto Sans Arabic", "Regular"),
|
||||
];
|
||||
|
||||
/// One validated `fonts/manifest.txt` row. Only the fields GridCraft consumes are kept.
|
||||
@@ -172,6 +174,7 @@ mod tests {
|
||||
("NanumGothic", "Regular"),
|
||||
("Noto Sans Symbols", "Regular"),
|
||||
("Noto Sans Symbols 2", "Regular"),
|
||||
("Noto Sans Arabic", "Regular"),
|
||||
]
|
||||
);
|
||||
for (family, _) in WEB_ALLOWED {
|
||||
|
||||
@@ -19,6 +19,24 @@ use std::sync::OnceLock;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_FONT_FILE: u64 = 64 << 20;
|
||||
|
||||
/// Reads a font file of at most [`MAX_FONT_FILE`] bytes, even when it grows after it was found.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_font(path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if file.metadata().ok()?.len() > MAX_FONT_FILE {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_FONT_FILE + 1).read_to_end(&mut bytes).ok()?;
|
||||
(bytes.len() as u64 <= MAX_FONT_FILE).then_some(bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn read_font(_path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A face keeps at most this many family names, so a hostile `name` table can't grow the catalog.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_FAMILY_NAMES: usize = 16;
|
||||
@@ -172,7 +190,7 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
|
||||
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&path) else { continue };
|
||||
let Some(data) = read_font(&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) });
|
||||
}
|
||||
@@ -335,7 +353,7 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face
|
||||
continue;
|
||||
}
|
||||
if !fonts.font_data.contains_key(&key) {
|
||||
let Ok(bytes) = std::fs::read(&face.path) else { continue };
|
||||
let Some(bytes) = read_font(&face.path) else { continue };
|
||||
let mut fd = egui::FontData::from_owned(bytes);
|
||||
fd.index = face.index;
|
||||
fonts.font_data.insert(key.clone(), std::sync::Arc::new(fd));
|
||||
@@ -353,11 +371,6 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face
|
||||
bound
|
||||
}
|
||||
|
||||
/// Bind every installed face of `family`, found by any of its names (see [`bind_faces`]).
|
||||
pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec<String> {
|
||||
bind_faces(fonts, family, &faces_of(family))
|
||||
}
|
||||
|
||||
/// The role families currently bound to font data. `cell_family` consults this, so it never names a
|
||||
/// family egui hasn't been given.
|
||||
static BOUND: OnceLock<std::sync::RwLock<HashSet<String>>> = OnceLock::new();
|
||||
@@ -472,6 +485,8 @@ mod tests {
|
||||
}
|
||||
// A family with no faces binds nothing and never touches the map.
|
||||
assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty());
|
||||
std::fs::File::create(&face.path).expect("sparse file").set_len(MAX_FONT_FILE + 1).expect("oversized file");
|
||||
assert!(read_font(&face.path).is_none(), "an oversized font is not read");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,12 @@ 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::{
|
||||
PREFERRED_ARABIC, arabic, cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, load_faces, preferred_file,
|
||||
symbols_more, thai,
|
||||
};
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
use font_fallback::{covers_arabic, fontconfig_arabic, parse_fc_match};
|
||||
use font_setup::pick;
|
||||
pub use font_setup::{
|
||||
CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MATH, MATH_BOLD, MATH_ITALIC, MONO, SERIF, UI, UI_BOLD, available_text,
|
||||
@@ -33,7 +38,7 @@ pub use font_setup::{
|
||||
font_definitions, font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use font_setup::{candidates, craft_font_faces, try_load};
|
||||
use font_setup::{candidates, craft_font_faces, font_definitions_impl, try_load};
|
||||
#[cfg(test)]
|
||||
pub(crate) use font_setup::{drop_unparseable_fonts, font_parses};
|
||||
|
||||
|
||||
@@ -12,12 +12,25 @@ use super::font_setup::{in_dirs, try_load};
|
||||
/// is bundled, and a machine without the face keeps the old behaviour.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec<String> {
|
||||
let loaded = preferred_file(fonts, fontconfig_arabic());
|
||||
load_faces(fonts, fallback_faces(han), loaded)
|
||||
}
|
||||
|
||||
/// The preferred Arabic file, once [`load_preferred_arabic`] has loaded it: a file that failed to
|
||||
/// load must not keep the system Arabic faces out of the chains.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn preferred_file(fonts: &FontDefinitions, preferred: Option<(String, u32)>) -> Vec<String> {
|
||||
preferred.filter(|_| fonts.font_data.contains_key(PREFERRED_ARABIC)).map(|(path, _)| path).into_iter().collect()
|
||||
}
|
||||
|
||||
/// Loads the first candidate of each fallback face that loads and returns their keys. `loaded`
|
||||
/// holds the files already loaded as a fallback face. A file serves at most one key: the
|
||||
/// region-merged Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai
|
||||
/// and Hebrew), so loading it again for a later script would only duplicate tens of MB in memory.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_faces(fonts: &mut FontDefinitions, faces: Vec<(&'static str, Vec<(String, u32)>)>, mut loaded: Vec<String>) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
// Files already loaded as a fallback face. A file serves at most one key: the region-merged
|
||||
// Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and
|
||||
// Hebrew), so loading it again for a later script would only duplicate tens of MB in memory.
|
||||
let mut loaded: Vec<String> = Vec::new();
|
||||
for (key, cands) in fallback_faces(han) {
|
||||
for (key, cands) in faces {
|
||||
for (path, index) in &cands {
|
||||
if loaded.contains(path) {
|
||||
break; // a face loaded for an earlier script already covers this one
|
||||
@@ -38,6 +51,60 @@ pub(super) fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The face fontconfig prefers for Arabic leads the script faces (#229), ahead of the system and
|
||||
/// bundled Arabic faces.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_preferred_arabic(fonts: &mut FontDefinitions) -> Vec<String> {
|
||||
let Some((path, index)) = fontconfig_arabic() else { return Vec::new() };
|
||||
let Some(fd) = try_load(&[(path.as_str(), index)]) else { return Vec::new() };
|
||||
fonts.font_data.insert(PREFERRED_ARABIC.into(), Arc::new(fd));
|
||||
vec![PREFERRED_ARABIC.into()]
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn load_preferred_arabic(_fonts: &mut FontDefinitions) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) const PREFERRED_ARABIC: &str = "sys-arabic-preferred";
|
||||
|
||||
/// The face fontconfig prefers for Arabic, such as a Vazirmatn the user set as their Arabic font
|
||||
/// (#229), kept only when it has Arabic letters. Asked once per process.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(super) fn fontconfig_arabic() -> Option<(String, u32)> {
|
||||
static PREFERRED: std::sync::OnceLock<Option<(String, u32)>> = std::sync::OnceLock::new();
|
||||
PREFERRED
|
||||
.get_or_init(|| {
|
||||
let out = std::process::Command::new("fc-match").args(["--format=%{file}\n%{index}\n", ":lang=ar"]).output().ok()?;
|
||||
let (file, index) = parse_fc_match(&String::from_utf8_lossy(&out.stdout)).filter(|_| out.status.success())?;
|
||||
let bytes = crate::fonts::read_font(std::path::Path::new(&file))?;
|
||||
covers_arabic(&bytes, index).then_some((file, index))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(all(unix, not(target_os = "macos")))))]
|
||||
pub(super) fn fontconfig_arabic() -> Option<(String, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The file and face index from `fc-match --format='%{file}\n%{index}\n'`. fontconfig packs a
|
||||
/// variable font's named instance into the upper 16 bits of the index; only the face index is kept.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(super) fn parse_fc_match(out: &str) -> Option<(String, u32)> {
|
||||
let mut lines = out.lines().map(str::trim);
|
||||
let file = lines.next().filter(|file| !file.is_empty())?;
|
||||
let index = lines.next().and_then(|index| index.parse::<u32>().ok()).map_or(0, |packed| packed & 0xFFFF);
|
||||
Some((file.to_owned(), index))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(super) fn covers_arabic(bytes: &[u8], index: u32) -> bool {
|
||||
use skrifa::MetadataProvider;
|
||||
skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| "العربية".chars().all(|ch| font.charmap().map(ch).is_some()))
|
||||
}
|
||||
|
||||
/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script
|
||||
/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one
|
||||
/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so
|
||||
@@ -57,6 +124,7 @@ pub(super) fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u
|
||||
("sys-thai", thai()),
|
||||
("sys-lao", lao()),
|
||||
("sys-hebrew", hebrew()),
|
||||
("sys-arabic", arabic()),
|
||||
("sys-symbols", symbols()),
|
||||
("sys-symbols-2", symbols_more()),
|
||||
]
|
||||
@@ -243,6 +311,23 @@ pub(super) fn hebrew() -> Vec<(String, u32)> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Arabic faces. Windows has Tahoma, Arial and Segoe UI with Arabic; macOS has Geeza Pro; Linux
|
||||
/// usually has Noto Sans Arabic or Noto Naskh Arabic. Installed Noto and Amiri faces found by the
|
||||
/// font scan come last, for layouts the paths miss.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn arabic() -> Vec<(String, u32)> {
|
||||
let mut v = script_fonts(
|
||||
&["tahoma.ttf", "arial.ttf", "segoeui.ttf"],
|
||||
&["/System/Library/Fonts/GeezaPro.ttc", "/System/Library/Fonts/Supplemental/GeezaPro.ttc"],
|
||||
&["NotoSansArabic-Regular.ttf", "NotoNaskhArabic-Regular.ttf", "noto/NotoSansArabic-Regular.ttf", "noto/NotoNaskhArabic-Regular.ttf"],
|
||||
"/usr/share/fonts/google-noto-vf/NotoSansArabic[wght].ttf",
|
||||
);
|
||||
for family in ["Noto Sans Arabic", "Noto Naskh Arabic", "Amiri"] {
|
||||
v.extend(crate::fonts::faces_of(family).into_iter().map(|face| (face.path.to_string_lossy().into_owned(), face.index)));
|
||||
}
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::Blob;
|
||||
use super::font_fallback::load_fallback;
|
||||
use super::font_fallback::{load_fallback, load_preferred_arabic};
|
||||
use super::{Arc, Color32, FontData, FontDefinitions, FontFamily, FontId};
|
||||
|
||||
/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see
|
||||
@@ -152,7 +152,7 @@ fn font_file(path: &str, index: u32) -> Option<Blob> {
|
||||
let bytes: &[u8] = (**blob).as_ref();
|
||||
return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob));
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let bytes = crate::fonts::read_font(std::path::Path::new(path))?;
|
||||
if !font_parses(&bytes, index) {
|
||||
log::warn!("skipping font {path} (face {index}): not a font file egui can read");
|
||||
return None;
|
||||
@@ -466,37 +466,45 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef
|
||||
/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository
|
||||
/// asset; without it only egui's bundled faces are available, which is the documented no-font mode.
|
||||
pub fn pinned_font_definitions() -> FontDefinitions {
|
||||
font_definitions_impl(crate::i18n::Language::En, &[], false)
|
||||
font_definitions_impl(crate::i18n::Language::En, Vec::new(), false)
|
||||
}
|
||||
|
||||
/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are
|
||||
/// installed, so cells set in them render with them; the rest fall back to the roles. The bound
|
||||
/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has.
|
||||
pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions {
|
||||
font_definitions_impl(language, used, true)
|
||||
let installed = crate::fonts::installed_among(used).into_iter().map(|family| {
|
||||
let faces = crate::fonts::faces_of(&family);
|
||||
(family, faces)
|
||||
});
|
||||
font_definitions_impl(language, installed.collect(), true)
|
||||
}
|
||||
|
||||
/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound.
|
||||
fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions {
|
||||
pub(super) fn font_definitions_impl(
|
||||
language: crate::i18n::Language,
|
||||
installed: Vec<(String, Vec<crate::fonts::Face>)>,
|
||||
system: bool,
|
||||
) -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let base_prop: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let base_mono: Vec<String> = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
let mut bound: Vec<String> = Vec::new();
|
||||
// The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin,
|
||||
// and epaint picks the first face that has the glyph, so they only catch their own scripts.
|
||||
let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() };
|
||||
let fallback = if system {
|
||||
let mut fallback = load_preferred_arabic(&mut fonts);
|
||||
fallback.extend(load_fallback(&mut fonts, HanOrder::of(language)));
|
||||
fallback
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let hack = fonts.font_data.contains_key(HACK).then_some(HACK);
|
||||
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO, MATH, MATH_ITALIC, MATH_BOLD] {
|
||||
let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system);
|
||||
fonts.families.insert(FontFamily::Name(role.into()), chain);
|
||||
bound.push(role.to_string());
|
||||
}
|
||||
// Installed workbook fonts, so a cell's chosen font actually renders in it.
|
||||
if system {
|
||||
for family in crate::fonts::installed_among(used) {
|
||||
bound.extend(crate::fonts::bind_family(&mut fonts, &family));
|
||||
}
|
||||
}
|
||||
// Interface text (formula bar, name box, dialogs) falls back to the cell font before the
|
||||
// script faces: whatever a cell can draw, the formula bar can draw too, even where no script
|
||||
// fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it).
|
||||
@@ -505,6 +513,11 @@ fn font_definitions_impl(language: crate::i18n::Language, used: &[String], syste
|
||||
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
|
||||
fonts.families.insert(FontFamily::Proportional, ui);
|
||||
}
|
||||
// Installed workbook fonts, so a cell's chosen font actually renders in it. They are bound
|
||||
// after the proportional chain has the script faces, which their chains end with.
|
||||
for (family, faces) in installed {
|
||||
bound.extend(crate::fonts::bind_faces(&mut fonts, &family, &faces));
|
||||
}
|
||||
// The optional craft-fonts faces go at the very end of every family: a system or bundled face
|
||||
// that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and
|
||||
// symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols).
|
||||
|
||||
@@ -76,6 +76,7 @@ fn the_required_craft_faces_keep_their_exact_scripts() {
|
||||
("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()),
|
||||
("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()),
|
||||
("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()),
|
||||
("Noto Sans Arabic", "Regular", ["Arab"].as_slice()),
|
||||
] {
|
||||
assert!(
|
||||
faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts),
|
||||
@@ -121,11 +122,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-hebrew", "sys-arabic", "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-hebrew", "sys-arabic", "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] {
|
||||
@@ -184,7 +185,7 @@ fn fallback_covers_its_script() {
|
||||
// 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.
|
||||
type Case = (&'static str, Vec<(String, u32)>, &'static str);
|
||||
let cases: [Case; 10] = [
|
||||
let cases: [Case; 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.
|
||||
@@ -193,6 +194,7 @@ fn fallback_covers_its_script() {
|
||||
("sys-thai", thai(), "ทดสอบ"),
|
||||
("sys-lao", lao(), "ພາສາລາວ"),
|
||||
("sys-hebrew", hebrew(), "עברית"),
|
||||
("sys-arabic", arabic(), "العربية١٢٣"),
|
||||
("craft-noto-sans-arabic", Vec::new(), "مرحبا"),
|
||||
("craft-noto-sans-georgian", Vec::new(), "ქართული"),
|
||||
("ui", candidates(UI), "Русский"),
|
||||
@@ -221,6 +223,138 @@ fn fallback_covers_its_script() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A preferred Arabic file keeps the system Arabic faces from loading it again only once it is
|
||||
/// loaded: one that failed to load leaves them free to load it.
|
||||
fn an_unloaded_preferred_arabic_file_does_not_block_the_system_face() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-theme-preferred-arabic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let path = dir.join("Arabic.ttf");
|
||||
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("font fixture");
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let preferred = || Some((path.clone(), 0));
|
||||
let faces = || vec![("sys-arabic", vec![(path.clone(), 0)])];
|
||||
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let loaded = preferred_file(&fonts, preferred());
|
||||
assert!(loaded.is_empty(), "a preferred face that did not load reserves no file");
|
||||
assert_eq!(load_faces(&mut fonts, faces(), loaded), ["sys-arabic"]);
|
||||
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert(PREFERRED_ARABIC.into(), Arc::new(FontData::from_static(epaint_default_fonts::UBUNTU_LIGHT)));
|
||||
let loaded = preferred_file(&fonts, preferred());
|
||||
assert_eq!(loaded, std::slice::from_ref(&path));
|
||||
assert!(load_faces(&mut fonts, faces(), loaded).is_empty(), "a loaded preferred file is not loaded a second time");
|
||||
assert!(!fonts.font_data.contains_key("sys-arabic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn with_installed_latin_font() -> std::io::Result<FontDefinitions> {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-theme-latin-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("Ubuntu-Light.ttf");
|
||||
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT)?;
|
||||
let face = crate::fonts::Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into()], path, index: 0, weight: 300.0, italic: false };
|
||||
let fonts = font_definitions_impl(Language::En, vec![("Ubuntu".into(), vec![face])], true);
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
Ok(fonts)
|
||||
}
|
||||
|
||||
fn installed_latin_chains(fonts: &FontDefinitions) -> Vec<(FontFamily, Option<&Vec<String>>)> {
|
||||
[(false, false), (true, false), (false, true), (true, true)]
|
||||
.into_iter()
|
||||
.map(|(bold, italic)| {
|
||||
let family = FontFamily::Name(crate::fonts::role_key("Ubuntu", bold, italic).into());
|
||||
let chain = fonts.families.get(&family);
|
||||
(family, chain)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn chain_covers(fonts: &FontDefinitions, chain: &[String], ch: char) -> bool {
|
||||
use skrifa::MetadataProvider;
|
||||
chain
|
||||
.iter()
|
||||
.filter_map(|key| fonts.font_data.get(key))
|
||||
.any(|data| skrifa::FontRef::from_index((*data.font).as_ref(), data.index).is_ok_and(|font| font.charmap().map(ch).is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A workbook font with no Arabic or Hangul still shows them through the system script faces.
|
||||
fn installed_latin_fonts_keep_system_script_fallbacks() {
|
||||
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let fonts = with_installed_latin_font().expect("Latin-only fixture");
|
||||
let loaded: Vec<&str> =
|
||||
fallback_faces(HanOrder::Chinese).into_iter().map(|(key, _)| key).filter(|key| fonts.font_data.contains_key(*key)).collect();
|
||||
for (family, chain) in installed_latin_chains(&fonts) {
|
||||
let chain = chain.expect("bound fixture family");
|
||||
for key in &loaded {
|
||||
assert!(chain.iter().any(|k| k == key), "{family:?} lost the {key} fallback");
|
||||
}
|
||||
if fonts.font_data.contains_key("sys-arabic") {
|
||||
assert!(chain_covers(&fonts, chain, 'ع'), "{family:?} has no glyph for ع");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The craft Arabic face reaches workbook fonts and the pinned web set, without help from system faces.
|
||||
fn the_craft_arabic_face_reaches_every_chain() {
|
||||
assert!(crate::craft_fonts_policy::web_allowed("Noto Sans Arabic", "Regular"));
|
||||
if !craft_fonts_cover("العربية١٢٣") {
|
||||
assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts face covers Arabic");
|
||||
return;
|
||||
}
|
||||
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut fonts = with_installed_latin_font().expect("Latin-only fixture");
|
||||
fonts.font_data.retain(|key, _| !key.starts_with("sys-"));
|
||||
for (family, chain) in installed_latin_chains(&fonts) {
|
||||
let chain = chain.expect("bound fixture family");
|
||||
assert!("العربية١٢٣".chars().all(|ch| chain_covers(&fonts, chain, ch)), "{family:?} has no Arabic glyphs");
|
||||
}
|
||||
let pinned = pinned_font_definitions();
|
||||
for (family, chain) in &pinned.families {
|
||||
assert!("العربية".chars().all(|ch| chain_covers(&pinned, chain, ch)), "pinned {family:?} has no Arabic glyphs");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
/// fontconfig's Arabic face, when it has Arabic letters, comes first among the Arabic fallbacks in every chain (#229). A
|
||||
/// UI or cell face with Arabic of its own sits ahead of every fallback and still draws Arabic first.
|
||||
fn the_fontconfig_arabic_choice_comes_first() {
|
||||
assert_eq!(
|
||||
parse_fc_match("/home/u/.local/share/fonts/Vazirmatn-Regular.ttf\n0\n"),
|
||||
Some(("/home/u/.local/share/fonts/Vazirmatn-Regular.ttf".into(), 0))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_fc_match("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc\n2\n"),
|
||||
Some(("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc".into(), 2))
|
||||
);
|
||||
assert_eq!(parse_fc_match("/usr/share/fonts/a.ttf"), Some(("/usr/share/fonts/a.ttf".into(), 0)));
|
||||
assert_eq!(
|
||||
parse_fc_match("/usr/share/fonts/vazirmatn/Vazirmatn[wght].ttf\n262145\n"),
|
||||
Some(("/usr/share/fonts/vazirmatn/Vazirmatn[wght].ttf".into(), 1)),
|
||||
"a named instance's bits are not part of the face index"
|
||||
);
|
||||
assert_eq!(parse_fc_match(""), None);
|
||||
assert!(!covers_arabic(epaint_default_fonts::UBUNTU_LIGHT, 0), "a Latin-only face is not an Arabic choice");
|
||||
if fontconfig_arabic().is_some() {
|
||||
let fonts = font_definitions();
|
||||
for (family, chain) in fonts.families.iter().filter(|(_, chain)| chain.iter().any(|key| key.starts_with("sys-"))) {
|
||||
let preferred = chain.iter().position(|key| key == PREFERRED_ARABIC);
|
||||
let other_arabic = chain.iter().position(|key| key == "sys-arabic" || key.starts_with("craft-"));
|
||||
assert!(preferred.is_some(), "{family:?} lacks the preferred Arabic face");
|
||||
assert!(other_arabic.is_none_or(|other| preferred < Some(other)), "{family:?} puts another Arabic face first");
|
||||
}
|
||||
if let (Some(preferred), Some(system)) = (fonts.font_data.get(PREFERRED_ARABIC), fonts.font_data.get("sys-arabic")) {
|
||||
assert!((*preferred.font).as_ref() != (*system.font).as_ref(), "the system Arabic fallback is a different file from the preferred face");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chromeos_font_layout_is_probed() {
|
||||
// #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per
|
||||
@@ -229,6 +363,7 @@ fn chromeos_font_layout_is_probed() {
|
||||
let cros = "/usr/share/fonts/chromeos";
|
||||
assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf")));
|
||||
assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf")));
|
||||
assert!(probes(arabic(), &format!("{cros}/noto/NotoSansArabic-Regular.ttf")));
|
||||
assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
||||
assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
||||
assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
||||
|
||||
+4
-1
@@ -55,7 +55,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
### 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
|
||||
- **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32), and for
|
||||
Arabic (system, fontconfig and craft-fonts faces); a UI font with Arabic of its own (DejaVu Sans
|
||||
in the Flatpak) still draws interface Arabic ahead of fontconfig's choice (#229); #142
|
||||
(Chinese text) awaits confirmation; #54 (symbols as boxes on Fedora); no shaping for Arabic or
|
||||
Devanagari; PDF writer uses 14 standard fonts only.
|
||||
- **Impact:** blocks most of the world's users even with an English UI.
|
||||
@@ -193,6 +195,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
| 2026-11-01 | minor | §4: chart data tables implemented for column, bar, line, area and compatible combo charts, with legend keys and border settings, live source values, XLSX round-trip and print/PDF; other chart gaps remain. |
|
||||
| 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model |
|
||||
| 2026-10-31 | minor | §13: Solver Load/Save Model (`data.solverSaveModel`, `data.solverLoadModel`) and Reset All (`data.solverReset`), with buttons in the Solver Parameters dialog |
|
||||
| 2026-10-26 | minor | §5: Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work |
|
||||
| 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-27 · **Change:** minor (number formats honour the `B1`/`B2` calendar prefixes, checked against Excel; earlier: missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks); previously minor (AutoFit estimates Arabic text width from measured glyph widths); previously minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Arabic font fallback; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (number formats honour the `B1`/`B2` calendar prefixes, checked against Excel; earlier: missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks); previously minor (AutoFit estimates Arabic text width from measured glyph widths); previously minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **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,
|
||||
@@ -60,7 +60,9 @@ right-to-left sheets and full complex-script support. Part of
|
||||
`/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
|
||||
Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). Arabic falls back to a system Arabic face, fontconfig's
|
||||
Arabic choice or craft-fonts' Noto Sans Arabic; fontconfig's choice leads these fallbacks, but a UI font with Arabic of its
|
||||
own (DejaVu Sans in the Flatpak) still draws interface Arabic (#229). There is no complex-text shaping: Arabic and
|
||||
Devanagari don't join or reorder, and there is no bidirectional layout. **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,
|
||||
@@ -124,6 +126,7 @@ language (none so far) and of localized function names.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-26 | minor | Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work |
|
||||
| 2026-10-27 | minor | Number formats honour the `B1`/`B2` calendar prefixes: never shown; `B2` renders year, month, day and Arabic month and weekday names in the tabular Hijri calendar (1 Jan 2001 = 6 Shawwal 1421; leap years 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 of the 30-year cycle, which is what Excel shows). Checked against Excel 16.0 (build 20527, en-US) on every serial and about 1,700 format/value pairs: a locale or currency tag before `B2` keeps the date Gregorian, a misplaced `B` and digit or a prefix before a number format is rejected, serial 60 has no Hijri date, serial 0 shows as serial 1, two-word month names use a no-break space. Not matched: Excel's weekday/month-name mix-ups in one `B2` section and its weekday-only quirks |
|
||||
| 2026-10-27 | minor | AutoFit column width, wrapped-row height and PivotTable column fit estimate Arabic letters at ~0.48 em (measured 0.36–0.53 em shaped, Noto Sans/Naskh Arabic and DejaVu Sans) instead of 1 em, Arabic-Indic digits as wide as ASCII digits, and give vowel marks and direction marks no width; vowelled Arabic no longer fits ~3.5× too wide |
|
||||
| 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 |
|
||||
|
||||
Reference in new issue
Block a user