mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 04:52:13 +00:00
Merge pull request #548 from dotCooCoo/feat/doc-full-size-composite
craft-doc: read the optional full-size PhotoCraft composite
This commit is contained in:
10 files changed
+234
-19
No files matched your search
+12
-2
@@ -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
|
||||
@@ -75,7 +79,7 @@ let outline = craft_doc::outline(&bytes, &limits)?;
|
||||
| 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 |
|
||||
|
||||
@@ -87,6 +91,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),
|
||||
|
||||
@@ -174,7 +174,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,
|
||||
}
|
||||
|
||||
@@ -187,6 +187,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.
|
||||
@@ -294,7 +299,7 @@ pub fn info(bytes: &[u8], limits: &Limits) -> 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, limits)
|
||||
|
||||
@@ -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};
|
||||
@@ -96,13 +96,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)
|
||||
}
|
||||
@@ -122,7 +125,9 @@ fn read_with_items<D: DeserializeOwned>(json: &[u8], budget: &Budget, max_items:
|
||||
return Err(too_new(Kind::PhotoCraft, version));
|
||||
}
|
||||
let Object(m) = parse::<Object<Envelope<D>>>(json, budget.limits)?;
|
||||
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
|
||||
{
|
||||
@@ -137,11 +142,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;
|
||||
}
|
||||
@@ -149,7 +156,7 @@ pub(crate) fn renditions(bytes: &[u8], wanted: Option<RenditionKind>, budget: &m
|
||||
let entry = zip.find(&path).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)
|
||||
}
|
||||
|
||||
@@ -511,7 +511,7 @@ pub(crate) fn renditions(bytes: &[u8], wanted: Option<RenditionKind>, budget: &m
|
||||
Some(_) => blobs.next().flatten().ok_or_else(|| malformed(format!("the file ends before its {what}")))?,
|
||||
None => m.bytes_at_most(&[], u64::MAX).ok_or_else(|| malformed(format!("the {what} is not valid base64")))?,
|
||||
};
|
||||
Ok(Rendition { kind, mime: m.mime, bytes })
|
||||
Ok(Rendition { kind, mime: m.mime, bytes, is_full_composite: false })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -632,7 +632,7 @@ mod tests {
|
||||
fn duplicate_copy_cost_is_checked_before_the_stream_is_read() {
|
||||
let limits = crate::Limits { max_total: 12, ..Default::default() };
|
||||
let mut budget = Budget::new(&limits);
|
||||
let tail = Tail::Stream { dec: Box::new(GzDecoder::new(&b"not a gzip stream"[..])), read: vec![] };
|
||||
let tail = Tail::Stream { dec: Box::new(gzip_decoder(&b"not a gzip stream"[..], usize::MAX)), read: vec![] };
|
||||
let result = tail.ranges(&[[0, 8], [0, 8]], &mut budget);
|
||||
assert!(matches!(result, Err(Error::LimitExceeded(_))), "{result:?}");
|
||||
assert_eq!(budget.left(), 12);
|
||||
@@ -642,7 +642,7 @@ mod tests {
|
||||
fn duplicate_copies_can_use_exactly_the_remaining_budget() {
|
||||
let limits = crate::Limits { max_total: 16, ..Default::default() };
|
||||
let mut budget = Budget::new(&limits);
|
||||
let tail = Tail::Stream { dec: Box::new(GzDecoder::new(&b"not a gzip stream"[..])), read: b"12345678".to_vec() };
|
||||
let tail = Tail::Stream { dec: Box::new(gzip_decoder(&b"not a gzip stream"[..], usize::MAX)), read: b"12345678".to_vec() };
|
||||
let result = tail.ranges(&[[0, 8], [0, 8]], &mut budget).unwrap();
|
||||
assert_eq!(result, [Some(b"12345678".to_vec()), Some(b"12345678".to_vec())]);
|
||||
assert_eq!(budget.left(), 0);
|
||||
@@ -652,7 +652,7 @@ mod tests {
|
||||
fn rendition_copy_size_overflow_is_rejected_before_reading() {
|
||||
let limits = crate::Limits { max_total: u64::MAX, ..Default::default() };
|
||||
let mut budget = Budget::new(&limits);
|
||||
let tail = Tail::Stream { dec: Box::new(GzDecoder::new(&b"not a gzip stream"[..])), read: vec![] };
|
||||
let tail = Tail::Stream { dec: Box::new(gzip_decoder(&b"not a gzip stream"[..], usize::MAX)), read: vec![] };
|
||||
assert!(matches!(tail.ranges(&[[0, u64::MAX], [0, 1]], &mut budget), Err(Error::LimitExceeded(_))));
|
||||
assert_eq!(budget.left(), u64::MAX);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
//! ```
|
||||
//!
|
||||
@@ -62,6 +63,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";
|
||||
|
||||
@@ -92,6 +95,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.
|
||||
///
|
||||
@@ -115,13 +120,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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}}"#;
|
||||
|
||||
Reference in new issue
Block a user