mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
616 lines
31 KiB
Rust
616 lines
31 KiB
Rust
//! Each format's documents, built from scratch: what `sniff`, `info`, `renditions` and `outline`
|
|
//! return for them.
|
|
|
|
mod common;
|
|
|
|
use common::*;
|
|
use craft_doc::{Error, Kind, Limits, RenditionKind, info, outline, rendition, renditions, sniff};
|
|
|
|
fn limits() -> Limits {
|
|
Limits::default()
|
|
}
|
|
|
|
/// A PDF-only importer need not decode a broken preview, for inline or blob payloads.
|
|
#[test]
|
|
fn selected_rendition_ignores_other_payloads() {
|
|
let inline = vectorcraft_json(
|
|
3,
|
|
&format!(r#""preview":{{"mime":"image/png","data":"!bad!"}}, "pdf":{{"mime":"application/pdf","data":"{}"}}"#, base64(PDF)),
|
|
);
|
|
let mut blob = vectorcraft_json(
|
|
4,
|
|
&format!(r#""preview":{{"mime":"image/png","blob":[{},100]}}, "pdf":{{"mime":"application/pdf","blob":[0,{}]}}"#, PDF.len() + 100, PDF.len()),
|
|
)
|
|
.into_bytes();
|
|
blob.extend(BLOB_MAGIC);
|
|
blob.extend(PDF);
|
|
for bytes in [inline.into_bytes(), blob] {
|
|
for bytes in [bytes.clone(), gzip(&bytes)] {
|
|
assert!(renditions(&bytes, &limits()).is_err());
|
|
let selected = rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap().unwrap();
|
|
assert_eq!(selected.bytes, PDF);
|
|
assert!(rendition(&bytes, RenditionKind::Thumbnail, &limits()).is_err());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stored DEFLATE blocks contain the intact prefix; the next block has reserved BTYPE=3.
|
|
fn gzip_with_broken_tail(prefix: &[u8]) -> Vec<u8> {
|
|
let mut bytes = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 255];
|
|
for chunk in prefix.chunks(u16::MAX as usize) {
|
|
let length = chunk.len() as u16;
|
|
bytes.push(0); // Non-final stored block.
|
|
bytes.extend(length.to_le_bytes());
|
|
bytes.extend((!length).to_le_bytes());
|
|
bytes.extend(chunk);
|
|
}
|
|
bytes.push(7); // Final block with an invalid block type.
|
|
bytes.extend([0; 8]);
|
|
bytes
|
|
}
|
|
|
|
#[test]
|
|
fn selected_inline_pdf_ignores_gzip_damage_after_its_json_marker() {
|
|
for padding in [0, 128 * 1024] {
|
|
let json = vectorcraft_json(
|
|
4,
|
|
&format!(r#""padding":"{}","preview":{},"pdf":{}"#, "x".repeat(padding), blob("image/png", 0, 100), inline("application/pdf", PDF)),
|
|
);
|
|
let mut prefix = json.as_bytes().to_vec();
|
|
prefix.extend(BLOB_MAGIC);
|
|
let bytes = gzip_with_broken_tail(&prefix);
|
|
let mut exact = limits();
|
|
exact.max_json = json.len() as u64;
|
|
exact.max_total = (prefix.len() + PDF.len()) as u64;
|
|
exact.max_entry = PDF.len() as u64;
|
|
assert_eq!(rendition(&bytes, RenditionKind::Pdf, &exact).unwrap().unwrap().bytes, PDF);
|
|
assert!(matches!(rendition(&bytes, RenditionKind::Thumbnail, &limits()), Err(Error::Malformed(_))));
|
|
assert!(matches!(renditions(&bytes, &limits()), Err(Error::Malformed(_))));
|
|
exact.max_total -= 1;
|
|
assert!(matches!(rendition(&bytes, RenditionKind::Pdf, &exact), Err(Error::LimitExceeded(_))));
|
|
exact.max_total += 1;
|
|
exact.max_json -= 1;
|
|
assert!(matches!(rendition(&bytes, RenditionKind::Pdf, &exact), Err(Error::LimitExceeded(_))));
|
|
// A broken stream without a complete marker still fails, even after a complete JSON.
|
|
assert!(matches!(rendition(&gzip_with_broken_tail(json.as_bytes()), RenditionKind::Pdf, &limits()), Err(Error::Malformed(_))));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn vectorcraft_sniff_does_not_unwrap_nested_gzip() {
|
|
let json = vectorcraft_json(4, "");
|
|
assert_eq!(sniff(&gzip(&gzip(json.as_bytes()))), None);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_rendition_skips_missing_zip_payloads_and_enforces_its_own_limits() {
|
|
let manifest = pcraft_manifest(1, "[]", r#", "thumbnail":"thumb.png", "composite":"composite/preview.png""#);
|
|
let image = png(2, 2, 1);
|
|
let bytes = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/preview.png", &image)]);
|
|
assert!(renditions(&bytes, &limits()).is_err());
|
|
assert_eq!(rendition(&bytes, RenditionKind::Composite, &limits()).unwrap().unwrap().bytes, image);
|
|
assert!(rendition(&bytes, RenditionKind::Thumbnail, &limits()).is_err());
|
|
assert_eq!(rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap(), None);
|
|
let mut small = limits();
|
|
small.max_entry = PDF.len() as u64 - 1;
|
|
let vc = vectorcraft_json(3, &format!(r#""pdf":{{"mime":"application/pdf","data":"{}"}}"#, base64(PDF)));
|
|
assert!(matches!(rendition(vc.as_bytes(), RenditionKind::Pdf, &small), Err(Error::LimitExceeded(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_rendition_preserves_format_and_version_checks() {
|
|
assert_eq!(rendition(b"not a document", RenditionKind::Pdf, &limits()), Err(Error::Unrecognized));
|
|
assert!(matches!(rendition(vectorcraft_json(99, "").as_bytes(), RenditionKind::Pdf, &limits()), Err(Error::TooNew { .. })));
|
|
for bytes in [vectorcraft_json(4, "").into_bytes(), ecproj(2).into_bytes()] {
|
|
assert_eq!(rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap(), None);
|
|
}
|
|
}
|
|
|
|
/// Ignored output is not charged, but gzip still has to traverse bytes before the PDF.
|
|
#[test]
|
|
fn selected_rendition_counts_its_payload_and_the_compressed_prefix() {
|
|
let preview = vec![0; 128 * 1024];
|
|
for before in [false, true] {
|
|
let (preview_at, pdf_at) = if before { (0, preview.len()) } else { (PDF.len(), 0) };
|
|
let json = vectorcraft_json(
|
|
4,
|
|
&format!(
|
|
r#""preview":{{"mime":"image/png","blob":[{preview_at},{}]}},"pdf":{{"mime":"application/pdf","blob":[{pdf_at},{}]}}"#,
|
|
preview.len(),
|
|
PDF.len()
|
|
),
|
|
);
|
|
let mut bytes = json.as_bytes().to_vec();
|
|
bytes.extend(BLOB_MAGIC);
|
|
if before {
|
|
bytes.extend(&preview);
|
|
bytes.extend(PDF);
|
|
} else {
|
|
bytes.extend(PDF);
|
|
bytes.extend(&preview);
|
|
}
|
|
let mut small = limits();
|
|
small.max_entry = PDF.len() as u64;
|
|
small.max_total = (json.len() + BLOB_MAGIC.len() + PDF.len()) as u64;
|
|
assert!(matches!(renditions(&bytes, &small), Err(Error::LimitExceeded(_))));
|
|
assert_eq!(rendition(&bytes, RenditionKind::Pdf, &small).unwrap().unwrap().bytes, PDF);
|
|
let compressed = rendition(&gzip(&bytes), RenditionKind::Pdf, &small);
|
|
if before {
|
|
assert!(matches!(compressed, Err(Error::LimitExceeded(_))));
|
|
} else {
|
|
assert_eq!(compressed.unwrap().unwrap().bytes, PDF);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sniff_tells_the_three_formats() {
|
|
let vc = vectorcraft_json(4, "");
|
|
assert_eq!(sniff(vc.as_bytes()), Some(Kind::VectorCraft));
|
|
assert_eq!(sniff(&gzip(vc.as_bytes())), Some(Kind::VectorCraft));
|
|
let legacy = vc.replacen("\"vectorcraft\"", "\"drawcraft\"", 1);
|
|
assert_eq!(sniff(legacy.as_bytes()), Some(Kind::VectorCraft));
|
|
// A gzip stream cut short still shows its start.
|
|
let gz = gzip(vc.as_bytes());
|
|
assert_eq!(sniff(gz.get(..gz.len() - 8).unwrap()), Some(Kind::VectorCraft));
|
|
|
|
let pc = pcraft(&png(4, 4, 9), &png(8, 8, 9));
|
|
assert_eq!(sniff(&pc), Some(Kind::PhotoCraft), "the manifest last, found through the central directory");
|
|
let first = zip(&[store("manifest.json", b"{}"), store("thumb.png", &png(1, 1, 0))]);
|
|
assert_eq!(sniff(first.get(..60).unwrap()), Some(Kind::PhotoCraft), "the manifest first, in a short head");
|
|
let second = zip(&[store("tiles/a.zst", b"x"), store("manifest.json", b"{}")]);
|
|
assert_eq!(sniff(second.get(..100).unwrap()), Some(Kind::PhotoCraft), "a later local header in the head");
|
|
|
|
assert_eq!(sniff(ecproj(2).as_bytes()), Some(Kind::EffectCraft));
|
|
let old = r#"{"schema":1,"settings":{},"items":{},"next_id":1}"#;
|
|
assert_eq!(sniff(old.as_bytes()), Some(Kind::EffectCraft), "saved before savedBy");
|
|
}
|
|
|
|
#[test]
|
|
fn sniff_refuses_other_files() {
|
|
let plain_zip = zip(&[store("word/document.xml", b"<w/>"), deflated("readme.txt", b"hello hello hello")]);
|
|
let behind = zip(&[store("tiles/a.zst", &[7; 4096]), store("manifest.json", b"{}")]);
|
|
for (what, bytes) in [
|
|
("empty", &b""[..]),
|
|
("a PDF", PDF),
|
|
("a PNG", &png(2, 2, 1)),
|
|
("a ZIP without a manifest", &plain_zip),
|
|
("a manifest past the head", behind.get(..100).unwrap()),
|
|
("other JSON", br#"{"name":"x","items":[],"schema":"y"}"#),
|
|
("JSON naming the format in a value", br#"{"title":"vectorcraft"}"#),
|
|
("another format", br#"{"format":"vectorcraftx","version":4}"#),
|
|
("a JSON array", br#"["vectorcraft"]"#),
|
|
("savedBy without schema", br#"{"savedBy":"1.0","items":{}}"#),
|
|
("savedBy with schema only in a nested object", br#"{"savedBy":"1.0","metadata":{"schema":1}}"#),
|
|
("savedBy with schema only in a value", br#"{"savedBy":"1.0","note":"\"schema\""}"#),
|
|
("schema with items only in a nested object", br#"{"schema":1,"meta":{"items":[]}}"#),
|
|
("gzip of other JSON", &gzip(br#"{"format":"svg"}"#)),
|
|
("PK alone", b"PK\x03\x04"),
|
|
] {
|
|
assert_eq!(sniff(bytes), None, "{what}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sniff_reads_the_names_in_the_central_directory_of_a_whole_archive() {
|
|
let mut pc = zip(&[store("manifest.json", pcraft_manifest(1, "[]", "").as_bytes())]);
|
|
// The local header still names manifest.json; the central directory names Xanifest.json.
|
|
let c = pc.windows(4).rposition(|w| w == [0x50, 0x4b, 0x01, 0x02]).unwrap();
|
|
pc[c + 46] = b'X';
|
|
assert_eq!(sniff(&pc), None);
|
|
assert_eq!(info(&pc, &limits()), Err(Error::Unrecognized));
|
|
// A head too short to reach the central directory is read through its local headers.
|
|
assert_eq!(sniff(pc.get(..60).unwrap()), Some(Kind::PhotoCraft));
|
|
}
|
|
|
|
#[test]
|
|
fn a_zip_whose_manifest_has_no_format_version_is_not_photocraft() {
|
|
// A browser extension: a ZIP archive with a manifest.json of its own.
|
|
let ext = zip(&[store("manifest.json", br#"{"manifest_version":3,"name":"Example","version":"1.0"}"#)]);
|
|
assert_eq!(sniff(&ext), Some(Kind::PhotoCraft));
|
|
assert_eq!(info(&ext, &limits()), Err(Error::Unrecognized));
|
|
assert_eq!(renditions(&ext, &limits()), Err(Error::Unrecognized));
|
|
assert_eq!(outline(&ext, &limits()), Err(Error::Unrecognized));
|
|
// A format_version that is present and not a number is a damaged manifest.
|
|
for version in ["null", r#""1""#] {
|
|
let damaged = zip(&[store("manifest.json", pcraft_manifest(1, "[]", "").replacen("1", version, 1).as_bytes())]);
|
|
assert!(matches!(info(&damaged, &limits()), Err(Error::Malformed(_))), "{version}");
|
|
assert!(matches!(outline(&damaged, &limits()), Err(Error::Malformed(_))), "{version}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn info_reads_version_and_writer() {
|
|
let i = info(vectorcraft_json(3, "").as_bytes(), &limits()).unwrap();
|
|
assert_eq!((i.kind, i.version, i.generator.as_deref(), i.supported), (Kind::VectorCraft, 3, Some("VectorCraft 0.1.0"), true));
|
|
let i = info(&gzip(vectorcraft_json(4, "").as_bytes()), &limits()).unwrap();
|
|
assert_eq!((i.version, i.supported), (4, true));
|
|
|
|
let i = info(&pcraft(&png(1, 1, 0), &png(1, 1, 0)), &limits()).unwrap();
|
|
assert_eq!((i.kind, i.version, i.generator.as_deref(), i.supported), (Kind::PhotoCraft, 1, Some("photocraft-format 0.1.0"), true));
|
|
|
|
let i = info(ecproj(2).as_bytes(), &limits()).unwrap();
|
|
assert_eq!((i.kind, i.version, i.generator.as_deref(), i.supported), (Kind::EffectCraft, 2, Some("0.12.0"), true));
|
|
let i = info(br#"{"schema":1,"settings":{},"items":{},"next_id":1}"#, &limits()).unwrap();
|
|
assert_eq!((i.version, i.generator), (1, None));
|
|
|
|
assert_eq!(info(PDF, &limits()), Err(Error::Unrecognized));
|
|
}
|
|
|
|
#[test]
|
|
fn too_new_files_are_reported_and_refused() {
|
|
// Newer versions may change everything after the version: a member of a new shape.
|
|
let vc = vectorcraft_json(5, r#""pdf":"elsewhere""#);
|
|
let manifest = r#"{"format_version":2,"generator":"photocraft-format 9","document":"elsewhere","thumbnail":7}"#;
|
|
let pc = zip(&[store("manifest.json", manifest.as_bytes())]);
|
|
let ec = r#"{"savedBy":"9.0.0","schema":3,"items":[]}"#.to_string();
|
|
for (bytes, kind, version) in [(vc.into_bytes(), Kind::VectorCraft, 5), (pc, Kind::PhotoCraft, 2), (ec.into_bytes(), Kind::EffectCraft, 3)] {
|
|
let i = info(&bytes, &limits()).unwrap();
|
|
assert_eq!((i.kind, i.version, i.supported), (kind, version, false));
|
|
let refused = Error::TooNew { kind, version, supported: kind.supported_version() };
|
|
assert_eq!(renditions(&bytes, &limits()), Err(refused.clone()), "{kind:?}");
|
|
assert_eq!(outline(&bytes, &limits()), Err(refused), "{kind:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_pcraft_format_version_of_zero_is_malformed() {
|
|
let pc = zip(&[store("manifest.json", pcraft_manifest(0, "[]", "").as_bytes())]);
|
|
assert!(matches!(info(&pc, &limits()), Err(Error::Malformed(_))));
|
|
assert!(matches!(outline(&pc, &limits()), Err(Error::Malformed(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn vectorcraft_members_inline_as_base64() {
|
|
let preview = png(16, 8, 200);
|
|
let members = format!(r#""preview":{},"pdf":{}"#, inline("image/png", &preview), inline("application/pdf", PDF));
|
|
let json = vectorcraft_json(3, &members);
|
|
for bytes in [json.clone().into_bytes(), gzip(json.as_bytes())] {
|
|
let r = renditions(&bytes, &limits()).unwrap();
|
|
assert_eq!(r.len(), 2);
|
|
assert_eq!((r[0].kind, r[0].mime.as_str(), r[0].bytes.as_slice()), (RenditionKind::Thumbnail, "image/png", preview.as_slice()));
|
|
assert_eq!((r[1].kind, r[1].mime.as_str(), r[1].bytes.as_slice()), (RenditionKind::Pdf, "application/pdf", PDF));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn vectorcraft_members_after_the_json() {
|
|
// Version 4: the PDF after the marker, behind an image the document uses; the preview inline.
|
|
let image = vec![0xAB; 9000];
|
|
let preview = png(3, 3, 1);
|
|
let members = format!(r#""preview":{},"pdf":{}"#, inline("image/png", &preview), blob("application/pdf", image.len(), PDF.len()));
|
|
let mut file = vectorcraft_json(4, &members).into_bytes();
|
|
file.extend(BLOB_MAGIC);
|
|
file.extend(&image);
|
|
file.extend(PDF);
|
|
for bytes in [file.clone(), gzip(&file)] {
|
|
let r = renditions(&bytes, &limits()).unwrap();
|
|
assert_eq!(r.iter().map(|r| r.kind).collect::<Vec<_>>(), [RenditionKind::Thumbnail, RenditionKind::Pdf]);
|
|
assert_eq!(r[0].bytes, preview);
|
|
assert_eq!(r[1].bytes, PDF);
|
|
// The JSON alone answers info and outline.
|
|
assert_eq!(info(&bytes, &limits()).unwrap().version, 4);
|
|
assert_eq!(outline(&bytes, &limits()).unwrap().pages.len(), 2);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compressed_members_after_the_json_read_their_own_ranges() {
|
|
// 100,000 bytes the document uses, then the preview and the PDF; then the same bytes named by
|
|
// a PDF member that overlaps the preview.
|
|
let image = vec![0xCD; 100_000];
|
|
let preview = png(5, 5, 2);
|
|
let mut tail = image.clone();
|
|
tail.extend(&preview);
|
|
tail.extend(PDF);
|
|
let at = image.len();
|
|
for (pdf_at, pdf_len, pdf) in [(at + preview.len(), PDF.len(), PDF.to_vec()), (at, preview.len() + PDF.len(), [&preview[..], PDF].concat())] {
|
|
let members = format!(r#""preview":{},"pdf":{}"#, blob("image/png", at, preview.len()), blob("application/pdf", pdf_at, pdf_len));
|
|
let mut file = vectorcraft_json(4, &members).into_bytes();
|
|
file.extend(BLOB_MAGIC);
|
|
file.extend(&tail);
|
|
for bytes in [file.clone(), gzip(&file)] {
|
|
let r = renditions(&bytes, &limits()).unwrap();
|
|
assert_eq!((&r[0].bytes, &r[1].bytes), (&preview, &pdf));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_marker_follows_the_json_directly() {
|
|
// A space between the JSON and the marker: the format crate does not split the file there.
|
|
let mut file = vectorcraft_json(4, &format!(r#""pdf":{}"#, blob("application/pdf", 0, PDF.len()))).into_bytes();
|
|
file.push(b' ');
|
|
file.extend(BLOB_MAGIC);
|
|
file.extend(PDF);
|
|
assert!(craft_vectorcraft_format::split_tail(&file).1.is_empty());
|
|
for bytes in [file.clone(), gzip(&file)] {
|
|
assert!(matches!(info(&bytes, &limits()), Err(Error::Malformed(_))));
|
|
assert!(matches!(renditions(&bytes, &limits()), Err(Error::Malformed(_))));
|
|
assert!(matches!(outline(&bytes, &limits()), Err(Error::Malformed(_))));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_compressed_marker_across_the_reader_s_64_kib_chunks() {
|
|
// The marker starts at every offset around the first 64 KiB boundary of the unpacked stream.
|
|
let base = vectorcraft_json(4, &format!(r#""pdf":{},"pad":"""#, blob("application/pdf", 0, PDF.len())));
|
|
for json_len in (64 << 10) - 12..(64 << 10) + 2 {
|
|
let json = base.replacen(r#""pad":"""#, &format!(r#""pad":"{}""#, "x".repeat(json_len - base.len())), 1);
|
|
assert_eq!(json.len(), json_len);
|
|
let mut file = json.into_bytes();
|
|
file.extend(BLOB_MAGIC);
|
|
file.extend(PDF);
|
|
let gz = gzip(&file);
|
|
assert_eq!(info(&gz, &limits()).unwrap().version, 4, "{json_len}");
|
|
assert_eq!(outline(&gz, &limits()).unwrap().pages.len(), 2, "{json_len}");
|
|
assert_eq!(renditions(&gz, &limits()).unwrap()[0].bytes, PDF, "{json_len}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_padded_base64_member_fits_a_limit_of_its_own_size() {
|
|
for len in 1..=3 {
|
|
let data = vec![7u8; len];
|
|
let bytes = vectorcraft_json(3, &format!(r#""preview":{}"#, inline("image/png", &data))).into_bytes();
|
|
let mut l = limits();
|
|
l.max_entry = len as u64;
|
|
assert_eq!(renditions(&bytes, &l).unwrap()[0].bytes, data);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn vectorcraft_without_members_has_no_renditions() {
|
|
let json = vectorcraft_json(4, "");
|
|
assert_eq!(renditions(json.as_bytes(), &limits()).unwrap(), vec![]);
|
|
assert_eq!(renditions(&gzip(json.as_bytes()), &limits()).unwrap(), vec![]);
|
|
}
|
|
|
|
#[test]
|
|
fn vectorcraft_outline_has_artboards_and_top_level_layers() {
|
|
for bytes in [vectorcraft_json(4, "").into_bytes(), gzip(vectorcraft_json(4, "").as_bytes())] {
|
|
let o = outline(&bytes, &limits()).unwrap();
|
|
assert_eq!(o.canvas, None);
|
|
let pages: Vec<_> = o.pages.iter().map(|p| (p.name.as_str(), p.x, p.y, p.width, p.height)).collect();
|
|
assert_eq!(pages, [("Front", 0.0, 0.0, 612.0, 792.0), ("Back", 650.0, 700.0, 50.0, 100.0)]);
|
|
assert!(o.pages.iter().all(|p| p.timing.is_none() && p.layers.is_empty()));
|
|
let layers: Vec<_> = o.layers.iter().map(|l| (l.name.as_str(), l.kind.as_str(), l.visible, l.opacity, l.blend.as_deref())).collect();
|
|
assert_eq!(layers, [("Art", "layer", true, Some(1.0), Some("Normal")), ("Notes", "layer", false, Some(0.5), Some("Multiply"))]);
|
|
assert!(o.layers.iter().all(|l| l.children.is_empty()));
|
|
}
|
|
}
|
|
|
|
/// Thumbnail and legacy composite previews never claim a full-size source.
|
|
#[test]
|
|
fn pcraft_previews_stored_and_deflated_with_the_manifest_last() {
|
|
let (thumb, composite) = (png(4, 3, 10), png(40, 30, 20));
|
|
let r = renditions(&pcraft(&thumb, &composite), &limits()).unwrap();
|
|
assert_eq!(r.len(), 2);
|
|
assert_eq!((r[0].kind, r[0].mime.as_str(), &r[0].bytes), (RenditionKind::Thumbnail, "image/png", &thumb));
|
|
assert_eq!((r[1].kind, r[1].mime.as_str(), &r[1].bytes), (RenditionKind::Composite, "image/png", &composite));
|
|
assert!(r.iter().all(|rendition| !rendition.is_full_composite));
|
|
}
|
|
|
|
/// Full-member identity is retained even when its dimensions differ from the document canvas.
|
|
#[test]
|
|
fn pcraft_prefers_the_full_size_composite_without_reading_the_preview() {
|
|
let full = png(80, 60, 30);
|
|
let small_preview = png(4, 3, 20);
|
|
for preview in [false, true] {
|
|
let manifest = pcraft_manifest(1, "[]", r#", "full_composite":"composite/full.png", "composite":"composite/preview.png""#);
|
|
let mut entries = vec![store("manifest.json", manifest.as_bytes()), deflated("composite/full.png", &full)];
|
|
if preview {
|
|
entries.push(store("composite/preview.png", &small_preview));
|
|
}
|
|
let r = renditions(&zip(&entries), &limits()).unwrap();
|
|
assert_eq!(r.len(), 1);
|
|
assert_eq!((r[0].kind, r[0].mime.as_str(), &r[0].bytes), (RenditionKind::Composite, "image/png", &full));
|
|
assert!(r[0].is_full_composite);
|
|
let canvas = outline(&zip(&entries), &limits()).unwrap().canvas.unwrap();
|
|
assert_eq!((canvas.width, canvas.height), (640, 480), "the flag does not validate the 80 by 60 PNG against this canvas");
|
|
}
|
|
let manifest = pcraft_manifest(1, "[]", r#", "full_composite":"composite/full.png", "composite":"composite/preview.png""#);
|
|
let mut tight = limits();
|
|
tight.max_entry = manifest.len().max(full.len()) as u64;
|
|
let oversized = vec![0; tight.max_entry as usize + 1];
|
|
for preview in [store("composite/preview.png", &oversized), deflated("composite/preview.png", &oversized)] {
|
|
let pc = zip(&[store("manifest.json", manifest.as_bytes()), deflated("composite/full.png", &full), preview]);
|
|
assert_eq!(renditions(&pc, &tight).unwrap()[0].bytes, full);
|
|
}
|
|
let manifest = pcraft_manifest(1, "[]", r#", "full_composite":"composite/full.png""#);
|
|
let pc = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/full.png", &full)]);
|
|
let selected = renditions(&pc, &limits()).unwrap().pop().unwrap();
|
|
assert_eq!(selected.bytes, full);
|
|
assert!(selected.is_full_composite);
|
|
let mut small = limits();
|
|
small.max_entry = manifest.len() as u64;
|
|
// Keep the manifest under the limit while bounding the image independently.
|
|
let large = vec![0; manifest.len() + full.len()];
|
|
let pc = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/full.png", &large)]);
|
|
assert!(matches!(renditions(&pc, &small), Err(Error::LimitExceeded(_))));
|
|
}
|
|
|
|
/// The source flag distinguishes identical payloads selected from different manifest members.
|
|
#[test]
|
|
fn pcraft_identical_images_keep_their_composite_source() {
|
|
let image = png(4, 3, 30);
|
|
let mut variants = Vec::new();
|
|
for declared in [false, true] {
|
|
let fields = if declared {
|
|
r#", "full_composite":"composite/full.png", "composite":"composite/preview.png""#
|
|
} else {
|
|
r#", "composite":"composite/preview.png""#
|
|
};
|
|
let manifest = pcraft_manifest(1, "[]", fields);
|
|
let bundle = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/full.png", &image), store("composite/preview.png", &image)]);
|
|
let rendition = renditions(&bundle, &limits()).unwrap().pop().unwrap();
|
|
assert_eq!(rendition.is_full_composite, declared);
|
|
variants.push(rendition);
|
|
}
|
|
assert_eq!((variants[0].kind, &variants[0].mime, &variants[0].bytes), (variants[1].kind, &variants[1].mime, &variants[1].bytes));
|
|
assert_ne!(variants[0], variants[1], "equality includes the selected source");
|
|
}
|
|
|
|
/// Selected extraction keeps full-composite precedence and provenance without reading other images.
|
|
#[test]
|
|
fn selected_full_composite_skips_missing_thumbnail_and_damaged_preview() {
|
|
let full = png(4, 3, 30);
|
|
let manifest =
|
|
pcraft_manifest(1, "[]", r#", "thumbnail":"thumb.png", "composite":"composite/preview.png", "full_composite":"composite/full.png""#);
|
|
let pc =
|
|
zip(&[store("manifest.json", manifest.as_bytes()), store("composite/full.png", &full), store("composite/preview.png", b"unused preview")]);
|
|
let selected = rendition(&pc, RenditionKind::Composite, &limits()).unwrap().unwrap();
|
|
assert_eq!(selected.bytes, full);
|
|
assert!(selected.is_full_composite);
|
|
assert!(rendition(&pc, RenditionKind::Thumbnail, &limits()).is_err());
|
|
let without_full = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/preview.png", &full)]);
|
|
assert!(rendition(&without_full, RenditionKind::Composite, &limits()).is_err(), "a declared full image cannot silently fall back");
|
|
}
|
|
|
|
/// Missing and null full declarations ignore an undeclared full entry; thumbnails stay false.
|
|
#[test]
|
|
fn pcraft_legacy_and_thumbnail_sources_are_not_full_composites() {
|
|
let image = png(4, 3, 30);
|
|
for declaration in ["", r#", "full_composite":null"#, r#", "full_composite":"composite/full.png""#] {
|
|
let manifest = pcraft_manifest(1, "[]", &format!(r#", "thumbnail":"thumb.png", "composite":"composite/preview.png"{declaration}"#));
|
|
let bundle = zip(&[
|
|
store("manifest.json", manifest.as_bytes()),
|
|
store("thumb.png", &image),
|
|
store("composite/preview.png", &image),
|
|
store("composite/full.png", &image),
|
|
]);
|
|
let renditions = renditions(&bundle, &limits()).unwrap();
|
|
assert_eq!(renditions.len(), 2);
|
|
assert_eq!(renditions[0].kind, RenditionKind::Thumbnail);
|
|
assert!(!renditions[0].is_full_composite);
|
|
assert_eq!(renditions[1].kind, RenditionKind::Composite);
|
|
assert_eq!(renditions[1].is_full_composite, declaration.contains("composite/full.png"));
|
|
}
|
|
}
|
|
|
|
/// VectorCraft thumbnails and PDFs retain false provenance for plain and gzip documents.
|
|
#[test]
|
|
fn vectorcraft_renditions_are_not_full_composites() {
|
|
let document = vectorcraft_json(3, &format!(r#""preview":{}, "pdf":{}"#, inline("image/png", &png(4, 3, 30)), inline("application/pdf", PDF)));
|
|
for bytes in [document.as_bytes().to_vec(), gzip(document.as_bytes())] {
|
|
let renditions = renditions(&bytes, &limits()).unwrap();
|
|
assert_eq!(renditions.iter().map(|rendition| rendition.kind).collect::<Vec<_>>(), [RenditionKind::Thumbnail, RenditionKind::Pdf]);
|
|
assert!(renditions.iter().all(|rendition| !rendition.is_full_composite));
|
|
}
|
|
}
|
|
|
|
/// A checksum failure in the declared full entry remains an error despite a valid preview.
|
|
#[test]
|
|
fn a_damaged_declared_full_composite_does_not_fall_back() {
|
|
let image = png(4, 3, 30);
|
|
let manifest = pcraft_manifest(1, "[]", r#", "full_composite":"composite/full.png", "composite":"composite/preview.png""#);
|
|
let mut bundle = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/preview.png", &image), store("composite/full.png", &image)]);
|
|
let central = bundle
|
|
.windows(4)
|
|
.enumerate()
|
|
.find(|(at, magic)| *magic == b"PK\x01\x02" && bundle.get(at + 46..at + 46 + b"composite/full.png".len()) == Some(b"composite/full.png"))
|
|
.unwrap()
|
|
.0;
|
|
bundle[central + 16] ^= 1;
|
|
assert!(matches!(renditions(&bundle, &limits()), Err(Error::Malformed(_))));
|
|
}
|
|
|
|
/// Invalid declarations and missing full entries never silently fall back to the preview.
|
|
#[test]
|
|
fn pcraft_refuses_invalid_or_missing_full_size_composites() {
|
|
for field in [r#""../full.png""#, r#""composite/preview.png""#, "12", r#""composite/full.png", "full_composite":null"#] {
|
|
let manifest = pcraft_manifest(1, "[]", &format!(r#", "full_composite":{field}"#));
|
|
let pc = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/preview.png", &png(4, 3, 20))]);
|
|
assert!(matches!(renditions(&pc, &limits()), Err(Error::Malformed(_))), "{field}");
|
|
}
|
|
let manifest = pcraft_manifest(1, "[]", r#", "full_composite":"composite/full.png", "composite":"composite/preview.png""#);
|
|
let pc = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/preview.png", &png(4, 3, 20))]);
|
|
assert!(matches!(renditions(&pc, &limits()), Err(Error::Malformed(_))));
|
|
let manifest = pcraft_manifest(1, "[]", r#", "full_composite":null, "composite":"composite/preview.png""#);
|
|
let preview = png(4, 3, 20);
|
|
let pc = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/preview.png", &preview)]);
|
|
let selected = renditions(&pc, &limits()).unwrap().pop().unwrap();
|
|
assert_eq!(selected.bytes, preview);
|
|
assert!(!selected.is_full_composite);
|
|
}
|
|
|
|
#[test]
|
|
fn pcraft_without_previews_has_none() {
|
|
let pc = zip(&[store("manifest.json", pcraft_manifest(1, "[]", "").as_bytes())]);
|
|
assert_eq!(renditions(&pc, &limits()).unwrap(), vec![]);
|
|
let missing = zip(&[store("manifest.json", pcraft_manifest(1, "[]", r#","thumbnail":"thumb.png""#).as_bytes())]);
|
|
assert!(matches!(renditions(&missing, &limits()), Err(Error::Malformed(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn pcraft_outline_has_the_canvas_and_layer_tree() {
|
|
let o = outline(&pcraft(&png(1, 1, 0), &png(1, 1, 0)), &limits()).unwrap();
|
|
let c = o.canvas.unwrap();
|
|
assert_eq!((c.width, c.height, c.resolution_dpi, c.mode.as_str(), c.depth.as_str()), (640, 480, 300.0, "Rgb", "U16"));
|
|
assert!(o.pages.is_empty());
|
|
let layers: Vec<_> = o.layers.iter().map(|l| (l.name.as_str(), l.kind.as_str(), l.visible, l.opacity, l.blend.as_deref())).collect();
|
|
assert_eq!(layers, [("Background", "raster", true, Some(1.0), Some("Normal")), ("Group", "group", false, Some(0.25), Some("Screen"))]);
|
|
let inner = &o.layers[1].children;
|
|
assert_eq!(inner.len(), 1);
|
|
assert_eq!((inner[0].name.as_str(), inner[0].blend.as_deref(), inner[0].opacity), ("Inner", Some("Multiply"), Some(0.5)));
|
|
}
|
|
|
|
#[test]
|
|
fn pcraft_groups_nest_to_photocraft_s_depth_limit() {
|
|
// 3 levels to the layer list, 3 per group and 4 for the leaf: 367 levels for 120 groups,
|
|
// PhotoCraft's limit (`manifest_depth(MAX_GROUP_DEPTH)`).
|
|
let nested = |groups: usize| {
|
|
let mut layer = pcraft_layer("Leaf", true, "Normal", 1.0, raster());
|
|
for i in 0..groups {
|
|
layer = pcraft_layer(&format!("G{i}"), true, "Normal", 1.0, &format!(r#"{{"kind":"group","expanded":false,"children":[{layer}]}}"#));
|
|
}
|
|
zip(&[deflated("manifest.json", pcraft_manifest(1, &format!("[{layer}]"), "").as_bytes())])
|
|
};
|
|
let o = outline(&nested(120), &limits()).unwrap();
|
|
let mut depth = 0;
|
|
let mut l = &o.layers[0];
|
|
while let Some(c) = l.children.first() {
|
|
depth += 1;
|
|
l = c;
|
|
}
|
|
assert_eq!((depth, l.name.as_str()), (120, "Leaf"));
|
|
// One more group is past the limit, though within MAX_DEPTH.
|
|
let deeper = nested(121);
|
|
assert!(matches!(outline(&deeper, &limits()), Err(Error::LimitExceeded(_))));
|
|
assert!(matches!(renditions(&deeper, &limits()), Err(Error::LimitExceeded(_))));
|
|
assert_eq!(info(&deeper, &limits()).unwrap().version, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn ecproj_has_no_renditions_and_lists_its_comps() {
|
|
let p = ecproj(2);
|
|
assert_eq!(renditions(p.as_bytes(), &limits()).unwrap(), vec![]);
|
|
let o = outline(p.as_bytes(), &limits()).unwrap();
|
|
assert!(o.canvas.is_none() && o.layers.is_empty());
|
|
// By item id (3 before 10), the folder and the footage left out.
|
|
let pages: Vec<_> = o.pages.iter().map(|p| (p.name.as_str(), p.width, p.height)).collect();
|
|
assert_eq!(pages, [("Intro", 640.0, 360.0), ("Main", 1920.0, 1080.0)]);
|
|
let t = o.pages[1].timing.unwrap();
|
|
assert_eq!((t.frame_rate_num, t.frame_rate_den, t.duration_seconds), (24000, 1001, 5.0));
|
|
assert_eq!(o.pages[0].timing.unwrap().duration_seconds, 1.0);
|
|
// Bottom first: the file lists the top layer first.
|
|
let layers: Vec<_> = o.pages[1].layers.iter().map(|l| (l.name.as_str(), l.kind.as_str(), l.visible, l.opacity, l.blend.as_deref())).collect();
|
|
assert_eq!(layers, [("Plate", "Footage", false, None, Some("Normal")), ("Title", "Text", true, None, Some("Add"))]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_ecproj_comp_without_a_duration_has_no_timing() {
|
|
let p = ecproj(2).replacen(r#""duration": 254016000000, "#, "", 1);
|
|
let o = outline(p.as_bytes(), &limits()).unwrap();
|
|
assert_eq!((o.pages[0].name.as_str(), o.pages[0].timing), ("Intro", None));
|
|
assert!(o.pages[1].timing.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn kinds_name_their_extension_and_version() {
|
|
assert_eq!(
|
|
[Kind::PhotoCraft, Kind::VectorCraft, Kind::EffectCraft].map(|k| (k.extension(), k.supported_version())),
|
|
[("pcraft", 1), ("vectorcraft", 4), ("ecproj", 2)]
|
|
);
|
|
}
|