638 lines
23 KiB
TypeScript
638 lines
23 KiB
TypeScript
import { uploadToS3, uploadToS3FromFile, downloadFromS3, isS3Enabled } from "../utils/s3";
|
|
import { uploadsDir } from "../utils/upload";
|
|
import { db } from "../db";
|
|
import { fileUploads, formFields, forms, tasks, taskFieldValues } from "@shared/schema";
|
|
import { eq, and, isNull } from "drizzle-orm";
|
|
import { DocumentTemplateService } from "./template.service";
|
|
import { DataResolutionService } from "./data-resolution.service";
|
|
import { AssetService } from "./asset.service";
|
|
import { PDFConverterService } from "./pdf-converter.service";
|
|
import { DOCXConverterService } from "./docx-converter.service";
|
|
import { convertDocxToPdf } from "./external-pdf.service";
|
|
import { execFile } from "child_process";
|
|
import { promisify } from "util";
|
|
import * as documentStorage from "../storage/documents.storage";
|
|
import { storage } from "../storage/index";
|
|
import type { DocumentTemplate, TemplateVariable } from "@shared/schema";
|
|
import path from "path";
|
|
import fs from "fs/promises";
|
|
import os from "os";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const SCRIPT_PATH = path.resolve(process.cwd(), "server/scripts/docx_form_controls.py");
|
|
|
|
export type OutputFormat = 'pdf' | 'docx' | 'both';
|
|
|
|
export interface GenerateRequest {
|
|
templateId: number;
|
|
taskId: number;
|
|
organizationId: number;
|
|
userId: number;
|
|
outputFormat: OutputFormat;
|
|
}
|
|
|
|
export interface GenerateResult {
|
|
generationId: number;
|
|
pdfUrl?: string;
|
|
docxUrl?: string;
|
|
unresolvedVariables: string[];
|
|
status: string;
|
|
}
|
|
|
|
export interface PreviewResult {
|
|
html: string;
|
|
unresolvedVariables: string[];
|
|
}
|
|
|
|
interface AssetPlacement {
|
|
assetId: number;
|
|
position: 'header' | 'footer' | 'top-right' | 'bottom-right' | 'bottom-left' | 'custom';
|
|
page?: 'first' | 'last' | 'all';
|
|
customX?: number;
|
|
customY?: number;
|
|
width?: number;
|
|
height?: number;
|
|
opacity?: number;
|
|
}
|
|
|
|
interface FileKeyInfo {
|
|
key: string;
|
|
size: number;
|
|
}
|
|
|
|
export class DocumentGenerationService {
|
|
private pdfConverter = new PDFConverterService();
|
|
private docxConverter = new DOCXConverterService();
|
|
|
|
constructor(
|
|
private templateService: DocumentTemplateService,
|
|
private dataResolution: DataResolutionService,
|
|
private assetService: AssetService,
|
|
) {}
|
|
|
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
const template = await this.templateService.getById(request.templateId, request.organizationId);
|
|
if (!template) throw new Error('Шаблон не найден');
|
|
|
|
if (template.sourceDocxFileKey) {
|
|
return this.generateFromDocx(template, request);
|
|
}
|
|
return this.generateFromHtml(template, request);
|
|
}
|
|
|
|
private async generateFromHtml(template: any, request: GenerateRequest): Promise<GenerateResult> {
|
|
const startTime = Date.now();
|
|
|
|
const generation = await documentStorage.createDocumentGeneration({
|
|
organizationId: request.organizationId,
|
|
templateId: request.templateId,
|
|
taskId: request.taskId,
|
|
generatedBy: request.userId,
|
|
templateSnapshot: template.content,
|
|
status: 'processing',
|
|
substitutedData: {},
|
|
});
|
|
|
|
try {
|
|
const { html, unresolvedVariables } = await this.prepareHtml(
|
|
template,
|
|
request.taskId,
|
|
request.organizationId,
|
|
request.userId,
|
|
);
|
|
|
|
const fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo } = {};
|
|
|
|
if (request.outputFormat === 'pdf' || request.outputFormat === 'both') {
|
|
const pdfBuffer = await this.pdfConverter.convert(html, {
|
|
format: template.pageSize || 'A4',
|
|
landscape: template.pageOrientation === 'landscape',
|
|
margin: template.margins || { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
printBackground: true,
|
|
footerTemplate: `
|
|
<div style="font-size:9pt; text-align:center; width:100%; color:#666; padding:0 40px;">
|
|
<span class="pageNumber"></span> / <span class="totalPages"></span>
|
|
</div>
|
|
`,
|
|
});
|
|
const { key } = await uploadToS3(
|
|
pdfBuffer,
|
|
`${template.name || 'document'}-${generation.id}.pdf`,
|
|
'application/pdf',
|
|
);
|
|
fileKeys.pdf = { key, size: pdfBuffer.length };
|
|
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request);
|
|
}
|
|
|
|
if (request.outputFormat === 'docx' || request.outputFormat === 'both') {
|
|
const docxBuffer = await this.docxConverter.convert(html, {
|
|
title: template.name,
|
|
margins: template.margins || { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
});
|
|
const { key } = await uploadToS3(
|
|
docxBuffer,
|
|
`${template.name || 'document'}-${generation.id}.docx`,
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
);
|
|
fileKeys.docx = { key, size: docxBuffer.length };
|
|
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxBuffer.length, request);
|
|
}
|
|
|
|
const { resolved } = await this.dataResolution.resolveAll(
|
|
template.variables,
|
|
request.taskId,
|
|
request.organizationId,
|
|
request.userId,
|
|
);
|
|
|
|
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
|
status: 'completed',
|
|
pdfFileKey: fileKeys.pdf?.key || null,
|
|
docxFileKey: fileKeys.docx?.key || null,
|
|
substitutedData: resolved,
|
|
unresolvedVariables: unresolvedVariables.length > 0 ? unresolvedVariables : null,
|
|
generationDurationMs: Date.now() - startTime,
|
|
});
|
|
|
|
await this.attachToTaskChat(request, template.name || 'document', fileKeys);
|
|
await this.saveToTargetField(template, request, fileKeys);
|
|
|
|
return {
|
|
generationId: generation.id,
|
|
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined,
|
|
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : undefined,
|
|
unresolvedVariables,
|
|
status: 'completed',
|
|
};
|
|
} catch (error) {
|
|
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
|
status: 'failed',
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
generationDurationMs: Date.now() - startTime,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async generateFromDocx(template: any, request: GenerateRequest): Promise<GenerateResult> {
|
|
const startTime = Date.now();
|
|
|
|
const { resolved, unresolved } = await this.dataResolution.resolveAll(
|
|
template.variables,
|
|
request.taskId,
|
|
request.organizationId,
|
|
request.userId,
|
|
);
|
|
|
|
const generation = await documentStorage.createDocumentGeneration({
|
|
organizationId: request.organizationId,
|
|
templateId: request.templateId,
|
|
taskId: request.taskId,
|
|
generatedBy: request.userId,
|
|
templateSnapshot: template.sourceDocxFileKey,
|
|
status: 'processing',
|
|
substitutedData: resolved,
|
|
});
|
|
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'docx-generate-'));
|
|
const sourcePath = path.join(tmpDir, 'source.docx');
|
|
const filledPath = path.join(tmpDir, 'filled.docx');
|
|
|
|
try {
|
|
await downloadFromS3(template.sourceDocxFileKey, sourcePath);
|
|
|
|
// Разделяем скалярные переменные, таблицы и ассеты
|
|
const scalarValues: Record<string, string> = {};
|
|
const tableValues: Record<string, { tabName?: string; columns: any[]; rows: any[] }> = {};
|
|
const assetValues: Record<string, { type: string; path: string; width?: number; height?: number }> = {};
|
|
for (const [code, value] of Object.entries(resolved)) {
|
|
const variable = template.variables.find((v: TemplateVariable) => v.code === code);
|
|
if (variable?.source === 'task_table_tab') {
|
|
try {
|
|
tableValues[code] = JSON.parse(value);
|
|
} catch {
|
|
scalarValues[code] = value;
|
|
}
|
|
} else if (variable?.source === 'asset' && value?.startsWith('__ASSET__:')) {
|
|
const fileKey = value.replace('__ASSET__:', '');
|
|
const assetId = (variable.mapping as Record<string, any>)?.assetId;
|
|
const asset = assetId
|
|
? await documentStorage.getOrganizationAsset(Number(assetId), request.organizationId)
|
|
: undefined;
|
|
const destPath = path.join(tmpDir, `asset-${code}-${Date.now()}${path.extname(fileKey) || '.png'}`);
|
|
await this.downloadAssetFile(fileKey, destPath);
|
|
assetValues[code] = {
|
|
type: 'image',
|
|
path: destPath,
|
|
width: asset?.settings?.width,
|
|
height: asset?.settings?.height,
|
|
};
|
|
} else {
|
|
scalarValues[code] = value;
|
|
}
|
|
}
|
|
|
|
// Подставляем переменные {{var}} через Python-скрипт
|
|
const valuesArgs = Object.entries(scalarValues).map(([k, v]) => `${k}=${v}`);
|
|
const pythonArgs = [SCRIPT_PATH, 'replace', sourcePath, filledPath];
|
|
if (valuesArgs.length > 0) {
|
|
pythonArgs.push(...valuesArgs);
|
|
}
|
|
if (Object.keys(tableValues).length > 0) {
|
|
const tablesPath = path.join(tmpDir, 'tables.json');
|
|
await fs.writeFile(tablesPath, JSON.stringify(tableValues), 'utf-8');
|
|
pythonArgs.push('--tables', tablesPath);
|
|
}
|
|
if (Object.keys(assetValues).length > 0) {
|
|
const assetsPath = path.join(tmpDir, 'assets.json');
|
|
await fs.writeFile(assetsPath, JSON.stringify(assetValues), 'utf-8');
|
|
pythonArgs.push('--assets', assetsPath);
|
|
}
|
|
if (valuesArgs.length > 0 || Object.keys(tableValues).length > 0 || Object.keys(assetValues).length > 0) {
|
|
await execFileAsync('python3', pythonArgs);
|
|
} else {
|
|
await fs.copyFile(sourcePath, filledPath);
|
|
}
|
|
|
|
const fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo } = {};
|
|
|
|
if (request.outputFormat === 'docx' || request.outputFormat === 'both') {
|
|
const { key } = await uploadToS3FromFile(
|
|
filledPath,
|
|
`${template.name || 'document'}-${generation.id}.docx`,
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
);
|
|
const docxSize = (await fs.stat(filledPath)).size;
|
|
fileKeys.docx = { key, size: docxSize };
|
|
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxSize, request);
|
|
}
|
|
|
|
if (request.outputFormat === 'pdf' || request.outputFormat === 'both') {
|
|
const pdfBuffer = await convertDocxToPdf(await fs.readFile(filledPath));
|
|
if (!pdfBuffer) {
|
|
throw new Error('Сервис генерации PDF временно недоступен. Попробуйте позже.');
|
|
}
|
|
const { key } = await uploadToS3(
|
|
pdfBuffer,
|
|
`${template.name || 'document'}-${generation.id}.pdf`,
|
|
'application/pdf',
|
|
);
|
|
fileKeys.pdf = { key, size: pdfBuffer.length };
|
|
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request);
|
|
}
|
|
|
|
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
|
status: 'completed',
|
|
pdfFileKey: fileKeys.pdf?.key || null,
|
|
docxFileKey: fileKeys.docx?.key || null,
|
|
substitutedData: resolved,
|
|
unresolvedVariables: unresolved.length > 0 ? unresolved : null,
|
|
generationDurationMs: Date.now() - startTime,
|
|
});
|
|
|
|
await this.attachToTaskChat(request, template.name || 'document', fileKeys);
|
|
await this.saveToTargetField(template, request, fileKeys);
|
|
|
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
|
|
return {
|
|
generationId: generation.id,
|
|
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined,
|
|
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : undefined,
|
|
unresolvedVariables: unresolved,
|
|
status: 'completed',
|
|
};
|
|
} catch (error) {
|
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
|
status: 'failed',
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
generationDurationMs: Date.now() - startTime,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async preview(templateId: number, taskId: number, organizationId: number, userId: number): Promise<PreviewResult> {
|
|
const template = await this.templateService.getById(templateId, organizationId);
|
|
if (!template) throw new Error('Шаблон не найден');
|
|
return this.prepareHtml(template, taskId, organizationId, userId);
|
|
}
|
|
|
|
private async downloadAssetFile(fileKey: string, destPath: string): Promise<void> {
|
|
if (isS3Enabled) {
|
|
await downloadFromS3(fileKey, destPath);
|
|
} else {
|
|
const src = path.join(uploadsDir, fileKey);
|
|
await fs.copyFile(src, destPath);
|
|
}
|
|
}
|
|
|
|
private async attachToTaskChat(
|
|
request: GenerateRequest,
|
|
templateName: string,
|
|
fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo },
|
|
): Promise<void> {
|
|
try {
|
|
const task = await storage.getTask(request.taskId, request.organizationId);
|
|
if (!task) return;
|
|
|
|
const attachments: any[] = [];
|
|
if (fileKeys.pdf) {
|
|
attachments.push({
|
|
url: `/api/files/${fileKeys.pdf.key}`,
|
|
name: `${templateName}.pdf`,
|
|
mimeType: 'application/pdf',
|
|
size: fileKeys.pdf.size,
|
|
});
|
|
}
|
|
if (fileKeys.docx) {
|
|
attachments.push({
|
|
url: `/api/files/${fileKeys.docx.key}`,
|
|
name: `${templateName}.docx`,
|
|
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
size: fileKeys.docx.size,
|
|
});
|
|
}
|
|
|
|
if (attachments.length === 0) return;
|
|
|
|
await storage.createTaskMessage({
|
|
taskId: request.taskId,
|
|
formId: task.formId,
|
|
authorId: request.userId,
|
|
message: `Сгенерирован документ по шаблону «${templateName}»`,
|
|
messageType: 'system',
|
|
attachments,
|
|
}, request.organizationId);
|
|
} catch (err) {
|
|
console.warn('[Documents] Failed to attach generation to task chat:', err);
|
|
}
|
|
}
|
|
|
|
private async saveToTargetField(
|
|
template: any,
|
|
request: GenerateRequest,
|
|
fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo },
|
|
): Promise<void> {
|
|
const targetFormId = template.targetFormId as number | undefined;
|
|
const targetFieldCode = template.targetFieldCode as string | undefined;
|
|
const targetTaskSource = (template.targetTaskSource as string | undefined) || 'current';
|
|
const targetSourceFieldCode = template.targetSourceFieldCode as string | undefined;
|
|
|
|
if (!targetFormId || !targetFieldCode) return;
|
|
|
|
try {
|
|
const currentTask = await storage.getTask(request.taskId, request.organizationId);
|
|
if (!currentTask) return;
|
|
|
|
let targetTaskId: number | null = null;
|
|
|
|
if (targetTaskSource === 'current') {
|
|
targetTaskId = request.taskId;
|
|
} else if (targetTaskSource === 'related_task_field') {
|
|
if (!targetSourceFieldCode) return;
|
|
const sourceFieldValue = await db
|
|
.select({ value: taskFieldValues.value })
|
|
.from(taskFieldValues)
|
|
.innerJoin(formFields, eq(formFields.id, taskFieldValues.fieldId))
|
|
.where(and(
|
|
eq(taskFieldValues.taskId, request.taskId),
|
|
eq(formFields.code, targetSourceFieldCode),
|
|
))
|
|
.limit(1);
|
|
const rawValue = sourceFieldValue[0]?.value;
|
|
if (rawValue === null || rawValue === undefined || rawValue === '') return;
|
|
if (typeof rawValue === 'number') {
|
|
targetTaskId = rawValue;
|
|
} else if (typeof rawValue === 'string') {
|
|
const parsed = Number(rawValue);
|
|
targetTaskId = Number.isFinite(parsed) ? parsed : null;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!targetTaskId) return;
|
|
|
|
const targetTask = await storage.getTask(targetTaskId, request.organizationId);
|
|
if (!targetTask) return;
|
|
|
|
const [targetField] = await db
|
|
.select()
|
|
.from(formFields)
|
|
.where(and(
|
|
eq(formFields.formId, targetFormId),
|
|
eq(formFields.code, targetFieldCode),
|
|
isNull(formFields.deletedAt),
|
|
))
|
|
.limit(1);
|
|
|
|
if (!targetField || targetField.type !== 'file') {
|
|
console.warn(`[Documents] Target field ${targetFieldCode} is not a file field or not found`);
|
|
return;
|
|
}
|
|
|
|
const existingValues = await storage.getTaskFieldValues(targetTaskId, request.organizationId);
|
|
const existingValue = existingValues.find((v) => v.fieldId === targetField.id);
|
|
const existingFiles: Array<{ url: string; name: string; size: number }> = Array.isArray(existingValue?.value)
|
|
? (existingValue.value as any[])
|
|
: [];
|
|
|
|
const newFiles: Array<{ url: string; name: string; size: number }> = [];
|
|
if (fileKeys.pdf) {
|
|
newFiles.push({
|
|
url: `/api/files/${fileKeys.pdf.key}`,
|
|
name: `${template.name || 'document'}.pdf`,
|
|
size: fileKeys.pdf.size,
|
|
});
|
|
}
|
|
if (fileKeys.docx) {
|
|
newFiles.push({
|
|
url: `/api/files/${fileKeys.docx.key}`,
|
|
name: `${template.name || 'document'}.docx`,
|
|
size: fileKeys.docx.size,
|
|
});
|
|
}
|
|
|
|
const mergedFiles = [...existingFiles, ...newFiles];
|
|
|
|
if (existingValue) {
|
|
await storage.updateTaskFieldValue(targetTaskId, targetField.id, request.organizationId, {
|
|
value: mergedFiles,
|
|
});
|
|
} else {
|
|
await storage.createTaskFieldValue({
|
|
taskId: targetTaskId,
|
|
fieldId: targetField.id,
|
|
formId: targetFormId,
|
|
value: mergedFiles,
|
|
});
|
|
}
|
|
|
|
// Обновляем fileUploads: привязываем сгенерированные файлы к целевой задаче/полю
|
|
for (const file of newFiles) {
|
|
const key = file.url.replace('/api/files/', '');
|
|
await db.update(fileUploads)
|
|
.set({ taskId: targetTaskId, fieldId: targetField.id })
|
|
.where(eq(fileUploads.fileKey, key));
|
|
}
|
|
} catch (err) {
|
|
console.warn('[Documents] Failed to save generated document to target field:', err);
|
|
}
|
|
}
|
|
|
|
private async trackFile(
|
|
key: string,
|
|
originalName: string,
|
|
sizeBytes: number,
|
|
request: GenerateRequest,
|
|
): Promise<void> {
|
|
try {
|
|
await db.insert(fileUploads).values({
|
|
organizationId: request.organizationId,
|
|
uploadedBy: request.userId,
|
|
fileKey: key,
|
|
originalName,
|
|
sizeBytes,
|
|
taskId: request.taskId,
|
|
fieldId: null,
|
|
}).onConflictDoNothing();
|
|
} catch (err) {
|
|
console.warn('[Documents] Failed to track generated file:', err);
|
|
}
|
|
}
|
|
|
|
private async prepareHtml(
|
|
template: DocumentTemplate & { variables: TemplateVariable[] },
|
|
taskId: number,
|
|
organizationId: number,
|
|
userId: number,
|
|
): Promise<{ html: string; unresolvedVariables: string[] }> {
|
|
const { resolved, unresolved } = await this.dataResolution.resolveAll(
|
|
template.variables,
|
|
taskId,
|
|
organizationId,
|
|
userId,
|
|
);
|
|
|
|
let html = this.substituteVariables(template.content, resolved);
|
|
html = await this.injectAssets(html, template.assetPlacements as AssetPlacement[] | null, organizationId);
|
|
html = await this.inlineImagesToBase64(html);
|
|
html = this.wrapHtml(html, template);
|
|
|
|
return { html, unresolvedVariables: unresolved };
|
|
}
|
|
|
|
private substituteVariables(html: string, values: Record<string, string>): string {
|
|
let result = html;
|
|
|
|
// TipTap variables: <span data-variable-name="X">...</span>
|
|
for (const [name, value] of Object.entries(values)) {
|
|
const regex = new RegExp(`(<span[^>]*data-variable-name="${name}"[^>]*>)(.*?)(</span>)`, 'g');
|
|
result = result.replace(regex, `$1${this.escapeHtml(value)}$3`);
|
|
}
|
|
|
|
// Fallback direct {{X}}
|
|
for (const [name, value] of Object.entries(values)) {
|
|
result = result.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, 'g'), this.escapeHtml(value));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
private async injectAssets(
|
|
html: string,
|
|
placements: AssetPlacement[] | null,
|
|
organizationId: number,
|
|
): Promise<string> {
|
|
if (!placements || placements.length === 0) return html;
|
|
|
|
const assets = await this.assetService.getAssetsForGeneration(placements, organizationId);
|
|
if (assets.length === 0) return html;
|
|
|
|
let injected = '';
|
|
for (const asset of assets) {
|
|
const placement = placements.find((p) => p.assetId === asset.assetId);
|
|
if (!placement) continue;
|
|
|
|
const positionStyles: Record<string, string> = {
|
|
header: 'position: absolute; top: 20px; right: 30px;',
|
|
footer: 'position: absolute; bottom: 20px; right: 30px;',
|
|
'top-right': 'position: absolute; top: 50px; right: 50px;',
|
|
'bottom-right': 'position: absolute; bottom: 50px; right: 50px;',
|
|
'bottom-left': 'position: absolute; bottom: 50px; left: 50px;',
|
|
custom: `position: absolute; left: ${placement.customX || 0}px; top: ${placement.customY || 0}px;`,
|
|
};
|
|
|
|
const width = asset.width || placement.width || 150;
|
|
const height = asset.height || placement.height || 150;
|
|
const opacity = asset.opacity || placement.opacity || 0.85;
|
|
|
|
injected += `
|
|
<div style="${positionStyles[placement.position] || ''} width: ${width}px; height: ${height}px; z-index: 100;">
|
|
<img src="${asset.base64}" style="width: 100%; height: 100%; object-fit: contain; opacity: ${opacity};" />
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (injected) {
|
|
return html.replace('</body>', `${injected}</body>`);
|
|
}
|
|
return html;
|
|
}
|
|
|
|
private async inlineImagesToBase64(html: string): Promise<string> {
|
|
const imgRegex = /<img[^>]+src=["']\/api\/files\/([^"']+)["'][^>]*>/g;
|
|
const matches = Array.from(html.matchAll(imgRegex));
|
|
const keys = [...new Set(matches.map((m) => m[1]))];
|
|
|
|
for (const key of keys) {
|
|
try {
|
|
const dataUri = await this.assetService.loadAsBase64(key);
|
|
if (!dataUri) continue;
|
|
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
html = html.replace(
|
|
new RegExp(`/api/files/${escapedKey}`, 'g'),
|
|
dataUri,
|
|
);
|
|
} catch (err) {
|
|
console.warn(`[Generation] Failed to inline image ${key}:`, err);
|
|
}
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
private wrapHtml(html: string, template: DocumentTemplate): string {
|
|
if (html.trim().toLowerCase().startsWith('<!doctype') || html.includes('<html')) {
|
|
return html;
|
|
}
|
|
return `<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>${this.escapeHtml(template.name || 'Document')}</title>
|
|
<style>
|
|
body { font-family: 'Noto Sans', 'DejaVu Sans', sans-serif; margin: 0; padding: 0; }
|
|
img { max-width: 100%; max-height: 80vh; height: auto; width: auto; object-fit: contain; }
|
|
@page { size: ${template.pageSize || 'A4'} ${template.pageOrientation === 'landscape' ? 'landscape' : 'portrait'}; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${html}
|
|
</body>
|
|
</html>`;
|
|
}
|
|
}
|