Skip to content
Open
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
10 changes: 10 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,16 @@ export type RunMarks = {
} | null;
/** Strikethrough text decoration. */
strike?: boolean;
/** Word `w:dstrike`: the strikethrough is drawn as two lines instead of one. */
doubleStrike?: boolean;
/** Word `w:outline`: glyphs are drawn as an outline with no fill. */
outline?: boolean;
/** Word `w:shadow`: a drop shadow is drawn behind the glyphs. */
shadow?: boolean;
/** Word `w:emboss`: glyphs are shaded to look raised out of the page. */
emboss?: boolean;
/** Word `w:imprint`: glyphs are shaded to look pressed into the page. */
imprint?: boolean;
/** Highlight (background) color as hex string. */
highlight?: string;
/** Text transformation (case modification). */
Expand Down
15 changes: 14 additions & 1 deletion packages/layout-engine/layout-bridge/src/run-visual-marks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { Run } from '@superdoc/contracts';
* not by this run-scoped helper.
*
* @param run - Flow run (text, tab, image, etc.); unknown fields are ignored safely.
* @returns Stable string encoding bold/italic/underline/strike/color/font/highlight/link.
* @returns Stable string encoding every visual mark listed in the body below.
*/
export const hashRunVisualMarks = (run: Run): string => {
const bold = 'bold' in run ? run.bold : false;
Expand All @@ -30,12 +30,25 @@ export const hashRunVisualMarks = (run: Run): string => {
// detection picks up rtl-only changes; otherwise an edit that flips just
// <w:rtl/> could reuse stale measure/DOM.
const bidi = 'bidi' in run ? run.bidi : undefined;
// The Word 97-2003 effect flags and the double strikethrough. Paint-only, but
// dirty-run detection is what decides whether the painted DOM is reused: an
// edit that flips just `<w:emboss/>` would otherwise keep the old span.
const doubleStrike = 'doubleStrike' in run ? run.doubleStrike : false;
const outline = 'outline' in run ? run.outline : false;
const shadow = 'shadow' in run ? run.shadow : false;
const emboss = 'emboss' in run ? run.emboss : false;
const imprint = 'imprint' in run ? run.imprint : false;

return [
bold ? 'b' : '',
italic ? 'i' : '',
underline ? `u:${JSON.stringify(underline)}` : '',
strike ? 's' : '',
doubleStrike ? 'ds' : '',
outline ? 'ol' : '',
shadow ? 'sh' : '',
emboss ? 'em' : '',
imprint ? 'im' : '',
color ?? '',
fontSize !== undefined ? `fs:${fontSize}` : '',
fontFamily ? `ff:${fontFamily}` : '',
Expand Down
23 changes: 23 additions & 0 deletions packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,27 @@ describe('hashRunVisualMarks', () => {
expect(a).toBe(b);
});
});
/**
* This hash is what dirty-run detection compares, so a paint-only mark that
* does not move it leaves the already-painted span on screen. For the Word
* 97-2003 effects that is the exact symptom they were added to fix — the file
* changes and the page does not — reappearing one layer up.
*/
describe('Word 97-2003 effect flags', () => {
const base = { text: 'Styled', fontFamily: 'Arial', fontSize: 12 } as Run;

it('produces a different hash for each flag', () => {
for (const mark of ['doubleStrike', 'outline', 'shadow', 'emboss', 'imprint'] as const) {
expect(hashRunVisualMarks({ ...base, [mark]: true } as Run)).not.toBe(hashRunVisualMarks(base));
}
});

it('distinguishes the flags from one another', () => {
const hashes = (['doubleStrike', 'outline', 'shadow', 'emboss', 'imprint'] as const).map((mark) =>
hashRunVisualMarks({ ...base, [mark]: true } as Run),
);

expect(new Set(hashes).size).toBe(hashes.length);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,42 @@ describe('deriveBlockVersion - horizontal scale', () => {
});
});

describe('deriveBlockVersion - Word 97-2003 run effects', () => {
const makeParagraph = (mark?: 'doubleStrike' | 'outline' | 'shadow' | 'emboss' | 'imprint'): FlowBlock => ({
kind: 'paragraph',
id: 'effect-paragraph',
attrs: {},
runs: [
{
text: 'Styled',
fontFamily: 'Arial',
fontSize: 16,
...(mark ? { [mark]: true } : {}),
} as TextRun,
],
});

/**
* This version is what the painter reuses a fragment by. These flags are
* paint-only, which is exactly why they are easy to leave out — and leaving
* them out means applying one changes the file and not the page, the very
* symptom they were added to fix.
*/
it('invalidates the block version for each effect flag', () => {
for (const mark of ['doubleStrike', 'outline', 'shadow', 'emboss', 'imprint'] as const) {
expect(deriveBlockVersion(makeParagraph(mark))).not.toBe(deriveBlockVersion(makeParagraph()));
}
});

it('gives each flag its own version — two effects are not one state', () => {
const versions = (['outline', 'shadow', 'emboss', 'imprint'] as const).map((mark) =>
deriveBlockVersion(makeParagraph(mark)),
);

expect(new Set(versions).size).toBe(versions.length);
});
});

describe('deriveBlockVersion - nested SDT containers', () => {
const childSdt = {
type: 'structuredContent',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
textRun.underline?.style ?? '',
textRun.underline?.color ?? '',
textRun.strike ? 1 : 0,
// The Word 97-2003 effect flags and the double strikethrough: paint-only,
// but this version is what the painter reuses a fragment by.
textRun.doubleStrike ? 1 : 0,
textRun.outline ? 1 : 0,
textRun.shadow ? 1 : 0,
textRun.emboss ? 1 : 0,
textRun.imprint ? 1 : 0,
textRun.highlight ?? '',
textRun.letterSpacing != null ? textRun.letterSpacing : '',
textRun.horizontalScale != null ? textRun.horizontalScale : '',
Expand Down
13 changes: 13 additions & 0 deletions packages/layout-engine/painters/dom/src/paragraph/block-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ export const deriveParagraphBlockVersion = (
textRun.underline?.style ?? '',
textRun.underline?.color ?? '',
textRun.strike ? 1 : 0,
// The Word 97-2003 effect flags and the double strikethrough: paint-only,
// but this version is what decides whether the block is repainted.
textRun.doubleStrike ? 1 : 0,
textRun.outline ? 1 : 0,
textRun.shadow ? 1 : 0,
textRun.emboss ? 1 : 0,
textRun.imprint ? 1 : 0,
textRun.highlight ?? '',
textRun.letterSpacing != null ? textRun.letterSpacing : '',
textRun.horizontalScale != null ? textRun.horizontalScale : '',
Expand Down Expand Up @@ -270,6 +277,12 @@ export const hashParagraphBlockForTableVersion = (
hash = hashString(hash, getRunUnderlineStyle(run));
hash = hashString(hash, getRunUnderlineColor(run));
hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : '');
// The Word 97-2003 effect flags and the double strikethrough, for the same
// reason `strike` is here and in the sibling version above: they are what
// the run paints, and a version that cannot see them reuses the old cell.
for (const mark of ['doubleStrike', 'outline', 'shadow', 'emboss', 'imprint'] as const) {
hash = hashString(hash, getRunBooleanProp(run, mark) ? '1' : '');
}
hash = hashString(hash, getRunStringProp(run, 'vertAlign'));
hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift'));
hash = hashString(hash, trackedChangeVersion(run as TextRun));
Expand Down
9 changes: 9 additions & 0 deletions packages/layout-engine/painters/dom/src/runs/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ export const textRunMergeSignature = (run: TextRun): string =>
color: run.color ?? null,
underline: run.underline ?? null,
strike: run.strike ?? false,
// The legacy effect flags belong in the merge signature for the same reason
// `strike` does: two neighbouring runs that differ only in one of them are
// not the same span, and merging them would paint one run's effect over the
// other's text.
doubleStrike: run.doubleStrike ?? false,
outline: run.outline ?? false,
shadow: run.shadow ?? false,
emboss: run.emboss ?? false,
imprint: run.imprint ?? false,
highlight: run.highlight ?? null,
textTransform: run.textTransform ?? null,
textEffects: run.textEffects ?? null,
Expand Down
15 changes: 12 additions & 3 deletions packages/layout-engine/painters/dom/src/runs/render-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { appendFormattingParagraphMark } from './formatting-marks.js';
import { textRunMergeSignature } from './hash.js';
import { inlineBoxAdvanceBeforeOffset, markInlineBoxRun, paintInlineBoxes, splitInlineBoxRuns } from './inline-box.js';
import { isBreakRun, isFieldAnnotationRun, isImageRun, isLineBreakRun, isMathRun, renderRun } from './render-run.js';
import { applyRunTypographyStyles } from './text-run.js';
import { applyRunTypographyStyles, runStrikeDecoration } from './text-run.js';
import {
canPaintUnderlineOverlay,
renderInlineTabRun,
Expand Down Expand Up @@ -1157,7 +1157,11 @@ const renderExplicitlyPositionedRuns = ({
const elem = renderRun(segmentRun, context, runContext, trackedConfig);
if (elem) {
if (coveredByOverlay) {
elem.style.textDecorationLine = segmentRun.strike ? 'line-through' : 'none';
// Shared with the painter so a doubleStrike-only run keeps its line here
// too — see runStrikeDecoration.
const strike = runStrikeDecoration(segmentRun);
elem.style.textDecorationLine = strike.line;
if (strike.style) elem.style.textDecorationStyle = strike.style;
}
if (styleId) {
elem.setAttribute('styleid', styleId);
Expand Down Expand Up @@ -1286,7 +1290,12 @@ const renderInlineRuns = ({

if (elem) {
if (suppressUnderline && run.kind !== 'tab') {
elem.style.textDecorationLine = 'strike' in runForRender && runForRender.strike ? 'line-through' : 'none';
const strike =
'strike' in runForRender || 'doubleStrike' in runForRender
? runStrikeDecoration(runForRender as TextRun)
: { line: 'none' as const };
elem.style.textDecorationLine = strike.line;
if (strike.style) elem.style.textDecorationStyle = strike.style;
}
if (styleId) {
elem.setAttribute('styleid', styleId);
Expand Down
Loading
Loading