diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 463d31e158..105e51c08e 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -51,9 +51,11 @@ class SuperConverter { { name: 'w:sz', type: 'fontSize', mark: 'textStyle', property: 'fontSize' }, // { name: 'w:szCs', type: 'fontSize', mark: 'textStyle', property: 'fontSize' }, { name: 'w:rFonts', type: 'fontFamily', mark: 'textStyle', property: 'fontFamily' }, + { name: 'w:rStyle', type: 'styleId', mark: 'textStyle', property: 'styleId' }, { name: 'w:jc', type: 'textAlign', mark: 'textStyle', property: 'textAlign' }, { name: 'w:ind', type: 'textIndent', mark: 'textStyle', property: 'textIndent' }, { name: 'w:spacing', type: 'lineHeight', mark: 'textStyle', property: 'lineHeight' }, + { name: 'w:spacing', type: 'letterSpacing', mark: 'textStyle', property: 'letterSpacing' }, { name: 'link', type: 'link', mark: 'link', property: 'href' }, { name: 'w:highlight', type: 'highlight', mark: 'highlight', property: 'color' }, { name: 'w:shd', type: 'highlight', mark: 'highlight', property: 'color' }, diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 9689b2f855..9baa24c2dc 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -1430,6 +1430,12 @@ function translateMark(mark) { }); break; + // Add ability to get run styleIds from textStyle marks and inject to run properties in word + case 'styleId': + markElement.name = 'w:rStyle'; + markElement.attributes['w:val'] = attrs.styleId; + break; + case 'color': let processedColor = attrs.color.replace(/^#/, '').replace(/;$/, ''); // Remove `#` and `;` if present if (processedColor.startsWith('rgb')) { @@ -1458,7 +1464,6 @@ function translateMark(mark) { case 'lineHeight': markElement.attributes['w:line'] = linesToTwips(attrs.lineHeight); break; - case 'highlight': markElement.attributes['w:fill'] = attrs.color?.substring(1); markElement.attributes['w:color'] = 'auto'; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js index 257cbef991..9b7fca9051 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js @@ -38,7 +38,27 @@ export function parseMarks(property, unknownMarks = [], docx = null) { } } - marksForType.forEach((m) => { + let filteredMarksForType = marksForType; + + /** + * Now that we have 2 marks named 'spacing' we need to determine if its + * for line height or letter spacing. + * + * If the spacing has a w:val attribute, it's for letter spacing. + * If the spacing has a w:line, w:lineRule, w:before, w:after attribute, it's for line height. + */ + if (element.name === 'w:spacing') { + const attrs = element.attributes || {}; + const hasLetterSpacing = attrs['w:val']; + filteredMarksForType = marksForType.filter((m) => { + if (hasLetterSpacing) { + return m.type === 'letterSpacing'; + } + return m.type === 'lineHeight'; + }); + } + + filteredMarksForType.forEach((m) => { if (!m || seen.has(m.type)) return; seen.add(m.type); @@ -145,6 +165,7 @@ function getMarkValue(markType, attributes, docx) { textIndent: () => getIndentValue(attributes), fontFamily: () => getFontFamilyValue(attributes, docx), lineHeight: () => getLineHeightValue(attributes), + letterSpacing: () => `${twipsToPt(attributes['w:val'])}pt`, textAlign: () => attributes['w:val'], link: () => attributes['href'], underline: () => attributes['w:val'], diff --git a/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js index 2dd5a2aa75..ff83463d9e 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js @@ -4,7 +4,7 @@ import { createImportMarks } from './markImporter.js'; /** * @type {import("docxImporter").NodeHandler} */ -const handleRunNode = (params) => { +export const handleRunNode = (params) => { const { nodes, nodeListHandler, parentStyleId, docx } = params; if (nodes.length === 0 || nodes[0].name !== 'w:r') { return { nodes: [], consumed: 0 }; @@ -19,25 +19,73 @@ const handleRunNode = (params) => { if (hasRunProperties) { const { marks = [], attributes = {} } = parseProperties(node); - // Apply fonts from related style definition if there is no marks - const textStyleMark = marks.find((m) => m.type === 'textStyle'); - const hasFontStyle = textStyleMark && Object.keys(textStyleMark.attrs).length > 0; - if (defaultNodeStyles.marks && !hasFontStyle) { - const hasBoldDisabled = marks.find((m) => m.type === 'bold')?.attrs?.value === '0'; - for (let mark of defaultNodeStyles.marks) { - if (['bold'].includes(mark.type) && hasBoldDisabled) continue; - marks.push(mark); + /* Store run style attributes in an array, then store the defaultNodeStyles (parent styles) in a second array + Then combine the two arrays and create a new array of marks, where the + run style attributes override the defaultNodeStyles + + */ + // Collect run style attributes + let runStyleAttributes = []; + const runStyleElement = node.elements + ?.find((el) => el.name === 'w:rPr') + ?.elements?.find((el) => el.name === 'w:rStyle'); + let runStyleId; + if (runStyleElement && runStyleElement.attributes?.['w:val'] && docx) { + runStyleId = runStyleElement.attributes['w:val']; + const runStyleDefinition = getMarksFromStyles(docx, runStyleId); + if (runStyleDefinition.marks && runStyleDefinition.marks.length > 0) { + runStyleAttributes = runStyleDefinition.marks; } } - if (node.marks) marks.push(...node.marks); - const newMarks = createImportMarks(marks); + // Collect paragraph style attributes + let paragraphStyleAttributes = []; + if (defaultNodeStyles.marks) { + // Filter out bold if it's disabled + paragraphStyleAttributes = defaultNodeStyles.marks.filter((mark) => { + if (['bold'].includes(mark.type) && marks.find((m) => m.type === 'bold')?.attrs?.value === '0') { + return false; + } + return true; + }); + } + + // Combine with correct precedence: paragraph styles first, then run styles (which override) + const combinedMarks = [...paragraphStyleAttributes]; + + // Add run style attributes if they don't already exist + runStyleAttributes.forEach((runStyle) => { + const exists = combinedMarks.some( + (mark) => + mark.type === runStyle.type && JSON.stringify(mark.attrs || {}) === JSON.stringify(runStyle.attrs || {}), + ); + if (!exists) { + combinedMarks.push(runStyle); + } + }); + + // Add direct marks if they don't already exist + marks.forEach((mark) => { + const exists = combinedMarks.some( + (existing) => + existing.type === mark.type && JSON.stringify(existing.attrs || {}) === JSON.stringify(mark.attrs || {}), + ); + if (!exists) { + combinedMarks.push(mark); + } + }); + // Attach the originating run style id so the span gets styleid like paragraph nodes + if (runStyleId) combinedMarks.push({ type: 'textStyle', attrs: { styleId: runStyleId } }); + + if (node.marks) combinedMarks.push(...node.marks); + const newMarks = createImportMarks(combinedMarks); processedRun = processedRun.map((n) => { const existingMarks = n.marks || []; - return { ...n, marks: [...newMarks, ...existingMarks], attributes }; + return { + ...n, + marks: [...newMarks, ...existingMarks], + }; }); - } else if (defaultNodeStyles.marks) { - processedRun = processedRun.map((n) => ({ ...n, marks: createImportMarks(defaultNodeStyles.marks) })); } return { nodes: processedRun, consumed: 1 }; }; @@ -53,7 +101,7 @@ const getMarksFromStyles = (docx, styleId) => { if (!style) return {}; - return parseProperties(style, docx); + return parseProperties(style); }; /** diff --git a/packages/super-editor/src/extensions/linked-styles/plugin.js b/packages/super-editor/src/extensions/linked-styles/plugin.js index 14370415d0..39c9d18e97 100644 --- a/packages/super-editor/src/extensions/linked-styles/plugin.js +++ b/packages/super-editor/src/extensions/linked-styles/plugin.js @@ -50,11 +50,17 @@ const generateDecorations = (state, styles) => { doc.descendants((node, pos) => { const { name } = node.type; - // Track the current StyleId if (node?.attrs?.styleId) lastStyleId = node.attrs.styleId; if (name === 'paragraph' && !node.attrs?.styleId) lastStyleId = null; if (name !== 'text' && name !== 'listItem' && name !== 'orderedList') return; + // Get the last styleId from the node marks + // This allows run-level styles and styleIds to override paragraph-level styles + for (const mark of node.marks) { + if (mark.type.name === 'textStyle' && mark.attrs.styleId) { + lastStyleId = mark.attrs.styleId; + } + } const { linkedStyle, basedOnStyle } = getLinkedStyle(lastStyleId, styles); if (!linkedStyle) return; diff --git a/packages/super-editor/src/extensions/text-style/text-style.js b/packages/super-editor/src/extensions/text-style/text-style.js index 324521ee64..2ca4d17790 100644 --- a/packages/super-editor/src/extensions/text-style/text-style.js +++ b/packages/super-editor/src/extensions/text-style/text-style.js @@ -29,6 +29,12 @@ export const TextStyle = Mark.create({ return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0]; }, + addAttributes() { + return { + styleId: {}, + }; + }, + addCommands() { return { removeEmptyTextStyle: diff --git a/packages/super-editor/src/tests/export/hyperlinkExporter.test.js b/packages/super-editor/src/tests/export/hyperlinkExporter.test.js index 99b027e5d1..325a0c16e1 100644 --- a/packages/super-editor/src/tests/export/hyperlinkExporter.test.js +++ b/packages/super-editor/src/tests/export/hyperlinkExporter.test.js @@ -21,8 +21,9 @@ describe('HyperlinkNodeExporter', async () => { ); const rPr = hyperLinkNode.elements[0].elements[0]; - expect(rPr.elements[1].name).toBe('w:u'); - expect(rPr.elements[1].attributes['w:val']).toBe('single'); + expect(rPr.elements[0].name).toBe('w:u'); + expect(rPr.elements[0].attributes['w:val']).toBe('single'); + expect(rPr.elements[1].name).toBe('w:color'); expect(rPr.elements[2].name).toBe('w:rFonts'); expect(rPr.elements[2].attributes['w:ascii']).toBe('Arial'); expect(rPr.elements[3].name).toBe('w:sz'); diff --git a/packages/super-editor/src/tests/import/hyperlinkImporter.test.js b/packages/super-editor/src/tests/import/hyperlinkImporter.test.js index f8e84f4b7f..7913cafce3 100644 --- a/packages/super-editor/src/tests/import/hyperlinkImporter.test.js +++ b/packages/super-editor/src/tests/import/hyperlinkImporter.test.js @@ -18,7 +18,6 @@ describe('HyperlinkNodeImporter', () => { nodeListHandler: defaultNodeListHandler(), }); const { marks } = nodes[0]; - expect(marks.length).toBe(3); expect(marks[0].type).toBe('underline'); expect(marks[1].type).toBe('link'); @@ -30,5 +29,12 @@ describe('HyperlinkNodeImporter', () => { 'https://stackoverflow.com/questions/66669593/how-to-attach-image-at-first-page-in-docx-file-nodejs', ); expect(marks[1].attrs.rId).toBe('rId4'); + + // Capture the textStyle mark + const textStyleMark = marks[2]; + expect(textStyleMark.type).toBe('textStyle'); + expect(textStyleMark.attrs.styleId).toBe('Hyperlink'); + expect(textStyleMark.attrs.fontFamily).toBe('Arial'); + expect(textStyleMark.attrs.fontSize).toBe('10pt'); }); }); diff --git a/packages/super-editor/src/tests/import/runImporter.test.js b/packages/super-editor/src/tests/import/runImporter.test.js new file mode 100644 index 0000000000..835996524b --- /dev/null +++ b/packages/super-editor/src/tests/import/runImporter.test.js @@ -0,0 +1,290 @@ +import { expect, describe, it } from 'vitest'; +import { handleRunNode } from '@core/super-converter/v2/importer/runNodeImporter.js'; + +// Helper functions to create common mocks +const createMockDocx = (styles = []) => ({ + 'word/styles.xml': { + elements: [ + { + elements: styles, + }, + ], + }, +}); + +const createMockStyle = (styleId, runProperties = []) => ({ + name: 'w:style', + attributes: { 'w:styleId': styleId }, + elements: [ + { + name: 'w:rPr', + elements: runProperties, + }, + ], +}); + +const createMockRunProperty = (name, attributes = {}) => ({ + name, + attributes, +}); + +const createMockRunNode = (runProperties = [], text = 'Test text') => ({ + name: 'w:r', + elements: [ + { + name: 'w:rPr', + elements: runProperties, + }, + { + name: 'w:t', + elements: [{ text }], + }, + ], +}); + +const createMockNodeListHandler = (returnType = 'text', returnText = 'Test text') => ({ + handler: () => [{ type: returnType, text: returnText, marks: [] }], +}); + +const createMockRunStyle = (styleId) => createMockRunProperty('w:rStyle', { 'w:val': styleId }); + +const createMockFont = (fontFamily) => createMockRunProperty('w:rFonts', { 'w:ascii': fontFamily }); + +const createMockSize = (size) => createMockRunProperty('w:sz', { 'w:val': size }); + +const createMockColor = (color) => createMockRunProperty('w:color', { 'w:val': color }); + +const createMockBold = () => createMockRunProperty('w:b', {}); + +const createMockItalic = () => createMockRunProperty('w:i', {}); + +describe('runImporter', () => { + describe('runStyle attributes override paragraphStyleAttributes', () => { + it('should override paragraph style attributes with run style attributes', () => { + // Create styles with paragraph and run styles + const paragraphStyle = createMockStyle('ParagraphStyle', [ + createMockFont('Times New Roman'), + createMockSize('24'), // 12pt + ]); + + const runStyle = createMockStyle('RunStyle', [ + createMockFont('Arial'), + createMockSize('32'), // 16pt + ]); + + const mockDocx = createMockDocx([paragraphStyle, runStyle]); + const mockRunNode = createMockRunNode([createMockRunStyle('RunStyle')]); + const mockNodeListHandler = createMockNodeListHandler(); + + const result = handleRunNode({ + nodes: [mockRunNode], + nodeListHandler: mockNodeListHandler, + parentStyleId: 'ParagraphStyle', + docx: mockDocx, + }); + + expect(result.nodes).toHaveLength(1); + expect(result.consumed).toBe(1); + + const textNode = result.nodes[0]; + expect(textNode.type).toBe('text'); + expect(textNode.text).toBe('Test text'); + + // Check that run style attributes override paragraph style attributes + const textStyleMark = textNode.marks.find((mark) => mark.type === 'textStyle'); + expect(textStyleMark).toBeDefined(); + expect(textStyleMark.attrs.fontFamily).toBe('Arial'); // Run style font + expect(textStyleMark.attrs.fontSize).toBe('16pt'); // Run style size + expect(textStyleMark.attrs.styleId).toBe('RunStyle'); // Run style ID + }); + + it('should combine paragraph and run styles with correct precedence', () => { + // Create styles with paragraph and run styles + const paragraphStyle = createMockStyle('ParagraphStyle', [ + createMockFont('Times New Roman'), + createMockSize('24'), // 12pt + createMockBold(), + ]); + + const runStyle = createMockStyle('RunStyle', [createMockFont('Arial'), createMockItalic()]); + + const mockDocx = createMockDocx([paragraphStyle, runStyle]); + const mockRunNode = createMockRunNode([createMockRunStyle('RunStyle')]); + const mockNodeListHandler = createMockNodeListHandler(); + + const result = handleRunNode({ + nodes: [mockRunNode], + nodeListHandler: mockNodeListHandler, + parentStyleId: 'ParagraphStyle', + docx: mockDocx, + }); + + expect(result.nodes).toHaveLength(1); + const textNode = result.nodes[0]; + + // Check that all marks are present with correct precedence + const textStyleMark = textNode.marks.find((mark) => mark.type === 'textStyle'); + const boldMark = textNode.marks.find((mark) => mark.type === 'bold'); + const italicMark = textNode.marks.find((mark) => mark.type === 'italic'); + + expect(textStyleMark).toBeDefined(); + expect(boldMark).toBeDefined(); + expect(italicMark).toBeDefined(); + + // Run style should override paragraph style for font properties + expect(textStyleMark.attrs.fontFamily).toBe('Arial'); // Run style overrides + expect(textStyleMark.attrs.fontSize).toBe('12pt'); // Paragraph style (no override) + }); + + it('should handle run nodes without run styles', () => { + // Create style with only paragraph styles + const paragraphStyle = createMockStyle('ParagraphStyle', [ + createMockFont('Times New Roman'), + createMockSize('24'), // 12pt + ]); + + const mockDocx = createMockDocx([paragraphStyle]); + const mockRunNode = createMockRunNode([createMockBold()]); + const mockNodeListHandler = createMockNodeListHandler(); + + const result = handleRunNode({ + nodes: [mockRunNode], + nodeListHandler: mockNodeListHandler, + parentStyleId: 'ParagraphStyle', + docx: mockDocx, + }); + + expect(result.nodes).toHaveLength(1); + const textNode = result.nodes[0]; + + // Should have paragraph style attributes + const textStyleMark = textNode.marks.find((mark) => mark.type === 'textStyle'); + const boldMark = textNode.marks.find((mark) => mark.type === 'bold'); + + expect(textStyleMark).toBeDefined(); + expect(textStyleMark.attrs.fontFamily).toBe('Times New Roman'); + expect(textStyleMark.attrs.fontSize).toBe('12pt'); + expect(boldMark).toBeDefined(); + + // Should not have styleId since no run style was applied + expect(textStyleMark.attrs.styleId).toBeUndefined(); + }); + }); + + describe('textStyle mark stores the styleId', () => { + it('should store run style ID in textStyle mark', () => { + const runStyle = createMockStyle('CustomRunStyle', [createMockFont('Calibri')]); + + const mockDocx = createMockDocx([runStyle]); + const mockRunNode = createMockRunNode([createMockRunStyle('CustomRunStyle')]); + const mockNodeListHandler = createMockNodeListHandler('text', 'Styled text'); + + const result = handleRunNode({ + nodes: [mockRunNode], + nodeListHandler: mockNodeListHandler, + parentStyleId: null, + docx: mockDocx, + }); + + expect(result.nodes).toHaveLength(1); + const textNode = result.nodes[0]; + + // Check that styleId is stored in textStyle mark + const textStyleMark = textNode.marks.find((mark) => mark.type === 'textStyle'); + expect(textStyleMark).toBeDefined(); + expect(textStyleMark.attrs.styleId).toBe('CustomRunStyle'); + expect(textStyleMark.attrs.fontFamily).toBe('Calibri'); + }); + + it('should not add styleId when no run style is present', () => { + const mockDocx = createMockDocx([]); + const mockRunNode = createMockRunNode([createMockBold()]); + const mockNodeListHandler = createMockNodeListHandler('text', 'Plain text'); + + const result = handleRunNode({ + nodes: [mockRunNode], + nodeListHandler: mockNodeListHandler, + parentStyleId: null, + docx: mockDocx, + }); + + expect(result.nodes).toHaveLength(1); + const textNode = result.nodes[0]; + + // Should not have textStyle mark with styleId + const textStyleMark = textNode.marks.find((mark) => mark.type === 'textStyle'); + if (textStyleMark) { + expect(textStyleMark.attrs.styleId).toBeUndefined(); + } + }); + + it('should handle multiple textStyle marks correctly', () => { + const runStyle = createMockStyle('MultiStyle', [ + createMockFont('Verdana'), + createMockSize('40'), // 20pt + ]); + + const mockDocx = createMockDocx([runStyle]); + const mockRunNode = createMockRunNode([createMockRunStyle('MultiStyle'), createMockColor('FF0000')]); + const mockNodeListHandler = createMockNodeListHandler('text', 'Multi-styled text'); + + const result = handleRunNode({ + nodes: [mockRunNode], + nodeListHandler: mockNodeListHandler, + parentStyleId: null, + docx: mockDocx, + }); + + expect(result.nodes).toHaveLength(1); + const textNode = result.nodes[0]; + + // Should have combined textStyle mark with all attributes + const textStyleMark = textNode.marks.find((mark) => mark.type === 'textStyle'); + expect(textStyleMark).toBeDefined(); + expect(textStyleMark.attrs.styleId).toBe('MultiStyle'); + expect(textStyleMark.attrs.fontFamily).toBe('Verdana'); + expect(textStyleMark.attrs.fontSize).toBe('20pt'); + expect(textStyleMark.attrs.color).toBe('#FF0000'); + }); + }); + + describe('integration with real document structure', () => { + it('should handle run nodes with complex style hierarchies', () => { + // Create a more complex document structure + const headingStyle = createMockStyle('Heading1', [ + createMockFont('Georgia'), + createMockSize('48'), // 24pt + createMockBold(), + ]); + + const emphasisStyle = createMockStyle('Emphasis', [createMockItalic(), createMockColor('0000FF')]); + + const mockDocx = createMockDocx([headingStyle, emphasisStyle]); + const mockRunNode = createMockRunNode([createMockRunStyle('Emphasis')], 'emphasized text'); + const mockNodeListHandler = createMockNodeListHandler('text', 'emphasized text'); + + const result = handleRunNode({ + nodes: [mockRunNode], + nodeListHandler: mockNodeListHandler, + parentStyleId: 'Heading1', + docx: mockDocx, + }); + + expect(result.nodes).toHaveLength(1); + const textNode = result.nodes[0]; + + // Should have both paragraph and run styles with correct precedence + const textStyleMark = textNode.marks.find((mark) => mark.type === 'textStyle'); + const boldMark = textNode.marks.find((mark) => mark.type === 'bold'); + const italicMark = textNode.marks.find((mark) => mark.type === 'italic'); + + expect(textStyleMark).toBeDefined(); + expect(textStyleMark.attrs.styleId).toBe('Emphasis'); // Run style ID + expect(textStyleMark.attrs.fontFamily).toBe('Georgia'); // From paragraph style + expect(textStyleMark.attrs.fontSize).toBe('24pt'); // From paragraph style + expect(textStyleMark.attrs.color).toBe('#0000FF'); // From run style + expect(boldMark).toBeDefined(); // From paragraph style + expect(italicMark).toBeDefined(); // From run style + }); + }); +});