Links: placed text files can stay linked (Word, RTF, text)

File > Place of a text file with `link: true`, or with the new
Preferences > File Handling > Create Links When Placing Text Files,
records on the story the file's path, size and hash, and the style map
and style-conflict option used. The Links panel and links.list show
these files OK / Modified / Missing like graphics. links.update imports
a modified file again into the same frames and thread with the saved
style map (Auto Rename's choices are kept, so no new _wrd_ styles). As
in InDesign, edits made in the layout are replaced: the Links panel and
Window > Links > Update Link ask first (Update / Cancel) when a linked
story was edited in the layout, while the links.update command updates
and returns a warning, as InDesign scripting does. links.relink,
links.goTo and links.embed (Unlink) take {story}. Saved in the native
format; IDML writes the text unlinked. Tracked changes import as
accepted, as before. Tests cover place-with-link, Modified/Missing
status, update with the saved style map keeping frames, the
local-edits warning, the confirmation dialog (confirm, cancel, no
dialog for unedited stories), relink, unlink, the preference, and a
native save/load round trip. Closes #597.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Thayne
2026-10-12 11:08:31 -06:00
co-authored by Claude Opus 5.5
parent d020b4e85d
commit 2d2c6bbc83
13 changed files with 553 additions and 53 deletions
+1
View File
@@ -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.
+27
View File
@@ -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<TextFileLink>,
}
/// 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<String, String>,
/// `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,
}
}
+2 -2
View File
@@ -100,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
}
// 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) {
+2 -2
View File
@@ -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(())
}
+303 -19
View File
@@ -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<CommandSpec> {
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<CommandSpec> {
"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<CommandSpec> {
"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<AssetId> {
p.get("asset").and_then(Value::as_u64).map(AssetId).ok_or_else(|| bad(cmd, "missing asset"))
}
fn story_param(p: &Value) -> Option<StoryId> {
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<ItemId> {
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<Value> {
let texts = d.stories.values().filter_map(|st| {
let l = st.file_link.as_ref()?;
let uses: Vec<Value> = 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<Value> {
.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<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn read_text(path: &str) -> Result<Vec<u8>> {
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<Vec<String>> {
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<u8>, (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<u8>, (u32, u32))> {
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn update(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<u8> {
use std::io::Write;
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
let styles = format!(
r#"<w:styles {W}><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:rPr><w:sz w:val="56"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Body"><w:name w:val="Body"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style></w:styles>"#
);
let doc = format!(
r#"<w:document {W}><w:body><w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:t>Head</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Body"/></w:pPr>{body}</w:p></w:body></w:document>"#
);
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<Value> {
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<String> {
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("<w:r><w:t>First draft.</w:t></w:r>")).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#"<w:r><w:t xml:space="preserve">Second </w:t></w:r><w:ins w:id="1" w:author="A"><w:r><w:t>revised </w:t></w:r></w:ins><w:del w:id="2" w:author="A"><w:r><w:delText>old </w:delText></w:r></w:del><w:r><w:t>draft.</w:t></w:r>"#),
)
.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("<w:r><w:t>Third draft.</w:t></w:r>")).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("<w:r><w:t>Moved draft.</w:t></w:r>")).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);
}
}
+94 -18
View File
@@ -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<Self> {
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<String, String>, conflict: Conflict) {
/// Returns `map` with the names Auto Rename chose.
fn map_styles(
d: &Document,
imp: &mut designcraft_textimport::Imported,
map: &std::collections::HashMap<String, String>,
conflict: Conflict,
) -> std::collections::HashMap<String, String> {
let mut rename: std::collections::HashMap<String, String> = 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<String, String>,
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<Vec<String>> {
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<String, String> =
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<Value> {
pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
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<String, String> = 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
};
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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()))?;
+4
View File
@@ -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,
}
}
}
+1
View File
@@ -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();
+54 -3
View File
@@ -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<Value, String> {
"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<Value, String> {
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};
+5
View File
@@ -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);
}
+58 -8
View File
@@ -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<Value, String> {
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());
+1
View File
@@ -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).