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
Original file line number Diff line number Diff line change
@@ -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 (`<w:smartTag>`); 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),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -969,6 +970,10 @@ const INLINE_CONVERTERS_REGISTRY: Record<string, InlineConverterSpec> = {
inlineConverter: structuredContentNodeToBlocks,
extraCheck: (node: PMNode) => Array.isArray(node.content),
},
smartTag: {
inlineConverter: smartTagNodeToBlocks,
extraCheck: (node: PMNode) => Array.isArray(node.content),
},
fieldAnnotation: {
inlineConverter: fieldAnnotationNodeToRun,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -218,6 +219,7 @@ export function exportSchemaToJson(params) {
fieldAnnotation: wSdtNodeTranslator,
tab: wTabNodeTranslator,
noBreakHyphen: wNoBreakHyphenNodeTranslator,
smartTag: wSmartTagNodeTranslator,
image: [wDrawingNodeTranslator, pictTranslator],
hardBreak: wBrNodeTranslator,
commentRangeStart: wCommentRangeStartTranslator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -339,6 +340,7 @@ export const defaultNodeListHandler = () => {
endnoteReferenceHandlerEntity,
tabNodeEntityHandler,
noBreakHyphenNodeEntityHandler,
smartTagNodeEntityHandler,
tableOfContentsHandlerEntity,
indexHandlerEntity,
bibliographyHandlerEntity,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<w:smartTag>` 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,
};
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

/**
* Convert SDT child elements into Word run elements.
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { config, translator } from './smartTag-translator.js';
Original file line number Diff line number Diff line change
@@ -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 <w:smartTag>', () => {
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');
});
});
Loading
Loading