From 271c6c4037de9b4a3517cc2d8a92d641c2c04559 Mon Sep 17 00:00:00 2001 From: Bryson Meiling Date: Fri, 16 Oct 2026 14:46:07 -0600 Subject: [PATCH 1/6] text: remap symbol-font U+F0xx codes to the installed Symbol face's real Unicode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerPoint stores a Symbol-font character as U+F0xx with : U+F0DE is the Symbol font's double arrow (=>). The layout drew it from whichever installed face happened to map U+F0DE (Wingdings draws an arrow at another angle), because Apple's "Symbol" on macOS has no private-use cmap at all — it maps the glyphs to real Unicode instead. The test that checked this skipped on macOS. Add the Adobe Symbol encoding table (unicode.org MAPPINGS/VENDORS/ADOBE/symbol.txt; Apple's Mac OS Symbol is laid out identically) and remap a document's U+F0xx code to the real Unicode character the installed Symbol face draws it at: the PUA code when the face has one, else the encoding's real character (U+F0DE -> U+21D2 =>). The remap applies wherever a symbol-font character is drawn: - text runs with an a:sym font (shape_cells picks the face, shape_item remaps during shaping), - bullets (symbol_font_char), - metafile text (symbol_char). The previously-skipped symbol_font_characters_draw_in_the_sym_font test now runs on macOS (its guard checks the remapped character), and a new test checks U+F0DE draws as U+21D2 on Apple's Symbol. --- ROADMAP.md | 3 + crates/text/src/lib.rs | 213 +++++++++++++++++++++++++++++++++++++-- crates/text/src/tests.rs | 30 +++++- 3 files changed, 232 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7dfa96f8..37e6cde0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: major, community PR wave 2026-10-14) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON @@ -131,6 +132,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-16 | Symbol-font characters: a run's U+F0xx symbol code (from ``) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | @@ -227,6 +229,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) | | 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9acc26c0..7963cbbb 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -865,13 +865,15 @@ fn shape_cells( let neutral = cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}'); // A symbol-font character (U+F0xx) with an `a:sym` font draws in that font when it's there. + // Some installed Symbol faces (Apple's on macOS) have no private-use mapping: they map the + // glyphs to real Unicode, so the PUA code is remapped to that character when shaping. let sym_face = st .sym .as_deref() .filter(|_| cluster.first().is_some_and(|c| ('\u{F000}'..='\u{F0FF}').contains(c))) .filter(|f| db.has_family(f)) .map(|f| db.face(f, st.style_name)) - .filter(|f| deckcraft_fonts::covers_cluster(f, cluster)); + .filter(|f| covers_symbol(f, cluster.first().copied().unwrap_or(' '))); let face = match (sym_face, prev.as_ref()) { (Some(f), _) => f, (None, Some(p)) @@ -1055,7 +1057,11 @@ fn shape_item( let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return }; let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script); let params = ShapeParams { features: &features, rtl, script, language: lang }; - let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { caps_char(ch) } else { ch }); + let sym = styles.get(first.style).and_then(|st| st.sym.as_deref()); + let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| { + let ch = if upper { caps_char(ch) } else { ch }; + symbol_map_char(face, ch, sym).unwrap_or(ch) + }); let upem = face.upem.max(1.0); let scale_of = |cell: &Cell| { let st = styles.get(cell.style); @@ -1137,6 +1143,199 @@ fn is_symbol_font(family: &str) -> bool { f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett") } +/// Does `face` draw the symbol-font character `c` (a U+F0xx private-use code)? Either directly at +/// the PUA, or, when the face has no private-use mapping (Apple's "Symbol" maps its glyphs to real +/// Unicode), at the Adobe Symbol encoding's real character. +fn covers_symbol(face: &FontFace, c: char) -> bool { + let code = (c as u32) & 0xFF; + face.covers(c) || symbol_unicode(code).is_some_and(|u| char::from_u32(u).is_some_and(|r| face.covers(r))) +} + +/// The character `ch` is drawn as when the item's face is the `a:sym` symbol font `sym`: a U+F0xx +/// private-use code becomes the face's glyph — at the PUA, or at the real Unicode the Symbol font +/// maps it to (Apple's "Symbol"). `None` when `ch` is not such a character or the face is not that +/// font, so the run's other characters pass through unchanged. +fn symbol_map_char(face: &FontFace, ch: char, sym: Option<&str>) -> Option { + let code = ch as u32; + if !(0xF000..=0xF0FF).contains(&code) { + return None; + } + let sym = sym?; + if !is_symbol_font(sym) || !face.family.eq_ignore_ascii_case(sym) { + return None; + } + symbol_font_draw(face, code) +} + +/// The real-Unicode character the Symbol font's code `code` (0x00–0xFF) maps to, in the Adobe +/// Symbol encoding (Unicode, Inc.'s `MAPPINGS/VENDORS/ADOBE/symbol.txt`; Apple's Mac OS Symbol is +/// laid out identically): the codes a document stores as U+F000 + code draw the same glyphs at +/// these Unicode points. Apple's "Symbol" on macOS has no private-use mapping at all — it maps the +/// glyphs to these real characters instead — so a document's U+F0xx symbol characters must be +/// remapped to them there (PowerPoint does the same against whatever the installed Symbol font +/// covers). Only the non-ASCII, non-CUS entries are listed: printable ASCII passes through as its +/// own code, and the CUS (Corporate Use Subarea) fragment glyphs have no standard Unicode point. +const fn symbol_unicode(code: u32) -> Option { + Some(match code { + // 0x20–0x7E: the few mathematical ones among the ASCII-range characters. + 0x22 => 0x2200, + 0x24 => 0x2203, + 0x27 => 0x220B, + 0x2A => 0x2217, + 0x2D => 0x2212, + 0x3C => 0x2264, + 0x3D => 0x2265, + 0x3E => 0x221E, + 0x3F => 0x2234, + 0x40 => 0x2245, + 0x5C => 0x2234, + 0x5E => 0x22A5, + 0x7E => 0x223C, + // 0x41–0x5A: Greek uppercase. + 0x41 => 0x0391, + 0x42 => 0x0392, + 0x43 => 0x03A7, + 0x44 => 0x0394, + 0x45 => 0x0395, + 0x46 => 0x03A6, + 0x47 => 0x0393, + 0x48 => 0x0397, + 0x49 => 0x0399, + 0x4A => 0x03D1, + 0x4B => 0x039A, + 0x4C => 0x039B, + 0x4D => 0x039C, + 0x4E => 0x039D, + 0x4F => 0x039F, + 0x50 => 0x03A0, + 0x51 => 0x0398, + 0x52 => 0x03A1, + 0x53 => 0x03A3, + 0x54 => 0x03A4, + 0x55 => 0x03A5, + 0x56 => 0x03C2, + 0x57 => 0x03A9, + 0x58 => 0x039E, + 0x59 => 0x03A8, + 0x5A => 0x0396, + // 0x61–0x7A: Greek lowercase (0x6A is the phi symbol, 0x6D the mu). + 0x61 => 0x03B1, + 0x62 => 0x03B2, + 0x63 => 0x03C7, + 0x64 => 0x03B4, + 0x65 => 0x03B5, + 0x66 => 0x03C6, + 0x67 => 0x03B3, + 0x68 => 0x03B7, + 0x69 => 0x03B9, + 0x6A => 0x03D5, + 0x6B => 0x03BA, + 0x6C => 0x03BB, + 0x6D => 0x03BC, + 0x6E => 0x03BD, + 0x6F => 0x03BF, + 0x70 => 0x03C0, + 0x71 => 0x03B8, + 0x72 => 0x03C1, + 0x73 => 0x03C3, + 0x74 => 0x03C4, + 0x75 => 0x03C5, + 0x76 => 0x03D6, + 0x77 => 0x03C9, + 0x78 => 0x03BE, + 0x79 => 0x03C8, + 0x7A => 0x03B6, + // 0xA1–0xAF: Greek upsilon with hook, primes, math, suits and arrows. + 0xA1 => 0x03D2, + 0xA2 => 0x2032, + 0xA3 => 0x2264, + 0xA4 => 0x2215, + 0xA5 => 0x221E, + 0xA6 => 0x0192, + 0xA7 => 0x2663, + 0xA8 => 0x2666, + 0xA9 => 0x2665, + 0xAA => 0x2660, + 0xAB => 0x2194, + 0xAC => 0x2190, + 0xAD => 0x2191, + 0xAE => 0x2192, + 0xAF => 0x2193, + // 0xB0–0xBC: degree, plus-minus, double prime, math and ellipsis. + 0xB0 => 0x00B0, + 0xB1 => 0x00B1, + 0xB2 => 0x2033, + 0xB3 => 0x2265, + 0xB4 => 0x00D7, + 0xB5 => 0x221D, + 0xB6 => 0x2202, + 0xB7 => 0x2022, + 0xB8 => 0x00F7, + 0xB9 => 0x2260, + 0xBA => 0x2261, + 0xBB => 0x2248, + 0xBC => 0x2026, + 0xBF => 0x21B5, + // 0xC0–0xCF: letterlike symbols, set relations. + 0xC0 => 0x2135, + 0xC1 => 0x2111, + 0xC2 => 0x211C, + 0xC3 => 0x2118, + 0xC4 => 0x2297, + 0xC5 => 0x2295, + 0xC6 => 0x2205, + 0xC7 => 0x2229, + 0xC8 => 0x222A, + 0xC9 => 0x2283, + 0xCA => 0x2287, + 0xCB => 0x2284, + 0xCC => 0x2282, + 0xCD => 0x2286, + 0xCE => 0x2208, + 0xCF => 0x2209, + // 0xD0–0xDF: angle, nabla, product, radical, not, ands, ors, double arrows. + 0xD0 => 0x2220, + 0xD1 => 0x2207, + 0xD5 => 0x220F, + 0xD6 => 0x221A, + 0xD7 => 0x22C5, + 0xD8 => 0x00AC, + 0xD9 => 0x2227, + 0xDA => 0x2228, + 0xDB => 0x21D4, + 0xDC => 0x21D0, + 0xDD => 0x21D1, + 0xDE => 0x21D2, + 0xDF => 0x21D3, + // 0xE0–0xF5: lozenge, angle brackets, summation, integral. + 0xE0 => 0x25CA, + 0xE1 => 0x2329, + 0xE5 => 0x2211, + 0xF1 => 0x232A, + 0xF2 => 0x222B, + 0xF3 => 0x2320, + 0xF5 => 0x2321, + _ => return None, + }) +} + +/// The character the symbol font draws the code `code` at, when the installed `face` is that font: +/// its private-use code U+F000 + code, or, when the face has no private-use mapping (Apple's +/// "Symbol" maps its glyphs to real Unicode), the Adobe Symbol encoding's real character. +fn symbol_font_draw(face: &FontFace, code: u32) -> Option { + let pua = char::from_u32(0xF000 | (code & 0xFF))?; + if face.covers(pua) { + return Some(pua); + } + let real = symbol_unicode(code & 0xFF)?; + let real = char::from_u32(real)?; + if face.covers(real) { + Some(real) + } else { + None + } +} + /// 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 { @@ -1145,14 +1344,8 @@ fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option`: @@ -955,10 +976,11 @@ fn symbol_font_characters_draw_in_the_sym_font() { // whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle). let symbol = "Symbol"; let db = FontDb::global(); - // Some installed Symbol faces use Unicode mappings only, without the legacy U+F0xx cmap. - // This integration test needs a face that can draw the symbol code used by the document. - if !db.has_family(symbol) || !db.face(symbol, "Regular").covers('\u{F0DE}') { - eprintln!("skipped: no installed Symbol face covers U+F0DE"); + // The installed Symbol face draws the code either at the private-use U+F0DE or, when it has no + // private-use mapping (Apple's Symbol on macOS maps glyphs to real Unicode), at the real + // character the Symbol font stores the code at. Skip only if neither is covered. + if !db.has_family(symbol) || !covers_symbol(&db.face(symbol, "Regular"), '\u{F0DE}') { + eprintln!("skipped: no installed Symbol face draws U+F0DE"); return; } let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0); From 8f4b0d4c3f686458f22ffbddc42534bcc425b820 Mon Sep 17 00:00:00 2001 From: Bryson Meiling Date: Fri, 16 Oct 2026 14:50:09 -0600 Subject: [PATCH 2/6] text: test the whole Adobe Symbol encoding table The symbol_unicode table has 130 entries; only U+F0DE was covered by a test. Add symbol_unicode_table_covers_the_adobe_encoding, which asserts every code->real-Unicode mapping directly against the authoritative unicode.org/MAPPINGS/VENDORS/ADOBE/symbol.txt table, plus that ASCII pass-through and CUS fragment codes return None. It is pure and platform-independent (no fonts involved), so it runs on every machine and catches any typo in the table. --- crates/text/src/tests.rs | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1c4df854..f51fd0c9 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -969,6 +969,56 @@ fn symbol_font_remaps_private_use_to_real_unicode() { } } +/// Every entry of the Adobe Symbol encoding table, in full. Pure and platform-independent: the +/// table maps a Symbol code to its real Unicode character regardless of any installed font, so this +/// runs on every machine. Guarded against typos in `symbol_unicode` (e.g. 0x40 must be U+2245, the +/// congruent sign, not U+22C5 dot operator). +#[test] +fn symbol_unicode_table_covers_the_adobe_encoding() { + // (code, real Unicode) from unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt. + // Only the non-ASCII, non-CUS entries; ASCII codes pass through as themselves and CUS fragment + // glyphs have no standard Unicode point, so `symbol_unicode` returns None for them. + let table: &[(u32, u32)] = &[ + // 0x20–0x7E: the few mathematical ones among the ASCII-range characters. + (0x22, 0x2200), (0x24, 0x2203), (0x27, 0x220B), (0x2A, 0x2217), (0x2D, 0x2212), (0x3C, 0x2264), (0x3D, 0x2265), (0x3E, 0x221E), (0x3F, 0x2234), (0x40, 0x2245), + (0x5C, 0x2234), (0x5E, 0x22A5), (0x7E, 0x223C), + // 0x41–0x5A: Greek uppercase. + (0x41, 0x0391), (0x42, 0x0392), (0x43, 0x03A7), (0x44, 0x0394), (0x45, 0x0395), (0x46, 0x03A6), (0x47, 0x0393), (0x48, 0x0397), (0x49, 0x0399), (0x4A, 0x03D1), + (0x4B, 0x039A), (0x4C, 0x039B), (0x4D, 0x039C), (0x4E, 0x039D), (0x4F, 0x039F), (0x50, 0x03A0), (0x51, 0x0398), (0x52, 0x03A1), (0x53, 0x03A3), (0x54, 0x03A4), + (0x55, 0x03A5), (0x56, 0x03C2), (0x57, 0x03A9), (0x58, 0x039E), (0x59, 0x03A8), (0x5A, 0x0396), + // 0x61–0x7A: Greek lowercase. + (0x61, 0x03B1), (0x62, 0x03B2), (0x63, 0x03C7), (0x64, 0x03B4), (0x65, 0x03B5), (0x66, 0x03C6), (0x67, 0x03B3), (0x68, 0x03B7), (0x69, 0x03B9), (0x6A, 0x03D5), + (0x6B, 0x03BA), (0x6C, 0x03BB), (0x6D, 0x03BC), (0x6E, 0x03BD), (0x6F, 0x03BF), (0x70, 0x03C0), (0x71, 0x03B8), (0x72, 0x03C1), (0x73, 0x03C3), (0x74, 0x03C4), + (0x75, 0x03C5), (0x76, 0x03D6), (0x77, 0x03C9), (0x78, 0x03BE), (0x79, 0x03C8), (0x7A, 0x03B6), + // 0xA1–0xAF: Greek upsilon with hook, primes, math, suits and arrows. + (0xA1, 0x03D2), (0xA2, 0x2032), (0xA3, 0x2264), (0xA4, 0x2215), (0xA5, 0x221E), (0xA6, 0x0192), (0xA7, 0x2663), (0xA8, 0x2666), (0xA9, 0x2665), (0xAA, 0x2660), + (0xAB, 0x2194), (0xAC, 0x2190), (0xAD, 0x2191), (0xAE, 0x2192), (0xAF, 0x2193), + // 0xB0–0xBC: degree, plus-minus, double prime, math and ellipsis. + (0xB0, 0x00B0), (0xB1, 0x00B1), (0xB2, 0x2033), (0xB3, 0x2265), (0xB4, 0x00D7), (0xB5, 0x221D), (0xB6, 0x2202), (0xB7, 0x2022), (0xB8, 0x00F7), (0xB9, 0x2260), + (0xBA, 0x2261), (0xBB, 0x2248), (0xBC, 0x2026), (0xBF, 0x21B5), + // 0xC0–0xCF: letterlike symbols, set relations. + (0xC0, 0x2135), (0xC1, 0x2111), (0xC2, 0x211C), (0xC3, 0x2118), (0xC4, 0x2297), (0xC5, 0x2295), (0xC6, 0x2205), (0xC7, 0x2229), (0xC8, 0x222A), (0xC9, 0x2283), + (0xCA, 0x2287), (0xCB, 0x2284), (0xCC, 0x2282), (0xCD, 0x2286), (0xCE, 0x2208), (0xCF, 0x2209), + // 0xD0–0xDF: angle, nabla, product, radical, not, ands, ors, double arrows. + (0xD0, 0x2220), (0xD1, 0x2207), (0xD5, 0x220F), (0xD6, 0x221A), (0xD7, 0x22C5), (0xD8, 0x00AC), (0xD9, 0x2227), (0xDA, 0x2228), (0xDB, 0x21D4), (0xDC, 0x21D0), + (0xDD, 0x21D1), (0xDE, 0x21D2), (0xDF, 0x21D3), + // 0xE0–0xF5: lozenge, angle brackets, summation, integral. + (0xE0, 0x25CA), (0xE1, 0x2329), (0xE5, 0x2211), (0xF1, 0x232A), (0xF2, 0x222B), (0xF3, 0x2320), (0xF5, 0x2321), + ]; + for &(code, uni) in table { + assert_eq!(symbol_unicode(code), Some(uni), "Symbol code 0x{code:02X} must map to U+{uni:04X}"); + } + // ASCII codes pass through as themselves (symbol_unicode has no entry), and the CUS fragment + // codes return None: 0x60 radical extender, 0xBD/0xBE arrow extenders, 0xD2–0xD4 serif marks, + // 0xE2–0xE4 sans-serif marks and the paren/bracket extender run 0xE6–0xFF. + for code in [0x20, 0x21, 0x23, 0x25, 0x26, 0x28, 0x29, 0x2B, 0x2C, 0x2E, 0x2F, 0x30, 0x31, 0x39, 0x3A, 0x3B, 0x5B, 0x5D, 0x5F, 0x7B, 0x7C, 0x7D] { + assert_eq!(symbol_unicode(code), None, "ASCII code 0x{code:02X} has no Symbol remap"); + } + for code in [0x60, 0xBD, 0xBE, 0xD2, 0xD3, 0xD4, 0xE2, 0xE3, 0xE4, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0, 0xF4, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF] { + assert_eq!(symbol_unicode(code), None, "CUS code 0x{code:02X} has no standard Unicode remap"); + } +} + #[test] fn symbol_font_characters_draw_in_the_sym_font() { // PowerPoint stores a Symbol-font character as U+F0xx with ``: From 883cc6e391d583fedbfda2fe654330f7ce5623ae Mon Sep 17 00:00:00 2001 From: Bryson Meiling Date: Mon, 19 Oct 2026 12:58:20 -0600 Subject: [PATCH 3/6] text: drop ASCII < = > ? remaps and draw them from Symbol directly 0x3C-0x3F are plain ASCII < = > ? in Adobe's symbol encoding (<= >= inf sit at 0xA3/0xB3/0xA5 and therefore at 0x5C), so 'a **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: major, community PR wave 2026-10-14) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7963cbbb..9e2a23fd 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1145,10 +1145,10 @@ fn is_symbol_font(family: &str) -> bool { /// Does `face` draw the symbol-font character `c` (a U+F0xx private-use code)? Either directly at /// the PUA, or, when the face has no private-use mapping (Apple's "Symbol" maps its glyphs to real -/// Unicode), at the Adobe Symbol encoding's real character. +/// Unicode), at the Adobe Symbol encoding's real character, or at the ASCII character itself when +/// `c`'s code is plain printable ASCII (0x20–0x7E with no table entry). fn covers_symbol(face: &FontFace, c: char) -> bool { - let code = (c as u32) & 0xFF; - face.covers(c) || symbol_unicode(code).is_some_and(|u| char::from_u32(u).is_some_and(|r| face.covers(r))) + symbol_font_draw(face, (c as u32) & 0xFF).is_some() } /// The character `ch` is drawn as when the item's face is the `a:sym` symbol font `sym`: a U+F0xx @@ -1183,10 +1183,6 @@ const fn symbol_unicode(code: u32) -> Option { 0x27 => 0x220B, 0x2A => 0x2217, 0x2D => 0x2212, - 0x3C => 0x2264, - 0x3D => 0x2265, - 0x3E => 0x221E, - 0x3F => 0x2234, 0x40 => 0x2245, 0x5C => 0x2234, 0x5E => 0x22A5, @@ -1321,19 +1317,31 @@ const fn symbol_unicode(code: u32) -> Option { /// The character the symbol font draws the code `code` at, when the installed `face` is that font: /// its private-use code U+F000 + code, or, when the face has no private-use mapping (Apple's -/// "Symbol" maps its glyphs to real Unicode), the Adobe Symbol encoding's real character. +/// "Symbol" maps its glyphs to real Unicode), the Adobe Symbol encoding's real character. Printable +/// ASCII with no table entry (the `< = > ?` codes among them) passes through as its own character +/// when the face covers it. fn symbol_font_draw(face: &FontFace, code: u32) -> Option { - let pua = char::from_u32(0xF000 | (code & 0xFF))?; + let code = code & 0xFF; + let pua = char::from_u32(0xF000 | code)?; if face.covers(pua) { return Some(pua); } - let real = symbol_unicode(code & 0xFF)?; - let real = char::from_u32(real)?; - if face.covers(real) { - Some(real) - } else { - None + if let Some(real) = symbol_unicode(code) { + let real = char::from_u32(real)?; + if face.covers(real) { + return Some(real); + } + return None; } + // 0x20–0x7E with no table entry draw the ASCII character itself (the Symbol font's glyphs for + // "< = > ?" etc. sit at their own ASCII codes, not at a remapped Unicode point). + if (0x20..=0x7E).contains(&code) { + let ascii = char::from_u32(code)?; + if face.covers(ascii) { + return Some(ascii); + } + } + None } /// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f51fd0c9..7554a00e 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -969,6 +969,35 @@ fn symbol_font_remaps_private_use_to_real_unicode() { } } +#[test] +fn symbol_font_draw_falls_back_to_ascii() { + // 0x3C–0x3F are plain ASCII < = > ? in Adobe's symbol encoding (≤ ≥ ∞ sit at 0xA3/0xB3/0xA5 and + // ∴ at 0x5C), so a document's "a Date: Tue, 20 Oct 2026 12:23:50 -0600 Subject: [PATCH 4/6] text: add Symbol euro and Apple alternatives; keep covered bullets Adobe symbol.txt also maps 0xA0 to the euro sign (U+20AC), which symbol_unicode lacked. Apple's Symbol maps a few codes to different Unicode than Adobe (0xA4 to U+2044, 0xE1/0xF1 to U+3008/U+3009), so symbol_font_draw now tries those alternatives after the Adobe character when the installed face doesn't cover it. The Adobe aliases for 0x44/0x57/0x6D (U+2206/U+2126/U+00B5) are not Apple alternatives and stay out. symbol_font_char masked every bullet character to its low byte before checking direct coverage, so a 'bullet' (U+2022) bullet drew an unrelated Symbol code. It now maps only symbol-font codes (low byte or U+F0xx) and falls back to the original character when the face covers it. --- crates/text/src/lib.rs | 56 +++++++++++++++++++++++------------ crates/text/src/tests.rs | 63 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9e2a23fd..e0974eba 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1168,13 +1168,14 @@ fn symbol_map_char(face: &FontFace, ch: char, sym: Option<&str>) -> Option } /// The real-Unicode character the Symbol font's code `code` (0x00–0xFF) maps to, in the Adobe -/// Symbol encoding (Unicode, Inc.'s `MAPPINGS/VENDORS/ADOBE/symbol.txt`; Apple's Mac OS Symbol is -/// laid out identically): the codes a document stores as U+F000 + code draw the same glyphs at -/// these Unicode points. Apple's "Symbol" on macOS has no private-use mapping at all — it maps the -/// glyphs to these real characters instead — so a document's U+F0xx symbol characters must be -/// remapped to them there (PowerPoint does the same against whatever the installed Symbol font -/// covers). Only the non-ASCII, non-CUS entries are listed: printable ASCII passes through as its -/// own code, and the CUS (Corporate Use Subarea) fragment glyphs have no standard Unicode point. +/// Symbol encoding (Unicode, Inc.'s `MAPPINGS/VENDORS/ADOBE/symbol.txt`; Apple's Mac OS Symbol +/// shares this layout, with a few alternative Unicode choices — see `symbol_unicode_alts`): the +/// codes a document stores as U+F000 + code draw the same glyphs at these Unicode points. Apple's +/// "Symbol" on macOS has no private-use mapping at all — it maps the glyphs to these real characters +/// instead — so a document's U+F0xx symbol characters must be remapped to them there (PowerPoint +/// does the same against whatever the installed Symbol font covers). Only the non-ASCII, non-CUS +/// entries are listed: printable ASCII passes through as its own code, and the CUS (Corporate Use +/// Subarea) fragment glyphs have no standard Unicode point. const fn symbol_unicode(code: u32) -> Option { Some(match code { // 0x20–0x7E: the few mathematical ones among the ASCII-range characters. @@ -1241,7 +1242,8 @@ const fn symbol_unicode(code: u32) -> Option { 0x78 => 0x03BE, 0x79 => 0x03C8, 0x7A => 0x03B6, - // 0xA1–0xAF: Greek upsilon with hook, primes, math, suits and arrows. + // 0xA0–0xAF: euro, Greek upsilon with hook, primes, math, suits and arrows. + 0xA0 => 0x20AC, 0xA1 => 0x03D2, 0xA2 => 0x2032, 0xA3 => 0x2264, @@ -1315,11 +1317,25 @@ const fn symbol_unicode(code: u32) -> Option { }) } +/// Apple's Mac OS Symbol (which shares the Adobe layout) diverges from the Adobe table on a few +/// codes: it maps the fraction slash to U+2044 and the angle brackets to U+3008/U+3009 instead of +/// Adobe's U+2215/U+2329/U+232A. `symbol_font_draw` tries these alternatives after the Adobe +/// mapping when the installed face doesn't cover the Adobe character. The additional Adobe aliases +/// for 0x44/0x57/0x6D (U+2206/U+2126/U+00B5) are not Apple alternatives and are deliberately absent. +const fn symbol_unicode_alts(code: u32) -> &'static [u32] { + match code { + 0xA4 => &[0x2044], + 0xE1 => &[0x3008], + 0xF1 => &[0x3009], + _ => &[], + } +} + /// The character the symbol font draws the code `code` at, when the installed `face` is that font: /// its private-use code U+F000 + code, or, when the face has no private-use mapping (Apple's -/// "Symbol" maps its glyphs to real Unicode), the Adobe Symbol encoding's real character. Printable -/// ASCII with no table entry (the `< = > ?` codes among them) passes through as its own character -/// when the face covers it. +/// "Symbol" maps its glyphs to real Unicode), the Adobe Symbol encoding's real character (then its +/// Apple alternative, then, for printable ASCII with no table entry — the `< = > ?` codes among +/// them — the ASCII character itself) when the face covers it. fn symbol_font_draw(face: &FontFace, code: u32) -> Option { let code = code & 0xFF; let pua = char::from_u32(0xF000 | code)?; @@ -1327,11 +1343,7 @@ fn symbol_font_draw(face: &FontFace, code: u32) -> Option { return Some(pua); } if let Some(real) = symbol_unicode(code) { - let real = char::from_u32(real)?; - if face.covers(real) { - return Some(real); - } - return None; + return std::iter::once(real).chain(symbol_unicode_alts(code).iter().copied()).filter_map(char::from_u32).find(|c| face.covers(*c)); } // 0x20–0x7E with no table entry draw the ASCII character itself (the Symbol font's glyphs for // "< = > ?" etc. sit at their own ASCII codes, not at a remapped Unicode point). @@ -1352,8 +1364,16 @@ fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option Date: Fri, 23 Oct 2026 17:13:31 -0400 Subject: [PATCH 5/6] Text: apply the Symbol encoding table only to the Symbol face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Wingdings or Webdings face without private-use coverage no longer gets Symbol's mapping (0xD8 drew as ¬). Co-Authored-By: Claude Opus 5.5 --- crates/text/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e0974eba..b4f85f02 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1342,6 +1342,10 @@ fn symbol_font_draw(face: &FontFace, code: u32) -> Option { if face.covers(pua) { return Some(pua); } + // The table below is the Symbol font's encoding; Wingdings, Webdings and the like are not Symbol. + if !face.family.eq_ignore_ascii_case("symbol") { + return None; + } if let Some(real) = symbol_unicode(code) { return std::iter::once(real).chain(symbol_unicode_alts(code).iter().copied()).filter_map(char::from_u32).find(|c| face.covers(*c)); } From 8c55529df9bf3c11fb58421585cc3221413caaeb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:21:03 -0400 Subject: [PATCH 6/6] Format the Symbol table tests (cargo fmt) Co-Authored-By: Claude Opus 5.5 --- crates/text/src/tests.rs | 154 ++++++++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 19 deletions(-) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a2c7e03e..a6601014 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1068,30 +1068,140 @@ fn symbol_unicode_table_covers_the_adobe_encoding() { // glyphs have no standard Unicode point, so `symbol_unicode` returns None for them. let table: &[(u32, u32)] = &[ // 0x20–0x7E: the few mathematical ones among the ASCII-range characters. - (0x22, 0x2200), (0x24, 0x2203), (0x27, 0x220B), (0x2A, 0x2217), (0x2D, 0x2212), (0x40, 0x2245), - (0x5C, 0x2234), (0x5E, 0x22A5), (0x7E, 0x223C), + (0x22, 0x2200), + (0x24, 0x2203), + (0x27, 0x220B), + (0x2A, 0x2217), + (0x2D, 0x2212), + (0x40, 0x2245), + (0x5C, 0x2234), + (0x5E, 0x22A5), + (0x7E, 0x223C), // 0x41–0x5A: Greek uppercase. - (0x41, 0x0391), (0x42, 0x0392), (0x43, 0x03A7), (0x44, 0x0394), (0x45, 0x0395), (0x46, 0x03A6), (0x47, 0x0393), (0x48, 0x0397), (0x49, 0x0399), (0x4A, 0x03D1), - (0x4B, 0x039A), (0x4C, 0x039B), (0x4D, 0x039C), (0x4E, 0x039D), (0x4F, 0x039F), (0x50, 0x03A0), (0x51, 0x0398), (0x52, 0x03A1), (0x53, 0x03A3), (0x54, 0x03A4), - (0x55, 0x03A5), (0x56, 0x03C2), (0x57, 0x03A9), (0x58, 0x039E), (0x59, 0x03A8), (0x5A, 0x0396), + (0x41, 0x0391), + (0x42, 0x0392), + (0x43, 0x03A7), + (0x44, 0x0394), + (0x45, 0x0395), + (0x46, 0x03A6), + (0x47, 0x0393), + (0x48, 0x0397), + (0x49, 0x0399), + (0x4A, 0x03D1), + (0x4B, 0x039A), + (0x4C, 0x039B), + (0x4D, 0x039C), + (0x4E, 0x039D), + (0x4F, 0x039F), + (0x50, 0x03A0), + (0x51, 0x0398), + (0x52, 0x03A1), + (0x53, 0x03A3), + (0x54, 0x03A4), + (0x55, 0x03A5), + (0x56, 0x03C2), + (0x57, 0x03A9), + (0x58, 0x039E), + (0x59, 0x03A8), + (0x5A, 0x0396), // 0x61–0x7A: Greek lowercase. - (0x61, 0x03B1), (0x62, 0x03B2), (0x63, 0x03C7), (0x64, 0x03B4), (0x65, 0x03B5), (0x66, 0x03C6), (0x67, 0x03B3), (0x68, 0x03B7), (0x69, 0x03B9), (0x6A, 0x03D5), - (0x6B, 0x03BA), (0x6C, 0x03BB), (0x6D, 0x03BC), (0x6E, 0x03BD), (0x6F, 0x03BF), (0x70, 0x03C0), (0x71, 0x03B8), (0x72, 0x03C1), (0x73, 0x03C3), (0x74, 0x03C4), - (0x75, 0x03C5), (0x76, 0x03D6), (0x77, 0x03C9), (0x78, 0x03BE), (0x79, 0x03C8), (0x7A, 0x03B6), + (0x61, 0x03B1), + (0x62, 0x03B2), + (0x63, 0x03C7), + (0x64, 0x03B4), + (0x65, 0x03B5), + (0x66, 0x03C6), + (0x67, 0x03B3), + (0x68, 0x03B7), + (0x69, 0x03B9), + (0x6A, 0x03D5), + (0x6B, 0x03BA), + (0x6C, 0x03BB), + (0x6D, 0x03BC), + (0x6E, 0x03BD), + (0x6F, 0x03BF), + (0x70, 0x03C0), + (0x71, 0x03B8), + (0x72, 0x03C1), + (0x73, 0x03C3), + (0x74, 0x03C4), + (0x75, 0x03C5), + (0x76, 0x03D6), + (0x77, 0x03C9), + (0x78, 0x03BE), + (0x79, 0x03C8), + (0x7A, 0x03B6), // 0xA0–0xAF: euro, Greek upsilon with hook, primes, math, suits and arrows. - (0xA0, 0x20AC), (0xA1, 0x03D2), (0xA2, 0x2032), (0xA3, 0x2264), (0xA4, 0x2215), (0xA5, 0x221E), (0xA6, 0x0192), (0xA7, 0x2663), (0xA8, 0x2666), (0xA9, 0x2665), (0xAA, 0x2660), - (0xAB, 0x2194), (0xAC, 0x2190), (0xAD, 0x2191), (0xAE, 0x2192), (0xAF, 0x2193), + (0xA0, 0x20AC), + (0xA1, 0x03D2), + (0xA2, 0x2032), + (0xA3, 0x2264), + (0xA4, 0x2215), + (0xA5, 0x221E), + (0xA6, 0x0192), + (0xA7, 0x2663), + (0xA8, 0x2666), + (0xA9, 0x2665), + (0xAA, 0x2660), + (0xAB, 0x2194), + (0xAC, 0x2190), + (0xAD, 0x2191), + (0xAE, 0x2192), + (0xAF, 0x2193), // 0xB0–0xBC: degree, plus-minus, double prime, math and ellipsis. - (0xB0, 0x00B0), (0xB1, 0x00B1), (0xB2, 0x2033), (0xB3, 0x2265), (0xB4, 0x00D7), (0xB5, 0x221D), (0xB6, 0x2202), (0xB7, 0x2022), (0xB8, 0x00F7), (0xB9, 0x2260), - (0xBA, 0x2261), (0xBB, 0x2248), (0xBC, 0x2026), (0xBF, 0x21B5), + (0xB0, 0x00B0), + (0xB1, 0x00B1), + (0xB2, 0x2033), + (0xB3, 0x2265), + (0xB4, 0x00D7), + (0xB5, 0x221D), + (0xB6, 0x2202), + (0xB7, 0x2022), + (0xB8, 0x00F7), + (0xB9, 0x2260), + (0xBA, 0x2261), + (0xBB, 0x2248), + (0xBC, 0x2026), + (0xBF, 0x21B5), // 0xC0–0xCF: letterlike symbols, set relations. - (0xC0, 0x2135), (0xC1, 0x2111), (0xC2, 0x211C), (0xC3, 0x2118), (0xC4, 0x2297), (0xC5, 0x2295), (0xC6, 0x2205), (0xC7, 0x2229), (0xC8, 0x222A), (0xC9, 0x2283), - (0xCA, 0x2287), (0xCB, 0x2284), (0xCC, 0x2282), (0xCD, 0x2286), (0xCE, 0x2208), (0xCF, 0x2209), + (0xC0, 0x2135), + (0xC1, 0x2111), + (0xC2, 0x211C), + (0xC3, 0x2118), + (0xC4, 0x2297), + (0xC5, 0x2295), + (0xC6, 0x2205), + (0xC7, 0x2229), + (0xC8, 0x222A), + (0xC9, 0x2283), + (0xCA, 0x2287), + (0xCB, 0x2284), + (0xCC, 0x2282), + (0xCD, 0x2286), + (0xCE, 0x2208), + (0xCF, 0x2209), // 0xD0–0xDF: angle, nabla, product, radical, not, ands, ors, double arrows. - (0xD0, 0x2220), (0xD1, 0x2207), (0xD5, 0x220F), (0xD6, 0x221A), (0xD7, 0x22C5), (0xD8, 0x00AC), (0xD9, 0x2227), (0xDA, 0x2228), (0xDB, 0x21D4), (0xDC, 0x21D0), - (0xDD, 0x21D1), (0xDE, 0x21D2), (0xDF, 0x21D3), + (0xD0, 0x2220), + (0xD1, 0x2207), + (0xD5, 0x220F), + (0xD6, 0x221A), + (0xD7, 0x22C5), + (0xD8, 0x00AC), + (0xD9, 0x2227), + (0xDA, 0x2228), + (0xDB, 0x21D4), + (0xDC, 0x21D0), + (0xDD, 0x21D1), + (0xDE, 0x21D2), + (0xDF, 0x21D3), // 0xE0–0xF5: lozenge, angle brackets, summation, integral. - (0xE0, 0x25CA), (0xE1, 0x2329), (0xE5, 0x2211), (0xF1, 0x232A), (0xF2, 0x222B), (0xF3, 0x2320), (0xF5, 0x2321), + (0xE0, 0x25CA), + (0xE1, 0x2329), + (0xE5, 0x2211), + (0xF1, 0x232A), + (0xF2, 0x222B), + (0xF3, 0x2320), + (0xF5, 0x2321), ]; for &(code, uni) in table { assert_eq!(symbol_unicode(code), Some(uni), "Symbol code 0x{code:02X} must map to U+{uni:04X}"); @@ -1099,10 +1209,16 @@ fn symbol_unicode_table_covers_the_adobe_encoding() { // ASCII codes pass through as themselves (symbol_unicode has no entry), and the CUS fragment // codes return None: 0x60 radical extender, 0xBD/0xBE arrow extenders, 0xD2–0xD4 serif marks, // 0xE2–0xE4 sans-serif marks and the paren/bracket extender run 0xE6–0xFF. - for code in [0x20, 0x21, 0x23, 0x25, 0x26, 0x28, 0x29, 0x2B, 0x2C, 0x2E, 0x2F, 0x30, 0x31, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x5B, 0x5D, 0x5F, 0x7B, 0x7C, 0x7D] { + for code in [ + 0x20, 0x21, 0x23, 0x25, 0x26, 0x28, 0x29, 0x2B, 0x2C, 0x2E, 0x2F, 0x30, 0x31, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x5B, 0x5D, 0x5F, + 0x7B, 0x7C, 0x7D, + ] { assert_eq!(symbol_unicode(code), None, "ASCII code 0x{code:02X} has no Symbol remap"); } - for code in [0x60, 0xBD, 0xBE, 0xD2, 0xD3, 0xD4, 0xE2, 0xE3, 0xE4, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0, 0xF4, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF] { + for code in [ + 0x60, 0xBD, 0xBE, 0xD2, 0xD3, 0xD4, 0xE2, 0xE3, 0xE4, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0, 0xF4, 0xF6, 0xF7, + 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF, + ] { assert_eq!(symbol_unicode(code), None, "CUS code 0x{code:02X} has no standard Unicode remap"); } }