Merge pull request #546 from dotCooCoo/feat/doc-selected-rendition

craft-doc: read a selected rendition without extracting other payloads
This commit is contained in:
FredApps authored and GitHub committed 2026-11-07 15:36:55 +01:00
commit af6c186f1e
5 files changed
+261 -27

No files matched your search

+9
View File
@@ -43,6 +43,15 @@ let outline = craft_doc::outline(&bytes, &limits)?;
reads.
- `renditions(bytes, &Limits) -> Result<Vec<Rendition>, Error>`: each `Rendition` has a `kind`
(`Pdf`, `Thumbnail`, `Composite`), a `mime` and the `bytes`.
- `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
PDF, and a missing thumbnail ZIP entry does not prevent reading the composite. A compressed
member's offset still counts against `max_total`, including bytes traversed to reach it.
Normal gzip reads remain buffered. If read-ahead reaches a damaged appended member, the
metadata read retries with exact compressed input and output through the JSON marker;
already-counted prefix bytes are replayed without retaining or charging another copy.
JSON and selected-payload limits still apply, and requesting the damaged member still fails.
- `outline(bytes, &Limits) -> Result<Outline, Error>`: `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
+15 -2
View File
@@ -5,6 +5,7 @@
//! - [`sniff`] tells which app wrote a file, from its first bytes.
//! - [`info`] reads the format version and the writer the file names.
//! - [`renditions`] returns the previews and the PDF a file carries.
//! - [`rendition`] reads just one kind of preview or PDF.
//! - [`outline`] returns the canvas, the pages (artboards or compositions) and the layers.
//! - `pcraft_layer_rgba`, with the feature `pcraft-pixels`, decodes a PhotoCraft layer's pixels
//! and its layer mask.
@@ -291,14 +292,26 @@ pub fn info(bytes: &[u8], limits: &Limits) -> Result<Info, Error> {
/// Create PDF-Compatible File; a `.pcraft` bundle has the `thumbnail` and `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)
}
fn read_renditions(bytes: &[u8], wanted: Option<RenditionKind>, limits: &Limits) -> Result<Vec<Rendition>, Error> {
let mut budget = Budget::new(limits);
match sniff(bytes).ok_or(Error::Unrecognized)? {
Kind::PhotoCraft => pcraft::renditions(bytes, &mut budget),
Kind::VectorCraft => vectorcraft::renditions(bytes, &mut budget),
Kind::PhotoCraft => pcraft::renditions(bytes, wanted, &mut budget),
Kind::VectorCraft => vectorcraft::renditions(bytes, wanted, &mut budget),
Kind::EffectCraft => ecproj::renditions(bytes, &mut budget),
}
}
/// Read the requested rendition, or `None` when this format or document carries none of that
/// kind. Container metadata is still validated. Other rendition payloads are not decoded,
/// copied or charged against the entry budget; JSON and bytes traversed to reach the selected
/// compressed member still count against the usual limits.
pub fn rendition(bytes: &[u8], kind: RenditionKind, limits: &Limits) -> Result<Option<Rendition>, Error> {
Ok(read_renditions(bytes, Some(kind), limits)?.pop())
}
/// The document's canvas, pages and layers (see [`Outline`]).
pub fn outline(bytes: &[u8], limits: &Limits) -> Result<Outline, Error> {
let mut budget = Budget::new(limits);
+4 -1
View File
@@ -131,11 +131,14 @@ pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option<Str
stamp(&json, budget)
}
pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result<Vec<Rendition>, Error> {
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 mut out = vec![];
for (kind, path) in [(RenditionKind::Thumbnail, m.thumbnail), (RenditionKind::Composite, m.composite)] {
if wanted.is_some_and(|wanted| wanted != kind) {
continue;
}
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;
+99 -23
View File
@@ -33,7 +33,34 @@ use crate::{
const CHUNK: u64 = 64 << 10;
pub(crate) fn sniff(head: &[u8]) -> bool {
craft_vectorcraft_format::sniff(head)
if craft_vectorcraft_format::sniff(head) {
return true;
}
if !is_compressed(head) {
return false;
}
// A decoder can inspect broken compressed data past the requested output. Retry the
// bounded sniff with one compressed byte at a time, retaining any intact prefix.
let mut unpacked = Vec::new();
let head = head.get(..64 * 1024).unwrap_or(head);
let _ = gzip_decoder(head, 1).take(256).read_to_end(&mut unpacked);
!is_compressed(&unpacked) && craft_vectorcraft_format::sniff(&unpacked)
}
struct GzipInput<'a> {
bytes: &'a [u8],
step: usize,
}
impl Read for GzipInput<'_> {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
let len = out.len().min(self.step);
self.bytes.read(&mut out[..len])
}
}
fn gzip_decoder(bytes: &[u8], step: usize) -> GzDecoder<GzipInput<'_>> {
GzDecoder::new(GzipInput { bytes, step })
}
/// Where the JSON ends and the data after the marker starts, when `text` holds a NUL. JSON holds
@@ -60,7 +87,7 @@ enum Tail<'a> {
Slice(&'a [u8]),
/// Still in the gzip stream: `read` holds what was decompressed past the marker already, not
/// yet counted against the budget.
Stream { dec: Box<GzDecoder<&'a [u8]>>, read: Vec<u8> },
Stream { dec: Box<GzDecoder<GzipInput<'a>>>, read: Vec<u8> },
}
impl Tail<'_> {
@@ -137,14 +164,31 @@ fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result<Unpacked<'a>, Erro
budget.check_json(json.len() as u64, JSON)?;
return Ok(Unpacked { json: Cow::Borrowed(json), tail: Tail::Slice(tail) });
}
let mut dec = Box::new(GzDecoder::new(bytes));
let mut dec = Box::new(gzip_decoder(bytes, usize::MAX));
let mut step = CHUNK;
let mut buf = Vec::new();
let mut split: Option<(usize, usize)> = None;
let mut nul: Option<usize> = None;
loop {
let start = buf.len();
let want = CHUNK.min(budget.left().saturating_add(1));
let n = (&mut *dec).take(want).read_to_end(&mut buf).map_err(damaged)?;
let want = step.min(budget.left().saturating_add(1));
let n = match (&mut *dec).take(want).read_to_end(&mut buf) {
Ok(n) => n,
Err(_) if step > 1 => {
// Keep the fast path for intact files. On damage, replay the already-counted
// prefix and read only through the marker, with no compressed read-ahead.
let mut retry = Box::new(gzip_decoder(bytes, 1));
let skipped = std::io::copy(&mut (&mut *retry).take(start as u64), &mut std::io::sink()).map_err(damaged)?;
if skipped != start as u64 {
return Err(malformed("the compressed document ends before its previously read prefix"));
}
dec = retry;
buf.truncate(start);
step = 1;
continue;
}
Err(e) => return Err(damaged(e)),
};
// The JSON ends before the first NUL: until there is one, all of it is JSON. The marker
// may end in a later chunk than its first NUL.
if nul.is_none() {
@@ -345,8 +389,9 @@ impl<'de, T: Container> de::DeserializeSeed<'de> for Contained<T> {
/// members are read as [`craft_vectorcraft_format::File::read`] reads them: a missing or repeated
/// `format` or `version` is refused, and a `generator`, `preview` or `pdf` of the wrong shape
/// reads as `None` and a repeated one as its last value. When `IMAGES` is true, a missing or
/// repeated `document` is refused too; its value is skipped.
struct Stamp<const IMAGES: bool> {
/// repeated `document` is refused too; its value is skipped. `PREVIEW` and `PDF` choose which
/// image values to read; unrequested values are skipped without copying their strings.
struct Stamp<const IMAGES: bool, const PREVIEW: bool, const PDF: bool> {
format: String,
version: u32,
generator: Option<String>,
@@ -363,17 +408,17 @@ fn once<'de, A: MapAccess<'de>, T: Deserialize<'de>>(map: &mut A, slot: &mut Opt
Ok(())
}
impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp<IMAGES> {
impl<'de, const IMAGES: bool, const PREVIEW: bool, const PDF: bool> Deserialize<'de> for Stamp<IMAGES, PREVIEW, PDF> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct StampVisitor<const IMAGES: bool>;
impl<'de, const IMAGES: bool> Visitor<'de> for StampVisitor<IMAGES> {
type Value = Stamp<IMAGES>;
struct StampVisitor<const IMAGES: bool, const PREVIEW: bool, const PDF: bool>;
impl<'de, const IMAGES: bool, const PREVIEW: bool, const PDF: bool> Visitor<'de> for StampVisitor<IMAGES, PREVIEW, PDF> {
type Value = Stamp<IMAGES, PREVIEW, PDF>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a VectorCraft file's JSON object")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Stamp<IMAGES>, A::Error> {
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Stamp<IMAGES, PREVIEW, PDF>, A::Error> {
let (mut format, mut version, mut generator, mut preview, mut pdf) = (None, None, None, None, None);
let mut document: Option<IgnoredAny> = None;
while let Some(key) = map.next_key::<String>()? {
@@ -382,8 +427,8 @@ impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp<IMAGES> {
"version" => once(&mut map, &mut version, "version")?,
"document" if IMAGES => once(&mut map, &mut document, "document")?,
"generator" => generator = map.next_value::<Lenient<String>>()?.0,
"preview" if IMAGES => preview = map.next_value::<Lenient<Image>>()?.0,
"pdf" if IMAGES => pdf = map.next_value::<Lenient<Image>>()?.0,
"preview" if IMAGES && PREVIEW => preview = map.next_value::<Lenient<Image>>()?.0,
"pdf" if IMAGES && PDF => pdf = map.next_value::<Lenient<Image>>()?.0,
_ => {
map.next_value::<IgnoredAny>()?;
}
@@ -401,14 +446,18 @@ impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp<IMAGES> {
})
}
}
d.deserialize_map(StampVisitor::<IMAGES>)
d.deserialize_map(StampVisitor::<IMAGES, PREVIEW, PDF>)
}
}
/// The file's [`Stamp`], refused when the format is not VectorCraft's, and when the version is
/// newer than this crate reads unless `newer` is true.
fn stamp<const IMAGES: bool>(json: &[u8], budget: &Budget, newer: bool) -> Result<Stamp<IMAGES>, Error> {
let s: Stamp<IMAGES> = if newer { parse(json, budget.limits)? } else { parse_versioned(json, budget.limits, Kind::VectorCraft)? };
fn stamp<const IMAGES: bool, const PREVIEW: bool, const PDF: bool>(
json: &[u8],
budget: &Budget,
newer: bool,
) -> Result<Stamp<IMAGES, PREVIEW, PDF>, Error> {
let s: Stamp<IMAGES, PREVIEW, PDF> = if newer { parse(json, budget.limits)? } else { parse_versioned(json, budget.limits, Kind::VectorCraft)? };
match craft_vectorcraft_format::check(&s.format, s.version) {
Ok(()) => Ok(s),
Err(craft_vectorcraft_format::Error::Format(format)) => Err(malformed(format!("format is `{format}`"))),
@@ -420,16 +469,28 @@ fn stamp<const IMAGES: bool>(json: &[u8], budget: &Budget, newer: bool) -> Resul
pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option<String>), Error> {
let json = unpack(bytes, budget)?.json;
let s = stamp::<false>(&json, budget, true)?;
let s = stamp::<false, false, false>(&json, budget, true)?;
Ok((s.version, s.generator))
}
pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result<Vec<Rendition>, Error> {
/// Skip unrequested image fields during deserialization, including their inline base64 strings.
fn images<const PREVIEW: bool, const PDF: bool>(json: &[u8], budget: &Budget) -> Result<(Option<Image>, Option<Image>), Error> {
let head = stamp::<true, PREVIEW, PDF>(json, budget, false)?;
Ok((head.preview, head.pdf))
}
pub(crate) fn renditions(bytes: &[u8], wanted: Option<RenditionKind>, budget: &mut Budget) -> Result<Vec<Rendition>, Error> {
let Unpacked { json, tail } = unpack(bytes, budget)?;
let head = stamp::<true>(&json, budget, false)?;
let (preview, pdf) = match wanted {
None => images::<true, true>(&json, budget),
Some(RenditionKind::Thumbnail) => images::<true, false>(&json, budget),
Some(RenditionKind::Pdf) => images::<false, true>(&json, budget),
Some(RenditionKind::Composite) => images::<false, false>(&json, budget),
}?;
// A member without a MIME type gives no rendition: a preview may be PNG or JPEG.
let members: Vec<_> = [(RenditionKind::Thumbnail, head.preview, "preview"), (RenditionKind::Pdf, head.pdf, "PDF")]
let members: Vec<_> = [(RenditionKind::Thumbnail, preview, "preview"), (RenditionKind::Pdf, pdf, "PDF")]
.into_iter()
.filter(|(kind, _, _)| wanted.is_none_or(|wanted| wanted == *kind))
.filter_map(|(kind, m, what)| Some((kind, m.filter(|m| !m.mime.is_empty())?, what)))
.collect();
// Every size is checked before any data after the JSON is decompressed. A member after the
@@ -516,7 +577,7 @@ struct KindM {
pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result<Outline, Error> {
let json = unpack(bytes, budget)?.json;
stamp::<false>(&json, budget, false)?;
stamp::<false, false, false>(&json, budget, false)?;
let f: WithDocument = parse(&json, budget.limits)?;
let pages = f
.document
@@ -554,6 +615,21 @@ mod tests {
use crate::tests::{unread_array, unread_object};
use serde::de::value::{Error as ValueError, MapDeserializer, SeqDeserializer, StrDeserializer};
/// An ignored field must use IgnoredAny, which can skip this sentinel without asking it
/// to deserialize an image or materialize any strings.
#[test]
fn unrequested_images_are_skipped_before_their_strings_are_read() {
fn skipped<const PREVIEW: bool, const PDF: bool>(field: &str) {
let map = MapDeserializer::<_, ValueError>::new([(field, crate::tests::Unread), ("document", crate::tests::Unread)].into_iter());
let err = Stamp::<true, PREVIEW, PDF>::deserialize(map).err().unwrap();
assert!(err.to_string().contains("missing field `format`"), "{err}");
}
skipped::<false, true>("preview");
skipped::<true, false>("pdf");
skipped::<false, false>("preview");
skipped::<false, false>("pdf");
}
#[test]
fn an_image_of_the_wrong_shape_is_skipped_without_reading_it_whole() {
assert!(Lenient::<Image>::deserialize(unread_object(1000)).unwrap().0.is_none());
@@ -612,7 +688,7 @@ mod tests {
for shape in shapes {
let json = format!(r#"{{"format":"vectorcraft","version":4,"generator":{shape},"preview":{shape},"pdf":{shape},"document":0}}"#);
let file = craft_vectorcraft_format::File::<IgnoredAny>::read(json.as_bytes()).unwrap();
let stamp: Stamp<true> = crate::parse(json.as_bytes(), &crate::Limits::default()).unwrap();
let stamp: Stamp<true, true, true> = crate::parse(json.as_bytes(), &crate::Limits::default()).unwrap();
assert_eq!((&stamp.generator, &stamp.preview, &stamp.pdf), (&file.generator, &file.preview, &file.pdf), "{shape}");
}
}
+134 -1
View File
@@ -4,12 +4,145 @@
mod common;
use common::*;
use craft_doc::{Error, Kind, Limits, RenditionKind, info, outline, renditions, sniff};
use craft_doc::{Error, Kind, Limits, RenditionKind, info, outline, rendition, renditions, sniff};
fn limits() -> Limits {
Limits::default()
}
/// A PDF-only importer need not decode a broken preview, for inline or blob payloads.
#[test]
fn selected_rendition_ignores_other_payloads() {
let inline = vectorcraft_json(
3,
&format!(r#""preview":{{"mime":"image/png","data":"!bad!"}}, "pdf":{{"mime":"application/pdf","data":"{}"}}"#, base64(PDF)),
);
let mut blob = vectorcraft_json(
4,
&format!(r#""preview":{{"mime":"image/png","blob":[{},100]}}, "pdf":{{"mime":"application/pdf","blob":[0,{}]}}"#, PDF.len() + 100, PDF.len()),
)
.into_bytes();
blob.extend(BLOB_MAGIC);
blob.extend(PDF);
for bytes in [inline.into_bytes(), blob] {
for bytes in [bytes.clone(), gzip(&bytes)] {
assert!(renditions(&bytes, &limits()).is_err());
let selected = rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap().unwrap();
assert_eq!(selected.bytes, PDF);
assert!(rendition(&bytes, RenditionKind::Thumbnail, &limits()).is_err());
}
}
}
/// Stored DEFLATE blocks contain the intact prefix; the next block has reserved BTYPE=3.
fn gzip_with_broken_tail(prefix: &[u8]) -> Vec<u8> {
let mut bytes = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 255];
for chunk in prefix.chunks(u16::MAX as usize) {
let length = chunk.len() as u16;
bytes.push(0); // Non-final stored block.
bytes.extend(length.to_le_bytes());
bytes.extend((!length).to_le_bytes());
bytes.extend(chunk);
}
bytes.push(7); // Final block with an invalid block type.
bytes.extend([0; 8]);
bytes
}
#[test]
fn selected_inline_pdf_ignores_gzip_damage_after_its_json_marker() {
for padding in [0, 128 * 1024] {
let json = vectorcraft_json(
4,
&format!(r#""padding":"{}","preview":{},"pdf":{}"#, "x".repeat(padding), blob("image/png", 0, 100), inline("application/pdf", PDF)),
);
let mut prefix = json.as_bytes().to_vec();
prefix.extend(BLOB_MAGIC);
let bytes = gzip_with_broken_tail(&prefix);
let mut exact = limits();
exact.max_json = json.len() as u64;
exact.max_total = (prefix.len() + PDF.len()) as u64;
exact.max_entry = PDF.len() as u64;
assert_eq!(rendition(&bytes, RenditionKind::Pdf, &exact).unwrap().unwrap().bytes, PDF);
assert!(matches!(rendition(&bytes, RenditionKind::Thumbnail, &limits()), Err(Error::Malformed(_))));
assert!(matches!(renditions(&bytes, &limits()), Err(Error::Malformed(_))));
exact.max_total -= 1;
assert!(matches!(rendition(&bytes, RenditionKind::Pdf, &exact), Err(Error::LimitExceeded(_))));
exact.max_total += 1;
exact.max_json -= 1;
assert!(matches!(rendition(&bytes, RenditionKind::Pdf, &exact), Err(Error::LimitExceeded(_))));
// A broken stream without a complete marker still fails, even after a complete JSON.
assert!(matches!(rendition(&gzip_with_broken_tail(json.as_bytes()), RenditionKind::Pdf, &limits()), Err(Error::Malformed(_))));
}
}
#[test]
fn vectorcraft_sniff_does_not_unwrap_nested_gzip() {
let json = vectorcraft_json(4, "");
assert_eq!(sniff(&gzip(&gzip(json.as_bytes()))), None);
}
#[test]
fn selected_rendition_skips_missing_zip_payloads_and_enforces_its_own_limits() {
let manifest = pcraft_manifest(1, "[]", r#", "thumbnail":"thumb.png", "composite":"composite/preview.png""#);
let image = png(2, 2, 1);
let bytes = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/preview.png", &image)]);
assert!(renditions(&bytes, &limits()).is_err());
assert_eq!(rendition(&bytes, RenditionKind::Composite, &limits()).unwrap().unwrap().bytes, image);
assert!(rendition(&bytes, RenditionKind::Thumbnail, &limits()).is_err());
assert_eq!(rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap(), None);
let mut small = limits();
small.max_entry = PDF.len() as u64 - 1;
let vc = vectorcraft_json(3, &format!(r#""pdf":{{"mime":"application/pdf","data":"{}"}}"#, base64(PDF)));
assert!(matches!(rendition(vc.as_bytes(), RenditionKind::Pdf, &small), Err(Error::LimitExceeded(_))));
}
#[test]
fn selected_rendition_preserves_format_and_version_checks() {
assert_eq!(rendition(b"not a document", RenditionKind::Pdf, &limits()), Err(Error::Unrecognized));
assert!(matches!(rendition(vectorcraft_json(99, "").as_bytes(), RenditionKind::Pdf, &limits()), Err(Error::TooNew { .. })));
for bytes in [vectorcraft_json(4, "").into_bytes(), ecproj(2).into_bytes()] {
assert_eq!(rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap(), None);
}
}
/// Ignored output is not charged, but gzip still has to traverse bytes before the PDF.
#[test]
fn selected_rendition_counts_its_payload_and_the_compressed_prefix() {
let preview = vec![0; 128 * 1024];
for before in [false, true] {
let (preview_at, pdf_at) = if before { (0, preview.len()) } else { (PDF.len(), 0) };
let json = vectorcraft_json(
4,
&format!(
r#""preview":{{"mime":"image/png","blob":[{preview_at},{}]}},"pdf":{{"mime":"application/pdf","blob":[{pdf_at},{}]}}"#,
preview.len(),
PDF.len()
),
);
let mut bytes = json.as_bytes().to_vec();
bytes.extend(BLOB_MAGIC);
if before {
bytes.extend(&preview);
bytes.extend(PDF);
} else {
bytes.extend(PDF);
bytes.extend(&preview);
}
let mut small = limits();
small.max_entry = PDF.len() as u64;
small.max_total = (json.len() + BLOB_MAGIC.len() + PDF.len()) as u64;
assert!(matches!(renditions(&bytes, &small), Err(Error::LimitExceeded(_))));
assert_eq!(rendition(&bytes, RenditionKind::Pdf, &small).unwrap().unwrap().bytes, PDF);
let compressed = rendition(&gzip(&bytes), RenditionKind::Pdf, &small);
if before {
assert!(matches!(compressed, Err(Error::LimitExceeded(_))));
} else {
assert_eq!(compressed.unwrap().unwrap().bytes, PDF);
}
}
}
#[test]
fn sniff_tells_the_three_formats() {
let vc = vectorcraft_json(4, "");