Skip to content

Commit 9adf23e

Browse files
feat: add w:pPrChange translator for paragraph property tracked changes (SD-2417) (#2705)
* feat: add translator for w:pPrChange * fix(pPrChange): preserve empty w:pPr on round-trip An empty <w:pPr/> inside <w:pPrChange> means "the paragraph previously had no properties" per the OOXML spec, but the encoder was dropping it: pPrTranslator.encode() returns undefined for an empty pPr, so the paragraphProperties key never landed in the encoded change. On decode, the existing fallback couldn't fire because the key was absent, and the element vanished on export. Fall back to an empty object when the inner encode returns undefined, so the key is always present when a <w:pPr> was in the source XML. The decode path already reconstructs the empty <w:pPr/> from this shape. * chore(super-converter): drop unused emitWhenAttributesOnly option * fix(super-converter): order sectPr before pPrChange in w:pPr CT_PPr requires sectPr to precede pPrChange, but the exporter always appended sectPr as the last child. Before the w:pPrChange translator existed, no export contained both children, so the ordering bug was latent. With pPrChange now round-tripping, paragraphs that carry both a section break and a tracked property change were emitted in an order Word rejects. Insert sectPr before any existing w:pPrChange child; otherwise append as before. * test(pPrChange): cover mixed sectPr and pPr routing (SD-2417) - pPrChange-translator: add a round-trip that mixes sectPr with other paragraph properties so a regression dropping either half surfaces. - pPr-translator: add two tests that exercise the pPrChange registration end-to-end through the real pPr translator, guarding against a silent SD-2417 regression if the registration is ever removed. --------- Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Co-authored-by: Caio Pizzol <caio@superdoc.dev>
1 parent 6c525de commit 9adf23e

9 files changed

Lines changed: 669 additions & 75 deletions

File tree

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ import { translator as w_personalCompose_translator } from './w/personalCompose/
118118
import { translator as w_personalReply_translator } from './w/personalReply/personalReply-translator.js';
119119
import { translator as w_position_translator } from './w/position/position-translator.js';
120120
import { translator as w_pPr_translator } from './w/pPr/pPr-translator.js';
121+
import { translator as w_pPrChange_translator } from './w/pPrChange/pPrChange-translator.js';
121122
import { translator as w_pStyle_translator } from './w/pStyle/pStyle-translator.js';
122123
import { translator as w_permEnd_translator } from './w/perm-end/perm-end-translator.js';
123124
import { translator as w_permStart_translator } from './w/perm-start/perm-start-translator.js';
@@ -324,6 +325,7 @@ const translatorList = Array.from(
324325
w_personalReply_translator,
325326
w_position_translator,
326327
w_pPr_translator,
328+
w_pPrChange_translator,
327329
w_pStyle_translator,
328330
w_permStart_translator,
329331
w_permEnd_translator,

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ export function generateParagraphProperties(params) {
4343
elements: [],
4444
};
4545
}
46-
pPr.elements.push(sectPr);
46+
// Per CT_PPr, sectPr must precede pPrChange.
47+
const pPrChangeIdx = pPr.elements.findIndex((el) => el.name === 'w:pPrChange');
48+
if (pPrChangeIdx === -1) {
49+
pPr.elements.push(sectPr);
50+
} else {
51+
pPr.elements.splice(pPrChangeIdx, 0, sectPr);
52+
}
4753
}
4854
return pPr;
4955
}

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ describe('generateParagraphProperties', () => {
6868
expect(result.elements[1]).toBe(sectPr);
6969
});
7070

71+
it('inserts sectPr before pPrChange to satisfy CT_PPr ordering', () => {
72+
const jc = { name: 'w:jc' };
73+
const pPrChange = { name: 'w:pPrChange' };
74+
const sectPr = { name: 'w:sectPr' };
75+
const decoded = { type: 'element', name: 'w:pPr', elements: [jc, pPrChange] };
76+
wPPrNodeTranslator.decode.mockReturnValue(decoded);
77+
const node = { type: 'paragraph', attrs: { paragraphProperties: { sectPr } } };
78+
79+
const result = generateParagraphProperties({ node });
80+
81+
expect(result.elements).toEqual([jc, sectPr, pPrChange]);
82+
});
83+
7184
it('creates paragraph properties when decoder returns nothing but sectPr exists', () => {
7285
wPPrNodeTranslator.decode.mockReturnValue(undefined);
7386
const sectPr = { name: 'w:sectPr', elements: [] };
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// @ts-check
2+
import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent';
3+
import { translator as wAdjustRightIndTranslator } from '../adjustRightInd';
4+
import { translator as wAutoSpaceDETranslator } from '../autoSpaceDE';
5+
import { translator as wAutoSpaceDNTranslator } from '../autoSpaceDN';
6+
import { translator as wBidiTranslator } from '../bidi';
7+
import { translator as wCnfStyleTranslator } from '../cnfStyle';
8+
import { translator as wContextualSpacingTranslator } from '../contextualSpacing';
9+
import { translator as wDivIdTranslator } from '../divId';
10+
import { translator as wFramePrTranslator } from '../framePr';
11+
import { translator as wIndTranslator } from '../ind';
12+
import { translator as wJcTranslatorTranslator } from '../jc';
13+
import { translator as wKeepLinesTranslator } from '../keepLines';
14+
import { translator as wKeepNextTranslator } from '../keepNext';
15+
import { translator as wKinsokuTranslator } from '../kinsoku';
16+
import { translator as wMirrorIndentsTranslator } from '../mirrorIndents';
17+
import { translator as wNumPrTranslator } from '../numPr';
18+
import { translator as wOutlineLvlTranslator } from '../outlineLvl';
19+
import { translator as wOverflowPunctTranslator } from '../overflowPunct';
20+
import { translator as wPBdrTranslator } from '../pBdr';
21+
import { translator as wPStyleTranslator } from '../pStyle';
22+
import { translator as wPageBreakBeforeTranslator } from '../pageBreakBefore';
23+
import { translator as wShdTranslator } from '../shd';
24+
import { translator as wSnapToGridTranslator } from '../snapToGrid';
25+
import { translator as wSpacingTranslator } from '../spacing';
26+
import { translator as wSuppressAutoHyphensTranslator } from '../suppressAutoHyphens';
27+
import { translator as wSuppressLineNumbersTranslator } from '../suppressLineNumbers';
28+
import { translator as wSuppressOverlapTranslator } from '../suppressOverlap';
29+
import { translator as wTabsTranslator } from '../tabs';
30+
import { translator as wTextAlignmentTranslator } from '../textAlignment';
31+
import { translator as wTextDirectionTranslator } from '../textDirection';
32+
import { translator as wTextboxTightWrapTranslator } from '../textboxTightWrap';
33+
import { translator as wTopLinePunctTranslator } from '../topLinePunct';
34+
import { translator as wWidowControlTranslator } from '../widowControl';
35+
import { translator as wWordWrapTranslator } from '../wordWrap';
36+
import { translator as wRPrTranslator } from '../rpr';
37+
38+
/** @type {import('@translator').NodeTranslator[]} */
39+
export const basePropertyTranslators = [
40+
mcAlternateContentTranslator,
41+
wAdjustRightIndTranslator,
42+
wAutoSpaceDETranslator,
43+
wAutoSpaceDNTranslator,
44+
wBidiTranslator,
45+
wCnfStyleTranslator,
46+
wContextualSpacingTranslator,
47+
wDivIdTranslator,
48+
wFramePrTranslator,
49+
wIndTranslator,
50+
wJcTranslatorTranslator,
51+
wKeepLinesTranslator,
52+
wKeepNextTranslator,
53+
wKinsokuTranslator,
54+
wMirrorIndentsTranslator,
55+
wNumPrTranslator,
56+
wOutlineLvlTranslator,
57+
wOverflowPunctTranslator,
58+
wPBdrTranslator,
59+
wPStyleTranslator,
60+
wPageBreakBeforeTranslator,
61+
wShdTranslator,
62+
wSnapToGridTranslator,
63+
wSpacingTranslator,
64+
wSuppressAutoHyphensTranslator,
65+
wSuppressLineNumbersTranslator,
66+
wSuppressOverlapTranslator,
67+
wTabsTranslator,
68+
wTextAlignmentTranslator,
69+
wTextDirectionTranslator,
70+
wTextboxTightWrapTranslator,
71+
wTopLinePunctTranslator,
72+
wWidowControlTranslator,
73+
wWordWrapTranslator,
74+
wRPrTranslator,
75+
];

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.js

Lines changed: 3 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,11 @@
11
// @ts-check
22
import { NodeTranslator } from '@translator';
33
import { createNestedPropertiesTranslator } from '@converter/v3/handlers/utils.js';
4-
import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent';
5-
import { translator as wAdjustRightIndTranslator } from '../adjustRightInd';
6-
import { translator as wAutoSpaceDETranslator } from '../autoSpaceDE';
7-
import { translator as wAutoSpaceDNTranslator } from '../autoSpaceDN';
8-
import { translator as wBidiTranslator } from '../bidi';
9-
import { translator as wCnfStyleTranslator } from '../cnfStyle';
10-
import { translator as wContextualSpacingTranslator } from '../contextualSpacing';
11-
import { translator as wDivIdTranslator } from '../divId';
12-
import { translator as wFramePrTranslator } from '../framePr';
13-
import { translator as wIndTranslator } from '../ind';
14-
import { translator as wJcTranslatorTranslator } from '../jc';
15-
import { translator as wKeepLinesTranslator } from '../keepLines';
16-
import { translator as wKeepNextTranslator } from '../keepNext';
17-
import { translator as wKinsokuTranslator } from '../kinsoku';
18-
import { translator as wMirrorIndentsTranslator } from '../mirrorIndents';
19-
import { translator as wNumPrTranslator } from '../numPr';
20-
import { translator as wOutlineLvlTranslator } from '../outlineLvl';
21-
import { translator as wOverflowPunctTranslator } from '../overflowPunct';
22-
import { translator as wPBdrTranslator } from '../pBdr';
23-
import { translator as wPStyleTranslator } from '../pStyle';
24-
import { translator as wPageBreakBeforeTranslator } from '../pageBreakBefore';
25-
import { translator as wShdTranslator } from '../shd';
26-
import { translator as wSnapToGridTranslator } from '../snapToGrid';
27-
import { translator as wSpacingTranslator } from '../spacing';
28-
import { translator as wSuppressAutoHyphensTranslator } from '../suppressAutoHyphens';
29-
import { translator as wSuppressLineNumbersTranslator } from '../suppressLineNumbers';
30-
import { translator as wSuppressOverlapTranslator } from '../suppressOverlap';
31-
import { translator as wTabsTranslator } from '../tabs';
32-
import { translator as wTextAlignmentTranslator } from '../textAlignment';
33-
import { translator as wTextDirectionTranslator } from '../textDirection';
34-
import { translator as wTextboxTightWrapTranslator } from '../textboxTightWrap';
35-
import { translator as wTopLinePunctTranslator } from '../topLinePunct';
36-
import { translator as wWidowControlTranslator } from '../widowControl';
37-
import { translator as wWordWrapTranslator } from '../wordWrap';
38-
import { translator as wRPrTranslator } from '../rpr';
4+
import { basePropertyTranslators } from './pPr-base-translators.js';
5+
import { translator as wPPrChangeTranslator } from '../pPrChange';
396

40-
// Property translators for w:pPr child elements
41-
// Each translator handles a specific property of the paragraph properties
427
/** @type {import('@translator').NodeTranslator[]} */
43-
const propertyTranslators = [
44-
mcAlternateContentTranslator,
45-
wAdjustRightIndTranslator,
46-
wAutoSpaceDETranslator,
47-
wAutoSpaceDNTranslator,
48-
wBidiTranslator,
49-
wCnfStyleTranslator,
50-
wContextualSpacingTranslator,
51-
wDivIdTranslator,
52-
wFramePrTranslator,
53-
wIndTranslator,
54-
wJcTranslatorTranslator,
55-
wKeepLinesTranslator,
56-
wKeepNextTranslator,
57-
wKinsokuTranslator,
58-
wMirrorIndentsTranslator,
59-
wNumPrTranslator,
60-
wOutlineLvlTranslator,
61-
wOverflowPunctTranslator,
62-
wPBdrTranslator,
63-
wPStyleTranslator,
64-
wPageBreakBeforeTranslator,
65-
wShdTranslator,
66-
wSnapToGridTranslator,
67-
wSpacingTranslator,
68-
wSuppressAutoHyphensTranslator,
69-
wSuppressLineNumbersTranslator,
70-
wSuppressOverlapTranslator,
71-
wTabsTranslator,
72-
wTextAlignmentTranslator,
73-
wTextDirectionTranslator,
74-
wTextboxTightWrapTranslator,
75-
wTopLinePunctTranslator,
76-
wWidowControlTranslator,
77-
wWordWrapTranslator,
78-
wRPrTranslator,
79-
];
8+
const propertyTranslators = [...basePropertyTranslators, wPPrChangeTranslator];
809

8110
/**
8211
* The NodeTranslator instance for the w:pPr element.

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPr/pPr-translator.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,58 @@ describe('w:pPr translator', () => {
359359
expect(encodedResult).toEqual(initialParagraphProperties);
360360
});
361361
});
362+
363+
describe('pPrChange integration (SD-2417 regression guard)', () => {
364+
it('routes w:pPrChange through the pPr encode pipeline', () => {
365+
const xmlNode = {
366+
name: 'w:pPr',
367+
elements: [
368+
{ name: 'w:jc', attributes: { 'w:val': 'center' } },
369+
{
370+
name: 'w:pPrChange',
371+
attributes: {
372+
'w:id': '0',
373+
'w:author': 'Regression Guard',
374+
'w:date': '2026-01-01T00:00:00Z',
375+
},
376+
elements: [
377+
{
378+
name: 'w:pPr',
379+
elements: [{ name: 'w:jc', attributes: { 'w:val': 'left' } }],
380+
},
381+
],
382+
},
383+
],
384+
};
385+
386+
const encoded = translator.encode({ nodes: [xmlNode] });
387+
388+
expect(encoded.justification).toBe('center');
389+
expect(encoded.change).toEqual({
390+
id: '0',
391+
author: 'Regression Guard',
392+
date: '2026-01-01T00:00:00Z',
393+
paragraphProperties: { justification: 'left' },
394+
});
395+
});
396+
397+
it('round-trips a paragraph whose pPr carries a pPrChange', () => {
398+
const initialParagraphProperties = {
399+
justification: 'center',
400+
change: {
401+
id: '0',
402+
author: 'Regression Guard',
403+
date: '2026-01-01T00:00:00Z',
404+
paragraphProperties: { justification: 'left' },
405+
},
406+
};
407+
408+
const decoded = translator.decode({
409+
node: { attrs: { paragraphProperties: initialParagraphProperties } },
410+
});
411+
const encoded = translator.encode({ nodes: [decoded] });
412+
413+
expect(encoded).toEqual(initialParagraphProperties);
414+
});
415+
});
362416
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './pPrChange-translator.js';
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { NodeTranslator } from '@translator';
2+
import { carbonCopy } from '@core/utilities/carbonCopy.js';
3+
import { createNestedPropertiesTranslator, createAttributeHandler } from '@converter/v3/handlers/utils.js';
4+
import { basePropertyTranslators } from '../pPr/pPr-base-translators.js';
5+
6+
const pPrTranslator = NodeTranslator.from(
7+
createNestedPropertiesTranslator('w:pPr', 'paragraphProperties', basePropertyTranslators),
8+
);
9+
10+
const ATTRIBUTE_HANDLERS = [
11+
createAttributeHandler('w:id'),
12+
createAttributeHandler('w:author'),
13+
createAttributeHandler('w:date'),
14+
];
15+
16+
function getSectPr(pPrNode) {
17+
const sectPr = pPrNode?.elements?.find((el) => el.name === 'w:sectPr');
18+
return sectPr ? carbonCopy(sectPr) : undefined;
19+
}
20+
21+
/**
22+
* The NodeTranslator instance for the w:pPrChange element.
23+
* @type {import('@translator').NodeTranslator}
24+
*/
25+
export const translator = NodeTranslator.from({
26+
xmlName: 'w:pPrChange',
27+
sdNodeOrKeyName: 'change',
28+
type: NodeTranslator.translatorTypes.NODE,
29+
attributes: ATTRIBUTE_HANDLERS,
30+
encode: (params, encodedAttrs = {}) => {
31+
const changeNode = params.nodes[0];
32+
const pPrNode = changeNode?.elements?.find((el) => el.name === 'w:pPr');
33+
34+
let paragraphProperties = pPrNode ? (pPrTranslator.encode({ ...params, nodes: [pPrNode] }) ?? {}) : undefined;
35+
const sectPr = getSectPr(pPrNode);
36+
if (sectPr) {
37+
paragraphProperties = {
38+
...(paragraphProperties || {}),
39+
sectPr,
40+
};
41+
}
42+
43+
const result = {
44+
...encodedAttrs,
45+
...(paragraphProperties ? { paragraphProperties } : {}),
46+
};
47+
48+
return Object.keys(result).length ? result : undefined;
49+
},
50+
decode: function (params) {
51+
const change = params.node?.attrs?.change;
52+
if (!change || typeof change !== 'object') return undefined;
53+
54+
const decodedAttrs = this.decodeAttributes({
55+
node: { ...params.node, attrs: change },
56+
});
57+
const hasParagraphProperties = Object.prototype.hasOwnProperty.call(change, 'paragraphProperties');
58+
const paragraphProperties = hasParagraphProperties ? change.paragraphProperties : undefined;
59+
60+
let pPrNode =
61+
paragraphProperties && typeof paragraphProperties === 'object'
62+
? pPrTranslator.decode({
63+
...params,
64+
node: { ...params.node, attrs: { paragraphProperties } },
65+
})
66+
: undefined;
67+
68+
const sectPr = paragraphProperties?.sectPr ? carbonCopy(paragraphProperties.sectPr) : undefined;
69+
if (sectPr) {
70+
if (!pPrNode) {
71+
pPrNode = {
72+
name: 'w:pPr',
73+
type: 'element',
74+
attributes: {},
75+
elements: [],
76+
};
77+
}
78+
pPrNode.elements = [...(pPrNode.elements || []), sectPr];
79+
}
80+
81+
if (!pPrNode && hasParagraphProperties) {
82+
pPrNode = {
83+
name: 'w:pPr',
84+
type: 'element',
85+
attributes: {},
86+
elements: [],
87+
};
88+
}
89+
90+
if (!pPrNode && !Object.keys(decodedAttrs).length) return undefined;
91+
92+
return {
93+
name: 'w:pPrChange',
94+
type: 'element',
95+
attributes: decodedAttrs,
96+
elements: pPrNode ? [pPrNode] : [],
97+
};
98+
},
99+
});

0 commit comments

Comments
 (0)