mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 03:36:51 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d020b4e85d
commit
2d2c6bbc83
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user