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 `${alt}`; }) .trim(); } }