import {
Document,
Paragraph,
TextRun,
HeadingLevel,
AlignmentType,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
Packer,
} from "docx";
import * as fs from "fs/promises";
import * as path from "path";
import type { DocumentInfo, FormattingOptions } from "../types/document.types.js";
export class DocumentManager {
private documents: Map<string, DocumentInfo> = new Map();
private idCounter = 0;
/**
* Create a new Word document
*/
createDocument(filepath: string, title?: string): string {
const docId = `doc_${++this.idCounter}_${Date.now()}`;
const sections: any[] = [];
const paragraphs: Paragraph[] = [];
if (title) {
const titlePara = new Paragraph({
text: title,
heading: HeadingLevel.TITLE,
});
paragraphs.push(titlePara);
}
const document = new Document({
sections: [
{
properties: {},
children: paragraphs,
},
],
});
this.documents.set(docId, {
id: docId,
filepath,
document,
paragraphs,
created: new Date(),
});
return docId;
}
/**
* Add a heading to the document
*/
addHeading(docId: string, text: string, level: number): void {
const docInfo = this.getDocument(docId);
const headingLevels: { [key: number]: typeof HeadingLevel[keyof typeof HeadingLevel] } = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
5: HeadingLevel.HEADING_5,
};
const heading = new Paragraph({
text,
heading: headingLevels[level] || HeadingLevel.HEADING_1,
});
docInfo.paragraphs.push(heading);
this.updateDocument(docId);
}
/**
* Add a paragraph with optional formatting
*/
addParagraph(docId: string, text: string, formatting?: FormattingOptions): void {
const docInfo = this.getDocument(docId);
const textRunOptions: any = { text };
if (formatting?.bold !== undefined) textRunOptions.bold = formatting.bold;
if (formatting?.italic !== undefined) textRunOptions.italics = formatting.italic;
if (formatting?.underline) textRunOptions.underline = {};
if (formatting?.fontSize !== undefined) textRunOptions.size = formatting.fontSize * 2; // Half-points
if (formatting?.font !== undefined) textRunOptions.font = formatting.font;
if (formatting?.color !== undefined) textRunOptions.color = formatting.color;
const textRun = new TextRun(textRunOptions);
const paragraphOptions: any = {
children: [textRun],
};
const alignment = this.getAlignment(formatting?.alignment);
if (alignment !== undefined) {
paragraphOptions.alignment = alignment;
}
const paragraph = new Paragraph(paragraphOptions);
docInfo.paragraphs.push(paragraph);
this.updateDocument(docId);
}
/**
* Add a bulleted list
*/
addBulletList(docId: string, items: string[]): void {
const docInfo = this.getDocument(docId);
items.forEach((item) => {
const paragraph = new Paragraph({
text: item,
bullet: {
level: 0,
},
});
docInfo.paragraphs.push(paragraph);
});
this.updateDocument(docId);
}
/**
* Add a numbered list
*/
addNumberedList(docId: string, items: string[]): void {
const docInfo = this.getDocument(docId);
items.forEach((item, index) => {
const paragraph = new Paragraph({
text: item,
numbering: {
reference: "default-numbering",
level: 0,
},
});
docInfo.paragraphs.push(paragraph);
});
this.updateDocument(docId);
}
/**
* Add a table
*/
addTable(docId: string, headers: string[], rows: string[][]): void {
const docInfo = this.getDocument(docId);
const tableRows: TableRow[] = [];
// Add header row
const headerRow = new TableRow({
children: headers.map(
(header) =>
new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: header, bold: true })],
})],
shading: {
fill: "D3D3D3",
},
})
),
});
tableRows.push(headerRow);
// Add data rows
rows.forEach((row) => {
const tableRow = new TableRow({
children: row.map(
(cell) =>
new TableCell({
children: [new Paragraph(cell)],
})
),
});
tableRows.push(tableRow);
});
const table = new Table({
rows: tableRows,
width: {
size: 100,
type: WidthType.PERCENTAGE,
},
});
// Tables need to be added differently - we'll add as a section child
docInfo.paragraphs.push(table as any);
this.updateDocument(docId);
}
/**
* Find and replace text in the document
*/
findAndReplace(
docId: string,
findText: string,
replaceText: string,
replaceAll: boolean = true
): number {
const docInfo = this.getDocument(docId);
let replacementCount = 0;
docInfo.paragraphs.forEach((paragraph: any) => {
if (paragraph.root && paragraph.root.length > 0) {
paragraph.root.forEach((element: any) => {
if (element.text) {
if (replaceAll) {
const regex = new RegExp(findText, "g");
if (regex.test(element.text)) {
const matches = element.text.match(regex);
replacementCount += matches ? matches.length : 0;
element.text = element.text.replace(regex, replaceText);
}
} else {
if (element.text.includes(findText) && replacementCount === 0) {
element.text = element.text.replace(findText, replaceText);
replacementCount = 1;
}
}
}
});
}
});
this.updateDocument(docId);
return replacementCount;
}
/**
* Get document structure/outline
*/
getDocumentStructure(docId: string): string {
const docInfo = this.getDocument(docId);
const structure: string[] = [];
docInfo.paragraphs.forEach((para: any, index) => {
const style = para.properties?.style || "Normal";
let text = "";
if (para.root && para.root.length > 0) {
text = para.root.map((r: any) => r.text || "").join("");
}
if (text) {
structure.push(`[${index}] ${style}: ${text.substring(0, 50)}...`);
}
});
return structure.join("\n");
}
/**
* Save document to disk
*/
async saveDocument(docId: string): Promise<string> {
const docInfo = this.getDocument(docId);
// Recreate document with all paragraphs
const document = new Document({
sections: [
{
properties: {},
children: docInfo.paragraphs,
},
],
});
const buffer = await Packer.toBuffer(document);
await fs.writeFile(docInfo.filepath, buffer);
return docInfo.filepath;
}
// Helper methods
private getDocument(docId: string): DocumentInfo {
const doc = this.documents.get(docId);
if (!doc) {
throw new Error(`Document not found: ${docId}`);
}
return doc;
}
private updateDocument(docId: string): void {
const docInfo = this.getDocument(docId);
// Document is updated in memory, will be saved when saveDocument is called
}
private getAlignment(alignment?: string): typeof AlignmentType[keyof typeof AlignmentType] | undefined {
const alignmentMap: { [key: string]: typeof AlignmentType[keyof typeof AlignmentType] } = {
left: AlignmentType.LEFT,
center: AlignmentType.CENTER,
right: AlignmentType.RIGHT,
justified: AlignmentType.JUSTIFIED,
};
return alignment ? alignmentMap[alignment] : undefined;
}
}