mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 13:27:27 -06:00
#3907: continuous sections running off a page go onto the next; empty marks in a row stack to the page bottom
A continuous section moves the column top down below the text above. at_top()
then held at every new section start, so a paragraph that did not fit was
placed anyway, as on an empty page. A run of one-line continuous sections ran
off the bottom of page 1 (100 of them reached 2,480 pt down a 792 pt page).
The overflow checks for paragraphs now use fresh(): at the top with nothing
above on this page. at_top() still drives the spacing rules, unchanged.
Empty paragraphs that close a continuous section right after another
section's mark took no room. Measured in Word on synthetic files ("Start", N
such marks, "End"): 10 put "End" about 9 cm down page 1; 50, 100 and 200 all
put it on the first line of page 2, the same with 24 pt marks or no paragraph
spacing. So each takes its line while it fits, then stays at the page's
bottom. A mark right after text stays quiet, as measured before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2cf1d6c7ae
commit
7757828295
@@ -172,6 +172,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed.
|
||||
- 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891).
|
||||
- 2026-10-22: Markdown inline code replaces each line ending with a space before export, including code in lists and table cells. A following heading, list marker or HTML tag stays literal code (#3738).
|
||||
- 2026-10-23: Layout: paragraphs that each start a continuous section now go onto new pages. A run of them ran off the bottom of the first page, because each new section counted as the top of a page. Empty section-break paragraphs after the first in a row take a line each until the page is full and then stay at its bottom, so the text after them starts the next page, as measured in Word (#3907).
|
||||
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
|
||||
- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs.
|
||||
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
|
||||
@@ -678,6 +679,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password |
|
||||
| 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) |
|
||||
| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: continuous sections paginate, #3907 |
|
||||
| 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there |
|
||||
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
|
||||
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
|
||||
|
||||
@@ -43,6 +43,9 @@ pub(crate) struct Ctx<'a> {
|
||||
/// Empty body paragraphs with hidden marks or continuous section breaks, in order:
|
||||
/// they take no room (see [`place_para`]).
|
||||
pub(crate) quiet_marks: Vec<usize>,
|
||||
/// Empty paragraphs closing a continuous section right after another section's mark, in
|
||||
/// order: each takes its line while it fits, then stays at the page's bottom (#3907).
|
||||
pub(crate) stacked_marks: Vec<usize>,
|
||||
/// Whether even pages are mirrored (mirrored margins, on pages): see [`mirror_even_pages`].
|
||||
pub(crate) mirror: bool,
|
||||
/// Cache-key hashes of the table-style text formatting cells were laid out with
|
||||
|
||||
@@ -36,6 +36,19 @@ pub(crate) fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, blo
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !pb.web && ctx.stacked_marks.binary_search(&block).is_ok() {
|
||||
// Measured in Word (#3907): each takes its line while it fits; past the bottom it stays
|
||||
// there instead of starting a page, so the text after a long run starts the next page.
|
||||
let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col));
|
||||
pb.bottom = f32::MAX / 4.0;
|
||||
place_para_in_flow(ctx, pb, p, block, body_top);
|
||||
pb.bottom = bottom;
|
||||
if (pb.pages.len(), pb.col) == at && pb.y > bottom + 0.01 {
|
||||
// At the bottom: nothing more fits on the page, even a line smaller than the mark's.
|
||||
(pb.y, pb.prev, pb.content_bottom) = (bottom.max(y), prev, content_bottom);
|
||||
}
|
||||
return;
|
||||
}
|
||||
place_para_in_flow(ctx, pb, p, block, body_top);
|
||||
}
|
||||
|
||||
@@ -164,7 +177,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
let need = height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0, &mut ctx.counters.clone()) } else { 0.0 };
|
||||
// (Against the whole column: balanced columns grow to hold it.)
|
||||
// With the 0.01 pt of slack the other fit checks take.
|
||||
if pb.y + need > pb.bottom + 0.01 && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top {
|
||||
if pb.y + need > pb.bottom + 0.01 && !pb.fresh() && need <= pb.uncapped_bottom() - pb.top {
|
||||
pb.advance(block, body_top);
|
||||
pb.y += pl.rp.space_before.min(0.0);
|
||||
applied_before = pl.rp.space_before.min(0.0);
|
||||
@@ -324,7 +337,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
// An empty paragraph closing a section before a new page: measured in Word, it stays
|
||||
// on a full page instead of starting one of its own.
|
||||
let closing_mark = pb.next_starts_page && p.section.is_some() && p.text.is_empty();
|
||||
if over && l1 == l0 && !pb.at_top() && !split && !only_break && !closing_mark {
|
||||
if over && l1 == l0 && !pb.fresh() && !split && !only_break && !closing_mark {
|
||||
break;
|
||||
}
|
||||
new_notes.extend(line_notes);
|
||||
@@ -343,11 +356,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
}
|
||||
// Widow/orphan control (2-line minimum at either end).
|
||||
if forced_break.is_none() && l1 < n && pl.rp.widow_control && n >= 2 {
|
||||
if l1 - l0 == 1 && l0 == 0 && !pb.at_top() {
|
||||
if l1 - l0 == 1 && l0 == 0 && !pb.fresh() {
|
||||
l1 = l0; // orphan: move the first line to the next page
|
||||
} else if n - l1 == 1 && (l1 - l0 >= 3 || (l0 > 0 && l1 - l0 >= 2)) {
|
||||
l1 -= 1; // widow: take one more line along
|
||||
} else if n - l1 == 1 && l0 == 0 && l1 - l0 == 2 && !pb.at_top() {
|
||||
} else if n - l1 == 1 && l0 == 0 && l1 - l0 == 2 && !pb.fresh() {
|
||||
// Neither split of a three-line paragraph is allowed (two and a widow, or an
|
||||
// orphan and two): it moves whole, as in Word.
|
||||
l1 = l0;
|
||||
@@ -358,7 +371,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
l1 -= 1;
|
||||
}
|
||||
if l1 == l0 {
|
||||
if pb.at_top() {
|
||||
if pb.fresh() {
|
||||
l1 = l0 + 1; // can't fit even one line on an empty page: overflow
|
||||
} else {
|
||||
pb.advance(block, body_top);
|
||||
|
||||
@@ -14,6 +14,9 @@ pub(crate) struct PageBuilder<'a> {
|
||||
pub(crate) cols: Vec<(f32, f32)>,
|
||||
pub(crate) y: f32,
|
||||
pub(crate) top: f32,
|
||||
/// The body's top on this page: `top` moves down below the text when a continuous section
|
||||
/// starts mid-page.
|
||||
pub(crate) page_top: f32,
|
||||
pub(crate) bottom: f32,
|
||||
pub(crate) number: u32,
|
||||
pub(crate) web: bool,
|
||||
@@ -317,6 +320,7 @@ impl PageBuilder<'_> {
|
||||
self.col = 0;
|
||||
self.cols = s.column_boxes();
|
||||
self.top = body_top;
|
||||
self.page_top = body_top;
|
||||
self.bottom = if self.web { f32::MAX / 8.0 } else { bottom };
|
||||
self.orig_bottom = self.bottom;
|
||||
self.y = self.top;
|
||||
@@ -356,6 +360,12 @@ impl PageBuilder<'_> {
|
||||
pub(crate) fn at_top(&self) -> bool {
|
||||
self.y <= self.top + 0.01
|
||||
}
|
||||
/// At the top with nothing above on this page, so moving on gains no room. A continuous
|
||||
/// section starting mid-page is at its top but not fresh: what doesn't fit there goes to the
|
||||
/// next page (#3907).
|
||||
pub(crate) fn fresh(&self) -> bool {
|
||||
self.at_top() && self.top <= self.page_top + 0.01
|
||||
}
|
||||
/// Move to the next column or page.
|
||||
pub(crate) fn advance(&mut self, block: usize, body_top: f32) {
|
||||
if self.col + 1 < self.cols.len() {
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(crate) fn layout_pass(
|
||||
track_totals: true,
|
||||
pct_base: None,
|
||||
quiet_marks: Vec::new(),
|
||||
stacked_marks: Vec::new(),
|
||||
mirror: false,
|
||||
table_keys: Vec::new(),
|
||||
fitted: HashMap::new(),
|
||||
@@ -80,12 +81,13 @@ pub(crate) fn layout_pass(
|
||||
let read = opts.view == ViewMode::Read;
|
||||
let reflow = web || read;
|
||||
ctx.mirror = doc.settings.mirror_margins && !reflow;
|
||||
ctx.quiet_marks = sections
|
||||
// Only the first of a run of such marks is quiet; the rest are stacked (Word, #3907).
|
||||
(ctx.stacked_marks, ctx.quiet_marks) = sections
|
||||
.windows(2)
|
||||
.filter(|w| w[1].1.start == SectionStart::Continuous)
|
||||
.map(|w| w[0].0)
|
||||
.filter(|end| doc.body.get(*end).and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()))
|
||||
.collect();
|
||||
.partition(|end| end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some()));
|
||||
if !opts.show_hidden {
|
||||
ctx.quiet_marks.extend(
|
||||
doc.body
|
||||
@@ -130,6 +132,7 @@ pub(crate) fn layout_pass(
|
||||
cols: Vec::new(),
|
||||
y: 0.0,
|
||||
top: 0.0,
|
||||
page_top: 0.0,
|
||||
bottom: 0.0,
|
||||
number: 0,
|
||||
web,
|
||||
|
||||
@@ -8667,6 +8667,40 @@ fn an_empty_continuous_section_mark_takes_no_room() {
|
||||
assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}");
|
||||
}
|
||||
|
||||
/// Empty marks closing continuous sections one after another each keep their line while it
|
||||
/// fits; only one right after text is quiet. Past the page's bottom they stay there, so the text
|
||||
/// after the run starts the next page. Measured in Word (#3907): "Start", N such marks, "End":
|
||||
/// 10 put "End" about 9 cm down page 1; 50, 100 and 200 all put it on page 2's first line.
|
||||
#[test]
|
||||
fn a_run_of_empty_continuous_section_marks_keeps_a_line_each() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let tops_sized = |marks: usize, size: Option<f32>| {
|
||||
let mut d = Document::from_text(&format!("Start\n{}End", "\n".repeat(marks)));
|
||||
for i in 1..=marks {
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
|
||||
p.section = Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
|
||||
p.mark.size = size;
|
||||
}
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
let l = lay(&d);
|
||||
let (start, end) = (l.caret(&Pos::body(0, 0)).unwrap(), l.caret(&Pos::body(marks + 1, 0)).unwrap());
|
||||
(end.page, end.top - start.top)
|
||||
};
|
||||
let tops = |marks: usize| tops_sized(marks, None);
|
||||
let (_, one) = tops(1);
|
||||
let (_, three) = tops(3);
|
||||
let line = three - one;
|
||||
assert!(line > 0.0, "marks after the first take room: one {one}, three {three}");
|
||||
let (_, five) = tops(5);
|
||||
assert!((five - three - line).abs() < 0.15, "a line per extra mark: three {three}, five {five}");
|
||||
let (fifty, two_hundred) = (tops(50), tops(200));
|
||||
assert_eq!(fifty.0, 1, "50 marks fill page 1, End starts page 2: {fifty:?}, line {line}");
|
||||
assert!((fifty.1 - two_hundred.1).abs() < 0.01 && two_hundred.0 == 1, "more marks stay on page 1: {fifty:?} {two_hundred:?}");
|
||||
// Large marks too: the run ends at the bottom, so a smaller line after it still starts page 2.
|
||||
let big = tops_sized(100, Some(24.0));
|
||||
assert!(big.0 == 1 && (big.1 - fifty.1).abs() < 0.01, "24 pt marks: {big:?}, small: {fifty:?}");
|
||||
}
|
||||
|
||||
/// A floating object positioned relative to its paragraph is measured from the paragraph's start
|
||||
/// above its space before, measured in Word for Windows (`plan/word` R22, 10 cases: 0, 10 and 20
|
||||
/// pt before, offsets 0 and −7.2 pt; the previous paragraph's space after still counts). The SARP
|
||||
@@ -11646,3 +11680,23 @@ fn a_row_leaves_room_for_the_border_below_it() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sections starting continuously one after another still go onto new pages: a paragraph that
|
||||
/// doesn't fit below the text above it moves on rather than running off the page (#3907). A
|
||||
/// hundred one-line sections lay out like a hundred plain paragraphs.
|
||||
#[test]
|
||||
fn continuous_sections_running_off_a_page_go_onto_the_next() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let marks = 100;
|
||||
let text = format!("Start\n{}End", "x\n".repeat(marks));
|
||||
let mut d = Document::from_text(&text);
|
||||
for i in 1..=marks {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section =
|
||||
Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
|
||||
}
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
let end = lay(&d).caret(&Pos::body(marks + 1, 0)).unwrap();
|
||||
let plain = lay(&Document::from_text(&text)).caret(&Pos::body(marks + 1, 0)).unwrap();
|
||||
assert!(end.page > 0, "End past the first page: {end:?}");
|
||||
assert_eq!((end.page, (end.top * 10.0).round()), (plain.page, (plain.top * 10.0).round()), "sections {end:?}, plain {plain:?}");
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user