Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 7cc73ab189 Merge tested main for v0.18.1
# Conflicts:
#	Cargo.lock
2026-11-11 18:40:26 -05:00
FredApps 2127bd1bd8 CI: nightly release dry run (#2052)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:03:54 +01:00
Dylan M. Taylor eec4f59535 AV1: decode tiles serially when parallel tile metadata copies would be huge (#1841) 2026-11-11 17:00:12 -05:00
Dylan M. Taylorandaiongg 988a492097 PPTX: copy only used, bounded namespace declarations onto imported fragments (#1740)
* pptx: copy only used, bounded namespace declarations onto imported fragments

* address review: index a branch's namespace declarations once, look up each child's prefixes

* address review: oversized declarations keep shadowing; keep MustUnderstand prefixes

* address review: keep declarations for prefixes used in QName attribute values

* Apply review fixes

- crates/pptx/src/read.rs

* address review: keep 400-part cap inclusive for rels part

* address review: budget the namespace bytes copied onto kept fragments per part

* fix test initializer: Part gained ns_budget

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 16:15:23 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> 2406ce46ec chore: bump version to 0.18.1 for the Flatpak update fix (#2048)
Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 15:25:10 -05:00
Dylan M. Taylor 60f91f7806 Video decoders: budget repeated-frame and queued output pictures, drop a failed VP9 sample's outputs (#1778)
* Video decoders: budget repeated-frame and queued output pictures, drop a failed VP9 sample's outputs

* address review: size AV1 repeats by the reference format and after layer selection, budget HEVC queued frames at their own size

* address review: charge the larger of the output queue and the in-flight reservation, not both

* address review: count frames held by the old DPB at their own size in the budget check

* address review: charge queued frames outside the DPB and the in-flight set separately
2026-11-11 15:13:36 -05:00
Dylan M. Taylor 2ef5f31bc8 PPTX export: stop adding chart series once a group's caches pass a point budget (#1804)
* PPTX export: stop adding chart series once a group's caches pass a point budget

* address review: size chart exports from the caches each group writes

* address review: cut each chart data cache at a cap far above Excel's 32,000 points per series, so the first series is bounded without failing export
2026-11-11 15:13:23 -05:00
Dylan M. Taylor e37df901ef Release 0.18.1: repair Flatpak update checks and self-updates (#2046)
* Release 0.18.1: fix Flatpak update permissions

* test: avoid unchecked option parsing in Flatpak regression
2026-11-11 14:27:18 -05:00
FredApps 0ce1a83c14 Merge main into release for DeckCraft v0.18.0 2026-11-11 16:35:50 +01:00
Dylan M. Taylor fc51f46c83 Release 0.17.0 2026-11-09 06:29:58 -05:00
22 changed files with 443 additions and 418 deletions
+5
View File
@@ -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
Generated
+24 -24
View File
@@ -1038,7 +1038,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"arboard",
"chrono",
@@ -1060,7 +1060,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1069,7 +1069,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1078,14 +1078,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1095,14 +1095,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1128,7 +1128,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1140,7 +1140,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-model",
"serde",
@@ -1151,7 +1151,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"kurbo",
"serde",
@@ -1159,7 +1159,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1168,7 +1168,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1177,7 +1177,7 @@ dependencies = [
[[package]]
name = "deckcraft-mcp"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1187,7 +1187,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"craft-audio",
"craft-media",
@@ -1203,7 +1203,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"image",
"kurbo",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "deckcraft-pdf"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1242,7 +1242,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1258,7 +1258,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1277,7 +1277,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1291,7 +1291,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -1317,7 +1317,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1326,7 +1326,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -6311,7 +6311,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.18.0"
version = "0.18.1"
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.0"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
-3
View File
@@ -148,9 +148,6 @@ fn warn_if_damaged(path: &str, opened: &serde_json::Value) {
if slides > 0 {
eprintln!("deckcraft-cli: warning: {path}: {slides} slide{} couldn't be read and left out", if slides == 1 { "" } else { "s" });
}
for w in opened.get("importWarnings").and_then(|v| v.as_array()).into_iter().flatten().filter_map(|w| w.as_str()) {
eprintln!("deckcraft-cli: warning: {path}: {w}");
}
if media > 0 {
eprintln!("deckcraft-cli: warning: {path}: {media} picture or media file{} couldn't be read", if media == 1 { "" } else { "s" });
}
+55 -5
View File
@@ -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 {
+4
View File
@@ -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.
+7
View File
@@ -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 {
-9
View File
@@ -433,15 +433,6 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result<deckcraft_model::Shap
pub fn xfrm_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
let mut x = shape.xfrm.unwrap_or_else(|| ctx_xfrm(doc, sel, shape));
if let deckcraft_model::ShapeKind::Table(t) = &shape.kind {
// A loaded table may not be normalized yet: measure the bounded grid an edit will
// leave, not every cell the file stored.
let normalized;
let t = if t.is_normalized() {
t
} else {
normalized = t.normalized();
&normalized
};
let drawn: f64 = table_drawn_heights(doc, sel, t).iter().sum();
if drawn.is_finite() && drawn > x.h {
x.h = drawn;
+9 -17
View File
@@ -139,13 +139,11 @@ pub struct Damage {
pub media: Vec<String>,
/// Slides a `.pptx` lists but that couldn't be read, so they are not in the presentation.
pub slides: Vec<String>,
/// Content a `.pptx` had that was cut to fit a limit; the file still opened.
pub warnings: Vec<String>,
}
impl Damage {
pub fn is_empty(&self) -> bool {
self.media.is_empty() && self.slides.is_empty() && self.warnings.is_empty()
self.media.is_empty() && self.slides.is_empty()
}
}
@@ -153,17 +151,17 @@ impl Damage {
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Damage)> {
if deckcraft_format::sniff(bytes) {
let (p, media) = deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
return Ok((p, Damage { media, ..Default::default() }));
return Ok((p, Damage { media, slides: vec![] }));
}
open_other(name, bytes).map(|(p, slides, warnings)| (p, Damage { slides, warnings, ..Default::default() }))
open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides }))
}
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>, Vec<String>)> {
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
if deckcraft_pptx::sniff(bytes) {
let (mut p, lost_slides, warnings) = deckcraft_pptx::import_report(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
deckcraft_format::repair(&mut p);
probe_clips(&mut p);
return Ok((p, lost_slides, warnings));
return Ok((p, lost_slides));
}
if deckcraft_pptx::is_encrypted(bytes) {
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
@@ -182,7 +180,7 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>, Ve
if p.slides.is_empty() {
p = defaults::new_presentation(None);
}
return Ok((p, vec![], vec![]));
return Ok((p, vec![]));
}
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
}
@@ -278,7 +276,7 @@ fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result<Value> {
let st = s.doc()?;
Ok(json!({
"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts,
"damagedMedia": damaged.media, "lostSlides": damaged.slides, "importWarnings": damaged.warnings,
"damagedMedia": damaged.media, "lostSlides": damaged.slides,
}))
}
@@ -313,9 +311,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let (doc, damaged) = open_checked(name, &data)?;
let i = add_opened(s, name, None, doc);
report_damage(s, &damaged)?;
Ok(
json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides, "importWarnings": damaged.warnings}),
)
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides}))
}
/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired.
@@ -343,10 +339,6 @@ fn report_damage(s: &mut Session, damaged: &Damage) -> Result<()> {
if n == 1 { "was" } else { "were" },
));
}
for w in &damaged.warnings {
text.push_str(w);
text.push_str(". ");
}
text.push_str("It was opened as a repaired copy; save it under a new name to keep the original file.");
s.ui_requests.push(crate::UiRequest::Message { text });
Ok(())
+4 -17
View File
@@ -286,12 +286,6 @@ fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> R
};
let r = edit_shapes(s, &json!({"id": id}), cmd, |sh| match &mut sh.kind {
ShapeKind::Table(t) => {
// Edits walk the grid: bound it first (a loaded table need not be normalized yet).
// An over-cap table is refused, not trimmed: a trim would delete rows while reporting success.
if t.over_cap() {
return Err(bad(cmd, format!("the table is larger than {} cells; edit refused", deckcraft_model::table::MAX_CELLS)));
}
t.normalize();
f(t)?;
t.normalize();
let (w, h) = (t.width(), t.height());
@@ -325,17 +319,10 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str, name: &'static str) -> Result
let op = op.to_string();
with_table(s, p, name, move |t| {
match op.as_str() {
"rowAbove" | "rowBelow" | "colLeft" | "colRight" => {
let ok = match op.as_str() {
"rowAbove" => t.insert_row(r),
"rowBelow" => t.insert_row(r.saturating_add(1)),
"colLeft" => t.insert_col(c),
_ => t.insert_col(c.saturating_add(1)),
};
if !ok {
return Err(bad(name, format!("the table is at its {}-cell limit", deckcraft_model::table::MAX_CELLS)));
}
}
"rowAbove" => t.insert_row(r),
"rowBelow" => t.insert_row(r.saturating_add(1)),
"colLeft" => t.insert_col(c),
"colRight" => t.insert_col(c.saturating_add(1)),
"delRow" => {
if !t.delete_row(r) {
return Err(bad(name, "can't delete the last row"));
-39
View File
@@ -6921,45 +6921,6 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
}
#[test]
fn table_edits_past_the_cell_cap_are_refused_not_trimmed() {
use deckcraft_model::table::{MAX_CELLS, Row};
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
let cols = 200;
let rows = MAX_CELLS / cols;
let big = deckcraft_model::Table {
cols: vec![1.0; cols],
rows: vec![Row { height: 1.0, cells: vec![Default::default(); cols] }; rows],
..Default::default()
};
for sl in std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides.iter_mut() {
for sh in std::sync::Arc::make_mut(sl).shapes.iter_mut() {
if sh.id == id {
sh.kind = deckcraft_model::ShapeKind::Table(big.clone());
}
}
}
for cmd in ["table.insertRowAbove", "table.insertRowBelow"] {
let e = s.execute(cmd, &json!({"id": id.0, "row": 0})).unwrap_err().to_string();
assert!(e.contains("cell limit"), "{cmd}: {e}");
}
let e = s.execute("table.insertColumnLeft", &json!({"id": id.0, "col": 0})).unwrap_err().to_string();
assert!(e.contains("cell limit"), "{e}");
assert_eq!(table_of(&s, id), big, "a refused insert leaves every row in place");
// A table already over the cap is refused too, not trimmed behind a success.
let huge = deckcraft_model::Table { rows: vec![Row { height: 1.0, cells: vec![Default::default(); cols] }; rows + 1], ..big.clone() };
for sl in std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides.iter_mut() {
for sh in std::sync::Arc::make_mut(sl).shapes.iter_mut() {
if sh.id == id {
sh.kind = deckcraft_model::ShapeKind::Table(huge.clone());
}
}
}
assert!(s.execute("table.deleteRow", &json!({"id": id.0, "row": 0})).is_err());
assert_eq!(table_of(&s, id).n_rows(), rows + 1);
}
/// A shape named many times in one edit is edited once and not copied per mention.
#[test]
fn repeated_target_ids_edit_the_shape_once() {
+28 -2
View File
@@ -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
);
+15 -185
View File
@@ -83,12 +83,6 @@ impl Default for Table {
}
}
/// Cells a table may hold after [`Table::normalize`] (PowerPoint's own limit is 75 x 75).
pub const MAX_CELLS: usize = 100_000;
/// Cell visits [`Table::merge`] may spend growing its rectangle over existing merges: a few
/// passes over the largest grid, where a real table needs one or two.
const MAX_MERGE_WORK: usize = 16 * MAX_CELLS;
impl Table {
pub fn new(rows: usize, cols: usize, width: f64, row_height: f64) -> Self {
let rows = rows.clamp(1, 1000);
@@ -119,24 +113,15 @@ impl Table {
self.rows.iter().map(|r| r.height).sum()
}
/// Insert an empty row before `at`. A merge the new row falls inside grows to cover it.
/// Returns false, leaving the table as it was, when the row would take it past [`MAX_CELLS`].
pub fn insert_row(&mut self, at: usize) -> bool {
if self.cols.len().saturating_mul(self.rows.len().saturating_add(1)) > MAX_CELLS {
return false;
}
pub fn insert_row(&mut self, at: usize) {
let at = at.min(self.rows.len());
let merges = self.merges();
let h = self.rows.get(at.saturating_sub(1)).map(|r| r.height).unwrap_or(30.0);
self.rows.insert(at, Row { height: h, cells: (0..self.cols.len()).map(|_| Cell::new("")).collect() });
self.set_merges(merges.into_iter().map(|m| m.insert_row(at)));
true
}
/// Insert an empty column before `at`. A merge the new column falls inside grows to cover it.
/// Returns false, leaving the table as it was, when the column would take it past [`MAX_CELLS`].
pub fn insert_col(&mut self, at: usize) -> bool {
if self.cols.len().saturating_add(1).saturating_mul(self.rows.len()) > MAX_CELLS {
return false;
}
pub fn insert_col(&mut self, at: usize) {
let at = at.min(self.cols.len());
let merges = self.merges();
let w = self.cols.get(at.saturating_sub(1)).copied().unwrap_or(80.0);
@@ -146,7 +131,6 @@ impl Table {
r.cells.insert(i, Cell::new(""));
}
self.set_merges(merges.into_iter().map(|m| m.insert_col(at)));
true
}
/// Delete row `at`. Merges through it shrink; a merge anchored on it moves its anchor (and
/// text) to the next row it covers.
@@ -186,12 +170,8 @@ impl Table {
true
}
/// The merged areas: every uncovered cell spanning more than one cell, clipped to the grid.
/// Merges of a well-formed table do not overlap, so together they cover at most the grid; a
/// file's overlapping spans past that are dropped, so rebuilding them cannot revisit cells
/// without bound.
fn merges(&self) -> Vec<Merge> {
let (nr, nc) = (self.rows.len(), self.cols.len());
let mut area = nr.saturating_mul(nc);
let mut out = vec![];
for (r, row) in self.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate().take(nc) {
@@ -200,10 +180,7 @@ impl Table {
}
let rs = (cell.row_span.max(1) as usize).min(nr - r);
let gs = (cell.grid_span.max(1) as usize).min(nc - c);
if (rs > 1 || gs > 1)
&& let Some(left) = area.checked_sub(rs.saturating_mul(gs))
{
area = left;
if rs > 1 || gs > 1 {
out.push(Merge { r, c, rs, gs });
}
}
@@ -256,17 +233,9 @@ impl Table {
if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) {
return false;
}
// Grow the rectangle until no existing merge sticks out of it. Each pass reads the whole
// grid and may grow it by one row, so the passes are charged against
// [`MAX_MERGE_WORK`]: past it the merge is refused, before anything changed.
// Grow the rectangle until no existing merge sticks out of it.
let (mut r0, mut c0, mut r1, mut c1) = (r0, c0, r1, c1);
let pass = self.rows.len().saturating_mul(self.cols.len()).max(1);
let mut work = 0usize;
loop {
work = work.saturating_add(pass);
if work > MAX_MERGE_WORK {
return false;
}
let before = (r0, c0, r1, c1);
for (r, row) in self.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate().take(self.cols.len()) {
@@ -320,50 +289,21 @@ impl Table {
return false;
}
// Spans come from the file and may reach past the table; only walk the cells that exist.
let end = r.saturating_add(rs).min(self.rows.len());
let gs = gs.min(self.cols.len().saturating_sub(c));
for row in self.rows.get_mut(r..end).unwrap_or_default() {
for x in row.cells.iter_mut().skip(c).take(gs) {
x.h_merge = false;
x.v_merge = false;
x.grid_span = 1;
x.row_span = 1;
for rr in r..r.saturating_add(rs).min(self.rows.len()) {
for cc in c..c.saturating_add(gs).min(self.cols.len()) {
if let Some(x) = self.cell_mut(rr, cc) {
x.h_merge = false;
x.v_merge = false;
x.grid_span = 1;
x.row_span = 1;
}
}
}
true
}
/// Whether the grid is as [`Table::normalize`] leaves it: within [`MAX_CELLS`], every row
/// exactly `cols.len()` cells wide.
pub fn is_normalized(&self) -> bool {
let n = self.cols.len();
n <= MAX_CELLS && self.rows.len() <= (MAX_CELLS / n.max(1)).max(1) && self.rows.iter().all(|r| r.cells.len() == n)
}
/// Whether [`Table::normalize`] would drop rows or columns to fit [`MAX_CELLS`].
pub fn over_cap(&self) -> bool {
let n = self.cols.len();
n > MAX_CELLS || self.rows.len() > (MAX_CELLS / n.max(1)).max(1)
}
/// What [`Table::normalize`] would leave of this table, cloning only the rows and cells that
/// survive (an oversized table is not copied whole first).
pub fn normalized(&self) -> Table {
let n = self.cols.len().min(MAX_CELLS);
let keep = (MAX_CELLS / n.max(1)).max(1);
let rows = self.rows.iter().take(keep).map(|r| Row { height: r.height, cells: r.cells.iter().take(n).cloned().collect() }).collect();
let mut t = Table { cols: self.cols[..n].to_vec(), rows, background: self.background.clone(), style: self.style.clone(), ..*self };
t.normalize();
if self.over_cap() {
t.clip_spans();
}
t
}
/// Make every row have exactly `cols.len()` cells (repair after loading). A grid over
/// [`MAX_CELLS`] loses its last rows (and columns) first, so a file naming many rows and many
/// columns but few cells cannot make this allocate their product.
/// Make every row have exactly `cols.len()` cells (repair after loading).
pub fn normalize(&mut self) {
let truncated = self.over_cap();
self.cols.truncate(MAX_CELLS);
let n = self.cols.len();
self.rows.truncate((MAX_CELLS / n.max(1)).max(1));
for r in &mut self.rows {
r.cells.resize_with(n, || Cell::new(""));
for c in &mut r.cells {
@@ -371,20 +311,6 @@ impl Table {
c.row_span = c.row_span.max(1);
}
}
if truncated {
self.clip_spans();
}
}
/// Cut every merge span to the rows and columns that remain, so no merge reaches past a
/// table that lost rows or columns to the cell cap.
fn clip_spans(&mut self) {
let (nr, nc) = (self.rows.len(), self.cols.len());
for (ri, r) in self.rows.iter_mut().enumerate() {
for (ci, c) in r.cells.iter_mut().enumerate() {
c.row_span = c.row_span.min((nr - ri) as u32).max(1);
c.grid_span = c.grid_span.min((nc - ci) as u32).max(1);
}
}
}
}
@@ -593,102 +519,6 @@ mod tests {
}
}
/// Each cell spans two rows, uncovered: merging the last two rows would grow the rectangle a
/// row per pass over the whole grid, 10,000 passes. The merge is refused at its work bound
/// instead, leaving the table as it was; a small merge still works.
#[test]
fn merge_expansion_stops_at_its_work_bound() {
let mut t = Table::new(1_000, 10, 400.0, 30.0);
for row in &mut t.rows {
for c in &mut row.cells {
c.row_span = 2;
}
}
let before = t.clone();
assert!(!t.merge(998, 0, 999, 9));
assert_eq!(t, before);
let mut small = Table::new(4, 4, 400.0, 30.0);
assert!(small.merge(0, 0, 1, 1));
assert!(small.is_normalized() && !Table { cols: vec![1.0; 3], rows: vec![Row::default(); 2], ..Default::default() }.is_normalized());
}
#[test]
fn normalize_caps_a_sparse_grid_at_max_cells() {
// 1,000 columns and 1,000 empty rows would be a million cells.
let mut t = Table { cols: vec![10.0; 1000], rows: vec![Row::default(); 1000], ..Default::default() };
t.normalize();
assert_eq!(t.cols.len(), 1000);
assert_eq!(t.rows.len(), MAX_CELLS / 1000);
assert!(t.rows.iter().all(|r| r.cells.len() == 1000));
// Columns alone past the cap keep one row of MAX_CELLS cells.
let mut t = Table { cols: vec![1.0; MAX_CELLS + 5], rows: vec![Row::default(); 3], ..Default::default() };
t.normalize();
assert_eq!((t.cols.len(), t.rows.len()), (MAX_CELLS, 1));
// `normalized` leaves what `normalize` does.
let wide = Table { cols: vec![10.0; 1000], rows: vec![Row { height: 1.0, cells: vec![Cell::new("x"); 1500] }; 150], ..Default::default() };
let mut n = wide.clone();
n.normalize();
assert_eq!(wide.normalized(), n);
// A normal table is untouched.
let mut t = Table::new(4, 4, 400.0, 30.0);
t.normalize();
assert_eq!((t.n_rows(), t.n_cols()), (4, 4));
}
#[test]
fn overlapping_file_spans_rebuild_in_bounded_work() {
// Every cell an uncovered anchor spanning to the far corner: their areas add up to about
// n^4 / 4, but only what fits the grid is kept, so an edit's rebuild stays near n^2.
let n = 200;
let mut t = Table::new(n, n, 400.0, 30.0);
for row in &mut t.rows {
for c in &mut row.cells {
c.grid_span = u32::MAX;
c.row_span = u32::MAX;
}
}
assert_eq!(t.merges().len(), 1, "the first anchor already covers the whole grid");
assert!(t.insert_row(n));
assert_eq!(t.n_rows(), n + 1);
assert!(t.split(0, 0));
}
/// A table at the cell cap refuses another row or column instead of losing rows to
/// normalization, and the cap sits above PowerPoint's own 75 x 75.
#[test]
fn insert_past_the_cap_is_refused() {
const { assert!(MAX_CELLS >= 75 * 75) };
let mut t =
Table { cols: vec![1.0; 200], rows: vec![Row { height: 1.0, cells: vec![Cell::new(""); 200] }; MAX_CELLS / 200], ..Default::default() };
assert!(!t.over_cap());
let before = t.clone();
assert!(!t.insert_row(0));
assert!(!t.insert_col(0));
assert_eq!(t, before);
assert!(Table::new(75, 75, 100.0, 10.0).insert_row(0));
}
/// A merge that crosses the truncation boundary is clipped to the rows and columns that remain.
#[test]
fn merges_are_clipped_when_the_cap_drops_rows() {
let cols = 1000;
let rows = MAX_CELLS / cols + 1;
let mut t = Table { cols: vec![1.0; cols], rows: vec![Row { height: 1.0, cells: vec![Cell::new(""); cols] }; rows], ..Default::default() };
t.rows[0].cells[0].row_span = rows as u32;
t.rows[0].cells[cols - 1].grid_span = 5;
let n = t.normalized();
assert_eq!(n.rows.len(), rows - 1);
assert_eq!(n.rows[0].cells[0].row_span as usize, rows - 1);
assert_eq!(n.rows[0].cells[cols - 1].grid_span, 1);
t.normalize();
assert_eq!(t.rows[0].cells[0].row_span as usize, rows - 1);
// Within the cap, spans are left as the file wrote them.
let mut small = Table::new(3, 3, 30.0, 10.0);
small.rows[2].cells[2].grid_span = 4;
small.normalize();
assert_eq!(small.rows[2].cells[2].grid_span, 4);
}
fn merged() -> Table {
// 4x4 with a 2x2 merge at (1,1) holding "M".
let mut t = Table::new(4, 4, 400.0, 30.0);
@@ -722,10 +552,10 @@ mod tests {
fn row_and_column_edits_keep_merges_consistent() {
for at in 0..=4 {
let mut t = merged();
assert!(t.insert_row(at));
t.insert_row(at);
assert_topology(&t);
let mut t = merged();
assert!(t.insert_col(at));
t.insert_col(at);
assert_topology(&t);
}
for at in 0..4 {
-6
View File
@@ -70,12 +70,6 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
read::import_checked(bytes)
}
/// [`import_checked`], plus warnings for content cut to fit a limit (a table past the cell cap
/// keeps its leading rows); the file still opens.
pub fn import_report(bytes: &[u8]) -> Result<(Presentation, Vec<String>, Vec<String>), PptxError> {
read::import_report(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)]
+196 -53
View File
@@ -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;
@@ -55,24 +56,12 @@ pub struct Imp<'a> {
pub tag_bytes: usize,
/// The tag budget ran out and the warning was given.
pub tags_capped: bool,
/// What the file lost that the reader kept going past (see [`MAX_IMPORT_WARNINGS`]).
pub warnings: Vec<String>,
/// Whether a part has relationships of its own, by normalized part name: looked up once
/// however many embedded objects point at the part.
pub has_rels: HashMap<String, bool>,
}
/// Import warnings kept; a hostile file with many oversized tables adds no more than this.
const MAX_IMPORT_WARNINGS: usize = 32;
impl Imp<'_> {
/// Record something the file lost, and log it.
pub fn warn(&mut self, msg: String) {
log::warn!("pptx: {msg}");
if self.warnings.len() < MAX_IMPORT_WARNINGS {
self.warnings.push(msg);
}
}
pub fn alloc(&mut self) -> u32 {
self.next = self.next.saturating_add(1);
self.next
@@ -111,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))
}
}
@@ -160,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()
}
@@ -338,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(),
@@ -350,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() });
}
}
}
}
@@ -387,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));
}
}
@@ -474,7 +543,6 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
warnings: vec![],
has_rels: HashMap::new(),
};
let part = Part::new(&pkg, &theme_path, &doc);
@@ -489,22 +557,11 @@ 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_report(bytes).map(|(p, lost, _)| (p, lost))
}
/// [`import_checked`], plus warnings for content the file had that was cut to fit a limit (a
/// table past the cell cap keeps its leading rows).
pub fn import_report(bytes: &[u8]) -> Result<(Presentation, Vec<String>, Vec<String>), PptxError> {
import_report_with_chart_points(bytes, chart::MAX_PRESENTATION_CHART_POINTS)
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> {
import_report_with_chart_points(bytes, chart_points_cap).map(|(p, lost, _)| (p, lost))
}
/// [`import_report`] with the presentation's chart point budget set.
fn import_report_with_chart_points(bytes: &[u8], chart_points_cap: usize) -> Result<(Presentation, Vec<String>, Vec<String>), PptxError> {
let mut lost_slides: Vec<String> = vec![];
let pkg = Package::open(bytes)?;
let root_rels = pkg.rels("");
@@ -537,7 +594,6 @@ fn import_report_with_chart_points(bytes: &[u8], chart_points_cap: usize) -> Res
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
warnings: vec![],
has_rels: HashMap::new(),
};
// Slide ids first so hyperlinks anywhere can point at slides.
@@ -569,7 +625,7 @@ fn import_report_with_chart_points(bytes: &[u8], chart_points_cap: usize) -> Res
p.media = std::mem::take(&mut imp.media);
p.next_id = imp.next;
p.fix_next_id();
Ok((p, lost_slides, std::mem::take(&mut imp.warnings)))
Ok((p, lost_slides))
}
/// Masters, layouts and slides to read, in file order and without duplicates: (master paths,
@@ -1470,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}");
}
}
+7 -1
View File
@@ -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");
-3
View File
@@ -785,9 +785,6 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
let n = t.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
t.cols = vec![72.0; n];
}
if t.over_cap() {
imp.warn(format!("a table in {} is over {} cells; its last rows were left out", part.name, deckcraft_model::table::MAX_CELLS));
}
t.normalize();
t
}
-9
View File
@@ -65,8 +65,6 @@ pub struct Exp<'a> {
n_kept: u32,
pub slide_parts: HashMap<SlideId, String>,
pub table_styles: Vec<String>,
/// A table past the cell cap was met; export fails rather than write a trimmed one.
pub table_over_cap: bool,
/// The ids in `table_styles`, for checking membership without a scan.
pub table_style_set: std::collections::HashSet<String>,
placeholder: Option<String>,
@@ -560,7 +558,6 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
n_kept: 0,
slide_parts: HashMap::new(),
table_styles: vec![],
table_over_cap: false,
table_style_set: Default::default(),
placeholder: None,
};
@@ -769,12 +766,6 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
root.add_new(&k.rel_type, &k.name, false);
}
x.pkg.rels("", &root);
if x.table_over_cap {
return Err(PptxError::Unsupported(format!(
"a table has more than {} cells; export refused rather than drop rows",
deckcraft_model::table::MAX_CELLS
)));
}
x.pkg.finish()
}
+79 -3
View File
@@ -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);
}
}
+2 -2
View File
@@ -721,8 +721,8 @@ fn frame_close(w: &mut W) {
}
fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
x.table_over_cap |= t.over_cap();
let t = t.normalized();
let mut t = t.clone();
t.normalize();
w.open0("a:tbl");
let (guid, _) = crate::tables::guid_for_style(&t.style);
if x.table_style_set.insert(t.style.clone()) {
-38
View File
@@ -288,44 +288,6 @@ fn unreadable_slide_is_reported() {
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
}
fn table_deck(rows: usize, cols: usize) -> deckcraft_model::Presentation {
use deckcraft_model::table::Row;
use deckcraft_model::{Shape, ShapeId, ShapeKind, Table, Xfrm};
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id().expect("ids"));
let t = Table { cols: vec![1.0; cols], rows: vec![Row { height: 1.0, cells: vec![Default::default(); cols] }; rows], ..Default::default() };
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
slide.shapes.push(Shape { id, name: "T".into(), xfrm: Some(Xfrm::new(0.0, 0.0, 10.0, 10.0)), kind: ShapeKind::Table(t), ..Default::default() });
p
}
/// A table past the cell cap opens with its leading rows and an import warning; saving one that
/// is over the cap is an error, never a silent trim.
#[test]
fn oversized_table_warns_on_import_and_refuses_export() {
let max = deckcraft_model::table::MAX_CELLS;
let cols = 1000;
let rows = max / cols;
// A table exactly at the cap round-trips with no warning.
let bytes = deckcraft_pptx::export(&table_deck(rows, cols)).expect("export at the cap");
let (_, _, warnings) = deckcraft_pptx::import_report(&bytes).expect("import");
assert!(warnings.is_empty(), "{warnings:?}");
// One more row in the file: kept truncated, and reported.
let mut es = entries(&bytes);
let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1");
let xml = String::from_utf8(std::mem::take(&mut slide.1)).expect("utf8");
let start = xml.rfind("<a:tr ").expect("row");
let end = xml.rfind("</a:tr>").expect("row end") + "</a:tr>".len();
slide.1 = format!("{}{}{}", &xml[..end], &xml[start..end], &xml[end..]).into_bytes();
let (q, _, warnings) = deckcraft_pptx::import_report(&rezip(&es)).expect("import");
assert_eq!(warnings.len(), 1, "{warnings:?}");
let t = q.slides[0].shapes.iter().find_map(|s| if let deckcraft_model::ShapeKind::Table(t) = &s.kind { Some(t) } else { None }).expect("table");
assert_eq!((t.rows.len(), t.cols.len()), (rows, cols));
// A model table past the cap is refused on export.
let err = deckcraft_pptx::export(&table_deck(rows + 1, cols)).expect_err("over the cap");
assert!(err.to_string().contains("table"), "{err}");
}
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
/// slide that names it.
#[test]
+7 -1
View File
@@ -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))
}