From a0956fee13ab3c32eb2403493e01798e0c94dee6 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:42:25 -0600 Subject: [PATCH] Preserve alternate run language --- ROADMAP.md | 2 ++ crates/format/src/tests.rs | 25 +++++++++++++++++++++++++ crates/model/src/text.rs | 24 ++++++++++++++++++++++++ crates/pptx/src/read/dml.rs | 1 + crates/pptx/src/write/dml.rs | 1 + crates/pptx/tests/it/roundtrip.rs | 26 ++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- 8 files changed, 85 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b653af40..512244d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves | | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | @@ -322,6 +323,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged | | 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | | 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index dcd15636..5525d5a4 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -11,6 +11,31 @@ fn chart_sidecars_are_optional_for_older_native_files() { assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); } +#[test] +fn alternate_run_language_survives_two_native_saves_and_absence() { + use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody}; + let mut p = defaults::new_presentation(None); + Arc::make_mut(&mut p.slides[0]).shapes[0].text = Some(TextBody { + paragraphs: vec![Paragraph { + runs: vec![ + Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }), + Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }), + ], + ..Default::default() + }], + ..Default::default() + }); + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs; + assert_eq!(runs[0].props.lang.as_deref(), Some("en-US")); + assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR")); + assert_eq!(runs[1].props.lang.as_deref(), Some("en-US")); + assert_eq!(runs[1].props.alt_lang, None); + q = load(&save(&q).expect("save again")).expect("load again"); + } +} + #[test] fn native_save_keeps_chart_relationships_and_payloads() { use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index ca992c38..39552629 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -456,6 +456,8 @@ pub struct RunProps { /// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`). pub hover: Option, pub lang: Option, + #[serde(deserialize_with = "bounded_language")] + pub alt_lang: Option, pub shadow: Option, /// `a:innerShdw` on the run. Outer shadow stays in `shadow`. pub inner_shadow: Option, @@ -467,6 +469,13 @@ pub struct RunProps { pub no_proof: Option, } +fn bounded_language<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|lang| lang.map(|value| value.chars().take(256).collect())) +} + impl RunProps { pub fn inherit(&mut self, p: &RunProps) { macro_rules! take { @@ -493,6 +502,7 @@ impl RunProps { link, hover, lang, + alt_lang, shadow, inner_shadow, glow, @@ -695,6 +705,20 @@ mod tests { use super::*; use deckcraft_color::Rgba; + #[test] + fn alternate_language_inherits_and_native_input_is_bounded() { + let mut child = RunProps::default(); + child.inherit(&RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }); + assert_eq!(child.lang.as_deref(), Some("en-US")); + assert_eq!(child.alt_lang.as_deref(), Some("fr-FR")); + + let oversized = "x".repeat(300); + let json = serde_json::json!({"altLang": oversized}); + let props: RunProps = serde_json::from_value(json).expect("run props"); + assert_eq!(props.alt_lang.as_deref().map(str::len), Some(256)); + assert_eq!(serde_json::from_value::(serde_json::json!({})).expect("old run props"), RunProps::default()); + } + #[test] fn explicit_bullet_follow_text_resets_block_inheritance() { let parent = ParaProps { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 300773a5..ebf73563 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -553,6 +553,7 @@ fn typeface(e: &El) -> Option { pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps { let mut p = RunProps { lang: r.attr("lang").map(String::from), + alt_lang: r.attr("altLang").map(|lang| lang.chars().take(256).collect()), size: r.f64("sz").map(|s| (s / 100.0).clamp(1.0, 4000.0)), bold: r.bool("b"), italic: r.bool("i"), diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 84b30c83..ec483341 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -629,6 +629,7 @@ fn typeface(w: &mut W, tag: &str, f: &Option) { pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) { let a = A::new() .o("lang", r.lang.as_deref().filter(|l| !l.is_empty())) + .o("altLang", r.alt_lang.as_deref().filter(|l| !l.is_empty())) .o("sz", r.size.map(|s| (s.clamp(1.0, 4000.0) * 100.0).round() as i64)) .b("b", r.bold) .b("i", r.italic) diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 1ed97f54..9251b325 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -1527,6 +1527,32 @@ fn literal_line_ends_in_run_text_read_as_breaks_and_survive_save() { assert_eq!(runs_of(&round(&a), "one"), want, "save and reopen keep the runs"); } +#[test] +fn alternate_run_language_survives_pptx_saves_and_absence() { + let mut p = defaults::new_presentation(None); + let first = p.slides[0].id; + let slide = p.slide_mut(first).expect("slide"); + slide.shapes[0].text = Some(TextBody { + paragraphs: vec![Paragraph { + runs: vec![ + Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }), + Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }), + ], + ..Default::default() + }], + ..Default::default() + }); + let mut q = round(&p); + for _ in 0..2 { + let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs; + assert_eq!(runs[0].props.lang.as_deref(), Some("en-US")); + assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR")); + assert_eq!(runs[1].props.lang.as_deref(), Some("en-US")); + assert_eq!(runs[1].props.alt_lang, None); + q = round(&q); + } +} + #[test] fn color_pulse_keeps_its_effect_colour_and_duration() { let mut p = defaults::new_presentation(None); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 329a69f8..da0c7778 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** minor (run alternate-language metadata round-trips through native and PPTX formats; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -82,7 +82,7 @@ the round trip even though DeckCraft doesn't model it. | Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | -| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) | +| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Run `lang` and `altLang` are independently retained through native and PPTX saves; other round-trip tests cover `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) | | Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow | | 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | @@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Text runs preserve alternate-language metadata in native and PPTX round trips | | 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-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | diff --git a/docs/gaps.md b/docs/gaps.md index 1fefa5d9..2d4fdff7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (text run alternate-language metadata now survives native and PPTX saves; per-run language editing and proofing gaps remain; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Run alternate-language metadata now survives native and PPTX saves; UI and proofing gaps remain; scores unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | @@ -427,7 +428,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | minor | AutoCorrect options: case rules, fractions, ellipsis, a larger built-in list and your own entries (`text.autocorrect`, Tools ▸ AutoCorrect) | | 2026-10-14 | trivial | Notes spelling menu, Review › Language with Do Not Check Spelling, repeated words | | 2026-10-14 | trivial | Spelling squiggles in table cells and notes, and on the web | -| 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run languages and compounds | +| 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run language editing and compounds; run language metadata now round-trips | | 2026-10-14 | minor | Spelling checks with installed Hunspell dictionaries and suggests corrections | | 2026-10-13 | minor | Animation preview plays the current slide on the editor canvas; the pane gap keeps the timeline, preview from the selected effect, and the remaining effects | | 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open |