diff --git a/ROADMAP.md b/ROADMAP.md index 6a9a7a9..6c0652f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,7 +87,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), and the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too. Hyphenation dictionaries for other languages aren't bundled yet. +- Language (Character panel ▸ Language, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks: Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Hyphenation dictionaries for other languages aren't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 075f19d..83ba818 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -20,4 +20,5 @@ regex = { workspace = true } miniz_oxide = "0.8" [dev-dependencies] +designcraft-fonts = { workspace = true, features = ["testing"] } proptest = { workspace = true } diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs index d3b8cf1..06d57ba 100644 --- a/crates/compose/src/ruby.rs +++ b/crates/compose/src/ruby.rs @@ -45,7 +45,7 @@ fn kenten(b: &PlacedGlyph, size: f64, character: &str) -> Vec { let mut base = b.clone(); if let Some(c) = character.chars().next() && !base.face.covers(c) - && let Some(face) = designcraft_fonts::FontDb::global().fallback_for(c, base.face.id()) + && let Some(face) = designcraft_fonts::FontDb::global().fallback_for(c, base.face.id(), None) { base.face = designcraft_fonts::FaceRef::of(&face); } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 6188f42..92e297f 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -466,6 +466,8 @@ fn shape_run_raw( out: &mut Vec, ) { let primary = db.face(&p.font_family, &p.font_style); + // The language picks the fallback for CJK characters the primary font lacks. + let lang = designcraft_doc::language_tag(&p.language); let strong = |c| match unicode_bidi::bidi_class(c) { unicode_bidi::BidiClass::AL => Some(true), unicode_bidi::BidiClass::L | unicode_bidi::BidiClass::R => Some(false), @@ -501,7 +503,8 @@ fn shape_run_raw( if d != c { flush(seg_start, i, &seg_face, out); seg_start = i + c.len_utf8(); - let face = if primary.covers(d) { primary.clone() } else { db.fallback_for(d, primary.id()).unwrap_or_else(|| primary.clone()) }; + let face = + if primary.covers(d) { primary.clone() } else { db.fallback_for(d, primary.id(), lang).unwrap_or_else(|| primary.clone()) }; shape_segment(db, text, i..i + c.len_utf8(), Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, style, out, sub.vertical); continue; } @@ -602,7 +605,7 @@ fn shape_run_raw( let face = if covered || is_mark(c) { if is_mark(c) { seg_face.clone() } else { primary.clone() } } else { - db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone()) + db.fallback_for(c, primary.id(), lang).unwrap_or_else(|| primary.clone()) }; if face.id() != seg_face.id() { flush(seg_start, i, &seg_face, out); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e68846b..a1261a3 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1258,7 +1258,7 @@ fn arabic_character_direction_override_survives_line_layout() { #[test] fn arabic_fallback_marks_stay_with_bases_and_custom_offsets_move_only_marks() { let db = designcraft_fonts::FontDb::global(); - let Some(face) = db.fallback_for('ب', db.face(designcraft_fonts::DEFAULT_FAMILY, "Regular").id()) else { return }; + let Some(face) = db.fallback_for('ب', db.face(designcraft_fonts::DEFAULT_FAMILY, "Regular").id(), None) else { return }; if !face.covers('ُ') { return; } @@ -1468,3 +1468,28 @@ fn chinese_and_japanese_take_their_localized_forms() { assert_eq!(chinese, FontDb::global().face(noto.family, noto.style).glyph_for('直')); assert_ne!(gid("Japanese"), chinese, "Japanese 直"); } + +#[test] +fn cjk_fallback_follows_the_language() { + use designcraft_fonts::testing::font_with; + // Stand-ins for Hiragino Mincho ProN (the Japanese chain's first system font) and Songti SC + // (first in the Simplified Chinese chain); the real ones when installed. + let db = designcraft_fonts::FontDb::global(); + db.add_font(font_with("Hiragino Mincho ProN", &['直']).unwrap()); + db.add_font(font_with("Songti SC", &['直']).unwrap()); + // (face of the Latin letter, face of the ideograph) + let faces_of = |language: &str| { + let (mut d, sid, _) = doc_with("a直", Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..4, |f| f.over.language = Some(language.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let face_at = |byte: usize| all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).find(|g| g.byte == byte).map(|g| g.face).unwrap(); + (face_at(0), face_at(1)) + }; + assert_eq!(faces_of("Chinese: Simplified").1.family, "Songti SC"); + // Japanese: the craft-fonts faces lead the chain (Mincho first), then the system fonts. + let japanese = designcraft_fonts::japanese_document_fonts().first().map_or("Hiragino Mincho ProN", |f| f.family); + assert_eq!(faces_of("Japanese").1.family, japanese); + // Without a CJK language, the language-blind search. + let (primary, han) = faces_of("English: USA"); + assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 6542bc0..d30d210 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -8,6 +8,10 @@ description = "DesignCraft fonts: font database (bundled OFL + system), metrics, [lints] workspace = true +[features] +# Synthetic fonts for other crates' tests (`designcraft_fonts::testing`). +testing = [] + [dependencies] kurbo = { workspace = true } skrifa = { workspace = true } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7347691..c5f0271 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -246,6 +246,8 @@ pub struct FontDb { struct SysFallback { enabled: bool, misses: std::collections::HashSet, + /// CJK chain families already looked up in the catalog (loaded, or not installed). + chain_tried: std::collections::HashSet<&'static str>, } /// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. @@ -278,6 +280,108 @@ const SYSTEM_FALLBACKS: &[&str] = &[ "Noto Color Emoji", ]; +/// CJK fallback chains: the families tried first (user-added, else installed) for characters of +/// each language that the primary font lacks. Each lists macOS, then Windows, then Linux / +/// cross-platform families, serif first; a platform only has its own installed. Japanese tries +/// the craft-fonts Japanese faces before these ([`japanese_chain`]). +const JAPANESE_FALLBACKS: &[&str] = &[ + "Hiragino Mincho ProN", + "YuMincho", + "Hiragino Sans", + "Yu Mincho", + "MS Mincho", + "Yu Gothic", + "Noto Serif CJK JP", + "Source Han Serif JP", + "Noto Serif JP", + "Noto Sans CJK JP", + "Source Han Sans JP", +]; +const SIMPLIFIED_CHINESE_FALLBACKS: &[&str] = &[ + "Songti SC", + "STSong", + "PingFang SC", + "SimSun", + "Microsoft YaHei", + "Noto Serif CJK SC", + "Source Han Serif SC", + "Noto Serif SC", + "Noto Sans CJK SC", + "Source Han Sans SC", +]; +const TRADITIONAL_CHINESE_FALLBACKS: &[&str] = &[ + "Songti TC", + "PingFang TC", + "PMingLiU", + "MingLiU", + "Microsoft JhengHei", + "Noto Serif CJK TC", + "Source Han Serif TC", + "Noto Serif TC", + "Noto Sans CJK TC", + "Source Han Sans TC", +]; +const KOREAN_FALLBACKS: &[&str] = &[ + "AppleMyungjo", + "Apple SD Gothic Neo", + "Batang", + "Malgun Gothic", + "Noto Serif CJK KR", + "Source Han Serif K", + "Noto Serif KR", + "Noto Sans CJK KR", + "Source Han Sans K", +]; + +/// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order +/// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. +fn japanese_chain() -> &'static [&'static str] { + static CHAIN: std::sync::OnceLock> = std::sync::OnceLock::new(); + CHAIN.get_or_init(|| { + let mut chain: Vec<&'static str> = Vec::new(); + for f in crate::japanese_document_fonts() { + if !chain.contains(&f.family) { + chain.push(f.family); + } + } + chain.extend_from_slice(JAPANESE_FALLBACKS); + chain + }) +} + +/// The CJK fallback chain for `c` in `language` (a BCP 47 tag). The script decides where it is +/// unambiguous (Hangul is Korean, kana Japanese, Bopomofo Traditional Chinese); ideographs and CJK +/// punctuation follow the language, and have no chain without a CJK one. +fn cjk_chain(c: char, language: Option<&str>) -> Option<&'static [&'static str]> { + match c as u32 { + 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC => return Some(KOREAN_FALLBACKS), + 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => return Some(japanese_chain()), + 0x3100..=0x312F | 0x31A0..=0x31BF => return Some(TRADITIONAL_CHINESE_FALLBACKS), + // Radicals, CJK symbols and punctuation, kanbun, strokes, enclosed and compatibility + // characters, ideographs, compatibility and vertical forms, full-width forms. + 0x2E80..=0x2FDF + | 0x3000..=0x303F + | 0x3190..=0x319F + | 0x31C0..=0x31EF + | 0x3200..=0x33FF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xF900..=0xFAFF + | 0xFE10..=0xFE1F + | 0xFE30..=0xFE4F + | 0xFF00..=0xFFEF + | 0x20000..=0x3FFFF => {} + _ => return None, + } + match language? { + "ja" => Some(japanese_chain()), + "ko" => Some(KOREAN_FALLBACKS), + "zh" | "zh-Hans" => Some(SIMPLIFIED_CHINESE_FALLBACKS), + "zh-Hant" => Some(TRADITIONAL_CHINESE_FALLBACKS), + _ => None, + } +} + static NEXT_ID: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; @@ -796,9 +900,16 @@ impl FontDb { .map(|f| (*f).clone()) } - /// First face (fallback family first, then load order) that covers `c`; on native, system + /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when + /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the + /// first face (fallback family first, then load order) that covers `c`. On native, system /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. - pub fn fallback_for(&self, c: char, exclude: u32) -> Option> { + pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { + if let Some(chain) = cjk_chain(c, language) + && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) + { + return Some(f); + } if let Some(f) = self.loaded_fallback(c, exclude) { return Some(f); } @@ -809,6 +920,23 @@ impl FontDb { None } + /// The face of `family` (regular first) that covers `c`, loading the installed family the + /// first time a chain asks for it (native, while the system fallback is on). + fn chain_face(&self, family: &'static str, c: char, exclude: u32) -> Option> { + #[cfg(not(target_arch = "wasm32"))] + if !self.is_loaded(family) { + // Held while loading, so a parallel lookup waits for the font instead of passing it by. + let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if sys.enabled && sys.chain_tried.insert(family) { + self.load_cataloged(family); + } + } + let faces = self.read_faces(); + let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(family)).collect(); + order.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { let faces = self.read_faces(); let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); @@ -903,3 +1031,8 @@ impl OutlinePen for FlipPen { #[cfg(not(target_arch = "wasm32"))] #[path = "tests_sysfonts.rs"] mod tests_sysfonts; + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +#[path = "tests_fallback.rs"] +mod tests_fallback; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index bda6987..9b3f0fb 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -8,6 +8,8 @@ #![forbid(unsafe_code)] mod fontdb; +#[cfg(any(test, feature = "testing"))] +pub mod testing; pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs}; pub use harfrust::Feature; @@ -382,10 +384,13 @@ mod tests { assert_eq!(horizontal.len(), vertical.len()); assert!(horizontal.iter().zip(&vertical).any(|(h, v)| h.gid != v.gid), "{} has vertical forms", cf.family); } - // Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts. + // Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts, in + // Japanese text (the Japanese chain) and without a language (the first loaded face). let latin = db.face(DEFAULT_FAMILY, "Regular"); - let fb = db.fallback_for('語', latin.id()).unwrap(); - assert!(fb.family.contains("Mincho"), "{fb:?}"); + for language in [Some("ja"), None] { + let fb = db.fallback_for('語', latin.id(), language).unwrap(); + assert!(fb.family.contains("Mincho"), "{language:?}: {fb:?}"); + } } #[test] @@ -398,7 +403,9 @@ mod tests { if CRAFT_FONTS.is_empty() { assert!(japanese_fonts().next().is_none() && japanese_document_fonts().is_empty()); let latin = db.face(DEFAULT_FAMILY, "Regular"); - assert!(db.fallback_for('語', latin.id()).is_none(), "no bundled Japanese font"); + for language in [Some("ja"), None] { + assert!(db.fallback_for('語', latin.id(), language).is_none(), "no bundled Japanese font: {language:?}"); + } } } } diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs new file mode 100644 index 0000000..c1ccae8 --- /dev/null +++ b/crates/fonts/src/testing.rs @@ -0,0 +1,84 @@ +//! Synthetic fonts for tests (here and in dependent crates, through the `testing` feature). + +use skrifa::MetadataProvider; + +/// A font named `family` (style Regular) that maps exactly `chars`, all to one glyph: the bundled +/// Source Sans 3 Regular with its `name` and `cmap` tables replaced. Stands in for a font that +/// isn't installed (a system CJK font on a machine without it). `None` only if the bundled font +/// can't be read. +pub fn font_with(family: &str, chars: &[char]) -> Option> { + let base = *crate::bundled().first()?; + let gid = skrifa::FontRef::new(base).ok()?.charmap().map('X')?.to_u32(); + let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes); + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + + let mut tables: Vec<([u8; 4], Vec)> = Vec::new(); + for r in 0..usize::from(be16(base, 4)?) { + let rec = 12 + r * 16; + let tag: [u8; 4] = base.get(rec..rec + 4)?.try_into().ok()?; + let (offset, len) = (be32(base, rec + 8)? as usize, be32(base, rec + 12)? as usize); + if &tag != b"name" && &tag != b"cmap" { + tables.push((tag, base.get(offset..offset.checked_add(len)?)?.to_vec())); + } + } + + // `name`: format 0, family (1) and subfamily (2), Windows Unicode English. + let strings: Vec> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect(); + let mut name = Vec::new(); + for v in [0u16, 2, 6 + 2 * 12] { + name.extend_from_slice(&v.to_be_bytes()); + } + let mut at = 0usize; + for (id, s) in [1u16, 2].iter().zip(&strings) { + for v in [3u16, 1, 0x409, *id, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] { + name.extend_from_slice(&v.to_be_bytes()); + } + at += s.len(); + } + strings.iter().for_each(|s| name.extend_from_slice(s)); + tables.push((*b"name", name)); + + // `cmap`: one format 12 subtable (Windows, full Unicode), one group per character. + let mut cps: Vec = chars.iter().map(|c| *c as u32).collect(); + cps.sort_unstable(); + cps.dedup(); + let sub_len = u32::try_from(16 + 12 * cps.len()).ok()?; + let mut cmap = Vec::new(); + for v in [0u16, 1, 3, 10] { + cmap.extend_from_slice(&v.to_be_bytes()); + } + cmap.extend_from_slice(&12u32.to_be_bytes()); + cmap.extend_from_slice(&12u16.to_be_bytes()); + cmap.extend_from_slice(&0u16.to_be_bytes()); + for v in [sub_len, 0, u32::try_from(cps.len()).ok()?] { + cmap.extend_from_slice(&v.to_be_bytes()); + } + for cp in cps { + for v in [cp, cp, gid] { + cmap.extend_from_slice(&v.to_be_bytes()); + } + } + tables.push((*b"cmap", cmap)); + + // The file: table directory (sorted by tag), then the tables, each 4-byte aligned. + tables.sort_by_key(|t| t.0); + let n = u16::try_from(tables.len()).ok()?; + let pow = 1u16 << (15 - n.leading_zeros().min(15)); + let mut out = base.get(0..4)?.to_vec(); + for v in [n, pow * 16, pow.trailing_zeros() as u16, n * 16 - pow * 16] { + out.extend_from_slice(&v.to_be_bytes()); + } + let mut offset = 12 + 16 * tables.len(); + for (tag, data) in &tables { + out.extend_from_slice(tag); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes()); + out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes()); + offset += data.len().next_multiple_of(4); + } + for (_, data) in &tables { + out.extend_from_slice(data); + out.resize(out.len().next_multiple_of(4), 0); + } + Some(out) +} diff --git a/crates/fonts/src/tests_fallback.rs b/crates/fonts/src/tests_fallback.rs new file mode 100644 index 0000000..c1f84af --- /dev/null +++ b/crates/fonts/src/tests_fallback.rs @@ -0,0 +1,127 @@ +//! CJK fallback follows the character's language (and the script where it is unambiguous). + +use super::*; +use crate::testing::font_with; + +const HAN: char = '直'; +const HANGUL: char = '한'; +const KANA: char = 'あ'; +const BOPOMOFO: char = 'ㄅ'; + +/// A database of the bundled fonts (and the craft-fonts Japanese faces, when built with them) +/// only, no system fonts, plus `fonts` as (family, chars). +fn db_with(fonts: &[(&str, &[char])]) -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + for (family, chars) in fonts { + assert_eq!(db.add_font(font_with(family, chars).unwrap()), 1, "{family}"); + } + db +} + +fn family(db: &FontDb, c: char, language: Option<&str>) -> Option { + db.fallback_for(c, 0, language).map(|f| f.family.clone()) +} + +/// The families the Japanese chain finds, in order: the craft-fonts Japanese faces (none without +/// craft-fonts), then `installed`, the stand-ins for system fonts (in chain order). +fn japanese_chain_with(installed: &[&'static str]) -> Vec<&'static str> { + let mut v: Vec<&'static str> = Vec::new(); + for f in crate::japanese_document_fonts() { + if !v.contains(&f.family) { + v.push(f.family); + } + } + v.extend_from_slice(installed); + v +} + +/// What the language-blind search finds first for CJK text when `added` is the first added font +/// covering it: the craft-fonts Japanese faces load ahead of added fonts. +fn first_loaded(added: &'static str) -> &'static str { + japanese_chain_with(&[added])[0] +} + +#[test] +fn ideographs_follow_the_language() { + let db = db_with(&[("Songti SC", &[HAN]), ("Songti TC", &[HAN]), ("AppleMyungjo", &[HAN]), ("Hiragino Mincho ProN", &[HAN])]); + assert_eq!(family(&db, HAN, Some("zh-Hans")).as_deref(), Some("Songti SC")); + assert_eq!(family(&db, HAN, Some("zh")).as_deref(), Some("Songti SC"), "plain Chinese is Simplified"); + assert_eq!(family(&db, HAN, Some("zh-Hant")).as_deref(), Some("Songti TC")); + assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some("AppleMyungjo")); + let ja = japanese_chain_with(&["Hiragino Mincho ProN"])[0]; + assert_eq!(family(&db, HAN, Some("ja")).as_deref(), Some(ja), "the craft-fonts faces lead the Japanese chain, then system fonts"); + // CJK punctuation follows the language too. + let db = db_with(&[("Other CJK", &['、']), ("Songti SC", &['、'])]); + assert_eq!(family(&db, '、', Some("zh-Hans")).as_deref(), Some("Songti SC")); + assert_eq!(family(&db, '、', None).as_deref(), Some(first_loaded("Other CJK"))); +} + +#[test] +fn ideographs_without_a_cjk_language_fall_back_as_before() { + let db = db_with(&[("Other CJK", &[HAN]), ("Songti SC", &[HAN]), ("AppleMyungjo", &[HAN])]); + for language in [None, Some("en-US"), Some("tr"), Some("not a tag")] { + assert_eq!(family(&db, HAN, language).as_deref(), Some(first_loaded("Other CJK")), "{language:?}"); + } +} + +#[test] +fn the_script_decides_where_it_is_unambiguous() { + // A non-chain font loaded first would win the language-blind search. + let db = db_with(&[ + ("Other CJK", &[HANGUL, KANA, BOPOMOFO]), + ("AppleMyungjo", &[HANGUL]), + ("Songti TC", &[BOPOMOFO]), + ("Yu Mincho", &[KANA]), + ("Hiragino Mincho ProN", &[KANA]), + ]); + for language in [None, Some("en-US"), Some("ja"), Some("zh-Hans")] { + assert_eq!(family(&db, HANGUL, language).as_deref(), Some("AppleMyungjo"), "Hangul is Korean: {language:?}"); + assert_eq!(family(&db, BOPOMOFO, language).as_deref(), Some("Songti TC"), "Bopomofo is Traditional Chinese: {language:?}"); + } + // Hiragino Mincho ProN comes before Yu Mincho in the chain, whatever order they loaded in. + let chain = japanese_chain_with(&["Hiragino Mincho ProN", "Yu Mincho"]); + for language in [None, Some("zh-Hans"), Some("ko")] { + assert_eq!(family(&db, KANA, language).as_deref(), Some(chain[0]), "kana is Japanese: {language:?}"); + } + // Past the faces the chain can't use (here its first, as the primary font), the next. + let first = db.face(chain[0], "Regular"); + assert_eq!(db.fallback_for(KANA, first.id(), None).map(|f| f.family.clone()).as_deref(), Some(chain[1])); +} + +#[test] +fn without_a_chain_font_the_fallback_is_unchanged() { + let db = db_with(&[("Other CJK", &[HANGUL])]); + // Without craft-fonts nothing covers the ideograph; with it, its first Japanese face does. + let craft = japanese_chain_with(&[]).first().copied(); + for language in ["zh-Hans", "zh-Hant", "ko", "ja"] { + assert_eq!(family(&db, HAN, Some(language)).as_deref(), craft, "{language}"); + } + assert_eq!(family(&db, HANGUL, Some("ko")).as_deref(), Some("Other CJK")); + assert_eq!(family(&db, BOPOMOFO, Some("zh-Hant")), None, "nothing covers it"); +} + +#[test] +fn chain_fonts_load_from_the_font_folders_when_first_needed() { + let dir = std::env::temp_dir().join(format!("dc-cjk-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("songti.ttf"), font_with("Songti SC", &[HAN]).unwrap()).unwrap(); + std::fs::write(dir.join("myungjo.ttf"), font_with("AppleMyungjo", &[HAN]).unwrap()).unwrap(); + // An added font covers the ideograph, so the language-blind search never scans the folder. + let other = || font_with("Other CJK", &[HAN]).unwrap(); + + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.add_font(other()); + assert_eq!(family(&db, HAN, None).as_deref(), Some(first_loaded("Other CJK"))); + assert!(!db.is_loaded("Songti SC"), "no language, no chain font loaded"); + assert_eq!(family(&db, HAN, Some("zh-Hans")).as_deref(), Some("Songti SC")); + assert!(!db.is_loaded("AppleMyungjo"), "only what the chain needed"); + assert_eq!(family(&db, HAN, None).as_deref(), Some(first_loaded("Other CJK")), "once loaded, still not used without the language"); + + // With the system fallback off, installed chain fonts aren't loaded. + let db = FontDb::with_font_dirs(vec![dir]); + db.add_font(other()); + db.set_system_fallback(false); + assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK"))); + assert!(!db.is_loaded("AppleMyungjo")); +} diff --git a/docs/cjk.md b/docs/cjk.md index 24d40e0..483c67c 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -41,14 +41,12 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading | | Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font | | Composite fonts | done: model, `style.compositeFont.*`, shaping, IDML, font list, replace and Preflight; no dialog | -| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; language-blind fallback; no per-document fonts | +| CJK fonts | Japanese faces (Shippori Mincho first) from the optional craft-fonts build input; fallback by language (Japanese: the craft-fonts faces, then system fonts; Simplified and Traditional Chinese, Korean: system fonts); no per-document fonts | | Everything else below | missing | Known bugs to fix first: - Korean breaks between every syllable (it should break at spaces by default). -- Fallback is language-blind: Chinese and Korean text without a covering font get the Japanese - fallback font (and so Japanese glyph forms, whatever their language's `locl` asks for). - Vertical advances are the horizontal ones and upright glyphs turn about a guessed centre (`adv × 0.38`), so proportional and non-square glyphs sit wrong in vertical text. - A justified CJK line has no stretch except Single Word Justification.