import { Injectable, Logger } from '@nestjs/common'; import { execFile } from 'child_process'; import { promisify } from 'util'; import * as path from 'path'; import * as fs from 'fs/promises'; import * as os from 'os'; const execFileAsync = promisify(execFile); @Injectable() export class PdfService { private readonly logger = new Logger(PdfService.name); /** * Konvertiert eine PDF-Seite in ein PNG-Bild via Ghostscript. * Gibt den Pfad zum temporären Bild zurück. */ async pdfPageToImage(pdfPath: string, page = 1, dpi = 300): Promise { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-')); const outputPath = path.join(tmpDir, `page-${page}.png`); await execFileAsync('gs', [ '-dNOPAUSE', '-dBATCH', '-dNOSAFER', '-sDEVICE=png16m', `-dFirstPage=${page}`, `-dLastPage=${page}`, `-r${dpi}`, `-sOutputFile=${outputPath}`, pdfPath, ]); this.logger.debug(`PDF Seite ${page} konvertiert: ${outputPath}`); return outputPath; } /** * Konvertiert alle Seiten einer PDF in Bilder. * Verwendet einen einzigen Ghostscript-Aufruf mit %d-Platzhalter für alle Seiten. */ async pdfToImages(pdfPath: string, dpi = 200): Promise { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-')); await execFileAsync('gs', [ '-dNOPAUSE', '-dBATCH', '-dNOSAFER', '-sDEVICE=png16m', `-r${dpi}`, `-sOutputFile=${path.join(tmpDir, 'page-%d.png')}`, pdfPath, ]); const entries = await fs.readdir(tmpDir); const images = entries .filter(f => f.endsWith('.png')) .sort((a, b) => { const numA = parseInt(a.match(/\d+/)?.[0] ?? '0', 10); const numB = parseInt(b.match(/\d+/)?.[0] ?? '0', 10); return numA - numB; }) .map(f => path.join(tmpDir, f)); if (images.length === 0) { this.logger.warn(`Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`); } else { this.logger.debug(`PDF konvertiert: ${images.length} Seite(n) in ${tmpDir}`); } return images; } /** * Ermittelt die Seitenanzahl einer PDF via Ghostscript. */ async getPageCount(pdfPath: string): Promise { const { stdout } = await execFileAsync('gs', [ '-q', '-dNODISPLAY', '-dNOSAFER', `-c`, `(${pdfPath.replace(/\\/g, '/')}) (r) file runpdfbegin pdfpagecount = quit`, ]); return parseInt(stdout.trim(), 10) || 1; } /** * Bereinigt eine PDF (entschlüsselt sie ggf. wenn nur Owner-Passwort gesetzt ist) * via Ghostscript pdfwrite. */ async sanitizePdf(inputPath: string): Promise { const outputPath = path.join(os.tmpdir(), `sanitized-${Date.now()}.pdf`); await execFileAsync('gs', [ '-dNOPAUSE', '-dBATCH', '-dNOSAFER', '-sDEVICE=pdfwrite', `-sOutputFile=${outputPath}`, inputPath, ]); return outputPath; } /** * Räumt temporäre Bilder und ihre Verzeichnisse auf. */ async cleanup(imagePaths: string[]): Promise { const dirs = new Set(); for (const imgPath of imagePaths) { try { await fs.unlink(imgPath); } catch {} dirs.add(path.dirname(imgPath)); } for (const dir of dirs) { await fs.rmdir(dir).catch(() => {}); } } }