mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Merge pull request #1182 from storytold/codex/photocraft-file-caps
Add shared format capability catalog
This commit is contained in:
23 files changed
+2176
-106
No files matched your search
Generated
+43
-2
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
+396
-43
@@ -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<craft_image_codecs::Format> {
|
||||
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<Format> {
|
||||
// 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<Format> {
|
||||
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<Format> {
|
||||
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<Format> {
|
||||
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];
|
||||
|
||||
@@ -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"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"), 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);
|
||||
}
|
||||
@@ -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<u16> {
|
||||
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()),
|
||||
}
|
||||
|
||||
@@ -199,6 +199,12 @@ impl<R: Read + Seek> Decoder<R> {
|
||||
}
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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<usize> = (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);
|
||||
|
||||
@@ -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<u32> {
|
||||
let v = number(image, a, 0.0)?;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
@@ -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<u8>,
|
||||
pub icc: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
}
|
||||
|
||||
/// 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<Image>,
|
||||
pub rect: (f64, f64, f64, f64),
|
||||
pub marks: Marks,
|
||||
pub description: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub page_label: Option<String>,
|
||||
pub plates: Vec<SpotPlate>,
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
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<PathElement>,
|
||||
pub fill: Option<(DeviceColor, ContentFillRule)>,
|
||||
pub stroke: Option<ContentStroke>,
|
||||
}
|
||||
|
||||
/// 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<ContentOp>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<Vec<u8>> {
|
||||
let encoded: Option<Vec<String>> = 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<String> {
|
||||
if plates.is_empty() {
|
||||
return doc_xmp.map(str::to_owned);
|
||||
}
|
||||
let mut description = format!(
|
||||
" <rdf:Description rdf:about=\"\" xmlns:rdf=\"{RDF_NAMESPACE}\" xmlns:pcsp=\"{SPOT_PLATES_NAMESPACE}\">\n <pcsp:plates>\n <rdf:Seq>\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!(
|
||||
" <rdf:li rdf:parseType=\"Resource\">\n <pcsp:index>{index}</pcsp:index>\n <pcsp:name>{}</pcsp:name>\n <pcsp:labL>{:.2}</pcsp:labL>\n <pcsp:labA>{:.2}</pcsp:labA>\n <pcsp:labB>{:.2}</pcsp:labB>\n <pcsp:srgb>#{hex}</pcsp:srgb>\n <pcsp:solidity>{}</pcsp:solidity>\n </rdf:li>\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(" </rdf:Seq>\n </pcsp:plates>\n </rdf:Description>\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(" </");
|
||||
out.push_str(name);
|
||||
out.push('>');
|
||||
}
|
||||
None => out.push_str(&description),
|
||||
}
|
||||
out.push_str(tail);
|
||||
return Some(out);
|
||||
}
|
||||
Some(format!(
|
||||
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"{RDF_NAMESPACE}\">\n{description} </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>"
|
||||
))
|
||||
}
|
||||
|
||||
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("</")?, None))
|
||||
}
|
||||
|
||||
/// A one-byte Unicode map for invisible, selectable text layered over vector outlines.
|
||||
#[derive(Default)]
|
||||
pub struct SelectionFont {
|
||||
chars: BTreeSet<char>,
|
||||
}
|
||||
|
||||
impl SelectionFont {
|
||||
/// Collect distinct characters until the one-byte code space is exceeded.
|
||||
pub fn new<'a>(texts: impl Iterator<Item = &'a str>) -> 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<String> {
|
||||
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<Vec<u8>> {
|
||||
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> <FF>\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::<String>();
|
||||
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("<FEFF");
|
||||
for u in s.encode_utf16() {
|
||||
o.push_str(&format!("{u:04X}"));
|
||||
}
|
||||
o.push('>');
|
||||
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<u8> {
|
||||
// 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<u8> {
|
||||
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<u8> {
|
||||
if pages.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
||||
let mut offsets: Vec<usize> = Vec::new();
|
||||
let mut obj = |out: &mut Vec<u8>, 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<usize>,
|
||||
plates: Vec<usize>,
|
||||
}
|
||||
// Field initialisers run in this order.
|
||||
let page_objects: Vec<PageObjects> = 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::<Vec<_>>().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::<Vec<_>>()
|
||||
.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
|
||||
}
|
||||
@@ -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<u16> {
|
||||
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<RawImage> {
|
||||
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()))?;
|
||||
|
||||
@@ -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"))),
|
||||
};
|
||||
|
||||
@@ -638,7 +638,15 @@ pub fn probe_info(bytes: &[u8]) -> Result<RawInfo> {
|
||||
|
||||
/// Orientation of a supported raw, read through the same format parser as [`probe_info`].
|
||||
pub fn orientation(bytes: &[u8]) -> Option<u16> {
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")]
|
||||
|
||||
+233
-30
@@ -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 `<use>`, 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<Option<(f64, f64)>, 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::<f64>().ok()).zip(root.attribute("height").and_then(|value| value.parse::<f64>().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<roxmltree::Document<'s>, Error> {
|
||||
let preflight_limits = crate::preflight::Limits {
|
||||
@@ -142,8 +171,8 @@ fn checked<'s>(svg: &'s str, limits: &Limits) -> Result<roxmltree::Document<'s>,
|
||||
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<Option<usize
|
||||
struct Used {
|
||||
bytes: AtomicUsize,
|
||||
depth: AtomicUsize,
|
||||
xml_depth: AtomicUsize,
|
||||
svg_count: AtomicUsize,
|
||||
svg_depth: AtomicUsize,
|
||||
raster_pixels: AtomicU64,
|
||||
raster_data: Mutex<HashSet<usize>>,
|
||||
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<Used>,
|
||||
}
|
||||
|
||||
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<craft_image_codecs::Format> {
|
||||
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<Vec<u8>>, format: craft_image_codecs::Format) -> Option<usvg::ImageKind> {
|
||||
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<usvg::ImageKind> {
|
||||
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 `<image>` 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<Used>) -> 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),
|
||||
}
|
||||
|
||||
@@ -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<Node>,
|
||||
/// 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<Box<usvg::Tree>>,
|
||||
}
|
||||
|
||||
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<usize>);
|
||||
|
||||
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<Node>, opacity: f32, transform: [f64; 6], bounds: [f64; 4], features: GroupFeatures, source_path: NodePath },
|
||||
Path(Box<Path>),
|
||||
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<std::sync::Arc<Vec<u8>>>,
|
||||
pub svg: Option<Box<Document>>,
|
||||
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<Segment>,
|
||||
pub fill: Option<Paint>,
|
||||
pub stroke: Option<Stroke>,
|
||||
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<GradientStop>, 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<Vec<f64>>,
|
||||
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<bool, Refusal> {
|
||||
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<Node> {
|
||||
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
|
||||
}
|
||||
+116
-1
@@ -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<tiny_skia::Pixmap, Refusal> {
|
||||
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<bool, Refusal> {
|
||||
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(()) }
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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"<!--") {
|
||||
at = after(input, at + 4, b"-->");
|
||||
} else if rest.starts_with(b"<![CDATA[") {
|
||||
at = after(input, at + 9, b"]]>");
|
||||
} else if rest.starts_with(b"<?") {
|
||||
at = after_instruction(input, at + 2);
|
||||
} else if rest.starts_with(b"<!DOCTYPE") && rest.get(9).is_some_and(|b| b.is_ascii_whitespace()) {
|
||||
if policy == Doctype::Reject {
|
||||
return true;
|
||||
}
|
||||
let (end, subset) = doctype_end(input, at + 9);
|
||||
if subset {
|
||||
return true;
|
||||
}
|
||||
at = end;
|
||||
} else {
|
||||
at = after_markup(input, at + 1);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn after(input: &[u8], start: usize, terminator: &[u8]) -> 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 {
|
||||
|
||||
Reference in new issue
Block a user