From 3803f145f1a86886ac2d6f72f025aab283de15c4 Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Mon, 7 Sep 2026 01:31:02 +0300 Subject: [PATCH] fix(layout): paint the Word 97-2003 run effects and the double strike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `w:outline`, `w:shadow`, `w:emboss` and `w:imprint` are what the Font dialog's "Effects" group writes, and Word still honours them — a document can carry them with no `w14:` counterpart anywhere. They round-tripped correctly through import and export and were then dropped: the normalizer never read them, so nothing downstream could paint them. Applying one to a selection changed the file and left the page identical. `w:dstrike` had the other half of the same problem. `normalizeRunAttrsFromOoxml` already emitted `doubleStrike`, and it reached the painter — the painter simply never read it, so a double strikethrough was drawn as a single line. - style-engine passes the four flags through as authored, including an explicit `false` (`` is how Word clears an inherited effect, and dropping the key would let a lower cascade layer switch it on). - `RunMarks` carries them, next to `strike`, for every run kind. - The DOM painter draws them, after `applyTextEffects` so it can see what an authored `w14:` effect already wrote. It stands aside for a `w14:textOutline` that actually painted a stroke, for a `w14:textFill`, and for a `w14:shadow`; it composes with a `w14:glow`, which is a different effect. The outline test reads what was written rather than whether the object exists, because `` is a present object that paints nothing. - The outline empties the glyph through `-webkit-text-fill-color`, not `color`. The stroke is `currentColor`, and clearing `color` would take the stroke with it — a run carrying `` with Automatic color, which is exactly what the Font dialog writes, would have painted nothing at all. Formatting marks opt back out of both inherited properties, so the dot inside an outlined run is not emptied with the text around it. - `doubleStrike` draws its own line: it is a separate mark and a separately settable run attribute, so a run carrying only `w:dstrike` still has a strikethrough. `render-line.ts` re-derives the decoration on the paths that lift an underline onto an overlay, so that rule is now shared rather than written twice. - The five paint-invalidation signatures count them — `textRunMergeSignature`, `hashRunVisualMarks`, `deriveBlockVersion`, `deriveParagraphBlockVersion` and `hashParagraphBlockForTableVersion`. Without those, an edit that flips only `` leaves the already-painted span on screen, and two adjacent runs differing only in one flag merge into a single span. Outline is faithful — a hairline stroke with the fill removed is what Word draws. Shadow is a hard-edged offset copy. Emboss and imprint keep the authored glyph color and put a light edge on one side and a dark edge on the other; Word instead paints the glyph near the page color, which needs a resolved page background that paint does not have, and guessing it wrong turns the text invisible. The reasoning sits in the source. CSS carries one `text-decoration-style` for the whole decoration set, so a run that is both underlined and double-struck keeps the underline's authored style and the strike stays single — except on the overlay paths, where the underline has already been lifted off the run and the double can be drawn. --- packages/layout-engine/contracts/src/index.ts | 10 + .../layout-bridge/src/run-visual-marks.ts | 15 +- .../test/run-visual-marks.test.ts | 23 ++ .../src/versionSignature.test.ts | 36 +++ .../layout-resolved/src/versionSignature.ts | 7 + .../dom/src/paragraph/block-version.ts | 13 + .../painters/dom/src/runs/hash.ts | 9 + .../painters/dom/src/runs/render-line.ts | 15 +- .../painters/dom/src/runs/text-run.test.ts | 225 +++++++++++++++++- .../painters/dom/src/runs/text-run.ts | 147 +++++++++++- .../layout-engine/painters/dom/src/styles.ts | 18 ++ .../src/normalize/normalize.test.ts | 26 ++ .../style-engine/src/normalize/run-attrs.ts | 8 + .../style-engine/src/normalize/types.ts | 4 + 14 files changed, 550 insertions(+), 6 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 23057c168d..3a6328d5eb 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -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). */ diff --git a/packages/layout-engine/layout-bridge/src/run-visual-marks.ts b/packages/layout-engine/layout-bridge/src/run-visual-marks.ts index 8c9f04757c..8b24f7b866 100644 --- a/packages/layout-engine/layout-bridge/src/run-visual-marks.ts +++ b/packages/layout-engine/layout-bridge/src/run-visual-marks.ts @@ -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; @@ -30,12 +30,25 @@ export const hashRunVisualMarks = (run: Run): string => { // detection picks up rtl-only changes; otherwise an edit that flips just // 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 `` 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}` : '', diff --git a/packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts b/packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts index 0c2e4ae9e7..4c9941eab9 100644 --- a/packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts +++ b/packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts @@ -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); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 92f3b623ef..c92bca4890 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -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', diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 358da4d9b8..0abce2ab16 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -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 : '', diff --git a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts index 3bf8630dbc..01856f0d6b 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts @@ -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 : '', @@ -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)); diff --git a/packages/layout-engine/painters/dom/src/runs/hash.ts b/packages/layout-engine/painters/dom/src/runs/hash.ts index 55db77b4fc..a0cefa9aed 100644 --- a/packages/layout-engine/painters/dom/src/runs/hash.ts +++ b/packages/layout-engine/painters/dom/src/runs/hash.ts @@ -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, 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 505d89d283..1d8221206c 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -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, @@ -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); @@ -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); diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.test.ts b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts index 6f278f2210..6832f124a8 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.test.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.test.ts @@ -4,7 +4,7 @@ import type { FragmentRenderContext } from '../renderer.js'; import type { DerivedRunTextPlane } from '../derived-run-text-plane.js'; import type { RunRenderContext } from './types.js'; import { textRunMergeSignature } from './hash.js'; -import { applyRunStyles, renderTextRun, resolveRunText } from './text-run.js'; +import { applyRunStyles, renderTextRun, resolveRunText, runStrikeDecoration } from './text-run.js'; const makeRunRenderContext = (overrides: Partial = {}): RunRenderContext => ({ @@ -172,6 +172,16 @@ describe('resolveRunText', () => { expect(textRunMergeSignature(baseRun)).not.toBe(textRunMergeSignature(effectedRun)); }); + + it('changes merge signature for each Word 97-2003 effect flag', () => { + const baseRun: TextRun = { text: 'Styled', fontFamily: 'Arial', fontSize: 12 }; + + // Neighbouring runs that differ only in one of these are not one span: + // merging them would paint the first run's effect over the second's text. + for (const mark of ['doubleStrike', 'outline', 'shadow', 'emboss', 'imprint'] as const) { + expect(textRunMergeSignature({ ...baseRun, [mark]: true })).not.toBe(textRunMergeSignature(baseRun)); + } + }); }); describe('renderTextRun', () => { @@ -283,4 +293,217 @@ describe('applyRunStyles', () => { expect(element.style.transform).toBe('scaleX(0.9)'); expect(element.style.transformOrigin).toBe('left center'); }); + + const effectRun = (marks: Partial): TextRun => ({ + text: 'Effect', + fontFamily: 'Arial', + fontSize: 16, + ...marks, + }); + + /** The two shading colors of the emboss/imprint pair, as the painter writes them. */ + const EFFECT_LIGHT_CSS = 'rgba(255, 255, 255, 0.85)'; + const EFFECT_DARK_CSS = 'rgba(0, 0, 0, 0.45)'; + + it('paints w:outline as a hollow glyph: a hairline stroke with the fill removed', () => { + const element = document.createElement('span'); + + applyRunStyles(element, effectRun({ outline: true, color: '#123456' }), false, (family) => family); + + expect(element.style.webkitTextStroke).toBe('0.67px currentColor'); + // The fill goes; the color stays, because the stroke reads currentColor from it. + expect(element.style.webkitTextFillColor).toBe('transparent'); + expect(element.style.color).toBe('#123456'); + }); + + /** + * The default path, and the one that goes invisible if the fill is cleared + * through `color`: Word's Font dialog writes the Outline effect with Automatic + * color, and `normalizeRunAttrsFromOoxml` deliberately reports Automatic as no + * color at all so paint can apply the document default. Clearing `color` here + * would leave the glyph with no fill AND a transparent stroke — blank space + * where there used to be readable text. + */ + it('keeps an outlined run visible when it carries no explicit color', () => { + const element = document.createElement('span'); + + applyRunStyles(element, effectRun({ outline: true }), false, (family) => family); + + expect(element.style.color).toBe(''); + expect(element.style.webkitTextFillColor).toBe('transparent'); + expect(element.style.webkitTextStroke).toBe('0.67px currentColor'); + }); + + it('paints w:shadow as a single offset copy behind the glyph', () => { + const element = document.createElement('span'); + + applyRunStyles(element, effectRun({ shadow: true }), false, (family) => family); + + expect(element.style.textShadow).toBe('1px 1px 0 rgba(0, 0, 0, 0.45)'); + }); + + it('separates emboss from imprint by the side the light falls on', () => { + const embossed = document.createElement('span'); + const imprinted = document.createElement('span'); + + applyRunStyles(embossed, effectRun({ emboss: true }), false, (family) => family); + applyRunStyles(imprinted, effectRun({ imprint: true }), false, (family) => family); + + expect(embossed.style.textShadow).toBe('-1px -1px 0 rgba(255, 255, 255, 0.85), 1px 1px 0 rgba(0, 0, 0, 0.45)'); + expect(imprinted.style.textShadow).toBe('1px 1px 0 rgba(255, 255, 255, 0.85), -1px -1px 0 rgba(0, 0, 0, 0.45)'); + expect(embossed.style.textShadow).not.toBe(imprinted.style.textShadow); + }); + + it('scales the effect offset and the outline stroke with the font size', () => { + const heading = document.createElement('span'); + + applyRunStyles(heading, effectRun({ fontSize: 48, outline: true, shadow: true }), false, (family) => family); + + expect(heading.style.webkitTextStroke).toBe('2px currentColor'); + expect(heading.style.textShadow).toBe('3px 3px 0 rgba(0, 0, 0, 0.45)'); + }); + + it('stands aside for an authored w14 outline on the same run', () => { + const element = document.createElement('span'); + const run = effectRun({ + outline: true, + color: '#000000', + textEffects: { outline: { width: 3, fill: '#ff0000' } }, + }); + + applyRunStyles(element, run, false, (family) => family); + + expect(element.style.webkitTextStroke).toBe('3px #ff0000'); + // And the legacy pass leaves no transparent fill behind: `w14:textOutline` + // strokes a **filled** glyph, so a leftover empty fill would turn an + // authored outline into a hollow one. + expect(element.style.webkitTextFillColor).toBeFalsy(); + }); + + it('stands aside for an authored w14 fill, which would otherwise paint into an empty glyph', () => { + const element = document.createElement('span'); + const run = effectRun({ outline: true, textEffects: { fill: '#00ff00' } }); + + applyRunStyles(element, run, false, (family) => family); + + expect(element.style.color).toBe('#00ff00'); + expect(element.style.webkitTextFillColor).toBeFalsy(); + }); + + /** + * Glow is not a shadow generation ahead of emboss — they are two different + * effects, and both are just `text-shadow` layers. Assigning rather than + * appending would let a glow silently erase the emboss on the same run. + */ + /** + * `` is what Word + * writes for "no outline": the object is present and `applyTextEffects` paints + * nothing from it. Standing aside for its mere existence would leave the run + * with neither an authored stroke nor the legacy one — blank space, which is + * the failure this whole change exists to remove. + */ + it('does not stand aside for a w14 outline that paints nothing', () => { + const element = document.createElement('span'); + const run = effectRun({ outline: true, textEffects: { outline: { width: 0, fill: null } } }); + + applyRunStyles(element, run, false, (family) => family); + + expect(element.style.webkitTextStroke).toBe('0.67px currentColor'); + expect(element.style.webkitTextFillColor).toBe('transparent'); + }); + + it('stands aside for an authored w14 shadow rather than drawing a second one', () => { + const element = document.createElement('span'); + const run = effectRun({ + shadow: true, + textEffects: { shadow: { color: { color: '#0000ff' }, direction: 45, distance: 3, blurRadius: 2 } }, + }); + + applyRunStyles(element, run, false, (family) => family); + + expect(element.style.textShadow).toContain('#0000ff'); + // One shadow, not two: the legacy flag and `w14:shadow` are the same effect. + expect(element.style.textShadow).not.toContain(EFFECT_DARK_CSS); + }); + + it('composes a legacy emboss with an authored w14 glow instead of losing one', () => { + const element = document.createElement('span'); + const run = effectRun({ emboss: true, textEffects: { glow: { color: { color: '#ff0000' }, radius: 4 } } }); + + applyRunStyles(element, run, false, (family) => family); + + const shadow = element.style.textShadow; + expect(shadow).toContain('#ff0000'); + expect(shadow).toContain(EFFECT_LIGHT_CSS); + expect(shadow).toContain(EFFECT_DARK_CSS); + }); + + it('draws w:dstrike as two lines rather than one', () => { + const element = document.createElement('span'); + + applyRunStyles(element, effectRun({ strike: true, doubleStrike: true }), false, (family) => family); + + expect(element.style.textDecorationLine).toBe('line-through'); + expect(element.style.textDecorationStyle).toBe('double'); + }); + + /** + * `doubleStrike` is its own `RunMarks` field and its own settable run + * attribute, so a run carrying only it still has a strikethrough to draw. + * Reading the line off `strike` alone would render such a run undecorated. + */ + it('draws a run that carries only doubleStrike', () => { + const element = document.createElement('span'); + + applyRunStyles(element, effectRun({ doubleStrike: true }), false, (family) => family); + + expect(element.style.textDecorationLine).toBe('line-through'); + expect(element.style.textDecorationStyle).toBe('double'); + }); + + it('keeps the authored underline style when a run is both underlined and double-struck', () => { + const element = document.createElement('span'); + const run = effectRun({ strike: true, doubleStrike: true, underline: { style: 'wavy' } }); + + applyRunStyles(element, run, false, (family) => family); + + expect(element.style.textDecorationLine).toBe('underline line-through'); + expect(element.style.textDecorationStyle).toBe('wavy'); + }); + + /** + * `render-line.ts` re-derives the strike after painting on the paths that lift + * an underline onto a line overlay. It read the line off `strike` alone, so a + * run carrying only `w:dstrike` came out undecorated there while an ordinary + * line drew it — the shared helper is what keeps the two in step. + */ + it('reports the strike for a doubleStrike-only run through the shared helper', () => { + expect(runStrikeDecoration(effectRun({ doubleStrike: true }))).toEqual({ + line: 'line-through', + style: 'double', + }); + expect(runStrikeDecoration(effectRun({ strike: true }))).toEqual({ line: 'line-through' }); + expect(runStrikeDecoration(effectRun({}))).toEqual({ line: 'none' }); + }); + + it('leaves the strike single for the helper when the element keeps its underline', () => { + // One `text-decoration-style` for the whole set: the underline's authored + // style wins, and the overlay paths get `double` because the underline has + // already been lifted off the run. + expect(runStrikeDecoration(effectRun({ strike: true, doubleStrike: true, underline: { style: 'wavy' } }))).toEqual({ + line: 'line-through', + }); + }); + + it('paints nothing extra for a run that carries no effect flag', () => { + const element = document.createElement('span'); + + applyRunStyles(element, effectRun({ strike: true }), false, (family) => family); + + expect(element.style.webkitTextStroke).toBeFalsy(); + expect(element.style.webkitTextFillColor).toBeFalsy(); + expect(element.style.textShadow).toBeFalsy(); + expect(element.style.textDecorationStyle).toBeFalsy(); + expect(element.style.color).toBe(''); + }); }); 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 0aa4e4f910..978910dfe2 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -38,6 +38,134 @@ const DEFAULT_SUBSCRIPT_LOWER_RATIO = 0.14; */ export const underlineThicknessPx = (fontSize: number): number => Math.max(1, Math.round(fontSize / 14)); +/** + * Stroke weight for `w:outline`, scaled to font size. + * + * Word draws the legacy outline as a hairline around the glyph, not as the + * authored-width stroke `w14:textOutline` carries. The divisor keeps it hairline + * at body sizes and lets it grow with display type; the floor keeps it visible + * on a low-density screen, where a sub-half-pixel stroke rounds away to nothing. + */ +const outlineStrokePx = (fontSize: number): number => Math.max(0.5, Math.round((fontSize / 24) * 100) / 100); + +/** + * Offset for the `w:shadow` / `w:emboss` / `w:imprint` shading, scaled to font size. + * + * One device pixel at body size, matching the offset Word draws, and growing + * with the glyph so a heading does not carry a shadow that reads as a fringe. + */ +const effectOffsetPx = (fontSize: number): number => Math.max(1, Math.round(fontSize / 16)); + +/** Highlight side of the emboss/imprint pair. */ +const EFFECT_LIGHT = 'rgba(255, 255, 255, 0.85)'; +/** Shaded side of the emboss/imprint pair, and the color of a legacy drop shadow. */ +const EFFECT_DARK = 'rgba(0, 0, 0, 0.45)'; + +/** + * The Word 97-2003 run effects: `w:outline`, `w:shadow`, `w:emboss`, `w:imprint`. + * + * These predate the `w14:` effect family and Word still honours them — they are + * exactly what the Font dialog's "Effects" group writes, so a document can carry + * them with no `w14:` counterpart anywhere. Word draws them inside its typography + * engine, so CSS can only approximate; that is the same trade `applyTextEffects` + * already makes for `w14:glow`, and it is stated rather than hidden: + * + * - **outline** is faithful — a hairline stroke with the fill removed is what + * Word draws, and `-webkit-text-stroke` draws exactly that. + * - **shadow** is a hard-edged offset copy, as in Word; the blur Word applies at + * large sizes is not reproduced. + * - **emboss / imprint** keep the authored glyph color and add a light edge on + * one side and a dark edge on the other, the direction being what separates + * the two. Word instead paints the glyph itself near the page color and lets + * the edges carry the shape. Doing that here would need the resolved page + * background, which paint does not have — and guessing it wrong turns the + * text invisible. Legibility wins over the last of the fidelity. + * + * Paint-only: no metric changes, so a run that gains an effect does not reflow. + * Called **after** `applyTextEffects`, so this function sees what the authored + * `w14:` effects already wrote and can compose with them or stand aside — see + * the two comments inside. + */ +const applyLegacyRunEffects = (element: HTMLElement, run: TextRun): void => { + /* + * The newer `w14:textOutline` / `w14:textFill` are the authored form of the + * same intent, so the legacy flag steps aside for them explicitly rather than + * being overwritten by declaration order — order would have left this + * function's empty fill behind on a run whose `w14:textOutline` carries no + * fill of its own, turning an authored outline into a hollow glyph. + * + * The test is **what `applyTextEffects` actually wrote**, not whether the + * object exists. `applyTextEffects` paints a stroke only for an outline that + * has a solid fill and a positive width, so + * `` — what Word + * writes for "no outline" — is a present object that paints nothing. Standing + * aside for it would leave the run with no stroke and no legacy fallback: + * blank space, which is the failure this whole change exists to remove. + */ + // `Boolean(...)` and not `!== ''`: an unset property reads back as `''` in a + // browser but as `undefined` in a DOM that does not implement the prefixed + // property, and both mean "nothing was painted". + const authoredOutline = Boolean(element.style.webkitTextStroke) || run.textEffects?.fill !== undefined; + if (run.outline && !authoredOutline) { + /* + * `-webkit-text-fill-color`, and deliberately **not** `color`. + * + * The stroke is `currentColor`, and `currentColor` resolves against the + * element's own `color`. Emptying the glyph through `color: transparent` + * would take the stroke with it, and a run carrying `` with + * Automatic color — which is exactly what Word's Font dialog writes — would + * paint nothing at all. `-webkit-text-fill-color` empties only the fill and + * leaves `color` intact for the stroke to read. + */ + element.style.webkitTextStroke = `${outlineStrokePx(run.fontSize)}px currentColor`; + element.style.webkitTextFillColor = 'transparent'; + } + + const offset = effectOffsetPx(run.fontSize); + const shadows: string[] = []; + /* + * `w14:shadow` is the authored form of this same drop shadow, so the legacy + * flag steps aside for it — exactly as the outline does above. Emboss and + * imprint have no `w14:` counterpart at all and are not drop shadows, so they + * keep composing with whatever `applyTextEffects` wrote. + */ + if (run.shadow && run.textEffects?.shadow == null) shadows.push(`${offset}px ${offset}px 0 ${EFFECT_DARK}`); + if (run.emboss) + shadows.push(`-${offset}px -${offset}px 0 ${EFFECT_LIGHT}`, `${offset}px ${offset}px 0 ${EFFECT_DARK}`); + if (run.imprint) + shadows.push(`${offset}px ${offset}px 0 ${EFFECT_LIGHT}`, `-${offset}px -${offset}px 0 ${EFFECT_DARK}`); + if (shadows.length > 0) { + /* + * Appended, not assigned: `w14:glow` is a different effect from an embossed + * edge and the two compose as two shadow layers, exactly as glow and + * `w14:shadow` already do inside `applyTextEffects`. Assigning here would + * make a glow silently erase the emboss on the same run. + */ + const authored = element.style.textShadow; + element.style.textShadow = authored ? `${authored}, ${shadows.join(', ')}` : shadows.join(', '); + } +}; + +/** + * The strike a run's own element should carry, and its line style. + * + * Shared because `render-line.ts` re-derives the decoration after painting on + * the paths where an underline is lifted onto a line overlay — and a second + * copy of the rule there would drift from this one. It already had: reading the + * line off `strike` alone left a run carrying only `w:dstrike` undecorated on + * exactly those lines. + * + * The style is `double` whenever the element itself carries no underline. On the + * overlay paths that is always true — the underline was moved off the run — so a + * run that is both underlined and double-struck gets a genuine double strike + * there, while on an ordinary line CSS's single `text-decoration-style` forces + * the underline's style to win and the strike stays single. + */ +export const runStrikeDecoration = (run: TextRun): { line: 'line-through' | 'none'; style?: 'double' } => { + if (!run.strike && !run.doubleStrike) return { line: 'none' }; + return run.doubleStrike && !run.underline ? { line: 'line-through', style: 'double' } : { line: 'line-through' }; +}; + const hasVerticalPositioning = (run: TextRun): boolean => normalizeBaselineShift(run.baselineShift) != null || run.vertAlign === 'superscript' || run.vertAlign === 'subscript'; @@ -153,6 +281,7 @@ export const applyRunStyles = ( element.style.textTransform = run.textTransform; } applyTextEffects(element, run.textEffects); + applyLegacyRunEffects(element, run); // Apply text decorations from the run. Even for links, inline decorations should reflect // the document styling (tests assert underline presence on anchors). @@ -170,11 +299,27 @@ export const applyRunStyles = ( element.style.textDecorationColor = u.color; } } - if (run.strike) { + // `doubleStrike` stands on its own: it is a separate `RunMarks` field and a + // separately settable run attribute, so a run that carries only `w:dstrike` + // still has a strikethrough to draw. + if (run.strike || run.doubleStrike) { decorations.push('line-through'); } if (decorations.length > 0) { element.style.textDecorationLine = decorations.join(' '); + /* + * `w:dstrike` is two lines rather than one. CSS carries a single + * `text-decoration-style` for the whole decoration set, so a run that is + * both underlined and double-struck can keep only one of the two: the + * underline already claimed it above, and it claimed it from an authored + * value (`w:u/@w:val`), while `w:dstrike` has no style to lose. So the + * underline keeps its style and the strike stays single — a visible + * shortfall on a rare combination, rather than a wrong underline on a + * common one. + */ + if (run.doubleStrike && !run.underline) { + element.style.textDecorationStyle = 'double'; + } } }; diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index e42892daa5..539aa4ddef 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -758,6 +758,15 @@ const FORMATTING_MARKS_STYLES = ` top: 50%; transform: translate(-50%, -50%); color: var(--sd-formatting-mark-color, var(--sd-ui-action, currentColor)); + /* + * A formatting mark is chrome, not document text, so it opts out of the two + * inherited properties a run with w:outline sets on itself: the emptied + * fill and the glyph stroke. Both inherit, and -webkit-text-fill-color + * beats color for the fill — without these, the dot inside an outlined run + * would render as a hairline ring, or as nothing at all. + */ + -webkit-text-fill-color: currentColor; + -webkit-text-stroke: 0; font-size: 0.75em; line-height: 1; pointer-events: none; @@ -775,6 +784,15 @@ const FORMATTING_MARKS_STYLES = ` top: 50%; transform: translate(-50%, -50%); color: var(--sd-formatting-mark-color, var(--sd-ui-action, currentColor)); + /* + * A formatting mark is chrome, not document text, so it opts out of the two + * inherited properties a run with w:outline sets on itself: the emptied + * fill and the glyph stroke. Both inherit, and -webkit-text-fill-color + * beats color for the fill — without these, the dot inside an outlined run + * would render as a hairline ring, or as nothing at all. + */ + -webkit-text-fill-color: currentColor; + -webkit-text-stroke: 0; font-size: 0.75em; line-height: 1; pointer-events: none; diff --git a/packages/layout-engine/style-engine/src/normalize/normalize.test.ts b/packages/layout-engine/style-engine/src/normalize/normalize.test.ts index 959b264391..fba3fa4eed 100644 --- a/packages/layout-engine/style-engine/src/normalize/normalize.test.ts +++ b/packages/layout-engine/style-engine/src/normalize/normalize.test.ts @@ -149,6 +149,32 @@ describe('normalizeRunAttrsFromOoxml', () => { }); }); + it('carries the Word 97-2003 effect flags through to the paint contract', () => { + expect(normalizeRunAttrsFromOoxml({ outline: true, shadow: true, emboss: true, imprint: true })).toMatchObject({ + outline: true, + shadow: true, + emboss: true, + imprint: true, + }); + }); + + it('preserves an explicitly cleared effect flag rather than dropping it', () => { + // `` is how Word turns an inherited effect off, and a + // dropped key would let the style layer below switch it back on. + const attrs = normalizeRunAttrsFromOoxml({ outline: false, shadow: false, emboss: false, imprint: false }); + + expect(attrs).toMatchObject({ outline: false, shadow: false, emboss: false, imprint: false }); + }); + + it('leaves the effect flags absent when the run does not carry them', () => { + const attrs = normalizeRunAttrsFromOoxml({ bold: true }); + + expect(attrs.outline).toBeUndefined(); + expect(attrs.shadow).toBeUndefined(); + expect(attrs.emboss).toBeUndefined(); + expect(attrs.imprint).toBeUndefined(); + }); + it('normalizes decimal and percent OOXML character-width values and rejects invalid values', () => { expect(normalizeRunAttrsFromOoxml({ w: '90' }).horizontalScale).toBe(0.9); expect(normalizeRunAttrsFromOoxml({ w: '125%' }).horizontalScale).toBe(1.25); diff --git a/packages/layout-engine/style-engine/src/normalize/run-attrs.ts b/packages/layout-engine/style-engine/src/normalize/run-attrs.ts index 4c152e30cd..e10b08b311 100644 --- a/packages/layout-engine/style-engine/src/normalize/run-attrs.ts +++ b/packages/layout-engine/style-engine/src/normalize/run-attrs.ts @@ -67,6 +67,14 @@ export function normalizeRunAttrsFromOoxml( out.strike = props.strike === true || props.dstrike === true; } if (props.dstrike != null) out.doubleStrike = props.dstrike === true; + // The Word 97-2003 effect flags. They predate the `w14:` effect family, Word + // still honours them, and they are what the Font dialog's "Effects" group + // writes — so a document can carry them with no `w14:` counterpart at all. + // Passed through as authored; the painter owns how they are approximated. + if (props.outline != null) out.outline = props.outline === true; + if (props.shadow != null) out.shadow = props.shadow === true; + if (props.emboss != null) out.emboss = props.emboss === true; + if (props.imprint != null) out.imprint = props.imprint === true; if (props.textTransform) { out.textTransform = props.textTransform; out.allCaps = props.textTransform === 'uppercase'; diff --git a/packages/layout-engine/style-engine/src/normalize/types.ts b/packages/layout-engine/style-engine/src/normalize/types.ts index b7c7c4ff38..11ef859577 100644 --- a/packages/layout-engine/style-engine/src/normalize/types.ts +++ b/packages/layout-engine/style-engine/src/normalize/types.ts @@ -28,6 +28,10 @@ export interface TextRunStyleAttrs { underline?: NonNullable; strike?: boolean; doubleStrike?: boolean; + outline?: boolean; + shadow?: boolean; + emboss?: boolean; + imprint?: boolean; color?: string; highlight?: string; textTransform?: TextRun['textTransform'];