feat: add md<->docx document converters
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,118 @@ async function convertMdToPdf(inputPath, outputPath) {
|
|||||||
await renderHtmlToPdf(html, 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) {
|
async function extractPdfPageTexts(inputPath) {
|
||||||
const data = new Uint8Array(await fs.readFile(inputPath));
|
const data = new Uint8Array(await fs.readFile(inputPath));
|
||||||
const doc = await pdfjsLib.getDocument({ data }).promise;
|
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: 'html', targetFormat: 'md', convert: convertHtmlToMd });
|
||||||
register({ family: 'document', sourceFormat: 'md', targetFormat: 'pdf', convert: convertMdToPdf });
|
register({ family: 'document', sourceFormat: 'md', targetFormat: 'pdf', convert: convertMdToPdf });
|
||||||
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'md', convert: convertPdfToTxt });
|
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: 'docx', targetFormat: 'pdf', convert: convertDocxToPdf });
|
||||||
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'txt', convert: convertPdfToTxt });
|
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'txt', convert: convertPdfToTxt });
|
||||||
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'html', convert: convertPdfToHtml });
|
register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'html', convert: convertPdfToHtml });
|
||||||
|
|||||||
@@ -112,4 +112,33 @@ describe('document converters', () => {
|
|||||||
const detected = await detectInputMime(outputPath);
|
const detected = await detectInputMime(outputPath);
|
||||||
expect(detected.mime).toBe('application/pdf');
|
expect(detected.mime).toBe('application/pdf');
|
||||||
}, 20000);
|
}, 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('<strong>bold</strong>');
|
||||||
|
expect(result.value).toContain('first point');
|
||||||
|
expect(result.value).toContain('<li>');
|
||||||
|
}, 20000);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user