mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:09:53 -06:00
* Arabic interface: mirrored right-to-left window and Arabic UI fonts Adds the Arabic (`ar`) interface language's code: its catalogs are craft-i18n's `locales/ar`, so this changes no catalog. - `ar` is registered in `i18n::LANGUAGES` and `Lang::rtl()` says it is written right to left; its translations are used as written (egui's text layout orders each line's bidi text itself). A ribbon location reads from the right with a leftwards mark. - `rtl.rs`: the window is mirrored. Containers open in `rtl::root`; columns, grids, checkboxes, radio buttons, menus, rows, panels, the dialog tab strip and the ribbon's scrolling row follow the language's direction. Title bar, status bar, ribbon tabs and groups, File pages (including Print, with the settings on the right and a solid scrollbar gutter), dialogs, menus and the task panes (their docking edge swaps) are laid out from the right. egui's `Grid` panics in a right-aligned layout, so grids go through `rtl::grid`. - Arabic UI font fallback: the craft-fonts Arabic face, else an installed Arabic font, loaded when the interface is Arabic. - The "Auto (200%)" size label keeps its percent sign inside the isolated number. - Tests: Arabic locale, saved preference and lookup order on fixture catalogs; an interface audit (skipped without the craft-i18n Arabic catalog) that lays the app out right to left at several widths, checks tab, group, pane, Print and Go To positions and that nothing panics. The Arabic work builds on Abdelhaq El Amraoui's mirrored-interface PR (#737). Co-Authored-By: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic interface: pin craft-i18n at a commit with the Arabic catalog; review fixes - craft-i18n.rev and the Flatpak manifest pin 19358491 (craft-i18n main), the first commit line that has locales/ar/wordcraft.tsv. The old pin had no Arabic catalog, so `ar` was registered but not embedded and two i18n tests (the_language_setting_is_a_ui_command_and_persists, the_languages_offered_are_the_embedded_ones_of_the_list) failed in CI. - The Arabic font fallback is requested for any right-to-left interface (`Lang::rtl()`), not only after the language picker sets the flag, so an automatic or saved Arabic gets the installed Arabic face. - The file browser, the update window and Reading Mode's toolbar run their bodies in `rtl::root`, so their far-side buttons and rows are mirrored. - A group in a right-to-left ribbon row is measured from the right edge: the cursor's left edge is negative infinity there, so its width was NaN and the ribbon planner dropped every group, never compacting a narrow Arabic window. A narrow window now folds groups into drop-downs. - The mirrored checkbox and radio marks are labelled by their text, so screen readers name them. - Tests: group width in a right-to-left row; the Arabic interface audit checks Reading Mode's toolbar. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic interface: ask for the installed Arabic font separately from the CJK one A previous commit made every right-to-left interface request the installed CJK fallback font (`system_cjk`) so that the installed Arabic font would load. That also made every Arabic launch read a large system CJK font file when no craft-fonts input is embedded (the Flatpak build has none). `install_ui_fonts` now passes a separate `system_arabic` flag (`Lang::rtl()`): the installed Arabic font is read for it, and `system_cjk` is again set only by a CJK interface or by CJK text on screen. `WordApp::fonts_system_arabic` remembers what was installed, so a language change reinstalls the fonts. Tests: the_installed_arabic_font_is_asked_for_by_arabic_alone (the Arabic flag adds the installed font only when none is embedded, and the CJK request alone adds no Arabic font). Checked a headless screenshot of the Arabic interface without craft-fonts: the installed Arabic font draws the labels. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Fix the AutoSave switch knob: on is at the right, mirrored in Arabic The title bar's AutoSave knob was placed with `lead_x(rtl == on, ..)`, which reverses main's mapping in both directions: left to right it drew the knob at the left when the switch was on, and right to left at the right. main draws it at the right when on and at the left when off, so the mirrored interface must draw it at the left when on: `rtl != on`. The position is now `autosave_knob_x(rtl, on, track)`, pinned by a test for all four cases. A headless screenshot of the off switch shows the knob at the left in English and at the right in Arabic. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic interface: file names and paths in right-to-left lines; kerning row order Tester feedback on the Arabic interface: - File > Open's Recent list, the built-in file browser and the title bar drew file names and paths raw, from the left edge. In an Arabic line the marks of a name ("(نهائي) 2.docx") and the folders of a path with Arabic parts were scrambled. Rows now start at the side reading starts from (`rtl::lead_x`), names go through `i18n::isolate` (a first-strong isolate, with a left-to-right mark before the extension so `2.docx` stays `2.docx`) and paths through `i18n::isolate_path` (a left-to-right isolate with a mark after every separator, so neighbouring Arabic folders do not swap). Other languages get the text untouched; saved and spoken text never carries the marks. - Font dialog, Kerning row: the dialog's grid is drawn left to right inside a right-to-left window, so "Points and above" came after its number; in an Arabic interface it now comes first, so the line reads "14 نقطة فأكثر" from the right. (The grid's columns themselves still keep their left-to-right order.) Tests: names_and_paths_are_isolated_in_a_right_to_left_interface. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic interface: mirror through craft_ui::direction and Style::right_to_left The suite's craft-ui now has the direction helpers (`craft_ui::direction`) and its egui fork a `Style::right_to_left` that mirrors egui's own chrome. Use them instead of our parallel wrappers: - `rtl::sync(ctx)` applies the interface direction every frame the language's changes (`direction::apply`): it sets the shared direction state and `Style::right_to_left` on every theme, which draws check boxes and radio buttons with the mark at the reading start, mirrors submenus, scroll bars, title bars and the combo boxes' arrows (the Print page's choice lists, the font list) and opens combo menus from the right. - `rtl::checkbox`, `radio_value`, `radio` and `indeterminate_checkbox` are gone: about 200 call sites use plain `ui.checkbox`, `ui.radio_value`, ... and egui does the mirroring (and names the control by its text, as a plain widget). - `lead_x`, `lead_center`, `lead_top`, `start_panel` and `end_panel` delegate to `craft_ui::direction`. `root`, `flows`, `island`, `grid`, `menu`, `vertical`, `far_side` and the other layout helpers stay: the shared crate has no equivalent that fits them. Visual changes: the combo boxes' drop-down arrows and lists are mirrored in Arabic; nothing changes in left-to-right languages (checked headless screenshots of the English main window, Font dialog and Print page). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Recent files and the file browser: full path on hover, long names keep their start Review feedback: - Each row of File > Open's Recent list shows the document's full path as its tooltip (the path line under the name can be longer than the row). - A long name in the built-in file browser is laid out in one row, with an ellipsis at its end, from the side reading starts from, instead of being clipped at the row's edge: in a right-to-left interface the old text anchored at the right edge cut the end of an Arabic name and the start of a Latin one. The whole name is the row's tooltip when it was shortened. Checked headless screenshots of the file browser with a long English and a long Arabic name. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Font combos in a right-to-left row: the arrow joins its field on the left The ribbon's font name and size boxes are drawn from a field and a separate arrow box that share one outline: the field is square and the arrow's box is rounded on the side where the arrow goes, painted a border width under the field. That assumed the arrow is right of the field. In a right-to-left row (the Arabic ribbon) the arrow comes after the field on its left, so the field was square on the wrong side and the arrow box rounded on the side facing the field: two boxes with a gap-like seam, the arrow looking outside the frame. `arrow_box_shape(rtl, ..)` mirrors the radii and the underlap, and the field's square corners are chosen by the row's direction (the nw/sw pair in right to left). Left-to-right rows are drawn exactly as before (English before/after crops are identical). Test: the_arrow_box_is_rounded_away_from_its_field. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Pin craft-i18n 4612850 (merged Arabic WordCraft rows, craft-i18n#76) The pin and the Flatpak manifest commit move from 1935849 to 4612850, which is on craft-i18n main and adds the 70 Arabic rows this branch relies on. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Pin craft-i18n cac2cf0 (Arabic number agreement and status-bar rows) Moves the pin and the Flatpak manifest commit from 4612850 to the current craft-i18n main, which includes craft-i18n#95 (48bed03) and #81 (ef58ff3). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic: isolate paths shown beside labels; mirror the Print page zoom File > Info shows Location as its own left-to-right path label (cut short with the full path on hover, not wrapped), and the open-failure, Exported and dictionary-file messages isolate their paths. The Print preview zoom puts minus at the slider's left end and plus at its right in Arabic, as the status bar does. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic: mirror the label/field rows of the Font, Info, Paragraph, Page Setup, Insert Table and Options forms rtl::Table lays a form out as an egui Grid in a left-to-right interface (same id, columns and spacing, so the output is unchanged) and, where the interface is mirrored, with each row's cells running from the right, every column as wide as its widest cell. File > Info puts Properties at the right and Statistics after it, and the Font dialog's kerning row reads 14 points and above. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: record Arabic mirrored interface landing (#1195) Update the language counts, Arabic parity row, gap #11, and UI/ROADMAP status so they match the craft-i18n catalog and mirrored shell already on this branch. * Fix Organizer Copy arrow in RTL and refresh roadmap status. Point the Copy glyph at the Document column when the shell is mirrored, and bump docs/roadmap.md status/revision history for Arabic UI (#1195). * fix(ui): keep pane dock left/right physical in RTL ui.paneDock and view.stylesPaneDock now store and report physical window sides; only unset defaults still open on the reading-start edge. Also wrap the language-packs grid for RTL, document the contract, and load a system Arabic face for Arabic names in LTR UIs. * docs: clarify mirrored default pane edges in RTL Start-default panes open on the physical right in Arabic; end-default panes open on the physical left — not every never-docked pane on the right. * refactor(ui): one PaneState for a saved pane place The four saved-dock arms built the same PaneState; only the place and whether it was set explicitly differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(ui): rustfmt the panes re-exports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: The Legend Of MTS Dev <tlo.mts.tsh@gmail.com> Co-authored-by: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: email <email@email.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
487 lines
24 KiB
Rust
487 lines
24 KiB
Rust
//! An installed CJK font for the interface, after the embedded craft-fonts faces, when they leave
|
||
//! characters of the interface text without a glyph: a build without craft-fonts, one whose
|
||
//! craft-fonts checkout lacks the Chinese face (#241), or one whose faces lack characters the
|
||
//! catalogs use (#486) would otherwise draw Chinese menus with boxes and `?`. Native only; wasm
|
||
//! has no system fonts to read.
|
||
//!
|
||
//! It looks for a short list of well-known files in the platform's font folders
|
||
//! ([`system_font_dirs`](crate::system_font_dirs)) rather than waiting for the full system font
|
||
//! scan, since the interface fonts are installed on the first frame.
|
||
|
||
use skrifa::MetadataProvider;
|
||
use skrifa::raw::FileRef;
|
||
|
||
use crate::CraftFont;
|
||
|
||
/// An installed font file chosen for the interface.
|
||
pub struct SystemUiFont {
|
||
/// The face's family name, e.g. `Microsoft YaHei`.
|
||
pub family: String,
|
||
/// The whole font file.
|
||
pub bytes: Vec<u8>,
|
||
/// The face's index in the file (0 unless it is a collection).
|
||
pub index: u32,
|
||
}
|
||
|
||
/// Is `c` drawn from a CJK font: Han ideographs, kana, bopomofo, Hangul, CJK punctuation or a
|
||
/// full-width form? (Latin, Cyrillic and symbols come from the interface's Latin faces.)
|
||
pub fn is_cjk(c: char) -> bool {
|
||
matches!(c,
|
||
'\u{2E80}'..='\u{2FDF}' // radicals
|
||
| '\u{3000}'..='\u{33FF}' // punctuation, kana, bopomofo, compatibility
|
||
| '\u{3400}'..='\u{4DBF}' // extension A
|
||
| '\u{4E00}'..='\u{9FFF}' // unified ideographs
|
||
| '\u{AC00}'..='\u{D7AF}' // Hangul syllables
|
||
| '\u{F900}'..='\u{FAFF}' // compatibility ideographs
|
||
| '\u{FE30}'..='\u{FE4F}' // vertical forms
|
||
| '\u{FF00}'..='\u{FFEF}' // full-width forms
|
||
| '\u{20000}'..='\u{3FFFF}' // extensions B and later
|
||
)
|
||
}
|
||
|
||
/// The distinct CJK characters of `texts` (see [`is_cjk`]) that none of the `embedded` faces
|
||
/// maps, in code point order. A craft-fonts face may lack characters the interface catalogs use
|
||
/// (an older or subset file), and those would draw as boxes or `?` (#486).
|
||
pub fn ui_uncovered_cjk(texts: &[&str], embedded: &[&CraftFont]) -> Vec<char> {
|
||
let faces: Vec<_> = embedded.iter().filter_map(|f| skrifa::FontRef::from_index(f.bytes, 0).ok()).map(|f| f.charmap()).collect();
|
||
let chars: std::collections::BTreeSet<char> = texts.iter().flat_map(|t| t.chars()).filter(|c| is_cjk(*c)).collect();
|
||
chars.into_iter().filter(|c| !faces.iter().any(|m| m.map(*c).is_some())).collect()
|
||
}
|
||
|
||
/// Do the embedded interface faces (`ui_cjk_fonts`) leave CJK characters of `texts` (the
|
||
/// interface catalogs and language names that can be on screen) without a glyph? Then an
|
||
/// installed CJK font goes after them. No embedded face (a build without craft-fonts) leaves
|
||
/// every CJK character uncovered; a Japanese face alone lacks the simplified-only hanzi (页, 选,
|
||
/// 删 …, #241) and some traditional ones (啟, 內 …, #486).
|
||
pub fn ui_needs_system_cjk(texts: &[&str], embedded: &[&CraftFont]) -> bool {
|
||
!ui_uncovered_cjk(texts, embedded).is_empty()
|
||
}
|
||
|
||
/// Files larger than this are skipped (the big system CJK collections are around 20–80 MB).
|
||
const MAX_FILE: u64 = 128 << 20;
|
||
/// Faces looked at in one collection.
|
||
const MAX_FACES: u32 = 64;
|
||
/// Entries looked at in one macOS font asset folder (each font is one `<hash>.asset` folder).
|
||
const MAX_ASSETS: usize = 8192;
|
||
/// Characters a face's coverage is measured on.
|
||
const MAX_NEEDED: usize = 4096;
|
||
/// The tables skrifa reads glyph outlines from: TrueType, CFF, CFF2 and VARC. A face with
|
||
/// outlines only in Apple's `hvgl` table has none of them, and the interface draws its characters
|
||
/// as blank space.
|
||
pub(crate) const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub(crate) enum Region {
|
||
Hans,
|
||
Hant,
|
||
Other,
|
||
}
|
||
|
||
impl Region {
|
||
fn from_hans(hans: bool) -> Region {
|
||
if hans { Region::Hans } else { Region::Other }
|
||
}
|
||
|
||
fn rank(self, family: &str) -> u8 {
|
||
match self {
|
||
Region::Hans => u8::from(family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei")),
|
||
Region::Hant => {
|
||
let taiwan =
|
||
[" TC", " TW"].iter().any(|s| family.ends_with(s)) || ["JhengHei", "CNS", "LiHei", "MingLiU"].iter().any(|s| family.contains(s));
|
||
if taiwan { 2 } else { u8::from(family.ends_with(" HK")) }
|
||
}
|
||
Region::Other => 1,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A character only a font for the language has: a simplified-only hanzi, kana, or Hangul.
|
||
fn probe(hans: bool, korean: bool) -> char {
|
||
if korean {
|
||
'한'
|
||
} else if hans {
|
||
'页'
|
||
} else {
|
||
'の'
|
||
}
|
||
}
|
||
|
||
/// Font files to look for, relative to a font folder (or absolute), best first.
|
||
fn candidates(hans: bool, korean: bool) -> Vec<&'static str> {
|
||
let (zh, ja, ko): (&[&str], &[&str], &[&str]) = if cfg!(windows) {
|
||
(
|
||
&["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"],
|
||
&["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"],
|
||
&["malgun.ttf", "malgunsl.ttf", "gulim.ttc", "batang.ttc"],
|
||
)
|
||
} else if cfg!(target_os = "macos") {
|
||
(
|
||
&[
|
||
// An asset on macOS 10.15 and later (see `with_asset_data`).
|
||
"PingFang.ttc",
|
||
"Hiragino Sans GB.ttc",
|
||
"STHeiti Light.ttc",
|
||
"STHeiti Medium.ttc",
|
||
// The system interface's own copy, present when the PingFang asset isn't.
|
||
"/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc",
|
||
],
|
||
&["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans W3.ttc"],
|
||
&["/System/Library/Fonts/AppleSDGothicNeo.ttc", "/System/Library/Fonts/Supplemental/AppleGothic.ttf"],
|
||
)
|
||
} else {
|
||
(
|
||
&[
|
||
"noto-cjk/NotoSansCJK-Regular.ttc",
|
||
"opentype/noto/NotoSansCJK-Regular.ttc",
|
||
"google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
||
"google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||
"noto-cjk/NotoSansCJKsc-Regular.otf",
|
||
"opentype/noto/NotoSansCJKsc-Regular.otf",
|
||
"adobe-source-han-sans/SourceHanSansCN-Regular.otf",
|
||
"wenquanyi/wqy-microhei/wqy-microhei.ttc",
|
||
"truetype/wqy/wqy-microhei.ttc",
|
||
"wenquanyi/wqy-zenhei/wqy-zenhei.ttc",
|
||
"truetype/wqy/wqy-zenhei.ttc",
|
||
"truetype/droid/DroidSansFallbackFull.ttf",
|
||
],
|
||
&["noto-cjk/NotoSansCJKjp-Regular.otf", "opentype/noto/NotoSansCJKjp-Regular.otf"],
|
||
&[
|
||
"noto-cjk/NotoSansCJKkr-Regular.otf",
|
||
"opentype/noto/NotoSansCJKkr-Regular.otf",
|
||
"google-noto-sans-cjk-fonts/NotoSansCJKkr-Regular.otf",
|
||
"adobe-source-han-sans/SourceHanSansK-Regular.otf",
|
||
"opentype/adobe-source-han-sans/SourceHanSansK-Regular.otf",
|
||
"truetype/nanum/NanumGothic.ttf",
|
||
],
|
||
)
|
||
};
|
||
if korean {
|
||
ko.iter().chain(ja).chain(zh).copied().collect()
|
||
} else if hans {
|
||
zh.to_vec()
|
||
} else {
|
||
ja.iter().chain(zh).copied().collect()
|
||
}
|
||
}
|
||
|
||
fn hant_candidates() -> &'static [&'static str] {
|
||
if cfg!(windows) {
|
||
&["msjh.ttc", "msjh.ttf", "mingliu.ttc"]
|
||
} else if cfg!(target_os = "macos") {
|
||
&[
|
||
"PingFang.ttc",
|
||
"Hiragino Sans CNS.ttc",
|
||
"LiHeiPro.ttf",
|
||
"/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc",
|
||
]
|
||
} else {
|
||
&[
|
||
"noto-cjk/NotoSansCJK-Regular.ttc",
|
||
"opentype/noto/NotoSansCJK-Regular.ttc",
|
||
"google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
||
"google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||
"noto-cjk/NotoSansCJKtc-Regular.otf",
|
||
"opentype/noto/NotoSansCJKtc-Regular.otf",
|
||
"google-noto-cjk/NotoSansCJKtc-Regular.otf",
|
||
"adobe-source-han-sans/SourceHanSansTW-Regular.otf",
|
||
"adobe-source-han-sans/SourceHanSansTC-Regular.otf",
|
||
"opentype/adobe-source-han-sans/SourceHanSansTW-Regular.otf",
|
||
"NotoSansCJK-Regular.ttc",
|
||
"NotoSansCJKtc-Regular.otf",
|
||
"SourceHanSansTW-Regular.otf",
|
||
"SourceHanSansTC-Regular.otf",
|
||
]
|
||
}
|
||
}
|
||
|
||
pub fn system_hant_ui_font(needed: &[char]) -> Option<SystemUiFont> {
|
||
let probe = ['體'];
|
||
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
|
||
hant_font_in(&with_asset_data(crate::system_font_dirs()), needed)
|
||
}
|
||
|
||
fn hant_font_in(dirs: &[std::path::PathBuf], needed: &[char]) -> Option<SystemUiFont> {
|
||
find_ui_font(dirs, hant_candidates(), needed, Region::Hant).filter(|f| Region::Hant.rank(&f.family) > 0)
|
||
}
|
||
|
||
/// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed`
|
||
/// (the characters the embedded faces lack; empty: a character typical of the language), if one
|
||
/// is found. Never panics on damaged files.
|
||
pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option<SystemUiFont> {
|
||
system_cjk_ui_font_for(hans, false, needed)
|
||
}
|
||
|
||
/// The installed CJK font for a Chinese, Korean or Japanese interface.
|
||
pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Option<SystemUiFont> {
|
||
let probe = [probe(hans, korean)];
|
||
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
|
||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans))
|
||
}
|
||
|
||
/// Arabic font files to look for, relative to a font folder, best first.
|
||
fn arabic_candidates() -> &'static [&'static str] {
|
||
if cfg!(windows) {
|
||
&["segoeui.ttf", "tahoma.ttf", "arial.ttf"]
|
||
} else if cfg!(target_os = "macos") {
|
||
&["SFArabic.ttf", "GeezaPro.ttc", "Supplemental/GeezaPro.ttc", "Supplemental/Arial Unicode.ttf", "Supplemental/Arial.ttf"]
|
||
} else {
|
||
&[
|
||
"noto/NotoSansArabic-Regular.ttf",
|
||
"truetype/noto/NotoSansArabic-Regular.ttf",
|
||
"google-noto/NotoSansArabic-Regular.ttf",
|
||
"google-noto-sans-arabic-vf-fonts/NotoSansArabic[wght].ttf",
|
||
"noto/NotoNaskhArabic-Regular.ttf",
|
||
"truetype/noto/NotoNaskhArabic-Regular.ttf",
|
||
"google-noto/NotoNaskhArabic-Regular.ttf",
|
||
"vazirmatn/Vazirmatn-Regular.ttf",
|
||
"truetype/dejavu/DejaVuSans.ttf",
|
||
"dejavu/DejaVuSans.ttf",
|
||
"TTF/DejaVuSans.ttf",
|
||
]
|
||
}
|
||
}
|
||
|
||
/// An installed font with Arabic letters, for an Arabic interface in a build whose craft-fonts
|
||
/// input has no Arabic face. Reads at most one font file per candidate; never panics on damaged
|
||
/// files.
|
||
pub fn system_arabic_ui_font() -> Option<SystemUiFont> {
|
||
find_ui_font(&crate::system_font_dirs(), arabic_candidates(), &['ع'], Region::Other)
|
||
}
|
||
|
||
/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font<N>`),
|
||
/// the `AssetData` folder of every `<hash>.asset` in it: macOS ships PingFang there, e.g.
|
||
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData/PingFang.ttc`
|
||
/// (#486). One level, at most [`MAX_ASSETS`] entries per folder.
|
||
pub(crate) fn with_asset_data(dirs: Vec<std::path::PathBuf>) -> Vec<std::path::PathBuf> {
|
||
let mut out = Vec::with_capacity(dirs.len());
|
||
for d in dirs {
|
||
let assets = d.file_name().is_some_and(|n| n.to_string_lossy().starts_with("com_apple_MobileAsset_Font"));
|
||
if assets && let Ok(entries) = std::fs::read_dir(&d) {
|
||
let mut found: Vec<_> =
|
||
entries.flatten().take(MAX_ASSETS).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "asset")).collect();
|
||
found.sort();
|
||
out.extend(found.into_iter().map(|p| p.join("AssetData")));
|
||
}
|
||
out.push(d);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The font file among `names` (tried in order, each in every folder of `dirs`) whose best face
|
||
/// maps all of `needed`, or failing that the one mapping the most of them (the earliest on a
|
||
/// tie). A file that maps none of them is never chosen.
|
||
pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], region: Region) -> Option<SystemUiFont> {
|
||
let mut tried = std::collections::HashSet::new();
|
||
let mut best: Option<((u8, usize), SystemUiFont)> = None;
|
||
for path in names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))) {
|
||
if !tried.insert(path.clone()) {
|
||
continue;
|
||
}
|
||
let Some(size) = std::fs::metadata(&path).ok().filter(std::fs::Metadata::is_file).map(|m| m.len()) else { continue };
|
||
if size > MAX_FILE {
|
||
continue;
|
||
}
|
||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||
let Some((index, family, covered)) = pick_face(&bytes, needed, region) else { continue };
|
||
let fit = if region == Region::Hant { region.rank(&family) } else { 0 };
|
||
if best.as_ref().is_some_and(|(b, _)| *b >= (fit, covered)) {
|
||
continue;
|
||
}
|
||
log::debug!("interface CJK fallback: {family} ({}), {covered} of {} characters", path.display(), needed.len());
|
||
let done = covered >= needed.len() && (region != Region::Hant || fit == 2);
|
||
best = Some(((fit, covered), SystemUiFont { family, bytes, index }));
|
||
if done {
|
||
break;
|
||
}
|
||
}
|
||
best.map(|(_, f)| f)
|
||
}
|
||
|
||
/// The face of a font file (or collection) to use and how many of `needed` it maps: the one
|
||
/// mapping the most, then preferring a Regular style and, for Chinese, a Simplified Chinese
|
||
/// family (`… SC`, `… GB`, as in a pan-CJK collection). Faces with none of the
|
||
/// [`OUTLINE_TABLES`] are skipped. `None` if the header isn't a font's or no face with outlines
|
||
/// maps any of `needed`.
|
||
pub(crate) fn pick_face(data: &[u8], needed: &[char], region: Region) -> Option<(u32, String, usize)> {
|
||
let magic = data.get(..4)?;
|
||
if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) {
|
||
return None;
|
||
}
|
||
let count = match FileRef::new(data).ok()? {
|
||
FileRef::Font(_) => 1,
|
||
FileRef::Collection(c) => c.len().min(MAX_FACES),
|
||
};
|
||
let mut best: Option<((u8, usize, bool, u8), u32, String)> = None;
|
||
for i in 0..count {
|
||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||
if !OUTLINE_TABLES.iter().any(|t| f.table_data(skrifa::Tag::new(t)).is_some()) {
|
||
continue;
|
||
}
|
||
let charmap = f.charmap();
|
||
let covered = needed.iter().filter(|c| charmap.map(**c).is_some()).count();
|
||
if covered == 0 {
|
||
continue;
|
||
}
|
||
let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue };
|
||
let rank = region.rank(&family);
|
||
let first = if region == Region::Hant { rank } else { 0 };
|
||
let score = (first, covered, style.eq_ignore_ascii_case("Regular"), rank);
|
||
if best.as_ref().is_none_or(|(s, ..)| score > *s) {
|
||
best = Some((score, i, family));
|
||
}
|
||
}
|
||
best.map(|((_, covered, ..), i, family)| (i, family, covered))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn korean_interface_uses_korean_first_system_font_candidates() {
|
||
let names = candidates(false, true);
|
||
assert_eq!(
|
||
names.first(),
|
||
Some(&if cfg!(windows) {
|
||
"malgun.ttf"
|
||
} else if cfg!(target_os = "macos") {
|
||
"/System/Library/Fonts/AppleSDGothicNeo.ttc"
|
||
} else {
|
||
"noto-cjk/NotoSansCJKkr-Regular.otf"
|
||
})
|
||
);
|
||
assert_eq!(probe(false, true), '한');
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn traditional_chinese_interface_picks_the_taiwan_face() {
|
||
use crate::fontdb::tests::sysfonts::{collection, renamed_to};
|
||
assert_eq!(
|
||
hant_candidates().first(),
|
||
Some(&if cfg!(windows) {
|
||
"msjh.ttc"
|
||
} else if cfg!(target_os = "macos") {
|
||
"PingFang.ttc"
|
||
} else {
|
||
"noto-cjk/NotoSansCJK-Regular.ttc"
|
||
})
|
||
);
|
||
let face = |family| renamed_to("SourceSans3-Regular.ttf", family);
|
||
let pan = collection(&[face("Probe Sans JP"), face("Probe Sans KR"), face("Probe Sans SC"), face("Probe Sans TC"), face("Probe Sans HK")]);
|
||
assert_eq!(pick_face(&pan, &['A'], Region::Hant), Some((3, "Probe Sans TC".to_string(), 1)));
|
||
assert_eq!(pick_face(&pan, &['A'], Region::Hans), Some((2, "Probe Sans SC".to_string(), 1)));
|
||
assert_eq!(pick_face(&pan, &['A'], Region::Other), Some((0, "Probe Sans JP".to_string(), 1)));
|
||
let hk = collection(&[face("Probe Sans SC"), face("Probe Sans HK")]);
|
||
assert_eq!(pick_face(&hk, &['A'], Region::Hant).map(|f| f.0), Some(1));
|
||
assert_eq!(Region::Hant.rank("Microsoft JhengHei"), 2);
|
||
assert_eq!(Region::Hant.rank("Microsoft YaHei"), 0);
|
||
let dir = std::env::temp_dir().join(format!("wc-uifallback-hant-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let names = ["missing.ttc", "damaged.ttc", "sc.ttc", "pan.ttc"];
|
||
std::fs::write(dir.join("damaged.ttc"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
|
||
std::fs::write(dir.join("sc.ttc"), collection(&[face("Probe Sans SC"), face("Probe Sans JP")])).unwrap();
|
||
std::fs::write(dir.join("pan.ttc"), &pan).unwrap();
|
||
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hant).unwrap();
|
||
assert_eq!((found.family.as_str(), found.index), ("Probe Sans TC", 3));
|
||
assert_eq!(find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap().family, "Probe Sans SC");
|
||
let only_sc = dir.join("only-sc");
|
||
std::fs::create_dir_all(only_sc.join("noto-cjk")).unwrap();
|
||
std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), collection(&[face("Probe Sans SC")])).unwrap();
|
||
assert!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).is_none());
|
||
std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), &pan).unwrap();
|
||
if !cfg!(windows) && !cfg!(target_os = "macos") {
|
||
assert_eq!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).map(|f| f.index), Some(3));
|
||
}
|
||
assert!(hant_font_in(std::slice::from_ref(&only_sc), &['體']).is_none());
|
||
let user = dir.join("user-fonts");
|
||
std::fs::create_dir_all(&user).unwrap();
|
||
std::fs::write(user.join("NotoSansCJKtc-Regular.otf"), face("Probe Sans TC")).unwrap();
|
||
if !cfg!(windows) && !cfg!(target_os = "macos") {
|
||
assert_eq!(hant_font_in(std::slice::from_ref(&user), &['A']).map(|f| f.family), Some("Probe Sans TC".to_string()));
|
||
}
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() {
|
||
// A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486).
|
||
let latin = CraftFont { family: "Source Sans 3", style: "Regular", scripts: &["Hans"], bytes: crate::bundled()[0] };
|
||
assert_eq!(ui_uncovered_cjk(&["选项 Options", "選項:选项"], &[&latin]), ['选', '選', '項', '项', ':']);
|
||
assert!(ui_needs_system_cjk(&["简体中文"], &[]));
|
||
assert!(ui_needs_system_cjk(&["Save", "开"], &[&latin]));
|
||
// Latin, Cyrillic and symbols are the Latin faces' business.
|
||
assert!(!ui_needs_system_cjk(&["Українська Español → ¶"], &[]));
|
||
// A damaged embedded face counts as covering nothing; it never panics.
|
||
let broken = CraftFont { family: "x", style: "Regular", scripts: &["Hans"], bytes: b"OTTO" };
|
||
assert!(ui_needs_system_cjk(&["页"], &[&broken]));
|
||
// The real faces, when built with craft-fonts: the Chinese face covers simplified and
|
||
// traditional hanzi the Japanese one lacks (#241, #486).
|
||
let embedded = crate::ui_cjk_fonts(true);
|
||
if crate::chinese_fonts().next().is_some() {
|
||
assert!(!ui_needs_system_cjk(&["页选项另存为啟內"], &embedded));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn finds_the_best_covering_font_also_in_macos_asset_folders() {
|
||
let dir = std::env::temp_dir().join(format!("wc-uifallback-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let assets = dir.join("com_apple_MobileAsset_Font7");
|
||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||
std::fs::create_dir_all(assets.join("0a1b.asset/AssetData")).unwrap();
|
||
std::fs::create_dir_all(assets.join("not-an-asset/AssetData")).unwrap();
|
||
let sans = crate::bundled()[0];
|
||
// Missing, damaged and non-font files are skipped without panicking.
|
||
std::fs::write(dir.join("Deng.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
|
||
std::fs::write(dir.join("msyh.ttc"), b"<html>not a font</html>").unwrap();
|
||
std::fs::write(dir.join("sub/good.ttf"), sans).unwrap();
|
||
let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"];
|
||
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap();
|
||
assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len()));
|
||
// No candidate maps a CJK character: nothing is chosen.
|
||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], Region::Hans).is_none());
|
||
assert!(pick_face(b"OTT", &['A'], Region::Other).is_none());
|
||
// macOS 12 keeps PingFang in `AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData`.
|
||
std::fs::write(assets.join("0a1b.asset/AssetData/PingFang.ttc"), sans).unwrap();
|
||
std::fs::write(assets.join("not-an-asset/AssetData/Hidden.ttc"), sans).unwrap();
|
||
let dirs = with_asset_data(vec![dir.clone(), assets.clone()]);
|
||
assert_eq!(dirs, [dir.clone(), assets.join("0a1b.asset/AssetData"), assets.clone()]);
|
||
let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], Region::Hans).unwrap();
|
||
assert_eq!(found.family, "Source Sans 3");
|
||
assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], Region::Hans).is_none());
|
||
// No file maps every needed character: the one mapping the most is used.
|
||
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap();
|
||
assert_eq!(found.family, "Source Sans 3");
|
||
// The bundled Latin face has no Arabic letters, so it is never taken for an Arabic interface.
|
||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['ع'], Region::Other).is_none());
|
||
assert!(!arabic_candidates().is_empty());
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn skips_faces_without_outlines() {
|
||
// Renaming the `glyf` table to `hvgl` gives a face like those of `PingFangUI.ttc` on
|
||
// macOS 27.0.1. Its cmap maps characters, and skrifa reads no outlines from it.
|
||
let sans = crate::bundled()[0];
|
||
let mut hvgl = sans.to_vec();
|
||
let tables = u16::from_be_bytes([hvgl[4], hvgl[5]]) as usize;
|
||
let glyf = (0..tables).map(|r| 12 + r * 16).find(|&at| &hvgl[at..at + 4] == b"glyf").unwrap();
|
||
hvgl[glyf..glyf + 4].copy_from_slice(b"hvgl");
|
||
assert!(skrifa::FontRef::new(&hvgl).unwrap().charmap().map('A').is_some());
|
||
assert!(pick_face(&hvgl, &['A'], Region::Hans).is_none());
|
||
assert_eq!(pick_face(sans, &['A'], Region::Hans), Some((0, "Source Sans 3".to_string(), 1)));
|
||
// `find_ui_font` uses the next file, and returns `None` when no other file is listed.
|
||
let dir = std::env::temp_dir().join(format!("wc-uifallback-outlines-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("hvgl.ttf"), &hvgl).unwrap();
|
||
std::fs::write(dir.join("glyf.ttf"), sans).unwrap();
|
||
let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], Region::Hans).unwrap();
|
||
assert!(found.bytes == sans);
|
||
assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], Region::Hans).is_none());
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|