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
26 changes: 17 additions & 9 deletions packages/super-editor/src/core/super-converter/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,16 +520,19 @@ export function decodeRPrFromMarks(marks) {
}

marks.forEach((mark) => {
const type = mark.type.name ?? mark.type;
if (!mark) return;
const type = mark.type?.name ?? mark.type;
if (!type) return;
// Normalization guard: tracked format snapshots can be sparse (`{ type }`) so attrs may be missing.
const attrs = mark.attrs || {};
switch (type) {
case 'strike':
case 'italic':
case 'bold':
runProperties[type] = mark.attrs.value !== '0' && mark.attrs.value !== false;
runProperties[type] = attrs.value !== '0' && attrs.value !== false;
break;
case 'underline': {
const { underlineType, underlineColor, underlineThemeColor, underlineThemeTint, underlineThemeShade } =
mark.attrs;
const { underlineType, underlineColor, underlineThemeColor, underlineThemeTint, underlineThemeShade } = attrs;
const underlineAttrs = {};
if (underlineType) {
underlineAttrs['w:val'] = underlineType;
Expand All @@ -552,21 +555,26 @@ export function decodeRPrFromMarks(marks) {
break;
}
case 'highlight':
if (mark.attrs.color) {
if (mark.attrs.color.toLowerCase() === 'transparent') {
if (attrs.color) {
if (attrs.color.toLowerCase() === 'transparent') {
runProperties.highlight = { 'w:val': 'none' };
} else {
runProperties.highlight = { 'w:val': mark.attrs.color };
runProperties.highlight = { 'w:val': attrs.color };
}
}
break;
case 'link':
runProperties.styleId = 'Hyperlink';
break;
case 'textStyle':
Object.keys(mark.attrs).forEach((attr) => {
const value = mark.attrs[attr];
Object.keys(attrs).forEach((attr) => {
const value = attrs[attr];
switch (attr) {
case 'styleId':
if (value != null) {
runProperties.styleId = value;
}
break;
case 'textTransform':
if (value != null) {
runProperties[attr] = value;
Expand Down
12 changes: 12 additions & 0 deletions packages/super-editor/src/core/super-converter/styles.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,18 @@ describe('decodeRPrFromMarks', () => {
expect(rPr).toEqual({ textTransform: 'uppercase' });
});

it('should decode textStyle with styleId into run style', () => {
const marks = [{ type: 'textStyle', attrs: { styleId: 'Emphasis' } }];
const rPr = decodeRPrFromMarks(marks);
expect(rPr).toEqual({ styleId: 'Emphasis' });
});

it('should handle sparse mark snapshots with missing attrs', () => {
const marks = [{ type: 'bold' }, { type: 'italic' }, { type: 'textStyle' }];
const rPr = decodeRPrFromMarks(marks);
expect(rPr).toEqual({ bold: true, italic: true });
});

it('should decode link mark into Hyperlink styleId', () => {
const marks = [{ type: 'link', attrs: { href: 'https://example.com' } }];
const rPr = decodeRPrFromMarks(marks);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,70 @@ describe('handleStyleChangeMarksV2', () => {
expect(result[0].attrs.before).toEqual([]);
expect(result[0].attrs.after).toEqual([]);
});

it('imports color/font family/font size/italic/bold/underline style changes from rPrChange', () => {
const currentMarks = [
{ type: 'bold', attrs: { value: true } },
{ type: 'italic', attrs: { value: true } },
{ type: 'underline', attrs: { underlineType: 'single' } },
{
type: 'textStyle',
attrs: {
color: '#FF0000',
fontFamily: 'Arial, sans-serif',
fontSize: '12pt',
},
},
];

const rPrChange = {
name: 'w:rPrChange',
attributes: {
'w:id': '55',
'w:date': '2026-03-10T12:00:00Z',
'w:author': 'Reviewer',
},
elements: [
{
name: 'w:rPr',
elements: [
{ name: 'w:b', attributes: { 'w:val': '1' } },
{ name: 'w:i', attributes: { 'w:val': '1' } },
{ name: 'w:u', attributes: { 'w:val': 'single' } },
{ name: 'w:color', attributes: { 'w:val': 'FF0000' } },
{
name: 'w:rFonts',
attributes: { 'w:ascii': 'Arial', 'w:eastAsia': 'Arial', 'w:hAnsi': 'Arial', 'w:cs': 'Arial' },
},
{ name: 'w:sz', attributes: { 'w:val': '24' } },
],
},
],
};

const result = handleStyleChangeMarksV2(rPrChange, currentMarks, { docx: {} });

expect(result).toHaveLength(1);
expect(result[0].type).toBe(TrackFormatMarkName);
expect(result[0].attrs.id).toBe('55');
expect(result[0].attrs.author).toBe('Reviewer');

const before = result[0].attrs.before;
const beforeBold = before.find((mark) => mark.type === 'bold');
const beforeItalic = before.find((mark) => mark.type === 'italic');
const beforeUnderline = before.find((mark) => mark.type === 'underline');
const beforeTextStyle = before.find((mark) => mark.type === 'textStyle');

expect(beforeBold?.attrs).toEqual({ value: true });
expect(beforeItalic?.attrs).toEqual({ value: true });
expect(beforeUnderline?.attrs).toEqual({ underlineType: 'single' });
expect(beforeTextStyle?.attrs).toMatchObject({
color: '#FF0000',
fontFamily: 'Arial, sans-serif',
fontSize: '12pt',
});
expect(result[0].attrs.after).toEqual(currentMarks);
});
});

describe('createImportMarks', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,40 @@
import { processOutputMarks } from '@converter/exporter.js';
import { decodeRPrFromMarks } from '@converter/styles.js';
import { translator as wRPrNodeTranslator } from '@converter/v3/handlers/w/rpr/rpr-translator.js';

// Normalizes marks into a stable `{ type, attrs }` shape before sending them into the newer
// decodeRPrFromMarks + w:rPr translator pipeline.
const normalizeMark = (mark) => {
if (!mark) return null;
const type = typeof mark.type === 'string' ? mark.type : typeof mark.type?.name === 'string' ? mark.type.name : null;
if (!type) return null;
// Some snapshots only store `{ type }`; normalize to empty attrs so decodeRPrFromMarks stays safe.
return { type, attrs: mark?.attrs || {} };
};

const toRunPropertyElements = (marks = []) =>
Copy link
Contributor

Choose a reason for hiding this comment

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

the IIFE wrapper doesn't do anything here — a regular block body works the same.

Suggested change
const toRunPropertyElements = (marks = []) =>
const toRunPropertyElements = (marks = []) => {
const normalizedMarks = marks.map((mark) => normalizeMark(mark)).filter(Boolean);
const runProperties = decodeRPrFromMarks(normalizedMarks);
const rPrNode = wRPrNodeTranslator.decode({ node: { attrs: { runProperties } } });
return Array.isArray(rPrNode?.elements) ? rPrNode.elements : [];
};

(() => {
const normalizedMarks = marks.map((mark) => normalizeMark(mark)).filter(Boolean);
const runProperties = decodeRPrFromMarks(normalizedMarks);
const rPrNode = wRPrNodeTranslator.decode({ node: { attrs: { runProperties } } });
return Array.isArray(rPrNode?.elements) ? rPrNode.elements : [];
})();
/**
* Creates export element for trackFormat mark
* @param {Array} marks SD node marks.
* @returns {Object|undefined} Properties element for trackFormat change or undefined.
*/
export const createTrackStyleMark = (marks) => {
const trackStyleMark = marks.find((mark) => mark.type === 'trackFormat');
const existingNode = marks.find((mark) => mark?.name === 'w:rPrChange');
if (existingNode) {
// Import path already produced a valid OOXML change node; re-use it verbatim.
// these xml nodes come from ins-translator and del-translator
return existingNode;
}

const trackStyleMark = marks.find((mark) => normalizeMark(mark)?.type === 'trackFormat');
if (trackStyleMark) {
const beforeElements = toRunPropertyElements(trackStyleMark.attrs?.before || []);

return {
type: 'element',
name: 'w:rPrChange',
Expand All @@ -16,7 +44,14 @@ export const createTrackStyleMark = (marks) => {
'w:authorEmail': trackStyleMark.attrs.authorEmail,
'w:date': trackStyleMark.attrs.date,
},
elements: trackStyleMark.attrs.before.map((mark) => processOutputMarks([mark])).filter((r) => r !== undefined),
elements: [
{
type: 'element',
name: 'w:rPr',
// Core fix: Word expects previous formatting inside <w:rPrChange><w:rPr>...</w:rPr></w:rPrChange>.
elements: beforeElements,
},
],
};
}
return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,50 @@ const decode = (params, decodedAttrs = {}) => {

const runPropsTemplate = runPropertiesElement ? cloneXmlNode(runPropertiesElement) : null;
const applyBaseRunProps = (runNode) => applyRunPropertiesTemplate(runNode, runPropsTemplate);
const getRunPropertyChangeNodes = (runNode) => {
if (!Array.isArray(runNode?.elements)) return [];
const existingRunProperties = runNode.elements.find((el) => el?.name === 'w:rPr');
if (!Array.isArray(existingRunProperties?.elements)) return [];
// Core fix: preserve any existing <w:rPrChange> so run-property normalization does not drop tracked formatting.
return existingRunProperties.elements.filter((el) => el?.name === 'w:rPrChange').map((el) => cloneXmlNode(el));
};

const getRunPropertyChangeKey = (changeNode) =>
`${changeNode?.attributes?.['w:id'] ?? ''}|${changeNode?.attributes?.['w:author'] ?? ''}|${changeNode?.attributes?.['w:date'] ?? ''}`;

const replaceRunProps = (runNode) => {
const preservedPropertyChanges = getRunPropertyChangeNodes(runNode);

// Remove existing rPr if any
if (Array.isArray(runNode.elements)) {
runNode.elements = runNode.elements.filter((el) => el?.name !== 'w:rPr');
} else {
runNode.elements = [];
}
if (runPropsTemplate) {
runNode.elements.unshift(cloneXmlNode(runPropsTemplate));

const nextRunProperties = runPropsTemplate ? cloneXmlNode(runPropsTemplate) : null;
const runPropertiesNode =
nextRunProperties || preservedPropertyChanges.length
? nextRunProperties || { name: 'w:rPr', elements: [] }
: null;

if (runPropertiesNode) {
if (!Array.isArray(runPropertiesNode.elements)) runPropertiesNode.elements = [];

const existingChangeKeys = new Set(
Copy link
Contributor

Choose a reason for hiding this comment

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

the dedup logic here can't actually trigger — the template never contains w:rPrChange nodes, so the Set is always empty. could just append directly without the key check.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Would the preserved changes ever have duplicates we need to account for? or safe to just put them all in?

Copy link
Contributor

Choose a reason for hiding this comment

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

to answer your question: no, duplicates can't happen. the template never contains w:rPrChange nodes, and the spec only allows one per w:rPr. safe to just push directly without the Set.

runPropertiesNode.elements.filter((el) => el?.name === 'w:rPrChange').map((el) => getRunPropertyChangeKey(el)),
);

preservedPropertyChanges.forEach((changeNode) => {
const changeKey = getRunPropertyChangeKey(changeNode);
if (!existingChangeKeys.has(changeKey)) {
// Keep tracked format changes even when we replace base run properties.
runPropertiesNode.elements.push(changeNode);
existingChangeKeys.add(changeKey);
}
});

runNode.elements.unshift(runPropertiesNode);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,4 +343,104 @@ describe('w:r r-translator (node)', () => {
const vertAlign = runProperties?.elements?.find((el) => el?.name === 'w:vertAlign');
expect(vertAlign?.attributes?.['w:val']).toBe('superscript');
});

it('exports trackFormat marks as w:rPrChange entries', () => {
const result = translator.decode({
node: {
type: 'run',
attrs: {},
content: [
{
type: 'text',
text: 'tracked formatting',
marks: [
{ type: 'bold', attrs: { value: true } },
{
type: 'trackFormat',
attrs: {
id: '42',
author: 'Reviewer',
authorEmail: 'reviewer@example.com',
date: '2026-03-10T12:00:00Z',
before: [
{ type: 'bold', attrs: { value: true } },
{ type: 'italic', attrs: { value: true } },
{
type: 'underline',
attrs: { underlineType: 'single' },
},
{
type: 'textStyle',
attrs: {
styleId: 'Emphasis',
color: '#FF0000',
fontFamily: 'Arial, sans-serif',
fontSize: '12pt',
letterSpacing: '1.5pt',
vertAlign: 'superscript',
},
},
],
after: [
{ type: 'bold', attrs: { value: true } },
{ type: 'italic', attrs: { value: true } },
{
type: 'underline',
attrs: { underlineType: 'single' },
},
{
type: 'textStyle',
attrs: {
styleId: 'Emphasis',
color: '#FF0000',
fontFamily: 'Arial, sans-serif',
fontSize: '12pt',
letterSpacing: '1.5pt',
vertAlign: 'superscript',
},
},
],
},
},
],
},
],
},
});

expect(result?.name).toBe('w:r');
const runProperties = result?.elements?.find((el) => el?.name === 'w:rPr');
const runPropertyChange = runProperties?.elements?.find((el) => el?.name === 'w:rPrChange');
expect(runPropertyChange).toBeDefined();
expect(runPropertyChange?.attributes).toMatchObject({
'w:id': '42',
'w:author': 'Reviewer',
'w:authorEmail': 'reviewer@example.com',
'w:date': '2026-03-10T12:00:00Z',
});

const beforeProperties = runPropertyChange?.elements?.find((el) => el?.name === 'w:rPr');
expect(beforeProperties).toBeDefined();
expect(beforeProperties?.elements?.some((el) => el?.name === 'w:b')).toBe(true);
expect(beforeProperties?.elements?.some((el) => el?.name === 'w:i')).toBe(true);
expect(beforeProperties?.elements?.some((el) => el?.name === 'w:u')).toBe(true);

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

const fontNode = beforeProperties?.elements?.find((el) => el?.name === 'w:rFonts');
expect(fontNode?.attributes?.['w:ascii']).toBe('Arial');

const styleNode = beforeProperties?.elements?.find((el) => el?.name === 'w:rStyle');
expect(styleNode?.attributes?.['w:val']).toBe('Emphasis');

const sizeNode = beforeProperties?.elements?.find((el) => el?.name === 'w:sz');
expect(Number(sizeNode?.attributes?.['w:val'])).toBe(24);

const spacingNode = beforeProperties?.elements?.find((el) => el?.name === 'w:spacing');
expect(Number(spacingNode?.attributes?.['w:val'])).toBe(30);

const vertAlignNode = beforeProperties?.elements?.find((el) => el?.name === 'w:vertAlign');
expect(vertAlignNode?.attributes?.['w:val']).toBe('superscript');
});
});
Loading
Loading