Integrate full-composite provenance with cancellable reads

This commit is contained in:
FredApps committed 2026-11-07 15:44:13 +01:00
commit e26f3d1102
10 files changed
+231 -16

No files matched your search

+12 -2
View File
@@ -43,7 +43,11 @@ let outline = craft_doc::outline(&bytes, &limits)?;
writer it names, and `supported`, which is `false` when the version is newer than the crate
reads.
- `renditions(bytes, &Limits) -> Result<Vec<Rendition>, Error>`: each `Rendition` has a `kind`
(`Pdf`, `Thumbnail`, `Composite`), a `mime` and the `bytes`.
(`Pdf`, `Thumbnail`, `Composite`), a `mime` and the `bytes`. `is_full_composite` is true only
for a PhotoCraft composite selected from an explicit `full_composite` declaration; it is
false for the legacy preview and every other rendition. It records the selected source,
without decoding pixels or checking canvas dimensions. Derived equality and debug output
include this provenance.
- `rendition(bytes, RenditionKind, &Limits) -> Result<Option<Rendition>, Error>`: the requested
kind only, or `None` when absent. Container metadata and JSON limits are still checked. Other
rendition payloads are not decoded or copied: a damaged preview does not prevent reading the
@@ -94,7 +98,7 @@ PhotoCraft layer pixel decoding are separate operations.
| Container | JSON, plain or gzip; since version 4, large members after the JSON (`\n\0VCBLOBS\0`) | ZIP of STORE or DEFLATE entries | JSON |
| Version read | `version` up to 4 (`"format": "vectorcraft"` or `"drawcraft"`) | `format_version` 1 | `schema` up to 2 |
| Writer | `generator` | `generator` | `savedBy` |
| Renditions | `preview` (Thumbnail) and `pdf` (Pdf), when the file was saved with them | the `thumbnail` and `composite` the manifest names | none |
| Renditions | `preview` (Thumbnail) and `pdf` (Pdf), when the file was saved with them | the `thumbnail` and flattened composite the manifest names | none |
| Pages | artboards, in points | none | compositions by item id, in pixels, with frame rate and duration |
| Layers | top-level layers | the layer tree with groups | each composition's layers |
@@ -106,6 +110,12 @@ EffectCraft layers have no `opacity`, because opacity is an animated property th
A `.pcraft` bundle saved as a folder, ZIP64 archives and encrypted entries are not read. The
`.ecprojx` XML copies are not read.
For PhotoCraft, `Composite` prefers `full_composite` (`composite/full.png`) when declared,
otherwise `composite` (`composite/preview.png`). The unused preview payload is not read. A
declared full-canvas image that is missing or exceeds the limits is an error. The reader does
not silently substitute the smaller preview. Returned PNG bytes retain any embedded color profile;
the reader does not decode pixels or convert colors.
## PhotoCraft layer pixels
With the feature `pcraft-pixels`, which adds `ruzstd` and `blake3` (both pure Rust),
+7 -2
View File
@@ -178,7 +178,7 @@ pub enum RenditionKind {
/// A small preview image: VectorCraft's `preview` (the first artboard) or PhotoCraft's
/// `thumbnail`.
Thumbnail,
/// PhotoCraft's flattened `composite` preview.
/// PhotoCraft's full-canvas flattened image, or its composite preview when absent.
Composite,
}
@@ -191,6 +191,11 @@ pub struct Rendition {
/// rendition), or `image/png` for PhotoCraft's previews.
pub mime: String,
pub bytes: Vec<u8>,
/// True only for a PhotoCraft [`RenditionKind::Composite`] selected from the manifest's
/// explicit `full_composite` member. False for the legacy composite preview and all other
/// renditions. This records the selected source; image bytes and canvas dimensions are
/// not decoded or validated. Equality and debug output include this provenance.
pub is_full_composite: bool,
}
/// The structure of a document, without its content.
@@ -328,7 +333,7 @@ fn read_info(bytes: &[u8], budget: &mut Budget) -> Result<Info, Error> {
/// The previews and the PDF the document carries, the thumbnail first. A
/// `.vectorcraft` file has a `preview` only when saved with one and a `pdf` only when saved with
/// Create PDF-Compatible File; a `.pcraft` bundle has the `thumbnail` and `composite` its
/// Create PDF-Compatible File; a `.pcraft` bundle has the `thumbnail` and flattened composite its
/// manifest names; an `.ecproj` project has none.
pub fn renditions(bytes: &[u8], limits: &Limits) -> Result<Vec<Rendition>, Error> {
read_renditions(bytes, None, &mut Budget::new(limits))
+13 -6
View File
@@ -1,12 +1,12 @@
//! PhotoCraft `.pcraft` bundles: a ZIP archive holding `manifest.json` (the document tree, with
//! `format_version`), tiles and blobs, and optionally the previews the manifest names
//! (`thumbnail`, `composite`). Only the ZIP form is read; a bundle saved as a folder is not. The
//! (`thumbnail`, `composite`, `full_composite`). Only the ZIP form is read; a bundle saved as a folder is not. The
//! manifest's envelope is [`craft_pcraft_format::Manifest`]'s.
use std::fmt;
use std::marker::PhantomData;
use craft_pcraft_format::{self as format, COMPOSITE, FORMAT_VERSION, MANIFEST, MAX_GROUP_DEPTH, THUMB, manifest_depth};
use craft_pcraft_format::{self as format, COMPOSITE, FORMAT_VERSION, FULL_COMPOSITE, MANIFEST, MAX_GROUP_DEPTH, THUMB, manifest_depth};
use serde::Deserialize;
use serde::de::value::MapAccessDeserializer;
use serde::de::{DeserializeOwned, Deserializer, IgnoredAny, MapAccess, Visitor};
@@ -103,13 +103,16 @@ struct Envelope<D> {
thumbnail: Option<String>,
#[serde(default)]
composite: Option<String>,
#[serde(default)]
full_composite: Option<String>,
}
/// The manifest's envelope, checked as [`craft_pcraft_format::read_manifest`] checks it: the
/// size, nesting and item count with [`craft_pcraft_format::check_manifest`] and PhotoCraft's
/// own nesting limit, a manifest that is not a JSON object refused, the version read first (0 or
/// newer than [`FORMAT_VERSION`] refused), and a preview that names an entry other than [`THUMB`]
/// or [`COMPOSITE`] refused. A member that appears twice is refused.
/// or [`COMPOSITE`] refused. `full_composite` may name only [`FULL_COMPOSITE`]. A member that
/// appears twice is refused.
fn read<D: DeserializeOwned>(json: &[u8], budget: &Budget) -> Result<Envelope<D>, Error> {
read_with_items(json, budget, format::MAX_MANIFEST_ITEMS)
}
@@ -135,7 +138,9 @@ fn read_with_items<D: DeserializeOwned>(json: &[u8], budget: &Budget, max_items:
}
budget.check_cancelled()?;
let Object(m) = parse::<Object<Envelope<D>>>(json, budget)?;
for (member, entry, expected) in [("thumbnail", &m.thumbnail, THUMB), ("composite", &m.composite, COMPOSITE)] {
for (member, entry, expected) in
[("thumbnail", &m.thumbnail, THUMB), ("composite", &m.composite, COMPOSITE), ("full_composite", &m.full_composite, FULL_COMPOSITE)]
{
if let Some(entry) = entry
&& entry != expected
{
@@ -150,11 +155,13 @@ pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option<Str
stamp(&json, budget)
}
/// Extract declared previews, retaining whether the selected composite names the full member.
pub(crate) fn renditions(bytes: &[u8], wanted: Option<RenditionKind>, budget: &mut Budget) -> Result<Vec<Rendition>, Error> {
let (zip, json) = manifest(bytes, budget)?;
let m: Envelope<IgnoredAny> = read(&json, budget)?;
let composite_is_full = m.full_composite.is_some();
let mut out = vec![];
for (kind, path) in [(RenditionKind::Thumbnail, m.thumbnail), (RenditionKind::Composite, m.composite)] {
for (kind, path) in [(RenditionKind::Thumbnail, m.thumbnail), (RenditionKind::Composite, m.full_composite.or(m.composite))] {
if wanted.is_some_and(|wanted| wanted != kind) {
continue;
}
@@ -163,7 +170,7 @@ pub(crate) fn renditions(bytes: &[u8], wanted: Option<RenditionKind>, budget: &m
zip.find(&path, budget.control)?.ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?;
let max = budget.limits.max_entry;
let bytes = zip.read(entry, max, budget, &path)?;
out.push(Rendition { kind, mime: "image/png".into(), bytes });
out.push(Rendition { kind, mime: "image/png".into(), bytes, is_full_composite: kind == RenditionKind::Composite && composite_is_full });
}
Ok(out)
}
+1 -1
View File
@@ -560,7 +560,7 @@ pub(crate) fn renditions(bytes: &[u8], wanted: Option<RenditionKind>, budget: &m
None => inline_bytes(&m, budget, what)?,
};
budget.check_cancelled()?;
Ok(Rendition { kind, mime: m.mime, bytes })
Ok(Rendition { kind, mime: m.mime, bytes, is_full_composite: false })
})
.collect()
}
+145
View File
@@ -380,6 +380,7 @@ fn vectorcraft_outline_has_artboards_and_top_level_layers() {
}
}
/// 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));
@@ -387,6 +388,150 @@ fn pcraft_previews_stored_and_deflated_with_the_manifest_last() {
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]
+4
View File
@@ -22,6 +22,7 @@ A bundle is a ZIP archive or a directory with these entries:
| `blobs/<blake3>.zst` | `blob_path(hash)` | binary data, zstd |
| `thumb.png` | `THUMB` | optional thumbnail |
| `composite/preview.png` | `COMPOSITE` | optional flattened preview |
| `composite/full.png` | `FULL_COMPOSITE` | optional full-canvas flattened image |
| `save.lock` | `SAVE_LOCK` | directory bundles only: held while a save runs (empty) |
`<blake3>` is the BLAKE3 hash of the uncompressed bytes as 64 lowercase hex digits.
@@ -51,6 +52,9 @@ let manifest: Manifest<MyDocument> = craft_pcraft_format::read_manifest(&manifes
than `thumb.png` or a `composite` other than `composite/preview.png` gives `Corrupt`.
`Manifest` has no `Deserialize` and is `#[non_exhaustive]`: `Manifest::new(generator,
document)` builds one at `FORMAT_VERSION` without previews.
- `Manifest::full_composite` optionally names `composite/full.png`. A missing or null member
reads as `None`; any other path gives `Corrupt`. When absent, serialization omits this member,
preserving the bytes of manifests without a full-canvas image. It does not change the format version.
- `TileRef` has `tx`, `ty` and `hash`, and is `#[non_exhaustive]`: `TileRef::new(tx, ty, hash)`
builds one. `TileRef::path` gives its entry, or `None` when the hash is not valid.
- `check_manifest(json, &Limits)` checks the size, the nesting and the item count in one pass
+9 -1
View File
@@ -8,6 +8,7 @@
//! blobs/<blake3>.zst content-addressed binary data
//! thumb.png optional thumbnail
//! composite/preview.png optional flattened preview
//! composite/full.png optional full-canvas flattened image
//! save.lock directory bundles only: held while a save runs (empty)
//! ```
//!
@@ -64,6 +65,8 @@ pub const MANIFEST: &str = "manifest.json";
pub const THUMB: &str = "thumb.png";
/// The flattened preview entry, a PNG, in [`COMPOSITE_DIR`].
pub const COMPOSITE: &str = "composite/preview.png";
/// The optional full-canvas flattened image, a PNG, in [`COMPOSITE_DIR`].
pub const FULL_COMPOSITE: &str = "composite/full.png";
/// The file a directory save holds an exclusive lock on while it runs. ZIP bundles do not have it.
pub const SAVE_LOCK: &str = "save.lock";
@@ -94,6 +97,8 @@ pub fn is_valid_hash(hash: &str) -> bool {
/// absent. [`read_manifest`] reads one: a missing `thumbnail` or `composite` reads as `None`,
/// members the type does not name are skipped, and a preview that names any entry other than
/// [`THUMB`] or [`COMPOSITE`] is refused.
/// `full_composite` names [`FULL_COMPOSITE`] when present and is omitted on serialization
/// when absent, preserving the bytes of manifests without it.
///
/// The struct is `#[non_exhaustive]`: build one with [`Manifest::new`] and set the previews.
///
@@ -117,13 +122,16 @@ pub struct Manifest<D> {
pub thumbnail: Option<String>,
/// The flattened preview entry, [`COMPOSITE`]. [`read_manifest`] refuses any other name.
pub composite: Option<String>,
/// The full-canvas flattened image, [`FULL_COMPOSITE`]. [`read_manifest`] refuses any other name.
#[serde(skip_serializing_if = "Option::is_none")]
pub full_composite: Option<String>,
}
impl<D> Manifest<D> {
/// A manifest at [`FORMAT_VERSION`] written by `generator`, holding `document`, with no
/// previews.
pub fn new(generator: impl Into<String>, document: D) -> Self {
Manifest { format_version: FORMAT_VERSION, generator: generator.into(), document, thumbnail: None, composite: None }
Manifest { format_version: FORMAT_VERSION, generator: generator.into(), document, thumbnail: None, composite: None, full_composite: None }
}
}
+7 -1
View File
@@ -2,7 +2,7 @@ use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::{COMPOSITE, Error, Limits, Manifest, Step, THUMB, migrate, parse_manifest};
use crate::{COMPOSITE, Error, FULL_COMPOSITE, Limits, Manifest, Step, THUMB, migrate, parse_manifest};
/// Reads manifest JSON into a [`Manifest`] whose document is `D`.
///
@@ -17,6 +17,8 @@ use crate::{COMPOSITE, Error, Limits, Manifest, Step, THUMB, migrate, parse_mani
/// `document`, which is reported after the members that are present, in the order serde's derived
/// `Deserialize` reports them. A `thumbnail` other than [`THUMB`] or a `composite` other than
/// [`COMPOSITE`] is [`Error::Corrupt`], checked after the members are read.
/// A missing or null `full_composite` reads as `None`; any path other than [`FULL_COMPOSITE`]
/// is [`Error::Corrupt`], and a value of the wrong type is [`Error::Json`].
pub fn read_manifest<D: DeserializeOwned, E: From<Error>>(json: &[u8], limits: &Limits, steps: &[Step<E>], target: u32) -> Result<Manifest<D>, E> {
read_manifest_with(json, limits, steps, target, |document| serde_json::from_value(document).map_err(|e| E::from(Error::Json(e))))
}
@@ -37,6 +39,7 @@ pub fn read_manifest_with<D, E: From<Error>>(
return Err(Error::Corrupt("the manifest is not a JSON object".to_owned()).into());
};
let (mut format_version, mut generator, mut doc, mut thumbnail, mut composite) = (None, None, None, None, None);
let mut full_composite = None;
for (name, member) in members {
match name.as_str() {
"format_version" => format_version = Some(u32::deserialize(member).map_err(Error::Json)?),
@@ -48,6 +51,7 @@ pub fn read_manifest_with<D, E: From<Error>>(
}
"thumbnail" => thumbnail = Option::<String>::deserialize(member).map_err(Error::Json)?,
"composite" => composite = Option::<String>::deserialize(member).map_err(Error::Json)?,
"full_composite" => full_composite = Option::<String>::deserialize(member).map_err(Error::Json)?,
_ => {}
}
}
@@ -58,9 +62,11 @@ pub fn read_manifest_with<D, E: From<Error>>(
document: doc.ok_or_else(|| missing("document"))?,
thumbnail,
composite,
full_composite,
};
check_preview("thumbnail", manifest.thumbnail.as_deref(), THUMB)?;
check_preview("composite", manifest.composite.as_deref(), COMPOSITE)?;
check_preview("full_composite", manifest.full_composite.as_deref(), FULL_COMPOSITE)?;
Ok(manifest)
}
+14 -2
View File
@@ -1,6 +1,6 @@
use craft_pcraft_format::{
BLOBS_DIR, COMPOSITE, COMPOSITE_DIR, EXTENSION, FORMAT_VERSION, Limits, MANIFEST, Manifest, OBJECT_SUFFIX, SAVE_LOCK, Step, THUMB, TILES_DIR,
TileRef, blob_path, is_valid_hash, tile_path,
BLOBS_DIR, COMPOSITE, COMPOSITE_DIR, EXTENSION, FORMAT_VERSION, FULL_COMPOSITE, Limits, MANIFEST, Manifest, OBJECT_SUFFIX, SAVE_LOCK, Step,
THUMB, TILES_DIR, TileRef, blob_path, is_valid_hash, tile_path,
};
use serde::{Deserialize, Serialize};
@@ -85,6 +85,18 @@ fn written_manifests_read_back_equal() {
}
}
#[test]
fn an_optional_full_composite_round_trips_without_changing_older_manifests() {
let mut manifest = manifest(true);
assert_eq!(manifest.full_composite, None);
manifest.full_composite = Some(FULL_COMPOSITE.into());
let text = serde_json::to_string_pretty(&manifest).unwrap();
assert!(text.ends_with("\"full_composite\": \"composite/full.png\"\n}"));
assert_eq!(read::<Doc>(&text).unwrap(), manifest);
manifest.full_composite = None;
assert_eq!(serde_json::to_string_pretty(&manifest).unwrap(), WITH_PREVIEWS);
}
#[test]
fn a_new_manifest_is_at_the_current_version_without_previews() {
let m = Manifest::new(String::from("g"), 5);
+19 -1
View File
@@ -1,4 +1,6 @@
use craft_pcraft_format::{COMPOSITE, Error, FORMAT_VERSION, Limits, Manifest, Step, THUMB, TileRef, read_manifest, read_manifest_with};
use craft_pcraft_format::{
COMPOSITE, Error, FORMAT_VERSION, FULL_COMPOSITE, Limits, Manifest, Step, THUMB, TileRef, read_manifest, read_manifest_with,
};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -35,6 +37,22 @@ fn a_manifest_reads_with_its_previews_and_document() {
assert_eq!(m.composite, None);
}
#[test]
fn a_full_composite_has_one_fixed_path_and_may_be_absent() {
for full in [Value::Null, json!(FULL_COMPOSITE)] {
let json = json!({"format_version": 1, "generator": "g", "document": {"name": "a"}, "full_composite": full});
assert_eq!(read(json.to_string().as_bytes()).unwrap().full_composite.as_deref(), full.as_str());
}
for path in ["../full.png", "composite/preview.png", "/composite/full.png", "composite/FULL.png"] {
let json = json!({"format_version": 1, "generator": "g", "document": {"name": "a"}, "full_composite": path});
assert!(matches!(read(json.to_string().as_bytes()), Err(Error::Corrupt(_))), "{path}");
}
for full in [json!(1), json!([]), json!({})] {
let json = json!({"format_version": 1, "generator": "g", "document": {"name": "a"}, "full_composite": full});
assert!(matches!(read(json.to_string().as_bytes()), Err(Error::Json(_))));
}
}
#[test]
fn a_newer_manifest_is_too_new_and_names_its_writer() {
let json = br#"{"format_version":2,"generator":"photocraft-format 9.0.0","document":{"name":"a"}}"#;