Merge pull request #1170 from dylanmtaylor/codex/designcraft-format-migration

Add reusable document bundle and SVG preflight APIs
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 04:03:23 -05:00
commit 7c56d282a2
15 files changed
+661 -17

No files matched your search

Generated
+22
View File
@@ -1336,6 +1336,16 @@ dependencies = [
name = "craft-docpath"
version = "0.1.0"
[[package]]
name = "craft-document-bundle"
version = "0.1.0"
dependencies = [
"craft-zip",
"serde_json",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "craft-docx"
version = "0.1.0"
@@ -1640,6 +1650,13 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-indd"
version = "0.1.0"
dependencies = [
"indd",
]
[[package]]
name = "craft-jmap"
version = "0.1.0"
@@ -4771,6 +4788,11 @@ version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "indd"
version = "0.1.0"
source = "git+https://github.com/storytold/indd-utils?rev=7f3060e5bce78d21ea70e0d5eacb96edce15dfbe#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe"
[[package]]
name = "indexmap"
version = "2.14.2"
+3
View File
@@ -39,6 +39,7 @@ craft-dav = { path = "crates/dav" }
craft-dialogs = { path = "crates/dialogs" }
craft-doc-binary = { path = "crates/doc-binary" }
craft-docx = { path = "crates/docx" }
craft-document-bundle = { path = "crates/document-bundle" }
craft-dxf = { path = "crates/dxf" }
craft-drawingml = { path = "crates/drawingml" }
craft-eps = { path = "crates/eps" }
@@ -65,6 +66,7 @@ craft-fig = { path = "crates/fig" }
craft-fonts-build = { path = "crates/fonts-build" }
craft-i18n = { path = "crates/i18n" }
craft-idml = { path = "crates/idml" }
craft-indd = { path = "crates/indd" }
craft-jmap = { path = "crates/jmap" }
craft-ical = { path = "crates/ical" }
craft-jpeg = { path = "crates/jpeg" }
@@ -129,6 +131,7 @@ craft-vectordrawable = { path = "crates/vectordrawable" }
craft-xls = { path = "crates/xls" }
craft-wordmodel = { path = "crates/wordmodel" }
craft-xlsx = { path = "crates/xlsx" }
indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" }
craft-xcf = { path = "crates/xcf" }
craft-xml = { path = "crates/xml" }
craft-zip = { path = "crates/zip" }
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "craft-document-bundle"
description = "A reusable JSON document bundle envelope over ZIP: metadata, previews, asset entries and legacy inline asset extraction."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-zip = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
+317
View File
@@ -0,0 +1,317 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! A JSON document bundle envelope over ZIP.
//!
//! Applications keep their document models. This crate owns the reusable container mechanics:
//! ordered mimetype, metadata, JSON model, previews, embedded asset paths, compression policy,
//! bounded reads and extraction of legacy base64 asset maps. Entry names and MIME type are
//! supplied by the format using the bundle.
use std::io::{Read, Seek};
use craft_zip::{Archive, Compression, Limits, Writer};
use serde_json::Value;
const MAX_ENTRIES: u64 = 100_000;
const JSON_LIMIT: u64 = 256 << 20;
const ASSET_LIMIT: u64 = 1 << 30;
const EXPANSION_LIMIT: u64 = 4 << 30;
/// Per-entry and total-expansion limits for an untrusted document bundle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ReadLimits {
/// Maximum expanded size of metadata and document JSON.
pub json_bytes: u64,
/// Maximum expanded size of one compressed asset. Stored assets are bounded by archive size.
pub compressed_asset_bytes: u64,
/// Maximum expansion beyond the bytes in the source archive.
pub expansion_bytes: u64,
}
impl Default for ReadLimits {
fn default() -> Self {
Self { json_bytes: JSON_LIMIT, compressed_asset_bytes: ASSET_LIMIT, expansion_bytes: EXPANSION_LIMIT }
}
}
/// Names and identity used by one JSON document bundle format.
#[derive(Clone, Copy, Debug)]
pub struct Spec<'a> {
/// Stored first entry and its payload.
pub mimetype_entry: &'a str,
pub mimetype: &'a str,
/// Metadata and document JSON member names.
pub metadata_entry: &'a str,
pub document_entry: &'a str,
/// Optional preview entry name.
pub preview_entry: &'a str,
/// Directory prefix for embedded assets, without a trailing slash.
pub assets_directory: &'a str,
/// Metadata value written in the `format` field.
pub format_name: &'a str,
/// Old single-file JSON field containing base64 assets keyed by decimal id.
pub legacy_assets_field: Option<&'a str>,
}
/// An embedded asset passed to [`write`].
#[derive(Clone, Copy, Debug)]
pub struct Asset<'a> {
/// Decimal asset id used in the archive entry path.
pub id: &'a str,
/// MIME type used to select the extension and compression policy.
pub mime: &'a str,
/// File bytes retained byte-for-byte.
pub data: &'a [u8],
}
/// A document and embedded payloads returned by [`read`].
#[derive(Clone, Debug, PartialEq)]
pub struct Package {
/// Parsed model JSON, after extracting legacy inline assets.
pub document: Value,
/// Format version declared by metadata, or 1 when absent.
pub version: u64,
/// Embedded asset bytes keyed by decimal asset id.
pub assets: Vec<(String, Vec<u8>)>,
}
/// Why a document bundle could not be read or written.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The bytes are not a bundle or legacy JSON document.
#[error("not a document bundle: {0}")]
NotBundle(String),
/// The bundle requires a newer reader.
#[error("document format {0} is newer than this reader supports")]
TooNew(u64),
/// A member could not be written.
#[error("could not write document bundle: {0}")]
Write(String),
/// A member exceeded a read limit or could not be read.
#[error("could not read document bundle: {0}")]
Read(String),
}
fn extension(mime: &str) -> &'static str {
match mime {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/tiff" => "tif",
"application/pdf" => "pdf",
"application/postscript" => "eps",
"image/svg+xml" => "svg",
_ => "bin",
}
}
/// Whether `bytes` are a ZIP bundle that contains the configured document member.
pub fn has_document(bytes: &[u8], spec: Spec<'_>) -> bool {
let Ok(archive) = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: 0 }) else { return false };
archive.contains(spec.document_entry)
}
/// Whether `prefix` starts with the configured stored mimetype entry.
pub fn starts_with_mimetype(prefix: &[u8], spec: Spec<'_>) -> bool {
craft_zip::stored_mimetype(prefix).is_some_and(|mime| mime == spec.mimetype.as_bytes())
}
/// Write a ZIP document bundle containing the serialized document, metadata, preview and assets.
pub fn write(document: &[u8], version: u32, generator: &str, preview: Option<&[u8]>, assets: &[Asset<'_>], spec: Spec<'_>) -> Result<Vec<u8>, Error> {
let mut writer = Writer::new();
writer.add(spec.mimetype_entry, spec.mimetype.as_bytes(), Compression::Stored).map_err(|e| Error::Write(e.to_string()))?;
let metadata = serde_json::json!({"format":spec.format_name, "version":version, "generator":generator});
let metadata = serde_json::to_vec(&metadata).map_err(|e| Error::Write(e.to_string()))?;
writer.add(spec.metadata_entry, &metadata, Compression::Deflated).map_err(|e| Error::Write(e.to_string()))?;
if let Some(preview) = preview {
writer.add(spec.preview_entry, preview, Compression::Stored).map_err(|e| Error::Write(e.to_string()))?;
}
writer.add(spec.document_entry, document, Compression::Deflated).map_err(|e| Error::Write(e.to_string()))?;
for asset in assets.iter().filter(|asset| !asset.data.is_empty()) {
let path = format!("{}/{}.{}", spec.assets_directory, asset.id, extension(asset.mime));
let compression =
if matches!(asset.mime, "image/png" | "image/jpeg" | "image/gif" | "image/webp") { Compression::Stored } else { Compression::Deflated };
writer.add(&path, asset.data, compression).map_err(|e| Error::Write(e.to_string()))?;
}
writer.finish().map_err(|e| Error::Write(e.to_string()))
}
/// Read a ZIP bundle or legacy JSON document within bounded member sizes.
///
/// Members under the configured assets directory whose id does not occur in the document's
/// `assets` object are never decompressed. Unreferenced data cannot consume the caller's memory.
pub fn read(bytes: &[u8], supported_version: u32, spec: Spec<'_>) -> Result<Package, Error> {
read_with_limits(bytes, supported_version, spec, ReadLimits::default())
}
/// Like [`read`], with caller-selected bounded-read limits.
pub fn read_with_limits(bytes: &[u8], supported_version: u32, spec: Spec<'_>, limits: ReadLimits) -> Result<Package, Error> {
if !bytes.starts_with(b"PK") {
return read_legacy(bytes, spec);
}
let source_size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
let mut archive = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: source_size.saturating_add(limits.expansion_bytes) })
.map_err(|e| Error::NotBundle(e.to_string()))?;
if archive.has_duplicate_names() {
return Err(Error::NotBundle("the archive has duplicate member names".into()));
}
let version = archive
.read(spec.metadata_entry, limits.json_bytes)
.map_err(|e| Error::Read(e.to_string()))?
.and_then(|metadata| serde_json::from_slice::<Value>(&metadata).ok())
.and_then(|metadata| metadata.get("version").and_then(Value::as_u64))
.unwrap_or(1);
if version > u64::from(supported_version) {
return Err(Error::TooNew(version));
}
let document_bytes = archive
.read(spec.document_entry, limits.json_bytes)
.map_err(|e| Error::Read(e.to_string()))?
.ok_or_else(|| Error::NotBundle(format!("missing {}", spec.document_entry)))?;
let document: Value = serde_json::from_slice(&document_bytes).map_err(|e| Error::NotBundle(e.to_string()))?;
let wanted: std::collections::HashSet<String> =
document.get("assets").and_then(Value::as_object).into_iter().flat_map(|assets| assets.keys().cloned()).collect();
let prefix = format!("{}/", spec.assets_directory);
let mut embedded = Vec::new();
for path in archive.names().map(|name| name.into_owned()).collect::<Vec<_>>() {
let Some(member) = path.strip_prefix(&prefix) else { continue };
let Some(id) = member.split('.').next() else { continue };
if !wanted.contains(id) || id.parse::<u64>().is_err() {
continue;
}
let stored = archive.is_stored(&path).unwrap_or(false);
let limit = if stored { source_size } else { limits.compressed_asset_bytes };
let data = archive.read(&path, limit).map_err(|e| Error::Read(e.to_string()))?;
if let Some(data) = data {
embedded.push((id.to_owned(), data));
}
}
Ok(Package { document, version, assets: embedded })
}
/// Read a preview with a fixed maximum size without decompressing the full bundle.
pub fn read_preview(bytes: &[u8], preview_entry: &str) -> Option<Vec<u8>> {
const PREVIEW_LIMIT: u64 = 4 << 20;
let mut archive = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: PREVIEW_LIMIT }).ok()?;
archive.read(preview_entry, PREVIEW_LIMIT).ok().flatten()
}
/// Read a bounded preview from a seekable file without loading the full bundle into memory.
pub fn read_preview_from<R: Read + Seek>(reader: R, preview_entry: &str) -> Option<Vec<u8>> {
const PREVIEW_LIMIT: u64 = 4 << 20;
let mut zip = zip::ZipArchive::new(reader).ok()?;
let entry = zip.by_name(preview_entry).ok()?;
if entry.size() > PREVIEW_LIMIT {
return None;
}
let mut preview = Vec::new();
entry.take(PREVIEW_LIMIT.saturating_add(1)).read_to_end(&mut preview).ok()?;
(u64::try_from(preview.len()).ok()? <= PREVIEW_LIMIT).then_some(preview)
}
fn read_legacy(bytes: &[u8], spec: Spec<'_>) -> Result<Package, Error> {
let mut document: Value = serde_json::from_slice(bytes).map_err(|e| Error::NotBundle(e.to_string()))?;
let mut assets = Vec::new();
let mut total = 0u64;
if let Some(field) = spec.legacy_assets_field {
if let Some(inline) = document.get(field).and_then(Value::as_object) {
for (id, value) in inline {
if id.parse::<u64>().is_ok()
&& let Some(encoded) = value.as_str()
{
let decoded_size = u64::try_from(encoded.len()).unwrap_or(u64::MAX).saturating_mul(3) / 4;
if decoded_size > ASSET_LIMIT {
return Err(Error::Read(format!("legacy asset {id} exceeds the {ASSET_LIMIT}-byte limit")));
}
total = total.saturating_add(decoded_size);
if total > EXPANSION_LIMIT {
return Err(Error::Read(format!("legacy assets exceed the {EXPANSION_LIMIT}-byte total limit")));
}
assets.push((id.clone(), decode_base64(encoded)));
}
}
}
if let Some(object) = document.as_object_mut() {
object.remove(field);
}
}
Ok(Package { document, version: 1, assets })
}
fn decode_base64(input: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(input.len().saturating_mul(3) / 4);
let (mut buffer, mut bits) = (0u32, 0u32);
for byte in input.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
_ => continue,
};
buffer = (buffer << 6) | u32::from(value);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buffer >> bits) as u8);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const SPEC: Spec<'static> = Spec {
mimetype_entry: "mimetype",
mimetype: "application/vnd.test+zip",
metadata_entry: "meta.json",
document_entry: "document.json",
preview_entry: "preview.png",
assets_directory: "assets",
format_name: "test",
legacy_assets_field: Some("assetData"),
};
#[test]
fn writes_and_reads_bundle_members() {
let payload = [1u8, 2, 3];
let bytes =
write(br#"{"assets":{"7":{}}}"#, 4, "Test 0.1", Some(b"png"), &[Asset { id: "7", mime: "image/png", data: &payload }], SPEC).unwrap();
assert!(has_document(&bytes, SPEC));
assert!(starts_with_mimetype(&bytes, SPEC));
let loaded = read(&bytes, 4, SPEC).unwrap();
assert_eq!(loaded.document, serde_json::json!({"assets":{"7":{}}}));
assert_eq!(loaded.version, 4);
assert_eq!(loaded.assets, [("7".to_owned(), payload.to_vec())]);
assert_eq!(read_preview(&bytes, SPEC.preview_entry).as_deref(), Some(&b"png"[..]));
}
#[test]
fn extracts_legacy_inline_assets() {
let loaded = read(br#"{"assetData":{"7":"AQID"},"name":"x"}"#, 4, SPEC).unwrap();
assert_eq!(loaded.document, serde_json::json!({"name":"x"}));
assert_eq!(loaded.assets, [("7".to_owned(), vec![1, 2, 3])]);
}
#[test]
fn refuses_newer_versions() {
let bytes = write(b"{}", 9, "future", None, &[], SPEC).unwrap();
assert_eq!(read(&bytes, 4, SPEC), Err(Error::TooNew(9)));
}
#[test]
fn stored_assets_use_source_size_and_deflated_assets_use_their_limit() {
let bytes = vec![7u8; 128];
let document = br#"{"assets":{"7":{}}}"#;
let limits = ReadLimits { json_bytes: 1 << 20, compressed_asset_bytes: 8, expansion_bytes: 1 << 20 };
let stored = write(document, 4, "test", None, &[Asset { id: "7", mime: "image/png", data: &bytes }], SPEC).unwrap();
let package = read_with_limits(&stored, 4, SPEC, limits).unwrap();
assert_eq!(package.assets, [("7".to_owned(), bytes.clone())]);
let compressed = write(document, 4, "test", None, &[Asset { id: "7", mime: "application/octet-stream", data: &bytes }], SPEC).unwrap();
assert!(read_with_limits(&compressed, 4, SPEC, limits).is_err());
}
}
+6
View File
@@ -166,6 +166,12 @@ pub fn blip_fill(src: &mut dyn Source, bf: &El) -> Option<PictureFill> {
{
pf.artistic = Some(crate::style::Artistic { effect, original });
}
pf.svg = blip.and_then(|b| b.child(Local("extLst"))).and_then(|extensions| {
extensions
.elements()
.filter(|extension| extension.local() == "ext" && extension.attr_local("uri") == Some("{96DAC541-7B7A-43D3-8B79-37D633B846F1}"))
.find_map(|extension| extension.child(Local("svgBlip")).and_then(|svg| media_ref(src, svg)))
});
if let Some(b) = blip {
for e in b.elements() {
match e.local() {
+3
View File
@@ -184,6 +184,9 @@ impl Default for PictureMode {
#[serde(default, rename_all = "camelCase")]
pub struct PictureFill {
pub media: MediaId,
/// Optional vector SVG version of `media`; the regular media remains the bitmap fallback.
#[serde(skip_serializing_if = "Option::is_none")]
pub svg: Option<MediaId>,
/// Source crop as fractions of the image (left, top, right, bottom); negative values pad.
pub crop: [f64; 4],
pub mode: PictureMode,
+8
View File
@@ -107,6 +107,7 @@ pub fn blip_fill(w: &mut Writer, out: &mut dyn Sink, tag: &str, pf: &PictureFill
w.tag(tag).attr("rotWithShape", if pf.upright { "0" } else { "1" }).open();
let adj = &pf.adjust;
let has_kids = pf.alpha.is_some()
|| pf.svg.is_some()
|| adj.brightness != 0.0
|| adj.contrast != 0.0
|| adj.grayscale
@@ -168,6 +169,13 @@ pub fn blip_fill(w: &mut Writer, out: &mut dyn Sink, tag: &str, pf: &PictureFill
w.close();
w.close();
}
if let Some(svg) = pf.svg.and_then(|id| out.media_rel(id, "image")) {
w.open("a:extLst", &[]);
w.tag("a:ext").attr("uri", "{96DAC541-7B7A-43D3-8B79-37D633B846F1}").open();
w.tag("asvg:svgBlip").attr("xmlns:asvg", "http://schemas.microsoft.com/office/drawing/2016/SVG/main").attr("r:embed", &svg).empty();
w.close();
w.close();
}
w.close();
} else {
w.tag("a:blip").list(a).empty();
+2
View File
@@ -19,10 +19,12 @@ pub mod ascii85;
mod container;
mod dsc;
mod preview;
mod rectangles;
pub use container::{DOS_HEADER, DOS_MAGIC, Sections, sections, wrap};
pub use dsc::{Comments, MAX_HEADER, MAX_TRAILER};
pub use preview::{Bitmap, MAX_EPSI, epsi};
pub use rectangles::{MAX_SIMPLE_RECTANGLE_SAVE_DEPTH, MAX_SIMPLE_RECTANGLE_TOKENS, MAX_SIMPLE_RECTANGLES, Rectangle, simple_rectangles};
use std::fmt;
+136
View File
@@ -0,0 +1,136 @@
//! A bounded subset of PostScript used for simple EPS previews.
use std::collections::HashMap;
/// A solid rectangle drawn by a simple EPS program.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rectangle {
/// Left edge in PostScript points.
pub x: f64,
/// Bottom edge in PostScript points.
pub y: f64,
/// Width in PostScript points.
pub width: f64,
/// Height in PostScript points.
pub height: f64,
/// Straight sRGB RGBA.
pub color: [u8; 4],
}
pub const MAX_SIMPLE_RECTANGLES: usize = 200_000;
pub const MAX_SIMPLE_RECTANGLE_TOKENS: usize = 2_000_000;
pub const MAX_SIMPLE_RECTANGLE_SAVE_DEPTH: usize = 64;
/// Simple PostScript tokens without comments, with braces kept as individual tokens.
fn tokens(text: &str) -> impl Iterator<Item = &str> {
text.split(['\n', '\r']).flat_map(|line| {
line.split('%').next().unwrap_or("").split_whitespace().flat_map(|word| word.split_inclusive(['{', '}'])).flat_map(|piece| {
let (body, brace) = match piece.char_indices().last() {
Some((at, '{' | '}')) => (piece.get(..at).unwrap_or(""), piece.get(at..)),
_ => (piece, None),
};
[Some(body).filter(|body| !body.is_empty()), brace].into_iter().flatten()
})
})
}
/// Read solid rectangle fills and their sRGB colours from the restricted PostScript subset used
/// by simple EPS previews. None means the program uses an unsupported operator or exceeds a
/// fixed work bound. The function never executes PostScript.
pub fn simple_rectangles(ps: &[u8]) -> Option<Vec<Rectangle>> {
let text = std::str::from_utf8(ps).ok()?;
let mut aliases: HashMap<String, String> = HashMap::new();
let mut stack: Vec<f64> = Vec::new();
let mut color = [0u8, 0, 0, 255];
let mut saved: Vec<[u8; 4]> = Vec::new();
let mut rectangles = Vec::new();
let mut input = tokens(text);
let mut count = 0usize;
while let Some(token) = input.next() {
count += 1;
if count > MAX_SIMPLE_RECTANGLE_TOKENS {
return None;
}
if let Ok(value) = token.parse::<f64>() {
if !value.is_finite() {
return None;
}
stack.push(value);
continue;
}
if let Some(name) = token.strip_prefix('/') {
let mut body: Vec<&str> = Vec::new();
while body.len() < 5 {
count += 1;
if count > MAX_SIMPLE_RECTANGLE_TOKENS {
return None;
}
match input.next() {
Some("def") | None => break,
Some(token) => body.push(token),
}
}
let op = match body.as_slice() {
["{", op, "}"] | ["{", op, "}", _] => (*op).to_string(),
_ => return None,
};
aliases.insert(name.to_string(), op);
continue;
}
let op = aliases.get(token).map_or(token, String::as_str);
let mut pop = |n: usize| -> Option<Vec<f64>> { (stack.len() >= n).then(|| stack.split_off(stack.len() - n)) };
match op {
"setcmykcolor" => {
let values = pop(4)?;
let black = values[3].clamp(0.0, 1.0);
let channel = |value: f64| ((1.0 - value.clamp(0.0, 1.0)) * (1.0 - black) * 255.0).round() as u8;
color = [channel(values[0]), channel(values[1]), channel(values[2]), 255];
}
"setrgbcolor" => {
let values = pop(3)?;
let channel = |value: f64| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
color = [channel(values[0]), channel(values[1]), channel(values[2]), 255];
}
"setgray" => {
let values = pop(1)?;
let gray = (values[0].clamp(0.0, 1.0) * 255.0).round() as u8;
color = [gray, gray, gray, 255];
}
"rectfill" => {
let values = pop(4)?;
if rectangles.len() >= MAX_SIMPLE_RECTANGLES {
return None;
}
let x1 = values[0] + values[2];
let y1 = values[1] + values[3];
if !x1.is_finite() || !y1.is_finite() {
return None;
}
rectangles.push(Rectangle {
x: values[0].min(x1),
y: values[1].min(y1),
width: (x1 - values[0]).abs(),
height: (y1 - values[1]).abs(),
color,
});
}
"gsave" | "save" => {
if saved.len() >= MAX_SIMPLE_RECTANGLE_SAVE_DEPTH {
return None;
}
saved.push(color);
}
"grestore" | "restore" => {
if let Some(previous) = saved.pop() {
color = previous;
}
}
"pop" => {
pop(1)?;
}
"bind" | "newpath" | "showpage" | "end" | "begin" => {}
_ => return None,
}
}
(!rectangles.is_empty()).then_some(rectangles)
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "craft-indd"
description = "Shared InDesign document to IDML conversion facade for Craft applications"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
indd = { workspace = true }
[lints]
workspace = true
+17
View File
@@ -0,0 +1,17 @@
//! Shared InDesign document conversion boundary.
//!
//! InDesign's binary document model is converted to IDML here. Applications can import the
//! resulting package through [`craft_idml`] while retaining only their own model and UI mapping.
pub use indd::header::SIGNATURE;
pub use indd::{Conversion, Error, Preview, Warning};
/// Whether `bytes` begins with the fixed signature of an InDesign document.
pub fn is_indd(bytes: &[u8]) -> bool {
bytes.starts_with(&SIGNATURE)
}
/// Convert an InDesign document to IDML. `name` is the file name stored in the package.
pub fn convert(bytes: &[u8], name: &str) -> Result<Conversion, Error> {
indd::convert(bytes, name)
}
+1
View File
@@ -20,6 +20,7 @@
pub mod color;
pub mod guard;
pub mod paths;
pub mod preflight;
pub mod sniff;
pub mod svgz;
#[cfg(feature = "tree")]
+91
View File
@@ -0,0 +1,91 @@
//! Decode and validate SVG/SVGZ text before a caller hands it to a renderer.
//!
//! This boundary has no dependency on an SVG renderer. It decodes the file's text encoding and
//! bounded gzip container, then checks entity expansion, attribute work, XML structure, node
//! count and nesting depth.
use std::borrow::Cow;
/// Limits for decoding and validating one SVG document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Bytes of source text, also the largest unpacked SVGZ.
pub bytes: usize,
/// Deepest XML element nesting (the root alone is 1).
pub depth: usize,
/// Total bytes entities may expand to.
pub entity_bytes: usize,
/// Refuse a doctype entity containing markup.
pub refuse_entity_markup: bool,
/// Attributes on one element.
pub attributes: usize,
/// Sum of squared attribute counts across the document.
pub attribute_work: usize,
/// Maximum XML nodes after entity expansion.
pub xml_nodes: u32,
}
impl Default for Limits {
fn default() -> Self {
Limits {
bytes: 64 << 20,
depth: 256,
entity_bytes: crate::guard::MAX_ENTITY_EXPANSION,
refuse_entity_markup: false,
attributes: crate::guard::MAX_ATTRIBUTES,
attribute_work: crate::guard::MAX_ATTRIBUTE_WORK,
xml_nodes: 4_000_000,
}
}
}
/// Why an SVG's text or XML could not be read within `Limits`.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The bytes are too large, corrupt SVGZ, nested SVGZ, or invalid text encoding.
#[error(transparent)]
Text(#[from] crate::svgz::Error),
/// Entity references expand past the configured limit.
#[error("the SVG's entities expand to too much text")]
Entities,
/// A doctype entity contains markup and is refused by the caller's policy.
#[error("the SVG's document type declares an entity holding markup, which isn't supported")]
EntityMarkup,
/// XML element nesting exceeds the configured limit.
#[error("the SVG's elements are nested more than {0} levels deep")]
TooDeep(usize),
/// Attribute count or total attribute work exceeds the configured limit.
#[error("an element of the SVG has more than {0} attributes, or its elements have too many in all")]
TooWide(usize),
/// The XML is malformed or exceeds the node limit.
#[error("invalid SVG XML: {0}")]
Xml(String),
}
/// Decodes SVG or SVGZ text and checks the XML against `limits`.
pub fn text<'a>(bytes: &'a [u8], limits: &Limits) -> Result<Cow<'a, str>, Error> {
let text = crate::svgz::text(bytes, limits.bytes)?;
parse_text(&text, limits)?;
Ok(text)
}
/// Parses already-decoded SVG text and checks it against `limits`.
pub fn parse_text<'a>(svg: &'a str, limits: &Limits) -> Result<roxmltree::Document<'a>, Error> {
if svg.len() > limits.bytes {
return Err(Error::Text(crate::svgz::Error::TooLarge(limits.bytes)));
}
if limits.refuse_entity_markup && crate::guard::entity_holds_markup(svg) {
return Err(Error::EntityMarkup);
}
if crate::guard::entities_expand_past(svg, limits.entity_bytes) {
return Err(Error::Entities);
}
if crate::guard::too_wide(svg, limits.attributes, limits.attribute_work) {
return Err(Error::TooWide(limits.attributes));
}
if crate::guard::deeper_than(svg, limits.depth) {
return Err(Error::TooDeep(limits.depth));
}
let options = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: limits.xml_nodes, ..Default::default() };
roxmltree::Document::parse_with_options(svg, options).map_err(|e| Error::Xml(e.to_string()))
}
+17 -17
View File
@@ -125,23 +125,23 @@ pub fn parse_text(svg: &str, limits: &Limits, options: &usvg::Options<'_>) -> Re
/// Every check on SVG text, cheapest first; the XML it parses to on the way.
fn checked<'s>(svg: &'s str, limits: &Limits) -> Result<roxmltree::Document<'s>, Error> {
if svg.len() > limits.bytes {
return Err(Error::Text(svgz::Error::TooLarge(limits.bytes)));
}
if limits.refuse_entity_markup && guard::entity_holds_markup(svg) {
return Err(Error::EntityMarkup);
}
if guard::entities_expand_past(svg, limits.entity_bytes) {
return Err(Error::Entities);
}
if guard::too_wide(svg, limits.attributes, limits.attribute_work) {
return Err(Error::TooWide(limits.attributes));
}
if guard::deeper_than(svg, limits.depth) {
return Err(Error::TooDeep(limits.depth));
}
let options = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: limits.xml_nodes, ..Default::default() };
let xml = roxmltree::Document::parse_with_options(svg, options).map_err(|e| Error::Xml(e.to_string()))?;
let preflight_limits = crate::preflight::Limits {
bytes: limits.bytes,
depth: limits.depth,
entity_bytes: limits.entity_bytes,
refuse_entity_markup: limits.refuse_entity_markup,
attributes: limits.attributes,
attribute_work: limits.attribute_work,
xml_nodes: limits.xml_nodes,
};
let xml = crate::preflight::parse_text(svg, &preflight_limits).map_err(|e| match e {
crate::preflight::Error::Text(e) => Error::Text(e),
crate::preflight::Error::Entities => Error::Entities,
crate::preflight::Error::EntityMarkup => Error::EntityMarkup,
crate::preflight::Error::TooDeep(depth) => Error::TooDeep(depth),
crate::preflight::Error::TooWide(attributes) => Error::TooWide(attributes),
crate::preflight::Error::Xml(msg) => Error::Xml(msg),
})?;
if height(&xml, limits.depth)?.is_none() {
return Err(Error::TooDeep(limits.depth));
}
+7
View File
@@ -97,6 +97,13 @@ impl<'a> Archive<'a> {
self.zip.by_index_raw(index).ok().map(|f| f.compressed_size())
}
/// Is `name` stored without compression? The answer lets a format with in-memory source bytes
/// apply an output-size cap to compressed entries while bounding stored reads by source size.
pub fn is_stored(&mut self, name: &str) -> Option<bool> {
let index = self.zip.index_for_name(name)?;
self.zip.by_index_raw(index).ok().map(|f| f.compression() == zip::CompressionMethod::Stored)
}
/// The first `n` bytes of entry `name` (all of it when it is shorter), or `None` when there
/// is no such entry: for telling a file by its signature without inflating it. The bytes are
/// charged to the total; a total with less left than the bytes wanted refuses before reading.