Skip to content

fix: balance multi-column sections at continuous section breaks (SD-3359)#3638

Open
tupizz wants to merge 10 commits into
mainfrom
tadeu/sd-3359-bug-two-column-section-is-not-balanced-before-a-continuous
Open

fix: balance multi-column sections at continuous section breaks (SD-3359)#3638
tupizz wants to merge 10 commits into
mainfrom
tadeu/sd-3359-bug-two-column-section-is-not-balanced-before-a-continuous

Conversation

@tupizz
Copy link
Copy Markdown
Contributor

@tupizz tupizz commented Jun 4, 2026

Summary

Before After
CleanShot 2026-06-05 at 09 36 11@2x CleanShot 2026-06-05 at 09 36 34@2x

A two-column section ending at a continuous section break rendered with lumpy columns. On the IT-1150 repro NDA the left column ran 169px deeper than the right, and the trailing single-column paragraph started below the unbalanced overhang. Word balances the section: per the ECMA-376 17.18.77 note, "a continuous section break balances the content of the previous section", defined as "starting the next section at the minimum section height such that all content constraints are met".

Linear: SD-3359

Root cause

Mid-page balancing infrastructure already existed and fired for this repro. Tracing showed three defects in the balancing path itself:

  1. balanceSectionOnPage fed the balancer atomic fragments (canBreak: false, no lineHeights). The core algorithm already computes line-level break points with widow/orphan control (blockBreakPoints), but no caller ever consumed them. A 273px paragraph straddling the column boundary could not split, forcing a 169px imbalance.
  2. The binary search floored its target at the tallest block's full height. For a breakable paragraph the indivisible unit is its tallest line, so the search was pinned above the balanced height and packed the overflow lines into column 0.
  3. The post-layout gate keyed "mid-doc continuous" off the section's own begin type (its sectPr w:type, 17.6.22) instead of the break that ends it (the next section's begin type). A 2-col section that merely started continuous balanced even when ended by a nextPage break.

Fix

  • Paragraph fragments opt into line-level breaking: per-line heights sliced to the fragment's fromLine..toLine. A chosen break is applied as fragment surgery: the first half keeps the leading lines, a cloned second half carries the rest to the top of the next column, the same fromLine/toLine and continuation-flag model pagination uses.
  • w:keepLines paragraphs (17.3.1.14) and sectPr marker paragraphs stay atomic. keepLinesBlockIds is threaded from the layout walk into both balancing call sites.
  • The search floor uses each block's tallest indivisible chunk.
  • The gate reads the next section's begin type, and the misleading comment on sectionEndBreakType that caused the off-by-one is corrected.

Spec conformance matrix

Verified in the browser against the repro plus eleven mutations derived from ECMA-376:

Document Spec basis Result
Repro: 2-col then continuous 1-col 17.18.77 note 169px -> 14px, trailing tucked below
3 columns 17.6.4 balanced, 13px
Explicit equal w:col 17.6.3 balanced, 14px
Genuinely unequal w:col Word column-flow not balanced (guard kept)
keepLines on the straddling paragraph 17.3.1.14 balanced without splitting it
Explicit column break author intent not balanced
End break nextPage 17.18.77 not balanced, trailing page 2 (gate fix)
w:sep="1" 17.6.4 balancing unaffected
End break nextColumn 17.18.77 not balanced
End break evenPage 17.18.77 not balanced, next even page
2-col on implicit body sectPr sd-1655 rule not balanced (679px column-flow)
2-col on explicit-continuous body mixed-columns-tabs-tnr precedent not balanced

Known pre-existing gap surfaced by the sweep, out of scope here: a nextColumn break starts the next section on a new page instead of the following column.

Test plan

  • 4 new unit tests for balanceSectionOnPage (straddle split with line partition, keepLines, single tall paragraph, fromLine offset for pagination-split fragments), written failing first
  • 2 new layoutDocument integration tests (straddle split end-to-end geometry; nextPage end break does not balance)
  • Full layout-engine suite: 0 failures
  • Browser verification of the repro and all 11 mutations on the dev server

tupizz and others added 6 commits June 3, 2026 09:14
balanceSectionOnPage skipped every section with equalWidth=false plus explicit widths, so continuous newspaper sections declared as <w:cols w:num=N w:equalWidth=0> with equal <w:col w:w> children (the common case) never balanced and rendered single-column. Narrow the skip to GENUINELY-unequal widths: explicit widths that are all equal now balance like implicit equal columns. Genuinely-unequal widths still fill column-by-column (Word parity, unchanged). (SD-2324)
Per ECMA-376 §17.6.4, when columns are not equal width (w:equalWidth=0) the section-level w:cols/@w:space is ignored and the inter-column gap comes from each <w:col w:space>. extractColumns used the section space, over-spacing explicit columns so their widths scaled down to fit and diverged from Word (e.g. the 2002 ISDA sections). Use the per-column w:space for unequal columns; equal-width columns keep the section space. Advances SD-2629 for the uniform-spacing case. (SD-2324)
…2324)

Equal-width sections (w:equalWidth="1" or omitted) now match Word: extraction
drops child <w:col> widths and takes the gap from the section w:space
(default 720), and normalizeColumnLayout honours per-column widths only when
w:equalWidth="0".

For explicit columns (w:equalWidth="0"), cap the count to min(w:num, valid
child-width count) at the source, so a w:num larger than the provided <w:col>
widths no longer creates surplus 1px phantom columns in the fill loop (which
reads the raw count). A matching clamp in normalizeColumnLayout stays as a
defensive net.
…ent w:cols (SD-2324)

Adds three extraction unit tests for the landed column fix: count caps to the
valid child-width count (four <w:col> but two usable w:w -> 2), equal mode takes
the count from w:num (count 3, no children), and a section without <w:cols>
yields no columnsPx.
A two-column section ending at a continuous section break rendered with
lumpy columns (SD-3359, IT-1150): on the repro NDA the left column ran
169px deeper than the right, and the following single-column content
started below the unbalanced overhang.

Three defects, all in the balancing path:

- balanceSectionOnPage fed the balancer atomic fragments (canBreak:
  false, no lineHeights), so a paragraph straddling the column boundary
  could not split. The balancer already computes line-level break points
  (blockBreakPoints) with widow/orphan control, but no caller consumed
  them. Paragraph fragments now opt in with their per-line heights, and
  a chosen break is applied as fragment surgery: the first half keeps
  the leading lines, a cloned second half carries the remaining lines to
  the top of the next column, the same fromLine/toLine and continuation
  model pagination uses. Paragraphs with w:keepLines (ECMA-376
  17.3.1.14) and sectPr marker paragraphs stay atomic.

- The binary search floored its target at the tallest block's full
  height. For a breakable paragraph the indivisible chunk is its tallest
  line, so the search could never reach the truly balanced height and
  packed the overflow lines into column 0.

- The post-layout gate keyed "mid-doc continuous" off the section's own
  begin type (its sectPr w:type, 17.6.22) instead of the type of the
  break that ends it, which is the NEXT section's begin type. A 2-col
  section that merely started continuous balanced even when ended by a
  nextPage break; per the 17.18.77 note only a continuous break
  balances the previous section.

Verified against the IT-1150 repro (169px -> 14px imbalance, trailing
paragraph tucked directly below the balanced region) and eleven
spec-derived document mutations (3 columns, explicit equal and unequal
w:col, keepLines, explicit column break, nextPage/evenPage/nextColumn
end breaks, w:sep, implicit and explicit body sectPr): all conform to
ECMA-376 and the documented Word behaviors (sd-1655, sd-1480,
mixed-columns-tabs-tnr). Adds 4 unit tests and 2 layoutDocument
integration tests.
Base automatically changed from tadeu/sd-2324-feature-render-multi-columns-with-explicit-column to main June 5, 2026 12:31
@linear-code
Copy link
Copy Markdown

linear-code Bot commented Jun 5, 2026

SD-3359

@tupizz tupizz self-assigned this Jun 5, 2026
@tupizz tupizz marked this pull request as ready for review June 5, 2026 12:36
@tupizz tupizz requested a review from a team as a code owner June 5, 2026 12:36
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 30742a8d5b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +869 to +872
const secondHalf = {
...f,
fromLine: splitLine,
toLine: originalToLine,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Slice remeasured paragraph lines when splitting

When this splits a paragraph fragment that was remeasured for a narrower column or floats, ...f copies the fragment’s full lines override onto the second half, and the first half keeps the same override after only toLine is changed. renderParagraphFragment prefers fragment.lines over measure.lines.slice(fromLine, toLine), so in real multi-column layouts that set fragment.lines both halves can render the entire pre-split paragraph instead of their respective line ranges, duplicating text across columns. The split needs to clear or partition lines alongside fromLine/toLine.

Useful? React with 👍 / 👎.

@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

tupizz added 2 commits June 5, 2026 10:06
…lumns

Review follow-up. A paragraph fragment remeasured for a narrower column
(or beside a float) carries its own `lines` array, and resolveParagraph
renders that array INSTEAD of slicing measure.lines by fromLine/toLine.
The column split cloned the fragment wholesale, so both halves kept the
full remeasured array and each column rendered the entire paragraph.

Three coordinated corrections:

- The split slices `lines` across the halves (first keeps the leading
  slice, the clone carries the rest), so each column renders only its
  own lines.
- Break points are computed against the fragment's own remeasured line
  heights when present; measure.lines describes the original width and
  can disagree in both count and height.
- getFragmentHeight sums fragment.lines when present, matching how
  resolveLayout sizes such fragments, so balancing cursors agree with
  what the resolve stage actually renders.

Adds a regression test with a remeasured fragment (22px lines vs a
stale 20px measure) asserting the halves partition the lines and the
column cursors advance by the remeasured heights.
@luccas-harbour luccas-harbour self-requested a review June 5, 2026 13:39
Copy link
Copy Markdown
Contributor

@luccas-harbour luccas-harbour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@luccas-harbour luccas-harbour enabled auto-merge (squash) June 5, 2026 13:55
@luccas-harbour luccas-harbour disabled auto-merge June 5, 2026 14:04
@luccas-harbour luccas-harbour enabled auto-merge (squash) June 5, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants