Document signing

This commit is contained in:
2026-07-21 14:35:34 +02:00
parent 56b603aba0
commit 6e726ef34d
18 changed files with 1239 additions and 1 deletions
+127 -1
View File
@@ -1,7 +1,133 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" packagePrefix="App\" />
<sourceFolder url="file://$MODULE_DIR$/database/factories" isTestSource="false" packagePrefix="Database\Factories\" />
<sourceFolder url="file://$MODULE_DIR$/database/seeders" isTestSource="false" packagePrefix="Database\Seeders\" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="Tests\" />
<excludeFolder url="file://$MODULE_DIR$/bootstrap/cache" />
<excludeFolder url="file://$MODULE_DIR$/storage/app" />
<excludeFolder url="file://$MODULE_DIR$/storage/framework" />
<excludeFolder url="file://$MODULE_DIR$/vendor/barryvdh/laravel-dompdf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/brick/math" />
<excludeFolder url="file://$MODULE_DIR$/vendor/carbonphp/carbon-doctrine-types" />
<excludeFolder url="file://$MODULE_DIR$/vendor/composer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dflydev/dot-access-data" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/inflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/lexer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/dompdf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-font-lib" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-svg-lib" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dragonmantank/cron-expression" />
<excludeFolder url="file://$MODULE_DIR$/vendor/egulias/email-validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/fakerphp/faker" />
<excludeFolder url="file://$MODULE_DIR$/vendor/filp/whoops" />
<excludeFolder url="file://$MODULE_DIR$/vendor/fruitcake/php-cors" />
<excludeFolder url="file://$MODULE_DIR$/vendor/graham-campbell/result-type" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/guzzle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/promises" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/uri-template" />
<excludeFolder url="file://$MODULE_DIR$/vendor/hamcrest/hamcrest-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/inertiajs/inertia-laravel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/laravel/framework" />
<excludeFolder url="file://$MODULE_DIR$/vendor/laravel/pail" />
<excludeFolder url="file://$MODULE_DIR$/vendor/laravel/pint" />
<excludeFolder url="file://$MODULE_DIR$/vendor/laravel/prompts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/laravel/sail" />
<excludeFolder url="file://$MODULE_DIR$/vendor/laravel/serializable-closure" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/commonmark" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/config" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/flysystem" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/flysystem-local" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/mime-type-detection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/uri" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/uri-interfaces" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/mockery/mockery" />
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nesbot/carbon" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nette/schema" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nette/utils" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nunomaduro/collision" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nunomaduro/termwind" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpoption/phpoption" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-text-template" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-timer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/phpunit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-factory" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-message" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/simple-cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ramsey/collection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ramsey/uuid" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sabberworm/php-css-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/comparator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/complexity" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/diff" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/environment" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/global-state" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/lines-of-code" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-enumerator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-reflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/recursion-context" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/type" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/staabm/side-effects-detector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/console" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/css-selector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-ctype" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-idn" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-mbstring" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php80" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php84" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php85" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php86" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-uuid" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/process" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/routing" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/uid" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
<excludeFolder url="file://$MODULE_DIR$/vendor/thecodingmachine/safe" />
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/tijsverkoyen/css-to-inline-styles" />
<excludeFolder url="file://$MODULE_DIR$/vendor/vlucas/phpdotenv" />
<excludeFolder url="file://$MODULE_DIR$/vendor/voku/portable-ascii" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
Generated
+127
View File
@@ -10,10 +10,137 @@
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/hamcrest/hamcrest-php" />
<path value="$PROJECT_DIR$/vendor/fakerphp/faker" />
<path value="$PROJECT_DIR$/vendor/fruitcake/php-cors" />
<path value="$PROJECT_DIR$/vendor/carbonphp/carbon-doctrine-types" />
<path value="$PROJECT_DIR$/vendor/phpoption/phpoption" />
<path value="$PROJECT_DIR$/vendor/inertiajs/inertia-laravel" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/guzzle" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/promises" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/uri-template" />
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
<path value="$PROJECT_DIR$/vendor/nunomaduro/collision" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
<path value="$PROJECT_DIR$/vendor/nunomaduro/termwind" />
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
<path value="$PROJECT_DIR$/vendor/dragonmantank/cron-expression" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
<path value="$PROJECT_DIR$/vendor/tijsverkoyen/css-to-inline-styles" />
<path value="$PROJECT_DIR$/vendor/graham-campbell/result-type" />
<path value="$PROJECT_DIR$/vendor/barryvdh/laravel-dompdf" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/filp/whoops" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/brick/math" />
<path value="$PROJECT_DIR$/vendor/voku/portable-ascii" />
<path value="$PROJECT_DIR$/vendor/nette/schema" />
<path value="$PROJECT_DIR$/vendor/nette/utils" />
<path value="$PROJECT_DIR$/vendor/league/mime-type-detection" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/league/flysystem-local" />
<path value="$PROJECT_DIR$/vendor/league/commonmark" />
<path value="$PROJECT_DIR$/vendor/league/uri-interfaces" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/league/uri" />
<path value="$PROJECT_DIR$/vendor/league/flysystem" />
<path value="$PROJECT_DIR$/vendor/league/config" />
<path value="$PROJECT_DIR$/vendor/ramsey/collection" />
<path value="$PROJECT_DIR$/vendor/nesbot/carbon" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/ramsey/uuid" />
<path value="$PROJECT_DIR$/vendor/dflydev/dot-access-data" />
<path value="$PROJECT_DIR$/vendor/vlucas/phpdotenv" />
<path value="$PROJECT_DIR$/vendor/laravel/pail" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/laravel/pint" />
<path value="$PROJECT_DIR$/vendor/laravel/serializable-closure" />
<path value="$PROJECT_DIR$/vendor/laravel/prompts" />
<path value="$PROJECT_DIR$/vendor/laravel/framework" />
<path value="$PROJECT_DIR$/vendor/mockery/mockery" />
<path value="$PROJECT_DIR$/vendor/laravel/sail" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php86" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php80" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-ctype" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.5" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PhpUnit">
<phpunit_settings>
<PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" />
</phpunit_settings>
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
@@ -30,6 +30,13 @@ const documentTiles = [
color: '#e67e22',
href: '/teilnahmebescheinigung',
},
{
label: 'Dokument unterschreiben',
description: 'Unterschrift auf ein PDF legen und als „eingescanntes" Dokument speichern.',
icon: 'signature',
color: '#1d4899',
href: '/unterschrift',
},
]
const externalApps = [
@@ -0,0 +1,63 @@
<?php
namespace App\Domains\Unterschrift\Actions\RenderPreview;
use Imagick;
use ImagickPixel;
use RuntimeException;
class RenderPreviewCommand
{
/** Aufloesung fuer die Vorschaubilder (klein halten, Platzierung erfolgt relativ). */
private const PREVIEW_DPI = 96;
/** Maximale Seitenzahl, die verarbeitet wird. */
public const MAX_PAGES = 30;
public function __construct(private readonly RenderPreviewRequest $request) {}
public function execute(): RenderPreviewResponse
{
$doc = new Imagick();
$doc->setResolution(self::PREVIEW_DPI, self::PREVIEW_DPI);
try {
$doc->readImage($this->request->pdfPath);
} catch (\ImagickException $e) {
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 . ').');
}
$pages = [];
for ($i = 0; $i < $pageCount; $i++) {
$doc->setIteratorIndex($i);
$doc->setImageBackgroundColor(new ImagickPixel('white'));
if ($doc->getImageAlphaChannel()) {
$doc->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE);
}
$doc->setImageFormat('png');
$pages[] = [
'dataUri' => 'data:image/png;base64,' . base64_encode($doc->getImageBlob()),
'width' => $doc->getImageWidth(),
'height' => $doc->getImageHeight(),
];
}
$doc->clear();
return new RenderPreviewResponse(pages: $pages, pageCount: $pageCount);
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Domains\Unterschrift\Actions\RenderPreview;
class RenderPreviewRequest
{
public function __construct(
public readonly string $pdfPath,
) {}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Unterschrift\Actions\RenderPreview;
class RenderPreviewResponse
{
public function __construct(
/** @var array<int, array{dataUri: string, width: int, height: int}> */
public readonly array $pages,
public readonly int $pageCount,
) {}
}
@@ -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,
) {}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Domains\Unterschrift\Controllers;
use App\Domains\Unterschrift\Actions\RenderPreview\RenderPreviewCommand;
use App\Domains\Unterschrift\Actions\RenderPreview\RenderPreviewRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class PreviewPageController
{
public function __invoke(Request $request): JsonResponse
{
$request->validate([
'pdf' => 'required|file|mimetypes:application/pdf|max:20480',
]);
try {
$command = new RenderPreviewCommand(
new RenderPreviewRequest($request->file('pdf')->getRealPath())
);
$response = $command->execute();
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json([
'pages' => $response->pages,
'pageCount' => $response->pageCount,
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Unterschrift\Controllers;
use App\Domains\Unterschrift\Actions\SignDocument\SignDocumentCommand;
use App\Domains\Unterschrift\Actions\SignDocument\SignDocumentRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use RuntimeException;
class SignController
{
public function __invoke(Request $request): Response|JsonResponse
{
$request->validate([
'pdf' => 'required|file|mimetypes:application/pdf|max:20480',
'signatur' => 'required|file|mimetypes:image/png,image/jpeg,image/webp|max:8192',
'page' => 'required|integer|min:1',
'xFrac' => 'required|numeric|between:0,1',
'yFrac' => 'required|numeric|between:0,1',
'widthFrac' => 'required|numeric|between:0,1',
'stempel' => 'nullable|file|mimetypes:image/png,image/jpeg,image/webp|max:8192',
'stempelXFrac' => 'nullable|numeric|between:0,1',
'stempelYFrac' => 'nullable|numeric|between:0,1',
'stempelWidthFrac' => 'nullable|numeric|between:0,1',
]);
try {
$signRequest = SignDocumentRequest::fromArray(
$request->all(),
$request->file('pdf')->getRealPath(),
$request->file('signatur')->getRealPath(),
$request->file('stempel')?->getRealPath(),
);
$result = (new SignDocumentCommand($signRequest))->execute();
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response($result->pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="' . $result->filename . '"',
]);
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Unterschrift\Controllers;
use App\Providers\InertiaProvider;
use Inertia\Response;
class UnterschriftController
{
public function __invoke(): Response
{
return (new InertiaProvider('Unterschrift/Unterschrift', []))->render();
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
use App\Domains\Unterschrift\Controllers\PreviewPageController;
use App\Domains\Unterschrift\Controllers\SignController;
use App\Domains\Unterschrift\Controllers\UnterschriftController;
use Illuminate\Support\Facades\Route;
Route::get('/unterschrift', UnterschriftController::class)->name('unterschrift');
Route::post('/unterschrift/preview', PreviewPageController::class)->name('unterschrift.preview');
Route::post('/unterschrift/sign', SignController::class)->name('unterschrift.sign');
@@ -0,0 +1,565 @@
<script setup>
import AppLayout from '@/layouts/AppLayout.vue'
import { ref, computed, getCurrentInstance } from 'vue'
const { proxy } = getCurrentInstance()
const step = ref(1) // 1 = Upload, 2 = Platzieren
const previewLoading = ref(false)
const signLoading = ref(false)
const pdfFile = ref(null)
const signaturFile = ref(null)
const signaturUrl = ref('')
const stempelFile = ref(null) // optional
const stempelUrl = ref('')
const dragPdf = ref(false)
const dragSig = ref(false)
const dragStempel = ref(false)
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']
const pages = ref([]) // [{ dataUri, width, height }]
const selectedPage = ref(1) // 1-basiert
const graustufen = ref(true)
// Platzierung relativ zur Seite (0..1)
const placement = ref({ xFrac: 0.55, yFrac: 0.8, widthFrac: 0.25 })
const stempelPlacement = ref({ xFrac: 0.15, yFrac: 0.78, widthFrac: 0.18 })
const stageRef = ref(null)
const currentPage = computed(() => pages.value[selectedPage.value - 1] ?? null)
const canPreview = computed(() => pdfFile.value && signaturFile.value)
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
}
function setPdf(file) {
if (!file) return
if (file.type !== 'application/pdf') {
proxy.$toast.error('Bitte eine PDF-Datei auswählen.')
return
}
pdfFile.value = file
}
function setSignatur(file) {
if (!file) return
if (!IMAGE_TYPES.includes(file.type)) {
proxy.$toast.error('Bitte ein Bild (PNG, JPG oder WebP) auswählen.')
return
}
signaturFile.value = file
if (signaturUrl.value) URL.revokeObjectURL(signaturUrl.value)
signaturUrl.value = URL.createObjectURL(file)
}
function setStempel(file) {
if (!file) return
if (!IMAGE_TYPES.includes(file.type)) {
proxy.$toast.error('Bitte ein Bild (PNG, JPG oder WebP) auswählen.')
return
}
stempelFile.value = file
if (stempelUrl.value) URL.revokeObjectURL(stempelUrl.value)
stempelUrl.value = URL.createObjectURL(file)
}
function removeStempel() {
stempelFile.value = null
if (stempelUrl.value) URL.revokeObjectURL(stempelUrl.value)
stempelUrl.value = ''
}
function onPdfChange(e) {
setPdf(e.target.files[0] ?? null)
}
function onSignaturChange(e) {
setSignatur(e.target.files[0] ?? null)
}
function onStempelChange(e) {
setStempel(e.target.files[0] ?? null)
}
function onDropPdf(e) {
dragPdf.value = false
setPdf(e.dataTransfer.files[0] ?? null)
}
function onDropSig(e) {
dragSig.value = false
setSignatur(e.dataTransfer.files[0] ?? null)
}
function onDropStempel(e) {
dragStempel.value = false
setStempel(e.dataTransfer.files[0] ?? null)
}
async function loadPreview() {
if (!canPreview.value) return
previewLoading.value = true
try {
const data = new FormData()
data.append('pdf', pdfFile.value)
const res = await fetch('/unterschrift/preview', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf() },
body: data,
})
if (!res.ok) {
if (res.status === 413) {
proxy.$toast.error('Die PDF-Datei ist zu groß.')
return
}
const err = await res.json().catch(() => ({}))
proxy.$toast.error(err.message ?? 'Die Vorschau konnte nicht erstellt werden.')
return
}
const json = await res.json()
pages.value = json.pages
selectedPage.value = 1
step.value = 2
} catch {
proxy.$toast.error('Netzwerkfehler beim Erstellen der Vorschau.')
} finally {
previewLoading.value = false
}
}
// ── Drag von Unterschrift/Stempel ──
let dragState = null
function placementFor(which) {
return which === 'stempel' ? stempelPlacement : placement
}
function startDrag(e, which) {
const rect = stageRef.value.getBoundingClientRect()
const p = placementFor(which).value
dragState = {
which,
startX: e.clientX,
startY: e.clientY,
origX: p.xFrac,
origY: p.yFrac,
w: rect.width,
h: rect.height,
}
window.addEventListener('pointermove', onDrag)
window.addEventListener('pointerup', endDrag)
}
function onDrag(e) {
if (!dragState) return
const p = placementFor(dragState.which).value
const dx = (e.clientX - dragState.startX) / dragState.w
const dy = (e.clientY - dragState.startY) / dragState.h
p.xFrac = clamp(dragState.origX + dx, 0, 1 - p.widthFrac * 0.2)
p.yFrac = clamp(dragState.origY + dy, 0, 0.99)
}
function endDrag() {
dragState = null
window.removeEventListener('pointermove', onDrag)
window.removeEventListener('pointerup', endDrag)
}
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v))
}
async function submitSign() {
signLoading.value = true
try {
const data = new FormData()
data.append('pdf', pdfFile.value)
data.append('signatur', signaturFile.value)
data.append('page', selectedPage.value)
data.append('xFrac', placement.value.xFrac)
data.append('yFrac', placement.value.yFrac)
data.append('widthFrac', placement.value.widthFrac)
data.append('graustufen', graustufen.value ? '1' : '0')
if (stempelFile.value) {
data.append('stempel', stempelFile.value)
data.append('stempelXFrac', stempelPlacement.value.xFrac)
data.append('stempelYFrac', stempelPlacement.value.yFrac)
data.append('stempelWidthFrac', stempelPlacement.value.widthFrac)
}
const res = await fetch('/unterschrift/sign', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf() },
body: data,
})
if (!res.ok) {
if (res.status === 413) {
proxy.$toast.error('Die hochgeladenen Dateien sind zu groß.')
return
}
const err = await res.json().catch(() => ({}))
proxy.$toast.error(err.message ?? 'Das Dokument konnte nicht signiert werden.')
return
}
const blob = await res.blob()
window.open(URL.createObjectURL(blob), '_blank')
} catch {
proxy.$toast.error('Netzwerkfehler beim Signieren.')
} finally {
signLoading.value = false
}
}
function reset() {
step.value = 1
pages.value = []
}
</script>
<template>
<AppLayout title="Dokument unterschreiben">
<div class="page">
<!-- Breadcrumb -->
<div class="page-header">
<nav class="breadcrumb">
<a href="/" class="bc-link">Startseite</a>
<font-awesome-icon icon="chevron-right" class="bc-sep" />
<span class="bc-current">Dokument unterschreiben</span>
</nav>
</div>
<!-- Titel -->
<div class="page-title">
<div class="title-icon">
<font-awesome-icon icon="signature" />
</div>
<div>
<h1 class="title-text">Dokument unterschreiben</h1>
<p class="title-sub">
PDF und Unterschrift hochladen, platzieren und als eingescanntes" Dokument herunterladen.
</p>
</div>
</div>
<!-- ── SCHRITT 1: Upload ── -->
<div v-if="step === 1" class="card upload-card">
<h2 class="card-heading">Dateien auswählen</h2>
<div class="field">
<label class="label">PDF-Dokument</label>
<label
class="dropzone"
:class="{ 'dropzone--active': dragPdf, 'dropzone--filled': pdfFile }"
@dragover.prevent="dragPdf = true"
@dragenter.prevent="dragPdf = true"
@dragleave.prevent="dragPdf = false"
@drop.prevent="onDropPdf"
>
<input type="file" accept="application/pdf" class="dropzone-input" @change="onPdfChange" />
<font-awesome-icon icon="file-pdf" class="dropzone-icon" />
<span v-if="pdfFile" class="dropzone-file">{{ pdfFile.name }}</span>
<span v-else class="dropzone-hint">
PDF hierher ziehen oder <span class="dropzone-link">durchsuchen</span>
</span>
</label>
</div>
<div class="field">
<label class="label">Unterschrift (Foto oder Scan, PNG/JPG)</label>
<label
class="dropzone"
:class="{ 'dropzone--active': dragSig, 'dropzone--filled': signaturFile }"
@dragover.prevent="dragSig = true"
@dragenter.prevent="dragSig = true"
@dragleave.prevent="dragSig = false"
@drop.prevent="onDropSig"
>
<input type="file" accept="image/png,image/jpeg,image/webp" class="dropzone-input" @change="onSignaturChange" />
<template v-if="signaturUrl">
<div class="sig-preview">
<img :src="signaturUrl" alt="Unterschrift" />
</div>
<span class="dropzone-file">{{ signaturFile.name }}</span>
</template>
<template v-else>
<font-awesome-icon icon="signature" class="dropzone-icon" />
<span class="dropzone-hint">
Bild hierher ziehen oder <span class="dropzone-link">durchsuchen</span>
</span>
</template>
</label>
</div>
<div class="field">
<label class="label">
Stempel <span class="optional">(optional)</span>
</label>
<label
class="dropzone"
:class="{ 'dropzone--active': dragStempel, 'dropzone--filled': stempelFile }"
@dragover.prevent="dragStempel = true"
@dragenter.prevent="dragStempel = true"
@dragleave.prevent="dragStempel = false"
@drop.prevent="onDropStempel"
>
<input type="file" accept="image/png,image/jpeg,image/webp" class="dropzone-input" @change="onStempelChange" />
<template v-if="stempelUrl">
<div class="sig-preview">
<img :src="stempelUrl" alt="Stempel" />
</div>
<span class="dropzone-file">{{ stempelFile.name }}</span>
<button type="button" class="dropzone-remove" @click.prevent.stop="removeStempel">
<font-awesome-icon icon="trash" /> Entfernen
</button>
</template>
<template v-else>
<font-awesome-icon icon="stamp" class="dropzone-icon" />
<span class="dropzone-hint">
Stempel hierher ziehen oder <span class="dropzone-link">durchsuchen</span>
</span>
</template>
</label>
</div>
<button class="btn-primary" :disabled="!canPreview || previewLoading" @click="loadPreview">
<font-awesome-icon :icon="previewLoading ? 'file-invoice' : 'chevron-right'" />
{{ previewLoading ? 'Vorschau wird erstellt…' : 'Weiter: Platzieren' }}
</button>
</div>
<!-- ── SCHRITT 2: Platzieren ── -->
<div v-else class="sign-grid">
<!-- Steuerung -->
<div class="card">
<h2 class="card-heading">Platzierung</h2>
<div class="field" v-if="pages.length > 1">
<label class="label">Seite</label>
<div class="page-badges">
<button
v-for="n in pages.length"
:key="n"
class="page-badge"
:class="{ 'page-badge--active': selectedPage === n }"
@click="selectedPage = n"
>{{ n }}</button>
</div>
<p class="hint">Die Unterschrift wird auf Seite {{ selectedPage }} eingefügt.</p>
</div>
<div class="field">
<label class="label">Größe der Unterschrift</label>
<input
type="range" min="0.05" max="0.6" step="0.01"
v-model.number="placement.widthFrac" class="range"
/>
</div>
<div class="field" v-if="stempelFile">
<label class="label">Größe des Stempels</label>
<input
type="range" min="0.05" max="0.6" step="0.01"
v-model.number="stempelPlacement.widthFrac" class="range"
/>
</div>
<label class="check-row">
<input type="checkbox" v-model="graustufen" />
<span>Graustufen (Scan-Look)</span>
</label>
<p class="hint">
Ziehe die Unterschrift<template v-if="stempelFile"> und den Stempel</template>
im Vorschaubild an die gewünschte Stelle.
</p>
<div class="btn-col">
<button class="btn-primary" :disabled="signLoading" @click="submitSign">
<font-awesome-icon :icon="signLoading ? 'file-invoice' : 'download'" />
{{ signLoading ? 'Wird erstellt…' : 'Signieren & herunterladen' }}
</button>
<button class="btn-back" @click="reset">
<font-awesome-icon icon="chevron-left" />
Zurück
</button>
</div>
</div>
<!-- Vorschau mit Overlay -->
<div class="card preview-card">
<div class="stage-wrap">
<div ref="stageRef" class="stage" v-if="currentPage">
<img class="stage-page" :src="currentPage.dataUri" alt="Seitenvorschau" draggable="false" />
<img
v-if="stempelUrl"
class="stage-overlay stage-stempel"
:src="stempelUrl"
alt="Stempel"
draggable="false"
:style="{
left: (stempelPlacement.xFrac * 100) + '%',
top: (stempelPlacement.yFrac * 100) + '%',
width: (stempelPlacement.widthFrac * 100) + '%',
}"
@pointerdown.prevent="startDrag($event, 'stempel')"
/>
<img
v-if="signaturUrl"
class="stage-overlay stage-sig"
:src="signaturUrl"
alt="Unterschrift"
draggable="false"
:style="{
left: (placement.xFrac * 100) + '%',
top: (placement.yFrac * 100) + '%',
width: (placement.widthFrac * 100) + '%',
}"
@pointerdown.prevent="startDrag($event, 'sig')"
/>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
.page { display: flex; flex-direction: column; gap: 20px; }
/* Breadcrumb */
.page-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.breadcrumb { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: #6b7280; }
.bc-link { color: #1d4899; text-decoration: none; }
.bc-link:hover { text-decoration: underline; }
.bc-sep { font-size: 0.7rem; color: #9ca3af; }
.bc-current { color: #374151; font-weight: 500; }
/* Titel */
.page-title { display: flex; align-items: center; gap: 14px; }
.title-icon {
width: 44px; height: 44px; background: #eef2ff; color: #1d4899;
border-radius: 10px; display: flex; align-items: center; justify-content: center;
font-size: 1.2rem; flex-shrink: 0;
}
.title-text { margin: 0 0 3px; font-size: 1.25rem; font-weight: 700; color: #111827; }
.title-sub { margin: 0; font-size: 0.85rem; color: #6b7280; }
/* Karte */
.card {
background: white; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.07);
padding: 24px; border: 1px solid #f0f0f0;
}
.card-heading { margin: 0 0 18px; font-size: 1rem; font-weight: 700; color: #111827; }
.upload-card { max-width: 560px; display: flex; flex-direction: column; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.label { font-size: 0.8rem; font-weight: 600; color: #374151; }
.dropzone {
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; text-align: center;
min-height: 120px; padding: 18px;
border: 2px dashed #d1d5db; border-radius: 10px;
background: #fafafa; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropzone:hover { border-color: #1d4899; background: #f5f7fd; }
.dropzone--active { border-color: #1d4899; background: #eef2ff; }
.dropzone--filled { border-style: solid; border-color: #c7d2fe; background: #fff; }
.dropzone-input {
position: absolute; inset: 0; width: 100%; height: 100%;
opacity: 0; cursor: pointer;
}
.dropzone-icon { font-size: 1.6rem; color: #9ca3af; }
.dropzone--filled .dropzone-icon { color: #1d4899; }
.dropzone-hint { font-size: 0.82rem; color: #6b7280; }
.dropzone-link { color: #1d4899; font-weight: 600; text-decoration: underline; }
.dropzone-file { font-size: 0.8rem; font-weight: 600; color: #374151; word-break: break-all; }
.dropzone-remove {
display: inline-flex; align-items: center; gap: 6px;
background: transparent; border: none; cursor: pointer;
font-size: 0.76rem; color: #ef4444; padding: 2px 4px;
}
.dropzone-remove:hover { text-decoration: underline; }
.optional { font-weight: 500; color: #9ca3af; }
.sig-preview {
padding: 10px; border: 1px solid #f0f0f0; border-radius: 8px;
background: repeating-conic-gradient(#f3f4f6 0% 25%, #fff 0% 50%) 0 / 16px 16px;
max-width: 240px;
}
.sig-preview img { max-width: 100%; max-height: 90px; display: block; }
/* Buttons */
.btn-primary {
display: flex; align-items: center; justify-content: center; gap: 9px;
padding: 11px 16px; background: #1d4899; color: white; border: none;
border-radius: 9px; font-size: 0.92rem; font-weight: 700; cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: #163a7a; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-back {
display: flex; align-items: center; justify-content: center; gap: 8px;
background: white; color: #374151; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 10px 16px; font-size: 0.88rem; font-weight: 600; cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-back:hover { background: #f9fafb; border-color: #d1d5db; }
.btn-col { display: flex; flex-direction: column; gap: 10px; margin-top: 20px; }
/* Schritt 2 Layout */
.sign-grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; align-items: start; }
.page-badges { display: flex; flex-wrap: wrap; gap: 6px; }
.page-badge {
min-width: 34px; padding: 5px 10px; border: 1px solid #e5e7eb; border-radius: 7px;
background: white; font-size: 0.82rem; font-weight: 600; color: #6b7280; cursor: pointer;
transition: all 0.15s;
}
.page-badge:hover { border-color: #1d4899; color: #1d4899; }
.page-badge--active { background: #1d4899; border-color: #1d4899; color: white; }
.range { width: 100%; accent-color: #1d4899; }
.check-row { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: #374151; cursor: pointer; margin-top: 4px; }
.check-row input { accent-color: #1d4899; }
.hint { font-size: 0.78rem; color: #9ca3af; margin: 10px 0 0; line-height: 1.5; }
/* Vorschau-Bühne */
.preview-card { padding: 16px; background: #f5f6fa; }
.stage-wrap { display: flex; justify-content: center; }
.stage {
position: relative; display: inline-block; max-width: 100%;
box-shadow: 0 2px 10px rgba(0,0,0,0.12); background: white;
}
.stage-page { display: block; max-width: 100%; height: auto; user-select: none; }
.stage-overlay {
position: absolute; cursor: grab; user-select: none;
mix-blend-mode: multiply; touch-action: none;
}
.stage-overlay:active { cursor: grabbing; }
.stage-stempel { outline: 1px dashed rgba(29, 72, 153, 0.35); outline-offset: 2px; }
@media (max-width: 900px) {
.sign-grid { grid-template-columns: 1fr; }
}
</style>
+13
View File
@@ -22,9 +22,19 @@ RUN apt-get install -y nginx \
libxml2-dev \
libmagickwand-dev \
imagemagick \
ghostscript \
&& pecl install imagick \
&& docker-php-ext-enable imagick
# ImageMagick blockt PDF-Lesen/-Schreiben per Default (Debian policy.xml).
# Fuer den falsisign-Nachbau (PDF -> Raster -> PDF via imagick/ghostscript)
# muessen PDF-Rechte auf read|write gesetzt werden.
RUN for policy in /etc/ImageMagick-7/policy.xml /etc/ImageMagick-6/policy.xml; do \
if [ -f "$policy" ]; then \
sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' "$policy"; \
fi; \
done
#&& rm -rf /var/lib/apt/lists/* \
RUN mkdir -p /run/nginx
@@ -35,6 +45,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
COPY /docker/php/composer.phar /usr/bin/composer
# Upload-/Speicher-Limits fuer die Bildverarbeitung (zz- laedt zuletzt).
COPY /docker/php/uploads.ini /usr/local/etc/php/conf.d/zz-uploads.ini
# Arbeitsverzeichnis
WORKDIR /var/www/html
+3
View File
@@ -16,6 +16,9 @@ server {
root /var/www/html/public;
index index.php;
# Upload-Groesse (PDF bis 20 MB + Unterschrift/Stempel); muss zu PHP post_max_size passen.
client_max_body_size 40m;
# Logging
access_log /var/log/nginx/nexus.access.log combined;
error_log /var/log/nginx/nexus.error.log warn;
+2
View File
@@ -16,6 +16,7 @@ import {
faGraduationCap, faFileInvoice, faHeart, faCertificate,
faChevronRight, faChevronLeft, faArrowRightFromBracket, faCube,
faTriangleExclamation, faPlus, faFilePdf, faDownload,
faSignature, faStamp,
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -26,6 +27,7 @@ library.add(
faGraduationCap, faFileInvoice, faHeart, faCertificate,
faChevronRight, faChevronLeft, faArrowRightFromBracket, faCube,
faTriangleExclamation, faPlus, faFilePdf, faDownload,
faSignature, faStamp,
)
InertiaProgress.init()
+1
View File
@@ -7,4 +7,5 @@ require_once __DIR__.'/../app/Domains/Schulbefreiung/Routes/web.php';
require_once __DIR__.'/../app/Domains/Rechnung/Routes/web.php';
require_once __DIR__.'/../app/Domains/Spendenquittung/Routes/web.php';
require_once __DIR__.'/../app/Domains/Teilnahmebescheinigung/Routes/web.php';
require_once __DIR__.'/../app/Domains/Unterschrift/Routes/web.php';
require_once __DIR__.'/../app/Domains/Dummy/Routes/web.php';