diff --git a/ROADMAP.md b/ROADMAP.md
index 54daca05..3996ef21 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -5,6 +5,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; 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; 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-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; 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; 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-18 · **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: minor, native chart payloads stored in bounded ZIP members; 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; 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
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -153,6 +154,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
+| 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 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
@@ -262,6 +264,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
+| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs
index d09f5b32..b78b96ea 100644
--- a/crates/text/src/lib.rs
+++ b/crates/text/src/lib.rs
@@ -873,13 +873,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))
@@ -1063,7 +1065,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);
@@ -1145,6 +1151,223 @@ 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, 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 {
+ 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
+/// 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
+/// 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.
+ 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,
+ // 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,
+ // 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,
+ // 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,
+ })
+}
+
+/// 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 (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)?;
+ 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));
+ }
+ // 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
/// 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 {
@@ -1153,14 +1376,16 @@ fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option ? in Adobe's symbol encoding (≤ ≥ ∞ sit at 0xA3/0xB3/0xA5 and
+ // ∴ at 0x5C), so a document's "a`:
@@ -955,10 +1230,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);