diff --git a/Cargo.lock b/Cargo.lock index 887b10ab11..d80b61462e 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" @@ -1090,12 +1126,23 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "craft-psd" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" +dependencies = [ + "encoding_rs", + "flate2", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "craft-svg" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" dependencies = [ - "craft-xml", + "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2)", "flate2", "kurbo 0.13.1", "roxmltree 0.21.2", @@ -1105,6 +1152,15 @@ dependencies = [ "vectorcraft-pathops 0.18.0", ] +[[package]] +name = "craft-tiff" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" +dependencies = [ + "serde", + "thiserror 2.0.21", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1203,6 +1259,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e2dc681a3#e2dc681a3ad8a47038da2f11ddd2c5e1c0454991" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -1715,6 +1780,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "multiversion", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -2747,6 +2828,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "qoi", "tiff", "zune-core 0.5.3", "zune-jpeg 0.5.15", @@ -3200,6 +3282,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.191" @@ -3399,6 +3487,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -4349,21 +4459,21 @@ dependencies = [ "unarray", ] -[[package]] -name = "psd" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195" -dependencies = [ - "thiserror 1.0.69", -] - [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -5734,6 +5844,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tempfile" version = "3.27.0" @@ -6546,6 +6662,7 @@ dependencies = [ "chrono", "craft-docpath", "craft-proof", + "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "crc32fast", "flate2", "hayro-jpeg2000", @@ -6558,7 +6675,6 @@ dependencies = [ "log", "proptest", "rayon", - "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", "same-file", @@ -6734,6 +6850,7 @@ dependencies = [ "aes", "chrono", "craft-pdf", + "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e2dc681a3)", "flate2", "getrandom 0.4.3", "image", @@ -6744,7 +6861,6 @@ dependencies = [ "md-5", "objc2-core-foundation", "objc2-core-graphics", - "roxmltree 0.21.2", "serde", "serde_json", "sha2", @@ -6785,16 +6901,16 @@ dependencies = [ name = "vectorcraft-render" version = "0.24.1" dependencies = [ + "craft-image-codecs", + "craft-psd", + "craft-tiff", "flate2", "gif 0.14.2", "image", "jpeg-encoder", "log", - "png 0.18.1", "proptest", - "psd", "serde_json", - "tiff", "unicode-general-category", "vectorcraft-brush", "vectorcraft-color", @@ -6917,6 +7033,7 @@ dependencies = [ name = "vectorcraft-trace" version = "0.24.1" dependencies = [ + "craft-image-codecs", "image", "kurbo 0.13.1", "serde", @@ -8371,6 +8488,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.4.21" diff --git a/Cargo.toml b/Cargo.toml index 610e2b462c..e77c950b1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,11 @@ craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } # WMF, EMF and EMF+ reader (craft-libs#778). craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } +craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } +craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } +craft-gif = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } +craft-tiff = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } +craft-psd = { git = "https://github.com/storytold/craft-libs", rev = "e2dc681a3" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } 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.rs b/crates/engine/src/cmd.rs index c6aed0a644..ae67281995 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -98,6 +98,8 @@ pub(crate) mod symboledit; pub mod symbollib; pub(crate) mod tabs; mod textedit; +mod xmlattrs; + pub use textedit::effective_kerning; pub mod textstyles; pub(crate) mod textwrap; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 52ed7f0e7f..22d7f5dad9 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -894,12 +894,19 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option { if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { return Some(format!("an image of {w} × {h} pixels is too large to draw")); } - let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; + let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; + let format = reader.format()?; + let d = reader.into_decoder().ok()?; let (w, h) = d.dimensions(); // The allowance also counts the pixels as RGBA: an image of fewer than 4 bytes a pixel (gray, // gray with alpha, RGB) takes more than the decoder counts (#1717). let rgba_over = u64::from(w) * u64::from(h) > MAX_RASTER_ALLOC / 4; - (rgba_over || d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw")) + let samples_over = d.total_bytes() > MAX_RASTER_ALLOC; + // The renderer can stream a non-interlaced PNG a row at a time when those buffers fit. Its + // full decoded byte count can exceed the allowance even though drawing it at native size is + // safe; use the renderer's decision for that case instead of warning from total_bytes alone. + let png_streams_at_size = samples_over && format == image::ImageFormat::Png && vectorcraft_render::reduced_size(bytes) == Some((w, h)); + (rgba_over || (samples_over && !png_streams_at_size)).then(|| format!("an image of {w} × {h} pixels is too large to draw")) } /// What Open, Place and the Links panel say of encoded image `bytes` that [`too_large_note`] is diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index 063444442e..804e3fd6ae 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,21 @@ 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"))); +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() } - let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() }; - roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}"))) -} - -/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, -/// CDATA sections, processing instructions, declarations and quoted attribute values. -pub(crate) fn nesting(xml: &str) -> usize { - let b = xml.as_bytes(); - let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); - // The index just past the first `end` at or after `from` (the end of the text if none). - let past = |from: usize, end: &[u8]| { - b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) - }; - while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { - let start = i + at; - let rest = b.get(start..).unwrap_or_default(); - if rest.starts_with(b""); - } 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 + .strict(); + let doc = opts.parse(xml.as_bytes()).map_err(|e| match e { + craft_xml::Error::TooDeep(_) => bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")), + _ => bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")), + })?; + crate::cmd::xmlattrs::validate(xml.as_bytes(), opts.limits) + .map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")))?; + Ok(doc) } /// The part that the first relationship of part `source` (`""`: the package) whose type ends in @@ -220,11 +185,10 @@ fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result 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 +244,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 +426,21 @@ struct Reader { impl Reader { /// The major and minor fonts of a theme part's font scheme. - fn theme_fonts(&mut self, doc: &roxmltree::Document) { + fn theme_fonts(&mut self, doc: &El) { for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) { - *slot = doc - .descendants() - .find(|n| n.tag_name().name() == scheme) - .and_then(|n| n.children().find(|c| c.tag_name().name() == "latin")) - .and_then(|n| n.attribute("typeface")) + *slot = std::iter::once(doc) + .chain(doc.descendants()) + .find(|n| n.local() == scheme) + .and_then(|n| n.elements().find(|c| c.local() == "latin")) + .and_then(|n| n.attr("typeface")) .map(font_name) .filter(|f| !f.is_empty()); } } /// The document defaults and the styles of the styles part. - fn styles(&mut self, doc: &roxmltree::Document) { - for n in doc.root_element().children() { + fn styles(&mut self, doc: &El) { + for n in doc.elements() { match local(n) { Some("docDefaults") => { let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner)); @@ -526,7 +490,7 @@ impl Reader { } /// The number of the font a `w:rFonts` element names. - fn font(&mut self, n: Node) -> Option { + 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 +513,9 @@ impl Reader { } /// The run properties of a `w:rPr` element. - fn run_props(&mut self, rpr: Node) -> RunProps { + fn run_props(&mut self, rpr: &El) -> RunProps { let mut p = RunProps::default(); - for n in rpr.children() { + for n in rpr.elements() { match local(n) { Some("rFonts") => p.font = self.font(n), Some("b") => p.bold = Some(on(n)), @@ -594,11 +558,11 @@ impl Reader { } /// The paragraphs, tables and containers of them that are `n`'s children. - fn block(&mut self, n: Node, depth: usize) { + fn block(&mut self, n: &El, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.children() { + for c in n.elements() { if self.full { return; } @@ -617,7 +581,7 @@ impl Reader { } /// A table: each row a paragraph, its cells separated by tabs. - fn table(&mut self, tbl: Node, depth: usize) { + fn table(&mut self, tbl: &El, depth: usize) { for row in items(tbl, "tr", depth) { for cell in items(row, "tc", depth) { self.flush(); @@ -638,7 +602,7 @@ impl Reader { } /// A paragraph. - fn paragraph(&mut self, p: Node, depth: usize) { + fn paragraph(&mut self, p: &El, depth: usize) { self.flush(); let ppr = child(p, "pPr"); let style = ppr @@ -675,11 +639,11 @@ impl Reader { } /// The runs of a paragraph, and the containers of runs in it. - fn inline(&mut self, n: Node, depth: usize) { + fn inline(&mut self, n: &El, depth: usize) { if depth > MAX_DEPTH { return; } - for c in n.children() { + for c in n.elements() { match local(c) { Some("r") => self.run(c), Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1), @@ -694,7 +658,7 @@ impl Reader { } /// A run. - fn run(&mut self, r: Node) { + fn run(&mut self, r: &El) { let rpr = child(r, "rPr"); let mut chars = self.base; let style = rpr @@ -709,11 +673,10 @@ impl Reader { let props = self.run_props(n); chars.apply(&props, false); } - for c in r.children() { + for c in r.elements() { match local(c) { Some("t") => { - // Comments and processing instructions may split the text. - for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) { + for ch in c.text().chars() { self.put(ch, chars); } } @@ -907,9 +870,9 @@ fn font_name(name: &str) -> String { } /// The paragraph properties of a `w:pPr` element. -fn para_props(ppr: Node) -> ParaProps { +fn para_props(ppr: &El) -> ParaProps { let mut p = ParaProps::default(); - for n in ppr.children() { + for n in ppr.elements() { match local(n) { Some("jc") => { p.align = match attr(n, "val") { @@ -945,12 +908,12 @@ fn para_props(ppr: Node) -> ParaProps { } /// `n`'s children named `name`, and those inside content controls and custom XML among them. -fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec> { +fn items<'a>(n: &'a El, name: &str, depth: usize) -> Vec<&'a El> { let mut out = vec![]; if depth > MAX_DEPTH { return out; } - for c in n.children() { + for c in n.elements() { match local(c) { Some(l) if l == name => out.push(c), Some("sdt") => { @@ -979,15 +942,6 @@ mod tests { assert_eq!(measure("wide", 20.0), None); } - #[test] - fn nesting_counts_elements_outside_markup_that_is_not_an_element() { - assert_eq!(nesting(r#"]]>"#), 2); - assert_eq!(nesting(""), 3, "unclosed elements count"); - assert_eq!(nesting(""), 1); - assert_eq!(nesting(" Result> { /// up to [`MAX_LIBRARY_ENTITIES`] entity declarations, each with a name of at most [`MAX_NAME`] /// bytes and a quoted value that holds no `<`, `&` or `%`. Anything else in it is refused, and so is /// a text that the replacements make longer than [`MAX_LIBRARY_BYTES`]. The XML parser then refuses -/// any other document type declaration, so [`nesting`](super::place::docx::nesting) counts the -/// elements the parser reads. +/// any other document type declaration, so its depth and node limits count the elements it +/// reads. fn without_doctype(xml: &str, cmd: &str) -> Result { let b = xml.as_bytes(); let past = |from: usize, end: &str| xml.get(from..).and_then(|rest| rest.find(end)).map(|at| from + at + end.len()); @@ -240,22 +240,25 @@ impl Library { /// type declaration defines are read as [`without_doctype`] says. fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result { let xml = without_doctype(xml, cmd)?; - // roxmltree parses nested elements recursively. - if crate::cmd::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING { - return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep"))); + let opts = craft_xml::Options { + names: craft_xml::Names::AsWritten, + limits: craft_xml::Limits { max_depth: MAX_LIBRARY_NESTING, max_nodes: MAX_LIBRARY_NODES as usize, ..craft_xml::Limits::DEFAULT }, + ..craft_xml::Options::default() } - let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() }; - let doc = - roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; - let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; - let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else { + .strict(); + let doc = opts.parse(xml.as_bytes()).map_err(|e| match e { + craft_xml::Error::TooDeep(_) => bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep")), + _ => bad(cmd, format!("the variable library is not well-formed XML: {e}")), + })?; + crate::cmd::xmlattrs::validate(xml.as_bytes(), opts.limits).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; + let Some(root) = std::iter::once(&doc).chain(doc.descendants()).find(|n| n.is(craft_xml::Local("variableSets"))) else { return Err(bad(cmd, "the file holds no `variableSets` element")); }; - let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); + let sets: Vec<&craft_xml::El> = root.children(craft_xml::Local("variableSet")).collect(); let (mut variables, mut kinds, mut refs) = (Vec::new(), BTreeMap::new(), Vec::new()); - for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { - let name = named(var.attribute("varName"), "varName", cmd)?; - let trait_name = var.attribute("trait").map(|t| t.chars().take(MAX_NAME).collect::()); + for var in sets.iter().flat_map(|s| s.children(craft_xml::Local("variables"))).flat_map(|v| v.children(craft_xml::Local("variable"))) { + let name = named(var.attr("varName"), "varName", cmd)?; + let trait_name = var.attr("trait").map(|t| t.chars().take(MAX_NAME).collect::()); let kind = trait_kind(trait_name.as_deref()); if kinds.insert(name.clone(), kind).is_some() { return Err(bad(cmd, format!("variable `{name}` is declared twice"))); @@ -265,7 +268,7 @@ fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result { } if kind == VariableKind::Text { // `docRef="id('…')"` names the element of the variable's bound object. - let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + let element = var.attr("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); if let Some(element) = element { refs.push((name.clone(), element.to_string())); } @@ -276,10 +279,8 @@ fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result { return Err(bad(cmd, "the variable library declares no variables")); } let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new()); - for row in - sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet")) - { - let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?; + for row in sets.iter().flat_map(|s| s.children(craft_xml::Local("sampleDataSets"))).flat_map(|d| d.children(craft_xml::Local("sampleDataSet"))) { + let name = named(row.attr("dataSetName"), "dataSetName", cmd)?; if !seen.insert(name.clone()) { return Err(bad(cmd, format!("data set `{name}` is declared twice"))); } @@ -287,8 +288,8 @@ fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets"))); } let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new()); - for el in row.children().filter(roxmltree::Node::is_element) { - let var = el.tag_name().name(); + for el in row.elements() { + let var = el.local(); let Some(kind) = kinds.get(var) else { return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); }; @@ -315,12 +316,12 @@ fn parse_library(xml: &str, cmd: &str, text_only: bool) -> Result { /// white space round them, and an element holding only white space gives none. A variable of /// unknown kind takes no value, so what its element holds is passed over. `set` names the data set /// in the errors. -fn library_value(el: roxmltree::Node, kind: VariableKind, set: &str, cmd: &str) -> Result> { - let refused = |what: &str| bad(cmd, format!("data set `{set}` holds a value for `{}` that isn't {what}", el.tag_name().name())); - let text = || if elements(el).next().is_some() { None } else { Some(deep_text(el)) }; +fn library_value(el: &craft_xml::El, kind: VariableKind, set: &str, cmd: &str) -> Result> { + let refused = |what: &str| bad(cmd, format!("data set `{set}` holds a value for `{}` that isn't {what}", el.local())); + let text = || if el.elements().next().is_some() { None } else { Some(el.deep_text()) }; match kind { VariableKind::Text => { - let paras: Vec = elements(el).filter(|n| n.tag_name().name() == "p").map(deep_text).collect(); + let paras: Vec = el.children(craft_xml::Local("p")).map(craft_xml::El::deep_text).collect(); Ok((!paras.is_empty()).then(|| DataValue::Text(paras.join("\n")))) } VariableKind::Visibility => { @@ -346,16 +347,6 @@ fn library_value(el: roxmltree::Node, kind: VariableKind, set: &str, cmd: &str) } } -/// The elements among `n`'s children. -fn elements<'a, 'i>(n: roxmltree::Node<'a, 'i>) -> impl Iterator> { - n.children().filter(roxmltree::Node::is_element) -} - -/// The text of every text node inside `n`, in order. -fn deep_text(n: roxmltree::Node) -> String { - n.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect() -} - /// The absolute path a library's image value `text` names: a `file:` URL's path (`file:///…`, /// `file://localhost/…` or `file:/…`), its `%XX` escapes decoded (a URL with a host names a share, /// `//host/…`), or `text` itself. `None` unless that is an absolute path: one starting with `/` or @@ -428,29 +419,28 @@ fn library_file(path: &str) -> String { /// series, or when `el` holds only white space, there is no value. Anything else in `el`, more than /// [`MAX_GRAPH_CATEGORIES`] rows or [`MAX_GRAPH_SERIES`] series (what a graph holds), and CSV longer /// than graph values hold, are refused. -fn graph_value(el: roxmltree::Node, set: &str, cmd: &str) -> Result> { - let var = el.tag_name().name(); +fn graph_value(el: &craft_xml::El, set: &str, cmd: &str) -> Result> { + let var = el.local(); let refused = || bad(cmd, format!("data set `{set}` holds a value for `{var}` that isn't a graph's `data` element")); - let named = |name: &'static str| move |n: &roxmltree::Node| n.tag_name().name() == name; - let mut children = elements(el); + let named = |name: &'static str| craft_xml::Local(name); + let mut children = el.elements(); let data = match (children.next(), children.next()) { - (None, _) if deep_text(el).trim_matches(XML_SPACE).is_empty() => return Ok(None), - (Some(data), None) if data.tag_name().name() == "data" => data, + (None, _) if el.deep_text().trim_matches(XML_SPACE).is_empty() => return Ok(None), + (Some(data), None) if data.local() == "data" => data, _ => return Err(refused()), }; // Labels keep their white space; numbers are read without it. let series: Vec = - elements(data).find(named("propertyRow")).map(|r| elements(r).filter(named("value")).skip(1).map(deep_text).collect()).unwrap_or_default(); - let rows: Vec = - elements(data).filter(named("values")).flat_map(elements).filter(named("row")).take(MAX_GRAPH_CATEGORIES + 1).collect(); + data.child(named("propertyRow")).map(|r| r.children(named("value")).skip(1).map(craft_xml::El::deep_text).collect()).unwrap_or_default(); + let rows: Vec<&craft_xml::El> = data.children(named("values")).flat_map(|v| v.children(named("row"))).take(MAX_GRAPH_CATEGORIES + 1).collect(); if rows.is_empty() && series.is_empty() { return Ok(None); } let too_many = || bad(cmd, format!("data set `{set}` holds a graph value for `{var}` with {}", graph_limits())); // A row's values after its name. - let wide = |row: &roxmltree::Node| { - let mut values = elements(*row).filter(named("value")).peekable(); - let _name = values.next_if(|v| v.attribute("key") == Some("name")); + let wide = |row: &&craft_xml::El| { + let mut values = row.children(named("value")).peekable(); + let _name = values.next_if(|v| v.attr("key") == Some("name")); values.count() > MAX_GRAPH_SERIES }; if rows.len() > MAX_GRAPH_CATEGORIES || series.len() > MAX_GRAPH_SERIES || rows.iter().any(wide) { @@ -462,12 +452,12 @@ fn graph_value(el: roxmltree::Node, set: &str, cmd: &str) -> Result, Vec>)> = rows .iter() .map(|row| { - let mut values = elements(*row).filter(named("value")).peekable(); - let name = values.next_if(|v| v.attribute("key") == Some("name")); - labelled |= name.is_some_and(|v| v.attribute("type") == Some("string") || !deep_text(v).is_empty()); - let name = name.map(deep_text); - let number = |v: roxmltree::Node| deep_text(v).trim_matches(XML_SPACE).parse::().ok().filter(|n| n.is_finite()); - let numbers = values.map(|v| if v.attribute("type") == Some("string") { None } else { number(v) }); + let mut values = row.children(named("value")).peekable(); + let name = values.next_if(|v| v.attr("key") == Some("name")); + labelled |= name.is_some_and(|v| v.attr("type") == Some("string") || !v.deep_text().is_empty()); + let name = name.map(craft_xml::El::deep_text); + let number = |v: &craft_xml::El| v.deep_text().trim_matches(XML_SPACE).parse::().ok().filter(|n| n.is_finite()); + let numbers = values.map(|v| if v.attr("type") == Some("string") { None } else { number(v) }); (name, numbers.collect()) }) .collect(); diff --git a/crates/engine/src/cmd/xmlattrs.rs b/crates/engine/src/cmd/xmlattrs.rs new file mode 100644 index 0000000000..6dd2578fae --- /dev/null +++ b/crates/engine/src/cmd/xmlattrs.rs @@ -0,0 +1,55 @@ +//! Attribute uniqueness checks missing from the pinned shared XML reader's strict mode. +//! +//! Temporary consumer guard: parsing remains in craft-xml. Move this invariant into the +//! shared strict reader and remove this second event pass when that dependency is updated. + +/// Check names before canonical/local-name rewriting can collapse distinct attributes. +/// The event reader and namespace scope use the same caller-supplied resource caps. +pub(super) fn validate(xml: &[u8], limits: craft_xml::Limits) -> Result<(), String> { + use std::collections::BTreeSet; + + let opts = craft_xml::Options { limits, doctype: craft_xml::Doctype::Reject, ..Default::default() }.strict(); + let mut events = opts.events(xml); + let mut bindings: Vec<(String, String)> = Vec::new(); + let mut scopes = Vec::new(); + while let Some(event) = events.next().map_err(|e| e.to_string())? { + match event { + craft_xml::Event::Start { attrs, .. } => { + scopes.push(bindings.len()); + let mut raw = BTreeSet::new(); + for (key, value) in &attrs { + if !raw.insert(key.as_str()) { + return Err(format!("duplicate attribute `{key}`")); + } + if let Some(prefix) = key.strip_prefix("xmlns:") { + if bindings.len() >= limits.max_attrs { + return Err("too many in-scope namespace declarations".into()); + } + bindings.push((prefix.to_string(), value.clone())); + } + } + let mut expanded = BTreeSet::new(); + for (key, _) in &attrs { + if key == "xmlns" || key.starts_with("xmlns:") { + continue; + } + // Default namespaces do not apply to attributes. Preserve undeclared + // prefixes for standalone SVG library fragments, as the importer did. + let name = match key.split_once(':') { + Some(("xml", local)) => ("http://www.w3.org/XML/1998/namespace", local), + Some((prefix, local)) => { + bindings.iter().rev().find(|(p, _)| p == prefix).map_or(("", key.as_str()), |(_, uri)| (uri.as_str(), local)) + } + None => ("", key.as_str()), + }; + if !expanded.insert(name) { + return Err(format!("duplicate expanded attribute `{key}`")); + } + } + } + craft_xml::Event::End { .. } => bindings.truncate(scopes.pop().unwrap_or(0)), + _ => {} + } + } + Ok(()) +} diff --git a/crates/engine/src/tests/textimport.rs b/crates/engine/src/tests/textimport.rs index b369346096..cc89a8261b 100644 --- a/crates/engine/src/tests/textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -468,6 +468,15 @@ fn malformed_docx_fails_cleanly() { assert!(e.contains("not a WordprocessingML document"), "{e}"); let e = place(&mut s, "a.docx", &store(&[("word/document.xml", b"")])).unwrap_err(); assert!(e.contains("not well-formed XML"), "{e}"); + for xml in [ + format!(r#"partial text"#), + document("A&missing;B"), + document("text"), + document("text"), + ] { + let e = place(&mut s, "a.docx", &store(&[("word/document.xml", xml.as_bytes())])).unwrap_err(); + assert!(e.contains("not well-formed XML"), "{e}"); + } let e = place(&mut s, "a.docx", &store(&[("word/document.xml", document("").as_bytes())])).unwrap_err(); assert!(e.contains("no text to place"), "{e}"); // A file whose headers understate what it unpacks to, and one whose headers say it is too large. diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 9119c3e151..3b9644e550 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2665,6 +2665,11 @@ fn malformed_variable_libraries_are_refused() { let long_graph = format!("{}", row.repeat(2_000)); let cases = [ ("not well-formed XML", "".to_string()), + ("not well-formed XML", ok.replace("

x

", "

A&missing;B

")), + ("not well-formed XML", ok.replace("
", "")), + ("not well-formed XML", ok.replace("varName=\"Name\"", "varName=\"Name\" varName=\"Other\"")), + ("not well-formed XML", ok.replace("".to_string()), ("declares no variables", "".to_string()), ("is declared twice", library(&[("Name", "textcontent"), ("Name", "textcontent")], &[])), @@ -3545,6 +3550,17 @@ fn variable_sort_orders_the_list_by_each_column() { } } +#[test] +fn variable_library_attributes_keep_their_namespace() { + let mut s = session(); + let xml = library(&[("Name", "textcontent")], &[("A", &[("Name", &["x"])])]) + .replace("varName=\"Name\"", "xmlns:m=\"urn:metadata\" m:varName=\"Other\" varName=\"Name\""); + let loaded = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + assert_eq!(loaded["variables"], json!(["Name"])); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "x"}})); +} + /// The sort is panel state: it is not an undo step and does not count as a change to save, and /// setting the order the list already has does not redraw anything. A bad `by` or `descending` /// is an error that leaves the order as it was. 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..930f7e4013 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,13 @@ const MAX_XMP: usize = 8 << 20; const MAX_NESTING: usize = 256; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; -const XML: &str = "http://www.w3.org/XML/1998/namespace"; pub(crate) const DC: &str = "http://purl.org/dc/elements/1.1/"; const XMP: &str = "http://ns.adobe.com/xap/1.0/"; // brand-ok: the XMP namespace the files use pub(crate) const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace the files use +const XMP_NAMESPACES: Canonical<'static> = Canonical::new(&[("rdf", RDF), ("dc", DC), ("xmp", XMP), ("xmpRights", XMP_RIGHTS), ("pdf", PDF)]); + /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { @@ -73,39 +74,39 @@ fn packet(pdf: &Pdf) -> Option> { } /// 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 +124,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,14 +142,10 @@ 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))) - .collect(), + Some(a) => a.children("rdf:li").map(|li| (li.attr("xml:lang").is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li))).collect(), None => vec![(false, value_of(p))], }; // A stable sort: the `x-default` item first, the others in their order. @@ -203,19 +156,19 @@ fn items(p: Node<'_, '_>) -> Vec { /// 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..e2df72e01c 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -144,9 +144,18 @@ fn unpack(mut src: &[u8], out: &mut Vec) { } } -/// The composite as a reader sees it. +/// The stored composite: RGB stays white-matted, with its original alpha. fn merged(f: &[u8]) -> Vec { - ::psd::Psd::from_bytes(f).expect("a PSD a reader takes").rgba() + let psd = craft_psd::PsdFile::from_bytes(f).expect("a PSD a reader takes"); + if psd.header.color_mode != craft_psd::ColorMode::Rgb { + return psd.composite_rgba8().expect("composite").data; + } + assert_eq!(psd.header.depth, 8); + let samples = psd.decode_merged().expect("stored composite planes"); + let count = psd.header.width as usize * psd.header.height as usize; + (0..count) + .flat_map(|i| [samples[i], samples[count + i], samples[2 * count + i], if psd.merged_has_alpha() { samples[3 * count + i] } else { 255 }]) + .collect() } fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] { @@ -168,11 +177,12 @@ fn layers_are_the_top_level_layers_and_the_merged_image_is_the_render() { assert_eq!(p.records[1].rect, [0, 0, 50, 60], "the blue square and the corner"); // A reader decodes it: the layers, in place, and the merged image. - let psd = ::psd::Psd::from_bytes(&file).unwrap(); - assert_eq!((psd.width(), psd.height(), psd.layers().len()), (100, 60, 2)); - let red = psd.layer_by_name("Layer 1").unwrap().rgba(); - assert_eq!(pixel(&red, 100, 20, 20), [255, 0, 0, 255]); - assert_eq!(pixel(&red, 100, 50, 45)[3], 0, "the blue square isn't on Layer 1"); + let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); + assert_eq!((psd.header.width, psd.header.height, psd.layers().len()), (100, 60, 2)); + let layer = psd.iter_layers().find(|l| l.name() == "Layer 1").unwrap(); + assert_eq!(layer.record.rect, craft_psd::layer::Rect { top: 10, left: 10, bottom: 40, right: 40 }); + let red = layer.rgba8().unwrap().data; + assert_eq!(pixel(&red, 30, 10, 10), [255, 0, 0, 255]); // The merged image is the flat render: colours on white where transparent, alpha apart. let img = Renderer::new().render_region_with(&doc, doc.artboards[0].rect, 1.0, &opts(PsdOptions::default()).render_options(RasterFormat::Psd)); @@ -200,7 +210,7 @@ fn flat_writes_the_merged_image_alone_on_white() { assert_eq!(o.encode(&img, RasterFormat::Psd).unwrap(), file); let file = export(&doc, &RasterExportOptions { ppi: 150.0, ..o }); assert_eq!(u32_at(parse(&file).resources, 12), 150 << 16); - assert_eq!(::psd::Psd::from_bytes(&file).unwrap().width(), 208); + assert_eq!(craft_psd::PsdFile::from_bytes(&file).unwrap().header.width, 208); } #[test] @@ -230,8 +240,11 @@ fn maximum_editability_writes_groups_and_an_object_per_layer() { assert_eq!((p.records[4].opacity, p.records[4].blend, p.records[4].rect), (128, *b"mul ", [20, 30, 50, 60])); assert_eq!((p.records[8].blend, p.records[8].opacity), (*b"pass", 255)); assert_eq!(p.records[6].rect, [0, 0, 6, 6]); - let psd = ::psd::Psd::from_bytes(&file).unwrap(); - assert_eq!((psd.layers().len(), psd.groups().len()), (3, 3)); + let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); + let content_layers = psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::SectionType::Other)).count(); + let groups = + psd.layers().iter().filter(|l| matches!(l.section_type(), craft_psd::SectionType::OpenFolder | craft_psd::SectionType::ClosedFolder)).count(); + assert_eq!((content_layers, groups), (3, 3)); assert_eq!(merged(&file), merged(&export(&doc, &opts(PsdOptions::default()))), "the same merged image"); // Hidden layers: written hidden, drawn as if visible, instead of left out. @@ -300,8 +313,8 @@ fn a_background_colour_is_the_bottom_layer() { let p = parse(&file); assert_eq!((p.channels, p.count), (3, 3), "opaque: no merged alpha"); assert_eq!((p.records[0].name.as_str(), p.records[0].rect), ("Background", [0, 0, 60, 100])); - let psd = ::psd::Psd::from_bytes(&file).unwrap(); - assert!(psd.layer_by_name("Background").unwrap().rgba().chunks(4).all(|p| p == [0, 0, 255, 255])); + let psd = craft_psd::PsdFile::from_bytes(&file).unwrap(); + assert!(psd.iter_layers().find(|l| l.name() == "Background").unwrap().rgba8().unwrap().data.chunks(4).all(|p| p == [0, 0, 255, 255])); assert_eq!(pixel(&merged(&file), 100, 5, 50), [0, 0, 255, 255]); } diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs index c70eacd2fc..e10739e67a 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::Ifd { + Tiff::parse(file).expect("a TIFF").ifds.into_iter().next().expect("IFD0") +} + +fn tag_u16_vec(ifd: &craft_tiff::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::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").into_data() } /// Straight RGBA: flat areas (as art is) with a transparent corner when `clear`. @@ -46,7 +49,7 @@ fn byte_order_writes_an_ii_or_mm_header() { assert_eq!(&file[..4], magic); let rgb: Vec = 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), Some(2)); + assert_eq!(rgba_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(4)); + assert_eq!(tag_u16_vec(&rgba_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8, 8, 8, 8].as_slice())); + assert_eq!(tag_u16_vec(&rgba_ifd, tags::EXTRA_SAMPLES).as_deref(), Some([2].as_slice()), "unassociated alpha"); let opaque = art(9, 6, false); let rgb = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); - assert_eq!(decoder(&rgb).colortype().unwrap(), tiff::ColorType::RGB(8)); - assert!(decoder(&rgb).find_tag(Tag::ExtraSamples).unwrap().is_none()); + let rgb_ifd = ifd0(&rgb); + assert_eq!(rgb_ifd.u16(tags::PHOTOMETRIC), Some(2)); + assert_eq!(rgb_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(3)); + assert!(rgb_ifd.value(tags::EXTRA_SAMPLES).is_none()); let gray = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - assert_eq!(decoder(&gray).colortype().unwrap(), tiff::ColorType::Gray(8)); - assert_eq!(decoder(&gray).get_tag_unsigned::(Tag::PhotometricInterpretation).unwrap(), 1, "black is zero"); + let gray_ifd = ifd0(&gray); + assert_eq!(gray_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1)); + assert_eq!(tag_u16_vec(&gray_ifd, tags::BITS_PER_SAMPLE).as_deref(), Some([8].as_slice())); + assert_eq!(gray_ifd.u16(tags::PHOTOMETRIC), Some(1), "black is zero"); let g = pixels(&gray); assert_eq!((g[0], g[8]), (super::jpeg::to_gray()([200, 30, 40]), 255), "lightness; white stays white"); let on_white = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); - assert_eq!(decoder(&on_white).colortype().unwrap(), tiff::ColorType::Gray(8), "grey has no alpha: few readers take it"); + let on_white_ifd = ifd0(&on_white); + assert_eq!(on_white_ifd.u16(tags::SAMPLES_PER_PIXEL), Some(1), "grey has no alpha: few readers take it"); assert_eq!(pixels(&on_white)[0], 255, "the transparent corner is white"); // CMYK: four samples a pixel, ink amounts as given. let inks: Vec = (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), Some(5), "separated"); + assert_eq!(d.u16(332), Some(1), "CMYK inks"); + let cmyk_img = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &cmyk).unwrap(); + assert_eq!(cmyk_img.data(), inks); } #[test] @@ -106,16 +116,18 @@ fn resolution_tags_and_the_profile() { let px = art(4, 4, false); for (ppi, rational) in [(300.0, (300, 1)), (72.5, (7250, 100))] { let file = t::encode_samples(&px, 4, 4, ppi, &opts(ColorModel::Rgb)).unwrap(); - let mut d = decoder(&file); - for tag in [Tag::XResolution, Tag::YResolution] { - assert_eq!(d.get_tag(tag).unwrap(), tiff::decoder::ifd::Value::Rational(rational.0, rational.1)); + let d = ifd0(&file); + for tag in [tags::X_RESOLUTION, tags::Y_RESOLUTION] { + let Some(craft_tiff::Value::Rational(v)) = d.value(tag) else { panic!("rational") }; + assert_eq!(v.as_slice(), &[rational]); } - assert_eq!(d.get_tag_unsigned::(Tag::ResolutionUnit).unwrap(), 2, "inches"); + assert_eq!(d.u16(tags::RESOLUTION_UNIT), Some(2), "inches"); } for model in ColorModel::ALL { let src = if model == ColorModel::Cmyk { vec![0; 64] } else { px.clone() }; let file = t::encode_samples(&src, 4, 4, 72.0, &TiffOptions { embed_icc: true, ..opts(model) }).unwrap(); - let icc = decoder(&file).get_tag_u8_vec(Tag::IccProfile).unwrap(); + let ifd = ifd0(&file); + let icc = ifd.bytes(tags::ICC_PROFILE).unwrap(); assert_eq!(&icc[36..40], b"acsp", "{model:?}: an ICC profile"); let space: &[u8; 4] = match model { ColorModel::Rgb => b"RGB ", @@ -125,7 +137,7 @@ fn resolution_tags_and_the_profile() { assert_eq!(&icc[16..20], space, "{model:?}"); } let file = t::encode_samples(&px, 4, 4, 72.0, &opts(ColorModel::Rgb)).unwrap(); - assert!(decoder(&file).find_tag(Tag::IccProfile).unwrap().is_none()); + assert!(ifd0(&file).value(tags::ICC_PROFILE).is_none()); } #[test] @@ -135,10 +147,10 @@ fn big_images_take_several_strips_in_either_order() { for order in ByteOrder::ALL { for lzw in [false, true] { let file = t::encode_samples(&px, w, h, 72.0, &TiffOptions { lzw, byte_order: order, ..opts(ColorModel::Gray) }).unwrap(); - let mut d = decoder(&file); - let offsets = d.get_tag_u32_vec(Tag::StripOffsets).unwrap(); + let d = ifd0(&file); + let offsets = tag_u32_vec(&d, tags::STRIP_OFFSETS).unwrap(); // 300 bytes a row: 27 rows a strip. - assert_eq!(d.get_tag_u32(Tag::RowsPerStrip).unwrap(), 27); + assert_eq!(d.u32(tags::ROWS_PER_STRIP), Some(27)); assert_eq!(offsets.len(), 4); assert!(offsets.iter().all(|o| o % 2 == 0), "strips start on a word"); assert_eq!(pixels(&file).len(), (w * h) as usize, "{order:?} lzw {lzw}"); @@ -152,11 +164,19 @@ fn packbits_and_bilevel_images_decode() { let rows = [0b1010_1010u8, 0b1100_0000, 0xFF, 0xC0, 0, 0]; let image = Image { width: 10, height: 3, data: &rows, photometric: Photometric::WhiteIsZero, samples: 1, bits: 1, alpha: false }; let file = t::write(&image, &Layout { byte_order: ByteOrder::Big, compression: Compression::PackBits, ppi: 72.0, icc: None }).unwrap(); - let mut d = decoder(&file); - assert_eq!(d.colortype().unwrap(), tiff::ColorType::Gray(1)); - assert_eq!(d.get_tag_unsigned::(Tag::Compression).unwrap(), 32773); + let d = ifd0(&file); + assert_eq!(d.u16(tags::SAMPLES_PER_PIXEL), Some(1)); + assert_eq!(tag_u16_vec(&d, tags::BITS_PER_SAMPLE).as_deref(), Some([1].as_slice())); + assert_eq!(d.u16(tags::COMPRESSION), Some(32773)); // The decoder turns WhiteIsZero into black-is-zero. - assert_eq!(pixels(&file), rows.map(|b| !b)); + let dec = craft_image_codecs::decode_as(craft_image_codecs::Format::Tiff, &file).unwrap(); + assert_eq!(dec.width(), 10); + assert_eq!(dec.height(), 3); + assert_eq!( + dec.data(), + [0, 255, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], + "WhiteIsZero samples are inverted and row padding is ignored" + ); // A buffer that doesn't match the size is refused, not written. let short = Image { data: &rows[..5], ..image }; assert!(t::write(&short, &Layout { byte_order: ByteOrder::Little, compression: Compression::None, ppi: 72.0, icc: None }).is_err()); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index c130fbe346..8650ae7886 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) + let streamed = craft_image_codecs::PngDecodeRows::new_streaming(bytes, &png_streaming_limits(allowance)) + .ok() + .filter(|dec| dec.streamable()) + .and_then(|dec| png_row_scratch(&dec.header())); + streamed.or_else(|| png_row_scratch_from_header(bytes, w, h, d.total_bytes())).filter(|&s| s <= allowance) } else { None }; @@ -272,6 +276,13 @@ fn reduction(bytes: &[u8], allowance: u64) -> Option { (rows || d.total_bytes() <= allowance).then_some(Reduction { size: (w, h), to, rows, scratch }) } +/// Streaming retains rows, not the declared full image. Preserve the renderer's source-pixel +/// allowance even when RGBA16's declared samples exceed the shared whole-image default budget. +/// Row scratch and the reduced pixmap are admitted separately before decoding starts. +fn png_streaming_limits(source_pixels: u64) -> craft_image_codecs::Limits { + craft_image_codecs::Limits { max_width: u32::MAX, max_height: u32::MAX, max_pixels: source_pixels, max_alloc: source_pixels.saturating_mul(8) } +} + /// How many rows of an image decoded whole [`reduced_pixmap`] converts to RGBA at a time. const BAND: u32 = 16; @@ -283,17 +294,58 @@ const PNG_KEPT_ROWS: u64 = 128 << 10; /// stream buffer grows by. const PNG_WINDOW_AND_STEP: u64 = (32 << 10) + (8 << 10); -/// The most memory `r` takes in its buffers to read its image a row at a time: the stream buffer -/// holds the rows already read up to [`PNG_KEPT_ROWS`] or four rows, whichever is more, the previous -/// and current rows, and [`PNG_WINDOW_AND_STEP`]. The buffer grows by doubling its reservation, -/// and while it grows its old reservation and the new one, up to twice the size it holds, are both -/// allocated: three times that size at most. A copy of a stored row and the row given out come on -/// top. `None` when a row given out doesn't fit memory. -fn png_row_scratch(r: &png::Reader>) -> 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(hdr.layout.channels() as u64)?.checked_mul(hdr.sample.bytes() as u64)?; + png_row_scratch_for_row(out) +} + +/// The row decoder rejects a truncated PNG's tiny IDAT before it can report its header. For a +/// size estimate, the image decoder still gives us the declared output bytes; use those only when +/// the PNG header says it is a static, non-interlaced image, the format the renderer can stream. +fn png_row_scratch_from_header(bytes: &[u8], width: u32, height: u32, total_bytes: u64) -> Option { + const SIG: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + if bytes.get(..8)? != SIG || height == 0 || !total_bytes.is_multiple_of(u64::from(height)) { + return None; + } + let mut at = 8usize; + let mut header = false; + loop { + let len = u32::from_be_bytes(bytes.get(at..at + 4)?.try_into().ok()?) as usize; + let kind = bytes.get(at + 4..at + 8)?; + let data_at = at.checked_add(8)?; + let end = data_at.checked_add(len)?; + let data = bytes.get(data_at..end)?; + match kind { + b"IHDR" if len == 13 && !header => { + header = true; + if at != 8 + || u32::from_be_bytes(data[..4].try_into().ok()?) != width + || u32::from_be_bytes(data[4..8].try_into().ok()?) != height + || data[12] != 0 + { + return None; + } + } + b"acTL" | b"eXIf" => return None, + b"IDAT" if header => { + let row = total_bytes / u64::from(height); + return row.checked_add(1).and_then(png_row_scratch_for_row); + } + _ => {} + } + at = end.checked_add(4)?; // CRC + } +} + +fn png_row_scratch_for_row(out: u64) -> Option { + // A filter byte and the row's samples. + let stored = 1u64.checked_add(out)?; let kept = (stored * 4).next_multiple_of(64).max(PNG_KEPT_ROWS); let stream = kept + stored * 2 + PNG_WINDOW_AND_STEP; Some(stream * 3 + stored + out) @@ -313,14 +365,6 @@ fn fitted_dims(w: u32, h: u32, allowance: u64) -> Option<(u32, u32)> { (u64::from(nw) * u64::from(nh) * 4 <= allowance).then_some((nw, nh)) } -/// A PNG reader of `bytes` that gives 8-bit gray, gray with alpha, RGB or RGBA rows, with its -/// header and the chunks before the pixels read. -fn png_reader(bytes: &[u8]) -> Option>> { - let mut d = png::Decoder::new(std::io::Cursor::new(bytes)); - d.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); - d.read_info().ok() -} - /// `bytes` decoded at the size [`reduction`] gives. Each pixel of the result is the mean of the /// image's pixels it covers. [`Undecoded::Unreadable`] when they can't be read, and /// [`Undecoded::NoRoom`] when the result and what reading takes ([`Reduction::scratch`]) don't fit @@ -335,19 +379,64 @@ fn reduced_pixmap(bytes: &[u8], r: &Reduction) -> Result Option { + shrunk_with_png_limits(bytes, r, &png_streaming_limits(allowance())) +} + +fn shrunk_with_png_limits(bytes: &[u8], r: &Reduction, png_limits: &craft_image_codecs::Limits) -> Option { let mut shrink = Shrink::new(r.size, r.to); if r.rows { - let mut reader = png_reader(bytes)?; - let (color, _) = reader.output_color_type(); - while let Some(row) = reader.next_row().ok()? { - let data = row.data(); - match color { - png::ColorType::Grayscale => shrink.row(data.iter().map(|&g| [g, g, g, 255])), - png::ColorType::GrayscaleAlpha => shrink.row(data.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])), - png::ColorType::Rgb => shrink.row(data.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])), - png::ColorType::Rgba => shrink.row(data.as_chunks::<4>().0.iter().copied()), - png::ColorType::Indexed => return None, - } + use craft_image_codecs::{ChannelLayout, CodecError, Format, PngDecodeRows, SampleType}; + let mut dec = PngDecodeRows::new_streaming(bytes, png_limits).ok()?; + if !dec.streamable() { + return None; + } + let hdr = dec.header(); + // Yield after each row and stop once all pixels have arrived. The pinned decoder's + // first tail batch only locates a text chunk; later batches inflate/retain its value. + // Never run those batches: the renderer needs pixels, not ancillary text. Its underlying + // PNG reader also ignores text chunks before IDAT, so neither location retains text. + for _ in 0..hdr.height { + let _extras = dec + .step_stream::(1, &mut || false, &mut |_y, row| { + match (hdr.layout, hdr.sample) { + (ChannelLayout::Gray, SampleType::U8) => shrink.row(row.iter().map(|&g| [g, g, g, 255])), + (ChannelLayout::GrayA, SampleType::U8) => shrink.row(row.as_chunks::<2>().0.iter().map(|&[g, a]| [g, g, g, a])), + (ChannelLayout::Rgb, SampleType::U8) => shrink.row(row.as_chunks::<3>().0.iter().map(|&[r, g, b]| [r, g, b, 255])), + (ChannelLayout::Rgba, SampleType::U8) => shrink.row(row.as_chunks::<4>().0.iter().copied()), + (ChannelLayout::Gray, SampleType::U16) => shrink.row(row.as_chunks::<2>().0.iter().map(|&g| { + let v = (u16::from_ne_bytes(g) >> 8) as u8; + [v, v, v, 255] + })), + (ChannelLayout::GrayA, SampleType::U16) => shrink.row(row.as_chunks::<4>().0.iter().map(|&[g0, g1, a0, a1]| { + let g = (u16::from_ne_bytes([g0, g1]) >> 8) as u8; + let a = (u16::from_ne_bytes([a0, a1]) >> 8) as u8; + [g, g, g, a] + })), + (ChannelLayout::Rgb, SampleType::U16) => shrink.row(row.as_chunks::<6>().0.iter().map(|&[r0, r1, g0, g1, b0, b1]| { + [ + (u16::from_ne_bytes([r0, r1]) >> 8) as u8, + (u16::from_ne_bytes([g0, g1]) >> 8) as u8, + (u16::from_ne_bytes([b0, b1]) >> 8) as u8, + 255, + ] + })), + (ChannelLayout::Rgba, SampleType::U16) => { + shrink.row(row.as_chunks::<8>().0.iter().map(|&[r0, r1, g0, g1, b0, b1, a0, a1]| { + [ + (u16::from_ne_bytes([r0, r1]) >> 8) as u8, + (u16::from_ne_bytes([g0, g1]) >> 8) as u8, + (u16::from_ne_bytes([b0, b1]) >> 8) as u8, + (u16::from_ne_bytes([a0, a1]) >> 8) as u8, + ] + })) + } + _ => { + return Err(CodecError::Unsupported { format: Format::Png, reason: "unsupported PNG channel layout".into() }); + } + } + Ok(()) + }) + .ok()?; } } else { let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; @@ -589,6 +678,58 @@ mod tests { assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(())); } + #[test] + fn streaming_header_accepts_rgba16_above_whole_image_default() { + // Header-only fixture: opening the stream checks declared size without allocating or + // decoding the 1.2 GB canvas. Padding meets the decoder's compressed-size plausibility + // bound; no rows are requested from this deliberately incomplete image. + let (w, h) = (10_000u32, 15_000u32); + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + let ihdr = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[16, 6, 0, 0, 0]].concat(); + let mut idat = b"IDAT\x78\x9c".to_vec(); + idat.resize(1_200_000, 0); + for chunk in [&ihdr[..], &idat, b"IEND"] { + bytes.extend((chunk.len() as u32 - 4).to_be_bytes()); + bytes.extend(chunk); + let mut crc = flate2::Crc::new(); + crc.update(chunk); + bytes.extend(crc.sum().to_be_bytes()); + } + let default = craft_image_codecs::Limits { max_alloc: 1 << 30, max_pixels: 150_000_000, ..Default::default() }; + assert!(craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &default).is_err()); + let rows = craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &png_streaming_limits(150_000_000)).unwrap(); + assert_eq!((rows.header().width, rows.header().height), (w, h)); + assert!(rows.streamable()); + assert!(craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &png_streaming_limits(149_999_999)).is_err()); + } + + #[test] + fn streaming_reduction_does_not_retain_text_after_pixels() { + let mut bytes = Vec::new(); + striped(8).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + let mut chunk = b"tEXtComment\0".to_vec(); + chunk.extend(std::iter::repeat_n(b'x', 1 << 20)); + // PNG's IEEE CRC over type and payload, kept local to this fixture. + let mut crc = u32::MAX; + for &byte in &chunk { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb88320 & 0u32.wrapping_sub(crc & 1)); + } + } + let mut tail = ((chunk.len() - 4) as u32).to_be_bytes().to_vec(); + tail.extend(chunk); + tail.extend((!crc).to_be_bytes()); + bytes.splice(bytes.len() - 12..bytes.len() - 12, tail); + let limits = craft_image_codecs::Limits { max_alloc: 1 << 19, ..Default::default() }; + let mut full = craft_image_codecs::PngDecodeRows::new_streaming(&bytes, &limits).unwrap(); + assert!(full.step_stream::(1, &mut || true, &mut |_, _| Ok(())).is_err()); + // The renderer stops after the pixels instead of retaining ancillary text. + let reduced = shrunk_with_png_limits(&bytes, &Reduction { size: (8, 1), to: (4, 1), rows: true, scratch: 0 }, &limits).unwrap(); + assert_eq!((reduced.width(), reduced.height()), (4, 1)); + assert!(red_at_its_coverage(&reduced)); + } + /// Opaque red beside clear black, every other column of a `w` × 1 image. fn striped(w: u32) -> image::RgbaImage { image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 99fa434ca3..b562e85865 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1883,14 +1883,14 @@ fn a_png_past_the_allowance_draws_at_reduced_size() { assert_eq!((pm.width(), pm.height(), [c.r, c.g, c.b, c.a]), (300, 300, [200, 40, 40, 255])); // A row is read only while the reader's buffers fit the allowance, counting the stream buffer // three times for the moment it grows. Below 4,096 columns of 16-bit RGBA they take 516,103 - // bytes and 60 a column: 1,398 columns take 599,983 bytes, within 600,000, and 1,399 columns - // take 600,043 and are not decoded (their samples, 8 bytes a pixel, are past it too). + // bytes plus 64 bytes a column: 1,310 columns take 599,943 bytes, within 600,000, and 1,311 + // columns take 600,007 and are not decoded (their samples, 8 bytes a pixel, are past it too). let wide = |w: u32| { let img = image::ImageBuffer::, _>::from_pixel(w, 60, image::Rgba([51400, 10280, 10280, 65535])); encoded(image::DynamicImage::ImageRgba16(img), image::ImageFormat::Png) }; - assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1398))), Some((1398, 60))); - assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1399))), None); + assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1310))), Some((1310, 60))); + assert_eq!(with_allowance(600_000, || paint::reduced_size(&wide(1311))), None); // An image of more pixels than the allowance has bytes is not read: 1,000 × 400 8-bit gray // pixels are drawn at 500 × 200 within 400,000 bytes, and 1,000 × 401 are not. let gray = |h: u32| encoded(image::DynamicImage::ImageLuma8(image::GrayImage::from_pixel(1000, h, image::Luma([90]))), image::ImageFormat::Png); @@ -1938,13 +1938,13 @@ fn a_reduced_decode_counts_what_reading_takes_beside_the_images_paints_hold() { // Decoded whole: 14,400 bytes of RGB and a 16-row band of 8,960 bytes, beside a 73 × 54 result // of 15,768 bytes. let jpeg = encoded(image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(80, 60, image::Rgb([30, 120, 200]))), image::ImageFormat::Jpeg); - // Read a row at a time: 540,103 bytes in the reader's buffers for 400 columns of 16-bit RGBA, + // Read a row at a time: 541,703 bytes in the reader's buffers for 400 columns of 16-bit RGBA, // beside a 387 × 387 result of 599,076 bytes. let png = encoded( image::DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel(400, 400, image::Rgba([51400, 10280, 10280, 65535]))), image::ImageFormat::Png, ); - for (bytes, allowance, room, size) in [(&jpeg, 16_000, 15_768 + 14_400 + 8_960, (73, 54)), (&png, 600_000, 599_076 + 540_103, (387, 387))] { + for (bytes, allowance, room, size) in [(&jpeg, 16_000, 15_768 + 14_400 + 8_960, (73, 54)), (&png, 600_000, 599_076 + 541_703, (387, 387))] { let failed = decode_failures(); let none = with_allowance(allowance, || with_room(room - 1, || paint::decode_pixmap_or_why(bytes))); assert_eq!(none.err(), Some(paint::Undecoded::NoRoom), "not decoded with a byte too few, and tried again later"); diff --git a/crates/trace/Cargo.toml b/crates/trace/Cargo.toml index a605a69adb..ad76667886 100644 --- a/crates/trace/Cargo.toml +++ b/crates/trace/Cargo.toml @@ -11,9 +11,11 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } kurbo = { workspace = true } -image = { workspace = true } +craft-image-codecs = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } [dev-dependencies] serde_json = { workspace = true } +# Test fixtures only: the JPEG-block test needs the block artifacts of the encoder it was tuned on. +image = { workspace = true } diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs index 52e80a368d..7a0b87d9e0 100644 --- a/crates/trace/src/opaque.rs +++ b/crates/trace/src/opaque.rs @@ -3,7 +3,7 @@ use vectorcraft_geom::{Affine, PathData}; -use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels}; +use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels, trace_header}; use crate::{Mode, Raster, TraceError, TraceParams, trace_within}; /// The most pixels [`opaque_outline`] traces: a larger image is reduced to this many first, @@ -24,26 +24,49 @@ pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20; /// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`], /// gives [`TraceError::TooLarge`] before it is decoded. pub fn decode_alpha(bytes: &[u8]) -> Result)>, TraceError> { - use image::ImageDecoder; - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let decoder = reader.into_decoder().map_err(decode_err)?; - let (w, h) = decoder.dimensions(); + use craft_image_codecs::{ChannelLayout, DecodeOptions, Limits, RasterColorType, SampleType, decode_with}; + let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string()); + if let Some(info) = trace_header(bytes, MAX_PIXELS, ALPHA_MAX_DECODE_BYTES)? { + if info.width == 0 || info.height == 0 { + return Err(TraceError::Empty); + } + let might_have_alpha = match info.color_type { + RasterColorType::GrayAlpha | RasterColorType::Rgba | RasterColorType::CmykAlpha => true, + RasterColorType::Rgb => info.channels > 3, + RasterColorType::Gray => info.channels > 1, + // The shared TIFF inspector reports the base photometric type even + // when an ExtraSamples channel carries alpha. + RasterColorType::Cmyk => info.channels > 4, + RasterColorType::Lab => info.channels > 3, + RasterColorType::Indexed => true, + _ => false, + }; + if !might_have_alpha { + return Ok(None); + } + let pixels = u64::from(info.width) * u64::from(info.height); + let frame = gif_first_frame_pixels(bytes).unwrap_or(0); + let total_bytes = pixels.saturating_mul(u64::from(info.channels)).saturating_mul(u64::from(info.bits_per_sample.max(8).div_ceil(8))); + if pixels.max(frame) > MAX_PIXELS || total_bytes > ALPHA_MAX_DECODE_BYTES { + return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 }); + } + } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); + } + let opts = + DecodeOptions { keep_orientation: true, limits: Limits { max_pixels: MAX_PIXELS, max_alloc: ALPHA_MAX_DECODE_BYTES, ..Limits::default() } }; + let img = decode_with(bytes, &opts).map_err(decode_err)?; + let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 { return Err(TraceError::Empty); } - if !decoder.color_type().has_alpha() { + if !img.layout().has_alpha() { return Ok(None); } - let pixels = u64::from(w) * u64::from(h); - let frame = gif_first_frame_pixels(bytes).unwrap_or(0); - if pixels.max(frame) > MAX_PIXELS || decoder.total_bytes() > ALPHA_MAX_DECODE_BYTES { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); - } - let alpha: Vec = 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,77 @@ 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::from_raw(4, 4, ChannelLayout::Rgba, SampleType::U16, rgba16_bytes).unwrap(); + for (name, img) in [("rgba", rgba), ("gray and alpha", luma_a), ("16 bits", rgba16)] { + let got = decode_alpha(&encoded(&img, Format::Png)).unwrap(); assert_eq!(got, Some((4, 4, want.clone())), "{name}"); } - let rgb = image::DynamicImage::ImageRgb8(rgba.to_rgb8()); - assert_eq!(decode_alpha(&encoded(rgb.clone(), image::ImageFormat::Png)).unwrap(), None); - assert_eq!(decode_alpha(&encoded(rgb, image::ImageFormat::Jpeg)).unwrap(), None); + let rgb = Image::from_u8(4, 4, ChannelLayout::Rgb, rgb_bytes).unwrap(); + assert_eq!(decode_alpha(&encoded(&rgb, Format::Png)).unwrap(), None); + assert_eq!(decode_alpha(&encoded(&rgb, Format::Jpeg)).unwrap(), None); assert!(decode_alpha(b"not an image").is_err()); } + + #[test] + fn decode_alpha_expands_gray_and_rgb_png_transparency_keys() { + // Original synthetic 2x1 PNGs: first pixel matches tRNS; second is opaque. + // Generated from PNG chunks and zlib scanlines, without an external image asset. + let gray: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, 0, 1, 8, 0, 0, 0, 0, 209, 73, 32, 86, 0, 0, 0, 2, 116, + 82, 78, 83, 0, 0, 118, 147, 205, 56, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 168, 7, 0, 0, 130, 0, 128, 32, 18, 25, 54, 0, 0, 0, + 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + let rgb: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0, 123, 64, 232, 221, 0, 0, 0, 6, 116, + 82, 78, 83, 0, 255, 0, 0, 0, 0, 164, 194, 192, 29, 0, 0, 0, 15, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 192, 240, 159, 1, 0, 7, 255, + 1, 255, 1, 127, 137, 167, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + for bytes in [gray, rgb] { + assert_eq!(decode_alpha(bytes).unwrap(), Some((2, 1, vec![0, 255]))); + } + } + + #[test] + fn decode_alpha_preserves_cmyk_and_lab_tiff_extra_samples() { + use craft_image_codecs::{ChannelLayout, Format, Image}; + for (layout, samples) in + [(ChannelLayout::CmykA, vec![10, 20, 30, 40, 0, 50, 60, 70, 80, 255]), (ChannelLayout::LabA, vec![128, 128, 128, 0, 192, 128, 128, 255])] + { + let img = Image::from_u8(2, 1, layout, samples).unwrap(); + assert_eq!(decode_alpha(&encoded(&img, Format::Tiff)).unwrap(), Some((2, 1, vec![0, 255])), "{layout:?}"); + } + for (layout, samples) in + [(ChannelLayout::Cmyk, vec![10, 20, 30, 40, 50, 60, 70, 80]), (ChannelLayout::Lab, vec![128, 128, 128, 192, 128, 128])] + { + let img = Image::from_u8(2, 1, layout, samples).unwrap(); + assert_eq!(decode_alpha(&encoded(&img, Format::Tiff)).unwrap(), None, "{layout:?}"); + } + } } diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 51312c685a..492b3b4eed 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -28,6 +28,68 @@ pub const MAX_PIXELS: u64 = 64 << 20; /// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused. pub const MAX_SOURCE_PIXELS: u64 = 192 << 20; +/// Header facts for tracing, without reserving a PNG's full decoded pixel buffer. +/// The shared allocation probe runs before the decoder's plausibility checks so the caller's +/// size refusal wins even for a large header-only file. The streaming reader still checks the +/// PNG structure and plausibility, and reports the expanded layout (including tRNS alpha). +pub(crate) fn trace_header(bytes: &[u8], max_pixels: u64, max_alloc: u64) -> Result, TraceError> { + use craft_image_codecs::{ChannelLayout, Format, Limits, PngDecodeRows, RasterColorType, RasterInspection}; + let decode_err = |e: craft_image_codecs::CodecError| TraceError::Decode(e.to_string()); + // Refuse a GIF frame larger than its logical screen before inspection's + // plausibility check can turn the caller's size refusal into a decode error. + if gif_first_frame_pixels(bytes).is_some_and(|pixels| pixels > max_pixels) { + return Err(TraceError::TooLarge { width: 0, height: 0, max: max_pixels >> 20 }); + } + if craft_image_codecs::detect(bytes) != Some(Format::Png) { + return craft_image_codecs::inspect_raster(bytes).map_err(decode_err); + } + let (width, height) = craft_image_codecs::probe_allocation_size(bytes).map_err(decode_err)?; + if u64::from(width) * u64::from(height) > max_pixels { + return Err(TraceError::TooLarge { width, height, max: max_pixels >> 20 }); + } + // The allocation probe validates the IHDR prefix. For explicit alpha layouts, its + // sample width also gives a lower bound on decoded bytes before plausibility checks. + // Preserve the alpha caller's TooLarge refusal for header-only RGBA16/GrayA images; + // the shared reader still validates CRCs, transparency expansion, and all other layouts. + let alpha_bytes_per_pixel = match bytes.get(24..26) { + Some([8, 4]) => 2, + Some([16, 4]) | Some([8, 6]) => 4, + Some([16, 6]) => 8, + _ => 0, + }; + if (u64::from(width) * u64::from(height)).saturating_mul(alpha_bytes_per_pixel) > max_alloc { + return Err(TraceError::TooLarge { width, height, max: max_pixels >> 20 }); + } + let limits = Limits { max_pixels, max_alloc, ..Limits::default() }; + let rows = PngDecodeRows::new_streaming(bytes, &limits).map_err(decode_err)?; + let h = rows.header(); + let color_type = match h.layout { + ChannelLayout::Gray => RasterColorType::Gray, + ChannelLayout::GrayA => RasterColorType::GrayAlpha, + ChannelLayout::Rgb => RasterColorType::Rgb, + ChannelLayout::Rgba => RasterColorType::Rgba, + _ => RasterColorType::Other, + }; + Ok(Some(RasterInspection { + width: h.width, + height: h.height, + channels: h.layout.channels() as u8, + bits_per_sample: (h.sample.bytes() * 8) as u8, + color_type, + palette: None, + transparent_index: None, + })) +} + +/// Reuse an already-normalized RGBA8 decode instead of allocating a second full pixel buffer. +fn into_rgba8(img: craft_image_codecs::Image) -> Vec { + if img.layout() == craft_image_codecs::ChannelLayout::Rgba && img.sample_type() == craft_image_codecs::SampleType::U8 { + img.into_data() + } else { + img.to_rgba8() + } +} + /// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1). pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { let pixels = u64::from(w) * u64::from(h); @@ -158,62 +220,84 @@ impl Raster { } /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). pub fn decode(bytes: &[u8]) -> Result { - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = reader.into_dimensions().map_err(decode_err)?; + if let Some(info) = trace_header(bytes, MAX_PIXELS, craft_image_codecs::Limits::default().max_alloc)? { + if info.width == 0 || info.height == 0 { + return Err(TraceError::Empty); + } + if u64::from(info.width) * u64::from(info.height) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_PIXELS >> 20 }); + } + } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_PIXELS >> 20 }); + } + let opts = craft_image_codecs::DecodeOptions { + // Match rendering and trace placement: coordinates refer to stored pixels. + keep_orientation: true, + limits: craft_image_codecs::Limits { max_pixels: MAX_PIXELS, ..craft_image_codecs::Limits::default() }, + }; + let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 { return Err(TraceError::Empty); } - if u64::from(w) * u64::from(h) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); - } - let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); - let (w, h) = img.dimensions(); - Ok(Self { width: w, height: h, rgba: img.into_raw() }) + Ok(Self { width: w, height: h, rgba: into_rgba8(img) }) } /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly /// less than its own resolution. The caller maps the result back with `width` / `height`. pub fn decode_to_fit(bytes: &[u8]) -> Result { - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = reader.into_dimensions().map_err(decode_err)?; - if w == 0 || h == 0 { - return Err(TraceError::Empty); - } - let pixels = u64::from(w) * u64::from(h); - if pixels <= MAX_PIXELS { - return Self::decode(bytes); - } - if pixels > MAX_SOURCE_PIXELS { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + if let Some(info) = trace_header(bytes, MAX_SOURCE_PIXELS, if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 })? { + if info.width == 0 || info.height == 0 { + return Err(TraceError::Empty); + } + let pixels = u64::from(info.width) * u64::from(info.height); + if pixels <= MAX_PIXELS && !gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) { + return Self::decode(bytes); + } + if pixels > MAX_SOURCE_PIXELS { + return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_SOURCE_PIXELS >> 20 }); + } + if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { + return Err(TraceError::TooLarge { width: info.width, height: info.height, max: MAX_SOURCE_PIXELS >> 20 }); + } + } else if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { + return Err(TraceError::TooLarge { width: 0, height: 0, max: MAX_SOURCE_PIXELS >> 20 }); } // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. // A deeper image (16 bits a channel) that needs more fails with a decode error. - if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + let opts = craft_image_codecs::DecodeOptions { + // Match rendering and trace placement: coordinates refer to stored pixels. + keep_orientation: true, + limits: craft_image_codecs::Limits { + max_pixels: MAX_SOURCE_PIXELS, + max_alloc: if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }, + ..craft_image_codecs::Limits::default() + }, + }; + let img = craft_image_codecs::decode_with(bytes, &opts).map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = (img.width(), img.height()); + if w == 0 || h == 0 { + return Err(TraceError::Empty); } - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let mut limits = image::Limits::default(); - limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); - reader.limits(limits); - Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS)) + let rgba = into_rgba8(img); + if u64::from(w) * u64::from(h) <= MAX_PIXELS { + return Ok(Self { width: w, height: h, rgba }); + } + Ok(Self::fitted(&Self { width: w, height: h, rgba }, MAX_PIXELS)) } /// `img` scaled down, aspect kept, to the largest size of at most `max` pixels. - pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self { - let (w, h) = img.dimensions(); - let (nw, nh) = fit_size(w, h, max); + pub(crate) fn fitted(img: &Self, max: u64) -> Self { + let (nw, nh) = fit_size(img.width, img.height, max); // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) } + Self { width: nw, height: nh, rgba: box_reduced(&img.rgba, img.width, img.height, nw, nh) } } /// Encode as PNG. pub fn encode_png(&self) -> Vec { - 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 85e9d09778..6b0f3d3b32 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1123,7 +1123,7 @@ fn scaling_down_weighs_color_by_alpha() { // Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit` // does: each scaled pixel covers a red and a clear one. let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] }); - let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8(); + let decoded = Raster::decode(&half_clear.encode_png()).unwrap(); let fitted = Raster::fitted(&decoded, 2); assert_eq!((fitted.width, fitted.height), (2, 1)); assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat()); @@ -2174,7 +2174,8 @@ fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() { } } -/// `img` saved as a JPEG of `quality` and read back. +/// `img` saved as a JPEG of `quality` and read back. The `image` crate's encoder stays the fixture: +/// `a_jpegs_blocks_are_not_traced_as_steps` measures block artifacts, which differ by encoder. fn through_jpeg(img: &Raster, quality: u8) -> Raster { let rgb: Vec = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); let mut bytes = vec![]; @@ -4033,4 +4034,75 @@ fn a_gif_frame_larger_than_its_screen_is_refused_before_decoding() { gif.extend([2, 1, 0, 0, 0x3B]); assert!(matches!(Raster::decode(&gif), Err(TraceError::TooLarge { .. })), "{:?}", Raster::decode(&gif).err()); assert!(matches!(Raster::decode_to_fit(&gif), Err(TraceError::TooLarge { .. }))); + assert!(matches!(decode_alpha(&gif), Err(TraceError::TooLarge { .. }))); +} + +// Synthetic PNG chunks for transparency/orientation regression fixtures. +fn png_test_chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = (data.len() as u32).to_be_bytes().to_vec(); + out.extend(kind); + out.extend(data); + let mut crc = !0u32; + for &b in &out[4..] { + crc ^= u32::from(b); + for _ in 0..8 { + crc = if crc & 1 != 0 { 0xedb8_8320 ^ (crc >> 1) } else { crc >> 1 }; + } + } + out.extend((!crc).to_be_bytes()); + out +} + +#[test] +fn alpha_decode_keeps_rgb_and_grayscale_png_transparency_keys() { + use craft_image_codecs::{ChannelLayout, Format, Image}; + for (layout, samples, key) in + [(ChannelLayout::Rgb, vec![10, 20, 30, 40, 50, 60], vec![0, 10, 0, 20, 0, 30]), (ChannelLayout::Gray, vec![10, 40], vec![0, 10])] + { + let image = Image::from_u8(2, 1, layout, samples).unwrap(); + let mut png = craft_image_codecs::encode(&image, Format::Png, &Default::default()).unwrap(); + // A transparency key precedes IDAT, immediately after the IHDR chunk. + png.splice(33..33, png_test_chunk(b"tRNS", &key)); + assert_eq!(decode_alpha(&png).unwrap(), Some((2, 1, vec![0, 255]))); + } +} + +#[test] +fn trace_and_alpha_keep_stored_pixels_with_exif_orientation() { + use craft_image_codecs::{ChannelLayout, Format, Image}; + // Little-endian TIFF IFD containing Orientation=6 (90 degrees clockwise). + let exif = [b'I', b'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 1, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0]; + let raster = Raster::from_fn(3, 2, |x, y| [x as u8 * 60, y as u8 * 90, 17, (x + 3 * y) as u8 * 40]); + let mut png = raster.encode_png(); + png.splice(33..33, png_test_chunk(b"eXIf", &exif)); + for actual in [Raster::decode(&png).unwrap(), Raster::decode_to_fit(&png).unwrap()] { + assert_eq!((actual.width, actual.height), (3, 2)); + assert_eq!(actual.rgba, raster.rgba); + } + assert_eq!(decode_alpha(&png).unwrap(), Some((3, 2, raster.rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect()))); + + let image = + Image::from_u8(3, 2, ChannelLayout::Rgb, raster.rgba.as_chunks::<4>().0.iter().flat_map(|p| p[..3].iter().copied()).collect()).unwrap(); + let mut jpeg = craft_image_codecs::encode(&image, Format::Jpeg, &Default::default()).unwrap(); + let expected = Raster::decode(&jpeg).unwrap(); + let mut app1 = vec![0xff, 0xe1]; + app1.extend(((exif.len() + 8) as u16).to_be_bytes()); + app1.extend(b"Exif\0\0"); + app1.extend(exif); + jpeg.splice(2..2, app1); + for actual in [Raster::decode(&jpeg).unwrap(), Raster::decode_to_fit(&jpeg).unwrap()] { + assert_eq!((actual.width, actual.height), (3, 2)); + assert_eq!(actual.rgba, expected.rgba); + } +} + +#[test] +fn alpha_rejects_oversized_png_before_decoding() { + assert!(matches!(decode_alpha(&png_header(30_000, 30_000)), Err(TraceError::TooLarge { .. }))); + assert!(matches!(decode_alpha(&png_header(64, 64)), Err(TraceError::Decode(_)))); + let mut deep = png_header(7000, 6000); + deep[24] = 16; + let ihdr = png_test_chunk(b"IHDR", &deep[16..29]); + deep.splice(8..33, ihdr); + assert!(matches!(decode_alpha(&deep), Err(TraceError::TooLarge { .. }))); }