From 2d2c6bbc839957465009efd2ff5bff93bede6f50 Mon Sep 17 00:00:00 2001 From: Jeffrey Thayne <36243577+thaynej@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:48:42 -0600 Subject: [PATCH 1/2] Links: placed text files can stay linked (Word, RTF, text) File > Place of a text file with `link: true`, or with the new Preferences > File Handling > Create Links When Placing Text Files, records on the story the file's path, size and hash, and the style map and style-conflict option used. The Links panel and links.list show these files OK / Modified / Missing like graphics. links.update imports a modified file again into the same frames and thread with the saved style map (Auto Rename's choices are kept, so no new _wrd_ styles). As in InDesign, edits made in the layout are replaced: the Links panel and Window > Links > Update Link ask first (Update / Cancel) when a linked story was edited in the layout, while the links.update command updates and returns a warning, as InDesign scripting does. links.relink, links.goTo and links.embed (Unlink) take {story}. Saved in the native format; IDML writes the text unlinked. Tracked changes import as accepted, as before. Tests cover place-with-link, Modified/Missing status, update with the saved style map keeping frames, the local-edits warning, the confirmation dialog (confirm, cancel, no dialog for unedited stories), relink, unlink, the preference, and a native save/load round trip. Closes #597. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/story.rs | 27 ++ crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/cmd/linked.rs | 4 +- crates/engine/src/cmd/links.rs | 322 ++++++++++++++++++++++-- crates/engine/src/cmd/place_text.rs | 112 +++++++-- crates/engine/src/cmd/prefs.rs | 2 +- crates/engine/src/lib.rs | 4 + crates/idml/src/import.rs | 1 + crates/ui-egui/src/dialogs.rs | 57 ++++- crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/properties.rs | 66 ++++- docs/target-app-parity.md | 1 + 13 files changed, 553 insertions(+), 53 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..56bfdb7d6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Linked text files (#597): placed Word, RTF and text files can stay linked; the Links panel shows them OK / Modified / Missing, and Update imports the file again into the same frames with the saved style map (the Links panel asks before replacing edits made in the layout). No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 6dbfa13c6..22562340d 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -155,6 +155,32 @@ pub struct Story { /// Place and Link: the parent story this one copies, and the parent's revision when copied. #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option<(StoryId, u64)>, + /// File › Place with Create Links: the text file the story was placed from (Links panel). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_link: Option, +} + +/// A linked text file: where a placed story came from, what the file held, and the Word Import +/// Options that brought it in, so Update Link can import it again the same way. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextFileLink { + pub path: String, + /// The file's size and FNV-1a hash (hex) when it was placed or last updated. + pub size: u64, + pub hash: String, + /// Imported style name → document style (`styleMap`), including the names Auto Rename chose. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub style_map: BTreeMap, + /// `styleConflicts`: useExisting, redefine or autoRename. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub style_conflicts: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub remove_styles: bool, + /// Hash of the story's text and formatting right after the import: a different one means it + /// was edited in the layout (an update replaces those edits). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub story_hash: String, } impl Story { @@ -177,6 +203,7 @@ impl Story { objects: Vec::new(), editorial: Vec::new(), link: None, + file_link: None, } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b5887bb50..4432fddad 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -100,7 +100,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}", has_doc, file_place ), @@ -291,7 +291,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { } // Text files (plain, Word, RTF) flow into frames. if designcraft_textimport::is_text_file(&name) { - return super::place_text::place_text(s, p, &name, &bytes); + return super::place_text::place_text(s, p, &name, &bytes, link); } // Image Import Options: which page of a PDF (1-based). let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { diff --git a/crates/engine/src/cmd/linked.rs b/crates/engine/src/cmd/linked.rs index ec07a24b2..96939afb9 100644 --- a/crates/engine/src/cmd/linked.rs +++ b/crates/engine/src/cmd/linked.rs @@ -11,8 +11,8 @@ use crate::Result; fn copy_into(d: &mut Document, parent: StoryId, child: StoryId) -> Result<()> { let src = d.story(parent).ok_or_else(|| bad("story.link", format!("no story {}", parent.0)))?.clone(); let st = d.story_mut(child).ok_or_else(|| bad("story.link", format!("no story {}", child.0)))?; - let (frames, rev) = (std::mem::take(&mut st.frames), st.rev); - *st = Story { id: child, frames, rev: rev + 1, link: Some((parent, src.rev)), ..src }; + let (frames, rev, file_link) = (std::mem::take(&mut st.frames), st.rev, st.file_link.take()); + *st = Story { id: child, frames, rev: rev + 1, link: Some((parent, src.rev)), file_link, ..src }; Ok(()) } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 73de3ea7e..1f9512953 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -4,10 +4,13 @@ //! Every placed graphic keeps its bytes in the document; a linked graphic also remembers its //! file. Its status compares that file with the copy in the document: missing, modified (the //! file's bytes differ) or OK. +//! +//! A linked text file (File › Place with Create Links) remembers its file's size and hash on the +//! story instead; updating it imports the file again into the story. use std::sync::Arc; -use designcraft_doc::{AssetId, Content, Document, ItemId, Selection}; +use designcraft_doc::{AssetId, Content, Document, ItemId, Selection, Story, StoryId, TextFileLink}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; @@ -15,7 +18,7 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}]", has_doc, |s, _| { + cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status, edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -23,7 +26,7 @@ pub fn specs() -> Vec { "Relink…", ["Window", "Links"], None, - "{asset, path} — use another file for every placement of the graphic", + "{asset | story, path} — use another file for every placement of the graphic, or import another text file into the linked story", has_doc, relink ), @@ -41,22 +44,38 @@ pub fn specs() -> Vec { "Update Link", ["Window", "Links"], None, - "{asset?} — reload modified links from their files (all when no asset)", + "{asset? | story?} — reload modified links from their files (all when neither); a linked text file is imported again into its story with the import options it was placed with, replacing edits made in the layout → {updated, warnings}", has_doc, update ), - cmd!("links.embed", "Embed Link", ["Window", "Links"], None, "{asset} — keep only the copy in the document", has_doc, |s, p| { - let aid = asset_param(p, "links.embed")?; - s.edit(|d, _| { - let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.embed", "no such graphic"))?; - Arc::make_mut(a).link = None; - Ok(Value::Null) - }) - }), - cmd!(noundo "links.goTo", "Go To Link", ["Window", "Links"], None, "{asset} — selects the frames showing the graphic", has_doc, |s, p| { - let aid = asset_param(p, "links.goTo")?; + cmd!( + "links.embed", + "Embed Link", + ["Window", "Links"], + None, + "{asset | story} — keep only the copy in the document (a text file: unlink the story)", + has_doc, + |s, p| { + if let Some(sid) = story_param(p) { + return s.edit(|d, _| { + d.story_mut(sid).ok_or_else(|| bad("links.embed", "no such story"))?.file_link = None; + Ok(Value::Null) + }); + } + let aid = asset_param(p, "links.embed")?; + s.edit(|d, _| { + let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.embed", "no such graphic"))?; + Arc::make_mut(a).link = None; + Ok(Value::Null) + }) + } + ), + cmd!(noundo "links.goTo", "Go To Link", ["Window", "Links"], None, "{asset | story} — selects the frames showing the graphic or the linked text", has_doc, |s, p| { let st = s.doc_mut()?; - let ids = uses(&st.doc, aid); + let ids = match story_param(p) { + Some(sid) => st.doc.story(sid).map(|s| s.frames.clone()).unwrap_or_default(), + None => uses(&st.doc, asset_param(p, "links.goTo")?), + }; if ids.is_empty() { return Err(bad("links.goTo", "the graphic isn't placed")); } @@ -71,6 +90,21 @@ fn asset_param(p: &Value, cmd: &str) -> Result { p.get("asset").and_then(Value::as_u64).map(AssetId).ok_or_else(|| bad(cmd, "missing asset")) } +fn story_param(p: &Value) -> Option { + p.get("story").and_then(Value::as_u64).map(StoryId) +} + +/// FNV-1a (64-bit) of `bytes`, in hex: a linked text file's fingerprint. +pub(crate) fn fnv_hex(bytes: &[u8]) -> String { + let h = bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0000_0100_0000_01b3)); + format!("{h:016x}") +} + +/// Fingerprint of a story's text and formatting (edits made in the layout change it). +pub(crate) fn story_hash(st: &Story) -> String { + fnv_hex(serde_json::to_string(&(&st.text, &st.paras, &st.chars)).unwrap_or_default().as_bytes()) +} + /// Frames (graphic content) showing an asset, top-level or in groups. fn uses(d: &Document, aid: AssetId) -> Vec { let mut out = Vec::new(); @@ -111,7 +145,37 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { } } +/// Link status of a linked text file. +pub fn text_status(l: &TextFileLink) -> &'static str { + #[cfg(not(target_arch = "wasm32"))] + { + match std::fs::metadata(&l.path) { + Err(_) => "missing", + Ok(m) if m.len() != l.size => "modified", + Ok(_) => match std::fs::read(&l.path) { + Ok(b) if fnv_hex(&b) == l.hash => "ok", + Ok(_) => "modified", + Err(_) => "missing", + }, + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = l; + "ok" + } +} + +fn file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) +} + fn list(d: &Document) -> Vec { + let texts = d.stories.values().filter_map(|st| { + let l = st.file_link.as_ref()?; + let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect(); + Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l), "edited": story_hash(st) != l.story_hash, "uses": uses})) + }); let used = d.used_assets(); d.assets .values() @@ -140,9 +204,30 @@ fn list(d: &Document) -> Vec { .collect(); json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "uses": uses}) }) + .chain(texts) .collect() } +#[cfg(not(target_arch = "wasm32"))] +fn read_text(path: &str) -> Result> { + std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +#[cfg(target_arch = "wasm32")] +fn read_text(path: &str) -> Result> { + Err(EngineError::Other(format!("{path}: files can't be read on the web"))) +} + +/// Import a linked text file again; a warning says when edits made in the layout were replaced. +fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8]) -> Result> { + let edited = d.story(sid).is_some_and(|st| st.file_link.as_ref().is_some_and(|l| story_hash(st) != l.story_hash)); + let mut warnings = super::place_text::reimport(d, sid, path, bytes)?; + if edited { + warnings.insert(0, format!("{}: edits made to story {} in the layout were replaced by the file's text", file_name(path), sid.0)); + } + Ok(warnings) +} + #[cfg(not(target_arch = "wasm32"))] fn reload(path: &str) -> Result<(Vec, (u32, u32))> { let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; @@ -156,8 +241,16 @@ fn reload(path: &str) -> Result<(Vec, (u32, u32))> { } fn relink(s: &mut Session, p: &Value) -> Result { - let aid = asset_param(p, "links.relink")?; let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string(); + if let Some(sid) = story_param(p) { + let bytes = read_text(&path)?; + let name = file_name(&path); + return s.edit(|d, _| { + let warnings = reimport(d, sid, &path, &bytes)?; + Ok(json!({"name": name, "warnings": warnings})) + }); + } + let aid = asset_param(p, "links.relink")?; let (bytes, px) = reload(&path)?; let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); s.edit(|d, _| { @@ -210,18 +303,31 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { fn update(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; + let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p)); + let all = only_asset.is_none() && only_story.is_none(); let which: Vec<(AssetId, String)> = d .assets .values() - .filter(|a| p.get("asset").and_then(Value::as_u64).is_none_or(|x| x == a.id.0)) + .filter(|a| all || only_asset == Some(a.id.0)) .filter(|a| status(a) == "modified") .filter_map(|a| a.link.clone().map(|l| (a.id, l))) .collect(); + let texts: Vec<(StoryId, String)> = d + .stories + .values() + .filter(|st| all || only_story == Some(st.id)) + .filter_map(|st| st.file_link.as_ref().filter(|l| text_status(l) == "modified").map(|l| (st.id, l.path.clone()))) + .collect(); let mut fresh = Vec::new(); for (aid, path) in which { fresh.push((aid, reload(&path)?)); } - let n = fresh.len(); + let mut fresh_texts = Vec::new(); + for (sid, path) in texts { + let bytes = read_text(&path)?; + fresh_texts.push((sid, path, bytes)); + } + let n = fresh.len() + fresh_texts.len(); if n == 0 { return Ok(json!({"updated": 0})); } @@ -233,7 +339,11 @@ fn update(s: &mut Session, p: &Value) -> Result { a.pixels = Some(*px); } } - Ok(json!({"updated": n})) + let mut warnings = Vec::new(); + for (sid, path, bytes) in &fresh_texts { + warnings.extend(reimport(d, *sid, path, bytes)?); + } + Ok(json!({"updated": n, "warnings": warnings})) }) } @@ -428,3 +538,177 @@ mod shared_asset_tests { assert_eq!(dst.assets.len(), 2); } } + +#[cfg(test)] +mod text_link_tests { + use serde_json::{Value, json}; + + use crate::Session; + use designcraft_doc::{ItemId, StoryId}; + + /// A small Word file: a Title paragraph, then Body paragraphs (`body` is WordprocessingML). + fn docx(body: &str) -> Vec { + use std::io::Write; + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let styles = format!( + r#" + "# + ); + let doc = format!( + r#"Head + {body}"# + ); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, c) in [("word/document.xml", doc), ("word/styles.xml", styles)] { + w.start_file(n, o).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + w.finish().unwrap().into_inner() + } + + fn text_rows(s: &mut Session) -> Vec { + let l = s.execute("links.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().filter(|r| r.get("story").is_some()).cloned().collect() + } + + fn story(s: &Session, sid: StoryId) -> designcraft_doc::Story { + s.doc().unwrap().doc.story(sid).unwrap().clone() + } + + fn styles(st: &designcraft_doc::Story) -> Vec { + st.paras.iter().map(|p| p.style.clone()).collect() + } + + #[test] + fn linked_word_file_status_update_relink_and_unlink() { + let dir = std::env::temp_dir().join(format!("dc-text-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("moved")).unwrap(); + let path = dir.join("chapter.docx"); + std::fs::write(&path, docx("First draft.")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 9}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Headline", "chars": {"size": 30}})).unwrap(); + // Two threaded frames. + let a = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "spread": 1, "content": "text"})).unwrap()["id"].as_u64().unwrap(); + s.edit(|d, _| Ok(d.thread(ItemId(a), ItemId(b))?)).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + + // Place and link, with a style map and Auto Rename. + let r = s + .execute( + "file.place", + &json!({"path": path.to_string_lossy(), "frame": a, "link": true, "styleMap": {"Title": "Headline"}, "styleConflicts": "autoRename"}), + ) + .unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let placed = story(&s, sid); + assert_eq!(placed.text, "Head\nFirst draft."); + assert_eq!(styles(&placed), ["Headline", "Body_wrd_1"]); + let link = placed.file_link.clone().unwrap(); + assert_eq!(link.path, path.to_string_lossy()); + assert_eq!(link.style_conflicts, "autoRename"); + assert_eq!(link.style_map.get("Title").map(String::as_str), Some("Headline")); + assert_eq!(link.style_map.get("Body").map(String::as_str), Some("Body_wrd_1"), "Auto Rename's choice is kept"); + let rows = text_rows(&mut s); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0]["story"].as_u64(), rows[0]["name"].as_str()), (Some(sid.0), Some("chapter.docx"))); + assert_eq!((rows[0]["status"].as_str(), rows[0]["edited"].as_bool()), (Some("ok"), Some(false))); + assert_eq!(rows[0]["uses"].as_array().unwrap().len(), 2, "both threaded frames"); + + // The file changes (with tracked changes): Modified; Update imports it as if accepted, with + // the saved style map, into the same frames, without new renamed styles. + std::fs::write( + &path, + docx(r#"Second revised old draft."#), + ) + .unwrap(); + assert_eq!(text_rows(&mut s)[0]["status"], "modified"); + let r = s.execute("links.update", &json!({"story": sid.0})).unwrap(); + assert_eq!(r["updated"], 1); + assert_eq!(r["warnings"], json!([]), "no edits in the layout to replace"); + let updated = story(&s, sid); + assert_eq!(updated.text, "Head\nSecond revised draft."); + assert_eq!(styles(&updated), ["Headline", "Body_wrd_1"]); + assert_eq!(updated.frames, [ItemId(a), ItemId(b)], "frames and thread kept"); + assert!(s.doc().unwrap().doc.styles.para("Body_wrd_2").is_none()); + assert!(s.doc().unwrap().doc.styles.para("Title").is_none(), "mapped styles aren't imported"); + assert_eq!(text_rows(&mut s)[0]["status"], "ok"); + + // An edit in the layout, then the file changes again: Update replaces the edit and warns. + s.execute("story.setText", &json!({"story": sid.0, "text": "Fixed in layout"})).unwrap(); + assert_eq!(text_rows(&mut s)[0]["edited"], true); + std::fs::write(&path, docx("Third draft.")).unwrap(); + let r = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(r["updated"], 1); + let w = r["warnings"][0].as_str().unwrap(); + assert!(w.contains("chapter.docx") && w.contains("replaced"), "{w}"); + assert_eq!(story(&s, sid).text, "Head\nThird draft."); + assert_eq!(text_rows(&mut s)[0]["edited"], false); + // Undo brings the edit back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(story(&s, sid).text, "Fixed in layout"); + s.execute("edit.redo", &json!({})).unwrap(); + + // Saved and opened again: the link and its options are kept. + let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); + assert_eq!(back.story(sid).unwrap().file_link, story(&s, sid).file_link); + assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap()), "ok"); + + // The file moves: Missing; Relink to it imports it into the story. + let moved = dir.join("moved").join("chapter.docx"); + std::fs::write(&moved, docx("Moved draft.")).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert_eq!(text_rows(&mut s)[0]["status"], "missing"); + s.execute("links.relink", &json!({"story": sid.0, "path": moved.to_string_lossy()})).unwrap(); + let rows = text_rows(&mut s); + assert_eq!((rows[0]["status"].as_str(), rows[0]["path"].as_str()), (Some("ok"), Some(moved.to_string_lossy().as_ref()))); + assert_eq!(story(&s, sid).text, "Head\nMoved draft."); + + // Go To selects the story's frames; Unlink keeps the text and drops the file. + let r = s.execute("links.goTo", &json!({"story": sid.0})).unwrap(); + assert_eq!(r["ids"], json!([a, b])); + s.execute("links.embed", &json!({"story": sid.0})).unwrap(); + assert!(text_rows(&mut s).is_empty()); + assert_eq!(story(&s, sid).text, "Head\nMoved draft."); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn create_links_when_placing_text_files_preference() { + let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("notes.txt"); + std::fs::write(&path, "Plain text").unwrap(); + let place = |s: &mut Session, extra: Value| { + let mut p = json!({"path": path.to_string_lossy(), "rect": [72, 72, 300, 200]}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("file.place", &p).unwrap(); + (s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap().file_link.is_some(), r) + }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert!(!place(&mut s, json!({})).0, "off by default"); + assert!(place(&mut s, json!({"link": true})).0); + s.execute("prefs.set", &json!({"linkTextFiles": true})).unwrap(); + assert!(place(&mut s, json!({})).0, "the preference links"); + assert!(!place(&mut s, json!({"link": false})).0, "`link` overrides it"); + // Placed from bytes: no file to link. + let b64 = crate::cmd::base64_encode(b"Plain text"); + let r = s.execute("file.place", &json!({"base64": b64, "name": "notes.txt", "rect": [72, 300, 300, 400]})).unwrap(); + assert!(s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap().file_link.is_none()); + // Into an insertion point: not linked, with a warning. + let fr = s.execute("frame.create", &json!({"rect": [72, 500, 300, 600], "content": "text", "text": "Start end"})).unwrap(); + 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(); + 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/place_text.rs b/crates/engine/src/cmd/place_text.rs index 97917ee3b..698f38809 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -2,7 +2,7 @@ //! selected frame, or a new frame on the page; with autoflow, pages and threaded frames are added //! until the whole story fits (InDesign's Shift-click with a loaded text cursor). -use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextSel}; +use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextFileLink, TextSel}; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -22,8 +22,33 @@ enum Conflict { AutoRename, } +impl Conflict { + fn parse(c: &str) -> Result { + match c { + "useExisting" | "" => Ok(Conflict::UseExisting), + "redefine" => Ok(Conflict::Redefine), + "autoRename" => Ok(Conflict::AutoRename), + c => Err(bad("file.place", format!("unknown styleConflicts `{c}` (useExisting, redefine, autoRename)"))), + } + } + + fn name(self) -> &'static str { + match self { + Conflict::UseExisting => "useExisting", + Conflict::Redefine => "redefine", + Conflict::AutoRename => "autoRename", + } + } +} + /// Map imported style names (`styleMap`) and resolve name clashes in the import before it lands. -fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &std::collections::HashMap, conflict: Conflict) { +/// Returns `map` with the names Auto Rename chose. +fn map_styles( + d: &Document, + imp: &mut designcraft_textimport::Imported, + map: &std::collections::HashMap, + conflict: Conflict, +) -> std::collections::HashMap { let mut rename: std::collections::HashMap = map.clone(); if conflict == Conflict::AutoRename { let st = &d.styles; @@ -39,7 +64,7 @@ fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &st } } if rename.is_empty() { - return; + return rename; } // Mapped styles aren't imported; renamed ones come in under their new name. imp.para_styles.retain(|s| !map.contains_key(&s.name)); @@ -63,6 +88,7 @@ fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &st f.style = m.clone(); } }); + rename } fn add_styles(d: &mut Document, imp: &designcraft_textimport::Imported, conflict: Conflict) { @@ -105,6 +131,60 @@ fn strip(story: &mut Story) { story.format_chars(0..n, |f| *f = Default::default()); } +/// The story's whole text replaced by the import's (its frames stay). +fn fill(story: &mut Story, from: &Story) { + let n = story.len(); + story.delete(0..n); + story.insert_story(0, from); + if let (Some(first), Some(p0)) = (from.paras.first(), story.paras.first_mut()) { + *p0 = ParaFormat { table: p0.table, ..first.clone() }; + } +} + +/// The link a story placed from `path` keeps (Create Links When Placing Text Files). +fn text_link( + path: &str, + bytes: &[u8], + map: std::collections::HashMap, + conflict: Conflict, + remove: bool, + story: &Story, +) -> TextFileLink { + TextFileLink { + path: path.to_string(), + size: bytes.len() as u64, + hash: super::links::fnv_hex(bytes), + style_map: map.into_iter().collect(), + style_conflicts: conflict.name().into(), + remove_styles: remove, + story_hash: super::links::story_hash(story), + } +} + +/// Update Link of a linked text file: `bytes` (read from `path`) imported again into the story +/// with the import options it was placed with, keeping its frames. Returns the import's warnings. +pub(crate) fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8]) -> Result> { + let link = + d.story(sid).and_then(|s| s.file_link.clone()).ok_or_else(|| bad("links.update", format!("story {} isn't linked to a file", sid.0)))?; + let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); + let mut imp = designcraft_textimport::import(&name, bytes).map_err(|e| bad("links.update", format!("{name}: {e}")))?; + if link.remove_styles { + strip(&mut imp.story); + imp.para_styles.clear(); + imp.char_styles.clear(); + } + let conflict = Conflict::parse(&link.style_conflicts)?; + // A mapping to a style the document no longer has is dropped: that style is imported again. + let map: std::collections::HashMap = + link.style_map.into_iter().filter(|(_, to)| d.styles.para(to).is_some() || d.styles.char_style(to).is_some()).collect(); + let map = map_styles(d, &mut imp, &map, conflict); + add_styles(d, &imp, conflict); + let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + fill(story, &imp.story); + story.file_link = Some(text_link(path, bytes, map, conflict, link.remove_styles, story)); + Ok(imp.warnings) +} + /// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much /// a frame holds. @@ -154,7 +234,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Resu } } -pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { +pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], path: Option) -> Result { let mut imp = designcraft_textimport::import(name, bytes).map_err(|e| bad("file.place", e.to_string()))?; let remove = p.get("removeStyles").and_then(Value::as_bool).unwrap_or(false); if remove { @@ -162,18 +242,15 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - imp.para_styles.clear(); imp.char_styles.clear(); } - let conflict = match p.get("styleConflicts").and_then(Value::as_str).unwrap_or("useExisting") { - "useExisting" => Conflict::UseExisting, - "redefine" => Conflict::Redefine, - "autoRename" => Conflict::AutoRename, - c => return Err(bad("file.place", format!("unknown styleConflicts `{c}` (useExisting, redefine, autoRename)"))), - }; + let conflict = Conflict::parse(p.get("styleConflicts").and_then(Value::as_str).unwrap_or("useExisting"))?; let map: std::collections::HashMap = p .get("styleMap") .and_then(Value::as_object) .map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect()) .unwrap_or_default(); - map_styles(&s.doc()?.doc, &mut imp, &map, conflict); + let map = map_styles(&s.doc()?.doc, &mut imp, &map, conflict); + // Create Links When Placing Text Files (only a file on disk can be linked). + let link = path.filter(|_| p.get("link").and_then(Value::as_bool).unwrap_or(s.prefs.link_text_files)); let autoflow_on = p.get("autoflow").and_then(Value::as_bool).unwrap_or(false); let st = s.doc()?; let caret = st.selection.text.filter(|t| t.cell.is_none()); @@ -182,7 +259,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - }); let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1)); let rect = super::rect_param(p, "rect"); - let warnings = imp.warnings.clone(); + let mut warnings = imp.warnings.clone(); s.edit(|d, sel| { add_styles(d, &imp, conflict); let sid = if let Some(t) = caret { @@ -192,6 +269,9 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - story.delete(r.clone()); let end = story.insert_story(r.start, &imp.story); sel.text = Some(TextSel { anchor: end, focus: end, ..t }); + if link.is_some() { + warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into()); + } t.story } else { let fid = match frame { @@ -221,12 +301,8 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - None => return Err(bad("file.place", "no such frame")), }; let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let n = story.len(); - story.delete(0..n); - story.insert_story(0, &imp.story); - if let Some(first) = imp.story.paras.first() { - story.paras[0] = ParaFormat { table: story.paras[0].table, ..first.clone() }; - } + fill(story, &imp.story); + story.file_link = link.as_deref().map(|path| text_link(path, bytes, map.clone(), conflict, remove, story)); *sel = designcraft_doc::Selection::items(vec![fid]); sid }; diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 68a5cf152..6c4908ffd 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?, linkTextFiles? (Create Links When Placing Text Files)} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 164719305..ccd8bd922 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -195,6 +195,9 @@ pub struct Prefs { pub show_note_anchors: bool, /// Preferences › File Handling: minutes between document recovery saves. pub recovery_minutes: f64, + /// Preferences › File Handling › Create Links When Placing Text Files: placed text files are + /// linked (Links panel) unless `file.place` says otherwise. + pub link_text_files: bool, } /// A starter autocorrect list (common English typing slips). @@ -242,6 +245,7 @@ impl Default for Prefs { show_added_text: true, show_note_anchors: true, recovery_minutes: 0.5, + link_text_files: false, } } } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..399d186e2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1596,6 +1596,7 @@ impl<'r> Importer<'r> { objects: objects.into_iter().map(Arc::new).collect(), editorial: Vec::new(), link: None, + file_link: None, }; st.fix_notes(); st.fix_marks(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..7b2f56623 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -828,6 +828,9 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he d.fields.insert("recoveryMinutes".into(), json!(v)); } }); + ui.add_space(6.0); + ui.label(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Links")).font(semibold(12.0)))); + check(ui, d, "linkTextFiles", crate::i18n::tr(&app.ui.language, "Create Links When Placing Text Files")); } "type" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Type Options")).font(semibold(12.0))); @@ -1138,6 +1141,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "userDictionary" => crate::i18n::tr(&app.ui.language, "User Dictionary"), "newWorkspace" => crate::i18n::tr(&app.ui.language, "New Workspace"), "closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"), + "updateLink" => crate::i18n::tr(&app.ui.language, "Update Link"), "menus" => crate::i18n::tr(&app.ui.language, "Menu Customization"), "importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"), "fittingOptions" => crate::i18n::tr(&app.ui.language, "Frame Fitting Options"), @@ -1160,7 +1164,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" => 380.0, + "closeDocument" | "updateLink" => 380.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, }; @@ -1541,6 +1545,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?").replace("{}", &d.s("title")); crate::rtl::label(ui, text); } + "updateLink" => { + let text = crate::i18n::tr(&app.ui.language, "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.") + .replace("{}", &d.s("files")); + crate::rtl::label(ui, text); + } "newWorkspace" => { ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); @@ -1703,7 +1712,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } }); ui.add_space(12.0); - let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " }; + let ok_label = match d.id.as_str() { + "closeDocument" => " Save ", + "updateLink" => " Update ", + _ => " OK ", + }; ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui @@ -1990,7 +2003,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "prefs.set", json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "absolutePageNumbers": d.b("absolutePageNumbers"), "highlightHj": d.b("highlightHj"), "highlightKeeps": d.b("highlightKeeps"), "highlightCustomTracking": d.b("highlightCustomTracking"), "highlightSubstitutedFonts": d.b("highlightSubstitutedFonts"), "richBlackOutput": d.b("richBlackOutput"), "typographersQuotes": d.b("typographersQuotes"), "showFontNamesInEnglish": d.b("showFontNamesInEnglish"), "smartTextReflow": d.b("smartTextReflow"), "autocorrect": d.b("autocorrect"), "showAddedText": d.b("showAddedText"), "showNoteAnchors": d.b("showNoteAnchors"), - "recoveryMinutes": d.n("recoveryMinutes").unwrap_or(0.5), + "recoveryMinutes": d.n("recoveryMinutes").unwrap_or(0.5), "linkTextFiles": d.b("linkTextFiles"), "autocorrectList": d.s("autocorrectText").lines().filter_map(|l| { let (a, b) = l.split_once('→').or_else(|| l.split_once("->"))?; let (a, b) = (a.trim().to_lowercase(), b.trim().to_string()); @@ -2075,6 +2088,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("document.preferences", doc) } "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), + "updateLink" => crate::panels::properties::run_update_links(app, d.fields.get("target").cloned().unwrap_or_else(|| json!({}))), "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. let uid = d.fields.get("uid").and_then(Value::as_u64); @@ -4396,6 +4410,43 @@ mod tests { assert!(!app.ui.pdf_export.tagged); } + #[test] + fn update_link_asks_before_replacing_edits_made_in_the_layout() { + let dir = std::env::temp_dir().join(format!("dc-update-link-dialog-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("note.txt"); + std::fs::write(&path, "One").unwrap(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let r = app.session.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true, "rect": [72, 72, 300, 200]})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let text = |app: &crate::DesignApp| app.session.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone(); + + // Not edited in the layout: updates without asking. + std::fs::write(&path, "Two").unwrap(); + crate::panels::properties::update_links(&mut app, json!({"story": sid})); + assert!(app.ui.dialog.is_none()); + assert_eq!(text(&app), "Two"); + + // Edited in the layout: asks first; Cancel changes nothing. + app.session.execute("story.setText", &json!({"story": sid, "text": "Mine"})).unwrap(); + std::fs::write(&path, "Three").unwrap(); + crate::panels::properties::update_links(&mut app, json!({"story": sid})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.id.as_str(), d.s("files").as_str()), ("updateLink", "note.txt")); + app.ui.dialog = None; + assert_eq!(text(&app), "Mine"); + + // Window › Links › Update Link (all links) asks too; confirming updates. + crate::menus::activate(&mut app, "links.update", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("updateLink")); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(text(&app), "Three"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn confirm_pdf_export_cancelled_save_writes_nothing() { use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..b0fbb51fa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1877,6 +1877,11 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { return; } let p = if params.is_null() { json!({}) } else { params.clone() }; + // Update Link asks before replacing edits made to linked text in the layout. + if id == "links.update" { + crate::panels::properties::update_links(app, p); + return; + } let _ = app.run(id, p); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..7d683a8ff 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1787,7 +1787,7 @@ pub fn align_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } /// Links: status (missing / modified / embedded), page and effective resolution of every placed -/// graphic; Relink, Go To Link, Update Link, Embed Link for the chosen row. +/// graphic and linked text file; Relink, Go To Link, Update Link, Embed Link for the chosen row. pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { // Linked stories (Place and Link). let stories = app.session.execute("story.links", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); @@ -1840,7 +1840,8 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page · PPI")).color(t.text_dim).size(11.0)); ui.end_row(); for r in &rows { - let aid = r["asset"].as_u64().unwrap_or(0); + // Asset and story ids come from one counter: a row is chosen by either. + let aid = r["asset"].as_u64().or_else(|| r["story"].as_u64()).unwrap_or(0); let name = r["name"].as_str().unwrap_or(""); if ui.selectable_label(chosen == Some(aid), name).clicked() { chosen = Some(aid); @@ -1880,19 +1881,39 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Choose a link.")).color(t.text_dim).size(11.0)); return; }; + // A linked text file is addressed by its story. + let is_text = rows.iter().any(|r| r["story"].as_u64() == Some(aid)); + let target = if is_text { json!({"story": aid}) } else { json!({"asset": aid}) }; + let with = |k: &str, v: Value| { + let mut p = target.clone(); + p[k] = v; + p + }; if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Relink…"))).clicked() && let Some(path) = app.services.pick_open.as_mut().and_then(|pick| pick("relink")) { - let _ = app.run("links.relink", json!({"asset": aid, "path": path})); + let _ = app.run("links.relink", with("path", json!(path))); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Go To"))).clicked() { - let _ = app.run("links.goTo", json!({"asset": aid})); + let _ = app.run("links.goTo", target.clone()); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update"))).clicked() { - let _ = app.run("links.update", json!({"asset": aid})); + let edited = rows.iter().any(|r| r["story"].as_u64() == Some(aid) && r["edited"].as_bool() == Some(true)); + let update = ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update"))); + let update = if edited { + update.on_hover_ui(|ui| { + crate::rtl::label( + ui, + crate::i18n::tr(&app.ui.language, "This story was edited in the layout: you'll be asked before those edits are replaced"), + ); + }) + } else { + update + }; + if update.clicked() { + update_links(app, target.clone()); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Embed"))).clicked() { - let _ = app.run("links.embed", json!({"asset": aid})); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if is_text { "Unlink" } else { "Embed" }))).clicked() { + let _ = app.run("links.embed", target.clone()); } }); ui.horizontal(|ui| { @@ -1914,6 +1935,35 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// Update Link from the Links panel or the menu (`{asset}`, `{story}`, or `{}` for all): asks +/// first when a linked text story edited in the layout would lose those edits, as InDesign's +/// Links panel does. The `links.update` command itself doesn't ask (as in InDesign scripting). +pub fn update_links(app: &mut DesignApp, target: Value) { + let rows = app.session.execute("links.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + let one = target.get("story").and_then(Value::as_u64); + let all = one.is_none() && target.get("asset").is_none(); + let edited: Vec<&str> = rows + .iter() + .filter(|r| (all || (one.is_some() && r["story"].as_u64() == one)) && r["edited"] == true && r["status"] == "modified") + .filter_map(|r| r["name"].as_str()) + .collect(); + if edited.is_empty() { + let _ = run_update_links(app, target); + } else { + let files = edited.join(", "); + app.ui.dialog = Some(crate::dialogs::Dialog::new("updateLink", json!({"target": target, "files": files}))); + } +} + +/// Run `links.update`; its first warning goes to the status bar. +pub(crate) fn run_update_links(app: &mut DesignApp, target: Value) -> Result { + let r = app.run("links.update", target)?; + if let Some(w) = r["warnings"].as_array().and_then(|w| w.first()).and_then(Value::as_str) { + app.status(w.to_string()); + } + Ok(r) +} + pub fn info_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let u = units(app); let t = Tokens::get(ui.ctx()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5705ee738..8ed35dc8a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -285,6 +285,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. - Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. +- Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). From bb7e81425ff86c76d99e1c09a6016245f4aa644b Mon Sep 17 00:00:00 2001 From: Jeffrey Thayne <36243577+thaynej@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:15:01 -0600 Subject: [PATCH 2/2] Links: never read a linked text file that isn't a regular file Checking a linked text file's status (every links.list, which the Links panel polls) compared sizes and then read the whole file, so a link to a FIFO or a device such as /dev/zero (size 0) never returned. Only regular files are read now, and no more than the Data Merge source limit (256 MiB): anything else shows as missing; a file over the limit shows as modified when its size changed and as tooLarge ("Too large to check") when it didn't. Update and Relink read through the same guard, and placing a file too large to check doesn't link it. Unix test: a FIFO, /dev/zero, a folder and an over-limit file each return promptly without being read (each call runs on a thread with a timeout, so a regression fails instead of hanging). Thanks @aiongg. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/links.rs | 94 +++++++++++++++++++++++-- crates/engine/src/cmd/place_text.rs | 8 +++ crates/ui-egui/src/panels/properties.rs | 1 + 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 1f9512953..e1c482a07 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -18,7 +18,7 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status, edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { + cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -145,14 +145,26 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { } } -/// Link status of a linked text file. +/// The largest linked text file read (the Data Merge source limit). Only regular files are read, +/// so a link to a FIFO or a device (e.g. `/dev/zero`) can't block or fill memory. +const MAX_TEXT_LINK_BYTES: u64 = super::datamerge::MAX_DATA_FILE_BYTES; + +/// Can a linked text file this size be checked and updated? +pub(crate) fn checkable_size(len: usize) -> bool { + len as u64 <= MAX_TEXT_LINK_BYTES +} + +/// Link status of a linked text file: `tooLarge` when it is the recorded size but over +/// [`MAX_TEXT_LINK_BYTES`], so its contents aren't checked. pub fn text_status(l: &TextFileLink) -> &'static str { #[cfg(not(target_arch = "wasm32"))] { match std::fs::metadata(&l.path) { Err(_) => "missing", + Ok(m) if !m.is_file() => "missing", Ok(m) if m.len() != l.size => "modified", - Ok(_) => match std::fs::read(&l.path) { + Ok(m) if m.len() > MAX_TEXT_LINK_BYTES => "tooLarge", + Ok(_) => match read_text(&l.path) { Ok(b) if fnv_hex(&b) == l.hash => "ok", Ok(_) => "modified", Err(_) => "missing", @@ -210,7 +222,23 @@ fn list(d: &Document) -> Vec { #[cfg(not(target_arch = "wasm32"))] fn read_text(path: &str) -> Result> { - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + use std::io::Read; + let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); + let m = std::fs::metadata(path).map_err(err)?; + if !m.is_file() { + return Err(EngineError::Other(format!("{path}: not a file"))); + } + let too_large = || EngineError::Other(format!("{path}: larger than {} MiB", MAX_TEXT_LINK_BYTES / (1024 * 1024))); + if m.len() > MAX_TEXT_LINK_BYTES { + return Err(too_large()); + } + // At most one byte over the limit, in case the file grew since. + let mut b = Vec::new(); + std::fs::File::open(path).map_err(err)?.take(MAX_TEXT_LINK_BYTES + 1).read_to_end(&mut b).map_err(err)?; + if b.len() as u64 > MAX_TEXT_LINK_BYTES { + return Err(too_large()); + } + Ok(b) } #[cfg(target_arch = "wasm32")] @@ -712,3 +740,61 @@ mod text_link_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +#[cfg(unix)] +mod text_link_read_tests { + use std::time::Duration; + + use designcraft_doc::TextFileLink; + + use super::{MAX_TEXT_LINK_BYTES, read_text, text_status}; + + /// `f` on another thread: a call that blocks fails the test instead of hanging it. + fn promptly(f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + // A timeout means the call blocked. + rx.recv_timeout(Duration::from_secs(10)).unwrap() + } + + fn status_of(path: &std::path::Path, size: u64) -> &'static str { + let l = TextFileLink { path: path.to_string_lossy().to_string(), size, hash: "0".into(), ..Default::default() }; + promptly(move || text_status(&l)) + } + + fn read(path: &std::path::Path) -> Result { + let p = path.to_string_lossy().to_string(); + promptly(move || read_text(&p).map(|b| b.len()).map_err(|e| e.to_string())) + } + + #[test] + fn links_to_fifos_devices_folders_and_huge_files_are_not_read() { + let dir = std::env::temp_dir().join(format!("dc-text-link-read-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // A FIFO with no writer: reading it would block for ever. + let fifo = dir.join("pipe.docx"); + assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success()); + assert_eq!(status_of(&fifo, 0), "missing"); + assert!(read(&fifo).is_err()); + // A device that never ends, recorded with size 0 (as its metadata reports). + let zero = std::path::Path::new("/dev/zero"); + assert_eq!(status_of(zero, 0), "missing"); + assert!(read(zero).is_err()); + // A folder. + assert_eq!(status_of(&dir, 0), "missing"); + assert!(read(&dir).is_err()); + // Over the limit (sparse, so cheap): not read; the same size is reported as too large + // to check rather than OK, another size as modified. + let big = dir.join("big.txt"); + std::fs::File::create(&big).unwrap().set_len(MAX_TEXT_LINK_BYTES + 1).unwrap(); + assert_eq!(status_of(&big, MAX_TEXT_LINK_BYTES + 1), "tooLarge"); + assert_eq!(status_of(&big, 10), "modified"); + assert!(read(&big).unwrap_err().contains("larger than")); + assert!(!super::checkable_size(MAX_TEXT_LINK_BYTES as usize + 1), "placing it doesn't link it"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 698f38809..4d4577a5f 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -251,6 +251,11 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], p let map = map_styles(&s.doc()?.doc, &mut imp, &map, conflict); // Create Links When Placing Text Files (only a file on disk can be linked). let link = path.filter(|_| p.get("link").and_then(Value::as_bool).unwrap_or(s.prefs.link_text_files)); + // A file too large for the Links panel to check isn't linked. + let (link, too_large) = match link { + Some(_) if !super::links::checkable_size(bytes.len()) => (None, true), + l => (l, false), + }; let autoflow_on = p.get("autoflow").and_then(Value::as_bool).unwrap_or(false); let st = s.doc()?; let caret = st.selection.text.filter(|t| t.cell.is_none()); @@ -260,6 +265,9 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], p let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1)); let rect = super::rect_param(p, "rect"); let mut warnings = imp.warnings.clone(); + if too_large { + warnings.push("not linked: the file is too large to keep linked".into()); + } s.edit(|d, sel| { add_styles(d, &imp, conflict); let sid = if let Some(t) = caret { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 7d683a8ff..3fc97c2ee 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1850,6 +1850,7 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { "missing" => ("\u{26A0} Missing", egui::Color32::from_rgb(0xe5, 0x4b, 0x4b)), "modified" => ("\u{25B2} Modified", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)), "embedded" => ("Embedded", t.text_dim), + "tooLarge" => ("Too large to check", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)), _ => ("OK", t.text_dim), }; crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, txt)).color(col).size(11.0));