Skip to content
7 changes: 7 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
25 changes: 25 additions & 0 deletions packages/layout-engine/measuring/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
21 changes: 19 additions & 2 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions packages/layout-engine/painters/dom/src/runs/tab-run.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
80 changes: 56 additions & 24 deletions packages/layout-engine/painters/dom/src/runs/tab-run.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Line, LineSegment, Run } from '@superdoc/contracts';
import { underlineThicknessPx } from './text-run.js';

export const renderInlineTabRun = (
run: Extract<Run, { kind: 'tab' }>,
Expand All @@ -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);
Expand Down Expand Up @@ -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';
}
Expand All @@ -73,23 +90,38 @@ export const renderPositionedTabRun = (
return { element: tabEl, tabEndX, actualTabWidth };
};

const applyTabUnderline = (tabEl: HTMLElement, run: Extract<Run, { kind: 'tab' }>): 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<Run, { kind: 'tab' }>): 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}`;
};
19 changes: 19 additions & 0 deletions packages/layout-engine/painters/dom/src/runs/text-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading