mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
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:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,3 +15,6 @@ designcraft-doc = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-compose = { workspace = true }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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}",
|
||||
"<",
|
||||
|
||||
Reference in New Issue
Block a user