diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/smart-tag.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/smart-tag.ts new file mode 100644 index 0000000000..d785b56723 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/smart-tag.ts @@ -0,0 +1,30 @@ +import { type InlineConverterParams } from './common'; + +/** + * pm-adapter inline converter for the SuperDoc `smartTag` node (SD-2647). + * + * A smartTag is a transparent OOXML inline wrapper (``); its + * children are normal inline content. The pm-adapter contribution mirrors + * `structuredContentNodeToBlocks`: visit the children with the inherited + * marks unchanged so the wrapper contributes no run of its own. + * + * SD-2781 (mirrors structured-content / bookmark-start): forward + * `inlineRunProperties` so children inside the smartTag wrapper preserve + * run-level bidi/script metadata. The wrapper itself doesn't introduce a new + * run boundary, so the parent run's inline source still applies. + * + * The wrapper's own attrs (`element`, `uri`, `smartTagPr`) are metadata only; + * they survive round-trip via the v3 translator and don't affect layout. + */ +export function smartTagNodeToBlocks({ + node, + inheritedMarks, + sdtMetadata, + visitNode, + runProperties, + inlineRunProperties, +}: InlineConverterParams): void { + node.content?.forEach((child) => + visitNode(child, inheritedMarks, sdtMetadata, runProperties, false, inlineRunProperties), + ); +} diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index a3f5d964e8..32f3c0cd57 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -48,6 +48,7 @@ import { } from './inline-converters/common.js'; import { runNodeChildrenToRuns } from './inline-converters/run.js'; import { structuredContentNodeToBlocks } from './inline-converters/structured-content.js'; +import { smartTagNodeToBlocks } from './inline-converters/smart-tag.js'; import { pageReferenceNodeToBlock } from './inline-converters/page-reference.js'; import { fieldAnnotationNodeToRun } from './inline-converters/field-annotation.js'; import { bookmarkStartNodeToBlocks } from './inline-converters/bookmark-start.js'; @@ -969,6 +970,10 @@ const INLINE_CONVERTERS_REGISTRY: Record = { inlineConverter: structuredContentNodeToBlocks, extraCheck: (node: PMNode) => Array.isArray(node.content), }, + smartTag: { + inlineConverter: smartTagNodeToBlocks, + extraCheck: (node: PMNode) => Array.isArray(node.content), + }, fieldAnnotation: { inlineConverter: fieldAnnotationNodeToRun, }, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js index bb8151ce75..a7a3882f9b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js @@ -6,6 +6,7 @@ import { translator as wBrNodeTranslator } from './v3/handlers/w/br/br-translato import { translator as wHighlightTranslator } from './v3/handlers/w/highlight/highlight-translator.js'; import { translator as wTabNodeTranslator } from './v3/handlers/w/tab/tab-translator.js'; import { translator as wNoBreakHyphenNodeTranslator } from './v3/handlers/w/noBreakHyphen/no-break-hyphen-translator.js'; +import { translator as wSmartTagNodeTranslator } from './v3/handlers/w/smartTag/smartTag-translator.js'; import { translator as wPNodeTranslator } from './v3/handlers/w/p/p-translator.js'; import { translator as wRNodeTranslator } from './v3/handlers/w/r/r-translator.js'; import { translator as wTcNodeTranslator } from './v3/handlers/w/tc/tc-translator'; @@ -218,6 +219,7 @@ export function exportSchemaToJson(params) { fieldAnnotation: wSdtNodeTranslator, tab: wTabNodeTranslator, noBreakHyphen: wNoBreakHyphenNodeTranslator, + smartTag: wSmartTagNodeTranslator, image: [wDrawingNodeTranslator, pictTranslator], hardBreak: wBrNodeTranslator, commentRangeStart: wCommentRangeStartTranslator, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index ca8c9cf1a7..9637bd7e97 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -26,6 +26,7 @@ import { getDefaultStyleDefinition } from '@converter/docx-helpers/index.js'; import { pruneIgnoredNodes } from './ignoredNodes.js'; import { tabNodeEntityHandler } from './tabImporter.js'; import { noBreakHyphenNodeEntityHandler } from './noBreakHyphenImporter.js'; +import { smartTagNodeEntityHandler } from './smartTagImporter.js'; import { footnoteReferenceHandlerEntity } from './footnoteReferenceImporter.js'; import { endnoteReferenceHandlerEntity } from './endnoteReferenceImporter.js'; import { tableNodeHandlerEntity } from './tableImporter.js'; @@ -339,6 +340,7 @@ export const defaultNodeListHandler = () => { endnoteReferenceHandlerEntity, tabNodeEntityHandler, noBreakHyphenNodeEntityHandler, + smartTagNodeEntityHandler, tableOfContentsHandlerEntity, indexHandlerEntity, bibliographyHandlerEntity, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/smartTagImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/smartTagImporter.js new file mode 100644 index 0000000000..f13915a319 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/smartTagImporter.js @@ -0,0 +1,37 @@ +// @ts-check +import { translator as wSmartTagNodeTranslator } from '../../v3/handlers/w/smartTag/index.js'; + +/** + * Smart-tag node handler (SD-2647 / SD-3298). + * + * Captures `` before it falls through to the passthrough handler. + * Without this entry the wrapper is hidden as `passthroughInline`, dropping + * its visible children (e.g. WIPO ST.3 country-region names inside the + * customer IT-945 doc). + * + * @param {import('../../v3/node-translator').SCEncoderConfig} params + * @returns {Object} Handler result + */ +const handleSmartTagNode = (params) => { + const { nodes } = params; + if (!nodes.length || nodes[0].name !== 'w:smartTag') { + return { nodes: [], consumed: 0 }; + } + const result = wSmartTagNodeTranslator.encode(params); + if (!result) return { nodes: [], consumed: 0 }; + return { + nodes: Array.isArray(result) ? result : [result], + consumed: 1, + }; +}; + +/** + * Smart-tag node handler entity. Slotted into `defaultNodeListHandler` + * BEFORE `passthroughNodeHandlerEntity`. + * + * @type {Object} + */ +export const smartTagNodeEntityHandler = { + handlerName: 'w:smartTagTranslator', + handler: handleSmartTagNode, +}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/smartTagImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/smartTagImporter.test.js new file mode 100644 index 0000000000..7fb68f7490 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/smartTagImporter.test.js @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from 'vitest'; +import { defaultNodeListHandler } from './docxImporter.js'; +import { smartTagNodeEntityHandler } from './smartTagImporter.js'; +import { registeredHandlers } from '../../v3/handlers/index.js'; + +const baseParams = () => ({ + docx: {}, + converter: {}, + editor: { extensionService: { extensions: [] } }, + nodeListHandler: { handler: vi.fn(() => []), handlerEntities: [] }, + path: [], + extraParams: {}, + importTrackingContext: { addUnhandled: () => {} }, +}); + +describe('smartTagNodeEntityHandler', () => { + it('claims w:smartTag and emits a smartTag PM node (consumed: 1)', () => { + const node = { + name: 'w:smartTag', + attributes: { 'w:element': 'country-region' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Brazil' }] }] }], + }; + + const result = smartTagNodeEntityHandler.handler({ ...baseParams(), nodes: [node] }); + + expect(result.consumed).toBe(1); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].type).toBe('smartTag'); + expect(result.nodes[0].attrs.element).toBe('country-region'); + }); + + it('refuses non-smartTag nodes (consumed: 0)', () => { + const node = { name: 'w:r', elements: [] }; + const result = smartTagNodeEntityHandler.handler({ ...baseParams(), nodes: [node] }); + expect(result).toEqual({ nodes: [], consumed: 0 }); + }); + + it('is the only entity that claims w:smartTag in defaultNodeListHandler (passthrough refuses because w:smartTag is in v3 registeredHandlers)', () => { + // Regression guard: cubic/codex flagged this v2 bridge as "redundant + // duplicate of the v3 registration". It is not. passthroughNodeImporter + // refuses any node present in registeredHandlers, so without this bridge + // w:smartTag would silently fall off the end of the reducer chain. + expect(registeredHandlers['w:smartTag']).toBeDefined(); + + const { handlerEntities } = defaultNodeListHandler(); + const withoutBridge = handlerEntities.filter((e) => e.handlerName !== 'w:smartTagTranslator'); + const node = { + name: 'w:smartTag', + attributes: { 'w:element': 'country-region' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'X' }] }] }], + }; + const params = { + ...baseParams(), + nodes: [node], + nodeListHandler: { handler: () => [], handlerEntities: withoutBridge }, + }; + const result = withoutBridge.reduce((acc, h) => (acc.consumed > 0 ? acc : h.handler(params)), { + nodes: [], + consumed: 0, + }); + expect(result.consumed).toBe(0); + expect(result.nodes).toHaveLength(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js index e6bd9fe1f3..544e950224 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js @@ -151,6 +151,7 @@ import { translator as w_szCs_translator } from './w/szcs/szcs-translator.js'; import { translator as w_t_translator } from './w/t/t-translator.js'; import { translator as w_tab_translator } from './w/tab/tab-translator.js'; import { translator as w_noBreakHyphen_translator } from './w/noBreakHyphen/no-break-hyphen-translator.js'; +import { translator as w_smartTag_translator } from './w/smartTag/smartTag-translator.js'; import { translator as w_tabs_translator } from './w/tabs/tabs-translator.js'; import { translator as w_tbl_translator } from './w/tbl/tbl-translator.js'; import { translator as w_tblBorders_translator } from './w/tblBorders/tblBorders-translator.js'; @@ -360,6 +361,7 @@ const translatorList = Array.from( w_t_translator, w_tab_translator, w_noBreakHyphen_translator, + w_smartTag_translator, w_tabs_translator, w_tbl_translator, w_tblBorders_translator, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/convert-sdt-content-to-runs.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/convert-sdt-content-to-runs.js index 3529f1f495..06ebc1d5ea 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/convert-sdt-content-to-runs.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/convert-sdt-content-to-runs.js @@ -1,4 +1,4 @@ -const RUN_LEVEL_WRAPPERS = new Set(['w:hyperlink', 'w:ins', 'w:del']); +const RUN_LEVEL_WRAPPERS = new Set(['w:hyperlink', 'w:ins', 'w:del', 'w:smartTag']); /** * Convert SDT child elements into Word run elements. @@ -31,7 +31,20 @@ export function convertSdtContentToRuns(elements) { } if (RUN_LEVEL_WRAPPERS.has(element.name)) { - const wrapperElements = convertSdtContentToRuns(element.elements || []); + const children = element.elements || []; + // w:smartTagPr is property metadata for w:smartTag, not run content. + // Preserve it directly on the wrapper instead of feeding it into the + // recursive flatten, which would mangle it into a fake w:r (SD-2647). + const preserved = []; + const rest = []; + for (const child of children) { + if (element.name === 'w:smartTag' && child?.name === 'w:smartTagPr') { + preserved.push(child); + } else { + rest.push(child); + } + } + const wrapperElements = [...preserved, ...convertSdtContentToRuns(rest)]; if (wrapperElements.length) { runs.push({ ...element, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/convert-sdt-content-to-runs.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/convert-sdt-content-to-runs.test.js index 941b598363..2884dd7820 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/convert-sdt-content-to-runs.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/convert-sdt-content-to-runs.test.js @@ -62,4 +62,26 @@ describe('convertSdtContentToRuns', () => { const result = convertSdtContentToRuns(emptyElement); expect(result).toEqual([]); }); + + it('keeps w:smartTagPr as smartTag metadata, not as a fake w:r (SD-2647)', () => { + const smartTagPr = { + name: 'w:smartTagPr', + elements: [{ name: 'w:attr', attributes: { 'w:name': 'CountryRegion', 'w:val': 'BR' } }], + }; + const innerRun = { name: 'w:r', elements: [{ name: 'w:t', text: 'Brazil' }] }; + const smartTag = { + name: 'w:smartTag', + attributes: { 'w:element': 'country-region' }, + elements: [smartTagPr, innerRun], + }; + + const result = convertSdtContentToRuns([{ name: 'w:sdtPr' }, smartTag]); + + expect(result).toHaveLength(1); + const wrapper = result[0]; + expect(wrapper.name).toBe('w:smartTag'); + expect(wrapper.elements[0]).toEqual(smartTagPr); + expect(wrapper.elements[1]).toBe(innerRun); + expect(wrapper.elements.every((el) => el.name !== 'w:r' || el === innerRun)).toBe(true); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/index.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/index.js new file mode 100644 index 0000000000..19b880bd25 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/index.js @@ -0,0 +1 @@ +export { config, translator } from './smartTag-translator.js'; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-export-routing.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-export-routing.test.js new file mode 100644 index 0000000000..bbeabf33be --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-export-routing.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { exportSchemaToJson } from '../../../../exporter.js'; + +function collectText(xmlNode) { + if (!xmlNode) return ''; + if (typeof xmlNode.text === 'string') return xmlNode.text; + const children = Array.isArray(xmlNode.elements) ? xmlNode.elements : []; + return children.map(collectText).join(''); +} + +describe('smartTag export routing (SD-2647)', () => { + it('routes a smartTag PM node through exportSchemaToJson to ', () => { + const node = { + type: 'smartTag', + attrs: { element: 'country-region', uri: 'urn:schemas-microsoft-com:office:smarttags' }, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text: 'Brazil' }] }], + }; + + const result = exportSchemaToJson({ node }); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('w:smartTag'); + expect(result?.attributes?.['w:element']).toBe('country-region'); + expect(result?.attributes?.['w:uri']).toBe('urn:schemas-microsoft-com:office:smarttags'); + expect(collectText(result)).toBe('Brazil'); + }); + + it('preserves the captured w:smartTagPr when re-emitting through exportSchemaToJson', () => { + const smartTagPr = { + type: 'element', + name: 'w:smartTagPr', + elements: [ + { + type: 'element', + name: 'w:attr', + attributes: { 'w:name': 'CountryRegion', 'w:val': 'BR' }, + }, + ], + }; + + const node = { + type: 'smartTag', + attrs: { element: 'country-region', uri: null, smartTagPr }, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text: 'Brazil' }] }], + }; + + const result = exportSchemaToJson({ node }); + + expect(result?.name).toBe('w:smartTag'); + const firstChild = result?.elements?.[0]; + expect(firstChild?.name).toBe('w:smartTagPr'); + expect(firstChild?.elements?.[0]?.name).toBe('w:attr'); + expect(firstChild?.elements?.[0]?.attributes?.['w:val']).toBe('BR'); + expect(collectText(result)).toBe('Brazil'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-translator.js new file mode 100644 index 0000000000..24e4502c50 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-translator.js @@ -0,0 +1,135 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { translateChildNodes } from '../../../../v2/exporter/helpers/translateChildNodes.js'; +import { cloneXmlNode } from '../r/helpers/helpers.js'; + +/** @type {import('@translator').XmlNodeName} */ +const XML_NODE_NAME = 'w:smartTag'; + +/** @type {import('@translator').SuperDocNodeOrKeyName} */ +const SD_NODE_NAME = 'smartTag'; + +/** + * Helper to create one-to-one attribute handlers between OOXML attrs and + * SuperDoc PM-node attrs. + * @param {string} xmlName + * @param {string} sdName + * @returns {import('@translator').AttrConfig} + */ +const _createAttributeHandler = (xmlName, sdName) => ({ + xmlName, + sdName, + encode: (attributes) => attributes[xmlName], + decode: (attributes) => attributes[sdName], +}); + +/** + * w:smartTag carries two named attributes (ECMA-376 §17.5.1.9): + * w:element - required, the smart tag's local name (e.g. "country-region") + * w:uri - optional, the namespace URI + * + * The optional child element is handled separately during + * encode/decode (preserved as raw XML in the PM-node attrs for round-trip). + * + * @type {import('@translator').AttrConfig[]} + */ +const validXmlAttributes = [_createAttributeHandler('w:element', 'element'), _createAttributeHandler('w:uri', 'uri')]; + +/** + * Encode as a SuperDoc `smartTag` PM container node (SD-2647 / + * SD-3298). The wrapper is transparent: its child runs / inline content are + * imported as the PM node's content. , if present, is preserved + * as raw XML on `attrs.smartTagPr` so export can re-emit it. + * + * Children are full `EG_PContent` (per §17.5.1.9 + the EG_ContentRunContent + * group): runs, hyperlinks, fields, SDTs, nested smartTags, customXml, range + * markers, etc., so we route the entire non-smartTagPr child list back + * through `nodeListHandler.handler` rather than filtering to `w:r` only. + * + * @param {import('@translator').SCEncoderConfig} params + * @param {import('@translator').EncodedAttributes} [encodedAttrs] + * @returns {import('@translator').SCEncoderResult} + */ +function encode(params, encodedAttrs = {}) { + const { nodes, nodeListHandler } = params; + const node = nodes[0]; + + const elements = Array.isArray(node?.elements) ? node.elements : []; + + // Capture if present (round-trip metadata) and strip it from + // the content stream so it doesn't get imported as a visible child. + let smartTagPr = null; + const visibleChildren = []; + for (const child of elements) { + if (child?.name === 'w:smartTagPr') { + smartTagPr = cloneXmlNode(child); + continue; + } + visibleChildren.push(child); + } + + const translatedContent = + visibleChildren.length > 0 + ? nodeListHandler.handler({ + ...params, + nodes: visibleChildren, + path: [...(params.path || []), node], + }) || [] + : []; + + return { + type: SD_NODE_NAME, + content: translatedContent, + attrs: { + element: encodedAttrs.element ?? null, + uri: encodedAttrs.uri ?? null, + smartTagPr, + }, + }; +} + +/** + * Decode a SuperDoc `smartTag` PM node back into , recursively + * translating its inline children and re-emitting the preserved + * when present. + * + * @param {import('@translator').SCDecoderConfig} params + * @param {import('@translator').DecodedAttributes} [decodedAttrs] + * @returns {import('@translator').SCDecoderResult} + */ +function decode(params, decodedAttrs = {}) { + const { node } = params || {}; + if (!node) return null; + + const childContent = translateChildNodes({ ...params, node }); + const childElements = Array.isArray(childContent) ? childContent : childContent ? [childContent] : []; + + const elements = []; + const smartTagPr = node.attrs?.smartTagPr; + if (smartTagPr) { + elements.push(cloneXmlNode(smartTagPr)); + } + elements.push(...childElements); + + return { + name: 'w:smartTag', + attributes: { ...decodedAttrs }, + elements, + }; +} + +/** @type {import('@translator').NodeTranslatorConfig} */ +export const config = { + xmlName: XML_NODE_NAME, + sdNodeOrKeyName: SD_NODE_NAME, + type: NodeTranslator.translatorTypes.NODE, + encode, + decode, + attributes: validXmlAttributes, +}; + +/** + * The NodeTranslator instance for the element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from(config); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-translator.test.js new file mode 100644 index 0000000000..0d860b3780 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/smartTag/smartTag-translator.test.js @@ -0,0 +1,190 @@ +// @ts-check +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { config, translator } from './smartTag-translator.js'; + +describe('w:smartTag translator', () => { + describe('attribute handlers', () => { + const findAttr = (sdName) => config.attributes.find((a) => a.sdName === sdName); + + it('encodes w:element -> element', () => { + const handler = findAttr('element'); + expect(handler.encode({ 'w:element': 'country-region' })).toBe('country-region'); + expect(handler.decode({ element: 'country-region' })).toBe('country-region'); + }); + + it('encodes w:uri -> uri', () => { + const handler = findAttr('uri'); + expect(handler.encode({ 'w:uri': 'urn:schemas-microsoft-com:office:smarttags' })).toBe( + 'urn:schemas-microsoft-com:office:smarttags', + ); + expect(handler.decode({ uri: 'urn:schemas-microsoft-com:office:smarttags' })).toBe( + 'urn:schemas-microsoft-com:office:smarttags', + ); + }); + }); + + describe('encode (import: -> PM smartTag node)', () => { + let nodeListHandler; + beforeEach(() => { + // Stub nodeListHandler to mirror what the real importer would do for one w:r + nodeListHandler = { + handler: vi.fn(({ nodes }) => { + // Pretend each non-smartTagPr child is a run that became a text node. + return nodes.map((n, i) => ({ type: 'text', text: `child-${i}-${n.name}` })); + }), + }; + }); + + it('produces a smartTag PM node with element + uri attrs from encodedAttrs', () => { + const node = { + name: 'w:smartTag', + attributes: { + 'w:element': 'country-region', + 'w:uri': 'urn:schemas-microsoft-com:office:smarttags', + }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'AFGHANISTAN' }] }] }], + }; + const result = config.encode( + { nodes: [node], nodeListHandler, path: [] }, + { element: 'country-region', uri: 'urn:schemas-microsoft-com:office:smarttags' }, + ); + expect(result.type).toBe('smartTag'); + expect(result.attrs.element).toBe('country-region'); + expect(result.attrs.uri).toBe('urn:schemas-microsoft-com:office:smarttags'); + expect(result.attrs.smartTagPr).toBeNull(); + expect(result.content).toEqual([{ type: 'text', text: 'child-0-w:r' }]); + }); + + it('routes the full child list through nodeListHandler.handler (not just w:r)', () => { + // EG_PContent allows runs, hyperlinks, fields, nested smartTags, etc. + // The translator must NOT filter to w:r; that would silently drop these + // siblings inside a smartTag, recreating the SD-2647 bug for richer content. + const node = { + name: 'w:smartTag', + attributes: { 'w:element': 'place' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'before' }] }] }, + { name: 'w:hyperlink', elements: [] }, + { + name: 'w:smartTag', + attributes: { 'w:element': 'country-region' }, + elements: [{ name: 'w:r' }], + }, + ], + }; + config.encode({ nodes: [node], nodeListHandler, path: [] }, { element: 'place' }); + const handlerCall = nodeListHandler.handler.mock.calls[0][0]; + // Confirm all three siblings (not just w:r) were passed through. + expect(handlerCall.nodes.map((n) => n.name)).toEqual(['w:r', 'w:hyperlink', 'w:smartTag']); + }); + + it('captures into attrs.smartTagPr (round-trip metadata) and strips it from content', () => { + const smartTagPrXml = { + name: 'w:smartTagPr', + elements: [{ name: 'w:attr', attributes: { 'w:name': 'date', 'w:val': '2026-05-28' } }], + }; + const node = { + name: 'w:smartTag', + attributes: { 'w:element': 'stockticker' }, + elements: [smartTagPrXml, { name: 'w:r' }], + }; + const result = config.encode({ nodes: [node], nodeListHandler, path: [] }, { element: 'stockticker' }); + // smartTagPr lives on attrs, not in content. + expect(result.attrs.smartTagPr).not.toBeNull(); + expect(result.attrs.smartTagPr.name).toBe('w:smartTagPr'); + // The child list passed to nodeListHandler must NOT include the smartTagPr. + const handlerCall = nodeListHandler.handler.mock.calls[0][0]; + expect(handlerCall.nodes.map((n) => n.name)).toEqual(['w:r']); + }); + + it('handles empty smartTag (no children) without error', () => { + const node = { name: 'w:smartTag', attributes: { 'w:element': 'empty' }, elements: [] }; + const result = config.encode({ nodes: [node], nodeListHandler, path: [] }, { element: 'empty' }); + expect(result.type).toBe('smartTag'); + expect(result.content).toEqual([]); + // nodeListHandler.handler is NOT called when there are no visible children. + expect(nodeListHandler.handler).not.toHaveBeenCalled(); + }); + + it('nests via the schema content model when the PM importer recurses', () => { + // Simulate a nested smartTag where nodeListHandler returns a PM smartTag + // child (mimicking the recursive import). The outer smartTag's content + // should hold that nested PM node natively, without flattening attrs. + nodeListHandler.handler = vi.fn(() => [ + { + type: 'smartTag', + attrs: { element: 'country-region', uri: null, smartTagPr: null }, + content: [{ type: 'text', text: 'U.S.' }], + }, + ]); + const node = { + name: 'w:smartTag', + attributes: { 'w:element': 'place' }, + elements: [ + { + name: 'w:smartTag', + attributes: { 'w:element': 'country-region' }, + elements: [{ name: 'w:r' }], + }, + ], + }; + const result = config.encode({ nodes: [node], nodeListHandler, path: [] }, { element: 'place' }); + expect(result.attrs.element).toBe('place'); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('smartTag'); + expect(result.content[0].attrs.element).toBe('country-region'); + expect(result.content[0].content[0].text).toBe('U.S.'); + }); + }); + + describe('decode (export: PM smartTag node -> )', () => { + // The decoder uses translateChildNodes, which is wired through the real + // exporter pipeline. For the unit test we verify the shape and that the + // smartTagPr round-trip path works. + + it('emits with the decoded attributes', () => { + const node = { + type: 'smartTag', + attrs: { element: 'country-region', uri: 'urn:schemas-microsoft-com:office:smarttags', smartTagPr: null }, + content: [], + }; + const result = config.decode( + { node, relationships: [] }, + { 'w:element': 'country-region', 'w:uri': 'urn:schemas-microsoft-com:office:smarttags' }, + ); + expect(result.name).toBe('w:smartTag'); + expect(result.attributes).toEqual({ + 'w:element': 'country-region', + 'w:uri': 'urn:schemas-microsoft-com:office:smarttags', + }); + }); + + it('re-emits when preserved on attrs', () => { + const preservedSmartTagPr = { + name: 'w:smartTagPr', + elements: [{ name: 'w:attr', attributes: { 'w:name': 'date', 'w:val': '2026-05-28' } }], + }; + const node = { + type: 'smartTag', + attrs: { element: 'stockticker', uri: null, smartTagPr: preservedSmartTagPr }, + content: [], + }; + const result = config.decode({ node, relationships: [] }, { 'w:element': 'stockticker' }); + expect(result.elements?.[0]?.name).toBe('w:smartTagPr'); + // Must be a clone, not the same reference, so callers can't accidentally + // mutate the PM-state copy via the exported tree. + expect(result.elements[0]).not.toBe(preservedSmartTagPr); + }); + }); + + describe('config', () => { + it('binds the translator to ', () => { + expect(config.xmlName).toBe('w:smartTag'); + expect(config.sdNodeOrKeyName).toBe('smartTag'); + }); + + it('exports a NodeTranslator instance', () => { + expect(translator).toBeDefined(); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/index.js b/packages/super-editor/src/editors/v1/extensions/index.js index acf4a1e655..a5e16e8548 100644 --- a/packages/super-editor/src/editors/v1/extensions/index.js +++ b/packages/super-editor/src/editors/v1/extensions/index.js @@ -39,6 +39,7 @@ import { TableCell } from './table-cell/index.js'; import { FieldAnnotation, fieldAnnotationHelpers } from './field-annotation/index.js'; import { Image } from './image/index.js'; import { BookmarkStart, BookmarkEnd } from './bookmarks/index.js'; +import { SmartTag } from './smart-tag/index.js'; import { Mention } from './mention/index.js'; import { PageNumber, TotalPageCount } from './page-number/index.js'; import { PageReference } from './page-reference/index.js'; @@ -185,6 +186,7 @@ const getStarterExtensions = () => { Image, BookmarkStart, BookmarkEnd, + SmartTag, Mention, Collaboration, CollaborationCursor, @@ -290,6 +292,7 @@ export { Image, BookmarkStart, BookmarkEnd, + SmartTag, PopoverPlugin, Mention, Collaboration, diff --git a/packages/super-editor/src/editors/v1/extensions/smart-tag/index.js b/packages/super-editor/src/editors/v1/extensions/smart-tag/index.js new file mode 100644 index 0000000000..b13d329802 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/smart-tag/index.js @@ -0,0 +1 @@ +export { SmartTag } from './smart-tag.js'; diff --git a/packages/super-editor/src/editors/v1/extensions/smart-tag/smart-tag.js b/packages/super-editor/src/editors/v1/extensions/smart-tag/smart-tag.js new file mode 100644 index 0000000000..1a9cbf2de5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/smart-tag/smart-tag.js @@ -0,0 +1,82 @@ +// @ts-nocheck +import { Node } from '@core/Node.js'; +import { Attribute } from '@core/Attribute.js'; + +/** + * Smart tag (OOXML ``): transparent inline wrapper that carries + * semantic metadata around normal paragraph content (ECMA-376 §17.5.1.9). + * + * Renders its children transparently. Per the SD-3298 architectural rule, this + * is a non-atomic inline container node with `content: 'inline*'`, NOT a mark: + * OOXML allows smartTags to nest (e.g. `` wrapping + * `` wrapping a run), and ProseMirror + * marks of the same type don't stack on a single inline node. A container node + * natively nests via the schema's content model. + * + * @module SmartTag + * @sidebarTitle Smart Tag + */ +export const SmartTag = Node.create({ + name: 'smartTag', + + group: 'inline', + + content: 'inline*', + + inline: true, + + // Transparent metadata wrapper, never atomic. + atom: false, + + // Cursor flows through smartTag boundaries freely; metadata only. + isolating: false, + + draggable: false, + + selectable: false, + + addOptions() { + return { + htmlAttributes: { + 'data-sd-smart-tag': '', + 'aria-label': 'Smart tag', + }, + }; + }, + + addAttributes() { + return { + element: { + default: null, + parseDOM: (elem) => elem.getAttribute('data-element'), + renderDOM: (attrs) => { + if (!attrs.element) return {}; + return { 'data-element': attrs.element }; + }, + }, + + uri: { + default: null, + parseDOM: (elem) => elem.getAttribute('data-uri'), + renderDOM: (attrs) => { + if (!attrs.uri) return {}; + return { 'data-uri': attrs.uri }; + }, + }, + + // Raw OOXML stored for round-trip export. Never rendered. + smartTagPr: { + default: null, + rendered: false, + }, + }; + }, + + parseDOM() { + return [{ tag: 'span[data-sd-smart-tag]' }]; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0]; + }, +}); diff --git a/packages/super-editor/src/editors/v1/extensions/smart-tag/smart-tag.test.js b/packages/super-editor/src/editors/v1/extensions/smart-tag/smart-tag.test.js new file mode 100644 index 0000000000..4eb0d824c5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/smart-tag/smart-tag.test.js @@ -0,0 +1,48 @@ +// @ts-nocheck +import { describe, it, expect } from 'vitest'; +import { SmartTag } from './smart-tag.js'; + +describe('SmartTag PM node', () => { + it('has the correct name', () => { + expect(SmartTag.name).toBe('smartTag'); + }); + + it('is configured as a non-atomic inline container', () => { + const config = SmartTag.config ?? SmartTag; + // Node.create's config sits on the static; key invariants for SD-2647: + expect(config.name).toBe('smartTag'); + expect(typeof SmartTag).toBe('object'); + }); + + it('exposes element, uri, and smartTagPr attrs', () => { + const attrsFn = SmartTag.config?.addAttributes ?? SmartTag.addAttributes; + expect(typeof attrsFn).toBe('function'); + const attrs = attrsFn.call({ options: {} }); + expect(attrs).toHaveProperty('element'); + expect(attrs).toHaveProperty('uri'); + expect(attrs).toHaveProperty('smartTagPr'); + // smartTagPr is preserved for round-trip but never rendered. + expect(attrs.smartTagPr.rendered).toBe(false); + }); + + it('renders transparently with a content hole', () => { + const renderFn = SmartTag.config?.renderDOM ?? SmartTag.renderDOM; + expect(typeof renderFn).toBe('function'); + const result = renderFn.call( + { + options: { + htmlAttributes: { + 'data-sd-smart-tag': '', + 'aria-label': 'Smart tag', + }, + }, + }, + { htmlAttributes: {} }, + ); + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toBe('span'); + // Third element MUST be 0 (PM content placeholder); otherwise children + // would not render. This is the core "transparent" property of the node. + expect(result[2]).toBe(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 0a16e250b5..7bd04f9c8e 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -740,6 +740,27 @@ export interface BookmarkEndAttrs extends InlineNodeAttributes { colLast?: number | null; } +// ============================================ +// SMART TAG (ECMA-376 §17.5.1.9) +// ============================================ + +/** + * Smart-tag node attributes (SD-2647 / SD-3298). + * + * `w:smartTag` is a transparent OOXML inline wrapper around `EG_PContent`. + * The wrapper carries semantic metadata (element + uri) plus an optional + * `` property bag (`` pairs). Children are + * normal inline content; the wrapper itself is invisible at render time. + */ +export interface SmartTagAttrs extends InlineNodeAttributes { + /** Smart-tag element name (w:element), e.g. "country-region", "PlaceName" */ + element?: string | null; + /** Smart-tag namespace URI (w:uri), e.g. "urn:schemas-microsoft-com:office:smarttags" */ + uri?: string | null; + /** @internal Preserved raw `` OOXML for round-trip export. */ + smartTagPr?: Record | null; +} + // ============================================ // SHAPE CONTAINER // ============================================ @@ -1237,6 +1258,9 @@ declare module '../../core/types/NodeAttributesMap.js' { bookmarkStart: BookmarkStartAttrs; bookmarkEnd: BookmarkEndAttrs; + // Smart tags + smartTag: SmartTagAttrs; + // Comments (note: no 'comment' node - only commentRangeStart/End/Reference) commentRangeStart: CommentRangeStartAttrs; commentRangeEnd: CommentRangeEndAttrs; diff --git a/tests/behavior/tests/exporting/fixtures/sd-2647-smarttag-roundtrip.docx b/tests/behavior/tests/exporting/fixtures/sd-2647-smarttag-roundtrip.docx new file mode 100644 index 0000000000..d312015755 Binary files /dev/null and b/tests/behavior/tests/exporting/fixtures/sd-2647-smarttag-roundtrip.docx differ diff --git a/tests/behavior/tests/exporting/sd-2647-smarttag-roundtrip.spec.ts b/tests/behavior/tests/exporting/sd-2647-smarttag-roundtrip.spec.ts new file mode 100644 index 0000000000..7561789911 --- /dev/null +++ b/tests/behavior/tests/exporting/sd-2647-smarttag-roundtrip.spec.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import JSZip from 'jszip'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, 'fixtures/sd-2647-smarttag-roundtrip.docx'); + +test.use({ config: { toolbar: 'none' } }); + +/** + * SD-2647: Word-authored content wrapped in must render visibly + * and round-trip back to the same OOXML on zero-edit export. + * + * Fixture is a sanitized subset of the IT-945 customer file (USPTO IDS + * instructions): a short paragraph carrying + the first six + * rows of the WIPO ST.3 country-region table where each country name is + * wrapped in . + * + * Fixture was produced by OOXML surgery on the customer .docx, NOT by + * routing through SuperDoc's own save path - laundering the input through + * the implementation under test would defeat the round-trip assertion. + */ +test('@behavior SD-2647: w:smartTag content renders and round-trips through zero-edit export', async ({ superdoc }) => { + // Sanity: input fixture already carries the constructs we are about to assert. + const inputZip = await JSZip.loadAsync(fs.readFileSync(DOC_PATH)); + const inputXml = await inputZip.file('word/document.xml')!.async('string'); + expect(inputXml).toMatch(/ are visible in the + // body text. Before SD-2647 these fell into hidden passthroughInline spans + // and disappeared. + const bodyText: string = await superdoc.page.evaluate(() => document.body.innerText); + expect(bodyText).toMatch(/AFGHANISTAN/); + expect(bodyText).toMatch(/ALBANIA/); + expect(bodyText).toMatch(/ALGERIA/); + + // Zero-edit export. + const bytes: number[] = await superdoc.page.evaluate(async () => { + const blob: Blob = await (window as any).editor.exportDocx(); + const buffer = await blob.arrayBuffer(); + return Array.from(new Uint8Array(buffer)); + }); + + const outZip = await JSZip.loadAsync(Buffer.from(bytes)); + const outXml = await outZip.file('word/document.xml')!.async('string'); + + // Export assertion 1: w:smartTag wrappers survive with their w:element value + // and at least one country-region instance is present in the output. + expect(outXml).toMatch(/ property metadata is preserved on + // export, not silently dropped (the round-trip half of SD-2647 + the SDT + // flatten fix in convertSdtContentToRuns). + expect(outXml).toMatch(/