mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
Place: an image whose pixels can't be read is refused instead of placed blank
Squashed from #4257 onto main.
This commit is contained in:
@@ -10,8 +10,8 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageObject, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::super::fileio;
|
||||
use super::super::place::pt_per_px;
|
||||
use super::super::{fileio, links};
|
||||
use super::{Clipboard, CommandSpec, Result, Session, always, bad, cmd, f64_or, has_doc, looks_like_svg, point_param, str_param};
|
||||
|
||||
/// Plain text: a type-only copy's text, else the SVG markup (Include SVG Code).
|
||||
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Image into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste",
|
||||
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error",
|
||||
has_doc,
|
||||
import_image
|
||||
),
|
||||
@@ -418,7 +418,9 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(m) = str_param(p, "mime").filter(|m| !m.starts_with("image/") || *m == SVG) {
|
||||
return Err(bad(C, format!("`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText")));
|
||||
}
|
||||
let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?;
|
||||
let data = data_param(p, C)?;
|
||||
let mut img = fileio::raster_image(&data).map_err(|e| bad(C, e.to_string()))?;
|
||||
img.blob = links::readable_and_kept(img.blob, links::kept_unread(&data), false).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let (w, h) = (img.width, img.height);
|
||||
let image = ImageObject {
|
||||
|
||||
@@ -282,16 +282,10 @@ fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
|
||||
|
||||
#[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.
|
||||
// A PNG whose header reads 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();
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
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}");
|
||||
|
||||
@@ -399,14 +399,56 @@ pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec
|
||||
// ---------- reading files into the document ----------
|
||||
|
||||
/// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a
|
||||
/// preview there is kept, and a linked image gets one (the one `full` carries, when it has one).
|
||||
/// preview there is kept, and a linked image gets the one `full` carries ([`readable`] makes it
|
||||
/// when the file is read). Nothing is decoded here.
|
||||
pub(crate) fn store_image(d: &mut Document, key: &str, full: ImageBlob, linked: bool) {
|
||||
let made = full.proxy.clone().filter(|_| linked);
|
||||
let blob = match d.images.get(key) {
|
||||
Some(b) if !b.is_proxy() => b.clone(),
|
||||
Some(b) if !b.is_proxy() => ImageBlob { proxy: b.proxy.clone().or(made), ..b.clone() },
|
||||
old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()).or(made), ..full },
|
||||
};
|
||||
d.images.insert(key.to_string(), if linked { blob.with_proxy() } else { blob });
|
||||
d.images.insert(key.to_string(), blob);
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a PNG, JPEG, GIF or WebP file: the formats [`fileio::raster_image`] keeps
|
||||
/// as they are after reading only their header.
|
||||
pub(crate) fn kept_unread(bytes: &[u8]) -> bool {
|
||||
use image::ImageFormat as F;
|
||||
matches!(image::guess_format(bytes), Ok(F::Png | F::Jpeg | F::Gif | F::WebP))
|
||||
}
|
||||
|
||||
/// `blob`, an image [`fileio::raster_image`] read from a file, ready to keep. When `unread` (the
|
||||
/// file is one [`kept_unread`] names), its pixels are decoded once to check that they can be read,
|
||||
/// and a `linked` image's preview is made from that decode ([`ImageBlob::checked_with_proxy`]);
|
||||
/// otherwise a `linked` image gets its preview ([`ImageBlob::with_proxy`]). An error when the
|
||||
/// pixels can't be read.
|
||||
pub(crate) fn readable(blob: ImageBlob, unread: bool, linked: bool) -> Result<ImageBlob> {
|
||||
if !unread {
|
||||
return Ok(if linked { blob.with_proxy() } else { blob });
|
||||
}
|
||||
blob.checked_with_proxy(linked).ok_or_else(|| EngineError::Other("the image's pixels can't be read".into()))
|
||||
}
|
||||
|
||||
/// [`readable`], and the pixels it decoded are kept in the image store the renderers share
|
||||
/// ([`vectorcraft_render::keep_decoded_image`]), so the first frame that draws the image finds
|
||||
/// them there and does not decode the file again.
|
||||
pub(crate) fn readable_and_kept(blob: ImageBlob, unread: bool, linked: bool) -> Result<ImageBlob> {
|
||||
if !unread {
|
||||
return readable(blob, unread, linked);
|
||||
}
|
||||
let (blob, pixels) = blob.checked_with_pixels(linked).ok_or_else(|| EngineError::Other("the image's pixels can't be read".into()))?;
|
||||
if let Some(pixels) = pixels {
|
||||
vectorcraft_render::keep_decoded_image(&blob, pixels);
|
||||
}
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
/// The linked image file `bytes`, read ([`fileio::raster_image`]) with its preview; an error when
|
||||
/// its pixels can't be read ([`readable`]).
|
||||
fn linked_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let mut img = fileio::raster_image(bytes)?;
|
||||
img.blob = readable(img.blob, kept_unread(bytes), true)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The file read for a link: the link to it and its image.
|
||||
@@ -415,7 +457,7 @@ type FileRead = (LinkInfo, RasterImage);
|
||||
/// The file at `f` read for a link like `base` (its image decoded).
|
||||
fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
|
||||
let bytes = f.read()?;
|
||||
Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?))
|
||||
Ok((f.link(&bytes, base), linked_image(&bytes)?))
|
||||
}
|
||||
|
||||
/// A linked file read for the objects that show it.
|
||||
@@ -553,7 +595,7 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_
|
||||
// the app connect to another computer.
|
||||
let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?;
|
||||
let bytes = read_file(&path)?;
|
||||
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
|
||||
let read = (link_info(&path, &bytes), linked_image(&bytes)?);
|
||||
install(d, &[id], read, Some(Preserve::Bounds))
|
||||
}
|
||||
|
||||
@@ -678,11 +720,11 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
// Read when the images show the preview, or when the file's details changed.
|
||||
let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
|
||||
if preview && !eps_preview {
|
||||
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, fileio::raster_image(b).ok()?))) else {
|
||||
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, linked_image(b).ok()?))) else {
|
||||
out.missing.extend(row);
|
||||
continue;
|
||||
};
|
||||
let blob = img.blob.with_proxy();
|
||||
let blob = img.blob;
|
||||
read_images.insert(f.path.clone(), (blob.clone(), hash_bytes(b)));
|
||||
store_image(d, &g.key, blob, true);
|
||||
} else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) {
|
||||
@@ -935,7 +977,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
// Read before it is copied: a file that can't be shown isn't copied.
|
||||
let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
||||
let img = if images.is_empty() { None } else { Some(linked_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
||||
let mut sources = vec![];
|
||||
for ((page, bounding, page_box), ids) in documents {
|
||||
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?));
|
||||
@@ -1275,8 +1317,10 @@ fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "name": format!("{stem}.{ext}"), "dataBase64": vectorcraft_format::base64_encode(&bytes) }));
|
||||
};
|
||||
let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() });
|
||||
// Read before writing: pixels that can't be read leave no file behind.
|
||||
let img = linked_image(&bytes)?;
|
||||
write_file(&path, &bytes)?;
|
||||
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
|
||||
let read = (link_info(&path, &bytes), img);
|
||||
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds)))?;
|
||||
Ok(json!({ "id": id.0, "path": path }))
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -479,15 +479,17 @@ impl PlaceJob {
|
||||
str_param(&self.params, "path").or_else(|| str_param(&self.params, "name")).map(fileio::file_name).unwrap_or_else(|| "Untitled".into())
|
||||
}
|
||||
|
||||
/// Read and decode the file, as `file.place` does, without the session. A linked image's
|
||||
/// preview is made here too.
|
||||
/// Read and decode the file, as `file.place` does, without the session. The pixels of a PNG,
|
||||
/// JPEG, GIF or WebP file are decoded here to check that they can be read (an error when they
|
||||
/// can't), a linked image's preview is made from that decode, and the decoded pixels are kept in
|
||||
/// the renderers' image store for the first frame that draws them
|
||||
/// ([`super::links::readable_and_kept`]).
|
||||
pub fn read(self) -> Result<PlaceRead> {
|
||||
let mut loaded = load(&self.params, PLACE, self.target, true)?;
|
||||
if loaded.link.is_some()
|
||||
&& bool_or(&self.params, "link", true)
|
||||
&& let Art::Image(img) = &mut loaded.art
|
||||
{
|
||||
img.blob = img.blob.clone().with_proxy();
|
||||
let linked = loaded.link.is_some() && bool_or(&self.params, "link", true);
|
||||
if let Art::Image(img) = &mut loaded.art {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?;
|
||||
}
|
||||
Ok(PlaceRead { job: self, loaded })
|
||||
}
|
||||
@@ -719,8 +721,19 @@ fn read_queue(p: Value, queued: Vec<Value>) -> Result<QueueRead> {
|
||||
q[k] = v.clone();
|
||||
}
|
||||
}
|
||||
// Read for its size and thumbnail: each click places it with file.place.
|
||||
match load(&q, QUEUE, None, false) {
|
||||
// Read for its size and thumbnail: each click places it with file.place, so a file whose
|
||||
// pixels file.place can't read is skipped. With a thumbnail, the decoded pixels are kept
|
||||
// so that drawing it does not decode the file again.
|
||||
let checked = load(&q, QUEUE, None, false).and_then(|mut l| {
|
||||
let linked = l.link.is_some() && bool_or(&q, "link", true);
|
||||
if let Art::Image(img) = &mut l.art {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable };
|
||||
img.blob = check(img.blob.clone(), unread, linked)?;
|
||||
}
|
||||
Ok(l)
|
||||
});
|
||||
match checked {
|
||||
Ok(l) => {
|
||||
let (w, h) = (l.natural.width(), l.natural.height());
|
||||
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
|
||||
|
||||
@@ -150,6 +150,42 @@ fn import_image_then_paste_gives_an_image() {
|
||||
assert!(s.execute("clipboard.importImage", &json!({"dataBase64": "!!"})).is_err());
|
||||
}
|
||||
|
||||
/// A pasted PNG cut to half its length (a copied file still downloading) has a header but not
|
||||
/// all its pixels: importing it is an error, and the clipboard keeps what it held.
|
||||
#[test]
|
||||
fn a_bitmap_whose_pixels_cant_be_read_is_not_pasted() {
|
||||
let mut s = session();
|
||||
copy_red_square(&mut s);
|
||||
let held = s.clipboard.clone();
|
||||
let cut = crate::tests_links::cut_png(600, 300);
|
||||
let e = s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&cut), "mime": "image/png"})).unwrap_err();
|
||||
assert!(e.to_string().ends_with("the image's pixels can't be read"), "{e}");
|
||||
assert_eq!(s.clipboard.nodes, held.nodes);
|
||||
}
|
||||
|
||||
/// The pixels `clipboard.importImage` decodes to check a pasted bitmap are kept in the renderers'
|
||||
/// image store, so the first render of the pasted image draws them without decoding it again.
|
||||
#[test]
|
||||
fn a_pasted_bitmap_is_drawn_without_decoding_it_again() {
|
||||
// The image has pixels no other test uses, since every test shares the image store.
|
||||
let img = image::RgbImage::from_fn(300, 200, |x, y| image::Rgb([(x % 253) as u8, (y % 233) as u8, 17]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = session();
|
||||
run(&mut s, "clipboard.importImage", json!({"dataBase64": vectorcraft_format::base64_encode(&png), "mime": "image/png"}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
let n = &pasted(&s)[0];
|
||||
let NodeKind::Image(im) = &n.kind else { panic!("not an image: {:?}", n.kind) };
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert!(vectorcraft_render::image_cached(&d.images[&im.key]), "kept before it is drawn");
|
||||
let decodes = vectorcraft_render::image_decodes();
|
||||
let r = vectorcraft_render::Renderer::new().render(&d, 400, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default());
|
||||
assert_eq!(vectorcraft_render::image_decodes(), decodes, "drawn without a decode");
|
||||
let at = im.xf * Point::new(150.0, 100.0);
|
||||
let px = r.pixel(at.x as u32, at.y as u32);
|
||||
assert!(px[3] > 0 && px[2] < 100, "the image is drawn at its middle: {px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_svgz_files_paste_as_svg() {
|
||||
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><rect width=\"40\" height=\"40\" fill=\"#ff0000\"/></svg>";
|
||||
|
||||
@@ -255,6 +255,45 @@ fn a_missing_link_renders_its_preview_and_relinks() {
|
||||
assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder");
|
||||
}
|
||||
|
||||
/// A `w`×`h` PNG of noise cut to half its length: its header reads, its pixels don't.
|
||||
pub(crate) fn cut_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(w, h, |x, y| {
|
||||
let v = x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503).rotate_left(13);
|
||||
image::Rgb([v as u8, (v >> 8) as u8, (v >> 16) as u8])
|
||||
});
|
||||
let mut out = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out.truncate(out.len() / 2);
|
||||
out
|
||||
}
|
||||
|
||||
/// A linked file whose pixels can't be read (cut short) isn't shown: Relink is an error, and
|
||||
/// Update Links and Open (Update Links: Automatically) count it as missing. The image keeps what
|
||||
/// it showed.
|
||||
#[test]
|
||||
fn a_linked_file_whose_pixels_cant_be_read_is_not_shown() {
|
||||
let dir = Folder::new("cut");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let shown = image(&s, id);
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
let cut = dir.file("cut.png");
|
||||
write(&cut, &cut_png(300, 100));
|
||||
let e = s.execute("links.relink", &json!({"ids": [id.0], "path": cut})).unwrap_err();
|
||||
assert!(e.to_string().ends_with("the image's pixels can't be read"), "{e}");
|
||||
write(&pic, &cut_png(300, 100));
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [], "missing": [id.0]}));
|
||||
assert_eq!(image(&s, id), shown);
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!((r["missingLinks"][0]["ids"].clone(), r["updatedLinks"].clone()), (json!([id.0]), json!([])), "{r}");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "the image still shows its preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modified_link_follows_the_update_links_preference() {
|
||||
let dir = Folder::new("modified");
|
||||
|
||||
@@ -68,7 +68,8 @@ fn an_image_too_large_to_draw_is_reported() {
|
||||
|
||||
/// A gray image is counted as RGBA, 4 bytes a pixel: 23,171 × 23,170 1-bit pixels decode to 537 MB
|
||||
/// of gray and are 2,147,488,280 bytes as RGBA, past the 2 GiB allowance (shown at reduced size);
|
||||
/// 23,170 × 23,170 are 2,147,395,600 bytes, within it.
|
||||
/// 23,170 × 23,170 are 2,147,395,600 bytes, within it. The file within the allowance holds no
|
||||
/// pixels, so Place decodes it and refuses it without the note.
|
||||
#[test]
|
||||
fn a_gray_image_too_large_as_rgba_is_reported_on_place() {
|
||||
const NOTE: &str = "an image of 23171 × 23170 pixels is shown at reduced size";
|
||||
@@ -78,10 +79,12 @@ fn a_gray_image_too_large_as_rgba_is_reported_on_place() {
|
||||
write(&within, &png_header(23_170, 23_170));
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": over, "at": [200, 150], "link": false}))["warnings"], json!([NOTE]));
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": within, "at": [20, 20], "link": false}))["warnings"], json!([]));
|
||||
assert_eq!(run(&mut s, "file.place.info", json!({"path": within}))["warnings"], json!([]), "the image within the allowance has nothing to say");
|
||||
let e = s.execute("file.place", &json!({"path": within, "at": [20, 20], "link": false})).unwrap_err();
|
||||
assert_eq!(e.to_string(), "the image's pixels can't be read");
|
||||
let l = run(&mut s, "links.list", json!({}));
|
||||
let notes: Vec<&Value> = l["links"].as_array().unwrap().iter().map(|r| &r["note"]).collect();
|
||||
assert_eq!(notes, [&Value::Null, &json!(NOTE)], "top first: the image within the allowance has nothing to say");
|
||||
assert_eq!(notes, [&json!(NOTE)]);
|
||||
}
|
||||
|
||||
/// File › Open says the same of a gray image past the allowance as RGBA, and nothing of one within it.
|
||||
@@ -182,6 +185,24 @@ fn unembed_writes_the_bytes_and_links_to_them() {
|
||||
assert!(s.execute("links.unembed", &json!({"id": id.0, "path": dir.file("photo.psd")})).is_err(), "not a format it writes");
|
||||
}
|
||||
|
||||
/// An embedded image whose pixels can't be read (a document stores its bytes without decoding
|
||||
/// them) is not unembedded, and no file is written.
|
||||
#[test]
|
||||
fn unembed_of_pixels_that_cant_be_read_writes_no_file() {
|
||||
let dir = Folder::new("unembed-unread");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let id = place_embedded(&mut s, &pic);
|
||||
let key = image(&s, id).key;
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.insert(key, vectorcraft_doc::ImageBlob::png(png_header(60, 30)));
|
||||
let out = dir.file("out.png");
|
||||
let e = s.execute("links.unembed", &json!({"id": id.0, "path": out})).unwrap_err();
|
||||
assert!(e.to_string().ends_with("the image's pixels can't be read"), "{e}");
|
||||
assert!(!std::path::Path::new(&out).exists(), "no file left behind");
|
||||
assert!(image(&s, id).link.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_selects_the_image() {
|
||||
let dir = Folder::new("goto");
|
||||
|
||||
@@ -38,8 +38,8 @@ fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// A placed 64 × 32 px 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.
|
||||
/// A placed 64 × 32 px PNG whose header reads but whose pixel data is cut short
|
||||
/// ([`cut_pixels`]): the renderer can't decode it and draws nothing.
|
||||
pub(crate) fn place_undecodable_png(s: &mut Session) -> NodeId {
|
||||
place_undecodable_png_sized(s, 64, 32)
|
||||
}
|
||||
@@ -51,10 +51,34 @@ pub(crate) fn place_undecodable_png_sized(s: &mut Session, w: u32, h: u32) -> No
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
cut_pixels(s, id);
|
||||
id
|
||||
}
|
||||
|
||||
/// Cut the pixel data of the PNG that placed image `id` shows short, right after its first `IDAT`
|
||||
/// chunk's type, as a document saved with a damaged image holds it: the header still reads, but
|
||||
/// the renderer can't decode the image. Place refuses such a file, so tests cut a placed one. The
|
||||
/// image is stored under the key of its new bytes.
|
||||
pub(crate) fn cut_pixels(s: &mut Session, id: NodeId) {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(NodeKind::Image(im)) = doc.node(id).map(|n| &n.kind) else { panic!("{id:?} is not an image") };
|
||||
let old = im.key.clone();
|
||||
let mut png = doc.images[&old].bytes.to_vec();
|
||||
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png.truncate(idat + 8);
|
||||
let r = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
let cut = vectorcraft_doc::ImageBlob::new("image/png", png);
|
||||
let key = cut.content_key();
|
||||
doc.images.insert(key.clone(), cut);
|
||||
if let Some(Node { kind: NodeKind::Image(im), .. }) = doc.node_mut(id) {
|
||||
im.key = key;
|
||||
}
|
||||
let mut shown = false;
|
||||
doc.visit_images(|_, im| shown |= im.key == old);
|
||||
if !shown {
|
||||
doc.images.remove(&old);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether reply `r` warns that an image couldn't be decoded, as raster exports do.
|
||||
|
||||
@@ -480,17 +480,10 @@ fn pdf_export_writes_patterns_nested_too_deep_as_tiles_with_a_warning() {
|
||||
|
||||
#[test]
|
||||
fn every_raster_export_says_when_a_pattern_holds_an_image_that_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short, made
|
||||
// into a pattern and then cleared from the page: only the pattern's tile draws it.
|
||||
// A PNG whose header reads but whose pixel data is cut short, made into a pattern and then
|
||||
// cleared from the page: only the pattern's tile draws it.
|
||||
let mut s = session();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(20, 20, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png.truncate(idat + 8);
|
||||
let placed = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let placed = NodeId(placed["ids"][0].as_u64().unwrap());
|
||||
let placed = crate::tests_menucmds::place_undecodable_png_sized(&mut s, 20, 20);
|
||||
sel(&mut s, &[placed]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Cut", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
|
||||
@@ -378,6 +378,59 @@ fn a_linked_image_keeps_the_preview_made_when_its_file_was_read() {
|
||||
assert!(d.images["embedded"].proxy.is_none());
|
||||
}
|
||||
|
||||
/// Keeping an image's pixels decodes nothing: a linked image read without a preview keeps none,
|
||||
/// and pixels already in the document take the preview the file was read with.
|
||||
#[test]
|
||||
fn keeping_an_image_decodes_nothing() {
|
||||
let bytes = png(600, 300, [9, 9, 9, 255], None);
|
||||
let mut d = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
cmd::links::store_image(&mut d, "k", vectorcraft_doc::ImageBlob::new("image/png", bytes.clone()), true);
|
||||
assert!(d.images["k"].proxy.is_none(), "no preview is made here");
|
||||
let read = vectorcraft_doc::ImageBlob::new("image/png", bytes).with_proxy();
|
||||
let made = read.proxy.clone().unwrap();
|
||||
cmd::links::store_image(&mut d, "k", read, true);
|
||||
assert!(d.images["k"].proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &made)), "the preview it was read with");
|
||||
}
|
||||
|
||||
/// `w`×`h` pixels of noise, which compress little, encoded as `format`.
|
||||
fn noise(w: u32, h: u32, format: image::ImageFormat) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(w, h, |x, y| {
|
||||
let v = x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503).rotate_left(13);
|
||||
image::Rgb([v as u8, (v >> 8) as u8, (v >> 16) as u8])
|
||||
});
|
||||
let mut out = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A PNG cut to half its length, as a download or a sync still running leaves it, has a header
|
||||
/// but not all its pixels: placing it, embedded or linked, is an error and places nothing. A JPEG
|
||||
/// cut short still decodes, with its missing rows empty, and is placed.
|
||||
#[test]
|
||||
fn an_image_whose_pixels_cant_be_read_is_not_placed() {
|
||||
let mut cut = noise(600, 300, image::ImageFormat::Png);
|
||||
cut.truncate(cut.len() / 2);
|
||||
let mut s = session();
|
||||
let info = s.execute("file.place.info", &file("cut.png", &cut)).unwrap();
|
||||
assert_eq!((info["pixelWidth"].as_u64(), info["pixelHeight"].as_u64()), (Some(600), Some(300)), "its header reads");
|
||||
let dir = crate::tests_links::Folder::new("cut-png");
|
||||
let path = dir.file("cut.png");
|
||||
crate::tests_links::write(&path, &cut);
|
||||
for p in [file("cut.png", &cut), json!({"path": path}), json!({"path": path, "link": false})] {
|
||||
let (undo, journal) = (s.doc().unwrap().history.undo.len(), s.journal.len());
|
||||
let e = s.execute("file.place", &p).unwrap_err();
|
||||
assert_eq!(e.to_string(), "the image's pixels can't be read", "{p}");
|
||||
assert!(s.prepare_place(&p).unwrap().read().is_err(), "{p}: read apart from the session");
|
||||
assert!(top_children(&s).is_empty(), "{p}: nothing placed");
|
||||
assert_eq!((s.doc().unwrap().history.undo.len(), s.journal.len()), (undo, journal), "{p}");
|
||||
}
|
||||
let mut jpeg = noise(600, 300, image::ImageFormat::Jpeg);
|
||||
jpeg.truncate(jpeg.len() / 2);
|
||||
let r = place(&mut s, "cut.jpg", &jpeg, json!({}));
|
||||
assert_eq!(r["format"], "jpg");
|
||||
assert_eq!(top_children(&s).len(), 1);
|
||||
}
|
||||
|
||||
/// Type and a DXF drawing fitted to the artboard, read apart from the session while the artboard
|
||||
/// under `at` changes size, are placed against the artboard as it is when they are placed: as
|
||||
/// `file.place` places them after the change, which is what a replay of the journal does.
|
||||
@@ -724,6 +777,36 @@ fn files_read_for_the_place_cursor_apart_from_the_session_load_it_as_file_place_
|
||||
assert_eq!(c.tool_id(), "selection");
|
||||
}
|
||||
|
||||
/// A file whose pixels `file.place` can't read (a PNG cut short) is skipped by the place cursor's
|
||||
/// queue, read with the session or apart from it.
|
||||
#[test]
|
||||
fn the_place_queue_skips_an_image_whose_pixels_cant_be_read() {
|
||||
let mut cut = noise(600, 300, image::ImageFormat::Png);
|
||||
cut.truncate(cut.len() / 2);
|
||||
let p = json!({"files": [file("a.png", &png(40, 20, [9, 9, 9, 255], None)), file("cut.png", &cut)], "link": false});
|
||||
let (mut a, mut b) = (session(), session());
|
||||
let r = a.execute("file.place.queue", &p).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
assert_eq!(r["skipped"], json!([{"name": "cut.png", "error": "the image's pixels can't be read"}]));
|
||||
let read = b.prepare_place_queue(&p).unwrap().read().unwrap();
|
||||
assert_eq!(b.place_queue_read(read).unwrap(), r);
|
||||
}
|
||||
|
||||
/// The place queue's thumbnail of an image is drawn from the pixels its check decoded, so each
|
||||
/// image is decoded once.
|
||||
#[test]
|
||||
fn the_place_queue_draws_a_thumbnail_without_decoding_the_image_again() {
|
||||
// Pixels no other test uses, since every test shares the image store.
|
||||
let img = image::RgbImage::from_fn(320, 160, |x, y| image::Rgb([(x % 251) as u8, 23, (y % 241) as u8]));
|
||||
let mut bytes = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
let p = json!({"files": [file("q.png", &bytes)], "link": false, "thumbnail": 64});
|
||||
let decodes = vectorcraft_render::image_decodes();
|
||||
let r = session().execute("file.place.queue", &p).unwrap();
|
||||
assert!(r["files"][0]["thumbnailBase64"].as_str().is_some_and(|t| !t.is_empty()), "{r}");
|
||||
assert_eq!(vectorcraft_render::image_decodes(), decodes, "the thumbnail is drawn without a decode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_place_params_are_errors() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -308,17 +308,6 @@ fn files_are_written_beside_the_path() {
|
||||
assert_eq!(r["bytes"].as_u64().unwrap(), std::fs::metadata(&page).unwrap().len() + std::fs::metadata(dir.join("images/page.png")).unwrap().len());
|
||||
}
|
||||
|
||||
/// A 64×32 PNG whose header reads (so it places at its size) but whose pixel data is cut short.
|
||||
fn cut_png() -> Vec<u8> {
|
||||
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 idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png.truncate(idat + 8);
|
||||
png
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_says_when_an_image_could_not_be_decoded() {
|
||||
let undecoded =
|
||||
@@ -328,7 +317,7 @@ fn an_export_says_when_an_image_could_not_be_decoded() {
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert_eq!(run(&mut s, "document.exportForWeb.preview", json!({}))["warnings"], json!([]));
|
||||
// The renderer can't decode the placed image and draws nothing in its place.
|
||||
run(&mut s, "file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut_png())}));
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
for format in ["gif", "jpg", "png8", "png24"] {
|
||||
let r = run(&mut s, "document.exportForWeb", json!({"format": format}));
|
||||
assert!(undecoded(&r), "{format}: {}", r["warnings"]);
|
||||
|
||||
Reference in New Issue
Block a user