From 6b7af9b545f9f9a35bb8ff91af8d3d179615bdac Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 27 Jan 2026 12:26:54 -0300 Subject: [PATCH 1/4] fix: patch broken numbering definitions --- .../v2/importer/docxImporter.js | 2 + .../v2/importer/patchNumberingDefinitions.js | 89 +++++++++++++++++++ .../patchNumberingDefinitions.test.js | 73 +++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.js create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js 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 a7c903433b..ae34942c3c 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 @@ -37,6 +37,7 @@ import bookmarkEndAttrConfigs from '@converter/v3/handlers/w/bookmark-end/attrib import { translator as wStylesTranslator } from '@converter/v3/handlers/w/styles/index.js'; import { translator as wNumberingTranslator } from '@converter/v3/handlers/w/numbering/index.js'; import { baseNumbering } from '@converter/v2/exporter/helpers/base-list.definitions.js'; +import { patchNumberingDefinitions } from './patchNumberingDefinitions.js'; /** * @typedef {import()} XmlNode @@ -142,6 +143,7 @@ export const createDocumentJson = (docx, converter, editor) => { const lists = {}; const inlineDocumentFonts = []; + patchNumberingDefinitions(docx); const numbering = getNumberingDefinitions(docx); const comments = importCommentData({ docx, nodeListHandler, converter, editor }); const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.js b/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.js new file mode 100644 index 0000000000..7cb2dd5519 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.js @@ -0,0 +1,89 @@ +import { baseOrderedListDef } from '@core/helpers/baseListDefinitions.js'; + +const WORD_2012_NAMESPACE = 'http://schemas.microsoft.com/office/word/2012/wordml'; + +/** + * Patch numbering definitions in a DOCX file to ensure proper numbering styles. + * This function modifies the numbering.xml part of the DOCX to fix issues + * where a numbering definition might target an undefined abstract numbering definition. + * @param {Object} docx - The DOCX file object to be patched. + */ +export function patchNumberingDefinitions(docx) { + const numberingXml = docx?.['word/numbering.xml']; + if (!numberingXml) return; + + const numberingRoot = getNumberingRoot(numberingXml); + if (!numberingRoot?.elements?.length) return; + + const numberingElements = numberingRoot.elements; + + const existingAbstractIds = new Set(); + for (const el of numberingElements) { + if (el?.name !== 'w:abstractNum') continue; + const abstractId = getAbstractIdFromAbstractNode(el); + if (abstractId) existingAbstractIds.add(abstractId); + } + + const missingAbstractIds = new Set(); + for (const el of numberingElements) { + if (el?.name !== 'w:num') continue; + const abstractId = getAbstractIdFromNum(el); + if (!abstractId) continue; + if (!existingAbstractIds.has(abstractId)) { + missingAbstractIds.add(abstractId); + } + } + + if (!missingAbstractIds.size) return; + + // Ensure the w15 namespace is declared when we add the base ordered list definition, + // which includes a w15:* attribute. + numberingRoot.attributes = numberingRoot.attributes || {}; + if (!numberingRoot.attributes['xmlns:w15']) { + numberingRoot.attributes['xmlns:w15'] = WORD_2012_NAMESPACE; + } + + const firstNumIndex = numberingElements.findIndex((el) => el?.name === 'w:num'); + let insertIndex = firstNumIndex === -1 ? numberingElements.length : firstNumIndex; + + const sortedMissingIds = Array.from(missingAbstractIds).sort((a, b) => { + const aNum = Number(a); + const bNum = Number(b); + const aIsNum = !Number.isNaN(aNum); + const bIsNum = !Number.isNaN(bNum); + if (aIsNum && bIsNum) return aNum - bNum; + if (aIsNum) return -1; + if (bIsNum) return 1; + return a.localeCompare(b); + }); + + for (const abstractId of sortedMissingIds) { + if (existingAbstractIds.has(abstractId)) continue; + const newAbstract = deepClone(baseOrderedListDef); + newAbstract.attributes = { + ...(newAbstract.attributes || {}), + 'w:abstractNumId': String(abstractId), + }; + numberingElements.splice(insertIndex, 0, newAbstract); + insertIndex += 1; + existingAbstractIds.add(abstractId); + } +} + +const deepClone = (value) => JSON.parse(JSON.stringify(value)); + +const getNumberingRoot = (numberingXml) => { + if (!numberingXml?.elements?.length) return null; + return numberingXml.elements.find((el) => el?.name === 'w:numbering') || numberingXml.elements[0] || null; +}; + +const getAbstractIdFromNum = (numNode) => { + const abstractRef = numNode?.elements?.find((child) => child?.name === 'w:abstractNumId'); + const abstractVal = abstractRef?.attributes?.['w:val']; + return abstractVal == null ? null : String(abstractVal); +}; + +const getAbstractIdFromAbstractNode = (abstractNode) => { + const raw = abstractNode?.attributes?.['w:abstractNumId']; + return raw == null ? null : String(raw); +}; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js b/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js new file mode 100644 index 0000000000..5f922a1974 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { patchNumberingDefinitions } from './patchNumberingDefinitions.js'; + +const makeNumberingXml = (elements) => ({ + elements: [ + { + name: 'w:numbering', + attributes: { + 'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + }, + elements, + }, + ], +}); + +const abstractNum = (id) => ({ + type: 'element', + name: 'w:abstractNum', + attributes: { 'w:abstractNumId': String(id) }, + elements: [ + { + type: 'element', + name: 'w:lvl', + attributes: { 'w:ilvl': '0' }, + elements: [{ type: 'element', name: 'w:numFmt', attributes: { 'w:val': 'decimal' } }], + }, + ], +}); + +const num = ({ numId, abstractId }) => ({ + type: 'element', + name: 'w:num', + attributes: { 'w:numId': String(numId) }, + elements: [ + { + type: 'element', + name: 'w:abstractNumId', + attributes: { 'w:val': String(abstractId) }, + }, + ], +}); + +describe('patchNumberingDefinitions', () => { + it('creates a missing abstractNum using the base ordered list definition', () => { + const docx = { + 'word/numbering.xml': makeNumberingXml([ + abstractNum(41), + { type: 'comment', comment: 'broken reference follows' }, + num({ numId: 1, abstractId: 42 }), + ]), + }; + + patchNumberingDefinitions(docx); + + const numberingRoot = docx['word/numbering.xml'].elements[0]; + const numberingElements = numberingRoot.elements; + + const patchedAbstract = numberingElements.find( + (el) => el?.name === 'w:abstractNum' && String(el.attributes?.['w:abstractNumId']) === '42', + ); + + expect(patchedAbstract).toBeTruthy(); + expect(patchedAbstract.elements?.some((el) => el?.name === 'w:lvl')).toBe(true); + expect(numberingRoot.attributes?.['xmlns:w15']).toBe('http://schemas.microsoft.com/office/word/2012/wordml'); + + const firstNumIndex = numberingElements.findIndex((el) => el?.name === 'w:num'); + const patchedAbstractIndex = numberingElements.findIndex( + (el) => el?.name === 'w:abstractNum' && String(el.attributes?.['w:abstractNumId']) === '42', + ); + expect(patchedAbstractIndex).toBeGreaterThan(-1); + expect(firstNumIndex).toBeGreaterThan(patchedAbstractIndex); + }); +}); From bd56e45142ee3e57ae8213a2cff6ae4274a37564 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 27 Jan 2026 18:35:39 -0300 Subject: [PATCH 2/4] test: add no-op test for patching numbering definitions --- .../importer/patchNumberingDefinitions.test.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js b/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js index 5f922a1974..898cce1132 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js @@ -70,4 +70,21 @@ describe('patchNumberingDefinitions', () => { expect(patchedAbstractIndex).toBeGreaterThan(-1); expect(firstNumIndex).toBeGreaterThan(patchedAbstractIndex); }); + + it('does not change the numbering xml when all abstract references exist', () => { + const docx = { + 'word/numbering.xml': makeNumberingXml([ + abstractNum(41), + abstractNum(42), + num({ numId: 1, abstractId: 41 }), + num({ numId: 2, abstractId: 42 }), + ]), + }; + + const before = JSON.parse(JSON.stringify(docx)); + + patchNumberingDefinitions(docx); + + expect(docx).toEqual(before); + }); }); From f7b424907f6a330e24b01e9bf90ce21967b1f1ae Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 27 Jan 2026 18:37:22 -0300 Subject: [PATCH 3/4] test: consider multiple missing abstract definitions --- .../patchNumberingDefinitions.test.js | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js b/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js index 898cce1132..2f05842488 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/patchNumberingDefinitions.test.js @@ -87,4 +87,37 @@ describe('patchNumberingDefinitions', () => { expect(docx).toEqual(before); }); + + it('creates multiple missing abstractNum definitions', () => { + const docx = { + 'word/numbering.xml': makeNumberingXml([ + abstractNum(41), + num({ numId: 1, abstractId: 43 }), + num({ numId: 2, abstractId: 42 }), + num({ numId: 3, abstractId: 44 }), + ]), + }; + + patchNumberingDefinitions(docx); + + const numberingRoot = docx['word/numbering.xml'].elements[0]; + const numberingElements = numberingRoot.elements; + + const missingIds = ['42', '43', '44']; + for (const id of missingIds) { + const patchedAbstract = numberingElements.find( + (el) => el?.name === 'w:abstractNum' && String(el.attributes?.['w:abstractNumId']) === id, + ); + expect(patchedAbstract).toBeTruthy(); + } + + const firstNumIndex = numberingElements.findIndex((el) => el?.name === 'w:num'); + for (const id of missingIds) { + const patchedAbstractIndex = numberingElements.findIndex( + (el) => el?.name === 'w:abstractNum' && String(el.attributes?.['w:abstractNumId']) === id, + ); + expect(patchedAbstractIndex).toBeGreaterThan(-1); + expect(firstNumIndex).toBeGreaterThan(patchedAbstractIndex); + } + }); }); From c05be5be9ff8496eb764b1fb068811776b4a0aca Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 28 Jan 2026 00:01:00 -0800 Subject: [PATCH 4/4] chore: fix tests --- .github/workflows/pr-validation.yml | 15 +-------------- package.json | 2 +- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 74498780c2..5eb1349ed9 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -130,23 +130,10 @@ jobs: run: | pnpm run build - - name: Cache Playwright browsers - uses: actions/cache@v4 - id: playwright-cache - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('e2e-tests/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-playwright- - - name: Install Playwright browsers run: | cd e2e-tests - if [ "${{ steps.playwright-cache.outputs.cache-hit }}" != "true" ]; then - pnpx playwright install --with-deps - else - pnpx playwright install-deps - fi + pnpx playwright install --with-deps chromium - name: Run e2e tests id: run-e2e-tests diff --git a/package.json b/package.json index 4047a22cca..328269e3b5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "vitest run", "test:bench": "VITEST_BENCH=true vitest run", - "test:slow": "vitest run --root ./packages/super-editor src/tests/editor/node-import-timing.test.js", + "test:slow": "vitest run --root ./packages/super-editor --exclude '**/node_modules/**' src/tests/editor/node-import-timing.test.js", "test:debug": "pnpm --prefix packages/super-editor run test:debug", "test:inspect": "NODE_OPTIONS=\"--inspect-brk=9229\" vitest --pool threads --poolOptions.threads.singleThread", "test:all": "vitest run",