From b54fa8d9bb548e1a347f64600d9ff0bcf6f2604c Mon Sep 17 00:00:00 2001 From: japbcoelho Date: Mon, 5 Oct 2026 22:44:18 -0300 Subject: [PATCH] Text: carriage returns in typed or created text start paragraphs frame.create's text and text.insert kept CR and CR LF as characters, so text written with InDesign's, classic Mac or Windows line ends ("One\rTwo") became a single paragraph, and paragraph formatting (spaceAfter) never applied between the parts. They now become \n, the paragraph separator, as edit.paste and text import already did. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 6 ++++++ crates/engine/src/cmd/object.rs | 23 ++++++++++++++++++++++- crates/engine/src/cmd/text.rs | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 85e4755..d1bb82d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -230,6 +230,12 @@ pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool { pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { p.get(key).and_then(Value::as_str) } +/// Plain text from a parameter, with CR LF and lone CR (classic Mac and InDesign line ends) as +/// `\n`, the paragraph separator, as edit.paste and text import already do. Kept as they were, +/// the CRs joined the paragraphs into one. +pub(crate) fn text_param(p: &Value, key: &str) -> String { + str_param(p, key).unwrap_or("").replace("\r\n", "\n").replace('\r', "\n") +} pub(crate) fn id_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_u64).map(ItemId) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 53915d9..489438a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -795,7 +795,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { let content = str_param(p, "content").unwrap_or("graphic"); let sr = spread_param(p, "spread"); let lid = s.doc()?.active_layer; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let caret = bool_or(p, "caret", content == "text"); let vertical = bool_or(p, "vertical", false); let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100); @@ -2369,3 +2369,24 @@ mod gap_tests { assert!(s.execute("gap.move", &json!({"at": [150, 200], "delta": 5})).is_err(), "inside an object"); } } + +#[cfg(test)] +mod line_end_tests { + use super::*; + + /// "One\rTwo" made one paragraph (the CR stayed in the text) instead of two. + #[test] + fn carriage_returns_separate_paragraphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\rTwo\r\nThree"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let story = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story(&s).text, "One\nTwo\nThree"); + assert_eq!(story(&s).paras.len(), 3); + // The caret is at the end: typed text gets the same treatment. + 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); + } +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 4ac0348..a3cb09c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -327,7 +327,7 @@ fn release(s: &mut Session, p: &Value) -> Result { } 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"); // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { return super::table::step_cell(s, true);