diff --git a/ROADMAP.md b/ROADMAP.md index b653af403..b653a6031 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (PPTX and native saves retain optional RTL table direction; feature scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **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-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -149,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order | | 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) | diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 46c7f7e96..214b638bc 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -48,6 +48,38 @@ fn native_save_keeps_chart_relationships_and_payloads() { } } +#[test] +fn native_table_direction_and_column_text_survive_two_saves() { + use deckcraft_model::table::{Cell, Table}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + use std::sync::Arc; + + for rtl in [Some(true), Some(false), None] { + let mut p = defaults::new_presentation(None); + let mut table = Table::new(1, 2, 200.0, 30.0); + table.rtl = rtl; + table.rows[0].cells[0] = Cell::new("Left column"); + table.rows[0].cells[1] = Cell::new("Right column"); + let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(table), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(shape); + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let table = q.slides[0] + .shapes + .iter() + .find_map(|s| match &s.kind { + ShapeKind::Table(table) => Some(table), + _ => None, + }) + .expect("table shape"); + assert_eq!(table.rtl, rtl); + assert_eq!(table.rows[0].cells[0].text.paragraphs[0].runs[0].text, "Left column"); + assert_eq!(table.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Right column"); + q = load(&save(&q).expect("save again")).expect("load again"); + } + } +} + /// The same package without the entries `drop` names. fn without(bytes: &[u8], drop: &str) -> Vec { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index e5a5c2f33..42ec5a9fe 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -56,6 +56,9 @@ pub struct Table { pub last_col: bool, pub band_row: bool, pub band_col: bool, + /// Whether columns are ordered right-to-left (`a:tblPr/@rtl`). + #[serde(skip_serializing_if = "Option::is_none")] + pub rtl: Option, /// `a:tblPr` fill: a fill behind the whole table (over the style's own background). #[serde(skip_serializing_if = "Option::is_none")] pub background: Option, @@ -73,6 +76,7 @@ impl Default for Table { last_col: false, band_row: true, band_col: false, + rtl: None, background: None, } } diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index be08c680f..3c488da2e 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -175,3 +175,43 @@ mod import_theme_tests { assert!(crate::import_theme(&out.finish().unwrap().into_inner()).is_err()); } } + +#[cfg(test)] +mod table_direction_tests { + use deckcraft_model::table::{Cell, Table}; + use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults}; + use std::sync::Arc; + + fn table(p: &Presentation) -> &Table { + p.slides[0] + .shapes + .iter() + .find_map(|shape| match &shape.kind { + ShapeKind::Table(table) => Some(table), + _ => None, + }) + .expect("table shape") + } + + #[test] + fn table_direction_and_column_order_survive_two_pptx_saves() { + for rtl in [Some(true), Some(false), None] { + let mut p = defaults::new_presentation(None); + let mut t = Table::new(1, 2, 200.0, 30.0); + t.rtl = rtl; + t.rows[0].cells[0] = Cell::new("First column"); + t.rows[0].cells[1] = Cell::new("Second column"); + let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(t), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(shape); + + let mut roundtrip = crate::import(&crate::export(&p).expect("export")).expect("import"); + for _ in 0..2 { + let t = table(&roundtrip); + assert_eq!(t.rtl, rtl); + assert_eq!(t.rows[0].cells[0].text.paragraphs[0].runs[0].text, "First column"); + assert_eq!(t.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Second column"); + roundtrip = crate::import(&crate::export(&roundtrip).expect("export again")).expect("import again"); + } + } + } +} diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 6b19f29b1..625bc1c11 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -757,6 +757,7 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { t.last_col = pr.bool("lastCol").unwrap_or(false); t.band_row = pr.bool("bandRow").unwrap_or(false); t.band_col = pr.bool("bandCol").unwrap_or(false); + t.rtl = pr.bool("rtl"); if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) { // A style the file defines is drawn from its definition (by its GUID). // DeckCraft's own styles (written with GUIDs derived from their ids) map back to them. diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 58af6ef75..b54a698e2 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -711,7 +711,8 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { .t("lastRow", t.last_row) .t("lastCol", t.last_col) .t("bandRow", t.band_row) - .t("bandCol", t.band_col), + .t("bandCol", t.band_col) + .b("rtl", t.rtl), ); if let Some(f) = &t.background { dml::fill(w, x, o, f); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 329a69f89..24e154b4e 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 (optional table RTL direction survives native and two PPTX saves; scores unchanged) · **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** @@ -23,7 +23,7 @@ PowerPoint opens and saves; Viewer = opens only). | `.ppt` / `.pot` / `.pps` 97–2003 binary | Editor | no | no | Owner decision; import only would be enough | none | | `.odp` OpenDocument | Editor | no | no | | none | | `.thmx` Office theme | Viewer | theme only (Design › Browse for Themes…) | no | Browse for Themes takes a `.thmx`'s colours, fonts and effects; a `.pptx` or `.potx` brings its slide master and layouts as well, and slides move to the matching layouts (#807). A `.thmx`'s own master and layouts aren't read yet | `crates/pptx/src/lib.rs` (`import_theme_tests`), `crates/engine/src/cmd/design/browse_theme_tests.rs` | -| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | +| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible; optional table RTL direction survives two saves | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` and `.rtf` outline; `.md` as Markdown (headings are slides, list items bullets) (open, drop, `slide.fromOutline`) | `.txt` and `.rtf` outline | — | format + engine tests | | PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size. PNG can leave the slide background out (`transparentBackground`, Export… “Transparent background”, `deckcraft-cli render --transparent`), so only the slide's content is opaque (#784) | render, engine, UI and CLI tests | @@ -87,7 +87,7 @@ the round trip even though DeckCraft doesn't model it. | 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 | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | -| Tables with styles | yes | yes | Merges and both diagonal borders round-trip | +| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; column text order survives two saves | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at, under fresh `keptN` names so they never replace a part DeckCraft writes (#335); editing the chart drops that workbook instead of writing a stale one | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept, with their embedded workbook (#335); edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | the drawing part as a group (text laid out on its `dsp:txXfrm` rectangle), and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn. SmartArt kept only as XML, without its relationship parts, still loses the drawing and data parts (#203) | @@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Optional table RTL direction is read and written as `a:tblPr/@rtl`; native and two PPTX saves preserve true, false and absent values with distinguishable column text | | 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 |