From e0de9229eeb7427ece0525cc1b393dcc6fa98c6c Mon Sep 17 00:00:00 2001 From: adarshsm <24850536+adarshsm@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:02:21 +0530 Subject: [PATCH] fix(xl-docx-exporter): use distinct bullet symbols per nesting level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bullet list level exported with the same "•" glyph, so nested bullet lists were visually indistinguishable from their parents (#2226). Word, LibreOffice and Google Docs all cycle the bullet by depth. Cycle the glyph by level - filled disc, hollow circle, filled square - using Unicode characters that render in the document font (no dependency on Symbol or Wingdings being installed). --- .../src/docx/docxExporter.test.ts | 53 +++++++++++++++++++ .../xl-docx-exporter/src/docx/docxExporter.ts | 7 ++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index 3fc45d1df0..16e45a304f 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -281,6 +281,59 @@ describe("exporter", () => { }, ); + it( + "should use distinct bullet symbols per nesting level", + { timeout: 10000 }, + async () => { + const schema = BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs }, + }); + + const exporter = new DOCXExporter(schema, docxDefaultSchemaMappings, { + resolveFileUrl: testResolveFileUrl, + }); + + const doc = await exporter.toDocxJsDocument( + partialBlocksToBlocksForTesting(schema, [ + { type: "bulletListItem", content: "level 0" }, + ]), + { sectionOptions: {}, documentOptions: {}, locale: "en-US" }, + ); + + const numberingXml = await getZIPEntryContent( + await new ZipReader( + new BlobReader(await Packer.toBlob(doc)), + ).getEntries(), + "word/numbering.xml", + ); + + // numbering.xml defines both the numbered and bullet abstract numberings, + // each with a `w:lvl` per depth. Pick out the bullet levels (numFmt + // "bullet") and read each level's glyph (`w:lvlText`) by depth (`w:ilvl`). + const bulletTextByLevel = new Map(); + for (const block of numberingXml.matchAll( + /]*w:ilvl="(\d+)"[^>]*>([\s\S]*?)<\/w:lvl>/g, + )) { + const body = block[2]; + if (!//.test(body)) { + continue; + } + const text = body.match(/ bulletTextByLevel.get(level))).toEqual([ + "•", + "○", + "▪", + ]); + }, + ); + 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 3bd65830ec..f987ad4a7d 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -211,7 +211,12 @@ export class DOCXExporter< externalStyles = externalStyles.replace(/\s*]*\/>/g, ""); } - const bullets = ["•"]; //, "◦", "▪"]; (these don't look great, just use solid bullet for now) + // Cycle bullet symbols by depth (filled disc, hollow circle, filled + // square), the same convention Word/LibreOffice/Google Docs use, so nested + // bullet levels are visually distinct instead of all rendering as "•" + // (#2226). These Unicode glyphs render in the document font, so they don't + // depend on Symbol/Wingdings being installed. + const bullets = ["•", "○", "▪"]; return { numbering: { config: [