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
14 changes: 14 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,27 @@ export type FieldAnnotationMetadata = {

export type StructuredContentLockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';

/**
* Visual chrome / labelling behavior of an SDT, mirroring
* `<w15:appearance w15:val="…">` (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';
id?: string | null;
tag?: string | null;
alias?: string | null;
lockMode?: StructuredContentLockMode;
/** Appearance from the SDT's `<w15:appearance>` element, when present. */
appearance?: StructuredContentAppearance;
sdtPr?: unknown;
};

Expand Down
81 changes: 81 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<w15:appearance w15:val="hidden"/>` 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',
Expand Down
17 changes: 17 additions & 0 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7308,12 +7308,29 @@ export class DomPainter {
/**
* Create an inline SDT wrapper `<span>` 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
* `<w15:appearance w15:val="hidden"/>`), 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`;
Expand Down
29 changes: 27 additions & 2 deletions packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
caio-pizzol marked this conversation as resolved.
.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;
}
Expand Down
29 changes: 29 additions & 0 deletions packages/layout-engine/style-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion packages/layout-engine/style-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ function normalizeStructuredContentMetadata(
nodeType: 'structuredContent' | 'structuredContentBlock',
attrs: Record<string, unknown>,
): StructuredContentMetadata {
return {
const metadata: StructuredContentMetadata = {
type: 'structuredContent',
scope: nodeType === 'structuredContentBlock' ? 'block' : 'inline',
id: toNullableString(attrs.id),
Expand All @@ -250,6 +250,14 @@ function normalizeStructuredContentMetadata(
lockMode: attrs.lockMode as StructuredContentMetadata['lockMode'],
sdtPr: attrs.sdtPr,
};
// `appearance` comes from the SDT's <w15:appearance> 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<string, unknown>): DocumentSectionMetadata {
Expand Down
Binary file not shown.
138 changes: 138 additions & 0 deletions tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading