diff --git a/Cargo.lock b/Cargo.lock index c8d3381d6c..f3e85a08a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,12 +508,6 @@ 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" @@ -1039,36 +1033,6 @@ 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" @@ -1115,23 +1079,12 @@ 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 0.1.0 (git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2)", + "craft-xml", "flate2", "kurbo 0.13.1", "roxmltree 0.21.2", @@ -1141,15 +1094,6 @@ 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" @@ -1248,15 +1192,6 @@ 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" @@ -1769,22 +1704,6 @@ 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" @@ -2817,7 +2736,6 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", - "qoi", "tiff", "zune-core 0.5.3", "zune-jpeg 0.5.15", @@ -3271,12 +3189,6 @@ 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" @@ -3476,28 +3388,6 @@ 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" @@ -4448,21 +4338,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" @@ -5833,12 +5723,6 @@ 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" @@ -6651,7 +6535,6 @@ dependencies = [ "chrono", "craft-docpath", "craft-proof", - "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "crc32fast", "flate2", "hayro-jpeg2000", @@ -6664,6 +6547,7 @@ dependencies = [ "log", "proptest", "rayon", + "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", "same-file", @@ -6837,7 +6721,6 @@ 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", @@ -6848,6 +6731,7 @@ dependencies = [ "md-5", "objc2-core-foundation", "objc2-core-graphics", + "roxmltree 0.21.2", "serde", "serde_json", "sha2", @@ -6888,9 +6772,6 @@ dependencies = [ name = "vectorcraft-render" version = "0.24.1" dependencies = [ - "craft-image-codecs", - "craft-psd", - "craft-tiff", "flate2", "gif 0.14.2", "image", @@ -6898,7 +6779,9 @@ dependencies = [ "log", "png 0.18.1", "proptest", + "psd", "serde_json", + "tiff", "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", @@ -8473,15 +8356,6 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 109f4d6f71..c7856d589e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,6 @@ 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" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b46678804b..0fccbcdecb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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). -craft-xml = { workspace = true } +roxmltree = "0.21" # The compound file that holds a Word 97-2003 document placed as type (place/doc.rs). cfb = "0.15" kurbo = { workspace = true } diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index ac528f3735..063444442e 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -38,7 +38,7 @@ use std::collections::HashMap; -use craft_xml::{Canonical, Doctype, El, Limits, Names, Options}; +use roxmltree::Node; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; @@ -88,7 +88,6 @@ 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`? @@ -105,7 +104,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result { 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) != Some("document") { + if local(doc.root_element()) != 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(); @@ -118,7 +117,7 @@ pub(super) fn import(bytes: &[u8], cmd: &str) -> Result { if let Some(xml) = part(&zip, &styles, cmd)? { r.styles(&parse(&xml, &styles, cmd)?); } - if let Some(body) = child(&doc, "body") { + if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) { r.block(body, 0); } r.finish(cmd) @@ -159,17 +158,57 @@ fn part(zip: &Archive, name: &str, cmd: &str) -> Result> { } /// The XML of the part `name`. -fn parse(xml: &str, name: &str, cmd: &str) -> Result { - 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() +fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result> { + // 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()) }; - 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}")), - }) + 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""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(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 part that the first relationship of part `source` (`""`: the package) whose type ends in @@ -181,10 +220,11 @@ fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result String { } /// `n`'s local name when it is a WordprocessingML element. -fn local(n: &El) -> Option<&str> { - n.name.strip_prefix("w:") +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()) } /// `n`'s first WordprocessingML child element named `name`. -fn child<'a>(n: &'a El, name: &str) -> Option<&'a El> { - n.elements().find(|c| local(c) == Some(name)) +fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option> { + n.children().find(|c| local(*c) == Some(name)) } /// `n`'s WordprocessingML attribute `name`. -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())) +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()) } /// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`. -fn on(n: &El) -> bool { +fn on(n: Node) -> bool { !matches!(attr(n, "val"), Some("false" | "0" | "off")) } @@ -240,12 +280,12 @@ fn measure(v: &str, per_pt: f64) -> Option { } /// Twentieths of a point. -fn twips(n: &El, name: &str) -> Option { +fn twips(n: Node, name: &str) -> Option { attr(n, name).and_then(|v| measure(v, 20.0)) } /// Half points. -fn half_points(n: &El) -> Option { +fn half_points(n: Node) -> Option { attr(n, "val").and_then(|v| measure(v, 2.0)) } @@ -422,21 +462,21 @@ struct Reader { impl Reader { /// The major and minor fonts of a theme part's font scheme. - fn theme_fonts(&mut self, doc: &El) { + fn theme_fonts(&mut self, doc: &roxmltree::Document) { for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) { - *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")) + *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")) .map(font_name) .filter(|f| !f.is_empty()); } } /// The document defaults and the styles of the styles part. - fn styles(&mut self, doc: &El) { - for n in doc.elements() { + fn styles(&mut self, doc: &roxmltree::Document) { + for n in doc.root_element().children() { match local(n) { Some("docDefaults") => { let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner)); @@ -486,7 +526,7 @@ impl Reader { } /// The number of the font a `w:rFonts` element names. - fn font(&mut self, n: &El) -> Option { + fn font(&mut self, n: Node) -> Option { 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) @@ -509,9 +549,9 @@ impl Reader { } /// The run properties of a `w:rPr` element. - fn run_props(&mut self, rpr: &El) -> RunProps { + fn run_props(&mut self, rpr: Node) -> RunProps { let mut p = RunProps::default(); - for n in rpr.elements() { + for n in rpr.children() { match local(n) { Some("rFonts") => p.font = self.font(n), Some("b") => p.bold = Some(on(n)), @@ -554,11 +594,11 @@ impl Reader { } /// The paragraphs, tables and containers of them that are `n`'s children. - fn block(&mut self, n: &El, depth: usize) { + fn block(&mut self, n: Node, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.elements() { + for c in n.children() { if self.full { return; } @@ -577,7 +617,7 @@ impl Reader { } /// A table: each row a paragraph, its cells separated by tabs. - fn table(&mut self, tbl: &El, depth: usize) { + fn table(&mut self, tbl: Node, depth: usize) { for row in items(tbl, "tr", depth) { for cell in items(row, "tc", depth) { self.flush(); @@ -598,7 +638,7 @@ impl Reader { } /// A paragraph. - fn paragraph(&mut self, p: &El, depth: usize) { + fn paragraph(&mut self, p: Node, depth: usize) { self.flush(); let ppr = child(p, "pPr"); let style = ppr @@ -635,11 +675,11 @@ impl Reader { } /// The runs of a paragraph, and the containers of runs in it. - fn inline(&mut self, n: &El, depth: usize) { + fn inline(&mut self, n: Node, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.elements() { + for c in n.children() { match local(c) { Some("r") => self.run(c), Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1), @@ -654,7 +694,7 @@ impl Reader { } /// A run. - fn run(&mut self, r: &El) { + fn run(&mut self, r: Node) { let rpr = child(r, "rPr"); let mut chars = self.base; let style = rpr @@ -669,10 +709,11 @@ impl Reader { let props = self.run_props(n); chars.apply(&props, false); } - for c in r.elements() { + for c in r.children() { match local(c) { Some("t") => { - for ch in c.text().chars() { + // 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) { self.put(ch, chars); } } @@ -866,9 +907,9 @@ fn font_name(name: &str) -> String { } /// The paragraph properties of a `w:pPr` element. -fn para_props(ppr: &El) -> ParaProps { +fn para_props(ppr: Node) -> ParaProps { let mut p = ParaProps::default(); - for n in ppr.elements() { + for n in ppr.children() { match local(n) { Some("jc") => { p.align = match attr(n, "val") { @@ -904,12 +945,12 @@ fn para_props(ppr: &El) -> ParaProps { } /// `n`'s children named `name`, and those inside content controls and custom XML among them. -fn items<'a>(n: &'a El, name: &str, depth: usize) -> Vec<&'a El> { +fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec> { let mut out = vec![]; if depth > MAX_DEPTH { return out; } - for c in n.elements() { + for c in n.children() { match local(c) { Some(l) if l == name => out.push(c), Some("sdt") => { @@ -938,6 +979,15 @@ 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#"]]>"#), 2); + assert_eq!(nesting(""), 3, "unclosed elements count"); + assert_eq!(nesting(""), 1); + assert_eq!(nesting(" Result { let xml = without_doctype(xml, cmd)?; - 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 { + // 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 { return Err(bad(cmd, "the file holds no `variableSets` element")); }; - let sets: Vec<&craft_xml::El> = root.children("variableSet").collect(); + let sets: Vec = root.children().filter(|n| is(n, "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("variables")).flat_map(|v| v.children("variable")) { - let name = named(var.attr("varName"), "varName", cmd)?; + 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)?; 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.attr("trait") { + match var.attribute("trait") { Some("textcontent") => { // `docRef="id('…')"` names the element of the variable's bound object. - let element = var.attr("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + let element = var.attribute("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())); } @@ -238,8 +237,10 @@ fn parse_library(xml: &str, cmd: &str) -> Result { } 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("sampleDataSets")).flat_map(|d| d.children("sampleDataSet")) { - let name = named(row.attr("dataSetName"), "dataSetName", cmd)?; + 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)?; if !seen.insert(name.clone()) { return Err(bad(cmd, format!("data set `{name}` is declared twice"))); } @@ -247,8 +248,8 @@ fn parse_library(xml: &str, cmd: &str) -> Result { 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.elements() { - let var = el.name.as_str(); + for el in row.children().filter(roxmltree::Node::is_element) { + let var = el.tag_name().name(); if !declared.contains(var) { return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); } @@ -258,7 +259,11 @@ fn parse_library(xml: &str, cmd: &str) -> Result { if !text_names.contains(var) { continue; } - let paras: Vec = el.children("p").map(craft_xml::El::deep_text).collect(); + let paras: Vec = el + .children() + .filter(|n| is(n, "p")) + .map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()) + .collect(); if !paras.is_empty() { values.insert(var.to_string(), DataValue::Text(paras.join("\n"))); } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index d59243eb75..d4394dfefa 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -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). -craft-xml = { workspace = true } +# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses. +roxmltree = "0.21" # 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 } diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 908212aabe..1cb0a45273 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -5,7 +5,7 @@ use craft_pdf::syntax::Pdf; use craft_pdf::syntax::object::{DateTime, Dict, Stream}; -use craft_xml::{Canonical, Doctype, El, Limits, Names, Options}; +use roxmltree::Node; use crate::import_scan::text_string; @@ -15,14 +15,12 @@ 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 { @@ -75,39 +73,39 @@ fn packet(pdf: &Pdf) -> Option> { } /// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16, or when its -/// [`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. +/// elements nest more than [`MAX_NESTING`] deep. fn xmp(packet: &[u8]) -> Option { let text = xml_text(packet)?; - let opts = Options { - names: Names::Canonical(&XMP_NAMESPACES), - doctype: Doctype::Reject, - limits: Limits { max_depth: MAX_NESTING, ..Limits::DEFAULT }, - ..Options::default() + // roxmltree parses nested elements recursively. + if nesting(&text) > MAX_NESTING { + return None; } - .strict(); - let root = opts.parse(text.as_bytes()).ok()?; + let xml = roxmltree::Document::parse(&text).ok()?; // The resource's own descriptions: those right under `rdf:RDF`, not the ones inside a value. - 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"); + let descriptions: Vec> = 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"); 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("xmpRights:WebStatement"), - copyrighted: first("xmpRights: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(XMP_RIGHTS, "WebStatement"), + copyrighted: first(XMP_RIGHTS, "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)), }) } @@ -125,14 +123,58 @@ fn xml_text(b: &[u8]) -> Option { } } -/// The values of property `name` on the first of `descriptions` that gives it: an attribute +/// 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""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(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 /// of the `rdf:Description`, or a property element's items ([`items`]), blank ones left out. -fn property(descriptions: &[&El], name: &str) -> Vec { +fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec { for d in descriptions { - if let Some(v) = d.attr(name).map(str::trim).filter(|v| !v.is_empty()) { + if let Some(v) = d.attribute((ns, name)).map(str::trim).filter(|v| !v.is_empty()) { return vec![v.to_string()]; } - let found = d.children(name).map(items).find(|v| !v.is_empty()); + let found = d.children().filter(|c| c.has_tag_name((ns, name))).map(items).find(|v| !v.is_empty()); if let Some(v) = found { return v; } @@ -143,12 +185,13 @@ fn property(descriptions: &[&El], name: &str) -> Vec { /// 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: &El) -> Vec { - let array = p.elements().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")); +fn items(p: Node<'_, '_>) -> Vec { + let array = p.children().find(|c| ["Seq", "Bag", "Alt"].into_iter().any(|t| c.has_tag_name((RDF, t)))); let mut values: Vec<(bool, String)> = match array { 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))) + .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(), None => vec![(false, value_of(p))], }; @@ -160,19 +203,19 @@ fn items(p: &El) -> Vec { /// 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: &El) -> String { +fn value_of(mut n: Node<'_, '_>) -> String { // Each step goes to an element inside `n`, so the loop ends. loop { - if let Some(uri) = n.attr("rdf:resource") { + if let Some(uri) = n.attribute((RDF, "resource")) { return uri.to_string(); } - let holder = n.child("rdf:Description").unwrap_or(n); - if let Some(v) = holder.attr("rdf:value") { + let holder = n.children().find(|c| c.has_tag_name((RDF, "Description"))).unwrap_or(n); + if let Some(v) = holder.attribute((RDF, "value")) { return v.to_string(); } - match holder.child("rdf:value") { + match holder.children().find(|c| c.has_tag_name((RDF, "value"))) { Some(v) => n = v, - None => return n.deep_text(), + None => return n.descendants().filter(|d| d.is_text()).filter_map(|d| d.text()).collect(), } } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index ecdaa60273..4bd499b9d4 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,12 +19,13 @@ 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" @@ -46,7 +47,8 @@ vectorcraft-plugins = { workspace = true } wat = { workspace = true } # Decoders the export tests read their files back with. image = { workspace = true, features = ["tga"] } -craft-tiff = { workspace = true } -craft-psd = { workspace = true } +tiff = "0.11" +# Reads the PSD exports back (MIT OR Apache-2.0). +psd = "0.3" proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index 0a8227b75f..a910480641 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -86,10 +86,8 @@ 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 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 mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap(); + assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1)); let bw: Vec = [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. diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index 7655fa16e7..a488cceff2 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -146,7 +146,7 @@ fn unpack(mut src: &[u8], out: &mut Vec) { /// The composite as a reader sees it. fn merged(f: &[u8]) -> Vec { - craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes").composite_rgba8().expect("composite").data + ::psd::Psd::from_bytes(f).expect("a PSD a reader takes").rgba() } fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] { @@ -168,12 +168,11 @@ 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 = 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]); + 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"); // 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)); @@ -201,7 +200,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!(craft_psd::PsdFile::from_bytes(&file).unwrap().header.width, 208); + assert_eq!(::psd::Psd::from_bytes(&file).unwrap().width(), 208); } #[test] @@ -231,10 +230,8 @@ 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 = 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)); + let psd = ::psd::Psd::from_bytes(&file).unwrap(); + assert_eq!((psd.layers().len(), psd.groups().len()), (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. @@ -303,8 +300,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 = 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])); + 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])); assert_eq!(pixel(&merged(&file), 100, 5, 50), [0, 0, 255, 255]); } diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs index 380b6d155c..c70eacd2fc 100644 --- a/crates/render/src/encode/tests_tiff.rs +++ b/crates/render/src/encode/tests_tiff.rs @@ -1,25 +1,22 @@ //! TIFF export: byte order, LZW, colour models and samples, resolution, profile, strips. -use craft_tiff::Tiff; -use craft_tiff::tags; +use std::io::Cursor; + +use tiff::decoder::{Decoder, DecodingResult}; +use tiff::tags::Tag; use super::jpeg::ColorModel; use super::tiff::{self as t, ByteOrder, Compression, Image, Layout, Photometric, TiffOptions}; -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> { - 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> { - ifd.value(tag).map(|v| v.to_u64_vec().into_iter().map(|x| x as u32).collect()) +fn decoder(file: &[u8]) -> Decoder> { + Decoder::new(Cursor::new(file)).expect("a TIFF") } fn pixels(file: &[u8]) -> Vec { - craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, file).expect("decodes").data + match decoder(file).read_image().expect("decodes") { + DecodingResult::U8(v) => v, + _ => panic!("8-bit samples"), + } } /// Straight RGBA: flat areas (as art is) with a transparent corner when `clear`. @@ -49,7 +46,7 @@ fn byte_order_writes_an_ii_or_mm_header() { assert_eq!(&file[..4], magic); let rgb: Vec = px.chunks(4).flat_map(|p| &p[..3]).copied().collect(); assert_eq!(pixels(&file), rgb, "{order:?}"); - assert_eq!(ifd0(&file).string(tags::SOFTWARE).as_deref(), Some("VectorCraft")); + assert_eq!(decoder(&file).get_tag_ascii_string(Tag::Software).unwrap(), "VectorCraft"); } for (s, o) in [("little", ByteOrder::Little), ("MM", ByteOrder::Big), ("mac", ByteOrder::Big), ("ii", ByteOrder::Little), ("big", ByteOrder::Big)] { @@ -64,51 +61,44 @@ 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!(ifd0(&lzw).u16(tags::COMPRESSION), Some(5)); - assert_eq!(ifd0(&plain).u16(tags::COMPRESSION), Some(1)); + assert_eq!(decoder(&lzw).get_tag_unsigned::(Tag::Compression).unwrap(), 5); + assert_eq!(decoder(&plain).get_tag_unsigned::(Tag::Compression).unwrap(), 1); assert_eq!(pixels(&lzw), px, "transparency kept as an alpha channel"); assert_eq!(pixels(&plain), px); - let img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &lzw).unwrap(); - assert_eq!(img.data, 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); } #[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(); - 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"); + 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 opaque = art(9, 6, false); let rgb = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); - 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()); + assert_eq!(decoder(&rgb).colortype().unwrap(), tiff::ColorType::RGB(8)); + assert!(decoder(&rgb).find_tag(Tag::ExtraSamples).unwrap().is_none()); let gray = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - 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"); + assert_eq!(decoder(&gray).colortype().unwrap(), tiff::ColorType::Gray(8)); + assert_eq!(decoder(&gray).get_tag_unsigned::(Tag::PhotometricInterpretation).unwrap(), 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(); - 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!(decoder(&on_white).colortype().unwrap(), tiff::ColorType::Gray(8), "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 = (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 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); + let mut d = decoder(&cmyk); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::CMYK(8)); + assert_eq!(d.get_tag_unsigned::(Tag::SamplesPerPixel).unwrap(), 4); + assert_eq!(d.get_tag_unsigned::(Tag::PhotometricInterpretation).unwrap(), 5, "separated"); + assert_eq!(d.get_tag_unsigned::(Tag::Unknown(332)).unwrap(), 1, "CMYK inks"); + assert_eq!(pixels(&cmyk), inks); } #[test] @@ -116,17 +106,16 @@ 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 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]); + 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)); } - assert_eq!(d.u16(tags::RESOLUTION_UNIT), Some(2), "inches"); + assert_eq!(d.get_tag_unsigned::(Tag::ResolutionUnit).unwrap(), 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 = ifd0(&file).bytes(tags::ICC_PROFILE).unwrap(); + let icc = decoder(&file).get_tag_u8_vec(Tag::IccProfile).unwrap(); assert_eq!(&icc[36..40], b"acsp", "{model:?}: an ICC profile"); let space: &[u8; 4] = match model { ColorModel::Rgb => b"RGB ", @@ -136,7 +125,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!(ifd0(&file).value(tags::ICC_PROFILE).is_none()); + assert!(decoder(&file).find_tag(Tag::IccProfile).unwrap().is_none()); } #[test] @@ -146,10 +135,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 d = ifd0(&file); - let offsets = tag_u32_vec(&d, tags::STRIP_OFFSETS).unwrap(); + let mut d = decoder(&file); + let offsets = d.get_tag_u32_vec(Tag::StripOffsets).unwrap(); // 300 bytes a row: 27 rows a strip. - assert_eq!(d.u32(tags::ROWS_PER_STRIP), Some(27)); + assert_eq!(d.get_tag_u32(Tag::RowsPerStrip).unwrap(), 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}"); @@ -163,14 +152,11 @@ 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 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)); + let mut d = decoder(&file); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::Gray(1)); + assert_eq!(d.get_tag_unsigned::(Tag::Compression).unwrap(), 32773); // The decoder turns WhiteIsZero into black-is-zero. - 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!(pixels(&file), rows.map(|b| !b)); // 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()); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index e51db9b251..c130fbe346 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -258,13 +258,9 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option { return None; } let to = fitted_dims(w, h, 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. + // A PNG that isn't interlaced is read a row at a time when the reader's buffers fit the allowance. let row_scratch = if format == image::ImageFormat::Png { - 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) + png_reader(bytes).filter(|r| !r.info().interlaced).and_then(|r| png_row_scratch(&r)).filter(|&s| s <= allowance) } else { None }; @@ -287,18 +283,17 @@ const PNG_KEPT_ROWS: u64 = 128 << 10; /// stream buffer grows by. const PNG_WINDOW_AND_STEP: u64 = (32 << 10) + (8 << 10); -/// 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 { - 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)?; +/// 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>) -> Option { + 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()?; 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) @@ -318,6 +313,14 @@ 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>> { + 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 @@ -334,58 +337,18 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result Option { let mut shrink = Shrink::new(r.size, r.to); if r.rows { - 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::(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")), + 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, } - Ok(()) - }) - .ok()??; + } } else { let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); diff --git a/crates/trace/Cargo.toml b/crates/trace/Cargo.toml index 83f7425c05..a605a69adb 100644 --- a/crates/trace/Cargo.toml +++ b/crates/trace/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } kurbo = { workspace = true } -craft-image-codecs = { workspace = true } +image = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs index 3949adf96a..52e80a368d 100644 --- a/crates/trace/src/opaque.rs +++ b/crates/trace/src/opaque.rs @@ -24,49 +24,26 @@ 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)>, TraceError> { - 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()); + 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(); if w == 0 || h == 0 { return Err(TraceError::Empty); } - if !img.layout().has_alpha() { + if !decoder.color_type().has_alpha() { return Ok(None); } - let alpha: Vec = 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(), + 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 = 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(), }; Ok(Some((w, h, alpha))) } @@ -288,41 +265,28 @@ mod tests { } /// `img` encoded as `format`. - fn encoded(img: &craft_image_codecs::Image, format: craft_image_codecs::Format) -> Vec { - craft_image_codecs::encode(img, format, &Default::default()).unwrap() + fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out } #[test] fn decode_alpha_reads_the_alpha_of_formats_that_have_one() { - 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(); + 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 = (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(); assert_eq!(got, Some((4, 4, want.clone())), "{name}"); } - 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); + 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); assert!(decode_alpha(b"not an image").is_err()); } } diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 0fdce8321d..51312c685a 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -158,100 +158,62 @@ impl Raster { } /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). pub fn decode(bytes: &[u8]) -> Result { - 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()); + 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); } - Ok(Self { width: w, height: h, rgba: img.to_rgba8() }) + 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() }) } /// 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 { - 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. - 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()); + 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 rgba = img.to_rgba8(); - if u64::from(w) * u64::from(h) <= MAX_PIXELS { - return Ok(Self { width: w, height: h, rgba }); + let pixels = u64::from(w) * u64::from(h); + if pixels <= MAX_PIXELS { + return Self::decode(bytes); } - Ok(Self::fitted(&Self { width: w, height: h, rgba }, MAX_PIXELS)) + if pixels > MAX_SOURCE_PIXELS { + return Err(TraceError::TooLarge { width: w, height: h, 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 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)) } /// `img` scaled down, aspect kept, to the largest size of at most `max` pixels. - pub(crate) fn fitted(img: &Self, max: u64) -> Self { - let (nw, nh) = fit_size(img.width, img.height, max); + pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { + let (w, h) = img.dimensions(); + let (nw, nh) = fit_size(w, h, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - Self { width: nw, height: nh, rgba: box_reduced(&img.rgba, img.width, img.height, nw, nh) } + Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec { - 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() + 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 } /// 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). diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index f2d4760a4d..80eb322bbc 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -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 = Raster::decode(&half_clear.encode_png()).unwrap(); + let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8(); 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,18 +2138,10 @@ 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 = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); - 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(); + let mut bytes = vec![]; + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality) + .encode(&rgb, img.width, img.height, image::ExtendedColorType::Rgb8) + .unwrap(); Raster::decode(&bytes).unwrap() }