From e833d736b3a3b025d5cbe6f266be75008bbc0db3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 20:39:03 -0800 Subject: [PATCH 1/4] craft-doc: prefer the optional full-size PhotoCraft composite --- crates/doc/README.md | 8 ++++- crates/doc/src/lib.rs | 4 +-- crates/doc/src/pcraft.rs | 15 ++++++---- crates/doc/tests/formats.rs | 41 ++++++++++++++++++++++++++ crates/pcraft-format/README.md | 4 +++ crates/pcraft-format/src/lib.rs | 10 ++++++- crates/pcraft-format/src/read.rs | 8 ++++- crates/pcraft-format/tests/manifest.rs | 16 ++++++++-- crates/pcraft-format/tests/read.rs | 20 ++++++++++++- 9 files changed, 113 insertions(+), 13 deletions(-) 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"}}"#; From 616d5b9ae09f1bb80dd3c9a69011593346251190 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 08:18:55 -0800 Subject: [PATCH 2/4] craft-doc: test oversized unused composite previews --- crates/doc/tests/formats.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/doc/tests/formats.rs b/crates/doc/tests/formats.rs index ba4392ea..a739f813 100644 --- a/crates/doc/tests/formats.rs +++ b/crates/doc/tests/formats.rs @@ -270,6 +270,14 @@ fn pcraft_prefers_the_full_size_composite_without_reading_the_preview() { 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", "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)]); assert_eq!(renditions(&pc, &limits()).unwrap()[0].bytes, full); From af2df05a3550025938973ad88ad9c57bf1a6241f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 10:40:31 -0800 Subject: [PATCH 3/4] craft-doc: retain full composite provenance in renditions --- crates/doc/README.md | 6 ++- crates/doc/src/lib.rs | 5 +++ crates/doc/src/pcraft.rs | 4 +- crates/doc/src/vectorcraft.rs | 3 +- crates/doc/tests/formats.rs | 84 ++++++++++++++++++++++++++++++++++- 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/crates/doc/README.md b/crates/doc/README.md index e1696264..e1e4b9b8 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -42,7 +42,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, 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. - `outline(bytes, &Limits) -> Result`: `canvas`, `pages` and `layers`. - `Error` is `Unrecognized`, `Malformed`, `TooNew`, `Unsupported` or `LimitExceeded`. A file whose version is newer than the crate reads gives `TooNew` from `renditions` and `outline`. A diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2ba380d9..8cc41a11 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -181,6 +181,11 @@ pub struct Rendition { /// rendition), or `image/png` for PhotoCraft's previews. pub mime: String, pub bytes: Vec, + /// 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. diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs index a452bb8e..8198f54a 100644 --- a/crates/doc/src/pcraft.rs +++ b/crates/doc/src/pcraft.rs @@ -136,16 +136,18 @@ pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option Result, Error> { let (zip, json) = manifest(bytes, budget)?; let m: Envelope = 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.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; 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) } diff --git a/crates/doc/src/vectorcraft.rs b/crates/doc/src/vectorcraft.rs index 83b06750..06a43b34 100644 --- a/crates/doc/src/vectorcraft.rs +++ b/crates/doc/src/vectorcraft.rs @@ -424,6 +424,7 @@ pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option Result, Error> { let Unpacked { json, tail } = unpack(bytes, budget)?; let head = stamp::(&json, budget, false)?; @@ -452,7 +453,7 @@ pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result 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() } diff --git a/crates/doc/tests/formats.rs b/crates/doc/tests/formats.rs index a739f813..69ccb227 100644 --- a/crates/doc/tests/formats.rs +++ b/crates/doc/tests/formats.rs @@ -247,6 +247,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)); @@ -254,8 +255,10 @@ 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); @@ -269,6 +272,9 @@ fn pcraft_prefers_the_full_size_composite_without_reading_the_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(); @@ -280,7 +286,9 @@ fn pcraft_prefers_the_full_size_composite_without_reading_the_preview() { } 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 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. @@ -289,6 +297,76 @@ fn pcraft_prefers_the_full_size_composite_without_reading_the_preview() { 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"); +} + +/// 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::>(), [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"#] { @@ -302,7 +380,9 @@ fn pcraft_refuses_invalid_or_missing_full_size_composites() { 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); + let selected = renditions(&pc, &limits()).unwrap().pop().unwrap(); + assert_eq!(selected.bytes, preview); + assert!(!selected.is_full_composite); } #[test] From c3456fbf7851bc44d554d8b5ab8d43ab7f69f03e Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:45:26 +0100 Subject: [PATCH 4/4] Update budget test decoders to the buffered input type --- crates/doc/src/vectorcraft.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/vectorcraft.rs b/crates/doc/src/vectorcraft.rs index 6007824f..1f18c7e6 100644 --- a/crates/doc/src/vectorcraft.rs +++ b/crates/doc/src/vectorcraft.rs @@ -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); }