Skip to content

Commit bcdefb0

Browse files
committed
fix(xl-docx-exporter): give each list its own numbering instance
Every bullet/numbered list item was exported with a single shared numbering reference, so docx assigned them all one w:numId. Word treats a shared numId as one continued list, which made separate lists carry on each other's numbering and bullets instead of restarting (#2225). Assign each maximal run of consecutive same-type sibling list items its own numbering instance, using a document-global counter, so docx emits a distinct w:numId per list. Items within a list still share a numId (so they number continuously), and a nested sub-list is its own list that restarts.
1 parent ea5d803 commit bcdefb0

4 files changed

Lines changed: 125 additions & 12 deletions

File tree

packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@
102102
<w:pStyle w:val="ListParagraph"/>
103103
<w:numPr>
104104
<w:ilvl w:val="1"/>
105-
<w:numId w:val="2"/>
105+
<w:numId w:val="3"/>
106106
</w:numPr>
107107
</w:pPr>
108108
<w:r>
@@ -114,7 +114,7 @@
114114
<w:pStyle w:val="ListParagraph"/>
115115
<w:numPr>
116116
<w:ilvl w:val="1"/>
117-
<w:numId w:val="2"/>
117+
<w:numId w:val="3"/>
118118
</w:numPr>
119119
<w:jc w:val="right"/>
120120
</w:pPr>
@@ -127,7 +127,7 @@
127127
<w:pStyle w:val="ListParagraph"/>
128128
<w:numPr>
129129
<w:ilvl w:val="1"/>
130-
<w:numId w:val="3"/>
130+
<w:numId w:val="4"/>
131131
</w:numPr>
132132
</w:pPr>
133133
<w:r>
@@ -139,7 +139,7 @@
139139
<w:pStyle w:val="ListParagraph"/>
140140
<w:numPr>
141141
<w:ilvl w:val="1"/>
142-
<w:numId w:val="3"/>
142+
<w:numId w:val="4"/>
143143
</w:numPr>
144144
</w:pPr>
145145
<w:r>
@@ -151,7 +151,7 @@
151151
<w:pStyle w:val="ListParagraph"/>
152152
<w:numPr>
153153
<w:ilvl w:val="2"/>
154-
<w:numId w:val="3"/>
154+
<w:numId w:val="5"/>
155155
</w:numPr>
156156
</w:pPr>
157157
<w:r>
@@ -163,7 +163,7 @@
163163
<w:pStyle w:val="ListParagraph"/>
164164
<w:numPr>
165165
<w:ilvl w:val="2"/>
166-
<w:numId w:val="3"/>
166+
<w:numId w:val="5"/>
167167
</w:numPr>
168168
</w:pPr>
169169
<w:r>
@@ -175,7 +175,7 @@
175175
<w:pStyle w:val="ListParagraph"/>
176176
<w:numPr>
177177
<w:ilvl w:val="2"/>
178-
<w:numId w:val="3"/>
178+
<w:numId w:val="5"/>
179179
</w:numPr>
180180
<w:shd w:fill="fbe4e4" w:val="clear"/>
181181
<w:jc w:val="right"/>
@@ -192,7 +192,7 @@
192192
<w:pStyle w:val="ListParagraph"/>
193193
<w:numPr>
194194
<w:ilvl w:val="2"/>
195-
<w:numId w:val="3"/>
195+
<w:numId w:val="5"/>
196196
</w:numPr>
197197
<w:shd w:fill="fbe4e4" w:val="clear"/>
198198
<w:jc w:val="center"/>
@@ -209,7 +209,7 @@
209209
<w:pStyle w:val="ListParagraph"/>
210210
<w:numPr>
211211
<w:ilvl w:val="0"/>
212-
<w:numId w:val="3"/>
212+
<w:numId w:val="6"/>
213213
</w:numPr>
214214
</w:pPr>
215215
<w:r>

packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,23 +126,27 @@ export const docxBlockMappingForDefaultSchema: BlockMapping<
126126
],
127127
});
128128
},
129-
numberedListItem: (block, exporter, nestingLevel) => {
129+
numberedListItem: (block, exporter, nestingLevel, numberingInstance) => {
130130
return new Paragraph({
131131
...blockPropsToStyles(block.props, exporter.options.colors),
132132
children: exporter.transformInlineContent(block.content),
133133
numbering: {
134134
reference: "blocknote-numbered-list",
135135
level: clampListLevel(nestingLevel),
136+
// Each distinct list gets its own instance so separate lists don't
137+
// continue each other's numbering (see DOCXExporter.transformBlocks).
138+
instance: numberingInstance,
136139
},
137140
});
138141
},
139-
bulletListItem: (block, exporter, nestingLevel) => {
142+
bulletListItem: (block, exporter, nestingLevel, numberingInstance) => {
140143
return new Paragraph({
141144
...blockPropsToStyles(block.props, exporter.options.colors),
142145
children: exporter.transformInlineContent(block.content),
143146
numbering: {
144147
reference: "blocknote-bullet-list",
145148
level: clampListLevel(nestingLevel),
149+
instance: numberingInstance,
146150
},
147151
});
148152
},

packages/xl-docx-exporter/src/docx/docxExporter.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,82 @@ describe("exporter", () => {
281281
},
282282
);
283283

284+
it(
285+
"should give each list its own numbering instance",
286+
{ timeout: 10000 },
287+
async () => {
288+
const schema = BlockNoteSchema.create({
289+
blockSpecs: { ...defaultBlockSpecs },
290+
});
291+
292+
// Two separate numbered lists split by a paragraph, then a bullet list.
293+
// Each is a distinct list and must not continue the previous one, so each
294+
// needs its own `w:numId`. A nested item stays part of its parent list.
295+
const blocks: PartialBlock<
296+
typeof schema.blockSchema,
297+
typeof schema.inlineContentSchema,
298+
typeof schema.styleSchema
299+
>[] = [
300+
{
301+
type: "numberedListItem",
302+
content: "list one item one",
303+
children: [{ type: "numberedListItem", content: "nested" }],
304+
},
305+
{ type: "numberedListItem", content: "list one item two" },
306+
{ type: "paragraph", content: "a paragraph breaks the list" },
307+
{ type: "numberedListItem", content: "list two item one" },
308+
{ type: "numberedListItem", content: "list two item two" },
309+
{ type: "bulletListItem", content: "a bullet list" },
310+
];
311+
312+
const exporter = new DOCXExporter(schema, docxDefaultSchemaMappings, {
313+
resolveFileUrl: testResolveFileUrl,
314+
});
315+
316+
const doc = await exporter.toDocxJsDocument(
317+
partialBlocksToBlocksForTesting(schema, blocks),
318+
{ sectionOptions: {}, documentOptions: {}, locale: "en-US" },
319+
);
320+
321+
const documentXml = await getZIPEntryContent(
322+
await new ZipReader(
323+
new BlobReader(await Packer.toBlob(doc)),
324+
).getEntries(),
325+
"word/document.xml",
326+
);
327+
328+
// Paragraphs appear in document order: list-one item one, its nested
329+
// child, list-one item two, list-two item one, list-two item two, bullet.
330+
const numIds = [
331+
...documentXml.matchAll(/<w:numId w:val="(\d+)"\/>/g),
332+
].map((match) => Number(match[1]));
333+
334+
expect(numIds).toHaveLength(6);
335+
const [listOneA, listOneNested, listOneB, listTwoA, listTwoB, bullet] =
336+
numIds;
337+
338+
// Items in the same list at the same level share one numId, so the list
339+
// numbers continuously (1, 2) instead of restarting per item.
340+
expect(listOneB).toBe(listOneA);
341+
expect(listTwoB).toBe(listTwoA);
342+
343+
// A nested sub-list is its own list: it gets its own numId and restarts,
344+
// rather than continuing its parent's numbering.
345+
expect(listOneNested).not.toBe(listOneA);
346+
347+
// Separate lists get separate numIds so they don't continue each other -
348+
// this is the actual bug (#2225): before the fix every numbered list
349+
// shared one numId and the second list continued 3, 4, ... instead of 1, 2.
350+
expect(listTwoA).not.toBe(listOneA);
351+
expect(listOneNested).not.toBe(listTwoA);
352+
353+
// The bullet list is distinct from every numbered list too.
354+
expect(bullet).not.toBe(listOneA);
355+
expect(bullet).not.toBe(listTwoA);
356+
expect(bullet).not.toBe(listOneNested);
357+
},
358+
);
359+
284360
async function exportAndGetStylesEntries(locale?: string) {
285361
const exporter = new DOCXExporter(
286362
BlockNoteSchema.create({

packages/xl-docx-exporter/src/docx/docxExporter.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ export class DOCXExporter<
104104
});
105105
}
106106

107+
/**
108+
* A document-global counter used to hand every distinct list its own numbering
109+
* instance (and therefore its own `w:numId`). Two lists that share a `numId`
110+
* are treated by Word as one continued list, so without this all lists in a
111+
* document number/bullet as if they were a single list. See issue #2225.
112+
*/
113+
private numberingInstanceCounter = 0;
114+
107115
/**
108116
* Mostly for internal use, you probably want to use `toBlob` or `toDocxJsDocument` instead.
109117
*/
@@ -113,7 +121,30 @@ export class DOCXExporter<
113121
): Promise<Array<Paragraph | Table>> {
114122
const ret: Array<Paragraph | Table> = [];
115123

124+
// The top-level call starts a fresh document, so restart instance numbering.
125+
if (nestingLevel === 0) {
126+
this.numberingInstanceCounter = 0;
127+
}
128+
129+
// A list in Word is a maximal run of consecutive sibling list items of the
130+
// same type; a break (any other block) or a switch between bullet/numbered
131+
// starts a new list. Each such run gets its own numbering instance so it
132+
// renders as a separate list rather than continuing the previous one.
133+
let runListType: string | undefined;
134+
let runInstance = 0;
135+
116136
for (const b of blocks) {
137+
let numberingInstance = 0;
138+
if (b.type === "bulletListItem" || b.type === "numberedListItem") {
139+
if (b.type !== runListType) {
140+
runInstance = ++this.numberingInstanceCounter;
141+
runListType = b.type;
142+
}
143+
numberingInstance = runInstance;
144+
} else {
145+
runListType = undefined;
146+
}
147+
117148
let children = await this.transformBlocks(b.children, nestingLevel + 1);
118149

119150
if (!["columnList", "column"].includes(b.type)) {
@@ -133,10 +164,12 @@ export class DOCXExporter<
133164
});
134165
}
135166

167+
// The `numberedListIndex` slot carries the numbering instance for the docx
168+
// block mappings (bullet/numbered list items); other block types ignore it.
136169
const self = await this.mapBlock(
137170
b as any,
138171
nestingLevel,
139-
0 /*unused*/,
172+
numberingInstance,
140173
children,
141174
); // TODO: any
142175
if (["columnList", "column"].includes(b.type)) {

0 commit comments

Comments
 (0)