diff --git a/Cargo.lock b/Cargo.lock index 3fc1e32..513b343 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 7646aab..dbc8819 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -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 }); } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 2174708..6d5f3f7 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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 { + 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, 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], 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) { + 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 { diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 41583b0..6e3854f 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -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 diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6c7da80..c853f31 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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 diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 21ac81b..6dbfa13 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -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) -> 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 = 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) { let a = floor_char_boundary(&self.text, range.start.min(self.text.len())); diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 12c09df..8781604 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -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}"), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 92ddaf6..576fa45 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -370,6 +370,14 @@ fn insert(s: &mut Session, p: &Value) -> Result { 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 { } } } + // 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 { 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 { }) } +/// 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) -> std::ops::Range { + 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; diff --git a/crates/idml/Cargo.toml b/crates/idml/Cargo.toml index 9ee6905..7e11a33 100644 --- a/crates/idml/Cargo.toml +++ b/crates/idml/Cargo.toml @@ -15,3 +15,6 @@ designcraft-doc = { workspace = true } thiserror = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } + +[dev-dependencies] +designcraft-compose = { workspace = true } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index c63815b..6636784 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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, 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() { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index a8f7e7a..2b5f3fe 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -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); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 850726b..86f2873 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -196,6 +196,49 @@ fn fixture_with_story(story: &str) -> Vec { ]) } +/// InDesign: a break character ends its paragraph (`
` 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#" + + + + Body +
+
+ + 1 + +
+
"#; + +#[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![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))); diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 670f8a0..67a2b71 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -378,6 +378,8 @@ pub fn import(bytes: &[u8]) -> Result { 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 = st.paras.iter().map(|p| p.style.clone()).collect(); let used_c: std::collections::HashSet = 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#""#); + let document = format!( + r#" + End of one + Two + "# + ); + 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. diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 9189b52..91a5ae5 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -257,6 +257,8 @@ pub fn import(bytes: &[u8]) -> Result { 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 }) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c8d54bc..74ebaa6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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}", "<",