diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..40d5dc38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 0c91bbaa..025c2f41 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -177,7 +177,7 @@ fn group(s: &mut Session, p: &Value) -> Result { let xs: Vec = 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, diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 3cad3f00..72c996f8 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -177,7 +177,7 @@ fn background(s: &mut Session, p: &Value) -> Result { } 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 { }; 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 { 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 { 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})) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30437b15..3154a2b3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -126,33 +126,34 @@ fn cut(s: &mut Session, p: &Value) -> Result { } /// 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 { @@ -174,12 +175,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { 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 { 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 { let list = super::current_shapes(doc, sel).cloned().unwrap_or_default(); let mut copies: Vec = 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; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..1d49af74 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -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 { +pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result { 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 { 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 { 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 { } _ => [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 { })?; 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 { } } } - 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 { } 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 { }); 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 { } } } - 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 { 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 { } } 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) { diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 855370ad..d9a3147d 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -139,7 +139,7 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result { 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); diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index 4c9a6d28..25573da7 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -176,7 +176,7 @@ fn merge(s: &mut Session, p: &Value) -> Result { 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"))?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..50c2e4a6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..af66cc20 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -284,7 +284,7 @@ fn fill(s: &mut Session, p: &Value) -> Result { } 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 { fn picture_change(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 3d8ad344..2c427301 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -99,7 +99,7 @@ fn new(s: &mut Session, p: &Value) -> Result { } } }; - 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 { 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"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..1e3fe1b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..a42905b9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -929,7 +929,7 @@ impl Session { let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default(); let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let boxes: Vec = 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 { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1b37a232..1a12fcff 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -114,8 +114,9 @@ pub fn load(bytes: &[u8]) -> Result { 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); diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 8432ce4a..82467b32 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -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 { + let id = SlideId(p.alloc_id()?); let mut shapes = vec![]; let lay_shapes: Vec = 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. diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..7f647842 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -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 { + 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) -> MediaId { + pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec) -> Option { 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 { diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 2c3b4ced..1dc3c559 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -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); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 305f1457..e85b6f18 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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() { diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..09591500 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -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(), diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index e5955c27..98069d11 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -36,8 +36,8 @@ fn png() -> Vec { /// A deck with one shape filled with a stretched picture, exported. fn deck() -> Vec { 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); diff --git a/crates/pptx/tests/locks.rs b/crates/pptx/tests/locks.rs index 2e903e60..60e0d874 100644 --- a/crates/pptx/tests/locks.rs +++ b/crates/pptx/tests/locks.rs @@ -15,14 +15,14 @@ fn png() -> Vec { } 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 }, diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..fa24da06 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -27,12 +27,12 @@ fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { } 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)]) -> (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(), diff --git a/crates/pptx/tests/table_styles.rs b/crates/pptx/tests/table_styles.rs index 015543f2..4a2627af 100644 --- a/crates/pptx/tests/table_styles.rs +++ b/crates/pptx/tests/table_styles.rs @@ -20,7 +20,7 @@ fn style_xml() -> String { /// An exported deck with a 3x2 table using the probe style. fn table_deck() -> Vec { 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(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..721b0191 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -361,7 +361,7 @@ fn two_frame_gif() -> Vec { #[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 { #[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 { diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 7e7f0eac..84ee0142 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -265,7 +265,7 @@ fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { fn picture_deck(line: Option) -> 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 { 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 { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1e2be177..b9aee025 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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));