diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index f632d4d2b..889ebfadc 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { collapseWhitespaceNextToInlinePassthrough, + defaultNodeListHandler, filterOutRootInlineNodes, normalizeTableBookmarksInContent, } from './docxImporter.js'; @@ -324,3 +325,162 @@ describe('normalizeTableBookmarksInContent', () => { expect(innerCellParagraphContent[2]).toMatchObject({ type: 'bookmarkEnd', attrs: { id: 'n1' } }); }); }); + +describe('docPartObj paragraph import regression', () => { + const createEditorStub = () => ({ + schema: { + nodes: { + run: { isInline: true, spec: { group: 'inline' } }, + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }); + + it('hoists a docPartObj SDT out of paragraph inline content', () => { + const nodeListHandler = defaultNodeListHandler(); + const paragraphNode = { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'AAA111' }, + elements: [ + { + name: 'w:sdt', + elements: [ + { + name: 'w:sdtPr', + elements: [ + { name: 'w:id', attributes: { 'w:val': '123456789' } }, + { + name: 'w:docPartObj', + elements: [ + { name: 'w:docPartGallery', attributes: { 'w:val': 'Table of Figures' } }, + { name: 'w:docPartUnique' }, + ], + }, + ], + }, + { + name: 'w:sdtContent', + elements: [ + { + name: 'w:p', + attributes: { 'w14:paraId': '11111111', 'w14:textId': '11111111' }, + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Table of Figures' }] }], + }, + ], + }, + { + name: 'w:p', + attributes: { 'w14:paraId': '22222222', 'w14:textId': '22222222' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Figure 1' }] }] }, + { name: 'w:r', elements: [{ name: 'w:tab' }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = nodeListHandler.handler({ + nodes: [paragraphNode], + docx: {}, + editor: createEditorStub(), + path: [], + }); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('documentPartObject'); + expect(result[0].attrs).toMatchObject({ + id: '123456789', + docPartGallery: 'Table of Figures', + docPartUnique: true, + }); + expect(result[0].content).toHaveLength(2); + expect(result[0].content[0].type).toBe('paragraph'); + expect(result[0].content[1].type).toBe('paragraph'); + }); + + it('splits inline text around a docPartObj SDT into sibling paragraphs', () => { + const nodeListHandler = defaultNodeListHandler(); + const paragraphNode = { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'BBB222' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Before' }] }] }, + { + name: 'w:sdt', + elements: [ + { + name: 'w:sdtPr', + elements: [ + { name: 'w:id', attributes: { 'w:val': '123456789' } }, + { + name: 'w:docPartObj', + elements: [{ name: 'w:docPartGallery', attributes: { 'w:val': 'Table of Figures' } }], + }, + ], + }, + { + name: 'w:sdtContent', + elements: [ + { + name: 'w:p', + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Figure 1' }] }] }, + ], + }, + ], + }, + ], + }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'After' }] }] }, + ], + }; + + const result = nodeListHandler.handler({ + nodes: [paragraphNode], + docx: {}, + editor: createEditorStub(), + path: [], + }); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('paragraph'); + expect(result[0].content?.[0]?.type).toBe('run'); + expect(result[0].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'Before' }); + expect(result[1]).toMatchObject({ + type: 'documentPartObject', + attrs: { id: '123456789', docPartGallery: 'Table of Figures' }, + }); + expect(result[2].type).toBe('paragraph'); + expect(result[2].content?.[0]?.type).toBe('run'); + expect(result[2].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'After' }); + }); + + it('keeps normal paragraphs intact when schema metadata is unavailable', () => { + const nodeListHandler = defaultNodeListHandler(); + const paragraphNode = { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'CCC333' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Header text' }] }] }], + }; + + const result = nodeListHandler.handler({ + nodes: [paragraphNode], + docx: {}, + editor: {}, + path: [], + }); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('paragraph'); + expect(result[0].content?.[0]?.type).toBe('run'); + expect(result[0].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'Header text' }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index 243d5c342..999c38c3f 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -16,7 +16,7 @@ export const handleParagraphNode = (params) => { return { nodes: [], consumed: 0 }; } const schemaNode = wPNodeTranslator.encode(params); - const newNodes = schemaNode ? [schemaNode] : []; + const newNodes = Array.isArray(schemaNode) ? schemaNode : schemaNode ? [schemaNode] : []; return { nodes: newNodes, consumed: 1 }; }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js new file mode 100644 index 000000000..9e0e58111 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js @@ -0,0 +1,51 @@ +/** + * Determine whether a translated PM JSON node should be treated as inline. + * + * Falls back to known inline leaf types when schema metadata is unavailable. + * + * @param {unknown} node + * @param {import('prosemirror-model').Schema | undefined} schema + * @returns {boolean} + */ +const INLINE_FALLBACK_TYPES = new Set([ + 'text', + 'run', + 'bookmarkStart', + 'bookmarkEnd', + 'tab', + 'lineBreak', + 'hardBreak', + 'commentRangeStart', + 'commentRangeEnd', + 'commentReference', + 'permStart', + 'permEnd', + 'footnoteReference', + 'endnoteReference', + 'fieldAnnotation', + 'structuredContent', + 'passthroughInline', + 'page-number', + 'total-page-number', + 'pageReference', + 'crossReference', + 'citation', + 'authorityEntry', + 'sequenceField', + 'indexEntry', + 'tableOfContentsEntry', +]); + +export function isInlineNode(node, schema) { + if (!node || typeof node !== 'object' || typeof node.type !== 'string') return false; + + const nodeType = schema?.nodes?.[node.type]; + if (nodeType) { + if (typeof nodeType.isInline === 'boolean') return nodeType.isInline; + if (nodeType.spec?.group && typeof nodeType.spec.group === 'string') { + return nodeType.spec.group.split(' ').includes('inline'); + } + } + + return INLINE_FALLBACK_TYPES.has(node.type); +} diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js new file mode 100644 index 000000000..04bb399e2 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { isInlineNode } from './is-inline-node.js'; + +describe('isInlineNode', () => { + it('treats common importer inline nodes as inline without schema metadata', () => { + expect(isInlineNode({ type: 'text', text: 'x' })).toBe(true); + expect(isInlineNode({ type: 'run', content: [] })).toBe(true); + expect(isInlineNode({ type: 'bookmarkStart', attrs: { id: '1' } })).toBe(true); + expect(isInlineNode({ type: 'bookmarkEnd', attrs: { id: '1' } })).toBe(true); + expect(isInlineNode({ type: 'tab' })).toBe(true); + expect(isInlineNode({ type: 'footnoteReference', attrs: { id: '1' } })).toBe(true); + }); + + it('uses nodeType.isInline when available', () => { + const schema = { + nodes: { + mention: { isInline: true, spec: {} }, + table: { isInline: false, spec: {} }, + }, + }; + + expect(isInlineNode({ type: 'mention', attrs: { id: 'm1' } }, schema)).toBe(true); + expect(isInlineNode({ type: 'table', content: [] }, schema)).toBe(false); + }); + + it('falls back to schema group metadata when isInline is unavailable', () => { + const schema = { + nodes: { + customInline: { spec: { group: 'inline custom-inline' } }, + customBlock: { spec: { group: 'block' } }, + }, + }; + + expect(isInlineNode({ type: 'customInline' }, schema)).toBe(true); + expect(isInlineNode({ type: 'customBlock' }, schema)).toBe(false); + }); + + it('returns false for missing or unknown node types', () => { + expect(isInlineNode(null)).toBe(false); + expect(isInlineNode({})).toBe(false); + expect(isInlineNode({ type: 'unknownNode' }, { nodes: {} })).toBe(false); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 68913d2f9..50939d74e 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -3,6 +3,7 @@ import { mergeTextNodes } from '@converter/v2/importer/index.js'; import { parseProperties } from '@converter/v2/importer/importerHelpers.js'; import { resolveParagraphProperties } from '@converter/styles'; import { translator as w_pPrTranslator } from '@converter/v3/handlers/w/pPr'; +import { isInlineNode } from '../../../helpers/is-inline-node.js'; function getTableStyleId(path) { const tbl = path.find((ancestor) => ancestor.name === 'w:tbl'); @@ -20,13 +21,99 @@ function getTableStyleId(path) { return tblStyle.attributes?.['w:val']; } +function cloneParagraphAttrsForFragment(attrs, { keepSectPr = false } = {}) { + if (!attrs) return {}; + + const nextAttrs = { ...attrs }; + if (attrs.paragraphProperties && typeof attrs.paragraphProperties === 'object') { + nextAttrs.paragraphProperties = { ...attrs.paragraphProperties }; + if (!keepSectPr) { + delete nextAttrs.paragraphProperties.sectPr; + } + } + + if (!keepSectPr) { + delete nextAttrs.pageBreakSource; + } + + return nextAttrs; +} + +function hasSectionBreakAttrs(attrs) { + return Boolean(attrs?.paragraphProperties?.sectPr); +} + +function cloneWrapperParagraphAttrs(attrs) { + return cloneParagraphAttrsForFragment(attrs, { keepSectPr: true }); +} + +function normalizeParagraphChildren(children, schema, textblockAttrs) { + const normalized = []; + let pendingInline = []; + + const flushInline = () => { + if (!pendingInline.length) return; + normalized.push({ + type: 'paragraph', + attrs: null, + content: pendingInline, + marks: [], + }); + pendingInline = []; + }; + + for (const child of children || []) { + if (isInlineNode(child, schema)) { + pendingInline.push(child); + continue; + } + + flushInline(); + if (child != null) normalized.push(child); + } + + flushInline(); + + const lastNodeIndex = normalized.length - 1; + const isSingleBlockResult = normalized.length === 1 && normalized[0] != null && normalized[0]?.type !== 'paragraph'; + const paragraphIndexes = normalized.reduce((indexes, node, index) => { + if (node?.type === 'paragraph') indexes.push(index); + return indexes; + }, []); + const lastParagraphIndex = paragraphIndexes.length ? paragraphIndexes[paragraphIndexes.length - 1] : -1; + const shouldAttachWrapperParagraph = + isSingleBlockResult || (hasSectionBreakAttrs(textblockAttrs) && lastNodeIndex !== lastParagraphIndex); + + paragraphIndexes.forEach((index) => { + normalized[index] = { + ...normalized[index], + attrs: cloneParagraphAttrsForFragment(textblockAttrs, { + keepSectPr: !shouldAttachWrapperParagraph && index === lastParagraphIndex, + }), + }; + }); + + if (shouldAttachWrapperParagraph) { + const lastNode = normalized[lastNodeIndex]; + normalized[lastNodeIndex] = { + ...lastNode, + attrs: { + ...(lastNode?.attrs || {}), + wrapperParagraph: cloneWrapperParagraphAttrs(textblockAttrs), + }, + }; + } + + return normalized; +} + /** * Paragraph node handler * @param {import('@translator').SCEncoderConfig} params * @returns {Object} Handler result */ export const handleParagraphNode = (params) => { - const { nodes, nodeListHandler, filename } = params; + const { nodes, nodeListHandler, filename, editor } = params; const node = carbonCopy(nodes[0]); let schemaNode; @@ -78,14 +165,6 @@ export const handleParagraphNode = (params) => { schemaNode.attrs.rsidRDefault = node.attributes?.['w:rsidRDefault']; schemaNode.attrs.filename = filename; - // Normalize text nodes. - if (schemaNode && schemaNode.content) { - schemaNode = { - ...schemaNode, - content: mergeTextNodes(schemaNode.content), - }; - } - // Pass through this paragraph's sectPr, if any const sectPr = pPr?.elements?.find((el) => el.name === 'w:sectPr'); if (sectPr) { @@ -93,5 +172,26 @@ export const handleParagraphNode = (params) => { schemaNode.attrs.pageBreakSource = 'sectPr'; } - return schemaNode; + const normalizedNodes = normalizeParagraphChildren(schemaNode.content, editor?.schema, schemaNode.attrs).map( + (node) => { + if (node?.type !== 'paragraph' || !Array.isArray(node.content)) return node; + return { + ...node, + content: mergeTextNodes(node.content), + }; + }, + ); + + if (!normalizedNodes.length) { + return { + ...schemaNode, + content: mergeTextNodes(schemaNode.content || []), + }; + } + + if (normalizedNodes.length === 1 && normalizedNodes[0]?.type === 'paragraph') { + return normalizedNodes[0]; + } + + return normalizedNodes; }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js index ad4011708..d77d58ebd 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js @@ -157,6 +157,7 @@ describe('legacy-handle-paragraph-node', () => { }, ], }, + { name: 'w:r', elements: [] }, ]; const out = handleParagraphNode(params); @@ -169,4 +170,267 @@ describe('legacy-handle-paragraph-node', () => { { tab: { tabType: 'center', pos: undefined } }, ]); }); + + it('preserves sectPr on wrapper metadata when translated paragraph content is block-only', () => { + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + const sectPr = { name: 'w:sectPr', elements: [] }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [docPart], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { + name: 'w:pPr', + elements: [{ name: 'w:sectPr', elements: [] }], + }, + { name: 'w:sdt', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out).toMatchObject([ + { + ...docPart, + attrs: { + ...docPart.attrs, + wrapperParagraph: { + filename: 'source.docx', + pageBreakSource: 'sectPr', + paragraphProperties: { + sectPr, + }, + rsidRDefault: 'ABCDEF', + }, + }, + }, + ]); + expect(mergeTextNodes).not.toHaveBeenCalled(); + }); + + it('preserves wrapper paragraph formatting when translated paragraph content is block-only', () => { + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [docPart], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { + name: 'w:pPr', + elements: [ + { name: 'w:pStyle', attributes: { 'w:val': 'TOCHeading' } }, + { name: 'w:spacing', attributes: { 'w:after': '120', 'w:line': '240', 'w:lineRule': 'auto' } }, + { name: 'w:keepNext', attributes: { 'w:val': 'true' } }, + ], + }, + { name: 'w:sdt', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out).toMatchObject([ + { + ...docPart, + attrs: { + ...docPart.attrs, + wrapperParagraph: { + filename: 'source.docx', + rsidRDefault: 'ABCDEF', + paragraphProperties: { + styleId: 'TOCHeading', + keepNext: true, + spacing: { after: 120, line: 240, lineRule: 'auto' }, + }, + }, + }, + }, + ]); + }); + + it('splits mixed inline and block children into sibling paragraph and block nodes', () => { + mergeTextNodes.mockImplementation((content) => content); + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [{ type: 'text', text: 'Before' }, docPart, { type: 'text', text: 'After' }], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { name: 'w:r', elements: [] }, + { name: 'w:sdt', elements: [] }, + { name: 'w:r', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out).toEqual([ + expect.objectContaining({ + type: 'paragraph', + content: [{ type: 'text', text: 'Before' }], + }), + docPart, + expect.objectContaining({ + type: 'paragraph', + content: [{ type: 'text', text: 'After' }], + }), + ]); + expect(mergeTextNodes).toHaveBeenCalledTimes(2); + }); + + it('keeps sectPr on the last paragraph fragment when content continues after a hoisted block', () => { + mergeTextNodes.mockImplementation((content) => content); + const sectPr = { name: 'w:sectPr', elements: [] }; + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [{ type: 'text', text: 'Before' }, docPart, { type: 'text', text: 'After' }], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { + name: 'w:pPr', + elements: [{ name: 'w:sectPr', elements: [] }], + }, + { name: 'w:r', elements: [] }, + { name: 'w:sdt', elements: [] }, + { name: 'w:r', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out[0].type).toBe('paragraph'); + expect(out[0].attrs).not.toHaveProperty('pageBreakSource'); + expect(out[0].attrs.paragraphProperties).not.toHaveProperty('sectPr'); + expect(out[1]).toEqual(docPart); + expect(out[2]).toMatchObject({ + type: 'paragraph', + attrs: { + pageBreakSource: 'sectPr', + paragraphProperties: { + sectPr, + }, + }, + }); + }); + + it('stores sectPr on a trailing block when it is the last emitted node', () => { + mergeTextNodes.mockImplementation((content) => content); + const sectPr = { name: 'w:sectPr', elements: [] }; + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [{ type: 'text', text: 'Before' }, docPart], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { + name: 'w:pPr', + elements: [{ name: 'w:sectPr', elements: [] }], + }, + { name: 'w:r', elements: [] }, + { name: 'w:sdt', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out[0]).toMatchObject({ + type: 'paragraph', + attrs: { + paragraphProperties: {}, + }, + }); + expect(out[0].attrs).not.toHaveProperty('pageBreakSource'); + expect(out[1]).toMatchObject({ + type: 'documentPartObject', + attrs: { + wrapperParagraph: { + pageBreakSource: 'sectPr', + paragraphProperties: { + sectPr, + }, + }, + }, + }); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js index 7b2f815d6..383a4c763 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js @@ -10,6 +10,23 @@ const XML_NODE_NAME = 'w:p'; /** @type {import('@translator').SuperDocNodeOrKeyName} */ const SD_NODE_NAME = 'paragraph'; +const IDENTITY_ATTR_NAMES = new Set(['paraId', 'textId']); + +function partitionEncodedParagraphAttrs(encodedAttrs = {}) { + const identityAttrs = {}; + const shareableAttrs = {}; + + Object.entries(encodedAttrs).forEach(([key, value]) => { + if (IDENTITY_ATTR_NAMES.has(key)) { + identityAttrs[key] = value; + return; + } + shareableAttrs[key] = value; + }); + + return { identityAttrs, shareableAttrs }; +} + /** * Encode a node as a SuperDoc paragraph node. * @param {import('@translator').SCEncoderConfig} params @@ -21,6 +38,24 @@ const encode = (params, encodedAttrs = {}) => { const node = legacyHandleParagraphNode(params); if (!node) return undefined; if (encodedAttrs && Object.keys(encodedAttrs).length) { + if (Array.isArray(node)) { + const { identityAttrs, shareableAttrs } = partitionEncodedParagraphAttrs(encodedAttrs); + let appliedIdentityAttrs = false; + + return node.map((child) => { + if (child?.type !== 'paragraph') return child; + const attrs = { ...(child.attrs || {}), ...shareableAttrs }; + if (!appliedIdentityAttrs) { + Object.assign(attrs, identityAttrs); + appliedIdentityAttrs = true; + } + + return { + ...child, + attrs, + }; + }); + } node.attrs = { ...node.attrs, ...encodedAttrs }; } return node; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js index f2b41d63e..8a8d36708 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js @@ -103,6 +103,62 @@ describe('w/p p-translator', () => { }); }); + it('encode() applies identity attrs only to the first paragraph fragment in split results', () => { + handleParagraphNode.mockReturnValueOnce([ + { type: 'paragraph', attrs: { fromLegacy: true }, content: [] }, + { type: 'documentPartObject', attrs: { id: '123' }, content: [] }, + { type: 'paragraph', attrs: { trailing: true }, content: [] }, + ]); + + const result = translator.encode({ + nodes: [{ name: 'w:p', attributes: { 'w14:paraId': 'X' } }], + docx: {}, + nodeListHandler: { handlerEntities: [] }, + }); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'paragraph', + attrs: expect.objectContaining({ + fromLegacy: true, + paraId: 'ENC_PARAID', + textId: 'ENC_TEXTID', + rsidR: 'ENC_RSIDR', + rsidRDefault: 'ENC_RSIDRDEF', + rsidP: 'ENC_RSIDP', + rsidRPr: 'ENC_RSIDRPR', + rsidDel: 'ENC_RSIDDEL', + }), + }), + { type: 'documentPartObject', attrs: { id: '123' }, content: [] }, + expect.objectContaining({ + type: 'paragraph', + attrs: expect.objectContaining({ + trailing: true, + rsidR: 'ENC_RSIDR', + rsidRDefault: 'ENC_RSIDRDEF', + rsidP: 'ENC_RSIDP', + rsidRPr: 'ENC_RSIDRPR', + rsidDel: 'ENC_RSIDDEL', + }), + }), + ]); + expect(result[2].attrs.paraId).toBeUndefined(); + expect(result[2].attrs.textId).toBeUndefined(); + }); + + it('encode() does not stamp paragraph identity attrs onto block-only results', () => { + handleParagraphNode.mockReturnValueOnce([{ type: 'documentPartObject', attrs: { id: '123' }, content: [] }]); + + const result = translator.encode({ + nodes: [{ name: 'w:p', attributes: { 'w14:paraId': 'X' } }], + docx: {}, + nodeListHandler: { handlerEntities: [] }, + }); + + expect(result).toEqual([{ type: 'documentPartObject', attrs: { id: '123' }, content: [] }]); + }); + it('decode() delegates to exporter and merges decoded attributes', () => { const params = { node: { type: 'paragraph', attrs: { any: 'thing' } }, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js index c63282113..91f581134 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js @@ -1,4 +1,6 @@ import { translateChildNodes } from '@converter/v2/exporter/helpers/translateChildNodes'; +import { generateParagraphProperties } from '../../p/helpers/generate-paragraph-properties.js'; +import paragraphXmlAttributes from '../../p/attributes/index.js'; /** * Translate a document part object node to its XML representation. @@ -27,7 +29,56 @@ export function translateDocumentPartObj(params) { elements: nodeElements, }; - return result; + if (!attrs.wrapperParagraph) { + return result; + } + + return wrapDocumentPartInParagraph(result, attrs.wrapperParagraph); +} + +function wrapDocumentPartInParagraph(sdtNode, wrapperParagraphAttrs) { + const elements = []; + const pPr = generateParagraphProperties({ + node: { + type: 'paragraph', + attrs: wrapperParagraphAttrs, + }, + }); + + if (pPr) { + elements.push(pPr); + } + elements.push(sdtNode); + + const attributes = { + ...extractRawParagraphXmlAttributes(wrapperParagraphAttrs), + ...decodeParagraphXmlAttributes(wrapperParagraphAttrs), + }; + + return { + name: 'w:p', + elements, + ...(Object.keys(attributes).length ? { attributes } : {}), + }; +} + +function extractRawParagraphXmlAttributes(attrs = {}) { + return Object.fromEntries(Object.entries(attrs).filter(([key]) => key.includes(':'))); +} + +function decodeParagraphXmlAttributes(attrs = {}) { + const decoded = {}; + + paragraphXmlAttributes.forEach(({ xmlName, decode }) => { + if (!decode) return; + + const value = decode(attrs); + if (value !== undefined && value !== null) { + decoded[xmlName] = value; + } + }); + + return decoded; } /** diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js index f56936350..4f0665840 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js @@ -33,4 +33,102 @@ describe('translateDocumentPartObj', () => { ), ).toBeUndefined(); }); + + it('rewraps the document part in a paragraph when wrapper paragraph attrs are present', () => { + const sectPr = { name: 'w:sectPr', elements: [] }; + const node = { + type: 'documentPartObject', + content: [], + attrs: { + id: '123', + docPartGallery: 'Table of Figures', + docPartUnique: true, + wrapperParagraph: { + paraId: '41964671', + textId: '04598795', + rsidR: '00233D7B', + rsidRDefault: 'ABCDEF', + rsidP: '003104CE', + rsidRPr: '003104CE', + rsidDel: '00DEAD00', + pageBreakSource: 'sectPr', + paragraphProperties: { + sectPr, + }, + }, + }, + }; + + const result = translateDocumentPartObj({ node }); + + expect(result.name).toBe('w:p'); + expect(result.attributes).toEqual({ + 'w14:paraId': '41964671', + 'w14:textId': '04598795', + 'w:rsidR': '00233D7B', + 'w:rsidRDefault': 'ABCDEF', + 'w:rsidP': '003104CE', + 'w:rsidRPr': '003104CE', + 'w:rsidDel': '00DEAD00', + }); + expect(result.elements[0]).toMatchObject({ + name: 'w:pPr', + elements: [sectPr], + }); + expect(result.elements[1]).toMatchObject({ + name: 'w:sdt', + }); + }); + + it('rewraps the document part in a paragraph for non-sectPr wrapper formatting', () => { + const node = { + type: 'documentPartObject', + content: [], + attrs: { + id: '123', + docPartGallery: 'Table of Figures', + docPartUnique: true, + wrapperParagraph: { + paraId: '41964671', + textId: '04598795', + rsidR: '00233D7B', + rsidRDefault: 'ABCDEF', + rsidP: '003104CE', + rsidRPr: '003104CE', + paragraphProperties: { + styleId: 'TOCHeading', + keepNext: true, + }, + }, + }, + }; + + const result = translateDocumentPartObj({ node }); + + expect(result.name).toBe('w:p'); + expect(result.attributes).toEqual({ + 'w14:paraId': '41964671', + 'w14:textId': '04598795', + 'w:rsidR': '00233D7B', + 'w:rsidRDefault': 'ABCDEF', + 'w:rsidP': '003104CE', + 'w:rsidRPr': '003104CE', + }); + expect(result.elements[0]).toMatchObject({ + name: 'w:pPr', + elements: expect.arrayContaining([ + { + name: 'w:pStyle', + attributes: { 'w:val': 'TOCHeading' }, + }, + { + name: 'w:keepNext', + attributes: {}, + }, + ]), + }); + expect(result.elements[1]).toMatchObject({ + name: 'w:sdt', + }); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js index a81322d9d..6a7da9162 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js @@ -1,5 +1,6 @@ import { twipsToPixels, resolveShadingFillColor } from '@converter/helpers'; import { translator as tcPrTranslator } from '../../tcPr'; +import { isInlineNode } from '../../../helpers/is-inline-node.js'; /** * @param {Object} options @@ -150,7 +151,6 @@ function normalizeTableCellContent(content, editor) { const normalized = []; const pendingForNextBlock = []; - const schema = editor?.schema; const cloneBlock = (node) => { if (!node) return node; @@ -170,29 +170,13 @@ function normalizeTableCellContent(content, editor) { return node.content; }; - const isInlineNode = (node) => { - if (!node || typeof node.type !== 'string') return false; - if (node.type === 'text') return true; - if (node.type === 'bookmarkStart' || node.type === 'bookmarkEnd') return true; - - const nodeType = schema?.nodes?.[node.type]; - if (nodeType) { - if (typeof nodeType.isInline === 'boolean') return nodeType.isInline; - if (nodeType.spec?.group && typeof nodeType.spec.group === 'string') { - return nodeType.spec.group.split(' ').includes('inline'); - } - } - - return false; - }; - for (const node of content) { if (!node || typeof node.type !== 'string') { normalized.push(node); continue; } - if (!isInlineNode(node)) { + if (!isInlineNode(node, editor?.schema)) { const blockNode = cloneBlock(node); if (pendingForNextBlock.length) { const blockContent = ensureArray(blockNode); @@ -212,7 +196,7 @@ function normalizeTableCellContent(content, editor) { } else { const lastIndex = normalized.length - 1; const lastNode = normalized[lastIndex]; - if (!lastNode || typeof lastNode.type !== 'string' || isInlineNode(lastNode)) { + if (!lastNode || typeof lastNode.type !== 'string' || isInlineNode(lastNode, editor?.schema)) { pendingForNextBlock.push(node); continue; } @@ -229,7 +213,7 @@ function normalizeTableCellContent(content, editor) { if (normalized.length) { const lastIndex = normalized.length - 1; const lastNode = normalized[lastIndex]; - if (lastNode && typeof lastNode.type === 'string' && !isInlineNode(lastNode)) { + if (lastNode && typeof lastNode.type === 'string' && !isInlineNode(lastNode, editor?.schema)) { const blockContent = ensureArray(lastNode); blockContent.push(...pendingForNextBlock); pendingForNextBlock.length = 0; diff --git a/packages/super-editor/src/extensions/structured-content/document-part-object.js b/packages/super-editor/src/extensions/structured-content/document-part-object.js index cf0c50857..9414a8155 100644 --- a/packages/super-editor/src/extensions/structured-content/document-part-object.js +++ b/packages/super-editor/src/extensions/structured-content/document-part-object.js @@ -45,6 +45,9 @@ export const DocumentPartObject = Node.create({ docPartUnique: { default: true, }, + wrapperParagraph: { + default: null, + }, }; }, }); diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index fa14eb46c..08bb8e292 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -1069,6 +1069,8 @@ export interface DocumentPartObjectAttrs extends BlockNodeAttributes { docPartGallery?: unknown; /** Whether document part is unique */ docPartUnique?: boolean; + /** @internal Original wrapper paragraph attrs for export preservation */ + wrapperParagraph?: unknown; } // ============================================