From 4701a00831f17623ea5e65e5250c95d39ad451a7 Mon Sep 17 00:00:00 2001 From: krinry <121806532+krinry@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:33:40 +0530 Subject: [PATCH] Edit text: support Devanagari system font fallback and Indic pre-base matra reordering (#1033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/ui-egui/src/system_fonts.rs | 56 ++++++++++++++++++++++++++++-- crates/ui-egui/src/theme.rs | 16 +++++++-- crates/ui-egui/tests/fonts.rs | 23 ++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 3c6e2a83aa..3a44e03cd1 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -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> { 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> { + static CACHE: OnceLock>> = 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 { + 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 { match need { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bb66a5b8c8..0a7407522b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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, diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index c900691160..862e888a1e 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -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 = catalog