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 @@ -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,22 +94,21 @@ 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);

// w:authorEmail is not part of the OOXML CT_TrackChange attribute set, so it is
// intentionally omitted from <w:rPrChange>. The author email remains available on
// the internal trackFormat mark attrs for editor-side use; it is just not serialized.
return {
type: 'element',
name: 'w:rPrChange',
attributes: {
'w:id': wordId,
'w:author': trackFormatMark.attrs?.author,
'w:authorEmail': trackFormatMark.attrs?.authorEmail,
'w:author': getTrackChangeAuthor(trackFormatMark),
'w:date': trackFormatMark.attrs?.date,
},
elements: [previousRunProperties],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,87 @@
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) => {
// A paragraph-split mark is sticky: once a descendant carries it, keep it.
// descendants() still visits later siblings, so a later unmarked child must
// not clear a mark already discovered on an earlier child.
if (found) return false;
const childMarks = Array.isArray(child?.marks) ? child.marks : [];
const match = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark));
if (match) found = match;
return !found;
});
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 insertRunPropertiesInOrder(pPr, runProperties) {
// Per CT_PPr, the paragraph-mark <w:rPr> comes after normal paragraph-level
// properties and before any terminal <w:sectPr> / <w:pPrChange>. Inserting at
// the front produces invalid ordering when the paragraph already has
// properties such as <w:pStyle>, <w:spacing>, or <w:jc>.
const terminalIdx = pPr.elements.findIndex(
(element) => element?.name === 'w:sectPr' || element?.name === 'w:pPrChange',
);
if (terminalIdx === -1) {
pPr.elements.push(runProperties);
} else {
pPr.elements.splice(terminalIdx, 0, runProperties);
}
}

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);
// Keep an existing <w:rPr> in place; only insert a freshly-created one in order.
if (!existingRunProperties) insertRunPropertiesInOrder(pPr, runProperties);
return pPr;
}

/**
* Generate the w:pPr props for a paragraph node
Expand Down Expand Up @@ -33,7 +115,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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Omit paragraph-split revision carriers in final exports

When exportDocx({ isFinalDoc: true }) is used after a user presses Enter in suggesting mode, this branch suppresses only the paragraph-mark <w:ins>, but the same paragraph-split trackFormat mark remains on the text and is still serialized by the run/text export path as a <w:rPrChange> carrier. That leaves a tracked revision in a supposedly final/accepted export instead of producing just the split paragraphs; the final-doc path needs to drop or skip the paragraph-split trackFormat carrier as well.

Useful? React with 👍 / 👎.

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