prosemirror-docx
Version:
Export from a prosemirror document to Microsoft word
719 lines • 29.5 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import { Paragraph, TextRun, ExternalHyperlink, MathRun, Math, TabStopType, TabStopPosition, SequentialIdentifier, Bookmark, ImageRun, AlignmentType, Table, TableRow, TableCell, InternalHyperlink, SimpleField, FootnoteReferenceRun, } from 'docx';
import { imageDimensionsFromData } from 'image-dimensions';
import { createNumbering } from './numbering';
import { buildDoc, createShortId } from './utils';
export const MAX_IMAGE_WIDTH = 600;
function createReferenceBookmark(id, kind, before, after) {
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 {
constructor(nodes, marks, options) {
this.currentSectionIndex = 0;
this.footnotes = {};
this.current = [];
// not sure what this actually is, seems to be close for 8.5x11
this.maxImageWidth = MAX_IMAGE_WIDTH;
this.$footnoteCounter = 0;
this.nodes = nodes;
this.marks = marks;
this.options = options !== null && options !== void 0 ? 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, opts) {
parent.forEach((node, _, i) => {
if (opts)
this.addParagraphOptions(opts);
this.render(node, parent, i);
});
}
render(node, parent, index) {
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, marks) {
return marks
.map((mark) => {
var _a, _b;
return (_b = (_a = this.marks)[mark.type.name]) === null || _b === void 0 ? void 0 : _b.call(_a, this, node, mark);
})
.reduce((a, b) => (Object.assign(Object.assign({}, a), b)), {});
}
renderInline(parent) {
// Pop the stack over to this object when we encounter a link, and closeLink restores it
let currentLink;
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) => {
const sameLink = href === (currentLink === null || currentLink === void 0 ? void 0 : 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, offset, index) => {
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, style) {
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) {
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) {
this.nextParentParagraphOpts = Object.assign(Object.assign({}, this.nextParentParagraphOpts), opts);
}
addRunOptions(opts) {
this.nextRunOpts = Object.assign(Object.assign({}, this.nextRunOpts), opts);
}
text(text, opts) {
if (!text)
return;
this.current.push(new TextRun(Object.assign(Object.assign({ text }, this.nextRunOpts), opts)));
delete this.nextRunOpts;
}
math(latex, opts = { inline: true }) {
var _a;
if (opts.inline || !opts.numbered) {
this.current.push(new Math({ children: [new MathRun(latex)] }));
return;
}
const id = (_a = opts.id) !== null && _a !== void 0 ? _a : 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,
},
],
});
}
image(src, widthPercent = 70, align = 'center', imageRunOpts, 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();
}
catch (e) {
it = 'png';
}
this.current.push(new ImageRun(Object.assign(Object.assign({ data: buffer }, imageRunOpts), { type: it, transformation: Object.assign(Object.assign({}, ((imageRunOpts === null || imageRunOpts === void 0 ? void 0 : imageRunOpts.transformation) || {})), { width, height: width * aspect }) })));
let alignment;
switch (align) {
case 'right':
alignment = AlignmentType.RIGHT;
break;
case 'left':
alignment = AlignmentType.LEFT;
break;
default:
alignment = AlignmentType.CENTER;
}
this.addParagraphOptions({
alignment: alignment,
});
}
table(node, opts = {}) {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows = [];
node.content.forEach((row) => {
const cells = [];
// Check if all cells are headers in this row
let tableHeader = true;
row.content.forEach((cell) => {
if (cell.type.name !== 'table_header') {
tableHeader = false;
}
});
// This scales images inside of tables
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
row.content.forEach((cell) => {
var _a, _b;
this.children = [];
this.renderContent(cell);
const tableCellOpts = { children: this.children };
const colspan = (_a = cell.attrs.colspan) !== null && _a !== void 0 ? _a : 1;
const rowspan = (_b = cell.attrs.rowspan) !== null && _b !== void 0 ? _b : 1;
if (colspan > 1)
tableCellOpts.columnSpan = colspan;
if (rowspan > 1)
tableCellOpts.rowSpan = rowspan;
cells.push(new TableCell(Object.assign(Object.assign({}, tableCellOpts), ((getCellOptions === null || getCellOptions === void 0 ? void 0 : getCellOptions(cell)) || {}))));
});
rows.push(new TableRow(Object.assign(Object.assign({}, ((getRowOptions === null || getRowOptions === void 0 ? void 0 : getRowOptions(row)) || {})), { children: cells, tableHeader })));
});
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table(Object.assign(Object.assign({}, tableOptions), { rows }));
actualChildren.push(table);
// If there are multiple tables, this seperates them
actualChildren.push(new Paragraph(''));
this.children = actualChildren;
}
captionLabel(id, kind, { suffix } = { suffix: ': ' }) {
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
}
footnote(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, props) {
const paragraph = new Paragraph(Object.assign(Object.assign({ 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) {
this.sections[this.currentSectionIndex].config = Object.assign(Object.assign({}, this.sections[this.currentSectionIndex].config), config);
}
/**
* Add a new section with the given configuration and switch to it
*/
addSection(config = {}) {
this.sections.push({
config,
children: [],
});
this.currentSectionIndex = this.sections.length - 1;
this.children = this.sections[this.currentSectionIndex].children;
}
/**
* Get the current section index
*/
getCurrentSectionIndex() {
return this.currentSectionIndex;
}
/**
* Get the current section configuration
*/
getCurrentSectionConfig() {
return this.sections[this.currentSectionIndex].config;
}
/**
* Get the current serialization state for document creation
*/
getSerializationState() {
return {
numbering: this.numbering,
sections: this.sections,
footnotes: this.footnotes,
};
}
createReference(id, before, after) {
const children = [];
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 {
constructor(nodes, marks) {
this.nodes = nodes;
this.marks = marks;
}
serialize(content, options, getDocumentOptions) {
const state = new DocxSerializerState(this.nodes, this.marks, options);
state.renderContent(content);
return buildDoc(state, getDocumentOptions === null || getDocumentOptions === void 0 ? void 0 : getDocumentOptions(state));
}
}
export class DocxSerializerStateAsync {
constructor(nodes, marks, options) {
this.currentSectionIndex = 0;
this.footnotes = {};
this.current = [];
// not sure what this actually is, seems to be close for 8.5x11
this.maxImageWidth = MAX_IMAGE_WIDTH;
this.$footnoteCounter = 0;
this.nodes = nodes;
this.marks = marks;
this.options = options !== null && options !== void 0 ? 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, opts) {
return __awaiter(this, void 0, void 0, function* () {
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
yield this.render(node, parent, i);
}
});
}
render(node, parent, index) {
return __awaiter(this, void 0, void 0, function* () {
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`);
yield Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
});
}
renderMarks(node, marks) {
return marks
.map((mark) => {
var _a, _b;
return (_b = (_a = this.marks)[mark.type.name]) === null || _b === void 0 ? void 0 : _b.call(_a, this, node, mark);
})
.reduce((a, b) => (Object.assign(Object.assign({}, a), b)), {});
}
renderInline(parent) {
return __awaiter(this, void 0, void 0, function* () {
// Pop the stack over to this object when we encounter a link, and closeLink restores it
let currentLink;
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) => {
const sameLink = href === (currentLink === null || currentLink === void 0 ? void 0 : 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, offset, index) => __awaiter(this, void 0, void 0, function* () {
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 {
yield 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
yield progress(parent.child(i), 0, i);
}
// Must call close at the end of everything, just in case
closeLink();
});
}
renderList(node, style) {
return __awaiter(this, void 0, void 0, function* () {
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 };
}
yield 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) {
return __awaiter(this, void 0, void 0, function* () {
if (!this.currentNumbering)
throw new Error('Trying to create a list item without a list?');
this.addParagraphOptions({ numbering: this.currentNumbering });
yield this.renderContent(node);
});
}
addParagraphOptions(opts) {
this.nextParentParagraphOpts = Object.assign(Object.assign({}, this.nextParentParagraphOpts), opts);
}
addRunOptions(opts) {
this.nextRunOpts = Object.assign(Object.assign({}, this.nextRunOpts), opts);
}
text(text, opts) {
if (!text)
return;
this.current.push(new TextRun(Object.assign(Object.assign({ text }, this.nextRunOpts), opts)));
delete this.nextRunOpts;
}
math(latex, opts = { inline: true }) {
var _a;
if (opts.inline || !opts.numbered) {
this.current.push(new Math({ children: [new MathRun(latex)] }));
return;
}
const id = (_a = opts.id) !== null && _a !== void 0 ? _a : 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,
},
],
});
}
image(src, widthPercent = 70, align = 'center', imageRunOpts, imageType) {
return __awaiter(this, void 0, void 0, function* () {
const buffer = yield 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();
}
catch (e) {
it = 'png';
}
this.current.push(new ImageRun(Object.assign(Object.assign({ data: buffer }, imageRunOpts), { type: it, transformation: Object.assign(Object.assign({}, ((imageRunOpts === null || imageRunOpts === void 0 ? void 0 : imageRunOpts.transformation) || {})), { width, height: width * aspect }) })));
let alignment;
switch (align) {
case 'right':
alignment = AlignmentType.RIGHT;
break;
case 'left':
alignment = AlignmentType.LEFT;
break;
default:
alignment = AlignmentType.CENTER;
}
this.addParagraphOptions({
alignment: alignment,
});
});
}
table(node, opts = {}) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows = [];
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
const row = node.content.child(rowIndex);
const cells = [];
// 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 !== 'table_header') {
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
yield this.renderContent(cell); // Ensure order
const tableCellOpts = { children: this.children };
const colspan = (_a = cell.attrs.colspan) !== null && _a !== void 0 ? _a : 1;
const rowspan = (_b = cell.attrs.rowspan) !== null && _b !== void 0 ? _b : 1;
if (colspan > 1)
tableCellOpts.columnSpan = colspan;
if (rowspan > 1)
tableCellOpts.rowSpan = rowspan;
cells.push(new TableCell(Object.assign(Object.assign({}, tableCellOpts), ((getCellOptions === null || getCellOptions === void 0 ? void 0 : getCellOptions(cell)) || {}))));
}
rows.push(new TableRow(Object.assign(Object.assign({}, ((getRowOptions === null || getRowOptions === void 0 ? void 0 : getRowOptions(row)) || {})), { children: cells, tableHeader })));
}
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table(Object.assign(Object.assign({}, tableOptions), { rows }));
actualChildren.push(table);
// If there are multiple tables, this separates them
actualChildren.push(new Paragraph(''));
this.children = actualChildren;
});
}
captionLabel(id, kind, { suffix } = { suffix: ': ' }) {
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
}
footnote(node) {
return __awaiter(this, void 0, void 0, function* () {
const { current, nextRunOpts } = this;
// Delete everything and work with the footnote inline on the current
this.current = [];
delete this.nextRunOpts;
this.$footnoteCounter += 1;
yield 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, props) {
const paragraph = new Paragraph(Object.assign(Object.assign({ 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) {
this.sections[this.currentSectionIndex].config = Object.assign(Object.assign({}, this.sections[this.currentSectionIndex].config), config);
}
/**
* Add a new section with the given configuration and switch to it
*/
addSection(config = {}) {
this.sections.push({
config,
children: [],
});
this.currentSectionIndex = this.sections.length - 1;
this.children = this.sections[this.currentSectionIndex].children;
}
/**
* Get the current section index
*/
getCurrentSectionIndex() {
return this.currentSectionIndex;
}
/**
* Get the current section configuration
*/
getCurrentSectionConfig() {
return this.sections[this.currentSectionIndex].config;
}
/**
* Get the current serialization state for document creation
*/
getSerializationState() {
return {
numbering: this.numbering,
sections: this.sections,
footnotes: this.footnotes,
};
}
createReference(id, before, after) {
const children = [];
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 {
constructor(nodes, marks) {
this.nodes = nodes;
this.marks = marks;
}
serializeAsync(content, options, getDocumentOptions) {
return __awaiter(this, void 0, void 0, function* () {
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
yield state.renderContent(content);
return buildDoc(state, getDocumentOptions === null || getDocumentOptions === void 0 ? void 0 : getDocumentOptions(state));
});
}
}
//# sourceMappingURL=serializer.js.map