mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-12 08:51:27 -06:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc73ab189 | ||
|
|
2127bd1bd8 | ||
|
|
eec4f59535 | ||
|
|
988a492097 | ||
|
|
2406ce46ec | ||
|
|
60f91f7806 | ||
|
|
2ef5f31bc8 | ||
|
|
c6846f6a75 | ||
|
|
a3f2f215b0 | ||
|
|
e37df901ef | ||
|
|
0ce1a83c14 | ||
|
|
fc51f46c83 |
@@ -1,6 +1,10 @@
|
||||
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:
|
||||
@@ -30,6 +34,7 @@ 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
|
||||
|
||||
@@ -118,6 +118,8 @@ enum Output {
|
||||
grain: FilmGrainParams,
|
||||
apply_film_grain: bool,
|
||||
pts: i64,
|
||||
/// What copying the picture out costs, in the reference's own format.
|
||||
bytes: u64,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -132,7 +134,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))
|
||||
}
|
||||
@@ -379,8 +381,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);
|
||||
if !scalable {
|
||||
self.out.extend(out.into_iter().map(|(o, _, _)| o));
|
||||
let kept: Vec<Output> = if !scalable {
|
||||
out.into_iter().map(|(o, _, _)| o).collect()
|
||||
} else {
|
||||
let mut last: Option<(usize, u32)> = None;
|
||||
let mut sel: Vec<Output> = Vec::new();
|
||||
@@ -400,8 +402,27 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.out.extend(sel);
|
||||
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(ok);
|
||||
r
|
||||
}
|
||||
|
||||
@@ -503,6 +524,15 @@ 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(),
|
||||
@@ -511,6 +541,7 @@ 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)
|
||||
@@ -629,6 +660,9 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -777,6 +811,14 @@ 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>>> {
|
||||
@@ -790,7 +832,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 {
|
||||
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc && tile_metadata_fits(tiles.len(), fs.tile_metadata_bytes()) {
|
||||
let mut states: Vec<TileState> = tiles
|
||||
.iter()
|
||||
.map(|(num, _)| {
|
||||
@@ -1075,6 +1117,14 @@ 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 {
|
||||
|
||||
@@ -360,6 +360,10 @@ 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.
|
||||
|
||||
@@ -253,6 +253,13 @@ 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 {
|
||||
|
||||
@@ -80,6 +80,12 @@ pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let opts = album_opts(p)?;
|
||||
let items = album_items(p)?;
|
||||
let (id, ids) = album_target(s, p)?;
|
||||
// The editor is seeded within the album limits; replacing the album from a trimmed seed would
|
||||
// delete what it left out, so refuse rather than lose it.
|
||||
let st = s.doc()?;
|
||||
if items_from_slides(&st.doc, &album_slides(st, &ids)).1 {
|
||||
return Err(bad("insert.photoAlbumEdit", "this album holds more than the editor can carry; editing it would delete the rest"));
|
||||
}
|
||||
commit_album(s, items, opts, Some((id, ids)))
|
||||
}
|
||||
|
||||
@@ -545,6 +551,19 @@ fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
|
||||
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
|
||||
}
|
||||
|
||||
/// Whether a slide holds anything besides album items: shapes, notes, comments, a background, a
|
||||
/// transition, animations, a colour map or tags.
|
||||
fn has_slide_content(s: &deckcraft_model::Slide) -> bool {
|
||||
!s.shapes.is_empty()
|
||||
|| !s.notes.text().trim().is_empty()
|
||||
|| !s.comments.is_empty()
|
||||
|| s.background.is_some()
|
||||
|| s.transition.is_some()
|
||||
|| !s.animations.is_empty()
|
||||
|| s.color_map.is_some()
|
||||
|| !s.tags.is_empty()
|
||||
}
|
||||
|
||||
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
|
||||
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
|
||||
}
|
||||
@@ -592,9 +611,16 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
|
||||
(layout, frame.into(), captions, any && gray, theme_now.to_string())
|
||||
}
|
||||
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
|
||||
/// The album's items for the dialog, within the limits `insert.photoAlbum` itself takes (items,
|
||||
/// bytes per picture and in all), checked before a picture is encoded: an album slide set can
|
||||
/// name one large picture many times.
|
||||
///
|
||||
/// The flag is true when something was left out for a limit.
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> (Vec<Value>, bool) {
|
||||
let mut items = Vec::new();
|
||||
for slide in slides {
|
||||
let mut total = 0usize;
|
||||
let mut omitted = false;
|
||||
'slides: for slide in slides {
|
||||
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
|
||||
if pics.is_empty() {
|
||||
let text = slide
|
||||
@@ -605,13 +631,27 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
||||
.find(|t| !t.trim().is_empty())
|
||||
.unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
if items.len() >= ALBUM_MAX_ITEMS {
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
items.push(json!({"text": text.trim()}));
|
||||
} else if items.len() >= ALBUM_MAX_ITEMS && has_slide_content(slide) {
|
||||
// Past the item limit, a slide with other content would be deleted by a replace.
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sh in pics {
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
let media = doc.media(fill.media);
|
||||
let size = media.map_or(0, |m| m.data.len());
|
||||
if items.len() >= ALBUM_MAX_ITEMS || size > ALBUM_MAX_BYTES || total.saturating_add(size) > ALBUM_MAX_TOTAL {
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
total += size;
|
||||
let name = if !sh.descr.is_empty() {
|
||||
sh.descr.clone()
|
||||
} else {
|
||||
@@ -626,14 +666,19 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
||||
}));
|
||||
}
|
||||
}
|
||||
items
|
||||
(items, omitted)
|
||||
}
|
||||
|
||||
/// The slides of album `ids` as the editor would seed them.
|
||||
fn album_slides<'a>(st: &'a crate::DocState, ids: &[u64]) -> Vec<&'a deckcraft_model::Slide> {
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect()
|
||||
}
|
||||
|
||||
fn album_seed(s: &Session) -> Result<Value> {
|
||||
let (id, ids) = album_target(s, &Value::Null)?;
|
||||
let st = s.doc()?;
|
||||
let slides: Vec<&deckcraft_model::Slide> =
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
|
||||
let slides = album_slides(st, &ids);
|
||||
let (items, _) = items_from_slides(&st.doc, &slides);
|
||||
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
|
||||
let inferred = infer_album(&slides, &theme_now);
|
||||
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
|
||||
@@ -655,7 +700,7 @@ fn album_seed(s: &Session) -> Result<Value> {
|
||||
"captions": captions,
|
||||
"grayscale": grayscale,
|
||||
"theme": theme,
|
||||
"items": items_from_slides(&st.doc, &slides),
|
||||
"items": items,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -722,3 +767,96 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Op
|
||||
s.execute("slide.go", &json!({"index": first}))?;
|
||||
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod seed_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reopening_an_album_seeds_at_most_the_items_an_album_takes() {
|
||||
// One picture named by 500 shapes: the dialog gets 48 items, not 500 encoded copies.
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("a.png", "image/png", vec![1; 64 * 1024]).unwrap();
|
||||
let pic = |k: u32| Shape {
|
||||
id: ShapeId(10_000 + k),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = (0..500).map(pic).collect();
|
||||
let slides: Vec<&deckcraft_model::Slide> = p.slides.iter().map(|s| s.as_ref()).collect();
|
||||
let (items, omitted) = items_from_slides(&p, &slides);
|
||||
assert_eq!(items.len(), ALBUM_MAX_ITEMS);
|
||||
assert!(omitted);
|
||||
let few: Vec<&deckcraft_model::Slide> = Vec::new();
|
||||
assert!(!items_from_slides(&p, &few).1);
|
||||
}
|
||||
|
||||
/// An album too big for the editor's seed cannot be replaced from that seed: the replace would
|
||||
/// delete what the seed left out.
|
||||
#[test]
|
||||
fn editing_an_album_the_editor_cannot_hold_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let n = doc.slides.len();
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
|
||||
}
|
||||
|
||||
/// Past the item limit, a slide holding only a drawing counts as content the replace would delete.
|
||||
#[test]
|
||||
fn editing_an_album_with_a_drawing_slide_past_the_limit_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
extra.shapes = vec![Shape { id: ShapeId(90_000), ..Default::default() }];
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
}
|
||||
|
||||
/// Notes or comments on a shapeless slide past the limit are content a replace would delete.
|
||||
#[test]
|
||||
fn editing_an_album_with_a_notes_only_slide_past_the_limit_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
extra.shapes.clear();
|
||||
extra.notes = deckcraft_model::text::TextBody::from_text("speaker notes");
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
}
|
||||
|
||||
/// A shapeless slide with a background, transition, colour map or tags counts as content; a plain empty one does not.
|
||||
#[test]
|
||||
fn slide_level_settings_count_as_slide_content() {
|
||||
use deckcraft_model::Slide;
|
||||
assert!(!has_slide_content(&Slide::default()));
|
||||
assert!(has_slide_content(&Slide { tags: vec![("k".into(), "v".into())], ..Default::default() }));
|
||||
assert!(has_slide_content(&Slide { color_map: Some(vec![("bg1".into(), "lt1".into())]), ..Default::default() }));
|
||||
assert!(has_slide_content(&Slide { transition: Some(Default::default()), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,10 +404,36 @@ 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!(
|
||||
shape.fits(pictures, self.memory_budget),
|
||||
"{}x{} pictures with {pictures} in the decoder exceed the memory budget",
|
||||
total.is_some_and(|b| b <= self.memory_budget),
|
||||
"{}x{} pictures with {pictures} in the decoder and {queued} bytes queued exceed the memory budget",
|
||||
sps.width,
|
||||
sps.height
|
||||
);
|
||||
|
||||
@@ -313,6 +313,8 @@ pub(crate) struct Canvas {
|
||||
saved: Vec<Saved>,
|
||||
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
|
||||
used: usize,
|
||||
/// Bytes retained outside the canvas (EMF+ objects) that count against the same cap.
|
||||
held: usize,
|
||||
objects: Vec<Option<Obj>>,
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
@@ -345,6 +347,7 @@ impl Canvas {
|
||||
viewport_set: false,
|
||||
saved: Vec::new(),
|
||||
used: 0,
|
||||
held: 0,
|
||||
objects: Vec::new(),
|
||||
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
|
||||
brush: Brush::new(BS_SOLID, [255, 255, 255, 255]),
|
||||
@@ -368,6 +371,17 @@ impl Canvas {
|
||||
self.surface = clip::normalize(rect);
|
||||
}
|
||||
|
||||
/// Bytes retained outside the canvas that share its budget (see [`Canvas::hold`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn held(&self) -> usize {
|
||||
self.held
|
||||
}
|
||||
|
||||
/// Bytes the canvas has retained so far.
|
||||
pub fn used(&self) -> usize {
|
||||
self.used
|
||||
}
|
||||
|
||||
/// True once the item cap is hit; the readers stop there and keep what they have.
|
||||
pub fn full(&self) -> bool {
|
||||
self.full
|
||||
@@ -385,6 +399,12 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the bytes another part of the player retains, so the items and they stay within
|
||||
/// [`MAX_BYTES`] together.
|
||||
pub fn hold(&mut self, bytes: usize) {
|
||||
self.held = bytes;
|
||||
}
|
||||
|
||||
fn push_raw(&mut self, mut raw: Raw) {
|
||||
if self.clip.as_ref().is_some_and(Vec::is_empty) {
|
||||
return;
|
||||
@@ -402,7 +422,7 @@ impl Canvas {
|
||||
Raw::Bitmap { img, .. } => img.px.len(),
|
||||
Raw::Text { text: t, .. } => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
|
||||
} + region_cost;
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
} else {
|
||||
self.used += cost;
|
||||
@@ -551,6 +571,10 @@ impl Canvas {
|
||||
/// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers
|
||||
/// the shape.
|
||||
pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) {
|
||||
// A full canvas or an empty clip keeps nothing more, so do not sample a tile for it.
|
||||
if self.full || self.clip.as_ref().is_some_and(Vec::is_empty) {
|
||||
return;
|
||||
}
|
||||
let dev: Vec<Op> = ops.iter().map(|o| self.map_op(*o)).collect();
|
||||
let Some([l, t, r, b]) = crate::cliprect::ops_bbox(&dev) else { return };
|
||||
let side = (r - l).max(b - t);
|
||||
@@ -666,7 +690,7 @@ impl Canvas {
|
||||
// A pattern brush's pixels count against the byte budget like drawn items.
|
||||
if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj {
|
||||
let cost = p.px.len();
|
||||
if self.used.saturating_add(cost) > MAX_BYTES {
|
||||
if self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,13 +288,23 @@ impl Plus {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
match typ {
|
||||
0x4008 => self.object(flags, r),
|
||||
let done = match typ {
|
||||
0x4008 => self.object(flags, r, cv.used().saturating_add(self.clip_bytes())),
|
||||
0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r),
|
||||
0x4025..=0x4035 => self.record_state(cv, typ, flags, r),
|
||||
0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r),
|
||||
_ => self.record_shape(cv, typ, flags, r),
|
||||
}
|
||||
};
|
||||
// What this player retains (objects, a continued object's pieces, the current and saved
|
||||
// clips) shares the canvas's byte budget; clip changes are charged as they happen.
|
||||
cv.hold(self.object_bytes.saturating_add(self.partial.as_ref().map_or(0, |p| p.data.len())).saturating_add(self.clip_bytes()));
|
||||
done
|
||||
}
|
||||
|
||||
/// Bytes of the current clip and of every saved one.
|
||||
fn clip_bytes(&self) -> usize {
|
||||
let area = |a: &Area| a.as_ref().map_or(0, |v| v.len().saturating_mul(std::mem::size_of::<P>()));
|
||||
self.stack.iter().map(|s| area(&s.clip)).fold(area(&self.clip), usize::saturating_add)
|
||||
}
|
||||
|
||||
/// Rectangle, polygon, ellipse and path records.
|
||||
@@ -559,7 +569,7 @@ impl Plus {
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn object(&mut self, flags: u16, r: Bytes) -> Option<()> {
|
||||
fn object(&mut self, flags: u16, r: Bytes, canvas_used: usize) -> Option<()> {
|
||||
let id = usize::from(flags & 0xFF);
|
||||
let kind = (flags >> 8) & 0x7F;
|
||||
if flags & 0x8000 != 0 {
|
||||
@@ -575,7 +585,7 @@ impl Plus {
|
||||
p.data.extend_from_slice(&chunk[..chunk.len().min(room)]);
|
||||
if p.data.len() >= p.total {
|
||||
let obj = parse_object(p.kind, Bytes(&p.data));
|
||||
self.store(p.id, obj);
|
||||
self.store(p.id, obj, canvas_used);
|
||||
} else {
|
||||
self.partial = Some(p);
|
||||
}
|
||||
@@ -589,22 +599,23 @@ impl Plus {
|
||||
let room = p.total.saturating_sub(p.data.len());
|
||||
p.data.extend_from_slice(&r.0[..r.0.len().min(room)]);
|
||||
let obj = parse_object(kind, Bytes(&p.data));
|
||||
self.store(id, obj);
|
||||
self.store(id, obj, canvas_used);
|
||||
return Some(());
|
||||
}
|
||||
let obj = parse_object(kind, r);
|
||||
self.store(id, obj);
|
||||
self.store(id, obj, canvas_used);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn store(&mut self, id: usize, obj: Option<Obj>) {
|
||||
/// `canvas_used` is what the canvas already retains: objects and items share one budget.
|
||||
fn store(&mut self, id: usize, obj: Option<Obj>, canvas_used: usize) {
|
||||
let Some(slot) = self.objects.get_mut(id) else { return };
|
||||
if let Some(old) = slot.take() {
|
||||
self.object_bytes = self.object_bytes.saturating_sub(Self::obj_cost(&old));
|
||||
}
|
||||
if let Some(new_obj) = obj {
|
||||
let cost = Self::obj_cost(&new_obj);
|
||||
if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES {
|
||||
if self.object_bytes.saturating_add(cost).saturating_add(canvas_used) <= crate::MAX_BYTES {
|
||||
self.object_bytes += cost;
|
||||
*slot = Some(new_obj);
|
||||
}
|
||||
@@ -615,7 +626,7 @@ impl Plus {
|
||||
match obj {
|
||||
Obj::Image(img) => match img.as_ref() {
|
||||
ImageData::Bitmap(rgba) => rgba.px.len(),
|
||||
ImageData::Meta(pic) => pic.items.len().saturating_mul(128),
|
||||
ImageData::Meta(pic) => picture_cost(&pic.items),
|
||||
},
|
||||
Obj::Brush(Fill::Texture { img, .. }) => img.px.len(),
|
||||
Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::<Op>()),
|
||||
@@ -1282,6 +1293,33 @@ fn metafile(r: Bytes) -> Option<Picture> {
|
||||
pic.filter(|p| p.width > 0.0 && p.height > 0.0)
|
||||
}
|
||||
|
||||
/// Bytes the items of a nested picture own: path segments, clip rectangles, pixels and text, with
|
||||
/// each shared pattern tile counted once.
|
||||
fn picture_cost(items: &[crate::Item]) -> usize {
|
||||
use crate::Item;
|
||||
let region = |r: &Option<Vec<[f32; 4]>>| r.as_ref().map_or(0, |v| v.len().saturating_mul(16));
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
let mut total = 0usize;
|
||||
for item in items {
|
||||
let own = match item {
|
||||
Item::Path { segs, region: r, .. } => segs.len().saturating_mul(std::mem::size_of::<crate::Seg>()).saturating_add(region(r)),
|
||||
Item::Bitmap { rgba, region: r, .. } => rgba.len().saturating_add(region(r)),
|
||||
Item::Pattern { segs, tile, region: r, .. } => {
|
||||
let tile_bytes = if tiles.insert(Arc::as_ptr(tile)) { tile.rgba.len() } else { 0 };
|
||||
segs.len().saturating_mul(std::mem::size_of::<crate::Seg>()).saturating_add(tile_bytes).saturating_add(region(r))
|
||||
}
|
||||
Item::Text(t) => t
|
||||
.text
|
||||
.len()
|
||||
.saturating_add(t.dx.len().saturating_mul(std::mem::size_of::<f32>()))
|
||||
.saturating_add(t.font.family.len())
|
||||
.saturating_add(region(&t.region)),
|
||||
};
|
||||
total = total.saturating_add(128).saturating_add(own);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn bitmap(r: Bytes) -> Option<Rgba> {
|
||||
let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32);
|
||||
let format = r.u32(20)?;
|
||||
@@ -1559,3 +1597,45 @@ fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec<Vec<u16>> {
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cost_tests {
|
||||
use super::*;
|
||||
use crate::{Item, Seg, Tile};
|
||||
|
||||
#[test]
|
||||
fn a_shared_pattern_tile_is_charged_once() {
|
||||
let tile = Arc::new(Tile { width: 512, height: 512, rgba: vec![0; 1 << 20], cell: [1.0, 1.0], origin: [0.0, 0.0] });
|
||||
let item = || Item::Pattern { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)], even_odd: false, tile: tile.clone(), region: None };
|
||||
let items: Vec<Item> = (0..257).map(|_| item()).collect();
|
||||
assert!(picture_cost(&items) < 2 << 20, "{}", picture_cost(&items));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_of_a_continued_object_count_against_the_shared_budget() {
|
||||
let (mut plus, mut cv) = (Plus::new(), Canvas::new());
|
||||
plus.active = true;
|
||||
// Image object (kind 5), id 0, continued: 1000 bytes announced, 100 delivered.
|
||||
let mut rec = 1000u32.to_le_bytes().to_vec();
|
||||
rec.extend([0u8; 100]);
|
||||
plus.record(&mut cv, 0x4008, 0x8000 | (5 << 8), Bytes(&rec));
|
||||
assert_eq!(cv.held(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_and_saved_clips_count_against_the_shared_budget() {
|
||||
let (mut plus, mut cv) = (Plus::new(), Canvas::new());
|
||||
plus.active = true;
|
||||
let pts = vec![(0.0, 0.0); 1000];
|
||||
plus.clip = Some(pts.clone());
|
||||
plus.stack.push(Saved { id: 1, world: IDENTITY, page: 1.0, clip: Some(pts) });
|
||||
// Any record refreshes the held count (an unknown record draws nothing).
|
||||
plus.record(&mut cv, 0x40FF, 0, Bytes(&[]));
|
||||
assert_eq!(cv.held(), 2 * 1000 * std::mem::size_of::<P>());
|
||||
// Restoring the saved state releases the charge of what it dropped.
|
||||
plus.stack.clear();
|
||||
plus.clip = None;
|
||||
plus.record(&mut cv, 0x40FF, 0, Bytes(&[]));
|
||||
assert_eq!(cv.held(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ impl Picture {
|
||||
let k = (c[0].abs() + c[3].abs()) / 2.0;
|
||||
let mut out = Vec::with_capacity(self.items.len());
|
||||
let mut total_elements = 0usize;
|
||||
let region_elements = std::cell::Cell::new(0usize);
|
||||
let mut cached_region: Option<(*const [f32; 4], usize, Option<Option<Arc<BezPath>>>)> = None;
|
||||
let mut get_region = |reg: &Option<Vec<[f32; 4]>>| -> Option<Option<Arc<BezPath>>> {
|
||||
let (ptr, len) = match reg {
|
||||
@@ -71,12 +72,15 @@ impl Picture {
|
||||
return res.clone();
|
||||
}
|
||||
let res = placed_region(&tf, reg);
|
||||
// Each distinct clip region is expanded once; its elements count against the placement budget.
|
||||
let n = res.as_ref().and_then(Option::as_ref).map_or(0, |p| p.elements().len());
|
||||
region_elements.set(region_elements.get().saturating_add(n));
|
||||
cached_region = Some((ptr, len, res.clone()));
|
||||
res
|
||||
};
|
||||
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
if total_elements >= MAX_PLACED_ELEMENTS {
|
||||
if total_elements.saturating_add(region_elements.get()) >= MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
match item {
|
||||
@@ -84,6 +88,10 @@ impl Picture {
|
||||
let path = tf * bez(segs);
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
if in_range(path.bounding_box()) {
|
||||
// This item's own region is expanded by now: it counts before it is kept.
|
||||
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region });
|
||||
@@ -97,6 +105,9 @@ impl Picture {
|
||||
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
|
||||
let det = tile.determinant();
|
||||
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
|
||||
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region });
|
||||
}
|
||||
|
||||
@@ -897,6 +897,24 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() {
|
||||
assert_eq!(stroke.map(|s| s.1), Some(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_counts_the_items_own_clip_region_before_keeping_it() {
|
||||
// A path of 499,998 elements nearly fills the 500,000 budget; a second one whose new clip
|
||||
// region adds elements goes past it and is left out.
|
||||
let mut segs = vec![Seg::Move(0.0, 0.0)];
|
||||
segs.extend((0..499_997).map(|i| Seg::Line(1.0 + (i % 50) as f32, 1.0)));
|
||||
let big = Item::Path { segs, fill: None, stroke: None, even_odd: false, region: None };
|
||||
let small = Item::Path {
|
||||
segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)],
|
||||
fill: None,
|
||||
stroke: None,
|
||||
even_odd: false,
|
||||
region: Some(vec![[0.0, 0.0, 50.0, 25.0], [10.0, 10.0, 20.0, 20.0]]),
|
||||
};
|
||||
let pic = crate::Picture { width: 100.0, height: 50.0, items: vec![big, small] };
|
||||
assert_eq!(pic.place(kurbo::Rect::new(0.0, 0.0, 100.0, 50.0), [0.0; 4]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_keeps_only_finite_bounded_bitmaps_with_area() {
|
||||
let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4], region: None };
|
||||
@@ -2155,3 +2173,24 @@ fn region_intersection_over_budget_keeps_the_previous_area() {
|
||||
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 10).is_none());
|
||||
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut crate::cliprect::MAX_AREA_CLIP_WORK.clone()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_canvas_does_not_sample_gradient_tiles() {
|
||||
let mut cv = Canvas::new();
|
||||
let big = || Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; MAX_BYTES / 2] };
|
||||
for _ in 0..3 {
|
||||
cv.bitmap([0.0, 0.0, 1.0, 1.0], big());
|
||||
}
|
||||
assert!(cv.full());
|
||||
cv.shade_shape(&crate::geom::rect(0.0, 0.0, 10.0, 10.0), &|_| panic!("sampled a full canvas"));
|
||||
assert_eq!(cv.items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_budget_counts_bytes_held_elsewhere() {
|
||||
let mut cv = Canvas::new();
|
||||
cv.hold(MAX_BYTES - 100);
|
||||
cv.bitmap([0.0, 0.0, 1.0, 1.0], Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; 200] });
|
||||
assert!(cv.full());
|
||||
assert!(cv.items.is_empty());
|
||||
}
|
||||
|
||||
+194
-26
@@ -10,6 +10,7 @@ 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;
|
||||
|
||||
@@ -99,21 +100,22 @@ 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 }
|
||||
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) }
|
||||
}
|
||||
/// Serialize an element with the namespace declarations it needs from the part root.
|
||||
pub fn keep(&self, el: &El) -> String {
|
||||
keep_xml(el, &self.ns)
|
||||
keep_with(el, &[&self.ns], Some(&self.ns_budget))
|
||||
}
|
||||
/// Like [`Part::keep`], also declaring prefixes that only an enclosing wrapper binds.
|
||||
pub fn keep_inner(&self, el: &El) -> String {
|
||||
let ns: Vec<(String, String)> = self.ns.iter().chain(&self.inner_ns).cloned().collect();
|
||||
keep_xml(el, &ns)
|
||||
keep_with(el, &[&self.ns, &self.inner_ns], Some(&self.ns_budget))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,36 +150,81 @@ fn inner_decls(root: &El, ns: &[(String, String)]) -> Vec<(String, String)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if let Some((p, _)) = e.name.split_once(':') {
|
||||
used.insert(p.to_string());
|
||||
used.insert(p);
|
||||
}
|
||||
for (k, _) in &e.attrs {
|
||||
if let Some((p, _)) = k.split_once(':')
|
||||
for (k, v) in &e.attrs {
|
||||
if let Some((p, local)) = k.split_once(':')
|
||||
&& p != "xmlns"
|
||||
&& p != "xml"
|
||||
{
|
||||
used.insert(p.to_string());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in e.elements() {
|
||||
rec(c, used, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut used = HashSet::new();
|
||||
rec(el, &mut used, 0);
|
||||
let mut el = el.clone();
|
||||
for (k, v) in ns {
|
||||
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 p = k.strip_prefix("xmlns:").unwrap_or("");
|
||||
if !p.is_empty() && used.contains(p) && !el.attrs.iter().any(|(a, _)| a == k) {
|
||||
el.attrs.push((k.clone(), v.clone()));
|
||||
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()));
|
||||
}
|
||||
}
|
||||
let mut el = el.clone();
|
||||
el.attrs.extend(extra);
|
||||
el.to_xml()
|
||||
}
|
||||
|
||||
@@ -326,10 +373,18 @@ 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)) {
|
||||
if let Some(x) = text(&sub.target)
|
||||
&& !out.iter().any(|k| k.name.eq_ignore_ascii_case(&sub.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) {
|
||||
out.push(KeptPart {
|
||||
content_type: pkg.content_type(&sub.target).unwrap_or_default(),
|
||||
name: sub.target.clone(),
|
||||
@@ -338,7 +393,9 @@ fn kept_parts(pkg: &Package, pres_path: &str) -> Vec<deckcraft_model::KeptPart>
|
||||
});
|
||||
}
|
||||
}
|
||||
out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() });
|
||||
if !capped && out.len() < 400 {
|
||||
out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,15 +432,39 @@ 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();
|
||||
// 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()));
|
||||
}
|
||||
}
|
||||
e.attrs.extend(wanted.into_iter().map(|i| (decls[i].0.clone(), decls[i].1.clone())));
|
||||
out.push(Node::El(e));
|
||||
}
|
||||
}
|
||||
@@ -1445,3 +1526,90 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,7 +741,13 @@ 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![] };
|
||||
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 mut para = Paragraph::default();
|
||||
push_math_run(&mut para, &part, &d.root);
|
||||
assert_eq!(para.runs[0].text, "word");
|
||||
|
||||
@@ -132,7 +132,20 @@ 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");
|
||||
@@ -147,6 +160,7 @@ 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");
|
||||
@@ -165,6 +179,7 @@ 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() {
|
||||
@@ -246,6 +261,9 @@ 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);
|
||||
@@ -305,7 +323,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());
|
||||
let n = c.categories.len().max(s.values.len()).min(MAX_CACHE_POINTS);
|
||||
let last = n + 1;
|
||||
if let Some(e) = &s.error_bars {
|
||||
error_bars(w, e, g);
|
||||
@@ -491,6 +509,24 @@ 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() {
|
||||
@@ -529,7 +565,17 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
|
||||
if g != Group::Stock {
|
||||
w.val("c:varyColors", vary);
|
||||
}
|
||||
for (i, s) in list {
|
||||
// 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);
|
||||
series(w, c, s, *i, kind, g);
|
||||
}
|
||||
if c.data_labels {
|
||||
@@ -1061,7 +1107,21 @@ mod region_map_tests {
|
||||
mod tests {
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::ColorRef;
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -1122,3 +1182,19 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,11 +166,17 @@ impl Decoder {
|
||||
if frames.len() > 1 {
|
||||
self.stats.superframes += 1;
|
||||
}
|
||||
let queued = self.pending.len();
|
||||
for f in frames {
|
||||
if f.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.decode_frame(f, pts)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Ok(self.drain(false))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user