collective

Browse / github.com/storytold/deckcraft / patch

text: CJK document families resolve to their open twins; spAutoFit single-line labels never wrap

text: CJK document families resolve to their open twins; spAutoFit single-line labels never wrap ## What Chinese course decks set their Latin runs in Times New Roman and their CJK in 黑体/微软雅黑. When those proprietary families are missing, 黑体 et al fell down the Calibri chain: the CJK 1em glyphs still broke lines the same way, but **spaces and digits narrowed**, so space-padded layouts shifted and the overlaid labels (归纳, equation objects) were covered. - `substitutes()` / `has_metric_twin()` gain the CJK mainstays: 黑体/SimHei, 微软雅黑/等线 → Noto Sans CJK SC (+Source Han Sans SC); 宋体/SimSun → Noto Serif CJK SC. CJK glyphs are 1em in both, so line breaking is unchanged; spaces/digits widen t

Status
open
Score
0.00
Adopters
0 other operators
Author
amosarc (imported from pull request #1096, open)
Base
f34f4121c8ee720ca08e5d47b912155a33504e69
Size
+344 −33 in 6 files
Review
pending
Shared
2026-10-28
Id
92e375eeae232dcb81b7a491

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts/src/fontdb.rs crates/fonts/src/script.rs crates/render/src/metafile.rs crates/render/src/tests.rs crates/text/src/lib.rs crates/text/src/tests.rs

diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs--- a/crates/fonts/src/fontdb.rs+++ b/crates/fonts/src/fontdb.rs@@ -39,6 +39,17 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {         "aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {             &["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]         }+        // 中文课件的主力族:CJK 字形一律 1em 等宽,换行不受影响;Noto/思源是+        // craft-fonts 收录的官方开源形(黑体类→Noto Sans CJK SC,宋体类→Noto Serif)。+        "黑体" | "simhei" | "微软雅黑" | "microsoft yahei" | "microsoft yahei ui" | "等线" | "dengxian" => {+            &["Noto Sans CJK SC", "Source Han Sans SC", "Noto Sans CJK JP"]+        }+        "宋体" | "simsun" | "新宋体" | "nsimsun" | "思源宋体" | "source han serif" | "华文中宋" => {+            &["Noto Serif CJK SC", "Source Han Serif SC"]+        }+        "楷体" | "kaiti" | "华文楷体" | "stkaiti" | "华文新魏" | "stxinwei" | "华文行楷" | "stxingkai" | "隶书" | "lisu" => {+            &["Noto Sans CJK SC", "Source Han Sans SC"]+        }         "georgia" | "garamond" | "book antiqua" | "palatino" | "palatino linotype" | "constantia" => &["Merriweather", "Liberation Serif", "Georgia"],         "century gothic" | "avenir" | "avenir next" | "futura" | "gill sans" | "gill sans mt" | "trebuchet ms" | "corbel" | "candara" => {             &["Montserrat", "Poppins", "Avenir Next", "Inter"]@@ -55,9 +66,28 @@ pub fn substitutes(family: &str) -> &'static [&'static str] { /// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with /// them breaks into the same lines. fn has_metric_twin(family: &str) -> bool {+    let binding = family.to_ascii_lowercase();+    let f = binding.as_str();     matches!(-        family.to_ascii_lowercase().as_str(),+        f,         "calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"+    ) || matches!(+        // 中文课件主力族:Noto/思源与其 CJK 字形同为 1em 等宽,换行一致。+        f,+        "黑体"+            | "simhei"+            | "微软雅黑"+            | "microsoft yahei"+            | "microsoft yahei ui"+            | "等线"+            | "dengxian"+            | "宋体"+            | "simsun"+            | "新宋体"+            | "nsimsun"+            | "思源宋体"+            | "source han serif"+            | "华文中宋"     ) } @@ -1428,6 +1458,26 @@ impl OutlinePen for FlipPen {     } } +#[cfg(test)]+mod cjk_document_tests {+    use super::*;++    /// 中文课件主力族(黑体/微软雅黑/宋体)在机器未装时落到 craft-fonts 收录的+    /// Noto/思源形——而不是 Calibri 一系:CJK 1em 等宽保证换行一致。+    #[test]+    fn cjk_document_families_resolve_to_their_open_twin() {+        let db = FontDb::global();+        let Some(twin) = ["Noto Sans CJK SC", "Source Han Sans SC"].into_iter().find(|f| db.has_family(f)) else {+            eprintln!("skipped: 本机与 craft-fonts 均无 Noto/思源黑体");+            return;+        };+        for family in ["黑体", "微软雅黑", "等线"] {+            let face = db.document_face(family, "Regular");+            assert!(face.family.eq_ignore_ascii_case(twin), "{family} → {},应为 {twin}", face.family);+        }+    }+}+ #[cfg(test)] mod font_dir_tests {     use std::path::{Path, PathBuf};diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs--- a/crates/fonts/src/script.rs+++ b/crates/fonts/src/script.rs@@ -133,6 +133,16 @@ pub fn slot_of(c: char) -> Option<FontSlot> {     None } +/// Is `c` a Common character Office classifies by code range rather than by its neighbours, so+/// the run's Latin typeface draws it in every script context (ECMA-376 font classes): ASCII —+/// spaces, digits, punctuation — and every code point outside the East Asian and Arabic ranges+/// (`≥`, `“`, `—`, `°`). Only the Arabic punctuation set, the harakat and BOM-like marks follow+/// the surrounding text.+pub fn latin_neutral(c: char) -> bool {+    let cp = c as u32;+    script_of(c).is_none() && slot_of(c).is_none() && !arabic_common(cp)+}+ /// A default BCP 47 language for a script (shaping picks `locl` forms by language). pub fn default_language(s: ScriptTag) -> Option<&'static str> {     Some(match &s {@@ -243,4 +253,28 @@ mod tests {             assert!(w[0].1 < w[1].0, "{:x?} {:x?}", w[0], w[1]);         }     }++    #[test]+    fn latin_neutral_by_code_range_not_neighbours() {+        // ASCII: spaces, digits, punctuation take the Latin typeface whatever surrounds them.+        for c in [' ', '\t', '\u{00A0}', '2', '(', ')', '+', '=', ',', '.', ':'] {+            assert!(latin_neutral(c), "{c:?}");+        }+        // High ANSI neutrals outside the East Asian and Arabic ranges too.+        for c in ['\u{2265}', '\u{201C}', '\u{2014}', '\u{00B0}', '\u{00B1}'] {+            assert!(latin_neutral(c), "{c:?}");+        }+        // East Asian punctuation and full-width forms are East Asian (slot_of decides).+        for c in [',', '。', '、', '\u{3000}', '%'] {+            assert!(!latin_neutral(c), "{c:?}");+        }+        // The Arabic punctuation set, harakat and tatweel follow the surrounding text.+        for c in ['\u{060C}', '\u{061B}', '\u{061F}', '\u{0640}', '\u{064E}'] {+            assert!(!latin_neutral(c), "{c:?}");+        }+        // Strong characters are never neutral.+        assert!(!latin_neutral('a'));+        assert!(!latin_neutral('日'));+        assert!(!latin_neutral('س'));+    } }diff --git a/crates/render/src/metafile.rs b/crates/render/src/metafile.rs--- a/crates/render/src/metafile.rs+++ b/crates/render/src/metafile.rs@@ -181,9 +181,10 @@ fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) {     if !k.is_finite() || k <= 0.0 {         return;     }-    // Characters as drawn: symbol fonts (Wingdings…) map like bullets; each character takes the-    // face that has it.-    let chars: Vec<char> = t.text.chars().map(|c| deckcraft_text::symbol_char(&face, family, c).unwrap_or(c)).collect();+    // Characters as drawn: symbol fonts (Wingdings…) map like bullets, a SYMBOL_CHARSET font+    // that is not a symbol family falls back from its U+F000+byte codes to the bytes' characters+    // where the face lacks them; each character takes the face that has it.+    let chars: Vec<char> = t.text.chars().map(|c| deckcraft_text::metafile_char(&face, family, font.charset, c)).collect();     let faces: Vec<Arc<FontFace>> = chars         .iter()         .map(|c| {diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs--- a/crates/render/src/tests.rs+++ b/crates/render/src/tests.rs@@ -2595,3 +2595,25 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {     assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179));     assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); }++#[test]+fn metafile_symbol_charset_text_in_a_latin_font_draws_the_characters() {+    // MathType writes equation text with "Times New Roman" in the symbol character set. Whether+    // the font is installed (its U+F000+byte glyph) or missing (the byte's own character), a "+"+    // draws a plus — not a .notdef box or a bullet.+    let mut f = i16s(&[-720, 0, 0, 0, 400]);+    f.extend([0, 0, 0, 2, 0, 0, 0, 0]); // charset 2: SYMBOL_CHARSET+    f.extend(b"Times New Roman");+    f.push(0);+    let mut recs = vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[0]))];+    recs.push(text_record(1440, 360, "+", &[]));+    let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");+    let (x0, y0, x1, y1) = mf_ink(&img).expect("plus ink");+    assert!(x1 - x0 > 12 && y1 - y0 > 12, "a sizeable mark: {x0} {y0} {x1} {y1}");+    let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);+    let on = |x: u32, y: u32| img.get_pixel(x, y)[3] > 100;+    assert!(on(cx, cy), "ink at the crossing");+    assert!(on(x0 + 2, cy) && on(x1 - 2, cy), "the horizontal bar");+    assert!(on(cx, y0 + 2) && on(cx, y1 - 2), "the vertical bar");+    assert!(!on(x0 + 1, y0 + 1) && !on(x1 - 1, y1 - 1), "empty corners: a plus, not a box or bullet");+}diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs--- a/crates/text/src/lib.rs+++ b/crates/text/src/lib.rs@@ -860,10 +860,15 @@ fn shape_cells(         let Some(st) = styles.get(si) else { continue };         let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin));         // Spaces, digits and punctuation stay in the face of the text before them when it can-        // draw them, so a phrase isn't cut into items at every space.+        // draw them, so a phrase isn't cut into items at every space — except the Common+        // characters Office classifies by code range (ECMA-376): ASCII spaces, digits and+        // punctuation, and high-ANSI neutrals such as `≥` or `“`, take the run's Latin typeface+        // whatever surrounds them (a space after a Han character is a narrow Latin one, and a+        // line of space-padded text wraps where PowerPoint's does).         // Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.         let neutral =             cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');+        let latin = neutral && cluster.iter().all(|c| script::latin_neutral(*c));         // A symbol-font character (U+F0xx) with an `a:sym` font draws in that font when it's there.         let sym_face = st             .sym@@ -875,11 +880,16 @@ fn shape_cells(         let face = match (sym_face, prev.as_ref()) {             (Some(f), _) => f,             (None, Some(p))-                if neutral && !p.is_color() && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) =>+                if neutral+                    && !latin+                    && !p.is_color()+                    && deckcraft_fonts::covers_cluster(p, cluster)+                    && char_style.get(a.wrapping_sub(1)) == Some(&si) =>             {                 p.clone()             }             (None, _) => {+                let slot = if latin { FontSlot::Latin } else { slot };                 let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));                 db.cascade(cluster, chain, st.style_name)             }@@ -1082,7 +1092,7 @@ fn shape_item(         let s = scale_of(cell);         let w = width.entry(h).or_insert(0.0);         cell.glyphs.push((g.gid, *w + g.x_offset as f64 * s, -(g.y_offset as f64) * s));-        *w += g.x_advance as f64 * s;+        *w += glyph_advance(g.x_advance as f64 * s);         let e = lig.entry(h).or_insert((g.gid, 0));         e.1 += 1;     }@@ -1137,6 +1147,16 @@ fn is_symbol_font(family: &str) -> bool {     f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett") } +/// A glyph's advance as PowerPoint measures it: Office lays text out in hundredths of a point (the+/// unit of `sz` and `spc`), quantizing each glyph's advance down. A line of space-padded course+/// text then fits its box by the width PowerPoint saw rather than exact font units — a 32 pt Times+/// New Roman line of 35 glyphs measures 0.18 pt narrower — and wraps where PowerPoint's does+/// (measured against PowerPoint on a deck whose first line fits its box by two hundredths of a+/// point).+fn glyph_advance(adv: f64) -> f64 {+    (adv * 100.0).floor() / 100.0+}+ /// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a /// symbol font that `face` is (not a substitute) and it has the glyph. fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {@@ -1169,6 +1189,25 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> {     Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•'))) } +/// `SYMBOL_CHARSET` (MS-WMF LOGFONT): the text bytes are the font's own symbol codes, drawn from+/// the U+F000 + byte code point.+pub const SYMBOL_CHARSET: u8 = 2;++/// The character a metafile text item draws for `c` (one decoded character of the record's text)+/// in the font of `charset`: symbol-font families map their codes as bullets do+/// ([`symbol_char`]), and a `SYMBOL_CHARSET` font that is not a symbol family — MathType writes+/// "Times New Roman" with the symbol character set — keeps its U+F000 + byte code where `face`+/// draws it there and falls back to the byte's own character where it doesn't (the requested+/// font is missing), rather than to a .notdef box.+pub fn metafile_char(face: &FontFace, family: &str, charset: u8, c: char) -> char {+    let c = symbol_char(face, family, c).unwrap_or(c);+    if charset == SYMBOL_CHARSET && ('\u{F000}'..='\u{F0FF}').contains(&c) && !face.covers(c) {+        char::from_u32(c as u32 & 0xFF).unwrap_or(c)+    } else {+        c+    }+}+ /// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character. pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {     let f = font.unwrap_or("").to_ascii_lowercase();@@ -1187,32 +1226,43 @@ pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {         };         return m.to_string();     }-    if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {+    if f.starts_with("wingdings 2") {+        let code = (first as u32) & 0xff;+        let m = match code {+            0x97 | 0x98 => '●',+            0x9e..=0xa1 => '■',+            0xa2..=0xa5 => '◆',+            0xea..=0xf3 => '★',+            0xb7..=0xbf => '○',+            0x50 => '✓',+            _ => '•',+        };+        return m.to_string();+    }+    if f.starts_with("wingdings 3") {+        let code = (first as u32) & 0xff;+        let m = match code {+            0x7d | 0x75 => '►',+            0x7c | 0x74 => '◄',+            0x70 => '▲',+            0x71 => '▼',+            0xc6 | 0xc7 => '➔',+            _ => '▶',+        };+        return m.to_string();+    }+    // The Adobe Symbol encoding is not a bullet set: it is the public reference mapping of the+    // Symbol font's codes (Greek, the math operators). Where a code has no Unicode equivalent+    // here, the character itself stands in rather than a bullet.+    if f.contains("symbol") {+        let code = ((first as u32) & 0xff) as u8;+        return match symbol_encoding(code) {+            0 => c.to_string(),+            v => char::from_u32(v).map(String::from).unwrap_or_else(|| c.to_string()),+        };+    }+    if f.contains("wingdings") || ('\u{f000}'..='\u{f0ff}').contains(&first) {         let code = (first as u32) & 0xff;-        // Wingdings 2 and 3 put different shapes at the same codes.-        if f.starts_with("wingdings 2") {-            let m = match code {-                0x97 | 0x98 => '●',-                0x9e..=0xa1 => '■',-                0xa2..=0xa5 => '◆',-                0xea..=0xf3 => '★',-                0xb7..=0xbf => '○',-                0x50 => '✓',-                _ => '•',-            };-            return m.to_string();-        }-        if f.starts_with("wingdings 3") {-            let m = match code {-                0x7d | 0x75 => '►',-                0x7c | 0x74 => '◄',-                0x70 => '▲',-                0x71 => '▼',-                0xc6 | 0xc7 => '➔',-                _ => '▶',-            };-            return m.to_string();-        }         let m = match code {             0xa7 => '▪',             0xd8 => '➢',@@ -1232,6 +1282,45 @@ pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {     c.graphemes(true).next().unwrap_or("•").to_string() } +/// The Unicode code point of a code in the Adobe Symbol encoding (the published mapping of the+/// Symbol font; AGLFN/Symbol.txt), or 0 where the encoding has no equivalent.+fn symbol_encoding(code: u8) -> u32 {+    const T: &[u16; 224] = &[+        // 0x20..=0x2F: space ! ∀ # ∃ % & ∋ ( ) ∗ + , − . /+        0x20, 0x21, 0x2200, 0x23, 0x2203, 0x25, 0x26, 0x220B, 0x28, 0x29, 0x2217, 0x2B, 0x2C, 0x2212, 0x2E, 0x2F, //+        // 0x30..=0x3F: digits : ; < = > ? ≅+        0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x2245, //+        // 0x40..=0x4F: Α Β Χ Δ Ε Φ Γ Η Ι ϑ Κ Λ Μ Ν Ο Π+        0x391, 0x392, 0x3A7, 0x394, 0x395, 0x3A6, 0x393, 0x397, 0x399, 0x3D1, 0x39A, 0x39B, 0x39C, 0x39D, 0x39F, 0x3A0, //+        // 0x50..=0x5F: Θ Ρ Σ Τ Υ ς Ω Ξ Ψ Ζ [ ∴ ] ⊥ _+        0x398, 0x3A1, 0x3A3, 0x3A4, 0x3A5, 0x3C2, 0x3A9, 0x39E, 0x3A8, 0x396, 0x5B, 0x2234, 0x5D, 0x22A5, 0x5F, 0, //+        // 0x60..=0x6F: ` α β χ δ ε φ γ η ι ϕ κ λ μ ν ο+        0, 0x3B1, 0x3B2, 0x3C7, 0x3B4, 0x3B5, 0x3C6, 0x3B3, 0x3B7, 0x3B9, 0x3D5, 0x3BA, 0x3BB, 0x3BC, 0x3BD, 0x3BF, //+        // 0x70..=0x7F: π θ ρ σ τ υ ϖ ω ξ ψ ζ { | } ∼ (0x7F unmapped)+        0x3C0, 0x3B8, 0x3C1, 0x3C3, 0x3C4, 0x3C5, 0x3D6, 0x3C9, 0x3BE, 0x3C8, 0x3B6, 0x7B, 0x7C, 0x7D, 0x223C, 0, //+        // 0x80..=0x8F, 0x90..=0x9F, 0xA0: € then nothing mapped+        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //+        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,      //+        0x20AC, //+        // 0xA1..=0xAF: ϒ ′ ≤ ⁄ ∞ ƒ ♣ ♦ ♥ ♠ ↔ ← ↑ → ↓+        0x3D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, 0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, //+        // 0xB0..=0xBF: ° ± ″ ≥ × ∝ ∂ • ÷ ≠ ≡ ≈ … (three spacing/position codes)+        0xB0, 0xB1, 0x2033, 0x2265, 0xD7, 0x221D, 0x2202, 0x2022, 0xF7, 0x2260, 0x2261, 0x2248, 0x2026, 0, 0, 0, //+        // 0xC0..=0xCF: ℑ ℜ ℘ ⊗ ⊕ ∅ ∩ ∪ ⊃ ⊂ ⊄ ⊆ ⊇ ∈ ∉ ∠+        0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, 0x222A, 0x2283, 0x2282, 0x2284, 0x2286, 0x2287, 0x2208, 0x2209, 0x2220, //+        // 0xD0..=0xDF: ∇ ® © ™ ∏ ∑ √ ∙ ¬ ∧ ∨ ⇔ ⟨ (three unmapped)+        0x2207, 0xAE, 0xA9, 0x2122, 0x220F, 0x2211, 0x221A, 0x2219, 0xAC, 0x2227, 0x2228, 0x21D4, 0x2329, 0, 0, 0, //+        // 0xE0..=0xEF: (unmapped)+        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //+        // 0xF0..=0xFF: ∫ ⌠ ⌡ (similar) ◊ (the rest unmapped)+        0, 0, 0x222B, 0x2320, 0x2321, 0, 0x25CA, 0, 0, 0, 0, 0, 0, 0, 0, 0, //+    ];+    match usize::from(code).checked_sub(0x20) {+        Some(i) => T.get(i).copied().map_or(0, u32::from),+        None => 0,+    }+}+ fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) {     let g = deckcraft_fonts::shape(face, s, &[], |c| c);     let k = size / face.upem.max(1.0);@@ -1631,7 +1720,27 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts     );     // Text that follows a WordArt arch or circle is set on one line per paragraph.     let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);-    let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));+    // A spAutoFit text box whose saved height fits exactly one line is a single-line+    // label the author sized by fit-to-text. Renderers whose metrics differ slightly+    // must not fold it into two lines at the stored width (a WPS-authored "变式3:"+    // label broke apart) — render it on one line, overflowing to the right instead.+    let single_line_autofit = matches!(bp.autofit, Some(AutoFit::Shape)) && shape.text_box && body.paragraphs.len() == 1 && {+        let max_sz = body+            .paragraphs+            .iter()+            .flat_map(|p| p.runs.iter())+            .filter(|r| !r.text.trim().is_empty())+            .map(|r| r.props.size.unwrap_or(18.0))+            .fold(0.0f64, f64::max)+            .max(1.0);+        let v_insets = bp.inset_t.unwrap_or(3.6) + bp.inset_b.unwrap_or(3.6);+        let one_line = max_sz * LINE_PITCH + v_insets;+        r.height() > 0.0 && r.height() <= one_line + 2.0+    };+    let wrap = bp.wrap.unwrap_or(true)+        && !follow+        && !single_line_autofit+        && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));     let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize;     let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0);     let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0);diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs--- a/crates/text/src/tests.rs+++ b/crates/text/src/tests.rs@@ -1467,3 +1467,98 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {     assert_eq!(l.lines.len(), 2);     assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item"); }++#[test]+fn ascii_neutrals_keep_the_latin_face_after_east_asian_text() {+    // ECMA-376 font classes: ASCII spaces, digits and punctuation, and high-ANSI neutrals such+    // as ≥, draw with the run's Latin typeface whatever surrounds them, so a line of+    // space-padded text measures — and wraps — where PowerPoint's does (course decks overlay+    // equation objects on runs of reserved spaces).+    let face_of = |l: &TextLayout, ch: char| {+        l.runs.iter().find(|r| r.cells.iter().any(|&(_, c)| c == ch)).map(|r| r.face.family.clone()).expect("every character is drawn")+    };+    let (p, sh) = setup("日 a ≥", 600.0, 200.0);+    let l = lay(&p, &sh, 600.0, 200.0);+    let latin = face_of(&l, 'a');+    assert_eq!(face_of(&l, ' '), latin, "the space after the Han character stays Latin");+    assert_eq!(face_of(&l, '≥'), latin, "≥ stays Latin after East Asian text");+    let (p, sh) = setup(" 日", 600.0, 200.0);+    let l = lay(&p, &sh, 600.0, 200.0);+    let (p2, sh2) = setup("a", 600.0, 200.0);+    let l2 = lay(&p2, &sh2, 600.0, 200.0);+    assert_eq!(face_of(&l, ' '), face_of(&l2, 'a'), "a leading space takes the Latin typeface too");+}++#[test]+fn the_symbol_font_maps_through_the_adobe_encoding() {+    // The Symbol font's codes are not bullets: the ASCII range and the math operators map to+    // their Unicode equivalents (the published Adobe Symbol encoding), so missing-font text+    // keeps its characters instead of collapsing into bullets.+    assert_eq!(map_symbol_bullet("+", Some("Symbol")), "+");+    assert_eq!(map_symbol_bullet("=", Some("Symbol")), "=");+    assert_eq!(map_symbol_bullet("(", Some("Symbol")), "(");+    assert_eq!(map_symbol_bullet("\u{F02D}", Some("Symbol")), "−");+    assert_eq!(map_symbol_bullet("\u{F0B3}", Some("Symbol")), "≥");+    assert_eq!(map_symbol_bullet("a", Some("Symbol")), "α");+    assert_eq!(map_symbol_bullet("\u{F0D6}", Some("Symbol")), "√");+    assert_eq!(map_symbol_bullet("\u{F0B7}", Some("Symbol")), "•");+    // The bullet fonts keep their look-alikes.+    assert_eq!(map_symbol_bullet("\u{F06C}", Some("Wingdings")), "●");+    assert_eq!(map_symbol_bullet("n", Some("Webdings")), "●");+    assert_eq!(map_symbol_bullet("\u{F0FC}", Some("Wingdings")), "✓");+}++#[test]+fn metafile_symbol_charset_text_falls_back_to_the_bytes_character() {+    // MathType writes equation text in "Times New Roman" with the symbol character set. Where+    // the face has the U+F000+byte glyph it is drawn; where it hasn't (the font is missing),+    // the byte's own character stands in rather than a .notdef box.+    let db = FontDb::global();+    let Some(family) = first_family(LATIN_FAMILIES, |f| f.covers('+') && !f.covers('\u{F02B}')) else {+        eprintln!("skipped: every Latin font maps the symbol range");+        return;+    };+    let face = db.face(&family, "Regular");+    assert_eq!(metafile_char(&face, "Times New Roman", SYMBOL_CHARSET, '\u{F02B}'), '+');+    assert_eq!(metafile_char(&face, "Times New Roman", SYMBOL_CHARSET, '\u{F06E}'), 'n');+    assert_eq!(metafile_char(&face, "Times New Roman", 0, '+'), '+');+    // A symbol family without its font keeps the look-alike mapping.+    assert_eq!(metafile_char(&face, "Symbol", SYMBOL_CHARSET, '\u{F02B}'), '+');+}++#[test]+fn glyph_advances_are_quantized_to_hundredths_of_a_point() {+    // Office measures in hundredths of a point, each glyph's advance rounded down: 17.5625 pt+    // advances 17.56, whole-point advances stay whole, and a space-padded line fits its box by+    // the width PowerPoint saw.+    assert_eq!(glyph_advance(17.5625), 17.56);+    assert_eq!(glyph_advance(23.109375), 23.1);+    assert_eq!(glyph_advance(10.65625), 10.65);+    assert_eq!(glyph_advance(32.0), 32.0);+    assert_eq!(glyph_advance(8.0), 8.0);+}++#[test]+fn single_line_autofit_labels_never_wrap() {+    // spAutoFit 单行标签框(保存高度=一行文字+内边距):度量略有差异时不按框宽+    // 换行——WPS 课件“变式3:”标签被折成两行叠到习题上的实测驱动。+    let (p, mut sh) = setup("变式3:", 95.0, 41.0);+    sh.text_box = true;+    let body = sh.text.as_mut().unwrap();+    body.body.autofit = Some(AutoFit::Shape);+    for para in &mut body.paragraphs {+        for run in &mut para.runs {+            run.props.size = Some(28.0);+        }+    }+    let l = lay(&p, &sh, 95.0, 41.0);+    assert_eq!(l.lines.len(), 1, "单行标签不换行");++    // 两行高的同款框(作者排的就是多行):换行语义不变。+    let (p, mut sh) = setup("变式3:变式3:变式3:", 95.0, 74.0);+    sh.text_box = true;+    let body = sh.text.as_mut().unwrap();+    body.body.autofit = Some(AutoFit::Shape);+    let l = lay(&p, &sh, 95.0, 74.0);+    assert!(l.lines.len() >= 2, "装不下的文字仍换行");+}