mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
Merge pull request #1517 from fastmikey99/fix/decode-print-size-rasters
Decode print-size rasters in Crop Image trim and the renderer; say when an export is missing an image
This commit is contained in:
@@ -382,7 +382,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
/// [`encode_all`] of `doc` as it is.
|
||||
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
|
||||
// that silently lacks it.
|
||||
if f.raster && vectorcraft_render::decode_failures() > failed {
|
||||
enc.warnings.push("an image couldn't be decoded and is missing from the picture".into());
|
||||
}
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
// themselves: SVG only when it outlines or embeds fonts).
|
||||
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
|
||||
|
||||
@@ -392,7 +392,7 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
|
||||
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod eps;
|
||||
mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod load;
|
||||
pub(crate) use load::MAX_RASTER_ALLOC;
|
||||
mod metafile;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
|
||||
@@ -163,3 +163,20 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
|
||||
assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
|
||||
// the renderer can't decode it and draws nothing, which the export must say.
|
||||
let mut s = session(72.0, 36.0, 1);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let cut = png_chunk(&png, b"IDAT").map(|d| d.as_ptr() as usize - png.as_ptr() as usize).unwrap() + 4;
|
||||
png.truncate(cut);
|
||||
s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
|
||||
}
|
||||
|
||||
@@ -625,7 +625,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
const C: &str = "object.cropImage";
|
||||
let st = s.doc()?;
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8();
|
||||
// A CMYK image (kept as its ink amounts) has no alpha: nothing to cut.
|
||||
if blob.is_cmyk() {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8();
|
||||
let (w, h) = px.dimensions();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (x, y, p) in px.enumerate_pixels() {
|
||||
|
||||
@@ -1826,3 +1826,15 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}");
|
||||
assert_eq!(bounds(&s, img), b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_trim_leaves_a_cmyk_image_alone() {
|
||||
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [255, 0, 0, 0].repeat(8)).unwrap();
|
||||
let tiff = vectorcraft_doc::ImageBlob::cmyk_tiff(&inks).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "inks.tif", "dataBase64": vectorcraft_format::base64_encode(&tiff.bytes)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64(), r["height"].as_u64()), (Some(false), Some(4), Some(2)), "{r}");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ mod ink;
|
||||
mod live;
|
||||
mod lru;
|
||||
mod paint;
|
||||
pub use paint::decode_failures;
|
||||
mod pattern;
|
||||
pub mod placed_document;
|
||||
pub mod proof;
|
||||
|
||||
@@ -52,14 +52,53 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap.
|
||||
/// The most memory decoding one image may take: print rasters run to gigabytes (a 300 ppi roll,
|
||||
/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it.
|
||||
const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// Images that couldn't be decoded (and so drew nothing), counted for exports to report.
|
||||
static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// How many images have failed to decode so far (process-wide): an export compares the count
|
||||
/// before and after to say it left images out.
|
||||
pub fn decode_failures() -> u64 {
|
||||
DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`].
|
||||
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
r.limits(limits);
|
||||
r.decode().ok()
|
||||
});
|
||||
if img.is_none() {
|
||||
DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels).
|
||||
fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let max = u16::MAX as u32;
|
||||
if w <= max && h <= max {
|
||||
return img;
|
||||
}
|
||||
let s = max as f64 / w.max(h) as f64;
|
||||
let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max));
|
||||
img.resize_exact(nw, nh, image::imageops::FilterType::Triangle)
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (shrunk to fit a pixmap's sides).
|
||||
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
|
||||
pixmap_of(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
pixmap_of(fit_pixmap(decode(bytes)?).to_rgba8())
|
||||
}
|
||||
|
||||
/// [`decode_pixmap`] shrunk to `w`×`h` pixels when the image is larger (never enlarged).
|
||||
pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixmap> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let img = decode(bytes)?;
|
||||
let img = if img.width() > w as u32 || img.height() > h as u32 {
|
||||
img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user