@blocknote/xl-docx-exporter
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
320 lines (313 loc) • 8.5 kB
text/typescript
import {
BlockMapping,
COLORS_DEFAULT,
DefaultBlockSchema,
DefaultProps,
pageBreakSchema,
StyledText,
UnreachableCaseError,
} from "@blocknote/core";
import { getImageDimensions } from "@shared/util/imageUtil.js";
import {
CheckBox,
Table as DocxTable,
ExternalHyperlink,
ImageRun,
IParagraphOptions,
PageBreak,
Paragraph,
ParagraphChild,
ShadingType,
TableCell,
TableRow,
TextRun,
} from "docx";
import { Table } from "../util/Table.js";
import { multiColumnSchema } from "@blocknote/xl-multi-column";
function blockPropsToStyles(
props: Partial<DefaultProps>,
colors: typeof COLORS_DEFAULT,
): IParagraphOptions {
return {
shading:
props.backgroundColor === "default" || !props.backgroundColor
? undefined
: {
type: ShadingType.SOLID,
color:
colors[
props.backgroundColor as keyof typeof colors
].background.slice(1),
},
run:
props.textColor === "default" || !props.textColor
? undefined
: {
color: colors[props.textColor as keyof typeof colors].text.slice(1),
},
alignment:
!props.textAlignment || props.textAlignment === "left"
? undefined
: props.textAlignment === "center"
? "center"
: props.textAlignment === "right"
? "right"
: props.textAlignment === "justify"
? "distribute"
: (() => {
throw new UnreachableCaseError(props.textAlignment);
})(),
};
}
export const docxBlockMappingForDefaultSchema: BlockMapping<
DefaultBlockSchema &
typeof pageBreakSchema.blockSchema &
typeof multiColumnSchema.blockSchema,
any,
any,
| Promise<Paragraph[] | Paragraph | DocxTable>
| Paragraph[]
| Paragraph
| DocxTable,
ParagraphChild
> = {
paragraph: (block, exporter) => {
return new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: exporter.transformInlineContent(block.content),
style: "Normal",
run: {
font: "Inter",
},
});
},
toggleListItem: (block, exporter) => {
return new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: [
new TextRun({
children: ["> "],
}),
...exporter.transformInlineContent(block.content),
],
});
},
numberedListItem: (block, exporter, nestingLevel) => {
return new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: exporter.transformInlineContent(block.content),
numbering: {
reference: "blocknote-numbered-list",
level: nestingLevel,
},
});
},
bulletListItem: (block, exporter, nestingLevel) => {
return new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: exporter.transformInlineContent(block.content),
numbering: {
reference: "blocknote-bullet-list",
level: nestingLevel,
},
});
},
checkListItem: (block, exporter) => {
return new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: [
new CheckBox({ checked: block.props.checked }),
new TextRun({
children: [" "],
}),
...exporter.transformInlineContent(block.content),
],
});
},
heading: (block, exporter) => {
return new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: exporter.transformInlineContent(block.content),
heading: `Heading${block.props.level}`,
});
},
quote: (block, exporter) => {
return new Paragraph({
shading: {
color: "#7D797A",
},
border: {
left: {
color: "#7D797A",
space: 100,
style: "single",
size: 8,
},
},
...blockPropsToStyles(block.props, exporter.options.colors),
children: exporter.transformInlineContent(block.content),
});
},
audio: (block, exporter) => {
return [
file(block.props, "Open audio", exporter),
...caption(block.props, exporter),
];
},
video: (block, exporter) => {
return [
file(block.props, "Open video", exporter),
...caption(block.props, exporter),
];
},
file: (block, exporter) => {
return [
file(block.props, "Open file", exporter),
...caption(block.props, exporter),
];
},
codeBlock: (block) => {
const textContent = (block.content as StyledText<any>[])[0]?.text || "";
return new Paragraph({
style: "Codeblock",
shading: {
type: ShadingType.SOLID,
fill: "161616",
color: "161616",
},
children: [
...textContent.split("\n").map((line, index) => {
return new TextRun({
text: line,
break: index > 0 ? 1 : 0,
});
}),
],
});
},
pageBreak: () => {
return new Paragraph({
children: [new PageBreak()],
});
},
column: (block, _exporter, _nestingLevel, _numberedListIndex, children) => {
return new TableCell({
width: {
size: `${block.props.width * 100}%`,
type: "pct",
},
children: (children || []).flatMap((child) => {
if (Array.isArray(child)) {
return child;
}
return [child];
}),
}) as any;
},
columnList: (
_block,
_exporter,
_nestingLevel,
_numberedListIndex,
children,
) => {
return new DocxTable({
layout: "autofit",
borders: {
bottom: { style: "nil" },
top: { style: "nil" },
left: { style: "nil" },
right: { style: "nil" },
insideHorizontal: { style: "nil" },
insideVertical: { style: "nil" },
},
rows: [
new TableRow({
children: (children as unknown as TableCell[]).map(
(cell, _index, children) => {
return new TableCell({
width: {
size: `${(parseFloat(`${cell.options.width?.size || "100%"}`) / (children.length * 100)) * 100}%`,
type: "pct",
},
children: cell.options.children,
});
},
),
}),
],
});
},
image: async (block, exporter) => {
const blob = await exporter.resolveFile(block.props.url);
const { width, height } = await getImageDimensions(blob);
return [
new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: [
new ImageRun({
data: await blob.arrayBuffer(),
// it would be nicer to set the actual data type here, but then we'd need to use a mime type / image type
// detector. atm passing gif does not seem to be causing issues as the "type" is mainly used by docxjs internally
// (i.e.: to make sure it's not svg)
type: "gif",
altText: block.props.caption
? {
description: block.props.caption,
name: block.props.caption,
title: block.props.caption,
}
: undefined,
transformation: {
width: block.props.previewWidth || width,
height: ((block.props.previewWidth || width) / width) * height,
},
}),
],
}),
...caption(block.props, exporter),
];
},
table: (block, exporter) => {
return Table(block.content, exporter);
},
};
function file(
props: Partial<DefaultProps & { name: string; url: string }>,
defaultText: string,
exporter: any,
) {
return new Paragraph({
...blockPropsToStyles(props, exporter.options.colors),
children: [
new ExternalHyperlink({
children: [
new TextRun({
text: props.name || defaultText,
style: "Hyperlink",
}),
],
link: props.url!,
}),
],
});
}
function caption(
props: Partial<DefaultProps & { caption: string }>,
exporter: any,
) {
if (!props.caption) {
return [];
}
return [
new Paragraph({
...blockPropsToStyles(props, exporter.options.colors),
children: [
new TextRun({
text: props.caption,
}),
],
style: "Caption",
}),
];
}