diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 04903dc0d..b8429f860 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -21,7 +21,7 @@ mod snippets; pub use bytes::{DOCUMENT_SNIFF_BYTES, base64_decode, base64_encode, from_bytes, is_document, to_bytes, with_document_extension}; pub(crate) use open::load_document_fonts; pub use open::{RecentPreview, recent_preview}; -pub(crate) use place::{crop_pdf, document_of, read_source, validate_placement}; +pub(crate) use place::{crop_pdf, crop_pdf_from, document_of, prepare_placed, read_source, validate_placement}; pub use registry::specs; pub(crate) use snippets::{snippet_bytes, snippet_place}; diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 82d589d4d..7a8a37b95 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -6,7 +6,8 @@ use super::bytes::{base64_decode, from_bytes}; use super::{ Affine, Asset, AssetId, Content, Graphic, Item, ItemId, PdfCrop, Rect, Result, Selection, Session, Shape, Value, bad, json, ok, shapes, str_param, }; -use crate::cmd::links::{FileLink, give_bytes}; +use crate::cmd::links::{FileLink, give_placed}; +use crate::cmd::native_links::Pending; pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { // The file's size and time before it is read (a linked text file remembers them). @@ -45,8 +46,10 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { if let Some(mime) = designcraft_doc::media_mime(&name) { let file = link.as_deref().map(|l| FileLink::of(std::path::Path::new(l), before)); let mut asset = Asset { name, mime: mime.to_string(), ..Default::default() }; - give_bytes(&mut asset, bytes, file, embed, document.as_deref()); - return crate::cmd::media::place_media(s, p, asset); + let pending = give_placed(&mut asset, bytes, file, embed, document.as_deref()); + let r = crate::cmd::media::place_media(s, p, asset)?; + prepare_placed(s, pending); + return Ok(r); } // Text files (plain, Word, RTF) flow into frames. if designcraft_textimport::is_text_file(&name) { @@ -98,10 +101,11 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { let mime = designcraft_render::image_mime(&bytes).to_string(); let file = link.as_deref().map(|l| FileLink::of(std::path::Path::new(l), before)); let mut asset = Asset { page: pdf_page, name, mime, pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; - give_bytes(&mut asset, bytes, file, embed, document.as_deref()); // The graphic's natural size: the box in points (the whole page when the box can't be found, // and then the document says so). - let (bw, bh) = match crop_pdf(&mut asset) { + let cropped = crop_pdf_from(&mut asset, &bytes); + let pending = give_placed(&mut asset, bytes, file, embed, document.as_deref()); + let (bw, bh) = match cropped { Some(size) => size, None => { asset.pdf_crop = PdfCrop::Crop; @@ -127,9 +131,11 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); if target_frame.is_none() && px.is_none() && py.is_none() && load_cursor_param { - return load_cursor(s, asset, (bw, bh), append); + let r = load_cursor(s, asset, (bw, bh), append)?; + prepare_placed(s, pending); + return Ok(r); } - s.edit(|d, sel| { + let r = s.edit(|d, sel| { let fresh = AssetId(d.alloc()); let aid = d.add_asset(Asset { id: fresh, ..asset }); // The width the whole page (or image) gets; the height follows the proportions. @@ -192,23 +198,48 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { }; *sel = Selection::items(vec![id]); Ok(json!({"id": id.0, "asset": aid.0})) - }) + })?; + prepare_placed(s, pending); + Ok(r) +} + +/// Have the hash and proxy of graphics just placed into the active document made in the +/// background ([`crate::cmd::native_links::prepare`]). +pub(crate) fn prepare_placed(s: &mut Session, pending: impl IntoIterator) { + if let Ok(st) = s.doc_mut() { + crate::cmd::native_links::prepare(st, pending.into_iter().collect()); + } } /// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in /// points. The crop box is the whole page and has none; nor has a page that can't be read, which -/// keeps its crop choice (a missing link relinked later gets its box then). +/// keeps its crop choice (a missing link relinked later gets its box then). Only a PDF with +/// another box chosen is read. pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { a.pdf_box = None; - let bytes = a.data.load().ok()?; - if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&bytes) { + if !crops_pdf(a) { return None; } - let (frac, size) = designcraft_render::pdf_crop_box(&bytes, a.page as usize, a.pdf_crop)?; + let bytes = a.data.load().ok()?; + crop_pdf_from(a, &bytes) +} + +/// [`crop_pdf`] with `a`'s bytes, `bytes`, in hand. +pub(crate) fn crop_pdf_from(a: &mut Asset, bytes: &[u8]) -> Option<(f64, f64)> { + a.pdf_box = None; + if !crops_pdf(a) || !designcraft_render::is_pdf(bytes) { + return None; + } + let (frac, size) = designcraft_render::pdf_crop_box(bytes, a.page as usize, a.pdf_crop)?; a.pdf_box = Some(frac); Some(size) } +/// Whether `a` is a PDF placed with a box other than its crop box, which only its bytes locate. +fn crops_pdf(a: &Asset) -> bool { + a.pdf_crop != PdfCrop::Crop && a.mime == "application/pdf" +} + /// A file to place: its bytes and name, and its link record when it was read from a path. pub(crate) struct Source { pub bytes: Vec, @@ -219,10 +250,10 @@ pub(crate) struct Source { } impl Source { - /// Give `a` these bytes: linked to the file they were read from, else embedded - /// ([`give_bytes`]). `a`'s `mime` and `page` are set. - pub(crate) fn give(self, a: &mut Asset, document: Option<&std::path::Path>) { - give_bytes(a, self.bytes, self.file, self.embed, document); + /// Give `a` these bytes: linked to the file they were read from once the returned work is + /// done, else embedded ([`give_placed`]). `a`'s `mime` and `page` are set. + pub(crate) fn give(self, a: &mut Asset, document: Option<&std::path::Path>) -> Option { + give_placed(a, self.bytes, self.file, self.embed, document) } } @@ -291,8 +322,10 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { pixels: Some((pw, ph)), ..Default::default() }; - source.give(&mut asset, document.as_deref()); - return load_cursor(s, asset, natural, append); + let pending = source.give(&mut asset, document.as_deref()); + let r = load_cursor(s, asset, natural, append)?; + prepare_placed(s, pending); + return Ok(r); } let sources = p.get("files").and_then(Value::as_array).ok_or_else(|| bad("place.load", "files must be an array"))?; if sources.is_empty() || sources.len() > MAX_LOADED_IMAGES { @@ -300,6 +333,7 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { } check_cursor_room(s, sources.len(), append, "place.load")?; let mut images = Vec::with_capacity(sources.len()); + let mut pending = Vec::new(); for source in sources { let mut source = read_source(source)?; source.embed |= embed; @@ -314,7 +348,7 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { pixels: Some((pw, ph)), ..Default::default() }; - source.give(&mut asset, document.as_deref()); + pending.extend(source.give(&mut asset, document.as_deref())); images.push((asset, size)); } let queue = s.edit(|d, _| { @@ -325,6 +359,7 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { } Ok(queue) })?; + prepare_placed(s, pending); if append && s.loaded.is_some() { s.loaded_queue.extend(queue); } else { diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs index d7a2d4b0a..c7aa3123d 100644 --- a/crates/engine/src/cmd/file/save.rs +++ b/crates/engine/src/cmd/file/save.rs @@ -18,6 +18,8 @@ pub(super) fn file_save(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; // Before anything about the document changes: a refused save leaves it as it was. crate::file_access::check_write(&path).map_err(EngineError::Other)?; + // Graphics just placed whose hash the background job hasn't made yet are saved embedded. + crate::cmd::native_links::settle_pending(st); // Prepare paths for the destination without changing the live document until persistence succeeds. let destination = std::path::Path::new(&path); let from = st.path.as_deref().map(std::path::Path::new); @@ -66,6 +68,7 @@ pub(super) fn file_save_a_copy(s: &mut Session, p: &Value) -> Result { let from = st.path.as_deref().map(std::path::Path::new); let mut copy = crate::cmd::native_links::for_destination(&st.doc, from, destination).unwrap_or_else(|| (*st.doc).clone()); crate::cmd::datamerge::refresh_relative_paths(&mut copy, destination); + crate::cmd::native_links::settle_copy(st, &mut copy); if embed { crate::cmd::native_links::embed_linked(&mut copy, "file.saveACopy")?; } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 3dd956205..cd705bf5b 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -227,6 +227,7 @@ pub(crate) fn give_bytes(a: &mut Asset, bytes: impl Into>>, file: Op }; if embed || cfg!(target_arch = "wasm32") { record_file(a, &bytes, file, document); + a.link_hash = Some(crate::asset_io::content_hash(&bytes)); a.storage = Storage::Embedded; a.data = AssetData::embedded(bytes); return; @@ -234,32 +235,90 @@ pub(crate) fn give_bytes(a: &mut Asset, bytes: impl Into>>, file: Op link_to_file(a, bytes, file, document, None); } +/// [`give_bytes`] for a graphic being placed, leaving out the work that grows with the file: its +/// content hash and its proxy are made in the background ([`super::native_links::prepare`] the +/// returned work once the asset is in the document). Until then the graphic is embedded with the +/// file's link record and no hash, and drawn from its bytes; [`link_prepared`] links it when they +/// are in. A save before that writes it embedded ([`super::native_links::settle_pending`]). +/// Placing many press-size images would otherwise decode and hash each one before the app +/// responds again. Without a file, or on the web (no threads), this is [`give_bytes`]. +#[must_use = "the hash and proxy are made only once the returned work is queued"] +pub(crate) fn give_placed( + a: &mut Asset, + bytes: impl Into>>, + file: Option, + embed: bool, + document: Option<&Path>, +) -> Option { + let bytes: Arc> = bytes.into(); + let file = match file { + Some(file) if cfg!(not(target_arch = "wasm32")) => file, + file => { + give_bytes(a, bytes, file, embed, document); + return None; + } + }; + a.proxy = None; + record_file(a, &bytes, file, document); + a.link_hash = None; + a.storage = Storage::Embedded; + a.data = AssetData::embedded(Arc::clone(&bytes)); + Some(super::native_links::Pending::of(a, bytes, !embed)) +} + /// Link `a` to `file`, whose bytes (`bytes`) were just read: the file's record (paths, size and /// time, content hash) and a proxy to draw while the file can't be read (`preview` when there is /// one, else made from the bytes). The bytes stay in memory for the first draw. pub(crate) fn link_to_file(a: &mut Asset, bytes: Arc>, file: FileLink, document: Option<&Path>, preview: Option) { record_file(a, &bytes, file, document); + a.link_hash = Some(crate::asset_io::content_hash(&bytes)); + link_recorded(a, bytes, designcraft_doc::SourceKey::fresh(), document, |a, bytes| preview.or_else(|| proxy_of(a, bytes))); +} + +/// A graphic placed with [`give_placed`] (embedded, its link record complete but for the hash) +/// once its content `hash` and its `proxy` are made: linked to its file under the key its bytes +/// already have, so nothing drawn from them is decoded again, or with only the hash added when it +/// was placed embedded (`link` false). +pub(super) fn link_prepared(a: &mut Asset, hash: String, link: bool, document: Option<&Path>, proxy: Option) { + a.link_hash = Some(hash); + if !link { + return; + } + let Some(bytes) = a.data.embedded_bytes().cloned() else { return }; + let key = a.data.key(); + link_recorded(a, bytes, key, document, |_, _| proxy); +} + +/// `a`, whose link record is complete, as a link to its file under `key`: drawn with `proxy`'s +/// image while the file can't be read, and `bytes` (just read from the file) kept in memory for +/// the first draw. Embedded under `key` when the record doesn't name a file. +fn link_recorded( + a: &mut Asset, + bytes: Arc>, + key: designcraft_doc::SourceKey, + document: Option<&Path>, + proxy: impl FnOnce(&Asset, &[u8]) -> Option, +) { a.link_fingerprint = Some(a.link_fingerprint.unwrap_or(Fingerprint { size: bytes.len() as u64, mtime: UNVERIFIED_MTIME })); let Some(source) = LinkedSource::of(a, document) else { a.storage = Storage::Embedded; a.proxy = None; - a.data = AssetData::embedded(bytes); + a.data = AssetData::embedded_under(key, bytes); return; }; a.storage = Storage::Linked; - a.proxy = preview.or_else(|| proxy_of(a, &bytes)); - a.data = AssetData::linked(source); + a.proxy = proxy(a, &bytes); + a.data = AssetData::linked_under(key, source); a.data.keep_resident(bytes); } /// Keep with `a` the record of `file`, which `bytes` were read from: its absolute path, its path -/// relative to the `document` file, the size and time that vouch for the bytes -/// ([`loaded_fingerprint`]) and their content hash. +/// relative to the `document` file and the size and time that vouch for the bytes +/// ([`loaded_fingerprint`]). The caller sets the content hash. fn record_file(a: &mut Asset, bytes: &[u8], file: FileLink, document: Option<&Path>) { a.link_fingerprint = loaded_fingerprint(file.before, bytes); a.link_relative = document.and_then(|d| super::native_links::relative_to(d, Path::new(&file.path))); a.link = Some(file.path); - a.link_hash = Some(crate::asset_io::content_hash(bytes)); } /// Does `bytes` (a file's) hold `a`'s bytes: its recorded hash, or, without one, its bytes? @@ -821,10 +880,10 @@ fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, document: Option<&Pat a.pixels = Some(px); a.page = page; a.name = file_name(&read.to_string_lossy()); + // A placed PDF keeps its crop choice; its box is found on the new file. + super::file::crop_pdf_from(&mut a, &bytes); let file = FileLink::of(read, before); give_bytes(&mut a, bytes, Some(file), false, document); - // A placed PDF keeps its crop choice; its box is found on the new file. - super::file::crop_pdf(&mut a); Ok(Arc::new(a)) } @@ -988,7 +1047,10 @@ fn embed(s: &mut Session, p: &Value) -> Result { a.proxy = None; a.link_hash = hash; Ok(Value::Null) - }) + })?; + // Just placed and not linked yet: the background work leaves it embedded. + s.doc_mut()?.links.keep_embedded(key); + Ok(Value::Null) } /// The file in `folder` named like the original (or `name` with an extension for `mime`) that @@ -1179,6 +1241,7 @@ mod relink_folder_tests { std::fs::write(&path, r#""#).unwrap(); s.doc_mut().unwrap().selection = Default::default(); s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap(); + crate::cmd::native_links::wait(&mut s); std::fs::rename(&path, dir.join("new").join(name)).unwrap(); } (s, dir) @@ -1246,6 +1309,7 @@ mod relink_folder_tests { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": orig.to_string_lossy()})).unwrap(); + crate::cmd::native_links::wait(&mut s); // The file moves away: relink from the new folder. std::fs::rename(&orig, dir.join("new").join("logo.svg")).unwrap(); let r = s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap(); @@ -1309,6 +1373,7 @@ mod shared_asset_tests { for (path, x) in [(&a, 72), (&b, 200)] { s.execute("edit.deselectAll", &json!({})).unwrap(); s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap(); + crate::cmd::native_links::wait(&mut s); } assert_eq!(s.doc().unwrap().doc.assets.len(), 2); let rows = |s: &mut Session| -> Vec<(String, Value)> { @@ -1614,6 +1679,7 @@ mod text_link_tests { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap(); + crate::cmd::native_links::wait(&mut s); let status = |s: &mut Session| text_rows(s)[0]["status"].as_str().unwrap().to_string(); // Size and time as placed: not read. @@ -1695,6 +1761,7 @@ mod text_link_tests { let sid = fr["story"].as_u64().unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 6})).unwrap(); let r = s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap(); + crate::cmd::native_links::wait(&mut s); assert!(r["warnings"][0].as_str().unwrap().contains("not linked"), "{r}"); assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().file_link.is_none()); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/engine/src/cmd/links/tests.rs b/crates/engine/src/cmd/links/tests.rs index 7898e3d68..ba8359ee4 100644 --- a/crates/engine/src/cmd/links/tests.rs +++ b/crates/engine/src/cmd/links/tests.rs @@ -33,6 +33,7 @@ fn unchanged_linked_images_are_not_read_again() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap(); + crate::cmd::native_links::wait(&mut s); let status = |s: &mut Session| { let l = s.execute("links.list", &json!({})).unwrap(); l.as_array().unwrap().iter().find(|r| r.get("asset").is_some()).unwrap()["status"].as_str().unwrap().to_string() @@ -140,6 +141,7 @@ fn a_graphic_rewritten_with_the_same_bytes_stays_ok() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + crate::cmd::native_links::wait(&mut s); rewrite(&image, 60); let a = s.doc().unwrap().doc.assets[&aid].clone(); assert!(a.link_fingerprint.is_some()); @@ -181,7 +183,9 @@ fn preview_rows_look_at_no_file() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let place = |s: &mut Session, path: &Path, x: u32| { - AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap()["asset"].as_u64().unwrap()) + let id = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap()["asset"].as_u64().unwrap()); + crate::cmd::native_links::wait(s); + id }; let ids = [place(&mut s, &linked, 72), place(&mut s, &embedded, 144), place(&mut s, &gone, 216)]; s.execute("links.embed", &json!({"asset": ids[1].0})).unwrap(); @@ -245,6 +249,7 @@ fn a_file_written_just_now_is_not_vouched_for_by_its_time() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + crate::cmd::native_links::wait(&mut s); let kept = s.doc().unwrap().doc.assets[&aid].link_fingerprint.unwrap(); assert_eq!((kept.size, kept.mtime), (image.len() as u64, UNVERIFIED_MTIME), "the time can't vouch for the bytes"); let mut other = image.clone(); @@ -271,6 +276,7 @@ fn a_stopped_check_looks_at_no_further_file() { let path = dir.join(name); std::fs::write(&path, png(x, 10)).unwrap(); s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap(); + crate::cmd::native_links::wait(&mut s); } let st = s.doc().unwrap(); let mut checks = LinkListChecks::default(); @@ -300,6 +306,7 @@ fn a_file_found_unchanged_by_its_bytes_is_remembered_with_the_document() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + crate::cmd::native_links::wait(&mut s); s.execute("file.saveAs", &json!({"path": dir.join("doc.designcraft").to_string_lossy()})).unwrap(); assert!(!s.doc().unwrap().is_dirty()); set_age(&path, 60); @@ -461,6 +468,7 @@ fn eps_status_tracks_source_bytes_across_save_update_relink_and_embed() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + crate::cmd::native_links::wait(&mut s); assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); let before = s.doc().unwrap().doc.clone(); assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 0); @@ -507,6 +515,7 @@ fn eps_legacy_preview_document_is_upgraded_by_update() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + crate::cmd::native_links::wait(&mut s); let proxy = designcraft_images::eps_proxy(source).unwrap().0; // A document saved before linked storage: the preview embedded, a link without a hash. s.edit(|d, _| { @@ -595,6 +604,7 @@ fn eps_relink_update_and_folder_relink_retain_source() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": original.to_string_lossy()})).unwrap(); + crate::cmd::native_links::wait(&mut s); let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); std::fs::write(&original, eps(20)).unwrap(); assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); @@ -623,6 +633,7 @@ fn pdf_relink_and_update_preserve_selected_page_size() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); + crate::cmd::native_links::wait(&mut s); let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); let a = &s.doc().unwrap().doc.assets[&aid]; @@ -653,6 +664,7 @@ fn pdf_reload_rejects_missing_page_without_edit() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); + crate::cmd::native_links::wait(&mut s); let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); if command == "links.update" { std::fs::write(&original, &short).unwrap(); @@ -690,6 +702,7 @@ fn link_status_relink_update_embed() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": a.to_string_lossy(), "x": 72, "y": 72, "width": 72})).unwrap(); + crate::cmd::native_links::wait(&mut s); let l = s.execute("links.list", &json!({})).unwrap(); assert_eq!(l[0]["status"], "ok"); assert_eq!(l[0]["uses"][0]["ppi"], 10.0, "10 px over 1 inch"); @@ -744,8 +757,11 @@ fn same_size_pngs() -> (Vec, Vec) { (a, b) } +/// Place a file and let the background job link it ([`crate::cmd::native_links::prepare`]). fn placed(s: &mut Session, params: Value) -> AssetId { - AssetId(s.execute("file.place", ¶ms).unwrap()["asset"].as_u64().unwrap()) + let id = AssetId(s.execute("file.place", ¶ms).unwrap()["asset"].as_u64().unwrap()); + crate::cmd::native_links::wait(s); + id } fn asset_of(s: &Session, id: AssetId) -> Arc { @@ -809,6 +825,43 @@ fn a_file_placed_from_a_path_links_to_it_and_embed_true_embeds_it() { assert_eq!((r["storage"].as_str(), r["status"].as_str()), (Some("embedded"), Some("embedded"))); } +/// Placing a large image returns before its proxy and hash are made: until the background job +/// has them it is embedded with its link record and drawn from its bytes, then it links under the +/// same key. A save in between writes it embedded, with the hash, never a link nobody checked. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn placing_leaves_the_proxy_and_hash_to_the_background() { + use designcraft_doc::Storage; + let dir = TempDir::new("deferred"); + let path = dir.0.join("big.png"); + let bytes = png(1600, 1200); + std::fs::write(&path, &bytes).unwrap(); + set_age(&path, 60); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + let a = asset_of(&s, id); + assert!(a.proxy.is_none() && a.link_hash.is_none(), "no proxy or hash is made while placing"); + assert_eq!((a.storage, a.link.as_deref()), (Storage::Embedded, path.to_str())); + assert!(s.doc().unwrap().links.checking(), "the background job is running"); + let key = a.data.key(); + + let early = dir.0.join("early.designcraft"); + s.execute("file.saveACopy", &json!({"path": early.to_string_lossy()})).unwrap(); + let copy = super::super::file::from_bytes(&std::fs::read(&early).unwrap()).unwrap(); + let c = copy.assets.values().next().unwrap(); + let hash = crate::asset_io::content_hash(&bytes); + assert_eq!((c.storage, c.link_hash.as_deref()), (Storage::Embedded, Some(hash.as_str()))); + + crate::cmd::native_links::wait(&mut s); + let a = asset_of(&s, id); + assert_eq!((a.storage, a.link_hash.as_deref()), (Storage::Linked, Some(hash.as_str()))); + assert_eq!(a.data.key(), key, "linked under the key its decodes are cached by"); + assert!(a.proxy.as_ref().is_some_and(|p| p.pixels == (1024, 768)), "the proxy is made in the background"); + assert_eq!(a.data.resident().as_deref(), Some(&bytes)); + assert!(!s.doc().unwrap().links.checking()); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn a_modified_link_is_updated_undoably() { @@ -902,6 +955,7 @@ fn every_image_the_place_cursor_loads_is_linked() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("place.load", &json!({"files": files})).unwrap(); + crate::cmd::native_links::wait(&mut s); s.execute("place.contactSheet", &json!({})).unwrap(); let d = &s.doc().unwrap().doc; let used = d.used_assets(); diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 4f700320a..deb4abf38 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -99,13 +99,18 @@ pub fn specs() -> Vec { pixels: Some(px), ..Default::default() }; - source.give(&mut poster, super::file::document_of(s).as_deref()); - Some(Some(poster)) + let pending = source.give(&mut poster, super::file::document_of(s).as_deref()); + Some(Some((poster, pending))) } Some(Value::Null) => Some(None), _ => None, }; - s.edit(|d, _| { + let (poster, pending) = match poster { + Some(Some((poster, pending))) => (Some(Some(poster)), pending), + Some(None) => (Some(None), None), + None => (None, None), + }; + let r = s.edit(|d, _| { let poster_id = match poster { Some(Some(poster)) => { let fresh = AssetId(d.alloc()); @@ -136,7 +141,9 @@ pub fn specs() -> Vec { return Err(bad("media.options", "select a video or sound")); } ok() - }) + })?; + super::file::prepare_placed(s, pending); + Ok(r) } ), cmd!(query "media.get", "Media", [], None, "{id?} → {kind, name, playOnPageLoad, loop, controls, poster}", has_selection, |s, p| { diff --git a/crates/engine/src/cmd/native_links.rs b/crates/engine/src/cmd/native_links.rs index 156130b2a..e720312a8 100644 --- a/crates/engine/src/cmd/native_links.rs +++ b/crates/engine/src/cmd/native_links.rs @@ -11,8 +11,11 @@ //! the bytes. One with the recorded size and another time is hashed on a background thread: the //! first whose hash is the recorded one is the file, and its time is kept with the asset; when //! none matches the link is Modified and the file's bytes are never used. +//! +//! The same thread makes the content hash and proxy of graphics just placed from files +//! ([`prepare`]), which link to their files once those are in. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex, PoisonError}; @@ -73,6 +76,8 @@ pub struct OpenLinks { /// Embedded images the check found identical to their linked files, so far. migrated: usize, notice: Option, + /// The keys of graphics placed from files whose content hash is still being made ([`prepare`]). + pending: BTreeSet, } impl std::fmt::Debug for OpenLinks { @@ -97,6 +102,18 @@ impl OpenLinks { pub fn take_notice(&mut self) -> Option { self.notice.take() } + + /// Whether `asset` was just placed from a file and its content hash and proxy are still + /// being made ([`prepare`]): embedded with its link record until then. + pub fn preparing(&self, asset: &Asset) -> bool { + unsettled(asset, &self.pending) + } + + /// A graphic placed with the bytes `key` was embedded on purpose (Links › Embed) before the + /// background work linked it: it stays embedded. + pub(crate) fn keep_embedded(&mut self, key: SourceKey) { + self.pending.remove(&key); + } } /// One file check for the background thread. @@ -109,6 +126,9 @@ enum Work { /// Compare the file at `source`'s path (the first of its candidates that has the size of the /// embedded `bytes`) with the embedded bytes of a document saved before linked storage. Migrate { asset: AssetId, key: SourceKey, bytes: Arc>, source: LinkedSource, page: u32 }, + /// Hash the `bytes` of graphics just placed from a file (and make their proxy with `proxy`), + /// to link them with `link`. + Prepare { key: SourceKey, bytes: Arc>, page: u32, proxy: bool, link: bool }, } /// What a [`Work`] found. `key` is the asset's bytes' key when the check started: a result for @@ -133,6 +153,28 @@ enum Outcome { }, /// The file isn't (or can't be shown to be) the embedded bytes: they stay embedded. Unconfirmed, + /// The content hash and proxy of placed graphics' bytes ([`Work::Prepare`]). + Prepared { key: SourceKey, hash: String, proxy: Option, link: bool }, +} + +/// A graphic placed from a file whose content hash and proxy are still to be made +/// ([`super::links::give_placed`]); [`prepare`] has them made in the background. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) struct Pending { + key: SourceKey, + bytes: Arc>, + page: u32, + proxy: bool, + link: bool, +} + +impl Pending { + /// The work for `a`, embedded with `bytes`, to be linked to its file when `link`. Video and + /// sound get no proxy, nor does a graphic that stays embedded. + pub(super) fn of(a: &Asset, bytes: Arc>, link: bool) -> Self { + let media = a.mime.starts_with("video/") || a.mime.starts_with("audio/"); + Pending { key: a.data.key(), bytes, page: a.page, proxy: link && !media, link } + } } #[derive(Default)] @@ -284,24 +326,104 @@ pub(crate) fn start(st: &mut DocState, opened: Opened) -> usize { } #[cfg(not(target_arch = "wasm32"))] { - let job = Arc::new(Job::default()); - lock(&job).work = opened.work.into_iter().rev().collect(); - let roots = crate::file_access::current(); - let worker = Arc::clone(&job); - let worker_roots = roots.clone(); - let spawned = std::thread::Builder::new().name("designcraft-links".into()).spawn(move || run_all(&worker, worker_roots.as_ref())); - if let Err(e) = spawned { - // No thread: check here rather than leave the links unchecked. - log::warn!("checking linked files without a thread: {e}"); - run_all(&job, roots.as_ref()); - } - st.links.job = Some(Arc::new(JobHandle(job))); + st.links.job = Some(spawn(opened.work.into_iter().rev().collect(), Vec::new())); } #[cfg(target_arch = "wasm32")] let _ = opened.work; n } +/// A background job for `work` (taken from the end), its results starting with `results`. +#[cfg(not(target_arch = "wasm32"))] +fn spawn(work: Vec, results: Vec) -> Arc { + let job = Arc::new(Job::default()); + { + let mut state = lock(&job); + state.work = work; + state.results = results; + } + let roots = crate::file_access::current(); + let worker = Arc::clone(&job); + let worker_roots = roots.clone(); + let spawned = std::thread::Builder::new().name("designcraft-links".into()).spawn(move || run_all(&worker, worker_roots.as_ref())); + if let Err(e) = spawned { + // No thread: check here rather than leave the links unchecked. + log::warn!("checking linked files without a thread: {e}"); + run_all(&job, roots.as_ref()); + } + Arc::new(JobHandle(job)) +} + +/// Have the content hash and proxy of graphics just placed into `st`'s document made in the +/// background, in the order given; [`Session::poll_link_checks`] links them when they are in. +pub(crate) fn prepare(st: &mut DocState, pending: Vec) { + if pending.is_empty() { + return; + } + st.links.pending.extend(pending.iter().map(|p| p.key)); + #[cfg(not(target_arch = "wasm32"))] + { + // Taken from the end: the first placed is made first, before checks still waiting. + let work: Vec = + pending.into_iter().rev().map(|Pending { key, bytes, page, proxy, link }| Work::Prepare { key, bytes, page, proxy, link }).collect(); + let mut results = Vec::new(); + if let Some(handle) = &st.links.job { + let mut state = lock(&handle.0); + if !state.done { + state.work.extend(work); + return; + } + // The job has ended: a new one takes over what it found and nobody has applied yet. + results = std::mem::take(&mut state.results); + } + st.links.job = Some(spawn(work, results)); + } +} + +/// Before a save of `d` (`st`'s document or a copy of it): the content hash of each graphic +/// placed from a file whose hash isn't made yet ([`prepare`]), from its bytes, which are embedded +/// until then. It is saved as an embedded copy with a complete link record, never as a link to a +/// file whose bytes nobody has checked; without the hash a later open would take it for a copy +/// saved before linked storage. → whether `d` changed. +fn settle(d: &mut Document, pending: &BTreeSet) -> bool { + let unsettled: Vec = d.assets.values().filter(|a| unsettled(a, pending)).map(|a| a.id).collect(); + for id in &unsettled { + let Some(a) = d.assets.get_mut(id).map(Arc::make_mut) else { continue }; + if let Some(bytes) = a.data.embedded_bytes() { + a.link_hash = Some(crate::asset_io::content_hash(bytes)); + } + } + !unsettled.is_empty() +} + +/// Whether `a` was placed from a file ([`prepare`]) and has no content hash yet. +fn unsettled(a: &Asset, pending: &BTreeSet) -> bool { + a.storage == Storage::Embedded && a.link.is_some() && a.link_hash.is_none() && pending.contains(&a.data.key()) +} + +/// [`settle`] for the live document of `st`, before it is saved. A document without unsaved +/// changes stays so. +pub(crate) fn settle_pending(st: &mut DocState) { + if !st.doc.assets.values().any(|a| unsettled(a, &st.links.pending)) { + return; + } + let clean = !st.is_dirty(); + let mut doc = (*st.doc).clone(); + if settle(&mut doc, &st.links.pending) { + st.doc = Arc::new(doc); + if clean { + st.saved_doc = Arc::clone(&st.doc); + } + } +} + +/// [`settle`] for a copy of `st`'s document, before the copy is written. +pub(crate) fn settle_copy(st: &DocState, copy: &mut Document) { + if !st.links.pending.is_empty() { + settle(copy, &st.links.pending); + } +} + fn lock(job: &Job) -> std::sync::MutexGuard<'_, JobState> { job.state.lock().unwrap_or_else(PoisonError::into_inner) } @@ -310,7 +432,14 @@ fn lock(job: &Job) -> std::sync::MutexGuard<'_, JobState> { fn run_all(job: &Job, roots: Option<&Arc>) { crate::file_access::confine(roots, || { while !job.cancelled() { - let Some(work) = lock(job).work.pop() else { break }; + // Ended in the same step the queue is found empty: work queued later starts a new job. + let next = { + let mut state = lock(job); + let next = state.work.pop(); + state.done |= next.is_none(); + next + }; + let Some(work) = next else { break }; // A file that trips a decoder (the proxy) leaves its asset as it was. let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check(work, &job.cancel))); match outcome { @@ -374,9 +503,44 @@ fn check(work: Work, cancel: &AtomicBool) -> Option { let fingerprint = if super::links::settled(&before) { before } else { Fingerprint { size: len, mtime: super::links::UNVERIFIED_MTIME } }; Some(Outcome::Confirmed { asset, key, path, fingerprint, hash, proxy }) } + Work::Prepare { key, bytes, page, proxy, link } => { + let hash = hash_bytes(&bytes, cancel)?; + if cancel.load(Ordering::Relaxed) { + return None; + } + let proxy = if proxy { designcraft_render::proxy::make_proxy(&bytes, page) } else { None }; + Some(Outcome::Prepared { key, hash, proxy, link }) + } } } +/// [`crate::asset_io::content_hash`] of `bytes`, a chunk at a time → `None` when cancelled. +#[cfg(not(target_arch = "wasm32"))] +fn hash_bytes(bytes: &[u8], cancel: &AtomicBool) -> Option { + use sha2::Digest; + let mut hasher = sha2::Sha256::new(); + for chunk in bytes.chunks(CHUNK) { + if cancel.load(Ordering::Relaxed) { + return None; + } + hasher.update(chunk); + } + Some(hex(hasher)) +} + +/// `sha256:` and the hex digits of what `hasher` was given. +#[cfg(not(target_arch = "wasm32"))] +fn hex(hasher: sha2::Sha256) -> String { + use sha2::Digest; + use std::fmt::Write; + let mut hex = String::with_capacity(7 + 64); + hex.push_str("sha256:"); + for b in hasher.finalize() { + let _ = write!(hex, "{b:02x}"); + } + hex +} + #[cfg(not(target_arch = "wasm32"))] fn fingerprint(path: &Path) -> Option { crate::io::file_metadata(path).map(|m| crate::asset_io::fingerprint_from(&m)) @@ -415,13 +579,7 @@ fn read_file(path: &Path, len: u64, cancel: &AtomicBool, mut keep: impl FnMut(us if at as u64 != len { return Some(None); } - let mut hex = String::with_capacity(7 + 64); - hex.push_str("sha256:"); - for b in hasher.finalize() { - use std::fmt::Write; - let _ = write!(hex, "{b:02x}"); - } - Some(Some(hex)) + Some(Some(hex(hasher))) } impl Session { @@ -526,6 +684,37 @@ fn apply_one(st: &mut DocState, outcome: Outcome, document: Option<&Path>) -> bo true } Outcome::Unconfirmed => false, + Outcome::Prepared { key, hash, proxy, link } => { + // Embedded on purpose since (Links › Embed): it stays so. + if !st.links.pending.remove(&key) { + return false; + } + let proxy = proxy.map(|p| designcraft_doc::Proxy { mime: p.mime.into(), pixels: p.pixels, data: AssetData::embedded(p.data) }); + // Each document changed once, its holders sharing the result: a history entry that + // was the saved state still is. + let mut done: Vec<(Arc, Arc)> = Vec::new(); + let mut prepared = |doc: &mut Arc| { + if let Some((_, after)) = done.iter().find(|(before, _)| Arc::ptr_eq(before, doc)) { + *doc = Arc::clone(after); + return true; + } + let before = Arc::clone(doc); + let changed = link_placed(doc, key, &hash, link, document, proxy.as_ref()); + if changed { + done.push((before, Arc::clone(doc))); + } + changed + }; + let changed = prepared(&mut st.doc); + // The undo history and the last saved state hold the same bytes: linked there too, so + // they don't keep every placed file in memory, and a redo of an undone placement + // brings it back linked. + for entry in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { + prepared(&mut entry.doc); + } + prepared(&mut st.saved_doc); + changed + } } } @@ -536,6 +725,30 @@ fn found(st: &mut DocState, id: AssetId, key: SourceKey, status: LinkStatus) { } } +/// The graphics of `doc` placed with the bytes `key` ([`prepare`]) with their content `hash` and +/// `proxy`, linked to their files when `link` ([`super::links::link_prepared`]) → whether `doc` +/// has any. +fn link_placed( + doc: &mut Arc, + key: SourceKey, + hash: &str, + link: bool, + document: Option<&Path>, + proxy: Option<&designcraft_doc::Proxy>, +) -> bool { + let placed: Vec = + doc.assets.values().filter(|a| a.data.key() == key && a.storage == Storage::Embedded && a.link.is_some()).map(|a| a.id).collect(); + if placed.is_empty() { + return false; + } + let d = Arc::make_mut(doc); + for id in placed { + let Some(a) = d.assets.get_mut(&id).map(Arc::make_mut) else { continue }; + super::links::link_prepared(a, hash.to_owned(), link, document, proxy.cloned()); + } + true +} + /// The status line's message once `n` embedded images were found identical to their files. fn notice(n: usize) -> String { if n == 1 { diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs index 65578f834..3a71bde69 100644 --- a/crates/engine/src/cmd/package/tests.rs +++ b/crates/engine/src/cmd/package/tests.rs @@ -245,8 +245,10 @@ fn package_leaves_missing_links_missing() { let mut s = Session::new(); s.execute("file.new", &json!({"title": "TwoLinks"})).unwrap(); s.execute("file.place", &json!({"path": gone.to_string_lossy(), "x": 10, "y": 10})).unwrap(); + crate::cmd::native_links::wait(&mut s); s.execute("edit.deselectAll", &json!({})).unwrap(); s.execute("file.place", &json!({"path": kept.to_string_lossy(), "x": 10, "y": 200})).unwrap(); + crate::cmd::native_links::wait(&mut s); let idml = dir.join("TwoLinks.idml"); s.execute("file.exportIdml", &json!({"path": idml.to_string_lossy(), "embedImages": false})).unwrap(); std::fs::remove_file(&gone).unwrap(); @@ -313,6 +315,7 @@ fn package_writes_document_links_and_report() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap(); + crate::cmd::native_links::wait(&mut s); assert_eq!(s.doc().unwrap().doc.assets.values().next().unwrap().link_fingerprint, fingerprint(&svg)); s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); let out = dir.join("Pkg"); diff --git a/crates/engine/src/cmd/table/content.rs b/crates/engine/src/cmd/table/content.rs index a021ae9c8..2efcef81e 100644 --- a/crates/engine/src/cmd/table/content.rs +++ b/crates/engine/src/cmd/table/content.rs @@ -85,8 +85,8 @@ pub(super) fn place_graphic(s: &mut Session, p: &Value) -> Result { pixels: Some((pw, ph)), ..Default::default() }; - source.give(&mut asset, crate::cmd::file::document_of(s).as_deref()); - s.edit(|d, sel| { + let pending = source.give(&mut asset, crate::cmd::file::document_of(s).as_deref()); + let placed = s.edit(|d, sel| { let fresh = designcraft_doc::AssetId(d.alloc()); let aid = d.add_asset(designcraft_doc::Asset { id: fresh, ..asset }); let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; @@ -106,7 +106,9 @@ pub(super) fn place_graphic(s: &mut Session, p: &Value) -> Result { }); clamp_selection(d, sel); Ok(json!({"asset": aid.0, "row": r, "col": c})) - }) + })?; + crate::cmd::file::prepare_placed(s, pending); + Ok(placed) } pub(super) fn sort_rows(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests/roots.rs b/crates/engine/src/tests/roots.rs index 0f9b9654e..1abc628d6 100644 --- a/crates/engine/src/tests/roots.rs +++ b/crates/engine/src/tests/roots.rs @@ -36,6 +36,7 @@ fn linked_picture(dir: &Path) -> Session { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": p(&dir.join("pic.png")), "x": 36, "y": 36, "width": 72})).unwrap(); + crate::cmd::native_links::wait(&mut s); s } diff --git a/docs/linked-artwork.md b/docs/linked-artwork.md index 7ead6ce02..e13ae8be8 100644 --- a/docs/linked-artwork.md +++ b/docs/linked-artwork.md @@ -14,6 +14,8 @@ A graphic placed from a file is linked to it. The document stores the link, a sm Video and audio link as well, but have no image proxy (the poster frame is used when there is one). An SVG that references other files resolves them relative to the SVG's own path. +Placing reads each file once and returns without decoding or hashing it, so placing many large images doesn't stop the app. A background thread then makes each graphic's hash and proxy, in placement order, and links it. Until then the graphic is drawn from the bytes just read, and is embedded with its link record. A save in that time computes the missing hashes and writes those graphics embedded; they link on the next save after the background work finishes. + Linked bytes are read when a render, export or check needs them. They are held in a shared cache of 512 MiB; a file larger than the cache is read for one use and not kept. A linked file larger than 1 GiB is not loaded. ## Proxies