UNPKG

specpress

Version:

Export PDF and/or DOCX files from a subset of Markdown, ASN.1 and JSON files

661 lines (605 loc) 26.7 kB
const { Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType, TableOfContents, SectionType } = require('docx') const { writeFileSync, readFileSync } = require('fs') const path = require('path') const MarkdownIt = require('markdown-it') const { extractText, classifyParagraph, buildJsonTableIndices, injectAllSectionNumbers, insertBreakAfterColon, parseTocRange, PARA } = require('../common/specProcessor') const { latexToDocxMath } = require('./handlers/latexEquationHandler') const { renderMermaidBatch, getSvgDimensions } = require('./handlers/mermaidHandler') const { jsonToDocxTable, jsonFileToDocxTable } = require('./handlers/jsonTableHandler') const { parseBullet, DEFAULT_BULLET } = require('../common/bullets') const { buildInlineRuns } = require('./handlers/inlineRunBuilder') const { asnToDocxParagraphs } = require('./handlers/asnHandler') const { HEADING_LEVELS, docxStyles } = require('./styles/docxStyles') const { detectCRCoverPage } = require('../common/crCoverPageDetector') const { loadCRCoverPageData } = require('../common/crCoverPageLoader') const { renderCRCoverPageDOCX } = require('./crCoverPageRenderer') const { renderCRHistoryTableDOCX } = require('./crHistoryRenderer') const MAX_IMAGE_WIDTH = 600 const MIN_IMAGE_DPI = 125 const DOCX_PPI = 96 /** * Reads image dimensions from a buffer by parsing file headers. * Supports PNG, JPEG, GIF, and BMP. * * @param {Buffer} buf - Image file buffer. * @param {string} ext - File extension (without dot). * @returns {{ width: number, height: number }} */ function getImageDimensions(buf, ext) { try { if (ext === 'png' && buf.length > 24 && buf.readUInt32BE(0) === 0x89504E47) { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) } } if ((ext === 'jpg' || ext === 'jpeg') && buf[0] === 0xFF && buf[1] === 0xD8) { let i = 2 while (i < buf.length - 1) { if (buf[i] !== 0xFF) break const marker = buf[i + 1] if (marker === 0xC0 || marker === 0xC2) { return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) } } const len = buf.readUInt16BE(i + 2) i += 2 + len } } if (ext === 'gif' && buf.length > 10) { return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) } } if (ext === 'bmp' && buf.length > 26) { return { width: buf.readUInt32LE(18), height: Math.abs(buf.readInt32LE(22)) } } } catch (e) { /* fall through */ } return { width: MAX_IMAGE_WIDTH, height: 400 } } /** * Scales image dimensions for DOCX embedding. * * @param {number} width - Native pixel width. * @param {number} height - Native pixel height. * @returns {{ width: number, height: number }} */ function scaleToFit(width, height) { const maxByDpi = Math.round(width * DOCX_PPI / MIN_IMAGE_DPI) const targetWidth = Math.min(MAX_IMAGE_WIDTH, maxByDpi) if (width <= targetWidth) return { width, height } return { width: targetWidth, height: Math.round(height * (targetWidth / width)) } } /** * DOCX-specific specification processor. * * Walks markdown-it tokens and generates docx elements for each token type. * Uses classifyParagraph() from specProcessor for paragraph classification. * * Delegates to extracted modules: * - styles/docxStyles.js for style definitions * - handlers/inlineRunBuilder.js for inline token -> TextRun conversion * - handlers/asnHandler.js for ASN.1 syntax highlighting */ class MarkdownToDocxConverter { /** * @param {string|null} mermaidConfig - Mermaid configuration JSON string, or null. * @param {string} [specRootPath=''] - Absolute path to the specification root for section numbering. * @param {Function|null} [mermaidRenderer=null] - Optional async function `(codes) => svgs[]`. * @param {Function|null} [fileResolver=null] - Optional function `(absolutePath) => Buffer|string` * for reading external files (images, JsonTable JSON). When null, reads from the local filesystem. * Use this to resolve files from git commits or other non-filesystem sources. */ constructor(mermaidConfig, specRootPath, mermaidRenderer, fileResolver, options) { this.mermaidConfig = mermaidConfig || null this.specRootPath = specRootPath || '' this.mermaidRenderer = mermaidRenderer || null this.fileResolver = fileResolver || null this.updateFields = !(options && options.updateFields === false) this.md = new MarkdownIt({ html: true }) /** @type {Object[]} Accumulated docx elements for the current conversion */ this.docElements = [] /** @type {number} Number of images embedded during conversion */ this.imageCount = 0 } /** * Reads a file using the fileResolver if available, otherwise from the filesystem. * @param {string} filePath - Absolute path to the file. * @param {string} [encoding] - Encoding (e.g. 'utf8'). Omit for Buffer. * @returns {Buffer|string} */ readFile(filePath, encoding) { if (this.fileResolver) { const content = this.fileResolver(filePath) if (encoding && Buffer.isBuffer(content)) return content.toString(encoding) return content } return encoding ? readFileSync(filePath, encoding) : readFileSync(filePath) } // -- Token -- /** * Walks the token array, classifies each element, and dispatches to * the appropriate export method. * * @param {Object[]} tokens - markdown-it token array. * @param {string} baseDir - Base directory for resolving relative paths. * @returns {Promise<void>} */ async walkTokens(tokens, baseDir) { const skipIndices = new Set() let currentFileDir = baseDir const fileDirByIndex = new Map() const jsonTableIndices = buildJsonTableIndices(tokens) for (let i = 0; i < tokens.length; i++) { if (tokens[i].type === 'html_block') { const m = tokens[i].content.match(/^<!--\s*FILE:\s*(.+?)\s*-->/) if (m) currentFileDir = path.dirname(m[1]) } fileDirByIndex.set(i, currentFileDir) } const ctx = { skipIndices, fileDirByIndex, jsonTableIndices, baseDir } await this.beforeWalk(tokens, ctx) for (let i = 0; i < tokens.length; i++) { if (skipIndices.has(i)) continue const token = tokens[i] if (token.type === 'html_block' && /^<!--\s*FILE:/.test(token.content)) continue if (token.type === 'html_block' && /^<!--\s*OMITTED\s*-->/.test(token.content)) { this.docElements.push(new Paragraph({})) this.docElements.push(new Paragraph({ children: [new TextRun({ text: '=== unmodified sections omitted ===', highlight: 'yellow' })], alignment: AlignmentType.CENTER })) this.docElements.push(new Paragraph({})) continue } if (await this.handlePreScanned(token, tokens, i, ctx)) continue switch (token.type) { case 'heading_open': { const inline = tokens[i + 1] const level = parseInt(token.tag.substring(1)) await this.exportHeading(level, inline, tokens, i, ctx) break } case 'paragraph_open': { if (skipIndices.has(i)) break const content = tokens[i + 1] if (!content || content.type !== 'inline') break const kind = classifyParagraph(tokens, i, jsonTableIndices) switch (kind) { case PARA.DISPLAY_MATH: { const latex = content.content.replace(/^\$\$|\$\$$/g, '').trim() await this.exportDisplayMath(latex, tokens, i, ctx) break } case PARA.EDITORS_NOTE: await this.exportEditorsNote(content, tokens, i, ctx); break case PARA.NOTE: await this.exportNote(content, tokens, i, ctx); break case PARA.TOC: await this.exportToc(content, tokens, i, ctx); break case PARA.CR_HISTORY: await this.exportCRHistory(content, tokens, i, ctx); break case PARA.EXAMPLE: await this.exportExample(content, tokens, i, ctx); break case PARA.TABLE_CAPTION: await this.exportTableCaption(content, tokens, i, ctx); break case PARA.FIGURE_CAPTION: await this.exportFigureCaption(content, tokens, i, ctx); break case PARA.IMAGE: await this.exportImage(content, tokens, i, ctx); break default: await this.exportParagraph(content, tokens, i, ctx); break } break } case 'fence': if (token.info === 'mermaid') { /* handled via handlePreScanned */ } else if (token.info === 'asn') await this.exportAsn(token.content, tokens, i, ctx) else if (token.info === 'jsonTable') await this.exportJsonTableFence(token.content, tokens, i, ctx) else if (token.info === 'math') await this.exportDisplayMath(token.content.trim(), tokens, i, ctx) else await this.exportCodeBlock(token, tokens, i, ctx) break case 'table_open': await this.exportTable(tokens, i, ctx) break case 'list_item_open': await this.exportBulletItem(token, tokens, i, ctx) break default: break } } } // -- Pre/post -- async beforeWalk(tokens, ctx) { const mermaidEntries = [] for (let i = 0; i < tokens.length; i++) { if (tokens[i].type === 'fence' && tokens[i].info === 'mermaid') { mermaidEntries.push({ idx: i, code: tokens[i].content.replace(/```mermaid\n?|\n?```/g, '').trim() }) } } const mermaidConfig = this.mermaidConfig || undefined const mermaidSvgs = mermaidEntries.length > 0 ? (this.mermaidRenderer ? await this.mermaidRenderer(mermaidEntries.map(e => e.code)) : renderMermaidBatch(mermaidEntries.map(e => e.code), mermaidConfig)) : [] ctx.svgByIndex = new Map(mermaidEntries.map((e, j) => [e.idx, mermaidSvgs[j]])) ctx.jsonTableByIndex = new Map() for (const i of ctx.jsonTableIndices) { const children = tokens[i].children const href = children[0].attrGet('href') try { const resolveDir = ctx.fileDirByIndex.get(i) || ctx.baseDir const filePath = path.resolve(resolveDir, href) const jsonContent = this.readFile(filePath, 'utf8') const data = JSON.parse(jsonContent) const items = Array.isArray(data) ? data : [data] const tables = [] for (const item of items) { const t = await jsonToDocxTable(item) if (t) tables.push(t) } ctx.jsonTableByIndex.set(i, tables) ctx.skipIndices.add(i - 1) ctx.skipIndices.add(i + 1) } catch (e) { ctx.jsonTableByIndex.set(i, [new Paragraph({ text: `[JsonTable error: ${e.message}]` })]) ctx.skipIndices.add(i - 1) ctx.skipIndices.add(i + 1) } } } async handlePreScanned(token, tokens, idx, ctx) { if (ctx.svgByIndex.has(idx)) { this.handleMermaidSvg(ctx.svgByIndex.get(idx), this.docElements, tokens, idx, ctx) return true } if (ctx.jsonTableByIndex.has(idx)) { ctx.jsonTableByIndex.get(idx).forEach(t => this.docElements.push(t)) return true } return false } // -- Export -- async exportHeading(level, inlineToken, tokens, idx, ctx) { const text = extractText(inlineToken) const isAnnex = level === 1 && /^Annex\b/i.test(text) if (isAnnex) { insertBreakAfterColon(inlineToken) const runs = await buildInlineRuns(inlineToken.children) this.docElements.push(new Paragraph({ children: runs, style: 'Heading8' })) } else { const hasNumber = /^\d/.test(text.trim()) const runs = await buildInlineRuns(inlineToken.children, hasNumber ? { replaceFirstSpace: true } : {}) this.docElements.push(new Paragraph({ children: runs, heading: HEADING_LEVELS[level - 1] || HEADING_LEVELS[5] })) } } async exportParagraph(inlineToken, tokens, idx, ctx) { if (idx > 0 && tokens[idx - 1].type === 'list_item_open') return const runs = await buildInlineRuns(inlineToken.children) this.docElements.push(new Paragraph({ children: runs })) } async exportNote(inlineToken, tokens, idx, ctx) { const runs = await buildInlineRuns(inlineToken.children, { replaceFirstColonSpace: true }) this.docElements.push(new Paragraph({ children: runs, style: 'NO' })) } async exportEditorsNote(inlineToken, tokens, idx, ctx) { const runs = await buildInlineRuns(inlineToken.children, { replaceFirstColonSpace: true, color: 'FF0000' }) this.docElements.push(new Paragraph({ children: runs, style: 'EN' })) } async exportExample(inlineToken, tokens, idx, ctx) { const runs = await buildInlineRuns(inlineToken.children, { replaceFirstColonSpace: true }) this.docElements.push(new Paragraph({ children: runs, style: 'EX' })) } async exportToc(inlineToken, tokens, idx, ctx) { const range = parseTocRange(extractText(inlineToken)) this.docElements.push(new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: range || '1-9' })) } async exportCRHistory(inlineToken, tokens, idx, ctx) { if (this.specRootPath) { const elements = renderCRHistoryTableDOCX(this.specRootPath) this.docElements.push(...elements) } } async exportTableCaption(inlineToken, tokens, idx, ctx) { const runs = await buildInlineRuns(inlineToken.children) this.docElements.push(new Paragraph({ children: runs, style: 'TH' })) } async exportFigureCaption(inlineToken, tokens, idx, ctx) { const runs = await buildInlineRuns(inlineToken.children) this.docElements.push(new Paragraph({ children: runs, style: 'TF' })) ctx.skipIndices.add(idx) ctx.skipIndices.add(idx + 1) ctx.skipIndices.add(idx + 2) } async exportDisplayMath(latex, tokens, idx, ctx) { try { const mathComponent = await latexToDocxMath(latex, true) this.docElements.push(new Paragraph({ children: [mathComponent], style: 'EQ', alignment: AlignmentType.LEFT })) } catch (e) { this.docElements.push(new Paragraph({ text: latex, style: 'EQ', alignment: AlignmentType.LEFT })) } } async exportImage(inlineToken, tokens, idx, ctx) { for (const child of inlineToken.children) { if (child.type === 'image') { const src = child.attrGet('src') const alt = child.content try { const resolveDir = ctx.fileDirByIndex.get(idx) || '' const imagePath = path.isAbsolute(src) ? src : path.resolve(resolveDir, src) const raw = this.readFile(imagePath) const imageBuffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw) const ext = path.extname(src).toLowerCase().replace('.', '') const typeMap = { png: 'png', jpg: 'jpg', jpeg: 'jpg', gif: 'gif', bmp: 'bmp', svg: 'svg' } const imgType = typeMap[ext] || 'png' const dims = getImageDimensions(imageBuffer, ext) const scaled = scaleToFit(dims.width, dims.height) this.docElements.push(new Paragraph({ children: [new ImageRun({ data: imageBuffer, type: imgType, transformation: scaled })], style: 'TH' })) this.imageCount++ } catch (e) { this.docElements.push(new Paragraph({ text: `[Image: ${alt}]` })) } } } let nextIdx = idx + 2 while (nextIdx < tokens.length && tokens[nextIdx].type === 'paragraph_close') nextIdx++ if (nextIdx + 1 < tokens.length && tokens[nextIdx].type === 'paragraph_open') { const nextInline = tokens[nextIdx + 1] if (nextInline && nextInline.type === 'inline' && /^(\*\*)?Figure/i.test(extractText(nextInline))) { const captionRuns = await buildInlineRuns(nextInline.children) this.docElements.push(new Paragraph({ children: captionRuns, style: 'TF' })) ctx.skipIndices.add(nextIdx) ctx.skipIndices.add(nextIdx + 1) ctx.skipIndices.add(nextIdx + 2) } } } async exportAsn(content, tokens, idx, ctx) { const paragraphs = await asnToDocxParagraphs(content) this.docElements.push(...paragraphs) } async exportJsonTableFence(jsonContent, tokens, idx, ctx) { try { const data = JSON.parse(jsonContent) const items = Array.isArray(data) ? data : [data] for (const item of items) { const table = await jsonToDocxTable(item) if (table) this.docElements.push(table) } } catch (e) { this.docElements.push(new Paragraph({ text: jsonContent })) } } async exportTable(tokens, idx, ctx) { const rows = [] const alignments = [] let i = idx + 1 let isHeader = true let alignIdx = idx + 1 while (alignIdx < tokens.length && tokens[alignIdx].type !== 'thead_close') { if (tokens[alignIdx].type === 'th_open') { alignments.push(tokens[alignIdx].attrGet('style') || 'text-align:left') } alignIdx++ } while (i < tokens.length && tokens[i].type !== 'table_close') { if (tokens[i].type === 'thead_close') isHeader = false if (tokens[i].type === 'tr_open') { const cells = [] let j = i + 1 let colIdx = 0 while (j < tokens.length && tokens[j].type !== 'tr_close') { if (tokens[j].type === 'th_open' || tokens[j].type === 'td_open') { const inline = tokens[j + 1] const text = inline ? extractText(inline) : '' const isNote = !isHeader && /^(\*\*)?NOTE(\*\*)?( \d+)?:/.test(text) const runs = inline && inline.children ? await buildInlineRuns(inline.children, isNote ? { replaceFirstColonSpace: true } : {}) : [new TextRun('')] let style = 'TAL' if (isNote) { style = 'TAN' } else if (isHeader) { style = 'TAH' } else { const align = alignments[colIdx] || 'text-align:left' if (align.includes('center')) style = 'TAC' else if (align.includes('right')) style = 'TAR' } cells.push(new TableCell({ children: [new Paragraph({ children: runs, style })], borders: { top: { style: BorderStyle.SINGLE, size: 1 }, bottom: { style: BorderStyle.SINGLE, size: 1 }, left: { style: BorderStyle.SINGLE, size: 1 }, right: { style: BorderStyle.SINGLE, size: 1 } } })) colIdx++ } j++ } if (cells.length > 0) rows.push(new TableRow({ children: cells })) i = j } i++ } if (rows.length > 0) { this.docElements.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } })) } } async exportBulletItem(token, tokens, idx, ctx) { const content = tokens[idx + 1] if (content && content.type === 'paragraph_open') { const inline = tokens[idx + 2] if (inline && inline.type === 'inline') { const level = token.level || 0 const bulletLevel = Math.floor(level / 2) + 1 const style = (bulletLevel >= 1 && bulletLevel <= 9) ? `B${bulletLevel}` : undefined const firstChild = inline.children && inline.children[0] if (firstChild && firstChild.type === 'text') { const { bullet, rest } = parseBullet(firstChild.content) firstChild.content = bullet + '\t' + rest } else { inline.children.unshift({ type: 'text', content: DEFAULT_BULLET + '\t' }) } const runs = await buildInlineRuns(inline.children) this.docElements.push(new Paragraph({ children: runs, ...(style && { style }) })) } } } async exportCodeBlock(token, tokens, idx, ctx) { const lines = token.content.split('\n').filter((line, i, arr) => i < arr.length - 1 || line.trim() !== '') for (const line of lines) { this.docElements.push(new Paragraph({ text: line, style: 'PL' })) } } // -- Mermaid -- handleMermaidSvg(result, elements, tokens, idx, ctx) { const svg = result && result.svg !== undefined ? result.svg : result const pngFromRender = result && result.png if (!svg) { elements.push(new Paragraph({ text: '[Mermaid diagram conversion failed]' })) return } const { width, height } = getSvgDimensions(svg) // Apply CSS !important rules to inline styles on <text> elements. // Word's SVG renderer ignores <style> blocks and only reads inline styles. let processedSvg = svg const styleMatch = svg.match(/<style>([\s\S]*?)<\/style>/) if (styleMatch) { const css = styleMatch[1] const re = /\.(\w[\w-]*)\s*\{([^}]*!important[^}]*)\}/g let m while ((m = re.exec(css)) !== null) { const cls = m[1] const props = m[2].replace(/!important/g, '').trim() const textRe = new RegExp(`<text([^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*)>`, 'g') processedSvg = processedSvg.replace(textRe, (tag) => { if (tag.includes('style="')) { return tag.replace(/style="([^"]*)"/, (_, existing) => { const merged = existing.replace(/font-size:[^;]+;?/, '') + ' ' + props return `style="${merged.trim()}"` }) } return tag.replace('>', ` style="${props}">`) }) } } // Fix vertical text alignment for Word's SVG renderer. // Word ignores dominant-baseline, alignment-baseline, and dy attributes. // For dominant-baseline="central": shift y down by ~5px (half cap height). processedSvg = processedSvg.replace(/<text([^>]*dominant-baseline="central"[^>]*)>/g, (tag) => { if (tag.includes('dy="')) return tag return tag.replace(/\by="([\d.]+)"/, (_, y) => `y="${(parseFloat(y) + 5).toFixed(1)}"`) }) // For dy="1em": bake the em offset into y and remove dy (Word ignores dy). // Also add half font-size for dominant-baseline="middle" which Word ignores. processedSvg = processedSvg.replace(/<text([^>]*)\bdy="([\d.]+)em"([^>]*)>/g, (tag, before, dyEm, after) => { const fontSize = (tag.match(/font-size:\s*([\d.]+)px/) || [])[1] const fs = fontSize ? parseFloat(fontSize) : 16 const emOffset = parseFloat(dyEm) * fs const baselineOffset = tag.includes('dominant-baseline="middle"') ? fs * 0.35 : 0 return tag.replace(/\by="([\d.]+)"/, (_, y) => `y="${(parseFloat(y) + emOffset + baselineOffset).toFixed(1)}"`).replace(/\s*dy="[^"]*"/, '') }) const svgBuffer = Buffer.from(processedSvg, 'utf8') const smallFallbackPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAABJRU5ErkJggg==', 'base64') const fallbackPng = pngFromRender || smallFallbackPng elements.push(new Paragraph({ children: [new ImageRun({ data: svgBuffer, type: 'svg', transformation: { width, height }, fallback: { data: fallbackPng, type: 'png' } })], style: 'TH' })) this.imageCount++ if (idx + 1 < tokens.length && tokens[idx + 1].type === 'paragraph_open') { const nextContent = tokens[idx + 2] if (nextContent && nextContent.type === 'inline') { elements.push(new Paragraph({ text: extractText(nextContent), style: 'TF' })) ctx.skipIndices.add(idx + 1) ctx.skipIndices.add(idx + 2) ctx.skipIndices.add(idx + 3) } } } // -- Main -- /** * Converts a markdown file to a styled DOCX document. * * @param {string} markdownPath - Absolute path to the markdown file. * @param {string} outputPath - Absolute path for the output .docx file. * @param {string} [baseDir] - Base directory for resolving relative paths. * @param {Object} [frontPage] - Front page sections from buildFrontPageDocx. * @returns {Promise<void>} */ /** * Converts markdown to DOCX. * * @param {string} markdownPath - Path to markdown file. * @param {string} outputPath - Path to output DOCX file. * @param {string|null} [baseDir=null] - Base directory for resolving relative paths. * @param {object|null} [frontPage=null] - Front page data (standard front page). * @param {object} [options={}] - Additional options. * @param {object} [options.crCoverPageData=null] - CR cover page data (takes precedence over frontPage). */ async convert(markdownPath, outputPath, baseDir, frontPage, options = {}) { const markdown = readFileSync(markdownPath, 'utf-8') if (!baseDir) baseDir = path.dirname(markdownPath) const tokens = this.md.parse(markdown, { _baseDir: baseDir }) this.docElements = [] this.imageCount = 0 injectAllSectionNumbers(tokens, this.specRootPath) await this.walkTokens(tokens, baseDir) const sections = [] if (frontPage || options.crCoverPageData) { // CR cover page takes precedence if provided if (options.crCoverPageData) { const crElements = renderCRCoverPageDOCX(options.crCoverPageData) sections.push({ properties: { type: SectionType.NEXT_PAGE, page: { size: { width: 11907, height: 16840 }, margin: { top: 1418, right: 1134, bottom: 1134, left: 1134, header: 850, footer: 510 }, }, }, children: crElements, }) sections.push({ properties: { type: SectionType.NEXT_PAGE, page: { size: { width: 11907, height: 16840 }, margin: { top: 1418, right: 1134, bottom: 1134, left: 1134, header: 850, footer: 510 }, }, }, children: this.docElements, }) } else { // Standard front page sections.push(frontPage.frontSection) sections.push(frontPage.innerSection) sections.push({ properties: { type: SectionType.NEXT_PAGE, page: { size: { width: 11907, height: 16840 }, margin: { top: 1418, right: 1134, bottom: 1134, left: 1134, header: 850, footer: 510 }, }, }, ...(frontPage.bodyHeaderFooter || {}), children: this.docElements, }) } } else { sections.push({ children: this.docElements }) } const doc = new Document({ ...(this.updateFields ? { features: { updateFields: true } } : {}), sections, styles: docxStyles() }) const buffer = await Packer.toBuffer(doc) writeFileSync(outputPath, buffer) } } module.exports = { MarkdownToDocxConverter, buildInlineRuns }