Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
*/
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);
/**
* @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'.
* @property {boolean | null} unpairedEndPreserveRaw - If an unpaired field 'end' bubbled through a tracked-change wrapper.
*/

/**
Expand All @@ -35,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();

Expand All @@ -48,14 +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 = _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);
Expand Down Expand Up @@ -205,7 +211,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 || isTrackChangeElement(node)) {
fieldInfo.preserveRaw = true;
}
currentFieldStack.push(fieldInfo);

// The current node should be added to the collected nodes
collectedNodesStack.push([node]);
Expand All @@ -216,6 +226,18 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => {
});
} else if (childResult.unpairedEnd) {
// A field from this level or higher ended in the children.
const shouldPreserveRaw = childResult.unpairedEndPreserveRaw || isTrackChangeElement(node);
if (collectedNodesStack.length === 0) {
// 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;
}

if (shouldPreserveRaw) {
currentFieldStack[currentFieldStack.length - 1].preserveRaw = true;
}
collectedNodesStack[collectedNodesStack.length - 1].push(node);
captureRawNodeForCurrentField(rawNode, capturedRawNodes, rawSourceToken);
finalizeField();
Expand Down Expand Up @@ -259,7 +281,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => {
}
}

return { processedNodes, unpairedBegin, unpairedEnd };
return { processedNodes, unpairedBegin, unpairedEnd, unpairedEndPreserveRaw };
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,255 @@ 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 = [
{
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('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('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' } }] },
{
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' } }] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -680,13 +680,13 @@ 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 relationships = rels?.elements?.find((el) => el.name === 'Relationships');
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
Loading