Skip to content

Commit ea3748e

Browse files
palmer-clclarencepalmer
andauthored
fix(runs): add ability to store an export run styleIds (#765)
* fix: improve run style and paragraph style handling in node importer - Enhanced the logic for combining run style attributes and paragraph styles, ensuring that run styles correctly override paragraph styles. - Refactored the code to collect and merge styles more efficiently, improving the overall import process for nodes. * test: mark order and multiple spacing attrs * fix: run style handling and attr management in importer and linked styles for lower level styleIds * fix: safer check * chore: cleanup old code * test: update tests * fix: export can grab run style and add back to xml * fix: run node marks must be unique to p nodes and node marks * fix: hyperlink exporter now pulls from styleId if the run has it --------- Co-authored-by: clarencepalmer <cole@rollprogramcole.com>
1 parent 44b3095 commit ea3748e

9 files changed

Lines changed: 406 additions & 21 deletions

File tree

packages/super-editor/src/core/super-converter/SuperConverter.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ class SuperConverter {
5151
{ name: 'w:sz', type: 'fontSize', mark: 'textStyle', property: 'fontSize' },
5252
// { name: 'w:szCs', type: 'fontSize', mark: 'textStyle', property: 'fontSize' },
5353
{ name: 'w:rFonts', type: 'fontFamily', mark: 'textStyle', property: 'fontFamily' },
54+
{ name: 'w:rStyle', type: 'styleId', mark: 'textStyle', property: 'styleId' },
5455
{ name: 'w:jc', type: 'textAlign', mark: 'textStyle', property: 'textAlign' },
5556
{ name: 'w:ind', type: 'textIndent', mark: 'textStyle', property: 'textIndent' },
5657
{ name: 'w:spacing', type: 'lineHeight', mark: 'textStyle', property: 'lineHeight' },
58+
{ name: 'w:spacing', type: 'letterSpacing', mark: 'textStyle', property: 'letterSpacing' },
5759
{ name: 'link', type: 'link', mark: 'link', property: 'href' },
5860
{ name: 'w:highlight', type: 'highlight', mark: 'highlight', property: 'color' },
5961
{ name: 'w:shd', type: 'highlight', mark: 'highlight', property: 'color' },

packages/super-editor/src/core/super-converter/exporter.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1430,6 +1430,12 @@ function translateMark(mark) {
14301430
});
14311431
break;
14321432

1433+
// Add ability to get run styleIds from textStyle marks and inject to run properties in word
1434+
case 'styleId':
1435+
markElement.name = 'w:rStyle';
1436+
markElement.attributes['w:val'] = attrs.styleId;
1437+
break;
1438+
14331439
case 'color':
14341440
let processedColor = attrs.color.replace(/^#/, '').replace(/;$/, ''); // Remove `#` and `;` if present
14351441
if (processedColor.startsWith('rgb')) {
@@ -1458,7 +1464,6 @@ function translateMark(mark) {
14581464
case 'lineHeight':
14591465
markElement.attributes['w:line'] = linesToTwips(attrs.lineHeight);
14601466
break;
1461-
14621467
case 'highlight':
14631468
markElement.attributes['w:fill'] = attrs.color?.substring(1);
14641469
markElement.attributes['w:color'] = 'auto';

packages/super-editor/src/core/super-converter/v2/importer/markImporter.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,27 @@ export function parseMarks(property, unknownMarks = [], docx = null) {
3838
}
3939
}
4040

41-
marksForType.forEach((m) => {
41+
let filteredMarksForType = marksForType;
42+
43+
/**
44+
* Now that we have 2 marks named 'spacing' we need to determine if its
45+
* for line height or letter spacing.
46+
*
47+
* If the spacing has a w:val attribute, it's for letter spacing.
48+
* If the spacing has a w:line, w:lineRule, w:before, w:after attribute, it's for line height.
49+
*/
50+
if (element.name === 'w:spacing') {
51+
const attrs = element.attributes || {};
52+
const hasLetterSpacing = attrs['w:val'];
53+
filteredMarksForType = marksForType.filter((m) => {
54+
if (hasLetterSpacing) {
55+
return m.type === 'letterSpacing';
56+
}
57+
return m.type === 'lineHeight';
58+
});
59+
}
60+
61+
filteredMarksForType.forEach((m) => {
4262
if (!m || seen.has(m.type)) return;
4363
seen.add(m.type);
4464

@@ -145,6 +165,7 @@ function getMarkValue(markType, attributes, docx) {
145165
textIndent: () => getIndentValue(attributes),
146166
fontFamily: () => getFontFamilyValue(attributes, docx),
147167
lineHeight: () => getLineHeightValue(attributes),
168+
letterSpacing: () => `${twipsToPt(attributes['w:val'])}pt`,
148169
textAlign: () => attributes['w:val'],
149170
link: () => attributes['href'],
150171
underline: () => attributes['w:val'],

packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createImportMarks } from './markImporter.js';
44
/**
55
* @type {import("docxImporter").NodeHandler}
66
*/
7-
const handleRunNode = (params) => {
7+
export const handleRunNode = (params) => {
88
const { nodes, nodeListHandler, parentStyleId, docx } = params;
99
if (nodes.length === 0 || nodes[0].name !== 'w:r') {
1010
return { nodes: [], consumed: 0 };
@@ -19,25 +19,73 @@ const handleRunNode = (params) => {
1919
if (hasRunProperties) {
2020
const { marks = [], attributes = {} } = parseProperties(node);
2121

22-
// Apply fonts from related style definition if there is no marks
23-
const textStyleMark = marks.find((m) => m.type === 'textStyle');
24-
const hasFontStyle = textStyleMark && Object.keys(textStyleMark.attrs).length > 0;
25-
if (defaultNodeStyles.marks && !hasFontStyle) {
26-
const hasBoldDisabled = marks.find((m) => m.type === 'bold')?.attrs?.value === '0';
27-
for (let mark of defaultNodeStyles.marks) {
28-
if (['bold'].includes(mark.type) && hasBoldDisabled) continue;
29-
marks.push(mark);
22+
/* Store run style attributes in an array, then store the defaultNodeStyles (parent styles) in a second array
23+
Then combine the two arrays and create a new array of marks, where the
24+
run style attributes override the defaultNodeStyles
25+
26+
*/
27+
// Collect run style attributes
28+
let runStyleAttributes = [];
29+
const runStyleElement = node.elements
30+
?.find((el) => el.name === 'w:rPr')
31+
?.elements?.find((el) => el.name === 'w:rStyle');
32+
let runStyleId;
33+
if (runStyleElement && runStyleElement.attributes?.['w:val'] && docx) {
34+
runStyleId = runStyleElement.attributes['w:val'];
35+
const runStyleDefinition = getMarksFromStyles(docx, runStyleId);
36+
if (runStyleDefinition.marks && runStyleDefinition.marks.length > 0) {
37+
runStyleAttributes = runStyleDefinition.marks;
3038
}
3139
}
3240

33-
if (node.marks) marks.push(...node.marks);
34-
const newMarks = createImportMarks(marks);
41+
// Collect paragraph style attributes
42+
let paragraphStyleAttributes = [];
43+
if (defaultNodeStyles.marks) {
44+
// Filter out bold if it's disabled
45+
paragraphStyleAttributes = defaultNodeStyles.marks.filter((mark) => {
46+
if (['bold'].includes(mark.type) && marks.find((m) => m.type === 'bold')?.attrs?.value === '0') {
47+
return false;
48+
}
49+
return true;
50+
});
51+
}
52+
53+
// Combine with correct precedence: paragraph styles first, then run styles (which override)
54+
const combinedMarks = [...paragraphStyleAttributes];
55+
56+
// Add run style attributes if they don't already exist
57+
runStyleAttributes.forEach((runStyle) => {
58+
const exists = combinedMarks.some(
59+
(mark) =>
60+
mark.type === runStyle.type && JSON.stringify(mark.attrs || {}) === JSON.stringify(runStyle.attrs || {}),
61+
);
62+
if (!exists) {
63+
combinedMarks.push(runStyle);
64+
}
65+
});
66+
67+
// Add direct marks if they don't already exist
68+
marks.forEach((mark) => {
69+
const exists = combinedMarks.some(
70+
(existing) =>
71+
existing.type === mark.type && JSON.stringify(existing.attrs || {}) === JSON.stringify(mark.attrs || {}),
72+
);
73+
if (!exists) {
74+
combinedMarks.push(mark);
75+
}
76+
});
77+
// Attach the originating run style id so the span gets styleid like paragraph nodes
78+
if (runStyleId) combinedMarks.push({ type: 'textStyle', attrs: { styleId: runStyleId } });
79+
80+
if (node.marks) combinedMarks.push(...node.marks);
81+
const newMarks = createImportMarks(combinedMarks);
3582
processedRun = processedRun.map((n) => {
3683
const existingMarks = n.marks || [];
37-
return { ...n, marks: [...newMarks, ...existingMarks], attributes };
84+
return {
85+
...n,
86+
marks: [...newMarks, ...existingMarks],
87+
};
3888
});
39-
} else if (defaultNodeStyles.marks) {
40-
processedRun = processedRun.map((n) => ({ ...n, marks: createImportMarks(defaultNodeStyles.marks) }));
4189
}
4290
return { nodes: processedRun, consumed: 1 };
4391
};
@@ -53,7 +101,7 @@ const getMarksFromStyles = (docx, styleId) => {
53101

54102
if (!style) return {};
55103

56-
return parseProperties(style, docx);
104+
return parseProperties(style);
57105
};
58106

59107
/**

packages/super-editor/src/extensions/linked-styles/plugin.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,17 @@ const generateDecorations = (state, styles) => {
5050
doc.descendants((node, pos) => {
5151
const { name } = node.type;
5252

53-
// Track the current StyleId
5453
if (node?.attrs?.styleId) lastStyleId = node.attrs.styleId;
5554
if (name === 'paragraph' && !node.attrs?.styleId) lastStyleId = null;
5655
if (name !== 'text' && name !== 'listItem' && name !== 'orderedList') return;
5756

57+
// Get the last styleId from the node marks
58+
// This allows run-level styles and styleIds to override paragraph-level styles
59+
for (const mark of node.marks) {
60+
if (mark.type.name === 'textStyle' && mark.attrs.styleId) {
61+
lastStyleId = mark.attrs.styleId;
62+
}
63+
}
5864
const { linkedStyle, basedOnStyle } = getLinkedStyle(lastStyleId, styles);
5965
if (!linkedStyle) return;
6066

packages/super-editor/src/extensions/text-style/text-style.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export const TextStyle = Mark.create({
2929
return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0];
3030
},
3131

32+
addAttributes() {
33+
return {
34+
styleId: {},
35+
};
36+
},
37+
3238
addCommands() {
3339
return {
3440
removeEmptyTextStyle:

packages/super-editor/src/tests/export/hyperlinkExporter.test.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ describe('HyperlinkNodeExporter', async () => {
2121
);
2222

2323
const rPr = hyperLinkNode.elements[0].elements[0];
24-
expect(rPr.elements[1].name).toBe('w:u');
25-
expect(rPr.elements[1].attributes['w:val']).toBe('single');
24+
expect(rPr.elements[0].name).toBe('w:u');
25+
expect(rPr.elements[0].attributes['w:val']).toBe('single');
26+
expect(rPr.elements[1].name).toBe('w:color');
2627
expect(rPr.elements[2].name).toBe('w:rFonts');
2728
expect(rPr.elements[2].attributes['w:ascii']).toBe('Arial');
2829
expect(rPr.elements[3].name).toBe('w:sz');

packages/super-editor/src/tests/import/hyperlinkImporter.test.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ describe('HyperlinkNodeImporter', () => {
1818
nodeListHandler: defaultNodeListHandler(),
1919
});
2020
const { marks } = nodes[0];
21-
2221
expect(marks.length).toBe(3);
2322
expect(marks[0].type).toBe('underline');
2423
expect(marks[1].type).toBe('link');
@@ -30,5 +29,12 @@ describe('HyperlinkNodeImporter', () => {
3029
'https://stackoverflow.com/questions/66669593/how-to-attach-image-at-first-page-in-docx-file-nodejs',
3130
);
3231
expect(marks[1].attrs.rId).toBe('rId4');
32+
33+
// Capture the textStyle mark
34+
const textStyleMark = marks[2];
35+
expect(textStyleMark.type).toBe('textStyle');
36+
expect(textStyleMark.attrs.styleId).toBe('Hyperlink');
37+
expect(textStyleMark.attrs.fontFamily).toBe('Arial');
38+
expect(textStyleMark.attrs.fontSize).toBe('10pt');
3339
});
3440
});

0 commit comments

Comments
 (0)