diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml index 4a9074e6eb..32f4ccaedb 100644 --- a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml @@ -102,7 +102,7 @@ - + @@ -114,7 +114,7 @@ - + @@ -127,7 +127,7 @@ - + @@ -139,7 +139,7 @@ - + @@ -151,7 +151,7 @@ - + @@ -163,7 +163,7 @@ - + @@ -175,7 +175,7 @@ - + @@ -192,7 +192,7 @@ - + @@ -209,7 +209,7 @@ - + diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index bfce5d335d..f7833d36a3 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -126,23 +126,27 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< ], }); }, - numberedListItem: (block, exporter, nestingLevel) => { + numberedListItem: (block, exporter, nestingLevel, numberingInstance) => { return new Paragraph({ ...blockPropsToStyles(block.props, exporter.options.colors), children: exporter.transformInlineContent(block.content), numbering: { reference: "blocknote-numbered-list", level: clampListLevel(nestingLevel), + // Each distinct list gets its own instance so separate lists don't + // continue each other's numbering (see DOCXExporter.transformBlocks). + instance: numberingInstance, }, }); }, - bulletListItem: (block, exporter, nestingLevel) => { + bulletListItem: (block, exporter, nestingLevel, numberingInstance) => { return new Paragraph({ ...blockPropsToStyles(block.props, exporter.options.colors), children: exporter.transformInlineContent(block.content), numbering: { reference: "blocknote-bullet-list", level: clampListLevel(nestingLevel), + instance: numberingInstance, }, }); }, diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index 16e45a304f..6543b9998f 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -334,6 +334,82 @@ describe("exporter", () => { }, ); + it( + "should give each list its own numbering instance", + { timeout: 10000 }, + async () => { + const schema = BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs }, + }); + + // Two separate numbered lists split by a paragraph, then a bullet list. + // Each is a distinct list and must not continue the previous one, so each + // needs its own `w:numId`. A nested item stays part of its parent list. + const blocks: PartialBlock< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema + >[] = [ + { + type: "numberedListItem", + content: "list one item one", + children: [{ type: "numberedListItem", content: "nested" }], + }, + { type: "numberedListItem", content: "list one item two" }, + { type: "paragraph", content: "a paragraph breaks the list" }, + { type: "numberedListItem", content: "list two item one" }, + { type: "numberedListItem", content: "list two item two" }, + { type: "bulletListItem", content: "a bullet list" }, + ]; + + const exporter = new DOCXExporter(schema, docxDefaultSchemaMappings, { + resolveFileUrl: testResolveFileUrl, + }); + + const doc = await exporter.toDocxJsDocument( + partialBlocksToBlocksForTesting(schema, blocks), + { sectionOptions: {}, documentOptions: {}, locale: "en-US" }, + ); + + const documentXml = await getZIPEntryContent( + await new ZipReader( + new BlobReader(await Packer.toBlob(doc)), + ).getEntries(), + "word/document.xml", + ); + + // Paragraphs appear in document order: list-one item one, its nested + // child, list-one item two, list-two item one, list-two item two, bullet. + const numIds = [ + ...documentXml.matchAll(//g), + ].map((match) => Number(match[1])); + + expect(numIds).toHaveLength(6); + const [listOneA, listOneNested, listOneB, listTwoA, listTwoB, bullet] = + numIds; + + // Items in the same list at the same level share one numId, so the list + // numbers continuously (1, 2) instead of restarting per item. + expect(listOneB).toBe(listOneA); + expect(listTwoB).toBe(listTwoA); + + // A nested sub-list is its own list: it gets its own numId and restarts, + // rather than continuing its parent's numbering. + expect(listOneNested).not.toBe(listOneA); + + // Separate lists get separate numIds so they don't continue each other - + // this is the actual bug (#2225): before the fix every numbered list + // shared one numId and the second list continued 3, 4, ... instead of 1, 2. + expect(listTwoA).not.toBe(listOneA); + expect(listOneNested).not.toBe(listTwoA); + + // The bullet list is distinct from every numbered list too. + expect(bullet).not.toBe(listOneA); + expect(bullet).not.toBe(listTwoA); + expect(bullet).not.toBe(listOneNested); + }, + ); + async function exportAndGetStylesEntries(locale?: string) { const exporter = new DOCXExporter( BlockNoteSchema.create({ diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts index f987ad4a7d..7085659138 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -104,6 +104,14 @@ export class DOCXExporter< }); } + /** + * A document-global counter used to hand every distinct list its own numbering + * instance (and therefore its own `w:numId`). Two lists that share a `numId` + * are treated by Word as one continued list, so without this all lists in a + * document number/bullet as if they were a single list. See issue #2225. + */ + private numberingInstanceCounter = 0; + /** * Mostly for internal use, you probably want to use `toBlob` or `toDocxJsDocument` instead. */ @@ -113,7 +121,30 @@ export class DOCXExporter< ): Promise> { const ret: Array = []; + // The top-level call starts a fresh document, so restart instance numbering. + if (nestingLevel === 0) { + this.numberingInstanceCounter = 0; + } + + // A list in Word is a maximal run of consecutive sibling list items of the + // same type; a break (any other block) or a switch between bullet/numbered + // starts a new list. Each such run gets its own numbering instance so it + // renders as a separate list rather than continuing the previous one. + let runListType: string | undefined; + let runInstance = 0; + for (const b of blocks) { + let numberingInstance = 0; + if (b.type === "bulletListItem" || b.type === "numberedListItem") { + if (b.type !== runListType) { + runInstance = ++this.numberingInstanceCounter; + runListType = b.type; + } + numberingInstance = runInstance; + } else { + runListType = undefined; + } + let children = await this.transformBlocks(b.children, nestingLevel + 1); if (!["columnList", "column"].includes(b.type)) { @@ -133,10 +164,12 @@ export class DOCXExporter< }); } + // The `numberedListIndex` slot carries the numbering instance for the docx + // block mappings (bullet/numbered list items); other block types ignore it. const self = await this.mapBlock( b as any, nestingLevel, - 0 /*unused*/, + numberingInstance, children, ); // TODO: any if (["columnList", "column"].includes(b.type)) {