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
4 changes: 2 additions & 2 deletions packages/document-api/src/contract/operation-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,7 +972,7 @@ export const OPERATION_DEFINITIONS = {
idempotency: 'conditional',
supportsDryRun: true,
supportsTrackedMode: true,
possibleFailureCodes: ['INVALID_TARGET'],
possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'],
throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT', ...T_STORY],
}),
referenceDocPath: 'format/apply.mdx',
Expand Down Expand Up @@ -1054,7 +1054,7 @@ export const OPERATION_DEFINITIONS = {
idempotency: 'conditional',
supportsDryRun: true,
supportsTrackedMode: true,
possibleFailureCodes: ['INVALID_TARGET'],
possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'],
Comment thread
harbournick marked this conversation as resolved.
throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT', ...T_STORY],
}),
referenceDocPath: 'format/apply.mdx',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -696,16 +696,15 @@ export class DocxExporter {
* };
* // Returns: ['<w:t>', 'Textcontent', '</w:t>']
*/
#generateXml(node) {
#generateXml(node, insideDeletion = false) {
if (!node) return null;
let { name } = node;
const { elements, attributes } = node;

// Normalize w:delInstrText → w:instrText. During import, w:del wrappers around
// field character runs lose their trackDelete marks (only text content gets marked),
// so on export the w:del wrapper is absent. Per ECMA-376 §17.16.13, w:delInstrText
// outside w:del is non-conformant — renaming to w:instrText keeps the field valid.
if (name === 'w:delInstrText') {
// Normalize w:delInstrText → w:instrText only when the field instruction is
// no longer inside a surviving w:del wrapper. Inside w:del, ECMA-376 expects
// w:delInstrText to remain intact.
if (name === 'w:delInstrText' && !insideDeletion) {
name = 'w:instrText';
}

Expand All @@ -726,8 +725,10 @@ export class DocxExporter {
return this.#replaceSpecialCharacters(node.text ?? '');
}

const nextInsideDeletion = insideDeletion || name === 'w:del';

if (elements) {
if (name === 'w:instrText') {
if (name === 'w:instrText' || name === 'w:delInstrText') {
const textContent = (elements || [])
.map((child) => (typeof child?.text === 'string' ? child.text : ''))
.join('');
Expand All @@ -754,7 +755,7 @@ export class DocxExporter {
} else {
if (elements) {
for (let child of elements) {
const newElements = this.#generateXml(child);
const newElements = this.#generateXml(child, nextInsideDeletion);
if (!newElements) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MUTATING_OPERATION_IDS,
OPERATION_IDS,
buildInternalContractSchemas,
createDocumentApi,
textReceiptToSDReceipt,
type InlineRunPatchKey,
type OperationId,
Expand All @@ -18,6 +19,7 @@ import {
} from '../../extensions/track-changes/constants.js';
import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js';
import { createCommentsWrapper } from '../plan-engine/comments-wrappers.js';
import { assembleDocumentApiAdapters } from '../assemble-adapters.js';
import { createParagraphWrapper, createHeadingWrapper } from '../plan-engine/create-wrappers.js';
import { blocksDeleteWrapper, blocksDeleteRangeWrapper } from '../plan-engine/blocks-wrappers.js';
import { clearContentWrapper } from '../plan-engine/clear-content-wrapper.js';
Expand Down Expand Up @@ -521,6 +523,56 @@ type MockParagraphNode = {
textContent: string;
};

function resolveMockNodePosition(root: ProseMirrorNode, pos: number) {
const path: Array<{ node: ProseMirrorNode; start: number }> = [{ node: root, start: -1 }];

const walk = (node: ProseMirrorNode, contentStart: number): void => {
const children = ((node as unknown as { _children?: ProseMirrorNode[] })._children ?? []) as ProseMirrorNode[];
let offset = contentStart;

for (const child of children) {
const childStart = offset;
const childEnd = childStart + child.nodeSize;
if (pos >= childStart && pos < childEnd) {
path.push({ node: child, start: childStart });
const grandChildren = ((child as unknown as { _children?: ProseMirrorNode[] })._children ??
[]) as ProseMirrorNode[];
if (grandChildren.length > 0) {
walk(child, childStart + 1);
}
return;
}
offset = childEnd;
}
};

walk(root, 0);

return {
depth: path.length - 1,
node(depth: number) {
return path[depth]?.node ?? root;
},
before(depth: number) {
return path[depth]?.start ?? 0;
},
start(depth: number) {
if (depth <= 0) return 0;
return (path[depth]?.start ?? 0) + 1;
},
end(depth: number) {
const entry = path[depth];
if (!entry) return 0;
return (path[depth]?.start ?? 0) + 1 + entry.node.content.size;
},
after(depth: number) {
const entry = path[depth];
if (!entry) return 0;
return (path[depth]?.start ?? 0) + entry.node.nodeSize;
},
};
}

function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode {
const attrs = options.attrs ?? {};
const text = options.text ?? '';
Expand Down Expand Up @@ -600,10 +652,42 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options:
}
walk(children, 0);
},
nodesBetween(from: number, to: number, callback: (node: ProseMirrorNode, pos: number) => boolean | void) {
function walk(kids: ProseMirrorNode[], startPos: number) {
let offset = startPos;
for (const child of kids) {
const childStart = offset;
const childEnd = childStart + child.nodeSize;
if (childEnd < from) {
offset = childEnd;
continue;
}
if (childStart > to) {
break;
}
const result = callback(child, childStart);
if (result !== false) {
const innerKids = (child as unknown as { _children?: ProseMirrorNode[] })._children;
if (innerKids && innerKids.length > 0) {
walk(innerKids, childStart + 1);
}
}
offset = childEnd;
}
}
walk(children, 0);
},
resolve(pos: number) {
return resolveMockNodePosition(node as unknown as ProseMirrorNode, pos);
},
};
return node as unknown as ProseMirrorNode;
}

function makeDocumentApiForEditor(editor: Editor) {
return createDocumentApi(assembleDocumentApiAdapters(editor));
}

function makeTextEditor(
text = 'Hello',
overrides: Partial<Editor> & {
Expand Down Expand Up @@ -768,6 +852,7 @@ function makeTextEditor(
})),
dispatch,
...overrides,
on: vi.fn(),
schema: {
marks: baseMarks,
...(overrides.schema ?? {}),
Expand Down Expand Up @@ -4468,6 +4553,53 @@ const mutationVectors: Partial<Record<OperationId, MutationVector>> = {
);
},
},
formatRange: {
throwCase: () => {
const { editor } = makeTextEditor();
const api = makeDocumentApiForEditor(editor);
return api.formatRange(
{
target: {
kind: 'selection',
start: { kind: 'text', blockId: 'missing', offset: 0 },
end: { kind: 'text', blockId: 'missing', offset: 1 },
},
properties: { bold: true },
},
{ changeMode: 'direct' },
);
},
failureCase: () => {
const { editor } = makeTextEditor();
const api = makeDocumentApiForEditor(editor);
return api.formatRange(
{
target: {
kind: 'selection',
start: { kind: 'text', blockId: 'p1', offset: 2 },
end: { kind: 'text', blockId: 'p1', offset: 2 },
},
properties: { bold: true },
},
{ changeMode: 'direct' },
);
},
applyCase: () => {
const { editor } = makeTextEditor();
const api = makeDocumentApiForEditor(editor);
return api.formatRange(
{
target: {
kind: 'selection',
start: { kind: 'text', blockId: 'p1', offset: 0 },
end: { kind: 'text', blockId: 'p1', offset: 5 },
},
properties: { bold: true, italic: false },
},
{ changeMode: 'direct' },
);
},
},
...formatInlineMutationVectors,
...paragraphMutationVectors,
'create.paragraph': {
Expand Down Expand Up @@ -8971,6 +9103,24 @@ const dryRunVectors: Partial<Record<OperationId, () => unknown>> = {
expect(tr.addMark).not.toHaveBeenCalled();
return result;
},
formatRange: () => {
const { editor, dispatch, tr } = makeTextEditor();
const api = makeDocumentApiForEditor(editor);
const result = api.formatRange(
{
target: {
kind: 'selection',
start: { kind: 'text', blockId: 'p1', offset: 0 },
end: { kind: 'text', blockId: 'p1', offset: 5 },
},
properties: { bold: true },
},
{ changeMode: 'direct', dryRun: true },
);
expect(dispatch).not.toHaveBeenCalled();
expect(tr.addMark).not.toHaveBeenCalled();
return result;
},
...formatInlineDryRunVectors,
...paragraphDryRunVectors,
'create.paragraph': () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,45 @@ describe('DocxExporter', () => {
expect(xml).toContain('REF _Ref258418237');
});

it('preserves w:delInstrText when it remains inside w:del', () => {
const exporter = new DocxExporter(createConverterStub());

const data = {
name: 'w:document',
attributes: {},
elements: [
{
name: 'w:del',
attributes: { 'w:id': '1' },
elements: [
{
name: 'w:r',
attributes: {},
elements: [
{
name: 'w:delInstrText',
attributes: { 'xml:space': 'preserve' },
elements: [
{
type: 'text',
text: ' PAGE \\\\* MERGEFORMAT ',
},
],
},
],
},
],
},
],
};

const xml = exporter.schemaToXml(data);

expect(xml).toContain('<w:delInstrText');
expect(xml).not.toContain('<w:instrText');
expect(xml).toContain('PAGE');
});

it('handles special characters along with [[sdspace]] placeholders', () => {
const exporter = new DocxExporter(createConverterStub());

Expand Down
Loading
Loading