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];
 
