Edit text: support Devanagari system font fallback and Indic pre-base matra reordering (#1033)

- UI font fallback: add Devanagari probe (U+0915) and candidate font paths (Noto Sans Devanagari, Lohit, Nirmala, Mangal, Devanagari MT, Kohinoor) in system_fonts.rs, and register SYSTEM_DEVANAGARI_FALLBACK across all family stacks in theme.rs so Hindi text renders clearly in the inline editor and chrome.
- Text editing & extraction: reorder visual-order Indic pre-base vowel signs (e.g. Devanagari 'ि' U+093F) to standard Unicode logical order upon extraction in text_lines and reading_lines, and provide a visual-order fallback during font reuse in replace_line and rewrite_block.
- Add unit tests for Devanagari font fallback coverage in crates/ui-egui/tests/fonts.rs and Indic visual/logical reordering round-trip in crates/edit/src/tests.rs.
- Credit krinry with sync_github_name in contributors/people.toml.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
krinryandBrandon Thomas authored and GitHub committed 2026-10-11 21:03:40 -04:00
1 parent a786c8fb02
commit 4701a00831
3 files changed
+91 -4

No files matched your search

+54 -2
View File
@@ -2,7 +2,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). It is
//! craft-fonts, or the Simplified Chinese interface in a build without a `Hans` face (#826). A
//! second installed face, [`devanagari_fallback`], draws Hindi 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).
@@ -17,8 +18,10 @@ const MAX_BYTES: u64 = 32 << 20;
const MAX_HAN_BYTES: u64 = 96 << 20;
/// Faces tried in a collection (`.ttc`).
const MAX_FACES: u32 = 16;
/// Arabic letter alef: the face must have it to be worth loading.
/// Arabic letter alef: the face must have it to be worth loading for Arabic script.
const PROBE: char = '\u{0627}';
/// Devanagari letter ka: the face must have it to cover Hindi.
const DEVANAGARI_PROBE: char = '\u{0915}';
/// 欢 (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.
@@ -62,6 +65,55 @@ fn load(need: Need) -> Option<Arc<FontData>> {
candidates(need).iter().find_map(|path| read(path, probe, max))
}
/// The installed fallback face for Devanagari (Hindi), read once. `None` when it is turned off
/// or no candidate is installed; the interface then draws what the embedded faces can.
pub fn devanagari_fallback() -> Option<Arc<FontData>> {
static CACHE: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
CACHE
.get_or_init(|| {
if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
return None;
}
devanagari_candidates().iter().find_map(|path| read(path, DEVANAGARI_PROBE, MAX_BYTES))
})
.clone()
}
/// Well-known locations of Devanagari (Hindi) faces, best first.
fn devanagari_candidates() -> Vec<PathBuf> {
if cfg!(windows) {
["Nirmala.ttf", "NirmalaB.ttf", "mangal.ttf", "aparaj.ttf", "utsah.ttf"].iter().map(|f| windows_fonts().join(f)).collect()
} else if cfg!(target_os = "macos") {
[
"/System/Library/Fonts/Supplemental/DevanagariMT.ttc",
"/System/Library/Fonts/Supplemental/DevanagariSangamMN.ttc",
"/System/Library/Fonts/Supplemental/Kohinoor.ttc",
"/System/Library/Fonts/Kohinoor.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
.iter()
.map(PathBuf::from)
.collect()
} else {
unix_fonts(&[
"google-noto/NotoSansDevanagariUI-Regular.ttf",
"google-noto/NotoSansDevanagari-Regular.ttf",
"google-noto-vf/NotoSansDevanagari[wght].ttf",
"truetype/noto/NotoSansDevanagari-Regular.ttf",
"truetype/noto/NotoSansDevanagariUI-Regular.ttf",
"noto/NotoSansDevanagari-Regular.ttf",
"noto/NotoSansDevanagariUI-Regular.ttf",
"lohit-devanagari-fonts/Lohit-Devanagari.ttf",
"truetype/lohit-devanagari/Lohit-Devanagari.ttf",
"truetype/deva/Lohit-Devanagari.ttf",
"lohit-hindi/Lohit-Hindi.ttf",
"google-droid-sans-fonts/DroidSansDevanagari-Regular.ttf",
"samyak-fonts/Samyak-Devanagari.ttf",
"gargi/Gargi.ttf",
])
}
}
/// Well-known locations of faces with broad script coverage, best first.
fn candidates(need: Need) -> Vec<PathBuf> {
match need {
+14 -2
View File
@@ -180,7 +180,7 @@ pub fn hand(resp: Response) -> Response {
}
pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions());
install_fonts_for(ctx, CjkPreference::Default);
}
/// The interface language's claim on the CJK fallback faces.
@@ -213,6 +213,8 @@ pub fn install_fonts_for(ctx: &egui::Context, cjk: CjkPreference) {
/// The name of the installed face [`installed_font_definitions`] may add after the embedded ones.
pub const SYSTEM_FALLBACK: &str = "system-fallback";
/// The name of the installed Devanagari (Hindi) face [`installed_font_definitions`] may add.
pub const SYSTEM_DEVANAGARI_FALLBACK: &str = "system-devanagari-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.
@@ -225,13 +227,23 @@ pub const SYSTEM_FALLBACK: &str = "system-fallback";
/// - Otherwise it is the last fallback of every family (an Arabic file name in a build without
/// craft-fonts).
///
/// `PDFCRAFT_SYSTEM_FONTS=0` leaves it out.
/// An installed Devanagari face, when there is one, ends every family just before that one, so
/// Hindi text draws in a build without a Devanagari face.
///
/// `PDFCRAFT_SYSTEM_FONTS=0` leaves them out.
pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions {
#[cfg_attr(target_arch = "wasm32", expect(unused_mut))]
let mut fonts = font_definitions_for(cjk == CjkPreference::SimplifiedChinese);
#[cfg(not(target_arch = "wasm32"))]
{
use crate::system_fonts::{Need, fallback};
// Before the general installed face, which stays the last fallback outside Chinese.
if let Some(data) = crate::system_fonts::devanagari_fallback() {
fonts.font_data.insert(SYSTEM_DEVANAGARI_FALLBACK.to_owned(), data);
for stack in fonts.families.values_mut() {
stack.push(SYSTEM_DEVANAGARI_FALLBACK.to_owned());
}
}
let need = match cjk {
_ if !pdfcraft_fonts::ui_chinese_fonts().is_empty() => Need::Other,
CjkPreference::SimplifiedChinese => Need::Hans,
+23
View File
@@ -147,6 +147,29 @@ fn system_fallback_fills_missing_scripts() {
assert!(layout_widths(&mut fonts, ARABIC).iter().all(|w| w.is_finite() && *w > 0.0));
}
/// On a machine with a suitable installed Devanagari font, the installed definitions include
/// it and Hindi text has glyphs; the embedded-only definitions never name it.
#[test]
fn system_fallback_covers_devanagari() {
assert!(!theme::font_definitions().font_data.contains_key(theme::SYSTEM_DEVANAGARI_FALLBACK));
let defs = theme::installed_font_definitions(theme::CjkPreference::Default);
if !defs.font_data.contains_key(theme::SYSTEM_DEVANAGARI_FALLBACK) {
eprintln!("skipping system_fallback_covers_devanagari: no installed Devanagari fallback font (or PDFCRAFT_SYSTEM_FONTS=0)");
assert!(defs.families.values().all(|stack| !stack.iter().any(|n| n == theme::SYSTEM_DEVANAGARI_FALLBACK)));
return;
}
for (family, stack) in &defs.families {
assert!(stack.iter().any(|n| n == theme::SYSTEM_DEVANAGARI_FALLBACK), "{family:?}: {stack:?}");
assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_DEVANAGARI_FALLBACK).count(), 1, "{family:?}");
}
let mut fonts = Fonts::new(TextOptions::default(), defs);
const DEVANAGARI: &str = "हिंदी पाठ सम्पादन";
for id in families() {
assert!(fonts.has_glyphs(&id, DEVANAGARI), "{id:?} lacks {DEVANAGARI}");
}
assert!(layout_widths(&mut fonts, DEVANAGARI).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