Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
29 changes: 24 additions & 5 deletions packages/layout-engine/layout-resolved/src/versionSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
7 changes: 6 additions & 1 deletion packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading