diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 075369404d..4ee7c1d164 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -121,6 +121,18 @@ export type FieldAnnotationMetadata = { export type StructuredContentLockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; +/** + * Visual chrome / labelling behavior of an SDT, mirroring + * `` (ECMA-376 §17.5.2.6 / OOXML 2010+). + * + * - `'boundingBox'` (default): visible chrome around the SDT content. + * - `'tags'`: tags-only mode (start/end markers). + * - `'hidden'`: no chrome at all; the SDT exists in the document but is + * visually transparent. The alias label MUST NOT leak into the rendered + * DOM textContent (a11y / copy-paste behavior). + */ +export type StructuredContentAppearance = 'boundingBox' | 'tags' | 'hidden'; + export type StructuredContentMetadata = { type: 'structuredContent'; scope: 'inline' | 'block'; @@ -128,6 +140,8 @@ export type StructuredContentMetadata = { tag?: string | null; alias?: string | null; lockMode?: StructuredContentLockMode; + /** Appearance from the SDT's `` element, when present. */ + appearance?: StructuredContentAppearance; sdtPr?: unknown; }; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 828c5fd685..023e4e5e40 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2694,6 +2694,87 @@ describe('DomPainter', () => { expect(wrapper.textContent).toContain('controlled text'); }); + it('omits chrome and alias label when inline SDT appearance is hidden (SD-3110)', () => { + // ECMA-376 `` should render the + // SDT transparently: no padding/border/label, and the alias text + // MUST NOT appear in DOM textContent (copy-paste / screen reader + // leak otherwise). + const block: FlowBlock = { + kind: 'paragraph', + id: 'inline-sc-hidden', + runs: [ + { text: 'See ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 4 }, + { + text: 'Alpha Corp v. SEC', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 21, + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'sc-hidden-1', + tag: 'citation', + alias: 'Harvey citation', + appearance: 'hidden', + }, + }, + { text: ' today.', fontFamily: 'Arial', fontSize: 16, pmStart: 21, pmEnd: 28 }, + ], + attrs: {}, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 2, toChar: 7, width: 200, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'inline-sc-hidden', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 0, + pmEnd: 28, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const wrapper = mount.querySelector( + '.superdoc-structured-content-inline[data-sdt-id="sc-hidden-1"]', + ) as HTMLElement | null; + expect(wrapper).toBeTruthy(); + if (!wrapper) return; + + // data-appearance="hidden" is the hook CSS uses to drop chrome. + expect(wrapper.dataset.appearance).toBe('hidden'); + + // No alias label child — must not be in the DOM at all. + expect(wrapper.querySelector('.superdoc-structured-content-inline__label')).toBeNull(); + + // textContent of the wrapper must equal exactly the wrapped phrase, + // with no alias text leaked in. + expect(wrapper.textContent).toBe('Alpha Corp v. SEC'); + expect(wrapper.textContent).not.toContain('Harvey citation'); + }); + it('keeps inline SDT wrapper font-size in sync when run font-size changes', () => { const block: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index a9903dde5e..52a71baa86 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -7308,12 +7308,29 @@ export class DomPainter { /** * Create an inline SDT wrapper `` with className, layoutEpoch, dataset, and label. * Shared by both the geometry and run-based rendering paths. + * + * When the SDT's `appearance` is `'hidden'` (matching ECMA-376 + * ``), the wrapper is rendered + * transparently: chrome is suppressed via `data-appearance="hidden"` + * (see styles.ts) and the alias label is omitted entirely. Without the + * latter, the alias text leaks into the rendered DOM `textContent` + * (copy-paste includes it) and screen readers announce it. */ private createInlineSdtWrapper(sdt: SdtMetadata): HTMLElement { const wrapper = this.doc!.createElement('span'); wrapper.className = DOM_CLASS_NAMES.INLINE_SDT_WRAPPER; wrapper.dataset.layoutEpoch = String(this.layoutEpoch); this.applySdtDataset(wrapper, sdt); + + const appearance = + sdt.type === 'structuredContent' ? (sdt as { appearance?: string }).appearance : undefined; + if (appearance === 'hidden') { + wrapper.dataset.appearance = 'hidden'; + // No alias label and no chrome: see CSS rule keyed off + // `[data-appearance="hidden"]`. + return wrapper; + } + const alias = (sdt as { alias?: string })?.alias || 'Inline content'; const labelEl = this.doc!.createElement('span'); labelEl.className = `${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`; diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 15340458bf..cd916debff 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -642,13 +642,38 @@ const SDT_CONTAINER_STYLES = ` display: none; } +/* Hidden appearance per ECMA-376 (w15:appearance val="hidden"). SDT + * exists in the document for anchoring but is visually transparent: no + * padding, no border, no hover background, no selected outline. The + * alias label is not emitted into the DOM at all (see renderer.ts), so + * there is nothing to hide from copy-paste or screen readers. */ +.superdoc-structured-content-inline[data-appearance='hidden'] { + padding: 0; + border: none; + border-radius: 0; +} +.superdoc-structured-content-inline[data-appearance='hidden']:hover { + background-color: transparent; + border: none; +} +.superdoc-structured-content-inline[data-appearance='hidden'].ProseMirror-selectednode { + border-color: transparent; + background-color: transparent; +} + /* Hover highlight for SDT containers. * Hover adds background highlight and z-index boost. * Block SDTs use .sdt-group-hover class (event delegation for multi-fragment coordination). * Inline SDTs use :hover (single element, no coordination needed). - * Hover is suppressed when the node is selected (SD-1584). */ + * Hover is suppressed when the node is selected (SD-1584). + * + * Inline SDTs with appearance=hidden are excluded via the same :not() + * that handles selection. Both predicates live in one :not(a, b) so the + * selector keeps (0,4,0) specificity. A second chained :not() would push + * it to (0,5,0) and beat the viewing-mode suppression rule below, which + * also sits at (0,4,0). */ .superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode), -.superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode) { +.superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode, [data-appearance='hidden']) { background-color: var(--sd-content-controls-lock-hover-bg, rgba(98, 155, 231, 0.08)); z-index: 9999999; } diff --git a/packages/layout-engine/style-engine/src/index.test.ts b/packages/layout-engine/style-engine/src/index.test.ts index 1188a62646..e5fbd933a9 100644 --- a/packages/layout-engine/style-engine/src/index.test.ts +++ b/packages/layout-engine/style-engine/src/index.test.ts @@ -87,6 +87,35 @@ describe('resolveSdtMetadata', () => { }); }); + it('carries appearance through for inline structured content (SD-3110)', () => { + const metadata = resolveSdtMetadata({ + nodeType: 'structuredContent', + attrs: { id: '7', tag: 'citation', alias: 'Harvey citation', appearance: 'hidden' }, + }); + expect(metadata).toMatchObject({ + type: 'structuredContent', + scope: 'inline', + appearance: 'hidden', + }); + }); + + it('drops unknown appearance values rather than letting them flow to the renderer', () => { + const metadata = resolveSdtMetadata({ + nodeType: 'structuredContent', + attrs: { id: '8', tag: 'x', appearance: 'malformed' }, + }); + expect(metadata).toMatchObject({ type: 'structuredContent', scope: 'inline' }); + expect((metadata as { appearance?: string }).appearance).toBeUndefined(); + }); + + it('omits appearance when the source attr is missing', () => { + const metadata = resolveSdtMetadata({ + nodeType: 'structuredContent', + attrs: { id: '9', tag: 'x' }, + }); + expect((metadata as { appearance?: string }).appearance).toBeUndefined(); + }); + it('normalizes document section metadata', () => { const metadata = resolveSdtMetadata({ nodeType: 'documentSection', diff --git a/packages/layout-engine/style-engine/src/index.ts b/packages/layout-engine/style-engine/src/index.ts index 419d2424ba..7fa06ce00e 100644 --- a/packages/layout-engine/style-engine/src/index.ts +++ b/packages/layout-engine/style-engine/src/index.ts @@ -241,7 +241,7 @@ function normalizeStructuredContentMetadata( nodeType: 'structuredContent' | 'structuredContentBlock', attrs: Record, ): StructuredContentMetadata { - return { + const metadata: StructuredContentMetadata = { type: 'structuredContent', scope: nodeType === 'structuredContentBlock' ? 'block' : 'inline', id: toNullableString(attrs.id), @@ -250,6 +250,14 @@ function normalizeStructuredContentMetadata( lockMode: attrs.lockMode as StructuredContentMetadata['lockMode'], sdtPr: attrs.sdtPr, }; + // `appearance` comes from the SDT's element on import. + // Only the three spec-defined values flow through; anything else is + // discarded so a bad value doesn't poison rendering decisions. + const rawAppearance = toOptionalString(attrs.appearance); + if (rawAppearance === 'boundingBox' || rawAppearance === 'tags' || rawAppearance === 'hidden') { + metadata.appearance = rawAppearance; + } + return metadata; } function normalizeDocumentSectionMetadata(attrs: Record): DocumentSectionMetadata { diff --git a/tests/behavior/tests/sdt/fixtures/sd-3110-inline-sdt-appearance-variants.docx b/tests/behavior/tests/sdt/fixtures/sd-3110-inline-sdt-appearance-variants.docx new file mode 100644 index 0000000000..b340deb809 Binary files /dev/null and b/tests/behavior/tests/sdt/fixtures/sd-3110-inline-sdt-appearance-variants.docx differ diff --git a/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts b/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts new file mode 100644 index 0000000000..bfed3b0803 --- /dev/null +++ b/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts @@ -0,0 +1,138 @@ +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, 'fixtures/sd-3110-inline-sdt-appearance-variants.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +// The fixture has five paragraphs; we keep the wrapper-by-sdtId mapping +// explicit because it's the contract this spec asserts against. +const HIDDEN_IDS = ['1001', '1004', '1005'] as const; +const VISIBLE_IDS = ['1002', '1003'] as const; // boundingBox + omitted (default) +const HIDDEN_ALIAS_CANARIES = [ + 'HIDDEN_ALIAS_LEAK_CANARY', + 'HIDDEN_ALIAS_DOUBLE_A', + 'HIDDEN_ALIAS_DOUBLE_B', +] as const; + +const INLINE_SDT = '.superdoc-structured-content-inline'; +const INLINE_LABEL = '.superdoc-structured-content-inline__label'; + +test.describe('inline SDT appearance=hidden (SD-3110)', () => { + test.beforeEach(async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + }); + + test('hidden wrappers carry data-appearance="hidden" and visible ones do not', async ({ superdoc }) => { + const attrs = await superdoc.page.evaluate((sel) => { + return Array.from(document.querySelectorAll(sel)).map((el) => ({ + sdtId: (el as HTMLElement).dataset.sdtId ?? null, + appearance: (el as HTMLElement).dataset.appearance ?? null, + })); + }, INLINE_SDT); + + const byId = new Map(attrs.map((a) => [a.sdtId, a.appearance])); + for (const id of HIDDEN_IDS) expect(byId.get(id)).toBe('hidden'); + for (const id of VISIBLE_IDS) expect(byId.get(id)).toBeNull(); + }); + + test('hidden wrappers have no alias label child; visible wrappers do', async ({ superdoc }) => { + const labelPresence = await superdoc.page.evaluate( + ({ sel, labelSel }) => { + return Array.from(document.querySelectorAll(sel)).map((el) => ({ + sdtId: (el as HTMLElement).dataset.sdtId ?? null, + hasLabel: !!el.querySelector(labelSel), + })); + }, + { sel: INLINE_SDT, labelSel: INLINE_LABEL }, + ); + + const byId = new Map(labelPresence.map((a) => [a.sdtId, a.hasLabel])); + for (const id of HIDDEN_IDS) expect(byId.get(id)).toBe(false); + for (const id of VISIBLE_IDS) expect(byId.get(id)).toBe(true); + }); + + test('hidden wrappers omit the alias canary from textContent', async ({ superdoc }) => { + const textByIdRaw = await superdoc.page.evaluate((sel) => { + return Array.from(document.querySelectorAll(sel)).map((el) => ({ + sdtId: (el as HTMLElement).dataset.sdtId ?? null, + text: el.textContent ?? '', + })); + }, INLINE_SDT); + const textById = new Map(textByIdRaw.map((a) => [a.sdtId, a.text])); + + expect(textById.get('1001')).toBe('Alpha Corp v. SEC'); + expect(textById.get('1004')).toBe('first hidden span'); + expect(textById.get('1005')).toBe('second hidden span'); + + // Visible wrappers still surface the alias as a label — that's the + // pre-existing boundingBox/default behavior. + expect(textById.get('1002')).toContain('VISIBLE_ALIAS_FOR_COMPARISON'); + expect(textById.get('1003')).toContain('DEFAULT_APPEARANCE_ALIAS'); + }); + + test('no hidden-SDT alias canary appears anywhere in the painted layout', async ({ superdoc }) => { + const layoutText = await superdoc.page.evaluate(() => { + // .presentation-editor__pages is the painter-dom root; selection, + // copy, and visual reads operate on it. + const root = + document.querySelector('.presentation-editor__pages') ?? + document.querySelector('.superdoc-layout'); + return root?.textContent ?? ''; + }); + + for (const canary of HIDDEN_ALIAS_CANARIES) { + expect(layoutText).not.toContain(canary); + } + }); + + test('hovering a hidden wrapper does not paint the lock-hover background or boost z-index', async ({ superdoc }) => { + // Regression guard for the CSS specificity bug caught in PR review: + // the lock-hover rule + // .superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode) + // has (0,4,0) specificity vs (0,3,0) for the hidden-appearance hover + // rule, so without an explicit :not([data-appearance='hidden']) it + // re-introduces the lock-hover blue background + z-index 9999999 on + // hover, contradicting "visually transparent". + // Painter may emit more than one wrapper for the same SDT when the run + // is split across lines/fragments — each fragment carries the same + // data-sdt-id. Scope to the painter class and take `.first()`: the + // CSS specificity bug is per-element, so a single wrapper is enough. + const wrapper = superdoc.page + .locator('.superdoc-structured-content-inline[data-sdt-id="1001"]') + .first(); + await wrapper.hover(); + await superdoc.waitForStable(); + + const styles = await wrapper.evaluate((el) => { + const cs = getComputedStyle(el); + return { backgroundColor: cs.backgroundColor, zIndex: cs.zIndex }; + }); + + // Default backgrounds on most browsers are transparent / rgba(0, 0, 0, 0); + // the regression value is rgba(98, 155, 231, 0.08). + expect(styles.backgroundColor).not.toContain('98, 155, 231'); + // The lock-hover rule sets z-index 9999999 on top of any default — if + // it slipped through, the hidden wrapper would jump above siblings. + expect(styles.zIndex).not.toBe('9999999'); + }); + + test('selecting a hidden wrapper copies only the wrapped phrase', async ({ superdoc }) => { + const selectionText = await superdoc.page.evaluate(() => { + const wrapper = document.querySelector('[data-sdt-id="1001"]'); + if (!wrapper) return null; + const range = document.createRange(); + range.selectNodeContents(wrapper); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + return sel?.toString() ?? null; + }); + + expect(selectionText).toBe('Alpha Corp v. SEC'); + expect(selectionText).not.toContain('HIDDEN_ALIAS_LEAK_CANARY'); + }); +});