feat: add md<->docx document converters

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 08:33:31 +02:00
co-authored by Claude Sonnet 5
parent 82533ca436
commit 70c0c5ce6b
2 changed files with 143 additions and 0 deletions
+114
View File
@@ -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 });