UNPKG

@blocknote/xl-docx-exporter

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

298 lines (275 loc) 8.16 kB
import { Block, BlockNoteSchema, BlockSchema, COLORS_DEFAULT, InlineContentSchema, StyleSchema, StyledText, } from "@blocknote/core"; import { AlignmentType, Document, IRunPropertiesOptions, ISectionOptions, LevelFormat, Packer, Paragraph, ParagraphChild, Tab, Table, TextRun, } from "docx"; import { Exporter, ExporterOptions } from "@blocknote/core"; import { corsProxyResolveFileUrl } from "@shared/api/corsProxy.js"; import { loadFileBuffer } from "@shared/util/fileUtil.js"; // get constructor arg type from Document type DocumentOptions = Partial<ConstructorParameters<typeof Document>[0]>; const DEFAULT_TAB_STOP = /* default font size */ 16 * /* 1 pixel is 0.75 points */ 0.75 * /* 1.5em*/ 1.5 * /* 1 point is 20 twips */ 20; /** * Exports a BlockNote document to a .docx file using the docxjs library. */ export class DOCXExporter< B extends BlockSchema, S extends StyleSchema, I extends InlineContentSchema, > extends Exporter< B, I, S, Promise<Paragraph[] | Paragraph | Table> | Paragraph[] | Paragraph | Table, ParagraphChild, IRunPropertiesOptions, TextRun > { public constructor( /** * The schema of your editor. The mappings are automatically typed checked against this schema. */ protected readonly schema: BlockNoteSchema<B, I, S>, /** * The mappings that map the BlockNote schema to the docxjs content. * Pass {@link docxDefaultSchemaMappings} for the default schema. */ protected readonly mappings: Exporter< NoInfer<B>, NoInfer<I>, NoInfer<S>, | Promise<Paragraph[] | Paragraph | Table> | Paragraph[] | Paragraph | Table, ParagraphChild, IRunPropertiesOptions, TextRun >["mappings"], options?: Partial<ExporterOptions>, ) { const defaults = { colors: COLORS_DEFAULT, resolveFileUrl: corsProxyResolveFileUrl, } satisfies Partial<ExporterOptions>; const newOptions = { ...defaults, ...options, }; super(schema, mappings, newOptions); } /** * Mostly for internal use, you probably want to use `toBlob` or `toDocxJsDocument` instead. */ public transformStyledText(styledText: StyledText<S>, hyperlink?: boolean) { const stylesArray = this.mapStyles(styledText.styles); const styles: IRunPropertiesOptions = Object.assign( {} as IRunPropertiesOptions, ...stylesArray, ); return new TextRun({ ...styles, style: hyperlink ? "Hyperlink" : undefined, text: styledText.text, }); } /** * Mostly for internal use, you probably want to use `toBlob` or `toDocxJsDocument` instead. */ public async transformBlocks( blocks: Block<B, I, S>[], nestingLevel = 0, ): Promise<Array<Paragraph | Table>> { const ret: Array<Paragraph | Table> = []; for (const b of blocks) { let children = await this.transformBlocks(b.children, nestingLevel + 1); if (!["columnList", "column"].includes(b.type)) { children = children.map((c, _i) => { // NOTE: nested tables not supported (we can't insert the new Tab before a table) if ( c instanceof Paragraph && !(c as any).properties.numberingReferences.length ) { c.addRunToFront( new TextRun({ children: [new Tab()], }), ); } return c; }); } const self = await this.mapBlock( b as any, nestingLevel, 0 /*unused*/, children, ); // TODO: any if (["columnList", "column"].includes(b.type)) { ret.push(self as Table); } else if (Array.isArray(self)) { ret.push(...self, ...children); } else { ret.push(self, ...children); } } return ret; } protected async getFonts(): Promise<DocumentOptions["fonts"]> { // Unfortunately, loading the variable font doesn't work // "./src/fonts/Inter-VariableFont_opsz,wght.ttf", let interFont = await loadFileBuffer( await import("@shared/assets/fonts/inter/Inter_18pt-Regular.ttf"), ); let geistMonoFont = await loadFileBuffer( await import("@shared/assets/fonts/GeistMono-Regular.ttf"), ); if ( interFont instanceof ArrayBuffer || geistMonoFont instanceof ArrayBuffer ) { // conversion with Polyfill needed because docxjs requires Buffer // NOTE: the buffer/ import is intentional and as documented in // the `buffer` package usage instructions // https://github.com/feross/buffer?tab=readme-ov-file#usage const Buffer = (await import("buffer/")).Buffer; if (interFont instanceof ArrayBuffer) { interFont = Buffer.from(interFont) as unknown as Buffer; } if (geistMonoFont instanceof ArrayBuffer) { geistMonoFont = Buffer.from(geistMonoFont) as unknown as Buffer; } } return [ { name: "Inter", data: interFont }, { name: "GeistMono", data: geistMonoFont, }, ]; } protected async createDefaultDocumentOptions(): Promise<DocumentOptions> { const externalStyles = (await import("./template/word/styles.xml?raw")) .default; const bullets = ["•"]; //, "◦", "▪"]; (these don't look great, just use solid bullet for now) return { numbering: { config: [ { reference: "blocknote-numbered-list", levels: Array.from({ length: 9 }, (_, i) => ({ start: 1, level: i, format: LevelFormat.DECIMAL, text: `%${i + 1}.`, alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: DEFAULT_TAB_STOP * (i + 1), hanging: DEFAULT_TAB_STOP, }, }, }, })), }, { reference: "blocknote-bullet-list", levels: Array.from({ length: 9 }, (_, i) => ({ start: 1, level: i, format: LevelFormat.BULLET, text: bullets[i % bullets.length], alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: DEFAULT_TAB_STOP * (i + 1), hanging: DEFAULT_TAB_STOP, }, }, }, })), }, ], }, fonts: await this.getFonts(), defaultTabStop: 200, externalStyles, }; } /** * Convert a document (array of Blocks to a Blob representing a .docx file) */ public async toBlob( blocks: Block<B, I, S>[], options: { sectionOptions: Omit<ISectionOptions, "children">; documentOptions: DocumentOptions; } = { sectionOptions: {}, documentOptions: {}, }, ) { const doc = await this.toDocxJsDocument(blocks, options); type GlobalThis = typeof globalThis & { Buffer?: any }; const prevBuffer = (globalThis as GlobalThis).Buffer; try { if (!(globalThis as GlobalThis).Buffer) { // load Buffer polyfill because docxjs requires this (globalThis as GlobalThis).Buffer = ( await import("buffer") ).default.Buffer; } return Packer.toBlob(doc); } finally { (globalThis as GlobalThis).Buffer = prevBuffer; } } /** * Convert a document (array of Blocks to a docxjs Document) */ public async toDocxJsDocument( blocks: Block<B, I, S>[], options: { sectionOptions: Omit<ISectionOptions, "children">; documentOptions: DocumentOptions; } = { sectionOptions: {}, documentOptions: {}, }, ) { const doc = new Document({ ...(await this.createDefaultDocumentOptions()), ...options.documentOptions, sections: [ { children: await this.transformBlocks(blocks), ...options.sectionOptions, }, ], }); return doc; } }