Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions .github/workflows/pr-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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);
});

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);
});

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);
}
});
});
Loading