mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 23:35:10 +00:00
An image's own fill or stroke with Outline Object now follows the outline of the image's pixels that are at least 50% opaque, traced from the alpha and fitted with curves, instead of the image's frame. An image without transparency, one whose pixels can't be read, one of more than 64 megapixels or whose decoded pixels take more than 256 MB, and one whose outline would need more than 100,000 anchor points keep the frame. An image with no pixel at least half opaque gives nothing to paint. Images of more than 4 megapixels are traced from their alpha averaged down to 4 megapixels, and an averaged pixel that covers a pixel at least half opaque stays at least half opaque, so thin opaque lines stay in the outline. Each outline is traced once for its pixels, kept for the next redraw while those pixels are loaded, and dropped after them; a second thread that needs the same outline waits for the trace. - vectorcraft-trace: `decode_alpha` reads an image's alpha, and `opaque_outline` traces it as Black and White mode at threshold 128. - vectorcraft-effects: the reshaped image art uses it for rows whose first effect is Outline Object; Convert to Shape rows keep the frame. - Docs: docs/mcp.md, the Outline Object params text and an evidence line in docs/target-app-parity.md. docs/mcp.md also says that Use Preview Bounds takes in the shapes an image's fills and strokes paint.
181 lines
8.4 KiB
Rust
181 lines
8.4 KiB
Rust
/// Errors decoding a raster.
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum TraceError {
|
||
#[error("could not decode image: {0}")]
|
||
Decode(String),
|
||
#[error("image is empty")]
|
||
Empty,
|
||
#[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")]
|
||
TooLarge { width: u32, height: u32, max: u64 },
|
||
#[error("this isn't a flat-colour logo ({:.0}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead", (.fraction * 100.0).floor())]
|
||
NotFlat { fraction: f64 },
|
||
#[error("\"{0}\" is not a colour: write it as #rrggbb")]
|
||
BadPalette(String),
|
||
#[error(
|
||
"this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset"
|
||
)]
|
||
TooComplex { max: usize },
|
||
/// The caller of [`crate::trace_watched`] stopped the trace.
|
||
#[error("the trace was cancelled")]
|
||
Cancelled,
|
||
}
|
||
|
||
/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are
|
||
/// refused instead of running out of memory.
|
||
pub const MAX_PIXELS: u64 = 64 << 20;
|
||
|
||
/// The most pixels an image may have for [`Raster::decode_to_fit`] to decode it and scale it down
|
||
/// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused.
|
||
pub const MAX_SOURCE_PIXELS: u64 = 192 << 20;
|
||
|
||
/// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1).
|
||
pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) {
|
||
let pixels = u64::from(w) * u64::from(h);
|
||
if pixels <= max || pixels == 0 {
|
||
return (w, h);
|
||
}
|
||
let k = (max as f64 / pixels as f64).sqrt();
|
||
let side = |v: u32| ((f64::from(v) * k).floor() as u32).clamp(1, v);
|
||
let (mut nw, mut nh) = (side(w), side(h));
|
||
// A side held at 1 pixel leaves the other too long: cut it to what fits.
|
||
if u64::from(nw) * u64::from(nh) > max {
|
||
if nw >= nh {
|
||
nw = u32::try_from(max / u64::from(nh)).unwrap_or(nw).clamp(1, nw);
|
||
} else {
|
||
nh = u32::try_from(max / u64::from(nw)).unwrap_or(nh).clamp(1, nh);
|
||
}
|
||
}
|
||
(nw, nh)
|
||
}
|
||
|
||
/// An RGBA8 raster (row-major, top row first).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Raster {
|
||
pub width: u32,
|
||
pub height: u32,
|
||
pub rgba: Vec<u8>,
|
||
}
|
||
|
||
/// The pixels the first frame of GIF `bytes` declares in its image descriptor, which the decoder
|
||
/// allocates for before it crops the frame to the logical screen the dimension check reads. `None`
|
||
/// when the bytes aren't a GIF or hold no frame header.
|
||
pub(crate) fn gif_first_frame_pixels(bytes: &[u8]) -> Option<u64> {
|
||
if !bytes.starts_with(b"GIF8") {
|
||
return None;
|
||
}
|
||
let flags = *bytes.get(10)?;
|
||
let mut at = 13 + if flags & 0x80 != 0 { 3 << ((flags & 7) + 1) } else { 0 };
|
||
loop {
|
||
match *bytes.get(at)? {
|
||
// An extension: its data sub-blocks end at a zero length.
|
||
0x21 => {
|
||
at += 2;
|
||
loop {
|
||
let len = usize::from(*bytes.get(at)?);
|
||
at += 1 + len;
|
||
if len == 0 {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
0x2C => {
|
||
let d = bytes.get(at + 5..at + 9)?;
|
||
return Some(u64::from(u16::from_le_bytes([d[0], d[1]])) * u64::from(u16::from_le_bytes([d[2], d[3]])));
|
||
}
|
||
_ => return None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Raster {
|
||
/// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other
|
||
/// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not
|
||
/// a crash. (That error's text is already translated; its detail names the sizes.)
|
||
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, TraceError> {
|
||
let want = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
|
||
if want != Some(rgba.len()) {
|
||
return Err(TraceError::Decode(format!("{} bytes of RGBA data for a {width} × {height} image", rgba.len())));
|
||
}
|
||
Ok(Self { width, height, rgba })
|
||
}
|
||
/// A raster whose pixel `(x, y)` is `f(x, y)`.
|
||
pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self {
|
||
let mut rgba = Vec::with_capacity(width as usize * height as usize * 4);
|
||
for y in 0..height {
|
||
for x in 0..width {
|
||
rgba.extend_from_slice(&f(x, y));
|
||
}
|
||
}
|
||
Self { width, height, rgba }
|
||
}
|
||
/// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding).
|
||
pub fn decode(bytes: &[u8]) -> Result<Self, TraceError> {
|
||
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
|
||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
|
||
let (w, h) = reader.into_dimensions().map_err(decode_err)?;
|
||
if w == 0 || h == 0 {
|
||
return Err(TraceError::Empty);
|
||
}
|
||
if u64::from(w) * u64::from(h) > MAX_PIXELS || gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_PIXELS) {
|
||
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 });
|
||
}
|
||
let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8();
|
||
let (w, h) = img.dimensions();
|
||
Ok(Self { width: w, height: h, rgba: img.into_raw() })
|
||
}
|
||
/// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to
|
||
/// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within
|
||
/// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly
|
||
/// less than its own resolution. The caller maps the result back with `width` / `height`.
|
||
pub fn decode_to_fit(bytes: &[u8]) -> Result<Self, TraceError> {
|
||
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
|
||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
|
||
let (w, h) = reader.into_dimensions().map_err(decode_err)?;
|
||
if w == 0 || h == 0 {
|
||
return Err(TraceError::Empty);
|
||
}
|
||
let pixels = u64::from(w) * u64::from(h);
|
||
if pixels <= MAX_PIXELS {
|
||
return Self::decode(bytes);
|
||
}
|
||
if pixels > MAX_SOURCE_PIXELS {
|
||
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 });
|
||
}
|
||
// Decoded within the allocation the source limit needs; the web keeps file import's 512 MB.
|
||
// A deeper image (16 bits a channel) that needs more fails with a decode error.
|
||
if gif_first_frame_pixels(bytes).is_some_and(|p| p > MAX_SOURCE_PIXELS) {
|
||
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 });
|
||
}
|
||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
|
||
let mut limits = image::Limits::default();
|
||
limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 });
|
||
reader.limits(limits);
|
||
let img = reader.decode().map_err(decode_err)?.into_rgba8();
|
||
let (w, h) = img.dimensions();
|
||
let (nw, nh) = fit_size(w, h, MAX_PIXELS);
|
||
// Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive.
|
||
let small = image::imageops::thumbnail(&img, nw, nh);
|
||
Ok(Self { width: nw, height: nh, rgba: small.into_raw() })
|
||
}
|
||
/// Encode as PNG.
|
||
pub fn encode_png(&self) -> Vec<u8> {
|
||
let mut out = Vec::new();
|
||
if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) {
|
||
let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png);
|
||
}
|
||
out
|
||
}
|
||
/// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose
|
||
/// fields were set to a size its bytes don't have).
|
||
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
|
||
if x >= self.width || y >= self.height {
|
||
return [0; 4];
|
||
}
|
||
let i = (y as usize * self.width as usize + x as usize) * 4;
|
||
match self.rgba.get(i..i + 4) {
|
||
Some(&[r, g, b, a]) => [r, g, b, a],
|
||
_ => [0; 4],
|
||
}
|
||
}
|
||
}
|