Skip to content

Commit 24890a1

Browse files
fix: import of w:sdt nested in paragraph (SD-2190) (#2380)
* refactor: extract helper for identifying inline nodes * fix(super-converter): preserve paragraph attrs across fragment output Allow paragraph translators to return fragment arrays during import and normalize them in the v2 paragraph importer. When the legacy paragraph handler returns mixed fragment output, apply encoded paragraph attributes only to paragraph nodes so embedded documentPartObject fragments remain unchanged. Add coverage for the array-return path in the paragraph translator tests. * fix(super-editor): hoist docPart SDTs out of paragraph inline content * fix(super-editor): preserve inline paragraph content without schema metadata * fix(super-editor): keep sectPr on the last split paragraph fragment * fix(super-editor): keep paraId/textId on only one split paragraph * fix(super-editor): preserve sectPr after hoisted docPart blocks * fix(super-editor): preserve wrapper paragraph formatting for block docParts * fix(super-editor): preserve paragraph XML attrs on wrapped docPart export * fix(super-editor): default docPartUnique to false per OOXML spec Per §17.5.2.14, the absence of w:docPartUnique means the SDT is not a built-in document part container. Defaulting to true caused the exporter to emit <w:docPartUnique/> on every SDT even when the original XML didn't have it.
1 parent da328ee commit 24890a1

13 files changed

Lines changed: 865 additions & 33 deletions

File tree

packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import {
33
collapseWhitespaceNextToInlinePassthrough,
4+
defaultNodeListHandler,
45
filterOutRootInlineNodes,
56
normalizeTableBookmarksInContent,
67
} from './docxImporter.js';
@@ -324,3 +325,162 @@ describe('normalizeTableBookmarksInContent', () => {
324325
expect(innerCellParagraphContent[2]).toMatchObject({ type: 'bookmarkEnd', attrs: { id: 'n1' } });
325326
});
326327
});
328+
329+
describe('docPartObj paragraph import regression', () => {
330+
const createEditorStub = () => ({
331+
schema: {
332+
nodes: {
333+
run: { isInline: true, spec: { group: 'inline' } },
334+
documentPartObject: { isInline: false, spec: { group: 'block' } },
335+
},
336+
},
337+
});
338+
339+
it('hoists a docPartObj SDT out of paragraph inline content', () => {
340+
const nodeListHandler = defaultNodeListHandler();
341+
const paragraphNode = {
342+
name: 'w:p',
343+
attributes: { 'w:rsidRDefault': 'AAA111' },
344+
elements: [
345+
{
346+
name: 'w:sdt',
347+
elements: [
348+
{
349+
name: 'w:sdtPr',
350+
elements: [
351+
{ name: 'w:id', attributes: { 'w:val': '123456789' } },
352+
{
353+
name: 'w:docPartObj',
354+
elements: [
355+
{ name: 'w:docPartGallery', attributes: { 'w:val': 'Table of Figures' } },
356+
{ name: 'w:docPartUnique' },
357+
],
358+
},
359+
],
360+
},
361+
{
362+
name: 'w:sdtContent',
363+
elements: [
364+
{
365+
name: 'w:p',
366+
attributes: { 'w14:paraId': '11111111', 'w14:textId': '11111111' },
367+
elements: [
368+
{
369+
name: 'w:r',
370+
elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Table of Figures' }] }],
371+
},
372+
],
373+
},
374+
{
375+
name: 'w:p',
376+
attributes: { 'w14:paraId': '22222222', 'w14:textId': '22222222' },
377+
elements: [
378+
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Figure 1' }] }] },
379+
{ name: 'w:r', elements: [{ name: 'w:tab' }] },
380+
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] },
381+
],
382+
},
383+
],
384+
},
385+
],
386+
},
387+
],
388+
};
389+
390+
const result = nodeListHandler.handler({
391+
nodes: [paragraphNode],
392+
docx: {},
393+
editor: createEditorStub(),
394+
path: [],
395+
});
396+
397+
expect(result).toHaveLength(1);
398+
expect(result[0].type).toBe('documentPartObject');
399+
expect(result[0].attrs).toMatchObject({
400+
id: '123456789',
401+
docPartGallery: 'Table of Figures',
402+
docPartUnique: true,
403+
});
404+
expect(result[0].content).toHaveLength(2);
405+
expect(result[0].content[0].type).toBe('paragraph');
406+
expect(result[0].content[1].type).toBe('paragraph');
407+
});
408+
409+
it('splits inline text around a docPartObj SDT into sibling paragraphs', () => {
410+
const nodeListHandler = defaultNodeListHandler();
411+
const paragraphNode = {
412+
name: 'w:p',
413+
attributes: { 'w:rsidRDefault': 'BBB222' },
414+
elements: [
415+
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Before' }] }] },
416+
{
417+
name: 'w:sdt',
418+
elements: [
419+
{
420+
name: 'w:sdtPr',
421+
elements: [
422+
{ name: 'w:id', attributes: { 'w:val': '123456789' } },
423+
{
424+
name: 'w:docPartObj',
425+
elements: [{ name: 'w:docPartGallery', attributes: { 'w:val': 'Table of Figures' } }],
426+
},
427+
],
428+
},
429+
{
430+
name: 'w:sdtContent',
431+
elements: [
432+
{
433+
name: 'w:p',
434+
elements: [
435+
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Figure 1' }] }] },
436+
],
437+
},
438+
],
439+
},
440+
],
441+
},
442+
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'After' }] }] },
443+
],
444+
};
445+
446+
const result = nodeListHandler.handler({
447+
nodes: [paragraphNode],
448+
docx: {},
449+
editor: createEditorStub(),
450+
path: [],
451+
});
452+
453+
expect(result).toHaveLength(3);
454+
expect(result[0].type).toBe('paragraph');
455+
expect(result[0].content?.[0]?.type).toBe('run');
456+
expect(result[0].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'Before' });
457+
expect(result[1]).toMatchObject({
458+
type: 'documentPartObject',
459+
attrs: { id: '123456789', docPartGallery: 'Table of Figures' },
460+
});
461+
expect(result[2].type).toBe('paragraph');
462+
expect(result[2].content?.[0]?.type).toBe('run');
463+
expect(result[2].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'After' });
464+
});
465+
466+
it('keeps normal paragraphs intact when schema metadata is unavailable', () => {
467+
const nodeListHandler = defaultNodeListHandler();
468+
const paragraphNode = {
469+
name: 'w:p',
470+
attributes: { 'w:rsidRDefault': 'CCC333' },
471+
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Header text' }] }] }],
472+
};
473+
474+
const result = nodeListHandler.handler({
475+
nodes: [paragraphNode],
476+
docx: {},
477+
editor: {},
478+
path: [],
479+
});
480+
481+
expect(result).toHaveLength(1);
482+
expect(result[0].type).toBe('paragraph');
483+
expect(result[0].content?.[0]?.type).toBe('run');
484+
expect(result[0].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'Header text' });
485+
});
486+
});

packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const handleParagraphNode = (params) => {
6464
}
6565

6666
const schemaNode = wPNodeTranslator.encode(params);
67-
const newNodes = schemaNode ? [schemaNode] : [];
67+
const newNodes = Array.isArray(schemaNode) ? schemaNode : schemaNode ? [schemaNode] : [];
6868
return { nodes: newNodes, consumed: 1 };
6969
};
7070

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Determine whether a translated PM JSON node should be treated as inline.
3+
*
4+
* Falls back to known inline leaf types when schema metadata is unavailable.
5+
*
6+
* @param {unknown} node
7+
* @param {import('prosemirror-model').Schema | undefined} schema
8+
* @returns {boolean}
9+
*/
10+
const INLINE_FALLBACK_TYPES = new Set([
11+
'text',
12+
'run',
13+
'bookmarkStart',
14+
'bookmarkEnd',
15+
'tab',
16+
'lineBreak',
17+
'hardBreak',
18+
'commentRangeStart',
19+
'commentRangeEnd',
20+
'commentReference',
21+
'permStart',
22+
'permEnd',
23+
'footnoteReference',
24+
'endnoteReference',
25+
'fieldAnnotation',
26+
'structuredContent',
27+
'passthroughInline',
28+
'page-number',
29+
'total-page-number',
30+
'pageReference',
31+
'crossReference',
32+
'citation',
33+
'authorityEntry',
34+
'sequenceField',
35+
'indexEntry',
36+
'tableOfContentsEntry',
37+
]);
38+
39+
export function isInlineNode(node, schema) {
40+
if (!node || typeof node !== 'object' || typeof node.type !== 'string') return false;
41+
42+
const nodeType = schema?.nodes?.[node.type];
43+
if (nodeType) {
44+
if (typeof nodeType.isInline === 'boolean') return nodeType.isInline;
45+
if (nodeType.spec?.group && typeof nodeType.spec.group === 'string') {
46+
return nodeType.spec.group.split(' ').includes('inline');
47+
}
48+
}
49+
50+
return INLINE_FALLBACK_TYPES.has(node.type);
51+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { isInlineNode } from './is-inline-node.js';
3+
4+
describe('isInlineNode', () => {
5+
it('treats common importer inline nodes as inline without schema metadata', () => {
6+
expect(isInlineNode({ type: 'text', text: 'x' })).toBe(true);
7+
expect(isInlineNode({ type: 'run', content: [] })).toBe(true);
8+
expect(isInlineNode({ type: 'bookmarkStart', attrs: { id: '1' } })).toBe(true);
9+
expect(isInlineNode({ type: 'bookmarkEnd', attrs: { id: '1' } })).toBe(true);
10+
expect(isInlineNode({ type: 'tab' })).toBe(true);
11+
expect(isInlineNode({ type: 'footnoteReference', attrs: { id: '1' } })).toBe(true);
12+
});
13+
14+
it('uses nodeType.isInline when available', () => {
15+
const schema = {
16+
nodes: {
17+
mention: { isInline: true, spec: {} },
18+
table: { isInline: false, spec: {} },
19+
},
20+
};
21+
22+
expect(isInlineNode({ type: 'mention', attrs: { id: 'm1' } }, schema)).toBe(true);
23+
expect(isInlineNode({ type: 'table', content: [] }, schema)).toBe(false);
24+
});
25+
26+
it('falls back to schema group metadata when isInline is unavailable', () => {
27+
const schema = {
28+
nodes: {
29+
customInline: { spec: { group: 'inline custom-inline' } },
30+
customBlock: { spec: { group: 'block' } },
31+
},
32+
};
33+
34+
expect(isInlineNode({ type: 'customInline' }, schema)).toBe(true);
35+
expect(isInlineNode({ type: 'customBlock' }, schema)).toBe(false);
36+
});
37+
38+
it('returns false for missing or unknown node types', () => {
39+
expect(isInlineNode(null)).toBe(false);
40+
expect(isInlineNode({})).toBe(false);
41+
expect(isInlineNode({ type: 'unknownNode' }, { nodes: {} })).toBe(false);
42+
});
43+
});

0 commit comments

Comments
 (0)