import { Block, BlockNoteSchema, BlockSchema, COLORS_DEFAULT, DefaultProps, Exporter, ExporterOptions, InlineContentSchema, StyleSchema, StyledText, } from "@blocknote/core"; import { Document, Font, Link, Page, StyleSheet, Text, TextProps, View, } from "@react-pdf/renderer"; import { corsProxyResolveFileUrl } from "@shared/api/corsProxy.js"; import { Fragment } from "react"; import { loadFontDataUrl } from "../../../../shared/util/fileUtil.js"; import { Style } from "./types.js"; const FONT_SIZE = 16; const PIXELS_PER_POINT = 0.75; type Options = ExporterOptions & { /** * * @default uses the remote emoji source hosted on cloudflare (https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/) */ emojiSource: false | ReturnType; }; /** * Exports a BlockNote document to a .pdf file using the react-pdf library. */ export class PDFExporter< B extends BlockSchema, S extends StyleSchema, I extends InlineContentSchema, > extends Exporter< B, I, S, React.ReactElement, React.ReactElement | React.ReactElement, TextProps["style"], React.ReactElement > { private fontsRegistered = false; public styles = StyleSheet.create({ page: { paddingTop: 35, paddingBottom: 65, paddingHorizontal: 35, fontFamily: "Inter", fontSize: FONT_SIZE * PIXELS_PER_POINT, // pixels lineHeight: 1.5, }, block: {}, blockChildren: {}, header: {}, footer: { position: "absolute", }, }); public readonly options: Options; public constructor( /** * The schema of your editor. The mappings are automatically typed checked against this schema. */ protected readonly schema: BlockNoteSchema, /** * The mappings that map the BlockNote schema to the react-pdf content. * * Pass {@link pdfDefaultSchemaMappings} for the default schema. */ mappings: Exporter< NoInfer, NoInfer, NoInfer, React.ReactElement, // RB React.ReactElement | React.ReactElement, // RI TextProps["style"], // RS React.ReactElement // TS >["mappings"], options?: Partial, ) { const defaults = { emojiSource: { format: "png", url: "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/", }, resolveFileUrl: corsProxyResolveFileUrl, colors: COLORS_DEFAULT, } satisfies Partial; const newOptions = { ...defaults, ...options, }; super(schema, mappings, newOptions); this.options = newOptions; } /** * Mostly for internal use, you probably want to use `toBlob` or `toReactPDFDocument` instead. */ public transformStyledText(styledText: StyledText) { const stylesArray = this.mapStyles(styledText.styles); const styles = Object.assign({}, ...stylesArray); return ( {styledText.text} ); } /** * Mostly for internal use, you probably want to use `toBlob` or `toReactPDFDocument` instead. */ public async transformBlocks( blocks: Block[], // Or BlockFromConfig? nestingLevel = 0, ): Promise[]> { const ret: React.ReactElement[] = []; let numberedListIndex = 0; for (const b of blocks) { if (b.type === "numberedListItem") { numberedListIndex++; } else { numberedListIndex = 0; } const children = await this.transformBlocks(b.children, nestingLevel + 1); const self = await this.mapBlock( b as any, nestingLevel, numberedListIndex, children, ); // TODO: any if (["pageBreak", "columnList", "column"].includes(b.type)) { ret.push(self); continue; } const style = this.blocknoteDefaultPropsToReactPDFStyle(b.props as any); ret.push( {self} {children.length > 0 && ( {children} )} , ); } return ret; } protected async registerFonts() { if (this.fontsRegistered) { return; } if (this.options.emojiSource) { Font.registerEmojiSource(this.options.emojiSource); } let font = await loadFontDataUrl( await import("@shared/assets/fonts/inter/Inter_18pt-Regular.ttf"), ); Font.register({ family: "Inter", src: font, }); font = await loadFontDataUrl( await import("@shared/assets/fonts/inter/Inter_18pt-Italic.ttf"), ); Font.register({ family: "Inter", fontStyle: "italic", src: font, }); font = await loadFontDataUrl( await import("@shared/assets/fonts/inter/Inter_18pt-Bold.ttf"), ); Font.register({ family: "Inter", src: font, fontWeight: "bold", }); font = await loadFontDataUrl( await import("@shared/assets/fonts/inter/Inter_18pt-BoldItalic.ttf"), ); Font.register({ family: "Inter", fontStyle: "italic", src: font, fontWeight: "bold", }); font = await loadFontDataUrl( await import("@shared/assets/fonts/GeistMono-Regular.ttf"), ); Font.register({ family: "GeistMono", src: font, }); this.fontsRegistered = true; } /** * Convert a document (array of Blocks) to a react-pdf Document. */ public async toReactPDFDocument( blocks: Block[], options: { /** * Add a header to every page. * The React component passed must be a React-PDF component */ header?: React.ReactElement; /** * Add a footer to every page. * The React component passed must be a React-PDF component */ footer?: React.ReactElement; } = {}, ) { await this.registerFonts(); return ( {options.header && ( {options.header} )} {await this.transformBlocks(blocks)} {options.footer && ( {options.footer} )} ); } protected blocknoteDefaultPropsToReactPDFStyle( props: Partial, ): Style { return { textAlign: props.textAlignment, backgroundColor: props.backgroundColor === "default" || !props.backgroundColor ? undefined : this.options.colors[props.backgroundColor]?.background, color: props.textColor === "default" || !props.textColor ? undefined : this.options.colors[props.textColor]?.text, alignItems: props.textAlignment === "right" ? "flex-end" : props.textAlignment === "center" ? "center" : undefined, }; } }