Skip to content
Open
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 @@ -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 @@ -500,6 +500,8 @@ export function decodePropertiesByKey(xmlName, sdName, translator, params, attrs
* @param {import('@translator').NodeTranslator[]} propertyTranslators An array of property translators to handle nested properties.
* @param {object} [defaultEncodedAttrs={}] Optional default attributes to include during encoding.
* @param {import('@translator').AttrConfig[]} [attributeHandlers=[]] Optional additional attribute handlers for the nested element.
* @param {object} [options={}] Optional configuration.
* @param {boolean} [options.emitWhenAttributesOnly=false] When true, the decode path emits the XML element if it has attributes even when there are no child elements. Useful for tracked-change wrappers (e.g. w:pPrChange) where the attributes carry independent semantic value.
* @returns {import('@translator').NodeTranslatorConfig} The nested property handler config with xmlName, sdName, encode, and decode functions.
*/
export function createNestedPropertiesTranslator(
Expand All @@ -508,6 +510,7 @@ export function createNestedPropertiesTranslator(
propertyTranslators,
defaultEncodedAttrs = {},
attributeHandlers = [],
{ emitWhenAttributesOnly = false } = {},
) {
const propertyTranslatorsByXmlName = {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

emitWhenAttributesOnly isn't used anywhere — the pPrChange translator handles this case with its own logic instead. worth removing so it doesn't sit here unused?

const propertyTranslatorsBySdName = {};
Expand Down Expand Up @@ -542,7 +545,9 @@ export function createNestedPropertiesTranslator(
// Process property translators
const elements = decodeProperties(params, propertyTranslatorsBySdName, currentValue);

if (elements.length === 0) {
const hasAttributes = emitWhenAttributesOnly && Object.keys(decodedAttrs).length > 0;

if (elements.length === 0 && !hasAttributes) {
return undefined;
}

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];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not blocking, just a heads up: the spec expects sectPr before pPrChange inside <w:pPr>, but the exporter always puts sectPr last. so if a paragraph has both a section break and a tracked property change, they'll be in the wrong order. this predates your PR — the pattern was already there, it just didn't matter until pPrChange existed. rare in practice. maybe a follow-up ticket?


/**
* The NodeTranslator instance for the w:pPr element.
Expand Down
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

when <w:pPr/> exists but is empty, pPrTranslator.encode() returns undefined here — so the paragraphProperties key never makes it into the result. later, when decoding back to XML, the guard at line 81 (hasParagraphProperties) can't fire because the key is missing. the empty <w:pPr/> is lost.

the spec says <w:pPr> is required inside <w:pPrChange> — even when empty it means "there were no previous properties." tested with a real Word file and confirmed the element disappears after export.

Suggested change
let paragraphProperties = pPrNode ? pPrTranslator.encode({ ...params, nodes: [pPrNode] }) : undefined;
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