import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as path from 'path'; import * as fs from 'fs/promises'; import sharp from 'sharp'; const THUMBNAIL_WIDTH = 180; @Injectable() export class PageCacheService { private readonly logger = new Logger(PageCacheService.name); private readonly inboxRoot: string; constructor(configService: ConfigService) { this.inboxRoot = configService.get( 'INBOX_DATA_DIR', '/mnt/data/inbox', ); } documentDir(documentId: string): string { return path.join(this.inboxRoot, documentId); } documentPdfPath(documentId: string): string { return path.join(this.documentDir(documentId), 'document.pdf'); } previewPath(documentId: string, page: number): string { return path.join(this.documentDir(documentId), `page-${page}.preview.png`); } thumbnailPath(documentId: string, page: number): string { return path.join(this.documentDir(documentId), `page-${page}.thumb.png`); } /** * Übernimmt die bereits gerenderten 200-dpi-PNGs als preview.png und * erzeugt parallel eine kleinere thumb.png pro Seite. */ async generate(documentId: string, renderedImages: string[]): Promise { const dir = this.documentDir(documentId); await fs.mkdir(dir, { recursive: true }); for (let i = 0; i < renderedImages.length; i++) { const page = i + 1; const src = renderedImages[i]; const previewDest = this.previewPath(documentId, page); const thumbDest = this.thumbnailPath(documentId, page); try { await fs.copyFile(src, previewDest); await sharp(src) .resize({ width: THUMBNAIL_WIDTH }) .png() .toFile(thumbDest); } catch (err: any) { this.logger.warn( `Seiten-Cache fehlgeschlagen (${documentId} Seite ${page}): ${err.message}`, ); } } } /** * Entfernt alle page-*.png-Dateien eines Dokuments (Original-PDF bleibt). */ async clear(documentId: string): Promise { const dir = this.documentDir(documentId); let entries: string[]; try { entries = await fs.readdir(dir); } catch { return; } for (const name of entries) { if (!/^page-\d+\.(preview|thumb)\.png$/.test(name)) continue; await fs.unlink(path.join(dir, name)).catch(() => undefined); } } /** * Verschiebt Page-Cache-Dateien nach dem Löschen einer Seite: * page-N.*.png weg, page-(N+1..oldPageCount) rutschen um 1 nach vorne. */ async shiftAfterPageDelete( documentId: string, deletedPage: number, oldPageCount: number, ): Promise { const dir = this.documentDir(documentId); await fs .unlink(path.join(dir, `page-${deletedPage}.thumb.png`)) .catch(() => undefined); await fs .unlink(path.join(dir, `page-${deletedPage}.preview.png`)) .catch(() => undefined); for (let n = deletedPage + 1; n <= oldPageCount; n++) { for (const variant of ['thumb', 'preview'] as const) { const from = path.join(dir, `page-${n}.${variant}.png`); const to = path.join(dir, `page-${n - 1}.${variant}.png`); try { await fs.rename(from, to); } catch (err: any) { this.logger.warn( `Cache-Shift fehlgeschlagen (${documentId} Seite ${n} ${variant}): ${err.message}`, ); } } } } async hasPreview(documentId: string, page: number): Promise { try { await fs.access(this.previewPath(documentId, page)); return true; } catch { return false; } } }