Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor ac3fdf8b42 Merge main; pin craft-media and craft-audio at craft-libs main (75895c03)
craft-libs #810 merged, so the two crates no longer need its branch head.
The other craft-libs pins are main's (6e03f65b).
2026-11-11 19:27:53 +00:00
Dylan M. Taylor 4c7e7446ab Media: Opus, Ogg, MP4 and Matroska come from craft-libs
DeckCraft kept its own Opus decoder (crates/opus) and Ogg demuxer
(crates/ogg), and two crates that only re-exported craft-media
(crates/isobmff, crates/matroska). craft-libs has all four: craft-audio's
opus module and craft-media's ogg, isobmff and matroska modules. The four
crates are removed and crates/media depends on craft-audio and craft-media
directly.

craft-media and craft-audio are pinned at 113f2360, the head of craft-libs
#810, which carries the two Ogg fixes DeckCraft made to its own copy
(51d19baa, 9d36b593). The pin moves to craft-libs main once #810 merges.
craft-pdf, craft-update and craft-ui keep their pins.

One API difference: craft-media's OpusTiming::of saturates on a hostile
granule position instead of returning an error. The end-trim product in
the Ogg Opus decode now saturates as well; a test with a three-channel
file and the largest granule panicked on it without that.

crates/bitstream stays: it also holds the video decoders' memory budget,
which craft-libs does not have.
2026-11-11 18:47:53 +00:00
38 changed files with 190 additions and 1478 deletions
-5
View File
@@ -1,10 +1,6 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "16 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -34,7 +30,6 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: deckcraft
app-title: DeckCraft
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
Generated
+24 -24
View File
@@ -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
View File
@@ -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"
+5 -55
View File
@@ -118,8 +118,6 @@ enum Output {
grain: FilmGrainParams,
apply_film_grain: bool,
pts: i64,
/// What copying the picture out costs, in the reference's own format.
bytes: u64,
},
}
@@ -134,7 +132,7 @@ impl Output {
fn take(self) -> Result<Picture> {
match self {
Output::Decoded(s) => s.wait().map(|p| Arc::try_unwrap(p).unwrap_or_else(|p| (*p).clone())),
Output::Existing { slot, seq, width, height, grain, apply_film_grain, pts, .. } => {
Output::Existing { slot, seq, width, height, grain, apply_film_grain, pts } => {
let rf = slot.wait()?;
Ok(output_picture(&seq, &rf, width, height, &grain, apply_film_grain, pts))
}
@@ -381,8 +379,8 @@ impl Decoder {
// Output policy (7.18.1 note): with scalability, show one frame per temporal unit, the
// highest spatial layer present.
let scalable = self.seq.as_ref().is_some_and(|s| s.op_idc != 0);
let kept: Vec<Output> = if !scalable {
out.into_iter().map(|(o, _, _)| o).collect()
if !scalable {
self.out.extend(out.into_iter().map(|(o, _, _)| o));
} else {
let mut last: Option<(usize, u32)> = None;
let mut sel: Vec<Output> = Vec::new();
@@ -402,27 +400,8 @@ impl Decoder {
}
}
}
sel
};
// Every repeat is copied out whole, so the repeats this call keeps (after the layer
// selection) must not queue more than half the decoder budget; the first one over it and
// everything after is dropped.
let mut r = r;
let mut repeat_bytes = 0u64;
let mut ok = Vec::with_capacity(kept.len());
for o in kept {
if let Output::Existing { bytes, .. } = &o {
repeat_bytes = repeat_bytes.saturating_add(*bytes);
if repeat_bytes > self.memory_budget / 2 {
if r.is_ok() {
r = Err(Error::Invalid("repeated frame outputs exceed the decoder memory budget"));
}
break;
}
}
ok.push(o);
self.out.extend(sel);
}
self.out.extend(ok);
r
}
@@ -524,15 +503,6 @@ impl Decoder {
let idx = fh.frame_to_show_map_idx;
let slot = self.ref_frames[idx].clone().ok_or(Error::Invalid("show_existing_frame of an empty slot"))?;
let info = self.refs[idx].clone();
// Sized in the reference's own format: a new sequence header may differ from it.
let shape = deckcraft_bitstream::budget::PictureShape {
width: u64::from(info.upscaled_width),
height: u64::from(info.frame_height),
shift_x: u32::from(info.subsampling_x),
shift_y: u32::from(info.subsampling_y),
chroma_planes: if info.mono_chrome { 0 } else { 2 },
bytes_per_sample: 2,
};
let o = Output::Existing {
slot: slot.clone(),
seq: seq.clone(),
@@ -541,7 +511,6 @@ impl Decoder {
grain: fh.film_grain.clone(),
apply_film_grain: self.apply_film_grain,
pts: self.pts,
bytes: shape.bytes().unwrap_or(u64::MAX),
};
if fh.frame_type == KEY_FRAME as u8 {
// reference frame loading process (7.21) then refresh every slot (7.20)
@@ -660,9 +629,6 @@ impl Decoder {
ri.lf_deltas = fh.lf.deltas;
ri.seg_features = fh.seg.features;
ri.grain = fh.film_grain.clone();
ri.subsampling_x = job.seq.color.subsampling_x;
ri.subsampling_y = job.seq.color.subsampling_y;
ri.mono_chrome = job.seq.color.mono_chrome;
self.ref_frames[i] = Some(job.slot.clone());
}
}
@@ -811,14 +777,6 @@ fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<
Ok((rf, pic))
}
/// Bytes all parallel tile states may hold of whole-frame CDEF and restoration metadata; past it
/// the tiles decode one after another on the frame's own metadata.
const TILE_METADATA_BUDGET: usize = 256 << 20;
fn tile_metadata_fits(tiles: usize, per_tile: usize) -> bool {
tiles.checked_mul(per_tile).is_some_and(|n| n <= TILE_METADATA_BUDGET)
}
/// Decode the tiles of a frame (tile number, data), in parallel when several; returns the final
/// CDFs of tile context_update_tile_id.
fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize, Vec<u8>)]) -> Result<Option<Box<Cdfs>>> {
@@ -832,7 +790,7 @@ fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize,
};
// Intra block copy reads the current frame through the tile's own buffer: keep whole-frame
// buffers (tiles one after another) for it.
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc && tile_metadata_fits(tiles.len(), fs.tile_metadata_bytes()) {
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc {
let mut states: Vec<TileState> = tiles
.iter()
.map(|(num, _)| {
@@ -1117,14 +1075,6 @@ mod guard_tests {
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn tile_metadata_copies_are_budgeted() {
assert!(tile_metadata_fits(64, 1 << 20));
assert!(!tile_metadata_fits(4096, 1 << 20));
assert!(!tile_metadata_fits(usize::MAX, 2));
}
use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET;
fn color(mono: bool) -> crate::header::ColorConfig {
-4
View File
@@ -360,10 +360,6 @@ pub struct RefInfo {
pub lf_deltas: LoopFilterDeltas,
pub seg_features: SegmentationFeatures,
pub grain: FilmGrainParams,
/// The colour format the frame was decoded in (a later sequence header may change it).
pub subsampling_x: u8,
pub subsampling_y: u8,
pub mono_chrome: bool,
}
/// A parsed uncompressed frame header with its derived variables.
-7
View File
@@ -253,13 +253,6 @@ impl FrameState {
self.lr = t.lr;
}
/// Bytes of CDEF and loop-restoration metadata each parallel tile state copies whole.
pub fn tile_metadata_bytes(&self) -> usize {
let lr = &self.lr;
// 1 (type) + 6 (Wiener) + 1 (SGR set) + 2 (SGR weights) bytes per unit.
self.cdef.len() + lr.lr_type.iter().map(|v| v.len() * 10).sum::<usize>()
}
/// Private buffers for the tile covering mode info rows [r0, r1) x columns [c0, c1); tiles
/// on the right / bottom frame edge also cover the allocation margins.
pub fn tile_state(&self, r0: usize, r1: usize, c0: usize, c1: usize) -> TileState {
+6 -144
View File
@@ -80,12 +80,6 @@ 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)))
}
@@ -551,19 +545,6 @@ 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()
}
@@ -611,16 +592,9 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
(layout, frame.into(), captions, any && gray, theme_now.to_string())
}
/// 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) {
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
let mut items = Vec::new();
let mut total = 0usize;
let mut omitted = false;
'slides: for slide in 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
@@ -631,27 +605,13 @@ 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 {
@@ -666,19 +626,14 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
}));
}
}
(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()
items
}
fn album_seed(s: &Session) -> Result<Value> {
let (id, ids) = album_target(s, &Value::Null)?;
let st = s.doc()?;
let slides = album_slides(st, &ids);
let (items, _) = items_from_slides(&st.doc, &slides);
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 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));
@@ -700,7 +655,7 @@ fn album_seed(s: &Session) -> Result<Value> {
"captions": captions,
"grayscale": grayscale,
"theme": theme,
"items": items,
"items": items_from_slides(&st.doc, &slides),
}))
}
@@ -767,96 +722,3 @@ 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() }));
}
}
+14 -26
View File
@@ -85,20 +85,14 @@ impl AutomationRoots {
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
self.check_path(Path::new(path), access)
}
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
let path_text = path.display();
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
};
}
let resolved = resolve(path)?;
@@ -107,8 +101,8 @@ impl AutomationRoots {
}
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
match access {
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
}
}
}
@@ -197,23 +191,17 @@ pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
}
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
pub fn check_write_path(path: &Path) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &Path) -> Result<PathBuf, String> {
if path.as_os_str().is_empty() {
fn resolve(path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
return Err("automation path rejected: path is empty".into());
}
let shown = path.display();
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
@@ -224,11 +212,11 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
},
}
let parent = at.parent();
@@ -238,12 +226,12 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
}
}
}
-16
View File
@@ -699,8 +699,6 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
return Ok(());
}
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
// A confined automation client may write only inside its write roots, this file included.
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
if let Some(d) = path.parent() {
std::fs::create_dir_all(d)?;
}
@@ -1022,20 +1020,6 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]);
}
#[test]
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
set_user_dictionary_path(outside.join("user.dic"));
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
assert!(!outside.exists(), "the refused write created nothing");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
use deckcraft_model::{Paragraph, Run, RunProps};
+2 -25
View File
@@ -169,14 +169,8 @@ impl FontDb {
if bundled.contains(fam) {
continue;
}
if self.is_loaded(fam) || (self.system_fallback_enabled() && self.has_family(fam)) {
// With system fallback off, only a face that is already loaded: `face` could load more catalog files.
let f = if self.system_fallback_enabled() {
self.face(fam, style)
} else {
let Some(f) = self.find(fam, style) else { continue };
f
};
if self.is_loaded(fam) || self.has_family(fam) {
let f = self.face(fam, style);
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
@@ -317,21 +311,4 @@ mod tests {
assert!(c.windows(2).all(|w| w[0] <= w[1]));
}
}
/// With system fallback off, the automatic cascades (script families and symbol faces) load no
/// installed family: the result comes from what is already loaded.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cascades_load_no_installed_family_when_system_fallback_is_off() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let base = db.face(crate::DEFAULT_FAMILY, "Regular");
let loaded = |db: &FontDb| [b"Arab", b"Hebr", b"Thai", b"Deva"].into_iter().flat_map(|s| os_families(*s)).filter(|f| db.is_loaded(f)).count();
let before = loaded(&db);
for c in ['\u{0628}', '\u{05D0}', '\u{0E01}', '\u{0905}', '\u{2211}', '\u{2603}'] {
let f = db.cascade(&[c], std::slice::from_ref(&base), "Regular");
assert!(!f.family.is_empty());
}
assert_eq!(loaded(&db), before, "no installed family was loaded");
}
}
+2 -14
View File
@@ -976,17 +976,6 @@ impl FontDb {
let _ = on;
}
/// Whether automatic system-font lookups (fallback by character, symbol and script families)
/// may touch installed fonts. Lookups of a family the document or host names are not gated.
pub(crate) fn system_fallback_enabled(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled
}
#[cfg(target_arch = "wasm32")]
false
}
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
@@ -1298,7 +1287,7 @@ impl FontDb {
Some(f)
}
pub(crate) fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
if style.contains('{') {
{
let faces = self.read_faces();
@@ -1484,8 +1473,7 @@ impl FontDb {
};
#[cfg(not(target_arch = "wasm32"))]
{
let cataloged: Vec<String> =
if self.system_fallback_enabled() { self.read_catalog().iter().map(|e| e.family.clone()).collect() } else { Vec::new() };
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
self.load_cataloged(fam);
-8
View File
@@ -425,11 +425,3 @@ fn the_font_scan_skips_fifos_folders_and_link_loops() {
assert!(fifo || n >= 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn disabling_system_fallback_gates_automatic_family_loads() {
let db = FontDb::with_font_dirs(Vec::new());
assert!(cfg!(target_arch = "wasm32") || db.system_fallback_enabled());
db.set_system_fallback(false);
assert!(!db.system_fallback_enabled());
}
+2 -28
View File
@@ -404,36 +404,10 @@ impl Decoder {
chroma_planes: if chroma == 0 { 0 } else { 2 },
bytes_per_sample: 2,
};
let frame_bytes = |f: &crate::picture::Frame| {
(f.width as u64)
.saturating_mul(f.height as u64)
.saturating_add((f.cwidth as u64).saturating_mul(f.cheight as u64).saturating_mul(2))
.saturating_mul(2)
};
// Output frames waiting to be returned stay allocated after they leave the DPB, at their
// own size: a resolution change in this call can leave pictures of the previous SPS.
let queued: u64 = self
.out_queue
.iter()
.filter(|o| !self.dpb.entries.iter().any(|e| Arc::ptr_eq(&e.frame, &o.frame)) && !self.in_flight.iter().any(|f| Arc::ptr_eq(f, &o.frame)))
.map(|o| frame_bytes(&o.frame))
.fold(0, u64::saturating_add);
// Frames the DPB holds now count at their own size too (an SPS change flushes them into the
// queue before the new pictures are made): the DPB part is the larger of its new slots and that.
let held: u64 = self.dpb.entries.iter().map(|e| frame_bytes(&e.frame)).fold(0, u64::saturating_add);
// A queued frame still being decoded is one of the in-flight pictures already reserved, so the
// queue and the in-flight reservation overlap: charge the larger, not both.
let pictures = u64::from(sps.max_dec_pic_buffering) + 1 + self.max_in_flight as u64;
let slots = shape.store_bytes(u64::from(sps.max_dec_pic_buffering));
let flight = shape.store_bytes(self.max_in_flight as u64);
let total = shape
.store_bytes(1)
.zip(slots)
.zip(flight)
.map(|((one, slots), flight)| slots.max(held).saturating_add(one).saturating_add(flight).saturating_add(queued));
ensure!(
total.is_some_and(|b| b <= self.memory_budget),
"{}x{} pictures with {pictures} in the decoder and {queued} bytes queued exceed the memory budget",
shape.fits(pictures, self.memory_budget),
"{}x{} pictures with {pictures} in the decoder exceed the memory budget",
sps.width,
sps.height
);
+1 -27
View File
@@ -16,12 +16,6 @@ const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
/// Entries one batch (a JSON-RPC batch, or the `batch` tool's commands) may hold.
pub(crate) const MAX_BATCH_ITEMS: usize = 1000;
/// Bytes of results one batch may gather before the rest of it is refused: each result can copy
/// much of the document (an inspection holds every note), so a batch holds at most this much.
pub(crate) const MAX_BATCH_BYTES: usize = 64 << 20;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// Keep request frames in step with the connected backend's reply limit.
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
@@ -99,28 +93,8 @@ impl Server {
// Batches were removed in 2025-06-18; still answer older clients sensibly.
if batch.is_empty() {
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
} else if batch.len() > MAX_BATCH_ITEMS {
Some(error(Value::Null, INVALID_REQUEST, format!("a batch holds at most {MAX_BATCH_ITEMS} messages")))
} else {
let mut used = 0usize;
let mut replies: Vec<Value> = Vec::new();
for m in batch {
if used > MAX_BATCH_BYTES {
// Not run: answer each remaining request with a short error.
if let Some(id) = m.get("id").filter(|id| id.is_string() || id.is_number()) {
replies.push(error(id.clone(), INTERNAL_ERROR, "the batch's replies passed 64 MiB; not run"));
}
continue;
}
if let Some(r) = self.handle(m) {
used = used.saturating_add(r.to_string().len());
replies.push(if used > MAX_BATCH_BYTES {
error(r.get("id").cloned().unwrap_or(Value::Null), INTERNAL_ERROR, "the batch's replies passed 64 MiB; reply dropped")
} else {
r
});
}
}
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
(!replies.is_empty()).then_some(Value::Array(replies))
}
}
+10 -29
View File
@@ -357,40 +357,21 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
}
"batch" => {
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let commands = a.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
if commands.len() > MAX_BATCH_ITEMS {
return ToolResult::error(format!("a batch holds at most {MAX_BATCH_ITEMS} commands"));
}
let mut out = vec![];
let mut used = 0usize;
for c in commands {
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
let (entry, failed) = match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
Ok(v) => (json!({"command": id, "ok": true, "result": v}), false),
Err(e) => (json!({"command": id, "ok": false, "error": e}), true),
};
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
// Failures count too: with keepGoing, a remote backend's large errors add up.
used = used.saturating_add(json!(entry.to_string()).to_string().len());
if used > MAX_BATCH_BYTES {
let shown: String = id.chars().take(128).collect();
out.push(json!({"command": shown, "ok": false, "error": "the batch's results passed 64 MiB; stopped here"}));
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
out.push(entry);
if failed && !keep {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
Err(e) => {
out.push(json!({"command": id, "ok": false, "error": e}));
if !keep {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
}
}
}
// The reply is pretty-printed, which is longer than the compact entries counted above.
let reply = ToolResult::json(&Value::Array(out));
let text = reply.content[0]["text"].as_str().unwrap_or_default();
if json!(text).to_string().len() > MAX_BATCH_BYTES {
return ToolResult::error("the batch's results passed 64 MiB; nothing is returned");
}
reply
ToolResult::json(&Value::Array(out))
}
"new_presentation" => wrap(exec(b, "file.new", a)),
"open_presentation" => wrap(exec(b, "file.open", a)),
-70
View File
@@ -1,70 +0,0 @@
//! A batch (JSON-RPC or the `batch` tool) is bounded in entries and in the bytes of results it
//! gathers, so a short request cannot copy a large document into memory hundreds of times.
use deckcraft_mcp::{Headless, Server};
use serde_json::{Value, json};
fn server_with_notes(bytes: usize) -> Server {
let mut server = Server::new(Box::new(Headless::with_document()));
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "n".repeat(bytes)}}}});
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
assert_ne!(reply["result"]["isError"], true, "{reply}");
server
}
#[test]
fn json_rpc_batches_are_bounded_in_entries_and_reply_bytes() {
let mut server = server_with_notes(1 << 20);
let ping = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "ping"});
let too_many: Vec<Value> = (0..1001).map(ping).collect();
let reply: Value = serde_json::from_str(&server.handle_line(&Value::Array(too_many).to_string()).unwrap()).unwrap();
assert_eq!(reply["error"]["code"], -32600, "{reply}");
// 100 inspections of a slide with 1 MB of notes: about 64 are answered, the rest refused.
let inspect = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "inspect_slide", "arguments": {}}});
let line = Value::Array((0..100).map(inspect).collect()).to_string();
let reply = server.handle_line(&line).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
let replies: Vec<Value> = serde_json::from_str(&reply).unwrap();
assert_eq!(replies.len(), 100, "every request is answered");
assert!(replies.last().unwrap()["error"]["message"].as_str().unwrap().contains("64 MiB"));
}
#[test]
fn the_batch_tool_is_bounded_in_commands_and_result_bytes() {
let mut server = server_with_notes(1 << 20);
let call = |server: &mut Server, commands: Vec<Value>| {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
(reply["result"]["isError"] == true, reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string())
};
let (err, text) = call(&mut server, vec![json!({"command": "document.inspect"}); 1001]);
assert!(err && text.contains("at most 1000"), "{text}");
let (err, text) = call(&mut server, vec![json!({"command": "slide.inspect"}); 100]);
assert!(err && text.contains("64 MiB"), "{}", &text[text.len().saturating_sub(200)..]);
assert!(text.len() < (64 << 20) + (4 << 20));
}
#[test]
fn the_batch_tool_charges_the_escaped_reply() {
// Quotes escape twice on the way out (`\\\"`), so 30 results of ~2 MiB serialize to ~120 MiB.
let mut server = Server::new(Box::new(Headless::with_document()));
let notes = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "\"".repeat(1 << 20)}}}});
server.handle_line(&notes.to_string()).unwrap();
let commands = vec![json!({"command": "slide.inspect"}); 30];
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply = server.handle_line(&line.to_string()).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
assert!(reply.contains("64 MiB"));
}
#[test]
fn the_batch_tool_charges_failures_too() {
// Each unknown command's error echoes its ~1 MiB id; with keepGoing none of them is a result.
let mut server = Server::new(Box::new(Headless::with_document()));
let id = "x".repeat(1 << 20);
let commands = vec![json!({"command": id}); 70];
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply = server.handle_line(&line.to_string()).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
assert!(reply.contains("64 MiB"));
}
-2
View File
@@ -1,5 +1,3 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod batch_budget;
+2 -26
View File
@@ -313,8 +313,6 @@ 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,
@@ -347,7 +345,6 @@ 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]),
@@ -371,17 +368,6 @@ 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
@@ -399,12 +385,6 @@ 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;
@@ -422,7 +402,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(self.held).saturating_add(cost) > MAX_BYTES {
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
self.full = true;
} else {
self.used += cost;
@@ -571,10 +551,6 @@ 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);
@@ -690,7 +666,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(self.held).saturating_add(cost) > MAX_BYTES {
if self.used.saturating_add(cost) > MAX_BYTES {
self.full = true;
return;
}
+10 -90
View File
@@ -288,23 +288,13 @@ impl Plus {
if !self.active {
return None;
}
let done = match typ {
0x4008 => self.object(flags, r, cv.used().saturating_add(self.clip_bytes())),
match typ {
0x4008 => self.object(flags, r),
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.
@@ -569,7 +559,7 @@ impl Plus {
Some(())
}
fn object(&mut self, flags: u16, r: Bytes, canvas_used: usize) -> Option<()> {
fn object(&mut self, flags: u16, r: Bytes) -> Option<()> {
let id = usize::from(flags & 0xFF);
let kind = (flags >> 8) & 0x7F;
if flags & 0x8000 != 0 {
@@ -585,7 +575,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, canvas_used);
self.store(p.id, obj);
} else {
self.partial = Some(p);
}
@@ -599,23 +589,22 @@ 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, canvas_used);
self.store(id, obj);
return Some(());
}
let obj = parse_object(kind, r);
self.store(id, obj, canvas_used);
self.store(id, obj);
Some(())
}
/// `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) {
fn store(&mut self, id: usize, obj: Option<Obj>) {
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).saturating_add(canvas_used) <= crate::MAX_BYTES {
if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES {
self.object_bytes += cost;
*slot = Some(new_obj);
}
@@ -626,7 +615,7 @@ impl Plus {
match obj {
Obj::Image(img) => match img.as_ref() {
ImageData::Bitmap(rgba) => rgba.px.len(),
ImageData::Meta(pic) => picture_cost(&pic.items),
ImageData::Meta(pic) => pic.items.len().saturating_mul(128),
},
Obj::Brush(Fill::Texture { img, .. }) => img.px.len(),
Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::<Op>()),
@@ -1293,33 +1282,6 @@ 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)?;
@@ -1597,45 +1559,3 @@ 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);
}
}
+1 -12
View File
@@ -58,7 +58,6 @@ 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 {
@@ -72,15 +71,12 @@ 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.saturating_add(region_elements.get()) >= MAX_PLACED_ELEMENTS {
if total_elements >= MAX_PLACED_ELEMENTS {
break;
}
match item {
@@ -88,10 +84,6 @@ 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 });
@@ -105,9 +97,6 @@ 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 });
}
-39
View File
@@ -897,24 +897,6 @@ 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 };
@@ -2173,24 +2155,3 @@ 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());
}
-5
View File
@@ -392,9 +392,6 @@ pub struct Chart {
/// Original chart XML part kept for round-trip of unmodelled options.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
/// The chart was too large to model: `raw` and `kept` are written back unchanged on save.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub verbatim: bool,
/// Package parts the original chart still points at (embedded workbook, styles, drawings).
/// The PPTX writer clears this before comparing modelled state. Native saves keep it too.
#[serde(skip_serializing_if = "ChartKept::is_empty")]
@@ -488,7 +485,6 @@ impl Default for Chart {
bin_count: None,
bin_size: None,
raw: None,
verbatim: false,
kept: ChartKept::default(),
}
}
@@ -1315,7 +1311,6 @@ impl Chart {
series: vec![],
gap_width: 0.0,
raw: None,
verbatim: false,
subtotals: vec![],
..self.clone()
};
+26 -37
View File
@@ -301,14 +301,18 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0));
let mut out = vec![];
for pt in deckcraft_render::place_master_texts(pres, m, n) {
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
for run in &pt.layout.runs {
let Some(g) = run.glyphs.first() else { continue };
let chars = para_chars.entry(run.para).or_insert_with(|| {
pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.content().chars()).collect()).unwrap_or_default()
});
let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
let text: String = pt
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
.take(run.chars.1.saturating_sub(run.chars.0))
.collect();
if text.trim().is_empty() {
continue;
}
@@ -494,9 +498,6 @@ impl Backdrops {
/// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters.
const MAX_RASTER_PIXELS: f64 = 64_000_000.0;
/// Most ruled note lines one handout area gets (a letter page holds about 30).
const MAX_NOTE_LINES: usize = 500;
/// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped.
fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 {
let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0;
@@ -573,17 +574,20 @@ impl Exporter<'_> {
/// Invisible glyphs exactly over the rasterised text.
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
// Each paragraph's characters, gathered once: a long paragraph wraps into many runs.
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
for run in &pt.layout.runs {
if run.glyphs.is_empty() || run.size <= 0.0 {
continue;
}
let chars = para_chars.entry(run.para).or_insert_with(|| {
pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.content().chars()).collect()).unwrap_or_default()
});
let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
let text: String = pt
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
.take(run.chars.1.saturating_sub(run.chars.0))
.collect();
if text.trim().is_empty() {
continue;
}
@@ -663,28 +667,19 @@ impl Exporter<'_> {
let lh = size * 1.3;
let mut y = area.y0 + size;
s.set_fill(Some(grey(0x20, 1.0)));
let space = width(" ");
'outer: for para in text.split('\n') {
// The line's width is kept as words are added, not measured again for each word.
let mut line = String::new();
let mut line_w = 0.0;
for word in para.split(' ') {
let word_w = width(word);
let cand_w = if line.is_empty() { word_w } else { line_w + space + word_w };
if cand_w > area.width() && !line.is_empty() {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if width(&cand) > area.width() && !line.is_empty() {
if y > area.y1 {
break 'outer;
}
s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto);
y += lh;
line = word.to_string();
line_w = word_w;
} else {
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
line_w = cand_w;
line = cand;
}
}
if y > area.y1 {
@@ -701,26 +696,20 @@ impl Exporter<'_> {
/// Ruled lines for handwritten notes (3-per-page handouts).
fn note_lines(&self, s: &mut Surface, r: Rect) {
// Counted, not stepped: at huge coordinates adding 22 leaves y unchanged, and a huge page
// would want millions of rules.
let first = r.y0 + 18.0;
let count = ((r.y1 - first) / 22.0).floor() + 1.0;
if count.is_nan() || count < 1.0 {
return;
}
let mut y = r.y0 + 18.0;
s.set_stroke(Some(krilla::paint::Stroke {
paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(),
width: 0.5,
..Default::default()
}));
for i in 0..(count.min(MAX_NOTE_LINES as f64) as usize) {
let y = first + 22.0 * i as f64;
while y <= r.y1 {
let mut p = kurbo::BezPath::new();
p.move_to((r.x0, y));
p.line_to((r.x1, y));
if let Some(path) = to_path(&p) {
s.draw_path(&path);
}
y += 22.0;
}
s.set_stroke(None);
}
-14
View File
@@ -728,17 +728,3 @@ fn raster_scale_caps_total_pixels_not_each_side() {
let s = raster_scale(720.0, (960.0, 540.0), 200.0);
assert!((s - 720.0 / 72.0 * 200.0 / 960.0).abs() < 1e-12);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn huge_paper_and_long_notes_export_in_bounded_work() {
// At 1e19 pt adding a rule's 22 pt leaves y unchanged: the rules are counted, not stepped. A narrow page keeps the raster small.
let p = deck(1);
let bytes = export(&p, &PdfOptions { paper: (612.0, 1.0e19), ..opts(PageLayout::Handouts { per_page: 3 }) }).expect("export");
assert!(bytes.starts_with(b"%PDF-"));
// 20,000 short words of notes, wrapped with the line width kept as words are added.
let mut p = deck(1);
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text(&"ab ".repeat(20_000));
let bytes = export(&p, &opts(PageLayout::Notes)).expect("export");
assert!(bytes.starts_with(b"%PDF-"));
}
-7
View File
@@ -70,13 +70,6 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
read::import_checked(bytes)
}
/// [`import`] with a lower presentation-wide chart point budget, so tests reach the over-budget
/// path without a multi-million-point chart.
#[doc(hidden)]
pub fn import_with_chart_budget(bytes: &[u8], points: usize) -> Result<Presentation, PptxError> {
read::import_with_chart_points(bytes, points).map(|(p, _)| p)
}
/// Read the theme of a `.thmx` file, or of a `.pptx` / `.potx`'s first master, with its media.
pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<deckcraft_model::MediaItem>), PptxError> {
read::import_theme(bytes)
+26 -216
View File
@@ -10,7 +10,6 @@ mod timing;
pub(crate) use chart::{chart_from as chart_from_el, read_chart_xml};
pub(crate) use chartex::{chartex_from, read_chartex_xml};
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -39,17 +38,8 @@ pub struct Imp<'a> {
pub vml_previews: HashMap<String, HashMap<String, String>>,
/// Aggregate byte count of retained chart sidecars across the presentation.
pub kept_sidecar_bytes: usize,
/// Data slots of the charts decoded so far (see `chart::MAX_PRESENTATION_CHART_POINTS`).
pub chart_points: usize,
/// Points the presentation may hold across modelled charts.
pub chart_points_cap: usize,
/// Cached kept sidecars by normalized chart part name.
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
/// Chart parts whose sidecar capture was refused, with the retained-byte total it started from:
/// the same start gives the same refusal, so the copy is not repeated.
pub rejected_kept: HashMap<String, usize>,
/// Bytes of chart parts decoded so far, rejected ones included (see `chart::MAX_CHART_DECODE_BYTES`).
pub chart_decode_bytes: usize,
/// Add-in tags parts, parsed once each, by normalized part name.
pub tags_by_part: HashMap<String, Arc<[(String, String)]>>,
/// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]).
@@ -100,22 +90,21 @@ pub struct Part {
pub ns: Vec<(String, String)>,
/// Prefixes bound below the root only (on a wrapper such as `mc:Choice`), each to one namespace.
pub inner_ns: Vec<(String, String)>,
/// Bytes of namespace declarations this part may still copy onto kept fragments.
ns_budget: Cell<usize>,
}
impl Part {
pub fn new(pkg: &Package, name: &str, doc: &Doc) -> Self {
let ns = doc.ns_decls();
Part { name: name.to_string(), rels: pkg.rels(name), inner_ns: inner_decls(&doc.root, &ns), ns, ns_budget: Cell::new(MAX_COPIED_NS_BYTES) }
Part { name: name.to_string(), rels: pkg.rels(name), inner_ns: inner_decls(&doc.root, &ns), ns }
}
/// Serialize an element with the namespace declarations it needs from the part root.
pub fn keep(&self, el: &El) -> String {
keep_with(el, &[&self.ns], Some(&self.ns_budget))
keep_xml(el, &self.ns)
}
/// Like [`Part::keep`], also declaring prefixes that only an enclosing wrapper binds.
pub fn keep_inner(&self, el: &El) -> String {
keep_with(el, &[&self.ns, &self.inner_ns], Some(&self.ns_budget))
let ns: Vec<(String, String)> = self.ns.iter().chain(&self.inner_ns).cloned().collect();
keep_xml(el, &ns)
}
}
@@ -150,81 +139,36 @@ fn inner_decls(root: &El, ns: &[(String, String)]) -> Vec<(String, String)> {
out
}
/// Longest namespace name copied onto a kept fragment or a resolved `mc` branch. Real ones are
/// URIs of well under 100 bytes; a longer one is not copied, so one declaration cannot be
/// multiplied by every fragment that uses its prefix.
const MAX_NS_VALUE: usize = 2048;
/// Namespace declaration bytes one part may copy onto all its kept fragments together; past it a
/// fragment keeps its markup but is not given further declarations.
const MAX_COPIED_NS_BYTES: usize = 8 << 20;
/// Prefixes `el` and its descendants use in element and attribute names, and those listed in
/// `mc:Ignorable` or `mc:MustUnderstand` (which must stay declared for the markup-compatibility rules to hold),
/// and those starting a QName-like attribute value.
fn used_prefixes(el: &El) -> HashSet<&str> {
fn rec<'a>(e: &'a El, used: &mut HashSet<&'a str>, depth: usize) {
/// Serialize `el`, declaring every prefix it uses that the source root declared.
pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String {
let mut used = HashSet::new();
fn rec(e: &El, used: &mut HashSet<String>, depth: usize) {
if depth > crate::xml::MAX_DEPTH {
return;
}
if let Some((p, _)) = e.name.split_once(':') {
used.insert(p);
used.insert(p.to_string());
}
for (k, v) in &e.attrs {
if let Some((p, local)) = k.split_once(':')
for (k, _) in &e.attrs {
if let Some((p, _)) = k.split_once(':')
&& p != "xmlns"
&& p != "xml"
{
used.insert(p);
if local == "Ignorable" || local == "MustUnderstand" {
used.extend(v.split_whitespace());
}
}
// QName-valued attributes (`xsi:type="q:Item"`) need their prefix declared too.
if k != "xmlns" && !k.starts_with("xmlns:") {
for t in v.split_whitespace() {
if let Some((p, _)) = t.split_once(':')
&& !p.is_empty()
{
used.insert(p);
}
}
used.insert(p.to_string());
}
}
for c in e.elements() {
rec(c, used, depth + 1);
}
}
let mut used = HashSet::new();
rec(el, &mut used, 0);
used
}
/// Serialize `el`, declaring every prefix it uses that the source root declared.
pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String {
keep_with(el, &[ns], None)
}
fn keep_with(el: &El, decls: &[&[(String, String)]], budget: Option<&Cell<usize>>) -> String {
let used = used_prefixes(el);
let mut have: HashSet<&str> = el.attrs.iter().map(|(a, _)| a.as_str()).collect();
let mut extra = Vec::new();
for (k, v) in decls.iter().flat_map(|d| d.iter()) {
let mut el = el.clone();
for (k, v) in ns {
let p = k.strip_prefix("xmlns:").unwrap_or("");
if !p.is_empty() && used.contains(p) && v.len() <= MAX_NS_VALUE && !have.contains(k.as_str()) {
let cost = k.len() + v.len();
if let Some(b) = budget {
if b.get() < cost {
continue;
}
b.set(b.get() - cost);
}
have.insert(k);
extra.push((k.clone(), v.clone()));
if !p.is_empty() && used.contains(p) && !el.attrs.iter().any(|(a, _)| a == k) {
el.attrs.push((k.clone(), v.clone()));
}
}
let mut el = el.clone();
el.attrs.extend(extra);
el.to_xml()
}
@@ -373,18 +317,10 @@ fn kept_parts(pkg: &Package, pres_path: &str) -> Vec<deckcraft_model::KeptPart>
// The part's own relationships (customXml item → itemProps) and their targets.
let rp = opc::rels_path(&name);
if let Some(rels_xml) = text(&rp) {
let mut capped = false;
for sub in pkg.rels(&name).items.iter().filter(|s| !s.external && keepable(&s.target)) {
// Checked before the part is read: repeated relationships to one large part
// must not copy it again each time.
if out.iter().any(|k| k.name.eq_ignore_ascii_case(&sub.target)) {
continue;
}
if out.len() >= 400 {
capped = true;
continue;
}
if let Some(x) = text(&sub.target) {
if let Some(x) = text(&sub.target)
&& !out.iter().any(|k| k.name.eq_ignore_ascii_case(&sub.target))
{
out.push(KeptPart {
content_type: pkg.content_type(&sub.target).unwrap_or_default(),
name: sub.target.clone(),
@@ -393,9 +329,7 @@ fn kept_parts(pkg: &Package, pres_path: &str) -> Vec<deckcraft_model::KeptPart>
});
}
}
if !capped && out.len() < 400 {
out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() });
}
out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() });
}
}
}
@@ -432,39 +366,15 @@ fn resolve_mc_rels(el: &mut El, depth: usize, rels: Option<&Rels>) {
});
let branch = choice.or_else(|| ac.child("Fallback"));
if let Some(b) = branch {
// The branch's namespace declarations (the first usable one of each name,
// in order), indexed by prefix once: each child then looks up the prefixes
// it uses instead of scanning every declaration.
let mut decls: Vec<(&String, &String)> = Vec::new();
// An oversized declaration still shadows an outer one of its name: its
// content is left unbound rather than bound to the outer namespace.
let mut by_prefix: HashMap<&str, Option<usize>> = HashMap::new();
for (k, v) in b.attrs.iter().chain(ac.attrs.iter()) {
let prefix = if k == "xmlns" { Some("") } else { k.strip_prefix("xmlns:") };
if let Some(p) = prefix
&& !by_prefix.contains_key(p)
{
by_prefix.insert(p, (v.len() <= MAX_NS_VALUE).then_some(decls.len()));
if v.len() <= MAX_NS_VALUE {
decls.push((k, v));
}
}
}
for n in &b.children {
if let Node::El(e) = n {
// Carry the namespace declarations from the branch that its content
// uses (all of them per child made children x declarations copies).
let used = used_prefixes(e);
let have: HashSet<&str> = e.attrs.iter().map(|(a, _)| a.as_str()).collect();
let mut wanted: Vec<usize> = std::iter::once("")
.chain(used.iter().copied())
.filter_map(|p| by_prefix.get(p).copied().flatten())
.filter(|&i| !have.contains(decls[i].0.as_str()))
.collect();
wanted.sort_unstable();
wanted.dedup();
let mut e = e.clone();
e.attrs.extend(wanted.into_iter().map(|i| (decls[i].0.clone(), decls[i].1.clone())));
// Carry namespace declarations from the branch onto its content.
for (k, v) in b.attrs.iter().chain(ac.attrs.iter()) {
if k.starts_with("xmlns") && !e.attrs.iter().any(|(a, _)| a == k) {
e.attrs.push((k.clone(), v.clone()));
}
}
out.push(Node::El(e));
}
}
@@ -535,11 +445,7 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
table_style_ids: Vec::new(),
vml_previews: HashMap::new(),
kept_sidecar_bytes: 0,
chart_points: 0,
chart_points_cap: chart::MAX_PRESENTATION_CHART_POINTS,
kept_charts: HashMap::new(),
rejected_kept: HashMap::new(),
chart_decode_bytes: 0,
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
@@ -557,11 +463,6 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
/// [`import`], plus the parts of the slide list the file names but that couldn't be read (a
/// missing or truncated slide): those slides are left out, so a caller can say so (#203).
pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxError> {
import_with_chart_points(bytes, chart::MAX_PRESENTATION_CHART_POINTS)
}
/// [`import_checked`] with the presentation's chart point budget set (tests lower it).
pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) -> Result<(Presentation, Vec<String>), PptxError> {
let mut lost_slides: Vec<String> = vec![];
let pkg = Package::open(bytes)?;
let root_rels = pkg.rels("");
@@ -586,11 +487,7 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) ->
table_style_ids: Vec::new(),
vml_previews: HashMap::new(),
kept_sidecar_bytes: 0,
chart_points: 0,
chart_points_cap,
kept_charts: HashMap::new(),
rejected_kept: HashMap::new(),
chart_decode_bytes: 0,
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
@@ -1526,90 +1423,3 @@ fn header_footer(p: &Presentation) -> HeaderFooter {
}
hf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mc_branches_carry_only_the_declarations_their_content_uses() {
// 2,000 declarations on the branch and 2,000 children: each child gets the one it uses
// (and the default), not all 2,000.
let decls: String = (0..2000).map(|i| format!(r#" xmlns:n{i}="urn:n{i}""#)).collect();
let kids: String = (0..2000).map(|i| format!("<n{i}:x/>")).collect();
let x = format!(
r#"<r xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Fallback xmlns="urn:d"{decls}>{kids}</mc:Fallback></mc:AlternateContent></r>"#
);
let mut d = crate::xml::parse(x.as_bytes()).unwrap();
resolve_mc(&mut d.root, 0);
let kids: Vec<&El> = d.root.elements().collect();
assert_eq!(kids.len(), 2000);
assert_eq!(kids[7].attrs, vec![("xmlns".to_string(), "urn:d".to_string()), ("xmlns:n7".to_string(), "urn:n7".to_string())]);
}
/// The branch's own declaration of a prefix wins over the `AlternateContent`'s; an oversized
/// one still shadows it (its content stays unbound); a child's own declaration is left alone.
#[test]
fn mc_branch_declarations_keep_their_precedence() {
let long = "u".repeat(MAX_NS_VALUE + 1);
let x = format!(
r#"<r xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent xmlns:p="urn:ac" xmlns:q="urn:q-short"><mc:Fallback xmlns:p="urn:b" xmlns:q="{long}"><p:x/><q:y/><p:z xmlns:p="urn:own"/></mc:Fallback></mc:AlternateContent></r>"#
);
let mut d = crate::xml::parse(x.as_bytes()).unwrap();
resolve_mc(&mut d.root, 0);
let kids: Vec<&El> = d.root.elements().collect();
let attrs = |i: usize| kids[i].attrs.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>();
assert_eq!(attrs(0), ["xmlns:p=urn:b"]);
assert!(attrs(1).is_empty(), "{:?}", attrs(1));
assert_eq!(attrs(2), ["xmlns:p=urn:own"]);
}
#[test]
fn mc_branches_keep_prefixes_named_by_must_understand() {
let x = r#"<r xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Fallback xmlns:q="urn:q"><a mc:MustUnderstand="q"/></mc:Fallback></mc:AlternateContent></r>"#;
let mut d = crate::xml::parse(x.as_bytes()).unwrap();
resolve_mc(&mut d.root, 0);
let kid = d.root.elements().next().unwrap();
assert!(kid.attrs.iter().any(|(k, v)| k == "xmlns:q" && v == "urn:q"), "{:?}", kid.attrs);
}
#[test]
fn mc_branches_keep_prefixes_used_in_qname_values() {
let x = r#"<r xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Fallback xmlns:q="urn:q" xmlns:z="urn:z"><n xmlns:xsi="urn:xsi" xsi:type="q:Item"/></mc:Fallback></mc:AlternateContent></r>"#;
let mut d = crate::xml::parse(x.as_bytes()).unwrap();
resolve_mc(&mut d.root, 0);
let kid = d.root.elements().next().unwrap();
assert!(kid.attrs.iter().any(|(k, v)| k == "xmlns:q" && v == "urn:q"), "{:?}", kid.attrs);
assert!(!kid.attrs.iter().any(|(k, _)| k == "xmlns:z"), "unused declarations stay out: {:?}", kid.attrs);
}
#[test]
fn kept_fragments_skip_oversized_namespace_names() {
let big = "u".repeat(MAX_NS_VALUE + 1);
let ns = vec![("xmlns:a".to_string(), "urn:a".to_string()), ("xmlns:b".to_string(), big)];
let d = crate::xml::parse(br#"<a:x xmlns:a="urn:a" xmlns:b="urn:b"><b:y/></a:x>"#).unwrap();
let mut el = d.root.clone();
el.attrs.clear();
let out = keep_xml(&el, &ns);
assert!(out.contains(r#"xmlns:a="urn:a""#), "{out}");
assert!(!out.contains("uuuu"), "the oversized declaration is not copied");
}
#[test]
fn copied_namespace_bytes_are_budgeted_per_part() {
let d = crate::xml::parse(br#"<a:x xmlns:a="urn:a"><a:y/></a:x>"#).unwrap();
let mut el = d.root.clone();
el.attrs.clear();
let part = Part {
name: String::new(),
rels: Default::default(),
ns: vec![("xmlns:a".to_string(), "urn:a".to_string())],
inner_ns: vec![],
ns_budget: Cell::new(15),
};
assert!(part.keep(&el).contains(r#"xmlns:a="urn:a""#), "within budget");
assert!(part.ns_budget.get() < 15);
let again = part.keep(&el);
assert!(!again.contains("xmlns:a"), "budget spent, no further declarations: {again}");
}
}
-102
View File
@@ -39,33 +39,6 @@ pub(crate) fn rich_text(t: &El) -> Option<String> {
/// Most category/value/size slots one chart may decode across all its series and caches, so
/// sparse `ptCount`/`idx` declarations can't expand far beyond the XML they came from.
pub(crate) const MAX_CHART_POINTS: usize = 1_000_000;
/// Data slots (values and categories) the charts of one presentation may hold in all; a chart past
/// it is read as a placeholder frame. Each frame that references a chart decodes its own copy.
pub(crate) const MAX_PRESENTATION_CHART_POINTS: usize = 4_000_000;
/// Chart part bytes all chart frames of a presentation may decode, counting every attempt: a
/// chart that is rejected, or has no data slots, would otherwise be decoded again for each frame
/// that shows it.
pub(crate) const MAX_CHART_DECODE_BYTES: usize = 256 * 1024 * 1024;
/// Slots a decoded chart holds, counting the chart itself as at least one.
pub(crate) fn point_count(c: &Chart) -> usize {
let series = |s: &deckcraft_model::chart::Series| {
let bars = s.error_bars.as_ref().map_or(0, |e| e.plus.len().saturating_add(e.minus.len()));
s.values.len().saturating_add(s.x_values.len()).saturating_add(s.sizes.len()).saturating_add(bars)
};
let levels: usize = c.category_levels.iter().map(Vec::len).sum();
1usize.saturating_add(c.categories.len()).saturating_add(levels).saturating_add(c.series.iter().map(series).fold(0, usize::saturating_add))
}
/// Bytes of the strings a kept relationship owns.
fn part_bytes(p: &deckcraft_model::chart::ChartPart) -> usize {
p.bytes.len() + p.name.len() + p.content_type.len()
}
fn rel_bytes(r: &deckcraft_model::chart::ChartRel) -> usize {
r.id.len() + r.rel_type.len() + r.target.len() + r.part.len()
}
/// Most slots one cache may hold.
const MAX_CACHE_POINTS: usize = 100_000;
@@ -130,48 +103,6 @@ pub fn read_chart_xml(bytes: &[u8]) -> Option<Chart> {
Some(c)
}
/// The chart a graphic frame points at. A chart that would cross the presentation's point or
/// decode limits is not modelled: it is kept as its original part with the relationships it
/// reaches (`verbatim`), so a save writes it back whole instead of dropping it and its workbook.
pub(crate) fn frame_chart(imp: &mut super::Imp, part: &super::Part, rid: &str, uri: &str) -> Option<Chart> {
let rel = part.rels.get(rid)?;
let chartex = uri.ends_with("/chartex");
let bytes = imp.pkg.xml_bytes(&rel.target)?;
if imp.chart_decode_bytes.saturating_add(bytes.len()) > MAX_CHART_DECODE_BYTES {
log::warn!("pptx: chart {} is past the presentation's chart decode limit and is not kept", rel.target);
return None;
}
imp.chart_decode_bytes += bytes.len();
let parsed = if imp.chart_points < imp.chart_points_cap {
let c = if chartex { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }?;
let n = point_count(&c);
if imp.chart_points.saturating_add(n) <= imp.chart_points_cap {
imp.chart_points = imp.chart_points.saturating_add(n);
let mut c = c;
c.kept = capture_kept(imp, &rel.target);
return Some(c);
}
Some(c)
} else {
None
};
// Past the limit: keep the part itself (charged to the sidecar budget) and show an empty chart.
if chartex || imp.kept_sidecar_bytes.saturating_add(bytes.len()) > MAX_KEPT_TOTAL_BYTES {
log::warn!("pptx: chart {} is past the presentation's chart limits and is not kept", rel.target);
return None;
}
imp.kept_sidecar_bytes += bytes.len();
log::warn!("pptx: chart {} is past the presentation's chart limits; shown empty and written back unchanged", rel.target);
let mut c = parsed.unwrap_or_default();
c.series.clear();
c.categories.clear();
c.category_levels.clear();
c.raw = Some(String::from_utf8_lossy(bytes).into_owned());
c.verbatim = true;
c.kept = capture_kept(imp, &rel.target);
Some(c)
}
pub(crate) fn chart_from(root: &El) -> Option<Chart> {
chart_within(root, MAX_CHART_POINTS)
}
@@ -535,20 +466,9 @@ pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_
let key = crate::opc::norm(chart_part);
if let Some(cached) = imp.kept_charts.get(&key) {
// Every frame that shows this chart gets its own copy, so each copy is charged like the first.
let bytes: usize = cached.parts.iter().map(part_bytes).sum::<usize>() + cached.rels.iter().map(rel_bytes).sum::<usize>();
if imp.kept_sidecar_bytes.saturating_add(bytes) > MAX_KEPT_TOTAL_BYTES {
log::warn!("pptx: chart sidecar retained byte budget exceeded for {chart_part}");
return ChartKept::default();
}
imp.kept_sidecar_bytes += bytes;
return cached.clone();
}
let charged_before = imp.kept_sidecar_bytes;
if imp.rejected_kept.get(&key) == Some(&charged_before) {
return ChartKept::default();
}
let mut kept = ChartKept::default();
// The chart document itself is rewritten; don't store a second copy of it.
let mut seen = std::collections::HashMap::from([(key.clone(), true)]);
@@ -565,18 +485,6 @@ pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: r.target, external: false });
}
}
// The first capture's relationship strings and the parts' names and content types are retained
// too (the part bytes were charged as copied).
let rel_total: usize =
kept.rels.iter().map(rel_bytes).sum::<usize>() + kept.parts.iter().map(|p| p.name.len() + p.content_type.len()).sum::<usize>();
if imp.kept_sidecar_bytes.saturating_add(rel_total) > MAX_KEPT_TOTAL_BYTES {
log::warn!("pptx: chart sidecar retained byte budget exceeded for {chart_part}");
// The dropped parts must not stay charged against later charts.
imp.kept_sidecar_bytes = charged_before;
imp.rejected_kept.insert(key, charged_before);
return ChartKept::default();
}
imp.kept_sidecar_bytes += rel_total;
imp.kept_charts.insert(key, kept.clone());
kept
}
@@ -682,16 +590,6 @@ fn text_size(e: Option<&El>) -> Option<f64> {
mod tests {
use super::*;
#[test]
fn point_count_charges_every_retained_vector() {
let mut c = Chart { categories: vec!["a".into()], category_levels: vec![vec!["x".into(); 2], vec!["y".into(); 3]], ..Default::default() };
let mut s =
deckcraft_model::chart::Series { values: vec![Some(1.0); 4], x_values: vec![None; 5], sizes: vec![None; 6], ..Default::default() };
s.error_bars = Some(deckcraft_model::chart::ErrorBars { plus: vec![None; 7], minus: vec![None; 8], ..Default::default() });
c.series = vec![s];
assert_eq!(point_count(&c), 1 + 1 + 5 + 4 + 5 + 6 + 15);
}
#[test]
fn sparse_caches_share_one_chart_wide_budget() {
// Every series declares 100000 points but writes one: without a chart-wide budget each
+1 -7
View File
@@ -741,13 +741,7 @@ mod tests {
// 200 `m:t` nested in each other around one word: the word, not 200 copies of it.
let x = format!(r#"<a14:m xmlns:a14="a" xmlns:m="m"><m:oMath>{}word{}</m:oMath></a14:m>"#, "<m:t>".repeat(200), "</m:t>".repeat(200));
let d = parse(x.as_bytes()).unwrap();
let part = Part {
name: "ppt/slides/slide1.xml".into(),
rels: Default::default(),
ns: vec![],
inner_ns: vec![],
ns_budget: std::cell::Cell::new(1 << 20),
};
let part = Part { name: "ppt/slides/slide1.xml".into(), rels: Default::default(), ns: vec![], inner_ns: vec![] };
let mut para = Paragraph::default();
push_math_run(&mut para, &part, &d.root);
assert_eq!(para.runs[0].text, "word");
+4 -1
View File
@@ -434,8 +434,11 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
return s;
}
if let Some(c) = gd.child("chart")
&& let Some(chart) = super::chart::frame_chart(imp, part, c.attr("r:id").unwrap_or(""), uri)
&& let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or(""))
&& let Some(bytes) = imp.pkg.xml_bytes(&rel.target)
&& let Some(mut chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
{
chart.kept = super::chart::capture_kept(imp, &rel.target);
s.kind = ShapeKind::Chart(Box::new(chart));
return s;
}
+3 -91
View File
@@ -132,20 +132,7 @@ fn title(w: &mut W, text: &str, c: &Chart) {
w.close("c:title");
}
/// Points one cache writes. Excel and PowerPoint hold 32,000 points per series, so this is far
/// above any chart they make; a longer vector is cut here with a warning, not refused.
const MAX_CACHE_POINTS: usize = 1_000_000;
/// `vals` cut to [`MAX_CACHE_POINTS`].
fn capped<T>(vals: &[T]) -> &[T] {
if vals.len() > MAX_CACHE_POINTS {
log::warn!("pptx: chart data cache cut from {} to {MAX_CACHE_POINTS} points", vals.len());
}
&vals[..vals.len().min(MAX_CACHE_POINTS)]
}
fn str_cache(w: &mut W, f: &str, vals: &[String]) {
let vals = capped(vals);
w.open0("c:strRef");
w.elt("c:f", f);
w.open0("c:strCache");
@@ -160,7 +147,6 @@ fn str_cache(w: &mut W, f: &str, vals: &[String]) {
}
fn num_cache(w: &mut W, f: &str, vals: &[Option<f64>]) {
let vals = capped(vals);
w.open0("c:numRef");
w.elt("c:f", f);
w.open0("c:numCache");
@@ -179,7 +165,6 @@ fn num_cache(w: &mut W, f: &str, vals: &[Option<f64>]) {
/// Numbers written into the chart itself (`c:numLit`), with no worksheet range behind them.
fn num_lit(w: &mut W, vals: &[Option<f64>]) {
let vals = capped(vals);
w.open0("c:numLit");
w.val("c:ptCount", vals.len());
for (i, v) in vals.iter().enumerate() {
@@ -261,9 +246,6 @@ fn point_fills(w: &mut W, s: &Series, g: Group) {
}
}
/// Data points (categories and values counted per series) one chart group writes to its caches.
const MAX_EXPORT_POINTS: usize = 4_000_000;
fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) {
w.open0("c:ser");
w.val("c:idx", i);
@@ -323,7 +305,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
trendline(w, t);
}
}
let n = c.categories.len().max(s.values.len()).min(MAX_CACHE_POINTS);
let n = c.categories.len().max(s.values.len());
let last = n + 1;
if let Some(e) = &s.error_bars {
error_bars(w, e, g);
@@ -509,24 +491,6 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
w.close("c:valAx");
}
/// Points `series` writes into its caches for group `g`, synthesized vectors included.
fn series_points(c: &Chart, s: &Series, g: Group) -> usize {
let v = s.values.len();
if matches!(g, Group::Scatter | Group::Bubble) {
let xs = if s.x_values.is_empty() { v } else { s.x_values.len() };
let sizes = if g != Group::Bubble {
0
} else if s.sizes.is_empty() {
v
} else {
s.sizes.len()
};
xs.saturating_add(v).saturating_add(sizes)
} else {
c.categories.len().saturating_add(v)
}
}
fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) {
let g = group_of(kind);
let grouping = if kind.percent() {
@@ -565,17 +529,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
if g != Group::Stock {
w.val("c:varyColors", vary);
}
// Each series repeats the categories in its own cache, so the output grows with series times
// categories: stop adding series once a group's caches pass the budget (the first is always written).
// The first series is always written; each of its caches is cut at MAX_CACHE_POINTS.
let mut left = MAX_EXPORT_POINTS;
for (n, (i, s)) in list.iter().enumerate() {
let cost = series_points(c, s, g);
if n > 0 && cost > left {
log::warn!("pptx: chart export stopped after {n} series; the data caches passed {MAX_EXPORT_POINTS} points");
break;
}
left = left.saturating_sub(cost);
for (i, s) in list {
series(w, c, s, *i, kind, g);
}
if c.data_labels {
@@ -656,18 +610,6 @@ fn kept_chartex(c: &Chart) -> Option<Vec<u8>> {
/// The chart part XML, and whether it is the original part (so its relationships still apply).
pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
// A chart too large to model is written back as it was read.
if c.verbatim
&& let Some(raw) = &c.raw
&& let Ok(doc) = crate::xml::parse(raw.as_bytes())
&& doc.root.is("chartSpace")
{
let mut keep = doc.root;
drop_unkept_refs(&mut keep, c);
let mut w = W::new();
w.raw(&keep.to_xml());
return (w.finish(), true);
}
// Reuse the kept part when the chart is unchanged since it was read.
if let Some(raw) = &c.raw
&& !raw.contains(NS_CX)
@@ -1107,21 +1049,7 @@ mod region_map_tests {
mod tests {
use deckcraft_color::{ColorTransform, Rgba};
use deckcraft_model::ColorRef;
use deckcraft_model::chart::{Chart, ChartType, Series};
#[test]
fn export_cost_counts_the_caches_each_group_writes() {
let s = Series { values: vec![Some(1.0); 600], ..Default::default() };
let mut c = Chart::sample(ChartType::Column);
c.categories = vec!["a".into(); 1000];
// Category and value caches: 1,000 + 600, however the series is stored.
assert_eq!(super::series_points(&c, &s, super::Group::Bar), 1600);
// A bubble series without x values or sizes still writes three value-length caches.
assert_eq!(super::series_points(&c, &s, super::Group::Bubble), 1800);
assert_eq!(super::series_points(&c, &s, super::Group::Scatter), 1200);
let s = Series { values: vec![Some(1.0); 600], x_values: vec![Some(1.0); 5], sizes: vec![Some(1.0); 7], ..Default::default() };
assert_eq!(super::series_points(&c, &s, super::Group::Bubble), 612);
}
use deckcraft_model::chart::{Chart, ChartType};
/// The chart's text colour is written where PowerPoint keeps it (`c:txPr` on the axes, legend,
/// data labels and chart, the title's run defaults) and read back as the same colour.
@@ -1182,19 +1110,3 @@ mod tests {
assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c).0).unwrap().cat_reversed);
}
}
#[cfg(test)]
mod cache_cap_tests {
use super::*;
/// A cache longer than the cap is cut, never refused; anything a real chart holds (32,000
/// points per series) is far under it. Zero-sized elements, so the test allocates nothing.
#[test]
fn caches_are_cut_at_the_cap_not_refused() {
const { assert!(MAX_CACHE_POINTS >= 32_000) };
let over = vec![(); MAX_CACHE_POINTS + 1];
assert_eq!(capped(&over).len(), MAX_CACHE_POINTS);
let real = vec![(); 32_000];
assert_eq!(capped(&real).len(), 32_000);
}
}
-27
View File
@@ -328,30 +328,3 @@ fn an_unchanged_chartex_keeps_its_embedded_workbook() {
assert!(saved.iter().all(|(n, _)| n != "ppt/charts/_rels/chart1.xml.rels"), "the fallback chart has no workbook");
assert!(!text_of(&saved, "ppt/charts/chart1.xml").contains("externalData"));
}
/// A chart past the presentation's point budget is not modelled, but a save writes the original
/// chart part and its workbook back instead of dropping both.
#[test]
fn a_chart_past_the_point_budget_is_saved_whole() {
let src = with_workbook(&chart_pptx());
let p = deckcraft_pptx::import_with_chart_budget(&src, 0).expect("import");
let chart = p
.slides
.iter()
.flat_map(|s| s.shapes.iter())
.find_map(|s| match &s.kind {
ShapeKind::Chart(c) => Some(c.clone()),
_ => None,
})
.expect("the chart stays a chart");
assert!(chart.verbatim && chart.series.is_empty());
let saved = parts(&deckcraft_pptx::export(&p).expect("export"));
let workbook = saved.iter().find(|(n, _)| n == "ppt/embeddings/kept1.xlsx").map(|(_, d)| d.as_slice());
assert_eq!(workbook, Some(WORKBOOK));
let xml = saved
.iter()
.find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml"))
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
.expect("chart xml");
assert!(xml.contains("externalData") && xml.contains("c:pie"), "{xml}");
}
+1 -4
View File
@@ -125,13 +125,10 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option<Rgba>, path: &B
let longest = bounds.width().max(bounds.height()) * k;
let s = (MAX_SIDE / longest).min(1.0) * k;
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048));
// Each pixel tests every edge, so a dense outline is simplified to the raster's edge budget.
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
let polys = crate::pathgrad::simplify(crate::pathgrad::polygons(path), pixel, crate::pathgrad::edge_budget(pw, ph));
let polys = crate::pathgrad::polygons(path);
if polys.is_empty() {
return;
}
let (pw, ph) = crate::pathgrad::fit_raster(polys.iter().map(|p| p.len().saturating_sub(1)).sum(), pw, ph);
let areas: Vec<f64> = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::<f64>() / 2.0).collect();
let face = lit.factor([0.0, 0.0, 1.0]);
let ratio = if bevel.w > 0.0 { bevel.h / bevel.w } else { 0.0 };
+1 -15
View File
@@ -11,8 +11,6 @@ use vello_cpu::{RenderContext, peniko};
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
const MAX_DEPTH: usize = 64;
/// Fills and gradient stops one colour glyph may paint; the rest of a hostile glyph is skipped.
const MAX_WORK: usize = 100_000;
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
@@ -28,7 +26,7 @@ pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, sca
.unwrap_or_default();
// Font units are y up; the slide is y down.
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new(), work: 0 };
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
let ok = glyph.paint(face.location(), &mut p).is_ok();
// A malformed glyph may leave layers open: close them all.
@@ -52,8 +50,6 @@ struct Painter<'a> {
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
opened: Vec<bool>,
/// Fills plus gradient stops painted so far, against [`MAX_WORK`].
work: usize,
}
fn affine(t: &Transform) -> Affine {
@@ -178,16 +174,6 @@ impl ColorPainter for Painter<'_> {
}
fn fill(&mut self, brush: Brush<'_>) {
let stops = match &brush {
Brush::Solid { .. } => 0,
Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => {
color_stops.len()
}
};
self.work = self.work.saturating_add(1 + stops);
if self.work > MAX_WORK {
return;
}
match brush {
Brush::Solid { palette_index, alpha } => {
let c = self.color(palette_index, alpha);
+28 -140
View File
@@ -25,39 +25,6 @@ const MAX_EDGE_WORK: u64 = 64_000_000;
/// Fewest edges a simplified polygon keeps, so a coarse outline still has a shape.
const MIN_POLY_EDGES: usize = 8;
/// Most points `polygons` keeps from one path; a path flattening past this (a huge finite curve) is
/// not drawn rather than held in memory first.
const MAX_POINTS: usize = 2_000_000;
/// Quads beyond one per cubic that a path's cubics may subdivide into before [`polygons`] refuses it.
const MAX_EXTRA_QUADS: f64 = 200_000.0;
/// Flattening tolerance of [`polygons`], in path units.
const FLATTEN_TOLERANCE: f64 = 0.25;
/// The edge budget for a `pw` x `ph` raster where each pixel tests every edge.
pub(crate) fn edge_budget(pw: u32, ph: u32) -> usize {
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES)
}
/// `pw` x `ph` cut down (aspect kept) until every pixel testing all of `edges` stays within
/// [`MAX_EDGE_WORK`]; the raster is stretched when painted, so a many-contour outline keeps every
/// contour at a coarser sampling.
pub(crate) fn fit_raster(edges: usize, pw: u32, ph: u32) -> (u32, u32) {
let work = u64::from(pw).saturating_mul(u64::from(ph)).saturating_mul(edges.max(1) as u64);
if work <= MAX_EDGE_WORK {
return (pw, ph);
}
let k = (MAX_EDGE_WORK as f64 / work as f64).sqrt();
let w = ((f64::from(pw) * k) as u32).max(1);
let h = ((f64::from(ph) * k) as u32).max(1);
// A side held at 1 pixel leaves the other to take the rest of the cut.
let room = |side: u32| u32::try_from(MAX_EDGE_WORK / u64::from(side).saturating_mul(edges.max(1) as u64).max(1)).unwrap_or(u32::MAX).max(1);
let w = w.min(room(h));
(w, h.min(room(w)))
}
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
/// shape's flattened polygon(s) for `shape`.
@@ -145,59 +112,23 @@ fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
/// The outline flattened into closed polygons.
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
// `kurbo::flatten` buffers all of a cubic's quads before calling back, and cannot be stopped
// once running, so bound the quads the cubics will make first (kurbo's own count: it splits
// cubics into quads at a tenth of the tolerance). An ordinary curve makes a few; only those
// past the first of each count.
let mut extra_quads = 0.0f64;
let mut at = Point::ZERO;
let mut start = Point::ZERO;
for el in path.iter() {
match el {
PathEl::MoveTo(p) => {
at = p;
start = p;
}
PathEl::LineTo(p) | PathEl::QuadTo(_, p) => at = p,
PathEl::CurveTo(a, b, p) => {
let err = ((b.to_vec2() * 3.0 - p.to_vec2()) - (a.to_vec2() * 3.0 - at.to_vec2())).hypot2();
extra_quads += (err / (432.0 * (FLATTEN_TOLERANCE * 0.1).powi(2))).powf(1.0 / 6.0).ceil().max(1.0) - 1.0;
// A non-finite estimate is over the limit too.
if extra_quads.is_nan() || extra_quads > MAX_EXTRA_QUADS {
return Vec::new();
}
at = p;
}
PathEl::ClosePath => at = start,
}
}
let mut out: Vec<Vec<Point>> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
let mut total = 0usize;
kurbo::flatten(path.iter(), FLATTEN_TOLERANCE, |el| {
total += 1;
if total > MAX_POINTS {
return;
}
match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![p];
kurbo::flatten(path.iter(), 0.25, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
cur = vec![p];
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
});
if total > MAX_POINTS {
return Vec::new();
}
if cur.len() > 1 {
out.push(cur);
}
@@ -214,9 +145,8 @@ pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
/// `outline` cut down to at most about `max_edges` edges in total: first by dropping points
/// closer than `tol` to the last kept one (detail finer than a raster pixel), then, if still too
/// many, by keeping every k-th point (a polygon too small to thin stays whole, and none is
/// dropped). Each polygon stays closed. Returns it unchanged when it fits.
pub(crate) fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
/// many, by keeping every k-th point. Each polygon stays closed. Returns it unchanged when it fits.
fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
let edges = |o: &[Vec<Point>]| o.iter().map(|p| p.len().saturating_sub(1)).sum::<usize>();
if edges(&outline) <= max_edges {
return outline;
@@ -259,8 +189,15 @@ pub(crate) fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) ->
}
*poly = kept;
}
// Polygons too small to thin stay whole, so the total can still exceed `max_edges`: the
// caller shrinks its raster ([`fit_raster`]) rather than lose contours.
// Polygons too small to thin stay whole; drop the smallest ones if they alone overflow.
if edges(&out) > max_edges {
out.sort_by_key(|p| std::cmp::Reverse(p.len()));
let mut sum = 0usize;
out.retain(|p| {
sum = sum.saturating_add(p.len().saturating_sub(1));
sum <= max_edges
});
}
}
out
}
@@ -295,20 +232,14 @@ pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64,
let longest = bounds.width().max(bounds.height()) * k;
let s = (MAX_SIDE / longest).min(1.0) * k;
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
let (outline, pw, ph) = if kind == "shape" {
let max_edges = edge_budget(pw, ph);
let outline = if kind == "shape" {
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
let max_edges = usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES);
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
let o = simplify(polygons(path), pixel, max_edges);
let (pw, ph) = fit_raster(o.iter().map(|p| p.len().saturating_sub(1)).sum(), pw, ph);
(o, pw, ph)
simplify(polygons(path), pixel, max_edges)
} else {
(Vec::new(), pw, ph)
Vec::new()
};
// An outline `polygons` refused (too many points) has no contours: skip the shape rather than
// paint it with the rectangle fallback's different gradient.
if kind == "shape" && outline.is_empty() {
return;
}
let lut = ramp(stops);
let a = alpha.clamp(0.0, 1.0);
let mut data = Vec::with_capacity((pw * ph) as usize);
@@ -441,20 +372,6 @@ mod tests {
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
}
#[test]
fn a_shape_gradient_with_no_usable_outline_paints_nothing() {
// One polygon over the per-polygon cap is dropped by `polygons`.
let path = many_segment_square(26_000);
assert!(polygons(&path).is_empty());
let mut ctx = RenderContext::new(64, 64);
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (1.0, Rgba::rgb(255, 255, 255))];
fill(&mut ctx, "shape", CENTRE, &stops, Rect::new(0.0, 0.0, 64.0, 64.0), &path, Affine::IDENTITY, 1.0);
ctx.flush();
let mut px = vec![0u8; 64 * 64 * 4];
ctx.render(vello_cpu::PixmapMut::new(64, 64, &mut px).unwrap(), &mut vello_cpu::Resources::new());
assert!(px.chunks(4).all(|p| p[3] == 0), "nothing was painted");
}
#[test]
fn the_ramp_runs_through_the_stops() {
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
@@ -465,33 +382,4 @@ mod tests {
assert!((r[64].r as i32 - 64).abs() <= 2);
assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9)));
}
#[test]
fn huge_cubics_are_refused_before_flattening() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
// About 250,000 quads each: over the limit.
for _ in 0..3 {
p.curve_to((1e15, -1e15), (-1e15, 1e15), (1.0, 1.0));
}
p.close_path();
assert!(polygons(&p).is_empty());
// A cubic of ordinary size still flattens.
let mut q = BezPath::new();
q.move_to((0.0, 0.0));
q.curve_to((100.0, 0.0), (100.0, 100.0), (0.0, 100.0));
q.close_path();
assert_eq!(polygons(&q).len(), 1);
}
#[test]
fn many_contours_keep_every_contour_at_a_coarser_raster() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 1.0, 0.0), Point::new(x + 1.0, 1.0), Point::new(x, 1.0), Point::new(x, 0.0)];
let polys: Vec<Vec<Point>> = (0..100).map(|i| square(f64::from(i) * 2.0)).collect();
let simple = simplify(polys, 0.001, 244);
assert_eq!(simple.len(), 100, "no contour is dropped");
let (pw, ph) = fit_raster(400, 512, 512);
assert!(u64::from(pw) * u64::from(ph) * 400 <= MAX_EDGE_WORK && pw > 100, "{pw}x{ph}");
assert_eq!(fit_raster(200, 512, 512), (512, 512));
}
}
+8 -88
View File
@@ -12,9 +12,6 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand
use crate::icons::{self, Icon};
/// Video shapes that get a decoder at once; more play their sound without a picture.
const MAX_VIDEO_FEEDS: usize = 16;
/// Who started a voice: the editor's control bar stops when you leave the slide, the slide show
/// manages its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -230,10 +227,14 @@ impl MediaHost {
audio: clip.volume > 0.0,
};
let source_changed = self.owners.get(&id).is_some_and(|o| o.source != media_key);
// A shape whose feed could not be had (the cap, a decode error) tries again on each play.
let feedless = self.videos.get(&id).is_some_and(|v| v.feed.is_none());
if clip.video && (source_changed || feedless || !self.videos.contains_key(&id)) {
let feed = self.new_feed(id, &bytes);
if clip.video && (source_changed || !self.videos.contains_key(&id)) {
let feed = match VideoFeed::new(bytes.clone()) {
Ok(f) => Some(f),
Err(e) => {
log::info!("video {id}: {e}");
None
}
};
self.videos.insert(id, Video { feed, frame: None, tex: None });
} else if !clip.video {
self.videos.remove(&id);
@@ -242,35 +243,6 @@ impl MediaHost {
self.owners.insert(id, Owned { owner, clip: clip.clone(), source: media_key });
}
/// A decoder for the video of shape `id`, within the cap on live decoders; `None` past it or
/// when the video cannot be decoded (the shape then plays its sound only).
fn new_feed(&mut self, id: ShapeId, bytes: &Arc<Vec<u8>>) -> Option<VideoFeed> {
// One decoder and frame queue per video shape: past the cap a shape plays its sound only.
let mut live = self.videos.iter().filter(|(vid, v)| **vid != id && v.feed.is_some()).count();
if live >= MAX_VIDEO_FEEDS {
// Videos that have ended no longer need their decoder: free it and keep the last
// picture (a replay makes a new feed).
for (vid, v) in self.videos.iter_mut() {
if *vid != id && v.feed.is_some() && self.player.status(key(*vid)).is_some_and(|s| s.state == PlayState::Ended) {
v.feed = None;
live -= 1;
}
}
}
if live >= MAX_VIDEO_FEEDS {
log::info!("video {id}: more than {MAX_VIDEO_FEEDS} videos playing; showing no picture");
None
} else {
match VideoFeed::new(bytes.clone()) {
Ok(f) => Some(f),
Err(e) => {
log::info!("video {id}: {e}");
None
}
}
}
}
pub fn pause(&mut self, id: ShapeId) {
self.player.pause(key(id));
}
@@ -308,15 +280,6 @@ impl MediaHost {
self.play(doc_uid, doc, id, clip, Some(t), owner);
self.pause(id);
}
// A video whose decoder was reclaimed (or refused at the cap) tries for one again, so its picture follows the seek.
if clip.video && self.videos.get(&id).is_some_and(|v| v.feed.is_none()) {
let bytes = doc.media(clip.media).map(|m| m.data.clone());
if let Some(feed) = bytes.and_then(|b| self.new_feed(id, &b))
&& let Some(v) = self.videos.get_mut(&id)
{
v.feed = Some(feed);
}
}
self.player.seek(key(id), t);
}
@@ -841,47 +804,4 @@ mod tests {
assert_eq!((a.rect.x, a.rect.y, a.rect.w, a.rect.h), (100.0, 50.0, 20.0, 20.0));
assert_eq!((b.rect.x, b.rect.y, b.rect.w, b.rect.h), (120.0, 50.0, 20.0, 20.0));
}
/// Past `MAX_VIDEO_FEEDS` live decoders a video shape plays without a picture, and gets a
/// decoder again on its next play once one has been freed.
#[test]
fn video_decoders_are_capped_and_retried_when_one_frees_up() {
let mut doc = Presentation::default();
let media = doc.add_media("clip.mp4", "video/mp4", include_bytes!("../../media/tests/fixtures/clip-h264.mp4").to_vec()).expect("media");
let clip = MediaClip { media, volume: 0.0, video: true, ..Default::default() };
let mut host = MediaHost::new(None);
let live = |h: &MediaHost| h.videos.values().filter(|v| v.feed.is_some()).count();
for n in 0..=MAX_VIDEO_FEEDS as u32 {
host.play(1, &doc, ShapeId(5000 + n), &clip, None, Owner::Editor);
}
assert_eq!(live(&host), MAX_VIDEO_FEEDS);
let extra = ShapeId(5000 + MAX_VIDEO_FEEDS as u32);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()), "the shape past the cap has no picture");
host.stop(ShapeId(5000));
host.play(1, &doc, extra, &clip, None, Owner::Editor);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_some()), "a freed decoder is taken on the next play");
assert_eq!(live(&host), MAX_VIDEO_FEEDS);
}
/// Seeking a video that has no decoder (refused at the cap, or reclaimed) retries one without
/// restarting playback, so its picture follows the seek.
#[test]
fn seeking_a_feedless_video_retries_its_decoder() {
let mut doc = Presentation::default();
let media = doc.add_media("clip.mp4", "video/mp4", include_bytes!("../../media/tests/fixtures/clip-h264.mp4").to_vec()).expect("media");
let clip = MediaClip { media, volume: 0.0, video: true, ..Default::default() };
let mut host = MediaHost::new(None);
for n in 0..=MAX_VIDEO_FEEDS as u32 {
host.play(1, &doc, ShapeId(7000 + n), &clip, None, Owner::Editor);
}
let extra = ShapeId(7000 + MAX_VIDEO_FEEDS as u32);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()));
// Still at the cap: a seek does not exceed it.
host.seek(1, &doc, extra, &clip, 0.0, Owner::Editor);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()));
host.stop(ShapeId(7000));
host.seek(1, &doc, extra, &clip, 0.0, Owner::Editor);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_some()), "the seek got a decoder");
assert_eq!(host.videos.values().filter(|v| v.feed.is_some()).count(), MAX_VIDEO_FEEDS);
}
}
+4 -36
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use deckcraft_model::edit;
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
use egui::text::{Galley, LayoutJob, TextFormat};
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
use egui::{Align2, Color32, Stroke, Ui, vec2};
use crate::theme;
@@ -210,30 +210,16 @@ pub fn editor(
out
}
/// The (min y, max y) of the row holding char index `at`; `walk` (row index, chars through that
/// row) carries over between calls, so successive non-decreasing `at`s cost one pass in all.
fn row_span_at(galley: &Galley, walk: &mut (usize, usize), at: usize) -> (f32, f32) {
while walk.0 + 1 < galley.rows.len() && (walk.1 < at || (walk.1 == at && galley.rows[walk.0].char_count_including_newline().0 > 0)) {
walk.0 += 1;
walk.1 += galley.rows[walk.0].char_count_including_newline().0;
}
galley.rows.get(walk.0).map_or((0.0, 0.0), |r| (r.min_y(), r.max_y()))
}
/// Bullets and numbers at the start of each paragraph's first line of `galley` (laid out from
/// `body`), drawn at `origin`.
fn paint_bullets(painter: &egui::Painter, body: &TextBody, galley: &Galley, origin: egui::Pos2, size: f32, color: Color32) {
let k = size / 12.0;
let mut at = 0usize;
// Paragraph starts only grow, so one forward walk over the rows finds each start's row
// (`pos_from_cursor` would rescan from the top for every bullet).
let mut walk = (0usize, galley.rows.first().map_or(0, |r| r.char_count_including_newline().0));
for (p, bullet) in body.paragraphs.iter().zip(bullets(body)) {
if let Some(b) = bullet {
let (min_y, max_y) = row_span_at(galley, &mut walk, at);
let (center_y, height) = ((min_y + max_y) / 2.0, max_y - min_y);
let pos = origin + vec2(px(indents(p).0, k), center_y);
let font = theme::font(height.clamp(size * 0.75, size * 2.0) * 0.8);
let row = galley.pos_from_cursor(CCursor::new(at));
let pos = origin + vec2(px(indents(p).0, k), row.center().y);
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
}
at += p.char_len() + 1;
@@ -255,7 +241,6 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use deckcraft_model::text::{ParaProps, Run};
use egui::text::CCursor;
use super::*;
@@ -332,23 +317,6 @@ mod tests {
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
}
#[test]
fn row_walk_matches_cursor_lookup_at_paragraph_starts() {
let ctx = egui::Context::default();
let body = notes();
let mut laid = None;
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
out.textures_delta.clear();
let g = laid.unwrap();
let mut walk = (0usize, g.rows.first().map_or(0, |r| r.char_count_including_newline().0));
let mut at = 0usize;
for p in &body.paragraphs {
let want = g.pos_from_cursor(CCursor::new(at));
assert_eq!(row_span_at(&g, &mut walk, at), (want.min.y, want.max.y), "paragraph at char {at}");
at += p.char_len() + 1;
}
}
#[test]
fn typing_keeps_the_formatting_around_it() {
let mut d = Draft::new(&notes());
+7 -29
View File
@@ -196,40 +196,18 @@ pub(super) fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Optio
]
}
/// Width of a slide-show grid thumbnail, in pixels.
const THUMB_WIDTH: f64 = 320.0;
/// Tallest grid thumbnail, so an extreme portrait slide cannot ask for a huge raster.
const THUMB_MAX_HEIGHT: f64 = 2048.0;
/// The scale and pixel size of a grid thumbnail of a `width` x `height` slide.
fn thumb_size(width: f64, height: f64) -> (f64, f64, f64) {
let (sw, sh) = (width.max(1.0), height.max(1.0));
let scale = (THUMB_WIDTH / sw).min(THUMB_MAX_HEIGHT / sh);
(scale, (sw * scale).round().max(1.0), (sh * scale).round().max(1.0))
}
pub(super) fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usize) -> Option<TextureHandle> {
if show.thumbs.len() >= GRID_CAP {
return None;
}
// 320 wide, unless the slide is so tall that this would pass the height limit: fit that box.
let (scale, w, h) = thumb_size(doc.slide_size.width, doc.slide_size.height);
let img = deckcraft_render::render_slide(doc, i, &RenderOpts { scale, size: Some((w as u32, h as u32)), ..Default::default() });
let w = 320.0_f64;
let h = (w * doc.slide_size.height / doc.slide_size.width.max(1.0)).round().max(1.0);
let img = deckcraft_render::render_slide(
doc,
i,
&RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() },
);
let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false));
show.thumbs.push((i, tex.clone()));
Some(tex)
}
#[cfg(test)]
mod thumb_size_tests {
use super::*;
#[test]
fn ordinary_slides_are_320_wide_and_extreme_ones_stay_in_the_height_limit() {
let (scale, w, h) = thumb_size(960.0, 540.0);
assert_eq!((w, h), (320.0, 180.0));
assert!((scale - 320.0 / 960.0).abs() < 1e-12);
let (_, w, h) = thumb_size(1.0, 1.0e9);
assert!(h <= THUMB_MAX_HEIGHT && w >= 1.0);
}
}
+1 -7
View File
@@ -166,17 +166,11 @@ impl Decoder {
if frames.len() > 1 {
self.stats.superframes += 1;
}
let queued = self.pending.len();
for f in frames {
if f.is_empty() {
continue;
}
if let Err(e) = self.decode_frame(f, pts) {
// A failed sample outputs nothing: drop what its earlier subframes queued so a
// later drain does not expand each repeat into its own picture.
self.pending.truncate(queued);
return Err(e);
}
self.decode_frame(f, pts)?;
}
Ok(self.drain(false))
}