Type: fall back to installed Indic fonts for document text (#4383) (#4386)

* Type: fall back to installed Indic fonts for document text (#4383)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: regenerate scorecard after merging main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-13 04:23:55 +02:00 committed by GitHub
parent 4543ef35b9
commit 20e8f949f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 96 additions and 3 deletions

View File

@ -59,7 +59,7 @@ fn inflate_font(deflated: &[u8]) -> Vec<u8> {
/// Families tried (if installed) after the requested one, for missing glyphs. The Southeast Asian
/// and CJK families ([`THAI_FAMILIES`], [`KHMER_FAMILIES`], [`LAO_FAMILIES`], [`MYANMAR_FAMILIES`],
/// [`crate::cjk::families`]) go between these two lists, in the UI locale's script order.
/// [`INDIC_FAMILIES`], [`crate::cjk::families`]) go between these two lists, in the UI locale's script order.
const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"];
/// Thai-capable families: Windows (Leelawadee UI since Windows 8; Tahoma and Leelawadee on
/// older systems), macOS (Thonburi, Sukhumvit Set) and Linux (Noto). Text in a font without Thai
@ -76,6 +76,38 @@ pub const LAO_FAMILIES: &[&str] = &["Lao Sangam MN", "Lao MN", "Lao UI", "Noto S
/// Myanmar/Burmese-capable families: macOS (Myanmar Sangam MN, Myanmar MN), Windows (Myanmar Text)
/// and Linux (Noto).
pub const MYANMAR_FAMILIES: &[&str] = &["Myanmar Sangam MN", "Myanmar MN", "Myanmar Text", "Noto Sans Myanmar"];
/// Indic-script families for document text. Windows has `Nirmala UI` for all nine scripts (from
/// Windows 8); macOS has the Kohinoor and Sangam MN families; Linux has Noto Sans. Indic text in a
/// font without these glyphs had no candidate to fall back to and drew .notdef boxes (#218).
pub const DEVANAGARI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Noto Sans Devanagari UI"];
/// Bengali-capable families (see [`DEVANAGARI_FAMILIES`]).
pub const BENGALI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Bangla", "Bangla Sangam MN", "Noto Sans Bengali", "Noto Sans Bengali UI"];
/// Gurmukhi-capable families (see [`DEVANAGARI_FAMILIES`]).
pub const GURMUKHI_FAMILIES: &[&str] = &["Nirmala UI", "Gurmukhi Sangam MN", "Noto Sans Gurmukhi", "Noto Sans Gurmukhi UI"];
/// Gujarati-capable families (see [`DEVANAGARI_FAMILIES`]).
pub const GUJARATI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Gujarati", "Gujarati Sangam MN", "Noto Sans Gujarati", "Noto Sans Gujarati UI"];
/// Tamil-capable families (see [`DEVANAGARI_FAMILIES`]).
pub const TAMIL_FAMILIES: &[&str] = &["Nirmala UI", "Tamil Sangam MN", "Noto Sans Tamil", "Noto Sans Tamil UI"];
/// Telugu-capable families (see [`DEVANAGARI_FAMILIES`]).
pub const TELUGU_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Telugu", "Telugu Sangam MN", "Noto Sans Telugu", "Noto Sans Telugu UI"];
/// Kannada-capable families (see [`DEVANAGARI_FAMILIES`]).
pub const KANNADA_FAMILIES: &[&str] = &["Nirmala UI", "Kannada Sangam MN", "Noto Sans Kannada", "Noto Sans Kannada UI"];
/// Malayalam-capable families (see [`DEVANAGARI_FAMILIES`]).
pub const MALAYALAM_FAMILIES: &[&str] = &["Nirmala UI", "Malayalam Sangam MN", "Noto Sans Malayalam", "Noto Sans Malayalam UI"];
/// Sinhala-capable families (see [`DEVANAGARI_FAMILIES`]).
pub const SINHALA_FAMILIES: &[&str] = &["Nirmala UI", "Sinhala Sangam MN", "Noto Sans Sinhala", "Noto Sans Sinhala UI"];
/// All Indic-script families, in the order they join the fallback candidates.
pub const INDIC_FAMILIES: &[&[&str]] = &[
DEVANAGARI_FAMILIES,
BENGALI_FAMILIES,
GURMUKHI_FAMILIES,
GUJARATI_FAMILIES,
TAMIL_FAMILIES,
TELUGU_FAMILIES,
KANNADA_FAMILIES,
MALAYALAM_FAMILIES,
SINHALA_FAMILIES,
];
/// Broad-coverage and emoji fonts, tried after the CJK script fonts so shared Han characters get
/// the locale's forms instead of Arial Unicode's.
const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"];
@ -87,6 +119,14 @@ pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static s
v.extend_from_slice(KHMER_FAMILIES);
v.extend_from_slice(LAO_FAMILIES);
v.extend_from_slice(MYANMAR_FAMILIES);
for fams in INDIC_FAMILIES {
for f in *fams {
// Nirmala UI covers all nine scripts and is listed by each; probe it once.
if !v.contains(f) {
v.push(f);
}
}
}
for s in order {
// craft-fonts' Japanese fonts (if built in) go ahead of the installed Japanese fonts, in
// the Japanese slot of the locale order, so shared Han keeps the locale's forms.

View File

@ -1655,6 +1655,59 @@ fn southeast_asian_fallback_candidates_cover_every_platform() {
}
}
/// The Indic scripts of document text, with conjuncts, matras and viramas (#218).
const INDIC: &[(&str, &str, &[&str])] = &[
("Devanagari", "हिन्दी भाषा क्षत्रिय श्री", fonts::DEVANAGARI_FAMILIES),
("Bengali", "বাংলা ভাষা ক্ষত্রিয় শ্রী", fonts::BENGALI_FAMILIES),
("Gurmukhi", "ਪੰਜਾਬੀ ਭਾਸ਼ਾ ਸਤਿ ਸ੍ਰੀ", fonts::GURMUKHI_FAMILIES),
("Gujarati", "ગુજરાતી ભાષા ક્ષત્રિય શ્રી", fonts::GUJARATI_FAMILIES),
("Tamil", "தமிழ் மொழி ஸ்ரீ கொடை", fonts::TAMIL_FAMILIES),
("Telugu", "తెలుగు భాష క్షత్రియ శ్రీ", fonts::TELUGU_FAMILIES),
("Kannada", "ಕನ್ನಡ ಭಾಷೆ ಕ್ಷತ್ರಿಯ ಶ್ರೀ", fonts::KANNADA_FAMILIES),
("Malayalam", "മലയാളം ഭാഷ ക്ഷത്രിയ ശ്രീ", fonts::MALAYALAM_FAMILIES),
("Sinhala", "සිංහල භාෂාව ක්‍ෂත්‍රිය ශ්‍රී", fonts::SINHALA_FAMILIES),
];
#[test]
fn indic_scripts_fall_back_to_installed_fonts() {
for (name, sample, families) in INDIC {
let mut e = TextEngine::with_system_fonts();
let Some(hit) = families.iter().find(|f| e.fonts.has_family(f)) else {
eprintln!("skipped: no {name}-capable font installed");
continue;
};
let direct = TextLayer { text: (*sample).into(), font_family: hit.to_string(), size_pt: 24.0, ..Default::default() };
let l = e.layout(&direct, 72.0);
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{hit} is installed but shapes .notdef for {name}");
let mixed = format!("Latin {sample}");
let l = e.layout(&point(&mixed, 24.0), 72.0);
assert!(l.glyphs.iter().all(|g| g.id != 0), "{name} in a Latin font drawn with .notdef although {hit} is installed");
let latin = l.glyphs.first().map(|g| g.face);
assert!(l.glyphs.iter().any(|g| Some(g.face) != latin), "Latin and {name} drawn with the same face");
}
}
#[test]
fn indic_fallback_candidates_cover_every_platform() {
// Logic-level half (runs without these fonts): every script has a Windows, macOS and Linux
// family in the candidates, ahead of the broad last-resort fonts; the shared Nirmala UI is
// probed once.
for order in [crate::cjk::script_order(None), crate::cjk::script_order(Some("ja"))] {
let fb = fonts::fallback_candidates(&order);
let last = fb.iter().position(|f| *f == "Arial Unicode MS").unwrap();
assert_eq!(fb.iter().filter(|f| **f == "Nirmala UI").count(), 1, "{fb:?}");
for (name, _, families) in INDIC {
assert!(families.contains(&"Nirmala UI"), "{name}");
assert!(families.iter().any(|f| f.ends_with("Sangam MN") || f.starts_with("Kohinoor")), "{name}: no macOS family");
assert!(families.iter().any(|f| f.starts_with("Noto Sans ")), "{name}: no Linux family");
for fam in *families {
let i = fb.iter().position(|f| f == fam).unwrap_or_else(|| panic!("{fam} missing from {fb:?}"));
assert!(i < last, "{fam} after the last-resort fonts");
}
}
}
}
#[test]
fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() {
let Some(cairo) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Cairo") else {

View File

@ -212,7 +212,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
|---|---|---|---|---|
| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | missing | [#218](https://github.com/storytold/photocraft/issues/218) | doc text.rs:209 TextShape has Point and Box only |
| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI |
| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test) |
| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) |
| TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet |
| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks |
| TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) |

View File

@ -23,7 +23,7 @@ id = "TYPE-218-3"
target = "Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL"
status = "partial"
issue = 218
note = "Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test)"
note = "Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test)"
[[item]]
id = "TYPE-218-4"