craft-doc: prefer the optional full-size PhotoCraft composite

This commit is contained in:
dotCooCoo committed 2026-11-02 20:39:03 -08:00
1 parent 6ae033e6c1
commit e833d736b3
9 files changed
+113 -13

No files matched your search

+7 -1
View File
@@ -55,7 +55,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 |
@@ -67,6 +67,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),
+2 -2
View File
@@ -168,7 +168,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,
}
@@ -288,7 +288,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> {
let mut budget = Budget::new(limits);
+10 -5
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};
@@ -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> {
let mut limits = format::Limits::default();
limits.max_manifest_bytes = json.len();
@@ -116,7 +119,9 @@ fn read<D: DeserializeOwned>(json: &[u8], budget: &Budget) -> Result<Envelope<D>
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
{
@@ -135,7 +140,7 @@ pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result<Vec<Rendit
let (zip, json) = manifest(bytes, budget)?;
let m: Envelope<IgnoredAny> = read(&json, budget)?;
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))] {
let Some(path) = path else { continue };
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;
+41
View File
@@ -256,6 +256,47 @@ fn pcraft_previews_stored_and_deflated_with_the_manifest_last() {
assert_eq!((r[1].kind, r[1].mime.as_str(), &r[1].bytes), (RenditionKind::Composite, "image/png", &composite));
}
#[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));
}
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)]);
assert_eq!(renditions(&pc, &limits()).unwrap()[0].bytes, full);
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(_))));
}
#[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)]);
assert_eq!(renditions(&pc, &limits()).unwrap()[0].bytes, preview);
}
#[test]
fn pcraft_without_previews_has_none() {
let pc = zip(&[store("manifest.json", pcraft_manifest(1, "[]", "").as_bytes())]);
+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)
//! ```
//!
@@ -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 }
}
}
+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"}}"#;