Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import { translator as w_personalCompose_translator } from './w/personalCompose/
import { translator as w_personalReply_translator } from './w/personalReply/personalReply-translator.js';
import { translator as w_position_translator } from './w/position/position-translator.js';
import { translator as w_pPr_translator } from './w/pPr/pPr-translator.js';
import { translator as w_pPrChange_translator } from './w/pPrChange/pPrChange-translator.js';
import { translator as w_pStyle_translator } from './w/pStyle/pStyle-translator.js';
import { translator as w_permEnd_translator } from './w/perm-end/perm-end-translator.js';
import { translator as w_permStart_translator } from './w/perm-start/perm-start-translator.js';
Expand Down Expand Up @@ -324,6 +325,7 @@ const translatorList = Array.from(
w_personalReply_translator,
w_position_translator,
w_pPr_translator,
w_pPrChange_translator,
w_pStyle_translator,
w_permStart_translator,
w_permEnd_translator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ export function generateParagraphProperties(params) {
elements: [],
};
}
pPr.elements.push(sectPr);
// Per CT_PPr, sectPr must precede pPrChange.
const pPrChangeIdx = pPr.elements.findIndex((el) => el.name === 'w:pPrChange');
if (pPrChangeIdx === -1) {
pPr.elements.push(sectPr);
} else {
pPr.elements.splice(pPrChangeIdx, 0, sectPr);
}
}
return pPr;
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ describe('generateParagraphProperties', () => {
expect(result.elements[1]).toBe(sectPr);
});

it('inserts sectPr before pPrChange to satisfy CT_PPr ordering', () => {
const jc = { name: 'w:jc' };
const pPrChange = { name: 'w:pPrChange' };
const sectPr = { name: 'w:sectPr' };
const decoded = { type: 'element', name: 'w:pPr', elements: [jc, pPrChange] };
wPPrNodeTranslator.decode.mockReturnValue(decoded);
const node = { type: 'paragraph', attrs: { paragraphProperties: { sectPr } } };

const result = generateParagraphProperties({ node });

expect(result.elements).toEqual([jc, sectPr, pPrChange]);
});

it('creates paragraph properties when decoder returns nothing but sectPr exists', () => {
wPPrNodeTranslator.decode.mockReturnValue(undefined);
const sectPr = { name: 'w:sectPr', elements: [] };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// @ts-check
import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent';
import { translator as wAdjustRightIndTranslator } from '../adjustRightInd';
import { translator as wAutoSpaceDETranslator } from '../autoSpaceDE';
import { translator as wAutoSpaceDNTranslator } from '../autoSpaceDN';
import { translator as wBidiTranslator } from '../bidi';
import { translator as wCnfStyleTranslator } from '../cnfStyle';
import { translator as wContextualSpacingTranslator } from '../contextualSpacing';
import { translator as wDivIdTranslator } from '../divId';
import { translator as wFramePrTranslator } from '../framePr';
import { translator as wIndTranslator } from '../ind';
import { translator as wJcTranslatorTranslator } from '../jc';
import { translator as wKeepLinesTranslator } from '../keepLines';
import { translator as wKeepNextTranslator } from '../keepNext';
import { translator as wKinsokuTranslator } from '../kinsoku';
import { translator as wMirrorIndentsTranslator } from '../mirrorIndents';
import { translator as wNumPrTranslator } from '../numPr';
import { translator as wOutlineLvlTranslator } from '../outlineLvl';
import { translator as wOverflowPunctTranslator } from '../overflowPunct';
import { translator as wPBdrTranslator } from '../pBdr';
import { translator as wPStyleTranslator } from '../pStyle';
import { translator as wPageBreakBeforeTranslator } from '../pageBreakBefore';
import { translator as wShdTranslator } from '../shd';
import { translator as wSnapToGridTranslator } from '../snapToGrid';
import { translator as wSpacingTranslator } from '../spacing';
import { translator as wSuppressAutoHyphensTranslator } from '../suppressAutoHyphens';
import { translator as wSuppressLineNumbersTranslator } from '../suppressLineNumbers';
import { translator as wSuppressOverlapTranslator } from '../suppressOverlap';
import { translator as wTabsTranslator } from '../tabs';
import { translator as wTextAlignmentTranslator } from '../textAlignment';
import { translator as wTextDirectionTranslator } from '../textDirection';
import { translator as wTextboxTightWrapTranslator } from '../textboxTightWrap';
import { translator as wTopLinePunctTranslator } from '../topLinePunct';
import { translator as wWidowControlTranslator } from '../widowControl';
import { translator as wWordWrapTranslator } from '../wordWrap';
import { translator as wRPrTranslator } from '../rpr';

/** @type {import('@translator').NodeTranslator[]} */
export const basePropertyTranslators = [
mcAlternateContentTranslator,
wAdjustRightIndTranslator,
wAutoSpaceDETranslator,
wAutoSpaceDNTranslator,
wBidiTranslator,
wCnfStyleTranslator,
wContextualSpacingTranslator,
wDivIdTranslator,
wFramePrTranslator,
wIndTranslator,
wJcTranslatorTranslator,
wKeepLinesTranslator,
wKeepNextTranslator,
wKinsokuTranslator,
wMirrorIndentsTranslator,
wNumPrTranslator,
wOutlineLvlTranslator,
wOverflowPunctTranslator,
wPBdrTranslator,
wPStyleTranslator,
wPageBreakBeforeTranslator,
wShdTranslator,
wSnapToGridTranslator,
wSpacingTranslator,
wSuppressAutoHyphensTranslator,
wSuppressLineNumbersTranslator,
wSuppressOverlapTranslator,
wTabsTranslator,
wTextAlignmentTranslator,
wTextDirectionTranslator,
wTextboxTightWrapTranslator,
wTopLinePunctTranslator,
wWidowControlTranslator,
wWordWrapTranslator,
wRPrTranslator,
];
Original file line number Diff line number Diff line change
@@ -1,82 +1,11 @@
// @ts-check
import { NodeTranslator } from '@translator';
import { createNestedPropertiesTranslator } from '@converter/v3/handlers/utils.js';
import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent';
import { translator as wAdjustRightIndTranslator } from '../adjustRightInd';
import { translator as wAutoSpaceDETranslator } from '../autoSpaceDE';
import { translator as wAutoSpaceDNTranslator } from '../autoSpaceDN';
import { translator as wBidiTranslator } from '../bidi';
import { translator as wCnfStyleTranslator } from '../cnfStyle';
import { translator as wContextualSpacingTranslator } from '../contextualSpacing';
import { translator as wDivIdTranslator } from '../divId';
import { translator as wFramePrTranslator } from '../framePr';
import { translator as wIndTranslator } from '../ind';
import { translator as wJcTranslatorTranslator } from '../jc';
import { translator as wKeepLinesTranslator } from '../keepLines';
import { translator as wKeepNextTranslator } from '../keepNext';
import { translator as wKinsokuTranslator } from '../kinsoku';
import { translator as wMirrorIndentsTranslator } from '../mirrorIndents';
import { translator as wNumPrTranslator } from '../numPr';
import { translator as wOutlineLvlTranslator } from '../outlineLvl';
import { translator as wOverflowPunctTranslator } from '../overflowPunct';
import { translator as wPBdrTranslator } from '../pBdr';
import { translator as wPStyleTranslator } from '../pStyle';
import { translator as wPageBreakBeforeTranslator } from '../pageBreakBefore';
import { translator as wShdTranslator } from '../shd';
import { translator as wSnapToGridTranslator } from '../snapToGrid';
import { translator as wSpacingTranslator } from '../spacing';
import { translator as wSuppressAutoHyphensTranslator } from '../suppressAutoHyphens';
import { translator as wSuppressLineNumbersTranslator } from '../suppressLineNumbers';
import { translator as wSuppressOverlapTranslator } from '../suppressOverlap';
import { translator as wTabsTranslator } from '../tabs';
import { translator as wTextAlignmentTranslator } from '../textAlignment';
import { translator as wTextDirectionTranslator } from '../textDirection';
import { translator as wTextboxTightWrapTranslator } from '../textboxTightWrap';
import { translator as wTopLinePunctTranslator } from '../topLinePunct';
import { translator as wWidowControlTranslator } from '../widowControl';
import { translator as wWordWrapTranslator } from '../wordWrap';
import { translator as wRPrTranslator } from '../rpr';
import { basePropertyTranslators } from './pPr-base-translators.js';
import { translator as wPPrChangeTranslator } from '../pPrChange';

// Property translators for w:pPr child elements
// Each translator handles a specific property of the paragraph properties
/** @type {import('@translator').NodeTranslator[]} */
const propertyTranslators = [
mcAlternateContentTranslator,
wAdjustRightIndTranslator,
wAutoSpaceDETranslator,
wAutoSpaceDNTranslator,
wBidiTranslator,
wCnfStyleTranslator,
wContextualSpacingTranslator,
wDivIdTranslator,
wFramePrTranslator,
wIndTranslator,
wJcTranslatorTranslator,
wKeepLinesTranslator,
wKeepNextTranslator,
wKinsokuTranslator,
wMirrorIndentsTranslator,
wNumPrTranslator,
wOutlineLvlTranslator,
wOverflowPunctTranslator,
wPBdrTranslator,
wPStyleTranslator,
wPageBreakBeforeTranslator,
wShdTranslator,
wSnapToGridTranslator,
wSpacingTranslator,
wSuppressAutoHyphensTranslator,
wSuppressLineNumbersTranslator,
wSuppressOverlapTranslator,
wTabsTranslator,
wTextAlignmentTranslator,
wTextDirectionTranslator,
wTextboxTightWrapTranslator,
wTopLinePunctTranslator,
wWidowControlTranslator,
wWordWrapTranslator,
wRPrTranslator,
];
const propertyTranslators = [...basePropertyTranslators, wPPrChangeTranslator];
Comment thread
caio-pizzol marked this conversation as resolved.

/**
* The NodeTranslator instance for the w:pPr element.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,58 @@ describe('w:pPr translator', () => {
expect(encodedResult).toEqual(initialParagraphProperties);
});
});

describe('pPrChange integration (SD-2417 regression guard)', () => {
it('routes w:pPrChange through the pPr encode pipeline', () => {
const xmlNode = {
name: 'w:pPr',
elements: [
{ name: 'w:jc', attributes: { 'w:val': 'center' } },
{
name: 'w:pPrChange',
attributes: {
'w:id': '0',
'w:author': 'Regression Guard',
'w:date': '2026-01-01T00:00:00Z',
},
elements: [
{
name: 'w:pPr',
elements: [{ name: 'w:jc', attributes: { 'w:val': 'left' } }],
},
],
},
],
};

const encoded = translator.encode({ nodes: [xmlNode] });

expect(encoded.justification).toBe('center');
expect(encoded.change).toEqual({
id: '0',
author: 'Regression Guard',
date: '2026-01-01T00:00:00Z',
paragraphProperties: { justification: 'left' },
});
});

it('round-trips a paragraph whose pPr carries a pPrChange', () => {
const initialParagraphProperties = {
justification: 'center',
change: {
id: '0',
author: 'Regression Guard',
date: '2026-01-01T00:00:00Z',
paragraphProperties: { justification: 'left' },
},
};

const decoded = translator.decode({
node: { attrs: { paragraphProperties: initialParagraphProperties } },
});
const encoded = translator.encode({ nodes: [decoded] });

expect(encoded).toEqual(initialParagraphProperties);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './pPrChange-translator.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { NodeTranslator } from '@translator';
import { carbonCopy } from '@core/utilities/carbonCopy.js';
import { createNestedPropertiesTranslator, createAttributeHandler } from '@converter/v3/handlers/utils.js';
import { basePropertyTranslators } from '../pPr/pPr-base-translators.js';

const pPrTranslator = NodeTranslator.from(
createNestedPropertiesTranslator('w:pPr', 'paragraphProperties', basePropertyTranslators),
);

const ATTRIBUTE_HANDLERS = [
createAttributeHandler('w:id'),
createAttributeHandler('w:author'),
createAttributeHandler('w:date'),
];

function getSectPr(pPrNode) {
const sectPr = pPrNode?.elements?.find((el) => el.name === 'w:sectPr');
return sectPr ? carbonCopy(sectPr) : undefined;
}

/**
* The NodeTranslator instance for the w:pPrChange element.
* @type {import('@translator').NodeTranslator}
*/
export const translator = NodeTranslator.from({
xmlName: 'w:pPrChange',
sdNodeOrKeyName: 'change',
type: NodeTranslator.translatorTypes.NODE,
attributes: ATTRIBUTE_HANDLERS,
encode: (params, encodedAttrs = {}) => {
const changeNode = params.nodes[0];
const pPrNode = changeNode?.elements?.find((el) => el.name === 'w:pPr');

let paragraphProperties = pPrNode ? (pPrTranslator.encode({ ...params, nodes: [pPrNode] }) ?? {}) : undefined;
const sectPr = getSectPr(pPrNode);
if (sectPr) {
paragraphProperties = {
...(paragraphProperties || {}),
sectPr,
};
}

const result = {
...encodedAttrs,
...(paragraphProperties ? { paragraphProperties } : {}),
};

return Object.keys(result).length ? result : undefined;
},
decode: function (params) {
const change = params.node?.attrs?.change;
if (!change || typeof change !== 'object') return undefined;

const decodedAttrs = this.decodeAttributes({
node: { ...params.node, attrs: change },
});
const hasParagraphProperties = Object.prototype.hasOwnProperty.call(change, 'paragraphProperties');
const paragraphProperties = hasParagraphProperties ? change.paragraphProperties : undefined;

let pPrNode =
paragraphProperties && typeof paragraphProperties === 'object'
? pPrTranslator.decode({
...params,
node: { ...params.node, attrs: { paragraphProperties } },
})
: undefined;

const sectPr = paragraphProperties?.sectPr ? carbonCopy(paragraphProperties.sectPr) : undefined;
if (sectPr) {
if (!pPrNode) {
pPrNode = {
name: 'w:pPr',
type: 'element',
attributes: {},
elements: [],
};
}
pPrNode.elements = [...(pPrNode.elements || []), sectPr];
}

if (!pPrNode && hasParagraphProperties) {
pPrNode = {
name: 'w:pPr',
type: 'element',
attributes: {},
elements: [],
};
}

if (!pPrNode && !Object.keys(decodedAttrs).length) return undefined;

return {
name: 'w:pPrChange',
type: 'element',
attributes: decodedAttrs,
elements: pPrNode ? [pPrNode] : [],
};
},
});
Loading
Loading