Seiten rastern -> Unterschrift auf Zielseite compositen -> zurueck zu PDF. * Kein shell_exec; nutzt die imagick-Extension (+ ghostscript-Delegate). */ class SignDocumentCommand { /** Raster-Aufloesung fuer die Ausgabe (analog falsisign density 150). */ private const OUTPUT_DPI = 150; /** Maximale Seitenzahl, die verarbeitet wird. */ public const MAX_PAGES = 30; public function __construct(private readonly SignDocumentRequest $request) {} public function execute(): SignDocumentResponse { $signature = $this->prepareOverlay($this->request->signaturPath, 'Unterschrift-Bild'); $stamp = $this->request->stempelPath !== null ? $this->prepareOverlay($this->request->stempelPath, 'Stempel-Bild') : null; $doc = new Imagick(); $doc->setResolution(self::OUTPUT_DPI, self::OUTPUT_DPI); try { $doc->readImage($this->request->pdfPath); } catch (\ImagickException $e) { $signature->clear(); $stamp?->clear(); throw new RuntimeException('Das PDF konnte nicht gelesen werden.', 0, $e); } $pageCount = $doc->getNumberImages(); if ($pageCount < 1) { throw new RuntimeException('Das PDF enthält keine Seiten.'); } if ($pageCount > self::MAX_PAGES) { throw new RuntimeException('Das PDF hat zu viele Seiten (max. ' . self::MAX_PAGES . ').'); } $targetIndex = min($this->request->page, $pageCount) - 1; for ($i = 0; $i < $pageCount; $i++) { $doc->setIteratorIndex($i); // Transparenten PDF-Hintergrund gegen Weiss aufloesen (sonst schwarz). $doc->setImageBackgroundColor(new ImagickPixel('white')); if ($doc->getImageAlphaChannel()) { $doc->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE); } if ($i === $targetIndex) { // Stempel zuerst, damit die Unterschrift ggf. darueber liegt. if ($stamp !== null) { $this->composeOverlay( $doc, $stamp, $this->request->stempelXFrac, $this->request->stempelYFrac, $this->request->stempelWidthFrac ); } $this->composeOverlay( $doc, $signature, $this->request->xFrac, $this->request->yFrac, $this->request->widthFrac ); } // Sauberer Scan-Look: Graustufen, aber kein Rauschen/keine Rotation. if ($this->request->graustufen) { $doc->transformImageColorspace(Imagick::COLORSPACE_GRAY); } // Format pro Seite setzen, damit getImagesBlob() ein mehrseitiges PDF liefert. $doc->setImageFormat('pdf'); } $signature->clear(); $stamp?->clear(); $doc->resetIterator(); $pdfContent = $doc->getImagesBlob(); $doc->clear(); return new SignDocumentResponse( pdfContent: $pdfContent, filename: $this->request->filename, ); } /** Overlay (Unterschrift/Stempel) laden, Weiss transparent machen und auf den Inhalt zuschneiden. */ private function prepareOverlay(string $path, string $label): Imagick { $overlay = new Imagick(); try { $overlay->readImage($path); } catch (\ImagickException $e) { throw new RuntimeException('Das ' . $label . ' konnte nicht gelesen werden.', 0, $e); } $overlay->setImageFormat('png'); $overlay->setImageAlphaChannel(Imagick::ALPHACHANNEL_ACTIVATE); $range = $overlay->getQuantumRange()['quantumRangeLong']; // Weiss (inkl. leicht abweichender Scan-/Foto-Toene) transparent setzen. $overlay->transparentPaintImage(new ImagickPixel('white'), 0.0, 0.15 * $range, false); // Ueberfluessigen Rand entfernen, damit die Positionierung exakt ist. $overlay->trimImage(0.1 * $range); $overlay->setImagePage(0, 0, 0, 0); return $overlay; } /** Overlay skaliert an der gewuenschten Position auf die aktuelle Seite legen. */ private function composeOverlay(Imagick $doc, Imagick $overlay, float $xFrac, float $yFrac, float $widthFrac): void { $pageWidth = $doc->getImageWidth(); $pageHeight = $doc->getImageHeight(); $targetWidth = max(1, (int) round($widthFrac * $pageWidth)); $placed = clone $overlay; $placed->resizeImage($targetWidth, 0, Imagick::FILTER_LANCZOS, 1); $x = (int) round($xFrac * $pageWidth); $y = (int) round($yFrac * $pageHeight); $doc->compositeImage($placed, Imagick::COMPOSITE_OVER, $x, $y); $placed->clear(); } }