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);
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,73 @@ 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);
+}
