mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
security(io): charge sparse layers, shape caches and shared decodes (#8955)
* security(io): charge sparse layers, shape caches and shared decodes ORA and PSD export refuse dense boxes past a cap; PSD import and ABR mapping decode shared masks/tips once; Affinity and SVG shape caches are charged to the import budget; SVG embedded JPEG size is probed; Krita tiles are charged for the backing tiles they land in. * fix(io): clippy range pattern; align the sparse Krita test tiles to the backing grid * io: address review - offset-aware KRA tile budget, saturating stroke reach, OpenRaster entry limit, PSD retained-surface budget, halved PSD gather cap
This commit is contained in:
@@ -57,7 +57,7 @@ pub fn map_file(f: &AbrFile, group: &str) -> AbrImport {
|
||||
/// [`map_file`] that checks `ctl` before each brush.
|
||||
pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrupt) -> Result<AbrImport, photocraft_raster::Cancelled> {
|
||||
let mut out = AbrImport { version: f.version, warnings: f.warnings.clone(), ..Default::default() };
|
||||
let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new() };
|
||||
let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new(), tips: Default::default() };
|
||||
let total = f.legacy.len().max(f.samples.len()).max(f.presets.len()).max(1) as f32;
|
||||
let step = |i: usize| -> Result<(), photocraft_raster::Cancelled> {
|
||||
ctl.check()?;
|
||||
@@ -255,18 +255,26 @@ struct Mapper<'a> {
|
||||
file: &'a AbrFile,
|
||||
warnings: BTreeSet<String>,
|
||||
unknown: BTreeSet<String>,
|
||||
/// Tips already converted, by sample: presets can name one sample thousands of times, and a
|
||||
/// tile clones by sharing its data.
|
||||
tips: std::collections::HashMap<*const AbrSample, (photocraft_paint::tile::GrayTile, f32)>,
|
||||
}
|
||||
|
||||
impl Mapper<'_> {
|
||||
/// A sampled tip (downsampled to [`MAX_TIP_EDGE`]) and its size (larger side).
|
||||
fn tip(&mut self, s: &AbrSample) -> (TipShape, f32) {
|
||||
if let Some((tile, size)) = self.tips.get(&std::ptr::from_ref(s)) {
|
||||
return (TipShape::Sampled(tile.clone()), *size);
|
||||
}
|
||||
let v = s.to_unit();
|
||||
let (w, h, v) = fit(s.width, s.height, v, MAX_TIP_EDGE);
|
||||
if (w, h) != (s.width, s.height) {
|
||||
self.warnings.insert(format!("tips larger than {MAX_TIP_EDGE} px were downsampled"));
|
||||
}
|
||||
let data = v.iter().map(|x| (x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect();
|
||||
(TipShape::Sampled(GrayTile { width: w, height: h, data }), s.width.max(s.height) as f32)
|
||||
let (tile, size) = (GrayTile { width: w, height: h, data }, s.width.max(s.height) as f32);
|
||||
self.tips.insert(std::ptr::from_ref(s), (tile.clone(), size));
|
||||
(TipShape::Sampled(tile), size)
|
||||
}
|
||||
|
||||
fn dynamic(&mut self, d: Option<&Descriptor>) -> Dynamic {
|
||||
|
||||
@@ -443,7 +443,17 @@ impl Builder {
|
||||
})
|
||||
});
|
||||
let mut sh = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() };
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
|
||||
// The pixel cache is optional (the shape renders from its path without it), so charge the
|
||||
// area it would cover to the import's pixel budget and go without it when that is spent:
|
||||
// geometry across a 300000 px canvas would otherwise be rasterized for every shape.
|
||||
let reach = sh.stroke.as_ref().map_or(0, |s| (s.width.ceil() as i32).saturating_add(1));
|
||||
let area = frame.inflate(reach).intersect(&self.canvas);
|
||||
let cost = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.format.bytes_per_pixel() as u64);
|
||||
if charge(&mut self.pixel_budget, cost) {
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
|
||||
} else {
|
||||
self.warn("shapes covering more pixels than the import's memory allows were left without a pixel cache");
|
||||
}
|
||||
sh
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ impl Reader<'_, '_> {
|
||||
if file.pixel_size != space.pixel_size() {
|
||||
return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size)));
|
||||
}
|
||||
let cost = file.decoded_bytes(fmt.bytes_per_pixel())?;
|
||||
let cost = file.decoded_bytes(fmt.bytes_per_pixel(), dx, dy)?;
|
||||
self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?;
|
||||
let mut surface = match self.default_pixel(name, space)? {
|
||||
Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)),
|
||||
|
||||
@@ -216,9 +216,27 @@ impl<'a> TileFile<'a> {
|
||||
Ok(TileFile { width, height, pixel_size, tiles })
|
||||
}
|
||||
|
||||
/// Bytes the decoded tiles take at `bpp` bytes per pixel.
|
||||
pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result<usize> {
|
||||
self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow"))
|
||||
/// Bytes the decoded tiles take at `bpp` bytes per pixel: the larger of their own pixels and
|
||||
/// the whole 256×256 backing tiles they land in (a surface stores no less than a tile, so
|
||||
/// sparse tiles at distinct positions retain far more than they hold).
|
||||
pub(crate) fn decoded_bytes(&self, bpp: usize, dx: i32, dy: i32) -> Result<usize> {
|
||||
let overflow = || invalid("tile size overflow");
|
||||
let logical = self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?;
|
||||
let side = photocraft_geom::TILE_SIZE;
|
||||
let (w, h) = (i32::try_from(self.width.max(1)).map_err(|_| overflow())?, i32::try_from(self.height.max(1)).map_err(|_| overflow())?);
|
||||
let mut backing = std::collections::HashSet::new();
|
||||
for &(x, y, _) in &self.tiles {
|
||||
// The tile lands at its offset position and covers every backing tile its box overlaps.
|
||||
let (x, y) = (x.checked_add(dx).ok_or_else(overflow)?, y.checked_add(dy).ok_or_else(overflow)?);
|
||||
let (x1, y1) = (x.checked_add(w - 1).ok_or_else(overflow)?, y.checked_add(h - 1).ok_or_else(overflow)?);
|
||||
for c in x.div_euclid(side)..=x1.div_euclid(side) {
|
||||
for r in y.div_euclid(side)..=y1.div_euclid(side) {
|
||||
backing.insert((c, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
let tile_bytes = (side as usize).checked_mul(side as usize).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?;
|
||||
Ok(logical.max(backing.len().checked_mul(tile_bytes).ok_or_else(overflow)?))
|
||||
}
|
||||
|
||||
/// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`).
|
||||
@@ -260,3 +278,31 @@ impl<'a> TileFile<'a> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn file(positions: &[(i32, i32)]) -> TileFile<'static> {
|
||||
TileFile { width: 64, height: 64, pixel_size: 4, tiles: positions.iter().map(|&(x, y)| (x, y, &[][..])).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_tiles_are_charged_for_the_backing_tiles_they_land_in() {
|
||||
// Sixteen 64×64 tiles that fill one 256×256 backing tile cost what they hold.
|
||||
let dense: Vec<_> = (0..4).flat_map(|y| (0..4).map(move |x| (x * 64, y * 64))).collect();
|
||||
assert_eq!(file(&dense).decoded_bytes(4, 0, 0).unwrap(), 16 * 64 * 64 * 4);
|
||||
// The same number far apart each take a whole backing tile: 16 × 256 × 256 × 4.
|
||||
let sparse: Vec<_> = (0..16).map(|i| (i * 10_240, 0)).collect();
|
||||
assert_eq!(file(&sparse).decoded_bytes(4, 0, 0).unwrap(), 16 * 256 * 256 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_placement_offset_and_tile_extent_decide_the_backing_tiles() {
|
||||
// One 64×64 tile straddling a backing-tile corner once offset touches four.
|
||||
assert_eq!(file(&[(0, 0)]).decoded_bytes(4, 224, 224).unwrap(), 4 * 256 * 256 * 4);
|
||||
// A tile wider than a backing tile reaches the ones between its corners.
|
||||
let wide = TileFile { width: 1024, height: 1, pixel_size: 4, tiles: vec![(0, 0, &[][..])] };
|
||||
assert_eq!(wide.decoded_bytes(4, 0, 0).unwrap(), 4 * 256 * 256 * 4);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-3
@@ -520,6 +520,20 @@ fn png(w: u32, h: u32, px: &[[f32; 4]], sample: CSample) -> Result<Vec<u8>> {
|
||||
|
||||
/// `px` (row-major over `rect`) cut down to the pixels that aren't fully transparent, with
|
||||
/// transparent pixels zeroed, so masked-out or empty margins aren't written.
|
||||
/// Most bytes of straight RGBA floats one layer may take while it is written. A layer is one PNG
|
||||
/// of its content's bounding box, so a few pixels far apart make a box of mostly nothing; refuse
|
||||
/// that rather than allocate it. 1.5 × [`MAX_ENTRY`] of floats is at most 8 B/px × a third of that
|
||||
/// raw, so the PNG stays within what [`Reader::png`] will read back.
|
||||
const MAX_EXPORT_LAYER_BYTES: u64 = 3 * MAX_ENTRY as u64 / 2;
|
||||
|
||||
fn admit_layer(rect: Rect) -> Result<()> {
|
||||
let bytes = u64::from(rect.width()).saturating_mul(u64::from(rect.height())).saturating_mul(std::mem::size_of::<[f32; 4]>() as u64);
|
||||
if bytes > MAX_EXPORT_LAYER_BYTES {
|
||||
return Err(IoError::Unsupported(format!("a {}×{} layer is too large to save to OpenRaster", rect.width(), rect.height())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn crop(rect: Rect, mut px: Vec<[f32; 4]>) -> (Rect, Vec<[f32; 4]>) {
|
||||
let w = rect.width() as usize;
|
||||
if w == 0 {
|
||||
@@ -609,11 +623,12 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
/// The layer's own pixels (straight RGBA floats) and their bounds; its pixel mask applied.
|
||||
fn pixels(&mut self, layer: &Layer) -> (Rect, Vec<[f32; 4]>) {
|
||||
fn pixels(&mut self, layer: &Layer) -> Result<(Rect, Vec<[f32; 4]>)> {
|
||||
let canvas = self.doc.bounds();
|
||||
let (rect, mut buf) = match &layer.content {
|
||||
LayerContent::Raster(s) => {
|
||||
let r = s.content_bounds();
|
||||
admit_layer(r)?;
|
||||
(r, photocraft_compose::surface_to_buffer(s, r).px)
|
||||
}
|
||||
_ => {
|
||||
@@ -627,6 +642,7 @@ impl Writer<'_> {
|
||||
solo.mask = None;
|
||||
solo.effects = Default::default();
|
||||
let r = photocraft_compose::composite_bounds(&solo, canvas).unwrap_or(canvas).intersect(&canvas);
|
||||
admit_layer(r)?;
|
||||
(r, photocraft_compose::render_layer(&solo, r).px)
|
||||
}
|
||||
};
|
||||
@@ -642,7 +658,7 @@ impl Writer<'_> {
|
||||
p[3] *= layer.fill_opacity.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
crop(rect, buf)
|
||||
Ok(crop(rect, buf))
|
||||
}
|
||||
|
||||
fn layers(&mut self, layers: &[Layer], depth: usize) -> Result<()> {
|
||||
@@ -671,7 +687,7 @@ impl Writer<'_> {
|
||||
if self.dry {
|
||||
continue;
|
||||
}
|
||||
let (rect, px) = self.pixels(layer);
|
||||
let (rect, px) = self.pixels(layer)?;
|
||||
let (rect, px) = if rect.is_empty() { (Rect::from_xywh(0, 0, 1, 1), vec![[0.0; 4]]) } else { (rect, px) };
|
||||
let src = format!("data/layer{}.png", self.next);
|
||||
self.next += 1;
|
||||
|
||||
@@ -21,6 +21,10 @@ use crate::psd_import::REGENERATED;
|
||||
const RASTER_STALE_KEYS: [&[u8; 4]; 8] = [b"SoCo", b"GdFl", b"PtFl", b"SoLd", b"PlLd", b"SoLE", b"TySh", b"vscg"];
|
||||
|
||||
const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
|
||||
/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and
|
||||
/// the channel planes made from them (or the band parts and the joined buffer) are alive together,
|
||||
/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`].
|
||||
const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 };
|
||||
const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024;
|
||||
const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024;
|
||||
|
||||
@@ -382,6 +386,13 @@ impl Ex {
|
||||
if r.is_empty() {
|
||||
return (PsdRect::default(), self.empty_channels(), warnings);
|
||||
}
|
||||
// A layer record is one dense rectangle, so a few pixels far apart make a box of mostly
|
||||
// nothing, gathered and then copied again. Past the cap the layer is saved empty, said so.
|
||||
let dense = u64::from(r.width()).saturating_mul(u64::from(r.height())).saturating_mul(s.format().bytes_per_pixel() as u64);
|
||||
if dense > MAX_LAYER_GATHER_BYTES {
|
||||
warnings.push(format!("layer \"{name}\" spans {}×{} px, too large to write: saved without its pixels", r.width(), r.height()));
|
||||
return (PsdRect::default(), self.empty_channels(), warnings);
|
||||
}
|
||||
// The interleaved gather is the sequential pass for full-canvas layers, so it runs
|
||||
// in row bands on all cores and the band results concatenate in order.
|
||||
let row_len = r.width() as usize;
|
||||
|
||||
@@ -59,6 +59,13 @@ pub(crate) struct Ctx<'a> {
|
||||
pub txt2: Option<photocraft_text::engine_data::Value>,
|
||||
/// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id).
|
||||
pub filter_effects: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
|
||||
/// Filter masks already decoded, by placed id: every layer of a source reads the same item, and
|
||||
/// a surface clones by sharing its tiles.
|
||||
pub filter_masks: std::collections::HashMap<String, Option<LayerMask>>,
|
||||
/// The same for the unfiltered pixels the mask mixes back in.
|
||||
pub unfiltered: std::collections::HashMap<String, Option<Surface>>,
|
||||
/// Pixel bytes retained in [`Self::unfiltered`], held under [`MAX_UNFILTERED_BYTES`].
|
||||
pub unfiltered_bytes: u64,
|
||||
/// Placed layers naming the same source share editable contents, not their placement.
|
||||
pub smart_contents: std::collections::HashMap<String, SmartContentsId>,
|
||||
/// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file).
|
||||
@@ -278,14 +285,19 @@ impl Ctx<'_> {
|
||||
/// document coordinates, white beyond its bounds (`filterMaskExtendWithWhite`). An all-white
|
||||
/// mask is no mask.
|
||||
fn filter_mask(&mut self, placed: &str, stack: &crate::smart_map::FilterStack, name: &str) -> Option<LayerMask> {
|
||||
if let Some(done) = self.filter_masks.get(placed) {
|
||||
return done.clone();
|
||||
}
|
||||
let item = self.filter_effects.iter().find(|i| i.id == placed)?;
|
||||
match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) {
|
||||
let mask = match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
self.warn(format!("layer \"{name}\": {e}; the filter mask was ignored"));
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
self.filter_masks.insert(placed.to_string(), mask.clone());
|
||||
mask
|
||||
}
|
||||
|
||||
fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option<photocraft_psd::BlendMode>) {
|
||||
@@ -459,7 +471,24 @@ impl Ctx<'_> {
|
||||
let shown = stack.enabled && stack.filters.iter().any(|f| f.visible);
|
||||
let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) {
|
||||
(Some(p), Some(m)) => {
|
||||
let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt));
|
||||
let unfiltered = match self.unfiltered.get(&p.placed) {
|
||||
Some(done) => done.clone(),
|
||||
None => {
|
||||
// Every distinct source is kept until the import ends, so what they add up to is
|
||||
// bounded: past the limit the layer keeps its stored pixels, unmixed.
|
||||
let item = self.filter_effects.iter().find(|i| i.id == p.placed);
|
||||
let cost = item
|
||||
.and_then(|i| i.rect.size().ok())
|
||||
.map_or(0, |(w, h)| (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.bytes_per_pixel() as u64));
|
||||
let fits = self.unfiltered_bytes.saturating_add(cost) <= MAX_UNFILTERED_BYTES;
|
||||
let made = item.filter(|_| fits).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt));
|
||||
if made.is_some() {
|
||||
self.unfiltered_bytes += cost;
|
||||
}
|
||||
self.unfiltered.insert(p.placed.clone(), made.clone());
|
||||
made
|
||||
}
|
||||
};
|
||||
match unfiltered {
|
||||
Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m),
|
||||
None => stored,
|
||||
@@ -660,6 +689,9 @@ pub(crate) fn group_depth(file: &PsdFile) -> usize {
|
||||
/// Additional-layer-information keys (Adobe PSD spec, plus `cust`, `vowv` and `SoLE` seen in
|
||||
/// Photoshop's files) that are not an adjustment of their own: names, ids, locks, effects,
|
||||
/// masks, metadata, and the fill, type, placed and shape content read before.
|
||||
/// Most pixel bytes the smart-filter `FEid` sources may keep decoded across one import.
|
||||
const MAX_UNFILTERED_BYTES: u64 = 1 << 30;
|
||||
|
||||
const LAYER_INFO_KEYS: [&[u8; 4]; 50] = [
|
||||
b"luni", b"lyid", b"lnsr", b"lsct", b"lsdk", b"lspf", b"lclr", b"iOpa", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"brst", b"shmd", b"fxrp",
|
||||
b"lfx2", b"lfxs", b"lmfx", b"lrFX", b"lyvr", b"sn2P", b"cust", b"CgEd", b"vmsk", b"vsms", b"vogk", b"vowv", b"vstk", b"vscg", b"artb", b"artd", b"abdd",
|
||||
@@ -975,6 +1007,9 @@ pub(crate) fn psd_to_document_decoded_integrity(
|
||||
dpi: doc.resolution_dpi,
|
||||
txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)),
|
||||
filter_effects: Vec::new(),
|
||||
filter_masks: Default::default(),
|
||||
unfiltered: Default::default(),
|
||||
unfiltered_bytes: 0,
|
||||
smart_contents: Default::default(),
|
||||
plld_sizes: Default::default(),
|
||||
ctl: *ctl,
|
||||
|
||||
+68
-3
@@ -54,15 +54,42 @@ fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
|
||||
/// Returns `Some(Some((w, h)))` when the format is PNG/WebP and its header parses cleanly,
|
||||
/// `Some(None)` when the format is PNG/WebP (by MIME or magic bytes under `"text/plain"`)
|
||||
/// and its header is malformed, or `None` for other formats.
|
||||
/// A JPEG's size from its frame header (the first SOF marker), without decoding anything.
|
||||
fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
|
||||
let mut i = 2;
|
||||
loop {
|
||||
if *data.get(i)? != 0xFF {
|
||||
return None;
|
||||
}
|
||||
let marker = *data.get(i + 1)?;
|
||||
match marker {
|
||||
0xFF => i += 1,
|
||||
// SOF0..SOF15, but for DHT (C4), JPG (C8) and DAC (CC).
|
||||
0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) => {
|
||||
let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]);
|
||||
let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]);
|
||||
return Some((u32::from(w), u32::from(h)));
|
||||
}
|
||||
// Start of scan or end of image before any frame header.
|
||||
0xD8..=0xDA => return None,
|
||||
0x01 | 0xD0..=0xD7 => i += 2,
|
||||
_ => i += 2 + usize::from(u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option<Option<(u32, u32)>> {
|
||||
match mime {
|
||||
"image/png" => Some(png_dimensions(data)),
|
||||
"image/webp" => Some(photocraft_codecs::probe_size(data).ok()),
|
||||
"image/webp" | "image/gif" => Some(photocraft_codecs::probe_size(data).ok()),
|
||||
"image/jpeg" | "image/jpg" => Some(jpeg_dimensions(data)),
|
||||
"text/plain" => {
|
||||
if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
Some(png_dimensions(data))
|
||||
} else if data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP") {
|
||||
} else if (data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP")) || data.starts_with(b"GIF8") {
|
||||
Some(photocraft_codecs::probe_size(data).ok())
|
||||
} else if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
Some(jpeg_dimensions(data))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -672,7 +699,25 @@ impl Conv {
|
||||
_ => FillRule::NonZero,
|
||||
};
|
||||
let mut sh = ShapeLayer { path: Path { subpaths, fill_rule, inverted: false }, fill, stroke, ..Default::default() };
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas));
|
||||
// The pixel cache is optional, so it is charged like any other retained raster and left
|
||||
// out (the shape renders from its path) once the budget is spent: compact geometry can
|
||||
// cover the whole canvas in every one of thousands of shapes.
|
||||
let b = data.bounds();
|
||||
let reach = sh.stroke.as_ref().map_or(0.0, |s| s.width);
|
||||
let area = Rect::new(
|
||||
(b.left() - reach).floor() as i32,
|
||||
(b.top() - reach).floor() as i32,
|
||||
(b.right() + reach).ceil() as i32,
|
||||
(b.bottom() + reach).ceil() as i32,
|
||||
)
|
||||
.intersect(&self.canvas);
|
||||
let pixels = u64::from(area.width()) * u64::from(area.height());
|
||||
if self.can_retain_pixels(pixels) {
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas));
|
||||
let bytes = pixels * self.fmt.bytes_per_pixel() as u64;
|
||||
self.pixel_budget -= bytes;
|
||||
self.retained_pixel_bytes += bytes;
|
||||
}
|
||||
Some(Layer::new(self.name(p.id(), 0), LayerContent::Shape(sh)))
|
||||
}
|
||||
|
||||
@@ -1153,6 +1198,26 @@ mod tests {
|
||||
assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_dimensions_come_from_the_frame_header_and_oversized_jpegs_are_refused() {
|
||||
// SOI, an APP0 segment of length 4, then SOF0 (length 11): 8 bits, height 40000, width 30000.
|
||||
let jpeg = |h: u16, w: u16| {
|
||||
let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8];
|
||||
v.extend(h.to_be_bytes());
|
||||
v.extend(w.to_be_bytes());
|
||||
v.extend([1, 1, 0x11, 0]);
|
||||
v
|
||||
};
|
||||
assert_eq!(jpeg_dimensions(&jpeg(40_000, 30_000)), Some((30_000, 40_000)));
|
||||
assert_eq!(jpeg_dimensions(&[0xFF, 0xD8, 0xFF, 0xDA, 0, 2]), None);
|
||||
assert_eq!(jpeg_dimensions(&[0xFF, 0xD8]), None);
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()));
|
||||
let resolve = &opt.image_href_resolver.resolve_data;
|
||||
// 1.2 billion pixels would be 4.8 GB of RGBA before resvg scaled anything down.
|
||||
assert!(resolve("image/jpeg", Arc::new(jpeg(40_000, 30_000)), &opt).is_none());
|
||||
assert!(resolve("text/plain", Arc::new(jpeg(40_000, 30_000)), &opt).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() {
|
||||
use std::io::Write;
|
||||
|
||||
Reference in New Issue
Block a user