From 62580c7f93e4622021e165c09374ef6e46af2bf3 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Thu, 26 Feb 2026 20:31:30 +0530 Subject: [PATCH 1/4] feat: preserve setting through DOCX round-trip Import the element from word/settings.xml during DOCX import and write it back on export, ensuring the document view preference (web, print, normal, etc.) is not lost during editing. Closes #2070 --- .../core/super-converter/SuperConverter.js | 1 + .../v2/exporter/footnotesExporter.js | 21 ++ .../v2/importer/docxImporter.js | 11 + .../view-setting-roundtrip.test.js | 221 ++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 packages/super-editor/src/tests/import-export/view-setting-roundtrip.test.js diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index ac95aef706..84e791a435 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -188,6 +188,7 @@ class SuperConverter { this.comments = []; this.footnotes = []; this.footnoteProperties = null; + this.viewSetting = null; this.inlineDocumentFonts = []; this.commentThreadingProfile = null; diff --git a/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js b/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js index 978bc96bb9..963f5e0126 100644 --- a/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js +++ b/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js @@ -130,6 +130,26 @@ const applyFootnotePropertiesToSettings = (converter, convertedXml) => { return { ...convertedXml, 'word/settings.xml': updatedSettings }; }; +const applyViewSettingToSettings = (converter, convertedXml) => { + const viewSetting = converter?.viewSetting; + if (!viewSetting?.originalXml) return convertedXml; + + const settingsXml = convertedXml['word/settings.xml']; + const settingsRoot = settingsXml?.elements?.[0]; + if (!settingsRoot) return convertedXml; + + const updatedSettings = carbonCopy(settingsXml); + const updatedRoot = updatedSettings.elements?.[0]; + if (!updatedRoot) return convertedXml; + + const elements = Array.isArray(updatedRoot.elements) ? updatedRoot.elements : []; + const nextElements = elements.filter((el) => el?.name !== 'w:view'); + nextElements.push(carbonCopy(viewSetting.originalXml)); + updatedRoot.elements = nextElements; + + return { ...convertedXml, 'word/settings.xml': updatedSettings }; +}; + const buildFootnotesRelsXml = (converter, convertedXml, relationships) => { if (!relationships.length) return null; @@ -155,6 +175,7 @@ const buildFootnotesRelsXml = (converter, convertedXml, relationships) => { export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, convertedXml }) => { let updatedXml = applyFootnotePropertiesToSettings(converter, convertedXml); + updatedXml = applyViewSettingToSettings(converter, updatedXml); if (!footnotes || !Array.isArray(footnotes) || footnotes.length === 0) { return { updatedXml, relationships: [], media: {} }; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 7b0d3948bc..e6155e47b9 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -113,6 +113,7 @@ export const createDocumentJson = (docx, converter, editor) => { if (converter) { importFootnotePropertiesFromSettings(docx, converter); + importViewSettingFromSettings(docx, converter); converter.documentOrigin = detectDocumentOrigin(docx); converter.commentThreadingProfile = detectCommentThreadingProfile(docx); } @@ -430,6 +431,16 @@ function importFootnotePropertiesFromSettings(docx, converter) { converter.footnoteProperties = parseFootnoteProperties(footnotePr, 'settings'); } +function importViewSettingFromSettings(docx, converter) { + if (!docx || !converter) return; + const settings = docx['word/settings.xml']; + const settingsRoot = settings?.elements?.[0]; + const elements = Array.isArray(settingsRoot?.elements) ? settingsRoot.elements : []; + const viewEl = elements.find((el) => el?.name === 'w:view'); + if (!viewEl) return; + converter.viewSetting = { val: viewEl.attributes?.['w:val'] ?? null, originalXml: carbonCopy(viewEl) }; +} + /** * * @param {XmlNode} node diff --git a/packages/super-editor/src/tests/import-export/view-setting-roundtrip.test.js b/packages/super-editor/src/tests/import-export/view-setting-roundtrip.test.js new file mode 100644 index 0000000000..1634e9a8ce --- /dev/null +++ b/packages/super-editor/src/tests/import-export/view-setting-roundtrip.test.js @@ -0,0 +1,221 @@ +import { describe, it, expect } from 'vitest'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { prepareFootnotesXmlForExport } from '@converter/v2/exporter/footnotesExporter.js'; +import { carbonCopy } from '@core/utilities/carbonCopy.js'; + +const minimalStylesXml = parseXmlToJson( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', +); + +const makeSettingsXml = (innerXml = '') => + parseXmlToJson( + '' + innerXml + '', + ); + +const makeDocumentXml = () => + parseXmlToJson( + '' + + 'Test' + + '', + ); + +const findViewInSettings = (settingsJson) => { + const root = settingsJson?.elements?.[0]; + return root?.elements?.find((el) => el?.name === 'w:view') || null; +}; + +describe('w:view setting roundtrip', () => { + describe('import', () => { + it('imports w:view with val="web" from settings.xml', async () => { + const settingsXml = makeSettingsXml(''); + const docx = { + 'word/document.xml': makeDocumentXml(), + 'word/settings.xml': settingsXml, + 'word/styles.xml': minimalStylesXml, + }; + + const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + createDocumentJson(docx, converter, editor); + + expect(converter.viewSetting).toBeDefined(); + expect(converter.viewSetting.val).toBe('web'); + expect(converter.viewSetting.originalXml).toBeDefined(); + expect(converter.viewSetting.originalXml.name).toBe('w:view'); + }); + + it('imports w:view with val="print" from settings.xml', async () => { + const settingsXml = makeSettingsXml(''); + const docx = { + 'word/document.xml': makeDocumentXml(), + 'word/settings.xml': settingsXml, + 'word/styles.xml': minimalStylesXml, + }; + + const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); + const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; + const editor = { options: {}, emit: () => {} }; + + createDocumentJson(docx, converter, editor); + + expect(converter.viewSetting).toBeDefined(); + expect(converter.viewSetting.val).toBe('print'); + }); + + it('leaves viewSetting null when settings.xml has no w:view', async () => { + const settingsXml = makeSettingsXml(''); + const docx = { + 'word/document.xml': makeDocumentXml(), + 'word/settings.xml': settingsXml, + 'word/styles.xml': minimalStylesXml, + }; + + const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); + const converter = { + headers: {}, + footers: {}, + headerIds: {}, + footerIds: {}, + docHiglightColors: new Set(), + viewSetting: null, + }; + const editor = { options: {}, emit: () => {} }; + + createDocumentJson(docx, converter, editor); + + expect(converter.viewSetting).toBeNull(); + }); + + it('leaves viewSetting null when settings.xml is missing entirely', async () => { + const docx = { + 'word/document.xml': makeDocumentXml(), + 'word/styles.xml': minimalStylesXml, + }; + + const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); + const converter = { + headers: {}, + footers: {}, + headerIds: {}, + footerIds: {}, + docHiglightColors: new Set(), + viewSetting: null, + }; + const editor = { options: {}, emit: () => {} }; + + createDocumentJson(docx, converter, editor); + + expect(converter.viewSetting).toBeNull(); + }); + }); + + describe('export', () => { + it('preserves w:view val="web" through export', () => { + const viewXml = { type: 'element', name: 'w:view', attributes: { 'w:val': 'web' }, elements: [] }; + const converter = { viewSetting: { val: 'web', originalXml: carbonCopy(viewXml) } }; + const convertedXml = { 'word/settings.xml': makeSettingsXml('') }; + + const { updatedXml } = prepareFootnotesXmlForExport({ + footnotes: [], + editor: {}, + converter, + convertedXml, + }); + + const viewEl = findViewInSettings(updatedXml['word/settings.xml']); + expect(viewEl).toBeDefined(); + expect(viewEl.attributes['w:val']).toBe('web'); + }); + + it('preserves w:view val="print" through export', () => { + const viewXml = { type: 'element', name: 'w:view', attributes: { 'w:val': 'print' }, elements: [] }; + const converter = { viewSetting: { val: 'print', originalXml: carbonCopy(viewXml) } }; + const convertedXml = { 'word/settings.xml': makeSettingsXml('') }; + + const { updatedXml } = prepareFootnotesXmlForExport({ + footnotes: [], + editor: {}, + converter, + convertedXml, + }); + + const viewEl = findViewInSettings(updatedXml['word/settings.xml']); + expect(viewEl).toBeDefined(); + expect(viewEl.attributes['w:val']).toBe('print'); + }); + + it('does not add w:view when converter has no viewSetting', () => { + const converter = { viewSetting: null }; + const convertedXml = { 'word/settings.xml': makeSettingsXml('') }; + + const { updatedXml } = prepareFootnotesXmlForExport({ + footnotes: [], + editor: {}, + converter, + convertedXml, + }); + + const viewEl = findViewInSettings(updatedXml['word/settings.xml']); + expect(viewEl).toBeNull(); + }); + + it('preserves other settings.xml elements alongside w:view', () => { + const viewXml = { type: 'element', name: 'w:view', attributes: { 'w:val': 'web' }, elements: [] }; + const converter = { viewSetting: { val: 'web', originalXml: carbonCopy(viewXml) } }; + const convertedXml = { + 'word/settings.xml': makeSettingsXml(''), + }; + + const { updatedXml } = prepareFootnotesXmlForExport({ + footnotes: [], + editor: {}, + converter, + convertedXml, + }); + + const root = updatedXml['word/settings.xml']?.elements?.[0]; + const compat = root?.elements?.find((el) => el?.name === 'w:compat'); + const tabStop = root?.elements?.find((el) => el?.name === 'w:defaultTabStop'); + const viewEl = root?.elements?.find((el) => el?.name === 'w:view'); + + expect(compat).toBeDefined(); + expect(tabStop).toBeDefined(); + expect(viewEl).toBeDefined(); + expect(viewEl.attributes['w:val']).toBe('web'); + }); + + it('replaces existing w:view rather than duplicating', () => { + const viewXml = { type: 'element', name: 'w:view', attributes: { 'w:val': 'normal' }, elements: [] }; + const converter = { viewSetting: { val: 'normal', originalXml: carbonCopy(viewXml) } }; + const convertedXml = { + 'word/settings.xml': makeSettingsXml(''), + }; + + const { updatedXml } = prepareFootnotesXmlForExport({ + footnotes: [], + editor: {}, + converter, + convertedXml, + }); + + const root = updatedXml['word/settings.xml']?.elements?.[0]; + const viewElements = root?.elements?.filter((el) => el?.name === 'w:view') || []; + + expect(viewElements.length).toBe(1); + expect(viewElements[0].attributes['w:val']).toBe('normal'); + }); + }); +}); From dffbc5d357c7dc019e5449107d9b44dac9411e39 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Thu, 26 Feb 2026 20:44:06 +0530 Subject: [PATCH 2/4] fix: preserve w:view element position in settings.xml schema order --- .../v2/exporter/footnotesExporter.js | 6 +++--- .../view-setting-roundtrip.test.js | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js b/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js index 963f5e0126..bd213a0546 100644 --- a/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js +++ b/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js @@ -143,9 +143,9 @@ const applyViewSettingToSettings = (converter, convertedXml) => { if (!updatedRoot) return convertedXml; const elements = Array.isArray(updatedRoot.elements) ? updatedRoot.elements : []; - const nextElements = elements.filter((el) => el?.name !== 'w:view'); - nextElements.push(carbonCopy(viewSetting.originalXml)); - updatedRoot.elements = nextElements; + const idx = elements.findIndex((el) => el?.name === 'w:view'); + elements.splice(idx !== -1 ? idx : 0, idx !== -1 ? 1 : 0, carbonCopy(viewSetting.originalXml)); + updatedRoot.elements = elements; return { ...convertedXml, 'word/settings.xml': updatedSettings }; }; diff --git a/packages/super-editor/src/tests/import-export/view-setting-roundtrip.test.js b/packages/super-editor/src/tests/import-export/view-setting-roundtrip.test.js index 1634e9a8ce..7cab689daa 100644 --- a/packages/super-editor/src/tests/import-export/view-setting-roundtrip.test.js +++ b/packages/super-editor/src/tests/import-export/view-setting-roundtrip.test.js @@ -217,5 +217,26 @@ describe('w:view setting roundtrip', () => { expect(viewElements.length).toBe(1); expect(viewElements[0].attributes['w:val']).toBe('normal'); }); + + it('preserves w:view position in element order', () => { + const viewXml = { type: 'element', name: 'w:view', attributes: { 'w:val': 'web' }, elements: [] }; + const converter = { viewSetting: { val: 'web', originalXml: carbonCopy(viewXml) } }; + const convertedXml = { + 'word/settings.xml': makeSettingsXml(''), + }; + + const { updatedXml } = prepareFootnotesXmlForExport({ + footnotes: [], + editor: {}, + converter, + convertedXml, + }); + + const root = updatedXml['word/settings.xml']?.elements?.[0]; + const names = root?.elements?.map((el) => el?.name); + + expect(names).toEqual(['w:compat', 'w:view', 'w:defaultTabStop']); + expect(root.elements[1].attributes['w:val']).toBe('web'); + }); }); }); From 959859f27c7913eb2528af71951f59f23d88fa02 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Fri, 27 Feb 2026 00:15:31 +0530 Subject: [PATCH 3/4] fix: reset viewSetting before parsing to prevent stale state on converter reuse When createDocumentJson is called again on the same converter instance, the early return left the previous converter.viewSetting intact if the new document lacked . Reset to null before searching settings. --- .../src/core/super-converter/v2/importer/docxImporter.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index e6155e47b9..471ae838f7 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -433,6 +433,7 @@ function importFootnotePropertiesFromSettings(docx, converter) { function importViewSettingFromSettings(docx, converter) { if (!docx || !converter) return; + converter.viewSetting = null; const settings = docx['word/settings.xml']; const settingsRoot = settings?.elements?.[0]; const elements = Array.isArray(settingsRoot?.elements) ? settingsRoot.elements : []; From 24e340c0efc056db6fb678a0cb3887c16ce4342d Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Fri, 27 Feb 2026 19:43:03 +0530 Subject: [PATCH 4/4] fix: add clarifying comments for w:view export placement - Explain why applyViewSettingToSettings lives in the footnotes exporter - Document the index-0 fallback and its schema-order correctness --- .../core/super-converter/v2/exporter/footnotesExporter.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js b/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js index bd213a0546..1a4425f3e9 100644 --- a/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js +++ b/packages/super-editor/src/core/super-converter/v2/exporter/footnotesExporter.js @@ -144,6 +144,10 @@ const applyViewSettingToSettings = (converter, convertedXml) => { const elements = Array.isArray(updatedRoot.elements) ? updatedRoot.elements : []; const idx = elements.findIndex((el) => el?.name === 'w:view'); + // If w:view already exists, replace it in place. Falling back to index 0 + // is acceptable because w:view is the first child of w:settings in the + // OOXML schema (before w:writeProtection). In practice the element always + // exists during round-trip since we import it. elements.splice(idx !== -1 ? idx : 0, idx !== -1 ? 1 : 0, carbonCopy(viewSetting.originalXml)); updatedRoot.elements = elements; @@ -175,6 +179,9 @@ const buildFootnotesRelsXml = (converter, convertedXml, relationships) => { export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, convertedXml }) => { let updatedXml = applyFootnotePropertiesToSettings(converter, convertedXml); + // NOTE: applyViewSettingToSettings lives here because this function already + // modifies settings.xml during export. If the footnotes export path is ever + // refactored, this call must move to wherever settings.xml is written. updatedXml = applyViewSettingToSettings(converter, updatedXml); if (!footnotes || !Array.isArray(footnotes) || footnotes.length === 0) {