Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5b2335a
feat(layout-engine): balance columns at continuous section breaks (SD…
tupizz Apr 20, 2026
e426596
fix(layout-engine): balance before forced page break on col-count red…
tupizz Apr 20, 2026
d306f03
Merge branch 'main' into tadeu/sd-2452-feature-implement-column-balan…
tupizz Apr 23, 2026
71fb404
fix: balance earlier pages of multi-page 2-col continuous sections (S…
tupizz Apr 30, 2026
c610111
Merge branch 'main' into tadeu/sd-2452-feature-implement-column-balan…
harbournick Apr 30, 2026
cf89728
Merge branch 'main' into tadeu/sd-2452-feature-implement-column-balan…
harbournick Apr 30, 2026
52ae84a
chore: update lock
harbournick Apr 30, 2026
d1f5525
fix(layout-engine): address review feedback for column balancing (SD-…
tupizz May 4, 2026
634fb2e
fix(layout-engine): gate column balancing on continuous break + not-l…
tupizz May 4, 2026
7153292
Merge remote-tracking branch 'origin/main' into tadeu/sd-2452-feature…
tupizz May 4, 2026
a11cdf2
fix(layout-engine): refine balance gate — last section balances if mu…
tupizz May 4, 2026
50dc1f7
fix(layout-engine): address luccas review comments (SD-2452)
tupizz May 4, 2026
a5ff6ed
fix(painter): suppress column separator over empty column (SD-2452)
tupizz May 4, 2026
6db27ad
fix(layout-engine): balance multi-col sections when doc has explicit …
tupizz May 4, 2026
692811f
fix(pm-adapter): surface typeIsExplicit only when authored (SD-2452)
tupizz May 5, 2026
4dc4c62
fix(layout-engine): exclude body-explicit-continuous from doc-wide ru…
tupizz May 5, 2026
e2d1055
fix(layout-engine): skip mid-doc multi-page balance (SD-2452)
tupizz May 5, 2026
13dcf3e
Merge remote-tracking branch 'origin/main' into tadeu/sd-2452-feature…
tupizz May 5, 2026
315ab84
fix(measuring): scope tab alignment heuristic per line segment (SD-1480)
tupizz May 5, 2026
4b63d25
fix(measuring): preserve lone trailing tab as the meaningful tab (SD-…
tupizz May 5, 2026
e9eaa04
fix(measuring): revert trailing-empty tab strip — false-positive regr…
tupizz May 5, 2026
bc91d9a
fix(measuring): gate SD-2447 alignment heuristic on default stops only
tupizz May 5, 2026
c50a408
fix(layout-engine): scope explicit-continuous rule to ending section …
tupizz May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 58 additions & 11 deletions packages/layout-engine/layout-bridge/src/remeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,14 +789,49 @@ const applyTabLayoutToLines = (
const alignmentTabStopsPx = tabStops
.map((stop, index) => ({ stop, index }))
.filter(({ stop }) => stop.val === 'end' || stop.val === 'center' || stop.val === 'decimal');

// Per-line-segment tab counts. Segments are delimited by explicit <w:br/> because
// pPr/tabs apply per line, not per paragraph. sd-1480: "Page\t2<br/>Page\t5" must
// bind the trailing tab of EACH segment to the alignment stop, not just the
// paragraph-final tab.
const tabSegmentInfo = new Map<number, { localOrdinal: number; segmentTotal: number }>();
{
let segmentTabRunIndices: number[] = [];
const closeSegment = () => {
const total = segmentTabRunIndices.length;
segmentTabRunIndices.forEach((runIdx, ord) => {
tabSegmentInfo.set(runIdx, { localOrdinal: ord, segmentTotal: total });
});
segmentTabRunIndices = [];
};
for (let i = 0; i < runs.length; i++) {
const r = runs[i];
if (r.kind === 'lineBreak' || (r.kind === 'break' && (r as { breakType?: string }).breakType === 'line')) {
closeSegment();
} else if (r.kind === 'tab') {
segmentTabRunIndices.push(i);
}
}
closeSegment();
}

// Word-compat heuristic (not ECMA-376 17.3.3.32): the last N tab characters in a
// paragraph bind to the last N explicit end/center/decimal stops. Needed for TOC
// line bind to the last N explicit end/center/decimal stops. Needed for TOC
// entries where a right-aligned dot-leader stop coexists with default grid stops.
// Mirrored in measuring/dom/src/index.ts.
const getAlignmentStopForOrdinal = (ordinal: number): { stop: TabStopPx; index: number } | null => {
const getAlignmentStopForOrdinal = (ordinal: number, runIdx?: number): { stop: TabStopPx; index: number } | null => {
if (alignmentTabStopsPx.length === 0 || totalTabRuns === 0 || !Number.isFinite(ordinal)) return null;
if (ordinal < 0 || ordinal >= totalTabRuns) return null;
const remainingTabs = totalTabRuns - ordinal - 1;
let scopeOrdinal = ordinal;
let scopeTotal = totalTabRuns;
if (runIdx !== undefined) {
const info = tabSegmentInfo.get(runIdx);
if (info) {
scopeOrdinal = info.localOrdinal;
scopeTotal = info.segmentTotal;
}
}
if (scopeOrdinal < 0 || scopeOrdinal >= scopeTotal) return null;
const remainingTabs = scopeTotal - scopeOrdinal - 1;
const targetIndex = alignmentTabStopsPx.length - 1 - remainingTabs;
if (targetIndex < 0 || targetIndex >= alignmentTabStopsPx.length) return null;
return alignmentTabStopsPx[targetIndex];
Expand Down Expand Up @@ -828,22 +863,34 @@ const applyTabLayoutToLines = (
/**
* Processes a tab character, calculating position and handling alignment.
*/
const applyTab = (startRunIndex: number, startChar: number, run?: Run, tabOrdinal?: number): void => {
const applyTab = (
startRunIndex: number,
startChar: number,
run?: Run,
tabOrdinal?: number,
tabRunIdx?: number,
): void => {
const originX = cursorX;
const absCurrentX = cursorX + effectiveIndent;
let stop: TabStopPx | undefined;
let target: number;
// Mirror of measuring/dom: only force the SD-2447 heuristic when greedy
// would land on a `source:default` stop (synthetic 0.5" grid). Explicit
// start stops should win greedy.
const greedy = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
const greedyOnDefault = greedy.stop?.source === 'default';
const forcedAlignment =
typeof tabOrdinal === 'number' && Number.isFinite(tabOrdinal) ? getAlignmentStopForOrdinal(tabOrdinal) : null;
greedyOnDefault && typeof tabOrdinal === 'number' && Number.isFinite(tabOrdinal)
? getAlignmentStopForOrdinal(tabOrdinal, tabRunIdx)
: null;
if (forcedAlignment && forcedAlignment.stop.pos > absCurrentX + TAB_EPSILON) {
stop = forcedAlignment.stop;
target = forcedAlignment.stop.pos;
tabStopCursor = forcedAlignment.index + 1;
} else {
const next = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
stop = next.stop;
target = next.target;
tabStopCursor = next.nextIndex;
stop = greedy.stop;
target = greedy.target;
tabStopCursor = greedy.nextIndex;
}
const clampedTarget = Number.isFinite(maxAbsWidth) ? Math.min(target, maxAbsWidth) : target;
const relativeTarget = clampedTarget - effectiveIndent;
Expand Down Expand Up @@ -901,7 +948,7 @@ const applyTabLayoutToLines = (
if (run.kind === 'tab') {
const tabRun = run as TabRun;
const ordinal = consumeTabOrdinal(tabRun.tabIndex);
applyTab(runIndex + 1, 0, run, ordinal);
applyTab(runIndex + 1, 0, run, ordinal, runIndex);
continue;
}

Expand Down
Loading
Loading