UNPKG

prosemirror-docx

Version:

Export from a prosemirror document to Microsoft word

719 lines 29.5 kB
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