import { Document, Packer, Paragraph, Table, TableCell, TableRow, TextRun, HeadingLevel, WidthType, BorderStyle, ShadingType } from "docx";
import * as fs from "fs";
import * as path from "path";
import { unified } from "unified";
import remarkParse from "remark-parse";
export interface ReportSection {
heading: string;
content?: string;
table?: {
headers: string[];
rows: string[][];
};
}
export interface ReportData {
filename: string;
title: string;
sections: ReportSection[];
}
export class WordGenerator {
private outputDir: string;
constructor(outputDir: string = "./output") {
this.outputDir = outputDir;
// Ensure output directory exists
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
}
public async generateReport(data: ReportData): Promise<string> {
const doc = new Document({
sections: [
{
children: [
new Paragraph({
text: data.title,
heading: HeadingLevel.TITLE,
spacing: {
after: 200,
},
}),
...this.renderSections(data.sections),
],
},
],
});
const buffer = await Packer.toBuffer(doc);
const fileNameWithExt = data.filename.endsWith(".docx")
? data.filename
: `${data.filename}.docx`;
const filePath = path.join(this.outputDir, fileNameWithExt);
fs.writeFileSync(filePath, buffer);
return filePath;
}
private renderSections(sections: ReportSection[]): (Paragraph | Table)[] {
const elements: (Paragraph | Table)[] = [];
for (const section of sections) {
// detailed heading
elements.push(
new Paragraph({
text: section.heading,
heading: HeadingLevel.HEADING_1,
spacing: {
before: 200,
after: 100,
},
})
);
// parse markdown content using AST
if (section.content) {
const processor = unified().use(remarkParse);
const ast = processor.parse(section.content);
const renderedContent = this.renderAstNode(ast);
elements.push(...renderedContent);
}
// table
if (section.table) {
elements.push(this.createTable(section.table.headers, section.table.rows));
}
}
return elements;
}
private renderAstNode(node: any): (Paragraph | Table)[] {
switch (node.type) {
case 'root':
return node.children.flatMap((child: any) => this.renderAstNode(child));
case 'paragraph':
return [
new Paragraph({
children: this.renderInlineChildrenWithStyle(node.children),
spacing: { after: 100 }
})
];
case 'heading':
// Use explicit typing or any to avoid "is a value" error if HeadingLevel is an object
const levels: Record<number, any> = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
5: HeadingLevel.HEADING_5,
6: HeadingLevel.HEADING_6,
};
return [
new Paragraph({
heading: levels[node.depth] || HeadingLevel.HEADING_1,
children: this.renderInlineChildrenWithStyle(node.children),
spacing: { before: 200, after: 100 }
})
];
case 'list':
return node.children.flatMap((child: any) => this.renderListItem(child, node.ordered));
case 'code':
return [
new Paragraph({
children: [
new TextRun({
text: node.value,
font: "Courier New",
size: 20,
})
],
shading: {
type: ShadingType.CLEAR,
fill: "F5F5F5",
},
spacing: { after: 100 }
})
];
case 'blockquote':
return node.children.flatMap((child: any) => this.renderAstNode(child));
case 'html':
return [];
default:
// console.warn(`Unhandled block definition: ${node.type}`);
return [];
}
}
private renderListItem(node: any, ordered: boolean): (Paragraph | Table)[] {
const results: (Paragraph | Table)[] = [];
node.children.forEach((child: any, index: number) => {
if (child.type === 'paragraph') {
const paraOpts: any = {
children: this.renderInlineChildrenWithStyle(child.children),
spacing: { after: 50 },
};
if (index === 0) {
paraOpts.bullet = { level: 0 };
} else {
paraOpts.indent = { left: 720 };
}
results.push(new Paragraph(paraOpts));
} else if (child.type === 'list') {
results.push(...this.renderAstNode(child));
} else if (child.type === 'code') {
const codeBlock = this.renderAstNode(child)[0];
results.push(codeBlock);
} else {
results.push(...this.renderAstNode(child));
}
});
return results;
}
private renderInlineChildrenWithStyle(nodes: any[], style: { bold?: boolean, italics?: boolean } = {}): TextRun[] {
return nodes.flatMap(node => this.renderInlineNodeWithStyle(node, style));
}
private renderInlineNodeWithStyle(node: any, style: { bold?: boolean, italics?: boolean } = {}): TextRun[] {
switch (node.type) {
case 'text':
return [new TextRun({ text: node.value, bold: style.bold, italics: style.italics })];
case 'strong':
return this.renderInlineChildrenWithStyle(node.children, { ...style, bold: true });
case 'emphasis':
return this.renderInlineChildrenWithStyle(node.children, { ...style, italics: true });
case 'inlineCode':
return [new TextRun({
text: node.value,
font: "Courier New",
bold: style.bold,
italics: style.italics,
shading: { type: ShadingType.CLEAR, fill: "E6E6E6" }
})];
case 'link':
return this.renderInlineChildrenWithStyle(node.children, { ...style, italics: true, color: "0000FF" } as any);
default:
if (node.children) {
return this.renderInlineChildrenWithStyle(node.children, style);
}
return [];
}
}
private createTable(headers: string[], rows: string[][]): Table {
const headerRow = new TableRow({
children: headers.map(
(header) =>
new TableCell({
children: [new Paragraph({ children: [new TextRun({ text: header, bold: true })] })],
width: {
size: 100 / headers.length,
type: WidthType.PERCENTAGE,
}
})
),
});
const dataRows = rows.map(
(row) =>
new TableRow({
children: row.map(
(cell) =>
new TableCell({
children: [new Paragraph(cell)],
width: {
size: 100 / headers.length,
type: WidthType.PERCENTAGE,
}
})
),
})
);
return new Table({
rows: [headerRow, ...dataRows],
width: {
size: 100,
type: WidthType.PERCENTAGE
},
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 },
insideHorizontal: { style: BorderStyle.SINGLE, size: 1 },
insideVertical: { style: BorderStyle.SINGLE, size: 1 },
}
});
}
}