Model: an exhausted id space refuses growth instead of duplicating ids (#407)

alloc_id bumped next_id with saturating_add: a presentation whose
next_id reached u32::MAX (a hostile file can arrive that way;
fix_next_id imports it) handed out that same id to every new slide,
shape, layout and media - duplicate ids, ambiguous references, silent
corruption on every later insert.

alloc_id now returns Option<u32> via checked_add, and every caller
refuses: the engine's insert/duplicate/media/layout commands answer
with a clear error naming the command, paste and duplicate re-id
helpers propagate it, media addition is fallible, and a fresh slide is
Option. The pptx export's synthesized blank layout is guarded the same
way. Loading keeps such a deck viewable: repair re-ids duplicate
slides while it can, and a duplicate it cannot re-id stays as it is -
growth is refused, and only that duplicate keeps its ambiguous id.

Renumbering the loaded graph was rejected: it needs the
cross-document reference remapping that issue #406 tracks.

Tests: model exhaustion (alloc_id None, fresh media refused, a new
slide refused), engine (slide.new, shape.insert, slide.duplicate and
edit.duplicate all refuse; the document holds no duplicate ids),
format repair (an unre-id-able duplicate stays visible).
This commit is contained in:
Sazid Al Bayazid
2026-10-13 21:13:25 +06:00
parent 457406a7a5
commit e70fce996b
25 changed files with 176 additions and 103 deletions
+1
View File
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
+1 -1
View File
@@ -177,7 +177,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
let xs: Vec<Xfrm> = members.iter().filter_map(|m| m.xfrm).collect();
let b = union_bounds(&xs).ok_or_else(|| bad("arrange.group", "nothing to group"))?;
let gx = Xfrm::new(b.x0, b.y0, b.width(), b.height());
let id = ShapeId(doc.alloc_id());
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("arrange.group", NO_ID_SPACE))?);
let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0);
let g = Shape {
id,
+5 -5
View File
@@ -177,7 +177,7 @@ fn background(s: &mut Session, p: &Value) -> Result<Value> {
} else if p.get("picture").is_some() {
let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
let ct = super::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?;
Some(Background::Fill { fill: Fill::Picture(deckcraft_model::style::PictureFill { media, ..Default::default() }) })
} else {
return Err(bad("design.background", "give `color`, `gradient`, `pattern`, `picture`, `style` or `reset`"));
@@ -325,7 +325,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut new_ids = vec![];
for _ in 0..3 {
new_ids.push(ShapeId(doc.alloc_id()));
new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?));
}
let Some(sl) = doc.slides.get_mut(i) else { continue };
let sl = Arc::make_mut(sl);
@@ -459,8 +459,8 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Custom Layout").to_string();
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let id = deckcraft_model::LayoutId(doc.alloc_id());
let tid = ShapeId(doc.alloc_id());
let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
let size = doc.slide_size;
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?);
let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| {
@@ -552,7 +552,7 @@ fn insert_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
text: Some(TextBody::default()),
..Default::default()
};
let id = super::insert::add_shape(s, shape, true)?;
let id = super::insert::add_shape(s, "master.insertPlaceholder", shape, true)?;
Ok(json!({"id": id}))
}
+16 -15
View File
@@ -126,33 +126,34 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Give pasted shapes fresh ids (groups included) and map connector ends.
pub(crate) fn reid(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) {
fn rec(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape], depth: usize) {
pub(crate) fn reid(cmd: &str, doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) -> Result<()> {
fn rec(cmd: &str, doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape], depth: usize) -> Result<()> {
if depth > 64 {
return;
return Ok(());
}
for s in shapes {
s.id = ShapeId(doc.alloc_id());
s.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
if let Some(ch) = s.children_mut() {
rec(doc, ch, depth + 1);
rec(cmd, doc, ch, depth + 1)?;
}
}
Ok(())
}
rec(doc, shapes, 0);
rec(cmd, doc, shapes, 0)
}
fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) {
fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) -> Result<()> {
for m in media {
if doc.media(m.id).is_none_or(|x| x.data != m.data) {
if doc.media(m.id).is_none() {
doc.media.push(m.clone());
} else {
// Id clash with different bytes: unusual (other deck); keep ours, add theirs as new.
let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone());
let _ = id;
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?;
}
}
}
Ok(())
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
@@ -174,12 +175,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if !clip.slides.is_empty() {
let at = s.doc()?.selection.slide + 1;
return s.edit(|doc, sel| {
bring_media(doc, &clip.media);
bring_media(doc, &clip.media)?;
let mut new_ids = vec![];
for (k, sl) in clip.slides.iter().enumerate() {
let mut sl = sl.clone();
sl.id = SlideId(doc.alloc_id());
reid(doc, &mut sl.shapes);
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?);
reid("edit.paste", doc, &mut sl.shapes)?;
if doc.layout(sl.layout).is_none() {
sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
}
@@ -200,9 +201,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let same_slide = s.doc().ok().is_some_and(|d| clip.shapes.iter().any(|c| d.shape(c.id).is_some()));
let off = if same_slide { off } else { 0.0 };
return s.edit(|doc, sel| {
bring_media(doc, &clip.media);
bring_media(doc, &clip.media)?;
let mut shapes = clip.shapes.clone();
reid(doc, &mut shapes);
reid("edit.paste", doc, &mut shapes)?;
for sh in &mut shapes {
if let Some(x) = sh.xfrm.as_mut() {
x.x += off;
@@ -251,7 +252,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
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();
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
reid(doc, &mut copies);
reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) {
let mut x = b;
x.x += off;
+17 -14
View File
@@ -104,9 +104,9 @@ fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm {
Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h)
}
pub(crate) fn add_shape(s: &mut Session, mut shape: Shape, select: bool) -> Result<ShapeId> {
pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result<ShapeId> {
s.edit(|doc, sel| {
shape.id = ShapeId(doc.alloc_id());
shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
if shape.name.is_empty() {
let base = match &shape.kind {
ShapeKind::Picture { .. } => "Picture".to_string(),
@@ -176,7 +176,7 @@ fn insert_shape(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(look), false) = (&s.default_look, line_like) {
super::format::apply_painted(&mut shape, look);
}
let id = add_shape(s, shape, true)?;
let id = add_shape(s, "shape.insert", shape, true)?;
Ok(json!({"id": id}))
}
@@ -204,7 +204,7 @@ fn text_box(s: &mut Session, p: &Value) -> Result<Value> {
t.body.wrap = Some(false);
}
}
let id = add_shape(s, shape, true)?;
let id = add_shape(s, "insert.textBox", shape, true)?;
super::text::fit_text_box(s, id)?;
if text.is_empty() {
// Start typing immediately.
@@ -341,7 +341,7 @@ fn picture(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => [0.0; 4],
};
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?;
let shape = Shape {
xfrm: Some(rect),
kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } },
@@ -368,7 +368,7 @@ fn picture(s: &mut Session, p: &Value) -> Result<Value> {
})?;
return Ok(json!({"id": ph_id}));
}
let id = add_shape(s, shape, true)?;
let id = add_shape(s, "insert.picture", shape, true)?;
Ok(json!({"id": id}))
}
@@ -389,7 +389,7 @@ fn table(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Table(t), ..Default::default() }, true)?;
let id = add_shape(s, "insert.table", Shape { xfrm: Some(rect), kind: ShapeKind::Table(t), ..Default::default() }, true)?;
Ok(json!({"id": id}))
}
@@ -446,7 +446,7 @@ fn chart(s: &mut Session, p: &Value) -> Result<Value> {
}
let size = s.doc()?.doc.slide_size;
let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7));
let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?;
let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?;
Ok(json!({"id": id}))
}
@@ -498,12 +498,15 @@ fn media(s: &mut Session, p: &Value, video: bool) -> Result<Value> {
});
let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone());
let (media, poster) = s.edit(|doc, _| {
let media = doc.add_media(&name, ct, data);
let poster = poster_png.map(|png| doc.add_media(&format!("{name}-poster.png"), "image/png", png));
let media = doc.add_media(&name, ct, data).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?;
let poster = match poster_png {
Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?),
None => None,
};
Ok((media, poster))
})?;
let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() };
let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?;
let id = add_shape(s, "insert.media", Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?;
if let Some(w) = &warning {
s.ui_requests.push(crate::UiRequest::Message { text: w.clone() });
}
@@ -545,7 +548,7 @@ fn word_art(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let id = add_shape(s, shape, true)?;
let id = add_shape(s, "insert.wordArt", shape, true)?;
super::text::fit_text_box(s, id)?;
Ok(json!({"id": id}))
}
@@ -624,7 +627,7 @@ fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
click: action.map(|a| Hyperlink { action: a, tooltip: String::new(), highlight_click: true }),
..Default::default()
};
let id = add_shape(s, shape, true)?;
let id = add_shape(s, "insert.actionButton", shape, true)?;
Ok(json!({"id": id}))
}
@@ -790,7 +793,7 @@ fn smart_art(s: &mut Session, p: &Value) -> Result<Value> {
}
}
sh.text = Some(body);
ids.push(add_shape(s, sh, false)?.0);
ids.push(add_shape(s, "insert.smartArt", sh, false)?.0);
}
let g = s.execute("arrange.group", &json!({"ids": ids}))?;
if let Some(gid) = g.get("id").and_then(Value::as_u64) {
+1 -1
View File
@@ -139,7 +139,7 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result<Value> {
let data = s.doc()?.doc.media(m.media).map(|i| i.data.clone()).ok_or_else(|| EngineError::Other("the media data is missing".into()))?;
let png = poster_png(&data, ms).ok_or_else(|| EngineError::Other("no frame could be decoded there".into()))?;
let name = format!("poster{id}.png");
let poster = s.edit(|doc, _| Ok(doc.add_media(&name, "image/png", png)))?;
let poster = s.edit(|doc, _| doc.add_media(&name, "image/png", png).ok_or_else(|| bad("media.posterFrame", NO_ID_SPACE)))?;
edit_shapes(s, &json!({"id": id}), "media.posterFrame", |sh| {
if let ShapeKind::Media(m) = &mut sh.kind {
m.poster = Some(poster);
+1 -1
View File
@@ -176,7 +176,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
made.push(sh);
}
for sh in &mut made {
sh.id = ShapeId(doc.alloc_id());
sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad("shape.merge", NO_ID_SPACE))?);
new_ids.push(sh.id);
}
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?;
+4
View File
@@ -152,6 +152,10 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
// ---------- param helpers ----------
/// The deck has used every object id (a hostile file can arrive that way): it cannot grow, and
/// growing would duplicate ids (#407).
pub(crate) const NO_ID_SPACE: &str = "the presentation has used every object id and can hold no more";
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
+2 -2
View File
@@ -284,7 +284,7 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
} else if p.get("picture").is_some() {
let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "shape.fill")?;
let ct = super::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("shape.fill", NO_ID_SPACE)))?;
Some(Some(Fill::Picture(PictureFill { media, ..Default::default() })))
} else {
None
@@ -674,7 +674,7 @@ fn picture_reset(s: &mut Session, p: &Value) -> Result<Value> {
fn picture_change(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = super::insert::media_bytes(p, "picture.change")?;
let ct = super::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("picture.change", NO_ID_SPACE)))?;
edit_shapes(s, p, "picture.change", |sh| {
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
fill.media = media;
+4 -4
View File
@@ -99,7 +99,7 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
}
}
};
let mut slide = defaults::new_slide(doc, layout);
let mut slide = defaults::new_slide(doc, layout).ok_or_else(|| bad("slide.new", NO_ID_SPACE))?;
if let Some(t) = str_param(p, "title")
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title()))
{
@@ -165,8 +165,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
for (k, i) in indices.iter().enumerate() {
let Some(src) = doc.slides.get(*i).map(|x| x.as_ref().clone()) else { continue };
let mut copy = src;
copy.id = SlideId(doc.alloc_id());
super::edit::reid(doc, &mut copy.shapes);
copy.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.duplicate", NO_ID_SPACE))?);
super::edit::reid("slide.duplicate", doc, &mut copy.shapes)?;
// Animations follow the new shape ids by position.
let old_ids: Vec<_> = doc
.slides
@@ -364,7 +364,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R
doc.layout(layout).map(|(_, l)| l.shapes.clone()).ok_or_else(|| bad("slide.layout", "no such layout"))?;
let mut fresh = vec![];
for ls in lay_shapes.iter().filter(|x| x.ph.as_ref().is_some_and(|p| !p.kind.is_footer_kind())) {
let id = deckcraft_model::ShapeId(doc.alloc_id());
let id = deckcraft_model::ShapeId(doc.alloc_id().ok_or_else(|| bad("slide.layout", NO_ID_SPACE))?);
fresh.push((id, ls.clone()));
}
let slide = doc.slides.get_mut(index).ok_or_else(|| bad("slide.layout", "no slide"))?;
+25
View File
@@ -835,3 +835,28 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
/// A deck whose ids are used up (a hostile file can arrive with next_id at the top of the
/// range) refuses to grow instead of handing out duplicate ids (#407).
#[test]
fn an_exhausted_id_space_refuses_growth() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).next_id = u32::MAX;
let before = s.doc().unwrap().doc.clone();
assert!(s.execute("slide.new", &json!({})).is_err(), "a new slide is refused");
assert!(s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).is_err());
assert!(s.execute("slide.duplicate", &json!({})).is_err());
assert!(s.execute("edit.duplicate", &json!({})).is_err());
// What the deck holds is untouched: same slides, same ids, no duplicates.
let after = s.doc().unwrap().doc.clone();
assert_eq!(after.slides.len(), before.slides.len());
let mut ids = vec![];
deckcraft_model::walk(&after.slides[0].shapes, &mut |sh, _| ids.push(sh.id.0));
ids.push(after.slides[0].id.0);
ids.push(after.next_id);
let mut sorted = ids.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "no id repeats");
}
+2 -2
View File
@@ -929,7 +929,7 @@ impl Session {
let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default();
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let boxes: Vec<Xfrm> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
cmd::edit::reid(doc, &mut copies);
cmd::edit::reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) {
c.xfrm = Some(b);
c.ph = None;
@@ -1049,7 +1049,7 @@ impl Session {
let id = match existing {
Some(id) => id,
None => {
let id = ShapeId(doc.alloc_id());
let id = ShapeId(doc.alloc_id().ok_or_else(|| cmd::bad("ink.draw", cmd::NO_ID_SPACE))?);
let size = doc.slide_size;
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?;
list.push(Shape {
+21 -5
View File
@@ -114,8 +114,9 @@ pub fn load(bytes: &[u8]) -> Result<Presentation> {
Ok(p)
}
/// Make a loaded presentation safe to edit: a master exists, every slide's layout exists,
/// ids are unique, tables are rectangular.
/// Make a loaded presentation safe to edit: a master exists, every slide's layout exists, tables
/// are rectangular, and duplicate ids are re-id'd — unless the deck has used every id, in which
/// case a duplicate stays (growth is refused; see the comment in the loop).
pub fn repair(p: &mut Presentation) {
if p.masters.is_empty() {
let fresh = defaults::blank_presentation(p.slide_size, Default::default(), false);
@@ -128,7 +129,10 @@ pub fn repair(p: &mut Presentation) {
let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none());
let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id));
if needs_layout || dup {
let new_id = if dup { Some(deckcraft_model::SlideId(p.alloc_id())) } else { None };
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
// grow such a deck (edits by id stay ambiguous for the duplicate), and the duplicate
// stays visible rather than the file failing to open (#407).
let new_id = if dup { p.alloc_id().map(deckcraft_model::SlideId) } else { None };
if let Some(s) = p.slides.get_mut(i) {
let s = Arc::make_mut(s);
if needs_layout {
@@ -169,7 +173,8 @@ pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize {
let line = raw.trim().trim_start_matches(['-', '*', '•']).trim();
if level == 0 {
flush(p, cur.take(), &mut made);
let mut s = defaults::new_slide(p, lay);
// An exhausted id space makes no new slides: what fit is kept (#407).
let Some(mut s) = defaults::new_slide(p, lay) else { continue };
if let Some(t) = s.shapes.first_mut() {
t.text = Some(TextBody::from_text(line));
}
@@ -221,7 +226,7 @@ mod tests {
#[test]
fn roundtrip_with_media() {
let mut p = Presentation::default();
let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]);
let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]).expect("media");
let bytes = save(&p).unwrap();
assert!(sniff(&bytes));
let q = load(&bytes).unwrap();
@@ -251,6 +256,17 @@ mod tests {
assert_eq!(p.slide_size.width, 960.0);
}
/// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is
/// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407).
#[test]
fn repair_keeps_a_duplicate_it_cannot_reid() {
let mut p = Presentation { next_id: u32::MAX, ..Default::default() };
p.slides.push(p.slides[0].clone());
repair(&mut p);
assert_eq!(p.slides.len(), 2, "the duplicate stays visible");
assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented");
}
#[test]
fn outline_roundtrip() {
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
+11 -6
View File
@@ -516,15 +516,20 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
raw_ext: None,
};
if title_slide {
// A fresh deck starts at id 256 and this builds a handful of ids, so the slide always
// fits; should that ever stop holding, the deck arrives without its title slide rather
// than with a duplicated id.
let s = new_slide(&mut p, first_layout);
p.slides.push(Arc::new(s));
debug_assert!(s.is_some(), "a fresh deck cannot run out of ids");
p.slides.extend(s.map(|s| vec![Arc::new(s)]).unwrap_or_default());
}
p
}
/// A slide for `layout` with its placeholders instantiated (empty, inheriting position).
pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Slide {
let id = SlideId(p.alloc_id());
/// A slide for `layout` with its placeholders instantiated (empty, inheriting position). `None`
/// when the deck has used every object id and cannot grow (#407).
pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Option<Slide> {
let id = SlideId(p.alloc_id()?);
let mut shapes = vec![];
let lay_shapes: Vec<Shape> = p.layout(layout).map(|(_, l)| l.shapes.clone()).unwrap_or_default();
for ls in lay_shapes {
@@ -532,7 +537,7 @@ pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Slide {
if ph.kind.is_footer_kind() {
continue;
}
let sid = ShapeId(p.alloc_id());
let sid = ShapeId(p.alloc_id()?);
shapes.push(Shape {
id: sid,
name: ls.name.clone(),
@@ -543,7 +548,7 @@ pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Slide {
..Default::default()
});
}
Slide { id, layout, shapes, ..Default::default() }
Some(Slide { id, layout, shapes, ..Default::default() })
}
/// Layout for a kind in the first master.
+11 -8
View File
@@ -855,10 +855,13 @@ impl Default for Presentation {
}
impl Presentation {
/// A fresh id, unique across slides, shapes, layouts, masters and media in this deck.
pub fn alloc_id(&mut self) -> u32 {
self.next_id = self.next_id.max(1).saturating_add(1);
self.next_id
/// A fresh id, unique across slides, shapes, layouts, masters and media in this deck. `None`
/// once every id is in use (a hostile file can arrive that way): the deck cannot grow
/// further, and callers must refuse the change rather than duplicate ids (#407).
pub fn alloc_id(&mut self) -> Option<u32> {
let next = self.next_id.max(1).checked_add(1)?;
self.next_id = next;
Some(next)
}
/// Make sure `next_id` is above every id in use (after loading a file).
pub fn fix_next_id(&mut self) {
@@ -910,13 +913,13 @@ impl Presentation {
self.media.iter().find(|m| m.id == id)
}
/// Add media bytes, reusing an identical existing item.
pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec<u8>) -> MediaId {
pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec<u8>) -> Option<MediaId> {
if let Some(m) = self.media.iter().find(|m| m.data.len() == data.len() && *m.data == data) {
return m.id;
return Some(m.id);
}
let id = MediaId(self.alloc_id());
let id = MediaId(self.alloc_id()?);
self.media.push(MediaItem { id, name: name.into(), content_type: content_type.into(), data: Arc::new(data), link: None });
id
Some(id)
}
/// Section index containing a slide (by position), when sections exist.
pub fn section_of(&self, slide: SlideId) -> Option<usize> {
+16 -4
View File
@@ -37,7 +37,7 @@ fn placeholder_inherits_position_and_styles() {
fn body_levels_from_master() {
let mut p = Presentation::default();
let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
let s = defaults::new_slide(&mut p, lay);
let s = defaults::new_slide(&mut p, lay).expect("slide");
let ctx = Ctx::for_slide(&p, &s).unwrap();
let body = &s.shapes[1];
assert_eq!(body.ph_type(), Some(PhType::Obj));
@@ -97,16 +97,28 @@ fn serde_roundtrip() {
assert!(q.slides.is_empty());
}
/// A deck whose ids are used up (a hostile file can arrive that way) yields no further ids:
/// allocating never repeats one (#407).
#[test]
fn exhausted_id_space_allocates_nothing() {
let mut p = Presentation { next_id: u32::MAX, ..Default::default() };
assert_eq!(p.alloc_id(), None);
assert_eq!(p.alloc_id(), None);
assert!(p.add_media("x.png", "image/png", vec![9]).is_none(), "fresh media bytes are refused too");
let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap_or_default();
assert!(defaults::new_slide(&mut p, lay).is_none(), "a new slide is refused");
}
#[test]
fn ids_are_unique_and_fix_next_id() {
let mut p = Presentation::default();
let a = p.alloc_id();
let b = p.alloc_id();
let a = p.alloc_id().expect("ids");
let b = p.alloc_id().expect("ids");
assert!(b > a);
p.next_id = 0;
p.fix_next_id();
assert!(p.next_id >= b.min(p.next_id));
let c = p.alloc_id();
let c = p.alloc_id().expect("ids");
let mut all = vec![];
for s in &p.slides {
all.push(s.id.0);
+1 -1
View File
@@ -7,7 +7,7 @@ fn deck(n: usize) -> Presentation {
let mut p = deckcraft_model::defaults::new_presentation(None);
let layout = p.slides[0].layout;
while p.slides.len() < n {
let s = deckcraft_model::defaults::new_slide(&mut p, layout);
let s = deckcraft_model::defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s));
}
for (i, s) in p.slides.iter_mut().enumerate() {
+5 -2
View File
@@ -354,8 +354,11 @@ fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> {
}
for m in q.masters.iter_mut() {
if m.layouts.is_empty() {
let id = LayoutId(q.next_id.saturating_add(1));
q.next_id = id.0;
// An exhausted id space exports without the synthesized layout rather than with a
// duplicated one (#407).
let Some(raw) = q.next_id.max(1).checked_add(1) else { continue };
let id = LayoutId(raw);
q.next_id = raw;
Arc::make_mut(m).layouts.push(Layout {
id,
name: "Blank".into(),
+8 -8
View File
@@ -36,8 +36,8 @@ fn png() -> Vec<u8> {
/// A deck with one shape filled with a stretched picture, exported.
fn deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let media = p.add_media("tile.png", "image/png", png());
let id = ShapeId(p.alloc_id());
let media = p.add_media("tile.png", "image/png", png()).expect("media");
let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
@@ -86,7 +86,7 @@ fn a_gradient_without_stops_is_saved_without_stops() {
// must not turn it into a gradient of its own (it used to become solid white).
use deckcraft_model::style::{Gradient, GradientShape};
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id;
let own = Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
p.slide_mut(first).expect("slide").shapes.push(Shape {
@@ -107,7 +107,7 @@ fn a_gradient_without_stops_is_saved_without_stops() {
fn a_fill_overlay_round_trips() {
use deckcraft_model::style::{FillOverlay, Gradient, GradientShape, GradientStop};
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id;
let overlay = FillOverlay {
blend: "mult".into(),
@@ -150,7 +150,7 @@ fn a_gradient_tile_rect_is_read_and_round_trips() {
tile: [-1.0, -1.0, 0.0, 0.0],
};
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
@@ -174,7 +174,7 @@ fn a_gradient_tile_rect_is_read_and_round_trips() {
#[test]
fn a_table_naming_no_style_is_drawn_with_the_table_grid() {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id;
let mut t = deckcraft_model::Table::new(2, 2, 200.0, 40.0);
t.style = "none".into();
@@ -207,8 +207,8 @@ fn a_table_naming_no_style_is_drawn_with_the_table_grid() {
#[test]
fn black_and_white_and_inverted_alpha_pictures_round_trip() {
let mut p = defaults::new_presentation(None);
let media = p.add_media("tile.png", "image/png", png());
let id = ShapeId(p.alloc_id());
let media = p.add_media("tile.png", "image/png", png()).expect("media");
let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id;
let mut pf = PictureFill { media, ..Default::default() };
pf.adjust.bilevel = Some(0.3);
+3 -3
View File
@@ -15,14 +15,14 @@ fn png() -> Vec<u8> {
}
fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape {
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() }
Shape { id: ShapeId(p.alloc_id().expect("ids")), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() }
}
/// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a
/// locked group (one child unlocked, one hidden), and a locked picture, connector and table.
fn deck() -> Presentation {
let mut p = defaults::new_presentation(None);
let img = p.add_media("image1.png", "image/png", png());
let img = p.add_media("image1.png", "image/png", png()).expect("media");
let mut v = vec![];
let mut a = shape(&mut p, "Locked", 0.0);
a.locked = true;
@@ -40,7 +40,7 @@ fn deck() -> Presentation {
c3.hidden = true;
let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0);
let mut g = Shape {
id: ShapeId(p.alloc_id()),
id: ShapeId(p.alloc_id().expect("ids")),
name: "Group".into(),
xfrm: Some(gx),
kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx },
+9 -9
View File
@@ -27,12 +27,12 @@ fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
}
fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape {
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() }
Shape { id: ShapeId(p.alloc_id().expect("ids")), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() }
}
fn new_slide(p: &mut Presentation, title: &str) -> Slide {
let layout = defaults::layout_of_kind(p, deckcraft_model::LayoutType::TitleOnly).unwrap_or_default();
let mut s = defaults::new_slide(p, layout);
let mut s = defaults::new_slide(p, layout).expect("slide");
if let Some(t) = s.shapes.first_mut() {
t.text = Some(TextBody::from_text(title));
}
@@ -45,9 +45,9 @@ pub fn rich_deck() -> Presentation {
p.props.title = "Rich deck".into();
p.props.author = "DeckCraft tests".into();
p.props.created = "2025-01-02T03:04:05Z".into();
let img = p.add_media("image1.png", "image/png", png(40, 30, [200, 40, 40]));
let img2 = p.add_media("image2.png", "image/png", png(8, 8, [20, 140, 220]));
let video = p.add_media("clip.mp4", "video/mp4", b"\x00\x00\x00\x18ftypmp42not-a-real-video".to_vec());
let img = p.add_media("image1.png", "image/png", png(40, 30, [200, 40, 40])).expect("media");
let img2 = p.add_media("image2.png", "image/png", png(8, 8, [20, 140, 220])).expect("media");
let video = p.add_media("clip.mp4", "video/mp4", b"\x00\x00\x00\x18ftypmp42not-a-real-video".to_vec()).expect("media");
// Slide 1: title slide with notes and formatted text.
{
@@ -328,7 +328,7 @@ pub fn rich_deck() -> Presentation {
// Slide 6: bulleted content with a by-paragraph build, a slide link and an equation.
let layout = defaults::layout_of_kind(&p, deckcraft_model::LayoutType::TitleAndContent).unwrap_or_default();
let mut s = defaults::new_slide(&mut p, layout);
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
s.shapes[0].text = Some(TextBody::from_text("Build"));
s.shapes[1].text = Some(TextBody::from_text("One\nTwo\nThree"));
let body = s.shapes[1].id;
@@ -589,7 +589,7 @@ fn every_transition_kind_survives() {
p.slides.clear();
for (kind, ..) in deckcraft_model::anim::TRANSITIONS {
let mut s = base.clone();
s.id = SlideId(p.alloc_id());
s.id = SlideId(p.alloc_id().expect("ids"));
s.transition = Some(Transition { kind: kind.to_string(), duration_ms: 1200, ..Default::default() });
p.slides.push(Arc::new(s));
}
@@ -754,7 +754,7 @@ fn colour_map_overrides_round_trip() {
#[test]
fn a_picture_duotone_round_trips() {
let mut p = defaults::new_presentation(None);
let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128]));
let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128])).expect("media");
let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0);
let mut pf = PictureFill { media: img, ..Default::default() };
pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200))));
@@ -867,7 +867,7 @@ fn transition_deck(list: &[(&str, &str, u32, Option<u32>)]) -> (Presentation, Ve
p.slides.clear();
for (kind, option, ms, adv) in list {
let mut s = base.clone();
s.id = SlideId(p.alloc_id());
s.id = SlideId(p.alloc_id().expect("ids"));
s.transition = Some(Transition {
kind: kind.to_string(),
option: option.to_string(),
+1 -1
View File
@@ -20,7 +20,7 @@ fn style_xml() -> String {
/// An exported deck with a 3x2 table using the probe style.
fn table_deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id;
let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0);
t.style = "medium2-accent1".into();
+6 -6
View File
@@ -361,7 +361,7 @@ fn two_frame_gif() -> Vec<u8> {
#[test]
fn animated_gif_shows_the_frame_for_its_time() {
let mut p = Presentation::default();
let media = p.add_media("a.gif", "image/gif", two_frame_gif());
let media = p.add_media("a.gif", "image/gif", two_frame_gif()).expect("media");
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
@@ -653,7 +653,7 @@ fn a_background_is_laid_over_white() {
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200]))).write_to(&mut png, image::ImageFormat::Png).unwrap();
let mut p = deck_with(vec![]);
let media = p.add_media("bg.png", "image/png", png.into_inner());
let media = p.add_media("bg.png", "image/png", png.into_inner()).expect("media");
let pf = PictureFill { media, mode: PictureMode::Stretch { fill_rect: [0.5, 0.5, 0.0, 0.0] }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(pf) });
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
@@ -1149,7 +1149,7 @@ fn half_red_wmf() -> Vec<u8> {
#[test]
fn metafile_pictures_draw_their_vectors() {
let mut p = Presentation::default();
let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf());
let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf()).expect("media");
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
@@ -1174,7 +1174,7 @@ fn broken_metafiles_draw_nothing_and_never_panic() {
cases.push(huge);
for bytes in cases {
let mut p = Presentation::default();
let media = p.add_media("image1.emf", "image/x-emf", bytes);
let media = p.add_media("image1.emf", "image/x-emf", bytes).expect("media");
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
@@ -1195,7 +1195,7 @@ fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() {
};
for with_preview in [true, false] {
let mut p = Presentation::default();
let media = p.add_media("image1.png", "image/png", png.clone());
let media = p.add_media("image1.png", "image/png", png.clone()).expect("media");
let sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
@@ -1567,7 +1567,7 @@ fn a_picture_casts_its_shadow_from_its_pixels() {
};
for as_fill in [false, true] {
let mut p = Presentation::default();
let media = p.add_media("logo.png", "image/png", png.clone());
let media = p.add_media("logo.png", "image/png", png.clone()).expect("media");
let pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() };
if as_fill {
+4 -4
View File
@@ -265,7 +265,7 @@ fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
fn picture_deck(line: Option<Line>) -> Presentation {
let mut p = Presentation::default();
let media = p.add_media("image1.png", "image/png", png(8, 8, [0, 0, 255]));
let media = p.add_media("image1.png", "image/png", png(8, 8, [0, 0, 255])).expect("media");
let sh = Shape {
id: ShapeId(15),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
@@ -399,7 +399,7 @@ fn half_red_half_blue() -> Vec<u8> {
fn tiled(sx: f64, flip: &str, align: &str) -> Image {
use deckcraft_model::style::{PictureFill, PictureMode};
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue());
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx, sy: 10.0, flip: flip.into(), align: align.into() };
let sh = Shape {
id: ShapeId(21),
@@ -606,7 +606,7 @@ fn a_large_pictures_shadow_is_whole() {
let mut counts = vec![];
for side in [8u32, 400] {
let mut p = Presentation::default();
let media = p.add_media("image1.png", "image/png", png(side, side * 3 / 4, [0, 0, 255]));
let media = p.add_media("image1.png", "image/png", png(side, side * 3 / 4, [0, 0, 255])).expect("media");
let mut fx = offset_shadow();
if let Some(o) = &mut fx.outer_shadow {
o.blur = 8.0;
@@ -690,7 +690,7 @@ fn an_upright_picture_fill_stays_upright_in_a_turned_shape() {
let blue = Rgba::rgb(0, 0, 255);
for upright in [false, true] {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue());
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
// Half red (left) half blue picture in a 200 × 200 square turned a quarter (and so
// covering the same square).
let sh = Shape {
+1 -1
View File
@@ -7,7 +7,7 @@ use deckcraft_model::{LayoutType, Presentation, Xfrm};
fn setup(text: &str, w: f64, h: f64) -> (Presentation, Shape) {
let mut p = Presentation::default();
let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
let mut s = defaults::new_slide(&mut p, lay);
let mut s = defaults::new_slide(&mut p, lay).expect("slide");
let mut body = s.shapes.remove(1);
body.xfrm = Some(Xfrm::new(0.0, 0.0, w, h));
body.text = Some(TextBody::from_text(text));