From 0ac04151c9a0ab63fa02bbe5b58a7fece07de20b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:12:02 +0200 Subject: [PATCH] fix(engine): Every command that writes story text reads CR as a paragraph return frame.create and text.insert turned \r and \r\n into paragraph returns, but story.replaceRange, story.setText, note.new, footnote and endnote text, type on a path and table cells kept the CR in the text, where it drew as a missing glyph and joined the paragraphs. They all use text_param now. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +++ crates/engine/src/cmd/editorial.rs | 2 +- crates/engine/src/cmd/endnotes.rs | 6 +++--- crates/engine/src/cmd/notes.rs | 6 +++--- crates/engine/src/cmd/object.rs | 5 +++++ crates/engine/src/cmd/path_type.rs | 2 +- crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/cmd/text.rs | 4 ++-- 8 files changed, 19 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c7..24241365 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,9 @@ Newest first. - **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. +- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read ` +` and ` +` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 6cee420e..451c9611 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { if ts.cell.is_some() { return Err(bad("note.new", "notes go in story text")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let author = str_param(p, "author").unwrap_or("").to_string(); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index bdf9ca69..664b5c10 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -4,7 +4,7 @@ use designcraft_doc::{ParaFormat, SpreadRef, StoryId}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_text, ok}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -20,7 +20,7 @@ pub fn specs() -> Vec { ), cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| { let (sid, id) = target(p)?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let style = d.endnote_options.para_style.clone(); let st = d.story_mut(sid).ok_or_else(|| bad("endnote.edit", "no such story"))?; @@ -103,7 +103,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { if st.doc.endnote_story == Some(ts.story) { return Err(bad(ID, "the endnote frame lists the endnotes")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { // The endnote frame: a text frame in the margins of a new last page. diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 4d7b0f13..c17dc6cd 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -3,7 +3,7 @@ use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, bad, cmd, has_doc}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> { } fn insert(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?; let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() }; @@ -130,7 +130,7 @@ fn list(s: &mut Session, p: &Value) -> Result { fn set_text(s: &mut Session, p: &Value) -> Result { let sid = story_id(p, "footnote.setText")?; let id = note_id(p, "footnote.setText")?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be11..62f52aca 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2604,6 +2604,11 @@ mod line_end_tests { s.execute("text.insert", &json!({"text": "\rFour", "raw": true})).unwrap(); assert_eq!(story(&s).text, "One\nTwo\nThree\nFour"); assert_eq!(story(&s).paras.len(), 4); + // Editing a range or the whole story (scripts written for InDesign end paragraphs with \r). + s.execute("story.replaceRange", &json!({"story": sid.0, "start": 0, "end": 3, "text": "Zero\rOne"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5)); + s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3)); } } diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 77bbf87a..6d0da227 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -36,7 +36,7 @@ fn align_param(p: &Value) -> Result> { fn on_path(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?; - let text = super::str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let mut pt = PathType { start: p.get("start").and_then(Value::as_f64).unwrap_or(0.0).max(0.0), flip: p.get("flip").and_then(Value::as_bool).unwrap_or(false), diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2313170..9a1cffc1 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -645,7 +645,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))), None => None, }; - let text = str_param(p, "text").map(str::to_string); + let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { let owners = t.owners(); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d0..6eafa233 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { ), cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { d.set_story_text(sid, &text)?; if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { @@ -97,7 +97,7 @@ pub fn specs() -> Vec { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?); let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));