collective

Browse / github.com/storytold/craft-libs / patch

Add Hebrew locale normalization, BiDi RTL/LTR helpers, and Hebrew font manifest build support

Add Hebrew locale normalization, BiDi RTL/LTR helpers, and Hebrew font manifest build support ## Summary Adds shared RTL, mixed RTL/LTR (BiDi), and Hebrew font catalog support across `craft-i18n`, `craft-fonts-build`, and `craft-ui`: ### `craft-i18n` - **Legacy ISO 639-1 tag normalization**: Normalizes deprecated primary language subtags (`iw` -> `he`, `ji` -> `yi`, `in` -> `id`) in `scripts()`/`direction()` and `I18n::match_tag()`, so `iw_IL.UTF-8` and `iw-IL` resolve to `he` (`Script::Hebr`, `Direction::Rtl`). - **BiDi mixed RTL/LTR helpers**: - `auto_direction(text, fallback)`: resolves paragraph direction from the first strong character outside isolates (UAX #9 P2/P3 via `base_

Status
open
Score
0.00
Adopters
0 other operators
Author
hrb9 (imported from pull request #342, open)
Base
9ff719499d8f4730b93071abed993e5f12fe1be8
Size
+204 −12 in 7 files
Review
pending
Shared
2026-10-28
Id
d329e40fe6777774c05367e3

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts-build/src/embed.rs crates/fonts-build/src/lib.rs crates/fonts-build/src/manifest.rs crates/fonts-build/src/tests.rs crates/i18n/src/lib.rs crates/i18n/src/scripts.rs crates/ui/src/text.rs

diff --git a/crates/fonts-build/src/embed.rs b/crates/fonts-build/src/embed.rs--- a/crates/fonts-build/src/embed.rs+++ b/crates/fonts-build/src/embed.rs@@ -9,7 +9,7 @@ use std::io; use std::path::{Path, PathBuf};  use crate::cmap::Cmap;-use crate::manifest::{ManifestEntry, ParseError, parse_manifest, script_matches};+use crate::manifest::{DEFAULT_MANIFEST, HEBREW_MANIFEST, LATIN_MANIFEST, ManifestEntry, ParseError, parse_manifest, script_matches};  const DIR_VAR: &str = "CRAFT_FONTS_DIR"; const REQUIRED_VAR: &str = "CRAFT_FONTS_REQUIRED";@@ -25,6 +25,7 @@ type Filter = Box<dyn Fn(&ManifestEntry) -> bool>; /// Which faces to embed, and from where. [`Embed::write`] does the work. #[derive(Default)] pub struct Embed {+    manifest: Option<PathBuf>,     scripts: Option<Vec<String>>,     filter: Option<Filter>,     dir: Option<PathBuf>,@@ -72,6 +73,37 @@ impl Embed {         Self { families: Some(families.iter().map(|f| (*f).to_owned()).collect()), ..Self::default() }     } +    /// Read an opt-in manifest file relative to the `craft-fonts` checkout instead of+    /// [`DEFAULT_MANIFEST`] (`"fonts/manifest.txt"`), such as [`HEBREW_MANIFEST`] or+    /// [`LATIN_MANIFEST`].+    pub fn manifest(mut self, rel_path: impl Into<PathBuf>) -> Self {+        self.manifest = Some(rel_path.into());+        self+    }++    /// Every face in the opt-in Hebrew catalog ([`HEBREW_MANIFEST`], `"fonts/hebrew-manifest.txt"`).+    /// Narrow it with [`Embed::filter`] or select specific families with+    /// [`Embed::hebrew_documents`].+    pub fn hebrew() -> Self {+        Self::new().manifest(HEBREW_MANIFEST)+    }++    /// Every face of these families from the opt-in Hebrew catalog ([`HEBREW_MANIFEST`]).+    pub fn hebrew_documents(families: &[&str]) -> Self {+        Self::documents(families).manifest(HEBREW_MANIFEST)+    }++    /// Every face in the opt-in Latin presentation catalog ([`LATIN_MANIFEST`],+    /// `"fonts/latin-manifest.txt"`).+    pub fn latin() -> Self {+        Self::new().manifest(LATIN_MANIFEST)+    }++    /// Every face of these families from the opt-in Latin presentation catalog ([`LATIN_MANIFEST`]).+    pub fn latin_documents(families: &[&str]) -> Self {+        Self::documents(families).manifest(LATIN_MANIFEST)+    }+     /// Embed only faces with at least one of these script tags (see     /// [`ManifestEntry::has_script`]). With `CRAFT_FONTS_REQUIRED=1`, each tag must match a face.     pub fn scripts(mut self, tags: &[&str]) -> Self {@@ -233,9 +265,14 @@ impl Embed {         Ok(faces)     } +    /// Path to the selected manifest file inside `dir`.+    fn manifest_path(&self, dir: &Path) -> PathBuf {+        dir.join(self.manifest.as_deref().unwrap_or_else(|| Path::new(DEFAULT_MANIFEST)))+    }+     /// The wanted manifest entries with their canonical file paths.     fn select(&self, dir: &Path, cargo: &mut Vec<String>) -> Result<Vec<Face>, Error> {-        let manifest = dir.join("fonts/manifest.txt");+        let manifest = self.manifest_path(dir);         let text = std::fs::read_to_string(&manifest).map_err(|source| Error::Read { path: manifest.clone(), source })?;         // Cargo treats a missing watched file as changed, which would rerun the build every time.         cargo.push(format!("cargo::rerun-if-changed={}", manifest.display()));@@ -364,7 +401,7 @@ impl Embed {         if self.elsewhere.is_empty() {             return Vec::new();         }-        let manifest = dir.join("fonts/manifest.txt");+        let manifest = self.manifest_path(dir);         // `select` read and parsed this manifest already.         let entries = std::fs::read_to_string(&manifest).ok().and_then(|text| parse_manifest(&text).ok()).unwrap_or_default();         for family in self.elsewhere.iter().filter(|f| !entries.iter().any(|e| &e.family == *f)) {@@ -420,7 +457,7 @@ impl Embed {     }      fn check_required(&self, dir: &Path, faces: Vec<Face>) -> Result<Vec<Face>, Error> {-        let manifest = dir.join("fonts/manifest.txt");+        let manifest = self.manifest_path(dir);         if faces.is_empty() {             return Err(Error::NoFaces { manifest });         }diff --git a/crates/fonts-build/src/lib.rs b/crates/fonts-build/src/lib.rs--- a/crates/fonts-build/src/lib.rs+++ b/crates/fonts-build/src/lib.rs@@ -63,7 +63,7 @@ mod subset; mod tests;  pub use embed::{Embed, Error, target_is_wasm};-pub use manifest::{CJK_SCRIPTS, ManifestEntry, ParseError, UI_ROLE, parse_manifest};+pub use manifest::{CJK_SCRIPTS, DEFAULT_MANIFEST, HEBREW_MANIFEST, LATIN_MANIFEST, ManifestEntry, ParseError, UI_ROLE, parse_manifest};  /// A craft-fonts face embedded by [`Embed`]: one line of `fonts/manifest.txt`. ///diff --git a/crates/fonts-build/src/manifest.rs b/crates/fonts-build/src/manifest.rs--- a/crates/fonts-build/src/manifest.rs+++ b/crates/fonts-build/src/manifest.rs@@ -60,6 +60,15 @@ impl ManifestEntry { /// The `role` of an interface face. pub const UI_ROLE: &str = "ui"; +/// Default manifest file relative to a `craft-fonts` checkout.+pub const DEFAULT_MANIFEST: &str = "fonts/manifest.txt";++/// Opt-in Latin presentation manifest relative to a `craft-fonts` checkout.+pub const LATIN_MANIFEST: &str = "fonts/latin-manifest.txt";++/// Opt-in Hebrew presentation and document manifest relative to a `craft-fonts` checkout.+pub const HEBREW_MANIFEST: &str = "fonts/hebrew-manifest.txt";+ /// Script tags of the CJK faces, which craft-fonts also ships as interface faces. `Hant` covers /// `Hant-HK`. pub const CJK_SCRIPTS: &[&str] = &["Hans", "Hant", "Jpan", "Kore", "Hani"];diff --git a/crates/fonts-build/src/tests.rs b/crates/fonts-build/src/tests.rs--- a/crates/fonts-build/src/tests.rs+++ b/crates/fonts-build/src/tests.rs@@ -123,7 +123,12 @@ fn rejects_a_checkout_under_a_non_utf8_path() {     use std::os::unix::ffi::OsStrExt;     let scratch = Scratch::new("non-utf8");     let dir = scratch.0.join(std::ffi::OsStr::from_bytes(b"craft-fonts-\xff"));-    std::fs::create_dir_all(dir.join("fonts")).unwrap();+    // Filesystems such as macOS APFS reject non-UTF-8 paths at creation time (EILSEQ).+    match std::fs::create_dir_all(dir.join("fonts")) {+        Ok(()) => {}+        Err(e) if matches!(e.raw_os_error(), Some(84 | 92)) || e.kind() == std::io::ErrorKind::InvalidInput => return,+        Err(e) => panic!("creating non-UTF-8 test directory failed: {e}"),+    }     std::fs::copy(fixture().join("fonts/sans-arabic/SansArabic.ttf"), dir.join("fonts/a.ttf")).unwrap();     std::fs::write(dir.join("fonts/manifest.txt"), "A | Regular | fonts/a.ttf | Arab | OFL-1.1 | OFL.txt | 00 | src\n").unwrap();     let r = run(Embed::new(), Some(&dir), Some("1"));@@ -253,6 +258,29 @@ fn integrity_is_base64_of_the_hex_hash() {     } } +/// Opt-in Hebrew and Latin manifests embed their selected faces independently of the default manifest.+#[test]+fn embeds_from_opt_in_hebrew_and_latin_manifests() {+    let scratch = Scratch::new("hebrew-manifest");+    let dir = scratch.0.join("craft-fonts");+    std::fs::create_dir_all(dir.join("fonts")).unwrap();+    std::fs::copy(fixture().join("fonts/sans-arabic/SansArabic.ttf"), dir.join("fonts/heebo.ttf")).unwrap();+    std::fs::copy(fixture().join("fonts/sans-arabic/SansArabic.ttf"), dir.join("fonts/david.ttf")).unwrap();+    std::fs::write(+        dir.join("fonts/hebrew-manifest.txt"),+        "Heebo | Variable | fonts/heebo.ttf | Hebr,Latn | OFL-1.1 | fonts/OFL.txt | 00 | src\n\+         David Libre | Regular | fonts/david.ttf | Hebr,Latn | OFL-1.1 | fonts/OFL.txt | 00 | src\n",+    )+    .unwrap();+    std::fs::write(dir.join("fonts/latin-manifest.txt"), "Latin Face | Regular | fonts/david.ttf | Latn | OFL-1.1 | fonts/OFL.txt | 00 | src\n")+        .unwrap();+    let r = run(Embed::hebrew(), Some(&dir), Some("1"));+    assert_eq!(r.result.unwrap(), ["Heebo Variable", "David Libre Regular"]);+    let r = run(Embed::hebrew_documents(&["David Libre"]), Some(&dir), Some("1"));+    assert_eq!(r.result.unwrap(), ["David Libre Regular"]);+    assert_eq!(run(Embed::latin(), Some(&dir), Some("1")).result.unwrap(), ["Latin Face Regular"]);+}+ /// A font with only a `cmap` table: a Windows format 4 subtable with `bmp` segments `(first, /// last, glyphs)`, mapped to glyphs 1.. when `glyphs` is `None` and through the glyph array /// otherwise, and a format 12 subtable with `groups` `(first, last)` when there are any.diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs--- a/crates/i18n/src/lib.rs+++ b/crates/i18n/src/lib.rs@@ -55,7 +55,7 @@ pub use fold::{Folded, Folder}; pub use format::{Digits, Format}; pub use number::{NumberError, parse_number}; pub use region::{HourCycle, Measurement, Paper, Region};-pub use scripts::{Direction, Han, Script, base_direction, direction, han, scripts};+pub use scripts::{Direction, Han, Script, auto_direction, base_direction, direction, han, scripts}; pub use size::{FileSize, SizeUnit};  /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog).@@ -456,6 +456,14 @@ pub fn ltr(value: &str) -> String {     wrap(LRI, value) } +/// `value` as a right-to-left isolate (RLI…PDI), for right-to-left phrases that start with a+/// number, Latin acronym or neutral character (`123 שלום`, `PDF בעברית`) so a first-strong+/// isolate ([`isolate`]) would otherwise treat them as left to right. [`fmt`] takes the isolate+/// off again in text with no right-to-left letter and no bidi control.+pub fn rtl_isolate(value: &str) -> String {+    wrap(RLI, value)+}+ /// `text` without isolate marks (LRI, RLI, FSI, PDI): a [`fmt`] or [`I18n::trn`] result made fit /// for a file name, a log or a comparison. pub fn strip_isolates(text: &str) -> String {@@ -584,9 +592,12 @@ fn candidates(tag: &str) -> Vec<String> {     let tag = tag.trim().to_ascii_lowercase();     let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-");     let mut parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();-    let Some(&primary) = parts.first() else {+    let Some(first) = parts.first_mut() else {         return Vec::new();     };+    let original = *first;+    *first = canonical_language(first);+    let primary = *first;     let modifier = match tag.split_once('@').map(|(_, m)| m) {         Some("latin") => Some("latn"),         Some("cyrillic") => Some("cyrl"),@@ -606,7 +617,20 @@ fn candidates(tag: &str) -> Vec<String> {         out.extend(candidates(&parts.join("-")));         return out;     }-    let mut out: Vec<String> = (1..=parts.len()).rev().map(|n| parts.get(..n).unwrap_or_default().join("-")).collect();+    let mut out = Vec::new();+    for n in (1..=parts.len()).rev() {+        out.push(parts.get(..n).unwrap_or_default().join("-"));+        if original != primary {+            let mut raw = parts.get(..n).unwrap_or_default().to_vec();+            if let Some(first) = raw.first_mut() {+                *first = original;+            }+            let raw = raw.join("-");+            if !out.contains(&raw) {+                out.push(raw);+            }+        }+    }     if let Some(s) = script         && usual_script(primary).is_some_and(|usual| usual != s)     {@@ -652,6 +676,17 @@ fn usual_script(language: &str) -> Option<&'static str> {     }) } +/// Canonicalize deprecated ISO 639-1 primary language subtags (`iw` → `he`, `ji` → `yi`,+/// `in` → `id`).+pub(crate) fn canonical_language(language: &str) -> &str {+    match language {+        "iw" => "he",+        "ji" => "yi",+        "in" => "id",+        other => other,+    }+}+ /// A BCP 47 region subtag: two letters or three digits. fn is_region(subtag: &str) -> bool {     (subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))@@ -1212,8 +1247,30 @@ mod tests {      #[test]     fn more_right_to_left() {-        let i = all(&[("ps", "", ""), ("ckb", "", ""), ("yi", "", ""), ("ug", "", "")]).0;+        let i = all(&[+            ("he", "@id\tlanguage.name\tעברית\n@plural\t{n} page|{n} pages\tעמוד {n}|{n} עמודים|{n} עמודים\n", ""),+            ("ps", "", ""),+            ("ckb", "", ""),+            ("yi", "", ""),+            ("ug", "", ""),+        ])+        .0;         assert!(i.languages().into_iter().skip(1).all(|l| i.rtl(l)));+        let he = lang(&i, "he");+        assert_eq!(i.name(he), "עברית");+        assert_eq!(i.match_tag("he_IL.UTF-8"), Some(he));+        assert_eq!(i.match_tag("iw_IL.UTF-8"), Some(he));+        assert_eq!(i.match_tag("iw-IL"), Some(he));+        assert_eq!(candidates("iw-IL"), ["he-il", "iw-il", "he", "iw"]);+        assert_eq!(i.trn(he, 1, "{n} page", "{n} pages"), "עמוד \u{2066}1\u{2069}");+        assert_eq!(i.trn(he, 2, "{n} page", "{n} pages"), "\u{2066}2\u{2069} עמודים");+        assert_eq!(i.trn(he, 10, "{n} page", "{n} pages"), "\u{2066}10\u{2069} עמודים");+        assert_eq!(i.trn_plain(he, 10, "{n} page", "{n} pages"), "10 עמודים");+        let mixed = fmt(+            "שמור את {file} בתיקייה {dir} ({note})",+            &[("file", "Report 2.pdf"), ("dir", &ltr("/Users/dana/docs")), ("note", &rtl_isolate("PDF בעברית"))],+        );+        assert_eq!(mixed, "שמור את \u{2068}Report 2.pdf\u{2069} בתיקייה \u{2066}/Users/dana/docs\u{2069} (\u{2067}PDF בעברית\u{2069})");     }      #[test]diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs--- a/crates/i18n/src/scripts.rs+++ b/crates/i18n/src/scripts.rs@@ -173,6 +173,13 @@ pub fn base_direction(text: &str) -> Option<Direction> {     } } +/// The resolved direction of a paragraph or mixed-direction snippet: its [`base_direction`] when+/// it contains a strong character outside isolates, or `fallback` (such as the interface or+/// document direction) when it contains only neutral characters or numbers.+pub fn auto_direction(text: &str, fallback: Direction) -> Direction {+    base_direction(text).unwrap_or(fallback)+}+ /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → /// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters /// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO`, `zh-Hant-HK` and `yue` → HongKong; `ja` →@@ -265,7 +272,7 @@ impl Subtags {         let tag = tag.trim().to_ascii_lowercase();         let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-");         let mut parts = base.split('-').filter(|p| !p.is_empty());-        let language = parts.next().unwrap_or("").to_owned();+        let language = super::canonical_language(parts.next().unwrap_or("")).to_owned();         let mut script = match tag.split_once('@').map(|(_, m)| m) {             Some("latin") => Some("latn".to_owned()),             Some("cyrillic") => Some("cyrl".to_owned()),@@ -301,6 +308,11 @@ mod tests {         assert_eq!(base_direction("123\nשלום"), None);         assert_eq!(base_direction("1920 × 1080"), None);         assert_eq!(base_direction(""), None);++        assert_eq!(auto_direction("שלום Hello", Direction::Ltr), Direction::Rtl);+        assert_eq!(auto_direction("Hello שלום", Direction::Rtl), Direction::Ltr);+        assert_eq!(auto_direction("1920 × 1080", Direction::Rtl), Direction::Rtl);+        assert_eq!(auto_direction("1920 × 1080", Direction::Ltr), Direction::Ltr);     }      /// Every locale the craft-i18n catalogs or an app ship, as the apps spell them.@@ -319,6 +331,7 @@ mod tests {             ("et", &[Latn], None),             ("fi", &[Latn], None),             ("fr", &[Latn], None),+            ("he", &[Hebr], None),             ("hr", &[Latn], None),             ("hu", &[Latn], None),             ("id", &[Latn], None),@@ -351,9 +364,15 @@ mod tests {         ] {             assert_eq!(scripts(tag), want, "{tag}");             assert_eq!(han(tag), han_forms, "{tag}");-            let rtl = tag == "ar";+            let rtl = matches!(tag, "ar" | "he");             assert_eq!(direction(tag), if rtl { Direction::Rtl } else { Direction::Ltr }, "{tag}");         }+        // Legacy ISO 639-1 aliases normalize to their BCP 47 primary subtags.+        assert_eq!(scripts("iw_IL.UTF-8"), &[Hebr]);+        assert_eq!(direction("iw-IL"), Direction::Rtl);+        assert_eq!(scripts("ji"), &[Hebr]);+        assert_eq!(direction("ji"), Direction::Rtl);+        assert_eq!(scripts("in-ID"), &[Latn]);     }      #[test]diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs--- a/crates/ui/src/text.rs+++ b/crates/ui/src/text.rs@@ -123,6 +123,27 @@ pub fn align_galley_rows(mut galley: Arc<Galley>, width: f32, mut align: impl Fn     AlignedGalley::Aligned { galley, paint_offset, reading_rect } } +/// The start-edge alignment for `row`'s paragraph direction: [`Align::Max`] (right) for a+/// right-to-left paragraph (`row.rtl_paragraph`), or [`Align::Min`] (left) for a left-to-right+/// paragraph.+pub fn row_start_align(row: &PlacedRow) -> Align {+    if row.rtl_paragraph { Align::Max } else { Align::Min }+}++/// The end-edge alignment for `row`'s paragraph direction: [`Align::Min`] (left) for a+/// right-to-left paragraph (`row.rtl_paragraph`), or [`Align::Max`] (right) for a left-to-right+/// paragraph.+pub fn row_end_align(row: &PlacedRow) -> Align {+    if row.rtl_paragraph { Align::Min } else { Align::Max }+}++/// Align each row inside `width` to the start edge of its paragraph ([`row_start_align`]):+/// right-to-left paragraphs flush with the right edge (`Align::Max`), and left-to-right+/// paragraphs flush with the left edge (`Align::Min`).+pub fn align_galley_rows_by_direction(galley: Arc<Galley>, width: f32) -> AlignedGalley {+    align_galley_rows(galley, width, row_start_align)+}+ #[cfg(test)] mod tests {     use super::*;@@ -512,6 +533,27 @@ mod tests {         assert_eq!(before, Arc::as_ptr(parts(&result).0));     } +    #[test]+    /// Mixed RTL (Hebrew) and LTR (English) paragraphs align to their respective start edges within `width`.+    fn align_galley_rows_by_direction_aligns_mixed_rtl_and_ltr_paragraphs() {+        let original = layout_text("שלום עולם\nHello world\nשורה שלישית Hello", 1.0, Align::Min);+        assert_eq!(original.rows.len(), 3);+        assert!(original.rows[0].rtl_paragraph);+        assert!(!original.rows[1].rtl_paragraph);+        assert!(original.rows[2].rtl_paragraph);+        assert_eq!(row_start_align(&original.rows[0]), Align::Max);+        assert_eq!(row_start_align(&original.rows[1]), Align::Min);+        assert_eq!(row_end_align(&original.rows[0]), Align::Min);+        assert_eq!(row_end_align(&original.rows[1]), Align::Max);++        let result = align_galley_rows_by_direction(original, 300.0);+        let (aligned, offset, _) = parts(&result);+        assert_eq!(offset, Vec2::ZERO);+        assert!((aligned.rows[0].rect().right() - 300.0).abs() < 1.0, "RTL paragraph must align to right edge");+        assert_eq!(aligned.rows[1].pos.x, 0.0, "LTR paragraph must align to left edge");+        assert!((aligned.rows[2].rect().right() - 300.0).abs() < 1.0, "mixed paragraph starting with Hebrew must align to right edge");+    }+     const MIXED: &str = "First extraordinarily long reading row\nSecond independently aligned reading row\nThird right-aligned overwide reading row";     const ALIGNMENTS: [Align; 3] = [Align::Min, Align::Center, Align::Max];