diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 502bddd774..9f4d8e2bf6 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -95,6 +95,65 @@ describe('deriveBlockVersion - tab underline', () => { const b = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' })); expect(a).toBe(b); }); + + // SD-3330: the painter's tab underline thickness comes from fontSize, and its offset/color + // come from measured line metrics fed by fontFamily and the run color. Each must change the + // block version, or a font-size/family/color edit leaves a stale tab underline cached. + const makeStyledTabParagraph = ( + overrides: Partial<{ fontSize: number; fontFamily: string; color: string; bold: boolean; italic: boolean }>, + ): FlowBlock => ({ + kind: 'paragraph', + id: 'p1', + attrs: {}, + runs: [ + { + kind: 'tab', + text: '\t', + pmStart: 1, + pmEnd: 2, + underline: { style: 'single', color: '#000000' }, + ...overrides, + } as TabRun, + ], + }); + + it('produces a different version when the tab fontSize changes', () => { + const small = deriveBlockVersion(makeStyledTabParagraph({ fontSize: 12 })); + const large = deriveBlockVersion(makeStyledTabParagraph({ fontSize: 24 })); + expect(large).not.toBe(small); + }); + + it('produces a different version when the tab fontFamily changes', () => { + const arial = deriveBlockVersion(makeStyledTabParagraph({ fontFamily: 'Arial' })); + const times = deriveBlockVersion(makeStyledTabParagraph({ fontFamily: 'Times New Roman' })); + expect(times).not.toBe(arial); + }); + + it('produces a different version when the tab run color changes', () => { + const black = deriveBlockVersion(makeStyledTabParagraph({ color: '#000000' })); + const red = deriveBlockVersion(makeStyledTabParagraph({ color: '#FF0000' })); + expect(red).not.toBe(black); + }); + + // SD-3330 review: tab-only line metrics now come from the tab's font via getFontInfoFromRun, which + // feeds bold/italic into the measured ascent/descent, so toggling them must change the version. + it('produces a different version when the tab bold changes', () => { + const plain = deriveBlockVersion(makeStyledTabParagraph({ bold: false })); + const bold = deriveBlockVersion(makeStyledTabParagraph({ bold: true })); + expect(bold).not.toBe(plain); + }); + + it('produces a different version when the tab italic changes', () => { + const plain = deriveBlockVersion(makeStyledTabParagraph({ italic: false })); + const italic = deriveBlockVersion(makeStyledTabParagraph({ italic: true })); + expect(italic).not.toBe(plain); + }); + + it('is stable when tab fontSize, fontFamily and color are identical', () => { + const a = deriveBlockVersion(makeStyledTabParagraph({ fontSize: 16, fontFamily: 'Arial', color: '#123456' })); + const b = deriveBlockVersion(makeStyledTabParagraph({ fontSize: 16, fontFamily: 'Arial', color: '#123456' })); + expect(a).toBe(b); + }); }); describe('deriveBlockVersion - table image content', () => { diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 687ca48c39..259fb715cf 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -307,11 +307,30 @@ export const deriveBlockVersion = (block: FlowBlock): string => { } if (run.kind === 'tab') { - // 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(','); + // Include every input the painter's tab underline depends on so the paint cache is + // not reused after a relevant change (SD-3330): underline style/color choose the + // mark; fontSize sets its thickness; fontFamily/color feed measured line metrics and + // the resolved underline color. The font epoch matters too: a tab's underline offset + // is derived from measured line metrics, so when a font loads/changes (resolved family + // unchanged, only availability) a tab-only underlined line must repaint - a mixed + // text+tab line is already busted by its text run, but a tab-only line has none. + // bold/italic matter for the same reason: a tab-only line's metrics now come from the + // tab's font via getFontInfoFromRun, which feeds bold/italic into the measured ascent/ + // descent (buildFontString), so the underline offset and line height depend on them. + // Without these a font/style/availability change can leave a stale tab underline until an + // unrelated edit forces a rebuild. + return [ + run.text ?? '', + 'tab', + run.underline?.style ?? '', + run.underline?.color ?? '', + run.fontSize ?? '', + run.fontFamily ?? '', + (run as { bold?: boolean }).bold ? 1 : 0, + (run as { italic?: boolean }).italic ? 1 : 0, + getFontConfigVersion(), + (run as { color?: string }).color ?? '', + ].join(','); } if (run.kind === 'fieldAnnotation') { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index d5f8adba15..3be6a341b2 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -1614,7 +1614,12 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P toChar: 1, width: 0, maxFontSize: lastFontSize, - maxFontInfo: hasSeenTextRun ? undefined : fallbackFontInfo, + // A tab-only paragraph has no text run, so fallbackFontInfo is undefined and the line + // would fall back to synthetic 0.8/0.2 ascent/descent. Derive metrics from the tab's own + // font (it carries fontFamily/fontSize) so a tab-only underlined line gets the same + // measured ascent/descent - hence underline offset and line height - as the equivalent + // text line. getFontInfoFromRun reads only fontFamily/fontSize/bold/italic, all on a TabRun. + maxFontInfo: hasSeenTextRun ? undefined : (fallbackFontInfo ?? getFontInfoFromRun(run as unknown as TextRun)), maxWidth: getEffectiveWidth(lines.length === 0 ? initialAvailableWidth : bodyContentWidth), segments: [], spaceCount: 0, diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 249856c9da..70b3913305 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js'; import { DomPainter } from './renderer.js'; +import { underlineOffsetFromLineTop } from './runs/tab-run.js'; import { resolveLayout } from '@superdoc/layout-resolved'; import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js'; import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js'; @@ -624,6 +625,321 @@ describe('DomPainter', () => { expect(parseFloat(lines[0].style.wordSpacing)).toBeGreaterThan(0); }); + it('paints underlined text and default-positioned tabs with one measured overlay', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'underlined-default-tabs', + runs: [ + { + text: 'This is some text followed by some tab stops ', + fontFamily: 'Arial', + fontSize: 16, + underline: { style: 'single' }, + }, + { kind: 'tab', text: '\t', width: 14.0859375, fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + ], + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 5, + toChar: 1, + width: 528, + maxWidth: 624, + ascent: 14.640625, + descent: 3.5390625, + lineHeight: 21.313333333333333, + segments: [{ runIndex: 0, fromChar: 0, toChar: 45, width: 321.9140625 }], + spaceCount: 9, + }, + ], + totalHeight: 21.313333333333333, + }; + + const layout: Layout = { + pageSize: { w: 816, h: 1056 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'underlined-default-tabs', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 624, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; + const overlay = lineEl.querySelector('.superdoc-underline-overlay') as HTMLElement; + const textRun = lineEl.querySelector('span:not(.superdoc-tab):not(.superdoc-underline-overlay)') as HTMLElement; + const tabRuns = Array.from(lineEl.querySelectorAll('.superdoc-tab')) as HTMLElement[]; + + expect(overlay).toBeTruthy(); + expect(overlay.style.left).toBe('0px'); + expect(overlay.style.width).toBe('528px'); + expect(overlay.style.borderTop).toContain('solid'); + // Pin the underline y to the computed offset - the whole point of SD-3330 - not just > ascent. + expect(parseFloat(overlay.style.top)).toBeCloseTo(underlineOffsetFromLineTop(measure.lines[0]), 5); + expect(textRun.style.textDecorationLine).toBe('none'); + expect(tabRuns).toHaveLength(5); + tabRuns.forEach((tab) => expect(tab.style.borderBottom).toBe('')); + }); + + it('keeps native underlines (no overlay) on RTL tab lines', () => { + // PR #3627 review: RTL paragraphs skip segment positioning (shouldUseSegmentPositioning returns + // false for RTL) and fall to inline flow so the browser's bidi algorithm places the tabs. The + // overlay builds LTR left-offsets from the line start, so it must NOT own the underline there - + // otherwise it suppresses the natively-correct underlines and paints on the wrong side. RTL must + // keep native text-decoration + tab borders. + const block: FlowBlock = { + kind: 'paragraph', + id: 'rtl-underlined-tabs', + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, + runs: [ + { text: 'שלום', fontFamily: 'Arial', fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + ], + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 2, + toChar: 1, + width: 140, + maxWidth: 624, + ascent: 14.640625, + descent: 3.5390625, + lineHeight: 21.313333333333333, + // Segments are present (as for any tab line); the RTL guard - not absent segments - is + // what must keep the overlay off. + segments: [{ runIndex: 0, fromChar: 0, toChar: 4, width: 44 }], + spaceCount: 0, + }, + ], + totalHeight: 21.313333333333333, + }; + + const layout: Layout = { + pageSize: { w: 816, h: 1056 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'rtl-underlined-tabs', fromLine: 0, toLine: 1, x: 0, y: 0, width: 624 }], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; + const overlay = lineEl.querySelector('.superdoc-underline-overlay'); + const textRun = lineEl.querySelector('span:not(.superdoc-tab):not(.superdoc-underline-overlay)') as HTMLElement; + const tabRuns = Array.from(lineEl.querySelectorAll('.superdoc-tab')) as HTMLElement[]; + + // No overlay on RTL lines - the LTR overlay offsets would land on the wrong side. + expect(overlay).toBeNull(); + // Native underlines are preserved (not suppressed): text keeps its decoration, tabs keep borders. + expect(textRun.style.textDecorationLine).not.toBe('none'); + expect(tabRuns).toHaveLength(2); + tabRuns.forEach((tab) => expect(tab.style.borderBottom).toContain('solid')); + }); + + // SD-3330 review: the inline overlay builds left-origin offsets, so it must stay off (native + // underlines preserved) whenever the content is horizontally shifted in a way it can't see, or + // when the line carries an atomic run the overlay can't measure. + const expectNoOverlayNativesKept = (blockAttrs: Record, extraRuns: unknown[] = []) => { + const block = { + kind: 'paragraph', + id: 'overlay-origin-mismatch', + attrs: blockAttrs, + runs: [ + { text: 'Name', fontFamily: 'Arial', fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + ...extraRuns, + ], + } as unknown as FlowBlock; + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 1 + extraRuns.length, + toChar: 1, + width: 120, + maxWidth: 624, + ascent: 14.640625, + descent: 3.5390625, + lineHeight: 21.313333333333333, + segments: [{ runIndex: 0, fromChar: 0, toChar: 4, width: 40 }], + spaceCount: 0, + }, + ], + totalHeight: 21.313333333333333, + }; + const layout: Layout = { + pageSize: { w: 816, h: 1056 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'overlay-origin-mismatch', fromLine: 0, toLine: 1, x: 0, y: 0, width: 624 }, + ], + }, + ], + }; + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; + const textRun = lineEl.querySelector('span:not(.superdoc-tab):not(.superdoc-underline-overlay)') as HTMLElement; + const tabRuns = Array.from(lineEl.querySelectorAll('.superdoc-tab')) as HTMLElement[]; + expect(lineEl.querySelector('.superdoc-underline-overlay')).toBeNull(); + expect(textRun.style.textDecorationLine).not.toBe('none'); + expect(tabRuns.length).toBeGreaterThan(0); + tabRuns.forEach((tab) => expect(tab.style.borderBottom).toContain('solid')); + }; + + it('keeps native underlines (no overlay) on center-aligned inline tab lines', () => { + expectNoOverlayNativesKept({ alignment: 'center' }); + }); + + it('keeps native underlines (no overlay) on right-aligned inline tab lines', () => { + expectNoOverlayNativesKept({ alignment: 'right' }); + }); + + it('keeps native underlines (no overlay) on hanging-indent inline tab lines', () => { + expectNoOverlayNativesKept({ indent: { left: 0, hanging: 360 } }); + }); + + it('keeps native underlines (no overlay) when an underlined field annotation shares the line', () => { + // FieldAnnotationRun.underline is a boolean the overlay would treat as eligible and suppress, but + // it cannot measure the field's width (run.size, not run.width). An atomic run on the line keeps + // the overlay off so the field's underline is not silently dropped. + expectNoOverlayNativesKept({}, [ + { + kind: 'fieldAnnotation', + variant: 'text', + displayLabel: 'Client', + fieldId: 'F1', + fieldType: 'text', + fieldColor: '#980043', + underline: true, + pmStart: 0, + pmEnd: 1, + }, + ]); + + // Sharper than just "no overlay": the field's own underline must not be suppressed. The overlay's + // suppression path forces textDecorationLine to 'none' on the run's element; with the overlay off + // it keeps its native value. + const fieldEl = mount.querySelector('[aria-label="Field annotation"]') as HTMLElement; + expect(fieldEl).toBeTruthy(); + expect(fieldEl.style.textDecorationLine).not.toBe('none'); + }); + + it('paints one measured overlay for underlined text + tabs on the segment-positioned path', () => { + // Regression for SD-3330: a line with an explicit segment x (e.g. text after a tab stop) + // takes the segment-positioned branch. The line-level underline overlay must own the mark + // there too, so text + preserved spaces + tabs share one y instead of text-decoration and + // tab-border landing on different rows. + const block: FlowBlock = { + kind: 'paragraph', + id: 'underlined-positioned-tabs', + runs: [ + { text: 'Name: ', fontFamily: 'Arial', fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + { kind: 'tab', text: '\t', width: 48, fontSize: 16, underline: { style: 'single' } }, + { text: 'Value', fontFamily: 'Arial', fontSize: 16, underline: { style: 'single' } }, + ], + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 3, + toChar: 5, + width: 232, + maxWidth: 624, + ascent: 14.640625, + descent: 3.5390625, + lineHeight: 21.313333333333333, + // The explicit x on the trailing "Value" segment forces the segment-positioned path. + segments: [ + { runIndex: 0, fromChar: 0, toChar: 6, width: 50 }, + { runIndex: 3, fromChar: 0, toChar: 5, width: 40, x: 192 }, + ], + spaceCount: 1, + }, + ], + totalHeight: 21.313333333333333, + }; + + const layout: Layout = { + pageSize: { w: 816, h: 1056 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'underlined-positioned-tabs', fromLine: 0, toLine: 1, x: 0, y: 0, width: 624 }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; + const overlays = Array.from(lineEl.querySelectorAll('.superdoc-underline-overlay')) as HTMLElement[]; + const textRuns = Array.from(lineEl.querySelectorAll('span')).filter((s) => + /Name:|Value/.test(s.textContent || ''), + ) as HTMLElement[]; + const borderedEls = Array.from(lineEl.querySelectorAll('span, div')).filter( + (el) => (el as HTMLElement).style.borderBottom !== '', + ); + + // The positioned branch must coalesce text + both tabs + the x=192 "Value" segment into a + // SINGLE continuous overlay spanning the whole content - Name: [0,50], tab [50,98], tab + // [98,192] (filling to the next segment x), Value [192,232] - not several abutting spans + // that could each round to a different sub-pixel edge and reintroduce a seam. + expect(overlays).toHaveLength(1); + expect(overlays[0].style.left).toBe('0px'); + expect(overlays[0].style.width).toBe('232px'); + expect(overlays[0].style.borderTop).toContain('solid'); + expect(parseFloat(overlays[0].style.top)).toBeCloseTo(underlineOffsetFromLineTop(measure.lines[0]), 5); + // Native underlines are suppressed where the overlay owns the mark. + expect(textRuns.length).toBeGreaterThan(0); + textRuns.forEach((t) => expect(t.style.textDecorationLine).toBe('none')); + expect(borderedEls).toHaveLength(0); + }); + it('uses first-line hanging width when justifying default-tab positioned segments', () => { const tabBlock: FlowBlock = { kind: 'paragraph', @@ -3052,7 +3368,7 @@ describe('DomPainter', () => { // data-appearance="hidden" is the hook CSS uses to drop chrome. expect(wrapper.dataset.appearance).toBe('hidden'); - // No alias label child — must not be in the DOM at all. + // No alias label child. It must not be in the DOM at all. expect(wrapper.querySelector('.superdoc-structured-content-inline__label')).toBeNull(); // textContent of the wrapper must equal exactly the wrapped phrase, diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.ts b/packages/layout-engine/painters/dom/src/runs/render-line.ts index b72d613bc8..339454977f 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -20,7 +20,13 @@ import { applyTooltipAccessibility } from './links.js'; import { appendFormattingParagraphMark } from './formatting-marks.js'; import { textRunMergeSignature } from './hash.js'; import { isBreakRun, isFieldAnnotationRun, isImageRun, isLineBreakRun, isMathRun, renderRun } from './render-run.js'; -import { renderInlineTabRun, renderPositionedTabRun } from './tab-run.js'; +import { + canPaintUnderlineOverlay, + renderInlineTabRun, + renderPositionedTabRun, + underlineBorderForRun, + underlineOffsetFromLineTop, +} from './tab-run.js'; import type { RenderLineParams } from './types.js'; /** @@ -163,6 +169,127 @@ const normalizeJustifiedRuns = (runsForLine: Run[]): Run[] => { return merged; }; +type UnderlineOverlaySpan = { + from: number; + to: number; + border: string; +}; + +const isTextRun = (run: Run): run is TextRun => (run.kind === 'text' || run.kind === undefined) && 'text' in run; + +// The overlay can only measure and cover text and tab runs - their widths come from line segments +// or run.width. Atomic runs (field annotations, inline images, math) carry their width elsewhere +// (run.size), so a line containing one would mis-advance the overlay cursor and could suppress an +// atomic run's native underline without painting a replacement (SD-3330 review). Restrict the +// overlay to lines built only from text / tab / line-break runs, with an overlay-eligible tab. +const isOverlaySafeRunKind = (run: Run): boolean => { + const kind = run.kind ?? 'text'; + return kind === 'text' || kind === 'tab' || kind === 'lineBreak' || kind === 'break'; +}; + +const shouldUseLineUnderlineOverlay = (runsForLine: Run[]): boolean => + runsForLine.every(isOverlaySafeRunKind) && + runsForLine.some((run) => run.kind === 'tab' && canPaintUnderlineOverlay(run)); + +const cloneRunWithoutUnderline = (run: T): T => ({ ...run, underline: undefined }) as T; + +const appendUnderlineOverlaySpan = ( + spans: UnderlineOverlaySpan[], + from: number, + to: number, + border: string | undefined, +): void => { + if (!border || to <= from) return; + const last = spans[spans.length - 1]; + if (last && last.border === border && Math.abs(last.to - from) < 0.5) { + last.to = to; + return; + } + spans.push({ from, to, border }); +}; + +const runInlinePaintWidth = ( + run: Run, + runIndex: number, + segmentsByRun: Map, + spacingPerSpace: number, +): number => { + if (run.kind === 'tab') { + return run.width ?? 48; + } + + const segments = segmentsByRun.get(runIndex); + if (segments?.length) { + return segments.reduce((sum, segment) => { + const text = isTextRun(run) ? (run.text ?? '').slice(segment.fromChar, segment.toChar) : ''; + return sum + segment.width + spacingPerSpace * countSpaces(text); + }, 0); + } + + if ('width' in run && typeof run.width === 'number') { + return run.width; + } + + return 0; +}; + +// Builds underline spans for the normal inline-flow branch. Spans are in line-relative px +// (the paragraph indent is folded into `from`/`to`) so a single coordinate space is shared +// with the segment-positioned branch and the draw step below. +const buildInlineUnderlineSpans = ( + block: ParagraphBlock, + line: import('@superdoc/contracts').Line, + spacingPerSpace: number, + lineTextStartOffsetPx: number, +): UnderlineOverlaySpan[] => { + const segmentsByRun = new Map(); + line.segments?.forEach((segment) => { + const segments = segmentsByRun.get(segment.runIndex); + if (segments) { + segments.push(segment); + } else { + segmentsByRun.set(segment.runIndex, [segment]); + } + }); + + const spans: UnderlineOverlaySpan[] = []; + let currentX = lineTextStartOffsetPx; + + for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) { + const run = block.runs[runIndex]; + if (!run) continue; + + const width = runInlinePaintWidth(run, runIndex, segmentsByRun, spacingPerSpace); + if (canPaintUnderlineOverlay(run)) { + appendUnderlineOverlaySpan(spans, currentX, currentX + width, underlineBorderForRun(run)); + } + currentX += width; + } + + return spans; +}; + +// Draws one absolutely-positioned underline element per span. Because the overlay owns the +// underline for both text and tabs in the covered range, text, preserved spaces, and tabs +// share one y, thickness, style and color - removing the text-decoration vs tab-border seam +// that two separate painters produced (SD-3330). `span.from`/`span.to` are line-relative px. +const renderUnderlineSpans = (spans: UnderlineOverlaySpan[], top: number, el: HTMLElement, doc: Document): void => { + spans.forEach((span) => { + const overlay = doc.createElement('div'); + overlay.classList.add('superdoc-underline-overlay'); + overlay.setAttribute('aria-hidden', 'true'); + overlay.style.position = 'absolute'; + overlay.style.left = `${span.from}px`; + overlay.style.top = `${top}px`; + overlay.style.width = `${Math.max(0, span.to - span.from)}px`; + overlay.style.height = '0px'; + overlay.style.borderTop = span.border; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = '2'; + el.appendChild(overlay); + }); +}; + export const renderLine = ({ block, line, @@ -304,6 +431,38 @@ export const renderLine = ({ shouldJustify: justifyShouldApply, }); const lineContainsInlineImage = runsForLine.some((run) => isImageRun(run)); + const useSegmentPositioning = shouldUseSegmentPositioning( + hasExplicitPositioning ?? false, + Boolean(line.segments), + isRtl, + ); + // Enabled for both inline-flow and segment-positioned lines: a single measured underline + // overlay owns the mark across text + preserved spaces + tabs, so the two never disagree + // on the underline's y (SD-3330). The segment-positioned branch captures span geometry as + // it renders; the inline branch builds it from segment/tab widths. + // The inline-flow overlay builds left-origin offsets that only line up with the content when the + // content actually starts at the left. Several layouts shift it the overlay can't see: + // - RTL: shouldUseSegmentPositioning returns false, so RTL falls to inline flow where the browser + // bidi-places the tabs - the LTR overlay would land on the wrong side. + // - center / right alignment: the browser shifts the in-flow content; the overlay does not. + // - hanging or negative indent: renderParagraphContent's CSS clamps negative indent and treats + // hanging continuation lines differently than the overlay's resolveLineIndentOffset, so the two + // origins diverge. + // In all of these, keep native underlines (don't suppress) rather than paint a misplaced overlay. + // The segment-positioned branch is exempt: it captures spans at the same absolute x it positions + // runs at, so it stays correct under any alignment/indent. + const overlayAlignment = (block.attrs as ParagraphAttrs | undefined)?.alignment; + const overlayIndent = (block.attrs as ParagraphAttrs | undefined)?.indent; + const inlineOverlayOriginMatchesContent = + overlayAlignment !== 'center' && + overlayAlignment !== 'right' && + (overlayIndent?.hanging ?? 0) === 0 && + (overlayIndent?.left ?? 0) >= 0; + const useLineUnderlineOverlay = + Boolean(line.segments) && + !isRtl && + shouldUseLineUnderlineOverlay(runsForLine) && + (useSegmentPositioning || inlineOverlayOriginMatchesContent); const resolveLineIndentOffset = (): number => { if (indentOffsetOverride != null) { return indentOffsetOverride; @@ -339,7 +498,12 @@ export const renderLine = ({ el.style.wordSpacing = `${spacingPerSpace}px`; } - if (shouldUseSegmentPositioning(hasExplicitPositioning ?? false, Boolean(line.segments), isRtl)) { + // Collects measured underline spans (line-relative px) from whichever branch renders, so a + // single draw step paints them. The segment-positioned branch fills it during rendering + // (using the same coordinates it positions runs at); the inline branch builds it afterwards. + const underlineSpans: UnderlineOverlaySpan[] = []; + + if (useSegmentPositioning) { renderExplicitlyPositionedRuns({ block, line, @@ -351,6 +515,8 @@ export const renderLine = ({ runContext, trackedConfig, lineContainsInlineImage, + useLineUnderlineOverlay, + underlineSpanCollector: useLineUnderlineOverlay ? underlineSpans : undefined, }); } else { renderInlineRuns({ @@ -362,7 +528,17 @@ export const renderLine = ({ runContext, trackedConfig, lineContainsInlineImage, + useLineUnderlineOverlay, }); + if (useLineUnderlineOverlay) { + underlineSpans.push( + ...buildInlineUnderlineSpans(expandedBlock as ParagraphBlock, line, spacingPerSpace, lineTextStartOffsetPx), + ); + } + } + + if (useLineUnderlineOverlay && underlineSpans.length > 0) { + renderUnderlineSpans(underlineSpans, underlineOffsetFromLineTop(line), el, runContext.doc); } appendFormattingParagraphMark( @@ -411,10 +587,14 @@ const renderExplicitlyPositionedRuns = ({ runContext, trackedConfig, lineContainsInlineImage, + useLineUnderlineOverlay, + underlineSpanCollector, }: RunRenderBranchParams & { block: ParagraphBlock; lineTextStartOffsetPx: number; spacingPerSpace: number; + useLineUnderlineOverlay: boolean; + underlineSpanCollector?: UnderlineOverlaySpan[]; }): void => { // Use segment-based rendering with absolute positioning for tab-aligned text. // shouldUseSegmentPositioning returns false for RTL because the layout engine @@ -539,6 +719,10 @@ const renderExplicitlyPositionedRuns = ({ // Find where the immediate next content begins (if it's right after this tab) const immediateNextSegment = findImmediateNextSegment(runIndex); const tabStartX = cumulativeX; + // When the line-level underline overlay owns this tab's underline, render the tab box + // without its own border and let the overlay draw the mark; capture the tab's measured + // span so the overlay covers exactly the geometry the tab occupies. + const coveredByOverlay = useLineUnderlineOverlay && canPaintUnderlineOverlay(baseRun); const { element: tabEl, tabEndX, @@ -552,8 +736,17 @@ const renderExplicitlyPositionedRuns = ({ indentOffset, immediateNextSegment, styleId, + !coveredByOverlay, ); appendToLineGeo(tabEl, baseRun, tabStartX + indentOffset, actualTabWidth); + if (coveredByOverlay && underlineSpanCollector) { + appendUnderlineOverlaySpan( + underlineSpanCollector, + tabStartX + indentOffset, + tabStartX + indentOffset + actualTabWidth, + underlineBorderForRun(baseRun), + ); + } // Update cumulativeX to where the next content begins // This ensures proper positioning for subsequent elements @@ -666,6 +859,10 @@ const renderExplicitlyPositionedRuns = ({ const runPmStart = baseRun.pmStart ?? null; const fallbackPmEnd = runPmStart != null && baseRun.pmEnd == null ? runPmStart + baseText.length : (baseRun.pmEnd ?? null); + // When the overlay owns this run's underline, render the text without text-decoration and + // let the overlay paint a single continuous mark spanning the text (incl. preserved + // trailing spaces) and the adjacent tabs (SD-3330). + const coveredByOverlay = useLineUnderlineOverlay && canPaintUnderlineOverlay(baseRun); runSegments.forEach((segment) => { const segmentText = baseText.slice(segment.fromChar, segment.toChar); @@ -678,10 +875,14 @@ const renderExplicitlyPositionedRuns = ({ text: segmentText, pmStart: pmSliceStart, pmEnd: pmSliceEnd, + ...(coveredByOverlay ? { underline: undefined } : {}), }; const elem = renderRun(segmentRun, context, runContext, trackedConfig); if (elem) { + if (coveredByOverlay) { + elem.style.textDecorationLine = segmentRun.strike ? 'line-through' : 'none'; + } if (styleId) { elem.setAttribute('styleid', styleId); } @@ -697,12 +898,16 @@ const renderExplicitlyPositionedRuns = ({ appendToLineGeo(elem, segmentRun, xPos, segment.width); // Advance cumulative X by the resolved segment width. LineSegment.width is the - // sole source of truth — the painter does not measure inline elements (SD-2957). + // sole source of truth. The painter does not measure inline elements (SD-2957). // Use baseX (without indent) to keep cumulativeX relative to content area, // matching how segment.x values are calculated in layout. const width = segment.width; const justifyExtraWidth = spacingPerSpace !== 0 ? spacingPerSpace * countSpaces(segmentText) : 0; const visualWidth = width + justifyExtraWidth; + // Span the visual width so the mark meets the next element (tab or run) flush. + if (coveredByOverlay && underlineSpanCollector) { + appendUnderlineOverlaySpan(underlineSpanCollector, xPos, xPos + visualWidth, underlineBorderForRun(baseRun)); + } cumulativeX = baseX + visualWidth; // Update SDT wrapper width if actual measured width differs from initial estimate if (geoSdtWrapper) { @@ -724,7 +929,8 @@ const renderInlineRuns = ({ runContext, trackedConfig, lineContainsInlineImage, -}: RunRenderBranchParams & { runsForLine: Run[] }): void => { + useLineUnderlineOverlay, +}: RunRenderBranchParams & { runsForLine: Run[]; useLineUnderlineOverlay: boolean }): void => { // Use run-based rendering for normal text flow // Track current inline SDT wrapper to group adjacent runs with the same SDT id let currentInlineSdtWrapper: HTMLElement | null = null; @@ -748,17 +954,30 @@ const renderInlineRuns = ({ closeCurrentWrapper(); } + const suppressUnderline = useLineUnderlineOverlay && canPaintUnderlineOverlay(run); + const runForRender = suppressUnderline ? cloneRunWithoutUnderline(run) : run; + // Special handling for TabRuns (e.g., signature lines with underlines) const elem = run.kind === 'tab' - ? renderInlineTabRun(run, line, runContext.doc, runContext.layoutEpoch, styleId) - : renderRun(run, context, runContext, trackedConfig); + ? renderInlineTabRun( + runForRender as Extract, + line, + runContext.doc, + runContext.layoutEpoch, + styleId, + !suppressUnderline, + ) + : renderRun(runForRender, context, runContext, trackedConfig); if (elem) { + if (suppressUnderline && run.kind !== 'tab') { + elem.style.textDecorationLine = 'strike' in runForRender && runForRender.strike ? 'line-through' : 'none'; + } if (styleId) { elem.setAttribute('styleid', styleId); } - alignNormalTextBesideInlineImage(elem, run, lineContainsInlineImage); + alignNormalTextBesideInlineImage(elem, runForRender, lineContainsInlineImage); // If this run has inline SDT, add to or create wrapper if (resolved) { 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 3c06a9c945..784ecb8d1c 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 @@ -1,6 +1,12 @@ import { describe, it, expect } from 'vitest'; import type { Line, TabRun } from '@superdoc/contracts'; -import { renderInlineTabRun, renderPositionedTabRun } from './tab-run.js'; +import { + canPaintUnderlineAsBorder, + canPaintUnderlineOverlay, + renderInlineTabRun, + renderPositionedTabRun, + underlineBorderForRun, +} 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 @@ -71,3 +77,68 @@ describe('tab underline alignment (SD-3330)', () => { expect(element.style.borderBottom).toBe(''); }); }); + +const withStyle = (style: string) => ({ underline: { style } }); + +// SD-3330: the line-level underline overlay is intentionally scoped to the styles a single CSS +// border-top reproduces. These guards stop a future change from quietly widening the allowlist +// (which would flatten a wavy/heavy style to solid inside a "continuous" line) or narrowing it. +describe('canPaintUnderlineOverlay - overlay scope', () => { + it('accepts the styles a border-top reproduces', () => { + for (const style of ['single', 'double', 'dotted', 'dashed']) { + expect(canPaintUnderlineOverlay(withStyle(style))).toBe(true); + } + }); + + it('defaults a missing style to single and accepts it', () => { + expect(canPaintUnderlineOverlay({ underline: {} })).toBe(true); + }); + + it('rejects styles a border-top cannot draw, leaving them on the per-run path', () => { + for (const style of [ + 'words', + 'none', + 'wave', + 'thick', + 'dotDash', + 'dotDotDash', + 'dashLong', + 'dashLongHeavy', + 'wavyDouble', + ]) { + expect(canPaintUnderlineOverlay(withStyle(style))).toBe(false); + } + }); + + it('rejects a run with no underline', () => { + expect(canPaintUnderlineOverlay({ underline: undefined })).toBe(false); + expect(canPaintUnderlineOverlay({})).toBe(false); + }); + + // wave/heavy still get a (degraded, solid-ish) border on the legacy per-run path - the overlay + // simply does not own them. words/none paint no border on either path. + it('keeps wave on the border path while excluding it from the overlay', () => { + expect(canPaintUnderlineAsBorder(withStyle('wave'))).toBe(true); + expect(canPaintUnderlineOverlay(withStyle('wave'))).toBe(false); + expect(canPaintUnderlineAsBorder(withStyle('words'))).toBe(false); + expect(canPaintUnderlineAsBorder(withStyle('none'))).toBe(false); + }); +}); + +describe('underlineBorderForRun - style and color', () => { + it('paints no border for none/words', () => { + expect(underlineBorderForRun(withStyle('none'))).toBeUndefined(); + expect(underlineBorderForRun(withStyle('words'))).toBeUndefined(); + }); + + it('maps each overlay style to the matching border style', () => { + expect(underlineBorderForRun(withStyle('single'))).toContain('solid'); + expect(underlineBorderForRun(withStyle('double'))).toContain('double'); + expect(underlineBorderForRun(withStyle('dotted'))).toContain('dotted'); + expect(underlineBorderForRun(withStyle('dashed'))).toContain('dashed'); + }); + + it('uses the literal underline color without resolving theme tokens', () => { + expect(underlineBorderForRun({ underline: { style: 'single', color: '#FF0000' } })).toContain('#FF0000'); + }); +}); 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 6192cba1d5..f0f4d94f2f 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.ts @@ -1,12 +1,84 @@ import type { Line, LineSegment, Run } from '@superdoc/contracts'; import { underlineThicknessPx } from './text-run.js'; +type UnderlinePaintRun = { + underline?: { + style?: string; + color?: string; + } | null; + fontSize?: number; + color?: string; +}; + +type UnderlineSource = Run | UnderlinePaintRun; + +const getRunUnderline = (run: UnderlineSource): UnderlinePaintRun['underline'] => + 'underline' in run ? (run.underline as UnderlinePaintRun['underline']) : undefined; + +const getRunFontSize = (run: UnderlineSource): number => + 'fontSize' in run && typeof run.fontSize === 'number' ? run.fontSize : 16; + +const getRunColor = (run: UnderlineSource): string | undefined => + 'color' in run && typeof run.color === 'string' ? run.color : undefined; + +export const underlineStyleForRun = (run: UnderlineSource): string | undefined => + getRunUnderline(run)?.style ?? 'single'; + +export const canPaintUnderlineAsBorder = (run: UnderlineSource): boolean => { + if (!getRunUnderline(run)) return false; + const style = underlineStyleForRun(run); + return style !== 'none' && style !== 'words'; +}; + +/** + * Whether the line-level underline overlay may own this run's underline (SD-3330). + * + * Scoped to the styles a single CSS `border-top` reproduces faithfully: `single`, `double`, + * `dotted`, `dashed`. Everything else stays on its existing path on purpose, so the overlay + * never silently flattens a distinct style into solid inside a "continuous" line: + * - `none` paints nothing (rejected by canPaintUnderlineAsBorder); + * - `wave`, `thick`, and the OOXML heavy/compound spellings (`dotDash`, `dashLongHeavy`, ...) a + * border-top cannot draw - they keep their current (degraded) per-run rendering. + * + * `words` is NOT solved end-to-end here. canPaintUnderlineAsBorder rejects a literal `words`, but + * the v1 adapter's normalizeUnderlineStyle collapses OOXML `words` to `single` BEFORE paint, so a + * `words` underline reaches this layer as `single` and gets overlaid - it does not reproduce Word's + * "underline the words, not the tab whitespace". Fixing that needs an importer/adapter change and is + * out of scope for the seam fix; the guard here is only defensive for a producer that passes `words` raw. + * + * Color is a separate axis: this gate is style-only, and underlineBorderForRun does not resolve + * theme/`auto` underline colors (it uses the literal color string, as the prior border path did), + * so theme-color fidelity is out of scope here too. + * + * Mixed lines stay per-run: a single-underlined tab can be overlaid while an adjacent wavy run + * keeps its native text-decoration. + */ +export const canPaintUnderlineOverlay = (run: UnderlineSource): boolean => { + if (!canPaintUnderlineAsBorder(run)) return false; + const style = underlineStyleForRun(run); + return style === 'single' || style === 'double' || style === 'dotted' || style === 'dashed'; +}; + +export const underlineBorderForRun = (run: UnderlineSource): string | undefined => { + if (!canPaintUnderlineAsBorder(run)) return undefined; + + const underlineStyle = underlineStyleForRun(run); + const borderStyle = + underlineStyle === 'double' || underlineStyle === 'dotted' || underlineStyle === 'dashed' + ? underlineStyle + : 'solid'; + const underlineColor = getRunUnderline(run)?.color ?? getRunColor(run) ?? '#000000'; + const fontSize = getRunFontSize(run); + return `${underlineThicknessPx(fontSize)}px ${borderStyle} ${underlineColor}`; +}; + export const renderInlineTabRun = ( run: Extract, line: Line, doc: Document, layoutEpoch: number, styleId?: string, + paintUnderline = true, ): HTMLElement => { const tabEl = doc.createElement('span'); tabEl.classList.add('superdoc-tab'); @@ -16,7 +88,8 @@ export const renderInlineTabRun = ( tabEl.style.display = 'inline-block'; tabEl.style.width = `${tabWidth}px`; - if (run.underline) { + const shouldPaintUnderline = paintUnderline && canPaintUnderlineAsBorder(run); + if (shouldPaintUnderline) { // 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 @@ -30,7 +103,9 @@ export const renderInlineTabRun = ( tabEl.style.verticalAlign = 'bottom'; } - applyTabUnderlineBorder(tabEl, run); + if (shouldPaintUnderline) { + applyTabUnderlineBorder(tabEl, run); + } if (styleId) { tabEl.setAttribute('styleid', styleId); @@ -51,6 +126,7 @@ export const renderPositionedTabRun = ( indentOffset: number, immediateNextSegment?: LineSegment, styleId?: string, + paintUnderline = true, ): { element: HTMLElement; tabEndX: number; actualTabWidth: number } => { // The tab should span from where previous content ended to where next content begins. // If layout supplied a tab-end boundary for the next segment, prefer it. @@ -65,18 +141,17 @@ export const renderPositionedTabRun = ( tabEl.style.left = `${tabStartX + indentOffset}px`; tabEl.style.top = '0px'; tabEl.style.width = `${actualTabWidth}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). - // 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`; + // Underlined positioned tabs use the same computed offset as inline tabs, while + // non-underlined positioned tabs keep the full line height and are hidden below. + const shouldPaintUnderline = paintUnderline && canPaintUnderlineAsBorder(run); + tabEl.style.height = shouldPaintUnderline ? `${underlineOffsetFromLineTop(line)}px` : `${line.lineHeight}px`; tabEl.style.display = 'inline-block'; tabEl.style.pointerEvents = 'none'; tabEl.style.zIndex = '1'; - applyTabUnderlineBorder(tabEl, run); - if (!run.underline) { + if (shouldPaintUnderline) { + applyTabUnderlineBorder(tabEl, run); + } else { tabEl.style.visibility = 'hidden'; } @@ -99,9 +174,9 @@ export const renderPositionedTabRun = ( * (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). + * the resolved line metrics. The painter never measures the DOM (SD-2957). */ -const underlineOffsetFromLineTop = (line: Line): number => { +export 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); @@ -117,11 +192,7 @@ const underlineOffsetFromLineTop = (line: Line): number => { * (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; - - const underlineStyle = run.underline.style ?? 'single'; - const underlineColor = run.underline.color ?? '#000000'; - const borderStyle = underlineStyle === 'double' ? 'double' : 'solid'; - const fontSize = run.fontSize ?? 16; - tabEl.style.borderBottom = `${underlineThicknessPx(fontSize)}px ${borderStyle} ${underlineColor}`; + const border = underlineBorderForRun(run); + if (!border) return; + tabEl.style.borderBottom = border; }; diff --git a/tests/behavior/tests/basic-commands/sd-3330-underline-tabs-continuous.spec.ts b/tests/behavior/tests/basic-commands/sd-3330-underline-tabs-continuous.spec.ts new file mode 100644 index 0000000000..043d24ed37 --- /dev/null +++ b/tests/behavior/tests/basic-commands/sd-3330-underline-tabs-continuous.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +// SD-3330: typing a word, pressing Tab a few times, another word, selecting everything and +// applying underline must (1) underline the tab characters too - not only the text - and +// (2) paint a single continuous underline, not a text-decoration line under the words with a +// separate (and previously lower / broken) line under the tab gap. +test.use({ config: { layout: true } }); + +test('SD-3330: underlining text + tab stops yields one continuous underline', async ({ superdoc }) => { + // Reproduce the exact reported interaction. + await superdoc.type('Name'); + await superdoc.press('Tab'); + await superdoc.press('Tab'); + await superdoc.press('Tab'); + await superdoc.type('Value'); + await superdoc.waitForStable(); + + await superdoc.selectAll(); + await superdoc.underline(); + await superdoc.waitForStable(); + + // (1) Interaction: the underline mark landed on both words AND every tab. The original bug + // was that tab characters could not be underlined - only text could. + // Text underline goes through the document-api text helper (preferred). Tab-node marks are + // not exposed by the text helpers, so the tab half reads ProseMirror state directly. + await superdoc.assertTextHasMarks('Name', ['underline']); + await superdoc.assertTextHasMarks('Value', ['underline']); + + const tabState = await superdoc.page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = (window as any).editor?.view; + let tabCount = 0; + let underlinedTabCount = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + view.state.doc.descendants((node: any) => { + if (node.type?.name === 'tab') { + tabCount += 1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (Array.isArray(node.marks) && node.marks.some((m: any) => m.type?.name === 'underline')) { + underlinedTabCount += 1; + } + } + return true; + }); + return { tabCount, underlinedTabCount }; + }); + + expect(tabState.tabCount).toBeGreaterThan(0); + expect(tabState.underlinedTabCount).toBe(tabState.tabCount); + + // (2) Visual: the painted underline is one continuous mark. The line-level overlay owns the + // underline, so no text run paints its own text-decoration and no tab paints its own border; + // the overlay spans the content with no horizontal seam. + const line = superdoc.page.locator('.superdoc-line').first(); + await expect(line.locator('.superdoc-underline-overlay').first()).toBeVisible(); + + const paint = await line.evaluate((lineEl: HTMLElement) => { + const spans = Array.from(lineEl.querySelectorAll('span')) as HTMLElement[]; + const borderedTabs = spans.filter((s) => { + const cs = getComputedStyle(s); + return cs.borderBottomStyle !== 'none' && cs.borderBottomWidth !== '0px'; + }); + const textWithDecoration = spans.filter((s) => { + const cs = getComputedStyle(s); + return ( + s.children.length === 0 && + (s.textContent || '').trim().length > 0 && + cs.textDecorationLine.includes('underline') + ); + }); + const overlays = Array.from(lineEl.querySelectorAll('.superdoc-underline-overlay')) as HTMLElement[]; + const rects = overlays.map((o) => o.getBoundingClientRect()).sort((a, b) => a.left - b.left); + let maxGap = 0; + let minTop = Infinity; + let maxTop = -Infinity; + for (let i = 0; i < rects.length; i += 1) { + minTop = Math.min(minTop, rects[i].top); + maxTop = Math.max(maxTop, rects[i].top); + if (i > 0) maxGap = Math.max(maxGap, rects[i].left - rects[i - 1].right); + } + // Content extent = rendered text + tab spans (overlays are
, so the span query excludes them). + const contentRects = spans.map((s) => s.getBoundingClientRect()).filter((r) => r.width > 0); + const contentLeft = contentRects.length ? Math.min(...contentRects.map((r) => r.left)) : null; + const contentRight = contentRects.length ? Math.max(...contentRects.map((r) => r.right)) : null; + const overlayLeft = rects.length ? Math.min(...rects.map((r) => r.left)) : null; + const overlayRight = rects.length ? Math.max(...rects.map((r) => r.right)) : null; + return { + overlayCount: overlays.length, + borderedTabCount: borderedTabs.length, + textDecorationCount: textWithDecoration.length, + maxGapPx: rects.length ? maxGap : null, + topSpreadPx: rects.length ? maxTop - minTop : null, + contentLeft, + contentRight, + overlayLeft, + overlayRight, + }; + }); + + // Overlay produced; native underline painters are suppressed where it owns the mark. + expect(paint.overlayCount).toBeGreaterThan(0); + expect(paint.borderedTabCount).toBe(0); + expect(paint.textDecorationCount).toBe(0); + // Continuous: overlay spans share one y (no vertical step) and have no horizontal gap. + expect(paint.topSpreadPx).toBeLessThan(1); + if (paint.maxGapPx !== null) { + expect(paint.maxGapPx).toBeLessThan(2); + } + // Coverage: the overlay must span the full rendered content (Name -> tabs -> Value), not merely + // exist. Without this, a degenerate "one tiny overlay + suppressed native painters" would pass. + expect(paint.contentLeft).not.toBeNull(); + expect(paint.overlayLeft).not.toBeNull(); + expect(paint.overlayLeft as number).toBeLessThanOrEqual((paint.contentLeft as number) + 2); + expect(paint.overlayRight as number).toBeGreaterThanOrEqual((paint.contentRight as number) - 2); + const contentWidth = (paint.contentRight as number) - (paint.contentLeft as number); + const overlayWidth = (paint.overlayRight as number) - (paint.overlayLeft as number); + expect(overlayWidth).toBeGreaterThanOrEqual(contentWidth - 2); +});