mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #526 from NikitkaRa: Format: report damaged embedded media and open such files as a repaired copy
# Conflicts: # crates/engine/src/tests.rs
This commit is contained in:
@@ -74,9 +74,18 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Recognise and read presentation bytes (native, PPTX).
|
||||
pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
open_checked(name, bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// [`open_presentation`], plus the embedded media a `.deckcraft` file had lost or damaged.
|
||||
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_format::sniff(bytes) {
|
||||
return deckcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
|
||||
return deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()));
|
||||
}
|
||||
open_other(name, bytes).map(|p| (p, vec![]))
|
||||
}
|
||||
|
||||
fn open_other(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
if deckcraft_pptx::sniff(bytes) {
|
||||
let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
deckcraft_format::repair(&mut p);
|
||||
@@ -115,11 +124,15 @@ pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presenta
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let doc = open_presentation(path, &bytes)?;
|
||||
let (doc, damaged) = open_checked(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
||||
// A damaged file opens as an untitled repaired copy, so Save can't overwrite the original
|
||||
// with the empty media.
|
||||
let keep_path = native && damaged.is_empty();
|
||||
let i = add_opened(s, path, keep_path.then(|| path.to_string()), doc);
|
||||
report_damage(s, &damaged)?;
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged}))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -129,9 +142,28 @@ fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Presentation");
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
|
||||
let doc = open_presentation(name, &data)?;
|
||||
let (doc, damaged) = open_checked(name, &data)?;
|
||||
let i = add_opened(s, name, None, doc);
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
|
||||
report_damage(s, &damaged)?;
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged}))
|
||||
}
|
||||
|
||||
/// Tell the user which pictures and media a damaged file lost, and mark the copy repaired.
|
||||
fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
|
||||
if damaged.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
st.title = format!("{} (Repaired)", st.title);
|
||||
let n = damaged.len();
|
||||
let text = format!(
|
||||
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. It was opened as a repaired copy; save it under a new name to keep the original file.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "is" } else { "are" },
|
||||
);
|
||||
s.ui_requests.push(crate::UiRequest::Message { text });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`).
|
||||
|
||||
@@ -1491,3 +1491,35 @@ fn edit_points_types_and_handles() {
|
||||
let (_, ho3, _) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 2).unwrap();
|
||||
assert!((ho3.unwrap() - ho).hypot() < 1e-3);
|
||||
}
|
||||
|
||||
/// #392: a `.deckcraft` whose embedded picture is damaged opens as an untitled "(Repaired)"
|
||||
/// copy with a message, so Save can't overwrite the original with the empty media.
|
||||
#[test]
|
||||
fn damaged_native_file_opens_as_repaired_copy() {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec());
|
||||
let good = deckcraft_format::save(&p).unwrap();
|
||||
// PNGs are stored as they are, so flipping a byte breaks the entry's CRC.
|
||||
let mut bad = good.clone();
|
||||
let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap();
|
||||
bad[at] ^= 0xff;
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-392-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name, bytes, damaged) in [("good.deckcraft", &good, false), ("bad.deckcraft", &bad, true)] {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.ui_requests.clear();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(r["damagedMedia"].as_array().map(|a| a.len()), Some(usize::from(damaged)), "{r}");
|
||||
assert_eq!(st.path.is_none(), damaged, "{name}");
|
||||
assert_eq!(st.title.ends_with("(Repaired)"), damaged);
|
||||
assert_eq!(s.ui_requests.iter().any(|u| matches!(u, crate::UiRequest::Message { .. })), damaged);
|
||||
// Bytes route too.
|
||||
let r = s.execute("file.openBytes", &json!({"name": name, "data": crate::cmd::base64_encode(bytes)})).unwrap();
|
||||
assert_eq!(r["damagedMedia"].as_array().map(|a| a.len()), Some(usize::from(damaged)));
|
||||
}
|
||||
assert_eq!(std::fs::read(dir.join("bad.deckcraft")).unwrap(), bad, "original untouched");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -96,8 +96,15 @@ fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read a presentation.
|
||||
/// Read a presentation. Embedded media that is missing or damaged is left empty; use
|
||||
/// [`load_checked`] to learn which.
|
||||
pub fn load(bytes: &[u8]) -> Result<Presentation> {
|
||||
load_checked(bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Read a presentation and list the embedded media that couldn't be read (missing, too large
|
||||
/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage.
|
||||
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?;
|
||||
let m: Manifest = serde_json::from_slice(&json).map_err(|e| FormatError::Damaged(e.to_string()))?;
|
||||
@@ -105,13 +112,18 @@ pub fn load(bytes: &[u8]) -> Result<Presentation> {
|
||||
return Err(FormatError::NotOurs(m.format));
|
||||
}
|
||||
let mut p = m.presentation;
|
||||
let mut damaged = vec![];
|
||||
for item in &mut p.media {
|
||||
if let Ok(data) = read_entry(&mut z, &format!("media/{}", item.id.0)) {
|
||||
item.data = Arc::new(data);
|
||||
let name = format!("media/{}", item.id.0);
|
||||
let linked_only = item.link.is_some() && z.index_for_name(&name).is_none();
|
||||
match read_entry(&mut z, &name) {
|
||||
Ok(data) => item.data = Arc::new(data),
|
||||
Err(_) if linked_only => {}
|
||||
Err(e) => damaged.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
repair(&mut p);
|
||||
Ok(p)
|
||||
Ok((p, damaged))
|
||||
}
|
||||
|
||||
/// Make a loaded presentation safe to edit: a master exists, every slide's layout exists,
|
||||
@@ -279,6 +291,44 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The same package without the entries `drop` names.
|
||||
fn without(bytes: &[u8], drop: &str) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let f = z.by_index(i).unwrap();
|
||||
if f.name() != drop {
|
||||
out.raw_copy_file(f).unwrap();
|
||||
}
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_corrupt_media_is_reported() {
|
||||
let mut p = Presentation::default();
|
||||
let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec());
|
||||
let good = save(&p).unwrap();
|
||||
assert!(load_checked(&good).unwrap().1.is_empty());
|
||||
// Missing member.
|
||||
let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap();
|
||||
assert_eq!(damaged.len(), 1, "{damaged:?}");
|
||||
assert!(q.media(id).is_some_and(|m| m.data.is_empty()));
|
||||
// Bad CRC: PNGs are stored, so their bytes sit in the file as they are.
|
||||
let mut bad = good.clone();
|
||||
let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap();
|
||||
bad[at] ^= 0xff;
|
||||
let (_, damaged) = load_checked(&bad).unwrap();
|
||||
assert_eq!(damaged.len(), 1, "{damaged:?}");
|
||||
// Linked media saved without its bytes isn't damage.
|
||||
let mut l = Presentation::default();
|
||||
let lid = l.add_media("v.mp4", "video/mp4", vec![]);
|
||||
if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) {
|
||||
m.link = Some("file:///movies/v.mp4".into());
|
||||
}
|
||||
assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_media() {
|
||||
let mut p = Presentation::default();
|
||||
|
||||
Reference in New Issue
Block a user