mirror of
https://github.com/docmost/docmost.git
synced 2026-07-24 14:22:53 +10:00
Merge branch 'main' into feat/bases
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
"name": "@docmost/editor-ext",
|
||||
"homepage": "https://docmost.com",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch"
|
||||
|
||||
@@ -33,4 +33,8 @@ export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/page-break";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
export {
|
||||
pageNodeToDocxBuffer,
|
||||
type DocxImageResolver,
|
||||
} from "./lib/prosemirror-docx";
|
||||
export * from "./lib/base-embed";
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# `prosemirror-docx`
|
||||
|
||||
[](https://www.npmjs.com/package/prosemirror-docx)
|
||||
[](https://github.com/curvenote/prosemirror-docx)
|
||||
[
|
||||
[](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
|
||||

|
||||
|
||||
Export a [prosemirror](https://prosemirror.net/) document to a Microsoft Word file, using [docx](https://docx.js.org/).
|
||||
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
`prosemirror-docx` has a similar structure to [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown), with a `DocxSerializerState` object that you write to as you walk the document. It is a light wrapper around <https://docx.js.org/>, which actually does the export. Currently `prosemirror-docx` is write only (i.e. can export to, but can’t read from `*.docx`), and has most of the basic nodes covered (see below).
|
||||
|
||||
[Curvenote](https://curvenote.com) uses this to export from [@curvenote/editor](https://github.com/curvenote/editor) to word docs, but this library currently only has dependence on `docx`, `prosemirror-model` and `image-dimensions` - and similar to `prosemirror-markdown`, the serialization schema can be edited externally (see `Extended usage` below).
|
||||
|
||||
## Basic usage
|
||||
|
||||
```ts
|
||||
import { defaultDocxSerializer, writeDocx } from 'prosemirror-docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { writeFileSync } from 'fs'; // Or some other way to write a file
|
||||
|
||||
// Set up your prosemirror state/document as you normally do
|
||||
const state = EditorState.create({ schema: mySchema });
|
||||
|
||||
// If there are images, we will need to preload the buffers
|
||||
const opts = {
|
||||
getImageBuffer(src: string) {
|
||||
return anImageBuffer;
|
||||
},
|
||||
};
|
||||
|
||||
// Create a doc in memory, and then write it to disk
|
||||
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts);
|
||||
|
||||
await writeDocx(wordDocument).then((buffer) => {
|
||||
writeFileSync('HelloWorld.docx', buffer);
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced usage
|
||||
|
||||
If you need to access the underlying state and modify the final docx `Document` you can use the last argument of `serialize` to pass in a callback function that receives the `DocxSerializerState`.
|
||||
|
||||
This function needs to return an `IPropertiesOptions` type, ie. the config that should be passed to a `Document`. Your options will be spread with the default options, so you can override any of the defaults.
|
||||
|
||||
```ts
|
||||
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts, (state) => {
|
||||
return {
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
fonts: [], // embed fonts,
|
||||
styles: {
|
||||
paragraphStyles,
|
||||
default: {
|
||||
heading1: paragraphStyles[1],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
See the [docx documentation](https://docx.js.org/#/usage/document) for more details on the options you can pass in.
|
||||
|
||||
## Extended usage
|
||||
|
||||
Instead of using the `defaultDocxSerializer` you can override or provide custom serializers.
|
||||
|
||||
```ts
|
||||
import { DocxSerializer, defaultNodes, defaultMarks } from 'prosemirror-docx';
|
||||
|
||||
const nodeSerializer = {
|
||||
...defaultNodes,
|
||||
my_paragraph(state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
};
|
||||
|
||||
export const myDocxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
|
||||
```
|
||||
|
||||
The `state` is the `DocxSerializerState` and has helper methods to interact with `docx`.
|
||||
|
||||
If the exported content includes image links that require fetching the image data, you can use asynchronous APIs. Here's a demo example:
|
||||
|
||||
```ts
|
||||
import { DocxSerializerAsync, defaultAsyncNodes, defaultMarks } from 'prosemirror-docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
const state = EditorState.create({ schema: mySchema });
|
||||
|
||||
export const docxSerializer = new DocxSerializerAsync(
|
||||
{
|
||||
...defaultAsyncNodes,
|
||||
async image(state, node) {
|
||||
const { src } = node.attrs;
|
||||
await state.image(src, 70, 'center', undefined, 'png');
|
||||
state.closeBlock(node);
|
||||
},
|
||||
},
|
||||
defaultMarks,
|
||||
);
|
||||
|
||||
// If there are images, we will need to preload the buffers
|
||||
const opts = {
|
||||
async getImageBuffer(src: string) {
|
||||
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
|
||||
return new Uint8Array(arrayBuffer);
|
||||
},
|
||||
};
|
||||
|
||||
// Create a doc in memory, and then write it to disk
|
||||
const wordDocument = docxSerializer.serializeAsync(state.doc, opts);
|
||||
|
||||
await writeDocx(wordDocument).then((buffer) => {
|
||||
writeFileSync('HelloWorld.docx', buffer);
|
||||
});
|
||||
```
|
||||
|
||||
## Supported Nodes
|
||||
|
||||
- text
|
||||
- paragraph
|
||||
- heading (levels)
|
||||
- TODO: Support numbering of headings
|
||||
- blockquote
|
||||
- code_block
|
||||
- TODO: No styles supported
|
||||
- horizontal_rule
|
||||
- hard_break
|
||||
- ordered_list
|
||||
- unordered_list
|
||||
- list_item
|
||||
- image
|
||||
- math
|
||||
- equations (numbered & unnumbered)
|
||||
- tables
|
||||
|
||||
Planned:
|
||||
|
||||
- Internal References (e.g. see Table 1)
|
||||
|
||||
## Supported Marks
|
||||
|
||||
- em
|
||||
- strong
|
||||
- link
|
||||
- Note: this is actually treated as a node in docx, so ignored as a prosemirror mark, but supported.
|
||||
- code
|
||||
- subscript
|
||||
- superscript
|
||||
- strikethrough
|
||||
- underline
|
||||
- smallcaps
|
||||
- allcaps
|
||||
|
||||
## Resources
|
||||
|
||||
- [Prosemirror Docs](https://prosemirror.net/docs/)
|
||||
- [docx](https://docx.js.org/)
|
||||
- [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown) - similar implementation for markdown!
|
||||
@@ -0,0 +1,24 @@
|
||||
// MIT - https://github.com/curvenote/prosemirror-docx/
|
||||
export type { SectionConfig, SerializationState } from './types';
|
||||
export type {
|
||||
MarkSerializer,
|
||||
NodeSerializer,
|
||||
NodeSerializerAsync,
|
||||
Options,
|
||||
OptionsAsync,
|
||||
} from './serializer';
|
||||
|
||||
export {
|
||||
DocxSerializerStateAsync,
|
||||
DocxSerializerAsync,
|
||||
DocxSerializerState,
|
||||
DocxSerializer,
|
||||
MAX_IMAGE_WIDTH,
|
||||
} from './serializer';
|
||||
export {
|
||||
defaultAsyncNodes,
|
||||
defaultMarks,
|
||||
pageNodeToDocxBuffer,
|
||||
type DocxImageResolver,
|
||||
} from './schema';
|
||||
export { writeDocx, createDocFromState, buildDoc } from './utils';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
|
||||
import { INumbering } from './types';
|
||||
|
||||
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
|
||||
return {
|
||||
alignment: AlignmentType.START,
|
||||
style: {
|
||||
paragraph: {
|
||||
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const numbered = Array(3)
|
||||
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
|
||||
.flat()
|
||||
.map((format, level) => ({
|
||||
level,
|
||||
format,
|
||||
text: `%${level + 1}.`,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const bullets = Array(3)
|
||||
.fill(['●', '○', '■'])
|
||||
.flat()
|
||||
.map((text, level) => ({
|
||||
level,
|
||||
format: LevelFormat.BULLET,
|
||||
text,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const styles = {
|
||||
numbered,
|
||||
bullets,
|
||||
};
|
||||
|
||||
export type NumberingStyles = keyof typeof styles;
|
||||
|
||||
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
|
||||
return {
|
||||
reference,
|
||||
levels: styles[style],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { HeadingLevel, ShadingType } from 'docx';
|
||||
import { Node } from 'prosemirror-model';
|
||||
import {
|
||||
DocxSerializerAsync,
|
||||
MarkSerializer,
|
||||
NodeSerializerAsync,
|
||||
OptionsAsync,
|
||||
} from './serializer';
|
||||
import { writeDocx } from './utils';
|
||||
|
||||
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
|
||||
|
||||
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
|
||||
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
|
||||
// (named colors, hsl, etc.) so the caller omits the color rather than letting
|
||||
// docx throw "Invalid hex value".
|
||||
function toDocxColor(input?: string): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const value = input.trim().toLowerCase();
|
||||
const hex = value.startsWith('#') ? value.slice(1) : value;
|
||||
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
|
||||
if (/^[0-9a-f]{3}$/.test(hex)) {
|
||||
return hex
|
||||
.split('')
|
||||
.map((ch) => ch + ch)
|
||||
.join('');
|
||||
}
|
||||
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (rgb) {
|
||||
const channel = (n: string) =>
|
||||
Math.max(0, Math.min(255, parseInt(n, 10)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Images and diagrams embed via the image resolver; the URL (with its file
|
||||
// extension) is passed through so docx can infer the image type.
|
||||
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
|
||||
const src = node.attrs?.src || node.attrs?.attachmentId;
|
||||
if (src) {
|
||||
try {
|
||||
await state.image(src, 100);
|
||||
} catch {
|
||||
// Unrenderable/missing image: skip rather than fail the whole export.
|
||||
}
|
||||
}
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
// Non-embeddable media render as a labelled line.
|
||||
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
|
||||
const label =
|
||||
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
|
||||
state.text(label);
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
|
||||
const label = node.attrs?.src || node.attrs?.url || 'embed';
|
||||
state.text(label);
|
||||
state.closeBlock(node);
|
||||
};
|
||||
|
||||
export const defaultAsyncNodes: NodeSerializerAsync = {
|
||||
text(state, node) {
|
||||
state.text(node.text ?? '');
|
||||
},
|
||||
async paragraph(state, node) {
|
||||
await state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
async heading(state, node) {
|
||||
await state.renderInline(node);
|
||||
const heading = [
|
||||
HeadingLevel.HEADING_1,
|
||||
HeadingLevel.HEADING_2,
|
||||
HeadingLevel.HEADING_3,
|
||||
HeadingLevel.HEADING_4,
|
||||
HeadingLevel.HEADING_5,
|
||||
HeadingLevel.HEADING_6,
|
||||
][(node.attrs.level ?? 1) - 1];
|
||||
state.closeBlock(node, { heading });
|
||||
},
|
||||
async blockquote(state, node) {
|
||||
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
async codeBlock(state, node) {
|
||||
await state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
horizontalRule(state, node) {
|
||||
state.closeBlock(node, { thematicBreak: true });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
hardBreak(state) {
|
||||
state.addRunOptions({ break: 1 });
|
||||
},
|
||||
async bulletList(state, node) {
|
||||
await state.renderList(node, 'bullets');
|
||||
},
|
||||
async orderedList(state, node) {
|
||||
await state.renderList(node, 'numbered');
|
||||
},
|
||||
async listItem(state, node) {
|
||||
await state.renderListItem(node);
|
||||
},
|
||||
async taskList(state, node) {
|
||||
await state.renderList(node, 'bullets');
|
||||
},
|
||||
async taskItem(state, node) {
|
||||
if (state.currentNumbering) {
|
||||
state.addParagraphOptions({ numbering: state.currentNumbering });
|
||||
}
|
||||
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async table(state, node) {
|
||||
await state.table(node);
|
||||
},
|
||||
// Docmost stores LaTeX in attrs.text.
|
||||
mathInline(state, node) {
|
||||
state.math(node.attrs?.text ?? '', { inline: true });
|
||||
},
|
||||
mathBlock(state, node) {
|
||||
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
image: renderImage,
|
||||
drawio: renderImage,
|
||||
excalidraw: renderImage,
|
||||
video: renderFileLine,
|
||||
audio: renderFileLine,
|
||||
pdf: renderFileLine,
|
||||
attachment: renderFileLine,
|
||||
embed: renderEmbedLine,
|
||||
youtube: renderEmbedLine,
|
||||
async callout(state, node) {
|
||||
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
async details(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async detailsSummary(state, node) {
|
||||
await state.renderInline(node);
|
||||
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
|
||||
},
|
||||
async detailsContent(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async columns(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async column(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
async transclusionSource(state, node) {
|
||||
await state.renderContent(node);
|
||||
},
|
||||
mention(state, node) {
|
||||
state.text(`@${node.attrs?.label ?? ''}`);
|
||||
},
|
||||
status(state, node) {
|
||||
state.text(`[${node.attrs?.text ?? ''}]`);
|
||||
},
|
||||
pageBreak(state, node) {
|
||||
state.closeBlock(node, { pageBreakBefore: true });
|
||||
},
|
||||
// No usable static export representation: skip without failing.
|
||||
subpages() {},
|
||||
transclusionReference() {},
|
||||
};
|
||||
|
||||
export const defaultMarks: MarkSerializer = {
|
||||
bold() {
|
||||
return { bold: true };
|
||||
},
|
||||
italic() {
|
||||
return { italics: true };
|
||||
},
|
||||
strike() {
|
||||
return { strike: true };
|
||||
},
|
||||
underline() {
|
||||
return { underline: {} };
|
||||
},
|
||||
code() {
|
||||
return {
|
||||
font: { name: 'Monospace' },
|
||||
color: '000000',
|
||||
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
|
||||
};
|
||||
},
|
||||
superscript() {
|
||||
return { superScript: true };
|
||||
},
|
||||
subscript() {
|
||||
return { subScript: true };
|
||||
},
|
||||
link() {
|
||||
// Handled specifically in the serializer; Word treats links as nodes.
|
||||
return {};
|
||||
},
|
||||
highlight(_state, _node, mark) {
|
||||
const fill = toDocxColor(mark.attrs?.color);
|
||||
return fill
|
||||
? { shading: { type: ShadingType.CLEAR, fill } }
|
||||
: { highlight: 'yellow' };
|
||||
},
|
||||
// @tiptap/extension-color stores the color on the textStyle mark.
|
||||
textStyle(_state, _node, mark) {
|
||||
const color = toDocxColor(mark.attrs?.color);
|
||||
return color ? { color } : {};
|
||||
},
|
||||
// Comments are editor-only; drop the annotation in the export.
|
||||
comment() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
export async function pageNodeToDocxBuffer(
|
||||
doc: Node,
|
||||
getImageBuffer: DocxImageResolver,
|
||||
): Promise<Buffer> {
|
||||
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
|
||||
const wordDoc = await serializer.serializeAsync(
|
||||
doc,
|
||||
{ getImageBuffer },
|
||||
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
|
||||
// has no heading color, so override the default heading run colors to the
|
||||
// normal text color. Sizes/italics mirror docx's own defaults so only the
|
||||
// color changes.
|
||||
() =>
|
||||
({
|
||||
styles: {
|
||||
default: {
|
||||
heading1: { run: { color: '000000', size: 32 } },
|
||||
heading2: { run: { color: '000000', size: 26 } },
|
||||
heading3: { run: { color: '000000', size: 24 } },
|
||||
heading4: { run: { color: '000000', italics: true } },
|
||||
heading5: { run: { color: '000000' } },
|
||||
heading6: { run: { color: '000000' } },
|
||||
},
|
||||
},
|
||||
}) as any,
|
||||
);
|
||||
return writeDocx(wordDoc);
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
import { Node, Mark } from 'prosemirror-model';
|
||||
import {
|
||||
IParagraphOptions,
|
||||
IRunOptions,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
ExternalHyperlink,
|
||||
ParagraphChild,
|
||||
MathRun,
|
||||
Math,
|
||||
TabStopType,
|
||||
TabStopPosition,
|
||||
SequentialIdentifier,
|
||||
Bookmark,
|
||||
ImageRun,
|
||||
AlignmentType,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
ITableCellOptions,
|
||||
InternalHyperlink,
|
||||
SimpleField,
|
||||
FootnoteReferenceRun,
|
||||
IImageOptions,
|
||||
Document,
|
||||
ITableOptions,
|
||||
ITableRowOptions,
|
||||
IPropertiesOptions,
|
||||
} from 'docx';
|
||||
import { imageDimensionsFromData } from 'image-dimensions';
|
||||
import { createNumbering, NumberingStyles } from './numbering';
|
||||
import { buildDoc, createShortId } from './utils';
|
||||
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
|
||||
|
||||
// This is duplicated from @curvenote/schema
|
||||
export type AlignOptions = 'left' | 'center' | 'right';
|
||||
|
||||
export type NodeSerializer = Record<
|
||||
string,
|
||||
(state: DocxSerializerState, node: Node, parent: Node, index: number) => void
|
||||
>;
|
||||
|
||||
export type NodeSerializerAsync = Record<
|
||||
string,
|
||||
(state: DocxSerializerStateAsync, node: Node, parent: Node, index: number) => void | Promise<void>
|
||||
>;
|
||||
|
||||
export type MarkSerializer = Record<
|
||||
string,
|
||||
(state: DocxSerializerState | DocxSerializerStateAsync, node: Node, mark: Mark) => IRunOptions
|
||||
>;
|
||||
|
||||
export type Options = {
|
||||
getImageBuffer: (src: string) => Uint8Array;
|
||||
sections?: SectionConfig[];
|
||||
};
|
||||
|
||||
export type OptionsAsync = {
|
||||
getImageBuffer: (src: string) => Uint8Array | Promise<Uint8Array>;
|
||||
sections?: SectionConfig[];
|
||||
};
|
||||
|
||||
export type IMathOpts = {
|
||||
inline?: boolean;
|
||||
id?: string | null;
|
||||
numbered?: boolean;
|
||||
};
|
||||
export type ImageType = 'jpg' | 'png' | 'gif' | 'bmp';
|
||||
|
||||
export const MAX_IMAGE_WIDTH = 600;
|
||||
|
||||
function createReferenceBookmark(
|
||||
id: string,
|
||||
kind: 'Equation' | 'Figure' | 'Table',
|
||||
before?: string,
|
||||
after?: string,
|
||||
) {
|
||||
const textBefore = before ? [new TextRun(before)] : [];
|
||||
const textAfter = after ? [new TextRun(after)] : [];
|
||||
return new Bookmark({
|
||||
id,
|
||||
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
|
||||
});
|
||||
}
|
||||
|
||||
export class DocxSerializerState {
|
||||
nodes: NodeSerializer;
|
||||
|
||||
options: Options;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
sections: Array<{
|
||||
config: SectionConfig;
|
||||
children: (Paragraph | Table)[];
|
||||
}>;
|
||||
|
||||
currentSectionIndex = 0;
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.options = options ?? ({} as Options);
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
|
||||
// Initialize sections
|
||||
if (options.sections && options.sections.length > 0) {
|
||||
this.sections = options.sections.map((config) => ({
|
||||
config,
|
||||
children: [],
|
||||
}));
|
||||
this.children = this.sections[0].children;
|
||||
} else {
|
||||
this.sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||
parent.forEach((node, _, i) => {
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
this.render(node, parent, i);
|
||||
});
|
||||
}
|
||||
|
||||
render(node: Node, parent: Node, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name])
|
||||
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
this.nodes[node.type.name](this, node, parent, index);
|
||||
}
|
||||
|
||||
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
renderInline(parent: Node) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = (node: Node, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||
} else {
|
||||
this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
parent.forEach(progress);
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
renderList(node: Node, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
renderListItem(node: Node) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
image(
|
||||
src: string,
|
||||
widthPercent = 70,
|
||||
align: AlignOptions = 'center',
|
||||
imageRunOpts?: IImageOptions,
|
||||
imageType?: ImageType,
|
||||
) {
|
||||
const buffer = this.options.getImageBuffer(src);
|
||||
const dimensions = imageDimensionsFromData(buffer);
|
||||
/* If the image is not a valid image, don't add it */
|
||||
if (!dimensions) return;
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
let it;
|
||||
try {
|
||||
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||
} catch (e) {
|
||||
it = 'png';
|
||||
}
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
...imageRunOpts,
|
||||
type: it,
|
||||
transformation: {
|
||||
...(imageRunOpts?.transformation || {}),
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let alignment: string;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment: alignment as any,
|
||||
});
|
||||
}
|
||||
|
||||
table(
|
||||
node: Node,
|
||||
opts: {
|
||||
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||
} = {},
|
||||
) {
|
||||
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
node.content.forEach((row) => {
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
row.content.forEach((cell) => {
|
||||
if (cell.type.name !== 'tableHeader') {
|
||||
tableHeader = false;
|
||||
}
|
||||
});
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||
row.content.forEach((cell) => {
|
||||
this.children = [];
|
||||
this.renderContent(cell);
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
...tableCellOpts,
|
||||
...(getCellOptions?.(cell) || {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||
});
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
const table = new Table({ ...tableOptions, rows });
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this seperates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
footnote(node: Node) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next section. If no more sections are available,
|
||||
* this will be ignored (content continues in current section).
|
||||
*/
|
||||
nextSection() {
|
||||
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||
this.currentSectionIndex += 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current section's configuration
|
||||
*/
|
||||
setSectionConfig(config: Partial<SectionConfig>) {
|
||||
this.sections[this.currentSectionIndex].config = {
|
||||
...this.sections[this.currentSectionIndex].config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new section with the given configuration and switch to it
|
||||
*/
|
||||
addSection(config: SectionConfig = {}) {
|
||||
this.sections.push({
|
||||
config,
|
||||
children: [],
|
||||
});
|
||||
this.currentSectionIndex = this.sections.length - 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section index
|
||||
*/
|
||||
getCurrentSectionIndex(): number {
|
||||
return this.currentSectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section configuration
|
||||
*/
|
||||
getCurrentSectionConfig(): SectionConfig {
|
||||
return this.sections[this.currentSectionIndex].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current serialization state for document creation
|
||||
*/
|
||||
getSerializationState(): SerializationState {
|
||||
return {
|
||||
numbering: this.numbering,
|
||||
sections: this.sections,
|
||||
footnotes: this.footnotes,
|
||||
};
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializer {
|
||||
nodes: NodeSerializer;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
constructor(nodes: NodeSerializer, marks: MarkSerializer) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
serialize(
|
||||
content: Node,
|
||||
options: Options,
|
||||
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||
): Document {
|
||||
const state = new DocxSerializerState(this.nodes, this.marks, options);
|
||||
state.renderContent(content);
|
||||
return buildDoc(state, getDocumentOptions?.(state));
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializerStateAsync {
|
||||
nodes: NodeSerializerAsync;
|
||||
|
||||
options: OptionsAsync;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
sections: Array<{
|
||||
config: SectionConfig;
|
||||
children: (Paragraph | Table)[];
|
||||
}>;
|
||||
|
||||
currentSectionIndex = 0;
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.options = options ?? ({} as OptionsAsync);
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
|
||||
// Initialize sections
|
||||
if (options.sections && options.sections.length > 0) {
|
||||
this.sections = options.sections.map((config) => ({
|
||||
config,
|
||||
children: [],
|
||||
}));
|
||||
this.children = this.sections[0].children;
|
||||
} else {
|
||||
this.sections = [];
|
||||
}
|
||||
}
|
||||
|
||||
async renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||
for (let i = 0; i < parent.childCount; i += 1) {
|
||||
const node = parent.child(i);
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.render(node, parent, i);
|
||||
}
|
||||
}
|
||||
|
||||
async render(node: Node, parent: Node, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name])
|
||||
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
|
||||
}
|
||||
|
||||
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
async renderInline(parent: Node) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = async (node: Node, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||
} else {
|
||||
await this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
// Process nodes sequentially to maintain order
|
||||
for (let i = 0; i < parent.childCount; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await progress(parent.child(i), 0, i);
|
||||
}
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
async renderList(node: Node, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
await this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
async renderListItem(node: Node) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
await this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
async image(
|
||||
src: string,
|
||||
widthPercent = 70,
|
||||
align: AlignOptions = 'center',
|
||||
imageRunOpts?: IImageOptions,
|
||||
imageType?: ImageType,
|
||||
) {
|
||||
const buffer = await Promise.resolve(this.options.getImageBuffer(src));
|
||||
const dimensions = imageDimensionsFromData(buffer);
|
||||
/* If the image is not a valid image, don't add it */
|
||||
if (!dimensions) return;
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
let it;
|
||||
try {
|
||||
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||
} catch (e) {
|
||||
it = 'png';
|
||||
}
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
...imageRunOpts,
|
||||
type: it,
|
||||
transformation: {
|
||||
...(imageRunOpts?.transformation || {}),
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let alignment: string;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment: alignment as any,
|
||||
});
|
||||
}
|
||||
|
||||
async table(
|
||||
node: Node,
|
||||
opts: {
|
||||
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||
} = {},
|
||||
) {
|
||||
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
|
||||
const row = node.content.child(rowIndex);
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
|
||||
// Check if all cells in the row are headers
|
||||
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||
const cell = row.content.child(cellIndex);
|
||||
if (cell.type.name !== 'tableHeader') {
|
||||
tableHeader = false;
|
||||
}
|
||||
}
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||
|
||||
// Iterate through cells and ensure order
|
||||
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||
const cell = row.content.child(cellIndex);
|
||||
this.children = [];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.renderContent(cell); // Ensure order
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(
|
||||
new TableCell({
|
||||
...tableCellOpts,
|
||||
...(getCellOptions?.(cell) || {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||
}
|
||||
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
const table = new Table({ ...tableOptions, rows });
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this separates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
async footnote(node: Node) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
await this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next section. If no more sections are available,
|
||||
* this will be ignored (content continues in current section).
|
||||
*/
|
||||
nextSection() {
|
||||
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||
this.currentSectionIndex += 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current section's configuration
|
||||
*/
|
||||
setSectionConfig(config: Partial<SectionConfig>) {
|
||||
this.sections[this.currentSectionIndex].config = {
|
||||
...this.sections[this.currentSectionIndex].config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new section with the given configuration and switch to it
|
||||
*/
|
||||
addSection(config: SectionConfig = {}) {
|
||||
this.sections.push({
|
||||
config,
|
||||
children: [],
|
||||
});
|
||||
this.currentSectionIndex = this.sections.length - 1;
|
||||
this.children = this.sections[this.currentSectionIndex].children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section index
|
||||
*/
|
||||
getCurrentSectionIndex(): number {
|
||||
return this.currentSectionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current section configuration
|
||||
*/
|
||||
getCurrentSectionConfig(): SectionConfig {
|
||||
return this.sections[this.currentSectionIndex].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current serialization state for document creation
|
||||
*/
|
||||
getSerializationState(): SerializationState {
|
||||
return {
|
||||
numbering: this.numbering,
|
||||
sections: this.sections,
|
||||
footnotes: this.footnotes,
|
||||
};
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializerAsync {
|
||||
nodes: NodeSerializerAsync;
|
||||
|
||||
marks: MarkSerializer;
|
||||
|
||||
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
async serializeAsync(
|
||||
content: Node,
|
||||
options: OptionsAsync,
|
||||
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||
) {
|
||||
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
|
||||
await state.renderContent(content);
|
||||
return buildDoc(state, getDocumentOptions?.(state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { INumberingOptions, Paragraph, ISectionOptions } from 'docx';
|
||||
|
||||
export type Mutable<T> = {
|
||||
-readonly [k in keyof T]: T[k];
|
||||
};
|
||||
|
||||
export type IFootnotes = Mutable<
|
||||
Readonly<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
readonly children: readonly Paragraph[];
|
||||
}
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type INumbering = INumberingOptions['config'][0];
|
||||
|
||||
export interface SectionConfig {
|
||||
properties?: ISectionOptions['properties'];
|
||||
headers?: ISectionOptions['headers'];
|
||||
footers?: ISectionOptions['footers'];
|
||||
}
|
||||
|
||||
export interface SerializationState {
|
||||
numbering: INumberingOptions['config'];
|
||||
sections?: Array<{
|
||||
config: SectionConfig;
|
||||
children: ISectionOptions['children'];
|
||||
}>;
|
||||
children?: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Document,
|
||||
INumberingOptions,
|
||||
IPropertiesOptions,
|
||||
ISectionOptions,
|
||||
Packer,
|
||||
SectionType,
|
||||
} from 'docx';
|
||||
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||
import { IFootnotes, SerializationState } from './types';
|
||||
|
||||
export function createShortId() {
|
||||
return Math.random().toString(36).slice(2, 11);
|
||||
}
|
||||
|
||||
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
|
||||
let sections = state?.sections?.length
|
||||
? state.sections.map((section) => ({
|
||||
properties: section.config.properties || {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
headers: section.config.headers,
|
||||
footers: section.config.footers,
|
||||
children: section.children,
|
||||
}))
|
||||
: undefined;
|
||||
if (!sections) {
|
||||
sections = [
|
||||
{
|
||||
headers: undefined,
|
||||
footers: undefined,
|
||||
properties: {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
children: state?.children || [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
footnotes: state.footnotes,
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
sections,
|
||||
...(opts || {}),
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - use `buildDoc` instead
|
||||
* Creates a docx document from the given state.
|
||||
* */
|
||||
export function createDocFromState(state: {
|
||||
numbering: INumberingOptions['config'];
|
||||
children: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}) {
|
||||
return buildDoc({
|
||||
numbering: state.numbering,
|
||||
sections: [
|
||||
{
|
||||
config: {},
|
||||
children: state.children,
|
||||
},
|
||||
],
|
||||
footnotes: state.footnotes,
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeDocx(
|
||||
doc: Document,
|
||||
/**
|
||||
* @deprecated use `.then()` or `await` instead
|
||||
*/
|
||||
write?: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>),
|
||||
) {
|
||||
const buffer = await Packer.toBuffer(doc);
|
||||
await write?.(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function getLatexFromNode(node: ProsemirrorNode): string {
|
||||
let math = '';
|
||||
node.forEach((child) => {
|
||||
if (child.isText) math += child.text;
|
||||
// TODO: improve this as we may have other things in the future
|
||||
});
|
||||
return math;
|
||||
}
|
||||
Reference in New Issue
Block a user