mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-13 09:22:40 -06:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9ac9ac7a | ||
|
|
eebede8ee4 | ||
|
|
a024a0e86c | ||
|
|
9850dc6088 | ||
|
|
3ce1886bea | ||
|
|
0a75620a73 | ||
|
|
74a0468917 | ||
|
|
6166cb0870 | ||
|
|
d2ee3ca9fc | ||
|
|
506db24b07 | ||
|
|
7f9acc1c27 | ||
|
|
3a92bcc93c | ||
|
|
18355eeca2 | ||
|
|
2d1e6ff348 | ||
|
|
6a3cbd18d2 | ||
|
|
5914363930 | ||
|
|
e0b0817288 | ||
|
|
63331b8ab6 | ||
|
|
3975b8cc5e | ||
|
|
db257178f5 | ||
|
|
e8802ccfbd | ||
|
|
730c6cde9c | ||
|
|
b7e5cf05ea | ||
|
|
ce2bd95414 | ||
|
|
41dd2bd3bb | ||
|
|
1469419354 | ||
|
|
70a0d7a2d8 | ||
|
|
ea97b18c70 | ||
|
|
17db75b74a | ||
|
|
cdca5bd09d | ||
|
|
7ebfa82297 | ||
|
|
de5398cfc5 | ||
|
|
2750e65096 | ||
|
|
c564070d9e | ||
|
|
3def50c98a | ||
|
|
a570031909 | ||
|
|
9d9610a893 | ||
|
|
5a2cab9483 | ||
|
|
3e2dc9ca7f | ||
|
|
d680914140 | ||
|
|
1c8b14edad | ||
|
|
09548fe55c | ||
|
|
ea809d93f5 | ||
|
|
26c1ac83dc | ||
|
|
23d0f67d17 | ||
|
|
fb67888e25 | ||
|
|
9e6758e276 | ||
|
|
6c0886a68a |
+2
-1
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}))
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user