Placed documents: the object, drawn exactly and kept as art by every exporter
This commit is contained in:
parent
02072c310a
commit
6d56195eda
|
|
@ -315,7 +315,7 @@ impl<'a> Scene<'a> {
|
|||
}
|
||||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
|
|
|
|||
|
|
@ -527,7 +527,8 @@ impl Lerp {
|
|||
(NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => Shape::Mesh,
|
||||
(NodeKind::Text(_), NodeKind::Text(_))
|
||||
| (NodeKind::SymbolInstance { .. }, NodeKind::SymbolInstance { .. })
|
||||
| (NodeKind::Image(_), NodeKind::Image(_)) => Shape::Placed,
|
||||
| (NodeKind::Image(_), NodeKind::Image(_))
|
||||
| (NodeKind::PlacedDocument(_), NodeKind::PlacedDocument(_)) => Shape::Placed,
|
||||
_ => match (node_path(a), node_path(b)) {
|
||||
(Some((pa, ra, ma)), Some((pb, rb, mb))) => {
|
||||
Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb), members: ma.zip(mb) }
|
||||
|
|
@ -586,6 +587,7 @@ impl Lerp {
|
|||
*o = lerp_affine(*x, *y, t)
|
||||
}
|
||||
(NodeKind::Image(o), NodeKind::Image(x), NodeKind::Image(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
|
||||
(NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
|
||||
_ => {}
|
||||
}
|
||||
n.mask = None;
|
||||
|
|
|
|||
|
|
@ -199,9 +199,12 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
|||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
|
||||
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
NodeKind::Text(_)
|
||||
| NodeKind::Image(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::SymbolInstance { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub mod overprint;
|
|||
pub mod pattern;
|
||||
pub mod perspective;
|
||||
mod pixels;
|
||||
pub mod placed_document;
|
||||
pub mod profiles;
|
||||
pub mod puppet;
|
||||
pub mod range;
|
||||
|
|
@ -72,6 +73,7 @@ pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, N
|
|||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use perspective::PerspectiveAttachment;
|
||||
pub use placed_document::PlacedDocument;
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use puppet::{PuppetPin, PuppetPins};
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ impl Document {
|
|||
}
|
||||
}
|
||||
|
||||
/// The keys of image blobs only linked images show: what a save writes as their preview.
|
||||
/// 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();
|
||||
|
|
@ -152,6 +152,8 @@ impl Document {
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1447,6 +1447,8 @@ fn map_node(n: &Node, w: &Warper) -> Node {
|
|||
let im = im.clone();
|
||||
return warp_image(&out, &im, w);
|
||||
}
|
||||
// Its art is plain, so it warps at this depth.
|
||||
NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -1607,7 +1609,7 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
|
|||
|
||||
/// Is this one of the live kinds?
|
||||
pub fn is_live(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_))
|
||||
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_))
|
||||
}
|
||||
|
||||
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
|
||||
|
|
@ -1641,6 +1643,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
|
|||
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks),
|
||||
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
|
||||
NodeKind::Repeat(r) => r.expand(),
|
||||
NodeKind::PlacedDocument(p) => p.art(n),
|
||||
_ => vec![n.clone()],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,6 +423,9 @@ pub enum NodeKind {
|
|||
Mesh(GradientMesh),
|
||||
/// Live Repeat (radial / grid / mirror) of source art.
|
||||
Repeat(RepeatSpec),
|
||||
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
|
||||
/// [`crate::placed_document`]).
|
||||
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
|
|
@ -642,6 +645,7 @@ impl Node {
|
|||
NodeKind::Envelope { .. } => "Envelope",
|
||||
NodeKind::Mesh(_) => "Mesh",
|
||||
NodeKind::Repeat(r) => r.kind.label(),
|
||||
NodeKind::PlacedDocument(_) => "Placed Document",
|
||||
}
|
||||
}
|
||||
/// Name shown in the Layers panel: explicit name or `<Kind>`.
|
||||
|
|
@ -715,6 +719,7 @@ impl Node {
|
|||
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
NodeKind::Repeat(r) => r.bounds(),
|
||||
NodeKind::PlacedDocument(p) => Some(p.bounds()),
|
||||
}
|
||||
}
|
||||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||||
|
|
@ -810,6 +815,7 @@ impl Node {
|
|||
}
|
||||
}
|
||||
NodeKind::Image(im) => im.xf = a * im.xf,
|
||||
NodeKind::PlacedDocument(p) => p.xf = a * p.xf,
|
||||
NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf,
|
||||
NodeKind::Blend { children, spec } => {
|
||||
for c in children.iter_mut() {
|
||||
|
|
@ -972,6 +978,10 @@ impl Node {
|
|||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||||
out.push((frame.to_bezpath(), FillRule::NonZero));
|
||||
}
|
||||
NodeKind::PlacedDocument(p) => {
|
||||
let frame = shapes::rectangle(p.natural()).transformed(p.xf);
|
||||
out.push((frame.to_bezpath(), FillRule::NonZero));
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,684 @@
|
|||
//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]).
|
||||
//!
|
||||
//! The object keeps the link to the file and the file's bytes, stored like image data in
|
||||
//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the
|
||||
//! document shows correctly without the file. The art it shows is read from those bytes and never
|
||||
//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting
|
||||
//! expand it like a live object ([`crate::live::expanded`]).
|
||||
//!
|
||||
//! The file is read by the native format's loader, which lives above this crate and registers
|
||||
//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting,
|
||||
//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses
|
||||
//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy
|
||||
//! of the document, so they never meet the user's or show in panels. Placed documents inside a
|
||||
//! placed document are read the same way, up to [`MAX_DEPTH`] deep (documents that place each
|
||||
//! other stop there, showing their previews).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use vectorcraft_color::Paint;
|
||||
|
||||
use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol};
|
||||
|
||||
/// A placed document: an artboard of another VectorCraft file, shown as one locked object.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlacedDocument {
|
||||
/// The file (its `page` is the artboard shown, 1-based).
|
||||
pub link: LinkInfo,
|
||||
/// Key into [`Document::images`]: the file's bytes (see [`key_for`]).
|
||||
pub key: String,
|
||||
/// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub bounding: bool,
|
||||
/// The artboard's size (or the art's) at 100% (pt).
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// Maps the box (0..width, 0..height) into the document.
|
||||
pub xf: Affine,
|
||||
/// Links panel → Placement Options: how the file read again takes this object's place.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub placement: PlacementOptions,
|
||||
}
|
||||
|
||||
/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing
|
||||
/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and
|
||||
/// its preview.
|
||||
pub fn key_for(hash: &str, page: u32, bounding: bool) -> String {
|
||||
format!("{hash}-{page}{}", if bounding { "b" } else { "" })
|
||||
}
|
||||
|
||||
impl PlacedDocument {
|
||||
/// The artboard shown (1-based).
|
||||
pub fn page(&self) -> u32 {
|
||||
self.link.page.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The box at 100%.
|
||||
pub fn natural(&self) -> Rect {
|
||||
Rect::new(0.0, 0.0, self.width, self.height)
|
||||
}
|
||||
|
||||
/// The four corners of the box in the document.
|
||||
pub fn corners(&self) -> [vectorcraft_geom::Point; 4] {
|
||||
let r = self.natural();
|
||||
[(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.xf * vectorcraft_geom::Point::new(x, y))
|
||||
}
|
||||
|
||||
/// The box in the document (axis-aligned).
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.xf.transform_rect_bbox(self.natural())
|
||||
}
|
||||
|
||||
/// The transform from the file's coordinates (its artboard or art bounds `frame`) to the
|
||||
/// document; `None` when either size is empty.
|
||||
pub fn art_xf(&self, frame: Rect) -> Option<Affine> {
|
||||
let ok = self.width > 0.0 && self.height > 0.0 && frame.width() > 0.0 && frame.height() > 0.0;
|
||||
ok.then(|| {
|
||||
self.xf * Affine::scale_non_uniform(self.width / frame.width(), self.height / frame.height()) * Affine::translate((-frame.x0, -frame.y0))
|
||||
})
|
||||
}
|
||||
|
||||
/// The art the object shows, as plain objects with `n`'s id: one group, its resources named
|
||||
/// as [`Document::with_placed_art`] adds them (nothing until that has read it).
|
||||
pub fn art(&self, n: &Node) -> Vec<Node> {
|
||||
let prefix = prefix(&self.key);
|
||||
read_cached(self).and_then(|e| self.art_node(n, &e, &prefix)).into_iter().collect()
|
||||
}
|
||||
|
||||
/// Read `e`'s art placed as this object `n` shows it (`n`'s id), its resources renamed with
|
||||
/// `prefix`.
|
||||
fn art_node(&self, n: &Node, e: &ReadDocument, prefix: &str) -> Option<Node> {
|
||||
let m = self.art_xf(e.frame)?;
|
||||
let mut g = (*e.art).clone();
|
||||
rename(&mut g, prefix);
|
||||
g.transform(m, true);
|
||||
each_mut(&mut g, &mut |c| crate::pattern::transform_pattern_paints(c, m));
|
||||
g.id = n.id;
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// An image object showing preview `bytes` (stored under this object's key) in this object's
|
||||
/// box: what it shows and outputs as when the file can't be read.
|
||||
pub fn preview_image(&self, bytes: &[u8]) -> Option<ImageObject> {
|
||||
let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?;
|
||||
let px = Affine::scale_non_uniform(self.width / w as f64, self.height / h as f64);
|
||||
Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The names the resources of the art stored under `key` take in a prepared document.
|
||||
fn prefix(key: &str) -> String {
|
||||
format!("{RESOURCE_PREFIX}{key}/")
|
||||
}
|
||||
|
||||
/// `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.mask {
|
||||
walk_all(&m.art, f);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk_all(c, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// The roots of `d`'s art: layers, symbol definitions and pattern swatches.
|
||||
fn roots(d: &Document) -> impl Iterator<Item = &Arc<Node>> {
|
||||
d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| &p.art))
|
||||
}
|
||||
|
||||
fn roots_mut(d: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
|
||||
d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Visit every placed document: in the layers, then in symbol definitions and pattern
|
||||
/// swatches, opacity-mask art included.
|
||||
pub fn visit_placed<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a PlacedDocument)) {
|
||||
for root in roots(self) {
|
||||
walk_all(root, &mut |n| {
|
||||
if let NodeKind::PlacedDocument(p) = &n.kind {
|
||||
f(n, p);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the document hold a placed document anywhere?
|
||||
pub fn has_placed(&self) -> bool {
|
||||
let mut any = false;
|
||||
self.visit_placed(|_, _| any = true);
|
||||
any
|
||||
}
|
||||
|
||||
/// Every placed document (those in the art they show too) replaced by a plain group of that
|
||||
/// art, for apps that don't know them: call it on [`Self::with_placed_art`]'s copy. Only the
|
||||
/// subtrees holding one are copied.
|
||||
pub fn placed_as_groups(&mut self) {
|
||||
fn holds(n: &Node) -> bool {
|
||||
let mut hit = false;
|
||||
walk_all(n, &mut |c| hit |= matches!(c.kind, NodeKind::PlacedDocument(_)));
|
||||
hit
|
||||
}
|
||||
fn update(n: &mut Arc<Node>) {
|
||||
if !holds(n) {
|
||||
return;
|
||||
}
|
||||
if matches!(n.kind, NodeKind::PlacedDocument(_)) {
|
||||
let mut g = crate::live::expanded_group(n, None);
|
||||
g.appearance = n.appearance.clone();
|
||||
*n = Arc::new(g);
|
||||
}
|
||||
// The art shown may hold placed documents too.
|
||||
let n = Arc::make_mut(n);
|
||||
if let Some(m) = &mut n.mask {
|
||||
update(&mut m.art);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
update(c);
|
||||
}
|
||||
}
|
||||
for root in roots_mut(self) {
|
||||
update(root);
|
||||
}
|
||||
}
|
||||
|
||||
/// Placed documents whose key is in `keys` replaced by images of their previews (for output
|
||||
/// when their files can't be read).
|
||||
pub fn placed_as_previews(&mut self, keys: &BTreeSet<String>) {
|
||||
fn update(n: &mut Arc<Node>, keys: &BTreeSet<String>, images: &std::collections::BTreeMap<String, ImageBlob>) {
|
||||
let mut hit = false;
|
||||
walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::PlacedDocument(p) if keys.contains(&p.key)));
|
||||
if !hit {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
if let NodeKind::PlacedDocument(p) = &n.kind
|
||||
&& keys.contains(&p.key)
|
||||
{
|
||||
if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) {
|
||||
n.kind = NodeKind::Image(im);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(m) = &mut n.mask {
|
||||
update(&mut m.art, keys, images);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
update(c, keys, images);
|
||||
}
|
||||
}
|
||||
let images = self.images.clone();
|
||||
for root in roots_mut(self) {
|
||||
update(root, keys, &images);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- previews and the files' bytes ----------
|
||||
|
||||
/// A placed document's preview: this many pixels per point of its box…
|
||||
pub const PREVIEW_PX_PER_PT: f64 = 1.0;
|
||||
/// …and at most this many on its longer side.
|
||||
pub const PREVIEW_MAX: u32 = 1024;
|
||||
/// How deep placed documents inside placed documents are read; deeper ones show their previews.
|
||||
pub const MAX_DEPTH: usize = 8;
|
||||
|
||||
/// Reads `p`'s file again (`None`: not found, or changed since it was read). The engine registers
|
||||
/// one with [`set_file_reader`].
|
||||
pub type FileReader = fn(&PlacedDocument) -> Option<Vec<u8>>;
|
||||
|
||||
static FILE_READER: OnceLock<FileReader> = OnceLock::new();
|
||||
|
||||
/// Register how files are read again (the first registration stays).
|
||||
pub fn set_file_reader(f: FileReader) {
|
||||
// Already set: the same engine, registered by another session.
|
||||
let _ = FILE_READER.set(f);
|
||||
}
|
||||
|
||||
/// The most bytes of files read again kept.
|
||||
const FULL_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Files read again, by key, the most recently read last.
|
||||
type FullFiles = Vec<(String, Arc<Vec<u8>>)>;
|
||||
|
||||
static FULL: Mutex<FullFiles> = Mutex::new(Vec::new());
|
||||
|
||||
fn full() -> MutexGuard<'static, FullFiles> {
|
||||
// Entries are only pushed and removed: a panic while held leaves it whole.
|
||||
FULL.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The file's bytes when they are at hand: stored (`blob` isn't just the preview) or read again
|
||||
/// already. Never reads it.
|
||||
pub fn full_bytes_cached(p: &PlacedDocument, blob: &ImageBlob) -> Option<Arc<Vec<u8>>> {
|
||||
if !blob.is_proxy() {
|
||||
return Some(blob.bytes.clone());
|
||||
}
|
||||
full().iter().rev().find(|(k, _)| *k == p.key).map(|(_, b)| b.clone())
|
||||
}
|
||||
|
||||
/// The file's bytes: stored, read again already, or read again now ([`set_file_reader`]). `None`
|
||||
/// when only the preview can be had.
|
||||
pub fn full_bytes(p: &PlacedDocument, blob: &ImageBlob) -> Option<Arc<Vec<u8>>> {
|
||||
if let Some(b) = full_bytes_cached(p, blob) {
|
||||
return Some(b);
|
||||
}
|
||||
let b = Arc::new(FILE_READER.get()?(p)?);
|
||||
let mut c = full();
|
||||
c.push((p.key.clone(), b.clone()));
|
||||
let mut total: usize = c.iter().map(|(_, b)| b.len()).sum();
|
||||
while total > FULL_BYTES && c.len() > 1 {
|
||||
total = total.saturating_sub(c.remove(0).1.len());
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
|
||||
/// The size in pixels of image bytes `bytes` (a preview), read from its header.
|
||||
pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
|
||||
}
|
||||
|
||||
// ---------- reading files ----------
|
||||
|
||||
/// The prefix of the names the resources of placed documents' art take in a prepared document:
|
||||
/// `placed:<key>/<name>`.
|
||||
pub const RESOURCE_PREFIX: &str = "placed:";
|
||||
|
||||
/// Reads a placed document's file (its bytes) into what it shows: a document holding the
|
||||
/// resources (no layers), the art as one group in the file's coordinates, and the frame shown
|
||||
/// (the artboard, or the art's bounds). The native format lives above this crate, so the engine
|
||||
/// registers one with [`set_loader`].
|
||||
pub type Loader = fn(&PlacedDocument, &[u8]) -> Option<(Document, Node, Rect)>;
|
||||
|
||||
static LOADER: OnceLock<Loader> = OnceLock::new();
|
||||
|
||||
/// Register how files are read (the first registration stays).
|
||||
pub fn set_loader(l: Loader) {
|
||||
// Already set: the same loader, registered by another session.
|
||||
let _ = LOADER.set(l);
|
||||
}
|
||||
|
||||
/// The most files kept read whatever their size.
|
||||
const CACHE_SIZE: usize = 64;
|
||||
/// Past [`CACHE_SIZE`], the most bytes of files kept read (their own sizes, standing for what
|
||||
/// reading them holds).
|
||||
const CACHE_BYTES: usize = 256 << 20;
|
||||
|
||||
/// A placed document's file read: the art it shows, ready to draw with its own resources.
|
||||
pub struct ReadDocument {
|
||||
key: String,
|
||||
/// Read from the preview, not the file.
|
||||
preview: bool,
|
||||
/// The bytes read (what tells a cached read is still the one shown).
|
||||
bytes: Arc<Vec<u8>>,
|
||||
/// The artboard (or art bounds) shown: what the object's box shows.
|
||||
pub frame: Rect,
|
||||
/// The art as one group, in the file's coordinates. Its images, symbols and patterns are
|
||||
/// `doc`'s, under their own names.
|
||||
pub art: Arc<Node>,
|
||||
/// The file's resources and setup (no layers: the art is `art`).
|
||||
pub doc: Arc<Document>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
e: Arc<ReadDocument>,
|
||||
used: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
entries: std::collections::HashMap<(String, bool), Entry>,
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
static CACHE: std::sync::LazyLock<Mutex<Cache>> = std::sync::LazyLock::new(Default::default);
|
||||
|
||||
fn cache() -> MutexGuard<'static, Cache> {
|
||||
// A panic while the cache was held leaves it whole (entries are only inserted and removed).
|
||||
CACHE.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// The read of `key` (its preview's when `preview`; made from `bytes` when given), marked used.
|
||||
fn get(&mut self, key: &str, preview: bool, bytes: Option<&Arc<Vec<u8>>>) -> Option<Arc<ReadDocument>> {
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
let e = self.entries.get_mut(&(key.to_string(), preview))?;
|
||||
if bytes.is_some_and(|b| !Arc::ptr_eq(&e.e.bytes, b)) {
|
||||
return None;
|
||||
}
|
||||
e.used = clock;
|
||||
Some(e.e.clone())
|
||||
}
|
||||
|
||||
fn insert(&mut self, e: Arc<ReadDocument>) {
|
||||
self.clock += 1;
|
||||
self.entries.insert((e.key.clone(), e.preview), Entry { e, used: self.clock });
|
||||
// The least recently used go while over both limits.
|
||||
let mut total: usize = self.entries.values().map(|x| x.e.bytes.len()).sum();
|
||||
while self.entries.len() > CACHE_SIZE && total > CACHE_BYTES {
|
||||
let Some(k) = self.entries.iter().min_by_key(|(_, x)| x.used).map(|(k, _)| k.clone()) else { break };
|
||||
if let Some(x) = self.entries.remove(&k) {
|
||||
total = total.saturating_sub(x.e.bytes.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `p`'s file (`bytes`) read, from the cache when it was read already: what the renderer draws a
|
||||
/// placed document from. `None` when it can't be read (or no loader is registered).
|
||||
pub fn read(p: &PlacedDocument, bytes: &Arc<Vec<u8>>) -> Option<Arc<ReadDocument>> {
|
||||
if let Some(e) = cache().get(&p.key, false, Some(bytes)) {
|
||||
return Some(e);
|
||||
}
|
||||
let loader = LOADER.get()?;
|
||||
let (doc, art, frame) = loader(p, bytes)?;
|
||||
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: false, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
|
||||
cache().insert(e.clone());
|
||||
Some(e)
|
||||
}
|
||||
|
||||
/// `p`'s preview (`bytes`) read as art: one image filling the box.
|
||||
fn read_preview(p: &PlacedDocument, bytes: &Arc<Vec<u8>>) -> Option<Arc<ReadDocument>> {
|
||||
if let Some(e) = cache().get(&p.key, true, Some(bytes)) {
|
||||
return Some(e);
|
||||
}
|
||||
let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?;
|
||||
let frame = p.natural();
|
||||
let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0));
|
||||
doc.layers.clear();
|
||||
doc.swatches.clear();
|
||||
let mime = if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { "image/png" };
|
||||
doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None });
|
||||
let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64);
|
||||
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() };
|
||||
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(im)))], clip: false });
|
||||
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
|
||||
cache().insert(e.clone());
|
||||
Some(e)
|
||||
}
|
||||
|
||||
/// `p`'s file as read already (else its preview's), without reading it.
|
||||
pub fn read_cached(p: &PlacedDocument) -> Option<Arc<ReadDocument>> {
|
||||
let mut c = cache();
|
||||
c.get(&p.key, false, None).or_else(|| c.get(&p.key, true, None))
|
||||
}
|
||||
|
||||
/// Keep `read` (what [`Loader`] gives), just read from `bytes` for `p` (when it was placed), so
|
||||
/// drawing it doesn't read it again.
|
||||
pub fn prime(p: &PlacedDocument, bytes: Arc<Vec<u8>>, (doc, art, frame): (Document, Node, Rect)) {
|
||||
cache().insert(Arc::new(ReadDocument { key: p.key.clone(), preview: false, bytes, frame, art: Arc::new(art), doc: Arc::new(doc) }));
|
||||
}
|
||||
|
||||
/// The id the read art's own nodes take (their object's, once expanded).
|
||||
fn art_id() -> crate::NodeId {
|
||||
crate::NodeId(0)
|
||||
}
|
||||
|
||||
/// Visit `n` and every descendant (opacity-mask art included) mutably.
|
||||
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
|
||||
f(n);
|
||||
if let Some(m) = &mut n.mask {
|
||||
each_mut(Arc::make_mut(&mut m.art), f);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
each_mut(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
|
||||
/// `n`'s references to images, symbols, patterns and placed files prefixed with `prefix`. Colours
|
||||
/// keep their swatch links (a spot colour stays a plate, one with the parent's spot of that name).
|
||||
fn rename(n: &mut Node, prefix: &str) {
|
||||
let paint = |p: &mut Paint| {
|
||||
if let Paint::Pattern { pattern, .. } = p {
|
||||
*pattern = format!("{prefix}{pattern}");
|
||||
}
|
||||
};
|
||||
each_mut(n, &mut |n| {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(f) => paint(&mut f.paint),
|
||||
AppearanceItem::Stroke(s) => paint(&mut s.paint),
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
paint(&mut r.style.fill);
|
||||
paint(&mut r.style.stroke);
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => im.key = format!("{prefix}{}", im.key),
|
||||
NodeKind::PlacedDocument(p) => p.key = format!("{prefix}{}", p.key),
|
||||
NodeKind::SymbolInstance { symbol, .. } => *symbol = format!("{prefix}{symbol}"),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// This document ready to export its placed documents: their files read (the file read again
|
||||
/// when only the preview is stored, else the preview), and the resources their art uses added
|
||||
/// under names of their own ([`RESOURCE_PREFIX`]); the same for the placed documents in that
|
||||
/// art, up to [`MAX_DEPTH`] deep. Borrowed when there is none. (The renderer draws them from
|
||||
/// [`read`] instead.)
|
||||
pub fn with_placed_art(&self) -> Cow<'_, Document> {
|
||||
let mut queue: Vec<(PlacedDocument, usize)> = vec![];
|
||||
let mut seen = BTreeSet::new();
|
||||
self.visit_placed(|_, p| {
|
||||
if seen.insert(p.key.clone()) {
|
||||
queue.push((p.clone(), 0));
|
||||
}
|
||||
});
|
||||
if queue.is_empty() {
|
||||
return Cow::Borrowed(self);
|
||||
}
|
||||
let mut d = self.clone();
|
||||
let mut found: Vec<Arc<ReadDocument>> = vec![];
|
||||
while let Some((p, depth)) = queue.pop() {
|
||||
let Some(blob) = d.images.get(&p.key).cloned() else { continue };
|
||||
let full = if depth < MAX_DEPTH { full_bytes(&p, &blob) } else { None };
|
||||
if full.and_then(|b| read(&p, &b)).is_none() {
|
||||
let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() };
|
||||
if let Some(bytes) = preview {
|
||||
read_preview(&p, &bytes);
|
||||
}
|
||||
}
|
||||
// What the art (`PlacedDocument::art`) will show.
|
||||
let Some(e) = read_cached(&p) else { continue };
|
||||
let prefix = prefix(&p.key);
|
||||
add_resources(&mut d, &e.doc, &prefix);
|
||||
// The placed documents in that art, under their names here.
|
||||
let inner = std::iter::once(&e.art).chain(e.doc.symbols.iter().map(|s| &s.art)).chain(e.doc.patterns.iter().flat_map(|p| &p.art));
|
||||
for root in inner {
|
||||
walk_all(root, &mut |n| {
|
||||
if let NodeKind::PlacedDocument(q) = &n.kind {
|
||||
let key = format!("{prefix}{}", q.key);
|
||||
if seen.insert(key.clone()) {
|
||||
queue.push((PlacedDocument { key, ..(**q).clone() }, depth + 1));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
found.push(e);
|
||||
}
|
||||
// Read last, so all stay read while the art is made (see `PlacedDocument::art`).
|
||||
{
|
||||
let mut c = cache();
|
||||
for e in &found {
|
||||
c.get(&e.key, e.preview, None);
|
||||
}
|
||||
}
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Drop the resources [`Self::with_placed_art`] added under its names (what a save leaves out).
|
||||
pub fn drop_placed_resources(&mut self) {
|
||||
self.symbols.retain(|s| !s.name.starts_with(RESOURCE_PREFIX));
|
||||
self.patterns.retain(|p| !p.name.starts_with(RESOURCE_PREFIX));
|
||||
self.images.retain(|k, _| !k.starts_with(RESOURCE_PREFIX));
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources of `src` (a read file's) added to `d` under `prefix`: symbols, patterns, images;
|
||||
/// swatches by name, when `d` has none of that name.
|
||||
fn add_resources(d: &mut Document, src: &Document, prefix: &str) {
|
||||
for s in &src.symbols {
|
||||
let name = format!("{prefix}{}", s.name);
|
||||
if !d.symbols.iter().any(|x| x.name == name) {
|
||||
let mut s = s.clone();
|
||||
rename(Arc::make_mut(&mut s.art), prefix);
|
||||
d.symbols.push(Symbol { name, ..s });
|
||||
}
|
||||
}
|
||||
for p in &src.patterns {
|
||||
let name = format!("{prefix}{}", p.name);
|
||||
if d.pattern(&name).is_none() {
|
||||
let mut p = p.clone();
|
||||
p.art.iter_mut().for_each(|a| rename(Arc::make_mut(a), prefix));
|
||||
d.patterns.push(PatternDef { name, ..p });
|
||||
}
|
||||
}
|
||||
for (k, b) in &src.images {
|
||||
d.images.entry(format!("{prefix}{k}")).or_insert_with(|| b.clone());
|
||||
}
|
||||
for w in &src.swatches {
|
||||
if d.swatch(&w.name).is_none() {
|
||||
d.swatches.push(w.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::NodeId;
|
||||
|
||||
fn placed(key: &str) -> PlacedDocument {
|
||||
PlacedDocument {
|
||||
link: LinkInfo::new("/art/logo.vectorcraft"),
|
||||
key: key.into(),
|
||||
bounding: false,
|
||||
width: 80.0,
|
||||
height: 40.0,
|
||||
xf: Affine::translate((10.0, 10.0)),
|
||||
placement: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whatever the bytes: a 40×20 artboard showing a symbol instance and, when the bytes say
|
||||
/// `nest`, a placed document of key `inner`.
|
||||
fn stub_loader(_: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
d.layers.clear();
|
||||
let art = Node::new(
|
||||
NodeId(50),
|
||||
NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
|
||||
);
|
||||
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
|
||||
d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
|
||||
let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))];
|
||||
if bytes.starts_with(b"nest") {
|
||||
d.images.insert("inner".into(), ImageBlob::new("application/json", b"leaf".to_vec()));
|
||||
let inner = PlacedDocument { key: "inner".into(), width: 10.0, height: 10.0, xf: Affine::IDENTITY, ..placed("inner") };
|
||||
children.push(Arc::new(Node::new(NodeId(52), NodeKind::PlacedDocument(Box::new(inner)))));
|
||||
}
|
||||
Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_json() {
|
||||
let n = Node::new(NodeId(3), NodeKind::PlacedDocument(Box::new(placed("doc1-1"))));
|
||||
let v = serde_json::to_value(&n).unwrap();
|
||||
assert_eq!(v["kind"]["type"], "placeddocument");
|
||||
assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft");
|
||||
let back: Node = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, n);
|
||||
assert_eq!(key_for("img00", 2, true), "img00-2b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() {
|
||||
set_loader(stub_loader);
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) });
|
||||
d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec()));
|
||||
let p = placed("doc1-1");
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, Node::new(NodeId(7), NodeKind::PlacedDocument(Box::new(p.clone())))).unwrap();
|
||||
assert!(d.has_placed());
|
||||
let prepared = d.with_placed_art();
|
||||
let names: Vec<&str> = prepared.symbols.iter().map(|s| s.name.as_str()).collect();
|
||||
// The placed document inside is read too, its resources under its own names.
|
||||
assert_eq!(names, ["Star", "placed:doc1-1/Star", "placed:placed:doc1-1/inner/Star"]);
|
||||
assert!(prepared.images.contains_key("placed:doc1-1/pic"));
|
||||
assert_eq!(d.symbols.len(), 1, "the document itself is unchanged");
|
||||
// The art: a group of the drawing, its resources renamed.
|
||||
let n = d.node(NodeId(7)).unwrap();
|
||||
let art = p.art(n);
|
||||
let [g] = &art[..] else { panic!("{art:?}") };
|
||||
let mut refs = vec![];
|
||||
g.walk(&mut |c| match &c.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => refs.push(symbol.clone()),
|
||||
NodeKind::PlacedDocument(q) => {
|
||||
// Its 10×10 box at the file's origin, scaled ×2 into the box at (10, 10).
|
||||
assert_eq!(q.bounds(), Rect::new(10.0, 10.0, 30.0, 30.0));
|
||||
refs.push(q.key.clone())
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
assert_eq!(refs, ["placed:doc1-1/Star", "placed:doc1-1/inner"]);
|
||||
// For older apps: plain groups all the way down.
|
||||
let mut older = prepared.clone().into_owned();
|
||||
older.placed_as_groups();
|
||||
assert!(!older.has_placed());
|
||||
let mut saved = prepared.into_owned();
|
||||
saved.drop_placed_resources();
|
||||
assert_eq!((saved.symbols.len(), saved.images.len()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_files_than_the_cache_holds_all_draw() {
|
||||
set_loader(stub_loader);
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let layer = d.layers[0].id;
|
||||
let n = CACHE_SIZE + 10;
|
||||
for i in 0..n {
|
||||
let key = format!("many{i}-1");
|
||||
d.images.insert(key.clone(), ImageBlob::new("application/json", format!("doc {i}").into_bytes()));
|
||||
d.insert(Some(layer), i, Node::new(NodeId(100 + i as u64), NodeKind::PlacedDocument(Box::new(placed(&key))))).unwrap();
|
||||
}
|
||||
let _prepared = d.with_placed_art();
|
||||
let mut empty = 0;
|
||||
d.visit_placed(|n, p| empty += usize::from(p.art(n).is_empty()));
|
||||
assert_eq!(empty, 0, "every file of the document is kept read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_cant_be_read_shows_its_preview() {
|
||||
set_loader(stub_loader);
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(8, 4, image::Rgba([0, 200, 0, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut blob = ImageBlob::new("image/png", png);
|
||||
blob.proxy = Some(blob.bytes.clone());
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
d.images.insert("gone-1".into(), blob);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, Node::new(NodeId(7), NodeKind::PlacedDocument(Box::new(placed("gone-1"))))).unwrap();
|
||||
// No file reader: only the preview can be had.
|
||||
let prepared = d.with_placed_art();
|
||||
assert!(prepared.images.contains_key("placed:gone-1/preview"));
|
||||
let n = d.node(NodeId(7)).unwrap();
|
||||
let NodeKind::PlacedDocument(p) = &n.kind else { panic!("not a placed document") };
|
||||
let art = p.art(n);
|
||||
assert_eq!(art.first().and_then(Node::geometric_bounds), Some(Rect::new(10.0, 10.0, 90.0, 50.0)));
|
||||
}
|
||||
}
|
||||
|
|
@ -324,14 +324,19 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
|
|||
Some(m)
|
||||
}
|
||||
|
||||
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
|
||||
/// document has none (export it as is).
|
||||
/// A copy of `doc` ready to export: every live geometry effect baked into plain paths, and the
|
||||
/// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when
|
||||
/// there is nothing to do (export it as is).
|
||||
pub fn bake_document(doc: &Document) -> Option<Document> {
|
||||
if !doc.layers.iter().any(|l| needs_bake(l)) {
|
||||
return None;
|
||||
let placed = doc.with_placed_art();
|
||||
if !placed.layers.iter().any(|l| needs_bake(l)) {
|
||||
return match placed {
|
||||
std::borrow::Cow::Owned(d) => Some(d),
|
||||
std::borrow::Cow::Borrowed(_) => None,
|
||||
};
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
let layers = doc.layers.clone();
|
||||
let mut d = placed.into_owned();
|
||||
let layers = d.layers.clone();
|
||||
d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
|
||||
Some(d)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub fn needs_outline(n: &Node) -> bool {
|
|||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Mesh(_)
|
||||
| NodeKind::Repeat(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ fn node_refs(n: &Node, f: Visit) {
|
|||
}
|
||||
}
|
||||
NodeKind::Image(im) => f(Ref::Name(Res::Image, &im.key)),
|
||||
NodeKind::PlacedDocument(pl) => f(Ref::Name(Res::Image, &pl.key)),
|
||||
NodeKind::SymbolInstance { symbol, .. } => f(Ref::Name(Res::Symbol, symbol)),
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -444,6 +445,7 @@ impl Relink {
|
|||
}
|
||||
}
|
||||
NodeKind::Image(im) => self.name(Res::Image, &mut im.key),
|
||||
NodeKind::PlacedDocument(pl) => self.name(Res::Image, &mut pl.key),
|
||||
NodeKind::SymbolInstance { symbol, .. } => self.name(Res::Symbol, symbol),
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@ fn not_liquified(n: &Node) -> Option<&'static str> {
|
|||
NodeKind::Mesh(_) => "meshes",
|
||||
NodeKind::Envelope { .. } => "envelopes",
|
||||
NodeKind::Repeat(_) => "repeats",
|
||||
NodeKind::PlacedDocument(_) => "placed documents",
|
||||
NodeKind::Blend { .. } => "blends",
|
||||
_ => return None,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -49,13 +49,14 @@ pub const CATEGORIES: [(&str, &str); 11] = [
|
|||
];
|
||||
|
||||
/// The object counts of `document.info` and their labels.
|
||||
const OBJECT_LABELS: [(&str, &str); 16] = [
|
||||
const OBJECT_LABELS: [(&str, &str); 17] = [
|
||||
("paths", "Paths"),
|
||||
("compoundPaths", "Compound Paths"),
|
||||
("groups", "Groups"),
|
||||
("clipGroups", "Clipping Masks"),
|
||||
("textObjects", "Text Objects"),
|
||||
("images", "Images"),
|
||||
("placedDocuments", "Placed Documents"),
|
||||
("symbolInstances", "Symbol Instances"),
|
||||
("gradients", "Gradient Objects"),
|
||||
("patterns", "Pattern Objects"),
|
||||
|
|
@ -252,6 +253,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
NodeKind::Envelope { .. } => Some("envelopes"),
|
||||
NodeKind::Mesh(_) => Some("meshes"),
|
||||
NodeKind::Repeat(_) => Some("repeats"),
|
||||
NodeKind::PlacedDocument(_) => Some("placedDocuments"),
|
||||
};
|
||||
if let Some(k) = k {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
|
|
|
|||
|
|
@ -1185,6 +1185,8 @@ impl Scratch {
|
|||
let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n));
|
||||
art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g))
|
||||
}
|
||||
// A placed document stays one: its art's resources aren't the document's.
|
||||
NodeKind::PlacedDocument(_) => None,
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &effects::outline_text;
|
||||
Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook))))
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ impl<'a> Scene<'a> {
|
|||
}
|
||||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
//! Versions: v1 wrote anchors as `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`; v2 as
|
||||
//! `{p: [x, y], in?, out?, kind?}` with default-valued fields left out; v3 keeps the assigned colour
|
||||
//! profiles in `document.color_profiles` (before: `document.unknown.colorProfiles`) and may be
|
||||
//! compressed; v4 may keep large data (images, the PDF, profiles of at least
|
||||
//! compressed; v4 may keep large data (images, placed documents, the PDF, profiles of at least
|
||||
//! [`INLINE_MAX`] bytes) after the JSON instead of in it: the JSON is followed by [`BLOB_MAGIC`] and
|
||||
//! the bytes, and such an entry says where they are (`"blob": [offset, length]`) instead of giving
|
||||
//! `data`. Every version loads, and [`save_with`] writes any of them for older apps (under the
|
||||
|
|
@ -137,6 +137,11 @@ impl Blobs {
|
|||
}
|
||||
}
|
||||
|
||||
/// The MIME type of a preview's bytes (JPEG or PNG).
|
||||
fn preview_mime(bytes: &[u8]) -> &'static str {
|
||||
if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME }
|
||||
}
|
||||
|
||||
/// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON
|
||||
/// value.
|
||||
fn split_tail(text: &[u8]) -> (&[u8], &[u8]) {
|
||||
|
|
@ -232,6 +237,14 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result<Vec<u8>, FormatError
|
|||
o.check()?;
|
||||
let pretty = o.pretty && !o.compress;
|
||||
let mut d = doc.without_edit_modes().into_owned();
|
||||
// Older apps don't know placed documents: they get the art the objects show, with its
|
||||
// resources. Otherwise resources only exporting adds are never saved.
|
||||
if o.version < VERSION && d.has_placed() {
|
||||
d = d.with_placed_art().into_owned();
|
||||
d.placed_as_groups();
|
||||
} else {
|
||||
d.drop_placed_resources();
|
||||
}
|
||||
let linked = d.linked_only_images();
|
||||
// The blobs go in the file's `images` (only those the document uses).
|
||||
let blobs = std::mem::take(&mut d.images);
|
||||
|
|
@ -256,7 +269,7 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result<Vec<u8>, FormatError
|
|||
// Include Linked Files keeps the file's bytes whenever they are loaded.
|
||||
let preview_only = linked.contains(k) && (!o.include_linked || b.is_proxy());
|
||||
let image = match b.proxy.as_ref().filter(|_| preview_only) {
|
||||
Some(p) => out_blobs.image(vectorcraft_doc::links::PROXY_MIME, p, true),
|
||||
Some(p) => out_blobs.image(preview_mime(p), p, true),
|
||||
None => out_blobs.image(&b.mime, &b.bytes, false),
|
||||
};
|
||||
(k.as_str(), image)
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ impl<'a> Scene<'a> {
|
|||
}
|
||||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
|
|
|
|||
|
|
@ -982,7 +982,7 @@ impl Exporter<'_> {
|
|||
}
|
||||
}
|
||||
// Live blends/envelopes/meshes export their evaluated (expanded) form.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(s, c, page, false);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ mod ink;
|
|||
mod live;
|
||||
mod paint;
|
||||
mod pattern;
|
||||
pub mod placed_document;
|
||||
pub mod proof;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -580,6 +581,23 @@ impl Renderer {
|
|||
self.render_node_thumbnail(doc, doc.node(id)?, size, None)
|
||||
}
|
||||
|
||||
/// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`.
|
||||
pub fn render_node(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<Rendered> {
|
||||
let inv = view.inverse();
|
||||
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
|
||||
let mut ctx = single_threaded_context(w, h);
|
||||
let opts = RenderOptions::default();
|
||||
let frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display };
|
||||
(self.knockout, self.nested, self.backdrop) = (false, 0, None);
|
||||
self.clip_paths.clear();
|
||||
self.draw_arc(&mut ctx, &frame, n);
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render(&mut pm, &mut self.resources);
|
||||
Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
|
||||
/// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into
|
||||
/// `size`×`size` pixels, optionally over a solid premultiplied background.
|
||||
pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option<Rendered> {
|
||||
|
|
@ -916,7 +934,9 @@ impl Renderer {
|
|||
self.draw_node(ctx, f, &art, true);
|
||||
}
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
self.draw_live_node(ctx, f, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1649,6 +1669,8 @@ mod tests_layeropts;
|
|||
#[cfg(test)]
|
||||
mod tests_objectfx;
|
||||
#[cfg(test)]
|
||||
mod tests_placed_document;
|
||||
#[cfg(test)]
|
||||
mod tests_setup;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegradient;
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ impl Renderer {
|
|||
/// Draw the evaluated content of a live node (no transparency group).
|
||||
pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
|
||||
match &a.kind {
|
||||
NodeKind::PlacedDocument(p) => self.draw_placed(ctx, f, p),
|
||||
NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => {
|
||||
let items = self.live_expanded(f.doc, a, cache);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
//! Drawing placed documents ([`PlacedDocument`]): the art read from the file
|
||||
//! ([`vectorcraft_doc::placed_document::read`]) drawn with its own resources, through a view that
|
||||
//! maps its artboard into the object's box (nothing is copied into the document).
|
||||
//!
|
||||
//! A document opened with only its placed documents' previews
|
||||
//! ([`vectorcraft_doc::ImageBlob::is_proxy`]) shows them until the file is at hand
|
||||
//! ([`vectorcraft_doc::placed_document::full_bytes_cached`]), and for good when it can't be found. Placed documents inside a placed document are read from their
|
||||
//! files, up to [`MAX_DEPTH`] deep; deeper ones show their previews.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::placed_document::MAX_DEPTH;
|
||||
use vectorcraft_doc::{Node, PlacedDocument};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vello_cpu::RenderContext;
|
||||
|
||||
use crate::{Frame, Renderer};
|
||||
|
||||
thread_local! {
|
||||
/// How deep inside placed documents this thread is drawing.
|
||||
static DEPTH: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// While alive: drawing inside a placed document, one level deeper.
|
||||
struct Inside;
|
||||
|
||||
impl Inside {
|
||||
fn enter() -> Self {
|
||||
DEPTH.with(|d| d.set(d.get() + 1));
|
||||
Inside
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Inside {
|
||||
fn drop(&mut self) {
|
||||
DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
|
||||
}
|
||||
}
|
||||
|
||||
fn depth() -> usize {
|
||||
DEPTH.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Render `art` of a placed document's file (`doc`, its resources) into `w`×`h` transparent
|
||||
/// pixels through `view`, the placed documents inside it one level deeper.
|
||||
pub fn render_inside(r: &mut Renderer, doc: &vectorcraft_doc::Document, art: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<crate::Rendered> {
|
||||
let _inside = Inside::enter();
|
||||
r.render_node(doc, art, w, h, view)
|
||||
}
|
||||
|
||||
/// The mean scale of `a`.
|
||||
fn mean_scale(a: Affine) -> f64 {
|
||||
a.determinant().abs().sqrt()
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Draw placed document `p` in frame `f`.
|
||||
pub(crate) fn draw_placed(&mut self, ctx: &mut RenderContext, f: &Frame, p: &PlacedDocument) {
|
||||
let Some(blob) = f.doc.images.get(&p.key) else { return };
|
||||
// The file's bytes: stored, read again already, or (inside another placed document, while
|
||||
// not too deep) read again now.
|
||||
let full = match depth() {
|
||||
0 => vectorcraft_doc::placed_document::full_bytes_cached(p, blob),
|
||||
d if d < MAX_DEPTH => vectorcraft_doc::placed_document::full_bytes(p, blob),
|
||||
_ => None,
|
||||
};
|
||||
let read = full.and_then(|bytes| vectorcraft_doc::placed_document::read(p, &bytes));
|
||||
let Some(e) = read else {
|
||||
// Only the preview: drawn scaled into the box.
|
||||
if blob.is_proxy()
|
||||
&& let Some(im) = p.preview_image(&blob.bytes)
|
||||
{
|
||||
self.draw_image(ctx, f, &im);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(m) = p.art_xf(e.frame) else { return };
|
||||
let k = mean_scale(m);
|
||||
if !k.is_finite() || k <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
// The art in its own coordinates, with its own resources.
|
||||
let inner = Frame { doc: &e.doc, view: f.view * m, visible: m.inverse().transform_rect_bbox(f.visible), px: f.px / k, ..*f };
|
||||
let _inside = Inside::enter();
|
||||
self.draw_arc(ctx, &inner, &e.art);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
//! Placed documents drawn exactly: with their own art, as their previews when that is all there
|
||||
//! is, and a few levels deep when they place themselves.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ImageBlob, LinkInfo, Node, NodeId, NodeKind, PlacedDocument};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Every file reads as a 40×20 artboard filled blue; a file whose bytes start with `loop` also
|
||||
/// places itself (the same key) in its middle.
|
||||
fn blue_page(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
d.layers.clear();
|
||||
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
|
||||
let mut children = vec![Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), blue))];
|
||||
if bytes.starts_with(b"loop") {
|
||||
d.images.insert(p.key.clone(), ImageBlob::new("application/json", bytes.to_vec()));
|
||||
let me = PlacedDocument { xf: Affine::translate((10.0, 5.0)) * Affine::scale(0.5), ..p.clone() };
|
||||
children.push(Arc::new(Node::new(NodeId(2), NodeKind::PlacedDocument(Box::new(me)))));
|
||||
}
|
||||
Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0)))
|
||||
}
|
||||
|
||||
/// A 100×100 document showing one placed file (`bytes` under `key`) in the box (10, 10)–(90, 50).
|
||||
fn doc_with(key: &str, bytes: &[u8]) -> Document {
|
||||
vectorcraft_doc::placed_document::set_loader(blue_page);
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert(key.into(), ImageBlob::new("application/json", bytes.to_vec()));
|
||||
let p = PlacedDocument {
|
||||
link: LinkInfo::new("/art/card.vectorcraft"),
|
||||
key: key.into(),
|
||||
bounding: false,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
xf: Affine::translate((10.0, 10.0)) * Affine::scale(2.0),
|
||||
placement: Default::default(),
|
||||
};
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, Node::new(NodeId(50), NodeKind::PlacedDocument(Box::new(p)))).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn doc(key: &str) -> Document {
|
||||
doc_with(key, format!("{{\"doc\": \"{key}\"}}").as_bytes())
|
||||
}
|
||||
|
||||
fn render(d: &Document) -> Rendered {
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_document_draws_exactly_with_its_own_art() {
|
||||
let d = doc("exact-1");
|
||||
let r = render(&d);
|
||||
assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255], "blue inside the box");
|
||||
assert_eq!(&r.pixel(95, 95)[..3], &[255, 255, 255], "nothing outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_document_kept_as_its_preview_draws_the_preview() {
|
||||
let mut d = doc("preview-1");
|
||||
// Only the preview (a 20×10 green PNG) is there, and the file can't be read here.
|
||||
let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([0, 200, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut blob = ImageBlob::new("image/png", png);
|
||||
blob.proxy = Some(blob.bytes.clone());
|
||||
d.images.insert("preview-1".into(), blob);
|
||||
let exact = render(&d);
|
||||
assert_eq!(&exact.pixel(50, 30)[..3], &[0, 200, 0], "the preview, in the box");
|
||||
assert_eq!(&exact.pixel(95, 95)[..3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_that_places_itself_draws_a_few_levels_deep_then_stops() {
|
||||
let d = doc_with("loop-1", b"loop");
|
||||
let t = std::time::Instant::now();
|
||||
let r = render(&d);
|
||||
assert!(t.elapsed().as_secs() < 10);
|
||||
assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]);
|
||||
}
|
||||
|
|
@ -343,7 +343,7 @@ impl Reuse {
|
|||
}
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
(self.turns, self.scales) = (false, false)
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
|
|
@ -1357,7 +1357,7 @@ impl Writer<'_> {
|
|||
(self.xf, self.instance_xf) = saved;
|
||||
}
|
||||
// Live blends/envelopes/meshes export their evaluated (expanded) form.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
self.share_images(&g);
|
||||
self.node_body(&g);
|
||||
|
|
|
|||
|
|
@ -98,11 +98,16 @@ fn native_roundtrip(doc: &Document) -> Result<(Value, Value), String> {
|
|||
}
|
||||
let mut lost = None;
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& doc.images.contains_key(&im.key)
|
||||
&& !back.images.contains_key(&im.key)
|
||||
let key = match &n.kind {
|
||||
NodeKind::Image(im) => Some(&im.key),
|
||||
NodeKind::PlacedDocument(p) => Some(&p.key),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = key
|
||||
&& doc.images.contains_key(key)
|
||||
&& !back.images.contains_key(key)
|
||||
{
|
||||
lost = Some(im.key.clone());
|
||||
lost = Some(key.clone());
|
||||
}
|
||||
});
|
||||
if let Some(k) = lost {
|
||||
|
|
|
|||
Loading…
Reference in New Issue