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).