Merge pull request #1118

This commit is contained in:
Brandon Thomas
2026-10-25 13:42:06 -04:00
8 changed files with 226 additions and 8 deletions
+2
View File
@@ -8,6 +8,7 @@
> **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)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (proofing language for the whole presentation; 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
@@ -154,6 +155,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
| 2026-10-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
+3
View File
@@ -62,3 +62,6 @@ sync_github_name = false
[people.GhostMitra]
sync_github_name = true
[people.Katacc]
sync_github_name = true
+70 -4
View File
@@ -6,6 +6,7 @@ use deckcraft_model::resolve;
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind};
use serde_json::{Value, json};
use std::sync::Arc;
use super::text::{format_body, format_para, format_run};
use super::{
@@ -64,6 +65,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_shapes,
language
),
cmd!(
"format.languageAll",
"Language for All Text",
["Review", "Language"],
None,
"{lang?, noProof?: bool} (proofing language of every text in the presentation: slides, tables, notes, masters and layouts, and the default for new text; one undo step)",
has_doc,
language_all
),
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
cmd!(
"format.case",
@@ -410,16 +420,17 @@ fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {
r.fill = Some(f);
})
}
fn language(s: &mut Session, p: &Value) -> Result<Value> {
/// The `lang` and `noProof` params of the language commands, checked: a run-props setter.
fn language_edit(id: &str, p: &Value) -> Result<impl Fn(&mut RunProps) + Send + Sync + 'static> {
let lang = str_param(p, "lang").map(str::to_string);
let no_proof = bool_param(p, "noProof");
if lang.is_none() && no_proof.is_none() {
return Err(bad("format.language", "give `lang` or `noProof`"));
return Err(bad(id, "give `lang` or `noProof`"));
}
if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) {
return Err(bad("format.language", "`lang` is a language tag such as en-US"));
return Err(bad(id, "`lang` is a language tag such as en-US"));
}
format_run(s, &move |r| {
Ok(move |r: &mut RunProps| {
if let Some(l) = &lang {
r.lang = Some(l.clone());
}
@@ -428,6 +439,61 @@ fn language(s: &mut Session, p: &Value) -> Result<Value> {
}
})
}
/// Review › Language on the selected text (or the selected shapes' text).
fn language(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.language", p)?;
format_run(s, &f)
}
/// Every run in the deck, and the text styles new text inherits from, in one language.
fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.languageAll", p)?;
/// Runs, end marks and the body's own list style levels (never creating one).
fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) {
ed::format_all(t, f);
t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
/// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep).
fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) {
if depth > 64 {
return;
}
for sh in list {
if let Some(t) = &mut sh.text {
body(t, f);
}
match &mut sh.kind {
deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)),
deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1),
_ => {}
}
}
}
/// A master or presentation text style's levels: what new text inherits.
fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) {
// Level 1 always, so a deck without a default text style gets one.
if l.level(0).is_none() {
l.set(0, Default::default());
}
l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
s.edit(|doc, _| {
for sl in &mut doc.slides {
let sl = Arc::make_mut(sl);
shapes(&mut sl.shapes, &f, 0);
body(&mut sl.notes, &f);
}
let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()];
for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) {
let m = Arc::make_mut(m);
shapes(&mut m.shapes, &f, 0);
m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0));
[&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f));
}
styles(&mut doc.default_text_style, &f);
Ok(())
})?;
ok()
}
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
format_run(s, &move |r| r.highlight = c.clone())
+58
View File
@@ -4475,6 +4475,64 @@ fn language_marks_text_and_no_proof_and_repeats_are_reported() {
assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err());
}
/// Review › Language › Whole presentation: every run, placeholder list style and default text style
/// takes the language, with nothing selected, in one undo step.
#[test]
fn language_all_marks_every_text_in_the_deck_with_nothing_selected() {
crate::spell::install("xx-AL", crate::spell::Dictionary::parse(b"", b"2\nhello\nworld\n"));
let mut s = session();
let a =
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "hello wurld"})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
s.execute("format.language", &json!({"lang": "de-DE"})).unwrap();
s.execute("slide.new", &json!({"title": "world hellow"})).unwrap();
s.execute("insert.table", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
// A layout placeholder with its own list style (a:lstStyle) in another language.
let placeholder = s
.edit(|doc, _| {
let m = std::sync::Arc::make_mut(doc.masters.first_mut().unwrap());
let sh = m.layouts[0].shapes.iter_mut().find(|sh| sh.text.is_some()).unwrap();
let lv = deckcraft_model::text::LevelStyle {
run: deckcraft_model::RunProps { lang: Some("de-DE".into()), ..Default::default() },
..Default::default()
};
sh.text.as_mut().unwrap().list_style.set(0, lv);
Ok(sh.id)
})
.unwrap();
s.execute("format.languageAll", &json!({"lang": "xx-AL"})).unwrap();
let st = s.doc().unwrap();
let ph = deckcraft_model::find_shape(&st.doc.masters[0].layouts[0].shapes, placeholder).unwrap();
assert_eq!(ph.text.as_ref().unwrap().list_style.level(0).unwrap().run.lang.as_deref(), Some("xx-AL"));
// A body without a list style doesn't get one.
let boxed = st.doc.slides.iter().find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(a as u32))).unwrap();
assert!(boxed.text.as_ref().unwrap().list_style.is_empty());
let mut langs = vec![];
for sl in &st.doc.slides {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
langs.extend(sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).map(|r| r.props.lang.clone()));
if let deckcraft_model::ShapeKind::Table(t) = &sh.kind {
langs.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.text.paragraphs).map(|p| p.end_props.lang.clone()));
}
});
}
assert!(!langs.is_empty() && langs.iter().all(|l| l.as_deref() == Some("xx-AL")), "{langs:?}");
// The defaults new text inherits.
assert_eq!(st.doc.default_text_style.level(0).unwrap().run.lang.as_deref(), Some("xx-AL"));
assert!(st.doc.masters.iter().all(|m| m.body_style.level(0).is_some_and(|l| l.run.lang.as_deref() == Some("xx-AL"))));
let words: Vec<String> =
s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().iter().map(|w| w["word"].as_str().unwrap().to_string()).collect();
assert_eq!(words, vec!["wurld", "hellow"]);
// One undo step puts the per-box language back.
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
let sh = st.doc.slides.iter().find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(a as u32))).unwrap();
assert_eq!(sh.text.as_ref().unwrap().paragraphs[0].runs[0].props.lang.as_deref(), Some("de-DE"));
assert!(s.execute("format.languageAll", &json!({})).is_err());
assert!(s.execute("format.languageAll", &json!({"lang": "en US!"})).is_err());
}
#[test]
fn the_caret_takes_the_colour_of_the_text() {
let caret = |theme: &str| {
+16 -2
View File
@@ -2058,7 +2058,21 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let (cur, np) = selected_language(app);
let lang = d.get("lang", &cur);
let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1";
ui.label("Mark selected text as:");
// The selected text, or every text in the presentation (all there is with nothing selected).
let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok();
let mut all = !has_sel || d.get("scope", "") == "all";
ui.horizontal(|ui| {
ui.label("Apply to:");
if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() {
d.fields.insert("scope".into(), "sel".into());
all = false;
}
if ui.radio(all, "Whole presentation").clicked() {
d.fields.insert("scope".into(), "all".into());
all = true;
}
});
ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" });
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
// The selection's own language first when the list doesn't have it.
let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone()));
@@ -2082,7 +2096,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let (ok, cancel) = buttons(ui, "OK");
if ok {
let lang = d.get("lang", &cur);
run(app, "format.language", json!({"lang": lang, "noProof": no_proof}));
run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof}));
}
ok || cancel
}
+70
View File
@@ -4570,6 +4570,76 @@ mod tests {
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR"));
}
/// Review > Language > Whole presentation marks every text box, not only the selected one.
#[test]
fn the_language_dialog_marks_the_whole_presentation() {
use egui_kittest::kittest::Queryable;
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
harness.run_steps(2);
let s = &mut harness.state_mut().session;
let title = s.active().unwrap().doc.slides[0].shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Hola"})).unwrap();
s.execute("slide.new", &json!({"title": "Adiós"})).unwrap();
s.execute("text.edit", &json!({"id": s.active().unwrap().doc.slides[1].shapes[0].id.0})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
harness.run_steps(4);
harness.get_by_label("Whole presentation").click();
harness.run_steps(2);
harness.get_by_label("Español (España)").click();
harness.run_steps(2);
harness.get_by_label("OK").click();
harness.run_steps(2);
assert!(harness.state().dialog.is_none());
let d = harness.state().session.active().unwrap();
for sl in &d.doc.slides {
let body = sl.shapes[0].text.as_ref().unwrap();
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES"));
}
}
/// With nothing selected, Review > Language applies to the whole presentation without
/// choosing it, and Selected text can't be picked.
#[test]
fn the_language_dialog_marks_the_whole_presentation_with_nothing_selected() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
harness.run_steps(2);
let s = &mut harness.state_mut().session;
let title = s.active().unwrap().doc.slides[0].shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Hola"})).unwrap();
s.execute("slide.new", &json!({"title": "Adiós"})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
assert!(s.active().unwrap().selection.text.is_none() && s.active().unwrap().selection.shapes.is_empty());
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
harness.run_steps(4);
assert!(harness.get_by_label("Selected text").accesskit_node().is_disabled());
harness.get_by_label("Español (España)").click();
harness.run_steps(2);
harness.get_by_label("OK").click();
harness.run_steps(2);
assert!(harness.state().dialog.is_none());
let d = harness.state().session.active().unwrap();
for sl in &d.doc.slides {
let body = sl.shapes[0].text.as_ref().unwrap();
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES"));
}
}
/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold
/// heading, Enter in a bulleted point, then saving as .pptx and reopening.
#[test]
+3 -2
View File
@@ -259,8 +259,9 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
for Add to Dictionary. Red squiggles on the current slide come from a worker thread, with a
context menu to correct, ignore or add; table cells and speaker notes are squiggled too, each
with a right-click menu; repeated words (`the the`) are flagged with Delete Repeated Word;
Review › Language marks the selection's proofing language or Do Not Check Spelling
(`lang`/`noProof` round-trip in PPTX). No grammar checking. The web build checks on the UI
Review › Language marks the selection's proofing language or Do Not Check Spelling, or the
whole presentation's (`format.languageAll`: every run, notes, masters, layouts and the default
text style) (`lang`/`noProof` round-trip in PPTX). No grammar checking. The web build checks on the UI
thread a few milliseconds per frame. No dictionaries ship with DeckCraft, so without one
installed (always on Windows and the web) only a short list of common misspellings is found.
`review.thesaurus` looks up the selected word (checklist row partial). AutoCorrect has
+4
View File
@@ -1,6 +1,7 @@
# Localization parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (one proofing language for the whole presentation; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
@@ -53,12 +54,15 @@ the next re-measure.
PowerPoint checks spelling in dozens of languages and lets each run carry a proofing language.
DeckCraft keeps `a:rPr/@lang` on round trip but its spelling check is a small English list of
common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#proofing-spelling-thesaurus-autocorrect).
Review › Language sets the proofing language of the selected text or, with *Whole presentation*,
of every text in the file and the default for new text (`format.languageAll`).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Local 0.37 candidate adds an opt-in native LXGW WenKai Lite glyph fallback and CJK interface-test coverage. This records local implementation only: managed tests, formatting, clippy, wasm-size and native GUI qualification remain pending. Browser font budget and uniform Chinese typography are unchanged. |
| 2026-10-21 | minor | Review › Language can set the proofing language of the whole presentation |
| 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret |
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |