UNPKG

@coko/server

Version:

Reusable server for use by Coko's projects

475 lines 18.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const image_size_1 = require("image-size"); const cloneDeep_1 = __importDefault(require("lodash/cloneDeep")); const docx_1 = require("docx"); /** * TO DO * * highlight * transform case * headings * * lists: every third level, reset style. eg. 1,a,i,1,a,i etc * */ // You can further simplify the content field for content nodes like this (optional): // type BlockNode = Exclude<TopLevelNode, TextNode | InlineNode>; // type ProseMirrorContent = BlockNode | ListItemNode | TableRowNode | TableCellNode | InlineNode; class WaxToDocxConverter { baseFontSize; baseMessage = 'WaxToDocxConverter:'; config; doc; fontFamily; imageData; listInstance; listIndentFirstLevelLeft = (0, docx_1.convertMillimetersToTwip)(12.7); listIndentFirstLevelHanging = (0, docx_1.convertMillimetersToTwip)(6.3); listIndentSecondLevelLeft = (0, docx_1.convertMillimetersToTwip)(25.3); listIndentSecondLevelHanging = (0, docx_1.convertMillimetersToTwip)(6.3); listIndentThirdLevelLeft = (0, docx_1.convertMillimetersToTwip)(38.1); listIndentThirdLevelHanging = (0, docx_1.convertMillimetersToTwip)(3.2); listTypes = { ORDERED: 'numbered-list', BULLET: 'bullet-list', }; paragraphSpacingAfter = 200; typeToHandlerMap; constructor(doc, imageData, options = {}) { if (!doc) this.error(`No document provided`); if (doc.type !== 'doc') this.error(`Document provided is not of type "doc"`); if (!doc.content) this.error(`Document provided has no children`); if (!Array.isArray(doc.content)) this.error(`Document content is not an array`); this.doc = doc; this.listInstance = 0; this.typeToHandlerMap = { bulletlist: this.bulletListHandler, figure: this.figureHandler, // Paragraph figcaption: this.captionHandler, hard_break: this.hardBreakHandler, // TextRun image: this.imageHandler, // ImageRun list_item: this.listItemHandler, orderedlist: this.orderedListHandler, paragraph: this.paragraphHandler, // Paragraph table: this.tableHandler, // Table table_cell: this.tableCellHandler, // TableCell table_header: this.tableCellHandler, // TableCell table_row: this.tableRowHandler, // TableRow text: this.textHandler, // TextRun | ExternalHyperlink }; this.imageData = imageData; this.baseFontSize = options.baseFontSize || 24; this.fontFamily = options.fontFamily || 'calibri'; this.config = { styles: { default: { listParagraph: { run: { font: this.fontFamily, size: this.baseFontSize, }, }, }, paragraphStyles: [ { id: 'paragraph-styles', name: 'Normal', run: { font: this.fontFamily, size: this.baseFontSize, }, paragraph: { alignment: docx_1.AlignmentType.JUSTIFIED, spacing: { after: this.paragraphSpacingAfter, }, }, }, ], }, numbering: { config: [ { levels: [ { level: 0, format: docx_1.LevelFormat.DECIMAL, text: '%1.', alignment: docx_1.AlignmentType.START, style: { paragraph: { contextualSpacing: true, indent: { left: this.listIndentFirstLevelLeft, hanging: this.listIndentFirstLevelHanging, }, }, }, }, { level: 1, format: docx_1.LevelFormat.LOWER_LETTER, text: '%2.', alignment: docx_1.AlignmentType.START, style: { paragraph: { contextualSpacing: true, indent: { left: this.listIndentSecondLevelLeft, hanging: this.listIndentSecondLevelHanging, }, }, }, }, { level: 2, format: docx_1.LevelFormat.LOWER_ROMAN, alignment: docx_1.AlignmentType.END, text: '%3.', style: { paragraph: { contextualSpacing: true, indent: { left: this.listIndentThirdLevelLeft, hanging: this.listIndentThirdLevelHanging, }, }, }, }, ], reference: this.listTypes.ORDERED, }, { levels: [ { level: 0, format: docx_1.LevelFormat.BULLET, text: '\u2022', alignment: docx_1.AlignmentType.START, style: { paragraph: { contextualSpacing: true, indent: { left: this.listIndentFirstLevelLeft, hanging: this.listIndentFirstLevelHanging, }, }, }, }, { level: 1, format: docx_1.LevelFormat.BULLET, text: '\u2022', alignment: docx_1.AlignmentType.START, style: { paragraph: { contextualSpacing: true, indent: { left: this.listIndentSecondLevelLeft, hanging: this.listIndentSecondLevelHanging, }, }, }, }, { level: 2, format: docx_1.LevelFormat.BULLET, text: '\u2022', alignment: docx_1.AlignmentType.END, style: { paragraph: { contextualSpacing: true, indent: { left: this.listIndentThirdLevelLeft, hanging: this.listIndentThirdLevelHanging, }, }, }, }, ], reference: this.listTypes.BULLET, }, ], }, }; } error = (e) => { throw new Error(`${this.baseMessage} ${e}`); }; #findHandler = (type) => { return this.typeToHandlerMap[type]; }; /* eslint-disable-next-line class-methods-use-this */ textHandler = (textObject, options = {}) => { const { text, marks } = textObject; const objectToPass = { text }; const { isTableHeader } = options; let isLink = false; let linkUrl = null; if (marks) { if (!Array.isArray(marks)) throw new Error(`Text object marks should be an array`); marks.forEach(mark => { if (mark.type === 'strong' || mark.type === 'bold') objectToPass.bold = true; if (mark.type === 'em' || mark.type === 'i') objectToPass.italics = true; if (mark.type === 'strikethrough') objectToPass.strike = true; if (mark.type === 'superscript') objectToPass.superScript = true; if (mark.type === 'subscript') objectToPass.subScript = true; if (mark.type === 'smallcaps') objectToPass.smallCaps = true; if (mark.type === 'underline') objectToPass.underline = { type: docx_1.UnderlineType.SINGLE, }; if (mark.type === 'code') { objectToPass.font = 'cascadia code'; objectToPass.shading = { type: docx_1.ShadingType.SOLID, color: 'F0F0F0', }; } if (mark.type === 'link') { objectToPass.style = 'Hyperlink'; isLink = true; linkUrl = mark.attrs.href; } }); } if (isLink) return new docx_1.ExternalHyperlink({ children: [new docx_1.TextRun(objectToPass)], link: linkUrl, }); if (isTableHeader) objectToPass.bold = true; return new docx_1.TextRun(objectToPass); }; // #region lists listItemHandler = (listItem, options) => { return this.contentParser(listItem.content, options); }; listHandler = (list, options) => { this.listInstance += 1; const level = Number.isInteger(options.level) ? options.level + 1 : 0; const optionsToPass = { ...options, level, instance: this.listInstance, }; return this.contentParser(list.content, optionsToPass); }; orderedListHandler = (list, options) => { const optionsToPass = { ...options, listType: this.listTypes.ORDERED, }; return this.listHandler(list, optionsToPass); }; bulletListHandler = (list, options) => { const optionsToPass = { ...options, listType: this.listTypes.BULLET, }; return this.listHandler(list, optionsToPass); }; // #endregion lists // #region tables tableHandler = (table) => { return new docx_1.Table({ rows: this.contentParser(table.content), width: { size: 100, type: docx_1.WidthType.PERCENTAGE, }, }); }; tableRowHandler = (row) => { const isTableHeader = row.content[0].type === 'table_header'; return new docx_1.TableRow({ children: this.contentParser(row.content, { isTableHeader, }), tableHeader: row.content[0].type === 'table_header', }); }; tableCellHandler = (cell, options = {}) => { return new docx_1.TableCell({ children: this.contentParser(cell.content, { isTableCell: true, isTableHeader: options.isTableHeader, }), margins: { top: 70, bottom: 70, left: 100, }, verticalAlign: docx_1.VerticalAlign.TOP, }); }; // #endregion tables // #region images figureHandler = (figure, options) => { const { listType, level, instance } = options; const paragraphData = { children: this.contentParser(figure.content), alignment: docx_1.AlignmentType.CENTER, }; if (listType && Number.isInteger(level) && instance) { paragraphData.numbering = { reference: listType, level, instance, }; } return new docx_1.Paragraph(paragraphData); }; getImageType(filePath) { let ext = path_1.default.parse(filePath).ext.substring(1).toLowerCase(); if (ext === 'jpeg') ext = 'jpg'; if (!['png', 'jpg', 'gif', 'bmp', 'svg'].includes(ext)) { throw new Error(`${this.baseMessage} Unsupported docx ImageType for extension: ${ext}`); } return ext; } imageHandler = (image) => { const { dataId, alt } = image.attrs; if (!dataId || !this.imageData || !this.imageData[dataId]) { throw new Error('Missing image data'); } const imagePath = this.imageData[dataId]; // Scale image to fit page // implementation from https://github.com/dolanmiu/docx/issues/232 // see link for possible change to a not-hardcoded doc width, though current value seems to work fine const docWidth = 600; // switch to async in a future version const imageBuffer = fs_1.default.readFileSync(imagePath); const dimensions = (0, image_size_1.imageSize)(imageBuffer); const { height, width } = dimensions; const scale = width / docWidth; const type = this.getImageType(imagePath); return new docx_1.ImageRun({ type, fallback: { type: 'png', data: fs_1.default.readFileSync(imagePath), }, data: fs_1.default.readFileSync(imagePath), altText: { title: alt, description: '', name: '', }, transformation: { width: scale > 1 ? width / scale : width, height: scale > 1 ? height / scale : height, }, }); }; /* eslint-disable-next-line class-methods-use-this */ captionHandler = (caption) => { if (!caption || !caption.content || caption.content.length === 0) return; const textContent = caption.content[0]; if (textContent.type === 'text') { /* eslint-disable-next-line consistent-return */ return new docx_1.TextRun({ text: `Caption: ${textContent.text}` }); } }; // #endregion images paragraphHandler = (paragraph, options = {}) => { const p = (0, cloneDeep_1.default)(paragraph); const { listType, level, instance, isTableCell } = options; const isListItem = !!listType && Number.isInteger(level) && Number.isInteger(instance); // empty paragraphs do not have a content key at all, so add a '' if (!p.content) { p.content = [ { type: 'text', text: '', }, ]; } const paragraphObject = { children: this.contentParser(p.content, options), }; if (isListItem) { paragraphObject.numbering = { reference: options.listType, level: options.level, instance: options.instance, }; } if (isTableCell) { paragraphObject.spacing = { before: 0, after: 0, }; } return new docx_1.Paragraph(paragraphObject); }; /* eslint-disable-next-line class-methods-use-this */ hardBreakHandler = () => { return new docx_1.TextRun({ text: '' }); }; contentParser = (content, options = {}) => { let children = []; if (content) { if (!Array.isArray(content)) throw new Error('Content needs to be an array'); else content.forEach(item => { const { type } = item; const handler = this.#findHandler(type); if (!handler) throw new Error(`Unknown content type "${type}"`); const childrenToAdd = handler(item, options); // handlers could return a single item or an array of items children = children.concat(childrenToAdd); }); } else if (options.renderEmpty) { return [this.paragraphHandler({}, options)]; } return children; }; buildDocx = () => { return new docx_1.Document({ ...this.config, sections: [ { children: this.contentParser(this.doc.content), }, ], }); }; async writeToPath(filePath) { try { if (!filePath) throw new Error('No path provided to write method'); const parsed = this.buildDocx(); const buffer = await docx_1.Packer.toBuffer(parsed); fs_1.default.writeFileSync(filePath, buffer); } catch (e) { this.error(e); } } } exports.default = WaxToDocxConverter; //# sourceMappingURL=docx.service.js.map