Sinhala and Tamil interface: draw with an installed font (part of #1688) (#1706)

Sinhala and Tamil file names showed as boxes on the home screen, in Recent
and in document tabs: no embedded face covers either script, and the
installed-font fallback did not look for one.

Add sinhala_fallback() and tamil_fallback(), each probing for its script's
letter ka, and end every interface family with them:
- macOS: Sinhala Sangam MN / Sinhala MN, Tamil Sangam MN / Tamil MN
- Windows: Nirmala UI, then Iskoola Pota (Sinhala) or Latha (Tamil)
- Linux: Noto Sans Sinhala / Noto Sans Tamil, Lohit Tamil

The faces are read at runtime, never embedded or shipped, and
PDFCRAFT_SYSTEM_FONTS=0 still turns them off.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
manchitmr
2026-10-15 02:20:51 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b558ef7447
commit 089e13c074
3 changed files with 109 additions and 5 deletions
+74 -3
View File
@@ -3,8 +3,8 @@
//! The embedded faces (Inter, egui's defaults, craft-fonts) come first in every family; this one
//! only draws characters none of them has, such as an Arabic file name in a build without
//! craft-fonts, or the Simplified Chinese interface in a build without a `Hans` face (#826). A
//! second and a third installed face, [`devanagari_fallback`] and [`telugu_fallback`], draw Hindi
//! and Telugu when one is installed. It is
//! few more installed faces, [`devanagari_fallback`], [`telugu_fallback`], [`sinhala_fallback`]
//! and [`tamil_fallback`], draw Hindi, Telugu, Sinhala and Tamil when one is installed. It is
//! read at runtime and never embedded or shipped (AGENTS.md §1.4), and `PDFCRAFT_SYSTEM_FONTS=0`
//! turns it off (published screenshots do).
@@ -25,6 +25,10 @@ const PROBE: char = '\u{0627}';
const DEVANAGARI_PROBE: char = '\u{0915}';
/// Telugu letter ka: the face must have it to cover Telugu.
const TELUGU_PROBE: char = '\u{0C15}';
/// Sinhala letter ka (ක): the face must have it to cover Sinhala.
const SINHALA_PROBE: char = '\u{0D9A}';
/// Tamil letter ka (க): the face must have it to cover Tamil.
const TAMIL_PROBE: char = '\u{0B95}';
/// 欢 (U+6B22): Simplified only, absent from the Japanese craft-fonts faces.
const HANS_PROBE: char = '\u{6B22}';
/// 說 (U+8AAA): Traditional only, absent from the Japanese craft-fonts faces.
@@ -82,6 +86,20 @@ pub fn telugu_fallback() -> Option<Arc<FontData>> {
CACHE.get_or_init(|| script_fallback(&telugu_candidates(), TELUGU_PROBE)).clone()
}
/// The installed fallback face for Sinhala, read once. `None` when it is turned off or no
/// candidate is installed; the interface then draws what the embedded faces can.
pub fn sinhala_fallback() -> Option<Arc<FontData>> {
static CACHE: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
CACHE.get_or_init(|| script_fallback(&sinhala_candidates(), SINHALA_PROBE)).clone()
}
/// The installed fallback face for Tamil, read once. `None` when it is turned off or no
/// candidate is installed; the interface then draws what the embedded faces can.
pub fn tamil_fallback() -> Option<Arc<FontData>> {
static CACHE: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
CACHE.get_or_init(|| script_fallback(&tamil_candidates(), TAMIL_PROBE)).clone()
}
fn script_fallback(candidates: &[PathBuf], probe: char) -> Option<Arc<FontData>> {
if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
return None;
@@ -155,6 +173,57 @@ fn telugu_candidates() -> Vec<PathBuf> {
}
}
/// Well-known locations of Sinhala faces, best first. Nirmala UI has Sinhala from Windows 10;
/// Iskoola Pota is the older Windows Sinhala face.
fn sinhala_candidates() -> Vec<PathBuf> {
if cfg!(windows) {
["Nirmala.ttc", "Nirmala.ttf", "iskpota.ttf"].iter().map(|f| windows_fonts().join(f)).collect()
} else if cfg!(target_os = "macos") {
["/System/Library/Fonts/Supplemental/Sinhala Sangam MN.ttc", "/System/Library/Fonts/Supplemental/Sinhala MN.ttc"]
.iter()
.map(PathBuf::from)
.collect()
} else {
unix_fonts(&[
"google-noto/NotoSansSinhalaUI-Regular.ttf",
"google-noto/NotoSansSinhala-Regular.ttf",
"google-noto-vf/NotoSansSinhala[wght].ttf",
"truetype/noto/NotoSansSinhala-Regular.ttf",
"truetype/noto/NotoSansSinhalaUI-Regular.ttf",
"noto/NotoSansSinhala-Regular.ttf",
"noto/NotoSansSinhalaUI-Regular.ttf",
])
}
}
/// Well-known locations of Tamil faces, best first. Latha is the older Windows Tamil face.
fn tamil_candidates() -> Vec<PathBuf> {
if cfg!(windows) {
["Nirmala.ttc", "Nirmala.ttf", "latha.ttf"].iter().map(|f| windows_fonts().join(f)).collect()
} else if cfg!(target_os = "macos") {
[
"/System/Library/Fonts/Supplemental/Tamil Sangam MN.ttc",
"/System/Library/Fonts/Supplemental/Tamil MN.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
.iter()
.map(PathBuf::from)
.collect()
} else {
unix_fonts(&[
"google-noto/NotoSansTamilUI-Regular.ttf",
"google-noto/NotoSansTamil-Regular.ttf",
"google-noto-vf/NotoSansTamil[wght].ttf",
"truetype/noto/NotoSansTamil-Regular.ttf",
"truetype/noto/NotoSansTamilUI-Regular.ttf",
"noto/NotoSansTamil-Regular.ttf",
"noto/NotoSansTamilUI-Regular.ttf",
"lohit-tamil-fonts/Lohit-Tamil.ttf",
"truetype/lohit-tamil/Lohit-Tamil.ttf",
])
}
}
/// Well-known locations of faces with broad script coverage, best first.
fn candidates(need: Need) -> Vec<PathBuf> {
match need {
@@ -292,11 +361,13 @@ mod tests {
assert!(script_fallback(&candidates, 'A').is_some());
assert!(script_fallback(&candidates, TELUGU_PROBE).is_none());
assert!(script_fallback(&candidates, DEVANAGARI_PROBE).is_none());
assert!(script_fallback(&candidates, SINHALA_PROBE).is_none());
assert!(script_fallback(&candidates, TAMIL_PROBE).is_none());
}
#[test]
fn script_candidates_are_absolute_font_files() {
for list in [devanagari_candidates(), telugu_candidates()] {
for list in [devanagari_candidates(), telugu_candidates(), sinhala_candidates(), tamil_candidates()] {
assert!(!list.is_empty());
assert!(list.iter().all(|p| p.is_absolute() || cfg!(windows)), "{list:?}");
assert!(list.iter().all(|p| p.extension().is_some_and(|e| e == "ttf" || e == "ttc")), "{list:?}");
+9 -2
View File
@@ -220,6 +220,10 @@ pub const SYSTEM_FALLBACK: &str = "system-fallback";
pub const SYSTEM_DEVANAGARI_FALLBACK: &str = "system-devanagari-fallback";
/// The name of the installed Telugu face [`installed_font_definitions`] may add.
pub const SYSTEM_TELUGU_FALLBACK: &str = "system-telugu-fallback";
/// The name of the installed Sinhala face [`installed_font_definitions`] may add.
pub const SYSTEM_SINHALA_FALLBACK: &str = "system-sinhala-fallback";
/// The name of the installed Tamil face [`installed_font_definitions`] may add.
pub const SYSTEM_TAMIL_FALLBACK: &str = "system-tamil-fallback";
/// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face
/// already installed on this machine. It only draws characters no embedded face has.
@@ -232,8 +236,9 @@ pub const SYSTEM_TELUGU_FALLBACK: &str = "system-telugu-fallback";
/// - Otherwise it is the last fallback of every family (an Arabic file name in a build without
/// craft-fonts).
///
/// An installed Devanagari face and an installed Telugu face, when there are, end every family
/// just before that one, so Hindi and Telugu text draw in a build without such faces (#1531).
/// Installed Devanagari, Telugu, Sinhala and Tamil faces, when there are, end every family just
/// before that one, so Hindi, Telugu (#1531), Sinhala and Tamil (#1688) text, such as a file
/// name, draws in a build without such faces.
///
/// `PDFCRAFT_SYSTEM_FONTS=0` leaves them out.
pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions {
@@ -246,6 +251,8 @@ pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions {
for (name, data) in [
(SYSTEM_DEVANAGARI_FALLBACK, crate::system_fonts::devanagari_fallback()),
(SYSTEM_TELUGU_FALLBACK, crate::system_fonts::telugu_fallback()),
(SYSTEM_SINHALA_FALLBACK, crate::system_fonts::sinhala_fallback()),
(SYSTEM_TAMIL_FALLBACK, crate::system_fonts::tamil_fallback()),
] {
let Some(data) = data else { continue };
fonts.font_data.insert(name.to_owned(), data);
+26
View File
@@ -193,6 +193,32 @@ fn system_fallback_covers_telugu() {
assert!(layout_widths(&mut fonts, TELUGU).iter().all(|w| w.is_finite() && *w > 0.0));
}
/// On a machine with installed Sinhala and Tamil fonts, the installed definitions include them
/// and Sinhala and Tamil text, such as a file name, has glyphs instead of boxes (#1688). macOS
/// always ships Sinhala Sangam MN and Tamil Sangam MN, so there the test never skips.
#[test]
fn system_fallback_covers_sinhala_and_tamil() {
for (name, text) in [(theme::SYSTEM_SINHALA_FALLBACK, "ශ්‍රී ලංකා ගොනුව"), (theme::SYSTEM_TAMIL_FALLBACK, "தமிழ் கோப்பு")]
{
assert!(!theme::font_definitions().font_data.contains_key(name));
let defs = theme::installed_font_definitions(theme::CjkPreference::Default);
let off = std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0");
if !defs.font_data.contains_key(name) && (off || !cfg!(target_os = "macos")) {
eprintln!("skipping {name}: no installed fallback font (or PDFCRAFT_SYSTEM_FONTS=0)");
assert!(defs.families.values().all(|stack| !stack.iter().any(|n| n == name)));
continue;
}
for (family, stack) in &defs.families {
assert_eq!(stack.iter().filter(|n| *n == name).count(), 1, "{family:?}: {stack:?}");
}
let mut fonts = Fonts::new(TextOptions::default(), defs);
for id in families() {
assert!(fonts.has_glyphs(&id, text), "{id:?} lacks {text}");
}
assert!(layout_widths(&mut fonts, text).iter().all(|w| w.is_finite() && *w > 0.0));
}
}
/// The Han characters of a catalog's translations.
fn catalog_han(catalog: &str) -> String {
let mut han: Vec<char> = catalog