import mammoth from "mammoth";
import fs from "fs/promises";
import path from "path";
import { uploadToS3, uploadToS3FromFile } from "../utils/s3";
import * as storage from "../storage/documents.storage";
import { db } from "../db";
import { fileUploads } from "@shared/schema";
import { DocumentTemplateService } from "./template.service";
import type { MulterFile } from "./asset.service";
import { convertEmfToPng } from "./external-pdf.service";
export interface ImportDocxOptions {
name?: string;
description?: string;
categoryId?: number;
formId?: number;
}
export class DocxImporterService {
constructor(private templateService: DocumentTemplateService) {}
async importDocx(
file: MulterFile,
organizationId: number,
userId: number,
options: ImportDocxOptions = {},
) {
if (!file.path) {
throw new Error("Файл не сохранён на диск");
}
const templateName = options.name || this.sanitizeName(file.originalname);
const { html, sourceDocxFileKey, warnings } = await this.processDocxFile(
file,
templateName,
organizationId,
userId,
);
const template = await this.templateService.create({
organizationId,
name: templateName,
description: options.description,
categoryId: options.categoryId,
formId: options.formId,
content: html,
status: "draft",
pageOrientation: "portrait",
pageSize: "A4",
margins: { top: 1440, left: 1440, right: 1440, bottom: 1440 },
assetPlacements: [],
sourceDocxFileKey,
createdBy: userId,
});
const variables = await this.templateService.autoCreateVariablesFromContent(
template.id,
organizationId,
);
return {
template: { ...template, variables },
variables,
warnings,
};
}
async replaceDocx(
file: MulterFile,
templateId: number,
organizationId: number,
userId: number,
) {
if (!file.path) {
throw new Error("Файл не сохранён на диск");
}
const template = await this.templateService.getByIdRaw(templateId, organizationId);
if (!template) throw new Error("Шаблон не найден");
const templateName = this.sanitizeName(file.originalname);
const { html, sourceDocxFileKey, warnings } = await this.processDocxFile(
file,
templateName,
organizationId,
userId,
);
await this.templateService.update(templateId, organizationId, {
name: template.name,
content: html,
sourceDocxFileKey,
});
const variables = await this.templateService.autoCreateVariablesFromContent(
templateId,
organizationId,
);
const updated = await this.templateService.getById(templateId, organizationId);
return {
template: updated!,
variables,
warnings,
};
}
private async processDocxFile(
file: MulterFile,
templateName: string,
organizationId: number,
userId: number,
): Promise<{ html: string; sourceDocxFileKey: string; warnings: string[] }> {
const result = await mammoth.convertToHtml(
{ path: file.path },
{
convertImage: mammoth.images.imgElement(async (image) => {
const buffer = await image.read("base64");
let binary = Buffer.from(buffer, "base64");
let contentType = image.contentType || "image/png";
let ext = this.extFromContentType(contentType);
// Конвертируем EMF/WMF в PNG через внешний document-worker
if (contentType === "image/x-emf" || contentType === "image/x-wmf") {
const png = await convertEmfToPng(binary);
if (!png) {
throw new Error("Сервис конвертации изображений временно недоступен. Не удалось конвертировать EMF/WMF в PNG.");
}
binary = png;
contentType = "image/png";
ext = "png";
}
const originalName = `image-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const { key } = await uploadToS3(binary, originalName, contentType);
await storage.createOrganizationAsset({
organizationId,
type: "image",
label: originalName,
fileKey: key,
userId,
});
await db.insert(fileUploads).values({
organizationId,
uploadedBy: userId,
fileKey: key,
originalName,
sizeBytes: binary.length,
taskId: null,
fieldId: null,
}).onConflictDoNothing();
return { src: `/api/files/${key}` };
}),
},
);
const html = this.sanitizeHtml(result.value);
// Сохраняем исходный DOCX как шаблон
const { key: sourceDocxFileKey } = await uploadToS3FromFile(
file.path,
`${templateName}.docx`,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
await fs.unlink(file.path).catch(() => {});
return {
html,
sourceDocxFileKey,
warnings: result.messages.map((m) => m.message),
};
}
private sanitizeName(originalname: string): string {
const base = path.basename(originalname, path.extname(originalname));
return base.replace(/[\/\\?%*:|<>"]/g, "-").trim() || "Импортированный шаблон";
}
private extFromContentType(contentType: string | undefined): string {
switch (contentType) {
case "image/png":
return "png";
case "image/jpeg":
return "jpg";
case "image/gif":
return "gif";
case "image/webp":
return "webp";
case "image/svg+xml":
return "svg";
default:
return "png";
}
}
private sanitizeHtml(html: string): string {
// mammoth может генерировать
без закрывающего слеша и с \n в src.
// Приводим к виду, удобному для TipTap.
return html
.replace(/\s+/g, " ")
.replace(/
]*?)>/gi, (match, attrs) => {
const srcMatch = /src="([^"]+)"/.exec(attrs);
const src = srcMatch ? srcMatch[1] : "";
const altMatch = /alt="([^"]*)"/.exec(attrs);
const alt = altMatch ? altMatch[1] : "";
return `
`;
})
.trim();
}
}