mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-13 09:22:40 -06:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f67e268f | ||
|
|
069ed6e10d | ||
|
|
c6846f6a75 | ||
|
|
a3f2f215b0 | ||
|
|
e109f9f568 | ||
|
|
c262a91d1a | ||
|
|
bc770c21cc | ||
|
|
bbb1bd1036 | ||
|
|
95471c9c58 |
Generated
+24
-24
@@ -1038,7 +1038,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1060,7 +1060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1069,7 +1069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1078,14 +1078,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1095,14 +1095,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1128,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1140,7 +1140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1151,7 +1151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1159,7 +1159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1168,7 +1168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1177,7 +1177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1187,7 +1187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"craft-media",
|
||||
@@ -1203,7 +1203,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1212,7 +1212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1224,7 +1224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1242,7 +1242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1258,7 +1258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1277,7 +1277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1291,7 +1291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1317,7 +1317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1326,7 +1326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -6311,7 +6311,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -80,6 +80,12 @@ pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let opts = album_opts(p)?;
|
||||
let items = album_items(p)?;
|
||||
let (id, ids) = album_target(s, p)?;
|
||||
// The editor is seeded within the album limits; replacing the album from a trimmed seed would
|
||||
// delete what it left out, so refuse rather than lose it.
|
||||
let st = s.doc()?;
|
||||
if items_from_slides(&st.doc, &album_slides(st, &ids)).1 {
|
||||
return Err(bad("insert.photoAlbumEdit", "this album holds more than the editor can carry; editing it would delete the rest"));
|
||||
}
|
||||
commit_album(s, items, opts, Some((id, ids)))
|
||||
}
|
||||
|
||||
@@ -545,6 +551,19 @@ fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
|
||||
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
|
||||
}
|
||||
|
||||
/// Whether a slide holds anything besides album items: shapes, notes, comments, a background, a
|
||||
/// transition, animations, a colour map or tags.
|
||||
fn has_slide_content(s: &deckcraft_model::Slide) -> bool {
|
||||
!s.shapes.is_empty()
|
||||
|| !s.notes.text().trim().is_empty()
|
||||
|| !s.comments.is_empty()
|
||||
|| s.background.is_some()
|
||||
|| s.transition.is_some()
|
||||
|| !s.animations.is_empty()
|
||||
|| s.color_map.is_some()
|
||||
|| !s.tags.is_empty()
|
||||
}
|
||||
|
||||
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
|
||||
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
|
||||
}
|
||||
@@ -592,9 +611,16 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
|
||||
(layout, frame.into(), captions, any && gray, theme_now.to_string())
|
||||
}
|
||||
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
|
||||
/// The album's items for the dialog, within the limits `insert.photoAlbum` itself takes (items,
|
||||
/// bytes per picture and in all), checked before a picture is encoded: an album slide set can
|
||||
/// name one large picture many times.
|
||||
///
|
||||
/// The flag is true when something was left out for a limit.
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> (Vec<Value>, bool) {
|
||||
let mut items = Vec::new();
|
||||
for slide in slides {
|
||||
let mut total = 0usize;
|
||||
let mut omitted = false;
|
||||
'slides: for slide in slides {
|
||||
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
|
||||
if pics.is_empty() {
|
||||
let text = slide
|
||||
@@ -605,13 +631,27 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
||||
.find(|t| !t.trim().is_empty())
|
||||
.unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
if items.len() >= ALBUM_MAX_ITEMS {
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
items.push(json!({"text": text.trim()}));
|
||||
} else if items.len() >= ALBUM_MAX_ITEMS && has_slide_content(slide) {
|
||||
// Past the item limit, a slide with other content would be deleted by a replace.
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sh in pics {
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
let media = doc.media(fill.media);
|
||||
let size = media.map_or(0, |m| m.data.len());
|
||||
if items.len() >= ALBUM_MAX_ITEMS || size > ALBUM_MAX_BYTES || total.saturating_add(size) > ALBUM_MAX_TOTAL {
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
total += size;
|
||||
let name = if !sh.descr.is_empty() {
|
||||
sh.descr.clone()
|
||||
} else {
|
||||
@@ -626,14 +666,19 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
||||
}));
|
||||
}
|
||||
}
|
||||
items
|
||||
(items, omitted)
|
||||
}
|
||||
|
||||
/// The slides of album `ids` as the editor would seed them.
|
||||
fn album_slides<'a>(st: &'a crate::DocState, ids: &[u64]) -> Vec<&'a deckcraft_model::Slide> {
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect()
|
||||
}
|
||||
|
||||
fn album_seed(s: &Session) -> Result<Value> {
|
||||
let (id, ids) = album_target(s, &Value::Null)?;
|
||||
let st = s.doc()?;
|
||||
let slides: Vec<&deckcraft_model::Slide> =
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
|
||||
let slides = album_slides(st, &ids);
|
||||
let (items, _) = items_from_slides(&st.doc, &slides);
|
||||
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
|
||||
let inferred = infer_album(&slides, &theme_now);
|
||||
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
|
||||
@@ -655,7 +700,7 @@ fn album_seed(s: &Session) -> Result<Value> {
|
||||
"captions": captions,
|
||||
"grayscale": grayscale,
|
||||
"theme": theme,
|
||||
"items": items_from_slides(&st.doc, &slides),
|
||||
"items": items,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -722,3 +767,96 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Op
|
||||
s.execute("slide.go", &json!({"index": first}))?;
|
||||
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod seed_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reopening_an_album_seeds_at_most_the_items_an_album_takes() {
|
||||
// One picture named by 500 shapes: the dialog gets 48 items, not 500 encoded copies.
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("a.png", "image/png", vec![1; 64 * 1024]).unwrap();
|
||||
let pic = |k: u32| Shape {
|
||||
id: ShapeId(10_000 + k),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = (0..500).map(pic).collect();
|
||||
let slides: Vec<&deckcraft_model::Slide> = p.slides.iter().map(|s| s.as_ref()).collect();
|
||||
let (items, omitted) = items_from_slides(&p, &slides);
|
||||
assert_eq!(items.len(), ALBUM_MAX_ITEMS);
|
||||
assert!(omitted);
|
||||
let few: Vec<&deckcraft_model::Slide> = Vec::new();
|
||||
assert!(!items_from_slides(&p, &few).1);
|
||||
}
|
||||
|
||||
/// An album too big for the editor's seed cannot be replaced from that seed: the replace would
|
||||
/// delete what the seed left out.
|
||||
#[test]
|
||||
fn editing_an_album_the_editor_cannot_hold_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let n = doc.slides.len();
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
|
||||
}
|
||||
|
||||
/// Past the item limit, a slide holding only a drawing counts as content the replace would delete.
|
||||
#[test]
|
||||
fn editing_an_album_with_a_drawing_slide_past_the_limit_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
extra.shapes = vec![Shape { id: ShapeId(90_000), ..Default::default() }];
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
}
|
||||
|
||||
/// Notes or comments on a shapeless slide past the limit are content a replace would delete.
|
||||
#[test]
|
||||
fn editing_an_album_with_a_notes_only_slide_past_the_limit_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
extra.shapes.clear();
|
||||
extra.notes = deckcraft_model::text::TextBody::from_text("speaker notes");
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
}
|
||||
|
||||
/// A shapeless slide with a background, transition, colour map or tags counts as content; a plain empty one does not.
|
||||
#[test]
|
||||
fn slide_level_settings_count_as_slide_content() {
|
||||
use deckcraft_model::Slide;
|
||||
assert!(!has_slide_content(&Slide::default()));
|
||||
assert!(has_slide_content(&Slide { tags: vec![("k".into(), "v".into())], ..Default::default() }));
|
||||
assert!(has_slide_content(&Slide { color_map: Some(vec![("bg1".into(), "lt1".into())]), ..Default::default() }));
|
||||
assert!(has_slide_content(&Slide { transition: Some(Default::default()), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,8 @@ pub(crate) struct Canvas {
|
||||
saved: Vec<Saved>,
|
||||
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
|
||||
used: usize,
|
||||
/// Bytes retained outside the canvas (EMF+ objects) that count against the same cap.
|
||||
held: usize,
|
||||
objects: Vec<Option<Obj>>,
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
@@ -345,6 +347,7 @@ impl Canvas {
|
||||
viewport_set: false,
|
||||
saved: Vec::new(),
|
||||
used: 0,
|
||||
held: 0,
|
||||
objects: Vec::new(),
|
||||
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
|
||||
brush: Brush::new(BS_SOLID, [255, 255, 255, 255]),
|
||||
@@ -368,6 +371,17 @@ impl Canvas {
|
||||
self.surface = clip::normalize(rect);
|
||||
}
|
||||
|
||||
/// Bytes retained outside the canvas that share its budget (see [`Canvas::hold`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn held(&self) -> usize {
|
||||
self.held
|
||||
}
|
||||
|
||||
/// Bytes the canvas has retained so far.
|
||||
pub fn used(&self) -> usize {
|
||||
self.used
|
||||
}
|
||||
|
||||
/// True once the item cap is hit; the readers stop there and keep what they have.
|
||||
pub fn full(&self) -> bool {
|
||||
self.full
|
||||
@@ -385,6 +399,12 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the bytes another part of the player retains, so the items and they stay within
|
||||
/// [`MAX_BYTES`] together.
|
||||
pub fn hold(&mut self, bytes: usize) {
|
||||
self.held = bytes;
|
||||
}
|
||||
|
||||
fn push_raw(&mut self, mut raw: Raw) {
|
||||
if self.clip.as_ref().is_some_and(Vec::is_empty) {
|
||||
return;
|
||||
@@ -402,7 +422,7 @@ impl Canvas {
|
||||
Raw::Bitmap { img, .. } => img.px.len(),
|
||||
Raw::Text { text: t, .. } => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
|
||||
} + region_cost;
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
} else {
|
||||
self.used += cost;
|
||||
@@ -551,6 +571,10 @@ impl Canvas {
|
||||
/// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers
|
||||
/// the shape.
|
||||
pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) {
|
||||
// A full canvas or an empty clip keeps nothing more, so do not sample a tile for it.
|
||||
if self.full || self.clip.as_ref().is_some_and(Vec::is_empty) {
|
||||
return;
|
||||
}
|
||||
let dev: Vec<Op> = ops.iter().map(|o| self.map_op(*o)).collect();
|
||||
let Some([l, t, r, b]) = crate::cliprect::ops_bbox(&dev) else { return };
|
||||
let side = (r - l).max(b - t);
|
||||
@@ -666,7 +690,7 @@ impl Canvas {
|
||||
// A pattern brush's pixels count against the byte budget like drawn items.
|
||||
if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj {
|
||||
let cost = p.px.len();
|
||||
if self.used.saturating_add(cost) > MAX_BYTES {
|
||||
if self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,13 +288,23 @@ impl Plus {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
match typ {
|
||||
0x4008 => self.object(flags, r),
|
||||
let done = match typ {
|
||||
0x4008 => self.object(flags, r, cv.used().saturating_add(self.clip_bytes())),
|
||||
0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r),
|
||||
0x4025..=0x4035 => self.record_state(cv, typ, flags, r),
|
||||
0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r),
|
||||
_ => self.record_shape(cv, typ, flags, r),
|
||||
}
|
||||
};
|
||||
// What this player retains (objects, a continued object's pieces, the current and saved
|
||||
// clips) shares the canvas's byte budget; clip changes are charged as they happen.
|
||||
cv.hold(self.object_bytes.saturating_add(self.partial.as_ref().map_or(0, |p| p.data.len())).saturating_add(self.clip_bytes()));
|
||||
done
|
||||
}
|
||||
|
||||
/// Bytes of the current clip and of every saved one.
|
||||
fn clip_bytes(&self) -> usize {
|
||||
let area = |a: &Area| a.as_ref().map_or(0, |v| v.len().saturating_mul(std::mem::size_of::<P>()));
|
||||
self.stack.iter().map(|s| area(&s.clip)).fold(area(&self.clip), usize::saturating_add)
|
||||
}
|
||||
|
||||
/// Rectangle, polygon, ellipse and path records.
|
||||
@@ -559,7 +569,7 @@ impl Plus {
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn object(&mut self, flags: u16, r: Bytes) -> Option<()> {
|
||||
fn object(&mut self, flags: u16, r: Bytes, canvas_used: usize) -> Option<()> {
|
||||
let id = usize::from(flags & 0xFF);
|
||||
let kind = (flags >> 8) & 0x7F;
|
||||
if flags & 0x8000 != 0 {
|
||||
@@ -575,7 +585,7 @@ impl Plus {
|
||||
p.data.extend_from_slice(&chunk[..chunk.len().min(room)]);
|
||||
if p.data.len() >= p.total {
|
||||
let obj = parse_object(p.kind, Bytes(&p.data));
|
||||
self.store(p.id, obj);
|
||||
self.store(p.id, obj, canvas_used);
|
||||
} else {
|
||||
self.partial = Some(p);
|
||||
}
|
||||
@@ -589,22 +599,23 @@ impl Plus {
|
||||
let room = p.total.saturating_sub(p.data.len());
|
||||
p.data.extend_from_slice(&r.0[..r.0.len().min(room)]);
|
||||
let obj = parse_object(kind, Bytes(&p.data));
|
||||
self.store(id, obj);
|
||||
self.store(id, obj, canvas_used);
|
||||
return Some(());
|
||||
}
|
||||
let obj = parse_object(kind, r);
|
||||
self.store(id, obj);
|
||||
self.store(id, obj, canvas_used);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn store(&mut self, id: usize, obj: Option<Obj>) {
|
||||
/// `canvas_used` is what the canvas already retains: objects and items share one budget.
|
||||
fn store(&mut self, id: usize, obj: Option<Obj>, canvas_used: usize) {
|
||||
let Some(slot) = self.objects.get_mut(id) else { return };
|
||||
if let Some(old) = slot.take() {
|
||||
self.object_bytes = self.object_bytes.saturating_sub(Self::obj_cost(&old));
|
||||
}
|
||||
if let Some(new_obj) = obj {
|
||||
let cost = Self::obj_cost(&new_obj);
|
||||
if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES {
|
||||
if self.object_bytes.saturating_add(cost).saturating_add(canvas_used) <= crate::MAX_BYTES {
|
||||
self.object_bytes += cost;
|
||||
*slot = Some(new_obj);
|
||||
}
|
||||
@@ -615,7 +626,7 @@ impl Plus {
|
||||
match obj {
|
||||
Obj::Image(img) => match img.as_ref() {
|
||||
ImageData::Bitmap(rgba) => rgba.px.len(),
|
||||
ImageData::Meta(pic) => pic.items.len().saturating_mul(128),
|
||||
ImageData::Meta(pic) => picture_cost(&pic.items),
|
||||
},
|
||||
Obj::Brush(Fill::Texture { img, .. }) => img.px.len(),
|
||||
Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::<Op>()),
|
||||
@@ -1282,6 +1293,33 @@ fn metafile(r: Bytes) -> Option<Picture> {
|
||||
pic.filter(|p| p.width > 0.0 && p.height > 0.0)
|
||||
}
|
||||
|
||||
/// Bytes the items of a nested picture own: path segments, clip rectangles, pixels and text, with
|
||||
/// each shared pattern tile counted once.
|
||||
fn picture_cost(items: &[crate::Item]) -> usize {
|
||||
use crate::Item;
|
||||
let region = |r: &Option<Vec<[f32; 4]>>| r.as_ref().map_or(0, |v| v.len().saturating_mul(16));
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
let mut total = 0usize;
|
||||
for item in items {
|
||||
let own = match item {
|
||||
Item::Path { segs, region: r, .. } => segs.len().saturating_mul(std::mem::size_of::<crate::Seg>()).saturating_add(region(r)),
|
||||
Item::Bitmap { rgba, region: r, .. } => rgba.len().saturating_add(region(r)),
|
||||
Item::Pattern { segs, tile, region: r, .. } => {
|
||||
let tile_bytes = if tiles.insert(Arc::as_ptr(tile)) { tile.rgba.len() } else { 0 };
|
||||
segs.len().saturating_mul(std::mem::size_of::<crate::Seg>()).saturating_add(tile_bytes).saturating_add(region(r))
|
||||
}
|
||||
Item::Text(t) => t
|
||||
.text
|
||||
.len()
|
||||
.saturating_add(t.dx.len().saturating_mul(std::mem::size_of::<f32>()))
|
||||
.saturating_add(t.font.family.len())
|
||||
.saturating_add(region(&t.region)),
|
||||
};
|
||||
total = total.saturating_add(128).saturating_add(own);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn bitmap(r: Bytes) -> Option<Rgba> {
|
||||
let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32);
|
||||
let format = r.u32(20)?;
|
||||
@@ -1559,3 +1597,45 @@ fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec<Vec<u16>> {
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cost_tests {
|
||||
use super::*;
|
||||
use crate::{Item, Seg, Tile};
|
||||
|
||||
#[test]
|
||||
fn a_shared_pattern_tile_is_charged_once() {
|
||||
let tile = Arc::new(Tile { width: 512, height: 512, rgba: vec![0; 1 << 20], cell: [1.0, 1.0], origin: [0.0, 0.0] });
|
||||
let item = || Item::Pattern { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)], even_odd: false, tile: tile.clone(), region: None };
|
||||
let items: Vec<Item> = (0..257).map(|_| item()).collect();
|
||||
assert!(picture_cost(&items) < 2 << 20, "{}", picture_cost(&items));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_of_a_continued_object_count_against_the_shared_budget() {
|
||||
let (mut plus, mut cv) = (Plus::new(), Canvas::new());
|
||||
plus.active = true;
|
||||
// Image object (kind 5), id 0, continued: 1000 bytes announced, 100 delivered.
|
||||
let mut rec = 1000u32.to_le_bytes().to_vec();
|
||||
rec.extend([0u8; 100]);
|
||||
plus.record(&mut cv, 0x4008, 0x8000 | (5 << 8), Bytes(&rec));
|
||||
assert_eq!(cv.held(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_and_saved_clips_count_against_the_shared_budget() {
|
||||
let (mut plus, mut cv) = (Plus::new(), Canvas::new());
|
||||
plus.active = true;
|
||||
let pts = vec![(0.0, 0.0); 1000];
|
||||
plus.clip = Some(pts.clone());
|
||||
plus.stack.push(Saved { id: 1, world: IDENTITY, page: 1.0, clip: Some(pts) });
|
||||
// Any record refreshes the held count (an unknown record draws nothing).
|
||||
plus.record(&mut cv, 0x40FF, 0, Bytes(&[]));
|
||||
assert_eq!(cv.held(), 2 * 1000 * std::mem::size_of::<P>());
|
||||
// Restoring the saved state releases the charge of what it dropped.
|
||||
plus.stack.clear();
|
||||
plus.clip = None;
|
||||
plus.record(&mut cv, 0x40FF, 0, Bytes(&[]));
|
||||
assert_eq!(cv.held(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ impl Picture {
|
||||
let k = (c[0].abs() + c[3].abs()) / 2.0;
|
||||
let mut out = Vec::with_capacity(self.items.len());
|
||||
let mut total_elements = 0usize;
|
||||
let region_elements = std::cell::Cell::new(0usize);
|
||||
let mut cached_region: Option<(*const [f32; 4], usize, Option<Option<Arc<BezPath>>>)> = None;
|
||||
let mut get_region = |reg: &Option<Vec<[f32; 4]>>| -> Option<Option<Arc<BezPath>>> {
|
||||
let (ptr, len) = match reg {
|
||||
@@ -71,12 +72,15 @@ impl Picture {
|
||||
return res.clone();
|
||||
}
|
||||
let res = placed_region(&tf, reg);
|
||||
// Each distinct clip region is expanded once; its elements count against the placement budget.
|
||||
let n = res.as_ref().and_then(Option::as_ref).map_or(0, |p| p.elements().len());
|
||||
region_elements.set(region_elements.get().saturating_add(n));
|
||||
cached_region = Some((ptr, len, res.clone()));
|
||||
res
|
||||
};
|
||||
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
if total_elements >= MAX_PLACED_ELEMENTS {
|
||||
if total_elements.saturating_add(region_elements.get()) >= MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
match item {
|
||||
@@ -84,6 +88,10 @@ impl Picture {
|
||||
let path = tf * bez(segs);
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
if in_range(path.bounding_box()) {
|
||||
// This item's own region is expanded by now: it counts before it is kept.
|
||||
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region });
|
||||
@@ -97,6 +105,9 @@ impl Picture {
|
||||
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
|
||||
let det = tile.determinant();
|
||||
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
|
||||
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region });
|
||||
}
|
||||
|
||||
@@ -897,6 +897,24 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() {
|
||||
assert_eq!(stroke.map(|s| s.1), Some(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_counts_the_items_own_clip_region_before_keeping_it() {
|
||||
// A path of 499,998 elements nearly fills the 500,000 budget; a second one whose new clip
|
||||
// region adds elements goes past it and is left out.
|
||||
let mut segs = vec![Seg::Move(0.0, 0.0)];
|
||||
segs.extend((0..499_997).map(|i| Seg::Line(1.0 + (i % 50) as f32, 1.0)));
|
||||
let big = Item::Path { segs, fill: None, stroke: None, even_odd: false, region: None };
|
||||
let small = Item::Path {
|
||||
segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)],
|
||||
fill: None,
|
||||
stroke: None,
|
||||
even_odd: false,
|
||||
region: Some(vec![[0.0, 0.0, 50.0, 25.0], [10.0, 10.0, 20.0, 20.0]]),
|
||||
};
|
||||
let pic = crate::Picture { width: 100.0, height: 50.0, items: vec![big, small] };
|
||||
assert_eq!(pic.place(kurbo::Rect::new(0.0, 0.0, 100.0, 50.0), [0.0; 4]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_keeps_only_finite_bounded_bitmaps_with_area() {
|
||||
let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4], region: None };
|
||||
@@ -2155,3 +2173,24 @@ fn region_intersection_over_budget_keeps_the_previous_area() {
|
||||
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 10).is_none());
|
||||
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut crate::cliprect::MAX_AREA_CLIP_WORK.clone()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_canvas_does_not_sample_gradient_tiles() {
|
||||
let mut cv = Canvas::new();
|
||||
let big = || Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; MAX_BYTES / 2] };
|
||||
for _ in 0..3 {
|
||||
cv.bitmap([0.0, 0.0, 1.0, 1.0], big());
|
||||
}
|
||||
assert!(cv.full());
|
||||
cv.shade_shape(&crate::geom::rect(0.0, 0.0, 10.0, 10.0), &|_| panic!("sampled a full canvas"));
|
||||
assert_eq!(cv.items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_budget_counts_bytes_held_elsewhere() {
|
||||
let mut cv = Canvas::new();
|
||||
cv.hold(MAX_BYTES - 100);
|
||||
cv.bitmap([0.0, 0.0, 1.0, 1.0], Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; 200] });
|
||||
assert!(cv.full());
|
||||
assert!(cv.items.is_empty());
|
||||
}
|
||||
|
||||
+105
-9
@@ -15,6 +15,14 @@ use crate::xml::{A, El, W};
|
||||
|
||||
const DEG: f64 = 60_000.0;
|
||||
|
||||
/// Distinct path coordinate spaces whose guides are evaluated for one custom geometry; paths in
|
||||
/// further spaces are left out.
|
||||
const MAX_SPACES: usize = 16;
|
||||
|
||||
/// Guide-name bytes all cached spaces may hold between them; the first space counts towards it but is always kept.
|
||||
/// Every space copies the names, which the XML limits bound only once.
|
||||
const MAX_SPACE_NAME_BYTES: usize = 4 << 20;
|
||||
|
||||
/// Guide variables for one shape.
|
||||
pub struct Guides {
|
||||
vars: HashMap<String, f64>,
|
||||
@@ -66,6 +74,10 @@ impl Guides {
|
||||
set("7cd8", 18_900_000.0);
|
||||
Guides { vars: v }
|
||||
}
|
||||
/// Bytes of the guide names held.
|
||||
fn name_bytes(&self) -> usize {
|
||||
self.vars.keys().map(String::len).sum()
|
||||
}
|
||||
pub fn get(&self, name: &str) -> f64 {
|
||||
let name = name.trim();
|
||||
if let Some(v) = self.vars.get(name) {
|
||||
@@ -149,16 +161,51 @@ fn pt(g: &Guides, e: &El) -> (f64, f64) {
|
||||
/// Convert `a:custGeom` to custom paths. `w`/`h` is the shape size in EMU (used for guides and for
|
||||
/// paths that don't give their own coordinate space).
|
||||
pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
read_cust_geom_counting(cg, w_emu, h_emu).0
|
||||
}
|
||||
|
||||
/// [`read_cust_geom`], and how many paths were left out for lack of room in the guide caches
|
||||
/// (`(too many coordinate spaces, too many guide-name bytes)`); each cause is also logged once.
|
||||
fn read_cust_geom_counting(cg: &El, w_emu: f64, h_emu: f64) -> (Vec<CustomPath>, (usize, usize)) {
|
||||
let mut out = vec![];
|
||||
let Some(pl) = cg.child("pathLst") else { return out };
|
||||
let mut dropped = (0usize, 0usize);
|
||||
let Some(pl) = cg.child("pathLst") else { return (out, dropped) };
|
||||
let mut spaces: HashMap<(u64, u64), Guides> = HashMap::new();
|
||||
let mut name_bytes = 0usize;
|
||||
let mut budget_full = false;
|
||||
// The cap on cached spaces saves guide evaluation: a geometry without guides has none to save.
|
||||
let guided = ["avLst", "gdLst"].iter().any(|n| cg.child(n).is_some_and(|l| l.elements().next().is_some()));
|
||||
for p in pl.children_named("path").take(1000) {
|
||||
let pw = p.f64("w").unwrap_or(0.0);
|
||||
let ph = p.f64("h").unwrap_or(0.0);
|
||||
// Guides are evaluated in the path's own space when it has one.
|
||||
let (gw, gh) = if pw > 0.0 || ph > 0.0 { (pw, ph) } else { (w_emu, h_emu) };
|
||||
let mut g = Guides::new(gw, gh);
|
||||
g.eval_list(cg.child("avLst"));
|
||||
g.eval_list(cg.child("gdLst"));
|
||||
// The guide lists are evaluated once per coordinate space, not once per path.
|
||||
let key = (gw.to_bits(), gh.to_bits());
|
||||
if !spaces.contains_key(&key) {
|
||||
if guided && (budget_full || spaces.len() >= MAX_SPACES) {
|
||||
if budget_full {
|
||||
dropped.1 += 1;
|
||||
} else {
|
||||
dropped.0 += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut g = Guides::new(gw, gh);
|
||||
g.eval_list(cg.child("avLst"));
|
||||
g.eval_list(cg.child("gdLst"));
|
||||
// The first space is always kept; later ones only while the cache stays within budget.
|
||||
let bytes = g.name_bytes();
|
||||
if guided && !spaces.is_empty() && name_bytes.saturating_add(bytes) > MAX_SPACE_NAME_BYTES {
|
||||
// Every space holds the same names, so no later space fits either.
|
||||
budget_full = true;
|
||||
dropped.1 += 1;
|
||||
continue;
|
||||
}
|
||||
name_bytes = name_bytes.saturating_add(bytes);
|
||||
spaces.insert(key, g);
|
||||
}
|
||||
let Some(g) = spaces.get(&key) else { continue };
|
||||
let mut d = String::new();
|
||||
let mut cur = (0.0, 0.0);
|
||||
let mut start = (0.0, 0.0);
|
||||
@@ -166,7 +213,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
match c.local() {
|
||||
"moveTo" => {
|
||||
if let Some(q) = c.child("pt") {
|
||||
cur = pt(&g, q);
|
||||
cur = pt(g, q);
|
||||
start = cur;
|
||||
d.push_str("M ");
|
||||
num(&mut d, cur.0);
|
||||
@@ -177,7 +224,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
"lnTo" => {
|
||||
if let Some(q) = c.child("pt") {
|
||||
cur = pt(&g, q);
|
||||
cur = pt(g, q);
|
||||
d.push_str("L ");
|
||||
num(&mut d, cur.0);
|
||||
d.push(' ');
|
||||
@@ -186,7 +233,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
}
|
||||
"cubicBezTo" => {
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(&g, q)).collect();
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(g, q)).collect();
|
||||
if let [a, b, e] = pts.as_slice() {
|
||||
d.push_str("C ");
|
||||
for (x, y) in [a, b, e] {
|
||||
@@ -199,7 +246,7 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
}
|
||||
}
|
||||
"quadBezTo" => {
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(&g, q)).collect();
|
||||
let pts: Vec<(f64, f64)> = c.children_named("pt").map(|q| pt(g, q)).collect();
|
||||
if let [a, e] = pts.as_slice() {
|
||||
d.push_str("Q ");
|
||||
for (x, y) in [a, e] {
|
||||
@@ -236,7 +283,16 @@ pub fn read_cust_geom(cg: &El, w_emu: f64, h_emu: f64) -> Vec<CustomPath> {
|
||||
let (ow, oh) = if pw > 0.0 || ph > 0.0 { (pw, ph) } else { (w_emu.max(1.0), h_emu.max(1.0)) };
|
||||
out.push(CustomPath { w: ow, h: oh, d: d.trim_end().to_string(), fill, stroke: p.bool("stroke").unwrap_or(true) });
|
||||
}
|
||||
out
|
||||
if dropped.0 > 0 {
|
||||
log::warn!("pptx: a custom geometry uses more than {MAX_SPACES} distinct path coordinate spaces; {} later path(s) are not kept", dropped.0);
|
||||
}
|
||||
if dropped.1 > 0 {
|
||||
log::warn!(
|
||||
"pptx: a custom geometry's guide names pass {MAX_SPACE_NAME_BYTES} bytes across its coordinate spaces; {} later path(s) are not kept",
|
||||
dropped.1
|
||||
);
|
||||
}
|
||||
(out, dropped)
|
||||
}
|
||||
|
||||
/// Ellipse parametric angle for a visual angle `a` (radians).
|
||||
@@ -470,6 +526,46 @@ mod tests {
|
||||
assert_eq!(paths[1].d, "M 1 1 Q 2 2 3 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_share_guides_and_distinct_spaces_are_capped() {
|
||||
let path = |w: usize| format!(r#"<a:path w="{w}" h="10"><a:moveTo><a:pt x="mid" y="1"/></a:moveTo></a:path>"#);
|
||||
let paths: String = (1..=40).map(|i| path(10 * i)).collect();
|
||||
let x = format!(
|
||||
r#"<a:custGeom xmlns:a="x"><a:gdLst><a:gd name="mid" fmla="*/ w 1 2"/></a:gdLst><a:pathLst>{paths}{}</a:pathLst></a:custGeom>"#,
|
||||
path(10)
|
||||
);
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
let (out, dropped) = read_cust_geom_counting(&d.root, 1.0, 1.0);
|
||||
assert_eq!(out.len(), MAX_SPACES + 1, "16 spaces plus the repeat of the first");
|
||||
assert_eq!(dropped, (40 - MAX_SPACES, 0), "the paths of later spaces are counted, to be warned about");
|
||||
assert!(out[0].d.starts_with("M 5 1"), "{}", out[0].d);
|
||||
assert!(out[MAX_SPACES].d.starts_with("M 5 1"), "{}", out[MAX_SPACES].d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_name_bytes_across_spaces_are_budgeted() {
|
||||
let name = "n".repeat(3 << 20);
|
||||
let path = |w: usize| format!(r#"<a:path w="{w}" h="10"><a:moveTo><a:pt x="{name}" y="1"/></a:moveTo></a:path>"#);
|
||||
let x = format!(
|
||||
r#"<a:custGeom xmlns:a="x"><a:gdLst><a:gd name="{name}" fmla="*/ w 1 2"/></a:gdLst><a:pathLst>{}{}{}</a:pathLst></a:custGeom>"#,
|
||||
path(10),
|
||||
path(20),
|
||||
path(10)
|
||||
);
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
let (out, dropped) = read_cust_geom_counting(&d.root, 1.0, 1.0);
|
||||
assert_eq!(out.len(), 2, "the second 3 MiB space is over budget and left out; the first space's repeat stays");
|
||||
assert_eq!(dropped, (0, 1), "the path left out is counted, to be warned about");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_free_geometries_are_not_capped_by_space() {
|
||||
let paths: String = (1..=40).map(|i| format!(r#"<a:path w="{}" h="10"><a:moveTo><a:pt x="1" y="1"/></a:moveTo></a:path>"#, 10 * i)).collect();
|
||||
let x = format!(r#"<a:custGeom xmlns:a="x"><a:avLst/><a:gdLst/><a:pathLst>{paths}</a:pathLst></a:custGeom>"#);
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
assert_eq!(read_cust_geom(&d.root, 1.0, 1.0).len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_path() {
|
||||
let p = CustomPath { w: 100.0, h: 50.0, d: "M 0 0 L 100 0 C 100 10 90 50 50 50 Q 0 50 0 0 Z".into(), fill: FillMode::Norm, stroke: true };
|
||||
|
||||
Reference in New Issue
Block a user