Skip to content
Closed
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 @@ -4,6 +4,31 @@ import { getHexColorFromDocxSystem, isValidHexColor, twipsToInches, twipsToLines
import { translator as wRPrTranslator } from '../../v3/handlers/w/rpr/index.js';
import { encodeMarksFromRPr } from '@converter/styles.js';
import { resolveTrackedChangeImportIds, stampImportTrackingAttrs } from './importTrackingContext.js';
import { ParagraphSplitSnapshotType } from '../../v3/handlers/helpers.js';

function getInlineParagraphMarkInsertion(params) {
return params?.extraParams?.inlineParagraphProperties?.runProperties?.trackInsert || null;
}

function isMatchingParagraphMarkInsertion(trackInsert, ids) {
if (!trackInsert || typeof trackInsert !== 'object') return false;

const insertionId = trackInsert.id;
if (insertionId == null) return false;
return ids.some((id) => id != null && String(id) === String(insertionId));
}

function createParagraphSplitSnapshots() {
const snapshot = {
type: ParagraphSplitSnapshotType,
attrs: { anchor: 'source' },
};

return {
before: [snapshot],
after: [{ ...snapshot, attrs: { ...snapshot.attrs } }],
};
}

/**
*
Expand Down Expand Up @@ -135,6 +160,12 @@ export function handleStyleChangeMarksV2(rPrChange, currentMarks, params) {
submarks = encodeMarksFromRPr(runProperties, params?.docx);
}

const paragraphMarkInsertion = getInlineParagraphMarkInsertion(params);
if (isMatchingParagraphMarkInsertion(paragraphMarkInsertion, [attributes['w:id'], sourceId, logicalId])) {
const snapshots = createParagraphSplitSnapshots();
return [{ type: TrackFormatMarkName, attrs: { ...mappedAttributes, ...snapshots } }];
}

return [{ type: TrackFormatMarkName, attrs: { ...mappedAttributes, before: submarks, after: [...currentMarks] } }];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,39 @@ describe('handleStyleChangeMarksV2', () => {
expect(result[0].attrs.after).toEqual([]);
});

it('restores paragraphSplit snapshots from matching paragraph mark insertion metadata', () => {
const currentMarks = [{ type: 'bold', attrs: { value: true } }];
const rPrChange = {
name: 'w:rPrChange',
attributes: {
'w:id': '7',
'w:date': '2026-06-01T17:00:00Z',
'w:author': 'Reviewer',
},
elements: [{ name: 'w:rPr', elements: [] }],
};

const result = handleStyleChangeMarksV2(rPrChange, currentMarks, {
docx: {},
extraParams: {
inlineParagraphProperties: {
runProperties: {
trackInsert: {
id: '7',
author: 'Reviewer',
date: '2026-06-01T17:00:00Z',
},
},
},
},
});

expect(result).toHaveLength(1);
expect(result[0].type).toBe(TrackFormatMarkName);
expect(result[0].attrs.before).toEqual([{ type: 'paragraphSplit', attrs: { anchor: 'source' } }]);
expect(result[0].attrs.after).toEqual([{ type: 'paragraphSplit', attrs: { anchor: 'source' } }]);
});

it('remaps format change ids and stamps overlapParentId through import tracking context', () => {
const context = createImportTrackingContext({});
context.pushParent({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import { processOutputMarks } from '@converter/exporter.js';
import { TrackFormatMarkName } from '@extensions/track-changes/constants.js';

export const ParagraphSplitSnapshotType = 'paragraphSplit';

const getMarkType = (mark) => mark?.type?.name ?? mark?.type ?? null;
const getSnapshotType = (snapshot) => snapshot?.type?.name ?? snapshot?.type ?? null;
const isDecimalString = (value) => typeof value === 'string' && /^\d+$/.test(value);

const toRunPropertyElements = (marks = []) =>
processOutputMarks(marks).filter((element) => element && typeof element === 'object' && element.name);

const hashStringToDecimalId = (value) => {
const source = String(value || '0');
let hash = 0;
for (let i = 0; i < source.length; i++) {
hash = (hash * 31 + source.charCodeAt(i)) >>> 0;
}
return String(hash || 1);
};

const getTrackFormatChangeWordId = (trackFormatMark, options = {}) => {
const allocator = options?.wordIdAllocator || null;
const partPath = options?.partPath || 'word/document.xml';
const sourceId = trackFormatMark.attrs?.sourceId == null ? '' : String(trackFormatMark.attrs.sourceId);
const logicalId = trackFormatMark.attrs?.id == null ? '' : String(trackFormatMark.attrs.id);

if (allocator) return allocator.allocate({ partPath, sourceId, logicalId });
if (isDecimalString(sourceId)) return sourceId;
if (isDecimalString(logicalId)) return logicalId;
return hashStringToDecimalId(sourceId || logicalId);
};

const getTrackChangeAuthor = (trackFormatMark) => {
const author = trackFormatMark.attrs?.author;
return author == null ? '' : String(author);
};

/**
* Return the first trackFormat mark from a mark list.
*
Expand All @@ -15,6 +45,35 @@ const toRunPropertyElements = (marks = []) =>
export const findTrackFormatMark = (marks = []) =>
marks.find((mark) => getMarkType(mark) === TrackFormatMarkName) ?? null;

export const findSnapshotByType = (snapshots = [], type) =>
Array.isArray(snapshots) ? (snapshots.find((snapshot) => getSnapshotType(snapshot) === type) ?? null) : null;

export const findParagraphSplitSnapshot = (trackFormatMark) => {
if (!trackFormatMark) return null;
return (
findSnapshotByType(trackFormatMark.attrs?.before, ParagraphSplitSnapshotType) ||
findSnapshotByType(trackFormatMark.attrs?.after, ParagraphSplitSnapshotType)
);
};

export const isParagraphSplitTrackFormatMark = (mark) =>
getMarkType(mark) === TrackFormatMarkName && Boolean(findParagraphSplitSnapshot(mark));

export const createParagraphSplitInsertionElement = (trackFormatMark, options = {}) => {
const paragraphSplit = findParagraphSplitSnapshot(trackFormatMark);
if (!paragraphSplit) return undefined;

return {
type: 'element',
name: 'w:ins',
attributes: {
'w:id': getTrackFormatChangeWordId(trackFormatMark, options),
'w:author': getTrackChangeAuthor(trackFormatMark),
'w:date': trackFormatMark.attrs?.date,
},
};
};

/**
* Build a valid OOXML <w:rPrChange> node from a trackFormat mark.
*
Expand All @@ -35,21 +94,18 @@ export const createRunPropertiesChangeElement = (trackFormatMark, options = {})
elements: toRunPropertyElements(beforeMarks),
};

// Phase 005 — if an allocator was passed in, mint a Word-native decimal
// `w:id`. Legacy callers (no `options.wordIdAllocator`) keep the prior
// `sourceId || id` behavior so the exported byte stream is unchanged.
const allocator = options?.wordIdAllocator || null;
const partPath = options?.partPath || 'word/document.xml';
const sourceId = trackFormatMark.attrs?.sourceId;
const logicalId = trackFormatMark.attrs?.id;
const wordId = allocator ? allocator.allocate({ partPath, sourceId, logicalId }) : sourceId || logicalId;
// Prefer the export allocator for Word-native revision ids. Legacy callers
// without an allocator still need decimal OOXML ids, so they use a decimal
// source/logical id when available and a deterministic decimal fallback
// otherwise.
const wordId = getTrackFormatChangeWordId(trackFormatMark, options);

return {
type: 'element',
name: 'w:rPrChange',
attributes: {
'w:id': wordId,
'w:author': trackFormatMark.attrs?.author,
'w:author': getTrackChangeAuthor(trackFormatMark),
'w:authorEmail': trackFormatMark.attrs?.authorEmail,
'w:date': trackFormatMark.attrs?.date,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,66 @@
import { carbonCopy } from '@core/utilities/carbonCopy.js';
import { translator as wPPrNodeTranslator } from '../../pPr/pPr-translator.js';
import { createParagraphSplitInsertionElement, isParagraphSplitTrackFormatMark } from '../../../helpers.js';

function resolveExportPartPath(params = {}) {
if (typeof params.currentPartPath === 'string' && params.currentPartPath.length > 0) return params.currentPartPath;
if (typeof params.filename === 'string' && params.filename.length > 0) {
return params.filename.startsWith('word/') ? params.filename : `word/${params.filename}`;
}
return 'word/document.xml';
}

function findParagraphSplitTrackFormatMark(node) {
if (!node || typeof node !== 'object') return null;

const marks = Array.isArray(node.marks) ? node.marks : [];
const directMark = marks.find((mark) => isParagraphSplitTrackFormatMark(mark));
if (directMark) return directMark;

if (typeof node.descendants === 'function') {
let found = null;
node.descendants((child) => {
const childMarks = Array.isArray(child?.marks) ? child.marks : [];
found = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark)) || null;
return !found;
Comment on lines +24 to +25
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Descendant traversal can clear a previously found paragraph-split mark, causing paragraph split tracking to be skipped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js, line 24:

<comment>Descendant traversal can clear a previously found paragraph-split mark, causing paragraph split tracking to be skipped.</comment>

<file context>
@@ -1,5 +1,66 @@
+    let found = null;
+    node.descendants((child) => {
+      const childMarks = Array.isArray(child?.marks) ? child.marks : [];
+      found = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark)) || null;
+      return !found;
+    });
</file context>
Suggested change
found = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark)) || null;
return !found;
const match = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark));
if (match) found = match;
return !found;
Fix with Cubic

});
if (found) return found;
}

const content = Array.isArray(node.content) ? node.content : [];
for (const child of content) {
const childMark = findParagraphSplitTrackFormatMark(child);
if (childMark) return childMark;
}

return null;
}

function ensureParagraphPropertiesNode(pPr) {
return (
pPr || {
type: 'element',
name: 'w:pPr',
elements: [],
}
);
}

function prependParagraphSplitInsertion(pPr, insertionElement) {
if (!pPr || !insertionElement) return pPr;
if (!Array.isArray(pPr.elements)) pPr.elements = [];
const existingRunProperties = pPr.elements.find((element) => element?.name === 'w:rPr');
const runProperties = existingRunProperties || {
type: 'element',
name: 'w:rPr',
elements: [],
};
if (!Array.isArray(runProperties.elements)) runProperties.elements = [];
const hasParagraphInsertion = runProperties.elements.some((element) => element?.name === 'w:ins');
if (!hasParagraphInsertion) runProperties.elements.unshift(insertionElement);
if (!existingRunProperties) pPr.elements.unshift(runProperties);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Jun 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: w:rPr is prepended to w:pPr, which can break paragraph-property element ordering.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js, line 61:

<comment>`w:rPr` is prepended to `w:pPr`, which can break paragraph-property element ordering.</comment>

<file context>
@@ -1,5 +1,66 @@
+  if (!Array.isArray(runProperties.elements)) runProperties.elements = [];
+  const hasParagraphInsertion = runProperties.elements.some((element) => element?.name === 'w:ins');
+  if (!hasParagraphInsertion) runProperties.elements.unshift(insertionElement);
+  if (!existingRunProperties) pPr.elements.unshift(runProperties);
+  return pPr;
+}
</file context>
Suggested change
if (!existingRunProperties) pPr.elements.unshift(runProperties);
if (!existingRunProperties) {
const anchorIdx = pPr.elements.findIndex((element) => element?.name === 'w:sectPr' || element?.name === 'w:pPrChange');
if (anchorIdx === -1) pPr.elements.push(runProperties);
else pPr.elements.splice(anchorIdx, 0, runProperties);
}
Fix with Cubic

return pPr;
}

/**
* Generate the w:pPr props for a paragraph node
Expand Down Expand Up @@ -33,7 +94,23 @@ export function generateParagraphProperties(params) {
}
}

const paragraphSplitTrackFormatMark = findParagraphSplitTrackFormatMark(node);
const paragraphSplitWordIdOptions = paragraphSplitTrackFormatMark
? {
wordIdAllocator: params?.converter?.wordIdAllocator || null,
partPath: resolveExportPartPath(params),
}
: null;
let pPr = wPPrNodeTranslator.decode({ node: { ...node, attrs: { paragraphProperties } } });
if (!params?.isFinalDoc && paragraphSplitTrackFormatMark) {
const insertionElement = createParagraphSplitInsertionElement(
paragraphSplitTrackFormatMark,
paragraphSplitWordIdOptions,
);
if (insertionElement) {
pPr = prependParagraphSplitInsertion(ensureParagraphPropertiesNode(pPr), insertionElement);
}
}
const sectPr = node.attrs?.paragraphProperties?.sectPr;
if (sectPr) {
if (!pPr) {
Expand Down
Loading
Loading