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
@@ -0,0 +1,55 @@
// @ts-check
/**
* Helpers for exporting w:rPr so we only output overrides relative to paragraph/style
* (inherited props are already in styles.xml).
*/
import { translator as wRPrTranslator } from '@converter/v3/handlers/w/rpr';

const STYLES_KEY = 'word/styles.xml';

/**
* Get the merged run properties for a paragraph style from styles.xml (including basedOn chain).
* @param {Object} docx - Converted XML (e.g. converter.convertedXml)
* @param {string} styleId - Paragraph style id (e.g. from w:pStyle)
* @param {import('@translator').SCEncoderConfig} [params] - Params for encoding (docx for theme etc.)
* @returns {Object} Run properties object from the style, or {} if not found
*/
export function getParagraphStyleRunPropertiesFromStylesXml(docx, styleId, params) {
const stylesPart = docx?.[STYLES_KEY];
if (!stylesPart?.elements?.[0]?.elements) return {};

const styleElements = stylesPart.elements[0].elements.filter((el) => el.name === 'w:style');
const styleById = new Map(styleElements.map((el) => [el.attributes?.['w:styleId'], el]));

const chain = [];
let currentId = styleId;
const seen = new Set();

while (currentId && !seen.has(currentId)) {
seen.add(currentId);
const styleTag = styleById.get(currentId);
if (!styleTag) break;
const rPr = styleTag.elements?.find((el) => el.name === 'w:rPr');
if (rPr?.elements?.length) chain.push(rPr);
const basedOn = styleTag.elements?.find((el) => el.name === 'w:basedOn');
currentId = basedOn?.attributes?.['w:val'];
}

if (chain.length === 0) return {};

// Chain is derived → base (walk from current to basedOn). Reverse so we merge base first, then derived (derived overrides base).
const byName = {};
chain.reverse().forEach((rPr) => {
(rPr.elements || []).forEach((el) => {
if (el?.name) byName[el.name] = el;
});
});
const mergedRPr = {
name: 'w:rPr',
elements: Object.values(byName),
};

const encodeParams = { ...params, docx: params.docx ?? docx, nodes: [mergedRPr] };
const encoded = wRPrTranslator.encode(encodeParams);
return encoded ?? {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';

vi.mock('@converter/v3/handlers/w/rpr', () => ({
translator: {
encode: vi.fn(() => ({})),
},
}));

const { getParagraphStyleRunPropertiesFromStylesXml } = await import('./run-properties-export.js');
const { translator: wRPrTranslator } = await import('@converter/v3/handlers/w/rpr');

function makeStylesDocx(styles) {
return {
'word/styles.xml': {
elements: [
{
name: 'w:styles',
type: 'element',
elements: styles,
},
],
},
};
}

function makeStyle({ styleId, basedOn, rPrElements = [] }) {
const elements = [];
if (basedOn) {
elements.push({
name: 'w:basedOn',
type: 'element',
attributes: { 'w:val': basedOn },
});
}
if (rPrElements.length) {
elements.push({
name: 'w:rPr',
type: 'element',
elements: rPrElements,
});
}
return {
name: 'w:style',
type: 'element',
attributes: { 'w:styleId': styleId },
elements,
};
}

describe('getParagraphStyleRunPropertiesFromStylesXml', () => {
beforeEach(() => {
wRPrTranslator.encode.mockReset();
});

it('returns empty object when styles part is missing or has no styles', () => {
expect(getParagraphStyleRunPropertiesFromStylesXml({}, 'Heading1', {})).toEqual({});
expect(
getParagraphStyleRunPropertiesFromStylesXml(
{
'word/styles.xml': {
elements: [{ name: 'w:styles', type: 'element', elements: [] }],
},
},
'Heading1',
{},
),
).toEqual({});
expect(wRPrTranslator.encode).not.toHaveBeenCalled();
});

it('merges basedOn chain from base to derived so derived overrides base', () => {
const baseStyle = makeStyle({
styleId: 'Base',
rPrElements: [
{ name: 'w:color', type: 'element', attributes: { 'w:val': '0000FF' } },
{ name: 'w:b', type: 'element', attributes: {} },
],
});
const derivedStyle = makeStyle({
styleId: 'Heading1',
basedOn: 'Base',
rPrElements: [
{ name: 'w:color', type: 'element', attributes: { 'w:val': 'FF0000' } },
{ name: 'w:i', type: 'element', attributes: {} },
],
});

const docx = makeStylesDocx([baseStyle, derivedStyle]);

wRPrTranslator.encode.mockImplementation(({ nodes }) => ({ fromNodes: nodes }));

const result = getParagraphStyleRunPropertiesFromStylesXml(docx, 'Heading1', { docx: { theme: 'x' } });

expect(wRPrTranslator.encode).toHaveBeenCalledTimes(1);
const encodeArg = wRPrTranslator.encode.mock.calls[0][0];

expect(encodeArg.docx).toEqual({ theme: 'x' });
expect(Array.isArray(encodeArg.nodes)).toBe(true);
expect(encodeArg.nodes).toHaveLength(1);

const mergedRPr = encodeArg.nodes[0];
expect(mergedRPr.name).toBe('w:rPr');

const elementNames = (mergedRPr.elements || []).map((el) => el.name);
expect(elementNames).toEqual(expect.arrayContaining(['w:b', 'w:i', 'w:color']));

const colorElement = mergedRPr.elements.find((el) => el.name === 'w:color');
expect(colorElement?.attributes?.['w:val']).toBe('FF0000');

expect(result).toEqual({ fromNodes: expect.any(Array) });
});

it('falls back to docx argument when params.docx is not provided', () => {
const style = makeStyle({
styleId: 'Normal',
rPrElements: [{ name: 'w:b', type: 'element', attributes: {} }],
});
const docx = makeStylesDocx([style]);

getParagraphStyleRunPropertiesFromStylesXml(docx, 'Normal', {});

expect(wRPrTranslator.encode).toHaveBeenCalledTimes(1);
const encodeArg = wRPrTranslator.encode.mock.calls[0][0];
expect(encodeArg.docx).toBe(docx);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ export function generateParagraphProperties(params) {
const { attrs = {} } = node;

const paragraphProperties = carbonCopy(attrs.paragraphProperties || {});

// Only include w:rPr in pPr when the paragraph had inline rPr on import; filter to inline keys and drop if empty.
const inlineKeys = paragraphProperties.runPropertiesInlineKeys;
delete paragraphProperties.runPropertiesInlineKeys;
if (!Array.isArray(inlineKeys) || inlineKeys.length === 0) {
delete paragraphProperties.runProperties;
} else if (paragraphProperties.runProperties) {
const filtered = Object.fromEntries(
inlineKeys
.filter((k) => k in paragraphProperties.runProperties)
.map((k) => [k, paragraphProperties.runProperties[k]]),
);
if (Object.keys(filtered).length > 0) {
paragraphProperties.runProperties = filtered;
} else {
delete paragraphProperties.runProperties;
}
}

let pPr = wPPrNodeTranslator.decode({ node: { ...node, attrs: { paragraphProperties } } });
const sectPr = node.attrs?.paragraphProperties?.sectPr;
if (sectPr) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,89 @@ describe('generateParagraphProperties', () => {
elements: [sectPr],
});
});

it('strips runProperties when runPropertiesInlineKeys is missing', () => {
const paragraphProperties = { spacing: { line: 240 }, runProperties: { bold: true } };
const node = { type: 'paragraph', attrs: { paragraphProperties } };
wPPrNodeTranslator.decode.mockImplementation(({ node: decodeNode }) => {
expect(decodeNode.attrs.paragraphProperties.runProperties).toBeUndefined();
return { type: 'element', name: 'w:pPr', elements: [] };
});

generateParagraphProperties({ node });

expect(wPPrNodeTranslator.decode).toHaveBeenCalledWith(
expect.objectContaining({
node: expect.objectContaining({
attrs: expect.objectContaining({
paragraphProperties: expect.not.objectContaining({ runProperties: expect.anything() }),
}),
}),
}),
);
});

it('strips runProperties when runPropertiesInlineKeys is empty array', () => {
const paragraphProperties = {
spacing: { line: 240 },
runProperties: { bold: true },
runPropertiesInlineKeys: [],
};
const node = { type: 'paragraph', attrs: { paragraphProperties } };
wPPrNodeTranslator.decode.mockImplementation(({ node: decodeNode }) => {
expect(decodeNode.attrs.paragraphProperties.runProperties).toBeUndefined();
return { type: 'element', name: 'w:pPr', elements: [] };
});

generateParagraphProperties({ node });

expect(wPPrNodeTranslator.decode).toHaveBeenCalledWith(
expect.objectContaining({
node: expect.objectContaining({
attrs: expect.objectContaining({
paragraphProperties: expect.not.objectContaining({ runProperties: expect.anything() }),
}),
}),
}),
);
});

it('passes filtered runProperties when runPropertiesInlineKeys is set and non-empty', () => {
const paragraphProperties = {
spacing: { line: 240 },
runProperties: { bold: true, color: 'FF0000' },
runPropertiesInlineKeys: ['bold'],
};
const node = { type: 'paragraph', attrs: { paragraphProperties } };
wPPrNodeTranslator.decode.mockImplementation(({ node: decodeNode }) => {
expect(decodeNode.attrs.paragraphProperties.runProperties).toEqual({ bold: true });
return { type: 'element', name: 'w:pPr', elements: [] };
});

generateParagraphProperties({ node });

expect(wPPrNodeTranslator.decode).toHaveBeenCalledWith(
expect.objectContaining({
node: expect.objectContaining({
attrs: expect.objectContaining({
paragraphProperties: expect.objectContaining({ runProperties: { bold: true } }),
}),
}),
}),
);
});

it('strips runProperties when runPropertiesInlineKeys has no matching keys', () => {
const paragraphProperties = {
runProperties: { color: 'FF0000' },
runPropertiesInlineKeys: ['bold'],
};
const node = { type: 'paragraph', attrs: { paragraphProperties } };
wPPrNodeTranslator.decode.mockImplementation(({ node: decodeNode }) => {
expect(decodeNode.attrs.paragraphProperties.runProperties).toBeUndefined();
return { type: 'element', name: 'w:pPr', elements: [] };
});

generateParagraphProperties({ node });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export const handleParagraphNode = (params) => {
let inlineParagraphProperties = {};
if (pPr) {
inlineParagraphProperties = w_pPrTranslator.encode({ ...params, nodes: [pPr] }) || {};
// Mark which runProperties were in w:pPr's w:rPr so export can omit style-inherited
if (inlineParagraphProperties.runProperties && typeof inlineParagraphProperties.runProperties === 'object') {
inlineParagraphProperties.runPropertiesInlineKeys = Object.keys(inlineParagraphProperties.runProperties);
}
}

// Resolve paragraph properties according to styles hierarchy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,31 @@ describe('legacy-handle-paragraph-node', () => {
{ tab: { tabType: 'center', pos: undefined } },
]);
});

it('sets paragraphProperties.runPropertiesInlineKeys from keys of w:pPr w:rPr for export filtering', () => {
const params = makeParams();
params.nodes[0].elements = [
{
name: 'w:pPr',
elements: [
{ name: 'w:pStyle', attributes: { 'w:val': 'Normal' } },
{
name: 'w:rPr',
elements: [
{ name: 'w:b', attributes: {} },
{ name: 'w:sz', attributes: { 'w:val': '24' } },
],
},
],
},
];

const out = handleParagraphNode(params);

expect(out.attrs.paragraphProperties.runPropertiesInlineKeys).toBeDefined();
expect(out.attrs.paragraphProperties.runPropertiesInlineKeys).toEqual(expect.arrayContaining(['bold', 'fontSize']));
expect(out.attrs.paragraphProperties.runPropertiesInlineKeys).toHaveLength(
Object.keys(out.attrs.paragraphProperties.runProperties || {}).length,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,14 @@ function mergeConsecutiveTrackedChanges(elements) {
* @returns {XmlReadyNode} JSON of the XML-ready paragraph node
*/
export function translateParagraphNode(params) {
let elements = translateChildNodes(params);
const exportParams = {
...params,
extraParams: {
...params.extraParams,
paragraphProperties: params.node?.attrs?.paragraphProperties,
},
};
let elements = translateChildNodes(exportParams);

// Merge consecutive tracked changes with the same ID, including comment markers between them
elements = mergeConsecutiveTrackedChanges(elements);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ describe('translateParagraphNode', () => {
const result = translateParagraphNode(params);

expect(result).toBe(annotationElements);
expect(translateChildNodes).toHaveBeenCalledWith(params);
expect(translateChildNodes).toHaveBeenCalledWith({
...params,
extraParams: { ...params.extraParams, paragraphProperties: params.node?.attrs?.paragraphProperties },
});
expect(generateParagraphProperties).not.toHaveBeenCalled();
});

Expand All @@ -51,7 +54,10 @@ describe('translateParagraphNode', () => {
elements: [paragraphProperties, ...childElements],
attributes: {},
});
expect(translateChildNodes).toHaveBeenCalledWith(params);
expect(translateChildNodes).toHaveBeenCalledWith({
...params,
extraParams: { ...params.extraParams, paragraphProperties: params.node?.attrs?.paragraphProperties },
});
expect(generateParagraphProperties).toHaveBeenCalledWith(params);
});

Expand All @@ -70,7 +76,10 @@ describe('translateParagraphNode', () => {
elements: childElements,
attributes: { 'w:rsidRDefault': '00DE1' },
});
expect(translateChildNodes).toHaveBeenCalledWith(params);
expect(translateChildNodes).toHaveBeenCalledWith({
...params,
extraParams: { ...params.extraParams, paragraphProperties: params.node?.attrs?.paragraphProperties },
});
expect(generateParagraphProperties).toHaveBeenCalledWith(params);
});
});
Loading
Loading