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
Expand Up @@ -104,12 +104,18 @@ function decode(params) {
return null;
}

// ECMA-376 renames w:t → w:delText inside <w:del>. Other inline content —
// w:noBreakHyphen, w:tab, w:br, etc. — stays as-is; the deletion is
// conveyed by the <w:del> wrapper alone. Guard the rename so non-text
// atoms inside <w:del> don't crash.
const textNode = translatedTextNode.elements.find((n) => n.name === 'w:t');
if (textNode) textNode.name = 'w:delText';
// ECMA-376 (17.3.3.7) requires w:delText for ALL text runs inside <w:del>. A
// single run can now hold multiple <w:t> siblings, because the newline export
// safety net splits text around <w:br/> (e.g. <w:t>Alpha</w:t><w:br/><w:t>Beta</w:t>),
// so rename every direct w:t, not just the first; a leftover <w:t> inside
// <w:del> would not be treated as deleted. Other inline content
// (w:noBreakHyphen, w:tab, w:br, etc.) stays as-is; the <w:del> wrapper alone
// conveys the deletion.
(translatedTextNode.elements || [])
.filter((n) => n.name === 'w:t')
.forEach((n) => {
n.name = 'w:delText';
});

return {
name: 'w:del',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,37 @@ describe('w:del translator', () => {
expect(result.elements[0].elements[0].name).toBe('w:delText');
});

it('renames every <w:t> in a multi-segment run to <w:delText> (newline split)', () => {
const mockTrackedMark = {
type: 'trackDelete',
attrs: {
id: '789',
sourceId: '',
author: 'Test',
authorEmail: 'test@example.com',
date: '2025-10-09T12:00:00Z',
},
};

// The newline export safety net produces one run with interleaved w:t/w:br;
// every w:t inside <w:del> must become w:delText, not just the first.
exportSchemaToJson.mockReturnValue({
name: 'w:r',
elements: [
{ name: 'w:t', elements: [{ text: 'Alpha', type: 'text' }] },
{ name: 'w:br' },
{ name: 'w:t', elements: [{ text: 'Beta', type: 'text' }] },
],
});

const node = { type: 'text', text: 'Alpha\nBeta', marks: [mockTrackedMark] };
const result = config.decode({ node });

const run = result.elements[0];
expect(run.elements.map((n) => n.name)).toEqual(['w:delText', 'w:br', 'w:delText']);
expect(run.elements.some((n) => n.name === 'w:t')).toBe(false);
});

it('writes sourceId to w:id for round-trip fidelity', () => {
const mockTrackedMark = {
type: 'trackDelete',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,38 @@ export function getTextNodeForExport(text, marks, params) {
partPath: resolveExportPartPath(params),
});

textNodes.push({
name: 'w:t',
elements: [{ text, type: 'text' }],
attributes: nodeAttrs,
});
const textValue = typeof text === 'string' ? text : '';
// Normalize CRLF/CR to LF so Windows line endings export Word-native breaks
// too, rather than leaving a stray carriage return inside <w:t>.
const normalizedText = textValue.includes('\r') ? textValue.replace(/\r\n?/g, '\n') : textValue;
if (normalizedText.includes('\n')) {
// Export safety net: a raw newline inside <w:t> is whitespace that Word
// collapses on open (it is not the OOXML representation of a line break),
// while SuperDoc still renders it as a break: the SD-3278
// divergence. Emit a Word-native <w:br/> between
// segments instead. Everything stays inside this single run so the
// surrounding <w:ins>/<w:del> wrappers keep wrapping exactly one run.
const segments = normalizedText.split('\n');
segments.forEach((segment, index) => {
if (segment.length > 0) {
const segmentNeedsSpace = /^\s|\s$/.test(segment);
textNodes.push({
name: 'w:t',
elements: [{ text: segment, type: 'text' }],
attributes: segmentNeedsSpace ? { 'xml:space': 'preserve' } : null,
});
}
if (index < segments.length - 1) {
textNodes.push({ name: 'w:br' });
}
});
} else {
textNodes.push({
name: 'w:t',
elements: [{ text: normalizedText, type: 'text' }],
attributes: nodeAttrs,
});
}

// For custom mark export, we need to add a bookmark start and end tag
// And store attributes in the bookmark name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,80 @@ describe('getTextNodeForExport', () => {
const runPropertiesChange = runProperties.elements.find((element) => element.name === 'w:rPrChange');
expect(runPropertiesChange.attributes['w:id']).toBe('7');
});

// SD-3278 export safety net: a raw newline left inside a PM text
// node (e.g. from an imported .docx that stored breaks as literal '\n') must
// export as a Word-native <w:br/>, not a collapsed newline inside <w:t>.
describe('raw newline export safety net', () => {
const contentElements = (result) => result.elements.filter((el) => el.name === 'w:t' || el.name === 'w:br');

it('exports a single newline as <w:t>/<w:br/>/<w:t> within one run', () => {
const result = getTextNodeForExport('Alpha\nBeta', [], buildParams());
expect(result.name).toBe('w:r');
const content = contentElements(result);
expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']);
expect(content[0].elements[0].text).toBe('Alpha');
expect(content[2].elements[0].text).toBe('Beta');
});

it('never leaves a raw newline inside a <w:t>', () => {
const result = getTextNodeForExport('Alpha\nBeta', [], buildParams());
const texts = result.elements.filter((el) => el.name === 'w:t');
expect(texts.some((el) => el.elements[0].text.includes('\n'))).toBe(false);
});

it('emits a soft break (no w:type="page") for the <w:br/>', () => {
const result = getTextNodeForExport('Alpha\nBeta', [], buildParams());
const br = result.elements.find((el) => el.name === 'w:br');
expect(br).toBeDefined();
expect(br.attributes?.['w:type']).toBeUndefined();
});

it('leaves newline-free text as a single <w:t> (unchanged)', () => {
const result = getTextNodeForExport('hello world', [], buildParams());
const content = contentElements(result);
expect(content).toHaveLength(1);
expect(content[0].name).toBe('w:t');
expect(content[0].elements[0].text).toBe('hello world');
});

it('emits a <w:br/> for each newline including leading, trailing, and consecutive newlines', () => {
const result = getTextNodeForExport('\nA\n\nB\n', [], buildParams());
const content = contentElements(result);
expect(content.map((el) => el.name)).toEqual(['w:br', 'w:t', 'w:br', 'w:br', 'w:t', 'w:br']);
const texts = content.filter((el) => el.name === 'w:t').map((el) => el.elements[0].text);
expect(texts).toEqual(['A', 'B']);
});

it('sets xml:space="preserve" only on segments with edge whitespace', () => {
const result = getTextNodeForExport('Alpha \n Beta', [], buildParams());
const texts = result.elements.filter((el) => el.name === 'w:t');
expect(texts[0].elements[0].text).toBe('Alpha ');
expect(texts[0].attributes).toEqual({ 'xml:space': 'preserve' });
expect(texts[1].elements[0].text).toBe(' Beta');
expect(texts[1].attributes).toEqual({ 'xml:space': 'preserve' });
});

it('does not set xml:space on segments without edge whitespace', () => {
const result = getTextNodeForExport('Alpha\nBeta', [], buildParams());
const texts = result.elements.filter((el) => el.name === 'w:t');
expect(texts[0].attributes).toBeNull();
expect(texts[1].attributes).toBeNull();
});

it('normalizes CRLF to a <w:br/> on export', () => {
const content = contentElements(getTextNodeForExport('Alpha\r\nBeta', [], buildParams()));
expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']);
expect(content[0].elements[0].text).toBe('Alpha');
expect(content[2].elements[0].text).toBe('Beta');
});

it('normalizes a bare CR to a <w:br/> without leaving a stray carriage return in <w:t>', () => {
const result = getTextNodeForExport('Alpha\rBeta', [], buildParams());
const content = contentElements(result);
expect(content.map((el) => el.name)).toEqual(['w:t', 'w:br', 'w:t']);
const texts = result.elements.filter((el) => el.name === 'w:t');
expect(texts.some((el) => el.elements[0].text.includes('\r'))).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type NodeOptions = {
text?: string;
marks?: Array<{ type: { name: string } }>;
leafText?: (node: ProseMirrorNode) => string;
isInline?: boolean;
isBlock?: boolean;
isLeaf?: boolean;
Expand All @@ -28,7 +29,7 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options:
const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2;

return {
type: { name: typeName },
type: { name: typeName, spec: options.leafText ? { leafText: options.leafText } : {} },
text: isText ? text : undefined,
nodeSize,
isText,
Expand Down Expand Up @@ -138,6 +139,25 @@ describe('resolveTextRangeInBlock', () => {

expect(result).toEqual({ from: 6, to: 7 });
});

it('maps visible offsets across tracked deleted leaf nodes without counting them', () => {
const textA = createNode('text', [], { text: 'A' });
const deletedBreak = createNode('lineBreak', [], {
isInline: true,
isLeaf: true,
marks: [{ type: { name: 'trackDelete' } }],
leafText: () => '\n',
});
const textB = createNode('text', [], { text: 'B' });
const paragraph = createNode('paragraph', [textA, deletedBreak, textB], {
isBlock: true,
inlineContent: true,
});

const result = resolveTextRangeInBlock(paragraph, 0, { start: 1, end: 2 }, { textModel: 'visible' });

expect(result).toEqual({ from: 3, to: 4 });
});
});

describe('computeTextContentLength', () => {
Expand Down Expand Up @@ -204,6 +224,26 @@ describe('computeTextContentLength', () => {
expect(computeTextContentLength(paragraph, { textModel: 'visible' })).toBe(2);
expect(textContentInBlock(paragraph, { textModel: 'visible' })).toBe('AB');
});

it('excludes tracked deleted leaf nodes in the visible text model', () => {
const textA = createNode('text', [], { text: 'A' });
const deletedBreak = createNode('lineBreak', [], {
isInline: true,
isLeaf: true,
marks: [{ type: { name: 'trackDelete' } }],
leafText: () => '\n',
});
const textB = createNode('text', [], { text: 'B' });
const paragraph = createNode('paragraph', [textA, deletedBreak, textB], {
isBlock: true,
inlineContent: true,
});

expect(computeTextContentLength(paragraph)).toBe(3);
expect(computeTextContentLength(paragraph, { textModel: 'visible' })).toBe(2);
expect(textContentInBlock(paragraph)).toBe('A\nB');
expect(textContentInBlock(paragraph, { textModel: 'visible' })).toBe('AB');
});
});

describe('pmPositionToTextOffset', () => {
Expand Down Expand Up @@ -268,4 +308,23 @@ describe('pmPositionToTextOffset', () => {
expect(pmPositionToTextOffset(paragraph, 0, 6, { textModel: 'visible' })).toBe(1);
expect(pmPositionToTextOffset(paragraph, 0, 7, { textModel: 'visible' })).toBe(2);
});

it('keeps PM positions inside tracked deleted leaf nodes at the surrounding visible offset', () => {
const textA = createNode('text', [], { text: 'A' });
const deletedBreak = createNode('lineBreak', [], {
isInline: true,
isLeaf: true,
marks: [{ type: { name: 'trackDelete' } }],
leafText: () => '\n',
});
const textB = createNode('text', [], { text: 'B' });
const paragraph = createNode('paragraph', [textA, deletedBreak, textB], {
isBlock: true,
inlineContent: true,
});

expect(pmPositionToTextOffset(paragraph, 0, 2, { textModel: 'visible' })).toBe(1);
expect(pmPositionToTextOffset(paragraph, 0, 3, { textModel: 'visible' })).toBe(1);
expect(pmPositionToTextOffset(paragraph, 0, 4, { textModel: 'visible' })).toBe(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ function shouldSkipTextNode(node: ProseMirrorNode, options?: TextOffsetOptions):
return isVisibleTextModel(options) && hasTrackDeleteMark(node);
}

function shouldSkipLeafNode(node: ProseMirrorNode, options?: TextOffsetOptions): boolean {
return isVisibleTextModel(options) && hasTrackDeleteMark(node);
}

function resolveSegmentPosition(
targetOffset: number,
segmentStart: number,
Expand Down Expand Up @@ -87,6 +91,10 @@ export function pmPositionToTextOffset(

if (node.isLeaf) {
const endPos = docPos + node.nodeSize;
if (shouldSkipLeafNode(node, options)) {
if (pmPos < endPos) done = true;
return;
}
if (pmPos >= endPos) {
offset += 1;
} else {
Expand Down Expand Up @@ -141,6 +149,7 @@ export function computeTextContentLength(blockNode: ProseMirrorNode, options?: T
return;
}
if (node.isLeaf) {
if (shouldSkipLeafNode(node, options)) return;
length += 1;
return;
}
Expand Down Expand Up @@ -229,6 +238,7 @@ export function resolveTextRangeInBlock(
}

if (node.isLeaf) {
if (shouldSkipLeafNode(node, options)) return;
advanceSegment(1, docPos, docPos + node.nodeSize);
return;
}
Expand Down Expand Up @@ -262,7 +272,14 @@ export function textContentInBlock(blockNode: ProseMirrorNode, options?: TextOff
}

if (node.isLeaf) {
text += '\ufffc';
if (shouldSkipLeafNode(node, options)) return;
// Honor a leaf's declared visible text (e.g. lineBreak -> '\n',
// noBreakHyphen -> U+2011) so this content model agrees with the visible
// document and with the offset model. All leafText values are one
// character, matching the 1-per-leaf length used by the offset helpers
// above; other leaves fall back to the U+FFFC placeholder.
const leafText = (node.type?.spec as { leafText?: (n: ProseMirrorNode) => string } | undefined)?.leafText;
text += typeof leafText === 'function' ? leafText(node) : '\ufffc';
return;
}

Expand Down
Loading
Loading