mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Merge pull request #1170 from dylanmtaylor/codex/designcraft-format-migration
Add reusable document bundle and SVG preflight APIs
This commit is contained in:
15 files changed
+661
-17
No files matched your search
Generated
+22
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user