Fit reused slide content to destination size

This commit is contained in:
Harrison Booth committed 2026-11-02 22:41:55 -06:00
1 parent 0c00f22403
commit bf60dbb773
5 files changed
+236 -10

No files matched your search

+2 -1
View File
@@ -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 (reused slides scale to the destination size, including kept masters, layouts, groups, tables and text; reuse remains partial because picker thumbnails are not implemented) · **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 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails |
| 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) |
+226 -3
View File
@@ -9,13 +9,151 @@
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
use crate::{Result, Session, media_refs};
/// Scale physical measurements in imported content to the destination slide's coordinate space.
/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale.
fn scale_text(t: &mut TextBody, sx: f64, sy: f64) {
let unit = (sx * sy).sqrt();
for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() {
*v *= sx;
}
for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() {
*v *= sy;
}
scale_list(&mut t.list_style, sx, unit);
for p in &mut t.paragraphs {
for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() {
*v *= sx;
}
if let Some(stops) = &mut p.props.tabs {
for stop in stops {
stop.pos *= sx;
}
}
for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] {
if let Some(Spacing::Pts(v)) = spacing {
*v *= unit;
}
}
if let Some(v) = &mut p.props.bullet_size_points {
*v *= unit;
}
scale_run(&mut p.def_props, unit);
scale_run(&mut p.end_props, unit);
for r in &mut p.runs {
scale_run(&mut r.props, unit);
}
}
}
fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) {
for level in list.levels.iter_mut().flatten() {
for v in
[&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten()
{
*v *= sx;
}
for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] {
if let Some(Spacing::Pts(v)) = spacing {
*v *= unit;
}
}
if let Some(v) = &mut level.para.bullet_size_points {
*v *= unit;
}
scale_run(&mut level.run, unit);
}
}
fn scale_run(run: &mut RunProps, factor: f64) {
for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() {
*v *= factor;
}
}
fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) {
if depth > 64 {
return;
}
let unit = (sx * sy).sqrt();
for shape in shapes {
if let Some(x) = &mut shape.xfrm {
x.x *= sx;
x.w *= sx;
x.y *= sy;
x.h *= sy;
}
if let Some(text) = &mut shape.text {
scale_text(text, sx, sy);
}
match &mut shape.kind {
ShapeKind::Table(table) => {
for col in &mut table.cols {
*col *= sx;
}
for row in &mut table.rows {
row.height *= sy;
for cell in &mut row.cells {
if let Some(margins) = &mut cell.margins {
if let Some(v) = margins.get_mut(0) {
*v *= sx;
}
if let Some(v) = margins.get_mut(1) {
*v *= sx;
}
if let Some(v) = margins.get_mut(2) {
*v *= sy;
}
if let Some(v) = margins.get_mut(3) {
*v *= sy;
}
}
scale_text(&mut cell.text, sx, sy);
}
}
}
ShapeKind::Group { children, child } => {
child.x *= sx;
child.w *= sx;
child.y *= sy;
child.h *= sy;
scale_shapes(children, sx, sy, depth + 1);
}
_ => {}
}
if let Some(effects) = &mut shape.effects {
for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() {
shadow.blur *= unit;
shadow.dist *= unit;
}
if let Some(glow) = &mut effects.glow {
glow.radius *= unit;
}
if let Some(reflection) = &mut effects.reflection {
reflection.blur *= unit;
reflection.dist *= unit;
}
}
}
}
fn scale_master(master: &mut Master, sx: f64, sy: f64) {
scale_shapes(&mut master.shapes, sx, sy, 0);
let unit = (sx * sy).sqrt();
scale_list(&mut master.title_style, sx, unit);
scale_list(&mut master.body_style, sx, unit);
scale_list(&mut master.other_style, sx, unit);
for layout in &mut master.layouts {
scale_shapes(&mut layout.shapes, sx, sy, 0);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -176,8 +314,14 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
}
let keep = bool_or(p, "keepFormatting", false);
let at = usize_param(p, "at");
let source_size = src.slide_size;
s.edit(|doc, sel| {
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
let scale = |source: f64, destination: f64| {
if source.is_finite() && source > 0.0 && destination.is_finite() && destination > 0.0 { destination / source } else { 1.0 }
};
let sx = scale(source_size.width, doc.slide_size.width);
let sy = scale(source_size.height, doc.slide_size.height);
// Media: what the slides use is copied under fresh ids.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Keep Source Formatting: bring each source master used, once, with fresh ids.
@@ -190,7 +334,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
masters_done.push(m.id);
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
scale_master(&mut m, sx, sy);
doc.masters.push(Arc::new(m));
}
}
@@ -201,6 +346,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
let Some(from) = src.slides.get(i) else { continue };
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
scale_shapes(&mut sl.shapes, sx, sy, 0);
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
@@ -284,13 +430,90 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
#[cfg(test)]
mod tests {
use deckcraft_geom::{Size, Xfrm};
use deckcraft_model::style::{Artistic, Fill, PictureFill};
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults};
use serde_json::json;
use super::*;
#[test]
fn reused_slide_content_fits_destination_size() {
let mut src = defaults::new_presentation(None);
src.slide_size = Size::new(960.0, 540.0);
let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default();
if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) {
let mut text = TextBody::from_text("scaled");
if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
run.props.size = Some(24.0);
}
slide.shapes = vec![
Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() },
Shape {
xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)),
text: Some(text),
kind: ShapeKind::Table(Table::new(2, 2, 400.0, 40.0)),
..Default::default()
},
Shape {
xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)),
kind: ShapeKind::Group {
child: Xfrm::new(0.0, 0.0, 300.0, 150.0),
children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }],
},
..Default::default()
},
];
}
if let Some(master) = src.masters.first_mut().map(Arc::make_mut) {
master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }];
if let Some(layout) = master.layouts.first_mut() {
layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }];
}
}
let bytes = deckcraft_format::save(&src).expect("source save");
let data = super::super::base64_encode(&bytes);
let mut session = Session::with_new();
session
.edit(|doc, _| {
doc.slide_size = Size::new(480.0, 270.0);
Ok(())
})
.expect("destination size");
session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse");
let doc = &session.doc().expect("doc").doc;
let reused = doc.slides.get(1).expect("reused slide");
let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform");
assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0));
let table_shape = reused.shapes.get(1).expect("table");
let table_box = table_shape.xfrm.expect("table transform");
assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0));
assert!(matches!(&table_shape.kind, ShapeKind::Table(_)));
let ShapeKind::Table(table) = &table_shape.kind else { return };
assert_eq!(table.cols, vec![100.0, 100.0]);
assert_eq!(table.rows.first().map(|r| r.height), Some(20.0));
let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size);
assert_eq!(font_size, Some(12.0));
let group = reused.shapes.get(2).expect("group");
assert!(matches!(&group.kind, ShapeKind::Group { .. }));
let ShapeKind::Group { children, child } = &group.kind else { return };
assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0)));
assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0));
assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0)));
let imported_master = doc.masters.last().expect("imported master");
assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0)));
assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0)));
assert_ne!(reused.id, slide_id);
let saved = deckcraft_format::save(&doc.clone()).expect("destination save");
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen");
let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm);
assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0)));
}
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
#[test]
+3 -2
View File
@@ -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-10-30 · **Last updated:** 2026-11-02 · **Change:** trivial (reused slide contents now scale to the destination; picker thumbnails remain a gap) · **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
@@ -284,7 +284,7 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
### Smaller missing rows
Zoom: live thumbnails, the zoom-in animation and the native `p166:sldZm` element (3–5 h;
`insert.zoom` makes linked pictures with Return to Zoom), reuse slides pane with thumbnails
(1–2 h; `slide.reuseList` picks which slides of a file to insert, #1103), laser pointer from the presenter window (1–2 h),
(1–2 h; `slide.reuseList` picks which slides of a file to insert, and reused content now fits the destination size), laser pointer from the presenter window (1–2 h),
artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove
background (6–10 h; needs a model or a classical matte), insert screenshot (2–3 h), record
audio/screen and record slide show (8–12 h), compare presentations beyond accepting and rejecting each revision, or all (#850; per-object accept and reject, markers on the slide: 2–4 h), password protection (4–6 h, ECMA-376
@@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | trivial | Reused slide geometry and content scale to the destination; picker thumbnails remain the reuse gap |
| 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 |
+2 -2
View File
@@ -2,7 +2,7 @@
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last updated:** 2026-11-02 · Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row re-assessed: remains P, since only how tiles are sized changed. Scores unchanged.
> **Last updated:** 2026-11-02 · Reused slides fit their geometry, tables and text to the destination size; reuse remains partial because the picker has no thumbnails. Scores unchanged.
>
> **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged.
@@ -72,7 +72,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P |
| Files | Print (slides, handouts, notes, outline) | P1 | M |
| Files | Password protection | P2 | M |
| Files | Reuse slides from another file | P1 | P |
| Files | Reuse slides from another file (content fits destination size; picker thumbnails remain) | P1 | P |
| Files | Drag and drop files onto the window | P1 | D |
| Files | OpenDocument .odp open/save | P2 | M |
| Slides | New slide by layout | P0 | D |
+3 -2
View File
@@ -1,6 +1,6 @@
# DeckCraft vs Microsoft PowerPoint: parity assessment
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (no break after `?`, `!`, `}`, `+` inside a word; previous: trivial, Animations shortfall: preview from the selected effect is in; before that: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; 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-11-02 · **Change:** trivial (reused slide contents fit the destination size; thumbnails remain; parity estimates unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
@@ -179,7 +179,7 @@ Features row of the dimension table keeps ~62% until the next full re-measure).
| Area | Weight | Breadth (measured) | Ready (est.) | Hours | Main shortfalls | Doc |
|---|---:|---:|---:|---|---|---|
| Application shell | 5 | 81% | ~55% | 8–14 | No native macOS menu bar, contextual tabs and multiple-window handling thinner than PowerPoint's | [ui-parity.md](ui-parity.md) |
| Files (commands) | 4 | 75% | ~50% | see file formats | Print, passwords, legacy and macro formats, video export; reuse slides is partial | [file-format-parity.md](file-format-parity.md) |
| Files (commands) | 4 | 75% | ~50% | see file formats | Print, passwords, legacy and macro formats, video export; reuse content now fits destination size, but the picker still lacks thumbnails | [file-format-parity.md](file-format-parity.md) |
| Slides and sections | 6 | 92% | ~85% | 3–5 | Zoom (summary/section/slide) is partial, copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) |
| Views | 4 | 98% | ~80% | 3–6 | Arrange All (Grid Options and New Window are in) | [ui-parity.md](ui-parity.md) |
| Masters and themes | 7 | 92% | ~70% | 8–14 | Notes/Handout master view polish, effect-scheme depth, Designer suggestions | [parity-checklist.md](parity-checklist.md) |
@@ -288,6 +288,7 @@ scored done (16 open issues).
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | trivial | Reused slide content scales to the destination; picker thumbnails remain; parity estimates unchanged |
| 2026-10-22 | trivial | Animations shortfall: preview from the selected effect (Play From) is in. Percentages unchanged |
| 2026-10-17 | trivial | A word no longer breaks after `?`, `!`, `}` or `+` (a URL's query wraps at the edge as in PowerPoint 2016 for Windows), as it already didn't after `/` and `\`. Headline percentages unchanged |
| 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged |