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