From 4079a4e66e6acde89c97ea6339da7d75a4c2aa40 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 5 May 2026 17:59:11 -0300 Subject: [PATCH 1/9] fix(converter): preserve tracked-change-wrapped fields split across paragraphs When a field (w:fldChar begin/separate/end) is wrapped in a w:del or w:ins tracked-change element and split across paragraphs, the field pre-processor would crash on the unpaired end or strip the tracked-change wrapper. Detect track-change wrappers and flag the field as `preserveRaw` so the raw nodes are emitted untouched, and handle an unpaired end at the top level by passing the node through with `unpairedEnd = true`. --- .../preProcessNodesForFldChar.js | 31 ++++++--- .../preProcessNodesForFldChar.test.js | 69 +++++++++++++++++++ 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 3cb85fb42a..94543ee197 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -7,10 +7,11 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); +const isTrackChangeWrapper = (node) => node?.name === 'w:del' || node?.name === 'w:ins'; /** * @typedef {object} FldCharProcessResult * @property {OpenXmlNode[]} processedNodes - The list of nodes after processing. - * @property {Array<{nodes: OpenXmlNode[], fieldInfo: {instrText: string, instructionTokens?: Array<{type: string, text?: string}>}}>| null} unpairedBegin - If a field 'begin' was found without a matching 'end'. Contains the current field data. + * @property {Array<{nodes: OpenXmlNode[], fieldInfo: {instrText: string, instructionTokens?: Array<{type: string, text?: string}>, afterSeparate?: boolean, preserveRaw?: boolean}}>| null} unpairedBegin - If a field 'begin' was found without a matching 'end'. Contains the current field data. * @property {boolean | null} unpairedEnd - If a field 'end' was found without a matching 'begin'. */ @@ -48,13 +49,15 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const rawCollectedNodes = rawCollectedNodesStack.pop().filter((n) => n !== null); const fieldRunRPr = fieldRunRPrStack.pop() ?? null; const currentField = currentFieldStack.pop(); - const combinedResult = _processCombinedNodesForFldChar( - collectedNodes, - currentField.instrText.trim(), - docx, - currentField.instructionTokens, - fieldRunRPr, - ); + const combinedResult = currentField.preserveRaw + ? { nodes: rawCollectedNodes, handled: false } + : _processCombinedNodesForFldChar( + collectedNodes, + currentField.instrText.trim(), + docx, + currentField.instructionTokens, + fieldRunRPr, + ); const outputNodes = combinedResult.handled ? combinedResult.nodes : rawCollectedNodes; if (collectedNodesStack.length === 0) { // We have completed a top-level field, add the combined nodes to the output. @@ -205,7 +208,11 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { if (childResult.unpairedBegin) { // A field started in the children, so this node is part of that field. childResult.unpairedBegin.forEach((pendingField) => { - currentFieldStack.push(pendingField.fieldInfo); + const fieldInfo = { ...pendingField.fieldInfo }; + if (fieldInfo.preserveRaw || isTrackChangeWrapper(node)) { + fieldInfo.preserveRaw = true; + } + currentFieldStack.push(fieldInfo); // The current node should be added to the collected nodes collectedNodesStack.push([node]); @@ -216,6 +223,12 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { }); } else if (childResult.unpairedEnd) { // A field from this level or higher ended in the children. + if (collectedNodesStack.length === 0) { + processedNodes.push(node); + unpairedEnd = true; + return; + } + collectedNodesStack[collectedNodesStack.length - 1].push(node); captureRawNodeForCurrentField(rawNode, capturedRawNodes, rawSourceToken); finalizeField(); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 9ccc1f4f60..dbd0fc321f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -303,6 +303,75 @@ describe('preProcessNodesForFldChar', () => { ]); }); + it('preserves a tracked-deletion-wrapped field split across paragraphs without throwing', () => { + const expectedNodes = [ + { + name: 'w:p', + elements: [ + { + name: 'w:del', + attributes: { 'w:id': '1', 'w:author': 'Repro', 'w:date': '2026-04-30T00:00:00Z' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [ + { + name: 'w:instrText', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: ' HYPERLINK \\l "Bookmark" ' }], + }, + ], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { + name: 'w:r', + elements: [ + { + name: 'w:delText', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: 'deleted link text' }], + }, + ], + }, + ], + }, + ], + }, + { + name: 'w:p', + elements: [ + { + name: 'w:del', + attributes: { 'w:id': '2', 'w:author': 'Repro', 'w:date': '2026-04-30T00:00:00Z' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + { + name: 'w:r', + elements: [ + { + name: 'w:delText', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: 'deleted text after field end' }], + }, + ], + }, + ], + }, + ], + }, + ]; + const nodes = structuredClone(expectedNodes); + + let result; + expect(() => { + result = preProcessNodesForFldChar(nodes, mockDocx); + }).not.toThrow(); + expect(result.processedNodes).toEqual(expectedNodes); + expect(result.unpairedBegin).toBeNull(); + expect(result.unpairedEnd).toBeNull(); + }); + it('should handle unpaired begin', () => { const nodes = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, From 6152fe30f0f6a4d2cc24d7a1cbaa4580c861de76 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 5 May 2026 18:01:03 -0300 Subject: [PATCH 2/9] fix(converter): null-safe header/footer relationship lookup (SD-2858) Guard against a missing `Relationships` element and entries without an `attributes` map when scanning `document.xml.rels` for headers/footers, so docs that omit either no longer throw during import. --- .../v1/core/super-converter/v2/importer/docxImporter.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 8de6cd9b36..6efb344895 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 @@ -681,12 +681,12 @@ export function addDefaultStylesIfMissing(styles) { const importHeadersFooters = (docx, converter, mainEditor, numbering, translatedNumbering, translatedLinkedStyles) => { const rels = docx['word/_rels/document.xml.rels']; const relationships = rels?.elements.find((el) => el.name === 'Relationships'); - const { elements } = relationships || { elements: [] }; + const elements = relationships?.elements ?? []; const headerType = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header'; const footerType = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer'; - const headers = elements.filter((el) => el.attributes['Type'] === headerType); - const footers = elements.filter((el) => el.attributes['Type'] === footerType); + const headers = elements.filter((el) => el.attributes?.['Type'] === headerType); + const footers = elements.filter((el) => el.attributes?.['Type'] === footerType); const sectPr = findSectPr(docx['word/document.xml']) || []; const allSectPrElements = sectPr.flatMap((el) => el.elements); From 22c728724209356972e885a56a2cf4f087701c88 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 6 May 2026 09:53:56 -0300 Subject: [PATCH 3/9] fix(converter): preserve raw field nodes when end fldChar is inside a tracked-change wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a field's `end` fldChar is wrapped in a tracked-change element (`w:del`/`w:ins`), the field cannot be safely re-emitted from the collected instruction tokens — round-tripping would drop the tracked deletion markup. Propagate a new `unpairedEndPreserveRaw` flag up through nested children so the active field is marked `preserveRaw` once it finalizes, keeping the original w:r/w:del nodes intact. --- .../preProcessNodesForFldChar.js | 9 +++- .../preProcessNodesForFldChar.test.js | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 94543ee197..f465a70add 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -13,6 +13,7 @@ const isTrackChangeWrapper = (node) => node?.name === 'w:del' || node?.name === * @property {OpenXmlNode[]} processedNodes - The list of nodes after processing. * @property {Array<{nodes: OpenXmlNode[], fieldInfo: {instrText: string, instructionTokens?: Array<{type: string, text?: string}>, afterSeparate?: boolean, preserveRaw?: boolean}}>| null} unpairedBegin - If a field 'begin' was found without a matching 'end'. Contains the current field data. * @property {boolean | null} unpairedEnd - If a field 'end' was found without a matching 'begin'. + * @property {boolean | null} unpairedEndPreserveRaw - If an unpaired field 'end' bubbled through a tracked-change wrapper. */ /** @@ -36,6 +37,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { let fieldRunRPrStack = []; let currentFieldStack = []; let unpairedEnd = null; + let unpairedEndPreserveRaw = null; let collecting = false; const rawNodeSourceTokens = new WeakMap(); @@ -223,12 +225,17 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { }); } else if (childResult.unpairedEnd) { // A field from this level or higher ended in the children. + const shouldPreserveRaw = childResult.unpairedEndPreserveRaw || isTrackChangeWrapper(node); if (collectedNodesStack.length === 0) { processedNodes.push(node); unpairedEnd = true; + if (shouldPreserveRaw) unpairedEndPreserveRaw = true; return; } + if (shouldPreserveRaw) { + currentFieldStack[currentFieldStack.length - 1].preserveRaw = true; + } collectedNodesStack[collectedNodesStack.length - 1].push(node); captureRawNodeForCurrentField(rawNode, capturedRawNodes, rawSourceToken); finalizeField(); @@ -272,7 +279,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { } } - return { processedNodes, unpairedBegin, unpairedEnd }; + return { processedNodes, unpairedBegin, unpairedEnd, unpairedEndPreserveRaw }; }; /** diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index dbd0fc321f..882374ca65 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -372,6 +372,52 @@ describe('preProcessNodesForFldChar', () => { expect(result.unpairedEnd).toBeNull(); }); + it('preserves raw field nodes when an active field ends inside a tracked deletion wrapper', () => { + const expectedNodes = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'HYPERLINK "http://example.com"' }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }, + { + name: 'w:p', + elements: [ + { + name: 'w:del', + attributes: { 'w:id': '1', 'w:author': 'Repro', 'w:date': '2026-04-30T00:00:00Z' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + { + name: 'w:r', + elements: [ + { + name: 'w:delText', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: 'deleted text after field end' }], + }, + ], + }, + ], + }, + ], + }, + ]; + const nodes = structuredClone(expectedNodes); + const docx = { + 'word/_rels/document.xml.rels': { + elements: [{ name: 'Relationships', elements: [] }], + }, + }; + const { processedNodes, unpairedBegin, unpairedEnd } = preProcessNodesForFldChar(nodes, docx); + + expect(processedNodes).toEqual(expectedNodes); + expect(unpairedBegin).toBeNull(); + expect(unpairedEnd).toBeNull(); + expect(docx['word/_rels/document.xml.rels'].elements[0].elements).toEqual([]); + }); + it('should handle unpaired begin', () => { const nodes = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, From 8350a49cbf98706084b922f00412c16192d54e72 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 6 May 2026 09:57:45 -0300 Subject: [PATCH 4/9] fix(converter): emit raw node when an unpaired field end bubbles up (SD-2858) When a field's `end` fldChar surfaces through nested wrappers (e.g. w:sdt/w:sdtContent or tracked-change elements) and there is no active field still collecting at this level, push the original `rawNode` rather than the processed `node`. The processed copy can have its child runs rewritten by the recursive pass, dropping the fldChar and breaking round-trip; the raw subtree preserves the input verbatim. Adds a regression test covering the non-collecting wrapper path. --- .../preProcessNodesForFldChar.js | 2 +- .../preProcessNodesForFldChar.test.js | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index f465a70add..f3845e7657 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -227,7 +227,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { // A field from this level or higher ended in the children. const shouldPreserveRaw = childResult.unpairedEndPreserveRaw || isTrackChangeWrapper(node); if (collectedNodesStack.length === 0) { - processedNodes.push(node); + processedNodes.push(rawNode); unpairedEnd = true; if (shouldPreserveRaw) unpairedEndPreserveRaw = true; return; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 882374ca65..2880948a98 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -418,6 +418,38 @@ describe('preProcessNodesForFldChar', () => { expect(docx['word/_rels/document.xml.rels'].elements[0].elements).toEqual([]); }); + it('preserves raw child nodes when an unpaired end bubbles through a non-collecting wrapper', () => { + const expectedNodes = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'CUSTOMFIELD foo' }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'value' }] }] }, + { + name: 'w:p', + elements: [ + { + name: 'w:sdt', + elements: [ + { + name: 'w:sdtContent', + elements: [{ name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }], + }, + ], + }, + ], + }, + ]; + const nodes = structuredClone(expectedNodes); + const { processedNodes, unpairedBegin, unpairedEnd } = preProcessNodesForFldChar(nodes, mockDocx); + + expect(processedNodes).toEqual(expectedNodes); + expect(unpairedBegin).toBeNull(); + expect(unpairedEnd).toBeNull(); + }); + it('should handle unpaired begin', () => { const nodes = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, From b56eec1b69653c9ee2d98605bc4bddd1f315b924 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 6 May 2026 10:25:57 -0300 Subject: [PATCH 5/9] fix: add null-safe relationship lookup --- .../editors/v1/core/super-converter/v2/importer/docxImporter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6efb344895..17798c2e05 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 @@ -680,7 +680,7 @@ export function addDefaultStylesIfMissing(styles) { */ const importHeadersFooters = (docx, converter, mainEditor, numbering, translatedNumbering, translatedLinkedStyles) => { const rels = docx['word/_rels/document.xml.rels']; - const relationships = rels?.elements.find((el) => el.name === 'Relationships'); + const relationships = rels?.elements?.find((el) => el.name === 'Relationships'); const elements = relationships?.elements ?? []; const headerType = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header'; From c1943f153a36fb336b8d8f3fd777e947609dcfc4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 6 May 2026 10:30:11 -0300 Subject: [PATCH 6/9] fix: document raw field preservation --- .../field-references/preProcessNodesForFldChar.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index f3845e7657..517da69883 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -227,6 +227,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { // A field from this level or higher ended in the children. const shouldPreserveRaw = childResult.unpairedEndPreserveRaw || isTrackChangeWrapper(node); if (collectedNodesStack.length === 0) { + // Preserve the original subtree; child processing may have stripped the fldChar end marker. processedNodes.push(rawNode); unpairedEnd = true; if (shouldPreserveRaw) unpairedEndPreserveRaw = true; From fd8442254d2e9e2275efe15ae21f590e0b25b7f5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 6 May 2026 10:31:02 -0300 Subject: [PATCH 7/9] fix: clarify raw field output policy --- .../preProcessNodesForFldChar.js | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 517da69883..875441d07b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -51,16 +51,17 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const rawCollectedNodes = rawCollectedNodesStack.pop().filter((n) => n !== null); const fieldRunRPr = fieldRunRPrStack.pop() ?? null; const currentField = currentFieldStack.pop(); - const combinedResult = currentField.preserveRaw - ? { nodes: rawCollectedNodes, handled: false } - : _processCombinedNodesForFldChar( - collectedNodes, - currentField.instrText.trim(), - docx, - currentField.instructionTokens, - fieldRunRPr, - ); - const outputNodes = combinedResult.handled ? combinedResult.nodes : rawCollectedNodes; + let outputNodes = rawCollectedNodes; + if (!currentField.preserveRaw) { + const combinedResult = _processCombinedNodesForFldChar( + collectedNodes, + currentField.instrText.trim(), + docx, + currentField.instructionTokens, + fieldRunRPr, + ); + outputNodes = combinedResult.handled ? combinedResult.nodes : rawCollectedNodes; + } if (collectedNodesStack.length === 0) { // We have completed a top-level field, add the combined nodes to the output. processedNodes.push(...outputNodes); From 4e9c9cadf844a642d759fc76b34e63b616c58147 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 6 May 2026 11:12:48 -0300 Subject: [PATCH 8/9] fix(converter): treat w:moveFrom/w:moveTo as tracked-change wrappers in field preservation --- .../preProcessNodesForFldChar.js | 6 +-- .../preProcessNodesForFldChar.test.js | 46 +++++++++++++++++++ .../v2/importer/trackChangeElements.js | 5 ++ .../v2/importer/trackChangesImporter.js | 5 +- 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackChangeElements.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 875441d07b..5fa58ad132 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -3,11 +3,11 @@ */ import { getInstructionPreProcessor } from './fld-preprocessors'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; +import { isTrackChangeElement } from '../v2/importer/trackChangeElements.js'; const SKIP_FIELD_PROCESSING_NODE_NAMES = new Set(['w:drawing', 'w:pict']); const shouldSkipFieldProcessing = (node) => SKIP_FIELD_PROCESSING_NODE_NAMES.has(node?.name); -const isTrackChangeWrapper = (node) => node?.name === 'w:del' || node?.name === 'w:ins'; /** * @typedef {object} FldCharProcessResult * @property {OpenXmlNode[]} processedNodes - The list of nodes after processing. @@ -212,7 +212,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { // A field started in the children, so this node is part of that field. childResult.unpairedBegin.forEach((pendingField) => { const fieldInfo = { ...pendingField.fieldInfo }; - if (fieldInfo.preserveRaw || isTrackChangeWrapper(node)) { + if (fieldInfo.preserveRaw || isTrackChangeElement(node)) { fieldInfo.preserveRaw = true; } currentFieldStack.push(fieldInfo); @@ -226,7 +226,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { }); } else if (childResult.unpairedEnd) { // A field from this level or higher ended in the children. - const shouldPreserveRaw = childResult.unpairedEndPreserveRaw || isTrackChangeWrapper(node); + const shouldPreserveRaw = childResult.unpairedEndPreserveRaw || isTrackChangeElement(node); if (collectedNodesStack.length === 0) { // Preserve the original subtree; child processing may have stripped the fldChar end marker. processedNodes.push(rawNode); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 2880948a98..99aa1c9c16 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -418,6 +418,52 @@ describe('preProcessNodesForFldChar', () => { expect(docx['word/_rels/document.xml.rels'].elements[0].elements).toEqual([]); }); + it('preserves raw field nodes when an active field ends inside a tracked move wrapper', () => { + const expectedNodes = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'HYPERLINK "http://example.com"' }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }, + { + name: 'w:p', + elements: [ + { + name: 'w:moveFrom', + attributes: { 'w:id': '1', 'w:author': 'Repro', 'w:date': '2026-04-30T00:00:00Z' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + { + name: 'w:r', + elements: [ + { + name: 'w:t', + attributes: { 'xml:space': 'preserve' }, + elements: [{ type: 'text', text: 'moved text after field end' }], + }, + ], + }, + ], + }, + ], + }, + ]; + const nodes = structuredClone(expectedNodes); + const docx = { + 'word/_rels/document.xml.rels': { + elements: [{ name: 'Relationships', elements: [] }], + }, + }; + const { processedNodes, unpairedBegin, unpairedEnd } = preProcessNodesForFldChar(nodes, docx); + + expect(processedNodes).toEqual(expectedNodes); + expect(unpairedBegin).toBeNull(); + expect(unpairedEnd).toBeNull(); + expect(docx['word/_rels/document.xml.rels'].elements[0].elements).toEqual([]); + }); + it('preserves raw child nodes when an unpaired end bubbles through a non-collecting wrapper', () => { const expectedNodes = [ { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackChangeElements.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackChangeElements.js new file mode 100644 index 0000000000..87bf904ee4 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackChangeElements.js @@ -0,0 +1,5 @@ +const TRACK_CHANGE_ELEMENT_NAMES = new Set(['w:del', 'w:ins', 'w:moveFrom', 'w:moveTo']); +const TRANSLATED_TRACK_CHANGE_ELEMENT_NAMES = new Set(['w:del', 'w:ins']); + +export const isTrackChangeElement = (node) => TRACK_CHANGE_ELEMENT_NAMES.has(node?.name); +export const isTranslatedTrackChangeElement = (node) => TRANSLATED_TRACK_CHANGE_ELEMENT_NAMES.has(node?.name); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackChangesImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackChangesImporter.js index 87f063060f..beaba82777 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackChangesImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackChangesImporter.js @@ -1,14 +1,13 @@ import { translator as wDelTranslator } from '@converter/v3/handlers/w/del'; import { translator as wInsTranslator } from '@converter/v3/handlers/w/ins'; - -const isTrackChangeElement = (node) => node?.name === 'w:del' || node?.name === 'w:ins'; +import { isTranslatedTrackChangeElement } from './trackChangeElements.js'; const unwrapTrackChangeNode = (node) => { if (!node) { return null; } - if (isTrackChangeElement(node)) { + if (isTranslatedTrackChangeElement(node)) { return node; } From 24c8473099d04a749dc076614bcf775da45f95c1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 6 May 2026 11:17:49 -0300 Subject: [PATCH 9/9] fix(converter): avoid raw field ends in non-tracked wrappers --- .../preProcessNodesForFldChar.js | 4 +- .../preProcessNodesForFldChar.test.js | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 5fa58ad132..01c0207f1f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -228,8 +228,8 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { // A field from this level or higher ended in the children. const shouldPreserveRaw = childResult.unpairedEndPreserveRaw || isTrackChangeElement(node); if (collectedNodesStack.length === 0) { - // Preserve the original subtree; child processing may have stripped the fldChar end marker. - processedNodes.push(rawNode); + // Track-change wrappers need the original field boundary; ordinary wrappers can keep processed children. + processedNodes.push(shouldPreserveRaw ? rawNode : node); unpairedEnd = true; if (shouldPreserveRaw) unpairedEndPreserveRaw = true; return; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 99aa1c9c16..a1fc12f769 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -303,6 +303,62 @@ describe('preProcessNodesForFldChar', () => { ]); }); + it('processes known fields that end inside nested non-tracked wrappers', () => { + const nodes = [ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'HYPERLINK "http://example.com"' }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { + name: 'w:p', + elements: [ + { + name: 'w:sdt', + elements: [ + { + name: 'w:sdtContent', + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ], + }, + ], + }, + ], + }, + ]; + + const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx); + + expect(processedNodes).toEqual([ + { + name: 'w:hyperlink', + type: 'element', + attributes: { 'r:id': 'rIdabc12345' }, + elements: [ + { + name: 'w:p', + elements: [ + { + name: 'w:sdt', + elements: [ + { + name: 'w:sdtContent', + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }, + ], + }, + ], + }, + ], + }, + ], + }, + ]); + }); + it('preserves a tracked-deletion-wrapped field split across paragraphs without throwing', () => { const expectedNodes = [ {