mirror of
https://github.com/docmost/docmost.git
synced 2026-08-21 05:01:37 +10:00
949 lines
27 KiB
TypeScript
949 lines
27 KiB
TypeScript
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));
|
|
}
|
|
|
|
// Fills the footnote body for an already-referenced footnote number from a
|
|
// node holding block content (Docmost keeps footnote text in a trailing
|
|
// list, separate from the inline reference).
|
|
async footnoteDefinition(node: Node, number: number) {
|
|
const { current, children, nextRunOpts, nextParentParagraphOpts } = this;
|
|
this.current = [];
|
|
this.children = [];
|
|
delete this.nextRunOpts;
|
|
delete this.nextParentParagraphOpts;
|
|
|
|
await this.renderContent(node);
|
|
this.footnotes[number] = {
|
|
children: this.children.filter(
|
|
(child): child is Paragraph => child instanceof Paragraph,
|
|
),
|
|
};
|
|
|
|
this.current = current;
|
|
this.children = children;
|
|
this.nextRunOpts = nextRunOpts;
|
|
this.nextParentParagraphOpts = nextParentParagraphOpts;
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|