Merge pull request #7664 from storytold/fix/restore-codec-cutover-ci

Restore shared codec cutover with CI compatibility fixes
This commit is contained in:
FredApps
2026-11-15 21:05:52 +01:00
committed by GitHub
22 changed files with 952 additions and 431 deletions
Generated
+141 -15
View File
@@ -508,6 +508,12 @@ dependencies = [
"serde",
]
[[package]]
name = "bit_field"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -1033,6 +1039,36 @@ dependencies = [
"writeable",
]
[[package]]
name = "craft-image-codecs"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991"
dependencies = [
"craft-jpeg",
"exr",
"flate2",
"half",
"image",
"image-webp",
"jpeg-encoder",
"png 0.18.1",
"rayon",
"serde_json",
"thiserror 2.0.21",
"tiff",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
"zune-jpegxl",
]
[[package]]
name = "craft-jpeg"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-keymap"
version = "0.1.0"
@@ -1090,12 +1126,23 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "craft-psd"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991"
dependencies = [
"encoding_rs",
"flate2",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "craft-svg"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
dependencies = [
"craft-xml",
"craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2)",
"flate2",
"kurbo 0.13.1",
"roxmltree 0.21.2",
@@ -1105,6 +1152,15 @@ dependencies = [
"vectorcraft-pathops 0.18.0",
]
[[package]]
name = "craft-tiff"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1203,6 +1259,15 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-xml"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991"
dependencies = [
"quick-xml 0.41.0",
"thiserror 2.0.21",
]
[[package]]
name = "crc32fast"
version = "1.5.3"
@@ -1715,6 +1780,22 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exr"
version = "1.74.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide 0.8.9",
"multiversion",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -2747,6 +2828,7 @@ dependencies = [
"moxcms",
"num-traits",
"png 0.18.1",
"qoi",
"tiff",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
@@ -3200,6 +3282,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libc"
version = "0.2.191"
@@ -3399,6 +3487,28 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076"
dependencies = [
"multiversion-macros",
"target-features",
]
[[package]]
name = "multiversion-macros"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"target-features",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -4349,21 +4459,21 @@ dependencies = [
"unarray",
]
[[package]]
name = "psd"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]]
name = "quick-error"
version = "1.2.3"
@@ -5734,6 +5844,12 @@ dependencies = [
"xattr",
]
[[package]]
name = "target-features"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -6546,6 +6662,7 @@ dependencies = [
"chrono",
"craft-docpath",
"craft-proof",
"craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)",
"crc32fast",
"flate2",
"hayro-jpeg2000",
@@ -6558,7 +6675,6 @@ dependencies = [
"log",
"proptest",
"rayon",
"roxmltree 0.21.2",
"rustix 1.1.6",
"ruzstd",
"same-file",
@@ -6734,6 +6850,7 @@ dependencies = [
"aes",
"chrono",
"craft-pdf",
"craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)",
"flate2",
"getrandom 0.4.3",
"image",
@@ -6744,7 +6861,6 @@ dependencies = [
"md-5",
"objc2-core-foundation",
"objc2-core-graphics",
"roxmltree 0.21.2",
"serde",
"serde_json",
"sha2",
@@ -6785,16 +6901,16 @@ dependencies = [
name = "vectorcraft-render"
version = "0.24.1"
dependencies = [
"craft-image-codecs",
"craft-psd",
"craft-tiff",
"flate2",
"gif 0.14.2",
"image",
"jpeg-encoder",
"log",
"png 0.18.1",
"proptest",
"psd",
"serde_json",
"tiff",
"unicode-general-category",
"vectorcraft-brush",
"vectorcraft-color",
@@ -6917,6 +7033,7 @@ dependencies = [
name = "vectorcraft-trace"
version = "0.24.1"
dependencies = [
"craft-image-codecs",
"image",
"kurbo 0.13.1",
"serde",
@@ -8371,6 +8488,15 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-jpeg"
version = "0.4.21"
+5
View File
@@ -33,6 +33,11 @@ craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
# WMF, EMF and EMF+ reader (craft-libs#778).
craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" }
craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" }
craft-gif = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" }
craft-tiff = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" }
craft-psd = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
+1 -1
View File
@@ -53,7 +53,7 @@ heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], op
flate2 = { workspace = true }
chrono = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
roxmltree = "0.21"
craft-xml = { workspace = true }
# The compound file that holds a Word 97-2003 document placed as type (place/doc.rs).
cfb = "0.15"
kurbo = { workspace = true }
+2
View File
@@ -98,6 +98,8 @@ pub(crate) mod symboledit;
pub mod symbollib;
pub(crate) mod tabs;
mod textedit;
mod xmlattrs;
pub use textedit::effective_kerning;
pub mod textstyles;
pub(crate) mod textwrap;
+9 -2
View File
@@ -894,12 +894,19 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
return Some(format!("an image of {w} × {h} pixels is too large to draw"));
}
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
let format = reader.format()?;
let d = reader.into_decoder().ok()?;
let (w, h) = d.dimensions();
// The allowance also counts the pixels as RGBA: an image of fewer than 4 bytes a pixel (gray,
// gray with alpha, RGB) takes more than the decoder counts (#1717).
let rgba_over = u64::from(w) * u64::from(h) > MAX_RASTER_ALLOC / 4;
(rgba_over || d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
let samples_over = d.total_bytes() > MAX_RASTER_ALLOC;
// The renderer can stream a non-interlaced PNG a row at a time when those buffers fit. Its
// full decoded byte count can exceed the allowance even though drawing it at native size is
// safe; use the renderer's decision for that case instead of warning from total_bytes alone.
let png_streams_at_size = samples_over && format == image::ImageFormat::Png && vectorcraft_render::reduced_size(bytes) == Some((w, h));
(rgba_over || (samples_over && !png_streams_at_size)).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
}
/// What Open, Place and the Links panel say of encoded image `bytes` that [`too_large_note`] is
+55 -101
View File
@@ -38,7 +38,7 @@
use std::collections::HashMap;
use roxmltree::Node;
use craft_xml::{Canonical, Doctype, El, Limits, Names, Options};
use vectorcraft_doc::text::Kashida;
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
@@ -88,6 +88,7 @@ const MAX_RUNS: usize = 100_000;
/// WordprocessingML's namespaces: Transitional, then Strict.
const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
const DOCX_NS: Canonical<'static> = Canonical::new(&[("w", W[0]), ("w", W[1])]);
/// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds
/// `word/document.xml`?
@@ -104,7 +105,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into());
let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) };
let doc = parse(&xml, &main, cmd)?;
if local(doc.root_element()) != Some("document") {
if local(&doc) != Some("document") {
return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document"));
}
let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default();
@@ -117,7 +118,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
if let Some(xml) = part(&zip, &styles, cmd)? {
r.styles(&parse(&xml, &styles, cmd)?);
}
if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) {
if let Some(body) = child(&doc, "body") {
r.block(body, 0);
}
r.finish(cmd)
@@ -158,57 +159,21 @@ fn part(zip: &Archive, name: &str, cmd: &str) -> Result<Option<String>> {
}
/// The XML of the part `name`.
fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<'a>> {
// roxmltree parses nested elements recursively.
if nesting(xml) > MAX_NESTING {
return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")));
fn parse(xml: &str, name: &str, cmd: &str) -> Result<El> {
let opts = Options {
names: Names::Canonical(&DOCX_NS),
doctype: Doctype::Reject,
limits: Limits { max_depth: MAX_NESTING, max_nodes: MAX_NODES as usize, ..Limits::DEFAULT },
..Options::default()
}
let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() };
roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")))
}
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
pub(crate) fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
let past = |from: usize, end: &[u8]| {
b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len())
};
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) {
let start = i + at;
let rest = b.get(start..).unwrap_or_default();
if rest.starts_with(b"<!--") {
i = past(start + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = past(start + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = past(start + 2, b"?>");
} else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
depth = depth.saturating_sub(usize::from(rest.starts_with(b"</")));
i = past(start + 2, b">");
} else {
// A start tag: up to its `>` outside quotes; `/>` closes it at once.
let mut j = start + 1;
let mut quote = None;
while let Some(&c) = b.get(j) {
match (quote, c) {
(None, b'"' | b'\'') => quote = Some(c),
(Some(q), _) if c == q => quote = None,
(None, b'>') => break,
_ => {}
}
j += 1;
}
if b.get(j.saturating_sub(1)) != Some(&b'/') {
depth += 1;
most = most.max(depth);
}
i = j + 1;
}
}
most
.strict();
let doc = opts.parse(xml.as_bytes()).map_err(|e| match e {
craft_xml::Error::TooDeep(_) => bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")),
_ => bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")),
})?;
crate::cmd::xmlattrs::validate(xml.as_bytes(), opts.limits)
.map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")))?;
Ok(doc)
}
/// The part that the first relationship of part `source` (`""`: the package) whose type ends in
@@ -220,11 +185,10 @@ fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result<Op
let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) };
let doc = parse(&xml, &rels, cmd)?;
let target = doc
.root_element()
.children()
.filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External"))
.find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind)))
.and_then(|n| n.attribute("Target"));
.elements()
.filter(|n| n.local() == "Relationship" && n.attr("TargetMode") != Some("External"))
.find(|n| n.attr("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind)))
.and_then(|n| n.attr("Target"));
Ok(target.map(|t| resolve(folder, t)))
}
@@ -248,22 +212,22 @@ fn resolve(folder: &str, target: &str) -> String {
}
/// `n`'s local name when it is a WordprocessingML element.
fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> {
(n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name())
fn local(n: &El) -> Option<&str> {
n.name.strip_prefix("w:")
}
/// `n`'s first WordprocessingML child element named `name`.
fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<Node<'a, 'i>> {
n.children().find(|c| local(*c) == Some(name))
fn child<'a>(n: &'a El, name: &str) -> Option<&'a El> {
n.elements().find(|c| local(c) == Some(name))
}
/// `n`'s WordprocessingML attribute `name`.
fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value())
fn attr<'a>(n: &'a El, name: &str) -> Option<&'a str> {
n.attrs.iter().find_map(|(k, v)| k.strip_prefix("w:").filter(|&l| l == name).map(|_| v.as_str()))
}
/// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`.
fn on(n: Node) -> bool {
fn on(n: &El) -> bool {
!matches!(attr(n, "val"), Some("false" | "0" | "off"))
}
@@ -280,12 +244,12 @@ fn measure(v: &str, per_pt: f64) -> Option<f64> {
}
/// Twentieths of a point.
fn twips(n: Node, name: &str) -> Option<f64> {
fn twips(n: &El, name: &str) -> Option<f64> {
attr(n, name).and_then(|v| measure(v, 20.0))
}
/// Half points.
fn half_points(n: Node) -> Option<f64> {
fn half_points(n: &El) -> Option<f64> {
attr(n, "val").and_then(|v| measure(v, 2.0))
}
@@ -462,21 +426,21 @@ struct Reader {
impl Reader {
/// The major and minor fonts of a theme part's font scheme.
fn theme_fonts(&mut self, doc: &roxmltree::Document) {
fn theme_fonts(&mut self, doc: &El) {
for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) {
*slot = doc
.descendants()
.find(|n| n.tag_name().name() == scheme)
.and_then(|n| n.children().find(|c| c.tag_name().name() == "latin"))
.and_then(|n| n.attribute("typeface"))
*slot = std::iter::once(doc)
.chain(doc.descendants())
.find(|n| n.local() == scheme)
.and_then(|n| n.elements().find(|c| c.local() == "latin"))
.and_then(|n| n.attr("typeface"))
.map(font_name)
.filter(|f| !f.is_empty());
}
}
/// The document defaults and the styles of the styles part.
fn styles(&mut self, doc: &roxmltree::Document) {
for n in doc.root_element().children() {
fn styles(&mut self, doc: &El) {
for n in doc.elements() {
match local(n) {
Some("docDefaults") => {
let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner));
@@ -526,7 +490,7 @@ impl Reader {
}
/// The number of the font a `w:rFonts` element names.
fn font(&mut self, n: Node) -> Option<usize> {
fn font(&mut self, n: &El) -> Option<usize> {
let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref);
let name = attr(n, "asciiTheme")
.and_then(theme)
@@ -549,9 +513,9 @@ impl Reader {
}
/// The run properties of a `w:rPr` element.
fn run_props(&mut self, rpr: Node) -> RunProps {
fn run_props(&mut self, rpr: &El) -> RunProps {
let mut p = RunProps::default();
for n in rpr.children() {
for n in rpr.elements() {
match local(n) {
Some("rFonts") => p.font = self.font(n),
Some("b") => p.bold = Some(on(n)),
@@ -594,11 +558,11 @@ impl Reader {
}
/// The paragraphs, tables and containers of them that are `n`'s children.
fn block(&mut self, n: Node, depth: usize) {
fn block(&mut self, n: &El, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
for c in n.elements() {
if self.full {
return;
}
@@ -617,7 +581,7 @@ impl Reader {
}
/// A table: each row a paragraph, its cells separated by tabs.
fn table(&mut self, tbl: Node, depth: usize) {
fn table(&mut self, tbl: &El, depth: usize) {
for row in items(tbl, "tr", depth) {
for cell in items(row, "tc", depth) {
self.flush();
@@ -638,7 +602,7 @@ impl Reader {
}
/// A paragraph.
fn paragraph(&mut self, p: Node, depth: usize) {
fn paragraph(&mut self, p: &El, depth: usize) {
self.flush();
let ppr = child(p, "pPr");
let style = ppr
@@ -675,11 +639,11 @@ impl Reader {
}
/// The runs of a paragraph, and the containers of runs in it.
fn inline(&mut self, n: Node, depth: usize) {
fn inline(&mut self, n: &El, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
for c in n.elements() {
match local(c) {
Some("r") => self.run(c),
Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1),
@@ -694,7 +658,7 @@ impl Reader {
}
/// A run.
fn run(&mut self, r: Node) {
fn run(&mut self, r: &El) {
let rpr = child(r, "rPr");
let mut chars = self.base;
let style = rpr
@@ -709,11 +673,10 @@ impl Reader {
let props = self.run_props(n);
chars.apply(&props, false);
}
for c in r.children() {
for c in r.elements() {
match local(c) {
Some("t") => {
// Comments and processing instructions may split the text.
for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) {
for ch in c.text().chars() {
self.put(ch, chars);
}
}
@@ -907,9 +870,9 @@ fn font_name(name: &str) -> String {
}
/// The paragraph properties of a `w:pPr` element.
fn para_props(ppr: Node) -> ParaProps {
fn para_props(ppr: &El) -> ParaProps {
let mut p = ParaProps::default();
for n in ppr.children() {
for n in ppr.elements() {
match local(n) {
Some("jc") => {
p.align = match attr(n, "val") {
@@ -945,12 +908,12 @@ fn para_props(ppr: Node) -> ParaProps {
}
/// `n`'s children named `name`, and those inside content controls and custom XML among them.
fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec<Node<'a, 'i>> {
fn items<'a>(n: &'a El, name: &str, depth: usize) -> Vec<&'a El> {
let mut out = vec![];
if depth > MAX_DEPTH {
return out;
}
for c in n.children() {
for c in n.elements() {
match local(c) {
Some(l) if l == name => out.push(c),
Some("sdt") => {
@@ -979,15 +942,6 @@ mod tests {
assert_eq!(measure("wide", 20.0), None);
}
#[test]
fn nesting_counts_elements_outside_markup_that_is_not_an_element() {
assert_eq!(nesting(r#"<?xml version="1.0"?><a><b x="1>2" y='</c>'><c/></b><!-- <d><e> --><![CDATA[<f><g>]]></a>"#), 2);
assert_eq!(nesting("<a><b><c>"), 3, "unclosed elements count");
assert_eq!(nesting("</a></b><a>"), 1);
assert_eq!(nesting("<a x=\"unterminated"), 1);
assert_eq!(nesting(""), 0);
}
#[test]
fn targets_resolve_against_their_folder() {
assert_eq!(resolve("", "word/document.xml"), "word/document.xml");
+43 -53
View File
@@ -60,8 +60,8 @@ fn library_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
/// up to [`MAX_LIBRARY_ENTITIES`] entity declarations, each with a name of at most [`MAX_NAME`]
/// bytes and a quoted value that holds no `<`, `&` or `%`. Anything else in it is refused, and so is
/// a text that the replacements make longer than [`MAX_LIBRARY_BYTES`]. The XML parser then refuses
/// any other document type declaration, so [`nesting`](super::place::docx::nesting) counts the
/// elements the parser reads.
/// any other document type declaration, so its depth and node limits count the elements it
/// reads.
fn without_doctype(xml: &str, cmd: &str) -> Result<String> {
let b = xml.as_bytes();
let past = |from: usize, end: &str| xml.get(from..).and_then(|rest| rest.find(end)).map(|at| from + at + end.len());
@@ -240,22 +240,25 @@ impl Library {
/// type declaration defines are read as [`without_doctype`] says.
fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result<Library> {
let xml = without_doctype(xml, cmd)?;
// roxmltree parses nested elements recursively.
if crate::cmd::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING {
return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep")));
let opts = craft_xml::Options {
names: craft_xml::Names::AsWritten,
limits: craft_xml::Limits { max_depth: MAX_LIBRARY_NESTING, max_nodes: MAX_LIBRARY_NODES as usize, ..craft_xml::Limits::DEFAULT },
..craft_xml::Options::default()
}
let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() };
let doc =
roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?;
let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name;
let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else {
.strict();
let doc = opts.parse(xml.as_bytes()).map_err(|e| match e {
craft_xml::Error::TooDeep(_) => bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep")),
_ => bad(cmd, format!("the variable library is not well-formed XML: {e}")),
})?;
crate::cmd::xmlattrs::validate(xml.as_bytes(), opts.limits).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?;
let Some(root) = std::iter::once(&doc).chain(doc.descendants()).find(|n| n.is(craft_xml::Local("variableSets"))) else {
return Err(bad(cmd, "the file holds no `variableSets` element"));
};
let sets: Vec<roxmltree::Node> = root.children().filter(|n| is(n, "variableSet")).collect();
let sets: Vec<&craft_xml::El> = root.children(craft_xml::Local("variableSet")).collect();
let (mut variables, mut kinds, mut refs) = (Vec::new(), BTreeMap::new(), Vec::new());
for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) {
let name = named(var.attribute("varName"), "varName", cmd)?;
let trait_name = var.attribute("trait").map(|t| t.chars().take(MAX_NAME).collect::<String>());
for var in sets.iter().flat_map(|s| s.children(craft_xml::Local("variables"))).flat_map(|v| v.children(craft_xml::Local("variable"))) {
let name = named(var.attr("varName"), "varName", cmd)?;
let trait_name = var.attr("trait").map(|t| t.chars().take(MAX_NAME).collect::<String>());
let kind = trait_kind(trait_name.as_deref());
if kinds.insert(name.clone(), kind).is_some() {
return Err(bad(cmd, format!("variable `{name}` is declared twice")));
@@ -265,7 +268,7 @@ fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result<Library> {
}
if kind == VariableKind::Text {
// `docRef="id('…')"` names the element of the variable's bound object.
let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty());
let element = var.attr("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty());
if let Some(element) = element {
refs.push((name.clone(), element.to_string()));
}
@@ -276,10 +279,8 @@ fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result<Library> {
return Err(bad(cmd, "the variable library declares no variables"));
}
let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new());
for row in
sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet"))
{
let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?;
for row in sets.iter().flat_map(|s| s.children(craft_xml::Local("sampleDataSets"))).flat_map(|d| d.children(craft_xml::Local("sampleDataSet"))) {
let name = named(row.attr("dataSetName"), "dataSetName", cmd)?;
if !seen.insert(name.clone()) {
return Err(bad(cmd, format!("data set `{name}` is declared twice")));
}
@@ -287,8 +288,8 @@ fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result<Library> {
return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets")));
}
let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new());
for el in row.children().filter(roxmltree::Node::is_element) {
let var = el.tag_name().name();
for el in row.elements() {
let var = el.local();
let Some(kind) = kinds.get(var) else {
return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare")));
};
@@ -315,12 +316,12 @@ fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result<Library> {
/// white space round them, and an element holding only white space gives none. A variable of
/// unknown kind takes no value, so what its element holds is passed over. `set` names the data set
/// in the errors.
fn library_value(el: roxmltree::Node, kind: VariableKind, set: &str, cmd: &str) -> Result<Option<DataValue>> {
let refused = |what: &str| bad(cmd, format!("data set `{set}` holds a value for `{}` that isn't {what}", el.tag_name().name()));
let text = || if elements(el).next().is_some() { None } else { Some(deep_text(el)) };
fn library_value(el: &craft_xml::El, kind: VariableKind, set: &str, cmd: &str) -> Result<Option<DataValue>> {
let refused = |what: &str| bad(cmd, format!("data set `{set}` holds a value for `{}` that isn't {what}", el.local()));
let text = || if el.elements().next().is_some() { None } else { Some(el.deep_text()) };
match kind {
VariableKind::Text => {
let paras: Vec<String> = elements(el).filter(|n| n.tag_name().name() == "p").map(deep_text).collect();
let paras: Vec<String> = el.children(craft_xml::Local("p")).map(craft_xml::El::deep_text).collect();
Ok((!paras.is_empty()).then(|| DataValue::Text(paras.join("\n"))))
}
VariableKind::Visibility => {
@@ -346,16 +347,6 @@ fn library_value(el: roxmltree::Node, kind: VariableKind, set: &str, cmd: &str)
}
}
/// The elements among `n`'s children.
fn elements<'a, 'i>(n: roxmltree::Node<'a, 'i>) -> impl Iterator<Item = roxmltree::Node<'a, 'i>> {
n.children().filter(roxmltree::Node::is_element)
}
/// The text of every text node inside `n`, in order.
fn deep_text(n: roxmltree::Node) -> String {
n.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()
}
/// The absolute path a library's image value `text` names: a `file:` URL's path (`file:///…`,
/// `file://localhost/…` or `file:/…`), its `%XX` escapes decoded (a URL with a host names a share,
/// `//host/…`), or `text` itself. `None` unless that is an absolute path: one starting with `/` or
@@ -428,29 +419,28 @@ fn library_file(path: &str) -> String {
/// series, or when `el` holds only white space, there is no value. Anything else in `el`, more than
/// [`MAX_GRAPH_CATEGORIES`] rows or [`MAX_GRAPH_SERIES`] series (what a graph holds), and CSV longer
/// than graph values hold, are refused.
fn graph_value(el: roxmltree::Node, set: &str, cmd: &str) -> Result<Option<DataValue>> {
let var = el.tag_name().name();
fn graph_value(el: &craft_xml::El, set: &str, cmd: &str) -> Result<Option<DataValue>> {
let var = el.local();
let refused = || bad(cmd, format!("data set `{set}` holds a value for `{var}` that isn't a graph's `data` element"));
let named = |name: &'static str| move |n: &roxmltree::Node| n.tag_name().name() == name;
let mut children = elements(el);
let named = |name: &'static str| craft_xml::Local(name);
let mut children = el.elements();
let data = match (children.next(), children.next()) {
(None, _) if deep_text(el).trim_matches(XML_SPACE).is_empty() => return Ok(None),
(Some(data), None) if data.tag_name().name() == "data" => data,
(None, _) if el.deep_text().trim_matches(XML_SPACE).is_empty() => return Ok(None),
(Some(data), None) if data.local() == "data" => data,
_ => return Err(refused()),
};
// Labels keep their white space; numbers are read without it.
let series: Vec<String> =
elements(data).find(named("propertyRow")).map(|r| elements(r).filter(named("value")).skip(1).map(deep_text).collect()).unwrap_or_default();
let rows: Vec<roxmltree::Node> =
elements(data).filter(named("values")).flat_map(elements).filter(named("row")).take(MAX_GRAPH_CATEGORIES + 1).collect();
data.child(named("propertyRow")).map(|r| r.children(named("value")).skip(1).map(craft_xml::El::deep_text).collect()).unwrap_or_default();
let rows: Vec<&craft_xml::El> = data.children(named("values")).flat_map(|v| v.children(named("row"))).take(MAX_GRAPH_CATEGORIES + 1).collect();
if rows.is_empty() && series.is_empty() {
return Ok(None);
}
let too_many = || bad(cmd, format!("data set `{set}` holds a graph value for `{var}` with {}", graph_limits()));
// A row's values after its name.
let wide = |row: &roxmltree::Node| {
let mut values = elements(*row).filter(named("value")).peekable();
let _name = values.next_if(|v| v.attribute("key") == Some("name"));
let wide = |row: &&craft_xml::El| {
let mut values = row.children(named("value")).peekable();
let _name = values.next_if(|v| v.attr("key") == Some("name"));
values.count() > MAX_GRAPH_SERIES
};
if rows.len() > MAX_GRAPH_CATEGORIES || series.len() > MAX_GRAPH_SERIES || rows.iter().any(wide) {
@@ -462,12 +452,12 @@ fn graph_value(el: roxmltree::Node, set: &str, cmd: &str) -> Result<Option<DataV
let read: Vec<(Option<String>, Vec<Option<f64>>)> = rows
.iter()
.map(|row| {
let mut values = elements(*row).filter(named("value")).peekable();
let name = values.next_if(|v| v.attribute("key") == Some("name"));
labelled |= name.is_some_and(|v| v.attribute("type") == Some("string") || !deep_text(v).is_empty());
let name = name.map(deep_text);
let number = |v: roxmltree::Node| deep_text(v).trim_matches(XML_SPACE).parse::<f64>().ok().filter(|n| n.is_finite());
let numbers = values.map(|v| if v.attribute("type") == Some("string") { None } else { number(v) });
let mut values = row.children(named("value")).peekable();
let name = values.next_if(|v| v.attr("key") == Some("name"));
labelled |= name.is_some_and(|v| v.attr("type") == Some("string") || !v.deep_text().is_empty());
let name = name.map(craft_xml::El::deep_text);
let number = |v: &craft_xml::El| v.deep_text().trim_matches(XML_SPACE).parse::<f64>().ok().filter(|n| n.is_finite());
let numbers = values.map(|v| if v.attr("type") == Some("string") { None } else { number(v) });
(name, numbers.collect())
})
.collect();
+55
View File
@@ -0,0 +1,55 @@
//! Attribute uniqueness checks missing from the pinned shared XML reader's strict mode.
//!
//! Temporary consumer guard: parsing remains in craft-xml. Move this invariant into the
//! shared strict reader and remove this second event pass when that dependency is updated.
/// Check names before canonical/local-name rewriting can collapse distinct attributes.
/// The event reader and namespace scope use the same caller-supplied resource caps.
pub(super) fn validate(xml: &[u8], limits: craft_xml::Limits) -> Result<(), String> {
use std::collections::BTreeSet;
let opts = craft_xml::Options { limits, doctype: craft_xml::Doctype::Reject, ..Default::default() }.strict();
let mut events = opts.events(xml);
let mut bindings: Vec<(String, String)> = Vec::new();
let mut scopes = Vec::new();
while let Some(event) = events.next().map_err(|e| e.to_string())? {
match event {
craft_xml::Event::Start { attrs, .. } => {
scopes.push(bindings.len());
let mut raw = BTreeSet::new();
for (key, value) in &attrs {
if !raw.insert(key.as_str()) {
return Err(format!("duplicate attribute `{key}`"));
}
if let Some(prefix) = key.strip_prefix("xmlns:") {
if bindings.len() >= limits.max_attrs {
return Err("too many in-scope namespace declarations".into());
}
bindings.push((prefix.to_string(), value.clone()));
}
}
let mut expanded = BTreeSet::new();
for (key, _) in &attrs {
if key == "xmlns" || key.starts_with("xmlns:") {
continue;
}
// Default namespaces do not apply to attributes. Preserve undeclared
// prefixes for standalone SVG library fragments, as the importer did.
let name = match key.split_once(':') {
Some(("xml", local)) => ("http://www.w3.org/XML/1998/namespace", local),
Some((prefix, local)) => {
bindings.iter().rev().find(|(p, _)| p == prefix).map_or(("", key.as_str()), |(_, uri)| (uri.as_str(), local))
}
None => ("", key.as_str()),
};
if !expanded.insert(name) {
return Err(format!("duplicate expanded attribute `{key}`"));
}
}
}
craft_xml::Event::End { .. } => bindings.truncate(scopes.pop().unwrap_or(0)),
_ => {}
}
}
Ok(())
}
+9
View File
@@ -468,6 +468,15 @@ fn malformed_docx_fails_cleanly() {
assert!(e.contains("not a WordprocessingML document"), "{e}");
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", b"<w:document><w:body>")])).unwrap_err();
assert!(e.contains("not well-formed XML"), "{e}");
for xml in [
format!(r#"<w:document xmlns:w="{W_NS}"><w:body><w:p><w:r><w:t>partial text</w:t></w:r></w:p>"#),
document("<w:p><w:r><w:t>A&missing;B</w:t></w:r></w:p>"),
document("<w:p><w:r><w:t xml:space=\"preserve\" xml:space=\"default\">text</w:t></w:r></w:p>"),
document("<w:p xmlns:a=\"urn:test\" xmlns:b=\"urn:test\" a:id=\"1\" b:id=\"2\"><w:r><w:t>text</w:t></w:r></w:p>"),
] {
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", xml.as_bytes())])).unwrap_err();
assert!(e.contains("not well-formed XML"), "{e}");
}
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", document("<w:p/>").as_bytes())])).unwrap_err();
assert!(e.contains("no text to place"), "{e}");
// A file whose headers understate what it unpacks to, and one whose headers say it is too large.
+16
View File
@@ -2665,6 +2665,11 @@ fn malformed_variable_libraries_are_refused() {
let long_graph = format!("<Shown><data><values>{}</values></data></Shown>", row.repeat(2_000));
let cases = [
("not well-formed XML", "<variableSets><variableSet>".to_string()),
("not well-formed XML", ok.replace("<p>x</p>", "<p>A&missing;B</p>")),
("not well-formed XML", ok.replace("</variableSets>", "")),
("not well-formed XML", ok.replace("varName=\"Name\"", "varName=\"Name\" varName=\"Other\"")),
("not well-formed XML", ok.replace("<variable ", "<variable xmlns:a=\"urn:test\" xmlns:b=\"urn:test\" a:id=\"1\" b:id=\"2\" ")),
("missing `varName`", ok.replace("varName=\"Name\"", "xmlns:m=\"urn:metadata\" m:varName=\"Name\"")),
("no `variableSets` element", "<svg><variables/></svg>".to_string()),
("declares no variables", "<variableSets><variableSet><variables/></variableSet></variableSets>".to_string()),
("is declared twice", library(&[("Name", "textcontent"), ("Name", "textcontent")], &[])),
@@ -3545,6 +3550,17 @@ fn variable_sort_orders_the_list_by_each_column() {
}
}
#[test]
fn variable_library_attributes_keep_their_namespace() {
let mut s = session();
let xml = library(&[("Name", "textcontent")], &[("A", &[("Name", &["x"])])])
.replace("varName=\"Name\"", "xmlns:m=\"urn:metadata\" m:varName=\"Other\" varName=\"Name\"");
let loaded = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap();
assert_eq!(loaded["variables"], json!(["Name"]));
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "x"}}));
}
/// The sort is panel state: it is not an undo step and does not count as a change to save, and
/// setting the order the list already has does not redraw anything. A bad `by` or `descending`
/// is an error that leaves the order as it was.
+2 -2
View File
@@ -33,8 +33,8 @@ smallvec = "1"
siphasher = "1"
weezl = "0.2"
serde = { workspace = true }
# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses.
roxmltree = "0.21"
# The XMP packet that File Info is read from (file_info.rs).
craft-xml = { workspace = true }
# PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm.
aes = "0.8"
md-5 = { version = "0.10", default-features = false }
+39 -86
View File
@@ -5,7 +5,7 @@
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{DateTime, Dict, Stream};
use roxmltree::Node;
use craft_xml::{Canonical, Doctype, El, Limits, Names, Options};
use crate::import_scan::text_string;
@@ -15,12 +15,13 @@ const MAX_XMP: usize = 8 << 20;
const MAX_NESTING: usize = 256;
const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
const XML: &str = "http://www.w3.org/XML/1998/namespace";
pub(crate) const DC: &str = "http://purl.org/dc/elements/1.1/";
const XMP: &str = "http://ns.adobe.com/xap/1.0/"; // brand-ok: the XMP namespace the files use
pub(crate) const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use
const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace the files use
const XMP_NAMESPACES: Canonical<'static> = Canonical::new(&[("rdf", RDF), ("dc", DC), ("xmp", XMP), ("xmpRights", XMP_RIGHTS), ("pdf", PDF)]);
/// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FileInfo {
@@ -73,39 +74,39 @@ fn packet(pdf: &Pdf) -> Option<Vec<u8>> {
}
/// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16, or when its
/// elements nest more than [`MAX_NESTING`] deep.
/// [`xmp`] of `packet`; `None` when it isn't XML in UTF-8 or UTF-16, or when its elements nest
/// more than [`MAX_NESTING`] deep.
fn xmp(packet: &[u8]) -> Option<FileInfo> {
let text = xml_text(packet)?;
// roxmltree parses nested elements recursively.
if nesting(&text) > MAX_NESTING {
return None;
let opts = Options {
names: Names::Canonical(&XMP_NAMESPACES),
doctype: Doctype::Reject,
limits: Limits { max_depth: MAX_NESTING, ..Limits::DEFAULT },
..Options::default()
}
let xml = roxmltree::Document::parse(&text).ok()?;
.strict();
let root = opts.parse(text.as_bytes()).ok()?;
// The resource's own descriptions: those right under `rdf:RDF`, not the ones inside a value.
let descriptions: Vec<Node<'_, '_>> = xml
.descendants()
.filter(|n| n.has_tag_name((RDF, "RDF")))
.flat_map(|rdf| rdf.children())
.filter(|n| n.has_tag_name((RDF, "Description")))
.collect();
let get = |ns: &str, name: &str| property(&descriptions, ns, name);
let first = |ns: &str, name: &str| get(ns, name).into_iter().next();
let creators = get(DC, "creator");
let subjects = get(DC, "subject");
let descriptions: Vec<&El> =
std::iter::once(&root).chain(root.descendants()).filter(|n| n.is("rdf:RDF")).flat_map(|rdf| rdf.children("rdf:Description")).collect();
let get = |name: &str| property(&descriptions, name);
let first = |name: &str| get(name).into_iter().next();
let creators = get("dc:creator");
let subjects = get("dc:subject");
Some(FileInfo {
title: first(DC, "title"),
title: first("dc:title"),
author: (!creators.is_empty()).then(|| creators.join(", ")),
description: first(DC, "description"),
keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) },
copyright_notice: first(DC, "rights"),
copyright_url: first(XMP_RIGHTS, "WebStatement"),
copyrighted: first(XMP_RIGHTS, "Marked").and_then(|m| match m.to_ascii_lowercase().as_str() {
description: first("dc:description"),
keywords: if subjects.is_empty() { first("pdf:Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) },
copyright_notice: first("dc:rights"),
copyright_url: first("xmpRights:WebStatement"),
copyrighted: first("xmpRights:Marked").and_then(|m| match m.to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}),
created: first(XMP, "CreateDate").and_then(|d| xmp_date(&d)),
modified: first(XMP, "ModifyDate").and_then(|d| xmp_date(&d)),
created: first("xmp:CreateDate").and_then(|d| xmp_date(&d)),
modified: first("xmp:ModifyDate").and_then(|d| xmp_date(&d)),
})
}
@@ -123,58 +124,14 @@ fn xml_text(b: &[u8]) -> Option<String> {
}
}
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
let past = |from: usize, end: &[u8]| {
b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len())
};
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) {
let start = i + at;
let rest = b.get(start..).unwrap_or_default();
if rest.starts_with(b"<!--") {
i = past(start + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = past(start + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = past(start + 2, b"?>");
} else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
depth = depth.saturating_sub(usize::from(rest.starts_with(b"</")));
i = past(start + 2, b">");
} else {
// A start tag: up to its `>` outside quotes; `/>` closes it at once.
let mut j = start + 1;
let mut quote = None;
while let Some(&c) = b.get(j) {
match (quote, c) {
(None, b'"' | b'\'') => quote = Some(c),
(Some(q), _) if c == q => quote = None,
(None, b'>') => break,
_ => {}
}
j += 1;
}
if b.get(j.saturating_sub(1)) != Some(&b'/') {
depth += 1;
most = most.max(depth);
}
i = j + 1;
}
}
most
}
/// The values of property `ns` `name` on the first of `descriptions` that gives it: an attribute
/// The values of property `name` on the first of `descriptions` that gives it: an attribute
/// of the `rdf:Description`, or a property element's items ([`items`]), blank ones left out.
fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec<String> {
fn property(descriptions: &[&El], name: &str) -> Vec<String> {
for d in descriptions {
if let Some(v) = d.attribute((ns, name)).map(str::trim).filter(|v| !v.is_empty()) {
if let Some(v) = d.attr(name).map(str::trim).filter(|v| !v.is_empty()) {
return vec![v.to_string()];
}
let found = d.children().filter(|c| c.has_tag_name((ns, name))).map(items).find(|v| !v.is_empty());
let found = d.children(name).map(items).find(|v| !v.is_empty());
if let Some(v) = found {
return v;
}
@@ -185,14 +142,10 @@ fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec<String>
/// The items of property element `p`: the `rdf:li` of its `rdf:Seq`, `rdf:Bag` or `rdf:Alt` (the
/// `x-default` language item first), else its own value ([`value_of`]); trimmed, blank ones left
/// out.
fn items(p: Node<'_, '_>) -> Vec<String> {
let array = p.children().find(|c| ["Seq", "Bag", "Alt"].into_iter().any(|t| c.has_tag_name((RDF, t))));
fn items(p: &El) -> Vec<String> {
let array = p.elements().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt"));
let mut values: Vec<(bool, String)> = match array {
Some(a) => a
.children()
.filter(|c| c.has_tag_name((RDF, "li")))
.map(|li| (li.attribute((XML, "lang")).is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li)))
.collect(),
Some(a) => a.children("rdf:li").map(|li| (li.attr("xml:lang").is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li))).collect(),
None => vec![(false, value_of(p))],
};
// A stable sort: the `x-default` item first, the others in their order.
@@ -203,19 +156,19 @@ fn items(p: Node<'_, '_>) -> Vec<String> {
/// The value of element `n`: the URI of its `rdf:resource` attribute (how RDF writes a URI value);
/// for a qualified value, its `rdf:value` (an attribute or an element, on `n` or on an
/// `rdf:Description` inside it), without the qualifiers; else the text inside it.
fn value_of(mut n: Node<'_, '_>) -> String {
fn value_of(mut n: &El) -> String {
// Each step goes to an element inside `n`, so the loop ends.
loop {
if let Some(uri) = n.attribute((RDF, "resource")) {
if let Some(uri) = n.attr("rdf:resource") {
return uri.to_string();
}
let holder = n.children().find(|c| c.has_tag_name((RDF, "Description"))).unwrap_or(n);
if let Some(v) = holder.attribute((RDF, "value")) {
let holder = n.child("rdf:Description").unwrap_or(n);
if let Some(v) = holder.attr("rdf:value") {
return v.to_string();
}
match holder.children().find(|c| c.has_tag_name((RDF, "value"))) {
match holder.child("rdf:value") {
Some(v) => n = v,
None => return n.descendants().filter(|d| d.is_text()).filter_map(|d| d.text()).collect(),
None => return n.deep_text(),
}
}
}
+3 -5
View File
@@ -19,13 +19,12 @@ vello_cpu = { workspace = true }
# Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2.
vello_common = { version = "0.2", default-features = false }
image = { workspace = true }
craft-image-codecs = { workspace = true }
flate2 = { workspace = true }
jpeg-encoder = { workspace = true }
gif = { workspace = true }
# TIFF LZW compression (MIT OR Apache-2.0; already used by the image crate's TIFF reader).
weezl = "0.2"
# PNG rows read one at a time (MIT OR Apache-2.0; already used by the image crate's PNG reader).
png = "0.18"
log = { workspace = true }
# Combining marks inside a lam-alef cluster (MIT OR Apache-2.0; already used by vectorcraft-text).
unicode-general-category = "1"
@@ -47,8 +46,7 @@ vectorcraft-plugins = { workspace = true }
wat = { workspace = true }
# Decoders the export tests read their files back with.
image = { workspace = true, features = ["tga"] }
tiff = "0.11"
# Reads the PSD exports back (MIT OR Apache-2.0).
psd = "0.3"
craft-tiff = { workspace = true }
craft-psd = { workspace = true }
proptest = { workspace = true }
serde_json = { workspace = true }
+4 -2
View File
@@ -86,8 +86,10 @@ fn gray_and_bilevel_images_store_gray() {
assert_eq!(gray(&file), levels);
let (mime, file) = img.to_bilevel(150.0).unwrap();
assert_eq!(mime, "image/tiff");
let mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap();
assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1));
let tiff = craft_tiff::Tiff::parse(&file).unwrap();
let ifd = &tiff.ifds[0];
assert_eq!(ifd.u16(craft_tiff::tags::SAMPLES_PER_PIXEL), Some(1));
assert_eq!(ifd.value(craft_tiff::tags::BITS_PER_SAMPLE).unwrap().to_u64_vec(), vec![1]);
let bw: Vec<u8> = [0u8, 0, 255, 255, 255, 0].iter().flat_map(|&g| [g, 255]).collect();
assert_eq!(gray(&file), bw, "from 128 up is white");
// Half-covered white over the light grey; a clear pixel is white.
+25 -12
View File
@@ -144,9 +144,18 @@ fn unpack(mut src: &[u8], out: &mut Vec<u8>) {
}
}
/// The composite as a reader sees it.
/// The stored composite: RGB stays white-matted, with its original alpha.
fn merged(f: &[u8]) -> Vec<u8> {
::psd::Psd::from_bytes(f).expect("a PSD a reader takes").rgba()
let psd = craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes");
if psd.header.color_mode != craft_psd::ColorMode::Rgb {
return psd.composite_rgba8().expect("composite").data;
}
assert_eq!(psd.header.depth, 8);
let samples = psd.decode_merged().expect("stored composite planes");
let count = psd.header.width as usize * psd.header.height as usize;
(0..count)
.flat_map(|i| [samples[i], samples[count + i], samples[2 * count + i], if psd.merged_has_alpha() { samples[3 * count + i] } else { 255 }])
.collect()
}
fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] {
@@ -168,11 +177,12 @@ fn layers_are_the_top_level_layers_and_the_merged_image_is_the_render() {
assert_eq!(p.records[1].rect, [0, 0, 50, 60], "the blue square and the corner");
// A reader decodes it: the layers, in place, and the merged image.
let psd = ::psd::Psd::from_bytes(&file).unwrap();
assert_eq!((psd.width(), psd.height(), psd.layers().len()), (100, 60, 2));
let red = psd.layer_by_name("Layer 1").unwrap().rgba();
assert_eq!(pixel(&red, 100, 20, 20), [255, 0, 0, 255]);
assert_eq!(pixel(&red, 100, 50, 45)[3], 0, "the blue square isn't on Layer 1");
let psd = craft_psd::PsdFile::from_bytes(&file).unwrap();
assert_eq!((psd.header.width, psd.header.height, psd.layers().len()), (100, 60, 2));
let layer = psd.iter_layers().find(|l| l.name() == "Layer 1").unwrap();
assert_eq!(layer.record.rect, craft_psd::layer::Rect { top: 10, left: 10, bottom: 40, right: 40 });
let red = layer.rgba8().unwrap().data;
assert_eq!(pixel(&red, 30, 10, 10), [255, 0, 0, 255]);
// The merged image is the flat render: colours on white where transparent, alpha apart.
let img = Renderer::new().render_region_with(&doc, doc.artboards[0].rect, 1.0, &opts(PsdOptions::default()).render_options(RasterFormat::Psd));
@@ -200,7 +210,7 @@ fn flat_writes_the_merged_image_alone_on_white() {
assert_eq!(o.encode(&img, RasterFormat::Psd).unwrap(), file);
let file = export(&doc, &RasterExportOptions { ppi: 150.0, ..o });
assert_eq!(u32_at(parse(&file).resources, 12), 150 << 16);
assert_eq!(::psd::Psd::from_bytes(&file).unwrap().width(), 208);
assert_eq!(craft_psd::PsdFile::from_bytes(&file).unwrap().header.width, 208);
}
#[test]
@@ -230,8 +240,11 @@ fn maximum_editability_writes_groups_and_an_object_per_layer() {
assert_eq!((p.records[4].opacity, p.records[4].blend, p.records[4].rect), (128, *b"mul ", [20, 30, 50, 60]));
assert_eq!((p.records[8].blend, p.records[8].opacity), (*b"pass", 255));
assert_eq!(p.records[6].rect, [0, 0, 6, 6]);
let psd = ::psd::Psd::from_bytes(&file).unwrap();
assert_eq!((psd.layers().len(), psd.groups().len()), (3, 3));
let psd = craft_psd::PsdFile::from_bytes(&file).unwrap();
let content_layers = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::SectionType::Other)).count();
let groups =
psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::SectionType::OpenFolder | craft_psd::SectionType::ClosedFolder)).count();
assert_eq!((content_layers, groups), (3, 3));
assert_eq!(merged(&file), merged(&export(&doc, &opts(PsdOptions::default()))), "the same merged image");
// Hidden layers: written hidden, drawn as if visible, instead of left out.
@@ -300,8 +313,8 @@ fn a_background_colour_is_the_bottom_layer() {
let p = parse(&file);
assert_eq!((p.channels, p.count), (3, 3), "opaque: no merged alpha");
assert_eq!((p.records[0].name.as_str(), p.records[0].rect), ("Background", [0, 0, 60, 100]));
let psd = ::psd::Psd::from_bytes(&file).unwrap();
assert!(psd.layer_by_name("Background").unwrap().rgba().chunks(4).all(|p| p == [0, 0, 255, 255]));
let psd = craft_psd::PsdFile::from_bytes(&file).unwrap();
assert!(psd.iter_layers().find(|l| l.name() == "Background").unwrap().rgba8().unwrap().data.chunks(4).all(|p| p == [0, 0, 255, 255]));
assert_eq!(pixel(&merged(&file), 100, 5, 50), [0, 0, 255, 255]);
}
+62 -42
View File
@@ -1,22 +1,25 @@
//! TIFF export: byte order, LZW, colour models and samples, resolution, profile, strips.
use std::io::Cursor;
use tiff::decoder::{Decoder, DecodingResult};
use tiff::tags::Tag;
use craft_tiff::Tiff;
use craft_tiff::tags;
use super::jpeg::ColorModel;
use super::tiff::{self as t, ByteOrder, Compression, Image, Layout, Photometric, TiffOptions};
fn decoder(file: &[u8]) -> Decoder<Cursor<&[u8]>> {
Decoder::new(Cursor::new(file)).expect("a TIFF")
fn ifd0(file: &[u8]) -> craft_tiff::Ifd {
Tiff::parse(file).expect("a TIFF").ifds.into_iter().next().expect("IFD0")
}
fn tag_u16_vec(ifd: &craft_tiff::Ifd, tag: u16) -> Option<Vec<u16>> {
ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u16).collect())
}
fn tag_u32_vec(ifd: &craft_tiff::Ifd, tag: u16) -> Option<Vec<u32>> {
ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u32).collect())
}
fn pixels(file: &[u8]) -> Vec<u8> {
match decoder(file).read_image().expect("decodes") {
DecodingResult::U8(v) => v,
_ => panic!("8-bit samples"),
}
craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, file).expect("decodes").into_data()
}
/// Straight RGBA: flat areas (as art is) with a transparent corner when `clear`.
@@ -46,7 +49,7 @@ fn byte_order_writes_an_ii_or_mm_header() {
assert_eq!(&file[..4], magic);
let rgb: Vec<u8> = px.chunks(4).flat_map(|p| &p[..3]).copied().collect();
assert_eq!(pixels(&file), rgb, "{order:?}");
assert_eq!(decoder(&file).get_tag_ascii_string(Tag::Software).unwrap(), "VectorCraft");
assert_eq!(ifd0(&file).string(tags::SOFTWARE).as_deref(), Some("VectorCraft"));
}
for (s, o) in [("little", ByteOrder::Little), ("MM", ByteOrder::Big), ("mac", ByteOrder::Big), ("ii", ByteOrder::Little), ("big", ByteOrder::Big)]
{
@@ -61,44 +64,51 @@ fn lzw_is_smaller_and_decodes_the_same() {
let plain = t::encode_samples(&px, 120, 90, 72.0, &TiffOptions { lzw: false, ..opts(ColorModel::Rgb) }).unwrap();
let lzw = t::encode_samples(&px, 120, 90, 72.0, &opts(ColorModel::Rgb)).unwrap();
assert!(lzw.len() * 4 < plain.len(), "{} vs {}", lzw.len(), plain.len());
assert_eq!(decoder(&lzw).get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 5);
assert_eq!(decoder(&plain).get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 1);
assert_eq!(ifd0(&lzw).u16(tags::COMPRESSION), Some(5));
assert_eq!(ifd0(&plain).u16(tags::COMPRESSION), Some(1));
assert_eq!(pixels(&lzw), px, "transparency kept as an alpha channel");
assert_eq!(pixels(&plain), px);
// The image crate reads it too.
let img = image::load_from_memory_with_format(&lzw, image::ImageFormat::Tiff).unwrap().to_rgba8();
assert_eq!(img.into_raw(), px);
let img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &lzw).unwrap();
assert_eq!(img.data(), px);
}
#[test]
fn colour_models_write_their_samples() {
let px = art(9, 6, true);
let rgba = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap();
assert_eq!(decoder(&rgba).colortype().unwrap(), tiff::ColorType::RGBA(8));
assert_eq!(decoder(&rgba).get_tag_u16_vec(Tag::ExtraSamples).unwrap(), [2], "unassociated alpha");
let rgba_ifd = ifd0(&rgba);
assert_eq!(rgba_ifd.u16(tags::PHOTOMETRIC), Some(2));
assert_eq!(rgba_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(4));
assert_eq!(tag_u16_vec(&rgba_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8, 8, 8, 8].as_slice()));
assert_eq!(tag_u16_vec(&rgba_ifd, tags::EXTRA_SAMPLES).as_deref(), Some([2].as_slice()), "unassociated alpha");
let opaque = art(9, 6, false);
let rgb = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap();
assert_eq!(decoder(&rgb).colortype().unwrap(), tiff::ColorType::RGB(8));
assert!(decoder(&rgb).find_tag(Tag::ExtraSamples).unwrap().is_none());
let rgb_ifd = ifd0(&rgb);
assert_eq!(rgb_ifd.u16(tags::PHOTOMETRIC), Some(2));
assert_eq!(rgb_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(3));
assert!(rgb_ifd.value(tags::EXTRA_SAMPLES).is_none());
let gray = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap();
assert_eq!(decoder(&gray).colortype().unwrap(), tiff::ColorType::Gray(8));
assert_eq!(decoder(&gray).get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 1, "black is zero");
let gray_ifd = ifd0(&gray);
assert_eq!(gray_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1));
assert_eq!(tag_u16_vec(&gray_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8].as_slice()));
assert_eq!(gray_ifd.u16(tags::PHOTOMETRIC), Some(1), "black is zero");
let g = pixels(&gray);
assert_eq!((g[0], g[8]), (super::jpeg::to_gray()([200, 30, 40]), 255), "lightness; white stays white");
let on_white = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap();
assert_eq!(decoder(&on_white).colortype().unwrap(), tiff::ColorType::Gray(8), "grey has no alpha: few readers take it");
let on_white_ifd = ifd0(&on_white);
assert_eq!(on_white_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1), "grey has no alpha: few readers take it");
assert_eq!(pixels(&on_white)[0], 255, "the transparent corner is white");
// CMYK: four samples a pixel, ink amounts as given.
let inks: Vec<u8> = (0..9 * 6 * 4).map(|i| (i * 7 % 256) as u8).collect();
let cmyk = t::encode_samples(&inks, 9, 6, 72.0, &opts(ColorModel::Cmyk)).unwrap();
let mut d = decoder(&cmyk);
assert_eq!(d.colortype().unwrap(), tiff::ColorType::CMYK(8));
assert_eq!(d.get_tag_unsigned::<u16>(Tag::SamplesPerPixel).unwrap(), 4);
assert_eq!(d.get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 5, "separated");
assert_eq!(d.get_tag_unsigned::<u16>(Tag::Unknown(332)).unwrap(), 1, "CMYK inks");
assert_eq!(pixels(&cmyk), inks);
let d = ifd0(&cmyk);
assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(4));
assert_eq!(d.u16(tags::PHOTOMETRIC), Some(5), "separated");
assert_eq!(d.u16(332), Some(1), "CMYK inks");
let cmyk_img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &cmyk).unwrap();
assert_eq!(cmyk_img.data(), inks);
}
#[test]
@@ -106,16 +116,18 @@ fn resolution_tags_and_the_profile() {
let px = art(4, 4, false);
for (ppi, rational) in [(300.0, (300, 1)), (72.5, (7250, 100))] {
let file = t::encode_samples(&px, 4, 4, ppi, &opts(ColorModel::Rgb)).unwrap();
let mut d = decoder(&file);
for tag in [Tag::XResolution, Tag::YResolution] {
assert_eq!(d.get_tag(tag).unwrap(), tiff::decoder::ifd::Value::Rational(rational.0, rational.1));
let d = ifd0(&file);
for tag in [tags::X_RESOLUTION, tags::Y_RESOLUTION] {
let Some(craft_tiff::Value::Rational(v)) = d.value(tag) else { panic!("rational") };
assert_eq!(v.as_slice(), &[rational]);
}
assert_eq!(d.get_tag_unsigned::<u16>(Tag::ResolutionUnit).unwrap(), 2, "inches");
assert_eq!(d.u16(tags::RESOLUTION_UNIT), Some(2), "inches");
}
for model in ColorModel::ALL {
let src = if model == ColorModel::Cmyk { vec![0; 64] } else { px.clone() };
let file = t::encode_samples(&src, 4, 4, 72.0, &TiffOptions { embed_icc: true, ..opts(model) }).unwrap();
let icc = decoder(&file).get_tag_u8_vec(Tag::IccProfile).unwrap();
let ifd = ifd0(&file);
let icc = ifd.bytes(tags::ICC_PROFILE).unwrap();
assert_eq!(&icc[36..40], b"acsp", "{model:?}: an ICC profile");
let space: &[u8; 4] = match model {
ColorModel::Rgb => b"RGB ",
@@ -125,7 +137,7 @@ fn resolution_tags_and_the_profile() {
assert_eq!(&icc[16..20], space, "{model:?}");
}
let file = t::encode_samples(&px, 4, 4, 72.0, &opts(ColorModel::Rgb)).unwrap();
assert!(decoder(&file).find_tag(Tag::IccProfile).unwrap().is_none());
assert!(ifd0(&file).value(tags::ICC_PROFILE).is_none());
}
#[test]
@@ -135,10 +147,10 @@ fn big_images_take_several_strips_in_either_order() {
for order in ByteOrder::ALL {
for lzw in [false, true] {
let file = t::encode_samples(&px, w, h, 72.0, &TiffOptions { lzw, byte_order: order, ..opts(ColorModel::Gray) }).unwrap();
let mut d = decoder(&file);
let offsets = d.get_tag_u32_vec(Tag::StripOffsets).unwrap();
let d = ifd0(&file);
let offsets = tag_u32_vec(&d, tags::STRIP_OFFSETS).unwrap();
// 300 bytes a row: 27 rows a strip.
assert_eq!(d.get_tag_u32(Tag::RowsPerStrip).unwrap(), 27);
assert_eq!(d.u32(tags::ROWS_PER_STRIP), Some(27));
assert_eq!(offsets.len(), 4);
assert!(offsets.iter().all(|o| o % 2 == 0), "strips start on a word");
assert_eq!(pixels(&file).len(), (w * h) as usize, "{order:?} lzw {lzw}");
@@ -152,11 +164,19 @@ fn packbits_and_bilevel_images_decode() {
let rows = [0b1010_1010u8, 0b1100_0000, 0xFF, 0xC0, 0, 0];
let image = Image { width: 10, height: 3, data: &rows, photometric: Photometric::WhiteIsZero, samples: 1, bits: 1, alpha: false };
let file = t::write(&image, &Layout { byte_order: ByteOrder::Big, compression: Compression::PackBits, ppi: 72.0, icc: None }).unwrap();
let mut d = decoder(&file);
assert_eq!(d.colortype().unwrap(), tiff::ColorType::Gray(1));
assert_eq!(d.get_tag_unsigned::<u16>(Tag::Compression).unwrap(), 32773);
let d = ifd0(&file);
assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(1));
assert_eq!(tag_u16_vec(&d, tags::BITS_PER_SAMPLE).as_deref(), Some([1].as_slice()));
assert_eq!(d.u16(tags::COMPRESSION), Some(32773));
// The decoder turns WhiteIsZero into black-is-zero.
assert_eq!(pixels(&file), rows.map(|b| !b));
let dec = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &file).unwrap();
assert_eq!(dec.width(), 10);
assert_eq!(dec.height(), 3);
assert_eq!(
dec.data(),
[0, 255, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"WhiteIsZero samples are inverted and row padding is ignored"
);
// A buffer that doesn't match the size is refused, not written.
let short = Image { data: &rows[..5], ..image };
assert!(t::write(&short, &Layout { byte_order: ByteOrder::Little, compression: Compression::None, ppi: 72.0, icc: None }).is_err());
+173 -32
View File
@@ -258,9 +258,13 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option<Reduction> {
return None;
}
let to = fitted_dims(w, h, allowance)?;
// A PNG that isn't interlaced is read a row at a time when the reader's buffers fit the allowance.
// A PNG that isn't interlaced or animated (and upright in pre-pixel EXIF) is read a row at a time when the reader's buffers fit the allowance.
let row_scratch = if format == image::ImageFormat::Png {
png_reader(bytes).filter(|r| !r.info().interlaced).and_then(|r| png_row_scratch(&r)).filter(|&s| s <= allowance)
let streamed = craft_image_codecs::PngDecodeRows::new_streaming(bytes, &png_streaming_limits(allowance))
.ok()
.filter(|dec| dec.streamable())
.and_then(|dec| png_row_scratch(&dec.header()));
streamed.or_else(|| png_row_scratch_from_header(bytes, w, h, d.total_bytes())).filter(|&s| s <= allowance)
} else {
None
};
@@ -272,6 +276,13 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option<Reduction> {
(rows || d.total_bytes() <= allowance).then_some(Reduction { size: (w, h), to, rows, scratch })
}
/// Streaming retains rows, not the declared full image. Preserve the renderer's source-pixel
/// allowance even when RGBA16's declared samples exceed the shared whole-image default budget.
/// Row scratch and the reduced pixmap are admitted separately before decoding starts.
fn png_streaming_limits(source_pixels: u64) -> craft_image_codecs::Limits {
craft_image_codecs::Limits { max_width: u32::MAX, max_height: u32::MAX, max_pixels: source_pixels, max_alloc: source_pixels.saturating_mul(8) }
}
/// How many rows of an image decoded whole [`reduced_pixmap`] converts to RGBA at a time.
const BAND: u32 = 16;
@@ -283,17 +294,58 @@ const PNG_KEPT_ROWS: u64 = 128 << 10;
/// stream buffer grows by.
const PNG_WINDOW_AND_STEP: u64 = (32 << 10) + (8 << 10);
/// The most memory `r` takes in its buffers to read its image a row at a time: the stream buffer
/// holds the rows already read up to [`PNG_KEPT_ROWS`] or four rows, whichever is more, the previous
/// and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer grows by doubling its reservation,
/// and while it grows its old reservation and the new one, up to twice the size it holds, are both
/// allocated: three times that size at most. A copy of a stored row and the row given out come on
/// top. `None` when a row given out doesn't fit memory.
fn png_row_scratch(r: &png::Reader<std::io::Cursor<&[u8]>>) -> Option<u64> {
let info = r.info();
// A filter byte and the row's samples, in whole bytes.
let stored = 1 + (u64::from(info.width) * info.color_type.samples() as u64 * info.bit_depth as u64).div_ceil(8);
let out = u64::try_from(r.output_line_size(info.width)?).ok()?;
/// The most memory `craft-image-codecs`'s PNG row decoder takes in its buffers to read its image
/// a row at a time: the stream buffer holds the rows already read up to [`PNG_KEPT_ROWS`] or four
/// rows, whichever is more, the previous and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer
/// grows by doubling its reservation, and while it grows its old reservation and the new one, up
/// to twice the size it holds, are both allocated: three times that size at most. A copy of a
/// stored row and the row given out come on top. `None` when a row given out doesn't fit memory.
fn png_row_scratch(hdr: &craft_image_codecs::PngHeader) -> Option<u64> {
let out = u64::from(hdr.width).checked_mul(hdr.layout.channels() as u64)?.checked_mul(hdr.sample.bytes() as u64)?;
png_row_scratch_for_row(out)
}
/// The row decoder rejects a truncated PNG's tiny IDAT before it can report its header. For a
/// size estimate, the image decoder still gives us the declared output bytes; use those only when
/// the PNG header says it is a static, non-interlaced image, the format the renderer can stream.
fn png_row_scratch_from_header(bytes: &[u8], width: u32, height: u32, total_bytes: u64) -> Option<u64> {
const SIG: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
if bytes.get(..8)? != SIG || height == 0 || !total_bytes.is_multiple_of(u64::from(height)) {
return None;
}
let mut at = 8usize;
let mut header = false;
loop {
let len = u32::from_be_bytes(bytes.get(at..at + 4)?.try_into().ok()?) as usize;
let kind = bytes.get(at + 4..at + 8)?;
let data_at = at.checked_add(8)?;
let end = data_at.checked_add(len)?;
let data = bytes.get(data_at..end)?;
match kind {
b"IHDR" if len == 13 && !header => {
header = true;
if at != 8
|| u32::from_be_bytes(data[..4].try_into().ok()?) != width
|| u32::from_be_bytes(data[4..8].try_into().ok()?) != height
|| data[12] != 0
{
return None;
}
}
b"acTL" | b"eXIf" => return None,
b"IDAT" if header => {
let row = total_bytes / u64::from(height);
return row.checked_add(1).and_then(png_row_scratch_for_row);
}
_ => {}
}
at = end.checked_add(4)?; // CRC
}
}
fn png_row_scratch_for_row(out: u64) -> Option<u64> {
// A filter byte and the row's samples.
let stored = 1u64.checked_add(out)?;
let kept = (stored * 4).next_multiple_of(64).max(PNG_KEPT_ROWS);
let stream = kept + stored * 2 + PNG_WINDOW_AND_STEP;
Some(stream * 3 + stored + out)
@@ -313,14 +365,6 @@ fn fitted_dims(w: u32, h: u32, allowance: u64) -> Option<(u32, u32)> {
(u64::from(nw) * u64::from(nh) * 4 <= allowance).then_some((nw, nh))
}
/// A PNG reader of `bytes` that gives 8-bit gray, gray with alpha, RGB or RGBA rows, with its
/// header and the chunks before the pixels read.
fn png_reader(bytes: &[u8]) -> Option<png::Reader<std::io::Cursor<&[u8]>>> {
let mut d = png::Decoder::new(std::io::Cursor::new(bytes));
d.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
d.read_info().ok()
}
/// `bytes` decoded at the size [`reduction`] gives. Each pixel of the result is the mean of the
/// image's pixels it covers. [`Undecoded::Unreadable`] when they can't be read, and
/// [`Undecoded::NoRoom`] when the result and what reading takes ([`Reduction::scratch`]) don't fit
@@ -335,19 +379,64 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result<vello_cpu::Pixmap, Unde
/// `bytes` read and shrunk as [`reduced_pixmap`] says.
fn shrunk(bytes: &[u8], r: &Reduction) -> Option<vello_cpu::Pixmap> {
shrunk_with_png_limits(bytes, r, &png_streaming_limits(allowance()))
}
fn shrunk_with_png_limits(bytes: &[u8], r: &Reduction, png_limits: &craft_image_codecs::Limits) -> Option<vello_cpu::Pixmap> {
let mut shrink = Shrink::new(r.size, r.to);
if r.rows {
let mut reader = png_reader(bytes)?;
let (color, _) = reader.output_color_type();
while let Some(row) = reader.next_row().ok()? {
let data = row.data();
match color {
png::ColorType::Grayscale => shrink.row(data.iter().map(|&g| [g, g, g, 255])),
png::ColorType::GrayscaleAlpha => shrink.row(data.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])),
png::ColorType::Rgb => shrink.row(data.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])),
png::ColorType::Rgba => shrink.row(data.as_chunks::<4>().0.iter().copied()),
png::ColorType::Indexed => return None,
}
use craft_image_codecs::{ChannelLayout, CodecError, Format, PngDecodeRows, SampleType};
let mut dec = PngDecodeRows::new_streaming(bytes, png_limits).ok()?;
if !dec.streamable() {
return None;
}
let hdr = dec.header();
// Yield after each row and stop once all pixels have arrived. The pinned decoder's
// first tail batch only locates a text chunk; later batches inflate/retain its value.
// Never run those batches: the renderer needs pixels, not ancillary text. Its underlying
// PNG reader also ignores text chunks before IDAT, so neither location retains text.
for _ in 0..hdr.height {
let _extras = dec
.step_stream::<CodecError>(1, &mut || false, &mut |_y, row| {
match (hdr.layout, hdr.sample) {
(ChannelLayout::Gray, SampleType::U8) => shrink.row(row.iter().map(|&g| [g, g, g, 255])),
(ChannelLayout::GrayA, SampleType::U8) => shrink.row(row.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])),
(ChannelLayout::Rgb, SampleType::U8) => shrink.row(row.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])),
(ChannelLayout::Rgba, SampleType::U8) => shrink.row(row.as_chunks::<4>().0.iter().copied()),
(ChannelLayout::Gray, SampleType::U16) => shrink.row(row.as_chunks::<2>().0.iter().map(|&g| {
let v = (u16::from_ne_bytes(g) >> 8) as u8;
[v, v, v, 255]
})),
(ChannelLayout::GrayA, SampleType::U16) => shrink.row(row.as_chunks::<4>().0.iter().map(|&[g0, g1, a0, a1]| {
let g = (u16::from_ne_bytes([g0, g1]) >> 8) as u8;
let a = (u16::from_ne_bytes([a0, a1]) >> 8) as u8;
[g, g, g, a]
})),
(ChannelLayout::Rgb, SampleType::U16) => shrink.row(row.as_chunks::<6>().0.iter().map(|&[r0, r1, g0, g1, b0, b1]| {
[
(u16::from_ne_bytes([r0, r1]) >> 8) as u8,
(u16::from_ne_bytes([g0, g1]) >> 8) as u8,
(u16::from_ne_bytes([b0, b1]) >> 8) as u8,
255,
]
})),
(ChannelLayout::Rgba, SampleType::U16) => {
shrink.row(row.as_chunks::<8>().0.iter().map(|&[r0, r1, g0, g1, b0, b1, a0, a1]| {
[
(u16::from_ne_bytes([r0, r1]) >> 8) as u8,
(u16::from_ne_bytes([g0, g1]) >> 8) as u8,
(u16::from_ne_bytes([b0, b1]) >> 8) as u8,
(u16::from_ne_bytes([a0, a1]) >> 8) as u8,
]
}))
}
_ => {
return Err(CodecError::Unsupported { format: Format::Png, reason: "unsupported PNG channel layout".into() });
}
}
Ok(())
})
.ok()?;
}
} else {
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
@@ -589,6 +678,58 @@ mod tests {
assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(()));
}
#[test]
fn streaming_header_accepts_rgba16_above_whole_image_default() {
// Header-only fixture: opening the stream checks declared size without allocating or
// decoding the 1.2 GB canvas. Padding meets the decoder's compressed-size plausibility
// bound; no rows are requested from this deliberately incomplete image.
let (w, h) = (10_000u32, 15_000u32);
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
let ihdr = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[16, 6, 0, 0, 0]].concat();
let mut idat = b"IDAT\x78\x9c".to_vec();
idat.resize(1_200_000, 0);
for chunk in [&ihdr[..], &idat, b"IEND"] {
bytes.extend((chunk.len() as u32 - 4).to_be_bytes());
bytes.extend(chunk);
let mut crc = flate2::Crc::new();
crc.update(chunk);
bytes.extend(crc.sum().to_be_bytes());
}
let default = craft_image_codecs::Limits { max_alloc: 1 << 30, max_pixels: 150_000_000, ..Default::default() };
assert!(craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &default).is_err());
let rows = craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &png_streaming_limits(150_000_000)).unwrap();
assert_eq!((rows.header().width, rows.header().height), (w, h));
assert!(rows.streamable());
assert!(craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &png_streaming_limits(149_999_999)).is_err());
}
#[test]
fn streaming_reduction_does_not_retain_text_after_pixels() {
let mut bytes = Vec::new();
striped(8).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
let mut chunk = b"tEXtComment\0".to_vec();
chunk.extend(std::iter::repeat_n(b'x', 1 << 20));
// PNG's IEEE CRC over type and payload, kept local to this fixture.
let mut crc = u32::MAX;
for &byte in &chunk {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xedb88320 & 0u32.wrapping_sub(crc & 1));
}
}
let mut tail = ((chunk.len() - 4) as u32).to_be_bytes().to_vec();
tail.extend(chunk);
tail.extend((!crc).to_be_bytes());
bytes.splice(bytes.len() - 12..bytes.len() - 12, tail);
let limits = craft_image_codecs::Limits { max_alloc: 1 << 19, ..Default::default() };
let mut full = craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &limits).unwrap();
assert!(full.step_stream::<craft_image_codecs::CodecError>(1, &mut || true, &mut |_, _| Ok(())).is_err());
// The renderer stops after the pixels instead of retaining ancillary text.
let reduced = shrunk_with_png_limits(&bytes, &Reduction { size: (8, 1), to: (4, 1), rows: true, scratch: 0 }, &limits).unwrap();
assert_eq!((reduced.width(), reduced.height()), (4, 1));
assert!(red_at_its_coverage(&reduced));
}
/// Opaque red beside clear black, every other column of a `w` × 1 image.
fn striped(w: u32) -> image::RgbaImage {
image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })
+6 -6
View File
@@ -1883,14 +1883,14 @@ fn a_png_past_the_allowance_draws_at_reduced_size() {
assert_eq!((pm.width(), pm.height(), [c.r, c.g, c.b, c.a]), (300, 300, [200, 40, 40, 255]));
// A row is read only while the reader's buffers fit the allowance, counting the stream buffer
// three times for the moment it grows. Below 4,096 columns of 16-bit RGBA they take 516,103
// bytes and 60 a column: 1,398 columns take 599,983 bytes, within 600,000, and 1,399 columns
// take 600,043 and are not decoded (their samples, 8 bytes a pixel, are past it too).
// bytes plus 64 bytes a column: 1,310 columns take 599,943 bytes, within 600,000, and 1,311
// columns take 600,007 and are not decoded (their samples, 8 bytes a pixel, are past it too).
let wide = |w: u32| {
let img = image::ImageBuffer::<image::Rgba<u16>, _>::from_pixel(w, 60, image::Rgba([51400, 10280, 10280, 65535]));
encoded(image::DynamicImage::ImageRgba16(img), image::ImageFormat::Png)
};
assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1398))), Some((1398, 60)));
assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1399))), None);
assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1310))), Some((1310, 60)));
assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1311))), None);
// An image of more pixels than the allowance has bytes is not read: 1,000 × 400 8-bit gray
// pixels are drawn at 500 × 200 within 400,000 bytes, and 1,000 × 401 are not.
let gray = |h: u32| encoded(image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(1000, h, image::Luma([90]))), image::ImageFormat::Png);
@@ -1938,13 +1938,13 @@ fn a_reduced_decode_counts_what_reading_takes_beside_the_images_paints_hold() {
// Decoded whole: 14,400 bytes of RGB and a 16-row band of 8,960 bytes, beside a 73 × 54 result
// of 15,768 bytes.
let jpeg = encoded(image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(80, 60, image::Rgb([30, 120, 200]))), image::ImageFormat::Jpeg);
// Read a row at a time: 540,103 bytes in the reader's buffers for 400 columns of 16-bit RGBA,
// Read a row at a time: 541,703 bytes in the reader's buffers for 400 columns of 16-bit RGBA,
// beside a 387 × 387 result of 599,076 bytes.
let png = encoded(
image::DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel(400, 400, image::Rgba([51400, 10280, 10280, 65535]))),
image::ImageFormat::Png,
);
for (bytes, allowance, room, size) in [(&jpeg, 16_000, 15_768 + 14_400 + 8_960, (73, 54)), (&png, 600_000, 599_076 + 540_103, (387, 387))] {
for (bytes, allowance, room, size) in [(&jpeg, 16_000, 15_768 + 14_400 + 8_960, (73, 54)), (&png, 600_000, 599_076 + 541_703, (387, 387))] {
let failed = decode_failures();
let none = with_allowance(allowance, || with_room(room - 1, || paint::decode_pixmap_or_why(bytes)));
assert_eq!(none.err(), Some(paint::Undecoded::NoRoom), "not decoded with a byte too few, and tried again later");
+3 -1
View File
@@ -11,9 +11,11 @@ workspace = true
[dependencies]
vectorcraft-geom = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
craft-image-codecs = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
# Test fixtures only: the JPEG-block test needs the block artifacts of the encoder it was tuned on.
image = { workspace = true }
+104 -32
View File
@@ -3,7 +3,7 @@
use vectorcraft_geom::{Affine, PathData};
use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels};
use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels, trace_header};
use crate::{Mode, Raster, TraceError, TraceParams, trace_within};
/// The most pixels [`opaque_outline`] traces: a larger image is reduced to this many first,
@@ -24,26 +24,49 @@ pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20;
/// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`],
/// gives [`TraceError::TooLarge`] before it is decoded.
pub fn decode_alpha(bytes: &[u8]) -> Result<Option<(u32, u32, Vec<u8>)>, TraceError> {
use image::ImageDecoder;
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
let decoder = reader.into_decoder().map_err(decode_err)?;
let (w, h) = decoder.dimensions();
use craft_image_codecs::{ChannelLayout, DecodeOptions, Limits, RasterColorType, SampleType, decode_with};
let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string());
if let Some(info) = trace_header(bytes, MAX_PIXELS, ALPHA_MAX_DECODE_BYTES)? {
if info.width == 0 || info.height == 0 {
return Err(TraceError::Empty);
}
let might_have_alpha = match info.color_type {
RasterColorType::GrayAlpha | RasterColorType::Rgba | RasterColorType::CmykAlpha => true,
RasterColorType::Rgb => info.channels > 3,
RasterColorType::Gray => info.channels > 1,
// The shared TIFF inspector reports the base photometric type even
// when an ExtraSamples channel carries alpha.
RasterColorType::Cmyk => info.channels > 4,
RasterColorType::Lab => info.channels > 3,
RasterColorType::Indexed => true,
_ => false,
};
if !might_have_alpha {
return Ok(None);
}
let pixels = u64::from(info.width) * u64::from(info.height);
let frame = gif_first_frame_pixels(bytes).unwrap_or(0);
let total_bytes = pixels.saturating_mul(u64::from(info.channels)).saturating_mul(u64::from(info.bits_per_sample.max(8).div_ceil(8)));
if pixels.max(frame) > MAX_PIXELS || total_bytes > ALPHA_MAX_DECODE_BYTES {
return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 });
}
} else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) {
return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 });
}
let opts =
DecodeOptions { keep_orientation: true, limits: Limits { max_pixels: MAX_PIXELS, max_alloc: ALPHA_MAX_DECODE_BYTES, ..Limits::default() } };
let img = decode_with(bytes, &opts).map_err(decode_err)?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 {
return Err(TraceError::Empty);
}
if !decoder.color_type().has_alpha() {
if !img.layout().has_alpha() {
return Ok(None);
}
let pixels = u64::from(w) * u64::from(h);
let frame = gif_first_frame_pixels(bytes).unwrap_or(0);
if pixels.max(frame) > MAX_PIXELS || decoder.total_bytes() > ALPHA_MAX_DECODE_BYTES {
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 });
}
let alpha: Vec<u8> = match image::DynamicImage::from_decoder(decoder).map_err(decode_err)? {
image::DynamicImage::ImageRgba8(img) => img.as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(),
image::DynamicImage::ImageLumaA8(img) => img.as_raw().as_chunks::<2>().0.iter().map(|p| p[1]).collect(),
img => img.to_rgba8().as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(),
let alpha: Vec<u8> = match (img.layout(), img.sample_type()) {
(ChannelLayout::Rgba, SampleType::U8) => img.data().as_chunks::<4>().0.iter().map(|p| p[3]).collect(),
(ChannelLayout::GrayA, SampleType::U8) => img.data().as_chunks::<2>().0.iter().map(|p| p[1]).collect(),
_ => img.to_rgba8().as_chunks::<4>().0.iter().map(|p| p[3]).collect(),
};
Ok(Some((w, h, alpha)))
}
@@ -265,28 +288,77 @@ mod tests {
}
/// `img` encoded as `format`.
fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec<u8> {
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap();
out
fn encoded(img: &craft_image_codecs::Image, format: craft_image_codecs::Format) -> Vec<u8> {
craft_image_codecs::encode(img, format, &Default::default()).unwrap()
}
#[test]
fn decode_alpha_reads_the_alpha_of_formats_that_have_one() {
let px = |x: u32, y: u32| image::Rgba([10, 20, 30, u8::try_from((x + 4 * y) * 16).unwrap()]);
let rgba = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, px));
let want: Vec<u8> = (0..16).map(|i| u8::try_from(i * 16).unwrap()).collect();
for (name, img) in [
("rgba", rgba.clone()),
("gray and alpha", image::DynamicImage::ImageLumaA8(rgba.to_luma_alpha8())),
("16 bits", image::DynamicImage::ImageRgba16(rgba.to_rgba16())),
] {
let got = decode_alpha(&encoded(img, image::ImageFormat::Png)).unwrap();
use craft_image_codecs::{ChannelLayout, Format, Image, SampleType};
let mut rgba_bytes = Vec::with_capacity(4 * 4 * 4);
let mut luma_a_bytes = Vec::with_capacity(4 * 4 * 2);
let mut rgba16_bytes = Vec::with_capacity(4 * 4 * 8);
let mut rgb_bytes = Vec::with_capacity(4 * 4 * 3);
let mut want = Vec::with_capacity(16);
for y in 0..4u32 {
for x in 0..4u32 {
let a = u8::try_from((x + 4 * y) * 16).unwrap();
want.push(a);
rgba_bytes.extend_from_slice(&[10, 20, 30, a]);
luma_a_bytes.extend_from_slice(&[18, a]);
let a16 = u16::from(a) * 257;
for v in [10u16 * 257, 20 * 257, 30 * 257, a16] {
rgba16_bytes.extend_from_slice(&v.to_ne_bytes());
}
rgb_bytes.extend_from_slice(&[10, 20, 30]);
}
}
let rgba = Image::from_u8(4, 4, ChannelLayout::Rgba, rgba_bytes).unwrap();
let luma_a = Image::from_u8(4, 4, ChannelLayout::GrayA, luma_a_bytes).unwrap();
let rgba16 = Image::from_raw(4, 4, ChannelLayout::Rgba, SampleType::U16, rgba16_bytes).unwrap();
for (name, img) in [("rgba", rgba), ("gray and alpha", luma_a), ("16 bits", rgba16)] {
let got = decode_alpha(&encoded(&img, Format::Png)).unwrap();
assert_eq!(got, Some((4, 4, want.clone())), "{name}");
}
let rgb = image::DynamicImage::ImageRgb8(rgba.to_rgb8());
assert_eq!(decode_alpha(&encoded(rgb.clone(), image::ImageFormat::Png)).unwrap(), None);
assert_eq!(decode_alpha(&encoded(rgb, image::ImageFormat::Jpeg)).unwrap(), None);
let rgb = Image::from_u8(4, 4, ChannelLayout::Rgb, rgb_bytes).unwrap();
assert_eq!(decode_alpha(&encoded(&rgb, Format::Png)).unwrap(), None);
assert_eq!(decode_alpha(&encoded(&rgb, Format::Jpeg)).unwrap(), None);
assert!(decode_alpha(b"not an image").is_err());
}
#[test]
fn decode_alpha_expands_gray_and_rgb_png_transparency_keys() {
// Original synthetic 2x1 PNGs: first pixel matches tRNS; second is opaque.
// Generated from PNG chunks and zlib scanlines, without an external image asset.
let gray: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, 0, 1, 8, 0, 0, 0, 0, 209, 73, 32, 86, 0, 0, 0, 2, 116,
82, 78, 83, 0, 0, 118, 147, 205, 56, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 168, 7, 0, 0, 130, 0, 128, 32, 18, 25, 54, 0, 0, 0,
0, 73, 69, 78, 68, 174, 66, 96, 130,
];
let rgb: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0, 123, 64, 232, 221, 0, 0, 0, 6, 116,
82, 78, 83, 0, 255, 0, 0, 0, 0, 164, 194, 192, 29, 0, 0, 0, 15, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 192, 240, 159, 1, 0, 7, 255,
1, 255, 1, 127, 137, 167, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
for bytes in [gray, rgb] {
assert_eq!(decode_alpha(bytes).unwrap(), Some((2, 1, vec![0, 255])));
}
}
#[test]
fn decode_alpha_preserves_cmyk_and_lab_tiff_extra_samples() {
use craft_image_codecs::{ChannelLayout, Format, Image};
for (layout, samples) in
[(ChannelLayout::CmykA, vec![10, 20, 30, 40, 0, 50, 60, 70, 80, 255]), (ChannelLayout::LabA, vec![128, 128, 128, 0, 192, 128, 128, 255])]
{
let img = Image::from_u8(2, 1, layout, samples).unwrap();
assert_eq!(decode_alpha(&encoded(&img, Format::Tiff)).unwrap(), Some((2, 1, vec![0, 255])), "{layout:?}");
}
for (layout, samples) in
[(ChannelLayout::Cmyk, vec![10, 20, 30, 40, 50, 60, 70, 80]), (ChannelLayout::Lab, vec![128, 128, 128, 192, 128, 128])]
{
let img = Image::from_u8(2, 1, layout, samples).unwrap();
assert_eq!(decode_alpha(&encoded(&img, Format::Tiff)).unwrap(), None, "{layout:?}");
}
}
}
+121 -37
View File
@@ -28,6 +28,68 @@ pub const MAX_PIXELS: u64 = 64 << 20;
/// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused.
pub const MAX_SOURCE_PIXELS: u64 = 192 << 20;
/// Header facts for tracing, without reserving a PNG's full decoded pixel buffer.
/// The shared allocation probe runs before the decoder's plausibility checks so the caller's
/// size refusal wins even for a large header-only file. The streaming reader still checks the
/// PNG structure and plausibility, and reports the expanded layout (including tRNS alpha).
pub(crate) fn trace_header(bytes: &[u8], max_pixels: u64, max_alloc: u64) -> Result<Option<craft_image_codecs::RasterInspection>, TraceError> {
use craft_image_codecs::{ChannelLayout, Format, Limits, PngDecodeRows, RasterColorType, RasterInspection};
let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string());
// Refuse a GIF frame larger than its logical screen before inspection's
// plausibility check can turn the caller's size refusal into a decode error.
if gif_first_frame_pixels(bytes).is_some_and(|pixels| pixels > max_pixels) {
return Err(TraceError::TooLarge { width: 0, height: 0, max: max_pixels >> 20 });
}
if craft_image_codecs::detect(bytes) != Some(Format::Png) {
return craft_image_codecs::inspect_raster(bytes).map_err(decode_err);
}
let (width, height) = craft_image_codecs::probe_allocation_size(bytes).map_err(decode_err)?;
if u64::from(width) * u64::from(height) > max_pixels {
return Err(TraceError::TooLarge { width, height, max: max_pixels >> 20 });
}
// The allocation probe validates the IHDR prefix. For explicit alpha layouts, its
// sample width also gives a lower bound on decoded bytes before plausibility checks.
// Preserve the alpha caller's TooLarge refusal for header-only RGBA16/GrayA images;
// the shared reader still validates CRCs, transparency expansion, and all other layouts.
let alpha_bytes_per_pixel = match bytes.get(24..26) {
Some([8, 4]) => 2,
Some([16, 4]) | Some([8, 6]) => 4,
Some([16, 6]) => 8,
_ => 0,
};
if (u64::from(width) * u64::from(height)).saturating_mul(alpha_bytes_per_pixel) > max_alloc {
return Err(TraceError::TooLarge { width, height, max: max_pixels >> 20 });
}
let limits = Limits { max_pixels, max_alloc, ..Limits::default() };
let rows = PngDecodeRows::new_streaming(bytes, &limits).map_err(decode_err)?;
let h = rows.header();
let color_type = match h.layout {
ChannelLayout::Gray => RasterColorType::Gray,
ChannelLayout::GrayA => RasterColorType::GrayAlpha,
ChannelLayout::Rgb => RasterColorType::Rgb,
ChannelLayout::Rgba => RasterColorType::Rgba,
_ => RasterColorType::Other,
};
Ok(Some(RasterInspection {
width: h.width,
height: h.height,
channels: h.layout.channels() as u8,
bits_per_sample: (h.sample.bytes() * 8) as u8,
color_type,
palette: None,
transparent_index: None,
}))
}
/// Reuse an already-normalized RGBA8 decode instead of allocating a second full pixel buffer.
fn into_rgba8(img: craft_image_codecs::Image) -> Vec<u8> {
if img.layout() == craft_image_codecs::ChannelLayout::Rgba && img.sample_type() == craft_image_codecs::SampleType::U8 {
img.into_data()
} else {
img.to_rgba8()
}
}
/// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1).
pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) {
let pixels = u64::from(w) * u64::from(h);
@@ -158,62 +220,84 @@ impl Raster {
}
/// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding).
pub fn decode(bytes: &[u8]) -> Result<Self, TraceError> {
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
let (w, h) = reader.into_dimensions().map_err(decode_err)?;
if let Some(info) = trace_header(bytes, MAX_PIXELS, craft_image_codecs::Limits::default().max_alloc)? {
if info.width == 0 || info.height == 0 {
return Err(TraceError::Empty);
}
if u64::from(info.width) * u64::from(info.height) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) {
return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 });
}
} else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) {
return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 });
}
let opts = craft_image_codecs::DecodeOptions {
// Match rendering and trace placement: coordinates refer to stored pixels.
keep_orientation: true,
limits: craft_image_codecs::Limits { max_pixels: MAX_PIXELS, ..craft_image_codecs::Limits::default() },
};
let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 {
return Err(TraceError::Empty);
}
if u64::from(w) * u64::from(h) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) {
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 });
}
let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8();
let (w, h) = img.dimensions();
Ok(Self { width: w, height: h, rgba: img.into_raw() })
Ok(Self { width: w, height: h, rgba: into_rgba8(img) })
}
/// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to
/// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within
/// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly
/// less than its own resolution. The caller maps the result back with `width` / `height`.
pub fn decode_to_fit(bytes: &[u8]) -> Result<Self, TraceError> {
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
let (w, h) = reader.into_dimensions().map_err(decode_err)?;
if w == 0 || h == 0 {
return Err(TraceError::Empty);
}
let pixels = u64::from(w) * u64::from(h);
if pixels <= MAX_PIXELS {
return Self::decode(bytes);
}
if pixels > MAX_SOURCE_PIXELS {
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 });
if let Some(info) = trace_header(bytes, MAX_SOURCE_PIXELS, if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 })? {
if info.width == 0 || info.height == 0 {
return Err(TraceError::Empty);
}
let pixels = u64::from(info.width) * u64::from(info.height);
if pixels <= MAX_PIXELS && !gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) {
return Self::decode(bytes);
}
if pixels > MAX_SOURCE_PIXELS {
return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_SOURCE_PIXELS >> 20 });
}
if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) {
return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_SOURCE_PIXELS >> 20 });
}
} else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) {
return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_SOURCE_PIXELS >> 20 });
}
// Decoded within the allocation the source limit needs; the web keeps file import's 512 MB.
// A deeper image (16 bits a channel) that needs more fails with a decode error.
if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) {
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 });
let opts = craft_image_codecs::DecodeOptions {
// Match rendering and trace placement: coordinates refer to stored pixels.
keep_orientation: true,
limits: craft_image_codecs::Limits {
max_pixels: MAX_SOURCE_PIXELS,
max_alloc: if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 },
..craft_image_codecs::Limits::default()
},
};
let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 {
return Err(TraceError::Empty);
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 });
reader.limits(limits);
Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS))
let rgba = into_rgba8(img);
if u64::from(w) * u64::from(h) <= MAX_PIXELS {
return Ok(Self { width: w, height: h, rgba });
}
Ok(Self::fitted(&Self { width: w, height: h, rgba }, MAX_PIXELS))
}
/// `img` scaled down, aspect kept, to the largest size of at most `max` pixels.
pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self {
let (w, h) = img.dimensions();
let (nw, nh) = fit_size(w, h, max);
pub(crate) fn fitted(img: &Self, max: u64) -> Self {
let (nw, nh) = fit_size(img.width, img.height, max);
// Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive.
Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) }
Self { width: nw, height: nh, rgba: box_reduced(&img.rgba, img.width, img.height, nw, nh) }
}
/// Encode as PNG.
pub fn encode_png(&self) -> Vec<u8> {
let mut out = Vec::new();
if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) {
let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png);
}
out
let Ok(img) = craft_image_codecs::Image::from_u8(self.width, self.height, craft_image_codecs::ChannelLayout::Rgba, self.rgba.clone()) else {
return Vec::new();
};
craft_image_codecs::encode(&img, craft_image_codecs::Format::Png, &Default::default()).unwrap_or_default()
}
/// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose
/// fields were set to a size its bytes don't have).
+74 -2
View File
@@ -1123,7 +1123,7 @@ fn scaling_down_weighs_color_by_alpha() {
// Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit`
// does: each scaled pixel covers a red and a clear one.
let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] });
let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8();
let decoded = Raster::decode(&half_clear.encode_png()).unwrap();
let fitted = Raster::fitted(&decoded, 2);
assert_eq!((fitted.width, fitted.height), (2, 1));
assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat());
@@ -2174,7 +2174,8 @@ fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() {
}
}
/// `img` saved as a JPEG of `quality` and read back.
/// `img` saved as a JPEG of `quality` and read back. The `image` crate's encoder stays the fixture:
/// `a_jpegs_blocks_are_not_traced_as_steps` measures block artifacts, which differ by encoder.
fn through_jpeg(img: &Raster, quality: u8) -> Raster {
let rgb: Vec<u8> = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
let mut bytes = vec![];
@@ -4033,4 +4034,75 @@ fn a_gif_frame_larger_than_its_screen_is_refused_before_decoding() {
gif.extend([2, 1, 0, 0, 0x3B]);
assert!(matches!(Raster::decode(&gif), Err(TraceError::TooLarge { .. })), "{:?}", Raster::decode(&gif).err());
assert!(matches!(Raster::decode_to_fit(&gif), Err(TraceError::TooLarge { .. })));
assert!(matches!(decode_alpha(&gif), Err(TraceError::TooLarge { .. })));
}
// Synthetic PNG chunks for transparency/orientation regression fixtures.
fn png_test_chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut out = (data.len() as u32).to_be_bytes().to_vec();
out.extend(kind);
out.extend(data);
let mut crc = !0u32;
for &b in &out[4..] {
crc ^= u32::from(b);
for _ in 0..8 {
crc = if crc & 1 != 0 { 0xedb8_8320 ^ (crc >> 1) } else { crc >> 1 };
}
}
out.extend((!crc).to_be_bytes());
out
}
#[test]
fn alpha_decode_keeps_rgb_and_grayscale_png_transparency_keys() {
use craft_image_codecs::{ChannelLayout, Format, Image};
for (layout, samples, key) in
[(ChannelLayout::Rgb, vec![10, 20, 30, 40, 50, 60], vec![0, 10, 0, 20, 0, 30]), (ChannelLayout::Gray, vec![10, 40], vec![0, 10])]
{
let image = Image::from_u8(2, 1, layout, samples).unwrap();
let mut png = craft_image_codecs::encode(&image, Format::Png, &Default::default()).unwrap();
// A transparency key precedes IDAT, immediately after the IHDR chunk.
png.splice(33..33, png_test_chunk(b"tRNS", &key));
assert_eq!(decode_alpha(&png).unwrap(), Some((2, 1, vec![0, 255])));
}
}
#[test]
fn trace_and_alpha_keep_stored_pixels_with_exif_orientation() {
use craft_image_codecs::{ChannelLayout, Format, Image};
// Little-endian TIFF IFD containing Orientation=6 (90 degrees clockwise).
let exif = [b'I', b'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 1, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0];
let raster = Raster::from_fn(3, 2, |x, y| [x as u8 * 60, y as u8 * 90, 17, (x + 3 * y) as u8 * 40]);
let mut png = raster.encode_png();
png.splice(33..33, png_test_chunk(b"eXIf", &exif));
for actual in [Raster::decode(&png).unwrap(), Raster::decode_to_fit(&png).unwrap()] {
assert_eq!((actual.width, actual.height), (3, 2));
assert_eq!(actual.rgba, raster.rgba);
}
assert_eq!(decode_alpha(&png).unwrap(), Some((3, 2, raster.rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect())));
let image =
Image::from_u8(3, 2, ChannelLayout::Rgb, raster.rgba.as_chunks::<4>().0.iter().flat_map(|p| p[..3].iter().copied()).collect()).unwrap();
let mut jpeg = craft_image_codecs::encode(&image, Format::Jpeg, &Default::default()).unwrap();
let expected = Raster::decode(&jpeg).unwrap();
let mut app1 = vec![0xff, 0xe1];
app1.extend(((exif.len() + 8) as u16).to_be_bytes());
app1.extend(b"Exif\0\0");
app1.extend(exif);
jpeg.splice(2..2, app1);
for actual in [Raster::decode(&jpeg).unwrap(), Raster::decode_to_fit(&jpeg).unwrap()] {
assert_eq!((actual.width, actual.height), (3, 2));
assert_eq!(actual.rgba, expected.rgba);
}
}
#[test]
fn alpha_rejects_oversized_png_before_decoding() {
assert!(matches!(decode_alpha(&png_header(30_000, 30_000)), Err(TraceError::TooLarge { .. })));
assert!(matches!(decode_alpha(&png_header(64, 64)), Err(TraceError::Decode(_))));
let mut deep = png_header(7000, 6000);
deep[24] = 16;
let ihdr = png_test_chunk(b"IHDR", &deep[16..29]);
deep.splice(8..33, ihdr);
assert!(matches!(decode_alpha(&deep), Err(TraceError::TooLarge { .. })));
}