From aa6f45ba2cc78f49d137f0e92d9f46d88e6b4c04 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 08:49:42 +0200 Subject: [PATCH] Text: table text takes the master's other-text style PowerPoint for Mac lays out a table's text in the master's other-text style (p:otherStyle), while shapes and text boxes take the presentation's default text style (p:defaultTextStyle). A synthetic deck with an otherStyle of 14 pt and a defaultTextStyle of 26 pt, exported as PDF, draws a table cell at 14 pt and a shape's and a text box's text at 26 pt. DeckCraft laid cells out through a stand-in shape that wasn't a table, so they took the default text style: a deck whose two styles differ (18 and 20 pt) drew its table text 2 pt too large and wrapped its cells. master_list_style sends text in a non-placeholder table to otherStyle, and Shape::table_cell builds the stand-in every cell layout uses (drawing, row heights, text placement, the caret and selection in a cell) as a table. The table shape itself resolves the same way, so the formatting state of a selected table reports the size its cells draw at. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 ++ crates/engine/src/cmd/text.rs | 4 ++-- crates/model/src/lib.rs | 5 +++++ crates/model/src/resolve.rs | 6 +++++- crates/model/src/tests.rs | 20 ++++++++++++++++++++ crates/render/src/placed.rs | 2 +- crates/render/src/table.rs | 4 ++-- 7 files changed, 37 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d3..635de92e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 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 | @@ -245,6 +246,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | +| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style | | 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/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index fc6fcacc..c681ac76 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -934,7 +934,7 @@ pub fn cell_range_underlines( return vec![]; }; let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); - let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() }; + let tmp = deckcraft_model::Shape::table_cell(body.clone()); let l = deckcraft_text::layout( &ctx, &tmp, @@ -1003,7 +1003,7 @@ fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option Shape { + Shape { text: Some(body), kind: ShapeKind::Table(Table::default()), ..Default::default() } + } pub fn is_placeholder(&self) -> bool { self.ph.is_some() } diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index a52e8a67..4457108b 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -100,7 +100,10 @@ pub fn parents<'a>(ctx: &Ctx<'a>, shape: &Shape) -> (Option<&'a Shape>, Option<& } } -/// The text style group (title / body / other) a shape draws from. +/// The text style group (title / body / other) a shape draws from. Text in a table that isn't a +/// placeholder takes the master's other-text style, as in PowerPoint for Mac (a table cell is 14 pt +/// under an `otherStyle` of 14 pt and a `defaultTextStyle` of 26 pt, while a shape's or a text +/// box's text is 26 pt); other shapes take the presentation's default text style. pub fn master_list_style<'a>(ctx: &Ctx<'a>, shape: &Shape) -> &'a ListStyle { match shape.ph_type() { Some(PhType::Title | PhType::CtrTitle) => &ctx.master.title_style, @@ -116,6 +119,7 @@ pub fn master_list_style<'a>(ctx: &Ctx<'a>, shape: &Shape) -> &'a ListStyle { | PhType::ClipArt, ) => &ctx.master.body_style, Some(_) => &ctx.master.other_style, + None if matches!(shape.kind, crate::ShapeKind::Table(_)) => &ctx.master.other_style, None => &ctx.pres.default_text_style, } } diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index da234d55..05915a56 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -270,3 +270,23 @@ fn ink_width_follows_pressure_and_keeps_the_old_placeholder() { assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}"); assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9); } + +#[test] +fn table_text_takes_the_masters_other_style_and_shapes_the_default_text_style() { + // PowerPoint for Mac, with an otherStyle of 14 pt and a defaultTextStyle of 26 pt: a table + // cell's text is 14 pt, a shape's and a text box's 26 pt. + let mut p = Presentation::default(); + let size = |pt: f64| crate::text::LevelStyle { run: RunProps { size: Some(pt), ..Default::default() }, ..Default::default() }; + p.default_text_style.set(0, size(26.0)); + std::sync::Arc::make_mut(&mut p.masters[0]).other_style.set(0, size(14.0)); + let s = p.slides[0].clone(); + let ctx = Ctx::for_slide(&p, &s).unwrap(); + let para = Paragraph::default(); + let pt = |sh: &Shape| resolve::run(&ctx, sh, ¶, &RunProps::default()).size; + let body = crate::text::TextBody::from_text("Cell"); + assert_eq!(pt(&Shape::table_cell(body.clone())), Some(14.0), "a table cell"); + let table = Shape { kind: ShapeKind::Table(Table::default()), ..Default::default() }; + assert_eq!(pt(&table), Some(14.0), "the table itself (its formatting state)"); + assert_eq!(pt(&Shape { text: Some(body.clone()), ..Default::default() }), Some(26.0), "a shape"); + assert_eq!(pt(&Shape { text: Some(body), text_box: true, ..Default::default() }), Some(26.0), "a text box"); +} diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index 0164f3f0..4dc9e108 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -134,7 +134,7 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) { let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue }; let body = table::cell_body(rctx, t, r, c, cell); - let tmp = Shape { text: Some(body.clone()), ..Default::default() }; + let tmp = Shape::table_cell(body.clone()); place_text(out, rctx, fields, s.id, &tmp, &body, rect, m); } } diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 498daf91..dd2630f6 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -266,7 +266,7 @@ pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec { let c1 = ci.saturating_add(cell.grid_span.max(1) as usize).min(t.cols.len()); let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else { continue }; let body = cell_body(rctx, t, ri, ci, cell); - let tmp = Shape { text: Some(body.clone()), ..Default::default() }; + let tmp = Shape::table_cell(body.clone()); let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields); if need.is_finite() { h = h.max(need.min(20_000.0)); @@ -423,7 +423,7 @@ pub fn draw( continue; } let body = cell_body(rctx, t, *r, *c, cell); - let tmp = Shape { text: Some(body.clone()), ..Default::default() }; + let tmp = Shape::table_cell(body.clone()); #[cfg(test)] TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1)); let mut l = deckcraft_text::layout(rctx, &tmp, &body, &deckcraft_text::Opts { rect: *rect, fields, prompt_color: None, no_shrink: false });