Cut over roxmltree, png, tiff, psd, and trace codecs to craft-libs (#7638)

This commit is contained in:
Dylan M. Taylor
2026-11-14 13:49:50 -05:00
committed by GitHub
parent 0adbdce6cb
commit bada8e1710
16 changed files with 562 additions and 393 deletions
Generated
+140 -14
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"
@@ -1079,12 +1115,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",
@@ -1094,6 +1141,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"
@@ -1192,6 +1248,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"
@@ -1704,6 +1769,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"
@@ -2736,6 +2817,7 @@ dependencies = [
"moxcms",
"num-traits",
"png 0.18.1",
"qoi",
"tiff",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
@@ -3189,6 +3271,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"
@@ -3388,6 +3476,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"
@@ -4338,21 +4448,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"
@@ -5723,6 +5833,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"
@@ -6535,6 +6651,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",
@@ -6547,7 +6664,6 @@ dependencies = [
"log",
"proptest",
"rayon",
"roxmltree 0.21.2",
"rustix 1.1.6",
"ruzstd",
"same-file",
@@ -6721,6 +6837,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",
@@ -6731,7 +6848,6 @@ dependencies = [
"md-5",
"objc2-core-foundation",
"objc2-core-graphics",
"roxmltree 0.21.2",
"serde",
"serde_json",
"sha2",
@@ -6772,6 +6888,9 @@ dependencies = [
name = "vectorcraft-render"
version = "0.24.1"
dependencies = [
"craft-image-codecs",
"craft-psd",
"craft-tiff",
"flate2",
"gif 0.14.2",
"image",
@@ -6779,9 +6898,7 @@ dependencies = [
"log",
"png 0.18.1",
"proptest",
"psd",
"serde_json",
"tiff",
"unicode-general-category",
"vectorcraft-brush",
"vectorcraft-color",
@@ -8356,6 +8473,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
@@ -31,6 +31,11 @@ craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e
craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
# DXF tags, reader and writer (craft-libs#778, #926, #927).
craft-dxf = { 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 }
+51 -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,17 @@ 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")));
}
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())
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()
};
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
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}")),
})
}
/// The part that the first relationship of part `source` (`""`: the package) whose type ends in
@@ -220,11 +181,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 +208,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 +240,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 +422,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 +486,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 +509,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 +554,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 +577,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 +598,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 +635,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 +654,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 +669,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 +866,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 +904,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 +938,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");
+20 -25
View File
@@ -198,31 +198,32 @@ impl Library {
/// [`without_doctype`] says.
fn parse_library(xml: &str, cmd: &str) -> 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 = 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 {
let opts = craft_xml::Options {
names: craft_xml::Names::Local,
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 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}")),
})?;
let Some(root) = std::iter::once(&doc).chain(doc.descendants()).find(|n| n.is("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("variableSet").collect();
let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::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)?;
for var in sets.iter().flat_map(|s| s.children("variables")).flat_map(|v| v.children("variable")) {
let name = named(var.attr("varName"), "varName", cmd)?;
if !declared.insert(name.clone()) {
return Err(bad(cmd, format!("variable `{name}` is declared twice")));
}
if declared.len() > MAX_LIBRARY_VARIABLES {
return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables")));
}
match var.attribute("trait") {
match var.attr("trait") {
Some("textcontent") => {
// `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()));
}
@@ -237,10 +238,8 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
}
let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect();
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("sampleDataSets")).flat_map(|d| d.children("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")));
}
@@ -248,8 +247,8 @@ fn parse_library(xml: &str, cmd: &str) -> 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.name.as_str();
if !declared.contains(var) {
return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare")));
}
@@ -259,11 +258,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
if !text_names.contains(var) {
continue;
}
let paras: Vec<String> = el
.children()
.filter(|n| is(n, "p"))
.map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect())
.collect();
let paras: Vec<String> = el.children("p").map(craft_xml::El::deep_text).collect();
if !paras.is_empty() {
values.insert(var.to_string(), DataValue::Text(paras.join("\n")));
}
+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 }
+41 -84
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,14 @@ 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 +75,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 +125,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,13 +143,12 @@ 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)))
.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))],
};
@@ -203,19 +160,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.
+14 -11
View File
@@ -146,7 +146,7 @@ fn unpack(mut src: &[u8], out: &mut Vec<u8>) {
/// The composite as a reader sees it.
fn merged(f: &[u8]) -> Vec<u8> {
::psd::Psd::from_bytes(f).expect("a PSD a reader takes").rgba()
craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes").composite_rgba8().expect("composite").data
}
fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] {
@@ -168,11 +168,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 +201,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 +231,10 @@ 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::layer::SectionType::Other)).count();
let groups = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::layer::SectionType::OpenFolder | craft_psd::layer::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 +303,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]);
}
+56 -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::reader::Ifd {
Tiff::parse(file).expect("a TIFF").ifds.into_iter().next().expect("IFD0")
}
fn tag_u16_vec(ifd: &craft_tiff::reader::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::reader::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").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_INTERPRETATION), 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_INTERPRETATION), 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_INTERPRETATION), 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_INTERPRETATION), 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,17 @@ 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::reader::IfdValue::Rationals(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 icc = ifd0(&file).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 +136,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 +146,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 +163,14 @@ 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);
// 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());
+69 -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)
craft_image_codecs::PngDecodeRows::new_streaming(bytes, &craft_image_codecs::Limits::default())
.ok()
.filter(|dec| dec.streamable())
.and_then(|dec| png_row_scratch(&dec.header()))
.filter(|&s| s <= allowance)
} else {
None
};
@@ -283,17 +287,18 @@ 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(u64::from(hdr.layout.channels()))?
.checked_mul(u64::from(hdr.sample.bytes()))?;
// 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 +318,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
@@ -337,18 +334,58 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result<vello_cpu::Pixmap, Unde
fn shrunk(bytes: &[u8], r: &Reduction) -> 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, Limits, PngDecodeRows, SampleType};
let mut dec = PngDecodeRows::new_streaming(bytes, &Limits::default()).ok()?;
if !dec.streamable() {
return None;
}
let hdr = dec.header();
dec.step_stream::<CodecError>(1, &mut || true, &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::Png, "unsupported PNG channel layout")),
}
Ok(())
})
.ok()??;
} else {
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
+1 -1
View File
@@ -11,7 +11,7 @@ workspace = true
[dependencies]
vectorcraft-geom = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
craft-image-codecs = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
+67 -31
View File
@@ -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, inspect_raster};
let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string());
if let Some(info) = inspect_raster(bytes).map_err(decode_err)? {
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,
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 {
limits: Limits { max_pixels: MAX_PIXELS, max_alloc: ALPHA_MAX_DECODE_BYTES, ..Limits::default() },
..DecodeOptions::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,41 @@ 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::new(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());
}
}
+75 -37
View File
@@ -158,62 +158,100 @@ 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) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? {
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 {
limits: craft_image_codecs::Limits {
max_pixels: MAX_PIXELS,
..craft_image_codecs::Limits::default()
},
..craft_image_codecs::DecodeOptions::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: img.to_rgba8() })
}
/// 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) = craft_image_codecs::inspect_raster(bytes).map_err(|e| TraceError::Decode(e.to_string()))? {
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 {
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()
},
..craft_image_codecs::DecodeOptions::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 = img.to_rgba8();
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).
+13 -5
View File
@@ -1094,7 +1094,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());
@@ -2138,10 +2138,18 @@ fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() {
/// `img` saved as a JPEG of `quality` and read back.
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![];
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality)
.encode(&rgb, img.width, img.height, image::ExtendedColorType::Rgb8)
.unwrap();
let encoded = craft_image_codecs::Image::from_u8(
img.width,
img.height,
craft_image_codecs::ChannelLayout::Rgb,
rgb,
)
.unwrap();
let opts = craft_image_codecs::EncodeOptions {
jpeg_quality: quality,
..Default::default()
};
let bytes = craft_image_codecs::encode(&encoded, craft_image_codecs::Format::Jpeg, &opts).unwrap();
Raster::decode(&bytes).unwrap()
}