diff --git a/packages/super-editor/src/core/super-converter/export-helpers/run-properties-export.js b/packages/super-editor/src/core/super-converter/export-helpers/run-properties-export.js new file mode 100644 index 0000000000..c9f03e9e20 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/export-helpers/run-properties-export.js @@ -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 ?? {}; +} diff --git a/packages/super-editor/src/core/super-converter/export-helpers/run-properties-export.test.js b/packages/super-editor/src/core/super-converter/export-helpers/run-properties-export.test.js new file mode 100644 index 0000000000..1b5f298dd6 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/export-helpers/run-properties-export.test.js @@ -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); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js index c4284e4636..7dc4c446df 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js @@ -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) { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js index f62c3ba41e..dbe8e7ca84 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js @@ -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 }); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 68913d2f94..dca51246d6 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -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 diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js index ad40117084..84f990cac0 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js @@ -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, + ); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js index 7132c7b439..1825db4489 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js @@ -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); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js index bf48841306..4b40bb4f83 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js @@ -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(); }); @@ -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); }); @@ -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); }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.js index 4ffe27c5cc..57cfdb5816 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.js @@ -7,6 +7,7 @@ import { translator as wHyperlinkTranslator } from '../hyperlink/hyperlink-trans import { translator as wRPrTranslator } from '../rpr'; import validXmlAttributes from './attributes/index.js'; import { handleStyleChangeMarksV2 } from '../../../../v2/importer/markImporter.js'; +import { getParagraphStyleRunPropertiesFromStylesXml } from '@converter/export-helpers/run-properties-export.js'; import { encodeMarksFromRPr, resolveRunProperties } from '../../../../styles.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:r'; @@ -20,12 +21,29 @@ const SD_KEY_NAME = 'run'; /* * Wraps the provided content in a SuperDoc run node. + * runProperties = resolved (from combine). runPropertiesInlineKeys = keys marked inline at combine (export only these). + * runPropertiesStyleKeys = keys from the run's style in styles.xml (export omits these). + * runPropertiesOverrideKeys = keys that override the style (inline ∩ style); export includes these to preserve user overrides. */ -const createRunNodeWithContent = (content, encodedAttrs, runLevelMarks, runProperties) => { +const createRunNodeWithContent = ( + content, + encodedAttrs, + runLevelMarks, + resolvedRunProperties, + inlineKeysFromCombine, + runPropertiesStyleKeys = null, + runPropertiesOverrideKeys = null, +) => { const node = { type: SD_KEY_NAME, content, - attrs: { ...encodedAttrs, runProperties }, + attrs: { + ...encodedAttrs, + runProperties: resolvedRunProperties, + runPropertiesInlineKeys: inlineKeysFromCombine?.length ? inlineKeysFromCombine : null, + runPropertiesStyleKeys: runPropertiesStyleKeys?.length ? runPropertiesStyleKeys : null, + runPropertiesOverrideKeys: runPropertiesOverrideKeys?.length ? runPropertiesOverrideKeys : null, + }, }; if (runLevelMarks.length) { node.marks = runLevelMarks.map((mark) => cloneMark(mark)); @@ -62,6 +80,8 @@ const encode = (params, encodedAttrs = {}) => { numRows: params.extraParams.totalRows, }; } + const runPropertiesInlineKeysFromCombine = + runProperties && typeof runProperties === 'object' ? Object.keys(runProperties) : []; const resolvedRunProperties = resolveRunProperties( params, runProperties ?? {}, @@ -127,9 +147,31 @@ const encode = (params, encodedAttrs = {}) => { const filtered = contentWithRunMarks.filter(Boolean); + // Keys from the run's style (styleId) in styles.xml — don't export these (already in styles.xml) + let runPropertiesStyleKeys = null; + if (runProperties?.styleId && params?.docx) { + const styleRPr = getParagraphStyleRunPropertiesFromStylesXml(params.docx, runProperties.styleId, params); + if (styleRPr && Object.keys(styleRPr).length > 0) { + runPropertiesStyleKeys = Object.keys(styleRPr); + } + } + // Keys that were in w:rPr and also in the style = explicit overrides; preserve on export + const runPropertiesOverrideKeys = + runPropertiesStyleKeys?.length && runPropertiesInlineKeysFromCombine?.length + ? runPropertiesInlineKeysFromCombine.filter((k) => runPropertiesStyleKeys.includes(k)) + : null; + const containsBreakNodes = filtered.some((child) => child?.type === 'lineBreak'); if (!containsBreakNodes) { - const defaultNode = createRunNodeWithContent(filtered, encodedAttrs, runLevelMarks, runProperties); + const defaultNode = createRunNodeWithContent( + filtered, + encodedAttrs, + runLevelMarks, + resolvedRunProperties, + runPropertiesInlineKeysFromCombine, + runPropertiesStyleKeys, + runPropertiesOverrideKeys, + ); return defaultNode; } @@ -142,7 +184,15 @@ const encode = (params, encodedAttrs = {}) => { */ const finalizeTextChunk = () => { if (!currentChunk.length) return; - const chunkNode = createRunNodeWithContent(currentChunk, encodedAttrs, runLevelMarks, runProperties); + const chunkNode = createRunNodeWithContent( + currentChunk, + encodedAttrs, + runLevelMarks, + resolvedRunProperties, + runPropertiesInlineKeysFromCombine, + runPropertiesStyleKeys, + runPropertiesOverrideKeys, + ); if (chunkNode) splitRuns.push(chunkNode); currentChunk = []; }; @@ -150,7 +200,15 @@ const encode = (params, encodedAttrs = {}) => { filtered.forEach((child) => { if (child?.type === 'lineBreak') { finalizeTextChunk(); - const breakNode = createRunNodeWithContent([child], encodedAttrs, runLevelMarks, runProperties); + const breakNode = createRunNodeWithContent( + [child], + encodedAttrs, + runLevelMarks, + resolvedRunProperties, + runPropertiesInlineKeysFromCombine, + runPropertiesStyleKeys, + runPropertiesOverrideKeys, + ); if (breakNode) splitRuns.push(breakNode); } else { currentChunk.push(child); @@ -180,6 +238,21 @@ const decode = (params, decodedAttrs = {}) => { const runAttrs = runNodeForExport.attrs || {}; const runProperties = runAttrs.runProperties || {}; + const inlineKeys = runAttrs.runPropertiesInlineKeys; + const styleKeys = runAttrs.runPropertiesStyleKeys; + const overrideKeys = runAttrs.runPropertiesOverrideKeys; + + // Export run properties that were inline or that override the style (so user overrides are preserved). + // Exclude keys that are style-only (in styleKeys but not in overrideKeys). + const candidateKeys = [...new Set([...(inlineKeys || []), ...(overrideKeys || [])])]; + const exportKeys = candidateKeys.filter( + (k) => + k in (runProperties || {}) && + (!(Array.isArray(styleKeys) && styleKeys.includes(k)) || + (Array.isArray(overrideKeys) && overrideKeys.includes(k))), + ); + const runPropertiesToExport = + exportKeys.length > 0 ? Object.fromEntries(exportKeys.map((k) => [k, runProperties[k]])) : {}; // Decode child nodes within the run const exportParams = { @@ -192,12 +265,14 @@ const decode = (params, decodedAttrs = {}) => { } const childElements = translateChildNodes(exportParams) || []; - // Parse marks back into run properties - // and combine with any direct run properties - let runPropertiesElement = wRPrTranslator.decode({ - ...params, - node: { attrs: { runProperties: runProperties } }, - }); + // Only emit w:rPr when we have inline overrides; omit when empty so we don't write empty or inherited-only rPr. + let runPropertiesElement = + Object.keys(runPropertiesToExport).length > 0 + ? wRPrTranslator.decode({ + ...params, + node: { attrs: { runProperties: runPropertiesToExport } }, + }) + : null; const runPropsTemplate = runPropertiesElement ? cloneXmlNode(runPropertiesElement) : null; const applyBaseRunProps = (runNode) => applyRunPropertiesTemplate(runNode, runPropsTemplate); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.test.js index 7e7b5cb5ed..ad861e5cfc 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.test.js @@ -1,6 +1,7 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { translator, config } from './r-translator.js'; import * as converterStyles from '../../../../styles.js'; +import * as runPropertiesExport from '../../../../export-helpers/run-properties-export.js'; describe('w:r r-translator (node)', () => { it('exposes correct metadata', () => { @@ -154,13 +155,19 @@ describe('w:r r-translator (node)', () => { expect(child.attrs).toEqual({ originalName: 'w:custom' }); }); - it('passes tableInfo and numberingDefinedInline to resolveRunProperties when table context is available', () => { + it('passes tableInfo and numberingDefinedInline to resolveRunProperties and preserves inline keys when table context is available', () => { const resolveRunPropertiesSpy = vi .spyOn(converterStyles, 'resolveRunProperties') .mockImplementation(() => ({ bold: true })); const runNode = { name: 'w:r', - elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Cell' }] }], + elements: [ + { + name: 'w:rPr', + elements: [{ name: 'w:b' }, { name: 'w:color', attributes: { 'w:val': 'FF0000' } }], + }, + { name: 'w:t', elements: [{ type: 'text', text: 'Cell' }] }, + ], }; const params = { @@ -178,12 +185,12 @@ describe('w:r r-translator (node)', () => { }, }; - translator.encode(params); + const result = translator.encode(params); expect(resolveRunPropertiesSpy).toHaveBeenCalledTimes(1); expect(resolveRunPropertiesSpy).toHaveBeenCalledWith( params, - {}, + { bold: true, color: { val: 'FF0000' } }, { styleId: 'ListParagraph' }, { rowIndex: 2, @@ -195,10 +202,39 @@ describe('w:r r-translator (node)', () => { false, true, ); + expect(result.attrs.runPropertiesInlineKeys).toEqual(['bold', 'color']); resolveRunPropertiesSpy.mockRestore(); }); + it('sets runPropertiesOverrideKeys to keys that are both in w:rPr and in the run style', () => { + const getStyleRPrSpy = vi + .spyOn(runPropertiesExport, 'getParagraphStyleRunPropertiesFromStylesXml') + .mockReturnValue({ color: { val: '0000FF' } }); + const runNode = { + name: 'w:r', + elements: [ + { + name: 'w:rPr', + elements: [ + { name: 'w:rStyle', attributes: { 'w:val': 'Heading1' } }, + { name: 'w:b' }, + { name: 'w:color', attributes: { 'w:val': 'FF0000' } }, + ], + }, + { name: 'w:t', elements: [{ type: 'text', text: 'Text' }] }, + ], + }; + const params = { + nodes: [runNode], + nodeListHandler: { handler: vi.fn(() => [{ type: 'text', text: 'Text', marks: [] }]) }, + docx: {}, + }; + const result = translator.encode(params); + expect(result.attrs.runPropertiesOverrideKeys).toEqual(['color']); + getStyleRPrSpy.mockRestore(); + }); + it('passes null tableInfo to resolveRunProperties when table context is incomplete', () => { const resolveRunPropertiesSpy = vi.spyOn(converterStyles, 'resolveRunProperties').mockImplementation(() => ({})); const runNode = { @@ -299,3 +335,78 @@ describe('w:r r-translator (node)', () => { ); }); }); + +describe('w:r r-translator decode (export only inline run properties)', () => { + const runWithContent = (attrs) => ({ + node: { + type: 'run', + attrs: { + rsidR: '00000000', + rsidRPr: '00000000', + rsidDel: '00000000', + ...attrs, + }, + content: [{ type: 'text', text: 'x', marks: [] }], + }, + }); + + it('does not emit w:rPr when runPropertiesInlineKeys is missing', () => { + const params = runWithContent({ runProperties: { bold: true, color: 'FF0000' } }); + const result = translator.decode(params); + const elements = result?.elements ?? []; + const hasRPr = elements.some((el) => el?.name === 'w:rPr'); + expect(hasRPr).toBe(false); + }); + + it('does not emit w:rPr when runPropertiesInlineKeys is empty array', () => { + const params = runWithContent({ + runProperties: { bold: true }, + runPropertiesInlineKeys: [], + }); + const result = translator.decode(params); + const elements = result?.elements ?? []; + const hasRPr = elements.some((el) => el?.name === 'w:rPr'); + expect(hasRPr).toBe(false); + }); + + it('emits w:rPr with only inline keys not in runPropertiesStyleKeys', () => { + const params = runWithContent({ + runProperties: { bold: true, color: 'FF0000' }, + runPropertiesInlineKeys: ['bold', 'color'], + runPropertiesStyleKeys: ['color'], + }); + const result = translator.decode(params); + const rPr = result?.elements?.find((el) => el?.name === 'w:rPr'); + expect(rPr).toBeDefined(); + // rPr decoder turns runProperties into OOXML elements; color was filtered out so we should not see w:color + const elementNames = (rPr.elements ?? []).map((e) => e.name); + expect(elementNames).not.toContain('w:color'); + expect(elementNames).toContain('w:b'); + }); + + it('emits w:rPr when runPropertiesInlineKeys is set and runPropertiesStyleKeys is empty', () => { + const params = runWithContent({ + runProperties: { bold: true }, + runPropertiesInlineKeys: ['bold'], + runPropertiesStyleKeys: [], + }); + const result = translator.decode(params); + const rPr = result?.elements?.find((el) => el?.name === 'w:rPr'); + expect(rPr).toBeDefined(); + expect((rPr.elements ?? []).map((e) => e.name)).toContain('w:b'); + }); + + it('emits w:rPr with style key when runPropertiesOverrideKeys includes it (preserves user override)', () => { + const params = runWithContent({ + runProperties: { bold: true, color: { val: 'FF0000' } }, + runPropertiesInlineKeys: ['bold', 'color'], + runPropertiesStyleKeys: ['color'], + runPropertiesOverrideKeys: ['color'], + }); + const result = translator.decode(params); + const rPr = result?.elements?.find((el) => el?.name === 'w:rPr'); + expect(rPr).toBeDefined(); + expect((rPr.elements ?? []).map((e) => e.name)).toContain('w:color'); + expect((rPr.elements ?? []).map((e) => e.name)).toContain('w:b'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index d8e6611a6a..df7d5b6f9a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -404,10 +404,12 @@ export function _getReferencedTableStyles(tableStyleReference, params) { // Find table properties to get borders and cell margins const tblPr = styleTag.elements.find((el) => el.name === 'w:tblPr'); if (tblPr && tblPr.elements) { - if (baseTblPr && baseTblPr.elements) { - tblPr.elements = [...baseTblPr.elements, ...tblPr.elements]; - } - const tableProperties = tblPrTranslator.encode({ ...params, nodes: [tblPr] }); + // Merge base + current for encoding only; do not mutate styles.xml (would duplicate w:tblCellMar etc. per table using this style) + const mergedTblPr = + baseTblPr?.elements?.length > 0 + ? { name: tblPr.name, attributes: tblPr.attributes, elements: [...baseTblPr.elements, ...tblPr.elements] } + : tblPr; + const tableProperties = tblPrTranslator.encode({ ...params, nodes: [mergedTblPr] }); if (tableProperties) { const borders = _processTableBorders(tableProperties.borders || {}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js index a81322d9dc..855ee4ef89 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js @@ -25,6 +25,7 @@ export function handleTableCellNode({ const tcPr = node.elements.find((el) => el.name === 'w:tcPr'); const tableCellProperties = tcPr ? (tcPrTranslator.encode({ ...params, nodes: [tcPr] }) ?? {}) : {}; attributes['tableCellProperties'] = tableCellProperties; + attributes['tableCellPropertiesInlineKeys'] = Object.keys(tableCellProperties); // Colspan const colspan = parseInt(tableCellProperties.gridSpan || 1, 10); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js index 0fdaca67a9..4196b3e5b9 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js @@ -134,6 +134,14 @@ describe('legacy-handle-table-cell-node', () => { // rowspan derived from vertical merge (restart + 2 continuations) expect(out.attrs.rowspan).toBe(3); + + // Inline keys from w:tcPr so export can avoid writing inherited table-style props (e.g. w:tcMar) + expect(out.attrs.tableCellPropertiesInlineKeys).toEqual( + expect.arrayContaining(['cellWidth', 'shading', 'gridSpan', 'cellMargins', 'vAlign', 'vMerge', 'borders']), + ); + expect(out.attrs.tableCellPropertiesInlineKeys).toHaveLength( + Object.keys(out.attrs.tableCellProperties || {}).length, + ); }); it('blends percentage table shading into a solid background color', () => { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js index 4c94d765a2..9b912fb02f 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js @@ -33,6 +33,8 @@ export function translateTableCell(params) { */ export function generateTableCellProperties(node) { let tableCellProperties = { ...(node.attrs?.tableCellProperties || {}) }; + /** When set by import: keys that were in the cell's w:tcPr. When null/undefined (e.g. new cell), do not filter. */ + const inlineKeys = node.attrs?.tableCellPropertiesInlineKeys; const { attrs } = node; @@ -77,9 +79,9 @@ export function generateTableCellProperties(node) { delete tableCellProperties.shading; } - // Margins + // Margins — only merge from attrs when the cell had w:tcMar in its w:tcPr (inline), or when inlineKeys was not set (new cell / backward compat). Do not output when inlineKeys is set and does not include 'cellMargins' (inherited from table style). const { cellMargins } = attrs; - if (cellMargins) { + if (cellMargins && (!Array.isArray(inlineKeys) || inlineKeys.includes('cellMargins'))) { ['left', 'right', 'top', 'bottom'].forEach((side) => { const key = `margin${side.charAt(0).toUpperCase() + side.slice(1)}`; if (cellMargins[side] != null) { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.test.js index 10d44cfbcb..f8557af901 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.test.js @@ -82,6 +82,40 @@ describe('translate-table-cell helpers', () => { expect(vMerge.attributes).toEqual({ 'w:val': 'continue' }); }); + it('generateTableCellProperties does not output w:tcMar when cell had no w:tcMar (tableCellPropertiesInlineKeys excludes cellMargins)', () => { + // Cell from DOCX with table style providing margins: only cellWidth was in w:tcPr + const node = { + attrs: { + tableCellProperties: { cellWidth: { value: 1000, type: 'dxa' } }, + tableCellPropertiesInlineKeys: ['cellWidth'], + colwidth: [50], + widthUnit: 'px', + cellMargins: { top: 96, right: 48, bottom: 0, left: 24 }, // from table style, not inline + }, + }; + const tcPr = generateTableCellProperties(node); + const byName = Object.fromEntries(tcPr.elements.map((e) => [e.name, e])); + expect(byName['w:tcMar']).toBeUndefined(); + expect(byName['w:tcW']).toBeTruthy(); + }); + + it('generateTableCellProperties does not output w:tcMar when tableCellPropertiesInlineKeys is empty array', () => { + // Cell had no w:tcPr at all (all props from table style) — margins should stay table-style-only. + const node = { + attrs: { + tableCellProperties: { cellWidth: { value: 1000, type: 'dxa' } }, + tableCellPropertiesInlineKeys: [], + colwidth: [50], + widthUnit: 'px', + cellMargins: { top: 12, right: 0, bottom: 0, left: 0 }, + }, + }; + const tcPr = generateTableCellProperties(node); + const byName = Object.fromEntries(tcPr.elements.map((e) => [e.name, e])); + expect(byName['w:tcMar']).toBeUndefined(); + expect(byName['w:tcW']).toBeTruthy(); + }); + it('translateTableCell wraps children with tcPr as the first element', async () => { const params = { node: { attrs: { colwidth: [60], widthUnit: 'px' } }, diff --git a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js index 75513db8a7..a2c3c0fe1a 100644 --- a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js +++ b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js @@ -117,14 +117,43 @@ export const calculateInlineRunPropertiesPlugin = (editor) => } } + const existingInlineKeys = runNode.attrs?.runPropertiesInlineKeys || []; + const styleKeys = runNode.attrs?.runPropertiesStyleKeys || []; + const keysFromMarks = (segment) => { + const textNode = segment.content?.find((n) => n.isText); + return Object.keys(decodeRPrFromMarks(textNode?.marks || [])); + }; + const overrideKeysFromInlineProps = (inlineProps) => styleKeys.filter((k) => inlineProps && k in inlineProps); + if (segments.length === 1) { - if (JSON.stringify(runProperties) === JSON.stringify(runNode.attrs.runProperties)) return; - tr.setNodeMarkup(mappedPos, runNode.type, { ...runNode.attrs, runProperties }, runNode.marks); + const hadInlineKeys = + Array.isArray(runNode.attrs?.runPropertiesInlineKeys) && runNode.attrs.runPropertiesInlineKeys.length > 0; + if (JSON.stringify(runProperties) === JSON.stringify(runNode.attrs.runProperties) && hadInlineKeys) return; + const newInlineKeys = [...new Set([...existingInlineKeys, ...keysFromMarks(segments[0])])]; + const newOverrideKeys = overrideKeysFromInlineProps(runProperties); + tr.setNodeMarkup( + mappedPos, + runNode.type, + { + ...runNode.attrs, + runProperties, + runPropertiesInlineKeys: newInlineKeys.length ? newInlineKeys : null, + runPropertiesOverrideKeys: newOverrideKeys.length ? newOverrideKeys : null, + }, + runNode.marks, + ); } else { const newRuns = segments.map((segment) => { const props = segment.inlineProps ?? null; + const segmentInlineKeys = [...new Set([...existingInlineKeys, ...keysFromMarks(segment)])]; + const segmentOverrideKeys = overrideKeysFromInlineProps(props); return runType.create( - { ...(runNode.attrs ?? {}), runProperties: props }, + { + ...(runNode.attrs ?? {}), + runProperties: props, + runPropertiesInlineKeys: segmentInlineKeys.length ? segmentInlineKeys : null, + runPropertiesOverrideKeys: segmentOverrideKeys.length ? segmentOverrideKeys : null, + }, Fragment.fromArray(segment.content), runNode.marks, ); diff --git a/packages/super-editor/src/extensions/run/run.js b/packages/super-editor/src/extensions/run/run.js index 75c4eb3e43..79b03ebf75 100644 --- a/packages/super-editor/src/extensions/run/run.js +++ b/packages/super-editor/src/extensions/run/run.js @@ -34,6 +34,24 @@ export const Run = OxmlNode.create({ rendered: false, keepOnSplit: true, }, + /** Keys of runProperties that were in the run's w:rPr (or set by user). Export outputs only these to avoid duplicating style-inherited props in styles.xml. */ + runPropertiesInlineKeys: { + default: null, + rendered: false, + keepOnSplit: true, + }, + /** Keys from the run's style (w:rStyle/styleId) in styles.xml. Export omits these so we don't duplicate run-style props. */ + runPropertiesStyleKeys: { + default: null, + rendered: false, + keepOnSplit: true, + }, + /** Keys that override the run's style (in w:rPr at import, or changed by user). Export includes these so user overrides are preserved. */ + runPropertiesOverrideKeys: { + default: null, + rendered: false, + keepOnSplit: true, + }, rsidR: { default: null, rendered: false, diff --git a/packages/super-editor/src/extensions/table-cell/table-cell.js b/packages/super-editor/src/extensions/table-cell/table-cell.js index 97b4b15f01..d4e696a1ba 100644 --- a/packages/super-editor/src/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/extensions/table-cell/table-cell.js @@ -75,6 +75,7 @@ import { renderCellBorderStyle } from './helpers/renderCellBorderStyle.js'; * @property {import('./helpers/createCellBorders.js').CellBorders} [borders] - Cell border configuration * @property {string} [widthType='auto'] @internal - Internal width type * @property {string} [widthUnit='px'] @internal - Internal width unit + * @property {string[]} [tableCellPropertiesInlineKeys] @internal - Keys present in the cell's w:tcPr (not from table style); used to avoid exporting inherited tcPr */ /** @@ -229,6 +230,12 @@ export const TableCell = Node.create({ default: null, rendered: false, }, + + /** @private - Keys from the cell's w:tcPr (exclude inherited from table style on export) */ + tableCellPropertiesInlineKeys: { + default: null, + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/extensions/table-header/table-header.js b/packages/super-editor/src/extensions/table-header/table-header.js index 7d91569b30..18c66e207d 100644 --- a/packages/super-editor/src/extensions/table-header/table-header.js +++ b/packages/super-editor/src/extensions/table-header/table-header.js @@ -24,6 +24,7 @@ import { renderCellBorderStyle } from '../table-cell/helpers/renderCellBorderSty * @property {string} [widthType='auto'] @internal - Internal width type * @property {string} [widthUnit='px'] @internal - Internal width unit * @property {import('../table-cell/table-cell.js').TableCellProperties} [tableCellProperties] @internal - Raw OOXML cell properties + * @property {string[]} [tableCellPropertiesInlineKeys] @internal - Keys present in the cell's w:tcPr (not from table style) */ /** @@ -143,6 +144,12 @@ export const TableHeader = Node.create({ rendered: false, }, + /** @private - Keys from the cell's w:tcPr (exclude inherited from table style on export) */ + tableCellPropertiesInlineKeys: { + default: null, + rendered: false, + }, + __placeholder: { default: null, parseDOM: (element) => { diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index 1b6ee9b840..318113ca37 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -1284,18 +1284,21 @@ export const Table = Node.create({ const nilBorder = { val: 'nil', size: 0, space: 0, color: 'auto' }; state.doc.nodesBetween(from, to, (node, pos) => { if (['tableCell', 'tableHeader'].includes(node.type.name)) { + const nextTableCellProperties = { + ...(node.attrs.tableCellProperties ?? {}), + borders: { + top: { ...nilBorder }, + bottom: { ...nilBorder }, + left: { ...nilBorder }, + right: { ...nilBorder }, + }, + }; + const nextInlineKeys = [...new Set([...(node.attrs.tableCellPropertiesInlineKeys || []), 'borders'])]; tr.setNodeMarkup(pos, undefined, { ...node.attrs, borders: null, - tableCellProperties: { - ...(node.attrs.tableCellProperties ?? {}), - borders: { - top: { ...nilBorder }, - bottom: { ...nilBorder }, - left: { ...nilBorder }, - right: { ...nilBorder }, - }, - }, + tableCellProperties: nextTableCellProperties, + tableCellPropertiesInlineKeys: nextInlineKeys, }); } }); diff --git a/packages/super-editor/src/extensions/table/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index 87cf18bb3d..da07e4658c 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -899,7 +899,7 @@ describe('Table commands', async () => { .forEach((tc) => { const tcPr = tc.elements.find((el) => el.name === 'w:tcPr'); const tcBorders = tcPr?.elements?.find((el) => el.name === 'w:tcBorders'); - expect(tcBorders.elements).toEqual( + expect(tcBorders?.elements).toEqual( expect.arrayContaining( ['w:top', 'w:bottom', 'w:left', 'w:right'].map((name) => ({ name: name, diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index 8d9ddeb0d1..51bc2a0925 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -387,6 +387,8 @@ export interface TableCellAttrs extends TableNodeAttributes { borders: CellBorders | null; /** Cell properties from OOXML */ tableCellProperties: TableCellProperties | null; + /** Keys present in the cell's w:tcPr (exclude inherited from table style on export) */ + tableCellPropertiesInlineKeys: string[] | null; /** Width type */ widthType: string; /** Width unit */ diff --git a/packages/super-editor/src/tests/export/hyperlinkExporter.test.js b/packages/super-editor/src/tests/export/hyperlinkExporter.test.js index 1a51db61bc..cfa2dbfe23 100644 --- a/packages/super-editor/src/tests/export/hyperlinkExporter.test.js +++ b/packages/super-editor/src/tests/export/hyperlinkExporter.test.js @@ -28,46 +28,27 @@ describe('HyperlinkNodeExporter', async () => { ); const rPr = hyperLinkNode.elements[0].elements[0]; - expect(rPr.elements).toEqual([ - { - name: 'w:rStyle', - attributes: { - 'w:val': 'Hyperlink', - }, - elements: undefined, - text: undefined, - type: undefined, - }, - { - name: 'w:rFonts', - attributes: { - 'w:ascii': 'Arial', - 'w:hAnsi': 'Arial', - 'w:cs': 'Arial', - }, - elements: undefined, - text: undefined, - type: undefined, + expect(rPr.elements).toHaveLength(4); + expect(rPr.elements[0]).toMatchObject({ + name: 'w:rStyle', + attributes: { 'w:val': 'Hyperlink' }, + }); + expect(rPr.elements[1]).toMatchObject({ + name: 'w:rFonts', + attributes: { + 'w:ascii': 'Arial', + 'w:hAnsi': 'Arial', + 'w:cs': 'Arial', }, - { - name: 'w:sz', - attributes: { - 'w:val': '20', - }, - elements: undefined, - text: undefined, - type: undefined, - }, - { - name: 'w:szCs', - attributes: { - 'w:val': '20', - }, - elements: undefined, - text: undefined, - type: undefined, - }, - ]); + }); + expect(rPr.elements[2]).toMatchObject({ + name: 'w:sz', + attributes: { 'w:val': '20' }, + }); + expect(rPr.elements[3]).toMatchObject({ + name: 'w:szCs', + attributes: { 'w:val': '20' }, + }); }); it('exports w:hyperlink linking to bookmark', async () => { diff --git a/packages/super-editor/src/tests/regression/superdoc-table-roundtrip.test.js b/packages/super-editor/src/tests/regression/superdoc-table-roundtrip.test.js index 834ccedae0..0537b2ea54 100644 --- a/packages/super-editor/src/tests/regression/superdoc-table-roundtrip.test.js +++ b/packages/super-editor/src/tests/regression/superdoc-table-roundtrip.test.js @@ -41,6 +41,7 @@ describe('superdoc_table_tester import/export', () => { const tbl = body?.elements?.find((el) => el.name === 'w:tbl'); expect(tbl).toBeDefined(); + // Cell margins come from the table style (w:tblCellMar), not duplicated in each cell's w:tcPr const tblPr = tbl.elements.find((el) => el.name === 'w:tblPr'); const tblCellMar = tblPr?.elements?.find((el) => el.name === 'w:tblCellMar'); expect(tblCellMar).toBeDefined(); @@ -52,15 +53,6 @@ describe('superdoc_table_tester import/export', () => { const firstTr = tbl.elements.find((el) => el.name === 'w:tr'); const firstTc = firstTr?.elements?.find((el) => el.name === 'w:tc'); - const tcPr = firstTc?.elements?.find((el) => el.name === 'w:tcPr'); - const tcMar = tcPr?.elements?.find((el) => el.name === 'w:tcMar'); - expect(tcMar).toBeDefined(); - - const tcLeft = tcMar.elements.find((el) => el.name === 'w:left'); - const tcRight = tcMar.elements.find((el) => el.name === 'w:right'); - expect(Number(tcLeft?.attributes?.['w:w'])).toBe(108); - expect(Number(tcRight?.attributes?.['w:w'])).toBe(108); - const firstParagraph = firstTc?.elements?.find((el) => el.name === 'w:p'); const pPr = firstParagraph?.elements?.find((el) => el.name === 'w:pPr'); const spacing = pPr?.elements?.find((el) => el.name === 'w:spacing');