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. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> c6846f6a75 Photo album: apply the album limits when reopening an album (#1755)
* engine: apply the photo album limits when reopening an album

* address review: refuse an album Update when the editor seed omitted items, instead of deleting them

* Apply review fixes

- crates/engine/src/cmd/insert/album.rs

* address review: refuse an album replace when a slide past the item limit holds other content

* address review: notes and comments on a shapeless slide past the item limit block an album replace

* address review: slide background, transition, animations, colour map and tags count as slide content

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:59:47 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> a3f2f215b0 Metafile: stop shading on a full canvas, charge nested pictures and clip regions to the budgets (#1777)
* Metafile: stop shading on a full canvas, charge nested pictures and clip regions to the budgets

* address review: count shared tiles once, include text advances, check clip region before keeping an item

* address review: count EMF+ object bytes against the canvas byte budget

* Apply review fixes

- crates/metafile/src/canvas.rs
- crates/metafile/src/tests.rs

* Apply review fixes

- crates/metafile/src/canvas.rs

* address review: count pieces of a continued EMF+ object against the shared byte budget

* Apply review fixes

- crates/metafile/src/canvas.rs

* address review: count the current and saved clips against the shared byte budget

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:59:32 -05:00
Dylan M. Taylor 35166b3841 Media: Opus, Ogg, MP4 and Matroska come from craft-libs (#2047)
DeckCraft kept its own Opus decoder (crates/opus) and Ogg demuxer
(crates/ogg), and two crates that only re-exported craft-media
(crates/isobmff, crates/matroska). craft-libs has all four: craft-audio's
opus module and craft-media's ogg, isobmff and matroska modules. The four
crates are removed and crates/media depends on craft-audio and craft-media
directly.

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

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

crates/bitstream stays: it also holds the video decoders' memory budget,
which craft-libs does not have.
2026-11-11 14:47:18 -05:00
Dylan M. Taylor b57b4ccfcf PPTX: charge repeated chart decodes and sidecar copies to presentation-wide budgets (#1790)
* PPTX: charge repeated chart decodes and sidecar copies to presentation-wide budgets

* address review: count all chart vectors and relationship strings; reject a chart that crosses the presentation limit

* address review: charge chart part names/content types, roll back rejected capture

* address review: bound chart decode work across rejected and repeated references, cache refused sidecar captures

* address review: keep a chart past the presentation chart limits as its original part and closure, so a save writes it back
2026-11-11 14:33:58 -05:00
Dylan M. Taylor 2fbdcfe5f5 UI: cap live video decoders and bound slide-show grid thumbnail height (#1801)
* UI: cap live video decoders and bound slide-show grid thumbnail height

* address review: retry video feed creation when playback resumes

* address review: reclaim the decoder of ended videos when the feed cap is reached

* test: cover the video decoder cap and its retry

* address review: seeking a video without a decoder retries the bounded decoder allocation
2026-11-11 14:33:44 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> 98eac5590d Render: bound bevel edge work, shape-gradient flattening and colour-glyph fills (#1786)
* Render: bound bevel edge work, shape-gradient flattening and colour-glyph fills

* address review: skip shape gradient fill when the outline is over the point limit

* address review: keep every contour of a compound bevel or shape gradient at a coarser raster, bound cubic subdivision before flattening

* Apply review fixes

- crates/render/src/pathgrad.rs

* Apply review fixes

- crates/render/src/pathgrad.rs

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:33:29 -05:00
Dylan M. Taylor 46712d0293 Fonts: honor the disabled system fallback for symbol and script family loads (#1840)
* Fonts: honor the disabled system fallback for symbol and script family loads

* address review: no catalog loading for loaded families when system fallback is off

* test: cascades load no installed family when system fallback is off
2026-11-11 14:33:15 -05:00
Dylan M. Taylor e03c22c445 Notes: place bullets in one pass over the galley rows (#1833)
* Notes: place bullets in one pass over the galley rows

* address review: keep zero-character leading rows in row_span_at
2026-11-11 14:33:01 -05:00
Dylan M. Taylorandaiongg 98208a9eda PDF: gather a paragraph's text once, keep note line widths, count handout rules (#1773)
* pdf: gather a paragraph's text once, keep note line widths, count handout rules

* pdf: spell the rule-count check without a negated comparison

* address review: use a narrow tall page in the bounded-work test

* address review: require the huge-paper export to succeed

* Apply review fixes

- crates/pdf/src/lib.rs

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 14:32:47 -05:00
Dylan M. Taylor de52b6b766 MCP: bound a batch's entries and the bytes of results it gathers (#1759)
* mcp: bound a batch's entries and the bytes of results it gathers

* address review: charge the escaped batch reply against the budget

* address review: charge batch error entries against the byte budget

* address review: truncate id in batch limit entry

* address review: count the pretty-printed batch reply against the byte cap
2026-11-11 14:32:33 -05:00
Dylan M. Taylor 7c83fa43ac Spelling: check the automation write roots before appending to the user dictionary (#1754)
* engine: check the automation write roots before appending to the user dictionary

* address review: check the original Path for the dictionary write; isolate the confinement test
2026-11-11 14:32:18 -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
38 changed files with 1477 additions and 189 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"
+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 {
+144 -6
View File
@@ -80,6 +80,12 @@ pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
let opts = album_opts(p)?;
let items = album_items(p)?;
let (id, ids) = album_target(s, p)?;
// The editor is seeded within the album limits; replacing the album from a trimmed seed would
// delete what it left out, so refuse rather than lose it.
let st = s.doc()?;
if items_from_slides(&st.doc, &album_slides(st, &ids)).1 {
return Err(bad("insert.photoAlbumEdit", "this album holds more than the editor can carry; editing it would delete the rest"));
}
commit_album(s, items, opts, Some((id, ids)))
}
@@ -545,6 +551,19 @@ fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
}
/// Whether a slide holds anything besides album items: shapes, notes, comments, a background, a
/// transition, animations, a colour map or tags.
fn has_slide_content(s: &deckcraft_model::Slide) -> bool {
!s.shapes.is_empty()
|| !s.notes.text().trim().is_empty()
|| !s.comments.is_empty()
|| s.background.is_some()
|| s.transition.is_some()
|| !s.animations.is_empty()
|| s.color_map.is_some()
|| !s.tags.is_empty()
}
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
}
@@ -592,9 +611,16 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
(layout, frame.into(), captions, any && gray, theme_now.to_string())
}
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
/// The album's items for the dialog, within the limits `insert.photoAlbum` itself takes (items,
/// bytes per picture and in all), checked before a picture is encoded: an album slide set can
/// name one large picture many times.
///
/// The flag is true when something was left out for a limit.
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> (Vec<Value>, bool) {
let mut items = Vec::new();
for slide in slides {
let mut total = 0usize;
let mut omitted = false;
'slides: for slide in slides {
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
if pics.is_empty() {
let text = slide
@@ -605,13 +631,27 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
.find(|t| !t.trim().is_empty())
.unwrap_or_default();
if !text.trim().is_empty() {
if items.len() >= ALBUM_MAX_ITEMS {
omitted = true;
break 'slides;
}
items.push(json!({"text": text.trim()}));
} else if items.len() >= ALBUM_MAX_ITEMS && has_slide_content(slide) {
// Past the item limit, a slide with other content would be deleted by a replace.
omitted = true;
break 'slides;
}
continue;
}
for sh in pics {
let ShapeKind::Picture { fill } = &sh.kind else { continue };
let media = doc.media(fill.media);
let size = media.map_or(0, |m| m.data.len());
if items.len() >= ALBUM_MAX_ITEMS || size > ALBUM_MAX_BYTES || total.saturating_add(size) > ALBUM_MAX_TOTAL {
omitted = true;
break 'slides;
}
total += size;
let name = if !sh.descr.is_empty() {
sh.descr.clone()
} else {
@@ -626,14 +666,19 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
}));
}
}
items
(items, omitted)
}
/// The slides of album `ids` as the editor would seed them.
fn album_slides<'a>(st: &'a crate::DocState, ids: &[u64]) -> Vec<&'a deckcraft_model::Slide> {
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect()
}
fn album_seed(s: &Session) -> Result<Value> {
let (id, ids) = album_target(s, &Value::Null)?;
let st = s.doc()?;
let slides: Vec<&deckcraft_model::Slide> =
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
let slides = album_slides(st, &ids);
let (items, _) = items_from_slides(&st.doc, &slides);
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
let inferred = infer_album(&slides, &theme_now);
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
@@ -655,7 +700,7 @@ fn album_seed(s: &Session) -> Result<Value> {
"captions": captions,
"grayscale": grayscale,
"theme": theme,
"items": items_from_slides(&st.doc, &slides),
"items": items,
}))
}
@@ -722,3 +767,96 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Op
s.execute("slide.go", &json!({"index": first}))?;
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
}
#[cfg(test)]
mod seed_tests {
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Shape, ShapeId, defaults};
use super::*;
#[test]
fn reopening_an_album_seeds_at_most_the_items_an_album_takes() {
// One picture named by 500 shapes: the dialog gets 48 items, not 500 encoded copies.
let mut p = defaults::new_presentation(None);
let img = p.add_media("a.png", "image/png", vec![1; 64 * 1024]).unwrap();
let pic = |k: u32| Shape {
id: ShapeId(10_000 + k),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes = (0..500).map(pic).collect();
let slides: Vec<&deckcraft_model::Slide> = p.slides.iter().map(|s| s.as_ref()).collect();
let (items, omitted) = items_from_slides(&p, &slides);
assert_eq!(items.len(), ALBUM_MAX_ITEMS);
assert!(omitted);
let few: Vec<&deckcraft_model::Slide> = Vec::new();
assert!(!items_from_slides(&p, &few).1);
}
/// An album too big for the editor's seed cannot be replaced from that seed: the replace would
/// delete what the seed left out.
#[test]
fn editing_an_album_the_editor_cannot_hold_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
doc.slides.push(Arc::new(extra));
let n = doc.slides.len();
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
}
/// Past the item limit, a slide holding only a drawing counts as content the replace would delete.
#[test]
fn editing_an_album_with_a_drawing_slide_past_the_limit_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
extra.shapes = vec![Shape { id: ShapeId(90_000), ..Default::default() }];
doc.slides.push(Arc::new(extra));
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
}
/// Notes or comments on a shapeless slide past the limit are content a replace would delete.
#[test]
fn editing_an_album_with_a_notes_only_slide_past_the_limit_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
extra.shapes.clear();
extra.notes = deckcraft_model::text::TextBody::from_text("speaker notes");
doc.slides.push(Arc::new(extra));
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
}
/// A shapeless slide with a background, transition, colour map or tags counts as content; a plain empty one does not.
#[test]
fn slide_level_settings_count_as_slide_content() {
use deckcraft_model::Slide;
assert!(!has_slide_content(&Slide::default()));
assert!(has_slide_content(&Slide { tags: vec![("k".into(), "v".into())], ..Default::default() }));
assert!(has_slide_content(&Slide { color_map: Some(vec![("bg1".into(), "lt1".into())]), ..Default::default() }));
assert!(has_slide_content(&Slide { transition: Some(Default::default()), ..Default::default() }));
}
}
+26 -14
View File
@@ -85,14 +85,20 @@ impl AutomationRoots {
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
self.check_path(Path::new(path), access)
}
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
let path_text = path.display();
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
};
}
let resolved = resolve(path)?;
@@ -101,8 +107,8 @@ impl AutomationRoots {
}
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
match access {
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
}
}
}
@@ -191,17 +197,23 @@ pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
}
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
pub fn check_write_path(path: &Path) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
fn resolve(path: &Path) -> Result<PathBuf, String> {
if path.as_os_str().is_empty() {
return Err("automation path rejected: path is empty".into());
}
let shown = path.display();
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
@@ -212,11 +224,11 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
},
}
let parent = at.parent();
@@ -226,12 +238,12 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
}
}
}
+16
View File
@@ -699,6 +699,8 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
return Ok(());
}
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
// A confined automation client may write only inside its write roots, this file included.
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
if let Some(d) = path.parent() {
std::fs::create_dir_all(d)?;
}
@@ -1020,6 +1022,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]);
}
#[test]
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
set_user_dictionary_path(outside.join("user.dic"));
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
assert!(!outside.exists(), "the refused write created nothing");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
use deckcraft_model::{Paragraph, Run, RunProps};
+25 -2
View File
@@ -169,8 +169,14 @@ impl FontDb {
if bundled.contains(fam) {
continue;
}
if self.is_loaded(fam) || self.has_family(fam) {
let f = self.face(fam, style);
if self.is_loaded(fam) || (self.system_fallback_enabled() && self.has_family(fam)) {
// With system fallback off, only a face that is already loaded: `face` could load more catalog files.
let f = if self.system_fallback_enabled() {
self.face(fam, style)
} else {
let Some(f) = self.find(fam, style) else { continue };
f
};
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
@@ -311,4 +317,21 @@ mod tests {
assert!(c.windows(2).all(|w| w[0] <= w[1]));
}
}
/// With system fallback off, the automatic cascades (script families and symbol faces) load no
/// installed family: the result comes from what is already loaded.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cascades_load_no_installed_family_when_system_fallback_is_off() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let base = db.face(crate::DEFAULT_FAMILY, "Regular");
let loaded = |db: &FontDb| [b"Arab", b"Hebr", b"Thai", b"Deva"].into_iter().flat_map(|s| os_families(*s)).filter(|f| db.is_loaded(f)).count();
let before = loaded(&db);
for c in ['\u{0628}', '\u{05D0}', '\u{0E01}', '\u{0905}', '\u{2211}', '\u{2603}'] {
let f = db.cascade(&[c], std::slice::from_ref(&base), "Regular");
assert!(!f.family.is_empty());
}
assert_eq!(loaded(&db), before, "no installed family was loaded");
}
}
+14 -2
View File
@@ -976,6 +976,17 @@ impl FontDb {
let _ = on;
}
/// Whether automatic system-font lookups (fallback by character, symbol and script families)
/// may touch installed fonts. Lookups of a family the document or host names are not gated.
pub(crate) fn system_fallback_enabled(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled
}
#[cfg(target_arch = "wasm32")]
false
}
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
@@ -1287,7 +1298,7 @@ impl FontDb {
Some(f)
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
pub(crate) fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
if style.contains('{') {
{
let faces = self.read_faces();
@@ -1473,7 +1484,8 @@ impl FontDb {
};
#[cfg(not(target_arch = "wasm32"))]
{
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
let cataloged: Vec<String> =
if self.system_fallback_enabled() { self.read_catalog().iter().map(|e| e.family.clone()).collect() } else { Vec::new() };
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
self.load_cataloged(fam);
+8
View File
@@ -425,3 +425,11 @@ fn the_font_scan_skips_fifos_folders_and_link_loops() {
assert!(fifo || n >= 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn disabling_system_fallback_gates_automatic_family_loads() {
let db = FontDb::with_font_dirs(Vec::new());
assert!(cfg!(target_arch = "wasm32") || db.system_fallback_enabled());
db.set_system_fallback(false);
assert!(!db.system_fallback_enabled());
}
+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
);
+27 -1
View File
@@ -16,6 +16,12 @@ const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
/// Entries one batch (a JSON-RPC batch, or the `batch` tool's commands) may hold.
pub(crate) const MAX_BATCH_ITEMS: usize = 1000;
/// Bytes of results one batch may gather before the rest of it is refused: each result can copy
/// much of the document (an inspection holds every note), so a batch holds at most this much.
pub(crate) const MAX_BATCH_BYTES: usize = 64 << 20;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// Keep request frames in step with the connected backend's reply limit.
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
@@ -93,8 +99,28 @@ impl Server {
// Batches were removed in 2025-06-18; still answer older clients sensibly.
if batch.is_empty() {
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
} else if batch.len() > MAX_BATCH_ITEMS {
Some(error(Value::Null, INVALID_REQUEST, format!("a batch holds at most {MAX_BATCH_ITEMS} messages")))
} else {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
let mut used = 0usize;
let mut replies: Vec<Value> = Vec::new();
for m in batch {
if used > MAX_BATCH_BYTES {
// Not run: answer each remaining request with a short error.
if let Some(id) = m.get("id").filter(|id| id.is_string() || id.is_number()) {
replies.push(error(id.clone(), INTERNAL_ERROR, "the batch's replies passed 64 MiB; not run"));
}
continue;
}
if let Some(r) = self.handle(m) {
used = used.saturating_add(r.to_string().len());
replies.push(if used > MAX_BATCH_BYTES {
error(r.get("id").cloned().unwrap_or(Value::Null), INTERNAL_ERROR, "the batch's replies passed 64 MiB; reply dropped")
} else {
r
});
}
}
(!replies.is_empty()).then_some(Value::Array(replies))
}
}
+29 -10
View File
@@ -357,21 +357,40 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
}
"batch" => {
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let commands = a.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
if commands.len() > MAX_BATCH_ITEMS {
return ToolResult::error(format!("a batch holds at most {MAX_BATCH_ITEMS} commands"));
}
let mut out = vec![];
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
let mut used = 0usize;
for c in commands {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
Err(e) => {
out.push(json!({"command": id, "ok": false, "error": e}));
if !keep {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
}
let (entry, failed) = match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
Ok(v) => (json!({"command": id, "ok": true, "result": v}), false),
Err(e) => (json!({"command": id, "ok": false, "error": e}), true),
};
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
// Failures count too: with keepGoing, a remote backend's large errors add up.
used = used.saturating_add(json!(entry.to_string()).to_string().len());
if used > MAX_BATCH_BYTES {
let shown: String = id.chars().take(128).collect();
out.push(json!({"command": shown, "ok": false, "error": "the batch's results passed 64 MiB; stopped here"}));
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
out.push(entry);
if failed && !keep {
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
}
ToolResult::json(&Value::Array(out))
// The reply is pretty-printed, which is longer than the compact entries counted above.
let reply = ToolResult::json(&Value::Array(out));
let text = reply.content[0]["text"].as_str().unwrap_or_default();
if json!(text).to_string().len() > MAX_BATCH_BYTES {
return ToolResult::error("the batch's results passed 64 MiB; nothing is returned");
}
reply
}
"new_presentation" => wrap(exec(b, "file.new", a)),
"open_presentation" => wrap(exec(b, "file.open", a)),
+70
View File
@@ -0,0 +1,70 @@
//! A batch (JSON-RPC or the `batch` tool) is bounded in entries and in the bytes of results it
//! gathers, so a short request cannot copy a large document into memory hundreds of times.
use deckcraft_mcp::{Headless, Server};
use serde_json::{Value, json};
fn server_with_notes(bytes: usize) -> Server {
let mut server = Server::new(Box::new(Headless::with_document()));
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "n".repeat(bytes)}}}});
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
assert_ne!(reply["result"]["isError"], true, "{reply}");
server
}
#[test]
fn json_rpc_batches_are_bounded_in_entries_and_reply_bytes() {
let mut server = server_with_notes(1 << 20);
let ping = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "ping"});
let too_many: Vec<Value> = (0..1001).map(ping).collect();
let reply: Value = serde_json::from_str(&server.handle_line(&Value::Array(too_many).to_string()).unwrap()).unwrap();
assert_eq!(reply["error"]["code"], -32600, "{reply}");
// 100 inspections of a slide with 1 MB of notes: about 64 are answered, the rest refused.
let inspect = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "inspect_slide", "arguments": {}}});
let line = Value::Array((0..100).map(inspect).collect()).to_string();
let reply = server.handle_line(&line).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
let replies: Vec<Value> = serde_json::from_str(&reply).unwrap();
assert_eq!(replies.len(), 100, "every request is answered");
assert!(replies.last().unwrap()["error"]["message"].as_str().unwrap().contains("64 MiB"));
}
#[test]
fn the_batch_tool_is_bounded_in_commands_and_result_bytes() {
let mut server = server_with_notes(1 << 20);
let call = |server: &mut Server, commands: Vec<Value>| {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
(reply["result"]["isError"] == true, reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string())
};
let (err, text) = call(&mut server, vec![json!({"command": "document.inspect"}); 1001]);
assert!(err && text.contains("at most 1000"), "{text}");
let (err, text) = call(&mut server, vec![json!({"command": "slide.inspect"}); 100]);
assert!(err && text.contains("64 MiB"), "{}", &text[text.len().saturating_sub(200)..]);
assert!(text.len() < (64 << 20) + (4 << 20));
}
#[test]
fn the_batch_tool_charges_the_escaped_reply() {
// Quotes escape twice on the way out (`\\\"`), so 30 results of ~2 MiB serialize to ~120 MiB.
let mut server = Server::new(Box::new(Headless::with_document()));
let notes = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "\"".repeat(1 << 20)}}}});
server.handle_line(&notes.to_string()).unwrap();
let commands = vec![json!({"command": "slide.inspect"}); 30];
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply = server.handle_line(&line.to_string()).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
assert!(reply.contains("64 MiB"));
}
#[test]
fn the_batch_tool_charges_failures_too() {
// Each unknown command's error echoes its ~1 MiB id; with keepGoing none of them is a result.
let mut server = Server::new(Box::new(Headless::with_document()));
let id = "x".repeat(1 << 20);
let commands = vec![json!({"command": id}); 70];
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply = server.handle_line(&line.to_string()).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
assert!(reply.contains("64 MiB"));
}
+2
View File
@@ -1,3 +1,5 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod batch_budget;
+26 -2
View File
@@ -313,6 +313,8 @@ pub(crate) struct Canvas {
saved: Vec<Saved>,
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
used: usize,
/// Bytes retained outside the canvas (EMF+ objects) that count against the same cap.
held: usize,
objects: Vec<Option<Obj>>,
pen: Pen,
brush: Brush,
@@ -345,6 +347,7 @@ impl Canvas {
viewport_set: false,
saved: Vec::new(),
used: 0,
held: 0,
objects: Vec::new(),
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
brush: Brush::new(BS_SOLID, [255, 255, 255, 255]),
@@ -368,6 +371,17 @@ impl Canvas {
self.surface = clip::normalize(rect);
}
/// Bytes retained outside the canvas that share its budget (see [`Canvas::hold`]).
#[cfg(test)]
pub(crate) fn held(&self) -> usize {
self.held
}
/// Bytes the canvas has retained so far.
pub fn used(&self) -> usize {
self.used
}
/// True once the item cap is hit; the readers stop there and keep what they have.
pub fn full(&self) -> bool {
self.full
@@ -385,6 +399,12 @@ impl Canvas {
}
}
/// Records the bytes another part of the player retains, so the items and they stay within
/// [`MAX_BYTES`] together.
pub fn hold(&mut self, bytes: usize) {
self.held = bytes;
}
fn push_raw(&mut self, mut raw: Raw) {
if self.clip.as_ref().is_some_and(Vec::is_empty) {
return;
@@ -402,7 +422,7 @@ impl Canvas {
Raw::Bitmap { img, .. } => img.px.len(),
Raw::Text { text: t, .. } => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
} + region_cost;
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES {
self.full = true;
} else {
self.used += cost;
@@ -551,6 +571,10 @@ impl Canvas {
/// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers
/// the shape.
pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) {
// A full canvas or an empty clip keeps nothing more, so do not sample a tile for it.
if self.full || self.clip.as_ref().is_some_and(Vec::is_empty) {
return;
}
let dev: Vec<Op> = ops.iter().map(|o| self.map_op(*o)).collect();
let Some([l, t, r, b]) = crate::cliprect::ops_bbox(&dev) else { return };
let side = (r - l).max(b - t);
@@ -666,7 +690,7 @@ impl Canvas {
// A pattern brush's pixels count against the byte budget like drawn items.
if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj {
let cost = p.px.len();
if self.used.saturating_add(cost) > MAX_BYTES {
if self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES {
self.full = true;
return;
}
+90 -10
View File
@@ -288,13 +288,23 @@ impl Plus {
if !self.active {
return None;
}
match typ {
0x4008 => self.object(flags, r),
let done = match typ {
0x4008 => self.object(flags, r, cv.used().saturating_add(self.clip_bytes())),
0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r),
0x4025..=0x4035 => self.record_state(cv, typ, flags, r),
0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r),
_ => self.record_shape(cv, typ, flags, r),
}
};
// What this player retains (objects, a continued object's pieces, the current and saved
// clips) shares the canvas's byte budget; clip changes are charged as they happen.
cv.hold(self.object_bytes.saturating_add(self.partial.as_ref().map_or(0, |p| p.data.len())).saturating_add(self.clip_bytes()));
done
}
/// Bytes of the current clip and of every saved one.
fn clip_bytes(&self) -> usize {
let area = |a: &Area| a.as_ref().map_or(0, |v| v.len().saturating_mul(std::mem::size_of::<P>()));
self.stack.iter().map(|s| area(&s.clip)).fold(area(&self.clip), usize::saturating_add)
}
/// Rectangle, polygon, ellipse and path records.
@@ -559,7 +569,7 @@ impl Plus {
Some(())
}
fn object(&mut self, flags: u16, r: Bytes) -> Option<()> {
fn object(&mut self, flags: u16, r: Bytes, canvas_used: usize) -> Option<()> {
let id = usize::from(flags & 0xFF);
let kind = (flags >> 8) & 0x7F;
if flags & 0x8000 != 0 {
@@ -575,7 +585,7 @@ impl Plus {
p.data.extend_from_slice(&chunk[..chunk.len().min(room)]);
if p.data.len() >= p.total {
let obj = parse_object(p.kind, Bytes(&p.data));
self.store(p.id, obj);
self.store(p.id, obj, canvas_used);
} else {
self.partial = Some(p);
}
@@ -589,22 +599,23 @@ impl Plus {
let room = p.total.saturating_sub(p.data.len());
p.data.extend_from_slice(&r.0[..r.0.len().min(room)]);
let obj = parse_object(kind, Bytes(&p.data));
self.store(id, obj);
self.store(id, obj, canvas_used);
return Some(());
}
let obj = parse_object(kind, r);
self.store(id, obj);
self.store(id, obj, canvas_used);
Some(())
}
fn store(&mut self, id: usize, obj: Option<Obj>) {
/// `canvas_used` is what the canvas already retains: objects and items share one budget.
fn store(&mut self, id: usize, obj: Option<Obj>, canvas_used: usize) {
let Some(slot) = self.objects.get_mut(id) else { return };
if let Some(old) = slot.take() {
self.object_bytes = self.object_bytes.saturating_sub(Self::obj_cost(&old));
}
if let Some(new_obj) = obj {
let cost = Self::obj_cost(&new_obj);
if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES {
if self.object_bytes.saturating_add(cost).saturating_add(canvas_used) <= crate::MAX_BYTES {
self.object_bytes += cost;
*slot = Some(new_obj);
}
@@ -615,7 +626,7 @@ impl Plus {
match obj {
Obj::Image(img) => match img.as_ref() {
ImageData::Bitmap(rgba) => rgba.px.len(),
ImageData::Meta(pic) => pic.items.len().saturating_mul(128),
ImageData::Meta(pic) => picture_cost(&pic.items),
},
Obj::Brush(Fill::Texture { img, .. }) => img.px.len(),
Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::<Op>()),
@@ -1282,6 +1293,33 @@ fn metafile(r: Bytes) -> Option<Picture> {
pic.filter(|p| p.width > 0.0 && p.height > 0.0)
}
/// Bytes the items of a nested picture own: path segments, clip rectangles, pixels and text, with
/// each shared pattern tile counted once.
fn picture_cost(items: &[crate::Item]) -> usize {
use crate::Item;
let region = |r: &Option<Vec<[f32; 4]>>| r.as_ref().map_or(0, |v| v.len().saturating_mul(16));
let mut tiles = std::collections::HashSet::new();
let mut total = 0usize;
for item in items {
let own = match item {
Item::Path { segs, region: r, .. } => segs.len().saturating_mul(std::mem::size_of::<crate::Seg>()).saturating_add(region(r)),
Item::Bitmap { rgba, region: r, .. } => rgba.len().saturating_add(region(r)),
Item::Pattern { segs, tile, region: r, .. } => {
let tile_bytes = if tiles.insert(Arc::as_ptr(tile)) { tile.rgba.len() } else { 0 };
segs.len().saturating_mul(std::mem::size_of::<crate::Seg>()).saturating_add(tile_bytes).saturating_add(region(r))
}
Item::Text(t) => t
.text
.len()
.saturating_add(t.dx.len().saturating_mul(std::mem::size_of::<f32>()))
.saturating_add(t.font.family.len())
.saturating_add(region(&t.region)),
};
total = total.saturating_add(128).saturating_add(own);
}
total
}
fn bitmap(r: Bytes) -> Option<Rgba> {
let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32);
let format = r.u32(20)?;
@@ -1559,3 +1597,45 @@ fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec<Vec<u16>> {
out.push(cur);
out
}
#[cfg(test)]
mod cost_tests {
use super::*;
use crate::{Item, Seg, Tile};
#[test]
fn a_shared_pattern_tile_is_charged_once() {
let tile = Arc::new(Tile { width: 512, height: 512, rgba: vec![0; 1 << 20], cell: [1.0, 1.0], origin: [0.0, 0.0] });
let item = || Item::Pattern { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)], even_odd: false, tile: tile.clone(), region: None };
let items: Vec<Item> = (0..257).map(|_| item()).collect();
assert!(picture_cost(&items) < 2 << 20, "{}", picture_cost(&items));
}
#[test]
fn pieces_of_a_continued_object_count_against_the_shared_budget() {
let (mut plus, mut cv) = (Plus::new(), Canvas::new());
plus.active = true;
// Image object (kind 5), id 0, continued: 1000 bytes announced, 100 delivered.
let mut rec = 1000u32.to_le_bytes().to_vec();
rec.extend([0u8; 100]);
plus.record(&mut cv, 0x4008, 0x8000 | (5 << 8), Bytes(&rec));
assert_eq!(cv.held(), 100);
}
#[test]
fn current_and_saved_clips_count_against_the_shared_budget() {
let (mut plus, mut cv) = (Plus::new(), Canvas::new());
plus.active = true;
let pts = vec![(0.0, 0.0); 1000];
plus.clip = Some(pts.clone());
plus.stack.push(Saved { id: 1, world: IDENTITY, page: 1.0, clip: Some(pts) });
// Any record refreshes the held count (an unknown record draws nothing).
plus.record(&mut cv, 0x40FF, 0, Bytes(&[]));
assert_eq!(cv.held(), 2 * 1000 * std::mem::size_of::<P>());
// Restoring the saved state releases the charge of what it dropped.
plus.stack.clear();
plus.clip = None;
plus.record(&mut cv, 0x40FF, 0, Bytes(&[]));
assert_eq!(cv.held(), 0);
}
}
+12 -1
View File
@@ -58,6 +58,7 @@ impl Picture {
let k = (c[0].abs() + c[3].abs()) / 2.0;
let mut out = Vec::with_capacity(self.items.len());
let mut total_elements = 0usize;
let region_elements = std::cell::Cell::new(0usize);
let mut cached_region: Option<(*const [f32; 4], usize, Option<Option<Arc<BezPath>>>)> = None;
let mut get_region = |reg: &Option<Vec<[f32; 4]>>| -> Option<Option<Arc<BezPath>>> {
let (ptr, len) = match reg {
@@ -71,12 +72,15 @@ impl Picture {
return res.clone();
}
let res = placed_region(&tf, reg);
// Each distinct clip region is expanded once; its elements count against the placement budget.
let n = res.as_ref().and_then(Option::as_ref).map_or(0, |p| p.elements().len());
region_elements.set(region_elements.get().saturating_add(n));
cached_region = Some((ptr, len, res.clone()));
res
};
for (index, item) in self.items.iter().enumerate() {
if total_elements >= MAX_PLACED_ELEMENTS {
if total_elements.saturating_add(region_elements.get()) >= MAX_PLACED_ELEMENTS {
break;
}
match item {
@@ -84,6 +88,10 @@ impl Picture {
let path = tf * bez(segs);
let Some(region) = get_region(region) else { continue };
if in_range(path.bounding_box()) {
// This item's own region is expanded by now: it counts before it is kept.
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
break;
}
total_elements = total_elements.saturating_add(path.elements().len());
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region });
@@ -97,6 +105,9 @@ impl Picture {
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
let det = tile.determinant();
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
break;
}
total_elements = total_elements.saturating_add(path.elements().len());
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region });
}
+39
View File
@@ -897,6 +897,24 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() {
assert_eq!(stroke.map(|s| s.1), Some(8.0));
}
#[test]
fn place_counts_the_items_own_clip_region_before_keeping_it() {
// A path of 499,998 elements nearly fills the 500,000 budget; a second one whose new clip
// region adds elements goes past it and is left out.
let mut segs = vec![Seg::Move(0.0, 0.0)];
segs.extend((0..499_997).map(|i| Seg::Line(1.0 + (i % 50) as f32, 1.0)));
let big = Item::Path { segs, fill: None, stroke: None, even_odd: false, region: None };
let small = Item::Path {
segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)],
fill: None,
stroke: None,
even_odd: false,
region: Some(vec![[0.0, 0.0, 50.0, 25.0], [10.0, 10.0, 20.0, 20.0]]),
};
let pic = crate::Picture { width: 100.0, height: 50.0, items: vec![big, small] };
assert_eq!(pic.place(kurbo::Rect::new(0.0, 0.0, 100.0, 50.0), [0.0; 4]).len(), 1);
}
#[test]
fn place_keeps_only_finite_bounded_bitmaps_with_area() {
let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4], region: None };
@@ -2155,3 +2173,24 @@ fn region_intersection_over_budget_keeps_the_previous_area() {
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 10).is_none());
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut crate::cliprect::MAX_AREA_CLIP_WORK.clone()).is_some());
}
#[test]
fn full_canvas_does_not_sample_gradient_tiles() {
let mut cv = Canvas::new();
let big = || Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; MAX_BYTES / 2] };
for _ in 0..3 {
cv.bitmap([0.0, 0.0, 1.0, 1.0], big());
}
assert!(cv.full());
cv.shade_shape(&crate::geom::rect(0.0, 0.0, 10.0, 10.0), &|_| panic!("sampled a full canvas"));
assert_eq!(cv.items.len(), 2);
}
#[test]
fn canvas_budget_counts_bytes_held_elsewhere() {
let mut cv = Canvas::new();
cv.hold(MAX_BYTES - 100);
cv.bitmap([0.0, 0.0, 1.0, 1.0], Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; 200] });
assert!(cv.full());
assert!(cv.items.is_empty());
}
+5
View File
@@ -392,6 +392,9 @@ pub struct Chart {
/// Original chart XML part kept for round-trip of unmodelled options.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
/// The chart was too large to model: `raw` and `kept` are written back unchanged on save.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub verbatim: bool,
/// Package parts the original chart still points at (embedded workbook, styles, drawings).
/// The PPTX writer clears this before comparing modelled state. Native saves keep it too.
#[serde(skip_serializing_if = "ChartKept::is_empty")]
@@ -485,6 +488,7 @@ impl Default for Chart {
bin_count: None,
bin_size: None,
raw: None,
verbatim: false,
kept: ChartKept::default(),
}
}
@@ -1311,6 +1315,7 @@ impl Chart {
series: vec![],
gap_width: 0.0,
raw: None,
verbatim: false,
subtotals: vec![],
..self.clone()
};
+37 -26
View File
@@ -301,18 +301,14 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0));
let mut out = vec![];
for pt in deckcraft_render::place_master_texts(pres, m, n) {
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
for run in &pt.layout.runs {
let Some(g) = run.glyphs.first() else { continue };
let text: String = pt
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
.take(run.chars.1.saturating_sub(run.chars.0))
.collect();
let chars = para_chars.entry(run.para).or_insert_with(|| {
pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.content().chars()).collect()).unwrap_or_default()
});
let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
if text.trim().is_empty() {
continue;
}
@@ -498,6 +494,9 @@ impl Backdrops {
/// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters.
const MAX_RASTER_PIXELS: f64 = 64_000_000.0;
/// Most ruled note lines one handout area gets (a letter page holds about 30).
const MAX_NOTE_LINES: usize = 500;
/// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped.
fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 {
let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0;
@@ -574,20 +573,17 @@ impl Exporter<'_> {
/// Invisible glyphs exactly over the rasterised text.
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
// Each paragraph's characters, gathered once: a long paragraph wraps into many runs.
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
for run in &pt.layout.runs {
if run.glyphs.is_empty() || run.size <= 0.0 {
continue;
}
let text: String = pt
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
.take(run.chars.1.saturating_sub(run.chars.0))
.collect();
let chars = para_chars.entry(run.para).or_insert_with(|| {
pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.content().chars()).collect()).unwrap_or_default()
});
let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
if text.trim().is_empty() {
continue;
}
@@ -667,19 +663,28 @@ impl Exporter<'_> {
let lh = size * 1.3;
let mut y = area.y0 + size;
s.set_fill(Some(grey(0x20, 1.0)));
let space = width(" ");
'outer: for para in text.split('\n') {
// The line's width is kept as words are added, not measured again for each word.
let mut line = String::new();
let mut line_w = 0.0;
for word in para.split(' ') {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if width(&cand) > area.width() && !line.is_empty() {
let word_w = width(word);
let cand_w = if line.is_empty() { word_w } else { line_w + space + word_w };
if cand_w > area.width() && !line.is_empty() {
if y > area.y1 {
break 'outer;
}
s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto);
y += lh;
line = word.to_string();
line_w = word_w;
} else {
line = cand;
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
line_w = cand_w;
}
}
if y > area.y1 {
@@ -696,20 +701,26 @@ impl Exporter<'_> {
/// Ruled lines for handwritten notes (3-per-page handouts).
fn note_lines(&self, s: &mut Surface, r: Rect) {
let mut y = r.y0 + 18.0;
// Counted, not stepped: at huge coordinates adding 22 leaves y unchanged, and a huge page
// would want millions of rules.
let first = r.y0 + 18.0;
let count = ((r.y1 - first) / 22.0).floor() + 1.0;
if count.is_nan() || count < 1.0 {
return;
}
s.set_stroke(Some(krilla::paint::Stroke {
paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(),
width: 0.5,
..Default::default()
}));
while y <= r.y1 {
for i in 0..(count.min(MAX_NOTE_LINES as f64) as usize) {
let y = first + 22.0 * i as f64;
let mut p = kurbo::BezPath::new();
p.move_to((r.x0, y));
p.line_to((r.x1, y));
if let Some(path) = to_path(&p) {
s.draw_path(&path);
}
y += 22.0;
}
s.set_stroke(None);
}
+14
View File
@@ -728,3 +728,17 @@ fn raster_scale_caps_total_pixels_not_each_side() {
let s = raster_scale(720.0, (960.0, 540.0), 200.0);
assert!((s - 720.0 / 72.0 * 200.0 / 960.0).abs() < 1e-12);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn huge_paper_and_long_notes_export_in_bounded_work() {
// At 1e19 pt adding a rule's 22 pt leaves y unchanged: the rules are counted, not stepped. A narrow page keeps the raster small.
let p = deck(1);
let bytes = export(&p, &PdfOptions { paper: (612.0, 1.0e19), ..opts(PageLayout::Handouts { per_page: 3 }) }).expect("export");
assert!(bytes.starts_with(b"%PDF-"));
// 20,000 short words of notes, wrapped with the line width kept as words are added.
let mut p = deck(1);
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text(&"ab ".repeat(20_000));
let bytes = export(&p, &opts(PageLayout::Notes)).expect("export");
assert!(bytes.starts_with(b"%PDF-"));
}
+7
View File
@@ -70,6 +70,13 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
read::import_checked(bytes)
}
/// [`import`] with a lower presentation-wide chart point budget, so tests reach the over-budget
/// path without a multi-million-point chart.
#[doc(hidden)]
pub fn import_with_chart_budget(bytes: &[u8], points: usize) -> Result<Presentation, PptxError> {
read::import_with_chart_points(bytes, points).map(|(p, _)| p)
}
/// Read the theme of a `.thmx` file, or of a `.pptx` / `.potx`'s first master, with its media.
pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<deckcraft_model::MediaItem>), PptxError> {
read::import_theme(bytes)
+216 -26
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;
@@ -38,8 +39,17 @@ pub struct Imp<'a> {
pub vml_previews: HashMap<String, HashMap<String, String>>,
/// Aggregate byte count of retained chart sidecars across the presentation.
pub kept_sidecar_bytes: usize,
/// Data slots of the charts decoded so far (see `chart::MAX_PRESENTATION_CHART_POINTS`).
pub chart_points: usize,
/// Points the presentation may hold across modelled charts.
pub chart_points_cap: usize,
/// Cached kept sidecars by normalized chart part name.
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
/// Chart parts whose sidecar capture was refused, with the retained-byte total it started from:
/// the same start gives the same refusal, so the copy is not repeated.
pub rejected_kept: HashMap<String, usize>,
/// Bytes of chart parts decoded so far, rejected ones included (see `chart::MAX_CHART_DECODE_BYTES`).
pub chart_decode_bytes: usize,
/// Add-in tags parts, parsed once each, by normalized part name.
pub tags_by_part: HashMap<String, Arc<[(String, String)]>>,
/// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]).
@@ -90,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))
}
}
@@ -139,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()
}
@@ -317,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(),
@@ -329,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() });
}
}
}
}
@@ -366,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));
}
}
@@ -445,7 +535,11 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
table_style_ids: Vec::new(),
vml_previews: HashMap::new(),
kept_sidecar_bytes: 0,
chart_points: 0,
chart_points_cap: chart::MAX_PRESENTATION_CHART_POINTS,
kept_charts: HashMap::new(),
rejected_kept: HashMap::new(),
chart_decode_bytes: 0,
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
@@ -463,6 +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_with_chart_points(bytes, chart::MAX_PRESENTATION_CHART_POINTS)
}
/// [`import_checked`] with the presentation's chart point budget set (tests lower it).
pub(crate) fn import_with_chart_points(bytes: &[u8], chart_points_cap: usize) -> Result<(Presentation, Vec<String>), PptxError> {
let mut lost_slides: Vec<String> = vec![];
let pkg = Package::open(bytes)?;
let root_rels = pkg.rels("");
@@ -487,7 +586,11 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
table_style_ids: Vec::new(),
vml_previews: HashMap::new(),
kept_sidecar_bytes: 0,
chart_points: 0,
chart_points_cap,
kept_charts: HashMap::new(),
rejected_kept: HashMap::new(),
chart_decode_bytes: 0,
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
@@ -1423,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}");
}
}
+102
View File
@@ -39,6 +39,33 @@ pub(crate) fn rich_text(t: &El) -> Option<String> {
/// Most category/value/size slots one chart may decode across all its series and caches, so
/// sparse `ptCount`/`idx` declarations can't expand far beyond the XML they came from.
pub(crate) const MAX_CHART_POINTS: usize = 1_000_000;
/// Data slots (values and categories) the charts of one presentation may hold in all; a chart past
/// it is read as a placeholder frame. Each frame that references a chart decodes its own copy.
pub(crate) const MAX_PRESENTATION_CHART_POINTS: usize = 4_000_000;
/// Chart part bytes all chart frames of a presentation may decode, counting every attempt: a
/// chart that is rejected, or has no data slots, would otherwise be decoded again for each frame
/// that shows it.
pub(crate) const MAX_CHART_DECODE_BYTES: usize = 256 * 1024 * 1024;
/// Slots a decoded chart holds, counting the chart itself as at least one.
pub(crate) fn point_count(c: &Chart) -> usize {
let series = |s: &deckcraft_model::chart::Series| {
let bars = s.error_bars.as_ref().map_or(0, |e| e.plus.len().saturating_add(e.minus.len()));
s.values.len().saturating_add(s.x_values.len()).saturating_add(s.sizes.len()).saturating_add(bars)
};
let levels: usize = c.category_levels.iter().map(Vec::len).sum();
1usize.saturating_add(c.categories.len()).saturating_add(levels).saturating_add(c.series.iter().map(series).fold(0, usize::saturating_add))
}
/// Bytes of the strings a kept relationship owns.
fn part_bytes(p: &deckcraft_model::chart::ChartPart) -> usize {
p.bytes.len() + p.name.len() + p.content_type.len()
}
fn rel_bytes(r: &deckcraft_model::chart::ChartRel) -> usize {
r.id.len() + r.rel_type.len() + r.target.len() + r.part.len()
}
/// Most slots one cache may hold.
const MAX_CACHE_POINTS: usize = 100_000;
@@ -103,6 +130,48 @@ pub fn read_chart_xml(bytes: &[u8]) -> Option<Chart> {
Some(c)
}
/// The chart a graphic frame points at. A chart that would cross the presentation's point or
/// decode limits is not modelled: it is kept as its original part with the relationships it
/// reaches (`verbatim`), so a save writes it back whole instead of dropping it and its workbook.
pub(crate) fn frame_chart(imp: &mut super::Imp, part: &super::Part, rid: &str, uri: &str) -> Option<Chart> {
let rel = part.rels.get(rid)?;
let chartex = uri.ends_with("/chartex");
let bytes = imp.pkg.xml_bytes(&rel.target)?;
if imp.chart_decode_bytes.saturating_add(bytes.len()) > MAX_CHART_DECODE_BYTES {
log::warn!("pptx: chart {} is past the presentation's chart decode limit and is not kept", rel.target);
return None;
}
imp.chart_decode_bytes += bytes.len();
let parsed = if imp.chart_points < imp.chart_points_cap {
let c = if chartex { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }?;
let n = point_count(&c);
if imp.chart_points.saturating_add(n) <= imp.chart_points_cap {
imp.chart_points = imp.chart_points.saturating_add(n);
let mut c = c;
c.kept = capture_kept(imp, &rel.target);
return Some(c);
}
Some(c)
} else {
None
};
// Past the limit: keep the part itself (charged to the sidecar budget) and show an empty chart.
if chartex || imp.kept_sidecar_bytes.saturating_add(bytes.len()) > MAX_KEPT_TOTAL_BYTES {
log::warn!("pptx: chart {} is past the presentation's chart limits and is not kept", rel.target);
return None;
}
imp.kept_sidecar_bytes += bytes.len();
log::warn!("pptx: chart {} is past the presentation's chart limits; shown empty and written back unchanged", rel.target);
let mut c = parsed.unwrap_or_default();
c.series.clear();
c.categories.clear();
c.category_levels.clear();
c.raw = Some(String::from_utf8_lossy(bytes).into_owned());
c.verbatim = true;
c.kept = capture_kept(imp, &rel.target);
Some(c)
}
pub(crate) fn chart_from(root: &El) -> Option<Chart> {
chart_within(root, MAX_CHART_POINTS)
}
@@ -466,9 +535,20 @@ pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_
let key = crate::opc::norm(chart_part);
if let Some(cached) = imp.kept_charts.get(&key) {
// Every frame that shows this chart gets its own copy, so each copy is charged like the first.
let bytes: usize = cached.parts.iter().map(part_bytes).sum::<usize>() + cached.rels.iter().map(rel_bytes).sum::<usize>();
if imp.kept_sidecar_bytes.saturating_add(bytes) > MAX_KEPT_TOTAL_BYTES {
log::warn!("pptx: chart sidecar retained byte budget exceeded for {chart_part}");
return ChartKept::default();
}
imp.kept_sidecar_bytes += bytes;
return cached.clone();
}
let charged_before = imp.kept_sidecar_bytes;
if imp.rejected_kept.get(&key) == Some(&charged_before) {
return ChartKept::default();
}
let mut kept = ChartKept::default();
// The chart document itself is rewritten; don't store a second copy of it.
let mut seen = std::collections::HashMap::from([(key.clone(), true)]);
@@ -485,6 +565,18 @@ pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> deckcraft_
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: r.target, external: false });
}
}
// The first capture's relationship strings and the parts' names and content types are retained
// too (the part bytes were charged as copied).
let rel_total: usize =
kept.rels.iter().map(rel_bytes).sum::<usize>() + kept.parts.iter().map(|p| p.name.len() + p.content_type.len()).sum::<usize>();
if imp.kept_sidecar_bytes.saturating_add(rel_total) > MAX_KEPT_TOTAL_BYTES {
log::warn!("pptx: chart sidecar retained byte budget exceeded for {chart_part}");
// The dropped parts must not stay charged against later charts.
imp.kept_sidecar_bytes = charged_before;
imp.rejected_kept.insert(key, charged_before);
return ChartKept::default();
}
imp.kept_sidecar_bytes += rel_total;
imp.kept_charts.insert(key, kept.clone());
kept
}
@@ -590,6 +682,16 @@ fn text_size(e: Option<&El>) -> Option<f64> {
mod tests {
use super::*;
#[test]
fn point_count_charges_every_retained_vector() {
let mut c = Chart { categories: vec!["a".into()], category_levels: vec![vec!["x".into(); 2], vec!["y".into(); 3]], ..Default::default() };
let mut s =
deckcraft_model::chart::Series { values: vec![Some(1.0); 4], x_values: vec![None; 5], sizes: vec![None; 6], ..Default::default() };
s.error_bars = Some(deckcraft_model::chart::ErrorBars { plus: vec![None; 7], minus: vec![None; 8], ..Default::default() });
c.series = vec![s];
assert_eq!(point_count(&c), 1 + 1 + 5 + 4 + 5 + 6 + 15);
}
#[test]
fn sparse_caches_share_one_chart_wide_budget() {
// Every series declares 100000 points but writes one: without a chart-wide budget each
+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");
+1 -4
View File
@@ -434,11 +434,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
return s;
}
if let Some(c) = gd.child("chart")
&& let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or(""))
&& let Some(bytes) = imp.pkg.xml_bytes(&rel.target)
&& let Some(mut chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
&& let Some(chart) = super::chart::frame_chart(imp, part, c.attr("r:id").unwrap_or(""), uri)
{
chart.kept = super::chart::capture_kept(imp, &rel.target);
s.kind = ShapeKind::Chart(Box::new(chart));
return s;
}
+91 -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 {
@@ -610,6 +656,18 @@ fn kept_chartex(c: &Chart) -> Option<Vec<u8>> {
/// The chart part XML, and whether it is the original part (so its relationships still apply).
pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
// A chart too large to model is written back as it was read.
if c.verbatim
&& let Some(raw) = &c.raw
&& let Ok(doc) = crate::xml::parse(raw.as_bytes())
&& doc.root.is("chartSpace")
{
let mut keep = doc.root;
drop_unkept_refs(&mut keep, c);
let mut w = W::new();
w.raw(&keep.to_xml());
return (w.finish(), true);
}
// Reuse the kept part when the chart is unchanged since it was read.
if let Some(raw) = &c.raw
&& !raw.contains(NS_CX)
@@ -1049,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.
@@ -1110,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);
}
}
+27
View File
@@ -328,3 +328,30 @@ fn an_unchanged_chartex_keeps_its_embedded_workbook() {
assert!(saved.iter().all(|(n, _)| n != "ppt/charts/_rels/chart1.xml.rels"), "the fallback chart has no workbook");
assert!(!text_of(&saved, "ppt/charts/chart1.xml").contains("externalData"));
}
/// A chart past the presentation's point budget is not modelled, but a save writes the original
/// chart part and its workbook back instead of dropping both.
#[test]
fn a_chart_past_the_point_budget_is_saved_whole() {
let src = with_workbook(&chart_pptx());
let p = deckcraft_pptx::import_with_chart_budget(&src, 0).expect("import");
let chart = p
.slides
.iter()
.flat_map(|s| s.shapes.iter())
.find_map(|s| match &s.kind {
ShapeKind::Chart(c) => Some(c.clone()),
_ => None,
})
.expect("the chart stays a chart");
assert!(chart.verbatim && chart.series.is_empty());
let saved = parts(&deckcraft_pptx::export(&p).expect("export"));
let workbook = saved.iter().find(|(n, _)| n == "ppt/embeddings/kept1.xlsx").map(|(_, d)| d.as_slice());
assert_eq!(workbook, Some(WORKBOOK));
let xml = saved
.iter()
.find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml"))
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
.expect("chart xml");
assert!(xml.contains("externalData") && xml.contains("c:pie"), "{xml}");
}
+4 -1
View File
@@ -125,10 +125,13 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option<Rgba>, path: &B
let longest = bounds.width().max(bounds.height()) * k;
let s = (MAX_SIDE / longest).min(1.0) * k;
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048));
let polys = crate::pathgrad::polygons(path);
// Each pixel tests every edge, so a dense outline is simplified to the raster's edge budget.
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
let polys = crate::pathgrad::simplify(crate::pathgrad::polygons(path), pixel, crate::pathgrad::edge_budget(pw, ph));
if polys.is_empty() {
return;
}
let (pw, ph) = crate::pathgrad::fit_raster(polys.iter().map(|p| p.len().saturating_sub(1)).sum(), pw, ph);
let areas: Vec<f64> = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::<f64>() / 2.0).collect();
let face = lit.factor([0.0, 0.0, 1.0]);
let ratio = if bevel.w > 0.0 { bevel.h / bevel.w } else { 0.0 };
+15 -1
View File
@@ -11,6 +11,8 @@ use vello_cpu::{RenderContext, peniko};
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
const MAX_DEPTH: usize = 64;
/// Fills and gradient stops one colour glyph may paint; the rest of a hostile glyph is skipped.
const MAX_WORK: usize = 100_000;
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
@@ -26,7 +28,7 @@ pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, sca
.unwrap_or_default();
// Font units are y up; the slide is y down.
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new(), work: 0 };
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
let ok = glyph.paint(face.location(), &mut p).is_ok();
// A malformed glyph may leave layers open: close them all.
@@ -50,6 +52,8 @@ struct Painter<'a> {
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
opened: Vec<bool>,
/// Fills plus gradient stops painted so far, against [`MAX_WORK`].
work: usize,
}
fn affine(t: &Transform) -> Affine {
@@ -174,6 +178,16 @@ impl ColorPainter for Painter<'_> {
}
fn fill(&mut self, brush: Brush<'_>) {
let stops = match &brush {
Brush::Solid { .. } => 0,
Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => {
color_stops.len()
}
};
self.work = self.work.saturating_add(1 + stops);
if self.work > MAX_WORK {
return;
}
match brush {
Brush::Solid { palette_index, alpha } => {
let c = self.color(palette_index, alpha);
+139 -27
View File
@@ -25,6 +25,39 @@ const MAX_EDGE_WORK: u64 = 64_000_000;
/// Fewest edges a simplified polygon keeps, so a coarse outline still has a shape.
const MIN_POLY_EDGES: usize = 8;
/// Most points `polygons` keeps from one path; a path flattening past this (a huge finite curve) is
/// not drawn rather than held in memory first.
const MAX_POINTS: usize = 2_000_000;
/// Quads beyond one per cubic that a path's cubics may subdivide into before [`polygons`] refuses it.
const MAX_EXTRA_QUADS: f64 = 200_000.0;
/// Flattening tolerance of [`polygons`], in path units.
const FLATTEN_TOLERANCE: f64 = 0.25;
/// The edge budget for a `pw` x `ph` raster where each pixel tests every edge.
pub(crate) fn edge_budget(pw: u32, ph: u32) -> usize {
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES)
}
/// `pw` x `ph` cut down (aspect kept) until every pixel testing all of `edges` stays within
/// [`MAX_EDGE_WORK`]; the raster is stretched when painted, so a many-contour outline keeps every
/// contour at a coarser sampling.
pub(crate) fn fit_raster(edges: usize, pw: u32, ph: u32) -> (u32, u32) {
let work = u64::from(pw).saturating_mul(u64::from(ph)).saturating_mul(edges.max(1) as u64);
if work <= MAX_EDGE_WORK {
return (pw, ph);
}
let k = (MAX_EDGE_WORK as f64 / work as f64).sqrt();
let w = ((f64::from(pw) * k) as u32).max(1);
let h = ((f64::from(ph) * k) as u32).max(1);
// A side held at 1 pixel leaves the other to take the rest of the cut.
let room = |side: u32| u32::try_from(MAX_EDGE_WORK / u64::from(side).saturating_mul(edges.max(1) as u64).max(1)).unwrap_or(u32::MAX).max(1);
let w = w.min(room(h));
(w, h.min(room(w)))
}
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
/// shape's flattened polygon(s) for `shape`.
@@ -112,23 +145,59 @@ fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
/// The outline flattened into closed polygons.
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
// `kurbo::flatten` buffers all of a cubic's quads before calling back, and cannot be stopped
// once running, so bound the quads the cubics will make first (kurbo's own count: it splits
// cubics into quads at a tenth of the tolerance). An ordinary curve makes a few; only those
// past the first of each count.
let mut extra_quads = 0.0f64;
let mut at = Point::ZERO;
let mut start = Point::ZERO;
for el in path.iter() {
match el {
PathEl::MoveTo(p) => {
at = p;
start = p;
}
PathEl::LineTo(p) | PathEl::QuadTo(_, p) => at = p,
PathEl::CurveTo(a, b, p) => {
let err = ((b.to_vec2() * 3.0 - p.to_vec2()) - (a.to_vec2() * 3.0 - at.to_vec2())).hypot2();
extra_quads += (err / (432.0 * (FLATTEN_TOLERANCE * 0.1).powi(2))).powf(1.0 / 6.0).ceil().max(1.0) - 1.0;
// A non-finite estimate is over the limit too.
if extra_quads.is_nan() || extra_quads > MAX_EXTRA_QUADS {
return Vec::new();
}
at = p;
}
PathEl::ClosePath => at = start,
}
}
let mut out: Vec<Vec<Point>> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
kurbo::flatten(path.iter(), 0.25, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![p];
let mut total = 0usize;
kurbo::flatten(path.iter(), FLATTEN_TOLERANCE, |el| {
total += 1;
if total > MAX_POINTS {
return;
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![p];
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
}
_ => {}
});
if total > MAX_POINTS {
return Vec::new();
}
if cur.len() > 1 {
out.push(cur);
}
@@ -145,8 +214,9 @@ pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
/// `outline` cut down to at most about `max_edges` edges in total: first by dropping points
/// closer than `tol` to the last kept one (detail finer than a raster pixel), then, if still too
/// many, by keeping every k-th point. Each polygon stays closed. Returns it unchanged when it fits.
fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
/// many, by keeping every k-th point (a polygon too small to thin stays whole, and none is
/// dropped). Each polygon stays closed. Returns it unchanged when it fits.
pub(crate) fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
let edges = |o: &[Vec<Point>]| o.iter().map(|p| p.len().saturating_sub(1)).sum::<usize>();
if edges(&outline) <= max_edges {
return outline;
@@ -189,15 +259,8 @@ fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Poi
}
*poly = kept;
}
// Polygons too small to thin stay whole; drop the smallest ones if they alone overflow.
if edges(&out) > max_edges {
out.sort_by_key(|p| std::cmp::Reverse(p.len()));
let mut sum = 0usize;
out.retain(|p| {
sum = sum.saturating_add(p.len().saturating_sub(1));
sum <= max_edges
});
}
// Polygons too small to thin stay whole, so the total can still exceed `max_edges`: the
// caller shrinks its raster ([`fit_raster`]) rather than lose contours.
}
out
}
@@ -232,14 +295,20 @@ pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64,
let longest = bounds.width().max(bounds.height()) * k;
let s = (MAX_SIDE / longest).min(1.0) * k;
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
let outline = if kind == "shape" {
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
let max_edges = usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES);
let (outline, pw, ph) = if kind == "shape" {
let max_edges = edge_budget(pw, ph);
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
simplify(polygons(path), pixel, max_edges)
let o = simplify(polygons(path), pixel, max_edges);
let (pw, ph) = fit_raster(o.iter().map(|p| p.len().saturating_sub(1)).sum(), pw, ph);
(o, pw, ph)
} else {
Vec::new()
(Vec::new(), pw, ph)
};
// An outline `polygons` refused (too many points) has no contours: skip the shape rather than
// paint it with the rectangle fallback's different gradient.
if kind == "shape" && outline.is_empty() {
return;
}
let lut = ramp(stops);
let a = alpha.clamp(0.0, 1.0);
let mut data = Vec::with_capacity((pw * ph) as usize);
@@ -372,6 +441,20 @@ mod tests {
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
}
#[test]
fn a_shape_gradient_with_no_usable_outline_paints_nothing() {
// One polygon over the per-polygon cap is dropped by `polygons`.
let path = many_segment_square(26_000);
assert!(polygons(&path).is_empty());
let mut ctx = RenderContext::new(64, 64);
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (1.0, Rgba::rgb(255, 255, 255))];
fill(&mut ctx, "shape", CENTRE, &stops, Rect::new(0.0, 0.0, 64.0, 64.0), &path, Affine::IDENTITY, 1.0);
ctx.flush();
let mut px = vec![0u8; 64 * 64 * 4];
ctx.render(vello_cpu::PixmapMut::new(64, 64, &mut px).unwrap(), &mut vello_cpu::Resources::new());
assert!(px.chunks(4).all(|p| p[3] == 0), "nothing was painted");
}
#[test]
fn the_ramp_runs_through_the_stops() {
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
@@ -382,4 +465,33 @@ mod tests {
assert!((r[64].r as i32 - 64).abs() <= 2);
assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9)));
}
#[test]
fn huge_cubics_are_refused_before_flattening() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
// About 250,000 quads each: over the limit.
for _ in 0..3 {
p.curve_to((1e15, -1e15), (-1e15, 1e15), (1.0, 1.0));
}
p.close_path();
assert!(polygons(&p).is_empty());
// A cubic of ordinary size still flattens.
let mut q = BezPath::new();
q.move_to((0.0, 0.0));
q.curve_to((100.0, 0.0), (100.0, 100.0), (0.0, 100.0));
q.close_path();
assert_eq!(polygons(&q).len(), 1);
}
#[test]
fn many_contours_keep_every_contour_at_a_coarser_raster() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 1.0, 0.0), Point::new(x + 1.0, 1.0), Point::new(x, 1.0), Point::new(x, 0.0)];
let polys: Vec<Vec<Point>> = (0..100).map(|i| square(f64::from(i) * 2.0)).collect();
let simple = simplify(polys, 0.001, 244);
assert_eq!(simple.len(), 100, "no contour is dropped");
let (pw, ph) = fit_raster(400, 512, 512);
assert!(u64::from(pw) * u64::from(ph) * 400 <= MAX_EDGE_WORK && pw > 100, "{pw}x{ph}");
assert_eq!(fit_raster(200, 512, 512), (512, 512));
}
}
+88 -8
View File
@@ -12,6 +12,9 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand
use crate::icons::{self, Icon};
/// Video shapes that get a decoder at once; more play their sound without a picture.
const MAX_VIDEO_FEEDS: usize = 16;
/// Who started a voice: the editor's control bar stops when you leave the slide, the slide show
/// manages its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -227,14 +230,10 @@ impl MediaHost {
audio: clip.volume > 0.0,
};
let source_changed = self.owners.get(&id).is_some_and(|o| o.source != media_key);
if clip.video && (source_changed || !self.videos.contains_key(&id)) {
let feed = match VideoFeed::new(bytes.clone()) {
Ok(f) => Some(f),
Err(e) => {
log::info!("video {id}: {e}");
None
}
};
// A shape whose feed could not be had (the cap, a decode error) tries again on each play.
let feedless = self.videos.get(&id).is_some_and(|v| v.feed.is_none());
if clip.video && (source_changed || feedless || !self.videos.contains_key(&id)) {
let feed = self.new_feed(id, &bytes);
self.videos.insert(id, Video { feed, frame: None, tex: None });
} else if !clip.video {
self.videos.remove(&id);
@@ -243,6 +242,35 @@ impl MediaHost {
self.owners.insert(id, Owned { owner, clip: clip.clone(), source: media_key });
}
/// A decoder for the video of shape `id`, within the cap on live decoders; `None` past it or
/// when the video cannot be decoded (the shape then plays its sound only).
fn new_feed(&mut self, id: ShapeId, bytes: &Arc<Vec<u8>>) -> Option<VideoFeed> {
// One decoder and frame queue per video shape: past the cap a shape plays its sound only.
let mut live = self.videos.iter().filter(|(vid, v)| **vid != id && v.feed.is_some()).count();
if live >= MAX_VIDEO_FEEDS {
// Videos that have ended no longer need their decoder: free it and keep the last
// picture (a replay makes a new feed).
for (vid, v) in self.videos.iter_mut() {
if *vid != id && v.feed.is_some() && self.player.status(key(*vid)).is_some_and(|s| s.state == PlayState::Ended) {
v.feed = None;
live -= 1;
}
}
}
if live >= MAX_VIDEO_FEEDS {
log::info!("video {id}: more than {MAX_VIDEO_FEEDS} videos playing; showing no picture");
None
} else {
match VideoFeed::new(bytes.clone()) {
Ok(f) => Some(f),
Err(e) => {
log::info!("video {id}: {e}");
None
}
}
}
}
pub fn pause(&mut self, id: ShapeId) {
self.player.pause(key(id));
}
@@ -280,6 +308,15 @@ impl MediaHost {
self.play(doc_uid, doc, id, clip, Some(t), owner);
self.pause(id);
}
// A video whose decoder was reclaimed (or refused at the cap) tries for one again, so its picture follows the seek.
if clip.video && self.videos.get(&id).is_some_and(|v| v.feed.is_none()) {
let bytes = doc.media(clip.media).map(|m| m.data.clone());
if let Some(feed) = bytes.and_then(|b| self.new_feed(id, &b))
&& let Some(v) = self.videos.get_mut(&id)
{
v.feed = Some(feed);
}
}
self.player.seek(key(id), t);
}
@@ -804,4 +841,47 @@ mod tests {
assert_eq!((a.rect.x, a.rect.y, a.rect.w, a.rect.h), (100.0, 50.0, 20.0, 20.0));
assert_eq!((b.rect.x, b.rect.y, b.rect.w, b.rect.h), (120.0, 50.0, 20.0, 20.0));
}
/// Past `MAX_VIDEO_FEEDS` live decoders a video shape plays without a picture, and gets a
/// decoder again on its next play once one has been freed.
#[test]
fn video_decoders_are_capped_and_retried_when_one_frees_up() {
let mut doc = Presentation::default();
let media = doc.add_media("clip.mp4", "video/mp4", include_bytes!("../../media/tests/fixtures/clip-h264.mp4").to_vec()).expect("media");
let clip = MediaClip { media, volume: 0.0, video: true, ..Default::default() };
let mut host = MediaHost::new(None);
let live = |h: &MediaHost| h.videos.values().filter(|v| v.feed.is_some()).count();
for n in 0..=MAX_VIDEO_FEEDS as u32 {
host.play(1, &doc, ShapeId(5000 + n), &clip, None, Owner::Editor);
}
assert_eq!(live(&host), MAX_VIDEO_FEEDS);
let extra = ShapeId(5000 + MAX_VIDEO_FEEDS as u32);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()), "the shape past the cap has no picture");
host.stop(ShapeId(5000));
host.play(1, &doc, extra, &clip, None, Owner::Editor);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_some()), "a freed decoder is taken on the next play");
assert_eq!(live(&host), MAX_VIDEO_FEEDS);
}
/// Seeking a video that has no decoder (refused at the cap, or reclaimed) retries one without
/// restarting playback, so its picture follows the seek.
#[test]
fn seeking_a_feedless_video_retries_its_decoder() {
let mut doc = Presentation::default();
let media = doc.add_media("clip.mp4", "video/mp4", include_bytes!("../../media/tests/fixtures/clip-h264.mp4").to_vec()).expect("media");
let clip = MediaClip { media, volume: 0.0, video: true, ..Default::default() };
let mut host = MediaHost::new(None);
for n in 0..=MAX_VIDEO_FEEDS as u32 {
host.play(1, &doc, ShapeId(7000 + n), &clip, None, Owner::Editor);
}
let extra = ShapeId(7000 + MAX_VIDEO_FEEDS as u32);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()));
// Still at the cap: a seek does not exceed it.
host.seek(1, &doc, extra, &clip, 0.0, Owner::Editor);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_none()));
host.stop(ShapeId(7000));
host.seek(1, &doc, extra, &clip, 0.0, Owner::Editor);
assert!(host.videos.get(&extra).is_some_and(|v| v.feed.is_some()), "the seek got a decoder");
assert_eq!(host.videos.values().filter(|v| v.feed.is_some()).count(), MAX_VIDEO_FEEDS);
}
}
+36 -4
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use deckcraft_model::edit;
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
use egui::text::{Galley, LayoutJob, TextFormat};
use egui::{Align2, Color32, Stroke, Ui, vec2};
use crate::theme;
@@ -210,16 +210,30 @@ pub fn editor(
out
}
/// The (min y, max y) of the row holding char index `at`; `walk` (row index, chars through that
/// row) carries over between calls, so successive non-decreasing `at`s cost one pass in all.
fn row_span_at(galley: &Galley, walk: &mut (usize, usize), at: usize) -> (f32, f32) {
while walk.0 + 1 < galley.rows.len() && (walk.1 < at || (walk.1 == at && galley.rows[walk.0].char_count_including_newline().0 > 0)) {
walk.0 += 1;
walk.1 += galley.rows[walk.0].char_count_including_newline().0;
}
galley.rows.get(walk.0).map_or((0.0, 0.0), |r| (r.min_y(), r.max_y()))
}
/// Bullets and numbers at the start of each paragraph's first line of `galley` (laid out from
/// `body`), drawn at `origin`.
fn paint_bullets(painter: &egui::Painter, body: &TextBody, galley: &Galley, origin: egui::Pos2, size: f32, color: Color32) {
let k = size / 12.0;
let mut at = 0usize;
// Paragraph starts only grow, so one forward walk over the rows finds each start's row
// (`pos_from_cursor` would rescan from the top for every bullet).
let mut walk = (0usize, galley.rows.first().map_or(0, |r| r.char_count_including_newline().0));
for (p, bullet) in body.paragraphs.iter().zip(bullets(body)) {
if let Some(b) = bullet {
let row = galley.pos_from_cursor(CCursor::new(at));
let pos = origin + vec2(px(indents(p).0, k), row.center().y);
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
let (min_y, max_y) = row_span_at(galley, &mut walk, at);
let (center_y, height) = ((min_y + max_y) / 2.0, max_y - min_y);
let pos = origin + vec2(px(indents(p).0, k), center_y);
let font = theme::font(height.clamp(size * 0.75, size * 2.0) * 0.8);
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
}
at += p.char_len() + 1;
@@ -241,6 +255,7 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use deckcraft_model::text::{ParaProps, Run};
use egui::text::CCursor;
use super::*;
@@ -317,6 +332,23 @@ mod tests {
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
}
#[test]
fn row_walk_matches_cursor_lookup_at_paragraph_starts() {
let ctx = egui::Context::default();
let body = notes();
let mut laid = None;
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
out.textures_delta.clear();
let g = laid.unwrap();
let mut walk = (0usize, g.rows.first().map_or(0, |r| r.char_count_including_newline().0));
let mut at = 0usize;
for p in &body.paragraphs {
let want = g.pos_from_cursor(CCursor::new(at));
assert_eq!(row_span_at(&g, &mut walk, at), (want.min.y, want.max.y), "paragraph at char {at}");
at += p.char_len() + 1;
}
}
#[test]
fn typing_keeps_the_formatting_around_it() {
let mut d = Draft::new(&notes());
+29 -7
View File
@@ -196,18 +196,40 @@ pub(super) fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Optio
]
}
/// Width of a slide-show grid thumbnail, in pixels.
const THUMB_WIDTH: f64 = 320.0;
/// Tallest grid thumbnail, so an extreme portrait slide cannot ask for a huge raster.
const THUMB_MAX_HEIGHT: f64 = 2048.0;
/// The scale and pixel size of a grid thumbnail of a `width` x `height` slide.
fn thumb_size(width: f64, height: f64) -> (f64, f64, f64) {
let (sw, sh) = (width.max(1.0), height.max(1.0));
let scale = (THUMB_WIDTH / sw).min(THUMB_MAX_HEIGHT / sh);
(scale, (sw * scale).round().max(1.0), (sh * scale).round().max(1.0))
}
pub(super) fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usize) -> Option<TextureHandle> {
if show.thumbs.len() >= GRID_CAP {
return None;
}
let w = 320.0_f64;
let h = (w * doc.slide_size.height / doc.slide_size.width.max(1.0)).round().max(1.0);
let img = deckcraft_render::render_slide(
doc,
i,
&RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() },
);
// 320 wide, unless the slide is so tall that this would pass the height limit: fit that box.
let (scale, w, h) = thumb_size(doc.slide_size.width, doc.slide_size.height);
let img = deckcraft_render::render_slide(doc, i, &RenderOpts { scale, size: Some((w as u32, h as u32)), ..Default::default() });
let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false));
show.thumbs.push((i, tex.clone()));
Some(tex)
}
#[cfg(test)]
mod thumb_size_tests {
use super::*;
#[test]
fn ordinary_slides_are_320_wide_and_extreme_ones_stay_in_the_height_limit() {
let (scale, w, h) = thumb_size(960.0, 540.0);
assert_eq!((w, h), (320.0, 180.0));
assert!((scale - 320.0 / 960.0).abs() < 1e-12);
let (_, w, h) = thumb_size(1.0, 1.0e9);
assert!(h <= THUMB_MAX_HEIGHT && w >= 1.0);
}
}
+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))
}