mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 18:56:50 -06:00
Share duplicate file data while keeping distinct link paths independent, and leave unused assets out of saved documents. Validation: all six cargo xtask ci gates passed on the merge candidate; 1,170 native tests passed and all 15 WASM packages checked. Independent regression checks cover updates, relinking, embedding, undo and save/reopen. Task: #420
352 lines
13 KiB
Rust
352 lines
13 KiB
Rust
//! Placed files (assets): one asset per distinct file, the assets a document uses, and the
|
|
//! compaction a save applies (duplicates merged, unused assets left out).
|
|
//!
|
|
//! An asset holds what is shared by every placement of a file (bytes, link, type, the PDF page
|
|
//! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`].
|
|
|
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
use std::sync::Arc;
|
|
|
|
use crate::story::Story;
|
|
use crate::{Asset, AssetId, Content, Document, Item};
|
|
|
|
/// Nesting limit for groups, anchored objects, table cells and notes. A document nested deeper
|
|
/// is treated as using every asset, so nothing it references is merged away or pruned.
|
|
const MAX_DEPTH: usize = 64;
|
|
|
|
impl Asset {
|
|
/// Whether `other` holds the same file: the same type, page, link and bytes. Files at
|
|
/// different paths stay apart even with identical bytes, since each can be updated or
|
|
/// relinked on its own. Assets without bytes (a link that couldn't be read) are the same only
|
|
/// when they link to the same path.
|
|
pub fn same_file(&self, other: &Asset) -> bool {
|
|
if self.mime != other.mime || self.page != other.page {
|
|
return false;
|
|
}
|
|
if self.link != other.link {
|
|
return false;
|
|
}
|
|
match (self.data.is_empty(), other.data.is_empty()) {
|
|
(false, false) => self.same_bytes(other),
|
|
(true, true) => self.link.is_some(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn same_bytes(&self, other: &Asset) -> bool {
|
|
Arc::ptr_eq(&self.data, &other.data) || (self.data.len() == other.data.len() && *self.data == *other.data)
|
|
}
|
|
}
|
|
|
|
/// The asset in `assets` holding the same file as `asset`.
|
|
pub fn find_same_asset(assets: &BTreeMap<AssetId, Arc<Asset>>, asset: &Asset) -> Option<AssetId> {
|
|
assets.values().find(|a| a.same_file(asset)).map(|a| a.id)
|
|
}
|
|
|
|
/// Add `asset` to `assets` unless they already hold the same file; returns the id placements
|
|
/// should reference. A new asset with the bytes of an existing one shares its buffer.
|
|
pub fn insert_asset(assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset) -> AssetId {
|
|
if let Some(id) = find_same_asset(assets, &asset) {
|
|
return id;
|
|
}
|
|
if !asset.data.is_empty()
|
|
&& let Some(a) = assets.values().find(|a| a.same_bytes(&asset))
|
|
{
|
|
asset.data = Arc::clone(&a.data);
|
|
}
|
|
let id = asset.id;
|
|
assets.insert(id, Arc::new(asset));
|
|
id
|
|
}
|
|
|
|
/// Calls `f` with every asset reference in `it` and its children; false when nesting exceeded
|
|
/// [`MAX_DEPTH`] (some references weren't visited).
|
|
fn item_refs(it: &Item, f: &mut impl FnMut(AssetId), depth: usize) -> bool {
|
|
if depth > MAX_DEPTH {
|
|
return false;
|
|
}
|
|
if let Content::Graphic(g) = &it.content {
|
|
f(g.asset);
|
|
}
|
|
if let Some(p) = it.media.as_ref().and_then(|m| m.poster) {
|
|
f(p);
|
|
}
|
|
it.children().iter().all(|c| item_refs(c, f, depth + 1))
|
|
}
|
|
|
|
/// Asset references in a story's anchored objects, graphic cells and notes (recursively).
|
|
fn story_refs(st: &Story, f: &mut impl FnMut(AssetId), depth: usize) -> bool {
|
|
if depth > MAX_DEPTH {
|
|
return false;
|
|
}
|
|
let mut complete = st.objects.iter().all(|o| item_refs(&o.item, f, depth + 1));
|
|
for t in st.tables.values() {
|
|
for c in &t.cells {
|
|
if let Some(g) = &c.graphic {
|
|
f(g.asset);
|
|
}
|
|
complete &= story_refs(&c.text, f, depth + 1);
|
|
}
|
|
}
|
|
for n in st.notes.iter().chain(&st.endnotes) {
|
|
complete &= story_refs(&n.text, f, depth + 1);
|
|
}
|
|
complete
|
|
}
|
|
|
|
/// Every asset reference in a story (its anchored objects, graphic cells and notes).
|
|
pub fn story_asset_refs(st: &Story) -> Vec<AssetId> {
|
|
let mut out = Vec::new();
|
|
story_refs(st, &mut |a| out.push(a), 0);
|
|
out
|
|
}
|
|
|
|
/// Point a story's references to a key of `map` at its value.
|
|
pub fn remap_story_assets(st: &mut Story, map: &HashMap<AssetId, AssetId>) {
|
|
if !map.is_empty() {
|
|
remap_story(st, map, 0);
|
|
}
|
|
}
|
|
|
|
fn item_uses(it: &Item, map: &HashMap<AssetId, AssetId>) -> bool {
|
|
let mut hit = false;
|
|
item_refs(it, &mut |a| hit |= map.contains_key(&a), 0);
|
|
hit
|
|
}
|
|
|
|
fn story_uses(st: &Story, map: &HashMap<AssetId, AssetId>) -> bool {
|
|
let mut hit = false;
|
|
story_refs(st, &mut |a| hit |= map.contains_key(&a), 0);
|
|
hit
|
|
}
|
|
|
|
fn remap_id(id: &mut AssetId, map: &HashMap<AssetId, AssetId>) {
|
|
if let Some(n) = map.get(id) {
|
|
*id = *n;
|
|
}
|
|
}
|
|
|
|
/// Rewrites the references in `it`, copying only the shared items that change.
|
|
fn remap_item(it: &mut Arc<Item>, map: &HashMap<AssetId, AssetId>, depth: usize) {
|
|
if depth > MAX_DEPTH || !item_uses(it, map) {
|
|
return;
|
|
}
|
|
remap_item_in(Arc::make_mut(it), map, depth);
|
|
}
|
|
|
|
fn remap_item_in(it: &mut Item, map: &HashMap<AssetId, AssetId>, depth: usize) {
|
|
if let Content::Graphic(g) = &mut it.content {
|
|
remap_id(&mut g.asset, map);
|
|
}
|
|
if let Some(p) = it.media.as_mut().and_then(|m| m.poster.as_mut()) {
|
|
remap_id(p, map);
|
|
}
|
|
if let Some(kids) = it.children_mut() {
|
|
for k in kids {
|
|
remap_item(k, map, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remap_story(st: &mut Story, map: &HashMap<AssetId, AssetId>, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
for o in &mut st.objects {
|
|
if item_uses(&o.item, map) {
|
|
remap_item_in(&mut Arc::make_mut(o).item, map, depth + 1);
|
|
}
|
|
}
|
|
for t in st.tables.values_mut() {
|
|
if !t.cells.iter().any(|c| c.graphic.as_ref().is_some_and(|g| map.contains_key(&g.asset)) || story_uses(&c.text, map)) {
|
|
continue;
|
|
}
|
|
for c in &mut Arc::make_mut(t).cells {
|
|
if let Some(g) = &mut c.graphic {
|
|
remap_id(&mut g.asset, map);
|
|
}
|
|
remap_story(&mut c.text, map, depth + 1);
|
|
}
|
|
}
|
|
for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
|
if story_uses(&n.text, map) {
|
|
remap_story(&mut Arc::make_mut(n).text, map, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// Add a placed file, reusing the asset of an identical file already in the document;
|
|
/// returns the id to reference.
|
|
pub fn add_asset(&mut self, asset: Asset) -> AssetId {
|
|
insert_asset(&mut self.assets, asset)
|
|
}
|
|
|
|
/// Copy asset `id` of `src` into this document (or reuse the identical asset it already
|
|
/// has); returns the id to reference here, `None` when `src` has no such asset.
|
|
pub fn adopt_asset(&mut self, src: &Document, id: AssetId) -> Option<AssetId> {
|
|
let a = src.assets.get(&id)?;
|
|
if let Some(same) = find_same_asset(&self.assets, a) {
|
|
return Some(same);
|
|
}
|
|
let fresh = AssetId(self.alloc());
|
|
Some(self.add_asset(Asset { id: fresh, ..(**a).clone() }))
|
|
}
|
|
|
|
/// Calls `f` with every asset reference: graphics on document and parent spreads (in
|
|
/// groups and multi-state objects too), media posters, anchored and inline objects, graphic
|
|
/// table cells and notes. False when nesting exceeded the depth limit.
|
|
fn asset_refs(&self, f: &mut impl FnMut(AssetId)) -> bool {
|
|
let mut complete = true;
|
|
for sp in self.spreads.iter().chain(&self.parents) {
|
|
for it in &sp.items {
|
|
complete &= item_refs(it, f, 0);
|
|
}
|
|
}
|
|
for st in self.stories.values() {
|
|
complete &= story_refs(st, f, 0);
|
|
}
|
|
complete
|
|
}
|
|
|
|
/// The assets something in the document references. When the document nests deeper than
|
|
/// the traversal limit, every asset counts as used.
|
|
pub fn used_assets(&self) -> BTreeSet<AssetId> {
|
|
let mut used = BTreeSet::new();
|
|
if !self.asset_refs(&mut |a| {
|
|
used.insert(a);
|
|
}) {
|
|
return self.assets.keys().copied().collect();
|
|
}
|
|
used
|
|
}
|
|
|
|
/// Point every reference to a key of `map` at its value.
|
|
pub fn remap_assets(&mut self, map: &HashMap<AssetId, AssetId>) {
|
|
if map.is_empty() {
|
|
return;
|
|
}
|
|
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
|
|
if sp.items.iter().any(|it| item_uses(it, map)) {
|
|
for it in &mut Arc::make_mut(sp).items {
|
|
remap_item(it, map, 0);
|
|
}
|
|
}
|
|
}
|
|
for st in self.stories.values_mut() {
|
|
if story_uses(st, map) {
|
|
remap_story(Arc::make_mut(st), map, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Merge assets holding the same file into the first of them (lowest id), rewriting the
|
|
/// references; returns how many assets were removed.
|
|
pub fn merge_duplicate_assets(&mut self) -> usize {
|
|
if !self.asset_refs(&mut |_| {}) {
|
|
return 0;
|
|
}
|
|
let mut reps: HashMap<(usize, &str, u32), Vec<&Arc<Asset>>> = HashMap::new();
|
|
let mut map = HashMap::new();
|
|
for a in self.assets.values() {
|
|
let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default();
|
|
match bucket.iter().find(|r| r.same_file(a)) {
|
|
Some(r) => {
|
|
map.insert(a.id, r.id);
|
|
}
|
|
None => bucket.push(a),
|
|
}
|
|
}
|
|
self.remap_assets(&map);
|
|
self.assets.retain(|id, _| !map.contains_key(id));
|
|
map.len()
|
|
}
|
|
|
|
/// Merge duplicate assets and drop the ones nothing references (what a saved file holds).
|
|
pub fn compact_assets(&mut self) {
|
|
self.merge_duplicate_assets();
|
|
let used = self.used_assets();
|
|
self.assets.retain(|id, _| used.contains(id));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{Graphic, ItemId, LayerId, Shape, StoryId};
|
|
use designcraft_geom::{Affine, Rect, shapes};
|
|
|
|
fn asset(id: u64, data: &[u8]) -> Asset {
|
|
Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 }
|
|
}
|
|
|
|
fn graphic_item(id: u64, asset: u64, crop: f64) -> Item {
|
|
let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
|
it.content = Content::Graphic(Graphic {
|
|
asset: AssetId(asset),
|
|
size: (10.0, 10.0),
|
|
xf: Affine::translate((crop, 0.0)),
|
|
auto_fit: Default::default(),
|
|
fit_align: 4,
|
|
crop: [crop; 4],
|
|
});
|
|
it
|
|
}
|
|
|
|
#[test]
|
|
fn identical_files_share_one_asset() {
|
|
let mut d = Document::new(&Default::default());
|
|
let a = d.add_asset(asset(1, b"same"));
|
|
let b = d.add_asset(asset(2, b"same"));
|
|
let c = d.add_asset(asset(3, b"diff"));
|
|
let p = d.add_asset(Asset { page: 1, ..asset(4, b"same") });
|
|
assert_eq!(a, b);
|
|
assert_ne!(a, c);
|
|
assert_ne!(a, p);
|
|
assert_eq!(d.assets.len(), 3);
|
|
// Unreadable links match by path only.
|
|
let m1 = d.add_asset(Asset { link: Some("/m/1.png".into()), ..asset(5, b"") });
|
|
let m2 = d.add_asset(Asset { link: Some("/m/2.png".into()), ..asset(6, b"") });
|
|
let m3 = d.add_asset(asset(7, b""));
|
|
let m4 = d.add_asset(asset(8, b""));
|
|
assert_ne!(m1, m2);
|
|
assert_ne!(m3, m4);
|
|
}
|
|
|
|
#[test]
|
|
fn files_at_different_paths_stay_separate_assets() {
|
|
let mut d = Document::new(&Default::default());
|
|
let linked = |id, path: &str| Asset { link: Some(path.into()), ..asset(id, b"same") };
|
|
let embedded = d.add_asset(asset(1, b"same"));
|
|
let a = d.add_asset(linked(2, "/p/a.png"));
|
|
let b = d.add_asset(linked(3, "/q/b.png"));
|
|
assert_eq!(d.add_asset(linked(4, "/p/a.png")), a, "the same path placed again");
|
|
assert_eq!(d.assets.len(), 3);
|
|
assert!(embedded != a && a != b && embedded != b);
|
|
assert_eq!(d.assets[&embedded].link, None);
|
|
assert_eq!(d.assets[&b].link.as_deref(), Some("/q/b.png"));
|
|
assert!(Arc::ptr_eq(&d.assets[&a].data, &d.assets[&b].data), "identical bytes share one buffer");
|
|
}
|
|
|
|
#[test]
|
|
fn compaction_merges_duplicates_and_drops_unused_assets() {
|
|
let mut d = Document::new(&Default::default());
|
|
for (id, data) in [(1, b"one"), (2, b"one"), (3, b"two"), (4, b"old")] {
|
|
d.assets.insert(AssetId(id), Arc::new(asset(id, data)));
|
|
}
|
|
let sp = Arc::make_mut(d.spreads.first_mut().unwrap());
|
|
sp.items.push(Arc::new(graphic_item(10, 1, 1.0)));
|
|
sp.items.push(Arc::new(graphic_item(11, 2, 2.0)));
|
|
// A graphic cell and an anchored object in a story.
|
|
let mut st = Story::new(StoryId(20));
|
|
st.objects.push(Arc::new(crate::AnchoredObject::new(graphic_item(12, 3, 3.0), Default::default())));
|
|
d.stories.insert(StoryId(20), Arc::new(st));
|
|
assert_eq!(d.used_assets(), [1, 2, 3].map(AssetId).into_iter().collect());
|
|
|
|
d.compact_assets();
|
|
assert_eq!(d.assets.keys().copied().collect::<Vec<_>>(), vec![AssetId(1), AssetId(3)]);
|
|
let crops: Vec<(AssetId, f64)> = d.spreads[0].items.iter().filter_map(|i| i.graphic().map(|g| (g.asset, g.crop[0]))).collect();
|
|
assert_eq!(crops, vec![(AssetId(1), 1.0), (AssetId(1), 2.0)]);
|
|
}
|
|
}
|