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