From 36b0f2e44a8e846b1752a832811e75157faf73ad Mon Sep 17 00:00:00 2001 From: adarshsm <24850536+adarshsm@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:45:33 +0530 Subject: [PATCH] fix(xl-docx-exporter): clamp list nesting to the levels DOCX defines `numberedListItem` and `bulletListItem` passed the block's `nestingLevel` straight into docx's `numbering.level`, but the numbering configs built in `createDefaultDocumentOptions` only define 9 levels (`w:ilvl` 0-8). Lists nested deeper than that produced two distinct failures: - 11 levels or more made docx throw "Level cannot be greater than 9", which aborted the entire export with no feedback beyond a console error. - Exactly 10 levels did not throw, but emitted `` while `numbering.xml` defines no level 9, so that item lost its bullet and indent. Clamp the level to the deepest one the numbering config defines, so deeper items render at that level instead - the same way Word collapses nesting past its own 9-level limit. The level count now comes from a single shared constant, so the config and the mappings cannot drift apart. Fixes #2228 --- .../src/docx/defaultSchema/blocks.ts | 5 +- .../src/docx/docxExporter.test.ts | 61 +++++++++++++++++++ .../xl-docx-exporter/src/docx/docxExporter.ts | 5 +- .../xl-docx-exporter/src/docx/listLevels.ts | 24 ++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 packages/xl-docx-exporter/src/docx/listLevels.ts diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index 77c360b668..7f36b78d15 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -22,6 +22,7 @@ import { TableRow, TextRun, } from "docx"; +import { clampListLevel } from "../listLevels.js"; import { Table } from "../util/Table.js"; function blockPropsToStyles( @@ -103,7 +104,7 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< children: exporter.transformInlineContent(block.content), numbering: { reference: "blocknote-numbered-list", - level: nestingLevel, + level: clampListLevel(nestingLevel), }, }); }, @@ -113,7 +114,7 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< children: exporter.transformInlineContent(block.content), numbering: { reference: "blocknote-bullet-list", - level: nestingLevel, + level: clampListLevel(nestingLevel), }, }); }, diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index a24340a7ab..ec57875bbd 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -2,6 +2,7 @@ import { BlockNoteSchema, defaultBlockSpecs, createPageBreakBlockSpec, + PartialBlock, } from "@blocknote/core"; import { testDocument } from "@shared/testDocument.js"; import { @@ -223,6 +224,66 @@ describe("exporter", () => { }, ); + it( + "should clamp list nesting deeper than DOCX supports", + { timeout: 10000 }, + async () => { + const schema = BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs }, + }); + + // A list nested `depth` items deep, i.e. nesting levels 0..depth-1. + const nestedList = (depth: number) => { + type SchemaPartialBlock = PartialBlock< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema + >; + + let block: SchemaPartialBlock = { + type: "bulletListItem", + content: `level ${depth}`, + }; + for (let i = depth - 1; i >= 1; i--) { + block = { + type: "bulletListItem", + content: `level ${i}`, + children: [block], + }; + } + return [block]; + }; + const exporter = new DOCXExporter(schema, docxDefaultSchemaMappings, { + resolveFileUrl: testResolveFileUrl, + }); + + // Deeper than the 9 levels the numbering config defines. Without + // clamping, `docx` throws "Level cannot be greater than 9" and the whole + // export fails. + const doc = await exporter.toDocxJsDocument( + partialBlocksToBlocksForTesting(schema, nestedList(12)), + { sectionOptions: {}, documentOptions: {}, locale: "en-US" }, + ); + + const blob = await Packer.toBlob(doc); + const zip = new ZipReader(new BlobReader(blob)); + const entries = await zip.getEntries(); + const documentXml = await getZIPEntryContent( + entries, + "word/document.xml", + ); + + const levels = [...documentXml.matchAll(//g)].map( + (match) => Number(match[1]), + ); + + // Every item is still exported, and every level it references is one the + // numbering config actually defines (0-8) - levels past that are pinned + // to the deepest defined level rather than dropped or left undefined. + expect(levels).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 8, 8, 8]); + }, + ); + 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 6fce968a3e..3bd65830ec 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -24,6 +24,7 @@ import { import { Exporter, ExporterOptions } from "@blocknote/core"; import { corsProxyResolveFileUrl } from "@shared/api/corsProxy.js"; import { loadFileBuffer } from "@shared/util/fileUtil.js"; +import { DOCX_LIST_LEVEL_COUNT } from "./listLevels.js"; // get constructor arg type from Document type DocumentOptions = Partial[0]>; @@ -216,7 +217,7 @@ export class DOCXExporter< config: [ { reference: "blocknote-numbered-list", - levels: Array.from({ length: 9 }, (_, i) => ({ + levels: Array.from({ length: DOCX_LIST_LEVEL_COUNT }, (_, i) => ({ start: 1, level: i, format: LevelFormat.DECIMAL, @@ -234,7 +235,7 @@ export class DOCXExporter< }, { reference: "blocknote-bullet-list", - levels: Array.from({ length: 9 }, (_, i) => ({ + levels: Array.from({ length: DOCX_LIST_LEVEL_COUNT }, (_, i) => ({ start: 1, level: i, format: LevelFormat.BULLET, diff --git a/packages/xl-docx-exporter/src/docx/listLevels.ts b/packages/xl-docx-exporter/src/docx/listLevels.ts new file mode 100644 index 0000000000..8122fb4cdd --- /dev/null +++ b/packages/xl-docx-exporter/src/docx/listLevels.ts @@ -0,0 +1,24 @@ +/** + * The number of levels defined for the `blocknote-numbered-list` and + * `blocknote-bullet-list` numbering configs in + * `DOCXExporter.createDefaultDocumentOptions`. + * + * OOXML numbering definitions cap out at 9 levels (`w:ilvl` 0-8), which is also + * the nesting limit Word itself exposes. + */ +export const DOCX_LIST_LEVEL_COUNT = 9; + +/** + * Clamps a block's nesting level to the deepest list level the numbering config + * actually defines. + * + * BlockNote allows lists to nest arbitrarily deep, so a `nestingLevel` can + * exceed what DOCX supports. Passing such a level straight through makes `docx` + * throw ("Level cannot be greater than 9"), which aborts the whole export, and + * a level with no matching definition renders without its bullet or indent. + * Deeper items are rendered at the deepest defined level instead, matching how + * Word collapses nesting past its own limit. + */ +export function clampListLevel(nestingLevel: number) { + return Math.min(nestingLevel, DOCX_LIST_LEVEL_COUNT - 1); +}