Merge #285: Text: a column, frame or page break ends its paragraph; odd and even page breaks

Conflict: crates/compose/src/lib.rs: start options, the break after the previous paragraph and a break inside a paragraph go through a local `go` that sends NextColumn to next's span-aware next_column (#276) and the rest to #285's Cursor::start; the in-paragraph break takes #283's `brk` (the line's last glyph); #283's ends_para uses is_break_char; Cursor keeps next's resume() and adds #285's start/next_page (#285's old next_baseline header dropped, next's is kept); the table branch is #285's
Conflict: crates/idml/src/tests.rs: tests only; kept both
Conflict: crates/textimport/src/docx.rs: tests only; kept both
This commit is contained in:
aiongg
2026-10-10 03:51:42 +00:00
15 changed files with 297 additions and 54 deletions
Generated
+1
View File
@@ -1015,6 +1015,7 @@ name = "designcraft-idml"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-geom",
"quick-xml 0.38.4",
+7 -5
View File
@@ -254,7 +254,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).
@@ -693,8 +693,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
}
@@ -819,9 +820,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 });
}
+67 -35
View File
@@ -564,15 +564,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);
@@ -813,16 +814,21 @@ 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) };
let go = |cur: &mut Cursor, start| match start {
StartParagraph::NextColumn => next_column(cur),
start => cur.start(start, &cols, frames, doc),
};
// Paragraph start options.
if let Some(start) = after_break
&& cur.last_baseline.is_some()
{
go(&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 => {}
}
go(&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
@@ -1060,7 +1066,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
// A column, frame or page break ends the line as a paragraph's last line (last-line
// alignment); a justified line ended by a forced line break stays justified.
let ends_para = last || matches!(brk, Some(story::COLUMN_BREAK | story::FRAME_BREAK | story::PAGE_BREAK));
let ends_para = last || brk.is_some_and(story::is_break_char);
let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last;
let (mut placed, end_x, ratio) =
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info);
@@ -1151,28 +1157,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)
{
go(&mut cur, start);
col_first_line = line_no;
g0 += b.next;
moved = true;
break;
}
}
if !moved {
@@ -1744,6 +1740,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)
}
@@ -1878,6 +1886,30 @@ impl Cursor {
}
}
}
/// 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;
}
fn band_key(&self) -> BandKey {
(self.fi, self.band.para)
}
+2
View File
@@ -703,6 +703,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
+17
View File
@@ -812,6 +812,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
+44 -1
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]";
@@ -284,6 +310,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()));
+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
@@ -469,6 +469,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()
@@ -491,8 +499,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);
@@ -649,6 +662,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());
@@ -662,6 +676,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())
}
@@ -1677,6 +1705,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;
+3
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 }
+5 -1
View File
@@ -1881,6 +1881,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() {
@@ -1927,6 +1928,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 {
@@ -2104,7 +2107,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() {
+9 -8
View File
@@ -1667,21 +1667,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);
+46 -3
View File
@@ -580,6 +580,49 @@ fn absent_item_and_document_attributes_take_indesign_defaults() {
}
}
/// 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();
@@ -624,8 +667,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));
@@ -792,7 +835,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)));
+20
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();
@@ -490,4 +492,22 @@ mod tests {
i.story.check().unwrap();
assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd.");
}
/// 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"]);
}
}
+2
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 })
}
+2
View File
@@ -364,6 +364,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}",
"<",