diff --git a/Cargo.lock b/Cargo.lock index 3f5257951..19ebefe99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1633,6 +1633,11 @@ version = "0.1.0" dependencies = [ "craft-eps", "craft-idml", + "craft-image-codecs", + "craft-kra", + "craft-ora", + "craft-pcraft-bundle", + "craft-raw", "craft-svg", "craft-zip", "serde", @@ -2175,6 +2180,7 @@ dependencies = [ "kurbo 0.13.1", "md-5 0.11.0", "proptest", + "roxmltree 0.21.2", "serde_json", "sha2 0.11.1", "stringprep", @@ -2517,9 +2523,11 @@ dependencies = [ name = "craft-svg" version = "0.1.0" dependencies = [ + "craft-image-codecs", "craft-xml", "flate2", "kurbo 0.13.1", + "resvg 0.48.1", "roxmltree 0.21.2", "thiserror 2.0.21", "usvg 0.48.1", @@ -3528,7 +3536,7 @@ dependencies = [ "itertools 0.15.0", "log", "profiling", - "resvg", + "resvg 0.45.1", ] [[package]] @@ -7811,10 +7819,28 @@ dependencies = [ "pico-args", "rgb", "svgtypes 0.15.3", - "tiny-skia", + "tiny-skia 0.11.4", "usvg 0.45.1", ] +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.2", + "tiny-skia 0.12.0", + "usvg 0.48.1", + "zune-jpeg", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -9371,6 +9397,21 @@ dependencies = [ "tiny-skia-path 0.11.4", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", +] + [[package]] name = "tiny-skia-path" version = "0.11.4" diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index c0cf58e77..14b17ad99 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -679,7 +679,11 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } let class = s.class(id).unwrap_or(Tag::of(b"ShpN")); let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| { - self.warn("an artboard whose outline could not be read (clipped to its rectangle)"); + if class == Tag::of(b"PCrv") { + self.warn("a converted artboard outline could not be read (clipped to its rectangle)"); + } else { + self.warn("an artboard whose outline could not be read (clipped to its rectangle)"); + } (Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false) }); let (background, strokes) = paint::node_paint(self, id, world); diff --git a/crates/file-formats/Cargo.toml b/crates/file-formats/Cargo.toml index a1a605918..afe7c6391 100644 --- a/crates/file-formats/Cargo.toml +++ b/crates/file-formats/Cargo.toml @@ -9,11 +9,39 @@ rust-version.workspace = true [dependencies] craft-eps = { workspace = true } +craft-image-codecs = { workspace = true, optional = true } +craft-raw = { workspace = true, optional = true } +craft-ora = { workspace = true, optional = true } +craft-kra = { workspace = true, optional = true } +craft-pcraft-bundle = { workspace = true, optional = true } craft-idml = { workspace = true } craft-svg = { workspace = true } craft-zip = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +[features] +default = [] +# These flags report which shared parser/codec families a client builds. The application still +# selects the corresponding shared crates in its dependency graph. +raster-codecs = ["dep:craft-image-codecs"] +heif = ["raster-codecs", "craft-image-codecs/heif"] +avif = ["raster-codecs", "craft-image-codecs/avif"] +pdf = [] +svg = [] +ora = ["dep:craft-ora"] +kra = ["dep:craft-kra"] +xcf = [] +pdn = [] +raw = ["dep:craft-raw"] +psd = [] +affinity = [] +pcraft = ["dep:craft-pcraft-bundle"] +ase = [] +office = [] +designcraft = [] +stencilcraft = [] +plotter = [] + [lints] workspace = true diff --git a/crates/file-formats/src/lib.rs b/crates/file-formats/src/lib.rs index 7859bf539..38619075f 100644 --- a/crates/file-formats/src/lib.rs +++ b/crates/file-formats/src/lib.rs @@ -23,6 +23,22 @@ pub enum Format { Tiff, WebP, Heif, + Tga, + Ico, + Pnm, + Pcx, + Xpm, + Dds, + Qoi, + OpenExr, + Hdr, + Avif, + Fits, + Dpx, + Jxl, + Cineon, + Dicom, + Iff, Ase, Psd, Affinity, @@ -54,9 +70,366 @@ pub enum Format { StencilCraft, } +/// Availability of one operation in the current client's shared-format build. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Availability { + /// The shared reader or writer is included in this build. + Available, + /// This format is known, but the corresponding shared operation is not implemented. + Unsupported, + /// The operation exists behind a Cargo feature that this client did not enable. + BuildDisabled, +} + +/// Shared reader/writer capability and sequence behavior for a known format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Capabilities { + pub read: Availability, + pub write: Availability, + /// Input may contain multiple frames/pages that the shared reader can expose individually. + pub multiple_frames: bool, + /// This format can be opened as an ordered sequence of files by an application adapter. + pub file_sequence: bool, +} + +const fn capability(feature: bool, read: bool, write: bool, multiple_frames: bool, file_sequence: bool) -> Capabilities { + Capabilities { read: availability(feature, read), write: availability(feature, write), multiple_frames, file_sequence } +} + +const fn availability(feature: bool, supported: bool) -> Availability { + if !supported { + Availability::Unsupported + } else if feature { + Availability::Available + } else { + Availability::BuildDisabled + } +} + +impl Format { + /// Every known format, including formats whose reader or writer is not enabled in this build. + pub const ALL: [Format; 56] = [ + Self::Pdf, + Self::Eps, + Self::PostScript, + Self::Illustrator, + Self::Dxf, + Self::Svg, + Self::Svgz, + Self::Png, + Self::Jpeg, + Self::Bmp, + Self::Gif, + Self::Tiff, + Self::WebP, + Self::Heif, + Self::Tga, + Self::Ico, + Self::Pnm, + Self::Pcx, + Self::Xpm, + Self::Dds, + Self::Qoi, + Self::OpenExr, + Self::Hdr, + Self::Avif, + Self::Fits, + Self::Dpx, + Self::Jxl, + Self::Cineon, + Self::Dicom, + Self::Iff, + Self::Ase, + Self::Psd, + Self::Affinity, + Self::Ora, + Self::Kra, + Self::Xcf, + Self::Pdn, + Self::Raw, + Self::Pcraft, + Self::Idml, + Self::DesignCraft, + Self::DesignCraftJson, + Self::Indd, + Self::Docx, + Self::LegacyDoc, + Self::Rtf, + Self::Xlsx, + Self::TaggedText, + Self::Pptx, + Self::PlainText, + Self::Csv, + Self::Tsv, + Self::LegacyExcel, + Self::Studio, + Self::StencilCraft, + Self::Gcode, + ]; + + /// Known extensions in lowercase form, in preferred-output order. + pub fn extensions(self) -> &'static [&'static str] { + match self { + Self::Pdf => &["pdf"], + Self::Eps => &["eps"], + Self::PostScript => &["ps"], + Self::Illustrator => &["ai"], + Self::Dxf => &["dxf"], + Self::Svg => &["svg"], + Self::Svgz => &["svgz"], + Self::Png => &["png", "apng"], + Self::Jpeg => &["jpg", "jpeg", "jpe", "jfif"], + Self::Bmp => &["bmp", "dib"], + Self::Gif => &["gif"], + Self::Tiff => &["tif", "tiff"], + Self::WebP => &["webp"], + Self::Heif => &["heic", "heif", "hif"], + Self::Tga => &["tga", "icb", "vda", "vst"], + Self::Ico => &["ico"], + Self::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"], + Self::Pcx => &["pcx"], + Self::Xpm => &["xpm"], + Self::Dds => &["dds"], + Self::Qoi => &["qoi"], + Self::OpenExr => &["exr"], + Self::Hdr => &["hdr"], + Self::Avif => &["avif"], + Self::Fits => &["fits", "fit", "fts"], + Self::Dpx => &["dpx"], + Self::Jxl => &["jxl"], + Self::Cineon => &["cin"], + Self::Dicom => &["dcm", "dicom", "dc3"], + Self::Iff => &["iff", "lbm", "ilbm"], + Self::Ase => &["ase"], + Self::Psd => &["psd", "psb", "psdt"], + Self::Affinity => &["afdesign", "afphoto", "afpub", "af"], + Self::Ora => &["ora"], + Self::Kra => &["kra"], + Self::Xcf => &["xcf"], + Self::Pdn => &["pdn"], + Self::Raw => &[ + "dng", "3fr", "ari", "arw", "bay", "cap", "cr2", "cr3", "dcs", "dcr", "drf", "eip", "erf", "fff", "gpr", "iiq", "k25", "kdc", "mdc", + "mef", "mos", "mrw", "nef", "nrw", "obm", "orf", "pef", "ptx", "pxn", "r3d", "raf", "raw", "rwl", "rw2", "rwz", "sr2", "srf", "sti", + "x3f", + ], + Self::Pcraft => &["pcraft"], + Self::Idml => &["idml"], + Self::DesignCraft => &["designcraft", "dcdoc"], + Self::DesignCraftJson => &[], + Self::Indd => &["indd", "indt"], + Self::Docx => &["docx"], + Self::LegacyDoc => &["doc"], + Self::Rtf => &["rtf"], + Self::Xlsx => &["xlsx"], + Self::TaggedText => &[], + Self::Pptx => &["pptx"], + Self::PlainText => &["txt", "text", "md"], + Self::Csv => &["csv"], + Self::Tsv => &["tsv", "tab"], + Self::LegacyExcel => &["xls", "xlsm"], + Self::Studio => &["studio", "studio3"], + Self::StencilCraft => &["stencilcraft"], + Self::Gcode => &["gcode", "nc"], + } + } + + /// Preferred extension for creating a new file of this type, if one is defined. + pub fn preferred_extension(self) -> Option<&'static str> { + self.extensions().first().copied() + } + + /// Registered MIME type, where the format has a stable MIME registration. + pub fn mime_type(self) -> Option<&'static str> { + Some(match self { + Self::Pdf => "application/pdf", + Self::Eps => "application/postscript", + Self::PostScript => "application/postscript", + Self::Illustrator => "application/illustrator", + Self::Dxf => "image/vnd.dxf", + Self::Svg | Self::Svgz => "image/svg+xml", + Self::Png => "image/png", + Self::Jpeg => "image/jpeg", + Self::Bmp => "image/bmp", + Self::Gif => "image/gif", + Self::Tiff => "image/tiff", + Self::WebP => "image/webp", + Self::Heif => "image/heif", + Self::Tga => "image/x-tga", + Self::Ico => "image/vnd.microsoft.icon", + Self::Pnm => "image/x-portable-anymap", + Self::Pcx => "image/vnd.zbrush.pcx", + Self::Xpm => "image/x-xpixmap", + Self::Dds => "image/vnd-ms.dds", + Self::Qoi => "image/qoi", + Self::OpenExr => "image/x-exr", + Self::Hdr => "image/vnd.radiance", + Self::Avif => "image/avif", + Self::Fits => "image/fits", + Self::Dpx => "image/dpx", + Self::Jxl => "image/jxl", + Self::Cineon => "image/x-cineon", + Self::Dicom => "application/dicom", + Self::Iff => "image/ilbm", + Self::Ase => "application/x-adobe-swatch-exchange", + Self::Psd => "image/vnd.adobe.photoshop", + Self::Affinity => "application/x-affinity", + Self::Ora => "image/openraster", + Self::Kra => "application/x-krita", + Self::Xcf => "image/x-xcf", + Self::Pdn => "application/x-paint.net", + Self::Raw => "image/x-camera-raw", + Self::Pcraft => "application/vnd.photocraft+zip", + Self::Idml => "application/vnd.adobe.indesign-idml-package", + Self::DesignCraft => "application/vnd.designcraft+zip", + Self::DesignCraftJson => "application/json", + Self::Indd => "application/x-indesign", + Self::Docx => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + Self::LegacyDoc => "application/msword", + Self::Rtf => "application/rtf", + Self::Xlsx => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Self::TaggedText => "text/plain", + Self::Pptx => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + Self::PlainText => "text/plain", + Self::Csv => "text/csv", + Self::Tsv => "text/tab-separated-values", + Self::LegacyExcel => "application/vnd.ms-excel", + Self::Studio => "application/x-silhouette-studio", + Self::StencilCraft => "application/vnd.stencilcraft+json", + Self::Gcode => "text/x.gcode", + }) + } + + /// The corresponding flat-raster codec, when this format is handled by `craft-image-codecs`. + #[cfg(feature = "raster-codecs")] + pub fn raster_codec(self) -> Option { + use craft_image_codecs::Format as Codec; + Some(match self { + Self::Png => Codec::Png, + Self::Jpeg => Codec::Jpeg, + Self::Bmp => Codec::Bmp, + Self::Gif => Codec::Gif, + Self::Tiff => Codec::Tiff, + Self::WebP => Codec::WebP, + Self::Heif => Codec::Heif, + Self::Tga => Codec::Tga, + Self::Ico => Codec::Ico, + Self::Pnm => Codec::Pnm, + Self::Pcx => Codec::Pcx, + Self::Xpm => Codec::Xpm, + Self::Dds => Codec::Dds, + Self::Qoi => Codec::Qoi, + Self::OpenExr => Codec::OpenExr, + Self::Hdr => Codec::Hdr, + Self::Avif => Codec::Avif, + Self::Fits => Codec::Fits, + Self::Dpx => Codec::Dpx, + Self::Jxl => Codec::Jxl, + Self::Cineon => Codec::Cineon, + Self::Dicom => Codec::Dicom, + Self::Iff => Codec::Iff, + _ => return None, + }) + } + + /// Operations exposed by this build's shared parser/codec features. + pub fn capabilities(self) -> Capabilities { + #[cfg(feature = "raster-codecs")] + if let Some(format) = self.raster_codec() { + let codec = format.caps(); + let read = if codec.read { + Availability::Available + } else if matches!(self, Self::Heif) && !cfg!(feature = "heif") { + Availability::BuildDisabled + } else { + Availability::Unsupported + }; + let write = if codec.write { + Availability::Available + } else if matches!(self, Self::Avif) && !cfg!(feature = "avif") { + Availability::BuildDisabled + } else { + Availability::Unsupported + }; + return Capabilities { read, write, multiple_frames: codec.animation, file_sequence: false }; + } + let raster = cfg!(feature = "raster-codecs"); + match self { + Self::Png | Self::Jpeg | Self::Bmp | Self::Gif | Self::Tiff | Self::WebP => { + capability(raster, true, true, matches!(self, Self::Png | Self::Gif | Self::WebP), false) + } + Self::Heif => Capabilities { + read: availability(cfg!(feature = "heif"), true), + write: Availability::Unsupported, + multiple_frames: true, + file_sequence: false, + }, + Self::Avif => Capabilities { + read: Availability::Unsupported, + write: availability(cfg!(feature = "avif"), true), + multiple_frames: false, + file_sequence: false, + }, + Self::Jxl => capability(raster, false, true, false, false), + Self::Pcx | Self::Xpm | Self::Dicom => capability(raster, true, false, false, false), + Self::Tga + | Self::Ico + | Self::Pnm + | Self::Dds + | Self::Qoi + | Self::OpenExr + | Self::Hdr + | Self::Fits + | Self::Dpx + | Self::Cineon + | Self::Iff => capability(raster, true, true, false, false), + Self::Pdf => capability(cfg!(feature = "pdf"), true, true, false, false), + Self::Eps | Self::PostScript | Self::Illustrator => capability(cfg!(feature = "pdf"), true, false, false, false), + Self::Svg | Self::Svgz => capability(cfg!(feature = "svg"), true, false, false, false), + Self::Dxf | Self::Gcode => capability(cfg!(feature = "plotter"), true, false, false, false), + Self::Ora => capability(cfg!(feature = "ora"), true, true, false, false), + Self::Kra => capability(cfg!(feature = "kra"), true, false, false, false), + Self::Xcf => capability(cfg!(feature = "xcf"), true, false, false, false), + Self::Pdn => capability(cfg!(feature = "pdn"), true, false, false, false), + Self::Raw => capability(cfg!(feature = "raw"), true, false, true, true), + Self::Psd => capability(cfg!(feature = "psd"), true, true, false, false), + Self::Affinity => capability(cfg!(feature = "affinity"), true, false, false, false), + Self::Pcraft => capability(cfg!(feature = "pcraft"), true, true, false, false), + Self::Ase => capability(cfg!(feature = "ase"), true, true, false, false), + Self::Studio => capability(cfg!(feature = "stencilcraft"), true, false, false, false), + Self::Idml | Self::Indd => capability(cfg!(feature = "designcraft"), true, false, false, false), + Self::DesignCraft | Self::DesignCraftJson => capability(cfg!(feature = "designcraft"), true, true, false, false), + Self::StencilCraft => capability(cfg!(feature = "stencilcraft"), true, true, false, false), + Self::Docx + | Self::LegacyDoc + | Self::Rtf + | Self::Xlsx + | Self::Pptx + | Self::TaggedText + | Self::PlainText + | Self::Csv + | Self::Tsv + | Self::LegacyExcel => capability(cfg!(feature = "office"), true, true, false, false), + } + } +} + /// Resolve a name and file bytes to a format. Strong signatures take precedence over /// extension hints. Only format-specific bounded probes and extension tables live here. pub fn detect(name: &str, bytes: &[u8]) -> Option { + // Container identifiers are stronger than generic ZIP signatures and filenames. The shared + // readers own their bounded, format-specific directory/mimetype probes. + #[cfg(feature = "pcraft")] + if craft_pcraft_bundle::is_pcraft_zip(bytes) { + return Some(Format::Pcraft); + } + #[cfg(feature = "ora")] + if craft_ora::is_ora(bytes) { + return Some(Format::Ora); + } + #[cfg(feature = "kra")] + if craft_kra::is_kra(bytes) { + return Some(Format::Kra); + } if craft_idml::is_idml(bytes) { return Some(Format::Idml); } @@ -86,12 +459,30 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option { if bytes.starts_with(b"PDN3") { return Some(Format::Pdn); } + // OpenEXR's four-byte magic is its canonical signature (v2, section 2.1.1). Keep it in + // the shared registry so file-handle importers can route without relying on a filename. + if bytes.starts_with(&[0x76, 0x2f, 0x31, 0x01]) { + return Some(Format::OpenExr); + } if bytes.starts_with(b"gimp xcf ") || gzipped_xcf(name, bytes) { return Some(Format::Xcf); } + // Several camera formats use TIFF containers. Ask the shared RAW parser before the generic + // raster signature so TIFF-based RAW files reach the RAW adapter, while ordinary TIFF files + // retain the raster classification. + #[cfg(feature = "raw")] + if craft_raw::probe(bytes).is_some() { + return Some(Format::Raw); + } if bytes.starts_with(b"\0\xffKA") { return Some(Format::Affinity); } + // The short `.af` alias is accepted only as an explicit Affinity filename. Treating it as + // a generic image based on unrelated pixel bytes makes misspelled native documents appear + // to open successfully as flat previews. + if extension(name).is_some_and(|ext| ext.eq_ignore_ascii_case("af")) { + return Some(Format::Affinity); + } if let Some(raster) = raster_signature(bytes) { return Some(raster); } @@ -126,6 +517,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option { None } +#[cfg(test)] +mod tests; + /// DesignCraft's older ZIPs predate their leading `mimetype`; they are identified by the /// `document.json` member. The directory is bounded before it is indexed and no entry is inflated. fn designcraft_zip_has_document(bytes: &[u8]) -> bool { @@ -183,49 +577,8 @@ fn is_designcraft_json(bytes: &[u8]) -> bool { /// Resolve a file extension to a family, case-insensitively. pub fn from_extension(ext: &str) -> Option { - match ext.trim_start_matches('.').to_ascii_lowercase().as_str() { - "pdf" => Some(Format::Pdf), - "eps" => Some(Format::Eps), - "ps" => Some(Format::PostScript), - "ai" => Some(Format::Illustrator), - "dxf" => Some(Format::Dxf), - "svg" => Some(Format::Svg), - "svgz" => Some(Format::Svgz), - "png" => Some(Format::Png), - "jpg" | "jpeg" => Some(Format::Jpeg), - "bmp" => Some(Format::Bmp), - "gif" => Some(Format::Gif), - "tif" | "tiff" => Some(Format::Tiff), - "webp" => Some(Format::WebP), - "heic" | "heif" => Some(Format::Heif), - "ase" => Some(Format::Ase), - "psd" | "psb" => Some(Format::Psd), - "afdesign" | "afphoto" | "afpub" => Some(Format::Affinity), - "ora" => Some(Format::Ora), - "kra" => Some(Format::Kra), - "xcf" => Some(Format::Xcf), - "pdn" => Some(Format::Pdn), - "dng" | "3fr" | "ari" | "arw" | "bay" | "cap" | "cr2" | "cr3" | "dcs" | "dcr" | "drf" | "eip" | "erf" | "fff" | "gpr" | "iiq" | "k25" - | "kdc" | "mdc" | "mef" | "mos" | "mrw" | "nef" | "nrw" | "obm" | "orf" | "pef" | "ptx" | "pxn" | "r3d" | "raf" | "raw" | "rwl" | "rw2" - | "rwz" | "sr2" | "srf" | "sti" | "x3f" => Some(Format::Raw), - "pcraft" => Some(Format::Pcraft), - "idml" => Some(Format::Idml), - "designcraft" | "dcdoc" => Some(Format::DesignCraft), - "indd" | "indt" => Some(Format::Indd), - "docx" => Some(Format::Docx), - "doc" => Some(Format::LegacyDoc), - "rtf" => Some(Format::Rtf), - "xlsx" => Some(Format::Xlsx), - "pptx" => Some(Format::Pptx), - "txt" | "text" | "md" => Some(Format::PlainText), - "csv" => Some(Format::Csv), - "tsv" | "tab" => Some(Format::Tsv), - "xls" | "xlsm" => Some(Format::LegacyExcel), - "studio" | "studio3" => Some(Format::Studio), - "stencilcraft" => Some(Format::StencilCraft), - "gcode" | "nc" => Some(Format::Gcode), - _ => None, - } + let ext = ext.trim_start_matches('.'); + Format::ALL.into_iter().find(|format| format.extensions().iter().any(|candidate| candidate.eq_ignore_ascii_case(ext))) } const INDD_SIGNATURE: [u8; 16] = [0x06, 0x06, 0xED, 0xF5, 0xD8, 0x1D, 0x46, 0xE5, 0xBD, 0x31, 0xEF, 0xE7, 0xFE, 0x74, 0xB7, 0x1D]; diff --git a/crates/file-formats/src/tests.rs b/crates/file-formats/src/tests.rs new file mode 100644 index 000000000..84c6fcb87 --- /dev/null +++ b/crates/file-formats/src/tests.rs @@ -0,0 +1,57 @@ +use super::{Availability, Format}; + +#[test] +fn every_registered_family_has_consistent_metadata_and_extension_lookup() { + for format in Format::ALL { + let extensions = format.extensions(); + assert_eq!(format.preferred_extension(), extensions.first().copied()); + assert!(format.mime_type().is_some()); + let _capabilities = format.capabilities(); + #[cfg(feature = "raster-codecs")] + let _codec = format.raster_codec(); + + for extension in extensions { + assert_eq!(super::from_extension(extension), Some(format), "{format:?}: {extension}"); + assert_eq!(super::from_extension(&extension.to_ascii_uppercase()), Some(format), "{format:?}: {extension}"); + } + } +} + +#[test] +fn known_formats_separate_build_disabled_and_unsupported_operations() { + let jpeg = Format::Jpeg.capabilities(); + assert!(!jpeg.multiple_frames && !jpeg.file_sequence); + #[cfg(feature = "raster-codecs")] + assert_eq!(jpeg.read, Availability::Available); + #[cfg(not(feature = "raster-codecs"))] + assert_eq!(jpeg.read, Availability::BuildDisabled); + + assert_eq!(Format::Jxl.capabilities().read, Availability::Unsupported); + #[cfg(not(feature = "raster-codecs"))] + assert_eq!(Format::Jxl.capabilities().write, Availability::BuildDisabled); + assert!(!Format::Psd.capabilities().file_sequence); + assert!(Format::Raw.capabilities().file_sequence); + assert!(Format::Raw.capabilities().multiple_frames); +} + +#[test] +fn bounded_probes_prefer_signatures_and_resolve_extensions() { + assert_eq!(super::detect("misnamed.png", b"\x76\x2f\x31\x01rest"), Some(Format::OpenExr)); + assert_eq!(super::detect("misnamed.exr", b"\x89PNG\r\n\x1a\n"), Some(Format::Png)); + assert_eq!(super::detect("native.psdt", b"8BPS\0\0"), Some(Format::Psd)); + assert_eq!(super::detect("art.svg", b""), Some(Format::Svg)); + assert_eq!(super::detect("photo.EXR", b""), Some(Format::OpenExr)); + assert_eq!(super::detect("notes.txt", b""), Some(Format::PlainText)); + assert_eq!(super::detect("unknown", b""), None); +} + +#[test] +fn registered_mime_and_preferred_extensions_remain_distinct() { + assert_eq!(Format::Svg.mime_type(), Format::Svgz.mime_type()); + assert_eq!(Format::Svg.preferred_extension(), Some("svg")); + assert_eq!(Format::Svgz.preferred_extension(), Some("svgz")); + assert_eq!(Format::DesignCraftJson.mime_type(), Some("application/json")); + assert_eq!(Format::DesignCraftJson.preferred_extension(), None); + assert_eq!(Format::TaggedText.preferred_extension(), None); + assert_eq!(super::from_extension("not-a-format"), None); +} diff --git a/crates/image-codecs/src/allocation.rs b/crates/image-codecs/src/allocation.rs index 5425a0c24..fe98122e7 100644 --- a/crates/image-codecs/src/allocation.rs +++ b/crates/image-codecs/src/allocation.rs @@ -1,10 +1,7 @@ //! Predecode bounds for raster containers whose frames may exceed their declared canvas. -use crate::codecs::webp; use crate::error::CodecError; use crate::format::Format; -use crate::image::{ChannelLayout, SampleType}; -use crate::options::Limits; /// The largest width and height any decoded frame can declare, without allocating pixel data. /// Animated GIF and WebP frames are inspected individually, including frames larger than the @@ -12,12 +9,10 @@ use crate::options::Limits; pub fn probe_allocation_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { let format = crate::detect(bytes).ok_or(CodecError::UnknownFormat)?; let (width, height) = match format { + Format::Png => png_bounds(bytes)?, + Format::Jpeg => jpeg_bounds(bytes)?, Format::Gif => gif_bounds(bytes)?, - Format::WebP => { - let (width, height, _) = webp::probe(bytes, &Limits::default())?; - let (frame_width, frame_height) = webp_bounds(bytes)?; - (width.max(frame_width), height.max(frame_height)) - } + Format::WebP => webp_bounds(bytes)?, _ => { if let Some(info) = crate::inspect_raster(bytes)? { (info.width, info.height) @@ -26,7 +21,9 @@ pub fn probe_allocation_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { } } }; - Limits::default().check(width, height, ChannelLayout::Rgba, SampleType::U8)?; + // This is an allocation-size probe, not a decode policy. Callers need the coded extents even + // when they exceed this crate's default decode limits so they can report/refuse against their + // own budgets before passing the bytes to a decoder. Ok((width, height)) } @@ -34,6 +31,57 @@ fn malformed(format: Format, message: &'static str) -> CodecError { CodecError::malformed(format, message) } +fn png_bounds(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let bad = || malformed(Format::Png, "truncated or malformed PNG header"); + if bytes.get(..8) != Some(b"\x89PNG\r\n\x1a\n") || bytes.get(8..16) != Some(b"\0\0\0\rIHDR") { + return Err(bad()); + } + let width = u32::from_be_bytes(bytes.get(16..20).ok_or_else(bad)?.try_into().map_err(|_| bad())?); + let height = u32::from_be_bytes(bytes.get(20..24).ok_or_else(bad)?.try_into().map_err(|_| bad())?); + if width == 0 || height == 0 { + return Err(bad()); + } + Ok((width, height)) +} + +fn jpeg_bounds(bytes: &[u8]) -> Result<(u32, u32), CodecError> { + let bad = || malformed(Format::Jpeg, "truncated or malformed JPEG frame header"); + if bytes.get(..2) != Some(&[0xff, 0xd8]) { + return Err(bad()); + } + let mut at = 2usize; + while at < bytes.len() { + while bytes.get(at) == Some(&0xff) { + at += 1; + } + let marker = *bytes.get(at).ok_or_else(bad)?; + at += 1; + if matches!(marker, 0xd8 | 0xd9 | 0x01 | 0xd0..=0xd7) { + continue; + } + let length = + usize::from(u16::from_be_bytes(bytes.get(at..at.checked_add(2).ok_or_else(bad)?).ok_or_else(bad)?.try_into().map_err(|_| bad())?)); + if length < 2 { + return Err(bad()); + } + let end = at.checked_add(length).ok_or_else(bad)?; + let segment = bytes.get(at..end).ok_or_else(bad)?; + if matches!(marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf) { + if segment.len() < 7 { + return Err(bad()); + } + let height = u32::from(u16::from_be_bytes([segment[3], segment[4]])); + let width = u32::from(u16::from_be_bytes([segment[5], segment[6]])); + if width == 0 || height == 0 { + return Err(bad()); + } + return Ok((width, height)); + } + at = end; + } + Err(bad()) +} + fn u16_le(bytes: &[u8], at: usize) -> Option { Some(u16::from_le_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?)) } @@ -76,20 +124,28 @@ fn gif_bounds(bytes: &[u8]) -> Result<(u32, u32), CodecError> { let frame_height = u32::from(u16_le(bytes, at + 7).ok_or_else(bad)?); let descriptor = bytes.get(at..at.checked_add(10).ok_or_else(bad)?).ok_or_else(bad)?; let flags = descriptor[9]; + // The frame rectangle determines its decoded allocation even when the later + // compressed payload is truncated. Report known dimensions so callers can + // refuse an oversized frame before asking a decoder to inspect its pixels. + width = width.max(left.saturating_add(frame_width)); + height = height.max(top.saturating_add(frame_height)); let mut body = at.checked_add(10).ok_or_else(bad)?; if flags & 0x80 != 0 { let table = 3usize.checked_mul(1usize.checked_shl(u32::from((flags & 7) + 1)).ok_or_else(bad)?).ok_or_else(bad)?; body = body.checked_add(table).ok_or_else(bad)?; } body = body.checked_add(1).ok_or_else(bad)?; // LZW minimum code size. - if bytes.get(..body).is_none() || frame_width == 0 || frame_height == 0 { + if frame_width == 0 || frame_height == 0 { return Err(bad()); } - width = width.max(left.saturating_add(frame_width)); - height = height.max(top.saturating_add(frame_height)); + if bytes.get(..body).is_none() { + return Ok((width, height)); + } at = body; + frames = frames.saturating_add(1); loop { - let length = usize::from(*bytes.get(at).ok_or_else(bad)?); + let Some(&length) = bytes.get(at) else { return Ok((width, height)) }; + let length = usize::from(length); at = at.checked_add(1).ok_or_else(bad)?; if length == 0 { break; @@ -99,7 +155,6 @@ fn gif_bounds(bytes: &[u8]) -> Result<(u32, u32), CodecError> { return Err(bad()); } } - frames = frames.saturating_add(1); } _ => return Err(bad()), } diff --git a/crates/image-codecs/src/exr_stream.rs b/crates/image-codecs/src/exr_stream.rs index 936814360..6ffa95c53 100644 --- a/crates/image-codecs/src/exr_stream.rs +++ b/crates/image-codecs/src/exr_stream.rs @@ -199,6 +199,12 @@ impl Decoder { } offsets.push(offset); } + // Check the bounded offset table before rejecting a large declared image. + // A truncated file with an oversized data window is malformed; a complete + // file above the streaming area limit is rejected before any pixel decode. + if (w as u64).saturating_mul(h as u64) > 1 << 30 { + return Err(limit("EXR pixel count exceeds the streaming limit of 1073741824")); + } Ok(Self { source, meta, info, offsets, seen: vec![false; expected], channels, next: 0, file_len, chunks_start, raw_limit, rows }) } diff --git a/crates/image-codecs/tests/it/resolution.rs b/crates/image-codecs/tests/it/resolution.rs index eadecbaa3..14c682f8d 100644 --- a/crates/image-codecs/tests/it/resolution.rs +++ b/crates/image-codecs/tests/it/resolution.rs @@ -516,10 +516,13 @@ fn exports_drop_the_source_exif_thumbnail() { assert!(out.windows(6).any(|w| w == b"NIKON\0"), "the rest of the EXIF is kept"); near(exif_resolution(&out), ppi.map_or(300.0, |p| p.0)); } - // Only the pointer changes. + // Only the IFD1 pointer and thumbnail payload change: the pointer is unlinked and the + // stale JPEG bytes are blanked by the shared metadata editor. let out = export_exif(&exif, None); + let thumb_len = b"\xFF\xD8stale thumbnail\xFF\xD9".len(); let differing: Vec = (0..exif.len()).filter(|&i| exif[i] != out[i]).collect(); - assert!(differing.iter().all(|&i| (8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4).contains(&i)), "{differing:?}"); + assert!(differing.iter().all(|&i| (8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4).contains(&i) || i >= exif.len() - thumb_len), "{differing:?}"); + assert!(!out.windows(5).any(|w| w == b"stale")); // With a JPEG-style prefix too. let prefixed = [b"Exif\0\0".as_slice(), &exif].concat(); assert_eq!(next_ifd(e, &export_exif(&prefixed, None)[6..]), 0); diff --git a/crates/kra/src/lib.rs b/crates/kra/src/lib.rs index 12ae3468b..f59bd95cd 100644 --- a/crates/kra/src/lib.rs +++ b/crates/kra/src/lib.rs @@ -247,7 +247,10 @@ impl Image { limits: craft_xml::Limits { max_input: MAX_MAINDOC, max_depth: MAX_XML_DEPTH, ..craft_xml::Limits::DEFAULT }, ..craft_xml::Options::local().strict() }; - let root = options.parse(maindoc).map_err(|e| invalid(format!("maindoc.xml: {e}")))?; + let root = options.parse(maindoc).map_err(|e| match e { + craft_xml::Error::Doctype => invalid("maindoc.xml: a document type with an internal DTD subset is not accepted"), + other => invalid(format!("maindoc.xml: {other}")), + })?; let image = root.child("IMAGE").ok_or_else(|| invalid("maindoc.xml has no IMAGE"))?; let side = |a: &str| -> Result { let v = number(image, a, 0.0)?; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index b370416ab..ff94c6d8e 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -18,6 +18,7 @@ paths = ["interpret", "dep:kurbo"] raster = ["interpret", "dep:hayro", "dep:bytemuck"] write = ["dep:krilla", "dep:thiserror"] write-pdf = ["write", "krilla/pdf"] +write-raster = ["dep:flate2", "dep:roxmltree"] write-simple-text = ["write", "krilla/simple-text"] filters = ["dep:craft-stream-codecs", "dep:flate2", "dep:thiserror"] cos = ["crypt", "filters", "dep:chrono", "dep:js-sys", "dep:wasm-bindgen"] @@ -30,9 +31,10 @@ hayro = { workspace = true, optional = true } bytemuck = { workspace = true, optional = true } krilla = { workspace = true, optional = true } thiserror = { workspace = true, optional = true } +flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"], optional = true } +roxmltree = { version = "0.21", optional = true } # The filter chain (`filters`). flate2 with zlib-rs, the backend the chain's Flate prices were measured on. craft-stream-codecs = { workspace = true, optional = true } -flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"], optional = true } # The standard security handler (`crypt`): pure Rust, MIT OR Apache-2.0, builds for wasm. aes = { version = "0.9", optional = true } md-5 = { version = "0.11", optional = true } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ed9f21a79..cae3117d9 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -27,3 +27,6 @@ pub mod paths; pub mod raster; #[cfg(feature = "write")] pub mod write; +#[cfg(feature = "write-raster")] +#[path = "write/raster.rs"] +pub mod write_raster; diff --git a/crates/pdf/src/write/raster.rs b/crates/pdf/src/write/raster.rs new file mode 100644 index 000000000..1aa64ec3c --- /dev/null +++ b/crates/pdf/src/write/raster.rs @@ -0,0 +1,676 @@ +//! Low-level raster and spot-plate PDF serialization. +//! +//! Callers map their page and color models to these neutral records. This module owns PDF object +//! layout, image streams, ICC/Separation resources, page labels, marks, xref and trailer syntax. + +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::io::Write as _; + +/// An 8-bit interleaved image placed on one page: gray, RGB or CMYK. +#[derive(Clone, Debug, Default)] +pub struct Image { + pub width: u32, + pub height: u32, + pub channels: usize, + pub data: Vec, + pub icc: Option>, +} + +/// One spot plate with 8-bit ink density and an alternate CMYK preview color. +#[derive(Clone, Debug, Default)] +pub struct SpotPlate { + pub name: String, + pub cmyk: [f32; 4], + /// CIE L*a*b* ink color for a caller's metadata packet. + pub lab: [f32; 3], + /// Encoded sRGB ink color for a caller's metadata packet. + pub srgb: [f32; 3], + /// Spot ink solidity in the inclusive range 0..=1. + pub solidity: f32, + pub width: u32, + pub height: u32, + pub data: Vec, +} + +/// Crop and registration marks around a page's image rectangle. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Marks { + pub corner_crop: bool, + pub center_crop: bool, + pub registration: bool, +} + +/// A neutral PDF page and its raster/spot resources. +#[derive(Clone, Debug, Default)] +pub struct Page { + pub paper: (f64, f64), + pub image: Option, + pub rect: (f64, f64, f64, f64), + pub marks: Marks, + pub description: Option, + pub label: Option, + pub page_label: Option, + pub plates: Vec, +} + +/// A device color used by neutral vector content. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DeviceColor { + Gray(f64), + Rgb([f64; 3]), + Cmyk([f64; 4]), +} + +/// The fill rule for a neutral vector path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContentFillRule { + NonZero, + EvenOdd, +} + +/// Path geometry independent of a drawing application's model. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PathElement { + MoveTo([f64; 2]), + LineTo([f64; 2]), + CubicTo([f64; 2], [f64; 2], [f64; 2]), + Close, +} + +/// A centered, device-space stroke on a vector path. +#[derive(Clone, Debug, PartialEq)] +pub struct ContentStroke { + pub color: DeviceColor, + pub width: f64, + pub cap: u8, + pub join: u8, + pub miter_limit: f64, + pub dashes: Vec, + pub dash_offset: f64, +} + +/// One path whose fill and stroke are emitted by the shared PDF writer. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ContentPath { + pub elements: Vec, + pub fill: Option<(DeviceColor, ContentFillRule)>, + pub stroke: Option, +} + +/// Typed vector/text content. Callers provide model geometry and text spans; this crate alone +/// turns the commands into PDF content-stream syntax. +#[derive(Clone, Debug, PartialEq)] +pub enum ContentOp { + Save, + Restore, + Concat([f64; 6]), + Path(ContentPath), + BeginSelectionLine, + SelectionText { actual_text: String, encoded: String, matrix: [f64; 6] }, + EndSelectionLine, +} + +/// Vector and selectable-text commands layered over a raster page. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct VectorContent { + pub operations: Vec, +} + +/// Serialize typed vector content to a PDF content stream. `None` means a number or operator +/// parameter was invalid; malformed commands are never written into the document. +pub fn encode_vector_content(content: &VectorContent) -> Option { + let mut out = String::new(); + for operation in &content.operations { + match operation { + ContentOp::Save => out.push_str("q\n"), + ContentOp::Restore => out.push_str("Q\n"), + ContentOp::Concat(matrix) => { + finite(matrix)?; + writeln!(out, "{} {} {} {} {} {} cm", matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]).ok()?; + } + ContentOp::Path(path) => write_content_path(&mut out, path)?, + ContentOp::BeginSelectionLine => out.push_str("BT\n3 Tr\n/FSelect 1 Tf\n"), + ContentOp::SelectionText { actual_text, encoded, matrix } => { + finite(matrix)?; + writeln!(out, "/Span << /ActualText <{}> >> BDC", actual_text_hex(actual_text)).ok()?; + writeln!(out, "{} {} {} {} {} {} Tm", matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]).ok()?; + writeln!(out, "<{encoded}> Tj\nEMC").ok()?; + } + ContentOp::EndSelectionLine => out.push_str("ET\n"), + } + } + Some(out) +} + +/// Serialize pages with typed vector content. Invalid vector commands return `None`. +pub fn write_pages_with_vector_content( + pages: &[Page], + vectors: &[VectorContent], + selection_font: &SelectionFont, + xmp: Option<&str>, +) -> Option> { + let encoded: Option> = vectors.iter().map(encode_vector_content).collect(); + encoded.map(|content| write_pages(pages, &content, selection_font, xmp)) +} + +fn finite(values: &[f64]) -> Option<()> { + values.iter().all(|value| value.is_finite()).then_some(()) +} + +fn write_content_path(out: &mut String, path: &ContentPath) -> Option<()> { + if let Some((color, _)) = path.fill { + write_device_color(out, color, false)?; + } + if let Some(stroke) = &path.stroke { + write_device_color(out, stroke.color, true)?; + finite(&[stroke.width, stroke.miter_limit, stroke.dash_offset])?; + finite(&stroke.dashes)?; + writeln!(out, "{} w {} J {} j {} M", stroke.width, stroke.cap, stroke.join, stroke.miter_limit).ok()?; + out.push('['); + for dash in &stroke.dashes { + write!(out, "{dash} ").ok()?; + } + writeln!(out, "] {} d", stroke.dash_offset).ok()?; + } + for element in &path.elements { + match element { + PathElement::MoveTo(point) => { + finite(point)?; + writeln!(out, "{} {} m", point[0], point[1]).ok()?; + } + PathElement::LineTo(point) => { + finite(point)?; + writeln!(out, "{} {} l", point[0], point[1]).ok()?; + } + PathElement::CubicTo(a, b, c) => { + finite(a)?; + finite(b)?; + finite(c)?; + writeln!(out, "{} {} {} {} {} {} c", a[0], a[1], b[0], b[1], c[0], c[1]).ok()?; + } + PathElement::Close => out.push_str("h\n"), + } + } + match (path.fill, path.stroke.is_some()) { + (Some((_, ContentFillRule::NonZero)), false) => out.push_str("f\n"), + (Some((_, ContentFillRule::EvenOdd)), false) => out.push_str("f*\n"), + (None, true) => out.push_str("S\n"), + (Some((_, ContentFillRule::NonZero)), true) => out.push_str("B\n"), + (Some((_, ContentFillRule::EvenOdd)), true) => out.push_str("B*\n"), + (None, false) => return None, + } + Some(()) +} + +fn write_device_color(out: &mut String, color: DeviceColor, stroke: bool) -> Option<()> { + match color { + DeviceColor::Gray(value) => { + finite(&[value])?; + writeln!(out, "{value} {}", if stroke { "G" } else { "g" }).ok()?; + } + DeviceColor::Rgb(values) => { + finite(&values)?; + for value in values { + write!(out, "{value} ").ok()?; + } + writeln!(out, "{}", if stroke { "RG" } else { "rg" }).ok()?; + } + DeviceColor::Cmyk(values) => { + finite(&values)?; + for value in values { + write!(out, "{value} ").ok()?; + } + writeln!(out, "{}", if stroke { "K" } else { "k" }).ok()?; + } + } + Some(()) +} + +fn actual_text_hex(text: &str) -> String { + let mut hex = String::from("FEFF"); + for code in text.encode_utf16() { + let _ = write!(hex, "{code:04X}"); + } + hex +} + +/// The RDF namespace used by PDF XMP packets. +pub const RDF_NAMESPACE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; + +/// PhotoCraft spot-plate metadata namespace. The packet encoder is shared so applications pass +/// neutral plate facts instead of assembling XML inside a PDF export path. +pub const SPOT_PLATES_NAMESPACE: &str = "http://ns.getartcraft.com/photocraft/spot-plates/1.0/"; + +/// Add spot-plate metadata to a PDF XMP packet. An unparseable caller packet is preserved as-is; +/// with no caller packet a complete XMP packet is created. An empty plate list returns the +/// caller packet unchanged, or `None` when there is no metadata to write. +pub fn xmp_with_spot_plates(doc_xmp: Option<&str>, plates: &[SpotPlate]) -> Option { + if plates.is_empty() { + return doc_xmp.map(str::to_owned); + } + let mut description = format!( + " \n \n \n" + ); + let two = |value: f32| { + let value = (value * 100.0).round() / 100.0; + if value == 0.0 { 0.0 } else { value } + }; + for (index, plate) in plates.iter().enumerate() { + let hex = plate.srgb.map(|value| format!("{:02X}", (value.clamp(0.0, 1.0) * 255.0).round() as u8)).concat(); + description.push_str(&format!( + " \n {index}\n {}\n {:.2}\n {:.2}\n {:.2}\n #{hex}\n {}\n \n", + xml_text(&plate.name), two(plate.lab[0]), two(plate.lab[1]), two(plate.lab[2]), (plate.solidity * 100.0).round() as u32 + )); + } + description.push_str(" \n \n \n"); + if let Some(xmp) = doc_xmp { + let Some((at, self_closing)) = rdf_insertion(xmp) else { return Some(xmp.to_owned()) }; + let tail_at = if self_closing.is_some() { at + 2 } else { at }; + let (Some(head), Some(tail)) = (xmp.get(..at), xmp.get(tail_at..)) else { return Some(xmp.to_owned()) }; + let mut out = String::with_capacity(xmp.len() + description.len() + 16); + out.push_str(head); + match self_closing { + Some(name) => { + out.push('>'); + out.push('\n'); + out.push_str(&description); + out.push_str(" '); + } + None => out.push_str(&description), + } + out.push_str(tail); + return Some(out); + } + Some(format!( + "\n\n \n{description} \n\n" + )) +} + +fn xml_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + ch => escaped.push(ch), + } + } + escaped +} + +fn rdf_insertion(xmp: &str) -> Option<(usize, Option<&str>)> { + let bom = xmp.strip_prefix('\u{feff}').map_or(0, |_| '\u{feff}'.len_utf8()); + let body = xmp.get(bom..)?; + let doc = roxmltree::Document::parse(body).ok()?; + let rdf = + doc.descendants().find(|node| node.is_element() && node.tag_name().namespace() == Some(RDF_NAMESPACE) && node.tag_name().name() == "RDF")?; + let range = rdf.range(); + let text = body.get(range.clone())?; + if let Some(open) = text.strip_suffix("/>") { + let name = open.get(1..)?; + let end = name.find(|ch: char| ch.is_whitespace() || ch == '/').unwrap_or(name.len()); + return Some((bom + range.end - 2, Some(name.get(..end)?))); + } + Some((bom + range.start + text.rfind(", +} + +impl SelectionFont { + /// Collect distinct characters until the one-byte code space is exceeded. + pub fn new<'a>(texts: impl Iterator) -> Self { + let mut chars = BTreeSet::new(); + 'texts: for text in texts { + for ch in text.chars() { + chars.insert(ch); + if chars.len() > 255 { + break 'texts; + } + } + } + Self { chars } + } + /// Encode text as hex character codes in this font's Unicode map. + pub fn encode(&self, text: &str) -> Option { + if self.chars.len() > 255 { + return None; + } + let mut encoded = String::new(); + for ch in text.chars() { + let code = self.chars.iter().position(|c| *c == ch)?.checked_add(1)?; + write!(encoded, "{code:02X}").ok()?; + } + Some(encoded) + } + fn objects(&self, base: usize) -> Vec> { + let count = self.chars.len().min(255); + let mut differences = String::new(); + let mut procs = String::new(); + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /PhotoCraftUnicode def\n/CMapType 2 def\n1 begincodespacerange\n<00> \nendcodespacerange\n", + ); + let chars: Vec<_> = self.chars.iter().take(count).collect(); + for (block, chunk) in chars.chunks(100).enumerate() { + let _ = writeln!(cmap, "{} beginbfchar", chunk.len()); + for (i, ch) in chunk.iter().enumerate() { + let code = block * 100 + i + 1; + let mut buf = [0; 2]; + let unicode = ch.encode_utf16(&mut buf).iter().map(|u| format!("{u:04X}")).collect::(); + let _ = writeln!(cmap, "<{code:02X}> <{unicode}>"); + } + cmap.push_str("endbfchar\n"); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + for code in 1..=count { + let _ = write!(differences, " /c{code}"); + let _ = write!(procs, " /c{code} {} 0 R", base + 1); + } + let widths = "1000 ".repeat(count.max(1)); + let font = format!( + "<< /Type /Font /Subtype /Type3 /Name /FSelect /FontBBox [0 -250 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs <<{procs} >> /Encoding << /Type /Encoding /Differences [1{differences}] >> /FirstChar 1 /LastChar {} /Widths [{widths}] /Resources << >> /ToUnicode {} 0 R >>", + count.max(1), + base + 2 + ); + let stream = |data: &str| format!("<< /Length {} >>\nstream\n{data}endstream", data.len()).into_bytes(); + vec![font.into_bytes(), stream("1000 0 0 -250 1000 1000 d1\n"), stream(&cmap)] + } +} + +fn pdf_string(s: &str) -> String { + let mut o = String::from("("); + for c in s.chars() { + match c { + '(' | ')' | '\\' => { + o.push('\\'); + o.push(c); + } + c if c.is_ascii() && !c.is_ascii_control() => o.push(c), + _ => o.push('?'), + } + } + o.push(')'); + o +} + +/// A PDF text string with UTF-16BE hex (the Unicode-capable form, byte-order mark included). +fn pdf_unicode_string(s: &str) -> String { + let mut o = String::from("'); + o +} + +/// `s` as a PDF name object (ISO 32000-1 §7.3.5): `/` then the UTF-8 bytes, every byte outside +/// the regular printable ASCII range, `#` and the delimiters as `#xx`. An empty name would be +/// the bare `/`, which some readers reject, so it becomes `/Spot`. +fn pdf_name(s: &str) -> String { + let mut o = String::from("/"); + for b in s.bytes() { + let regular = (0x21..=0x7e).contains(&b) && !matches!(b, b'#' | b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'); + if regular { + o.push(char::from(b)); + } else { + o.push_str(&format!("#{b:02X}")); + } + } + if o.len() == 1 { + o.push_str("Spot"); + } + o +} + +fn deflate(data: &[u8]) -> Vec { + // Fast: print PDFs are transient and large. Level 3, not 1: zlib-rs level 1 writes static + // Huffman blocks only (13-30% bigger on photos); level 3 is the lowest that is never bigger + // than miniz's level 1 was, at about twice its time. + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(3)); + let _ = e.write_all(data); + e.finish().unwrap_or_default() +} + +/// Vector marks around `(x, y, w, h)`. +fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { + let mut s = String::from("q 0 G 0.3 w\n"); + let (gap, len) = (6.0, 18.0); + let line = |s: &mut String, x0: f64, y0: f64, x1: f64, y1: f64| s.push_str(&format!("{x0:.2} {y0:.2} m {x1:.2} {y1:.2} l S\n")); + if m.corner_crop { + for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { + line(&mut s, cx + dx * gap, cy, cx + dx * (gap + len), cy); + line(&mut s, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + } + } + if m.center_crop { + let (mx, my) = (x + w / 2.0, y + h / 2.0); + line(&mut s, mx, y - gap, mx, y - gap - len); + line(&mut s, mx, y + h + gap, mx, y + h + gap + len); + line(&mut s, x - gap, my, x - gap - len, my); + line(&mut s, x + w + gap, my, x + w + gap + len, my); + } + if m.registration { + let r = 5.0; + let off = gap + len / 2.0; + for (cx, cy) in [(x + w / 2.0, y - off), (x + w / 2.0, y + h + off), (x - off, y + h / 2.0), (x + w + off, y + h / 2.0)] { + // Circle (four Béziers) plus crosshair. + let k = 0.5523 * r; + s.push_str(&format!( + "{:.2} {cy:.2} m {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c S\n", + cx + r, cx + r, cy + k, cx + k, cy + r, cy + r, cx - k, cy + r, cx - r, cy + k, cx - r, cx - r, cy - k, cx - k, cy - r, cy - r, cx + k, cy - r, cx + r, cy - k, cx + r + )); + line(&mut s, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(&mut s, cx, cy - r * 1.6, cx, cy + r * 1.6); + } + } + s.push_str("Q\n"); + s +} + +/// An image XObject dictionary and its Flate-compressed samples, as one object body. +fn image_object(width: u32, height: u32, colour_space: &str, data: &[u8]) -> Vec { + let z = deflate(data); + let mut body = format!( + "<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace {colour_space} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n", + z.len() + ) + .into_bytes(); + body.extend_from_slice(&z); + body.extend_from_slice(b"\nendstream"); + body +} + +/// A PDF with one page per entry; `vectors[i]` (when present) is drawn over page `i`, after its +/// image and before its plates and marks (the top layers kept as paths and selectable text), and +/// the selection font their text refers to is `/FSelect`. `xmp` is the packet of the catalog's +/// `/Metadata` stream, if any. +/// +/// Objects: 1 catalog, 2 pages, then per page its page, contents, image and one XObject per +/// plate; then the shared font, ICC profile, overprint ExtGState and XMP metadata stream, each +/// only when present, the selection font's three objects, and last the page-label tree. +pub fn write_pages(pages: &[Page], vectors: &[String], selection_font: &SelectionFont, xmp: Option<&str>) -> Vec { + if pages.is_empty() { + return Vec::new(); + } + let mut out: Vec = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); + let mut offsets: Vec = Vec::new(); + let mut obj = |out: &mut Vec, body: &[u8]| { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", offsets.len()).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + let n = pages.len(); + // A label tree is written only for a non-empty name: an all-empty tree would be `/Nums []`, + // and filtering the first empty name away would start the tree at page index 1, not 0. + let labels_on = pages.iter().any(|p| p.page_label.as_deref().is_some_and(|label| !label.is_empty())); + // Object numbers, handed out in the order the objects are written. + let mut next = 3usize; + let mut take = |present: bool| { + let o = present.then_some(next); + next += usize::from(present); + o + }; + struct PageObjects { + page: usize, + contents: usize, + image: Option, + plates: Vec, + } + // Field initialisers run in this order. + let page_objects: Vec = pages + .iter() + .map(|p| PageObjects { + page: take(true).unwrap_or_default(), + contents: take(true).unwrap_or_default(), + image: take(p.image.is_some()), + plates: p.plates.iter().filter_map(|_| take(true)).collect(), + }) + .collect(); + let font_obj = take(true).unwrap_or_default(); + // The profile is the document's, shared by every page's image. + let icc = pages.iter().find_map(|p| p.image.as_ref().and_then(|i| i.icc.as_ref().map(|icc| (icc, i.channels)))); + let icc_obj = take(icc.is_some()); + let gs_obj = take(pages.iter().any(|p| !p.plates.is_empty())); + let xmp_obj = take(xmp.is_some()); + let selection_base = next; + let selection_objects = selection_font.objects(selection_base); + let labels_obj = labels_on.then_some(selection_base + selection_objects.len()); + + let metadata = xmp_obj.map(|o| format!(" /Metadata {o} 0 R")).unwrap_or_default(); + let page_labels = labels_obj.map(|o| format!(" /PageLabels {o} 0 R")).unwrap_or_default(); + obj(&mut out, format!("<< /Type /Catalog /Pages 2 0 R{metadata}{page_labels} >>").as_bytes()); + let kids = page_objects.iter().map(|p| format!("{} 0 R", p.page)).collect::>().join(" "); + obj(&mut out, format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").as_bytes()); + for (i, (page, objects)) in pages.iter().zip(&page_objects).enumerate() { + let (pw, ph) = page.paper; + let (x, y, w, h) = page.rect; + let mut xobjects = String::new(); + if let Some(o) = objects.image { + xobjects.push_str(&format!("/Im0 {o} 0 R ")); + } + for (k, o) in objects.plates.iter().enumerate() { + xobjects.push_str(&format!("/Sp{k} {o} 0 R ")); + } + let ext_gstate = match gs_obj { + Some(o) if !page.plates.is_empty() => format!("/ExtGState << /GS0 {o} 0 R >> "), + _ => String::new(), + }; + obj( + &mut out, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << {xobjects}>> {ext_gstate}/Font << /F1 {font_obj} 0 R /FSelect {selection_base} 0 R >> >> /Contents {} 0 R >>", + objects.contents + ) + .as_bytes(), + ); + // An explicit white page, so every viewer (and rasteriser) shows paper, not transparency. + let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\n"); + if objects.image.is_some() { + content.push_str(&format!("q {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n")); + } + if let Some(v) = vectors.get(i) { + content.push_str(v); + } + // Each plate overprints what is already on the page: with `/OP true` a Separation paints + // its own colorant only, leaving the others (and the composite) as they are. + for k in 0..page.plates.len() { + content.push_str(&format!("q /GS0 gs {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Sp{k} Do Q\n")); + } + content.push_str(&marks_ops(page.marks, page.rect)); + let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration; + let pad = if any_marks { 30.0 } else { 6.0 }; + if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l))); + } + if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d))); + } + let c = deflate(content.as_bytes()); + let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes(); + body.extend_from_slice(&c); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + if let Some(img) = &page.image { + let device = match img.channels { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + let cs = match icc_obj { + Some(o) if img.icc.is_some() => format!("[/ICCBased {o} 0 R]"), + _ => device.to_string(), + }; + obj(&mut out, &image_object(img.width, img.height, &cs, &img.data)); + } + for p in &page.plates { + // A tint transform from the colorant's density to its alternate: 0 → paper, 1 → `cmyk` + // (an exponential interpolation function with N = 1 is linear). + let cmyk = p.cmyk.map(|v| format!("{:.4}", v.clamp(0.0, 1.0))).join(" "); + let cs = format!("[/Separation {} /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [{cmyk}] /N 1 >>]", pdf_name(&p.name)); + // No mask on the plate: where it has no ink it paints "0" of its own colorant, which a + // separating RIP reads as no ink and overprint keeps the other plates under. (A stencil + // or colour-key mask would let plain viewers show every plate, but Illustrator reports + // an explicit image mask as an unknown imaging construct, and Quartz draws nothing for a + // colour-key-masked Separation image.) + obj(&mut out, &image_object(p.width, p.height, &cs, &p.data)); + } + } + obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); + if let Some((icc, channels)) = icc { + let device = match channels { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + let z = deflate(icc); + let mut body = format!("<< /N {channels} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", z.len()).into_bytes(); + body.extend_from_slice(&z); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + } + if gs_obj.is_some() { + obj(&mut out, b"<< /Type /ExtGState /OP true /op true /OPM 1 >>"); + } + if let Some(xmp) = xmp { + // Unfiltered, as the XMP specification asks of a packet in a PDF, so a reader can find it + // by scanning for the packet header. + let mut body = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n", xmp.len()).into_bytes(); + body.extend_from_slice(xmp.as_bytes()); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + } + for body in &selection_objects { + obj(&mut out, body); + } + if labels_obj.is_some() { + // Every page gets an entry: a number-tree range runs until the next key, so omitting an + // empty name would make that page inherit the previous page's label. An empty + // dictionary gives the page the default (decimal) label instead. + let nums = pages + .iter() + .enumerate() + .map(|(i, p)| match p.page_label.as_deref().filter(|label| !label.is_empty()) { + Some(label) => format!("{i} << /P {} >>", pdf_unicode_string(label)), + None => format!("{i} << >>"), + }) + .collect::>() + .join(" "); + obj(&mut out, format!("<< /Nums [{nums}] >>").as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes()); + for o in &offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes()); + out +} diff --git a/crates/raw/src/cr3.rs b/crates/raw/src/cr3.rs index 60fe292c8..956446c6e 100644 --- a/crates/raw/src/cr3.rs +++ b/crates/raw/src/cr3.rs @@ -184,6 +184,13 @@ fn black_at_active(levels: [f32; 4], pattern: u8, active: Rect) -> BlackLevel { BlackLevel { repeat_rows: 2, repeat_cols: 2, values, delta_h: vec![], delta_v: vec![] } } +/// Orientation metadata usable for embedded-preview fallback even when the raw track is unsupported. +pub(crate) fn orientation(bytes: &[u8]) -> Option { + let container = parse_cr3(bytes)?; + let cmt1 = container.cmt.first().and_then(|b| *b).and_then(|b| Tiff::parse(b).ok())?; + Some(cmt1.ifds.first()?.u16(craft_tiff::tags::ORIENTATION).unwrap_or(1)) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result { let container = parse_cr3(bytes).ok_or(RawError::NotRaw)?; let track = main_track(&container.tracks).ok_or_else(|| RawError::Unsupported("CR3 without a Bayer raw track".into()))?; diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 128f4eeea..e0458fb68 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -238,6 +238,10 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits } let max = match header.metadata.bit_depth { BitDepth::IntegerSample { bits_per_sample } if !float && (1..=16).contains(&bits_per_sample) => ((1u32 << bits_per_sample) - 1) as f32, + // Some DNG writers label LinearRaw samples as floating point while storing a lossless + // integer JPEG XL codestream. JXL exposes decoded pixels as normalized f32 in either + // case, so retain them as floats instead of rejecting the otherwise valid IFD/tile pair. + BitDepth::IntegerSample { bits_per_sample: 1..=16 } if float => 0.0, BitDepth::FloatSample { .. } if float => 0.0, d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))), }; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index c9c072122..dbcf6f953 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -638,7 +638,15 @@ pub fn probe_info(bytes: &[u8]) -> Result { /// Orientation of a supported raw, read through the same format parser as [`probe_info`]. pub fn orientation(bytes: &[u8]) -> Option { - probe_info(bytes).ok().map(|info| info.orientation.to_exif()) + if let Ok(info) = probe_info(bytes) { + return Some(info.orientation.to_exif()); + } + // A damaged/unsupported pixel IFD must not hide usable orientation metadata from preview + // fallback. CR3 keeps IFD0 in its CMT1 box; TIFF-based raws keep it at the file start. + if probe(bytes) == Some(RawFormat::Cr3) { + return cr3::orientation(bytes); + } + craft_tiff::Tiff::parse(bytes).ok()?.ifds.first()?.u16(craft_tiff::tags::ORIENTATION).or(Some(1)) } /// [`probe_info`] within `limits`: a file that declares more than they allow fails as [`decode_with`] would. diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index ccbc92025..57dc83a6c 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -37,14 +37,6 @@ pub(crate) fn check_image(data: &[u8], info: &ImageInfo, limits: &Limits) -> Res if total > limits.samples() || total == 0 { return Err(RawError::Limit("image too large")); } - let bits = info.bits() as u32; - let float = info.sample_format == 3; - if float && !matches!(bits, 16 | 24 | 32) { - return Err(RawError::Unsupported(format!("{bits}-bit floating point samples"))); - } - if !float && !(1..=16).contains(&bits) { - return Err(RawError::Unsupported(format!("{bits}-bit integer samples"))); - } let chunks = info.chunks(data.len() as u64); if chunks.is_empty() { return Err(RawError::Corrupt("no image data chunks".into())); @@ -95,6 +87,17 @@ pub(crate) fn check_image(data: &[u8], info: &ImageInfo, limits: &Limits) -> Res if info.compression == comp::JPEG && referenced.saturating_mul(32) < total as u64 { return Err(RawError::Corrupt(format!("{referenced} bytes of lossless JPEG data cannot hold {total} samples"))); } + // Validate that the source actually contains the declared image before reporting a format + // limitation. A truncated unsupported float DNG must not be mistaken for a valid raw and + // silently replaced by its embedded preview. + let bits = info.bits() as u32; + let float = info.sample_format == 3; + if float && !matches!(bits, 16 | 24 | 32) { + return Err(RawError::Unsupported(format!("{bits}-bit floating-point samples"))); + } + if !float && !(1..=16).contains(&bits) { + return Err(RawError::Unsupported(format!("{bits}-bit integer samples"))); + } Ok(total) } diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index e296fe625..c3912696d 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] craft-xml = { workspace = true } +craft-image-codecs = { workspace = true } # SVGZ (gzip), pure-Rust backend. flate2 = { version = "1", default-features = false, features = ["rust_backend"] } kurbo = { workspace = true } @@ -22,6 +23,9 @@ vectorcraft-pathops = { workspace = true } # The full SVG parser (Apache-2.0 OR MIT). The one version every Craft app uses: apps depend on # craft-svg's `tree` feature and take usvg from its re-export, never directly. usvg = { version = "=0.48.1", default-features = false, optional = true } +# Rendering the parsed tree uses the matching renderer version. Keep it behind an opt-in feature +# so callers that only need bounded SVG text/tree parsing do not pull in raster backends. +resvg = { version = "=0.48.1", default-features = false, features = ["text", "raster-images"], optional = true } [features] # `craft_svg::tree`: usvg behind the bounded entry point. @@ -30,3 +34,6 @@ tree = ["dep:usvg"] tree-text = ["tree", "usvg/text"] # The system's fonts in `usvg::fontdb`. tree-system-fonts = ["tree-text", "usvg/system-fonts"] +# Bounded rendering of a parsed tree through the matching resvg/tiny-skia versions. +tree-render = ["tree", "dep:resvg"] +tree-render-system-fonts = ["tree-render", "resvg/system-fonts"] diff --git a/crates/svg/src/svgz.rs b/crates/svg/src/svgz.rs index 1f3ca90ec..de5232612 100644 --- a/crates/svg/src/svgz.rs +++ b/crates/svg/src/svgz.rs @@ -21,7 +21,7 @@ pub enum Error { Corrupt(String), /// The gzip stream holds gzip data again. A reader that unpacks what it is handed would /// unpack that second layer with no limit, so it is refused here. - #[error("the compressed SVG holds another compressed file, not a document ({0} decoded bytes)")] + #[error("the compressed SVG holds another compressed file, not UTF-8 SVG text ({0} decoded bytes)")] Nested(usize), /// The text is not valid in its encoding. #[error("the SVG is not UTF-8, UTF-16 or ISO 8859-1")] diff --git a/crates/svg/src/tree.rs b/crates/svg/src/tree.rs index dd14be619..9cb004d89 100644 --- a/crates/svg/src/tree.rs +++ b/crates/svg/src/tree.rs @@ -17,21 +17,22 @@ //! [`draw`] bounds what drawing a tree with resvg allocates. //! //! Stack: usvg uses about 15 kB of stack for each level of nesting (measured natively by -//! VectorCraft). [`Limits::depth`] levels, times one more than [`Limits::embedded_depth`], must +//! VectorCraft). [`Limits::tree_depth`] levels, times one more than [`Limits::embedded_depth`], must //! fit the stack of the thread that calls [`parse`]: the default of 256 needs a thread with //! 16 MB or so, and a 1 MB stack takes a depth of about 34. use std::borrow::Cow; -use std::collections::HashMap; use std::collections::hash_map::Entry; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; pub use usvg; use crate::paths::{MAX_CSS_SELECTOR_CHECKS, StyleMap, computed_style, parse_stylesheets}; use crate::{guard, svgz}; +pub mod document; pub mod draw; /// What one import may use. The defaults suit a desktop app that parses on a thread with a @@ -40,8 +41,12 @@ pub mod draw; pub struct Limits { /// Bytes of the document, unpacked if it is SVGZ. pub bytes: usize, - /// How deep elements may nest, references followed (the root alone is 1). + /// How deep the XML elements may nest (the root alone is 1). pub depth: usize, + /// Maximum depth usvg may create after following references such as ``, patterns, + /// clip paths, masks and markers (the root alone is 1). Choose this for the stack available + /// to the parsing thread; usvg conversion recurses after this preflight. + pub tree_depth: usize, /// Bytes the document type's entity references may expand to in all. pub entity_bytes: usize, /// Refuse a document type that declares an entity holding markup, instead of counting what @@ -58,6 +63,10 @@ pub struct Limits { pub embedded_bytes: usize, /// SVG documents embedded inside one another, for [`image_resolver`]. pub embedded_depth: usize, + /// Decoded RGBA pixels retained across the raster images embedded in one drawing. Dimensions + /// are probed before usvg hands the bytes to a decoder; repeated references to the same data + /// URL share one charge. + pub embedded_raster_pixels: u64, } impl Default for Limits { @@ -65,6 +74,7 @@ impl Default for Limits { Limits { bytes: 64 << 20, depth: 256, + tree_depth: 256, entity_bytes: guard::MAX_ENTITY_EXPANSION, refuse_entity_markup: false, attributes: guard::MAX_ATTRIBUTES, @@ -72,6 +82,7 @@ impl Default for Limits { xml_nodes: 4_000_000, embedded_bytes: 32 << 20, embedded_depth: 4, + embedded_raster_pixels: 24_000_000, } } } @@ -123,6 +134,24 @@ pub fn parse_text(svg: &str, limits: &Limits, options: &usvg::Options<'_>) -> Re usvg::Tree::from_xmltree(&xml, options).map_err(|e| Error::Parse(e.to_string())) } +/// Read the root SVG's unitless width and height without constructing a rendering tree. +/// +/// Lengths with units, percentages, missing dimensions, and non-SVG roots return `Ok(None)`; +/// malformed or over-limit XML returns the same bounded parsing error as [`parse_text`]. +pub fn root_unitless_size(svg: &str, limits: &Limits) -> Result, Error> { + let xml = checked(svg, limits)?; + let root = xml.root_element(); + if root.tag_name().name() != "svg" { + return Ok(None); + } + let Some((width, height)) = + root.attribute("width").and_then(|value| value.parse::().ok()).zip(root.attribute("height").and_then(|value| value.parse::().ok())) + else { + return Ok(None); + }; + Ok(Some((width, height))) +} + /// Every check on SVG text, cheapest first; the XML it parses to on the way. fn checked<'s>(svg: &'s str, limits: &Limits) -> Result, Error> { let preflight_limits = crate::preflight::Limits { @@ -142,8 +171,8 @@ fn checked<'s>(svg: &'s str, limits: &Limits) -> Result, crate::preflight::Error::TooWide(attributes) => Error::TooWide(attributes), crate::preflight::Error::Xml(msg) => Error::Xml(msg), })?; - if height(&xml, limits.depth)?.is_none() { - return Err(Error::TooDeep(limits.depth)); + if height(&xml, limits.tree_depth)?.is_none() { + return Err(Error::TooDeep(limits.tree_depth)); } Ok(xml) } @@ -264,18 +293,151 @@ pub fn height(xml: &roxmltree::Document<'_>, most: usize) -> Result>, + invalid_raster: AtomicUsize, + raster_limit: AtomicUsize, + invalid_svg: AtomicUsize, + svg_limit: AtomicUsize, } -fn raster(data: &[u8]) -> bool { - data.starts_with(b"\x89PNG") - || data.starts_with(&[0xff, 0xd8, 0xff]) - || data.starts_with(b"GIF8") - || (data.len() > 12 && data.get(8..12) == Some(b"WEBP")) +/// What resolving one drawing's embedded images used or refused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ImageUsage { + /// Unique decoded raster pixels reserved before decode. + pub raster_pixels: u64, + /// Unpacked SVG bytes consumed by embedded SVG data URLs. + pub embedded_svg_bytes: usize, + /// Embedded SVG images parsed, including nested images. + pub embedded_svg_count: usize, + /// Deepest embedded SVG chain. + pub embedded_svg_depth: usize, + /// Raster image data that could not be probed or decoded by the usvg resolver. + pub invalid_raster_images: usize, + /// Raster images refused by their per-image or aggregate pixel limit. + pub raster_limit_images: usize, + /// Embedded SVG data that was invalid. + pub invalid_svg_images: usize, + /// Embedded SVG data refused by byte or nesting limits. + pub svg_limit_images: usize, +} + +/// A data-URL resolver and the accounting for the one drawing it serves. +/// +/// Keep this value while parsing, then read [`usage`](Self::usage). Linked files are never read. +pub struct ImageResolver { + limits: Limits, + used: Arc, +} + +impl ImageResolver { + /// Create one resolver budget for one drawing import. + pub fn new(limits: Limits) -> Self { + ImageResolver { + limits, + used: Arc::new(Used { + bytes: AtomicUsize::new(0), + depth: AtomicUsize::new(0), + xml_depth: AtomicUsize::new(0), + svg_count: AtomicUsize::new(0), + svg_depth: AtomicUsize::new(0), + raster_pixels: AtomicU64::new(0), + raster_data: Mutex::new(HashSet::new()), + invalid_raster: AtomicUsize::new(0), + raster_limit: AtomicUsize::new(0), + invalid_svg: AtomicUsize::new(0), + svg_limit: AtomicUsize::new(0), + }), + } + } + + /// Resolver to put in `usvg::Options::image_href_resolver`. + pub fn resolver(&self) -> usvg::ImageHrefResolver<'static> { + image_resolver_for(self.limits, Arc::clone(&self.used)) + } + + /// Record the already-checked depth of the outer SVG before parsing it. Embedded SVGs then + /// share the same total XML recursion budget instead of each receiving a fresh allowance. + pub fn set_document_depth(&self, depth: usize) { + self.used.xml_depth.store(depth, Ordering::Relaxed); + } + + /// Snapshot accounting after parsing has finished. + pub fn usage(&self) -> ImageUsage { + ImageUsage { + raster_pixels: self.used.raster_pixels.load(Ordering::Relaxed), + embedded_svg_bytes: self.used.bytes.load(Ordering::Relaxed), + embedded_svg_count: self.used.svg_count.load(Ordering::Relaxed), + embedded_svg_depth: self.used.svg_depth.load(Ordering::Relaxed), + invalid_raster_images: self.used.invalid_raster.load(Ordering::Relaxed), + raster_limit_images: self.used.raster_limit.load(Ordering::Relaxed), + invalid_svg_images: self.used.invalid_svg.load(Ordering::Relaxed), + svg_limit_images: self.used.svg_limit.load(Ordering::Relaxed), + } + } +} + +fn raster_format(data: &[u8]) -> Option { + match craft_image_codecs::detect(data)? { + format @ (craft_image_codecs::Format::Png + | craft_image_codecs::Format::Jpeg + | craft_image_codecs::Format::Gif + | craft_image_codecs::Format::WebP) => Some(format), + _ => None, + } +} + +fn raster_image(used: &Used, limits: &Limits, data: Arc>, format: craft_image_codecs::Format) -> Option { + let Ok((width, height)) = craft_image_codecs::probe_allocation_size(&data) else { + used.invalid_raster.fetch_add(1, Ordering::Relaxed); + return None; + }; + if !matches!( + format, + craft_image_codecs::Format::Png | craft_image_codecs::Format::Jpeg | craft_image_codecs::Format::Gif | craft_image_codecs::Format::WebP + ) { + used.invalid_raster.fetch_add(1, Ordering::Relaxed); + return None; + } + let pixels = u64::from(width).checked_mul(u64::from(height))?; + if pixels == 0 || pixels > limits.embedded_raster_pixels { + used.raster_limit.fetch_add(1, Ordering::Relaxed); + return None; + } + let pointer = Arc::as_ptr(&data) as usize; + let mut seen = used.raster_data.lock().unwrap_or_else(PoisonError::into_inner); + if seen.insert(pointer) { + let mut current = used.raster_pixels.load(Ordering::Relaxed); + loop { + let next = current.checked_add(pixels)?; + if next > limits.embedded_raster_pixels { + seen.remove(&pointer); + used.raster_limit.fetch_add(1, Ordering::Relaxed); + return None; + } + match used.raster_pixels.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(actual) => current = actual, + } + } + } + drop(seen); + Some(match format { + craft_image_codecs::Format::Png => usvg::ImageKind::PNG(data), + craft_image_codecs::Format::Jpeg => usvg::ImageKind::JPEG(data), + craft_image_codecs::Format::Gif => usvg::ImageKind::GIF(data), + craft_image_codecs::Format::WebP => usvg::ImageKind::WEBP(data), + _ => return None, + }) } /// An embedded SVG document, if the budgets allow it. fn embedded(used: &Used, limits: &Limits, data: &[u8], options: &usvg::Options<'_>) -> Option { if used.depth.load(Ordering::Relaxed) >= limits.embedded_depth { + used.svg_limit.fetch_add(1, Ordering::Relaxed); return None; } let left = limits.embedded_bytes.saturating_sub(used.bytes.load(Ordering::Relaxed)); @@ -285,15 +447,43 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], options: &usvg::Options<' // is left, so the next image can't repeat it. Err(_) => { used.bytes.fetch_add(left, Ordering::Relaxed); + used.svg_limit.fetch_add(1, Ordering::Relaxed); return None; } }; used.bytes.fetch_add(text.len(), Ordering::Relaxed); - let xml = checked(&text, limits).ok()?; - used.depth.fetch_add(1, Ordering::Relaxed); + let xml = match checked(&text, limits) { + Ok(xml) => xml, + Err(_) => { + used.invalid_svg.fetch_add(1, Ordering::Relaxed); + return None; + } + }; + let Some(xml_depth) = height(&xml, limits.tree_depth).ok().flatten() else { + used.svg_limit.fetch_add(1, Ordering::Relaxed); + return None; + }; + let total_depth = used.xml_depth.load(Ordering::Relaxed).checked_add(xml_depth); + let Some(total_depth) = total_depth.filter(|depth| *depth <= limits.tree_depth) else { + used.svg_limit.fetch_add(1, Ordering::Relaxed); + return None; + }; + let previous_depth = used.xml_depth.swap(total_depth, Ordering::Relaxed); + let depth = used.depth.fetch_add(1, Ordering::Relaxed).saturating_add(1); + used.svg_depth.fetch_max(depth, Ordering::Relaxed); let tree = usvg::Tree::from_xmltree(&xml, options); + used.xml_depth.store(previous_depth, Ordering::Relaxed); used.depth.fetch_sub(1, Ordering::Relaxed); - tree.ok().map(usvg::ImageKind::SVG) + match tree { + Ok(tree) => { + used.svg_count.fetch_add(1, Ordering::Relaxed); + Some(usvg::ImageKind::SVG(tree)) + } + Err(_) => { + used.invalid_svg.fetch_add(1, Ordering::Relaxed); + None + } + } } /// An image resolver for `usvg::Options::image_href_resolver` that reads `` data URLs @@ -302,22 +492,35 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], options: &usvg::Options<' /// [`Limits::embedded_depth`] inside one another. An image past a limit is left out. Linked /// files are never read. One resolver is one import's budget: make a new one for each [`parse`]. pub fn image_resolver(limits: Limits) -> usvg::ImageHrefResolver<'static> { - let used = Arc::new(Used { bytes: AtomicUsize::new(0), depth: AtomicUsize::new(0) }); + ImageResolver::new(limits).resolver() +} + +fn image_resolver_for(limits: Limits, used: Arc) -> usvg::ImageHrefResolver<'static> { usvg::ImageHrefResolver { - resolve_data: Box::new(move |mime, data, options| match mime { - "image/jpg" | "image/jpeg" => Some(usvg::ImageKind::JPEG(data)), - "image/png" => Some(usvg::ImageKind::PNG(data)), - "image/gif" => Some(usvg::ImageKind::GIF(data)), - "image/webp" => Some(usvg::ImageKind::WEBP(data)), - "image/svg+xml" => embedded(&used, &limits, &data, options), - "text/plain" if raster(&data) => match data.first() { - Some(0x89) => Some(usvg::ImageKind::PNG(data)), - Some(0xff) => Some(usvg::ImageKind::JPEG(data)), - Some(b'G') => Some(usvg::ImageKind::GIF(data)), - _ => Some(usvg::ImageKind::WEBP(data)), - }, - "text/plain" => embedded(&used, &limits, &data, options), - _ => None, + resolve_data: Box::new(move |mime, data, options| { + let detected = raster_format(&data); + let format = match mime { + "image/jpg" | "image/jpeg" => (detected == Some(craft_image_codecs::Format::Jpeg)).then_some(craft_image_codecs::Format::Jpeg), + "image/png" => (detected == Some(craft_image_codecs::Format::Png)).then_some(craft_image_codecs::Format::Png), + "image/gif" => (detected == Some(craft_image_codecs::Format::Gif)).then_some(craft_image_codecs::Format::Gif), + "image/webp" => (detected == Some(craft_image_codecs::Format::WebP)).then_some(craft_image_codecs::Format::WebP), + "text/plain" => detected, + _ => None, + }; + if let Some(format) = format { + return raster_image(&used, &limits, data, format); + } + // usvg can decode only the raster variants above. Count every other declared + // image as an invalid raster so strict consumers can reject it instead of + // silently accepting a document after the image was omitted (for example BMP). + if mime.starts_with("image/") && mime != "image/svg+xml" { + used.invalid_raster.fetch_add(1, Ordering::Relaxed); + } + match mime { + "image/svg+xml" => embedded(&used, &limits, &data, options), + "text/plain" => embedded(&used, &limits, &data, options), + _ => None, + } }), resolve_string: Box::new(|_, _| None), } diff --git a/crates/svg/src/tree/document.rs b/crates/svg/src/tree/document.rs new file mode 100644 index 000000000..f77d5a8c0 --- /dev/null +++ b/crates/svg/src/tree/document.rs @@ -0,0 +1,392 @@ +//! A format-neutral vector scene made from a bounded SVG tree. +//! +//! The scene preserves paths, groups, paints, transforms, opacity, strokes and image sources +//! without depending on an application's document model. Coordinates and stroke widths use the +//! SVG tree's CSS-pixel coordinate system. + +#[cfg(feature = "tree-render")] +use super::draw::Refusal; +use super::usvg; + +/// A parsed SVG scene. Text is represented by its outlined paths. +#[derive(Clone, Debug, Default)] +pub struct Document { + pub width: f64, + pub height: f64, + pub nodes: Vec, + /// Number of image nodes omitted from the vector scene. + pub images: usize, + /// True if nodes deeper than the requested depth were omitted. + pub too_deep: bool, + source: Option>, +} + +impl PartialEq for Document { + fn eq(&self, other: &Self) -> bool { + self.width == other.width + && self.height == other.height + && self.nodes == other.nodes + && self.images == other.images + && self.too_deep == other.too_deep + } +} + +/// Stable node indices from the root of the source usvg tree. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NodePath(Vec); + +impl NodePath { + pub fn indices(&self) -> &[usize] { + &self.0 + } +} + +/// A group or path in document order. +#[derive(Clone, Debug, PartialEq)] +pub enum Node { + Group { children: Vec, opacity: f32, transform: [f64; 6], bounds: [f64; 4], features: GroupFeatures, source_path: NodePath }, + Path(Box), + Image(Image), +} + +/// Features that need a renderer capable of SVG compositing or external resources. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GroupFeatures { + pub filters: bool, + pub clip_path: bool, + pub mask: bool, + pub non_normal_blend: bool, + pub isolation: bool, +} + +/// An embedded image node and its bounded source data/placement. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + pub kind: ImageKind, + pub visible: bool, + pub transform: [f64; 6], + pub bounds: [f64; 4], + pub encoded: Option>>, + pub svg: Option>, + pub source_path: NodePath, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageKind { + Jpeg, + Png, + Gif, + WebP, + Svg, +} + +/// A transformed path with its resolved fill and stroke. +#[derive(Clone, Debug, PartialEq)] +pub struct Path { + pub segments: Vec, + pub fill: Option, + pub stroke: Option, + pub source_path: NodePath, +} + +/// A path segment in CSS pixels after its absolute transform. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Segment { + MoveTo([f64; 2]), + LineTo([f64; 2]), + QuadTo([f64; 2], [f64; 2]), + CubicTo([f64; 2], [f64; 2], [f64; 2]), + Close, +} + +/// A resolved color or an unsupported SVG paint server. +#[derive(Clone, Debug, PartialEq)] +pub enum Paint { + Color { rgb: [u8; 3], opacity: f32 }, + Gradient { kind: GradientKind, stops: Vec, transform: [f64; 6], spread: Spread, opacity: f32 }, + Pattern, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GradientKind { + Linear { start: [f64; 2], end: [f64; 2] }, + Radial { center: [f64; 2], radius: f64, focus: [f64; 2], focus_radius: f64 }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GradientStop { + pub offset: f64, + pub rgb: [u8; 3], + pub opacity: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Spread { + Pad, + Reflect, + Repeat, +} + +/// A resolved outline. +#[derive(Clone, Debug, PartialEq)] +pub struct Stroke { + pub paint: Paint, + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter_limit: f64, + pub dash_array: Option>, + pub dash_offset: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineCap { + Butt, + Round, + Square, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineJoin { + Miter, + Round, + Bevel, +} + +impl Document { + /// Convert a bounded usvg tree to a scene, omitting nodes beyond `max_depth`. + pub fn from_tree(tree: &usvg::Tree, max_depth: usize) -> Self { + let size = tree.size(); + let mut document = Self { width: f64::from(size.width()), height: f64::from(size.height()), ..Self::default() }; + document.nodes = convert_group(tree.root(), 0, max_depth, &mut document, &[]); + document + } + + /// Build a scene that retains its source tree for fallback rendering. + pub fn from_owned_tree(tree: usvg::Tree, max_depth: usize) -> Self { + let size = tree.size(); + let mut document = Self { width: f64::from(size.width()), height: f64::from(size.height()), ..Self::default() }; + document.nodes = convert_group(tree.root(), 0, max_depth, &mut document, &[]); + document.source = Some(Box::new(tree)); + document + } + + /// Draw a source subtree named by a [`NodePath`] without exposing usvg traversal to callers. + #[cfg(feature = "tree-render")] + pub fn draw_subtree( + &self, + path: &NodePath, + transform: usvg::Transform, + target: &mut super::draw::tiny_skia::Pixmap, + held_bytes: u64, + budget_bytes: u64, + ) -> Result { + let tree = self.source.as_deref().ok_or_else(|| Refusal { + complex: true, + memory: false, + message: "the SVG scene does not retain its source tree".into(), + })?; + let mut group = tree.root(); + let mut node = None; + for (i, index) in path.0.iter().enumerate() { + node = Some(group.children().get(*index).ok_or_else(|| Refusal { + complex: true, + memory: false, + message: "the SVG subtree path is no longer valid".into(), + })?); + if i + 1 < path.0.len() { + group = match node { + Some(usvg::Node::Group(g)) => g, + Some(usvg::Node::Text(t)) => t.flattened(), + _ => return Err(Refusal { complex: true, memory: false, message: "the SVG subtree path does not name a group".into() }), + }; + } + } + let node = node.ok_or_else(|| Refusal { complex: true, memory: false, message: "the SVG subtree path is empty".into() })?; + super::draw::render_node(node, transform, target, held_bytes, budget_bytes) + } +} + +fn transform(t: usvg::Transform) -> [f64; 6] { + [f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)] +} + +fn rect(r: usvg::Rect) -> [f64; 4] { + [f64::from(r.x()), f64::from(r.y()), f64::from(r.width()), f64::from(r.height())] +} + +fn spread(s: usvg::SpreadMethod) -> Spread { + match s { + usvg::SpreadMethod::Pad => Spread::Pad, + usvg::SpreadMethod::Reflect => Spread::Reflect, + usvg::SpreadMethod::Repeat => Spread::Repeat, + } +} + +fn color(paint: &usvg::Paint, opacity: f32) -> Paint { + match paint { + usvg::Paint::Color(c) => Paint::Color { rgb: [c.red, c.green, c.blue], opacity }, + usvg::Paint::LinearGradient(g) => Paint::Gradient { + kind: GradientKind::Linear { start: [f64::from(g.x1()), f64::from(g.y1())], end: [f64::from(g.x2()), f64::from(g.y2())] }, + stops: g + .stops() + .iter() + .map(|s| GradientStop { + offset: f64::from(s.offset().get()), + rgb: [s.color().red, s.color().green, s.color().blue], + opacity: s.opacity().get(), + }) + .collect(), + transform: transform(g.transform()), + spread: spread(g.spread_method()), + opacity, + }, + usvg::Paint::RadialGradient(g) => Paint::Gradient { + kind: GradientKind::Radial { + center: [f64::from(g.cx()), f64::from(g.cy())], + radius: f64::from(g.r().get()), + focus: [f64::from(g.fx()), f64::from(g.fy())], + focus_radius: f64::from(g.fr().get()), + }, + stops: g + .stops() + .iter() + .map(|s| GradientStop { + offset: f64::from(s.offset().get()), + rgb: [s.color().red, s.color().green, s.color().blue], + opacity: s.opacity().get(), + }) + .collect(), + transform: transform(g.transform()), + spread: spread(g.spread_method()), + opacity, + }, + usvg::Paint::Pattern(_) => Paint::Pattern, + } +} + +fn point(transform: usvg::Transform, p: usvg::tiny_skia_path::Point) -> [f64; 2] { + let mut p = p; + transform.map_point(&mut p); + [f64::from(p.x), f64::from(p.y)] +} + +fn convert_group(group: &usvg::Group, depth: usize, max_depth: usize, doc: &mut Document, parent_path: &[usize]) -> Vec { + let mut result = Vec::new(); + for (index, node) in group.children().iter().enumerate() { + let mut source_path = parent_path.to_vec(); + source_path.push(index); + match node { + usvg::Node::Path(path) => { + if !path.is_visible() || path.data().is_empty() { + continue; + } + let transform = path.abs_transform(); + let segments = path + .data() + .segments() + .map(|segment| { + use usvg::tiny_skia_path::PathSegment as S; + match segment { + S::MoveTo(a) => Segment::MoveTo(point(transform, a)), + S::LineTo(a) => Segment::LineTo(point(transform, a)), + S::QuadTo(a, b) => Segment::QuadTo(point(transform, a), point(transform, b)), + S::CubicTo(a, b, c) => Segment::CubicTo(point(transform, a), point(transform, b), point(transform, c)), + S::Close => Segment::Close, + } + }) + .collect(); + let fill = path.fill().map(|f| color(f.paint(), f.opacity().get())); + let stroke = path.stroke().map(|s| { + let scale = f64::from((transform.sx * transform.sy - transform.kx * transform.ky).abs().sqrt()); + Stroke { + paint: color(s.paint(), s.opacity().get()), + width: f64::from(s.width().get()) * scale, + cap: match s.linecap() { + usvg::LineCap::Butt => LineCap::Butt, + usvg::LineCap::Round => LineCap::Round, + usvg::LineCap::Square => LineCap::Square, + }, + join: match s.linejoin() { + usvg::LineJoin::Round => LineJoin::Round, + usvg::LineJoin::Bevel => LineJoin::Bevel, + usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter, + }, + miter_limit: f64::from(s.miterlimit().get()), + dash_array: s.dasharray().map(|d| d.iter().map(|v| f64::from(*v) * scale).collect()), + dash_offset: f64::from(s.dashoffset()) * scale, + } + }); + result.push(Node::Path(Box::new(Path { segments, fill, stroke, source_path: NodePath(source_path) }))); + } + usvg::Node::Group(child) => { + if depth >= max_depth { + doc.too_deep = true; + continue; + } + let children = convert_group(child, depth + 1, max_depth, doc, &source_path); + if !children.is_empty() { + result.push(Node::Group { + children, + opacity: child.opacity().get(), + transform: transform(child.abs_transform()), + bounds: rect(child.abs_layer_bounding_box().to_rect()), + features: GroupFeatures { + filters: !child.filters().is_empty(), + clip_path: child.clip_path().is_some(), + mask: child.mask().is_some(), + non_normal_blend: child.blend_mode() != usvg::BlendMode::Normal, + isolation: child.should_isolate(), + }, + source_path: NodePath(source_path), + }); + } + } + usvg::Node::Text(text) => { + if depth >= max_depth { + doc.too_deep = true; + continue; + } + let children = convert_group(text.flattened(), depth + 1, max_depth, doc, &source_path); + if !children.is_empty() { + result.push(Node::Group { + children, + opacity: 1.0, + transform: transform(text.abs_transform()), + bounds: rect(text.abs_bounding_box()), + features: GroupFeatures::default(), + source_path: NodePath(source_path), + }); + } + } + usvg::Node::Image(image) => { + doc.images += 1; + let kind = match image.kind() { + usvg::ImageKind::JPEG(_) => ImageKind::Jpeg, + usvg::ImageKind::PNG(_) => ImageKind::Png, + usvg::ImageKind::GIF(_) => ImageKind::Gif, + usvg::ImageKind::WEBP(_) => ImageKind::WebP, + usvg::ImageKind::SVG(_) => ImageKind::Svg, + }; + let (encoded, svg) = match image.kind() { + usvg::ImageKind::JPEG(data) | usvg::ImageKind::PNG(data) | usvg::ImageKind::GIF(data) | usvg::ImageKind::WEBP(data) => { + (Some(data.clone()), None) + } + usvg::ImageKind::SVG(tree) => { + (None, Some(Box::new(Document::from_owned_tree(tree.clone(), max_depth.saturating_sub(depth + 1))))) + } + }; + result.push(Node::Image(Image { + kind, + visible: image.is_visible(), + transform: transform(image.abs_transform()), + bounds: rect(image.abs_bounding_box()), + encoded, + svg, + source_path: NodePath(source_path), + })); + } + } + } + result +} diff --git a/crates/svg/src/tree/draw.rs b/crates/svg/src/tree/draw.rs index fed7ac569..a7bdfd80f 100644 --- a/crates/svg/src/tree/draw.rs +++ b/crates/svg/src/tree/draw.rs @@ -24,6 +24,9 @@ use std::collections::HashMap; use usvg::Transform; +#[cfg(feature = "tree-render")] +pub use resvg::tiny_skia; + /// Filter primitives per filter. A result of every primitive is kept; real filters have 1 to 12 /// (Inkscape's drop shadow has 5), and 64 leaves headroom for generated ones. const MAX_PRIMITIVES: usize = 64; @@ -36,7 +39,7 @@ const MAX_PATTERN_DEPTH: usize = 8; /// Embedded SVG images inside embedded SVG images. Each draws on a pixmap of the enclosing /// layer, which the live-bytes check charges, so deep chains of small images are harmless and /// chains of big ones stop at the budget long before this. -const MAX_IMAGE_DEPTH: usize = 32; +const MAX_IMAGE_DEPTH: usize = 24; /// Nested isolated groups, patterns, embedded images and filter images followed. It keeps this /// walk, and resvg's own recursion, far from a stack overflow; real drawings stay under a few dozen. const MAX_DEPTH: usize = 200; @@ -157,6 +160,118 @@ pub fn check(nodes: &[usvg::Node], ts: Transform, width: u32, height: u32, held: walk.nodes(nodes, ts, cx).map(|_| ()) } +/// Draws a complete tree onto a transparent pixmap after checking the peak RGBA allocation. +/// `held` is caller-owned RGBA storage that remains live during the draw; the target itself is +/// charged by this function. +#[cfg(feature = "tree-render")] +pub fn render(tree: &usvg::Tree, ts: Transform, width: u32, height: u32, held: u64, budget: u64) -> Result { + check_filter_cost(tree, ts, 2_000_000_000, 10_000_000, 200)?; + // Even an empty tree allocates the target. `check` only walks buffers added by nodes, so + // enforce the target allocation here before `Pixmap::new` (whose OOM handler aborts). + let target = u64::from(width).saturating_mul(u64::from(height)).saturating_mul(4).saturating_add(held); + if target > budget { + return Err(memory("the target pixmap", target as f64, budget as f64)); + } + check(tree.root().children(), ts, width, height, held, budget)?; + let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or_else(|| Refusal { + complex: false, + memory: true, + message: format!("the {width}×{height} px target pixmap could not be allocated"), + })?; + resvg::render(tree, ts, &mut pixmap.as_mut()); + Ok(pixmap) +} + +/// Draws one node onto `target` after checking the peak RGBA allocation. The node remains in its +/// parent's coordinate space, matching `resvg::render_node`. +#[cfg(feature = "tree-render")] +pub fn render_node(node: &usvg::Node, ts: Transform, target: &mut tiny_skia::Pixmap, held: u64, budget: u64) -> Result { + check(std::slice::from_ref(node), ts, target.width(), target.height(), held, budget)?; + Ok(resvg::render_node(node, ts, &mut target.as_mut()).is_some()) +} + +/// Refuse a tree whose filters would exceed `max_work` pixel reads, or whose referenced roots +/// exceed `max_visits` and `max_depth`. +pub fn check_filter_cost(tree: &usvg::Tree, ts: Transform, max_work: u64, max_visits: usize, max_depth: usize) -> Result<(), Refusal> { + let mut visits = max_visits; + let cost = filter_cost(tree.root(), ts, 0, &mut visits, max_depth); + if cost > max_work { + Err(Refusal { + complex: visits == 0, + memory: false, + message: "the drawing's filters are too costly to render (a large morphology radius, convolution kernel or turbulence octave count, or patterns nested too deep)".into(), + }) + } else { + Ok(()) + } +} + +fn filter_cost(root: &usvg::Group, base: Transform, rec: usize, visits: &mut usize, max_depth: usize) -> u64 { + use usvg::filter::Kind; + let mut cost = 0u64; + let mut stack: Vec<&usvg::Group> = vec![root]; + while let Some(group) = stack.pop() { + let ts = base.pre_concat(group.abs_transform()); + let (sx, sy) = ts.get_scale(); + for filter in group.filters() { + let Some(rect) = filter.rect().transform(ts) else { continue }; + let (width, height) = (rect.width().ceil() as u64, rect.height().ceil() as u64); + let window = |radius: f32, scale: f32, side: u64| ((radius * scale).ceil() as u64).saturating_mul(2).min(side); + for primitive in filter.primitives() { + let reads = match primitive.kind() { + Kind::ConvolveMatrix(conv) => u64::from(conv.matrix().columns()) * u64::from(conv.matrix().rows()), + Kind::Morphology(morphology) => { + window(morphology.radius_x().get(), sx, width).saturating_mul(window(morphology.radius_y().get(), sy, height)) + } + Kind::Turbulence(turbulence) => u64::from(turbulence.num_octaves()).saturating_mul(16), + _ => 0, + }; + cost = cost.saturating_add(width.saturating_mul(height).saturating_mul(reads.saturating_sub(256))); + } + } + for node in group.children() { + *visits = visits.saturating_sub(1); + if *visits == 0 { + return u64::MAX; + } + if let usvg::Node::Group(child) = node { + stack.push(child); + } + if rec >= max_depth { + let mut skipped = false; + match node { + usvg::Node::Path(path) => { + skipped = [path.fill().map(|fill| fill.paint()), path.stroke().map(|stroke| stroke.paint())] + .into_iter() + .flatten() + .any(|paint| matches!(paint, usvg::Paint::Pattern(_))); + } + _ => node.subroots(|_| skipped = true), + } + if skipped { + return u64::MAX; + } + continue; + } + let world = base.pre_concat(node.abs_transform()); + let mut sub = + |root: &usvg::Group, transform: Transform| cost = cost.saturating_add(filter_cost(root, transform, rec + 1, visits, max_depth)); + match node { + usvg::Node::Path(path) => { + for paint in [path.fill().map(|fill| fill.paint()), path.stroke().map(|stroke| stroke.paint())].into_iter().flatten() { + if let usvg::Paint::Pattern(pattern) = paint { + let (sx, sy) = world.pre_concat(pattern.transform()).get_scale(); + sub(pattern.root(), Transform::from_scale(sx, sy)); + } + } + } + _ => node.subroots(|root| sub(root, world)), + } + } + } + cost +} + impl Walk { fn need(&self, live: f64, what: impl FnOnce() -> String) -> Result<(), Refusal> { if live > self.budget { Err(memory(&what(), live, self.budget)) } else { Ok(()) } diff --git a/crates/xml/src/lib.rs b/crates/xml/src/lib.rs index 5d3d40f27..9947b3252 100644 --- a/crates/xml/src/lib.rs +++ b/crates/xml/src/lib.rs @@ -44,7 +44,7 @@ pub enum Error { #[error("the XML is larger than {0} bytes")] TooLarge(usize), /// Elements nest deeper than [`Limits::max_depth`]. - #[error("XML elements nest deeper than {0} levels")] + #[error("XML elements are nested deeper than {0} levels")] TooDeep(usize), /// The tree would hold more than [`Limits::max_nodes`] nodes. #[error("the XML holds more than {0} nodes")] diff --git a/crates/xml/src/read.rs b/crates/xml/src/read.rs index a4cfdcd36..3b79a8751 100644 --- a/crates/xml/src/read.rs +++ b/crates/xml/src/read.rs @@ -285,6 +285,9 @@ impl<'a> Options<'a> { if bytes.len() > self.limits.max_input { return (None, Some(Error::TooLarge(self.limits.max_input).into())); } + if doctype_refused(bytes, self.doctype) { + return (None, Some(Error::Doctype.into())); + } let text = to_utf8(bytes); let mut events = Events::new(*self, &text, false); let mut tree = Builder { opts: self, split, stack: Vec::new(), marks: Vec::new(), kept: 0, elements: 0, attrs: 0, root: None, at: 0 }; @@ -329,6 +332,103 @@ impl<'a> Options<'a> { } } +/// Check declarations before handing input to the XML parser. Some parser implementations stop +/// on malformed XML declarations before reporting a later DOCTYPE; formats that reject internal +/// subsets need the security decision to take precedence over that unrelated syntax error. +fn doctype_refused(input: &[u8], policy: Doctype) -> bool { + if policy == Doctype::Ignore { + return false; + } + let mut at = 0; + while at < input.len() { + let Some(rel) = input.get(at..).and_then(|bytes| bytes.iter().position(|b| *b == b'<')) else { + break; + }; + at += rel; + let Some(rest) = input.get(at..) else { break }; + if rest.starts_with(b""); + } else if rest.starts_with(b"") { + at = after(input, at + 9, b""); + } else if rest.starts_with(b" usize { + input + .get(start..) + .and_then(|rest| rest.windows(terminator.len()).position(|w| w == terminator)) + .map_or(input.len(), |i| start + i + terminator.len()) +} + +fn after_instruction(input: &[u8], mut at: usize) -> usize { + let mut quote = None; + while let Some(&byte) = input.get(at) { + if let Some(q) = quote { + if byte == q { + quote = None; + } + } else if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + } else if input.get(at..).is_some_and(|rest| rest.starts_with(b"?>")) { + return at + 2; + } + at += 1; + } + input.len() +} + +fn doctype_end(input: &[u8], mut at: usize) -> (usize, bool) { + let mut quote = None; + let mut subset = false; + while let Some(&byte) = input.get(at) { + if let Some(q) = quote { + if byte == q { + quote = None; + } + } else if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + } else if byte == b'[' { + subset = true; + } else if byte == b'>' { + return (at + 1, subset); + } + at += 1; + } + (input.len(), subset) +} + +fn after_markup(input: &[u8], mut at: usize) -> usize { + let mut quote = None; + while let Some(&byte) = input.get(at) { + if let Some(q) = quote { + if byte == q { + quote = None; + } + } else if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + } else if byte == b'>' { + return at + 1; + } + at += 1; + } + input.len() +} + /// One step of a document. See [`Options::events`]. #[derive(Clone, Debug, PartialEq)] pub enum Event {