From 0c0dbcb04d7af242ea9ba7f1687c1dc869a6f02b Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 11 May 2026 12:13:40 -0300 Subject: [PATCH] fix(converter): preserve numbering.xml definitions on round-trip (SD-2911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #exportNumberingFile filtered out every w:num whose numId wasn't referenced from the exported document parts (including headers, footers, and footnotes). Word's tentative numbering, where a document carries definitions for lists the user hasn't applied yet, was therefore silently wiped: the active-numbering fixture lost 7 of 8 definitions and the tentative fixture lost them all. The filter was introduced for the lists.delete document-api operation, but it couldn't distinguish "user just deleted a list in this session" from "definition was always unused in the source file" — both arrived at the export with no referencing paragraph and both were dropped. Word tolerates unused definitions, so the safe default on export is to emit every abstractNum and num the importer captured. The strip-orphaned helper is removed entirely; the inline write in #exportNumberingFile is now four lines. Verified: both fixtures round-trip byte-identical (after pretty-print) at the numbering.xml level. New integration test covers both the active and tentative variants. --- .../v1/core/super-converter/SuperConverter.js | 21 +- .../strip-orphaned-numbering.js | 77 ------ .../strip-orphaned-numbering.test.js | 245 ------------------ .../tests/data/sd-2911-active-numbering.docx | Bin 0 -> 16222 bytes .../data/sd-2911-tentative-numbering.docx | Bin 0 -> 12633 bytes .../sd-2911-numbering-roundtrip.test.js | 77 ++++++ 6 files changed, 87 insertions(+), 333 deletions(-) delete mode 100644 packages/super-editor/src/editors/v1/core/super-converter/export-helpers/strip-orphaned-numbering.js delete mode 100644 packages/super-editor/src/editors/v1/core/super-converter/export-helpers/strip-orphaned-numbering.test.js create mode 100644 packages/super-editor/src/editors/v1/tests/data/sd-2911-active-numbering.docx create mode 100644 packages/super-editor/src/editors/v1/tests/data/sd-2911-tentative-numbering.docx create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/sd-2911-numbering-roundtrip.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js index 4e453eb2de..56a0976ef6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js @@ -34,11 +34,6 @@ import { syncBibliographyPartToPackage, getBibliographyPartExportPaths, } from './citation-sources.js'; -import { - collectReferencedNumIds, - filterOrphanedNumberingDefinitions, -} from './export-helpers/strip-orphaned-numbering.js'; - const FONT_FAMILY_FALLBACKS = Object.freeze({ swiss: 'Arial, sans-serif', roman: 'Times New Roman, serif', @@ -1434,13 +1429,17 @@ class SuperConverter { if (!numberingXml) numberingXml = baseNumbering; const currentNumberingXml = numberingXml.elements[0]; - // D7: Strip orphaned numbering definitions (entries not referenced by any - // paragraph in the exported document parts). - const referencedNumIds = collectReferencedNumIds(this.convertedXml); - + // SD-2911: emit every abstractNum / num the importer captured. The previous + // implementation pruned definitions whose numId wasn't referenced from the + // exported document parts, but that couldn't distinguish "user just deleted a + // list in this session" from "definition was always unused in the source file" + // (Word's tentative numbering). Both arrived here with no referencing paragraph, + // and both were dropped — silently lossy on round-trip. Word tolerates unused + // definitions, so the safe default is to preserve. if (this.numbering?.definitions && this.numbering?.abstracts) { - const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(this.numbering, referencedNumIds); - currentNumberingXml.elements = [...liveAbstracts, ...liveDefinitions]; + const abstracts = Object.values(this.numbering.abstracts); + const definitions = Object.values(this.numbering.definitions); + currentNumberingXml.elements = [...abstracts, ...definitions]; } else { currentNumberingXml.elements = []; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/export-helpers/strip-orphaned-numbering.js b/packages/super-editor/src/editors/v1/core/super-converter/export-helpers/strip-orphaned-numbering.js deleted file mode 100644 index c6ae4611f7..0000000000 --- a/packages/super-editor/src/editors/v1/core/super-converter/export-helpers/strip-orphaned-numbering.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Collect all w:numId values referenced in exported document parts. - * Walks all word/* XML entries except word/numbering.xml itself. - * - * @param {Record} convertedXml - The full set of exported XML-JSON objects - * @returns {Set} Set of numId values referenced in the document - */ -export function collectReferencedNumIds(convertedXml) { - const numIds = new Set(); - - function walkElements(elements) { - if (!Array.isArray(elements)) return; - for (const el of elements) { - if (!el || typeof el !== 'object') continue; - if (el.name === 'w:numId' && el.attributes?.['w:val'] != null) { - numIds.add(Number(el.attributes['w:val'])); - } - if (el.elements) walkElements(el.elements); - } - } - - for (const [path, xml] of Object.entries(convertedXml)) { - if (path.startsWith('word/') && path !== 'word/numbering.xml' && xml?.elements) { - walkElements(xml.elements); - } - } - - return numIds; -} - -/** - * Extract the w:abstractNumId value from a w:num XML-JSON element. - * - * @param {object} numDef - A w:num XML-JSON element from numbering.definitions - * @returns {number | undefined} The abstractNumId, or undefined if not found - */ -function getAbstractNumIdFromDef(numDef) { - const abstractEl = numDef.elements?.find((el) => el.name === 'w:abstractNumId'); - if (abstractEl?.attributes?.['w:val'] != null) { - return Number(abstractEl.attributes['w:val']); - } - return undefined; -} - -/** - * Filter numbering definitions to remove orphaned entries not referenced by - * any paragraph in the exported document. Returns new arrays (does not mutate). - * - * @param {{ abstracts: Record, definitions: Record }} numbering - * The converter's numbering data (abstracts keyed by abstractNumId, definitions keyed by numId) - * @param {Set} referencedNumIds - * The set of numId values actually referenced in the exported document - * @returns {{ liveAbstracts: any[], liveDefinitions: any[] }} - * Filtered arrays ready to be written to word/numbering.xml - */ -export function filterOrphanedNumberingDefinitions(numbering, referencedNumIds) { - // Keep only w:num entries whose numId is still referenced - const liveDefinitions = Object.values(numbering.definitions).filter((def) => - referencedNumIds.has(Number(def.attributes?.['w:numId'])), - ); - - // Derive the set of abstractNumIds referenced by surviving w:num entries - const referencedAbstractIds = new Set(); - for (const def of liveDefinitions) { - const abstractId = getAbstractNumIdFromDef(def); - if (abstractId != null) { - referencedAbstractIds.add(abstractId); - } - } - - // Keep only w:abstractNum entries still referenced by a surviving w:num - const liveAbstracts = Object.values(numbering.abstracts).filter((abs) => - referencedAbstractIds.has(Number(abs.attributes?.['w:abstractNumId'])), - ); - - return { liveAbstracts, liveDefinitions }; -} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/export-helpers/strip-orphaned-numbering.test.js b/packages/super-editor/src/editors/v1/core/super-converter/export-helpers/strip-orphaned-numbering.test.js deleted file mode 100644 index 2d2499ade5..0000000000 --- a/packages/super-editor/src/editors/v1/core/super-converter/export-helpers/strip-orphaned-numbering.test.js +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { collectReferencedNumIds, filterOrphanedNumberingDefinitions } from './strip-orphaned-numbering.js'; - -// --------------------------------------------------------------------------- -// Helpers for building XML-JSON structures -// --------------------------------------------------------------------------- - -function makeNumIdElement(numId) { - return { - name: 'w:numId', - type: 'element', - attributes: { 'w:val': String(numId) }, - }; -} - -function makeParagraphWithNumId(numId) { - return { - name: 'w:p', - type: 'element', - elements: [ - { - name: 'w:pPr', - type: 'element', - elements: [ - { - name: 'w:numPr', - type: 'element', - elements: [makeNumIdElement(numId), { name: 'w:ilvl', type: 'element', attributes: { 'w:val': '0' } }], - }, - ], - }, - ], - }; -} - -function makeDocumentXml(paragraphs) { - return { - elements: [ - { - name: 'w:document', - type: 'element', - elements: [{ name: 'w:body', type: 'element', elements: paragraphs }], - }, - ], - }; -} - -function makeNumDef(numId, abstractNumId, extraElements = []) { - return { - name: 'w:num', - type: 'element', - attributes: { 'w:numId': String(numId) }, - elements: [ - { - name: 'w:abstractNumId', - type: 'element', - attributes: { 'w:val': String(abstractNumId) }, - }, - ...extraElements, - ], - }; -} - -function makeAbstractDef(abstractNumId) { - return { - name: 'w:abstractNum', - type: 'element', - attributes: { 'w:abstractNumId': String(abstractNumId) }, - elements: [], - }; -} - -// --------------------------------------------------------------------------- -// collectReferencedNumIds -// --------------------------------------------------------------------------- - -describe('collectReferencedNumIds', () => { - it('collects numIds from document body paragraphs', () => { - const convertedXml = { - 'word/document.xml': makeDocumentXml([makeParagraphWithNumId(1), makeParagraphWithNumId(3)]), - }; - const result = collectReferencedNumIds(convertedXml); - expect(result).toEqual(new Set([1, 3])); - }); - - it('collects numIds from headers and footers', () => { - const convertedXml = { - 'word/document.xml': makeDocumentXml([makeParagraphWithNumId(1)]), - 'word/header1.xml': { - elements: [{ name: 'w:hdr', type: 'element', elements: [makeParagraphWithNumId(5)] }], - }, - 'word/footer1.xml': { - elements: [{ name: 'w:ftr', type: 'element', elements: [makeParagraphWithNumId(7)] }], - }, - }; - const result = collectReferencedNumIds(convertedXml); - expect(result).toEqual(new Set([1, 5, 7])); - }); - - it('ignores word/numbering.xml to avoid self-referencing', () => { - const convertedXml = { - 'word/document.xml': makeDocumentXml([makeParagraphWithNumId(1)]), - 'word/numbering.xml': { - elements: [ - { - name: 'w:numbering', - type: 'element', - elements: [makeNumDef(1, 10), makeNumDef(99, 20)], - }, - ], - }, - }; - const result = collectReferencedNumIds(convertedXml); - // Only numId 1 from document body — numId 99 from numbering.xml should NOT appear - expect(result).toEqual(new Set([1])); - }); - - it('ignores non-word paths', () => { - const convertedXml = { - 'word/document.xml': makeDocumentXml([makeParagraphWithNumId(1)]), - 'docProps/custom.xml': { elements: [makeParagraphWithNumId(999)] }, - }; - const result = collectReferencedNumIds(convertedXml); - expect(result).toEqual(new Set([1])); - }); - - it('returns empty set when no paragraphs have numbering', () => { - const convertedXml = { - 'word/document.xml': makeDocumentXml([{ name: 'w:p', type: 'element', elements: [] }]), - }; - const result = collectReferencedNumIds(convertedXml); - expect(result).toEqual(new Set()); - }); - - it('deduplicates repeated numIds', () => { - const convertedXml = { - 'word/document.xml': makeDocumentXml([ - makeParagraphWithNumId(2), - makeParagraphWithNumId(2), - makeParagraphWithNumId(2), - ]), - }; - const result = collectReferencedNumIds(convertedXml); - expect(result).toEqual(new Set([2])); - }); -}); - -// --------------------------------------------------------------------------- -// filterOrphanedNumberingDefinitions -// --------------------------------------------------------------------------- - -describe('filterOrphanedNumberingDefinitions', () => { - it('keeps definitions referenced by document paragraphs', () => { - const numbering = { - abstracts: { 10: makeAbstractDef(10) }, - definitions: { 1: makeNumDef(1, 10) }, - }; - const referencedNumIds = new Set([1]); - - const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); - - expect(liveDefinitions).toHaveLength(1); - expect(liveDefinitions[0].attributes['w:numId']).toBe('1'); - expect(liveAbstracts).toHaveLength(1); - expect(liveAbstracts[0].attributes['w:abstractNumId']).toBe('10'); - }); - - it('strips orphaned w:num not referenced by any paragraph', () => { - const numbering = { - abstracts: { 10: makeAbstractDef(10), 20: makeAbstractDef(20) }, - definitions: { 1: makeNumDef(1, 10), 99: makeNumDef(99, 20) }, - }; - // Only numId 1 is referenced — numId 99 is orphaned - const referencedNumIds = new Set([1]); - - const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); - - expect(liveDefinitions).toHaveLength(1); - expect(liveDefinitions[0].attributes['w:numId']).toBe('1'); - // abstractNum 20 is also orphaned (only referenced by stripped numId 99) - expect(liveAbstracts).toHaveLength(1); - expect(liveAbstracts[0].attributes['w:abstractNumId']).toBe('10'); - }); - - it('keeps abstract shared by multiple w:num when at least one survives', () => { - const numbering = { - abstracts: { 10: makeAbstractDef(10) }, - definitions: { - 1: makeNumDef(1, 10), - 2: makeNumDef(2, 10), // same abstract as numId 1 - 3: makeNumDef(3, 10), // orphaned — not referenced - }, - }; - const referencedNumIds = new Set([1, 2]); - - const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); - - expect(liveDefinitions).toHaveLength(2); - expect(liveAbstracts).toHaveLength(1); - expect(liveAbstracts[0].attributes['w:abstractNumId']).toBe('10'); - }); - - it('strips all definitions when no numIds are referenced', () => { - const numbering = { - abstracts: { 10: makeAbstractDef(10) }, - definitions: { 1: makeNumDef(1, 10) }, - }; - const referencedNumIds = new Set(); - - const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); - - expect(liveDefinitions).toHaveLength(0); - expect(liveAbstracts).toHaveLength(0); - }); - - it('handles empty numbering gracefully', () => { - const numbering = { abstracts: {}, definitions: {} }; - const referencedNumIds = new Set([1]); - - const { liveAbstracts, liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); - - expect(liveDefinitions).toHaveLength(0); - expect(liveAbstracts).toHaveLength(0); - }); - - it('preserves w:num entries with lvlOverride elements', () => { - const lvlOverride = { - name: 'w:lvlOverride', - type: 'element', - attributes: { 'w:ilvl': '0' }, - elements: [{ name: 'w:startOverride', type: 'element', attributes: { 'w:val': '5' } }], - }; - const numbering = { - abstracts: { 10: makeAbstractDef(10) }, - definitions: { 1: makeNumDef(1, 10, [lvlOverride]) }, - }; - const referencedNumIds = new Set([1]); - - const { liveDefinitions } = filterOrphanedNumberingDefinitions(numbering, referencedNumIds); - - expect(liveDefinitions).toHaveLength(1); - // Verify lvlOverride is preserved - expect(liveDefinitions[0].elements).toHaveLength(2); // abstractNumId + lvlOverride - }); -}); diff --git a/packages/super-editor/src/editors/v1/tests/data/sd-2911-active-numbering.docx b/packages/super-editor/src/editors/v1/tests/data/sd-2911-active-numbering.docx new file mode 100644 index 0000000000000000000000000000000000000000..cfe610c35206ace9f0224c0ad8f19decf038229d GIT binary patch literal 16222 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
7{IU@?9252lv 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 A)Bpeg literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/editors/v1/tests/data/sd-2911-tentative-numbering.docx b/packages/super-editor/src/editors/v1/tests/data/sd-2911-tentative-numbering.docx new file mode 100644 index 0000000000000000000000000000000000000000..7b9b214fb1272b46f607a85f829f648a496ce738 GIT binary patch literal 12633 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 WflNIBfCv0Rfd&9rfbaYOfd2=H!X#z@ literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/editors/v1/tests/import-export/sd-2911-numbering-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/sd-2911-numbering-roundtrip.test.js new file mode 100644 index 0000000000..2c0fa2270f --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/sd-2911-numbering-roundtrip.test.js @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'node:url'; +import { promises as fs } from 'fs'; +import { Editor } from '@core/Editor.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { initTestEditor } from '../helpers/helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const findNumberingRoot = (json) => { + if (!json?.elements?.length) return null; + if (json.elements[0]?.name === 'w:numbering') return json.elements[0]; + return json.elements.find((el) => el?.name === 'w:numbering') || null; +}; + +const countByName = (numberingRoot, elementName) => + (numberingRoot?.elements || []).filter((el) => el?.name === elementName).length; + +const collectIds = (numberingRoot, elementName, attrName) => + (numberingRoot?.elements || []) + .filter((el) => el?.name === elementName) + .map((el) => String(el.attributes?.[attrName])) + .filter(Boolean) + .sort(); + +async function roundTripNumberingCounts(fileName) { + const docxPath = join(__dirname, '../data', fileName); + const docxBuffer = await fs.readFile(docxPath); + + const originalZipper = new DocxZipper(); + const originalEntries = await originalZipper.getDocxData(docxBuffer, true); + const originalNumberingEntry = originalEntries.find((entry) => entry.name === 'word/numbering.xml'); + expect(originalNumberingEntry, 'fixture must contain word/numbering.xml').toBeDefined(); + const originalRoot = findNumberingRoot(parseXmlToJson(originalNumberingEntry.content)); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedNumberingEntry = exportedFiles.find((entry) => entry.name === 'word/numbering.xml'); + expect(exportedNumberingEntry, 'export must contain word/numbering.xml').toBeDefined(); + const exportedRoot = findNumberingRoot(parseXmlToJson(exportedNumberingEntry.content)); + + return { originalRoot, exportedRoot }; +} + +describe('SD-2911 — numbering.xml definitions preserved on DOCX round-trip', () => { + it('preserves every abstractNum and num for the active-numbering fixture (numId 1 is used)', async () => { + const { originalRoot, exportedRoot } = await roundTripNumberingCounts('sd-2911-active-numbering.docx'); + + expect(countByName(originalRoot, 'w:abstractNum')).toBe(8); + expect(countByName(originalRoot, 'w:num')).toBe(8); + expect(countByName(exportedRoot, 'w:abstractNum')).toBe(countByName(originalRoot, 'w:abstractNum')); + expect(countByName(exportedRoot, 'w:num')).toBe(countByName(originalRoot, 'w:num')); + }); + + it('preserves every abstractNumId and numId verbatim for the active-numbering fixture', async () => { + const { originalRoot, exportedRoot } = await roundTripNumberingCounts('sd-2911-active-numbering.docx'); + expect(collectIds(exportedRoot, 'w:abstractNum', 'w:abstractNumId')).toEqual( + collectIds(originalRoot, 'w:abstractNum', 'w:abstractNumId'), + ); + expect(collectIds(exportedRoot, 'w:num', 'w:numId')).toEqual(collectIds(originalRoot, 'w:num', 'w:numId')); + }); + + it('preserves tentative numbering even when no numId is referenced in the document body', async () => { + const { originalRoot, exportedRoot } = await roundTripNumberingCounts('sd-2911-tentative-numbering.docx'); + + expect(countByName(originalRoot, 'w:abstractNum')).toBe(2); + expect(countByName(originalRoot, 'w:num')).toBe(1); + expect(countByName(exportedRoot, 'w:abstractNum')).toBe(countByName(originalRoot, 'w:abstractNum')); + expect(countByName(exportedRoot, 'w:num')).toBe(countByName(originalRoot, 'w:num')); + }); +});