diff --git a/src/converters/document.js b/src/converters/document.js index 47f2134..ef0730d 100644 --- a/src/converters/document.js +++ b/src/converters/document.js @@ -64,6 +64,118 @@ async function convertMdToPdf(inputPath, outputPath) { await renderHtmlToPdf(html, outputPath); } +const MARKDOWN_HEADING_LEVELS = { + h1: HeadingLevel.HEADING_1, + h2: HeadingLevel.HEADING_2, + h3: HeadingLevel.HEADING_3, + h4: HeadingLevel.HEADING_4, + h5: HeadingLevel.HEADING_5, + h6: HeadingLevel.HEADING_6, +}; + +function markdownInlineTokensToRuns(children) { + const runs = []; + let bold = false; + let italics = false; + for (const token of children ?? []) { + if (token.type === 'strong_open') bold = true; + else if (token.type === 'strong_close') bold = false; + else if (token.type === 'em_open') italics = true; + else if (token.type === 'em_close') italics = false; + else if (token.type === 'text' || token.type === 'code_inline') { + if (token.content) runs.push(new TextRun({ text: token.content, bold, italics })); + } else if (token.type === 'softbreak' || token.type === 'hardbreak') { + runs.push(new TextRun({ text: ' ' })); + } + } + return runs; +} + +function markdownToDocxParagraphs(markdownText) { + const tokens = new MarkdownIt().parse(markdownText, {}); + const paragraphs = []; + const listStack = []; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + + if (token.type === 'heading_open') { + const inline = tokens[i + 1]; + paragraphs.push( + new Paragraph({ + heading: MARKDOWN_HEADING_LEVELS[token.tag], + children: markdownInlineTokensToRuns(inline.children), + }) + ); + i += 2; + continue; + } + + if (token.type === 'bullet_list_open') { + listStack.push({ type: 'bullet' }); + continue; + } + if (token.type === 'ordered_list_open') { + listStack.push({ type: 'ordered', counter: 0 }); + continue; + } + if (token.type === 'bullet_list_close' || token.type === 'ordered_list_close') { + listStack.pop(); + continue; + } + if (token.type === 'list_item_open') { + const current = listStack[listStack.length - 1]; + if (current?.type === 'ordered') current.counter += 1; + continue; + } + if (token.type === 'list_item_close') { + continue; + } + + if (token.type === 'paragraph_open') { + const inline = tokens[i + 1]; + const runs = markdownInlineTokensToRuns(inline.children); + const current = listStack[listStack.length - 1]; + if (current?.type === 'bullet') { + paragraphs.push(new Paragraph({ children: runs, bullet: { level: listStack.length - 1 } })); + } else if (current?.type === 'ordered') { + paragraphs.push( + new Paragraph({ children: [new TextRun({ text: `${current.counter}. ` }), ...runs] }) + ); + } else { + paragraphs.push(new Paragraph({ children: runs })); + } + i += 2; + continue; + } + + if (token.type === 'fence' || token.type === 'code_block') { + if (token.content.trim()) paragraphs.push(new Paragraph({ children: [new TextRun({ text: token.content })] })); + continue; + } + + if (token.type === 'hr') { + paragraphs.push(new Paragraph({ children: [new TextRun({ text: '---' })] })); + } + } + + return paragraphs; +} + +async function convertDocxToMd(inputPath, outputPath) { + const result = await mammoth.convertToHtml({ path: inputPath }); + const markdown = new TurndownService({ headingStyle: 'atx' }).turndown(result.value); + await fs.writeFile(outputPath, markdown); +} + +async function convertMdToDocx(inputPath, outputPath) { + const markdown = await fs.readFile(inputPath, 'utf8'); + const paragraphs = markdownToDocxParagraphs(markdown); + const doc = new Document({ sections: [{ children: paragraphs }] }); + const buffer = await Packer.toBuffer(doc); + await fs.writeFile(outputPath, buffer); +} + async function extractPdfPageTexts(inputPath) { const data = new Uint8Array(await fs.readFile(inputPath)); const doc = await pdfjsLib.getDocument({ data }).promise; @@ -109,6 +221,8 @@ export function registerDocumentConverters() { register({ family: 'document', sourceFormat: 'html', targetFormat: 'md', convert: convertHtmlToMd }); register({ family: 'document', sourceFormat: 'md', targetFormat: 'pdf', convert: convertMdToPdf }); register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'md', convert: convertPdfToTxt }); + register({ family: 'document', sourceFormat: 'docx', targetFormat: 'md', convert: convertDocxToMd }); + register({ family: 'document', sourceFormat: 'md', targetFormat: 'docx', convert: convertMdToDocx }); register({ family: 'document', sourceFormat: 'docx', targetFormat: 'pdf', convert: convertDocxToPdf }); register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'txt', convert: convertPdfToTxt }); register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'html', convert: convertPdfToHtml }); diff --git a/test/converters/document.test.js b/test/converters/document.test.js index ac64af9..62009a4 100644 --- a/test/converters/document.test.js +++ b/test/converters/document.test.js @@ -112,4 +112,33 @@ describe('document converters', () => { const detected = await detectInputMime(outputPath); expect(detected.mime).toBe('application/pdf'); }, 20000); + + it('converts DOCX to MD containing the source text', async () => { + const outputPath = path.join(tmpDir, 'from-docx.md'); + const entry = resolve('docx', 'md'); + + await entry.convert(docxFixturePath, outputPath); + + const markdown = await fs.readFile(outputPath, 'utf8'); + expect(markdown).toContain('Hello from the fixture document'); + }, 20000); + + it('converts MD to DOCX preserving headings, bold text, and bullet lists', async () => { + const inputPath = path.join(tmpDir, 'fixture-for-docx.md'); + await fs.writeFile( + inputPath, + '# Report Title\n\nSome **bold** finding.\n\n- first point\n- second point\n' + ); + const outputPath = path.join(tmpDir, 'from-md.docx'); + const entry = resolve('md', 'docx'); + + await entry.convert(inputPath, outputPath); + + const mammoth = await import('mammoth'); + const result = await mammoth.default.convertToHtml({ path: outputPath }); + expect(result.value).toContain('Report Title'); + expect(result.value).toContain('bold'); + expect(result.value).toContain('first point'); + expect(result.value).toContain('
  • '); + }, 20000); });