Compare commits

...
Author SHA1 Message Date
FredApps ef9ac9ac7a Merge pull request #2062 from cloutier/fix/2060-large-adjustment-guides
PPTX: preserve large connector adjustment guides
2026-11-13 10:41:19 +01:00
FredApps eebede8ee4 Merge pull request #2058 from cloutier/fix/empty-master-text-styles
PPTX: preserve empty imported master text styles
2026-11-13 10:41:00 +01:00
FredApps a024a0e86c Merge pull request #1978 from aiongg/autocraft/fix-1977-pptx-bar-chart-series-level-c
Fix pPTX bar chart: series-level `<c:dLbls><c:delete val="1"/></c:dLbls>`
2026-11-13 10:40:24 +01:00
FredApps 9850dc6088 Merge pull request #1973 from scks1/fix/ungroup-nested-group
engine: arrange.ungroup frees a group nested in another group
2026-11-13 10:40:04 +01:00
FredApps 3ce1886bea Merge pull request #1972 from scks1/fix/duplicate-group-member
engine: edit.duplicate copies a shape inside a group into its group
2026-11-13 10:39:35 +01:00
FredApps 0a75620a73 Merge pull request #1968 from scks1/fix/copy-group-member-slide-box
engine: a shape copied from a group pastes where it was drawn
2026-11-13 10:39:13 +01:00
FredApps 74a0468917 Merge pull request #1967 from scks1/fix/merge-refuses-group-members
engine: shape.merge refuses a shape inside a group
2026-11-13 10:38:20 +01:00
FredApps 6166cb0870 Merge pull request #1951 from cloutier/fix/1910-slide-import-integrity
PPTX: report missing slide relationships and refuse over-limit lists
2026-11-13 10:36:39 +01:00
FredApps d2ee3ca9fc Merge pull request #1941 from neilcawse/fix/pptx-paragraph-build-inference
pptx: infer paragraph builds when bldP does not disable them
2026-11-13 10:36:06 +01:00
FredApps 506db24b07 Merge pull request #1966 from Silvertree2010/fix/h11008-delete-key-removes-only-the-firs
Delete a whole grapheme cluster with the forward Delete key
2026-11-13 10:34:40 +01:00
FredApps 7f9acc1c27 Merge pull request #2055 from cloutier/fix/picture-inherited-group-fill
PPTX: keep inherited group backing on transparent pictures
2026-11-13 10:27:28 +01:00
FredApps 3a92bcc93c Merge pull request #1915 from adikshan11/fix/automation-roots-exact-names
Automation roots: compare names exactly and refuse COM¹ to LPT³
2026-11-13 10:26:08 +01:00
Dylan M. Taylor 18355eeca2 Merge pull request #2064 from storytold/switch-metafile-craft-metafile
Metafile: read WMF, EMF and EMF+ with craft-metafile
2026-11-13 02:47:12 -05:00
Adithya Shankaran 2d1e6ff348 Drop the repeated test comment and show one backslash in the docs
The case-sensitive Windows test already says what it checks. In the MCP docs the OUT example is a code span, so one backslash is what a reader sees.
2026-11-12 12:10:41 +00:00
Vincent Cloutier 6a3cbd18d2 PPTX #2060: preserve large adjustment guides 2026-11-11 20:06:47 -05:00
Vincent Cloutier 5914363930 DC002: refuse over-limit PPTX slide lists 2026-11-11 19:32:31 -05:00
Vincent Cloutier e0b0817288 fix(pptx): preserve empty imported master styles (#2057) 2026-11-11 18:33:37 -05:00
autocraft-bot[bot] 63331b8ab6 Apply review fixes
- crates/pptx/src/read.rs
2026-11-11 23:14:10 +00:00
Vincent Cloutier 3975b8cc5e Merge official main to refresh PR1951 2026-11-11 18:02:42 -05:00
Vincent Cloutier db257178f5 fix(pptx): keep pictures inherited group backing (#2054) 2026-11-11 17:43:03 -05:00
Neil Cawse e8802ccfbd Merge remote-tracking branch 'upstream/main' into fix/pptx-paragraph-build-inference 2026-11-11 18:35:52 +00:00
Neil Cawse 730c6cde9c Merge remote-tracking branch 'origin/main' into fix/pptx-paragraph-build-inference 2026-11-11 02:18:40 +00:00
Neil Cawse b7e5cf05ea style(pptx): format timing.rs with rustfmt 2026-11-09 22:52:57 +00:00
Neil Cawse ce2bd95414 fix(pptx): require matching parent offsets for click-started inferred paragraph builds 2026-11-09 22:41:38 +00:00
Neil Cawse 41dd2bd3bb Merge remote-tracking branch 'origin/main' into fix/pptx-paragraph-build-inference 2026-11-09 14:23:39 +00:00
autocraft-bot[bot] 1469419354 Apply review fixes
- ROADMAP.md
- docs/file-format-parity.md
- docs/gaps.md
2026-11-08 21:35:34 +00:00
Simon Koch-Sultan 70a0d7a2d8 engine: test ungrouping a top-level group with a group inside it
Ungrouping a top-level group and a group inside it in one call, in
either order, frees the shapes of both onto the slide in the top-level
group's place, drawn where they were, returns them once each and
selects them. The other ungroup tests leave the outermost group
unnamed, so none checked that the inner group is found at the slide's
top level after the same call frees the group around it.
2026-11-08 15:36:50 -05:00
aiongg ea97b18c70 Fix pPTX bar chart: series-level <c:dLbls><c:delete val="1"/></c:dLbls>
Fixes #1977.

- crates/pptx/src/read/chart.rs
- crates/pptx/tests/chart_data_labels.rs
- crates/pptx/tests/autocraft_regression_1977.rs
2026-11-08 17:03:31 +00:00
Simon Koch-Sultan 17db75b74a engine: test ungrouping nested groups named out of order
Ungrouping a middle group, the group around it and then the innermost
group, all inside a fourth group, returns the four freed rectangles
once each, selects them, and leaves them in the fourth group in order.
Before the previous commit, the innermost group was listed twice and
replaced once, so the call also returned that group's id after the
group was gone.
2026-11-08 10:22:34 -05:00
aiongg cdca5bd09d Apply review fixes
- crates/engine/src/cmd/arrange.rs
2026-11-08 14:56:11 +00:00
Simon Koch-Sultan 7ebfa82297 engine: group copy test compares site counts
The test compared the pasted shape's connection sites with the original's
through zip, which stops at the shorter list. It now checks that both have
the same number of sites first.
2026-11-08 09:01:24 -05:00
Simon Koch-Sultan de5398cfc5 engine: nested ungroup test compares site counts and checks the selection
The test compared each shape's connection sites before and after through
zip, which stops at the shorter list, so a shape that lost sites would still
pass. It now checks the counts first. The case with three nested groups also
checks that the freed shapes are selected.
2026-11-08 09:01:24 -05:00
Simon Koch-Sultan 2750e65096 engine: group duplicate test compares site counts
The test compared the copy's connection sites with the original's through
zip, which stops at the shorter list. It now checks that both have the same
number of sites first.
2026-11-08 09:01:23 -05:00
Neil Cawse c564070d9e fix(pptx): check click group and parent-derived start offsets before folding inferred paragraph builds 2026-11-08 13:40:42 +00:00
Neil Cawse 3def50c98a fix(pptx): gate inferred non-click paragraph builds on 500ms/0ms delay when bldP is absent 2026-11-08 13:36:49 +00:00
Neil Cawse a570031909 fix(pptx): drop timing gate in plays_as_build, merge duplicate bldP entries, and expand build tests 2026-11-08 13:36:49 +00:00
Neil Cawse 9d9610a893 pptx: infer paragraph builds when bldP does not disable them 2026-11-08 13:36:49 +00:00
aiongg 5a2cab9483 Apply review fixes
- crates/model/src/edit.rs
- crates/engine/src/tests.rs
2026-11-08 07:34:41 +00:00
NetterSilverandClaude Haiku 5.5 3e2dc9ca7f Delete a whole grapheme cluster with the forward Delete key
Found while testing crates/engine/src/cmd/text/caret.rs:60 (delete_dir, forward) with crates/model/src/edit.rs:423 (char_move)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 07:17:28 +00:00
Simon Koch-Sultan d680914140 engine: a shape copied from a group pastes where it was drawn
edit.copy and edit.cut put a shape inside a group on the clipboard with
its box in the group's own space. Pasted, it landed where it would be
drawn without the group's move, stretch, turn or flip: a shape from a
group moved 200 pt to the right pasted 200 pt left of where it was
drawn, and one from a group stretched to twice its width pasted half as
wide.

The copied shape now carries the box it is drawn in on the slide, mapped
out through each group around it as Ungroup maps a freed shape. It
pastes at the slide's top level where it was drawn, offset as any paste
on the same slide is. Cut still deletes the shape from its group.
A turned shape in a group stretched more one way than the other is drawn
skewed, which a box cannot hold; it pastes with the unskewed box Ungroup
gives it. Copying with a text caret is unchanged.

Test: a_shape_copied_from_a_group_pastes_where_it_was_drawn.
2026-11-08 01:21:59 +00:00
Simon Koch-Sultan 1c8b14edad engine: arrange.ungroup frees a group nested in another group
arrange.ungroup looked only at the slide's top-level list, so a group
inside another group answered `{"ids": []}`, stayed grouped, and the
selection was emptied.

Such a group's children now take its place among the outer group's
children, with their boxes mapped into the outer group's space by
child_to_parent, as for a top-level group, so they are drawn where they
were. They are selected and returned. A shape freed from a named group
and then from a named group around it is listed once.

Test: ungrouping_a_nested_group_frees_its_shapes_into_the_outer_group.
2026-11-08 01:21:58 +00:00
Simon Koch-Sultan 09548fe55c engine: edit.duplicate copies a shape inside a group into its group
edit.duplicate took its copies from the slide's top-level list only, so
duplicating a shape inside a group answered `{"ids": []}` and changed
nothing.

A shape inside a group is now copied into that group, after its other
children, and the copy is selected and returned. The duplicate offset is
converted into the group's space, so the copy is drawn as far from the
original on the slide as a top-level copy is, however the groups around
it are moved, stretched or turned. Duplicating a moved copy again
repeats the move (#966), measured on the slide. A shape whose group is
duplicated in the same call is copied only with the group's copy.

Test: duplicating_a_shape_in_a_group_copies_it_into_the_group.
2026-11-08 01:21:57 +00:00
Simon Koch-Sultan ea809d93f5 engine: shape.merge refuses a shape inside a group
shape.merge worked on the slide's top-level list. Given shapes inside a
group, it merged them from their boxes in the group's own space, left
them in the group and added the result to the slide's top level, so in a
moved or resized group the merged shape was drawn away from the shapes
it came from, which stayed drawn as well.

A call whose shapes include one inside a group is now refused with the
shape's name and a request to ungroup it first, and the slide is left as
it was. Merge Shapes is unavailable while a shape inside a group is
selected; a call that names top-level shapes in `ids` still runs
whatever is selected, as it does when nothing is selected.

Test: a_shape_in_a_group_is_refused.
2026-11-08 01:21:55 +00:00
Vincent Cloutier 26c1ac83dc DC-001: report unresolved slide relationships as damage 2026-11-07 16:12:41 -05:00
Adithya Shankaran 23d0f67d17 Say what the touched tests check
Doc comments only, on the tests this change touches and the ones the docstring scan listed.
2026-11-06 15:45:24 +00:00
aiongg fb67888e25 Apply review fixes
- docs/mcp.md
2026-11-06 06:29:34 +00:00
Adithya Shankaran 9e6758e276 Credit my name in the About window 2026-11-06 00:14:47 +05:30
Adithya Shankaran 6c0886a68a Automation roots: compare names exactly and refuse COM¹ to LPT³
On Windows a folder can be set to tell case apart, and then IN beside the root in passed as inside
it, because names were compared with to_lowercase. Both paths have the spelling the file system
gives them by then, so another spelling of the root still matches with an exact compare. A missing
name keeps the spelling it was given, but it can only match a root that exists, and resolve has
already found that one in its real spelling.

is_device_name also missed COM¹, COM², COM³ and LPT¹, LPT², LPT³, which Windows reserves too.
It also cut the name only at a period, so COM1: and NUL:x were not seen as device names.
2026-11-06 00:14:47 +05:30
24 changed files with 1136 additions and 66 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-11 · **Change:** trivial (the Opus decoder and the Ogg, MP4 and Matroska demuxers come from craft-libs; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-11 · **Change:** trivial (slide-list entries with missing relationships are reported as lost slides and open as repaired copies; the Opus decoder and the Ogg, MP4 and Matroska demuxers come from craft-libs; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -155,6 +155,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-11-11 | Media: the Opus decoder and the Ogg, MP4/MOV and Matroska demuxers are craft-audio and craft-media from craft-libs; DeckCraft's own copies and re-export crates are removed |
| 2026-11-07 | PPTX slide-list entries with missing relationships are reported in lostSlides instead of silently omitted. The engine opens the partial deck as a repaired copy with no original save path. Synthetic 13-slide regression; does not establish the cause of #1910's field report. |
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
| 2026-11-02 | Slide show on Linux and the BSDs: Presenter View stays off when winit lists exactly one display, as in PowerPoint; if it lists none, the setting is kept. On one display the presenter window covered the show, and the hidden window stalled every frame: slide changes took up to ~1.7 s under Hyprland (#1156) |
+4
View File
@@ -73,3 +73,7 @@ sync_github_name = true
display_name = "Arshiya"
real_name = "Arshiya Rahgozar"
sync_github_name = false
[people.adikshan11]
real_name = "Adithya Shankaran"
sync_github_name = true
+32
View File
@@ -365,6 +365,38 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
out.push(sh);
}
*list = out;
// A group inside a group: its children take its place in the outer group, mapped into the
// outer group's child space, where the inner group's own box is.
for id in &ids {
let Some(siblings) = deckcraft_model::sibling_list_mut(list, *id) else { continue };
let Some(at) = siblings.iter().position(|x| x.id == *id) else { continue };
let freed: Vec<Shape> = match siblings.get(at) {
Some(Shape { kind: ShapeKind::Group { children, child }, xfrm: Some(gx), .. }) => children
.iter()
.map(|c| {
let mut c = c.clone();
if let Some(cx) = c.xfrm {
c.xfrm = Some(child_to_parent(gx, child, &cx));
}
c
})
.collect(),
_ => continue,
};
let freed_ids: Vec<ShapeId> = freed.iter().map(|c| c.id).collect();
siblings.splice(at..=at, freed);
// Ungrouped along with an outer group, it was listed among that group's children (once per expanded ancestor).
match new_sel.iter().position(|x| x == id) {
Some(i) => {
new_sel.splice(i..=i, freed_ids);
new_sel.retain(|x| x != id);
}
None => new_sel.extend(freed_ids),
}
}
// A shape freed from a named group and then from a named group around it is listed once.
let mut listed = std::collections::HashSet::new();
new_sel.retain(|x| listed.insert(*x));
sel.shapes = new_sel.clone();
Ok(json!({"ids": new_sel}))
})
+91 -6
View File
@@ -120,6 +120,12 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
})
})
.collect();
// A shape in a group is copied with the box it is drawn in, so it pastes where it was seen.
for sh in &mut shapes {
if let Some(x) = sh.xfrm.and_then(|x| box_on_slide(&st.doc, &st.selection, st.shapes(), sh.id, &x)) {
sh.xfrm = Some(x);
}
}
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
// paste can be in another deck. Slide Master view pastes the originals instead.
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
@@ -147,6 +153,39 @@ pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::
}
}
/// Where shape `id` is drawn on the slide when it is inside a group: `x`, its box in its group's
/// space, mapped out through each group around it, as Ungroup places a freed shape. `None` for a
/// shape on the top level or not found. A turned shape in a group stretched more one way than the
/// other is drawn skewed, which a box cannot hold, so it gets the unskewed box Ungroup gives it.
fn box_on_slide(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
shapes: &[Shape],
id: ShapeId,
x: &deckcraft_geom::Xfrm,
) -> Option<deckcraft_geom::Xfrm> {
fn rec(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
shapes: &[Shape],
id: ShapeId,
x: &deckcraft_geom::Xfrm,
depth: usize,
) -> Option<deckcraft_geom::Xfrm> {
if depth > 64 {
return None;
}
shapes.iter().find_map(|sh| {
let deckcraft_model::ShapeKind::Group { children, child } = &sh.kind else {
return None;
};
let inner = if children.iter().any(|c| c.id == id) { *x } else { rec(doc, sel, children, id, x, depth + 1)? };
Some(super::arrange::child_to_parent(&xfrm_of(doc, sel, sh), child, &inner))
})
}
rec(doc, sel, shapes, id, x, 0)
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
@@ -559,6 +598,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (new_ids, made) = s.edit(|doc, sel| {
let list = super::current_shapes(doc, sel).cloned().unwrap_or_default();
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
// A shape inside a group is copied into that group, with the map from the group's space onto
// the slide so that its copy is offset on the slide as a top-level copy is.
let top = copies.len();
let mut to_slide = vec![None; top];
for id in grouped_targets(&list, &ids) {
if let Some((m, a)) = crate::connect::locate(doc, sel, &list, id) {
copies.push(m.clone());
to_slide.push(Some(a));
}
}
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
detach_on_slide(doc, sel, &mut copies);
@@ -570,10 +619,12 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
})
.and_then(|(_, _, pairs)| {
let (src, copy) = pairs.first()?;
let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?);
let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?);
let d = (b.x - a.x, b.y - a.y);
(d.0.is_finite() && d.1.is_finite()).then_some(d)
// Measured on the slide, so the copy of a shape in a group repeats its drawn move.
let (a, to_a) = crate::connect::locate(doc, sel, &list, *src)?;
let (b, to_b) = crate::connect::locate(doc, sel, &list, *copy)?;
let (a, b) = (xfrm_of(doc, sel, a), xfrm_of(doc, sel, b));
let d = to_b * deckcraft_geom::Point::new(b.x, b.y) - to_a * deckcraft_geom::Point::new(a.x, a.y);
(d.x.is_finite() && d.y.is_finite()).then_some((d.x, d.y))
})
.unwrap_or((off, off));
reid("edit.duplicate", doc, &mut copies)?;
@@ -588,7 +639,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
rename_automatic(c, &mut taken, count);
count += 1;
});
for (c, b) in copies.iter_mut().zip(boxes) {
for ((c, b), to) in copies.iter_mut().zip(boxes).zip(&to_slide) {
let (dx, dy) = to.map_or((dx, dy), |a| offset_in(a, dx, dy));
let mut x = b;
x.x += dx;
x.y += dy;
@@ -599,7 +651,12 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
shapes.extend(copies);
let mut copies = copies.into_iter();
shapes.extend(copies.by_ref().take(top));
// A member's copy goes last among its group's children, as a top-level copy goes last on the slide.
for (c, src) in copies.zip(sources.iter().skip(top)) {
deckcraft_model::sibling_list_mut(shapes, *src).ok_or_else(|| bad("edit.duplicate", "no such shape"))?.push(c);
}
sel.shapes = new_ids.clone();
let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect());
Ok((new_ids, made))
@@ -608,6 +665,34 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": new_ids}))
}
/// The shapes in `ids` that are inside groups, in drawing order, without those in a group that is
/// in `ids` too: they are copied along with it.
fn grouped_targets(list: &[Shape], ids: &[ShapeId]) -> Vec<ShapeId> {
fn rec(shapes: &[Shape], ids: &[ShapeId], nested: bool, out: &mut Vec<ShapeId>, depth: usize) {
if depth > 64 {
return;
}
for sh in shapes {
if !ids.contains(&sh.id) {
rec(sh.children(), ids, true, out, depth + 1);
} else if nested {
out.push(sh.id);
}
}
}
let mut out = vec![];
rec(list, ids, false, &mut out, 0);
out
}
/// The offset in the space `to_slide` maps onto the slide that moves a shape by (`dx`, `dy`) on
/// the slide. A group with no width or height draws nothing, and the offset stays as it is.
fn offset_in(to_slide: deckcraft_geom::Affine, dx: f64, dy: f64) -> (f64, f64) {
let back = to_slide.inverse();
let v = back * deckcraft_geom::Point::new(dx, dy) - back * deckcraft_geom::Point::ZERO;
if v.x.is_finite() && v.y.is_finite() { (v.x, v.y) } else { (dx, dy) }
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.selection.text.is_some() && ids_param(p, "ids").is_none() {
return super::text::delete_dir(s, "forward");
+54 -2
View File
@@ -24,12 +24,24 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge Shapes",
["Shape Format", "Insert Shapes", "Merge Shapes"],
None,
"{op: union|combine|fragment|intersect|subtract, ids?: shapes in selection order (the first one's formatting is kept)} → {ids}",
has_two,
"{op: union|combine|fragment|intersect|subtract, ids?: shapes in selection order (the first one's formatting is kept)} → {ids} (a shape inside a group is refused; ungroup it first)",
can_merge,
merge
)]
}
/// Why Merge Shapes is unavailable while a shape inside a group is selected.
pub(crate) const IN_GROUP: &str = "a shape in a group can't be merged; ungroup it first";
/// Two or more shapes selected, none of them inside a group: the result replaces its sources in
/// the slide's top-level list, so a member would be drawn outside its group's scale and offset.
fn can_merge(s: &Session) -> std::result::Result<(), String> {
has_two(s)?;
let in_group =
s.active().is_some_and(|st| st.selection.shapes.iter().any(|id| st.shape(*id).is_some() && !st.shapes().iter().any(|t| t.id == *id)));
if in_group { Err(IN_GROUP.into()) } else { Ok(()) }
}
/// The filled outline of `s` in slide points (curves flattened). A text box with no fill and no
/// outline is its letters, as in PowerPoint (merging text with a picture fills the letters).
fn area_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Area {
@@ -197,6 +209,10 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let st = s.doc()?;
let shapes: Vec<Shape> = ids.iter().filter_map(|id| st.shape(*id).cloned()).collect();
if let Some(member) = shapes.iter().find(|x| !st.shapes().iter().any(|t| t.id == x.id)) {
let name = if member.name.is_empty() { format!("shape {}", member.id.0) } else { format!("`{}`", member.name) };
return Err(bad("shape.merge", format!("{name} is in a group; ungroup it first to merge it")));
}
let mergeable = |x: &Shape| matches!(x.kind, ShapeKind::Shape | ShapeKind::Picture { .. }) && !x.is_line();
if shapes.len() < 2 || !shapes.iter().all(mergeable) {
return Err(bad("shape.merge", "select two or more shapes (not lines, groups, tables or charts)"));
@@ -379,4 +395,40 @@ mod tests {
assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
}
}
/// Merge Shapes refuses a shape inside a group, naming it, and is unavailable while one is
/// selected. Before, the result went to the slide's top level with the member's box from the
/// group's own space, so the shapes of a moved or resized group were redrawn somewhere else.
#[test]
fn a_shape_in_a_group_is_refused() {
fn rect(s: &mut Session, x: f64) -> u64 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap()
}
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let (a, b, c) = (rect(&mut s, 100.0), rect(&mut s, 150.0), rect(&mut s, 500.0));
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.move", &json!({"ids": [g], "dx": 300})).unwrap();
let before = s.doc().unwrap().current_slide().unwrap().shapes.clone();
let name = s.doc().unwrap().shape(ShapeId(a as u32)).unwrap().name.clone();
for ids in [json!([a, b]), json!([c, a])] {
let err = s.execute("shape.merge", &json!({"op": "union", "ids": ids})).unwrap_err().to_string();
assert!(err.contains(&format!("`{name}` is in a group; ungroup it first")), "{ids}: {err}");
}
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes, before, "a refused merge leaves the slide as it was");
let spec = crate::cmd::find_command("shape.merge").unwrap();
s.execute("edit.select", &json!({"ids": [a, c]})).unwrap();
assert_eq!((spec.enabled)(&s), Err("a shape in a group can't be merged; ungroup it first".to_string()));
assert!(s.execute("shape.merge", &json!({"op": "union"})).is_err());
// Named top-level shapes don't depend on the selection.
let d = rect(&mut s, 550.0);
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
assert_eq!(s.execute("shape.merge", &json!({"op": "union", "ids": [c, d]})).unwrap()["ids"].as_array().map(Vec::len), Some(1));
s.execute("arrange.ungroup", &json!({"ids": [g]})).unwrap();
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
assert_eq!((spec.enabled)(&s), Ok(()));
assert_eq!(s.execute("shape.merge", &json!({"op": "union"})).unwrap()["ids"].as_array().map(Vec::len), Some(1));
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
ed::delete(body, a, b)
} else {
let other = match dir.as_str() {
"forward" => ed::char_move(body, a, true),
"forward" => ed::cluster_end(body, a),
_ => {
// Backspace at the start of an indented/bulleted paragraph outdents first.
if a.1 == 0
+61 -23
View File
@@ -9,7 +9,7 @@
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! `/work`), exactly: on Windows what exists is resolved to its real case first.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
@@ -249,30 +249,19 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
}
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
/// Windows, whose file systems ignore it)?
/// Is `path` (resolved) `root` or inside it, component by component? Exactly, on Windows too: what
/// exists has its real case from [`resolve`], and a folder that doesn't exist yet may be a different
/// one from the root where a parent folder is case sensitive.
fn inside(path: &Path, root: &Path) -> bool {
let mut path = path.components();
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
root.components().all(|r| path.next() == Some(r))
}
fn same_component(a: Component, b: Component) -> bool {
if a == b {
return true;
}
if !cfg!(windows) {
return false;
}
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
_ => false,
}
}
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
/// A Windows device name (`CON`, `NUL`, `COM1`, `COM¹`… also with an extension or a colon), which names no file.
fn is_device_name(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
let stem = component.split(['.', ':']).next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
let numbered =
|prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"));
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
}
@@ -377,6 +366,7 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// A root and a path reached through links are compared after the links are followed.
#[test]
fn links_are_followed_before_comparing() {
let (base, roots) = folders("links");
@@ -413,20 +403,67 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// A name that differs in case is another name until the file system says otherwise.
#[test]
fn names_are_compared_exactly() {
let root = Path::new("/work/In");
assert!(inside(Path::new("/work/In/new/a.deckcraft"), root));
assert!(!inside(Path::new("/work/IN/a.deckcraft"), root));
assert!(!inside(Path::new("/WORK/In/a.deckcraft"), root));
assert!(!inside(Path::new("/work/in\u{212A}/a.deckcraft"), Path::new("/work/ink")), "the Kelvin sign folds to k");
if cfg!(windows) {
let root = Path::new(r"C:\work\In");
assert!(inside(Path::new(r"c:\work\In\a.deckcraft"), root), "a drive letter is one drive in either case");
assert!(!inside(Path::new(r"C:\work\IN\a.deckcraft"), root));
}
}
/// In a case-sensitive folder, `IN` beside the root `in` is another folder, there or not yet.
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
fn a_case_sensitive_folder_keeps_a_differently_cased_name_out() {
let base = std::env::temp_dir().join(format!("dc-file-access-case-sensitive-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let set = std::process::Command::new("fsutil").args(["file", "setCaseSensitiveInfo"]).arg(&base).arg("enable").output();
if !set.as_ref().is_ok_and(|o| o.status.success()) {
eprintln!("skipped: a case-sensitive folder can't be made here: {set:?}");
let _ = std::fs::remove_dir_all(base);
return;
}
std::fs::create_dir(base.join("in")).unwrap();
let root = [base.join("in")];
let roots = AutomationRoots::new(&root, &root).unwrap().unwrap();
assert_eq!(roots.check(&s(base.join("in/new.deckcraft")), Access::Write), Ok(()));
let e = roots.check(&s(base.join("IN/new.deckcraft")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "{e}");
std::fs::create_dir(base.join("IN")).unwrap();
std::fs::write(base.join("IN/secret.deckcraft"), "x").unwrap();
let e = roots.check(&s(base.join("IN/secret.deckcraft")), Access::Read).unwrap_err();
assert!(e.contains("outside the read root"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
/// On Windows a path finds its real case on disk, and device names are refused.
#[test]
fn windows_finds_the_real_case_and_refuses_devices() {
let (base, roots) = folders("case");
let upper = s(base.join("IN/a.deckcraft"));
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
assert_eq!(roots.check(&s(base.join("In/Sub/New.deckcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("OUT/New.deckcraft")), Access::Write), Ok(()));
assert_eq!(roots.check(&s(base.join("OUT")), Access::Write), Ok(()));
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
assert!(e.contains("reserved device names"), "{e}");
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
let e = roots.check(&s(base.join("out/COM¹.txt")), Access::Write).unwrap_err();
assert!(e.contains("reserved device names"), "{e}");
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
/// Scopes nest, and a scope applies only on the thread that set it.
#[test]
fn scopes_nest_and_stay_on_their_thread() {
let (base, roots) = folders("scopes");
@@ -448,12 +485,13 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// What is a device name, with superscript digits and a name before a colon, and what isn't.
#[test]
fn device_names() {
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL "] {
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL ", "COM¹", "Lpt²", "lpt³.txt", "COM1:", "nul:x", "COM¹:"] {
assert!(is_device_name(d), "{d}");
}
for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x"] {
for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x", "COM⁴", "com¹0", "lpt¹x", "console:x"] {
assert!(!is_device_name(f), "{f}");
}
}
+3 -1
View File
@@ -646,7 +646,9 @@ impl Session {
self.settle_composition();
}
if let Err(e) = (spec.enabled)(self) {
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
// A command told which shapes to act on doesn't depend on what is selected.
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO || e == cmd::merge::IN_GROUP)
&& (params.get("ids").is_some() || params.get("id").is_some());
if !named {
return Err(EngineError::Disabled(id.into(), e));
}
+300
View File
@@ -98,6 +98,33 @@ fn text_editing_flow() {
assert_eq!(s.doc().unwrap().selection.shapes.len(), 1);
}
/// Forward Delete removes a whole grapheme cluster, not just its first scalar.
#[test]
fn forward_delete_removes_whole_grapheme_cluster() {
for (input, rest) in [("\u{1F44D}\u{1F3FD}y", "y"), ("e\u{301}x", "x")] {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
s.execute("text.insert", &json!({"text": input})).unwrap();
s.execute("text.move", &json!({"to": "start"})).unwrap();
s.execute("text.delete", &json!({"dir": "forward"})).unwrap();
let t = s.execute("text.get", &json!({"id": title})).unwrap();
assert_eq!(t["text"], rest);
}
// Inside a cluster, Delete stops at that cluster's end and leaves the next flag intact.
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
s.execute("text.insert", &json!({"text": "\u{1F1FA}\u{1F1F8}\u{1F1E8}\u{1F1E6}"})).unwrap();
s.execute("text.move", &json!({"to": "start"})).unwrap();
s.execute("text.move", &json!({"to": "right"})).unwrap();
s.execute("text.delete", &json!({"dir": "forward"})).unwrap();
let t = s.execute("text.get", &json!({"id": title})).unwrap();
assert_eq!(t["text"], "\u{1F1FA}\u{1F1E8}\u{1F1E6}");
}
/// `text.set` treats CRLF (or a lone CR) as one paragraph break and keeps no `\r` in the text.
#[test]
fn text_set_crlf_makes_clean_paragraphs() {
@@ -214,6 +241,71 @@ fn clipboard_copy_paste_shapes_and_slides() {
assert!(s.doc().unwrap().doc.validate().is_empty());
}
/// A shape copied or cut from inside a group pastes where it was drawn: on another slide in the
/// same place, on its own slide offset as any paste there is. Its box is mapped out through each
/// group around it, however the groups were moved, stretched, turned or flipped. Before, the
/// clipboard kept the box inside the group, so the shape pasted where it had been before the group
/// was moved or stretched.
#[test]
fn a_shape_copied_from_a_group_pastes_where_it_was_drawn() {
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
}
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
let sites = |s: &Session, id: ShapeId| {
let st = s.doc().unwrap();
connect::sites_of(&st.doc, &st.selection, st.shapes(), id).unwrap()
};
type Edits<'a> = &'a [(&'a str, Value)];
let edit = |s: &mut Session, id: u64, edits: Edits<'_>| {
for (cmd, p) in edits {
let mut p = p.clone();
p["ids"] = json!([id]);
s.execute(cmd, &p).unwrap();
}
};
// Edits to the shape's group, then to a group around that one (none: no outer group).
let cases: [(Edits<'_>, Edits<'_>); 4] = [
(&[("shape.move", json!({"dx": 200}))], &[]),
(&[("shape.resize", json!({"w": 600}))], &[]),
(&[("shape.rotate", json!({"deg": 30})), ("shape.flip", json!({"axis": "vertical"})), ("shape.move", json!({"dx": 50, "dy": 20}))], &[]),
(&[("shape.move", json!({"dx": 200}))], &[("shape.resize", json!({"w": 450})), ("shape.move", json!({"dy": 60}))]),
];
let off = session().prefs.duplicate_offset;
for (n, (inner_edits, outer_edits)) in cases.into_iter().enumerate() {
for (cmd, same_slide) in [("edit.copy", false), ("edit.cut", false), ("edit.copy", true)] {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0));
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
edit(&mut s, g, inner_edits);
if !outer_edits.is_empty() {
let c = rect(&mut s, 100.0, 300.0);
let outer = s.execute("arrange.group", &json!({"ids": [g, c]})).unwrap()["id"].as_u64().unwrap();
edit(&mut s, outer, outer_edits);
}
let was = sites(&s, sid(a));
s.doc_mut().unwrap().selection.shapes = vec![sid(a)];
s.execute(cmd, &json!({})).unwrap();
assert_eq!(s.doc().unwrap().shape(sid(a)).is_some(), cmd == "edit.copy", "case {n}: {cmd}");
if !same_slide {
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
}
let r = s.execute("edit.paste", &json!({})).unwrap();
let pasted = sid(r["ids"][0].as_u64().unwrap());
assert!(s.doc().unwrap().shapes().iter().any(|x| x.id == pasted), "case {n}: {cmd} pastes on the top level");
let d = if same_slide { off } else { 0.0 };
let now = sites(&s, pasted);
assert_eq!(was.len(), now.len(), "case {n}: {cmd}");
assert!(
was.iter().zip(&now).all(|(p, q)| (q.x - p.x - d).hypot(q.y - p.y - d) < 1e-6),
"case {n}: {cmd} on the same slide {same_slide}: drawn at {was:?}, pasted at {now:?}"
);
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
}
}
}
/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font,
/// colour, bold, bullet, indent, alignment, and the box's insets and anchor.
fn resolved_look(s: &Session, id: ShapeId) -> String {
@@ -885,6 +977,154 @@ fn group_ungroup_roundtrip_positions() {
assert!((v["box"]["x"].as_f64().unwrap() - 310.0).abs() < 1e-6, "{v}");
}
/// Ungrouping a group inside another group frees its shapes into the outer group, in its place
/// in the outer group's order, drawn where they were, and selects them. Before, arrange.ungroup
/// looked only at the slide's top level: it answered `{ids: []}` and changed nothing.
#[test]
fn ungrouping_a_nested_group_frees_its_shapes_into_the_outer_group() {
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
}
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
let sites = |s: &Session, id: u64| {
let st = s.doc().unwrap();
connect::sites_of(&st.doc, &st.selection, st.shapes(), sid(id)).unwrap()
};
// The outer group moved and stretched; then an inner group turned and flipped, inside an outer
// group that is turned and moved.
type Edits<'a> = &'a [(&'a str, Value)];
let cases: [(Edits<'_>, Edits<'_>); 2] = [
(&[], &[("shape.move", json!({"dx": 200})), ("shape.resize", json!({"w": 450}))]),
(
&[("shape.rotate", json!({"deg": 30})), ("shape.flip", json!({"axis": "horizontal"}))],
&[("shape.rotate", json!({"deg": 45})), ("shape.move", json!({"dx": 120, "dy": 40}))],
),
];
let edit = |s: &mut Session, id: u64, edits: Edits<'_>| {
for (cmd, p) in edits {
let mut p = p.clone();
p["ids"] = json!([id]);
s.execute(cmd, &p).unwrap();
}
};
for (n, (inner_edits, outer_edits)) in cases.into_iter().enumerate() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let (a, b, c) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0), rect(&mut s, 100.0, 300.0));
let inner = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
edit(&mut s, inner, inner_edits);
let outer = s.execute("arrange.group", &json!({"ids": [inner, c]})).unwrap()["id"].as_u64().unwrap();
edit(&mut s, outer, outer_edits);
let before = [sites(&s, a), sites(&s, b), sites(&s, c)];
let r = s.execute("arrange.ungroup", &json!({"ids": [inner]})).unwrap();
assert_eq!(r["ids"], json!([a, b]), "case {n}");
assert_eq!(s.doc().unwrap().selection.shapes, [sid(a), sid(b)]);
let st = s.doc().unwrap();
let order: Vec<ShapeId> = deckcraft_model::find_shape(st.shapes(), sid(outer)).unwrap().children().iter().map(|x| x.id).collect();
assert_eq!(order, [sid(a), sid(b), sid(c)], "case {n}");
for (id, was) in [a, b, c].into_iter().zip(before) {
let now = sites(&s, id);
assert_eq!(was.len(), now.len(), "case {n}: shape {id}");
assert!(was.iter().zip(&now).all(|(p, q)| (*p - *q).hypot() < 1e-6), "case {n}: shape {id} moved from {was:?} to {now:?}");
}
}
// Two named groups, one inside the other, inside a third: the shapes of both go to the third,
// each listed once.
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let (a, b, c, d) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0), rect(&mut s, 100.0, 300.0), rect(&mut s, 300.0, 300.0));
let inner = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
let middle = s.execute("arrange.group", &json!({"ids": [inner, c]})).unwrap()["id"].as_u64().unwrap();
let outer = s.execute("arrange.group", &json!({"ids": [middle, d]})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.resize", &json!({"ids": [outer], "w": 600})).unwrap();
let before = [a, b, c, d].map(|id| sites(&s, id));
let r = s.execute("arrange.ungroup", &json!({"ids": [inner, middle]})).unwrap();
assert_eq!(r["ids"], json!([a, b, c]));
assert_eq!(s.doc().unwrap().selection.shapes, [sid(a), sid(b), sid(c)]);
let st = s.doc().unwrap();
let order: Vec<ShapeId> = deckcraft_model::find_shape(st.shapes(), sid(outer)).unwrap().children().iter().map(|x| x.id).collect();
assert_eq!(order, [sid(a), sid(b), sid(c), sid(d)]);
for (id, was) in [a, b, c, d].into_iter().zip(before) {
let now = sites(&s, id);
assert_eq!(was.len(), now.len(), "shape {id}");
assert!(was.iter().zip(&now).all(|(p, q)| (*p - *q).hypot() < 1e-6), "shape {id} moved from {was:?} to {now:?}");
}
}
/// Ungrouping nested groups in one call returns each freed shape once, whatever order the groups
/// are named in. Before, a group freed into a group that the same call freed next was listed twice
/// and replaced once, so the call returned the id of a group that no longer existed.
#[test]
fn ungrouping_nested_groups_in_any_order_returns_only_freed_shapes() {
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
}
fn group(s: &mut Session, ids: [u64; 2]) -> u64 {
s.execute("arrange.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap()
}
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let [a, b, c, d, e] = [(100.0, 100.0), (300.0, 100.0), (100.0, 300.0), (300.0, 300.0), (500.0, 300.0)].map(|(x, y)| rect(&mut s, x, y));
let inner = group(&mut s, [a, b]);
let middle = group(&mut s, [inner, c]);
let upper = group(&mut s, [middle, d]);
let outer = group(&mut s, [upper, e]);
let r = s.execute("arrange.ungroup", &json!({"ids": [middle, upper, inner]})).unwrap();
assert_eq!(r["ids"], json!([a, b, c, d]));
assert_eq!(s.doc().unwrap().selection.shapes, [sid(a), sid(b), sid(c), sid(d)]);
let st = s.doc().unwrap();
let order: Vec<ShapeId> = deckcraft_model::find_shape(st.shapes(), sid(outer)).unwrap().children().iter().map(|x| x.id).collect();
assert_eq!(order, [sid(a), sid(b), sid(c), sid(d), sid(e)]);
}
/// Ungrouping a top-level group and a group inside it in one call frees the shapes of both onto
/// the slide, in the top-level group's place and drawn where they were, whichever is named first.
#[test]
fn ungrouping_a_top_level_group_with_a_group_inside_it_frees_both() {
fn rect(s: &mut Session, x: f64, y: f64) -> u64 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 100, 100]})).unwrap()["id"].as_u64().unwrap()
}
fn group(s: &mut Session, ids: [u64; 2]) -> u64 {
s.execute("arrange.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap()
}
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
let sites = |s: &Session, id: u64| {
let st = s.doc().unwrap();
connect::sites_of(&st.doc, &st.selection, st.shapes(), sid(id)).unwrap()
};
let roots = |s: &Session| -> Vec<ShapeId> { s.doc().unwrap().shapes().iter().map(|x| x.id).collect() };
for outer_first in [true, false] {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let [a, b, c, z] = [(100.0, 100.0), (300.0, 100.0), (100.0, 300.0), (500.0, 500.0)].map(|(x, y)| rect(&mut s, x, y));
let inner = group(&mut s, [a, b]);
let outer = group(&mut s, [inner, c]);
s.execute("shape.resize", &json!({"ids": [outer], "w": 600})).unwrap();
let before = [a, b, c].map(|id| sites(&s, id));
let freed = [sid(a), sid(b), sid(c)];
let mut expected = vec![];
for x in roots(&s) {
if x == sid(outer) {
expected.extend(freed);
} else {
expected.push(x);
}
}
assert!(expected.contains(&sid(z)));
let ids = if outer_first { [outer, inner] } else { [inner, outer] };
let r = s.execute("arrange.ungroup", &json!({"ids": ids})).unwrap();
assert_eq!(r["ids"], json!([a, b, c]), "{ids:?}");
assert_eq!(s.doc().unwrap().selection.shapes, freed, "{ids:?}");
assert_eq!(roots(&s), expected, "{ids:?}");
for (id, was) in [a, b, c].into_iter().zip(before) {
let now = sites(&s, id);
assert_eq!(was.len(), now.len(), "{ids:?}: shape {id}");
assert!(was.iter().zip(&now).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{ids:?}: shape {id} moved from {was:?} to {now:?}");
}
}
}
/// Grouping a group again and again stops at `MAX_GROUP_DEPTH`: past about 39 levels a saved
/// .deckcraft file no longer opens, and past 64 commands such as format.languageAll miss the text.
#[test]
@@ -6594,6 +6834,66 @@ fn duplicating_a_moved_copy_repeats_the_move() {
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
}
/// Duplicating a shape inside a group puts the copy in that group, last among its children, and
/// selects it. The copy is offset on the slide as a top-level copy is, however the group is moved,
/// stretched or turned, and duplicating a moved copy repeats the move (#966). A shape whose group is
/// duplicated as well goes along with the group's copy. Before, Duplicate looked only at the slide's
/// top level: it answered `{ids: []}` and changed nothing.
#[test]
fn duplicating_a_shape_in_a_group_copies_it_into_the_group() {
fn rect(s: &mut Session, x: f64) -> u64 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap()
}
let sid = |id: u64| ShapeId(u32::try_from(id).unwrap());
// How far shape `to` is drawn from shape `from` on the slide, the same for each of its sites.
let offset = |s: &Session, from: u64, to: u64| {
let st = s.doc().unwrap();
let sites = |id| connect::sites_of(&st.doc, &st.selection, st.shapes(), sid(id)).unwrap();
let (p, q) = (sites(from), sites(to));
assert_eq!(p.len(), q.len(), "{p:?} {q:?}");
let d = q[0] - p[0];
assert!(p.iter().zip(&q).all(|(p, q)| (*q - *p - d).hypot() < 1e-6), "{p:?} {q:?}");
(d.x, d.y)
};
let grouped = |edit: &str, p: Value| {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 300.0));
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
let mut p = p;
p["ids"] = json!([g]);
s.execute(edit, &p).unwrap();
(s, a, b, g)
};
for (edit, p) in [("shape.move", json!({"dx": 200})), ("shape.resize", json!({"w": 600})), ("shape.rotate", json!({"deg": 90}))] {
let (mut s, a, b, g) = grouped(edit, p);
let off = s.prefs.duplicate_offset;
let r = s.execute("edit.duplicate", &json!({"ids": [a]})).unwrap();
assert_eq!(r["ids"].as_array().map(Vec::len), Some(1), "{edit}: {r}");
let c = r["ids"][0].as_u64().unwrap();
assert_eq!(s.doc().unwrap().selection.shapes, [sid(c)]);
let st = s.doc().unwrap();
let order: Vec<ShapeId> = deckcraft_model::find_shape(st.shapes(), sid(g)).unwrap().children().iter().map(|x| x.id).collect();
assert_eq!(order, [sid(a), sid(b), sid(c)], "{edit}");
let (dx, dy) = offset(&s, a, c);
assert!((dx - off).abs() < 1e-6 && (dy - off).abs() < 1e-6, "{edit}: the copy is drawn ({dx}, {dy}) from the original");
// Moved, then duplicated again: the next copy is drawn as far from it again.
s.execute("shape.move", &json!({"ids": [c], "dx": 30})).unwrap();
let (mx, my) = offset(&s, a, c);
let d = s.execute("edit.duplicate", &json!({"ids": [c]})).unwrap()["ids"][0].as_u64().unwrap();
let (nx, ny) = offset(&s, c, d);
assert!((nx - mx).abs() < 1e-6 && (ny - my).abs() < 1e-6, "{edit}: moved ({mx}, {my}), then ({nx}, {ny})");
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
}
let (mut s, a, _, g) = grouped("shape.move", json!({"dx": 200}));
let r = s.execute("edit.duplicate", &json!({"ids": [a, g]})).unwrap();
assert_eq!(r["ids"].as_array().map(Vec::len), Some(1), "{r}");
let st = s.doc().unwrap();
let count = |id: u64| deckcraft_model::find_shape(st.shapes(), sid(id)).unwrap().children().len();
assert_eq!((count(g), count(r["ids"][0].as_u64().unwrap())), (2, 2));
assert_eq!(st.shapes().len(), 2);
}
fn chart_of(s: &Session, id: u64) -> deckcraft_model::Chart {
match &s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().kind {
deckcraft_model::ShapeKind::Chart(c) => (**c).clone(),
+16
View File
@@ -3,6 +3,7 @@
use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody};
use crate::words;
use unicode_segmentation::UnicodeSegmentation;
/// (paragraph, char offset in the paragraph).
pub type Pos = (usize, usize);
@@ -419,6 +420,21 @@ fn smart_space(body: &mut TextBody, at: Pos) -> Pos {
at
}
/// The position after the grapheme cluster at `pos`, or the next paragraph's start at a paragraph end.
pub fn cluster_end(body: &TextBody, pos: Pos) -> Pos {
let (pi, ch) = clamp(body, pos);
let text = body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default();
// Boundaries come from the whole paragraph, so a caret inside a cluster deletes only to its end.
let mut end = 0;
for g in text.graphemes(true) {
end += g.chars().count();
if end > ch {
return (pi, end);
}
}
char_move(body, pos, true)
}
/// One character left/right across paragraph boundaries.
pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
let (pi, ch) = clamp(body, pos);
+24 -6
View File
@@ -554,12 +554,15 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
import_checked(bytes).map(|(p, _)| p)
}
/// [`import`], plus the parts of the slide list the file names but that couldn't be read (a
/// missing or truncated slide): those slides are left out, so a caller can say so (#203).
/// [`import`], plus the slides the file's slide list names but that couldn't be read (a missing
/// or truncated slide part, or an entry whose relationship is missing): those slides are left
/// out, so a caller can say so (#203).
pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxError> {
import_with_chart_points(bytes, chart::MAX_PRESENTATION_CHART_POINTS)
}
const MAX_SLIDE_ENTRIES: usize = 10_000;
/// [`import_checked`] with the presentation's chart point budget set (tests lower it).
pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) -> Result<(Presentation, Vec<String>), PptxError> {
let mut lost_slides: Vec<String> = vec![];
@@ -571,9 +574,15 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) ->
if !pres_el.is("presentation") {
return Err(PptxError::NotPptx(format!("{pres_path} is not a presentation part")));
}
// Refuse an oversized list before reading slide parts: truncating it would look like a
// complete import and allow a later save to overwrite the omitted slides (#1910).
// Looking only for the first entry past the cap keeps this check bounded.
if pres_el.child("sldIdLst").is_some_and(|list| list.children_named("sldId").nth(MAX_SLIDE_ENTRIES).is_some()) {
return Err(PptxError::Unsupported(format!("PPTX slide list exceeds the {MAX_SLIDE_ENTRIES}-entry import limit")));
}
let pres_part = Part::new(&pkg, &pres_path, &pres_doc);
let (master_paths, slide_entries) = master_and_slide_paths(pres_el, &pres_part);
let (master_paths, slide_entries) = master_and_slide_paths(pres_el, &pres_part, &mut lost_slides);
let (docs, max_id) = load_part_docs(&pkg, &master_paths, &slide_entries);
let mut imp = Imp {
@@ -630,7 +639,7 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) ->
/// Masters, layouts and slides to read, in file order and without duplicates: (master paths,
/// (file slide id, slide path)).
fn master_and_slide_paths(pres_el: &El, pres_part: &Part) -> (Vec<String>, Vec<(u32, String)>) {
fn master_and_slide_paths(pres_el: &El, pres_part: &Part, lost_slides: &mut Vec<String>) -> (Vec<String>, Vec<(u32, String)>) {
let master_paths: Vec<String> = pres_el
.child("sldMasterIdLst")
.map(|l| l.children_named("sldMasterId").filter_map(|m| pres_part.rels.target(m.attr("r:id").unwrap_or(""))).map(String::from).collect())
@@ -641,8 +650,17 @@ fn master_and_slide_paths(pres_el: &El, pres_part: &Part) -> (Vec<String>, Vec<(
.child("sldIdLst")
.map(|l| {
l.children_named("sldId")
.take(10_000)
.filter_map(|s| Some((s.u32("id").unwrap_or(0), pres_part.rels.target(s.attr("r:id").unwrap_or(""))?.to_string())))
.take(MAX_SLIDE_ENTRIES)
.filter_map(|s| {
let rid = s.attr("r:id").unwrap_or("");
let Some(path) = pres_part.rels.target(rid) else {
// A missing relationship still represents a lost slide. Report it so
// the engine opens a repaired copy instead of overwriting the original.
lost_slides.push(format!("slide {} (missing relationship {rid})", s.u32("id").unwrap_or(0)));
return None;
};
Some((s.u32("id").unwrap_or(0), path.to_string()))
})
.collect()
})
.unwrap_or_default();
+4 -1
View File
@@ -389,8 +389,11 @@ fn read_series(s: &El, g: &El, group: (ChartType, bool, Option<bool>), categorie
secondary_axis: secondary,
..Default::default()
};
// `<c:dLbls><c:delete val="1"/>` hides every label of the series, whatever the group says.
if s.path(&["dLbls", "delete"]).map(|v| v.bool("val").unwrap_or(true)) == Some(true) {
ser.data_labels = Some(false);
// `<c:showVal/>` without a val attribute means true, as in the schema's default.
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) {
} else if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) {
ser.data_labels = Some(d);
} else if let Some(false) = group_labels {
// The group asks for no labels and the series does not say otherwise.
+31 -14
View File
@@ -126,7 +126,7 @@ fn flat_starts(anims: &[&Animation]) -> Vec<u32> {
/// a click group listed out of time order, whose flat reading disagrees with the file, is sorted by
/// start time and its starts and delays rewritten to land every effect where PowerPoint plays it.
/// Every other group is left untouched.
fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Option<u32>, usize, u32)]) {
fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool, usize, u32)], at: &[(Option<u32>, usize, u32)]) {
let mut groups: Vec<Vec<usize>> = vec![];
let mut slot: std::collections::HashMap<(Option<u32>, usize), usize> = std::collections::HashMap::new();
for (i, &(t, g, _)) in at.iter().enumerate() {
@@ -155,7 +155,7 @@ fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Opt
let had_click = out[idx[0]].0.start == AnimStart::OnClick;
let mut order: Vec<usize> = (0..idx.len()).collect();
order.sort_by_key(|&k| abs[k]);
let mut sorted: Vec<(Animation, u32, String, bool)> = order.iter().map(|&k| out[idx[k]].clone()).collect();
let mut sorted: Vec<(Animation, u32, String, bool, usize, u32)> = order.iter().map(|&k| out[idx[k]].clone()).collect();
let (mut begin, mut end) = (0u32, 0u32);
for (n, (&k, item)) in order.iter().zip(sorted.iter_mut()).enumerate() {
let a = &mut item.0;
@@ -170,6 +170,7 @@ fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Opt
a.start = AnimStart::WithPrevious;
a.delay_ms = t.saturating_sub(begin);
}
item.5 = 0;
end = end.max(t.saturating_add(span_ms(a)));
}
for (&i, item) in idx.iter().zip(sorted) {
@@ -239,17 +240,19 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
let Some(tn) = timing.child("tnLst") else { return vec![] };
let mut found = vec![];
collect(tn, At { trigger: None, group: 0, offset: 0 }, &mut 0, &mut found, 0);
// Paragraph builds (`bldP build="p"`) by (spid, grpId).
let by_para: Vec<(u32, String)> = timing
// Paragraph builds (`bldP`) by (spid, grpId) -> whether `build="p"`.
let bld_p: std::collections::HashMap<(u32, String), bool> = timing
.child("bldLst")
.map(|b| {
b.children_named("bldP")
.filter(|p| p.attr("build") == Some("p"))
.filter_map(|p| Some((p.u32("spid")?, p.attr("grpId").unwrap_or("0").to_string())))
.collect()
.filter_map(|p| Some(((p.u32("spid")?, p.attr("grpId").unwrap_or("0").to_string()), p.attr("build") == Some("p"))))
.fold(std::collections::HashMap::new(), |mut m, (k, v)| {
m.entry(k).and_modify(|e| *e |= v).or_insert(v);
m
})
})
.unwrap_or_default();
let mut out: Vec<(Animation, u32, String, bool)> = vec![];
let mut out: Vec<(Animation, u32, String, bool, usize, u32)> = vec![];
let mut at: Vec<(Option<u32>, usize, u32)> = vec![];
for f in found {
let c = f.ctn;
@@ -314,23 +317,27 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
a.color = dml::color(clr);
}
let grp = c.attr("grpId").unwrap_or("0").to_string();
let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp);
out.push((a, file_id, grp, build));
let build = paragraph.is_some() && bld_p.get(&(file_id, grp.clone())) != Some(&false);
out.push((a, file_id, grp, build, f.group, f.offset));
at.push((f.trigger, f.group, f.offset));
}
place_by_parent_delays(&mut out, &at);
// A paragraph build's effects, one per paragraph, become one by-paragraph animation only when
// the timeline plays that back exactly: PowerPoint lets each paragraph keep its own start and
// effect, and other shapes' effects can sit between them (#844).
let same_build =
|x: &(Animation, u32, String, bool), y: &(Animation, u32, String, bool)| x.3 && y.3 && x.1 == y.1 && x.2 == y.2 && x.0.trigger == y.0.trigger;
let same_build = |x: &(Animation, u32, String, bool, usize, u32), y: &(Animation, u32, String, bool, usize, u32)| {
x.3 && y.3 && x.1 == y.1 && x.2 == y.2 && x.0.trigger == y.0.trigger
};
let mut anims = Vec::with_capacity(out.len());
for run in out.chunk_by(same_build) {
let effects: Vec<&Animation> = run.iter().map(|(a, ..)| a).collect();
let offsets: Vec<u32> = run.iter().map(|r| r.5).collect();
let build = run.first().is_some_and(|r| r.3);
let explicit_bld = run.first().is_some_and(|r| bld_p.get(&(r.1, r.2.clone())) == Some(&true));
let same_click_group = run.first().is_some_and(|first| first.0.start == AnimStart::OnClick || run.iter().all(|r| r.4 == first.4));
let text = effects.first().and_then(|a| find_shape(shapes, a.shape)).and_then(|s| s.text.as_ref());
match (effects.first(), text) {
(Some(first), Some(text)) if build && plays_as_build(&effects, text) => {
(Some(first), Some(text)) if build && (explicit_bld || same_click_group) && plays_as_build(&effects, &offsets, text, explicit_bld) => {
anims.push(Animation { text_build: TextBuild::ByParagraph, paragraph: None, ..(*first).clone() });
}
_ => anims.extend(effects.into_iter().cloned()),
@@ -343,7 +350,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
/// first one plays: one effect per paragraph the build animates, in paragraph order, later top
/// paragraphs on click (after previous when the build doesn't start on click), deeper ones with
/// their parent, all with the same settings?
fn plays_as_build(effects: &[&Animation], text: &TextBody) -> bool {
fn plays_as_build(effects: &[&Animation], offsets: &[u32], text: &TextBody, explicit_bld: bool) -> bool {
let roles = paragraph_build_roles(text);
let expected = roles.iter().enumerate().filter(|(i, r)| match r {
BuildRole::Top => true,
@@ -351,6 +358,16 @@ fn plays_as_build(effects: &[&Animation], text: &TextBody) -> bool {
BuildRole::Unowned => false,
});
let Some(first) = effects.first() else { return false };
let off0 = offsets.first().copied().unwrap_or(0);
let flat = flat_starts(effects);
let parent_starts_match = offsets
.iter()
.zip(effects.iter())
.zip(flat.iter())
.all(|((&off, a), &ft)| off == off0 || off.saturating_add(a.delay_ms) == off0.saturating_add(ft));
if !explicit_bld && (!parent_starts_match || (first.start != AnimStart::OnClick && (span_ms(first) != 500 || first.delay_ms != 0 || off0 != 0))) {
return false;
}
let settings = |a: &Animation| Animation { start: AnimStart::OnClick, paragraph: None, ..a.clone() };
let base = settings(first);
let mut matched = 0;
+5 -5
View File
@@ -794,11 +794,11 @@ fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) -
}
w.close("p:sldLayoutIdLst");
w.open0("p:txStyles");
let (dt, db, dother) = defaults::master_styles();
let pick = |s: &deckcraft_model::text::ListStyle, d: deckcraft_model::text::ListStyle| if s.is_empty() { d } else { s.clone() };
dml::list_style(&mut w, x, o, "p:titleStyle", &pick(&m.title_style, dt));
dml::list_style(&mut w, x, o, "p:bodyStyle", &pick(&m.body_style, db));
dml::list_style(&mut w, x, o, "p:otherStyle", &pick(&m.other_style, dother));
// An imported empty body style has no bullets; saving must not author defaults into it.
// New decks already carry their populated master styles in the model.
dml::list_style(&mut w, x, o, "p:titleStyle", &m.title_style);
dml::list_style(&mut w, x, o, "p:bodyStyle", &m.body_style);
dml::list_style(&mut w, x, o, "p:otherStyle", &m.other_style);
w.close("p:txStyles");
ext_list(&mut w, ext_children(m.raw_ext.as_deref()).as_deref());
w.close("p:sldMaster");
+23 -3
View File
@@ -106,7 +106,9 @@ pub fn geometry(w: &mut W, g: &Geom) {
let single = !adj1 && if defaults.is_empty() { vals.len() == 1 } else { defaults.len() == 1 };
for (i, v) in vals.iter().enumerate() {
let n = if single && i == 0 { "adj".to_string() } else { format!("adj{}", i + 1) };
let v = if v.is_finite() { v.round().clamp(-2.0e9, 2.0e9) as i64 } else { 0 };
// A near-zero-width connector can need a large ratio for an ordinary-size bend.
// Keep guide literals bounded to the signed integer serialization domain.
let v = if v.is_finite() { v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64 } else { 0 };
w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}")));
}
for (n, v) in extra_guides(name) {
@@ -303,7 +305,8 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
w.close("p:nvPicPr");
dml::blip_fill(w, x, o, "p:blipFill", fill);
let mut s2 = s.clone();
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
// Group fill is a separate backing behind transparent image pixels.
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background)) {
s2.fill = None;
}
sp_pr(w, x, o, &s2, true);
@@ -940,7 +943,7 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i
// As for any picture, a colour or pattern fill shows behind the image (an object's preview
// keeps its fallback picture's fill).
let mut s2 = s.clone();
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background)) {
s2.fill = None;
}
sp_pr(w, x, o, &s2, true);
@@ -1031,6 +1034,23 @@ pub fn tree_header(w: &mut W) {
mod tests {
use super::*;
#[test]
fn guide_literals_remain_finite_bounded_integers() {
for (guide, expected) in [
(f64::NAN, 0),
(f64::INFINITY, 0),
(f64::NEG_INFINITY, 0),
(f64::MAX, i64::MAX),
(f64::MIN, i64::MIN),
(12500.6, 12501),
(-12500.6, -12501),
] {
let mut w = W::frag();
geometry(&mut w, &Geom::Preset { name: "bentConnector3".into(), adj: vec![guide] });
assert!(w.s.contains(&format!(r#"fmla="val {expected}""#)), "{}", w.s);
}
}
/// PowerPoint asks to repair a `star5` whose guide list lacks `hf`/`vf` (found black-box).
#[test]
fn preset_guides_are_complete() {
@@ -0,0 +1,73 @@
//! A series-level `c:dLbls/c:delete val="1"` hides that series' labels although the
//! chart-wide `showVal` is on (#1977).
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_geom::Xfrm;
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, ShapeKind, defaults};
fn bar_chart_pptx() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
p.slides.clear();
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
let mut slide = defaults::new_slide(&mut p, layout).expect("slide");
let mut shape =
Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Bars".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() };
let mut chart = Chart::sample(ChartType::Column);
chart.data_labels = false;
for s in &mut chart.series {
s.data_labels = None;
}
shape.kind = ShapeKind::Chart(Box::new(chart));
slide.shapes.push(shape);
p.slides.push(Arc::new(slide));
deckcraft_pptx::export(&p).expect("export")
}
fn reimport(bytes: &[u8], f: impl Fn(&str) -> String) -> Presentation {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
for i in 0..zip.len() {
let mut entry = zip.by_index(i).expect("entry");
let name = entry.name().to_string();
let mut data = vec![];
entry.read_to_end(&mut data).expect("read");
if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") && !name.contains("_rels") {
let xml = String::from_utf8(data).expect("chart xml");
data = f(&xml).into_bytes();
}
out.start_file(&name, opts).expect("start");
out.write_all(&data).expect("write");
}
let patched = out.finish().expect("finish").into_inner();
deckcraft_pptx::import(&patched).expect("import")
}
fn chart_of(p: &Presentation) -> &Chart {
let s = p.slides.first().expect("slide");
for sh in &s.shapes {
if let ShapeKind::Chart(c) = &sh.kind {
return c;
}
}
panic!("no chart in the deck");
}
#[test]
fn series_dlbls_delete_hides_that_series_labels() {
let p = reimport(&bar_chart_pptx(), |xml| {
xml.replacen("<c:cat>", r#"<c:dLbls><c:delete val="1"/></c:dLbls><c:cat>"#, 1).replacen(
"<c:gapWidth",
r#"<c:dLbls><c:showVal val="1"/></c:dLbls><c:gapWidth"#,
1,
)
});
let c = chart_of(&p);
assert!(c.data_labels, "the group-level showVal turns labels on");
assert_eq!(c.series[0].data_labels, Some(false), "the deleted series has no labels");
assert!(!c.series_data_labels(&c.series[0]), "so its labels are not drawn");
assert!(c.series_data_labels(&c.series[1]), "the next series shows the chart-wide labels");
}
+96
View File
@@ -316,3 +316,99 @@ fn left_of(p: &Presentation) -> ShapeId {
fn right_of(p: &Presentation) -> ShapeId {
by_name(p, "Right").id
}
/// Return a copy of the `.pptx` archive `bytes` with `entry_name` rewritten by `f`.
fn rewrite_zip_entry(bytes: &[u8], entry_name: &str, f: impl Fn(&str) -> String) -> Vec<u8> {
use std::io::{Cursor, Read, Write};
let mut r = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = Cursor::new(Vec::new());
{
let mut w = zip::ZipWriter::new(&mut out);
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for i in 0..r.len() {
let mut file = r.by_index(i).unwrap();
let name = file.name().to_string();
let mut buf = Vec::new();
file.read_to_end(&mut buf).unwrap();
w.start_file(&name, opts).unwrap();
if name == entry_name {
let text = String::from_utf8(buf).unwrap();
w.write_all(f(&text).as_bytes()).unwrap();
} else {
w.write_all(&buf).unwrap();
}
}
w.finish().unwrap();
}
out.into_inner()
}
#[test]
fn paragraph_build_without_bld_lst_still_folds_into_by_paragraph() {
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild};
let mut pres = defaults::new_presentation(None);
let mut sh = shape(&mut pres, "Title 1", 50.0, 50.0, 400.0, 200.0);
let shape_id = sh.id;
sh.text = Some(TextBody { paragraphs: vec![Paragraph::new("First"), Paragraph::new("Second")], ..Default::default() });
let s0 = Arc::make_mut(&mut pres.slides[0]);
s0.shapes.push(sh);
s0.animations.push(Animation {
shape: shape_id,
class: AnimClass::Entrance,
effect: "fade".into(),
start: AnimStart::OnClick,
duration_ms: 500,
text_build: TextBuild::ByParagraph,
..Default::default()
});
let bytes = deckcraft_pptx::export(&pres).unwrap();
let without_bld = rewrite_zip_entry(&bytes, "ppt/slides/slide1.xml", |xml| {
let start = xml.find("<p:bldLst>").expect("bldLst");
let end = xml.find("</p:bldLst>").expect("/bldLst") + "</p:bldLst>".len();
format!("{}{}", &xml[..start], &xml[end..])
});
let back = deckcraft_pptx::import(&without_bld).unwrap();
assert_eq!(back.slides[0].animations.len(), 1);
assert_eq!(back.slides[0].animations[0].text_build, TextBuild::ByParagraph);
// Explicit bldP build="p" folds non-500ms After Previous builds, while without bldLst an 800ms
// After Previous run stays separate so playback does not alter its 800ms stagger to 500ms.
let mut pres_after = pres.clone();
let s_after = Arc::make_mut(&mut pres_after.slides[0]);
s_after.animations[0].start = AnimStart::AfterPrevious;
s_after.animations[0].duration_ms = 800;
let bytes_after = deckcraft_pptx::export(&pres_after).unwrap();
let back_explicit_after = deckcraft_pptx::import(&bytes_after).unwrap();
assert_eq!(back_explicit_after.slides[0].animations.len(), 1);
assert_eq!(back_explicit_after.slides[0].animations[0].text_build, TextBuild::ByParagraph);
assert_eq!(back_explicit_after.slides[0].animations[0].duration_ms, 800);
let without_bld_after = rewrite_zip_entry(&bytes_after, "ppt/slides/slide1.xml", |xml| {
let start = xml.find("<p:bldLst>").expect("bldLst");
let end = xml.find("</p:bldLst>").expect("/bldLst") + "</p:bldLst>".len();
format!("{}{}", &xml[..start], &xml[end..])
});
let back_after = deckcraft_pptx::import(&without_bld_after).unwrap();
assert_eq!(back_after.slides[0].animations.len(), 2);
assert!(back_after.slides[0].animations.iter().all(|a| a.text_build == TextBuild::default()));
// Explicit bldP with build!="p" (e.g. build="cust") opts out of folding.
let custom_bld = rewrite_zip_entry(&bytes, "ppt/slides/slide1.xml", |xml| xml.replace(r#"build="p""#, r#"build="cust""#));
let back_cust = deckcraft_pptx::import(&custom_bld).unwrap();
assert_eq!(back_cust.slides[0].animations.len(), 2);
assert!(back_cust.slides[0].animations.iter().all(|a| a.text_build == TextBuild::default()));
// Without bldLst, an AfterPrevious second paragraph whose parent container delay (1000ms)
// disagrees with the 500ms first paragraph span must not fold into a 500ms build.
let mut pres_with = pres.clone();
Arc::make_mut(&mut pres_with.slides[0]).animations[0].start = AnimStart::WithPrevious;
let bytes_with = deckcraft_pptx::export(&pres_with).unwrap();
let mismatched_offset = rewrite_zip_entry(&bytes_with, "ppt/slides/slide1.xml", |xml| {
let start = xml.find("<p:bldLst>").expect("bldLst");
let end = xml.find("</p:bldLst>").expect("/bldLst") + "</p:bldLst>".len();
let stripped = format!("{}{}", &xml[..start], &xml[end..]);
stripped.replacen(r#"<p:cond delay="500"/>"#, r#"<p:cond delay="1000"/>"#, 1)
});
let back_mismatch = deckcraft_pptx::import(&mismatched_offset).unwrap();
assert_eq!(back_mismatch.slides[0].animations.len(), 2);
assert!(back_mismatch.slides[0].animations.iter().all(|a| a.text_build == TextBuild::default()));
}
+3
View File
@@ -14,6 +14,7 @@ mod fills;
mod kept_parts;
mod locks;
mod malformed;
mod master_text_styles;
mod math_ns;
mod media_timing;
mod notes;
@@ -29,3 +30,5 @@ mod table_styles;
mod tags;
mod transition_xml;
mod trigger_steps;
mod picture_backing;
+88
View File
@@ -288,6 +288,94 @@ fn unreadable_slide_is_reported() {
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
}
/// Missing slide relationships must not turn a damaged deck into a clean partial load (#1910).
#[test]
fn missing_slide_relationships_are_reported() {
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
for _ in 1..13 {
let slide = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(std::sync::Arc::new(slide));
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
let pres = es.iter_mut().find(|(n, _)| n == "ppt/presentation.xml").expect("presentation");
let xml = String::from_utf8(pres.1.clone()).expect("utf8");
let doc = deckcraft_pptx::parse_xml(xml.as_bytes()).expect("xml");
let slides = doc.root.child("sldIdLst").expect("slide list");
let mut broken = xml.clone();
for slide in slides.children_named("sldId").skip(6) {
let rid = slide.attr("r:id").expect("relationship id");
broken = broken.replace(&format!("r:id=\"{rid}\""), &format!("r:id=\"missing-{rid}\""));
}
pres.1 = broken.into_bytes();
let damaged = rezip(&es);
let (q, lost) = deckcraft_pptx::import_checked(&damaged).expect("import");
assert_eq!(q.slides.len(), 6);
assert_eq!(lost.len(), 7, "every omitted slide must be reported: {lost:?}");
let loaded = deckcraft_engine::cmd::file::loaded_file("Original.pptx", &damaged).expect("load");
let mut session = deckcraft_engine::Session::new();
let token = session.stage_loaded(loaded);
let result = session.execute("file.adopt", &serde_json::json!({"path": "Original.pptx", "token": token})).expect("adopt");
assert_eq!(result["lostSlides"].as_array().expect("lost slides").len(), 7);
assert!(session.doc().expect("doc").path.is_none(), "Save must not overwrite the damaged source");
assert!(session.doc().expect("doc").title.ends_with("(Repaired)"));
let (q, lost) = deckcraft_pptx::import_checked(&deckcraft_pptx::export(&p).expect("export")).expect("import");
assert_eq!(q.slides.len(), 13);
assert!(lost.is_empty());
}
/// Every listed entry has its own slide part and relationship: the resource cap must not
/// silently turn an otherwise readable large deck into a complete-looking partial load.
#[test]
fn over_limit_slide_lists_are_refused() {
fn deck(count: usize) -> Vec<u8> {
let mut es = entries(&valid());
let pres = es.iter_mut().find(|(n, _)| n == "ppt/presentation.xml").expect("presentation");
let mut xml = String::from_utf8(pres.1.clone()).expect("utf8");
let start = xml.find("<p:sldIdLst>").expect("slide list");
let end = xml.find("</p:sldIdLst>").expect("slide list end") + "</p:sldIdLst>".len();
let ids: String = (1..=count).map(|i| format!(r#"<p:sldId id="{}" r:id="capSlide{i}"/>"#, 255 + i)).collect();
xml.replace_range(start..end, &format!("<p:sldIdLst>{ids}</p:sldIdLst>"));
pres.1 = xml.into_bytes();
let rels = es.iter_mut().find(|(n, _)| n == "ppt/_rels/presentation.xml.rels").expect("relationships");
let mut xml = String::from_utf8(rels.1.clone()).expect("utf8");
let targets: String = (1..=count).map(|i| format!(r#"<Relationship Id="capSlide{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide{i}.xml"/>"#)).collect();
xml = xml.replace("</Relationships>", &format!("{targets}</Relationships>"));
rels.1 = xml.into_bytes();
let types = es.iter_mut().find(|(n, _)| n == "[Content_Types].xml").expect("content types");
let xml = String::from_utf8(types.1.clone()).expect("utf8");
let overrides: String = (2..=count).map(|i| format!(r#"<Override PartName="/ppt/slides/slide{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>"#)).collect();
types.1 = xml.replace("</Types>", &format!("{overrides}</Types>")).into_bytes();
let blank = br#"<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld></p:sld>"#.to_vec();
es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide").1 = blank.clone();
let slide_rels = es.iter().find(|(n, _)| n == "ppt/slides/_rels/slide1.xml.rels").expect("slide relationships").1.clone();
for i in 2..=count {
es.push((format!("ppt/slides/slide{i}.xml"), blank.clone()));
es.push((format!("ppt/slides/_rels/slide{i}.xml.rels"), slide_rels.clone()));
}
rezip(&es)
}
let at_limit = deck(10_000);
let (p, lost) = deckcraft_pptx::import_checked(&at_limit).expect("exactly at the limit");
assert_eq!(p.slides.len(), 10_000);
assert!(lost.is_empty());
drop(p);
let over_limit = deck(10_001);
for error in [
deckcraft_pptx::import_checked(&over_limit).expect_err("must refuse a partial load"),
deckcraft_pptx::import(&over_limit).expect_err("plain import must also refuse"),
] {
assert!(matches!(error, deckcraft_pptx::PptxError::Unsupported(_)));
assert!(error.to_string().contains("10000"), "{error}");
assert!(error.to_string().contains("slide"), "{error}");
}
assert!(
deckcraft_engine::cmd::file::loaded_file("Original.pptx", &over_limit).is_err(),
"no partial file may be adopted with the original save path"
);
}
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
/// slide that names it.
#[test]
+110
View File
@@ -0,0 +1,110 @@
//! Imported empty master styles must not become the defaults used to author new decks.
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_geom::Xfrm;
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Align, Bullet, ParaProps, TextBody};
use deckcraft_model::{PhType, Placeholder, Presentation, Shape, ShapeId, defaults};
fn subtitle(text: &str) -> Shape {
let mut body = TextBody::from_text(text);
body.paragraphs[0].props.align = Some(Align::Center);
Shape {
id: ShapeId(102),
name: "Centered subtitle".into(),
ph: Some(Placeholder { kind: PhType::SubTitle, idx: 1, ..Default::default() }),
xfrm: Some(Xfrm::new(120.0, 120.0, 720.0, 90.0)),
text: Some(body),
..Default::default()
}
}
/// Author an input with empty title/other styles and the specified body style, without asking
/// the serializer under test to supply its own master text styles in the input.
fn imported(body_style: &str) -> Presentation {
let mut p = defaults::new_presentation(None);
let m = Arc::make_mut(&mut p.masters[0]);
m.shapes.clear();
for l in &mut m.layouts {
l.shapes = vec![subtitle("")];
}
Arc::make_mut(&mut p.slides[0]).shapes = vec![subtitle("Centered subtitle")];
let bytes = deckcraft_pptx::export(&p).expect("base package");
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut part = z.by_index(i).expect("part");
let name = part.name().to_string();
let mut data = Vec::new();
part.read_to_end(&mut data).expect("read");
if name.starts_with("ppt/slideMasters/slideMaster") && name.ends_with(".xml") {
let mut xml = String::from_utf8(data).expect("generated XML");
let start = xml.find("<p:txStyles>").expect("styles start");
let end = xml.find("</p:txStyles>").expect("styles end") + "</p:txStyles>".len();
xml.replace_range(start..end, &format!("<p:txStyles><p:titleStyle/><p:bodyStyle>{body_style}</p:bodyStyle><p:otherStyle/></p:txStyles>"));
data = xml.into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
out.write_all(&data).expect("write");
}
deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import fixture")
}
fn resolved_subtitle(p: &Presentation) -> ParaProps {
let slide = &p.slides[0];
let shape = slide.shapes.iter().find(|s| s.name == "Centered subtitle").expect("subtitle");
assert_eq!(shape.ph_type(), Some(PhType::SubTitle));
assert_eq!(shape.xfrm, Some(Xfrm::new(120.0, 120.0, 720.0, 90.0)));
let text = shape.text.as_ref().expect("text");
assert_eq!(text.text(), "Centered subtitle");
resolve::para(&Ctx::for_slide(p, slide).expect("context"), shape, &text.paragraphs[0])
}
fn saved(p: &Presentation) -> Presentation {
deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("reopen")
}
#[test]
fn an_imported_empty_master_keeps_its_unbulleted_subtitle_after_two_saves() {
let mut p = imported("");
let before = resolved_subtitle(&p);
assert_eq!(before.bullet, None);
assert_eq!(before.align, Some(Align::Center));
for _ in 0..2 {
p = saved(&p);
assert_eq!(resolved_subtitle(&p).bullet, None, "saving must not add a subtitle bullet");
let master = &p.masters[0];
assert!(master.title_style.is_empty(), "do not invent an imported title style");
assert!(master.body_style.is_empty(), "do not invent bullets in an imported body style");
assert!(master.other_style.is_empty(), "do not invent an imported other-text style");
assert_eq!(resolved_subtitle(&p), before);
}
}
#[test]
fn an_explicit_unbulleted_body_style_keeps_its_properties() {
let mut p = imported(r#"<a:lvl1pPr><a:buNone/><a:defRPr sz="1900"><a:latin typeface="Carlito"/></a:defRPr></a:lvl1pPr>"#);
let expected = p.masters[0].body_style.clone();
assert_eq!(resolved_subtitle(&p).bullet, Some(Bullet::None));
for _ in 0..2 {
p = saved(&p);
assert_eq!(p.masters[0].body_style, expected);
assert_eq!(resolved_subtitle(&p).bullet, Some(Bullet::None));
}
}
#[test]
fn newly_authored_presentations_keep_their_populated_master_defaults() {
let mut p = defaults::new_presentation(None);
let m = &p.masters[0];
let expected = (m.title_style.clone(), m.body_style.clone(), m.other_style.clone());
assert!(!expected.0.is_empty() && !expected.1.is_empty() && !expected.2.is_empty());
assert_eq!(expected.1.levels.len(), 9);
assert!(matches!(expected.1.level(0).expect("body level").para.bullet, Some(Bullet::Char { .. })));
for _ in 0..2 {
p = saved(&p);
let m = &p.masters[0];
assert_eq!((&m.title_style, &m.body_style, &m.other_style), (&expected.0, &expected.1, &expected.2));
}
}
+72
View File
@@ -0,0 +1,72 @@
//! A picture's fill is a backing behind its transparent image, independent of its blip fill.
use std::sync::Arc;
use deckcraft_color::Rgba;
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
use deckcraft_model::{ColorRef, Presentation, Shape, ShapeId, ShapeKind, Xfrm, defaults};
fn deck(fill: Option<Fill>, object: bool) -> Presentation {
let mut p = defaults::new_presentation(None);
let image = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([0, 0, 128, 255]) });
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image).write_to(&mut png, image::ImageFormat::Png).expect("PNG");
let media = p.add_media("transparent.png", "image/png", png.into_inner()).expect("media");
let kind = if object {
ShapeKind::Opaque { xml: String::new(), preview: Some(media), label: "synthetic preview".into(), parts: vec![] }
} else {
ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }
};
let pic = Shape { id: ShapeId(102), name: "Picture".into(), xfrm: Some(Xfrm::new(20.0, 10.0, 80.0, 40.0)), fill, kind, ..Default::default() };
let group = Shape {
id: ShapeId(101),
name: "Group".into(),
xfrm: Some(Xfrm::new(100.0, 150.0, 400.0, 180.0)),
fill: Some(Fill::solid(ColorRef::rgb(Rgba::WHITE))),
kind: ShapeKind::Group { children: vec![pic], child: Xfrm::new(0.0, 0.0, 200.0, 120.0) },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes.push(group);
p
}
fn picture(p: &Presentation) -> &Shape {
let g = p.slides[0].shapes.iter().find(|s| s.name == "Group").expect("group");
let ShapeKind::Group { children, child } = &g.kind else { panic!("group kind") };
assert_eq!(*child, Xfrm::new(0.0, 0.0, 200.0, 120.0));
assert_eq!(g.xfrm, Some(Xfrm::new(100.0, 150.0, 400.0, 180.0)));
assert_eq!(g.fill, Some(Fill::solid(ColorRef::rgb(Rgba::WHITE))));
children.iter().find(|s| s.name == "Picture").expect("picture")
}
fn check_saves(mut p: Presentation, fill: Option<Fill>) {
let expected_media = p.media[0].data.clone();
for _ in 0..2 {
p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
let pic = picture(&p);
assert_eq!(pic.fill, fill, "keep the backing separate from the picture's blip fill");
assert_eq!(pic.xfrm, Some(Xfrm::new(20.0, 10.0, 80.0, 40.0)));
let ShapeKind::Picture { fill: image } = &pic.kind else { panic!("picture kind") };
assert!(matches!(image.mode, PictureMode::Stretch { .. }));
let media = p.media.iter().find(|m| m.id == image.media).expect("referenced image");
assert_eq!(media.data, expected_media, "keep transparent PNG bytes unchanged");
}
}
#[test]
fn a_transparent_picture_keeps_its_inherited_group_backing() {
check_saves(deck(Some(Fill::Group), false), Some(Fill::Group));
}
#[test]
fn an_object_saved_as_its_picture_preview_keeps_its_inherited_group_backing() {
// Empty kept XML deliberately exercises shape_with_id's preview fallback, not the ordinary
// picture writer. Both must keep the same backing when they serialize p:pic.
check_saves(deck(Some(Fill::Group), true), Some(Fill::Group));
}
#[test]
fn unfilled_and_directly_filled_pictures_keep_their_own_backing() {
for fill in [None, Some(Fill::None), Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 100, 20))))] {
check_saves(deck(fill.clone(), false), fill);
}
}
+39
View File
@@ -1396,6 +1396,45 @@ fn single_guide_names_follow_the_preset() {
}
}
/// Large finite guides position a visible bend even when the connector is only one EMU wide.
#[test]
fn large_connector_adjustments_keep_their_elbows_through_two_saves() {
let cases = [
("ordinary", 80.0, 50000.0),
("near control", 1588.0 / 12700.0, -12800000.0),
("near negative", 1.0 / 12700.0, -20320000000.0),
("near positive", 1.0 / 12700.0, 20320000000.0),
("zero width", 0.0, 20320000000.0),
("ordinary negative", 80.0, -20000.0),
];
let mut p = defaults::new_presentation(None);
p.slides.clear();
let mut s = new_slide(&mut p, "Connectors");
s.shapes.clear();
for (name, width, guide) in cases {
let mut sh = shape(&mut p, name, 100.0, 80.0, width, 140.0);
sh.kind = ShapeKind::Connector { start: None, end: None };
sh.geom = Geom::Preset { name: "bentConnector3".into(), adj: vec![guide] };
s.shapes.push(sh);
}
p.slides.push(Arc::new(s));
for generation in 1..=2 {
p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reopen");
for (name, width, guide) in cases {
let sh = p.slides[0].shapes.iter().find(|sh| sh.name == name).expect("connector");
let Geom::Preset { name: preset, adj } = &sh.geom else {
panic!("preset connector");
};
assert_eq!(adj, &[guide], "{name}, save {generation}");
let xfrm = sh.xfrm.expect("transform");
assert_eq!((xfrm.w, xfrm.h), (width, 140.0));
let expected = deckcraft_geom::preset::build("bentConnector3", width, 140.0, &[guide]).expect("expected geometry");
let actual = deckcraft_geom::preset::build(preset, xfrm.w, xfrm.h, adj).expect("reopened geometry");
assert_eq!(actual.outline(), expected.outline(), "{name}, save {generation}");
}
}
}
/// #147: PowerPoint's Float Down is preset 47 (Float Up is 42); it imported as a plain fade.
#[test]
fn float_down_is_preset_47_both_ways() {
+2 -1
View File
@@ -1,6 +1,6 @@
# File-format parity with PowerPoint
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** minor (cell anchorCtr, vertOverflow/horzOverflow, compatLnSpc, normalizeH, thaiDist, table rtl, run altLang and reflection transforms round-trip; Reuse Slides brings referenced custom table styles; 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)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-07 · **Change:** trivial (a slide whose relationship is missing is reported in `lostSlides`; previous: minor (cell anchorCtr, vertOverflow/horzOverflow, compatLnSpc, normalizeH, thaiDist, table rtl, run altLang and reflection transforms round-trip; Reuse Slides brings referenced custom table styles; 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**
@@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | trivial | A slide-list entry whose relationship is missing is reported in `lostSlides` like an unreadable slide part, so the deck opens as a repaired copy (#1910) |
| 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-11-02 | minor | Text runs preserve alternate-language metadata in native and PPTX round trips |
| 2026-11-03 | trivial | Reuse Slides transfers referenced custom table styles from native and PPTX sources; grouped tables, undo, native save/reopen and style-id conflicts are covered |
+2 -2
View File
@@ -56,8 +56,8 @@ folders:
its nearest existing folder's links are followed and the rest must be plain names, so `..` below a
folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would
create its target). The result is compared with the root by whole folder names: `/work2/x` is not
inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like
drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused.
inside `/work`. On Windows names are compared exactly after what exists is resolved to its real case (so `OUT\x` reaches the root `out` on an ordinary volume, but not in a case-sensitive folder), UNC paths (`\\server\share\...`) are resolved like
drive-letter ones, and device names (`CON`, `NUL`, `COM1`, `COM¹`..., also followed by an extension or a colon) are refused.
- A refused path is an ordinary tool error the agent can read and correct, for example
`automation path rejected: outside the write root /work/project/out: /etc/x.pptx` or
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.pptx`.