diff --git a/Cargo.lock b/Cargo.lock index f3e85a08a7..c8d3381d6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index c7856d589e..109f4d6f71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0fccbcdecb..b46678804b 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). -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 } diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index 063444442e..ac528f3735 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 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 { 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 { 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> { } /// The XML of the part `name`. -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()) +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() }; - 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 + 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 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> { - 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 { } /// Twentieths of a point. -fn twips(n: Node, name: &str) -> Option { +fn twips(n: &El, name: &str) -> Option { attr(n, name).and_then(|v| measure(v, 20.0)) } /// Half points. -fn half_points(n: Node) -> Option { +fn half_points(n: &El) -> Option { 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 { + fn font(&mut self, n: &El) -> 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) @@ -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> { +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#"]]>"#), 2); - assert_eq!(nesting(""), 3, "unclosed elements count"); - assert_eq!(nesting(""), 1); - assert_eq!(nesting(" Result { 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 = 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 { } 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 { 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 { if !text_names.contains(var) { continue; } - 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(); + let paras: Vec = el.children("p").map(craft_xml::El::deep_text).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 d4394dfefa..d59243eb75 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); 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 } diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 1cb0a45273..908212aabe 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 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> { } /// 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 { 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> = 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 { } } -/// 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 +/// 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 { +fn property(descriptions: &[&El], name: &str) -> Vec { 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 /// 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 { - let array = p.children().find(|c| ["Seq", "Bag", "Alt"].into_iter().any(|t| c.has_tag_name((RDF, t)))); +fn items(p: &El) -> Vec { + 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 { /// 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(), } } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 4bd499b9d4..ecdaa60273 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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 } diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index a910480641..0a8227b75f 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -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 = [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 a488cceff2..7655fa16e7 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 { - ::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]); } diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs index c70eacd2fc..380b6d155c 100644 --- a/crates/render/src/encode/tests_tiff.rs +++ b/crates/render/src/encode/tests_tiff.rs @@ -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> { - 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> { + 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 pixels(file: &[u8]) -> Vec { - 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 = 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::(Tag::Compression).unwrap(), 5); - assert_eq!(decoder(&plain).get_tag_unsigned::(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::(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 = (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::(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); + 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::(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::(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()); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index c130fbe346..e51db9b251 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -258,9 +258,13 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option { 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>) -> 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()?; +/// 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)?; 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>> { - 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 Option { 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::(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(); diff --git a/crates/trace/Cargo.toml b/crates/trace/Cargo.toml index a605a69adb..83f7425c05 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 } -image = { workspace = true } +craft-image-codecs = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs index 52e80a368d..3949adf96a 100644 --- a/crates/trace/src/opaque.rs +++ b/crates/trace/src/opaque.rs @@ -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)>, 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 = 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 = 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 { - 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 { + 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 = (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()); } } diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 51312c685a..0fdce8321d 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -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 { - 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 { - 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 { - 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). diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 80eb322bbc..f2d4760a4d 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 = 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 = 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() }