Document signing
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Unterschrift\Actions\SignDocument;
|
||||
|
||||
use Imagick;
|
||||
use ImagickPixel;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* imagick-Portierung des falsisign-Ablaufs (sauberer Scan, Modus -c):
|
||||
* PDF -> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Unterschrift\Actions\SignDocument;
|
||||
|
||||
class SignDocumentRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $pdfPath,
|
||||
public readonly string $signaturPath,
|
||||
public readonly int $page,
|
||||
public readonly float $xFrac,
|
||||
public readonly float $yFrac,
|
||||
public readonly float $widthFrac,
|
||||
public readonly bool $graustufen,
|
||||
public readonly string $filename,
|
||||
public readonly ?string $stempelPath = null,
|
||||
public readonly float $stempelXFrac = 0.0,
|
||||
public readonly float $stempelYFrac = 0.0,
|
||||
public readonly float $stempelWidthFrac = 0.18,
|
||||
) {}
|
||||
|
||||
public static function fromArray(array $data, string $pdfPath, string $signaturPath, ?string $stempelPath = null): self
|
||||
{
|
||||
return new self(
|
||||
pdfPath: $pdfPath,
|
||||
signaturPath: $signaturPath,
|
||||
page: max(1, (int) ($data['page'] ?? 1)),
|
||||
xFrac: self::clamp((float) ($data['xFrac'] ?? 0)),
|
||||
yFrac: self::clamp((float) ($data['yFrac'] ?? 0)),
|
||||
widthFrac: self::clamp((float) ($data['widthFrac'] ?? 0.2), 0.02, 1.0),
|
||||
graustufen: filter_var($data['graustufen'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||
filename: 'signiert.pdf',
|
||||
stempelPath: $stempelPath,
|
||||
stempelXFrac: self::clamp((float) ($data['stempelXFrac'] ?? 0)),
|
||||
stempelYFrac: self::clamp((float) ($data['stempelYFrac'] ?? 0)),
|
||||
stempelWidthFrac: self::clamp((float) ($data['stempelWidthFrac'] ?? 0.18), 0.02, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
private static function clamp(float $value, float $min = 0.0, float $max = 1.0): float
|
||||
{
|
||||
return max($min, min($max, $value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Unterschrift\Actions\SignDocument;
|
||||
|
||||
class SignDocumentResponse
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $pdfContent,
|
||||
public readonly string $filename,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user