mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 22:07:06 -06:00
A paragraph in a Thai font that isn't installed (TH Sarabun New, Angsana New, Cordia New, the UPC families…) drew its Thai letters in one fallback face and the Latin letters, digits and baht sign in a Latin substitute at a visibly different size. Word substitutes one font (Cordia New) for the whole run. Thai families now have their own substitute lists (Cordia New and Leelawadee UI on Windows, Thonburi on macOS, Noto Sans Thai and the TLWG fonts on Linux; serif families prefer Angsana New, Sathu or Noto Serif Thai), and the first listed face with Latin letters and digits as well as Thai wins. When the substitute still lacks some of the run's Thai or Latin text, the run moves to one face covering it through FontDb::fallback_for_run, the generalised East Asian run fallback. Only Thai fonts get this: a missing Latin font keeps Latin text in its own substitute, a run with its own complex-script font keeps it for the Thai letters, and a missing roman-classed Thai font is no longer drawn in Cambria. Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Brandon Thomas
parent
7fc2bbf248
commit
1c8c352cf5
@@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-15 | A missing Thai document font (TH Sarabun New, Angsana New, Cordia New, the UPC families…) is substituted by one Thai face for the whole run, Latin letters and digits included, as Word substitutes Cordia New; Thai no longer sits in one font next to larger Latin in another (#1549) |
|
||||
|---|---|
|
||||
| 2026-10-17 | Paste from LibreOffice: a paragraph indented on both sides (HTML `<blockquote>` on the clipboard) pastes as an indented Normal paragraph with LibreOffice's margins, not centred and italic in the Quote style (#1270). |
|
||||
| 2026-10-17 | DrawingML shape outlines retain explicit preset/custom dashes on screen and in PDF, including zero-width path strokes. |
|
||||
@@ -422,6 +423,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | trivial | Progress log: one Thai face for a run in a missing Thai font (#1549) |
|
||||
| 2026-10-18 | trivial | Progress log: Different First Page headers reachable from page 1 (#2826) |
|
||||
| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) |
|
||||
| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) |
|
||||
|
||||
+72
-34
@@ -310,7 +310,7 @@ pub struct FontDb {
|
||||
/// search sorts every loaded face, so without this text in a script the requested font lacks
|
||||
/// (Thai, #59) got slower with every font loaded.
|
||||
fallbacks: RwLock<FallbackCache>,
|
||||
ea_fallbacks: RwLock<EaFallbackCache>,
|
||||
run_fallbacks: RwLock<RunFallbackCache>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// Fonts the user added at run time, and the folder they are stored in (#582).
|
||||
@@ -546,10 +546,10 @@ struct FallbackCache {
|
||||
map: HashMap<(char, u32), Option<Arc<FontFace>>>,
|
||||
}
|
||||
|
||||
/// [`FontDb::fallback_for_east_asian`] results: (sorted, deduplicated chars, excluded face, bold,
|
||||
/// [`FontDb::fallback_for_run`] results: (sorted, deduplicated chars, excluded face, bold,
|
||||
/// italic) → face, valid while the loaded face count is `faces`.
|
||||
#[derive(Default)]
|
||||
struct EaFallbackCache {
|
||||
struct RunFallbackCache {
|
||||
faces: usize,
|
||||
/// Chars held in the keys (bounds memory: hostile runs can be long).
|
||||
chars: usize,
|
||||
@@ -557,19 +557,24 @@ struct EaFallbackCache {
|
||||
map: HashMap<(Vec<char>, u32, bool, bool), Option<Arc<FontFace>>>,
|
||||
}
|
||||
|
||||
/// Most cached run-level East Asian fallbacks, and most chars in their keys, before the cache is
|
||||
/// reset.
|
||||
const EA_FALLBACK_CACHE_MAX: usize = 4096;
|
||||
const EA_FALLBACK_CACHE_CHARS: usize = 1 << 20;
|
||||
/// Most cached run-level fallbacks, and most chars in their keys, before the cache is reset.
|
||||
const RUN_FALLBACK_CACHE_MAX: usize = 4096;
|
||||
const RUN_FALLBACK_CACHE_CHARS: usize = 1 << 20;
|
||||
|
||||
/// Loaded faces other than `exclude`, in fallback order: for right-to-left text the fonts made
|
||||
/// for the script first (so a word isn't split across fonts, which breaks Arabic joining), then
|
||||
/// the fallback family, upright, nearest to regular weight.
|
||||
fn fallback_order(faces: &[Arc<FontFace>], exclude: u32, rtl: bool) -> Vec<&Arc<FontFace>> {
|
||||
/// for the script first (so a word isn't split across fonts, which breaks Arabic joining); for a
|
||||
/// complex-script character `complex` (Thai, Myanmar…) faces with OpenType layout tables, the
|
||||
/// script's own families first, as the per-character fallback ranks them; then the fallback
|
||||
/// family, upright, nearest to regular weight.
|
||||
fn fallback_order(faces: &[Arc<FontFace>], exclude: u32, rtl: bool, complex: Option<char>) -> Vec<&Arc<FontFace>> {
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
let rank =
|
||||
|f: &FontFace| if rtl { RTL_FALLBACKS.iter().position(|n| f.family.eq_ignore_ascii_case(n)).unwrap_or(RTL_FALLBACKS.len()) } else { 0 };
|
||||
order.sort_by_key(|f| (rank(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
let shaping = |f: &FontFace| match complex {
|
||||
Some(c) => (!f.has_layout_tables(), crate::fallback_rank(c, &f.family)),
|
||||
None => (false, 0),
|
||||
};
|
||||
order.sort_by_key(|f| (rank(f), shaping(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order
|
||||
}
|
||||
|
||||
@@ -917,7 +922,7 @@ impl FontDb {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
fallbacks: RwLock::new(FallbackCache::default()),
|
||||
ea_fallbacks: RwLock::new(EaFallbackCache::default()),
|
||||
run_fallbacks: RwLock::new(RunFallbackCache::default()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
user: RwLock::new(user::UserFonts::default()),
|
||||
@@ -947,7 +952,7 @@ impl FontDb {
|
||||
}
|
||||
// Misses remembered while it was off may be found now.
|
||||
self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear();
|
||||
*self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = EaFallbackCache::default();
|
||||
*self.run_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = RunFallbackCache::default();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
}
|
||||
@@ -1531,16 +1536,20 @@ impl FontDb {
|
||||
(order.into_iter().find(|f| f.covers(c)).cloned(), faces.len())
|
||||
}
|
||||
|
||||
/// One fallback face for a run's East Asian characters: the first face in the fallback order
|
||||
/// that covers **every** given character, so a whole run draws in a single CJK face instead of
|
||||
/// mixing faces character by character (a bundled Japanese Mincho for JIS-covered ideographs
|
||||
/// beside a Simplified-Chinese sans for the rest). Like [`Self::fallback_for`], a system font
|
||||
/// is loaded lazily the first time no loaded face covers a character. `None` when no single
|
||||
/// face covers them all, and the caller keeps the per-character fallback.
|
||||
/// One fallback face for a run: the first face in the fallback order that covers **every**
|
||||
/// given character, so a whole run draws in a single face instead of mixing faces character
|
||||
/// by character. East Asian runs: a bundled Japanese Mincho for JIS-covered ideographs beside
|
||||
/// a Simplified-Chinese sans for the rest. A missing Thai font (#1549): the Thai letters in
|
||||
/// one face and the Latin letters and digits beside them in another, at another size; a Thai
|
||||
/// face covers them all, so the run is given every character, Latin ones included.
|
||||
/// Complex-script characters prefer the script's own families with OpenType layout tables, as
|
||||
/// [`Self::fallback_for`] does. Like it, a system font is loaded lazily the first time no
|
||||
/// loaded face covers a character. `None` when no single face covers them all, and the caller
|
||||
/// keeps the per-character fallback.
|
||||
///
|
||||
/// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces
|
||||
/// change, like [`Self::fallback_for`], so relayout doesn't repeat the search.
|
||||
pub fn fallback_for_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
pub fn fallback_for_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
if chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1550,18 +1559,18 @@ impl FontDb {
|
||||
let key = (set, exclude, bold, italic);
|
||||
let n = self.read_faces().len();
|
||||
{
|
||||
let cache = self.ea_fallbacks.read().unwrap_or_else(|e| e.into_inner());
|
||||
let cache = self.run_fallbacks.read().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.faces == n
|
||||
&& let Some(hit) = cache.map.get(&key)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
}
|
||||
let found = self.search_east_asian(&key.0, exclude, bold, italic);
|
||||
let found = self.search_run(&key.0, exclude, bold, italic);
|
||||
// Keyed by the face count after the search (a system fallback may have loaded fonts).
|
||||
let n = self.read_faces().len();
|
||||
let mut cache = self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.faces != n || cache.map.len() >= EA_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > EA_FALLBACK_CACHE_CHARS {
|
||||
let mut cache = self.run_fallbacks.write().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.faces != n || cache.map.len() >= RUN_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > RUN_FALLBACK_CACHE_CHARS {
|
||||
cache.map.clear();
|
||||
cache.faces = n;
|
||||
cache.chars = 0;
|
||||
@@ -1571,7 +1580,7 @@ impl FontDb {
|
||||
found
|
||||
}
|
||||
|
||||
fn search_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
fn search_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
|
||||
let mut base = self.loaded_covering(chars, exclude);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1611,7 +1620,10 @@ impl FontDb {
|
||||
fn loaded_covering(&self, chars: &[char], exclude: u32) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let rtl = chars.iter().any(|c| crate::is_rtl(*c) || arabic_block(*c));
|
||||
fallback_order(&faces, exclude, rtl).into_iter().find(|f| chars.iter().all(|c| f.covers(*c))).cloned()
|
||||
// A complex-script character picks the families its script prefers (the lowest one: the
|
||||
// run's characters come sorted).
|
||||
let complex = chars.iter().copied().find(|c| crate::needs_layout_tables(*c));
|
||||
fallback_order(&faces, exclude, rtl, complex).into_iter().find(|f| chars.iter().all(|c| f.covers(*c))).cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
@@ -1752,15 +1764,15 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn east_asian_run_fallback_is_cached_per_char_set() {
|
||||
fn run_fallback_is_cached_per_char_set() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let latin = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let a = db.fallback_for_east_asian(&['分', '类', '分'], latin.id(), false, false).map(|f| f.id());
|
||||
let a = db.fallback_for_run(&['分', '类', '分'], latin.id(), false, false).map(|f| f.id());
|
||||
// The same set in another order (and without the repeat) is the same entry.
|
||||
assert_eq!(db.fallback_for_east_asian(&['类', '分'], latin.id(), false, false).map(|f| f.id()), a);
|
||||
assert_eq!(db.fallback_for_run(&['类', '分'], latin.id(), false, false).map(|f| f.id()), a);
|
||||
{
|
||||
let cache = db.ea_fallbacks.read().unwrap();
|
||||
let cache = db.run_fallbacks.read().unwrap();
|
||||
assert_eq!(cache.faces, db.read_faces().len());
|
||||
assert_eq!(cache.map.len(), 1);
|
||||
assert!(cache.map.contains_key(&(vec!['分', '类'], latin.id(), false, false)));
|
||||
@@ -1768,14 +1780,40 @@ mod tests {
|
||||
// Long hostile runs keep the cache bounded.
|
||||
let many: Vec<char> = (0x4E00u32..0x4E00 + 50_000).filter_map(char::from_u32).collect();
|
||||
for i in 0..30 {
|
||||
let _ = db.fallback_for_east_asian(many.get(i..).unwrap_or(&[]), latin.id(), false, false);
|
||||
let _ = db.fallback_for_run(many.get(i..).unwrap_or(&[]), latin.id(), false, false);
|
||||
}
|
||||
let cache = db.ea_fallbacks.read().unwrap();
|
||||
assert!(cache.chars <= EA_FALLBACK_CACHE_CHARS, "{}", cache.chars);
|
||||
let cache = db.run_fallbacks.read().unwrap();
|
||||
assert!(cache.chars <= RUN_FALLBACK_CACHE_CHARS, "{}", cache.chars);
|
||||
// Turning the system fallback on or off forgets remembered misses.
|
||||
drop(cache);
|
||||
db.set_system_fallback(false);
|
||||
assert!(db.ea_fallbacks.read().unwrap().map.is_empty());
|
||||
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
|
||||
}
|
||||
|
||||
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
|
||||
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
||||
|
||||
#[test]
|
||||
fn a_thai_run_with_latin_falls_back_to_one_face() {
|
||||
let db = FontDb::global();
|
||||
if db.fallback_for('น', 0).is_none() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
let latin = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let chars: Vec<char> = THAI_RUN.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let f = db.fallback_for_run(&chars, latin.id(), false, false).expect("one face covers the run");
|
||||
assert_ne!(f.id, latin.id);
|
||||
for c in &chars {
|
||||
assert!(f.covers(*c), "{} covers {c}", f.family);
|
||||
}
|
||||
// A Thai font with OpenType tables, as the per-character fallback prefers.
|
||||
assert!(f.has_layout_tables(), "{} shapes Thai", f.family);
|
||||
// Without system fonts no bundled face covers Thai: the caller keeps the per-character path.
|
||||
let bundled = FontDb::with_font_dirs(Vec::new());
|
||||
bundled.set_system_fallback(false);
|
||||
let latin = bundled.face(FALLBACK_FAMILY, "Regular");
|
||||
assert!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -385,20 +385,20 @@ mod tests {
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
// No bundled CJK font and no system fallback: nothing can draw the run.
|
||||
assert!(db.fallback_for_east_asian(&['分', '类'], latin.id(), false, false).is_none());
|
||||
assert!(db.fallback_for_run(&['分', '类'], latin.id(), false, false).is_none());
|
||||
return;
|
||||
}
|
||||
// JIS-covered ideographs alone would fall back to a Mincho and simplified-only ones to the
|
||||
// SC sans: the run-level pick must cover every character in one face, or a run mixes
|
||||
// serif and sans glyphs (the per-character fallback still applies when it returns None).
|
||||
let run: Vec<char> = "分类监督学习".chars().collect();
|
||||
let f = db.fallback_for_east_asian(&run, latin.id(), false, false).expect("a CJK face covers the run");
|
||||
let f = db.fallback_for_run(&run, latin.id(), false, false).expect("a CJK face covers the run");
|
||||
for c in &run {
|
||||
assert!(f.covers(*c), "{f:?} covers {c}");
|
||||
}
|
||||
// Pure Japanese still gets the document serif (Mincho first).
|
||||
let jp: Vec<char> = "日本語".chars().collect();
|
||||
let f2 = db.fallback_for_east_asian(&jp, latin.id(), false, false).expect("a CJK face");
|
||||
let f2 = db.fallback_for_run(&jp, latin.id(), false, false).expect("a CJK face");
|
||||
assert!(f2.family.contains("Mincho"), "{f2:?}");
|
||||
}
|
||||
|
||||
|
||||
+185
-2
@@ -49,6 +49,25 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
const HEBREW: &[&str] = &["Helvetica", "Noto Sans Hebrew", "Arial", "Tahoma", "Geeza Pro", "DejaVu Sans", "Source Sans 3"];
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
const HEBREW: &[&str] = &["Noto Sans Hebrew", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans", "Source Sans 3"];
|
||||
// Thai document fonts (TH Sarabun New, Cordia New, the UPC families…): Thai faces, every one
|
||||
// with Thai letters; [`substitute`] prefers one with Latin letters and digits too, so a run
|
||||
// mixing Thai and Latin keeps one font at one size, as Word substitutes Cordia New for the
|
||||
// whole run (#1549). Windows' own Thai fonts first, then the Mac's, then the open Noto and Thai
|
||||
// Linux Working Group families. The Mac's Thonburi and Sathu carry Apple's `morx` tables
|
||||
// instead of OpenType ones; the shaper applies those, so their marks stack as they should.
|
||||
#[cfg(windows)]
|
||||
const THAI: &[&str] = &["Cordia New", "Leelawadee UI", "Leelawadee", "Tahoma", "Noto Sans Thai", "Browallia New", "Angsana New"];
|
||||
#[cfg(target_os = "macos")]
|
||||
const THAI: &[&str] = &["Thonburi", "Ayuthaya", "Sathu", "Krungthep", "Noto Sans Thai", "Tahoma"];
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
const THAI: &[&str] = &["Noto Sans Thai", "Laksaman", "Garuda", "Waree", "Tlwg Typo", "Loma", "Umpush", "Noto Serif Thai"];
|
||||
// Serif Thai document fonts (Angsana New, EucrosiaUPC, Norasi…) prefer a serif Thai face.
|
||||
#[cfg(windows)]
|
||||
const THAI_SERIF: &[&str] = &["Angsana New", "AngsanaUPC", "Noto Serif Thai", "Cordia New", "Leelawadee UI", "Tahoma", "Noto Sans Thai"];
|
||||
#[cfg(target_os = "macos")]
|
||||
const THAI_SERIF: &[&str] = &["Sathu", "Noto Serif Thai", "Thonburi", "Ayuthaya", "Krungthep", "Noto Sans Thai", "Tahoma"];
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
const THAI_SERIF: &[&str] = &["Noto Serif Thai", "Norasi", "Kinnari", "Noto Sans Thai", "Laksaman", "Garuda", "Waree"];
|
||||
// Palatino Linotype / Book Antiqua: macOS ships the same design as "Palatino"; open
|
||||
// look-alikes with matching metrics (TeX Gyre Pagella, URW P052 / Palladio) on Linux.
|
||||
const PALATINO: &[&str] = &[
|
||||
@@ -68,6 +87,12 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
"Source Serif 4",
|
||||
];
|
||||
let f = family.to_ascii_lowercase();
|
||||
if is_thai_serif(&f) {
|
||||
return THAI_SERIF;
|
||||
}
|
||||
if is_thai(&f) {
|
||||
return THAI;
|
||||
}
|
||||
if is_hebrew(&f) {
|
||||
return HEBREW;
|
||||
}
|
||||
@@ -196,12 +221,86 @@ fn is_persian_sans(f: &str) -> bool {
|
||||
NAMES.contains(&f) || f.starts_with("iransans") || f.starts_with("iranyekan")
|
||||
}
|
||||
|
||||
/// Thai document fonts: Word's Thai families (Cordia New, Angsana New, Browallia New, the UPC
|
||||
/// families, Leelawadee), the Thai national fonts (TH Sarabun New and the other "TH …" fonts),
|
||||
/// the Mac's, the Thai Linux Working Group's and Noto's. Matched by name only.
|
||||
fn is_thai(f: &str) -> bool {
|
||||
const NAMES: &[&str] = &[
|
||||
"cordia new",
|
||||
"cordiaupc",
|
||||
"angsana new",
|
||||
"angsanaupc",
|
||||
"browallia new",
|
||||
"browalliaupc",
|
||||
"leelawadee",
|
||||
"leelawadee ui",
|
||||
"leelawadee ui semilight",
|
||||
"th sarabun new",
|
||||
"th sarabunpsk",
|
||||
"th sarabun psk",
|
||||
"th sarabun",
|
||||
"thonburi",
|
||||
"ayuthaya",
|
||||
"sathu",
|
||||
"krungthep",
|
||||
"silom",
|
||||
"sukhumvit set",
|
||||
"norasi",
|
||||
"kinnari",
|
||||
"garuda",
|
||||
"laksaman",
|
||||
"loma",
|
||||
"purisa",
|
||||
"sawasdee",
|
||||
"umpush",
|
||||
"waree",
|
||||
"tlwg typo",
|
||||
"tlwg typist",
|
||||
"tlwg mono",
|
||||
];
|
||||
NAMES.contains(&f) || f.ends_with("upc") || f.starts_with("th ") || f.contains("thai")
|
||||
}
|
||||
|
||||
/// Serif Thai document fonts.
|
||||
fn is_thai_serif(f: &str) -> bool {
|
||||
const NAMES: &[&str] = &["angsana new", "angsanaupc", "eucrosiaupc", "jasmineupc", "kodchiangupc", "dilleniaupc", "norasi", "kinnari"];
|
||||
NAMES.contains(&f) || (f.contains("thai") && f.contains("serif"))
|
||||
}
|
||||
|
||||
/// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the
|
||||
/// whole run, never in a Latin substitute (#1549).
|
||||
pub fn is_thai_family(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
}
|
||||
|
||||
/// Hebrew document fonts.
|
||||
fn is_hebrew(f: &str) -> bool {
|
||||
const NAMES: &[&str] = &["david", "miriam", "noto sans hebrew", "arial hebrew", "calibri hebrew"];
|
||||
NAMES.contains(&f) || f.contains("hebrew")
|
||||
}
|
||||
|
||||
/// The first installed substitute for a missing `family`. For a Thai font, the first with Latin
|
||||
/// letters and digits as well as Thai ones (a Thai face without Latin would leave a mixed run in
|
||||
/// two fonts, #1549), else the first with Thai letters, else the first installed.
|
||||
fn substitute(db: &FontDb, family: &str) -> Option<&'static str> {
|
||||
let mut installed = substitutes(family).iter().copied().filter(|s| db.has_family(s));
|
||||
if !is_thai_family(family) {
|
||||
return installed.next();
|
||||
}
|
||||
let installed: Vec<&'static str> = installed.collect();
|
||||
let covers = |name: &str, chars: &[char]| {
|
||||
let face = db.face(name, "Regular");
|
||||
face.named(name) && chars.iter().all(|c| face.covers(*c))
|
||||
};
|
||||
installed
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|s| covers(s, &['\u{0E19}', 'A', 'z', '0', '9']))
|
||||
.or_else(|| installed.iter().copied().find(|s| covers(s, &['\u{0E19}'])))
|
||||
.or_else(|| installed.first().copied())
|
||||
}
|
||||
|
||||
fn style_name(bold: bool, italic: bool) -> &'static str {
|
||||
match (bold, italic) {
|
||||
(false, false) => "Regular",
|
||||
@@ -237,8 +336,7 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
// Windows maps the charset names to the font itself: not a substitution.
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
let s = substitutes(base).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
|
||||
(s.to_string(), true)
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
@@ -390,4 +488,89 @@ mod tests {
|
||||
// Generic names with "Hebrew" in them also get Hebrew substitutions
|
||||
assert!(substitutes("SomeFont Hebrew").contains(&"Noto Sans Hebrew"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_fonts_substitute_thai_faces() {
|
||||
// Every Thai document family maps to a list of Thai faces, never to the Latin lists.
|
||||
for f in [
|
||||
"TH Sarabun New",
|
||||
"TH SarabunPSK",
|
||||
"Angsana New",
|
||||
"AngsanaUPC",
|
||||
"Cordia New",
|
||||
"CordiaUPC",
|
||||
"Browallia New",
|
||||
"BrowalliaUPC",
|
||||
"JasmineUPC",
|
||||
"KodchiangUPC",
|
||||
"LilyUPC",
|
||||
"DilleniaUPC",
|
||||
"EucrosiaUPC",
|
||||
"FreesiaUPC",
|
||||
"IrisUPC",
|
||||
"Leelawadee",
|
||||
"Leelawadee UI",
|
||||
"Noto Sans Thai",
|
||||
"TH Niramit AS",
|
||||
] {
|
||||
assert!(is_thai_family(f), "{f}");
|
||||
let list = substitutes(f);
|
||||
assert!(list.iter().any(|s| s.contains("Thai")), "{f}: {list:?}");
|
||||
assert!(!list.contains(&"Carlito") && !list.contains(&"Caladea"), "{f}: {list:?}");
|
||||
}
|
||||
// Serif Thai families prefer a serif Thai face; the sans families a sans one.
|
||||
let pos = |family: &str, name: &str| substitutes(family).iter().position(|s| *s == name).unwrap_or(usize::MAX);
|
||||
assert!(pos("Angsana New", "Noto Serif Thai") < pos("Angsana New", "Noto Sans Thai"));
|
||||
assert!(pos("TH Sarabun New", "Noto Sans Thai") < pos("TH Sarabun New", "Noto Serif Thai"));
|
||||
// Latin and other script families are unchanged.
|
||||
assert!(!is_thai_family("Arial") && !is_thai_family("Tahoma") && !is_thai_family("Calibri") && !is_thai_family("David"));
|
||||
assert_eq!(substitutes("Calibri").first(), Some(&"Carlito"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_thai_font_resolves_to_an_installed_thai_face() {
|
||||
// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht
|
||||
// sign in one run of a Thai font that isn't installed.
|
||||
const RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
||||
let db = FontDb::global();
|
||||
for family in ["TH Sarabun New", "Angsana New", "Cordia New", "BrowalliaUPC"] {
|
||||
if db.has_family(family) {
|
||||
continue;
|
||||
}
|
||||
let r = resolve(family, false, false);
|
||||
let face = r.face.get();
|
||||
if !face.covers('\u{0E19}') {
|
||||
eprintln!("skipped {family}: no Thai face from its substitute list is installed");
|
||||
continue;
|
||||
}
|
||||
assert!(r.substituted, "{family}");
|
||||
let thai: Vec<char> = RUN.chars().filter(|c| ('\u{0E00}'..='\u{0E7F}').contains(c) && !face.covers(*c)).collect();
|
||||
assert!(thai.is_empty(), "{family} → {} lacks {thai:?}", face.family);
|
||||
// A listed Thai face with Latin letters and digits wins over one without them.
|
||||
let latin = |name: &str| {
|
||||
let f = db.face(name, "Regular");
|
||||
f.named(name) && f.covers('\u{0E19}') && f.covers('D') && f.covers('5')
|
||||
};
|
||||
if substitutes(family).iter().any(|s| db.has_family(s) && latin(s)) {
|
||||
let missing: Vec<char> = RUN.chars().filter(|c| !c.is_whitespace() && !face.covers(*c)).collect();
|
||||
assert!(missing.is_empty(), "{family} → {} lacks {missing:?}", face.family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thonburi_stacks_thai_marks_through_its_apple_tables() {
|
||||
// Thonburi has `morx` but no GSUB: the shaper must still move a tone mark up over an upper
|
||||
// vowel (ปั่) instead of drawing it where it sits on a bare consonant (ป่).
|
||||
let db = FontDb::global();
|
||||
if !db.has_family("Thonburi") {
|
||||
eprintln!("skipped: Thonburi not installed");
|
||||
return;
|
||||
}
|
||||
let face = db.face("Thonburi", "Regular");
|
||||
let tone = |text: &str| crate::shape(&face, text, &[], |c| c).into_iter().last().map(|g| (g.gid, g.y_offset));
|
||||
let (bare, stacked) = (tone("\u{0E1B}\u{0E48}"), tone("\u{0E1B}\u{0E31}\u{0E48}"));
|
||||
assert!(bare.is_some() && stacked.is_some());
|
||||
assert_ne!(bare, stacked, "the tone mark over ั is raised or swapped");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,10 +671,12 @@ fn east_asian(c: char) -> bool {
|
||||
const MISSING_ROMAN_FONT: &str = "Cambria";
|
||||
|
||||
/// The family to look `font` up as: [`MISSING_ROMAN_FONT`] for a missing roman font, else `font`.
|
||||
/// A missing Thai font keeps its name: it is drawn in a Thai face, not in Cambria (#1549).
|
||||
fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font));
|
||||
if roman
|
||||
&& !wordcraft_fonts::word::is_thai_family(font)
|
||||
&& !wordcraft_doc::symbol_fonts::is_symbol_font(font)
|
||||
&& !db.has_family(font)
|
||||
&& !db.has_family(wordcraft_fonts::word::charset_base_family(font))
|
||||
@@ -685,6 +687,28 @@ fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
|
||||
}
|
||||
}
|
||||
|
||||
/// One face for a run in a Thai font that isn't installed (#1549): Word substitutes one font
|
||||
/// (Cordia New) for the whole run, so the Latin letters, digits and ฿ keep the Thai letters' face
|
||||
/// and size. `None` unless `font` is a Thai family, it was substituted, and its substitute
|
||||
/// `primary` lacks some of the run's Thai or Latin characters; then the first face covering all of
|
||||
/// them, if one does. Other families keep Latin text in their own substitute, as Word does.
|
||||
pub(crate) fn thai_run_face(font: &str, substituted: bool, primary: FaceRef, text: &str, bold: bool, italic: bool) -> Option<FaceRef> {
|
||||
if !substituted || !wordcraft_fonts::word::is_thai_family(font) {
|
||||
return None;
|
||||
}
|
||||
let run: Vec<char> = text.chars().filter(|c| thai_or_latin(*c)).collect();
|
||||
if run.iter().all(|c| primary.covers(*c)) {
|
||||
return None;
|
||||
}
|
||||
wordcraft_fonts::FontDb::global().fallback_for_run(&run, primary.id(), bold, italic).map(|f| FaceRef::of(&f))
|
||||
}
|
||||
|
||||
/// Text a Thai substitute draws: Thai letters, digits and signs (฿), and Latin letters, digits
|
||||
/// and punctuation.
|
||||
fn thai_or_latin(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0021..=0x024F | 0x0E00..=0x0E7F) && !c.is_whitespace() && !c.is_control() && c != SOFT_HYPHEN
|
||||
}
|
||||
|
||||
impl<'a> Builder<'a> {
|
||||
/// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one
|
||||
/// char out. `rtl` is the shaping direction; in Context mode `context` (from
|
||||
@@ -780,6 +804,13 @@ impl<'a> Builder<'a> {
|
||||
.map(|r| r.face);
|
||||
let use_levels = para_text && !self.levels.is_empty();
|
||||
let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() };
|
||||
// A missing Thai font: one face for the run's Thai and Latin text (#1549). A run with its
|
||||
// own complex-script font keeps that font for the Thai letters.
|
||||
let run_face: Option<FaceRef> = if primary_cs.is_none_or(|f| f.id() == primary.id()) {
|
||||
thai_run_face(&rc.font, resolved.substituted, primary, text, rc.bold, rc.italic)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// One fallback face for the run's East Asian characters: the first face in fallback order
|
||||
// covering every character the primary font lacks, so the run draws in a single CJK face
|
||||
// instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered
|
||||
@@ -793,7 +824,7 @@ impl<'a> Builder<'a> {
|
||||
if ea.is_empty() {
|
||||
None
|
||||
} else {
|
||||
wordcraft_fonts::FontDb::global().fallback_for_east_asian(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f))
|
||||
wordcraft_fonts::FontDb::global().fallback_for_run(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f))
|
||||
}
|
||||
};
|
||||
// Split by font coverage (fallback faces), small caps case, script formatting and direction.
|
||||
@@ -817,6 +848,8 @@ impl<'a> Builder<'a> {
|
||||
let face = match cur {
|
||||
Some(k) if mark => k.face,
|
||||
_ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
|
||||
// The run's one substitute face for a missing Thai font (#1549).
|
||||
_ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,
|
||||
_ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
|
||||
// Stay in the fallback face the word started in while it covers the letters.
|
||||
Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),
|
||||
|
||||
@@ -109,3 +109,160 @@ fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() {
|
||||
let x0 = l.caret(&Pos::body(0, 0)).map(|c| c.x).unwrap_or(0.0);
|
||||
assert!(x6 > x0);
|
||||
}
|
||||
|
||||
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
|
||||
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
||||
|
||||
/// Is a font with Thai letters installed? `false` skips the test.
|
||||
fn thai_font_installed() -> bool {
|
||||
wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some()
|
||||
}
|
||||
|
||||
/// Body paragraph 0 of `text`, its run set in `font` at 16 pt (`None` keeps the document's
|
||||
/// default font), and the face of every cluster: (cluster start, face).
|
||||
fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts::FaceRef)> {
|
||||
let mut d = Document::from_text(text);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.runs[0].props.font = font.map(Into::into);
|
||||
p.runs[0].props.size = Some(16.0);
|
||||
let l = lay(&d);
|
||||
let pl = para_layout(&l, 0);
|
||||
pl.clusters.iter().map(|c| (c.start, pl.styles[c.style as usize].face)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// Word substitutes one font (Cordia New) for a missing Thai font, so the Latin letters, the
|
||||
// digits and the baht sign keep the Thai letters' face and size. A Thai name the lists don't
|
||||
// spell out ("TH Niramit AS") is matched by its "TH " prefix and substituted the same way.
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
for font in ["TH Sarabun New", "Angsana New", "TH Niramit AS"] {
|
||||
if db.has_family(font) {
|
||||
continue;
|
||||
}
|
||||
let faces = cluster_faces(THAI_RUN, Some(font));
|
||||
let first = faces[0].1;
|
||||
let names: Vec<&str> = faces.iter().map(|(_, f)| f.family.as_str()).collect();
|
||||
assert!(faces.iter().all(|(_, f)| f.id() == first.id()), "{font}: one face for the run, not {names:?}");
|
||||
assert!(first.covers('\u{0E19}') && first.covers('D') && first.covers('1') && first.covers('\u{0E3F}'), "{font} → {}", first.family);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// When the substitute lacks the run's Thai letters (no listed Thai font is installed, so the
|
||||
// Latin fallback family stands in) or its Latin ones, the run moves to one face covering all.
|
||||
let latin = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
let face = crate::para::thai_run_face("TH Sarabun New", true, latin, THAI_RUN, false, false).expect("one face covers the run");
|
||||
assert_ne!(face.id(), latin.id());
|
||||
for c in THAI_RUN.chars().filter(|c| !c.is_whitespace()) {
|
||||
assert!(face.covers(c), "{} covers {c}", face.family);
|
||||
}
|
||||
// Only for a Thai family that was substituted.
|
||||
assert!(crate::para::thai_run_face("Calibri", true, latin, THAI_RUN, false, false).is_none());
|
||||
assert!(crate::para::thai_run_face("TH Sarabun New", false, latin, THAI_RUN, false, false).is_none());
|
||||
// A substitute that already covers the run keeps it.
|
||||
assert!(crate::para::thai_run_face("TH Sarabun New", true, face, THAI_RUN, false, false).is_none());
|
||||
// Text that is neither Thai nor Latin doesn't make the run move.
|
||||
assert!(crate::para::thai_run_face("TH Sarabun New", true, latin, "abc \u{2603}", false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// Word keeps Latin text in the Latin font's substitute; only the Thai (or Myanmar) letters
|
||||
// fall back. A missing Latin family (and the default body font, Aptos, wherever it is
|
||||
// missing) must not move "Hello" into a Thai face.
|
||||
let myanmar = myanmar_face().is_some();
|
||||
for font in [Some("No Such Latin Font 1549"), None] {
|
||||
let requested = font.unwrap_or(wordcraft_doc::styles::BODY_FONT);
|
||||
let latin = wordcraft_fonts::word::resolve(requested, false, false).face;
|
||||
for text in ["Hello \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}\u{0E14}\u{0E35}", "Hello \u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}"] {
|
||||
if text.contains('\u{1019}') && !myanmar {
|
||||
continue;
|
||||
}
|
||||
let faces = cluster_faces(text, font);
|
||||
let hello: Vec<_> = faces.iter().filter(|(at, _)| *at < 5).collect();
|
||||
assert_eq!(hello.len(), 5, "{faces:?}");
|
||||
assert!(
|
||||
hello.iter().all(|(_, f)| f.id() == latin.id()),
|
||||
"{requested}: Hello in {}, not {:?}",
|
||||
latin.family,
|
||||
hello.iter().map(|(_, f)| f.family.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(faces.iter().any(|(at, f)| *at >= 6 && f.id() != latin.id()), "the other script falls back");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// The family exists, so nothing is substituted: Latin stays in it and only the Thai letters
|
||||
// fall back, character by character, as before.
|
||||
let faces = cluster_faces(THAI_RUN, Some("Source Sans 3"));
|
||||
let latin = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
assert!(faces.iter().any(|(_, f)| f.id() == latin.id()), "Latin clusters stay in Source Sans 3");
|
||||
assert!(faces.iter().any(|(_, f)| f.id() != latin.id() && f.covers('\u{0E19}')), "Thai clusters fall back to a Thai face");
|
||||
assert_eq!(faces[0].1.id(), latin.id(), "[D] opens the run in Source Sans 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// A run with its own complex-script font (`w:cs`) draws the Thai letters in that font's
|
||||
// substitute and the Latin letters in the Latin font's substitute, as Word does.
|
||||
let mut d = Document::from_text(THAI_RUN);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.runs[0].props.font = Some("No Such Latin Font 1549".into());
|
||||
p.runs[0].props.font_cs = Some("TH Sarabun New".into());
|
||||
let l = lay(&d);
|
||||
let pl = para_layout(&l, 0);
|
||||
let faces: Vec<_> = pl.clusters.iter().map(|c| pl.styles[c.style as usize].face).collect();
|
||||
let latin = wordcraft_fonts::word::resolve("No Such Latin Font 1549", false, false).face;
|
||||
let thai = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face;
|
||||
assert_eq!(faces[0].id(), latin.id(), "[D] in the Latin substitute");
|
||||
assert!(faces.iter().any(|f| f.id() == thai.id()), "Thai letters in the Thai substitute {}", thai.family);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
|
||||
// Word draws a missing font the font table classes as roman in Cambria, but a missing Thai
|
||||
// font keeps its Thai substitute: the same face whether or not the table calls it roman.
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
if db.has_family("TH Sarabun New") {
|
||||
eprintln!("skipped: TH Sarabun New is installed");
|
||||
return;
|
||||
}
|
||||
let face = |roman: bool| {
|
||||
let mut d = Document::from_text("\u{0E19}\u{0E49}\u{0E33} Thai");
|
||||
if roman {
|
||||
d.settings.roman_fonts = vec!["TH Sarabun New".into()];
|
||||
}
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.runs[0].props.font = Some("TH Sarabun New".into());
|
||||
let l = lay(&d);
|
||||
let pl = para_layout(&l, 0);
|
||||
pl.styles[pl.clusters.last().expect("a cluster").style as usize].face
|
||||
};
|
||||
let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face;
|
||||
assert_ne!(face(true).id(), cambria.id());
|
||||
assert_eq!(face(true).id(), face(false).id());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -24,6 +24,7 @@ font can't match Word's metrics.
|
||||
| Spaces at the end of a line hang past the right margin instead of wrapping; the caret after them stays at the margin, in the body and in headers and footers | ✅ | `layout::tests::caret_after_hanging_spaces_stays_at_the_margin` (#2146): a letter typed after them wraps to the next line | — |
|
||||
| Complex scripts (Myanmar, Khmer, Indic): shaped per script run with OpenType fallback fonts, one cluster per syllable, Myanmar wraps between syllables | 🟡 | `layout::tests_complex` (#604). Khmer, Thai and Lao still wrap only at spaces (need a dictionary) | 4–8 |
|
||||
| Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 |
|
||||
| Missing Thai document fonts (TH Sarabun New, Angsana New, Cordia New, Browallia New, the UPC families, Leelawadee): one Thai substitute face for the whole run, Latin letters, digits and ฿ included, so the run keeps one font at one size as Word's Cordia New substitution does | ✅ | `fonts::word::tests::a_missing_thai_font_resolves_to_an_installed_thai_face`, `fonts::fontdb::tests::a_thai_run_with_latin_falls_back_to_one_face`, `layout::tests_complex::a_missing_thai_font_draws_the_whole_run_in_one_thai_face`, `layout::tests_complex::a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it` (#1549). Windows: Cordia New, then Leelawadee UI, Tahoma, Noto Sans Thai; macOS: Thonburi (Sathu for the serif families); Linux: Noto Sans Thai, then the TLWG fonts. The first listed face with Latin letters and digits as well as Thai wins. Other Thai names (`TH …`, `…UPC`, names with `Thai`) use the same lists. When no listed face is installed, or the one found lacks the run's Thai or Latin letters, the run moves to one installed face covering its Thai and Latin text if there is one. Only Thai fonts: a missing Latin font keeps Latin text in its own substitute, and a run with its own complex-script font keeps it for the Thai letters, as Word does | — |
|
||||
| Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 |
|
||||
| Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Missing Arial Unicode MS tries Arial then the Arial substitutes, case-insensitively (`fonts::word::tests::unicode_arial_is_proportional`); glyph coverage and exact Word line height can differ. Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner |
|
||||
| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 |
|
||||
@@ -186,6 +187,7 @@ Theme-inherited dash styles, line caps, ink and open freeform strokes are not co
|
||||
| 2026-10-16 | trivial | Symbol (PCL6) uses the existing Symbol mapping and Symbol face/fallback despite stale Roman font metadata; stored codes/fonts stay unchanged |
|
||||
| 2026-10-16 | trivial | Prefer a covering Times New Roman face for an uncovered black-circle glyph; retain normal label-ascent and other fallback rules |
|
||||
| 2026-10-15 | trivial | The caret after spaces hanging past the right margin stays at the margin instead of running off the page (#2146) |
|
||||
| 2026-10-15 | trivial | A missing Thai document font is substituted by one Thai face for the whole run, Latin letters and digits included (#1549) |
|
||||
| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows |
|
||||
| 2026-10-14 | trivial | Consistent cell widths override a stale table grid |
|
||||
| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |
|
||||
|
||||
Reference in New Issue
Block a user