mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 20:01:43 +00:00
A save wrote the path relative to the document only for linked images and placed documents in the layers. Links inside symbols, graph designs, patterns and opacity masks kept only their absolute path, so after the document and its files moved to another folder together, those files were not found. - The save sets the relative path of every link the document holds. - On open, a link inside that art whose file was found away from its path takes the new path, as a link in the layers does, so the next save writes the right relative path. - Document::update_links_where changes only the links it picks and copies only the nodes on the way to them. Docs: docs/mcp.md (Linked images), docs/target-app-parity.md.
732 lines
31 KiB
Rust
732 lines
31 KiB
Rust
//! Linked files: where a placed file lives and what it was when last read ([`LinkInfo`]), the
|
||
//! low-resolution preview a linked image keeps for when its file can't be read
|
||
//! ([`ImageBlob::proxy`]), and every image object of a document ([`Document::visit_images`]).
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::io::Cursor;
|
||
use std::sync::Arc;
|
||
|
||
use serde::{Deserialize, Deserializer, Serialize};
|
||
|
||
use crate::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||
|
||
/// The longest side of a linked image's preview (pixels). Smaller images keep no preview: their
|
||
/// full pixels are saved instead.
|
||
pub const PROXY_SIZE: u32 = 256;
|
||
/// The MIME type of previews.
|
||
pub const PROXY_MIME: &str = "image/png";
|
||
|
||
/// A linked file: an image object shows the file at `path` (File → Place with Link) and the
|
||
/// document saves only a preview of it.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct LinkInfo {
|
||
/// The file's absolute path.
|
||
pub path: String,
|
||
/// The path relative to the document's folder (`/` separators), written on save: how the file
|
||
/// is found after the document and its links move together.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub relative: Option<String>,
|
||
/// The file's modification time when last read (milliseconds since the Unix epoch).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub modified: Option<u64>,
|
||
/// The file's size when last read (bytes).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub size: Option<u64>,
|
||
/// The hash of the file's bytes when last read ([`hash_bytes`]).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub hash: Option<String>,
|
||
/// The page shown, for a linked document page (1-based).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub page: Option<u32>,
|
||
}
|
||
|
||
impl LinkInfo {
|
||
/// A link to `path` with nothing known about the file.
|
||
pub fn new(path: impl Into<String>) -> Self {
|
||
Self { path: path.into(), ..Self::default() }
|
||
}
|
||
|
||
/// The file name: the path's last component, whichever system's separators it uses
|
||
/// ([`path_separators`]).
|
||
pub fn name(&self) -> &str {
|
||
self.path.rsplit(path_separators(&self.path)).next().unwrap_or(&self.path)
|
||
}
|
||
|
||
/// The folder the file is in: the path without its name and the separators before it.
|
||
pub fn folder(&self) -> &str {
|
||
let folder = self.path.get(..self.path.len().saturating_sub(self.name().len())).unwrap_or_default();
|
||
folder.trim_end_matches(path_separators(&self.path))
|
||
}
|
||
}
|
||
|
||
/// The characters that separate the folders of `path`. A path with a colon and no `/` or `\` is a
|
||
/// classic Mac OS path (`Disk:Folder:file.tif`, as older documents store links), whose folders are
|
||
/// separated by colons. Any other path is separated by `/` and `\`: its file name may hold a colon
|
||
/// (`/Users/a/x:y.png`) and never holds a `/` or `\`.
|
||
pub fn path_separators(path: &str) -> &'static [char] {
|
||
const SLASHES: &[char] = &['/', '\\'];
|
||
if path.contains(':') && !path.contains(SLASHES) { &[':'] } else { SLASHES }
|
||
}
|
||
|
||
impl From<&str> for LinkInfo {
|
||
fn from(path: &str) -> Self {
|
||
Self::new(path)
|
||
}
|
||
}
|
||
|
||
/// [`ImageObject::link`] from a link object or, as files saved before links had details, a path.
|
||
pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result<Option<LinkInfo>, D::Error> {
|
||
#[derive(Deserialize)]
|
||
#[serde(untagged)]
|
||
enum Repr {
|
||
Path(String),
|
||
Info(LinkInfo),
|
||
}
|
||
Ok(Option::<Repr>::deserialize(d)?.map(|r| match r {
|
||
Repr::Path(path) => LinkInfo::new(path),
|
||
Repr::Info(info) => info,
|
||
}))
|
||
}
|
||
|
||
/// The FNV-1a offset basis: the hash of no bytes ([`fnv1a`]).
|
||
const FNV_BASIS: u64 = 0xcbf29ce484222325;
|
||
|
||
/// The 64-bit FNV-1a hash `h` of some bytes, carried on over `bytes`.
|
||
fn fnv1a(h: u64, bytes: &[u8]) -> u64 {
|
||
bytes.iter().fold(h, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
|
||
}
|
||
|
||
/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits.
|
||
pub fn hash_bytes(bytes: &[u8]) -> String {
|
||
format!("img{:016x}", fnv1a(FNV_BASIS, bytes))
|
||
}
|
||
|
||
/// [`hash_bytes`] of everything `r` reads, read 64 KB at a time.
|
||
pub fn hash_reader(r: impl std::io::Read) -> std::io::Result<String> {
|
||
hash_reader_within(r, u64::MAX).map(|(hash, _)| hash)
|
||
}
|
||
|
||
/// [`hash_reader`] of at most `max` bytes, and how many it read: `max` + 1 when there were more (the
|
||
/// hash then covers only the first `max` + 1). A file that grew since its size was looked at isn't
|
||
/// read to its new end.
|
||
pub fn hash_reader_within(r: impl std::io::Read, max: u64) -> std::io::Result<(String, u64)> {
|
||
use std::io::Read;
|
||
let mut r = r.take(max.saturating_add(1));
|
||
let mut buf = vec![0; 1 << 16];
|
||
let (mut h, mut read) = (FNV_BASIS, 0u64);
|
||
loop {
|
||
match r.read(&mut buf) {
|
||
Ok(0) => return Ok((format!("img{h:016x}"), read)),
|
||
Ok(n) => {
|
||
h = fnv1a(h, buf.get(..n).unwrap_or_default());
|
||
read = read.saturating_add(n as u64);
|
||
}
|
||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||
Err(e) => return Err(e),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ImageBlob {
|
||
/// A blob of encoded image `bytes` of type `mime`, without a preview.
|
||
pub fn new(mime: impl Into<String>, bytes: Vec<u8>) -> Self {
|
||
Self { mime: mime.into(), bytes: Arc::new(bytes), proxy: None }
|
||
}
|
||
|
||
/// This blob with a preview of at most [`PROXY_SIZE`] pixels a side (PNG), when the image is
|
||
/// larger than that and decodes; otherwise unchanged.
|
||
pub fn with_proxy(mut self) -> Self {
|
||
if self.proxy.is_none() {
|
||
self.proxy = proxy_png(&self.bytes).map(Arc::new);
|
||
}
|
||
self
|
||
}
|
||
|
||
/// This blob after its pixels are decoded once, within [`crate::MAX_RASTER_ALLOC`], to check
|
||
/// that they can be read; with `proxy`, it also gets the preview [`Self::with_proxy`] makes,
|
||
/// made from that decode. `None` when the pixels can't be read, as with a file cut short that
|
||
/// has a header but not all its pixels. An image whose pixels would take more than the
|
||
/// allowance as RGBA is not decoded, and one whose decode needs more than the allowance stops
|
||
/// decoding; either is returned as it is.
|
||
pub fn checked_with_proxy(self, proxy: bool) -> Option<Self> {
|
||
self.checked_with_pixels(proxy).map(|(blob, _)| blob)
|
||
}
|
||
|
||
/// [`Self::checked_with_proxy`], with the pixels it decoded: `None` beside the blob when it
|
||
/// decoded none (an image past the allowance).
|
||
pub fn checked_with_pixels(mut self, proxy: bool) -> Option<(Self, Option<image::DynamicImage>)> {
|
||
let max = crate::MAX_RASTER_ALLOC;
|
||
let reader = || image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok();
|
||
let (w, h) = reader()?.into_dimensions().ok()?;
|
||
if u64::from(w) * u64::from(h) > max / 4 {
|
||
return Some((self, None));
|
||
}
|
||
let mut r = reader()?;
|
||
let mut limits = image::Limits::default();
|
||
limits.max_alloc = Some(max);
|
||
r.limits(limits);
|
||
let img = match r.decode() {
|
||
Ok(img) => img,
|
||
Err(image::ImageError::Limits(_)) => return Some((self, None)),
|
||
Err(_) => return None,
|
||
};
|
||
if proxy && self.proxy.is_none() && w.max(h) > PROXY_SIZE {
|
||
self.proxy = proxy_of(&img).map(Arc::new);
|
||
}
|
||
Some((self, Some(img)))
|
||
}
|
||
|
||
/// Are the bytes the preview (the linked file hasn't been read)?
|
||
pub fn is_proxy(&self) -> bool {
|
||
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
|
||
}
|
||
|
||
/// Does a colorize paint ([`crate::NodeExtras::colorize`]) show on these bytes? Not while
|
||
/// they are a preview whose pixels aren't grayscale, such as [`missing_placeholder`]: until
|
||
/// the file is relinked the image shows the preview as it is, and keeps the paint.
|
||
pub fn takes_colorize(&self) -> bool {
|
||
!self.is_proxy() || self.gray_source().is_some()
|
||
}
|
||
}
|
||
|
||
/// The side of [`missing_placeholder`]'s image, in pixels.
|
||
pub const MISSING_SIDE: u32 = 64;
|
||
|
||
/// What a linked image shows when an importer has no pixels for it (its file is missing or isn't
|
||
/// in the data): a light tile, crossed out, [`MISSING_SIDE`] pixels a side, as a PNG. `None` when
|
||
/// it can't be encoded.
|
||
pub fn missing_placeholder() -> Option<Vec<u8>> {
|
||
let last = MISSING_SIDE - 1;
|
||
let img = image::RgbaImage::from_fn(MISSING_SIDE, MISSING_SIDE, |x, y| {
|
||
let edge = x == 0 || y == 0 || x == last || y == last;
|
||
let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1;
|
||
image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] })
|
||
});
|
||
let mut png = Vec::new();
|
||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||
Some(png)
|
||
}
|
||
|
||
/// `bytes` decoded and shrunk to fit [`PROXY_SIZE`] as a PNG; `None` when small enough already or
|
||
/// undecodable.
|
||
fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
|
||
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||
if w.max(h) <= PROXY_SIZE {
|
||
return None;
|
||
}
|
||
// The proxy decodes within the project's raster cap, counted as RGBA like every other
|
||
// decode path (#3597): the header alone decides whether the pixels could ever fit, and the
|
||
// decoder gets the same budget instead of image's own default.
|
||
if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 {
|
||
return None;
|
||
}
|
||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
|
||
let mut limits = image::Limits::default();
|
||
limits.max_alloc = Some(crate::MAX_RASTER_ALLOC);
|
||
reader.limits(limits);
|
||
proxy_of(&reader.decode().ok()?)
|
||
}
|
||
|
||
/// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG.
|
||
fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> {
|
||
let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE);
|
||
let mut png = Vec::new();
|
||
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||
Some(png)
|
||
}
|
||
|
||
/// `n` and every descendant, opacity-mask art included.
|
||
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||
f(n);
|
||
if let Some(m) = &n.extras().mask {
|
||
walk_all(&m.art, f);
|
||
}
|
||
for c in n.children().into_iter().flatten() {
|
||
walk_all(c, f);
|
||
}
|
||
}
|
||
|
||
impl Document {
|
||
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
|
||
/// opacity-mask art included.
|
||
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
|
||
let roots = self
|
||
.layers
|
||
.iter()
|
||
.chain(self.symbols.iter().map(|s| &s.art))
|
||
.chain(self.graph_designs.iter().map(|g| &g.art))
|
||
.chain(self.patterns.iter().flat_map(|p| &p.art));
|
||
for root in roots {
|
||
walk_all(root, &mut |n| {
|
||
if let NodeKind::Image(im) = &n.kind {
|
||
f(n, im);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// The keys of image blobs only linked images (or placed documents, always linked) show: what a save writes as their preview.
|
||
pub fn linked_only_images(&self) -> BTreeSet<String> {
|
||
if self.images.values().all(|b| b.proxy.is_none()) {
|
||
return BTreeSet::new();
|
||
}
|
||
// Per key: (shown linked, shown embedded).
|
||
let mut uses: BTreeMap<&str, (bool, bool)> = BTreeMap::new();
|
||
self.visit_images(|_, im| {
|
||
let u = uses.entry(im.key.as_str()).or_default();
|
||
if im.link.is_some() {
|
||
u.0 = true;
|
||
} else {
|
||
u.1 = true;
|
||
}
|
||
});
|
||
// A placed document saves its file's preview the same way.
|
||
self.visit_placed(|_, p| uses.entry(p.key.as_str()).or_default().0 = true);
|
||
uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect()
|
||
}
|
||
}
|
||
|
||
/// Links panel → Placement Options: how a file read again (Relink, Update Link) takes the place of
|
||
/// the art it replaces.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PlacementOptions {
|
||
#[serde(default)]
|
||
pub preserve: Preserve,
|
||
/// Where the new art sits in the old bounds (all but [`Preserve::Bounds`]).
|
||
#[serde(default)]
|
||
pub align: Align,
|
||
/// Clip the new art to the old bounds where it is larger.
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub clip: bool,
|
||
}
|
||
|
||
/// What a relinked image keeps of the art it replaces.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum Preserve {
|
||
/// Its scale (relative to each file's 100% size), rotation and position.
|
||
Transforms,
|
||
/// Its bounds: the new art is stretched into them.
|
||
#[default]
|
||
Bounds,
|
||
/// Nothing but its position: the new art at 100%, unrotated.
|
||
FileDimensions,
|
||
/// Its bounds: the new art scaled proportionally to fit inside them.
|
||
Fit,
|
||
/// Its bounds: the new art scaled proportionally to fill them.
|
||
Fill,
|
||
}
|
||
|
||
impl Preserve {
|
||
pub const ALL: [Preserve; 5] = [Preserve::Transforms, Preserve::Bounds, Preserve::FileDimensions, Preserve::Fit, Preserve::Fill];
|
||
|
||
/// The id commands use (`transforms`, `bounds`, `fileDimensions`, `fit`, `fill`).
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
Preserve::Transforms => "transforms",
|
||
Preserve::Bounds => "bounds",
|
||
Preserve::FileDimensions => "fileDimensions",
|
||
Preserve::Fit => "fit",
|
||
Preserve::Fill => "fill",
|
||
}
|
||
}
|
||
|
||
pub fn from_id(id: &str) -> Option<Self> {
|
||
Self::ALL.into_iter().find(|p| p.id() == id)
|
||
}
|
||
}
|
||
|
||
/// A point of the 3×3 alignment grid, row by row from the top left.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum Align {
|
||
TopLeft,
|
||
Top,
|
||
TopRight,
|
||
Left,
|
||
#[default]
|
||
Center,
|
||
Right,
|
||
BottomLeft,
|
||
Bottom,
|
||
BottomRight,
|
||
}
|
||
|
||
impl Align {
|
||
pub const ALL: [Align; 9] =
|
||
[Align::TopLeft, Align::Top, Align::TopRight, Align::Left, Align::Center, Align::Right, Align::BottomLeft, Align::Bottom, Align::BottomRight];
|
||
|
||
/// The id commands use (`topLeft` … `bottomRight`).
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
Align::TopLeft => "topLeft",
|
||
Align::Top => "top",
|
||
Align::TopRight => "topRight",
|
||
Align::Left => "left",
|
||
Align::Center => "center",
|
||
Align::Right => "right",
|
||
Align::BottomLeft => "bottomLeft",
|
||
Align::Bottom => "bottom",
|
||
Align::BottomRight => "bottomRight",
|
||
}
|
||
}
|
||
|
||
pub fn from_id(id: &str) -> Option<Self> {
|
||
Self::ALL.into_iter().find(|a| a.id() == id)
|
||
}
|
||
|
||
/// The fractions (0, ½ or 1) of the free room left of and above the new art.
|
||
pub fn fractions(self) -> (f64, f64) {
|
||
let i = Self::ALL.iter().position(|a| *a == self).unwrap_or(4);
|
||
((i % 3) as f64 / 2.0, (i / 3) as f64 / 2.0)
|
||
}
|
||
}
|
||
|
||
impl Document {
|
||
/// Rewrite linked file locations in both raster images and placed VectorCraft documents,
|
||
/// including those inside symbols, patterns and opacity masks. Used to make packages portable.
|
||
pub fn update_links(&mut self, mut f: impl FnMut(&mut LinkInfo)) {
|
||
fn walk(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut LinkInfo)) {
|
||
let n = Arc::make_mut(n);
|
||
match &mut n.kind {
|
||
NodeKind::Image(image) => {
|
||
if let Some(link) = &mut image.link {
|
||
f(link);
|
||
}
|
||
}
|
||
NodeKind::PlacedDocument(placed) => f(&mut placed.link),
|
||
_ => {}
|
||
}
|
||
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||
walk(&mut mask.art, f);
|
||
}
|
||
for child in n.children_mut().into_iter().flatten() {
|
||
walk(child, f);
|
||
}
|
||
}
|
||
let roots = self
|
||
.layers
|
||
.iter_mut()
|
||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
|
||
for root in roots {
|
||
walk(root, &mut f);
|
||
}
|
||
}
|
||
|
||
/// [`Self::update_links`] for the links `pick` holds for only, in one pass; only the nodes on the
|
||
/// way to one are copied.
|
||
pub fn update_links_where(&mut self, pick: impl Fn(&LinkInfo) -> bool, mut f: impl FnMut(&mut LinkInfo)) {
|
||
fn link_of(n: &Node) -> Option<&LinkInfo> {
|
||
match &n.kind {
|
||
NodeKind::Image(image) => image.link.as_ref(),
|
||
NodeKind::PlacedDocument(placed) => Some(&placed.link),
|
||
_ => None,
|
||
}
|
||
}
|
||
/// `n` updated in place, a copy of it when shared; left as it is when it holds no picked link.
|
||
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&LinkInfo) -> bool, f: &mut dyn FnMut(&mut LinkInfo)) {
|
||
// Each child is updated through a second handle, so an updated child is a new node and a new
|
||
// pointer marks a change.
|
||
let mask = n.extras().mask.as_ref().and_then(|m| {
|
||
let mut art = Arc::clone(&m.art);
|
||
update(&mut art, pick, f);
|
||
(!Arc::ptr_eq(&art, &m.art)).then_some(art)
|
||
});
|
||
let mut children = vec![];
|
||
for (i, c) in n.children().into_iter().flatten().enumerate() {
|
||
let mut new = Arc::clone(c);
|
||
update(&mut new, pick, f);
|
||
if !Arc::ptr_eq(&new, c) {
|
||
children.push((i, new));
|
||
}
|
||
}
|
||
let own = link_of(n).is_some_and(pick);
|
||
if !own && mask.is_none() && children.is_empty() {
|
||
return;
|
||
}
|
||
let n = Arc::make_mut(n);
|
||
let link = match &mut n.kind {
|
||
NodeKind::Image(image) => image.link.as_mut(),
|
||
NodeKind::PlacedDocument(placed) => Some(&mut placed.link),
|
||
_ => None,
|
||
};
|
||
if let Some(link) = link.filter(|_| own) {
|
||
f(link);
|
||
}
|
||
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
|
||
m.art = art;
|
||
}
|
||
if let Some(slots) = n.children_mut() {
|
||
for (i, c) in children {
|
||
if let Some(slot) = slots.get_mut(i) {
|
||
*slot = c;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let roots = self
|
||
.layers
|
||
.iter_mut()
|
||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
|
||
for root in roots {
|
||
update(root, &pick, &mut f);
|
||
}
|
||
}
|
||
|
||
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
|
||
/// art included) for which `pick` holds; only the subtrees holding one are copied.
|
||
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
|
||
fn holds(n: &Node, pick: &dyn Fn(&ImageObject) -> bool) -> bool {
|
||
let mut hit = false;
|
||
walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::Image(im) if pick(im)));
|
||
hit
|
||
}
|
||
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&ImageObject) -> bool, f: &mut dyn FnMut(&mut ImageObject)) {
|
||
if !holds(n, pick) {
|
||
return;
|
||
}
|
||
let n = Arc::make_mut(n);
|
||
if let NodeKind::Image(im) = &mut n.kind
|
||
&& pick(im)
|
||
{
|
||
f(im);
|
||
}
|
||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||
update(&mut m.art, pick, f);
|
||
}
|
||
for c in n.children_mut().into_iter().flatten() {
|
||
update(c, pick, f);
|
||
}
|
||
}
|
||
let roots = self
|
||
.layers
|
||
.iter_mut()
|
||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
|
||
for root in roots {
|
||
update(root, &pick, &mut f);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::NodeId;
|
||
use vectorcraft_geom::Affine;
|
||
|
||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||
let mut out = vec![];
|
||
image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||
out
|
||
}
|
||
|
||
fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) -> NodeId {
|
||
let id = d.alloc_id();
|
||
let layer = d.layers[0].id;
|
||
d.insert(
|
||
Some(layer),
|
||
0,
|
||
Node::new(
|
||
id,
|
||
NodeKind::Image(Box::new(ImageObject {
|
||
key: key.into(),
|
||
width: 4,
|
||
height: 4,
|
||
xf: Affine::IDENTITY,
|
||
link,
|
||
placement: Default::default(),
|
||
pixels: None,
|
||
})),
|
||
),
|
||
)
|
||
.unwrap()
|
||
}
|
||
|
||
/// `update_links_where` changes only the picked links, in a copy of the nodes on the way to them;
|
||
/// the other nodes stay shared with the document it was cloned from.
|
||
#[test]
|
||
fn update_links_where_copies_only_the_nodes_it_changes() {
|
||
let mut d = Document::new(10.0, 10.0);
|
||
image(&mut d, "a", Some(LinkInfo::new("/art/a.png")));
|
||
image(&mut d, "b", Some(LinkInfo::new("/art/b.png")));
|
||
image(&mut d, "c", None);
|
||
let mut copy = d.clone();
|
||
let mut calls = 0;
|
||
copy.update_links_where(
|
||
|l| l.path == "/art/a.png",
|
||
|l| {
|
||
calls += 1;
|
||
l.relative = Some("art/a.png".into());
|
||
},
|
||
);
|
||
assert_eq!(calls, 1);
|
||
let node = |d: &Document, key: &str| {
|
||
d.layers[0].children().unwrap().iter().find(|n| matches!(&n.kind, NodeKind::Image(im) if im.key == key)).unwrap().clone()
|
||
};
|
||
let relative = |n: &Node| match &n.kind {
|
||
NodeKind::Image(im) => im.link.as_ref().and_then(|l| l.relative.clone()),
|
||
_ => None,
|
||
};
|
||
assert_eq!((relative(&node(©, "a")), relative(&node(&d, "a"))), (Some("art/a.png".into()), None));
|
||
assert!(Arc::ptr_eq(&node(©, "b"), &node(&d, "b")) && Arc::ptr_eq(&node(©, "c"), &node(&d, "c")));
|
||
assert!(!Arc::ptr_eq(©.layers[0], &d.layers[0]));
|
||
// Nothing picked: nothing is copied.
|
||
let mut same = d.clone();
|
||
same.update_links_where(|_| false, |_| {});
|
||
assert!(Arc::ptr_eq(&same.layers[0], &d.layers[0]));
|
||
}
|
||
|
||
#[test]
|
||
fn the_placeholder_is_a_png() {
|
||
let png = missing_placeholder().unwrap();
|
||
assert_eq!(image::load_from_memory(&png).unwrap().width(), MISSING_SIDE);
|
||
}
|
||
|
||
#[test]
|
||
fn a_path_string_from_older_files_reads_as_a_link() {
|
||
let old: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":"C:\\art\\photo.png"}"#).unwrap();
|
||
let link = old.link.unwrap();
|
||
assert_eq!((link.path.as_str(), link.name(), link.hash.as_deref()), ("C:\\art\\photo.png", "photo.png", None));
|
||
let new: ImageObject =
|
||
serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":{"path":"/a/b.png","size":9,"page":2}}"#).unwrap();
|
||
assert_eq!(new.link, Some(LinkInfo { size: Some(9), page: Some(2), ..LinkInfo::new("/a/b.png") }));
|
||
let none: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0]}"#).unwrap();
|
||
assert_eq!(none.link, None);
|
||
// Written as an object, with only what is known.
|
||
let v = serde_json::to_value(&new).unwrap();
|
||
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
|
||
}
|
||
|
||
/// A classic Mac OS path is named by its last colon-separated component, and its folder is
|
||
/// what comes before; other paths keep their `/` and `\` separators, and a name never holds
|
||
/// one.
|
||
#[test]
|
||
fn a_classic_mac_path_is_named_by_its_last_component() {
|
||
let parts = |path: &str| {
|
||
let l = LinkInfo::new(path);
|
||
(l.name().to_string(), l.folder().to_string())
|
||
};
|
||
assert_eq!(parts("Macintosh HD:Images:photo.tif"), ("photo.tif".into(), "Macintosh HD:Images".into()));
|
||
assert_eq!(parts("Disk:photo.tif"), ("photo.tif".into(), "Disk".into()));
|
||
assert_eq!(parts("Disk:Art/2024:a\\b.tif"), ("b.tif".into(), "Disk:Art/2024:a".into()));
|
||
assert_eq!(parts("Disk:Images:../other.png"), ("other.png".into(), "Disk:Images:..".into()), "a name reaches no other folder");
|
||
assert_eq!(parts("C:\\art\\photo.png"), ("photo.png".into(), "C:\\art".into()));
|
||
assert_eq!(parts("c:/art/photo.png"), ("photo.png".into(), "c:/art".into()));
|
||
assert_eq!(parts("/Users/a/x:y.png"), ("x:y.png".into(), "/Users/a".into()), "a POSIX name may hold a colon");
|
||
assert_eq!(parts("./art/x:y.png"), ("x:y.png".into(), "./art".into()));
|
||
assert_eq!(parts("..\\art\\x:y.png"), ("x:y.png".into(), "..\\art".into()));
|
||
assert_eq!(parts("\\\\?\\C:\\art\\photo.png"), ("photo.png".into(), "\\\\?\\C:\\art".into()));
|
||
assert_eq!(parts("file:///art/photo.png"), ("photo.png".into(), "file:///art".into()));
|
||
assert_eq!(parts("file:/art/photo.png"), ("photo.png".into(), "file:/art".into()));
|
||
assert_eq!(parts("assets/icon:dark.png"), ("icon:dark.png".into(), "assets".into()), "a relative path with slashes");
|
||
assert_eq!(parts(":Images:photo.tif"), ("photo.tif".into(), ":Images".into()), "a classic path relative to its folder");
|
||
assert_eq!(parts("/Users/a//photo.png").1, "/Users/a", "repeated separators");
|
||
assert_eq!(parts("photo.png"), ("photo.png".into(), String::new()));
|
||
assert_eq!(parts("C:photo.png"), ("photo.png".into(), "C".into()), "a drive-relative path has the same shape");
|
||
assert_eq!(LinkInfo::new("Macintosh HD:Images:photo.tif").path, "Macintosh HD:Images:photo.tif", "the stored path is kept");
|
||
}
|
||
|
||
#[test]
|
||
fn a_reader_hashes_as_its_bytes_do() {
|
||
// Several 64 KB reads, and none.
|
||
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
|
||
assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes));
|
||
assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string()));
|
||
// Within a size: the whole file at that size; one byte past it when it is longer.
|
||
assert_eq!(hash_reader_within(&bytes[..], bytes.len() as u64).unwrap(), (hash_bytes(&bytes), bytes.len() as u64));
|
||
assert_eq!(hash_reader_within(&bytes[..], 3).unwrap().1, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn large_images_get_a_small_png_preview() {
|
||
let big = ImageBlob::new("image/png", png(600, 300)).with_proxy();
|
||
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
|
||
assert_eq!((p.width(), p.height()), (256, 128));
|
||
assert!(!big.is_proxy(), "the bytes are the full image");
|
||
assert!(ImageBlob::new("image/png", png(40, 20)).with_proxy().proxy.is_none(), "small images keep their pixels");
|
||
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
|
||
}
|
||
|
||
/// A checked image's pixels are decoded once: the preview comes from that decode, and an image
|
||
/// whose header reads but whose pixels don't is refused.
|
||
#[test]
|
||
fn a_checked_image_is_refused_when_its_pixels_cant_be_read() {
|
||
let full = png(600, 300);
|
||
let big = ImageBlob::new("image/png", full.clone()).checked_with_proxy(true).unwrap();
|
||
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
|
||
assert_eq!((p.width(), p.height()), (256, 128));
|
||
assert!(ImageBlob::new("image/png", full.clone()).checked_with_proxy(false).unwrap().proxy.is_none(), "no preview asked for");
|
||
assert!(ImageBlob::new("image/png", png(40, 20)).checked_with_proxy(true).unwrap().proxy.is_none(), "small images keep their pixels");
|
||
let cut = full[..full.len() / 2].to_vec();
|
||
assert_eq!(image::ImageReader::new(Cursor::new(&cut)).with_guessed_format().unwrap().into_dimensions().unwrap(), (600, 300));
|
||
assert!(ImageBlob::new("image/png", cut).checked_with_proxy(true).is_none(), "cut to half its length");
|
||
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).checked_with_proxy(false).is_none(), "not an image");
|
||
}
|
||
|
||
/// A checked image hands on the pixels it decoded, so they need not be decoded again.
|
||
#[test]
|
||
fn a_checked_image_hands_on_its_pixels() {
|
||
let full = png(600, 300);
|
||
let (blob, pixels) = ImageBlob::new("image/png", full.clone()).checked_with_pixels(true).unwrap();
|
||
assert!(blob.proxy.is_some(), "the preview is made as checked_with_proxy makes it");
|
||
assert_eq!(pixels.map(|p| p.to_rgba8()), Some(image::load_from_memory(&full).unwrap().to_rgba8()));
|
||
}
|
||
|
||
/// PNG CRC-32 (the same polynomial ZIP uses), so a hand-written header is one a decoder accepts.
|
||
fn crc32(data: &[u8]) -> u32 {
|
||
let mut crc = !0u32;
|
||
for &b in data {
|
||
crc ^= u32::from(b);
|
||
for _ in 0..8 {
|
||
crc = (crc >> 1) ^ (0xEDB8_8320 & u32::from(crc & 1 != 0).wrapping_neg());
|
||
}
|
||
}
|
||
!crc
|
||
}
|
||
|
||
/// A PNG header and nothing else, declaring `w` × `h` 8-bit gray pixels.
|
||
fn png_header_declaring(w: u32, h: u32) -> Vec<u8> {
|
||
let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||
let mut chunk = Vec::new();
|
||
chunk.extend(b"IHDR");
|
||
chunk.extend(w.to_be_bytes());
|
||
chunk.extend(h.to_be_bytes());
|
||
chunk.extend([8, 0, 0, 0, 0]);
|
||
let crc = crc32(&chunk);
|
||
out.extend(13u32.to_be_bytes());
|
||
out.extend(&chunk);
|
||
out.extend(crc.to_be_bytes());
|
||
out
|
||
}
|
||
|
||
/// A linked image past the raster cap gets no proxy: the header alone decides (#3597).
|
||
#[test]
|
||
fn an_image_past_the_raster_cap_gets_no_proxy() {
|
||
// 65536 × 65536 gray pixels are 4 GiB as RGBA, past the 2 GiB cap on every target.
|
||
let huge = png_header_declaring(1 << 16, 1 << 16);
|
||
assert!(proxy_png(&huge).is_none());
|
||
// A header within the cap but with no image data decodes to no preview, as before.
|
||
let truncated = png_header_declaring(600, 300);
|
||
assert!(proxy_png(&truncated).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn only_blobs_no_embedded_image_shows_are_linked_only() {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
for k in ["a", "b", "c"] {
|
||
d.images.insert(k.into(), ImageBlob::new("image/png", png(300, 10)).with_proxy());
|
||
}
|
||
image(&mut d, "a", Some(LinkInfo::new("/a.png")));
|
||
image(&mut d, "b", Some(LinkInfo::new("/b.png")));
|
||
image(&mut d, "b", None);
|
||
image(&mut d, "c", None);
|
||
assert_eq!(d.linked_only_images().into_iter().collect::<Vec<_>>(), ["a"]);
|
||
}
|
||
}
|