Skip to content

fix: correct tab leader from/to bounds when effectiveIndent is applied#2141

Open
roncallyt wants to merge 5 commits intosuperdoc-dev:mainfrom
roncallyt:fix/toc-leaders-overlap
Open

fix: correct tab leader from/to bounds when effectiveIndent is applied#2141
roncallyt wants to merge 5 commits intosuperdoc-dev:mainfrom
roncallyt:fix/toc-leaders-overlap

Conversation

@roncallyt
Copy link
Contributor

Summary

Fixes tab leader rendering in Word-generated Tables of Contents. Leaders were overlapping with section titles on the left and page numbers on the right due to effectiveIndent not being considered when calculating leader boundaries.

Closes #2075

Changes

  • layout-engine/measuring: Added effectiveIndent to from calculation so leaders start after indented text
  • layout-engine/measuring: Updated to after look-ahead to account for effectiveIndent, ensuring leaders end where right-aligned content begins

Showcase

ToC with lined tabs (3).docx

Before
image
After
image

ToC with dotted tabs (1).docx

Before
image
After
image

Observations

  • When I ran "pnpm run lint", got a lot of warnings that were unrelated to my changes, as it's unrelated I decided to not fix for now to keep this PR focused, but if needed I can fix them.
  • The same occurred to "pnpm run format:check", one warning shows up from another package unrelated to my changes, so I kept the same decision.

- Add effectiveIndent to leader `from` calculation so leaders start
  after indented text
- Update leader `to` after look-ahead to account for effectiveIndent,
  ensuring leaders end where right-aligned content begins
- Fix applies to `end`, `center`, and `decimal` tab stop alignments
- Add unit tests for leader positioning with and without paragraph
  indent

Closes superdoc-dev#2075
@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

Copy link
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

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

Hey @roncallyt, nice work!

The root cause is correct - leader bounds weren't accounting for effectiveIndent, so they overlapped with text and page numbers. The fix is clean and the before/after screenshots look great.

Couple of things to address before merging (details in inline comments)

Also, it would be great to add a visual regression test in tests/visual/ using one of the attached TOC documents to prevent future regressions on this.

Leader `from` and `to` were mixing relative and absolute coordinate
spaces, causing start-aligned tabs with leaders to be off by
`effectiveIndent` px.

Both values now use absolute coordinates consistently.

For end/center/decimal alignments, `to` is still overwritten by the
look-ahead correction.
`leader.to` passed even without the absolute-coordinate fix. I measured
the "42" text width independently so the check actually covers the `to`
correction.

Collapse the "without indent" and "with indent" variants into a single
`it.each`.
@roncallyt
Copy link
Contributor Author

@caio-pizzol I tried creating visual regression tests, but seems like I don't have permissions to do so. Perharps it's something to do with my Cloudflare account.

Anyway, is there another way to create them?

@caio-pizzol
Copy link
Contributor

@caio-pizzol I tried creating visual regression tests, but seems like I don't have permissions to do so. Perharps it's something to do with my Cloudflare account.

Anyway, is there another way to create them?

Right! Let us add that on your side for now

@caio-pizzol caio-pizzol self-requested a review February 23, 2026 21:53
Copy link
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

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

all four items initial are addressed -- absolute coordinates, test precision, deduplication. looks good. left two non-blocking inline comments.

we'll add the visual regression test on our side.

Comment on lines 1358 to 2319
@@ -1383,6 +1382,13 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
groupStartX = Math.max(0, relativeTarget - beforeDecimal);
}

// Update leader "to" ensuring leaders end where right-aligned content begins
if (currentLine.leaders && currentLine.leaders.length > 0) {
const lastLeader = currentLine.leaders.at(-1);

if (lastLeader) lastLeader.to = groupStartX + effectiveIndent;
}

// Set up active tab group for subsequent run processing
activeTabGroup = {
measure: groupMeasure,
@@ -2307,9 +2313,8 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
// Emit leader decoration if requested
if (stop && stop.leader && stop.leader !== 'none' && stop.leader !== 'middleDot') {
const leaderStyle: 'heavy' | 'dot' | 'hyphen' | 'underscore' = stop.leader;
const relativeTarget = clampedTarget - effectiveIndent;
const from = Math.min(originX, relativeTarget);
const to = Math.max(originX, relativeTarget);
const from = Math.min(originX + effectiveIndent, clampedTarget);
const to = Math.max(originX + effectiveIndent, clampedTarget);
if (!currentLine.leaders) currentLine.leaders = [];
currentLine.leaders.push({ from, to, style: leaderStyle });
Copy link
Contributor

Choose a reason for hiding this comment

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

the inline-tab path here doesn't correct to after content is positioned (the explicit tab-run path does at L1389).

means inline \t + right-aligned tab + leader could still overlap.

not urgent since TOCs use explicit tab runs -- just something to know about.


// Update leader "to" ensuring leaders end where right-aligned content begins
if (currentLine.leaders && currentLine.leaders.length > 0) {
const lastLeader = currentLine.leaders.at(-1);
Copy link
Contributor

Choose a reason for hiding this comment

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

.at(-1) grabs the last leader on the line, which might not be the one this tab just pushed. if a previous tab has a leader but this tab doesn't, this overwrites the wrong one.

easy fix: guard this block with stop.leader too, or save a reference when you push instead of fishing it back with .at(-1).

Copy link
Contributor Author

@roncallyt roncallyt Feb 23, 2026

Choose a reason for hiding this comment

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

Fixed, I preferred to save a reference when pushing to the leaders array, it worked properly.


// Update leader "to" ensuring leaders end where right-aligned content begins
if (currentLine.leaders && currentLine.leaders.length > 0) {
const lastLeader = currentLine.leaders.at(-1);
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: .length > 0 is redundant here -- .at(-1) returns undefined on empty arrays and the if (lastLeader) below handles it. can simplify to

Suggested change
const lastLeader = currentLine.leaders.at(-1);
const lastLeader = currentLine.leaders?.at(-1);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As I decided to save a reference to the current leader, this adjustment became unnecessary.

const measure = expectParagraphMeasure(await measureBlock(block, 1000));
expect(measure.lines).toHaveLength(1);

const leaders = measure.lines[0].leaders;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: expect(leaders).toBeDefined() + expect(leaders!.length).toBe(1) can be just expect(leaders).toHaveLength(1) -- covers both checks and avoids the ! assertion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

- Replace `toBeDefined()` and `toBe(1)` for `toHaveLength(1)`
Now saves a direct reference when pushing to the leaders array and uses
it in the look-ahead correction.
Copy link
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

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

@roncallyt thanks again for the fixes

left an inline comment + one note below. nothing blocking.


packages/layout-engine/layout-bridge/src/remeasure.ts:804 (not in diff, noting here)

applyTabLayoutToLines still computes relativeTarget = clampedTarget - effectiveIndent for leader from/to, so remeasured lines get relative coordinates while initial measurement now produces absolute ones. the renderer uses ld.from directly as CSS left without paddingLeft, so remeasured leaders land effectiveIndent pixels too far left. this was pre-existing, but the PR widens the gap between the two paths — worth fixing together or tracking in a follow-up issue?

const from = Math.min(originX + effectiveIndent, clampedTarget);
const to = Math.max(originX + effectiveIndent, clampedTarget);
if (!currentLine.leaders) currentLine.leaders = [];
currentLine.leaders.push({ from, to, style: leaderStyle });
Copy link
Contributor

Choose a reason for hiding this comment

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

the inline tab path sets pendingTabAlignment but never updates the leader's to after aligning end/center/decimal stops — the same correction that TabRun applies at line ~1385. for a typical TOC this won't show up (they use TabRun objects), but if someone hits an end-aligned stop with a leader via an inline \t, the dots would stretch past where the text starts. worth a follow-up?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that makes sense, I'll look into it

@roncallyt
Copy link
Contributor Author

Good catch! I'd prefer to fix this in the same PR to keep both paths consistent. I'll let you know as soon as I'm finished.

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.

Bug: TOC leaders overlap with numbers and titles

2 participants