mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 19:46:58 -06:00
* M2.1: preserve chart parts through native saves Serialize retained chart relationships and payloads in native files. Add native and headless file-command round-trip regressions. Contribution on behalf of @MMexicann. * Format: bound opaque chart parts as native ZIP entries Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * Model: track transient native chart part entries Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * PPTX: initialize native chart entry metadata Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * Engine: adapt chart native roundtrip fixtures Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * Docs: describe bounded chart sidecars and validation Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * Roadmap: record bounded chart persistence evidence Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. --------- Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
113 lines
4.5 KiB
Rust
113 lines
4.5 KiB
Rust
//! Keep opaque chart payloads out of the JSON manifest and within the media load budget.
|
|
use std::collections::HashSet;
|
|
use std::io::Cursor;
|
|
use std::sync::Arc;
|
|
|
|
use deckcraft_model::chart::ChartPart;
|
|
use deckcraft_model::{Master, Presentation, Shape, ShapeKind};
|
|
|
|
use crate::{FormatError, MAX_ENTRY, Result, read_entry_max};
|
|
|
|
const PREFIX: &str = "chart-parts/";
|
|
type Payload = (String, String, Vec<u8>);
|
|
|
|
pub(super) fn separate(p: &mut Presentation) -> Result<Vec<Payload>> {
|
|
let mut payloads = Vec::new();
|
|
visit_parts(p, |part| {
|
|
// The native namespace is independent of the original OPC names, which may repeat
|
|
// when charts from several decks are combined. No user-supplied path is extracted.
|
|
let entry = format!("{PREFIX}{}", payloads.len());
|
|
part.native_entry = Some(entry.clone());
|
|
payloads.push((entry, part.content_type.clone(), std::mem::take(&mut part.bytes)));
|
|
Ok(())
|
|
})?;
|
|
Ok(payloads)
|
|
}
|
|
|
|
pub(super) fn restore(
|
|
p: &mut Presentation,
|
|
z: &mut zip::ZipArchive<Cursor<&[u8]>>,
|
|
left: &mut u64,
|
|
mut items: usize,
|
|
max_items: usize,
|
|
damaged: &mut Vec<String>,
|
|
) -> Result<()> {
|
|
let mut seen = HashSet::new();
|
|
visit_parts(p, |part| {
|
|
if items >= max_items {
|
|
return Err(FormatError::Damaged(format!("media and chart parts exceed {max_items} items")));
|
|
}
|
|
items += 1;
|
|
if let Some(entry) = part.native_entry.take() {
|
|
// Accept only names produced by separate(), and inflate each member once.
|
|
let canonical = entry.strip_prefix(PREFIX).and_then(|s| s.parse::<usize>().ok()).is_some_and(|n| entry == format!("{PREFIX}{n}"));
|
|
if !canonical || !seen.insert(entry.clone()) || !part.bytes.is_empty() {
|
|
return Err(FormatError::Damaged("invalid or ambiguous chart part reference".into()));
|
|
}
|
|
if z.index_for_name(&entry).is_none() {
|
|
// Missing members consume no inflation work and can be reported for recovery.
|
|
damaged.push(format!("{entry}: missing chart part"));
|
|
return Ok(());
|
|
}
|
|
if z.by_name(&entry).is_ok_and(|f| f.size() > *left) {
|
|
return Err(FormatError::Damaged("embedded media and chart parts are larger than DeckCraft can open".into()));
|
|
}
|
|
// A CRC/decode error may already have inflated bytes. Stop on that first error
|
|
// rather than letting later corrupt members reuse the unchanged byte budget.
|
|
let bytes = read_entry_max(z, &entry, (*left).min(MAX_ENTRY))?;
|
|
*left -= bytes.len() as u64;
|
|
part.bytes = bytes;
|
|
} else {
|
|
// Older manifests used JSON byte arrays. Charge those too, after the existing
|
|
// bounded manifest parser has read them, so resaving migrates them losslessly.
|
|
let size = part.bytes.len() as u64;
|
|
if size > (*left).min(MAX_ENTRY) {
|
|
return Err(FormatError::Damaged("inline chart part is larger than DeckCraft can open".into()));
|
|
}
|
|
*left -= size;
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn visit_parts(p: &mut Presentation, mut visit: impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
|
// Use an explicit stack for groups. Charts can also live on masters and their layouts,
|
|
// including the notes and handout masters; none of these owners may leak inline bytes.
|
|
for slide in &mut p.slides {
|
|
shapes(&mut Arc::make_mut(slide).shapes, &mut visit)?;
|
|
}
|
|
for master in &mut p.masters {
|
|
master_parts(Arc::make_mut(master), &mut visit)?;
|
|
}
|
|
for master in [&mut p.notes_master, &mut p.handout_master].into_iter().flatten() {
|
|
master_parts(Arc::make_mut(master), &mut visit)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn master_parts(master: &mut Master, visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
|
shapes(&mut master.shapes, visit)?;
|
|
for layout in &mut master.layouts {
|
|
shapes(&mut layout.shapes, visit)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn shapes(root: &mut [Shape], visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
|
let mut pending = vec![root];
|
|
while let Some(shapes) = pending.pop() {
|
|
for shape in shapes {
|
|
match &mut shape.kind {
|
|
ShapeKind::Group { children, .. } => pending.push(children),
|
|
ShapeKind::Chart(chart) => {
|
|
for part in &mut chart.kept.parts {
|
|
visit(part)?;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|