mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-12 17:02:34 -06:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c516fdf5a | ||
|
|
4106e85ad7 | ||
|
|
761c1428bb | ||
|
|
9b77b29dcb | ||
|
|
f2a12bfde7 | ||
|
|
33108d5fb9 | ||
|
|
b396f94f0d | ||
|
|
469eede715 |
Generated
+24
-24
@@ -1038,7 +1038,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1060,7 +1060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1069,7 +1069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1078,14 +1078,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1095,14 +1095,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1128,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1140,7 +1140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1151,7 +1151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1159,7 +1159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1168,7 +1168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1177,7 +1177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1187,7 +1187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"craft-media",
|
||||
@@ -1203,7 +1203,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1212,7 +1212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1224,7 +1224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1242,7 +1242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1258,7 +1258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1277,7 +1277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1291,7 +1291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1317,7 +1317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1326,7 +1326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -6311,7 +6311,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.1"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -148,6 +148,9 @@ 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" });
|
||||
}
|
||||
|
||||
@@ -118,8 +118,6 @@ enum Output {
|
||||
grain: FilmGrainParams,
|
||||
apply_film_grain: bool,
|
||||
pts: i64,
|
||||
/// What copying the picture out costs, in the reference's own format.
|
||||
bytes: u64,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -134,7 +132,7 @@ impl Output {
|
||||
fn take(self) -> Result<Picture> {
|
||||
match self {
|
||||
Output::Decoded(s) => s.wait().map(|p| Arc::try_unwrap(p).unwrap_or_else(|p| (*p).clone())),
|
||||
Output::Existing { slot, seq, width, height, grain, apply_film_grain, pts, .. } => {
|
||||
Output::Existing { slot, seq, width, height, grain, apply_film_grain, pts } => {
|
||||
let rf = slot.wait()?;
|
||||
Ok(output_picture(&seq, &rf, width, height, &grain, apply_film_grain, pts))
|
||||
}
|
||||
@@ -381,8 +379,8 @@ impl Decoder {
|
||||
// Output policy (7.18.1 note): with scalability, show one frame per temporal unit, the
|
||||
// highest spatial layer present.
|
||||
let scalable = self.seq.as_ref().is_some_and(|s| s.op_idc != 0);
|
||||
let kept: Vec<Output> = if !scalable {
|
||||
out.into_iter().map(|(o, _, _)| o).collect()
|
||||
if !scalable {
|
||||
self.out.extend(out.into_iter().map(|(o, _, _)| o));
|
||||
} else {
|
||||
let mut last: Option<(usize, u32)> = None;
|
||||
let mut sel: Vec<Output> = Vec::new();
|
||||
@@ -402,27 +400,8 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
sel
|
||||
};
|
||||
// Every repeat is copied out whole, so the repeats this call keeps (after the layer
|
||||
// selection) must not queue more than half the decoder budget; the first one over it and
|
||||
// everything after is dropped.
|
||||
let mut r = r;
|
||||
let mut repeat_bytes = 0u64;
|
||||
let mut ok = Vec::with_capacity(kept.len());
|
||||
for o in kept {
|
||||
if let Output::Existing { bytes, .. } = &o {
|
||||
repeat_bytes = repeat_bytes.saturating_add(*bytes);
|
||||
if repeat_bytes > self.memory_budget / 2 {
|
||||
if r.is_ok() {
|
||||
r = Err(Error::Invalid("repeated frame outputs exceed the decoder memory budget"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
ok.push(o);
|
||||
self.out.extend(sel);
|
||||
}
|
||||
self.out.extend(ok);
|
||||
r
|
||||
}
|
||||
|
||||
@@ -524,15 +503,6 @@ impl Decoder {
|
||||
let idx = fh.frame_to_show_map_idx;
|
||||
let slot = self.ref_frames[idx].clone().ok_or(Error::Invalid("show_existing_frame of an empty slot"))?;
|
||||
let info = self.refs[idx].clone();
|
||||
// Sized in the reference's own format: a new sequence header may differ from it.
|
||||
let shape = deckcraft_bitstream::budget::PictureShape {
|
||||
width: u64::from(info.upscaled_width),
|
||||
height: u64::from(info.frame_height),
|
||||
shift_x: u32::from(info.subsampling_x),
|
||||
shift_y: u32::from(info.subsampling_y),
|
||||
chroma_planes: if info.mono_chrome { 0 } else { 2 },
|
||||
bytes_per_sample: 2,
|
||||
};
|
||||
let o = Output::Existing {
|
||||
slot: slot.clone(),
|
||||
seq: seq.clone(),
|
||||
@@ -541,7 +511,6 @@ impl Decoder {
|
||||
grain: fh.film_grain.clone(),
|
||||
apply_film_grain: self.apply_film_grain,
|
||||
pts: self.pts,
|
||||
bytes: shape.bytes().unwrap_or(u64::MAX),
|
||||
};
|
||||
if fh.frame_type == KEY_FRAME as u8 {
|
||||
// reference frame loading process (7.21) then refresh every slot (7.20)
|
||||
@@ -660,9 +629,6 @@ impl Decoder {
|
||||
ri.lf_deltas = fh.lf.deltas;
|
||||
ri.seg_features = fh.seg.features;
|
||||
ri.grain = fh.film_grain.clone();
|
||||
ri.subsampling_x = job.seq.color.subsampling_x;
|
||||
ri.subsampling_y = job.seq.color.subsampling_y;
|
||||
ri.mono_chrome = job.seq.color.mono_chrome;
|
||||
self.ref_frames[i] = Some(job.slot.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,10 +360,6 @@ pub struct RefInfo {
|
||||
pub lf_deltas: LoopFilterDeltas,
|
||||
pub seg_features: SegmentationFeatures,
|
||||
pub grain: FilmGrainParams,
|
||||
/// The colour format the frame was decoded in (a later sequence header may change it).
|
||||
pub subsampling_x: u8,
|
||||
pub subsampling_y: u8,
|
||||
pub mono_chrome: bool,
|
||||
}
|
||||
|
||||
/// A parsed uncompressed frame header with its derived variables.
|
||||
|
||||
@@ -433,6 +433,15 @@ 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;
|
||||
|
||||
@@ -139,11 +139,13 @@ 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.media.is_empty() && self.slides.is_empty() && self.warnings.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,17 +153,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, slides: vec![] }));
|
||||
return Ok((p, Damage { media, ..Default::default() }));
|
||||
}
|
||||
open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides }))
|
||||
open_other(name, bytes).map(|(p, slides, warnings)| (p, Damage { slides, warnings, ..Default::default() }))
|
||||
}
|
||||
|
||||
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>, Vec<String>)> {
|
||||
if deckcraft_pptx::sniff(bytes) {
|
||||
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (mut p, lost_slides, warnings) = deckcraft_pptx::import_report(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
deckcraft_format::repair(&mut p);
|
||||
probe_clips(&mut p);
|
||||
return Ok((p, lost_slides));
|
||||
return Ok((p, lost_slides, warnings));
|
||||
}
|
||||
if deckcraft_pptx::is_encrypted(bytes) {
|
||||
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
|
||||
@@ -180,7 +182,7 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if p.slides.is_empty() {
|
||||
p = defaults::new_presentation(None);
|
||||
}
|
||||
return Ok((p, vec![]));
|
||||
return Ok((p, vec![], vec![]));
|
||||
}
|
||||
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
|
||||
}
|
||||
@@ -276,7 +278,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,
|
||||
"damagedMedia": damaged.media, "lostSlides": damaged.slides, "importWarnings": damaged.warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -311,7 +313,9 @@ 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}))
|
||||
Ok(
|
||||
json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides, "importWarnings": damaged.warnings}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired.
|
||||
@@ -339,6 +343,10 @@ 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(())
|
||||
|
||||
@@ -286,6 +286,12 @@ 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());
|
||||
@@ -319,10 +325,17 @@ 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" => 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)),
|
||||
"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)));
|
||||
}
|
||||
}
|
||||
"delRow" => {
|
||||
if !t.delete_row(r) {
|
||||
return Err(bad(name, "can't delete the last row"));
|
||||
|
||||
@@ -6921,6 +6921,45 @@ 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() {
|
||||
|
||||
@@ -404,36 +404,10 @@ impl Decoder {
|
||||
chroma_planes: if chroma == 0 { 0 } else { 2 },
|
||||
bytes_per_sample: 2,
|
||||
};
|
||||
let frame_bytes = |f: &crate::picture::Frame| {
|
||||
(f.width as u64)
|
||||
.saturating_mul(f.height as u64)
|
||||
.saturating_add((f.cwidth as u64).saturating_mul(f.cheight as u64).saturating_mul(2))
|
||||
.saturating_mul(2)
|
||||
};
|
||||
// Output frames waiting to be returned stay allocated after they leave the DPB, at their
|
||||
// own size: a resolution change in this call can leave pictures of the previous SPS.
|
||||
let queued: u64 = self
|
||||
.out_queue
|
||||
.iter()
|
||||
.filter(|o| !self.dpb.entries.iter().any(|e| Arc::ptr_eq(&e.frame, &o.frame)) && !self.in_flight.iter().any(|f| Arc::ptr_eq(f, &o.frame)))
|
||||
.map(|o| frame_bytes(&o.frame))
|
||||
.fold(0, u64::saturating_add);
|
||||
// Frames the DPB holds now count at their own size too (an SPS change flushes them into the
|
||||
// queue before the new pictures are made): the DPB part is the larger of its new slots and that.
|
||||
let held: u64 = self.dpb.entries.iter().map(|e| frame_bytes(&e.frame)).fold(0, u64::saturating_add);
|
||||
// A queued frame still being decoded is one of the in-flight pictures already reserved, so the
|
||||
// queue and the in-flight reservation overlap: charge the larger, not both.
|
||||
let pictures = u64::from(sps.max_dec_pic_buffering) + 1 + self.max_in_flight as u64;
|
||||
let slots = shape.store_bytes(u64::from(sps.max_dec_pic_buffering));
|
||||
let flight = shape.store_bytes(self.max_in_flight as u64);
|
||||
let total = shape
|
||||
.store_bytes(1)
|
||||
.zip(slots)
|
||||
.zip(flight)
|
||||
.map(|((one, slots), flight)| slots.max(held).saturating_add(one).saturating_add(flight).saturating_add(queued));
|
||||
ensure!(
|
||||
total.is_some_and(|b| b <= self.memory_budget),
|
||||
"{}x{} pictures with {pictures} in the decoder and {queued} bytes queued exceed the memory budget",
|
||||
shape.fits(pictures, self.memory_budget),
|
||||
"{}x{} pictures with {pictures} in the decoder exceed the memory budget",
|
||||
sps.width,
|
||||
sps.height
|
||||
);
|
||||
|
||||
+186
-16
@@ -83,6 +83,12 @@ 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);
|
||||
@@ -113,15 +119,24 @@ 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.
|
||||
pub fn insert_row(&mut self, at: usize) {
|
||||
/// 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;
|
||||
}
|
||||
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.
|
||||
pub fn insert_col(&mut self, at: usize) {
|
||||
/// 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;
|
||||
}
|
||||
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);
|
||||
@@ -131,6 +146,7 @@ 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.
|
||||
@@ -170,8 +186,12 @@ 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) {
|
||||
@@ -180,7 +200,10 @@ 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 {
|
||||
if (rs > 1 || gs > 1)
|
||||
&& let Some(left) = area.checked_sub(rs.saturating_mul(gs))
|
||||
{
|
||||
area = left;
|
||||
out.push(Merge { r, c, rs, gs });
|
||||
}
|
||||
}
|
||||
@@ -233,9 +256,17 @@ 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.
|
||||
// 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.
|
||||
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()) {
|
||||
@@ -289,21 +320,50 @@ impl Table {
|
||||
return false;
|
||||
}
|
||||
// Spans come from the file and may reach past the table; only walk the cells that exist.
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Make every row have exactly `cols.len()` cells (repair after loading).
|
||||
pub fn normalize(&mut self) {
|
||||
/// 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.
|
||||
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 {
|
||||
@@ -311,6 +371,20 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +593,102 @@ 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);
|
||||
@@ -552,10 +722,10 @@ mod tests {
|
||||
fn row_and_column_edits_keep_merges_consistent() {
|
||||
for at in 0..=4 {
|
||||
let mut t = merged();
|
||||
t.insert_row(at);
|
||||
assert!(t.insert_row(at));
|
||||
assert_topology(&t);
|
||||
let mut t = merged();
|
||||
t.insert_col(at);
|
||||
assert!(t.insert_col(at));
|
||||
assert_topology(&t);
|
||||
}
|
||||
for at in 0..4 {
|
||||
|
||||
@@ -70,6 +70,12 @@ 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)]
|
||||
|
||||
+27
-2
@@ -55,12 +55,24 @@ 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
|
||||
@@ -462,6 +474,7 @@ 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);
|
||||
@@ -476,11 +489,22 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
/// [`import`], plus the parts of the slide list the file names but that couldn't be read (a
|
||||
/// missing or truncated slide): those slides are left out, so a caller can say so (#203).
|
||||
pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxError> {
|
||||
import_with_chart_points(bytes, chart::MAX_PRESENTATION_CHART_POINTS)
|
||||
import_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_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("");
|
||||
@@ -513,6 +537,7 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) ->
|
||||
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.
|
||||
@@ -544,7 +569,7 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) ->
|
||||
p.media = std::mem::take(&mut imp.media);
|
||||
p.next_id = imp.next;
|
||||
p.fix_next_id();
|
||||
Ok((p, lost_slides))
|
||||
Ok((p, lost_slides, std::mem::take(&mut imp.warnings)))
|
||||
}
|
||||
|
||||
/// Masters, layouts and slides to read, in file order and without duplicates: (master paths,
|
||||
|
||||
@@ -785,6 +785,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ 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>,
|
||||
@@ -558,6 +560,7 @@ 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,
|
||||
};
|
||||
@@ -766,6 +769,12 @@ 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -132,20 +132,7 @@ fn title(w: &mut W, text: &str, c: &Chart) {
|
||||
w.close("c:title");
|
||||
}
|
||||
|
||||
/// Points one cache writes. Excel and PowerPoint hold 32,000 points per series, so this is far
|
||||
/// above any chart they make; a longer vector is cut here with a warning, not refused.
|
||||
const MAX_CACHE_POINTS: usize = 1_000_000;
|
||||
|
||||
/// `vals` cut to [`MAX_CACHE_POINTS`].
|
||||
fn capped<T>(vals: &[T]) -> &[T] {
|
||||
if vals.len() > MAX_CACHE_POINTS {
|
||||
log::warn!("pptx: chart data cache cut from {} to {MAX_CACHE_POINTS} points", vals.len());
|
||||
}
|
||||
&vals[..vals.len().min(MAX_CACHE_POINTS)]
|
||||
}
|
||||
|
||||
fn str_cache(w: &mut W, f: &str, vals: &[String]) {
|
||||
let vals = capped(vals);
|
||||
w.open0("c:strRef");
|
||||
w.elt("c:f", f);
|
||||
w.open0("c:strCache");
|
||||
@@ -160,7 +147,6 @@ fn str_cache(w: &mut W, f: &str, vals: &[String]) {
|
||||
}
|
||||
|
||||
fn num_cache(w: &mut W, f: &str, vals: &[Option<f64>]) {
|
||||
let vals = capped(vals);
|
||||
w.open0("c:numRef");
|
||||
w.elt("c:f", f);
|
||||
w.open0("c:numCache");
|
||||
@@ -179,7 +165,6 @@ fn num_cache(w: &mut W, f: &str, vals: &[Option<f64>]) {
|
||||
|
||||
/// Numbers written into the chart itself (`c:numLit`), with no worksheet range behind them.
|
||||
fn num_lit(w: &mut W, vals: &[Option<f64>]) {
|
||||
let vals = capped(vals);
|
||||
w.open0("c:numLit");
|
||||
w.val("c:ptCount", vals.len());
|
||||
for (i, v) in vals.iter().enumerate() {
|
||||
@@ -261,9 +246,6 @@ fn point_fills(w: &mut W, s: &Series, g: Group) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Data points (categories and values counted per series) one chart group writes to its caches.
|
||||
const MAX_EXPORT_POINTS: usize = 4_000_000;
|
||||
|
||||
fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) {
|
||||
w.open0("c:ser");
|
||||
w.val("c:idx", i);
|
||||
@@ -323,7 +305,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
|
||||
trendline(w, t);
|
||||
}
|
||||
}
|
||||
let n = c.categories.len().max(s.values.len()).min(MAX_CACHE_POINTS);
|
||||
let n = c.categories.len().max(s.values.len());
|
||||
let last = n + 1;
|
||||
if let Some(e) = &s.error_bars {
|
||||
error_bars(w, e, g);
|
||||
@@ -509,24 +491,6 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
|
||||
w.close("c:valAx");
|
||||
}
|
||||
|
||||
/// Points `series` writes into its caches for group `g`, synthesized vectors included.
|
||||
fn series_points(c: &Chart, s: &Series, g: Group) -> usize {
|
||||
let v = s.values.len();
|
||||
if matches!(g, Group::Scatter | Group::Bubble) {
|
||||
let xs = if s.x_values.is_empty() { v } else { s.x_values.len() };
|
||||
let sizes = if g != Group::Bubble {
|
||||
0
|
||||
} else if s.sizes.is_empty() {
|
||||
v
|
||||
} else {
|
||||
s.sizes.len()
|
||||
};
|
||||
xs.saturating_add(v).saturating_add(sizes)
|
||||
} else {
|
||||
c.categories.len().saturating_add(v)
|
||||
}
|
||||
}
|
||||
|
||||
fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) {
|
||||
let g = group_of(kind);
|
||||
let grouping = if kind.percent() {
|
||||
@@ -565,17 +529,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
|
||||
if g != Group::Stock {
|
||||
w.val("c:varyColors", vary);
|
||||
}
|
||||
// Each series repeats the categories in its own cache, so the output grows with series times
|
||||
// categories: stop adding series once a group's caches pass the budget (the first is always written).
|
||||
// The first series is always written; each of its caches is cut at MAX_CACHE_POINTS.
|
||||
let mut left = MAX_EXPORT_POINTS;
|
||||
for (n, (i, s)) in list.iter().enumerate() {
|
||||
let cost = series_points(c, s, g);
|
||||
if n > 0 && cost > left {
|
||||
log::warn!("pptx: chart export stopped after {n} series; the data caches passed {MAX_EXPORT_POINTS} points");
|
||||
break;
|
||||
}
|
||||
left = left.saturating_sub(cost);
|
||||
for (i, s) in list {
|
||||
series(w, c, s, *i, kind, g);
|
||||
}
|
||||
if c.data_labels {
|
||||
@@ -916,7 +870,6 @@ fn cx_text(w: &mut W, t: &str) {
|
||||
|
||||
/// A `cx:lvl` of points.
|
||||
fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) {
|
||||
let pts = capped(pts);
|
||||
let mut a = A::new().a("ptCount", pts.len());
|
||||
if let Some(f) = format {
|
||||
a = a.a("formatCode", f);
|
||||
@@ -942,39 +895,14 @@ fn kept_region_layout(raw: &str) -> Option<String> {
|
||||
lp.name.starts_with("cx:").then(|| lp.to_xml())
|
||||
}
|
||||
|
||||
/// Points one `chartEx` part may carry in all, categories repeated for every series: Excel's 255
|
||||
/// series x 32,000 points. Series past it are left out with a warning (the first is always written).
|
||||
const MAX_CHARTEX_POINTS: usize = 255 * 32_000;
|
||||
|
||||
/// How many of `c`'s series fit [`MAX_CHARTEX_POINTS`] (at least one).
|
||||
fn chartex_series_within_budget(c: &Chart) -> usize {
|
||||
let level = |v: &Vec<String>| v.len().min(MAX_CACHE_POINTS);
|
||||
let cats = if c.categories.is_empty() {
|
||||
0
|
||||
} else {
|
||||
level(&c.categories).saturating_add(c.category_levels.iter().map(level).fold(0usize, usize::saturating_add))
|
||||
};
|
||||
let mut left = MAX_CHARTEX_POINTS;
|
||||
for (n, s) in c.series.iter().enumerate() {
|
||||
let cost = cats.saturating_add(s.values.len().min(MAX_CACHE_POINTS));
|
||||
if n > 0 && cost > left {
|
||||
log::warn!("pptx: chart part stopped after {n} series; its data passed {MAX_CHARTEX_POINTS} points");
|
||||
return n;
|
||||
}
|
||||
left = left.saturating_sub(cost);
|
||||
}
|
||||
c.series.len()
|
||||
}
|
||||
|
||||
/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data`
|
||||
/// per series (categories and values), then the plot area with each series' layout, the axes
|
||||
/// the layout uses, the title and the legend.
|
||||
pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
let n_series = chartex_series_within_budget(c);
|
||||
let mut w = W::new();
|
||||
w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX));
|
||||
w.open0("cx:chartData");
|
||||
for (i, s) in c.series.iter().enumerate().take(n_series) {
|
||||
for (i, s) in c.series.iter().enumerate() {
|
||||
w.open("cx:data", A::new().a("id", i));
|
||||
if !c.categories.is_empty() {
|
||||
w.open("cx:strDim", A::new().a("type", "cat"));
|
||||
@@ -1014,7 +942,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
_ => "clusteredColumn",
|
||||
};
|
||||
let one = !matches!(c.kind, ChartType::BoxWhisker);
|
||||
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { n_series }) {
|
||||
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) {
|
||||
w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}")));
|
||||
cx_text(&mut w, &s.name);
|
||||
if c.series_data_labels(s) {
|
||||
@@ -1133,21 +1061,7 @@ mod region_map_tests {
|
||||
mod tests {
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::ColorRef;
|
||||
use deckcraft_model::chart::{Chart, ChartType, Series};
|
||||
|
||||
#[test]
|
||||
fn export_cost_counts_the_caches_each_group_writes() {
|
||||
let s = Series { values: vec![Some(1.0); 600], ..Default::default() };
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.categories = vec!["a".into(); 1000];
|
||||
// Category and value caches: 1,000 + 600, however the series is stored.
|
||||
assert_eq!(super::series_points(&c, &s, super::Group::Bar), 1600);
|
||||
// A bubble series without x values or sizes still writes three value-length caches.
|
||||
assert_eq!(super::series_points(&c, &s, super::Group::Bubble), 1800);
|
||||
assert_eq!(super::series_points(&c, &s, super::Group::Scatter), 1200);
|
||||
let s = Series { values: vec![Some(1.0); 600], x_values: vec![Some(1.0); 5], sizes: vec![Some(1.0); 7], ..Default::default() };
|
||||
assert_eq!(super::series_points(&c, &s, super::Group::Bubble), 612);
|
||||
}
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
|
||||
/// The chart's text colour is written where PowerPoint keeps it (`c:txPr` on the axes, legend,
|
||||
/// data labels and chart, the title's run defaults) and read back as the same colour.
|
||||
@@ -1208,33 +1122,3 @@ mod tests {
|
||||
assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c).0).unwrap().cat_reversed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cache_cap_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_chartex_part_stops_adding_series_past_excels_point_count() {
|
||||
const { assert!(MAX_CHARTEX_POINTS >= 255 * 32_000) };
|
||||
let mut c = Chart::sample(ChartType::Waterfall);
|
||||
c.categories = vec![String::new(); 32_000];
|
||||
let mut s = c.series[0].clone();
|
||||
s.values = Vec::new();
|
||||
c.series = vec![s; 300];
|
||||
// 32,000 categories per series: 255 fit exactly, the 256th does not.
|
||||
assert_eq!(chartex_series_within_budget(&c), 255);
|
||||
c.series.truncate(3);
|
||||
assert_eq!(chartex_series_within_budget(&c), 3);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,8 +721,8 @@ fn frame_close(w: &mut W) {
|
||||
}
|
||||
|
||||
fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
let mut t = t.clone();
|
||||
t.normalize();
|
||||
x.table_over_cap |= t.over_cap();
|
||||
let t = t.normalized();
|
||||
w.open0("a:tbl");
|
||||
let (guid, _) = crate::tables::guid_for_style(&t.style);
|
||||
if x.table_style_set.insert(t.style.clone()) {
|
||||
|
||||
@@ -288,6 +288,44 @@ 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]
|
||||
|
||||
@@ -166,17 +166,11 @@ impl Decoder {
|
||||
if frames.len() > 1 {
|
||||
self.stats.superframes += 1;
|
||||
}
|
||||
let queued = self.pending.len();
|
||||
for f in frames {
|
||||
if f.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.decode_frame(f, pts) {
|
||||
// A failed sample outputs nothing: drop what its earlier subframes queued so a
|
||||
// later drain does not expand each repeat into its own picture.
|
||||
self.pending.truncate(queued);
|
||||
return Err(e);
|
||||
}
|
||||
self.decode_frame(f, pts)?;
|
||||
}
|
||||
Ok(self.drain(false))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user