UNPKG

myst-to-docx

Version:

Export from a MyST Markdown document to Microsoft Word (*.docx)

481 lines (480 loc) 14.8 kB
import { FootnoteReferenceRun, TabStopPosition, TabStopType, TextRun, AlignmentType, BorderStyle, convertInchesToTwip, ExternalHyperlink, HeadingLevel, ImageRun, ShadingType, Math, MathRun, TableRow, Table, TableCell, Paragraph, PageBreak, } from 'docx'; import { RuleId, fileError, getMetadataTags } from 'myst-common'; import { createReference, createReferenceBookmark, createShortId, getImageWidth, MAX_DOCX_IMAGE_WIDTH, } from './utils.js'; import { createNumbering } from './numbering.js'; import sizeOf from 'buffer-image-size'; const text = (state, node) => { var _a; state.text((_a = node.value) !== null && _a !== void 0 ? _a : ''); }; const paragraph = (state, node) => { state.renderChildren(node); state.closeBlock(); }; const block = (state, node) => { if (node.visibility === 'remove' || node.visibility === 'hide') return; const metadataTags = getMetadataTags(node); if (metadataTags.includes('page-break') || metadataTags.includes('new-page')) { state.current.push(new PageBreak()); } state.renderChildren(node); }; const heading = (state, node) => { if (!state.options.useFieldsForCrossReferences && node.enumerator) { state.text(`${node.enumerator}\t`); } else { // some way to number the headings? } state.renderChildren(node); const headingLevel = [ HeadingLevel.HEADING_1, HeadingLevel.HEADING_2, HeadingLevel.HEADING_3, HeadingLevel.HEADING_4, HeadingLevel.HEADING_5, HeadingLevel.HEADING_6, ][node.depth - 1]; state.closeBlock({ heading: headingLevel }); }; const emphasis = (state, node) => { state.addRunOptions({ italics: true }); state.renderChildren(node); }; const strong = (state, node) => { state.addRunOptions({ bold: true }); state.renderChildren(node); }; const list = (state, node) => { const style = node.ordered ? 'numbered' : 'bullets'; if (!state.data.currentNumbering) { const nextId = createShortId(); state.numbering.push(createNumbering(nextId, style)); state.data.currentNumbering = { reference: nextId, level: 0 }; } else { const { reference, level } = state.data.currentNumbering; state.data.currentNumbering = { reference, level: level + 1 }; } state.renderChildren(node); if (state.data.currentNumbering.level === 0) { delete state.data.currentNumbering; } else { const { reference, level } = state.data.currentNumbering; state.data.currentNumbering = { reference, level: level - 1 }; } }; const listItem = (state, node, parent) => { if (!state.data.currentNumbering) throw new Error('Trying to create a list item without a list?'); if (state.current.length > 0) { // This is a list within a list state.closeBlock(); } state.addParagraphOptions({ numbering: state.data.currentNumbering }); state.renderChildren(node); if (parent.type !== 'paragraph') { state.closeBlock(); } }; const link = (state, node) => { // Pop the stack when we encounter a link const stack = state.current; state.addRunOptions({ style: 'Hyperlink' }); state.current = []; state.renderChildren(node); const hyperlink = new ExternalHyperlink({ link: node.url, children: state.current, }); state.current = [...stack, hyperlink]; }; const inlineCode = (state, node) => { state.text(node.value, { font: { name: 'Monospace', }, color: '000000', shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2', }, }); }; const _break = (state) => { state.addRunOptions({ break: 1 }); }; const thematicBreak = (state) => { // Kinda hacky, but this works to insert two paragraphs, the first with a break state.closeBlock({ thematicBreak: true }); state.blankLine(); }; const abbreviation = (state, node) => { // TODO: handle abbreviation title state.renderChildren(node); }; const subscript = (state, node) => { state.addRunOptions({ subScript: true }); state.renderChildren(node); }; const superscript = (state, node) => { state.addRunOptions({ superScript: true }); state.renderChildren(node); }; const _delete = (state, node) => { state.addRunOptions({ strike: true }); state.renderChildren(node); }; const underline = (state, node) => { state.addRunOptions({ underline: {} }); state.renderChildren(node); }; const smallcaps = (state, node) => { state.addRunOptions({ smallCaps: true }); state.renderChildren(node); }; const blockquote = (state, node) => { state.renderChildren(node, { style: 'IntenseQuote' }); }; const code = (state, node) => { // TODO: render with color etc. // put each line in a new paragraph node.value.split('\n').forEach((line) => { state.text(line, { font: { name: 'Monospace', }, }); state.closeBlock(); }); }; function getAspect(buffer, size) { if (size) return size.height / size.width; try { // This does not run client side const dimensions = sizeOf(buffer); return dimensions.height / dimensions.width; } catch (error) { return undefined; } } const image = (state, node) => { var _a, _b, _c; const buffer = state.options.getImageBuffer(node.url); const dimensions = (_b = (_a = state.options).getImageDimensions) === null || _b === void 0 ? void 0 : _b.call(_a, node.url); const width = getImageWidth(node.width, (_c = state.data.maxImageWidth) !== null && _c !== void 0 ? _c : state.options.maxImageWidth); const aspect = getAspect(buffer, dimensions); if (!aspect) { fileError(state.file, `Error with checking image aspect ratio for "${node.url}".`, { node, source: 'myst-to-docx:image', note: 'Either provide dimensions of the image with "getImageDimensions" or ensure that the result is a Buffer.', ruleId: RuleId.docxRenders, }); } state.current.push(new ImageRun({ data: buffer, transformation: { width, height: width * (aspect !== null && aspect !== void 0 ? aspect : 1), }, })); let alignment; switch (node.align) { case 'right': alignment = AlignmentType.RIGHT; break; case 'left': alignment = AlignmentType.LEFT; break; default: alignment = AlignmentType.CENTER; } state.addParagraphOptions({ alignment, }); state.closeBlock(); }; const admonitionStyle = { border: { left: { style: BorderStyle.DOUBLE, color: '5D85D0', }, }, indent: { left: convertInchesToTwip(0.2), right: convertInchesToTwip(0.2) }, }; const admonition = (state, node) => { state.blankLine(); state.renderChildren(node, admonitionStyle); state.closeBlock(); state.blankLine(); }; const admonitionTitle = (state, node) => { state.renderChildren(node, { ...admonitionStyle, shading: { type: ShadingType.SOLID, color: '5D85D0', }, }, { bold: true, color: 'FFFFFF' }); state.closeBlock(); }; const definitionStyle = { border: { left: { style: BorderStyle.THICK, color: 'D2D3D2', }, }, indent: { left: convertInchesToTwip(0.2), right: convertInchesToTwip(0.2) }, }; const definitionList = (state, node) => { state.renderChildren(node, definitionStyle); state.closeBlock(); state.blankLine(); }; const definitionTerm = (state, node) => { state.renderChildren(node, { ...definitionStyle, shading: { type: ShadingType.SOLID, color: 'D2D3D2', }, }); state.closeBlock(); }; const definitionDescription = (state, node) => { state.text('\t'); state.renderChildren(node, definitionStyle); state.closeBlock(); }; const inlineMath = (state, node) => { const latex = node.value; state.current.push(new Math({ children: [new MathRun(latex)] })); }; const math = (state, node) => { state.blankLine(); const latex = node.value; state.current = [ new TextRun('\t'), new Math({ children: [new MathRun(latex)], }), ]; // Add the number at the end of the field if (node.enumerator && node.identifier && state.options.useFieldsForCrossReferences) { state.current.push(new TextRun('\t('), createReferenceBookmark(node.identifier, 'Equation'), new TextRun(')')); } else if (node.enumerator) { state.current.push(new TextRun(`\t(${node.enumerator})`)); } state.closeBlock({ tabStops: [ { type: TabStopType.CENTER, position: TabStopPosition.MAX / 2, }, { type: TabStopType.RIGHT, position: TabStopPosition.MAX, }, ], }); state.blankLine(); }; const crossReference = (state, node) => { if (state.options.useFieldsForCrossReferences && node.identifier) { state.current.push(createReference(node.identifier)); } else { state.renderChildren(node); } }; const container = (state, node) => { state.renderChildren(node); }; function figCaptionToWordCaption(file, kind) { switch (kind.toLowerCase()) { case 'figure': return 'Figure'; case 'table': return 'Table'; case 'equation': return 'Equation'; case 'code': // This is a hack, I don't think word knows about other things! return 'Figure'; default: fileError(file, `Unknown figure caption of kind ${kind}`, { ruleId: RuleId.docxRenders, }); return 'Figure'; } } const captionNumber = (state, node) => { if (state.options.useFieldsForCrossReferences) { const bookmarkKind = figCaptionToWordCaption(state.file, node.kind); state.current.push(createReferenceBookmark(node.identifier, bookmarkKind, `${bookmarkKind} `, ': ')); } else { state.renderChildren(node, undefined, { bold: true }); state.text(' '); } }; const caption = (state, node) => { state.renderChildren(node, { style: 'Caption' }); }; function getFootnoteNumber(node) { var _a; return (_a = Number.parseInt(node.enumerator, 10)) !== null && _a !== void 0 ? _a : Number(node.identifier); } const footnoteDefinition = (state, node) => { const { children, current } = state; const number = getFootnoteNumber(node); // Delete everything and work with the footnote definition as children state.children = []; state.current = []; state.renderChildren(node); // TODO: a problem here if there are numberings or images state.footnotes[number] = { children: state.children }; // Put the children back, and continue state.children = children; state.current = current; }; const footnoteReference = (state, node) => { const number = getFootnoteNumber(node); state.current.push(new FootnoteReferenceRun(number)); }; const table = (state, node) => { var _a, _b; const actualChildren = state.children; const rows = []; const imageWidth = (_b = (_a = state.data.maxImageWidth) !== null && _a !== void 0 ? _a : state.options.maxImageWidth) !== null && _b !== void 0 ? _b : MAX_DOCX_IMAGE_WIDTH; node.children.forEach(({ children }) => { const rowContent = children; const cells = []; // Check if all cells are headers in this row let tableHeader = true; rowContent.forEach((cell) => { if (cell.header) { tableHeader = false; } }); // This scales images inside of tables state.data.maxImageWidth = imageWidth / rowContent.length; rowContent.forEach((cell) => { var _a, _b; state.children = []; state.renderChildren(cell); state.closeBlock(); const tableCellOpts = { children: state.children }; const colspan = (_a = cell.colspan) !== null && _a !== void 0 ? _a : 1; const rowspan = (_b = cell.rowspan) !== null && _b !== void 0 ? _b : 1; if (colspan > 1) tableCellOpts.columnSpan = colspan; if (rowspan > 1) tableCellOpts.rowSpan = rowspan; cells.push(new TableCell(tableCellOpts)); }); rows.push(new TableRow({ children: cells, tableHeader })); }); state.data.maxImageWidth = imageWidth; const tableNode = new Table({ rows }); actualChildren.push(tableNode); // If there are multiple tables, this seperates them actualChildren.push(new Paragraph('')); state.children = actualChildren; }; const cite = (state, node) => { state.renderChildren(node); }; const citeGroup = (state, node) => { if (node.kind === 'narrative') { state.renderChildren(node); } else { state.text('('); node.children.forEach((child, ind) => { state.render(child); if (ind < node.children.length - 1) state.text('; '); }); state.text(')'); } }; const embed = (state, node) => { state.renderChildren(node); }; const include = (state, node) => { state.renderChildren(node); }; const comment = () => { // Do nothing! return; }; const mystDirective = (state, node) => { state.renderChildren(node); }; const mystRole = (state, node) => { state.renderChildren(node); }; const inlineExpression = (state, node, parent) => { var _a; if ((_a = node.children) === null || _a === void 0 ? void 0 : _a.length) { state.renderChildren(node); } else { inlineCode(state, { ...node, type: 'inlineCode' }, parent); } }; export const defaultHandlers = { text, paragraph, heading, emphasis, strong, inlineCode, link, break: _break, thematicBreak, list, listItem, abbreviation, subscript, superscript, delete: _delete, underline, smallcaps, blockquote, code, image, block, comment, mystDirective, mystRole, admonition, admonitionTitle, definitionList, definitionTerm, definitionDescription, math, inlineMath, crossReference, container, caption, captionNumber, footnoteReference, footnoteDefinition, table, cite, citeGroup, embed, include, inlineExpression, };