Text: a column, frame or page break ends its paragraph (#285)

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'.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aiongg 2026-10-10 03:36:47 -04:00 committed by GitHub
parent a50d279cba
commit 8146737e97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 298 additions and 53 deletions

1
Cargo.lock generated
View File

@ -1013,6 +1013,7 @@ name = "designcraft-idml"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-geom",
"quick-xml 0.38.4",

View File

@ -208,7 +208,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
pub fn is_forced(c: char) -> bool {
use designcraft_doc::story::*;
matches!(c, FORCED_LINE_BREAK | COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK)
c == FORCED_LINE_BREAK || is_break_char(c)
}
/// Optical margin protrusion of `c` as fractions of its advance: (left edge, right edge).
@ -613,8 +613,9 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain:
if out.is_empty() {
out.push(Break { start: 0, end: n, next: n, hyphen: false, forced: true });
}
// Lines produced past the paragraph end (e.g. a trailing forced break) are dropped.
let trailing_forced = glyphs.last().is_some_and(|g| is_forced(g.ch));
// Lines produced past the paragraph end are dropped, except the empty line after a trailing
// forced line break (a trailing column, frame or page break ends the paragraph).
let trailing_forced = glyphs.last().is_some_and(|g| g.ch == designcraft_doc::story::FORCED_LINE_BREAK);
out.retain(|b| b.start < n || (b.start == n && trailing_forced));
out
}
@ -725,9 +726,10 @@ pub fn greedy(
start = next;
line += 1;
}
// A forced break as the very last glyph leaves an empty final line (InDesign shows it).
// A forced line break as the very last glyph leaves an empty final line (InDesign shows it);
// a column, frame or page break there ends the paragraph.
if let Some(last) = glyphs.last()
&& is_forced(last.ch)
&& last.ch == designcraft_doc::story::FORCED_LINE_BREAK
{
out.push(Break { start: n, end: n, next: n, hyphen: false, forced: true });
}

View File

@ -544,15 +544,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
}
let pf = &story.paras[pi];
let (pp, base_chars) = doc.styles.resolve_para(pf);
// A break character that ends the previous paragraph sends this one on, like a start option.
let after_break = pi
.checked_sub(1)
.and_then(|p| para_ranges.get(p))
.and_then(|r| story.text.get(r.clone()))
.and_then(|t| t.chars().next_back())
.and_then(break_start);
if let Some(t) = story.para_table(pi) {
if cur.last_baseline.is_some() {
match pp.start_paragraph {
StartParagraph::NextColumn => cur.next_column(&cols),
StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => {
cur.next_frame()
}
StartParagraph::Anywhere => {}
}
cur.start(after_break.unwrap_or(pp.start_paragraph), &cols, frames, doc);
}
let at_top = cur.last_baseline.is_none();
let start_at = (cur.fi, cur.col);
@ -747,16 +748,23 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1);
span_paras[pi] = spanning;
let next_column = |cur: &mut Cursor| if spanning { cur.next_frame() } else { cur.next_column(&cols) };
// A paragraph start or a break character; Next Column while a paragraph spans columns is
// the next frame, as above.
let begin_at = |cur: &mut Cursor, start: StartParagraph| match start {
StartParagraph::NextColumn => next_column(cur),
other => cur.start(other, &cols, frames, doc),
};
// Paragraph start options.
if let Some(start) = after_break
&& cur.last_baseline.is_some()
{
begin_at(&mut cur, start);
}
if force_col[pi] {
next_column(&mut cur);
}
if cur.last_baseline.is_some() {
match pp.start_paragraph {
StartParagraph::NextColumn => next_column(&mut cur),
StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => cur.next_frame(),
StartParagraph::Anywhere => {}
}
begin_at(&mut cur, pp.start_paragraph);
}
if spanning && cur.fi < frames.len() {
// Close the band: the paragraph goes below the deepest of its columns, which are
@ -1001,28 +1009,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.last_descent = desc;
cur.pending = 0.0;
line_no += 1;
// Column / frame / page break characters.
if b.forced && e < glyphs.len() + 1 {
let jumped = match brk {
Some(story::COLUMN_BREAK) => {
next_column(&mut cur);
true
}
Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => {
cur.next_frame();
true
}
_ => false,
};
if jumped {
// The rest of the paragraph continues in the new column/frame: re-break it there.
col_first_line = line_no;
if k + 1 < breaks.len() {
g0 += b.next;
moved = true;
break;
}
}
// A column / frame / page break inside the paragraph: the rest of it continues in the
// new column/frame/page, re-broken there. (A break that ends the paragraph moves the
// next paragraph instead.)
if b.forced
&& k + 1 < breaks.len()
&& let Some(start) = brk.and_then(break_start)
{
begin_at(&mut cur, start);
col_first_line = line_no;
g0 += b.next;
moved = true;
break;
}
}
if !moved {
@ -1575,6 +1573,18 @@ fn keep_violation(ctx: &KeepCtx, pp: &ParaProps, info: &[ParaInfo], force_col: &
None
}
/// The start option a column, frame or page break character stands for.
fn break_start(c: char) -> Option<StartParagraph> {
match c {
story::COLUMN_BREAK => Some(StartParagraph::NextColumn),
story::FRAME_BREAK => Some(StartParagraph::NextFrame),
story::PAGE_BREAK => Some(StartParagraph::NextPage),
story::ODD_PAGE_BREAK => Some(StartParagraph::NextOddPage),
story::EVEN_PAGE_BREAK => Some(StartParagraph::NextEvenPage),
_ => None,
}
}
fn ft_prev_end(overset: &Option<usize>, len: usize) -> usize {
overset.unwrap_or(len)
}
@ -1719,6 +1729,30 @@ impl Cursor {
let y1 = limit_of(&self.limits, self.band_key()).map_or(col.y1, |b| b.min(col.y1).max(y0));
Rect::new(col.x0, y0, col.x1, y1)
}
/// Move on as a paragraph start option (or a break character) asks.
fn start(&mut self, start: StartParagraph, cols: &[Vec<Rect>], frames: &[FrameSpec], doc: &Document) {
match start {
StartParagraph::Anywhere => {}
StartParagraph::NextColumn => self.next_column(cols),
StartParagraph::NextFrame => self.next_frame(),
StartParagraph::NextPage => self.next_page(frames, doc, None),
StartParagraph::NextOddPage => self.next_page(frames, doc, Some(true)),
StartParagraph::NextEvenPage => self.next_page(frames, doc, Some(false)),
}
}
/// Move to the next frame on a later page, one with an odd page number (`odd` true) or an
/// even one (false) when asked. Frames without a page (parent pages) qualify; with no frame
/// left, the rest of the story is overset.
fn next_page(&mut self, frames: &[FrameSpec], doc: &Document, odd: Option<bool>) {
let from = frames.get(self.fi).and_then(|f| f.page);
let skip = |f: &FrameSpec| f.page.is_some_and(|p| Some(p) == from || odd.is_some_and(|o| (doc.page_number(p) % 2 == 1) != o));
let mut fi = self.fi.saturating_add(1);
while frames.get(fi).is_some_and(skip) {
fi += 1;
}
self.next_frame();
self.fi = fi;
}
/// Baseline for the next line with leading `lead` and ascent `asc`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 {
match self.last_baseline {

View File

@ -545,6 +545,8 @@ fn shape_run_raw(
| story::COLUMN_BREAK
| story::FRAME_BREAK
| story::PAGE_BREAK
| story::ODD_PAGE_BREAK
| story::EVEN_PAGE_BREAK
| story::INDENT_HERE
| story::RIGHT_INDENT_TAB
| story::TABLE_ANCHOR

View File

@ -573,6 +573,23 @@ fn column_break_moves_following_text() {
assert!(lines[1].baseline < 20.0, "second column starts at the top");
}
/// InDesign: a break character ends its paragraph; the next paragraph starts on the next page (odd
/// or even for those breaks), with no empty line where the break was.
#[test]
fn break_characters_end_their_paragraph_and_keep_page_parity() {
use designcraft_doc::story::{EVEN_PAGE_BREAK, ODD_PAGE_BREAK, PAGE_BREAK};
let mut d = Document::new(&NewDocument { pages: 4, facing_pages: false, primary_text_frame: true, ..Default::default() });
let sid = d.settings.primary_story.unwrap();
let text = format!("one{ODD_PAGE_BREAK}\ntwo{EVEN_PAGE_BREAK}\nthree{PAGE_BREAK}\nfour");
d.story_mut(sid).unwrap().insert(0, &text);
let cs = compose_story(&d, sid, &ComposeOptions::default());
// Frame i is on page i + 1: "one" on 1, "two" on the next odd page (3), "three" on the next
// even page (4), and "four" has no page left.
let lines: Vec<(usize, usize)> = cs.frames.iter().enumerate().flat_map(|(fi, f)| f.lines.iter().map(move |l| (fi, l.para))).collect();
assert_eq!(lines, vec![(0, 0), (2, 1), (3, 2)]);
assert_eq!(cs.overset_at, Some(text.find("four").unwrap()));
}
#[test]
fn line_before_a_break_character_is_a_last_line() {
// As in InDesign: column, frame and page breaks end the paragraph's last line (set with the

View File

@ -8,7 +8,9 @@
//! the "typing" format).
//!
//! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`],
//! [`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], `\u{AD}` discretionary hyphen,
//! break characters ([`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], [`ODD_PAGE_BREAK`],
//! [`EVEN_PAGE_BREAK`]; each ends its paragraph, so it is followed by `\n`, see
//! [`is_break_char`]), `\u{AD}` discretionary hyphen,
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`],
//! [`TABLE_ANCHOR`] (a table, see [`crate::table`]), [`crate::notes::FOOTNOTE_REF`] (a footnote),
//! [`crate::xref::ANCHOR_MARK`] / [`crate::xref::XREF_MARK`] (text anchor / cross-reference).
@ -34,6 +36,30 @@ pub const PREV_PAGE_NUMBER: char = '\u{E008}';
/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table.
pub const TABLE_ANCHOR: char = '\u{E009}';
pub const FORCED_LINE_BREAK: char = '\u{2028}';
pub const ODD_PAGE_BREAK: char = '\u{E011}';
pub const EVEN_PAGE_BREAK: char = '\u{E012}';
/// A column, frame or page break character. It ends its paragraph: the story stores it as the
/// paragraph's last character, followed by the `\n` separator (also at the end of the story), and
/// the next paragraph starts in the next column, frame or page. A break inside a paragraph (from
/// an older document or a raw edit) still moves the rest of that paragraph on.
pub fn is_break_char(c: char) -> bool {
matches!(c, COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK | ODD_PAGE_BREAK | EVEN_PAGE_BREAK)
}
/// `text` with a `\n` after every break character that isn't followed by one. `next` is the
/// character after the text (None at the end of the story).
pub fn end_paragraphs_at_breaks(text: &str, next: Option<char>) -> String {
let mut out = String::with_capacity(text.len() + 1);
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
out.push(c);
if is_break_char(c) && chars.peek().copied().or(next) != Some('\n') {
out.push('\n');
}
}
out
}
pub const BASIC_PARAGRAPH: &str = "[Basic Paragraph]";
pub const NO_CHAR_STYLE: &str = "[None]";
@ -269,6 +295,23 @@ impl Story {
self.rev += 1;
}
/// End the paragraph after every break character that isn't followed by a `\n`.
pub fn end_paragraphs_at_breaks(&mut self) {
let at: Vec<usize> = self
.text
.char_indices()
.filter(|&(i, c)| is_break_char(c) && !self.text[i + c.len_utf8()..].starts_with('\n'))
.map(|(i, c)| i + c.len_utf8())
.collect();
for &pos in at.iter().rev() {
let fmt = self.char_format_at(pos).clone();
self.insert_with_raw(pos, "\n", fmt);
}
if !at.is_empty() {
self.fix_tables();
}
}
/// Delete the byte range (clamped to char boundaries). Merged paragraphs keep the first one's format.
pub fn delete(&mut self, range: std::ops::Range<usize>) {
let a = floor_char_boundary(&self.text, range.start.min(self.text.len()));

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}"),

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())
}
@ -1349,6 +1377,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;

View File

@ -15,3 +15,6 @@ designcraft-doc = { workspace = true }
thiserror = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }
[dev-dependencies]
designcraft-compose = { workspace = true }

View File

@ -1865,6 +1865,7 @@ impl<'a> Ex<'a> {
let pf = s.paras.get(pi).cloned().unwrap_or_default();
let mut psr = self.psr_el(&pf);
let last = pi + 1 == n;
let ends_with_break = s.text.get(r.clone()).and_then(|t| t.chars().next_back()).is_some_and(st::is_break_char);
// Character runs intersecting the paragraph.
let mut segs: Vec<(std::ops::Range<usize>, CharFormat)> = Vec::new();
for (rr, f) in s.runs() {
@ -1911,6 +1912,8 @@ impl<'a> Ex<'a> {
st::COLUMN_BREAK => Some("NextColumn"),
st::FRAME_BREAK => Some("NextFrame"),
st::PAGE_BREAK => Some("NextPage"),
st::ODD_PAGE_BREAK => Some("NextOddPage"),
st::EVEN_PAGE_BREAK => Some("NextEvenPage"),
_ => None,
};
if let Some(code) = ace {
@ -2088,7 +2091,8 @@ impl<'a> Ex<'a> {
flush_text(&mut cur, &mut pending);
flush_content(&mut pending, &mut out);
let is_last_seg = k + 1 == nseg;
if is_last_seg && !last {
// A break character that ends the paragraph was written as its `Br`.
if is_last_seg && !last && !ends_with_break {
out.push(Node::El(El::new("Br")));
}
if !out.is_empty() || csrs.is_empty() {

View File

@ -1626,21 +1626,22 @@ impl<'r> Importer<'r> {
}
}
}
"Br" => match brk {
Some(t) => {
"Br" => {
// A column, frame or page break ends its paragraph like a return does.
if let Some(t) = brk {
let ch = match t {
"NextColumn" => st::COLUMN_BREAK,
"NextFrame" => st::FRAME_BREAK,
"NextOddPage" => st::ODD_PAGE_BREAK,
"NextEvenPage" => st::EVEN_PAGE_BREAK,
_ => st::PAGE_BREAK,
};
b.push(&ch.to_string(), cf);
}
None => {
b.push("\n", cf);
b.paras.push(pf.clone());
b.fresh = true;
}
},
b.push("\n", cf);
b.paras.push(pf.clone());
b.fresh = true;
}
"Table" => {
let t = self.table(c);
b.push_table(t, pf, cf);

View File

@ -196,6 +196,49 @@ fn fixture_with_story(story: &str) -> Vec<u8> {
])
}
/// InDesign: a break character ends its paragraph (`<Br/>` with a ParagraphBreakType closes the
/// range); the text after it is a paragraph with its own style, set on the next page of the
/// break's parity.
const ODD_PAGE_BREAK_STORY: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1">
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Text%3aBody">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Body</Content></CharacterStyleRange>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" ParagraphBreakType="NextOddPage"><Br/></CharacterStyleRange>
</ParagraphStyleRange>
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle" Justification="CenterAlign">
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>1</Content></CharacterStyleRange>
</ParagraphStyleRange>
</Story>
</idPkg:Story>"#;
#[test]
fn odd_page_break_ends_its_paragraph_and_keeps_its_parity() {
let mut d = import_idml_with(&fixture_with_story(ODD_PAGE_BREAK_STORY), &|_| None).unwrap();
let sid = *d.stories.keys().next().unwrap();
let story = &d.stories[&sid];
assert_eq!(story.text, format!("Body{}\n1", st::ODD_PAGE_BREAK));
assert_eq!(story.paras.len(), 2);
assert_eq!(story.paras[0].style, "Text/Body");
assert_eq!(story.paras[1].style, st::BASIC_PARAGRAPH);
assert_eq!(story.paras[1].para.align, Some(designcraft_doc::Align::Center));
// Export writes the break as the paragraph's one `Br`; importing that gives the same story.
let back = import_idml(&export_idml(&d)).unwrap();
let again = back.stories.values().next().unwrap();
assert_eq!(again.text, story.text);
assert_eq!(again.paras, story.paras);
// The fixture's frames are on pages v and vi: the next odd page after v has no frame.
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
assert_eq!(cs.frames[0].lines.len(), 1, "no empty line after the break");
assert!(cs.frames[1].lines.is_empty());
assert_eq!(cs.overset_at, Some(story.text.len() - 1));
// Numbered from 4, the second frame is on page 5: "1" starts there.
d.sections[0].start_number = Some(4);
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
assert_eq!(cs.overset_at, None);
assert_eq!(cs.frames[1].lines.iter().map(|l| l.para).collect::<Vec<_>>(), vec![1]);
}
#[test]
fn imports_hand_written_fixture() {
let d = import_idml_with(&fixture(), &|_| None).unwrap();
@ -240,8 +283,8 @@ fn imports_hand_written_fixture() {
assert_eq!(d.styles.char_style("Strong").unwrap().chars.font_style.as_deref(), Some("Bold"));
// Frames, threads, text.
let story = d.stories.values().next().unwrap();
assert_eq!(story.text, format!("Hello & bold\nPage {}\tend{}after", st::PAGE_NUMBER, st::FRAME_BREAK));
assert_eq!(story.paras.len(), 2);
assert_eq!(story.text, format!("Hello & bold\nPage {}\tend{}\nafter", st::PAGE_NUMBER, st::FRAME_BREAK));
assert_eq!(story.paras.len(), 3);
assert_eq!(story.paras[0].style, "Text/Body");
assert_eq!(story.paras[1].style, st::BASIC_PARAGRAPH);
assert_eq!(story.paras[1].para.align, Some(designcraft_doc::Align::Center));
@ -408,7 +451,7 @@ fn small_doc() -> Document {
if let Some(s) = d.story_mut(sid) {
s.format_chars(0..3, |f| f.over = CharAttrs { size: Some(20.0), fill: Some("Brand".into()), ..Default::default() });
let end = s.len();
s.insert(end, &format!(" {}", st::COLUMN_BREAK));
s.insert(end, &format!(" {}\n", st::COLUMN_BREAK));
}
let id = designcraft_doc::ItemId(d.alloc());
let mut it = designcraft_doc::Item::new(id, lid, Shape::Oval, shapes::ellipse(Rect::new(650.0, 300.0, 750.0, 380.0)));

View File

@ -378,6 +378,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let body = doc.child("body").ok_or_else(|| ImportError::Corrupt("no document body".into()))?;
let mut st = Story::new(StoryId(0));
ctx.blocks(body, &mut st, true);
// A page or column break ends its paragraph.
st.end_paragraphs_at_breaks();
// Only styles that are used, plus what they are based on.
let used_p: std::collections::HashSet<String> = st.paras.iter().map(|p| p.style.clone()).collect();
let used_c: std::collections::HashSet<String> = st.runs().map(|(_, f)| f.style.clone()).collect();
@ -472,6 +474,24 @@ mod tests {
assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic"));
}
/// A page break ends its paragraph: the text after it is a paragraph of its own (same style).
#[test]
fn page_break_ends_its_paragraph() {
let styles = format!(r#"<w:styles {W}><w:style w:type="paragraph" w:styleId="H"><w:name w:val="Chapter"/></w:style></w:styles>"#);
let document = format!(
r#"<w:document {W}><w:body>
<w:p><w:r><w:t>End of one</w:t></w:r><w:r><w:br w:type="page"/></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="H"/></w:pPr><w:r><w:br w:type="page"/><w:t>Two</w:t><w:br w:type="column"/></w:r></w:p>
</w:body></w:document>"#
);
let st = import(&docx(&document, &styles, "")).unwrap().story;
st.check().unwrap();
let (pb, cb) = (designcraft_doc::PAGE_BREAK, designcraft_doc::COLUMN_BREAK);
assert_eq!(st.text, format!("End of one{pb}\n{pb}\nTwo{cb}\n"));
let styles: Vec<&str> = st.paras.iter().map(|p| p.style.as_str()).collect();
assert_eq!(styles, [BASIC_PARAGRAPH, "Chapter", "Chapter", "Chapter"]);
}
#[test]
fn tracked_changes_import_as_accepted_including_moves() {
// Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import.

View File

@ -257,6 +257,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let n = st.len();
st.delete(n - 1..n);
}
// A page or column break ends its paragraph.
st.end_paragraphs_at_breaks();
Ok(Imported { story: st, para_styles: vec![], char_styles: vec![], warnings })
}

View File

@ -358,6 +358,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:text.insert|Column Break|{\"text\": \"\u{e002}\", \"raw\": true}",
"cmd:text.insert|Frame Break|{\"text\": \"\u{e003}\", \"raw\": true}",
"cmd:text.insert|Page Break|{\"text\": \"\u{e004}\", \"raw\": true}",
"cmd:text.insert|Odd Page Break|{\"text\": \"\u{e011}\", \"raw\": true}",
"cmd:text.insert|Even Page Break|{\"text\": \"\u{e012}\", \"raw\": true}",
"cmd:text.insert|Paragraph Return|{\"text\": \"\\n\", \"raw\": true}",
"cmd:text.insert|Forced Line Break|{\"text\": \"\u{2028}\", \"raw\": true}",
"<",