From 339ad1d84c6a3b57482c7a262695aff62731c774 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 2 Jun 2026 20:16:08 -0300 Subject: [PATCH 1/5] fix(layout-engine): align underlined tab to text baseline (SD-3330) Underlined tab characters render their underline as a border-bottom on the tab box. The box was the full line height and bottom-aligned, so the border landed ~descent+half-leading below the text-decoration underline of adjacent text, making a continuous underline look broken where text meets tabs. Anchor the underlined tab box to the line-box top and end it at the baseline offset derived from the resolved line metrics (ascent/descent/lineHeight), so the border-bottom sits flush with the text underline. Gated to underlined tabs only; non-underlined tabs keep their previous geometry to avoid any tab-stop or line-layout regression. Covers SD-3347 signature/fill-in line rendering. No DOM measurement is added (SD-2957). Adds a regression test for both the inline and positioned paths. --- .../painters/dom/src/runs/tab-run.test.ts | 64 +++++++++++++++++++ .../painters/dom/src/runs/tab-run.ts | 37 ++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/runs/tab-run.test.ts diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts new file mode 100644 index 0000000000..f87dbf8716 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import type { Line, TabRun } from '@superdoc/contracts'; +import { renderInlineTabRun, renderPositionedTabRun } from './tab-run.js'; + +// A line with leading: lineHeight (24) exceeds ascent (12) + descent (4) by 8px. +// Adjacent text draws its `text-decoration` underline near the baseline, which +// sits at ascent + half-leading = 12 + 4 = 16px from the line-box top — well +// above the line-box bottom at 24px. SD-3330: a tab underline drawn at the +// line-box bottom lands ~8px below the text underline and the combined line +// looks broken. The tab underline must land in the baseline region instead. +const LINE: Line = { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 200, + ascent: 12, + descent: 4, + lineHeight: 24, +}; + +const underlinedTab = (): TabRun => ({ + kind: 'tab', + text: '\t', + width: 48, + underline: { style: 'single', color: '#000000' }, +}); + +const plainTab = (): TabRun => ({ kind: 'tab', text: '\t', width: 48 }); + +describe('tab underline alignment (SD-3330)', () => { + it('anchors the inline tab underline to the baseline region, not the line-box bottom', () => { + const el = renderInlineTabRun(underlinedTab(), LINE, document, 0); + + expect(el.style.borderBottom).toContain('solid'); + // The box top is pinned to the line-box top and ends at the underline offset, + // so its border-bottom lands at the baseline rather than the line-box bottom. + expect(el.style.verticalAlign).toBe('top'); + const offset = parseFloat(el.style.height); + expect(offset).toBeGreaterThanOrEqual(LINE.ascent); + expect(offset).toBeLessThan(LINE.lineHeight); + }); + + it('anchors the positioned tab underline to the baseline region, not the line-box bottom', () => { + const { element } = renderPositionedTabRun(underlinedTab(), LINE, document, 0, 0, 0); + + expect(element.style.borderBottom).toContain('solid'); + expect(element.style.visibility).not.toBe('hidden'); + const offset = parseFloat(element.style.height); + expect(offset).toBeGreaterThanOrEqual(LINE.ascent); + expect(offset).toBeLessThan(LINE.lineHeight); + }); + + it('does not draw a border on a plain (non-underlined) inline tab', () => { + const el = renderInlineTabRun(plainTab(), LINE, document, 0); + expect(el.style.borderBottom).toBe(''); + }); + + it('keeps a plain positioned tab invisible with no border', () => { + const { element } = renderPositionedTabRun(plainTab(), LINE, document, 0, 0, 0); + expect(element.style.visibility).toBe('hidden'); + expect(element.style.borderBottom).toBe(''); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.ts index bb404bc0ff..6b0cd85fcd 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.ts @@ -15,8 +15,18 @@ export const renderInlineTabRun = ( tabEl.style.display = 'inline-block'; tabEl.style.width = `${tabWidth}px`; - tabEl.style.height = `${line.lineHeight}px`; - tabEl.style.verticalAlign = 'bottom'; + if (run.underline) { + // Underlined tabs render the underline as a border-bottom. A full-height, + // bottom-aligned box puts that border ~descent+half-leading below the + // text-decoration underline of adjacent text, making the combined line look + // broken (SD-3330). End the box at the computed underline offset and pin its + // top to the line-box top so the border lands flush with the text underline. + tabEl.style.height = `${underlineOffsetFromLineTop(line)}px`; + tabEl.style.verticalAlign = 'top'; + } else { + tabEl.style.height = `${line.lineHeight}px`; + tabEl.style.verticalAlign = 'bottom'; + } applyTabUnderline(tabEl, run); @@ -53,7 +63,10 @@ export const renderPositionedTabRun = ( tabEl.style.left = `${tabStartX + indentOffset}px`; tabEl.style.top = '0px'; tabEl.style.width = `${actualTabWidth}px`; - tabEl.style.height = `${line.lineHeight}px`; + // Underlined positioned tabs end the box at the text underline offset (not the full + // line height) so the border-bottom aligns with adjacent text underlines (SD-3330). + // Non-underlined positioned tabs keep the full line height (they are hidden below). + tabEl.style.height = run.underline ? `${underlineOffsetFromLineTop(line)}px` : `${line.lineHeight}px`; tabEl.style.display = 'inline-block'; tabEl.style.pointerEvents = 'none'; tabEl.style.zIndex = '1'; @@ -73,6 +86,24 @@ export const renderPositionedTabRun = ( return { element: tabEl, tabEndX, actualTabWidth }; }; +/** + * Distance, in pixels from the top of the line box, at which a tab's underline + * (border-bottom) should be drawn so it lines up with the `text-decoration` + * underline of adjacent text runs. + * + * The line box places the baseline at `half-leading + ascent` from its top + * (the remaining `half-leading + descent` sits below). `text-decoration` + * underlines render slightly below the baseline, so we add a small gap that + * scales with font size (capped by the descent). This is geometry derived from + * the resolved line metrics — the painter never measures the DOM (SD-2957). + */ +const underlineOffsetFromLineTop = (line: Line): number => { + const halfLeading = Math.max(0, (line.lineHeight - line.ascent - line.descent) / 2); + const baselineFromTop = halfLeading + line.ascent; + const underlineGap = Math.min(line.descent, line.lineHeight * 0.08); + return baselineFromTop + underlineGap; +}; + const applyTabUnderline = (tabEl: HTMLElement, run: Extract): void => { // Apply underline styling if present (common for signature lines) // From af6127e50948201e748292ed94621ea361aa9742 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 2 Jun 2026 20:54:47 -0300 Subject: [PATCH 2/5] fix(layout-engine): unify tab underline rendering with text-decoration Refactor the rendering of underlined tabs to use the same text-decoration mechanism as adjacent text, ensuring consistent baseline alignment and weight. This change addresses issues where the underline appeared misaligned due to the previous border-bottom approach. The tests have been updated to reflect these changes, ensuring that both underlined and plain tabs render correctly without unexpected borders. This aligns with the goal of maintaining visual fidelity across text and tab elements (SD-3330). --- .../painters/dom/src/runs/tab-run.test.ts | 41 +++++---- .../painters/dom/src/runs/tab-run.ts | 86 ++++++++++++------- .../painters/dom/src/runs/text-run.ts | 19 ++++ 3 files changed, 102 insertions(+), 44 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts index f87dbf8716..7ca8167962 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts @@ -19,26 +19,36 @@ const LINE: Line = { lineHeight: 24, }; -const underlinedTab = (): TabRun => ({ - kind: 'tab', - text: '\t', - width: 48, - underline: { style: 'single', color: '#000000' }, -}); +const underlinedTab = (fontSize?: number): TabRun => + ({ + kind: 'tab', + text: '\t', + width: 48, + fontSize, + underline: { style: 'single', color: '#000000' }, + }) as TabRun; const plainTab = (): TabRun => ({ kind: 'tab', text: '\t', width: 48 }); describe('tab underline alignment (SD-3330)', () => { - it('anchors the inline tab underline to the baseline region, not the line-box bottom', () => { + it('draws the inline tab underline with baseline-aligned text-decoration (matches text)', () => { const el = renderInlineTabRun(underlinedTab(), LINE, document, 0); - expect(el.style.borderBottom).toContain('solid'); - // The box top is pinned to the line-box top and ends at the underline offset, - // so its border-bottom lands at the baseline rather than the line-box bottom. - expect(el.style.verticalAlign).toBe('top'); - const offset = parseFloat(el.style.height); - expect(offset).toBeGreaterThanOrEqual(LINE.ascent); - expect(offset).toBeLessThan(LINE.lineHeight); + // Same mechanism as adjacent text: text-decoration on a baseline-aligned box, so the + // browser places the underline on the same baseline and at the same weight (not a + // separate border guessing the position). + expect(el.style.textDecorationLine).toBe('underline'); + expect(el.style.borderBottom).toBe(''); + expect(el.style.verticalAlign).toBe('baseline'); + // Filler whitespace overfills the tab so the (horizontally clipped) underline spans it. + expect(el.textContent.length).toBeGreaterThan(0); + expect(el.textContent.trim()).toBe(''); + }); + + it('matches the tab underline weight to the text underline (shared font-scaled thickness)', () => { + const el = renderInlineTabRun(underlinedTab(48), LINE, document, 0); + // 48 / 14 rounds to 3px — the same value applyRunStyles sets on text-decoration-thickness. + expect(el.style.textDecorationThickness).toBe('3px'); }); it('anchors the positioned tab underline to the baseline region, not the line-box bottom', () => { @@ -51,8 +61,9 @@ describe('tab underline alignment (SD-3330)', () => { expect(offset).toBeLessThan(LINE.lineHeight); }); - it('does not draw a border on a plain (non-underlined) inline tab', () => { + it('does not underline a plain (non-underlined) inline tab', () => { const el = renderInlineTabRun(plainTab(), LINE, document, 0); + expect(el.style.textDecorationLine).toBe(''); expect(el.style.borderBottom).toBe(''); }); diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.ts index 6b0cd85fcd..b2ca428451 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.ts @@ -1,4 +1,5 @@ import type { Line, LineSegment, Run } from '@superdoc/contracts'; +import { underlineThicknessPx } from './text-run.js'; export const renderInlineTabRun = ( run: Extract, @@ -16,20 +17,20 @@ export const renderInlineTabRun = ( tabEl.style.display = 'inline-block'; tabEl.style.width = `${tabWidth}px`; if (run.underline) { - // Underlined tabs render the underline as a border-bottom. A full-height, - // bottom-aligned box puts that border ~descent+half-leading below the - // text-decoration underline of adjacent text, making the combined line look - // broken (SD-3330). End the box at the computed underline offset and pin its - // top to the line-box top so the border lands flush with the text underline. - tabEl.style.height = `${underlineOffsetFromLineTop(line)}px`; - tabEl.style.verticalAlign = 'top'; + // Draw the underline with text-decoration on a baseline-aligned box so the browser + // places it on the SAME baseline as adjacent text — identical vertical position AND + // weight, with no stepped/broken line where text meets tabs (SD-3330). A tab has no + // glyphs, so it is filled with clipped, transparent whitespace (see + // applyTabUnderlineDecoration). Matching the line height keeps the box from changing + // line spacing. + tabEl.style.lineHeight = `${line.lineHeight}px`; + tabEl.style.verticalAlign = 'baseline'; + applyTabUnderlineDecoration(tabEl, run, tabWidth); } else { tabEl.style.height = `${line.lineHeight}px`; tabEl.style.verticalAlign = 'bottom'; } - applyTabUnderline(tabEl, run); - if (styleId) { tabEl.setAttribute('styleid', styleId); } @@ -66,12 +67,14 @@ export const renderPositionedTabRun = ( // Underlined positioned tabs end the box at the text underline offset (not the full // line height) so the border-bottom aligns with adjacent text underlines (SD-3330). // Non-underlined positioned tabs keep the full line height (they are hidden below). + // Positioned tabs are absolutely placed, so the baseline-aligned text-decoration path + // used for inline tabs does not apply here; a border at the computed offset is used. tabEl.style.height = run.underline ? `${underlineOffsetFromLineTop(line)}px` : `${line.lineHeight}px`; tabEl.style.display = 'inline-block'; tabEl.style.pointerEvents = 'none'; tabEl.style.zIndex = '1'; - applyTabUnderline(tabEl, run); + applyTabUnderlineBorder(tabEl, run); if (!run.underline) { tabEl.style.visibility = 'hidden'; } @@ -104,23 +107,48 @@ const underlineOffsetFromLineTop = (line: Line): number => { return baselineFromTop + underlineGap; }; -const applyTabUnderline = (tabEl: HTMLElement, run: Extract): void => { - // Apply underline styling if present (common for signature lines) - // - // Signature line use case: In documents with signature lines, tabs are often used - // to create underlined blank spaces where signatures should go. The underline mark - // is inherited from a parent node (e.g., a paragraph with underline formatting) and - // applied to the tab, creating a visible underline even though the tab itself has - // no visible text content. - if (run.underline) { - const underlineStyle = run.underline.style ?? 'single'; - // We must use an explicit color instead of currentColor because tab content is - // invisible (no text). If we used currentColor, the underline would inherit the - // text color, which might be transparent or the same as the background, making - // the underline invisible. Using an explicit color (defaulting to black) ensures - // the underline is always visible for signature lines. - const underlineColor = run.underline.color ?? '#000000'; - const borderStyle = underlineStyle === 'double' ? 'double' : 'solid'; - tabEl.style.borderBottom = `1px ${borderStyle} ${underlineColor}`; - } +/** + * Inline underlined tabs (signature / fill-in lines): draw the underline with the same + * `text-decoration` mechanism as adjacent text. The tab box is baseline-aligned and the + * box is filled with transparent, horizontally-clipped whitespace, so the browser places + * the underline on the exact same baseline and at the same weight as the surrounding + * text — one continuous, even line (SD-3330). + */ +const applyTabUnderlineDecoration = (tabEl: HTMLElement, run: Extract, widthPx: number): void => { + if (!run.underline) return; + + const underlineStyle = run.underline.style ?? 'single'; + // Explicit color, not currentColor: the filler glyphs are transparent, and currentColor + // could resolve to transparent and hide the underline. + const underlineColor = run.underline.color ?? '#000000'; + const fontSize = (run as { fontSize?: number }).fontSize ?? 16; + + tabEl.style.fontSize = `${fontSize}px`; + tabEl.style.whiteSpace = 'pre'; + tabEl.style.color = 'transparent'; + // Clip the filler horizontally to the tab width; the negative top/bottom insets leave + // the underline vertically unclipped regardless of the font's metrics. + tabEl.style.clipPath = 'inset(-50% 0 -50% 0)'; + tabEl.style.textDecorationLine = 'underline'; + tabEl.style.textDecorationStyle = underlineStyle === 'double' ? 'double' : 'solid'; + tabEl.style.textDecorationColor = underlineColor; + tabEl.style.textDecorationThickness = `${underlineThicknessPx(fontSize)}px`; + // Enough whitespace to overfill the tab width once clipped (a space is ≥ ~2px wide for + // any readable font size). + tabEl.textContent = ' '.repeat(Math.max(8, Math.ceil(widthPx / 2) + 2)); +}; + +/** + * Positioned (right/center/decimal-aligned) underlined tabs are absolutely placed, so the + * baseline-aligned text-decoration path used for inline tabs does not apply. They draw the + * underline as a border at the computed offset, with the same font-scaled weight. + */ +const applyTabUnderlineBorder = (tabEl: HTMLElement, run: Extract): void => { + if (!run.underline) return; + + const underlineStyle = run.underline.style ?? 'single'; + const underlineColor = run.underline.color ?? '#000000'; + const borderStyle = underlineStyle === 'double' ? 'double' : 'solid'; + const fontSize = (run as { fontSize?: number }).fontSize ?? 16; + tabEl.style.borderBottom = `${underlineThicknessPx(fontSize)}px ${borderStyle} ${underlineColor}`; }; diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.ts b/packages/layout-engine/painters/dom/src/runs/text-run.ts index d9927998c1..1fab4dbe25 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -15,6 +15,20 @@ import { const DEFAULT_SUPERSCRIPT_RAISE_RATIO = 0.33; const DEFAULT_SUBSCRIPT_LOWER_RATIO = 0.14; +/** + * Underline thickness in px, scaled to font size. Shared by text runs + * (`text-decoration-thickness`) and tab underlines (border width) so a run's + * underline renders as a single uniform weight across text and tab characters, + * matching Word, on any display density (SD-3330). The divisor approximates the + * font's natural underline weight (≈ what `text-decoration-thickness: auto` + * produces) while staying deterministic across platforms. + * + * Rounded to an integer px because CSS borders snap to integer device pixels + * while `text-decoration-thickness` keeps fractional values; using an integer + * makes the tab border and the text underline rasterize to the same line weight. + */ +export const underlineThicknessPx = (fontSize: number): number => Math.max(1, Math.round(fontSize / 14)); + const hasVerticalPositioning = (run: TextRun): boolean => normalizeBaselineShift(run.baselineShift) != null || run.vertAlign === 'superscript' || run.vertAlign === 'subscript'; @@ -97,6 +111,11 @@ export const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false): decorations.push('underline'); const u = run.underline; element.style.textDecorationStyle = u.style && u.style !== 'single' ? u.style : 'solid'; + // Pin the thickness to an explicit, font-scaled value (instead of `auto`, which + // browsers render at the font's underline weight). Tab underlines reuse the same + // value for their border width, so a run's underline is one uniform weight across + // text and tab characters (SD-3330). See underlineThicknessPx. + element.style.textDecorationThickness = `${underlineThicknessPx(run.fontSize)}px`; if (u.color) { element.style.textDecorationColor = u.color; } From 7dd368664f8c31377842008b52f5a6dd84555887 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 2 Jun 2026 21:11:23 -0300 Subject: [PATCH 3/5] fix(layout-engine): revert tab underline to border, keep matched weight (SD-3330) The text-decoration approach for inline tab underlines required filling the tab with transparent whitespace for the browser to underline. That filler is selectable content, so selecting a line produced a broken/clipped selection highlight across the tab region, and it complicated the editor underline flow. Revert inline tabs to a border-bottom at the computed baseline offset (no filler, no selectable content, no selection artifacts). Keep the matched weight: the border width and text-decoration-thickness both use the shared font-scaled underlineThicknessPx, so text and tab underlines render at the same integer-px weight. Trade-off: the border position is a formula approximation of the text underline baseline (within ~1px) rather than the browser's exact placement, but it has no interaction side effects. --- .../painters/dom/src/runs/tab-run.test.ts | 24 ++++---- .../painters/dom/src/runs/tab-run.ts | 59 +++++-------------- 2 files changed, 27 insertions(+), 56 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts index 7ca8167962..3c06a9c945 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts @@ -31,24 +31,23 @@ const underlinedTab = (fontSize?: number): TabRun => const plainTab = (): TabRun => ({ kind: 'tab', text: '\t', width: 48 }); describe('tab underline alignment (SD-3330)', () => { - it('draws the inline tab underline with baseline-aligned text-decoration (matches text)', () => { + it('anchors the inline tab underline to the baseline region, not the line-box bottom', () => { const el = renderInlineTabRun(underlinedTab(), LINE, document, 0); - // Same mechanism as adjacent text: text-decoration on a baseline-aligned box, so the - // browser places the underline on the same baseline and at the same weight (not a - // separate border guessing the position). - expect(el.style.textDecorationLine).toBe('underline'); - expect(el.style.borderBottom).toBe(''); - expect(el.style.verticalAlign).toBe('baseline'); - // Filler whitespace overfills the tab so the (horizontally clipped) underline spans it. - expect(el.textContent.length).toBeGreaterThan(0); - expect(el.textContent.trim()).toBe(''); + // Border-bottom (not a selectable text-decoration filler) at the box bottom; the box + // top is pinned to the line-box top and ends at the underline offset, so the border + // lands near the baseline rather than the line-box bottom. + expect(el.style.borderBottom).toContain('solid'); + expect(el.style.verticalAlign).toBe('top'); + const offset = parseFloat(el.style.height); + expect(offset).toBeGreaterThanOrEqual(LINE.ascent); + expect(offset).toBeLessThan(LINE.lineHeight); }); it('matches the tab underline weight to the text underline (shared font-scaled thickness)', () => { const el = renderInlineTabRun(underlinedTab(48), LINE, document, 0); // 48 / 14 rounds to 3px — the same value applyRunStyles sets on text-decoration-thickness. - expect(el.style.textDecorationThickness).toBe('3px'); + expect(parseFloat(el.style.borderBottomWidth)).toBe(3); }); it('anchors the positioned tab underline to the baseline region, not the line-box bottom', () => { @@ -61,9 +60,8 @@ describe('tab underline alignment (SD-3330)', () => { expect(offset).toBeLessThan(LINE.lineHeight); }); - it('does not underline a plain (non-underlined) inline tab', () => { + it('does not draw a border on a plain (non-underlined) inline tab', () => { const el = renderInlineTabRun(plainTab(), LINE, document, 0); - expect(el.style.textDecorationLine).toBe(''); expect(el.style.borderBottom).toBe(''); }); diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.ts index b2ca428451..bf36aec2ed 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.ts @@ -17,20 +17,21 @@ export const renderInlineTabRun = ( tabEl.style.display = 'inline-block'; tabEl.style.width = `${tabWidth}px`; if (run.underline) { - // Draw the underline with text-decoration on a baseline-aligned box so the browser - // places it on the SAME baseline as adjacent text — identical vertical position AND - // weight, with no stepped/broken line where text meets tabs (SD-3330). A tab has no - // glyphs, so it is filled with clipped, transparent whitespace (see - // applyTabUnderlineDecoration). Matching the line height keeps the box from changing - // line spacing. - tabEl.style.lineHeight = `${line.lineHeight}px`; - tabEl.style.verticalAlign = 'baseline'; - applyTabUnderlineDecoration(tabEl, run, tabWidth); + // Underlined tabs render the underline as a border-bottom (the tab has no glyphs to + // carry a text-decoration, and a transparent-filler text-decoration would become + // selectable content and break line selection). A full-height, bottom-aligned box + // would put the border ~descent+half-leading below the text-decoration underline of + // adjacent text and look broken (SD-3330), so the box ends at the computed underline + // offset with its top pinned to the line-box top, landing the border at the baseline. + tabEl.style.height = `${underlineOffsetFromLineTop(line)}px`; + tabEl.style.verticalAlign = 'top'; } else { tabEl.style.height = `${line.lineHeight}px`; tabEl.style.verticalAlign = 'bottom'; } + applyTabUnderlineBorder(tabEl, run); + if (styleId) { tabEl.setAttribute('styleid', styleId); } @@ -108,40 +109,12 @@ const underlineOffsetFromLineTop = (line: Line): number => { }; /** - * Inline underlined tabs (signature / fill-in lines): draw the underline with the same - * `text-decoration` mechanism as adjacent text. The tab box is baseline-aligned and the - * box is filled with transparent, horizontally-clipped whitespace, so the browser places - * the underline on the exact same baseline and at the same weight as the surrounding - * text — one continuous, even line (SD-3330). - */ -const applyTabUnderlineDecoration = (tabEl: HTMLElement, run: Extract, widthPx: number): void => { - if (!run.underline) return; - - const underlineStyle = run.underline.style ?? 'single'; - // Explicit color, not currentColor: the filler glyphs are transparent, and currentColor - // could resolve to transparent and hide the underline. - const underlineColor = run.underline.color ?? '#000000'; - const fontSize = (run as { fontSize?: number }).fontSize ?? 16; - - tabEl.style.fontSize = `${fontSize}px`; - tabEl.style.whiteSpace = 'pre'; - tabEl.style.color = 'transparent'; - // Clip the filler horizontally to the tab width; the negative top/bottom insets leave - // the underline vertically unclipped regardless of the font's metrics. - tabEl.style.clipPath = 'inset(-50% 0 -50% 0)'; - tabEl.style.textDecorationLine = 'underline'; - tabEl.style.textDecorationStyle = underlineStyle === 'double' ? 'double' : 'solid'; - tabEl.style.textDecorationColor = underlineColor; - tabEl.style.textDecorationThickness = `${underlineThicknessPx(fontSize)}px`; - // Enough whitespace to overfill the tab width once clipped (a space is ≥ ~2px wide for - // any readable font size). - tabEl.textContent = ' '.repeat(Math.max(8, Math.ceil(widthPx / 2) + 2)); -}; - -/** - * Positioned (right/center/decimal-aligned) underlined tabs are absolutely placed, so the - * baseline-aligned text-decoration path used for inline tabs does not apply. They draw the - * underline as a border at the computed offset, with the same font-scaled weight. + * Underlined tabs (signature / fill-in lines) draw the underline as a border-bottom. The + * tab has no glyphs to carry a text-decoration, so the weight is matched to adjacent text + * by using the same font-scaled thickness text runs apply via text-decoration-thickness + * (underlineThicknessPx), giving a uniform line across text and tabs (SD-3330). The run + * carries the font size even though the rendered span sets none. An explicit color is used + * (not currentColor) because the tab has no visible text to inherit a color from. */ const applyTabUnderlineBorder = (tabEl: HTMLElement, run: Extract): void => { if (!run.underline) return; From b5f7bc779b63f793cfe4c9497c1c2b1211620cd7 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 3 Jun 2026 07:49:34 -0300 Subject: [PATCH 4/5] fix(layout-engine): repaint tab when its underline changes (SD-3330) Applying underline to an already-rendered tab in the editor did not show until an unrelated edit forced a rebuild. Root cause: deriveBlockVersion (the paint cache key the DomPainter compares to decide whether to reuse a fragment) encoded a tab run as just text + "tab", omitting its marks. Toggling underline produced an identical version, so the painter reused the cached, non-underlined fragment. Include the tab's underline (style + color) in its version, matching how text runs already encode their underline. Now a tab mark change invalidates the paint cache and the underline appears immediately. Adds a regression test asserting the version changes when a tab gains/recolors an underline. Pre-existing; reproduced on main. Independent of the tab underline rendering fix. --- .../src/versionSignature.test.ts | 32 ++++++++++++++++++- .../layout-resolved/src/versionSignature.ts | 6 +++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 44b3f10eb3..502bddd774 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TabRun, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -67,6 +67,36 @@ describe('deriveBlockVersion - bidi', () => { }); }); +describe('deriveBlockVersion - tab underline', () => { + const makeTabParagraph = (underline?: { style?: string; color?: string }): FlowBlock => ({ + kind: 'paragraph', + id: 'p1', + attrs: {}, + runs: [{ kind: 'tab', text: '\t', pmStart: 1, pmEnd: 2, ...(underline ? { underline } : {}) } as TabRun], + }); + + // SD-3330: toggling underline on a tab must change the block version, otherwise the + // DomPainter reuses the cached (non-underlined) fragment and the underline does not + // appear until an unrelated edit forces a rebuild. + it('produces a different version when a tab gains an underline', () => { + const plain = deriveBlockVersion(makeTabParagraph()); + const underlined = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' })); + expect(underlined).not.toBe(plain); + }); + + it('produces a different version when the tab underline color changes', () => { + const black = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' })); + const red = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#FF0000' })); + expect(red).not.toBe(black); + }); + + it('is stable when the tab underline is identical', () => { + const a = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' })); + const b = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' })); + expect(a).toBe(b); + }); +}); + describe('deriveBlockVersion - table image content', () => { const makeTableWithImage = (image: ImageBlock): TableBlock => ({ kind: 'table', diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 89d56326f8..376d99d4a3 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -306,7 +306,11 @@ export const deriveBlockVersion = (block: FlowBlock): string => { } if (run.kind === 'tab') { - return [run.text ?? '', 'tab'].join(','); + // Include the underline (the only mark a tab paints, as a border) so toggling + // underline on a tab changes the block version and the painter repaints it. + // Without this, an underline applied to an already-rendered tab is not shown + // until an unrelated edit forces a rebuild (SD-3330). + return [run.text ?? '', 'tab', run.underline?.style ?? '', run.underline?.color ?? ''].join(','); } if (run.kind === 'fieldAnnotation') { From 9c7998cbc8b7fe7836cec72c78a8ab39fa130382 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 3 Jun 2026 08:11:23 -0300 Subject: [PATCH 5/5] fix(layout-engine): give tab-only lines the paragraph font height (SD-3330) A line containing only tabs was measured at the 12px default and rendered ~4px shorter than a text or empty line in the same paragraph, so tab fill-in lines sat at a different height than typed text. Two parts: - Adapter (paragraph converter): a bare tab carries no font of its own, so give it the paragraph's resolved default font (mirroring the empty-paragraph run). - Measuring: when a paragraph has no sized text run, fall back to any run that carries a font size/family (e.g. a tab) instead of the 12px default, so the tab's font drives the line height. Add fontFamily/fontSize to the TabRun type (the tab legitimately carries them for line height and underline weight) and drop the matching casts. Tab widths are unaffected. Adds a measuring regression test asserting a tab-only line matches a text line of the same font. Pre-existing; independent of the underline work. --- packages/layout-engine/contracts/src/index.ts | 7 ++++++ .../measuring/dom/src/index.test.ts | 25 +++++++++++++++++++ .../layout-engine/measuring/dom/src/index.ts | 21 ++++++++++++++-- .../painters/dom/src/runs/tab-run.ts | 2 +- .../layout-adapter/converters/paragraph.ts | 11 +++++++- 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index e12ab7ea53..945742c05e 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -422,6 +422,13 @@ export type TextRun = RunMarks & { export type TabRun = RunMarks & { kind: 'tab'; text: '\t'; + /** + * Font of the tab, inherited from the paragraph's resolved run properties. A tab has + * no glyphs, but its font drives the line height (so a tab-only line matches a text + * line) and the underline weight. Optional: not every producer sets it. + */ + fontFamily?: string; + fontSize?: number; /** Width in pixels (assigned by measurer/resolver). */ width?: number; tabStops?: TabStop[]; diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index 37821a82a4..f6407cb251 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -83,6 +83,31 @@ describe('measureBlock', () => { expect(measure.totalHeight).toBe(measure.lines[0].lineHeight); }); + // SD-3330: a line containing only tabs must be measured at the run's font size, + // not the 12px fallback, so it has the same height as a text line in the same + // paragraph font. Without this, tab-only lines render shorter than text lines. + it('measures a tab-only line at the run font size, not the 12px default', async () => { + const textBlock: FlowBlock = { + kind: 'paragraph', + id: 'text', + runs: [{ text: 'x', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const tabBlock: FlowBlock = { + kind: 'paragraph', + id: 'tab', + runs: [{ kind: 'tab', text: '\t', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + + const textMeasure = expectParagraphMeasure(await measureBlock(textBlock, 1000)); + const tabMeasure = expectParagraphMeasure(await measureBlock(tabBlock, 1000)); + + // The tab-only line height matches the text line (both 16px), not 12px × 1.15. + expect(tabMeasure.lines[0].lineHeight).toBeCloseTo(textMeasure.lines[0].lineHeight, 1); + expect(tabMeasure.lines[0].lineHeight).toBeGreaterThan(16); + }); + it('breaks lines when text exceeds maxWidth', async () => { const block: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 6e6aa87412..406b57c90a 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -845,11 +845,28 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P const firstTextRunWithSize = block.runs.find( (run): run is TextRun => isTextRun(run) && 'fontSize' in run && run.fontSize != null, ); - const fallbackFontSize = normalizeFontSize(firstTextRunWithSize?.fontSize, DEFAULT_PARAGRAPH_FONT_SIZE); + // Prefer a text run's size, but fall back to any run (e.g. a tab) carrying a font + // size when the paragraph has no sized text run. Otherwise a tab-only line is + // measured at the 12px default and renders shorter than a text or empty line in the + // same paragraph (SD-3330). + const firstRunWithSize = + firstTextRunWithSize ?? + block.runs.find( + (run): run is Run & { fontSize: number } => + typeof (run as { fontSize?: unknown }).fontSize === 'number' && (run as { fontSize: number }).fontSize > 0, + ); + const fallbackFontSize = normalizeFontSize(firstRunWithSize?.fontSize, DEFAULT_PARAGRAPH_FONT_SIZE); const firstTextRunWithFont = block.runs.find( (run): run is TextRun => isTextRun(run) && typeof run.fontFamily === 'string' && run.fontFamily.trim().length > 0, ); - const fallbackFontFamily = firstTextRunWithFont?.fontFamily ?? DEFAULT_PARAGRAPH_FONT_FAMILY; + const firstRunWithFont = + firstTextRunWithFont ?? + block.runs.find( + (run): run is Run & { fontFamily: string } => + typeof (run as { fontFamily?: unknown }).fontFamily === 'string' && + (run as { fontFamily: string }).fontFamily.trim().length > 0, + ); + const fallbackFontFamily = firstRunWithFont?.fontFamily ?? DEFAULT_PARAGRAPH_FONT_FAMILY; const normalizedRuns = normalizeRunsForMeasurement(block.runs as Run[], fallbackFontSize, fallbackFontFamily); const markerInfo: ParagraphMeasure['marker'] | undefined = wordLayout?.marker diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.ts index bf36aec2ed..6192cba1d5 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.ts @@ -122,6 +122,6 @@ const applyTabUnderlineBorder = (tabEl: HTMLElement, run: Extract