diff --git a/ROADMAP.md b/ROADMAP.md index 226be138f..f386e7d79 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,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** · Text in oval, polygon and other non-rectangular text frames follows the frame's outline: each line is set in the part of its band inside the shape, one inset away from the outline, in every column and in vertical frames; text wrap still applies on top (#646). No readiness percentage change. - **2026-10-12** · Colour entry: the Color Picker's hex, R/G/B and C/M/Y/K fields and the Color panel's number fields keep typed text until it is complete; panel and picker values are applied on a fixed scale (`{model, …}` colours in 0..1), so M 1 is 1 %. Picking keeps a CMYK colour CMYK; dialogs leave the page behind them undimmed; colours that round to the same swatch name share one swatch (#323). No readiness percentage change. - **2026-10-12** · Placed photos show in their own colours: embedded ICC profiles (Adobe RGB, ProPhoto, P3, grey) are converted to sRGB on screen, and CMYK JPEGs (including YCCK), CMYK TIFFs and CMYK PSDs go through their embedded CMYK profile or the working CMYK space with the colour settings' intent and black-point compensation, instead of the profile-free formula. Unreadable profiles are ignored. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index aadbc7b5a..d6ebe13e9 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 d401a22bc..7f1936dc1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -124,7 +124,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; 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?: threads one frame per page column (from the first frame on), adding pages 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?: finite number > 0 (null: absent), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; 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?: threads one frame per page column (from the first frame on), adding pages 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 ), @@ -405,7 +405,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); } let want_w = p .get("width") diff --git a/crates/engine/src/cmd/linked.rs b/crates/engine/src/cmd/linked.rs index ab53837b0..0b80e3aab 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.wrapping_add(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.wrapping_add(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 d867a3d29..b37dae68e 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, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}]", has_doc, |s, _| { + cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, pdfCrop (placed PDFs: the box shown), 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!( @@ -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,49 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { } } +/// 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(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", + }, + } + } + #[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() @@ -141,9 +217,46 @@ fn list(d: &Document) -> Vec { let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name()); json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) }) + .chain(texts) .collect() } +#[cfg(not(target_arch = "wasm32"))] +fn read_text(path: &str) -> Result> { + 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")] +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}")))?; @@ -157,8 +270,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, _| { @@ -213,18 +334,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})); } @@ -237,7 +371,11 @@ fn update(s: &mut Session, p: &Value) -> Result { super::file::crop_pdf(a); } } - 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})) }) } @@ -432,3 +570,235 @@ 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); + } +} + +#[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 6f5276eda..73f9e5495 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, TextDirection, TextSel}; +use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextDirection, 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. @@ -226,7 +306,7 @@ fn add_flow_frame( Ok(fid) } -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 { @@ -234,18 +314,20 @@ 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)); + // 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()); @@ -254,7 +336,10 @@ 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(); + 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 { @@ -264,6 +349,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 { @@ -295,12 +383,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 b8b1f3ef6..cbd0e1ece 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language)} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), 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 5f647e34e..534204475 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -233,6 +233,9 @@ pub struct Prefs { pub recovery_minutes: f64, /// File › New › Document › Save Document Preset: saved document presets, by name (`title`). pub document_presets: Vec, + /// 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). @@ -283,6 +286,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 20fe98074..d184e5072 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1900,6 +1900,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 4b1b0bcd3..fb67508d5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1141,6 +1141,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))); @@ -1503,6 +1506,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"), "loadStyles" => crate::i18n::tr(&app.ui.language, "Load Styles"), @@ -1530,7 +1534,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" => 960.0, "numberingSection" => 560.0, _ => 640.0, @@ -1887,6 +1891,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:")); @@ -2057,6 +2066,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.add_space(12.0); let ok_label = match d.id.as_str() { "closeDocument" => crate::i18n::tr(&app.ui.language, " Save ").to_string(), + "updateLink" => crate::i18n::tr(&app.ui.language, " Update ").to_string(), "findFont" => crate::i18n::tr(&app.ui.language, "Replace All").to_string(), "newDocument" => format!(" {} ", crate::i18n::tr(&app.ui.language, "Create")), _ => crate::i18n::tr(&app.ui.language, " OK ").to_string(), @@ -2519,7 +2529,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "preferences" => { let mut prefs = 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()); @@ -2613,6 +2623,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); @@ -7127,6 +7138,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 67847bdff..a4abd3b15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2186,6 +2186,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 ee3673a03..dfe08a417 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2038,7 +2038,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(); @@ -2096,7 +2096,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); @@ -2105,6 +2106,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)); @@ -2136,19 +2138,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| { @@ -2170,6 +2192,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 2adb84906..f958a8732 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).