diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 9f701475b4..e0e070b9e1 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/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 3f8a79fc25..687ca48c39 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -307,7 +307,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') { 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 3da3a58807..d5f8adba15 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -875,11 +875,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.test.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts new file mode 100644 index 0000000000..3c06a9c945 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.test.ts @@ -0,0 +1,73 @@ +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 = (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', () => { + const el = renderInlineTabRun(underlinedTab(), LINE, document, 0); + + // 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(parseFloat(el.style.borderBottomWidth)).toBe(3); + }); + + 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..6192cba1d5 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, @@ -15,10 +16,21 @@ 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 (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'; + } - applyTabUnderline(tabEl, run); + applyTabUnderlineBorder(tabEl, run); if (styleId) { tabEl.setAttribute('styleid', styleId); @@ -53,12 +65,17 @@ 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). + // 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'; } @@ -73,23 +90,38 @@ export const renderPositionedTabRun = ( return { element: tabEl, tabEndX, actualTabWidth }; }; -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}`; - } +/** + * 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; +}; + +/** + * 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; + + 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}`; }; 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 be6f31e935..e26741dac0 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -16,6 +16,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'; @@ -100,6 +114,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; } diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts index ff78a5be22..59e895b179 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts @@ -834,10 +834,19 @@ export function paragraphToFlowBlocks({ } else { const run = inlineConverter(inlineConverterParams); if (run) { - currentRuns.push(run); if (node.type === 'tab') { + // A bare tab carries no font of its own, so a tab-only line would be + // measured at the 12px measuring fallback and render shorter than a + // text or empty line in the same paragraph. Give the tab the paragraph's + // resolved default font (mirroring the empty-paragraph run) so its line + // height matches (SD-3330). Explicit run properties from the DOCX still + // win — only fill when absent. + const tabRun = run as { fontSize?: number; fontFamily?: string }; + if (tabRun.fontSize == null) tabRun.fontSize = defaultSize; + if (tabRun.fontFamily == null) tabRun.fontFamily = defaultFont; tabOrdinal += 1; } + currentRuns.push(run); } } } catch (error) {