diff --git a/crates/doc/README.md b/crates/doc/README.md index 03ee07e1..e1696264 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -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), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ed5daa36..2ba380d9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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 { /// 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, Error> { let mut budget = Budget::new(limits); diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs index bfbb53ff..a452bb8e 100644 --- a/crates/doc/src/pcraft.rs +++ b/crates/doc/src/pcraft.rs @@ -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 { thumbnail: Option, #[serde(default)] composite: Option, + #[serde(default)] + full_composite: Option, } /// 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(json: &[u8], budget: &Budget) -> Result, Error> { let mut limits = format::Limits::default(); limits.max_manifest_bytes = json.len(); @@ -116,7 +119,9 @@ fn read(json: &[u8], budget: &Budget) -> Result return Err(too_new(Kind::PhotoCraft, version)); } let Object(m) = parse::>>(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 = 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; diff --git a/crates/doc/tests/formats.rs b/crates/doc/tests/formats.rs index bc3e6b08..ba4392ea 100644 --- a/crates/doc/tests/formats.rs +++ b/crates/doc/tests/formats.rs @@ -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())]); diff --git a/crates/pcraft-format/README.md b/crates/pcraft-format/README.md index 29321df8..93cc0a39 100644 --- a/crates/pcraft-format/README.md +++ b/crates/pcraft-format/README.md @@ -22,6 +22,7 @@ A bundle is a ZIP archive or a directory with these entries: | `blobs/.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) | `` is the BLAKE3 hash of the uncompressed bytes as 64 lowercase hex digits. @@ -51,6 +52,9 @@ let manifest: Manifest = 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 diff --git a/crates/pcraft-format/src/lib.rs b/crates/pcraft-format/src/lib.rs index eb6482aa..a4e0cc10 100644 --- a/crates/pcraft-format/src/lib.rs +++ b/crates/pcraft-format/src/lib.rs @@ -8,6 +8,7 @@ //! blobs/.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 { pub thumbnail: Option, /// The flattened preview entry, [`COMPOSITE`]. [`read_manifest`] refuses any other name. pub composite: Option, + /// The full-canvas flattened image, [`FULL_COMPOSITE`]. [`read_manifest`] refuses any other name. + #[serde(skip_serializing_if = "Option::is_none")] + pub full_composite: Option, } impl Manifest { /// A manifest at [`FORMAT_VERSION`] written by `generator`, holding `document`, with no /// previews. pub fn new(generator: impl Into, 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 } } } diff --git a/crates/pcraft-format/src/read.rs b/crates/pcraft-format/src/read.rs index ab9b5167..8e0876f2 100644 --- a/crates/pcraft-format/src/read.rs +++ b/crates/pcraft-format/src/read.rs @@ -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>(json: &[u8], limits: &Limits, steps: &[Step], target: u32) -> Result, 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>( 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>( } "thumbnail" => thumbnail = Option::::deserialize(member).map_err(Error::Json)?, "composite" => composite = Option::::deserialize(member).map_err(Error::Json)?, + "full_composite" => full_composite = Option::::deserialize(member).map_err(Error::Json)?, _ => {} } } @@ -58,9 +62,11 @@ pub fn read_manifest_with>( 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) } diff --git a/crates/pcraft-format/tests/manifest.rs b/crates/pcraft-format/tests/manifest.rs index 550c829b..ff2c3013 100644 --- a/crates/pcraft-format/tests/manifest.rs +++ b/crates/pcraft-format/tests/manifest.rs @@ -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::(&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); diff --git a/crates/pcraft-format/tests/read.rs b/crates/pcraft-format/tests/read.rs index ab3a218e..d82d83b5 100644 --- a/crates/pcraft-format/tests/read.rs +++ b/crates/pcraft-format/tests/read.rs @@ -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"}}"#;