Text: a column, frame or page break ends its paragraph

A break character is stored as the last character of the paragraph it
ends, followed by the usual '\n' separator. The story model, paragraph
ranges, styles, editing and every exporter keep splitting paragraphs on
'\n' only, so the change stays in the places that create, lay out or
write break characters:

- IDML import writes the break and ends the paragraph, so the range that
  follows keeps its own paragraph style. NextOddPage and NextEvenPage map
  to new odd and even page break characters; IDML export writes a
  paragraph that ends in a break as that one Br with its
  ParagraphBreakType, so export and re-import give the same story.
- Word and RTF import, Type > Insert Break Character (with odd and even
  page breaks added) and typing end the paragraph after a break. Typing
  between a break and its return goes to the next paragraph; deleting
  either deletes both.
- Layout: a break that ends a paragraph leaves no empty line; the next
  paragraph starts in the next column, frame or page, as a paragraph start
  option would. Page breaks and Next Page start options move to the next
  frame on a later page; odd and even ones to the next page with that
  page number parity (no such frame: overset). A break inside a paragraph
  from an older document still moves the rest of that paragraph on.

Storing the break as a paragraph separator of its own would have changed
every place that counts or splits paragraphs on '\n'.
This commit is contained in:
aiongg
2026-10-10 03:41:12 +00:00
parent 4e0e3949f1
commit 69f8d0fcdd
15 changed files with 292 additions and 54 deletions
+2
View File
@@ -80,6 +80,8 @@ fn metachars(s: &str, grep: bool) -> String {
("~M", "\u{E002}"),
("~R", "\u{E003}"),
("~P", "\u{E004}"),
("~L", "\u{E011}"),
("~E", "\u{E012}"),
("~=", "\u{2014}"),
("~_", "\u{2014}"),
("~-", "\u{AD}"),
+70 -1
View File
@@ -370,6 +370,14 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let mut r = r.start.min(st.len())..r.end.min(st.len());
// Between a break character and its return is the end of that paragraph: typing there goes
// to the start of the next one.
if r.is_empty()
&& st.text.get(r.start..).is_some_and(|t| t.starts_with('\n'))
&& st.text.get(..r.start).and_then(|t| t.chars().next_back()).is_some_and(designcraft_doc::is_break_char)
{
r = r.start + 1..r.start + 1;
}
// Autocorrect: a word ended by a space or punctuation is looked up.
if let Some(list) = &autocorrect
&& r.is_empty()
@@ -392,8 +400,13 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
// A column, frame or page break ends its paragraph.
let mut text = if text.contains(designcraft_doc::is_break_char) {
designcraft_doc::end_paragraphs_at_breaks(&text, st.text.get(r.end..).and_then(|t| t.chars().next()))
} else {
text.clone()
};
// Typing next to a table anchor starts a paragraph of its own.
let mut text = text.clone();
let mut trail = 0;
if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() {
let pi = st.para_at(r.start);
@@ -538,6 +551,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) };
start..r.start
};
let r = with_break_pair(&st.text, r);
if tracking && t.cell.is_none() {
// Marked, not removed: the caret steps over the deleted text.
let at = super::changes::mark_deleted(st, r.clone());
@@ -551,6 +565,20 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// A break character and the return after it end one paragraph: deleting either deletes both.
fn with_break_pair(text: &str, r: std::ops::Range<usize>) -> std::ops::Range<usize> {
let Some(del) = text.get(r.clone()) else { return r };
let mut chars = del.chars();
match (chars.next(), chars.next()) {
(Some('\n'), None) => match text.get(..r.start).and_then(|t| t.chars().next_back()) {
Some(b) if designcraft_doc::is_break_char(b) => r.start.saturating_sub(b.len_utf8())..r.end,
_ => r,
},
(Some(b), None) if designcraft_doc::is_break_char(b) && text.get(r.end..).is_some_and(|t| t.starts_with('\n')) => r.start..r.end + 1,
_ => r,
}
}
pub fn next_char(s: &str, i: usize) -> usize {
s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
}
@@ -1291,6 +1319,47 @@ mod autocorrect_tests {
}
}
#[cfg(test)]
mod break_tests {
use serde_json::json;
use crate::Session;
/// Type › Insert Break Character: the break ends its paragraph, the caret starts the next one.
#[test]
fn inserted_break_ends_its_paragraph() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "OneTwo\nThree"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let story = |s: &Session| s.doc().unwrap().doc.stories[&sid].clone();
s.execute("text.select", &json!({"story": sid.0, "anchor": 3, "focus": 3})).unwrap();
s.execute("text.insert", &json!({"text": "\u{e011}", "raw": true})).unwrap();
assert_eq!(story(&s).text, "One\u{e011}\nTwo\nThree");
assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "One\u{e011}\n".len());
// At the end of a paragraph the break takes the place of its return.
let at = story(&s).text.find("\nThree").unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap();
s.execute("text.insert", &json!({"text": "\u{e002}", "raw": true})).unwrap();
let st = story(&s);
assert_eq!(st.text, "One\u{e011}\nTwo\u{e002}\nThree");
assert_eq!(st.paras.len(), 3);
st.check().unwrap();
// Typing after the break goes to the next paragraph; deleting the break or its return
// removes both.
let at = "One\u{e011}".len();
s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap();
s.execute("text.insert", &json!({"text": "x"})).unwrap();
assert_eq!(story(&s).text, "One\u{e011}\nxTwo\u{e002}\nThree");
s.execute("text.select", &json!({"story": sid.0, "anchor": at + 1, "focus": at + 1})).unwrap();
s.execute("text.delete", &json!({})).unwrap();
assert_eq!(story(&s).text, "OnexTwo\u{e002}\nThree");
s.execute("text.select", &json!({"story": sid.0, "anchor": "OnexTwo".len(), "focus": "OnexTwo".len()})).unwrap();
s.execute("text.delete", &json!({"forward": true})).unwrap();
assert_eq!(story(&s).text, "OnexTwoThree");
}
}
#[cfg(test)]
mod language_tests {
use serde_json::json;