chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

63
src/Domain/AI/AiModel.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI;
enum AiModel: string
{
// OpenAI Models
case GPT_35_TURBO = 'gpt-3.5-turbo';
case GPT_4 = 'gpt-4';
case GPT_4_TURBO = 'gpt-4-turbo';
case GPT_4O = 'gpt-4o';
case GPT_4O_MINI = 'gpt-4o-mini';
// GPT4All Models
case LLAMA2_7B = 'llama2-7b';
case MISTRAL_7B = 'mistral-7b-instruct';
case CODE_LLAMA_7B = 'codellama-7b-instruct';
case ORCA_MINI_3B = 'orca-mini-3b';
case VICUNA_7B = 'vicuna-7b';
// Ollama Models
case OLLAMA_LLAMA2 = 'llama2';
case OLLAMA_LLAMA2_13B = 'llama2:13b';
case OLLAMA_LLAMA2_70B = 'llama2:70b';
case OLLAMA_MISTRAL = 'mistral';
case OLLAMA_MISTRAL_7B = 'mistral:7b';
case OLLAMA_CODELLAMA = 'codellama';
case OLLAMA_CODELLAMA_13B = 'codellama:13b';
case OLLAMA_GEMMA = 'gemma';
case OLLAMA_GEMMA_7B = 'gemma:7b';
case OLLAMA_NEURAL_CHAT = 'neural-chat';
case OLLAMA_STARLING = 'starling-lm';
case OLLAMA_ORCA_MINI = 'orca-mini';
case OLLAMA_VICUNA = 'vicuna';
case OLLAMA_LLAMA3 = 'llama3';
case OLLAMA_LLAMA3_8B = 'llama3:8b';
case OLLAMA_LLAMA3_70B = 'llama3:70b';
case OLLAMA_PHI3 = 'phi3';
case OLLAMA_QWEN = 'qwen';
case OLLAMA_QWEN2_5 = 'qwen2.5:7b';
case OLLAMA_QWEN2_5_CODER = 'qwen2.5-coder:7b';
case OLLAMA_LLAMA3_1 = 'llama3.1:8b';
case OLLAMA_LLAMA3_2_3B = 'llama3.2:3b';
case OLLAMA_DEEPSEEK_CODER = 'deepseek-coder:6.7b';
public function getProvider(): AiProvider
{
return match($this) {
self::GPT_35_TURBO, self::GPT_4, self::GPT_4_TURBO, self::GPT_4O, self::GPT_4O_MINI => AiProvider::OPENAI,
self::LLAMA2_7B, self::MISTRAL_7B, self::CODE_LLAMA_7B, self::ORCA_MINI_3B, self::VICUNA_7B => AiProvider::GPT4ALL,
self::OLLAMA_LLAMA2, self::OLLAMA_LLAMA2_13B, self::OLLAMA_LLAMA2_70B,
self::OLLAMA_MISTRAL, self::OLLAMA_MISTRAL_7B, self::OLLAMA_CODELLAMA,
self::OLLAMA_CODELLAMA_13B, self::OLLAMA_GEMMA, self::OLLAMA_GEMMA_7B,
self::OLLAMA_NEURAL_CHAT, self::OLLAMA_STARLING, self::OLLAMA_ORCA_MINI,
self::OLLAMA_VICUNA, self::OLLAMA_LLAMA3, self::OLLAMA_LLAMA3_8B,
self::OLLAMA_LLAMA3_70B, self::OLLAMA_PHI3, self::OLLAMA_QWEN,
self::OLLAMA_QWEN2_5, self::OLLAMA_QWEN2_5_CODER, self::OLLAMA_LLAMA3_1, self::OLLAMA_LLAMA3_2_3B, self::OLLAMA_DEEPSEEK_CODER => AiProvider::OLLAMA,
};
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI;
enum AiProvider: string
{
case OPENAI = 'openai';
case GPT4ALL = 'gpt4all';
case OLLAMA = 'ollama';
}

15
src/Domain/AI/AiQuery.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI;
final class AiQuery
{
public function __construct(
public string $message,
public AiModel $model,
public array $messages = [],
public float $temperature = 0.7,
public ?int $maxTokens = null
) {}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI;
interface AiQueryHandlerInterface
{
public function __invoke(AiQuery $query): AiResponse;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI;
final class AiResponse
{
public function __construct(
public string $content,
public string $provider,
public string $model,
public ?int $tokensUsed = null
) {}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI\Exception;
use App\Framework\Exception\FrameworkException;
use App\Domain\AI\AiProvider;
class AiProviderUnavailableException extends FrameworkException
{
public function __construct(AiProvider $provider, string $reason = '')
{
$message = "AI Provider '{$provider->value}' ist nicht verfügbar";
if ($reason) {
$message .= ": $reason";
}
parent::__construct($message);
}
}

11
src/Domain/AI/Role.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Domain\AI;
enum Role:string
{
case SYSTEM = 'system';
case USER = 'user';
case ASSISTANT = 'assistant';
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Domain\Common\ValueObject;
final readonly class Email
{
/**
* @param string $value Email-Adresse
*/
public function __construct(
public string $value
) {
if (empty($value)) {
throw new \InvalidArgumentException('E-Mail-Adresse darf nicht leer sein.');
}
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Ungültige E-Mail-Adresse: ' . $value);
}
// Optional: Einfache Domain-Prüfung ohne externen Aufruf
$domain = substr(strrchr($value, '@'), 1);
if (strlen($domain) < 4 || !str_contains($domain, '.')) {
throw new \InvalidArgumentException('Ungültige Domain in der E-Mail-Adresse: ' . $domain);
}
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Domain\Common\ValueObject;
final readonly class RGBColor
{
private function __construct(
public int $red,
public int $green,
public int $blue
) {
foreach([$red, $green, $blue,
] as $value) {
if ($value < 0 || $value > 255) {
throw new \InvalidArgumentException(
sprintf(
'RGB-Farbwert (%d, %d, %d) muss zwischen 0 und 255 liegen.',
$red, $green, $blue
));
}
}
}
public static function fromHex(string $hex): self
{
$red = hexdec(substr($hex, 0, 2));
$green = hexdec(substr($hex, 2, 2));
$blue = hexdec(substr($hex, 4, 2));
return new self($red, $green, $blue);
}
public static function fromRgb(int $red, int $green, int $blue): self
{
return new self($red, $green, $blue);
}
public function toHex(): string
{
return sprintf('#%02x%02x%02x', $this->red, $this->green, $this->blue);
}
public function toRgb(): string
{
return sprintf('rgb(%d, %d, %d)', $this->red, $this->green, $this->blue);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Domain\Contact;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'contacts')]
final readonly class ContactMessage
{
public function __construct(
public string $name,
public string $email,
public string $message,
){}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Domain\Contact;
use App\Framework\Database\EntityManager;
final readonly class ContactRepository
{
public function __construct(
private EntityManager $entityManager
) {}
public function save(ContactMessage $contact): void
{
$this->entityManager->save($contact);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domain\Contact\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
final readonly class CreateContactTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS contacts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
message TEXT NOT NULL
)
SQL;
$connection->execute($sql);
}
public function down(ConnectionInterface $connection): void
{
$connection->execute('DROP TABLE IF EXISTS contacts');
}
public function getVersion(): string
{
return '005';
}
public function getDescription(): string
{
return 'Create contact table';
}
}

View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
/**
* GD-basierter Bildprozessor als Fallback
*/
final readonly class GdImageProcessor implements ImageProcessorInterface
{
/**
* @inheritDoc
*/
public function createAllVariants(Image $image): array
{
$variants = [];
foreach (ImageVariantConfig::getAllVariants() as $variantConfig) {
try {
$variant = $this->createVariant($image, $variantConfig);
$variants[] = $variant;
} catch (\Exception $e) {
error_log("Failed to create variant {$variantConfig['type']->value}_{$variantConfig['size']->value}_{$variantConfig['format']->value}: " . $e->getMessage());
}
}
return $variants;
}
/**
* @inheritDoc
*/
public function createVariantsForType(Image $image, ImageVariantType $type): array
{
$variants = [];
foreach (ImageVariantConfig::getVariantsForType($type) as $variantConfig) {
try {
$variant = $this->createVariant($image, $variantConfig);
$variants[] = $variant;
} catch (\Exception $e) {
error_log("Failed to create variant {$variantConfig['type']->value}_{$variantConfig['size']->value}_{$variantConfig['format']->value}: " . $e->getMessage());
}
}
return $variants;
}
/**
* Erstellt eine einzelne Bildvariante
*
* @param Image $image
* @param array $config
* @return ImageVariant
*/
private function createVariant(Image $image, array $config): ImageVariant
{
$sourcePath = $image->path . $image->filename;
/** @var ImageVariantType $type */
/** @var ImageSize $size */
/** @var ImageFormat $format */
$type = $config['type'];
$size = $config['size'];
$format = $config['format'];
$width = $config['width'];
$filename = $this->generateVariantFilename(
$image->filename,
$type->value,
$size->value,
$format->value
);
$destination = $image->path . $filename;
$actualDimensions = $this->processImage($sourcePath, $destination, $width, $format);
$fileSize = filesize($destination);
return new ImageVariant(
imageId: $image->ulid,
variantType: $type->value,
size: $size->value,
format: $format->value,
mimeType: $format->getMimeType(),
fileSize: $fileSize,
width: $actualDimensions['width'],
height: $actualDimensions['height'],
filename: $filename,
path: $image->path,
);
}
/**
* Generiert einen Dateinamen für eine Bildvariante
*/
private function generateVariantFilename(string $originalFilename, string $type, string $size, string $format): string
{
$pathInfo = pathinfo($originalFilename);
return $pathInfo['filename'] . "_{$type}_{$size}.{$format}";
}
/**
* Verarbeitet das Bild mit GD
*
* @param string $sourcePath
* @param string $destination
* @param int $maxWidth
* @param ImageFormat $format
* @return array{width: int, height: int}
*/
private function processImage(string $sourcePath, string $destination, int $maxWidth, ImageFormat $format): array
{
$imageInfo = getimagesize($sourcePath);
if ($imageInfo === false) {
throw new \RuntimeException('Could not get image info');
}
$source = $this->createImageFromFile($sourcePath, $imageInfo[2]);
$originalWidth = imagesx($source);
$originalHeight = imagesy($source);
// Nur die Breite begrenzen, Höhe proportional berechnen
$scale = $maxWidth / $originalWidth;
// Nur skalieren wenn nötig
if ($originalWidth > $maxWidth) {
$newWidth = $maxWidth;
$newHeight = (int)round($originalHeight * $scale);
} else {
$newWidth = $originalWidth;
$newHeight = $originalHeight;
// Kopie für konsistente Verarbeitung
$dst = imagecreatetruecolor($newWidth, $newHeight);
imagecopy($dst, $source, 0, 0, 0, 0, $newWidth, $newHeight);
imagedestroy($source);
$this->saveImageInFormat($dst, $destination, $format);
imagedestroy($dst);
return ['width' => $newWidth, 'height' => $newHeight];
}
$dst = imagecreatetruecolor($newWidth, $newHeight);
// Transparenz erhalten
if ($format === ImageFormat::WEBP || $imageInfo[2] === IMAGETYPE_PNG) {
imagealphablending($dst, false);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefilledrectangle($dst, 0, 0, $newWidth, $newHeight, $transparent);
}
// Bessere Qualität mit IMAGICK_FILTER_LANCZOS Equivalent
imagecopyresampled($dst, $source, 0, 0, 0, 0, $newWidth, $newHeight, $originalWidth, $originalHeight);
// Einfache Schärfung bei Verkleinerung
if ($scale < 0.8) {
$this->sharpenImage($dst);
}
$this->saveImageInFormat($dst, $destination, $format);
imagedestroy($source);
imagedestroy($dst);
return ['width' => $newWidth, 'height' => $newHeight];
}
/**
* Erstellt ein Bild aus einer Datei
*/
private function createImageFromFile(string $sourcePath, int $imageType): \GdImage
{
return match ($imageType) {
IMAGETYPE_PNG => imagecreatefrompng($sourcePath),
IMAGETYPE_JPEG => imagecreatefromjpeg($sourcePath),
IMAGETYPE_GIF => imagecreatefromgif($sourcePath),
IMAGETYPE_WEBP => imagecreatefromwebp($sourcePath),
default => throw new \RuntimeException('Unsupported image type'),
};
}
/**
* Speichert ein Bild im angegebenen Format
*/
private function saveImageInFormat(\GdImage $image, string $destination, ImageFormat $format): void
{
match ($format) {
ImageFormat::JPEG => imagejpeg($image, $destination, $format->getQuality()),
ImageFormat::WEBP => imagewebp($image, $destination, $format->getQuality()),
ImageFormat::AVIF => function_exists('imageavif')
? imageavif($image, $destination, $format->getQuality())
: imagewebp($image, $destination, $format->getQuality()), // Fallback auf WebP
};
}
/**
* Einfache Schärfung mit Konvolution
*/
private function sharpenImage(\GdImage $image): void
{
// Einfacher Sharpen-Kernel
$sharpenMatrix = [
[-1, -1, -1],
[-1, 16, -1],
[-1, -1, -1]
];
$divisor = 8;
$offset = 0;
if (function_exists('imageconvolution')) {
imageconvolution($image, $sharpenMatrix, $divisor, $offset);
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Type;
#[Entity(tableName: 'images', idColumn: 'ulid')]
final readonly class Image
{
/** @var ImageVariant[] $variants */
#[Type(ImageVariant::class, foreignKey: 'image_id', localKey: 'ulid')]
public array $variants;
public function __construct(
/*#[Column(name: 'id', primary: true)]
public int $id,*/
#[Column(name: 'ulid', primary: true)]
public string $ulid,
#[Column(name: 'filename')]
public string $filename,
#[Column(name: 'original_filename')]
public string $originalFilename,
#[Column(name: 'mime_type')]
public string $mimeType,
#[Column(name: 'file_size')]
public int $fileSize,
#[Column(name: 'width')]
public int $width,
#[Column(name: 'height')]
public int $height,
#[Column(name: 'hash'/*, unique: true*/)]
public string $hash,
#[Column(name: 'path')]
public string $path,
#[Column(name: 'alt_text')]
public string $altText,
){}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domain\Media;
enum ImageFormat: string
{
case AVIF = 'avif';
case WEBP = 'webp';
case JPEG = 'jpeg';
public function getMimeType(): string
{
return match ($this) {
self::AVIF => 'image/avif',
self::WEBP => 'image/webp',
self::JPEG => 'image/jpeg',
};
}
public function getQuality(): int
{
return match ($this) {
self::AVIF => 80,
self::WEBP => 85,
self::JPEG => 85,
};
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domain\Media;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'image_galleries')]
class ImageGallery
{
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
/**
* Adapter-Klasse für Abwärtskompatibilität
* Delegiert an die konkrete Implementierung
*/
final readonly class ImageProcessor implements ImageProcessorInterface
{
/**
* @var ImageProcessorInterface
*/
private ImageProcessorInterface $processor;
/**
* Konstruktor mit automatischer Dependency-Injection
*/
public function __construct(?ImageProcessorInterface $processor = null)
{
// Wenn kein Processor übergeben wurde, Factory verwenden
$this->processor = $processor ?? ImageProcessorFactory::create();
}
/**
* @inheritDoc
*/
public function createAllVariants(Image $image): array
{
return $this->processor->createAllVariants($image);
}
/**
* @inheritDoc
*/
public function createVariantsForType(Image $image, ImageVariantType $type): array
{
return $this->processor->createVariantsForType($image, $type);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
/**
* Factory-Klasse zum Erstellen des richtigen ImageProcessor
*/
final readonly class ImageProcessorFactory
{
/**
* Erstellt den optimalen ImageProcessor je nach verfügbaren Extensions
*
* @return ImageProcessorInterface Die beste verfügbare Implementation
*/
public static function create(): ImageProcessorInterface
{
// Prüfen ob ImageMagick verfügbar ist
if (extension_loaded('imagick') && class_exists('\Imagick')) {
try {
// Test ob ImageMagick funktioniert
$test = new \Imagick();
$test->clear();
return new ImagickImageProcessor();
} catch (\Exception $e) {
error_log('ImageMagick is installed but not working: ' . $e->getMessage());
}
}
// Fallback auf GD
if (extension_loaded('gd') && function_exists('imagecreatetruecolor')) {
return new GdImageProcessor();
}
throw new \RuntimeException(
'No image processing library available. ' .
'Please install and enable either the ImageMagick or GD PHP extension.'
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
interface ImageProcessorInterface
{
/**
* Erstellt alle konfigurierten Bildvarianten für ein Bild
*
* @param Image $image
* @return array<ImageVariant>
*/
public function createAllVariants(Image $image): array;
/**
* Erstellt alle Bildvarianten für einen bestimmten Typ
*
* @param Image $image
* @param ImageVariantType $type
* @return array<ImageVariant>
*/
public function createVariantsForType(Image $image, ImageVariantType $type): array;
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\EntityManager;
use App\Framework\Database\Transaction;
final readonly class ImageRepository
{
public function __construct(
private EntityManager $entityManager,
private ConnectionInterface $connection,
) {}
public function save(Image $image, string $tempPath): void
{
new SaveImageFile()($image, $tempPath);
$this->entityManager->save($image);
}
public function findBySlotName(string $slotName): Image
{
return $this->entityManager->findOneBy(ImageSlot::class, ['slot_name' => $slotName])->image;
}
public function findById(string $id): ?Image
{
return $this->entityManager->find(Image::class, $id);
}
public function findByFilename(string $filename): ?Image
{
return $this->entityManager->findOneBy(Image::class, ['filename' => $filename]);
}
public function findByHash(string $hash): ?Image
{
return $this->entityManager->findOneBy(Image::class, ['hash' => $hash]);
}
public function findAll(): array
{
return $this->entityManager->findAll(Image::class);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Framework\DateTime\SystemClock;
use App\Framework\Ulid\StringConverter;
use App\Framework\Ulid\Ulid;
final readonly class ImageResizer
{
public function __construct() {}
public function __invoke(
Image $image,
int $maxWidth,
int $maxHeight,
int $quality = 9
): ImageVariant
{
$sourcePath = $image->path . $image->filename;
$filename = str_replace('original', 'thumbnail', $image->filename);
$destination = $image->path . $filename;
$imageInfo = getimagesize($sourcePath);
if($imageInfo === false) {
throw new \RuntimeException('Could not get image info');
}
$imageType = $imageInfo[2];
$source = $this->createImageFromFile($sourcePath, $imageType);
$orignalWidth = imagesx($source);
$orignalHeight = imagesy($source);
$scale = min($maxWidth / $orignalWidth, $maxHeight / $orignalHeight);
$newWidth = (int)round($orignalWidth * $scale);
$newHeight = (int)round($orignalHeight * $scale);
#$src = imagecreatefrompng($sourcePath);
$dst = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($dst, $source, 0, 0, 0, 0, $newWidth, $newHeight, $orignalWidth, $orignalHeight);
$this->saveImage($dst, $destination, $imageType, $quality);
imagedestroy($source);
imagedestroy($dst);
$fileSize = filesize($destination);
// ULID mit StringConverter in binäres Format konvertieren
$binaryImageId = $image->ulid;
return new ImageVariant(
imageId : $binaryImageId,
variantType: 'thumbnail',
format : '',
mimeType : $image->mimeType,
fileSize : $fileSize,
width : $newWidth,
height : $newHeight,
filename : $filename,
path: $image->path,
);
}
private function createImageFromFile(string $sourcePath, mixed $imageType) {
return match ($imageType) {
IMAGETYPE_PNG => imagecreatefrompng($sourcePath),
IMAGETYPE_JPEG => imagecreatefromjpeg($sourcePath),
IMAGETYPE_GIF => imagecreatefromgif($sourcePath),
IMAGETYPE_WEBP => imagecreatefromwebp($sourcePath),
default => throw new \RuntimeException('Unsupported image type'),
};
}
private function saveImage($image, string $destination, int $imageType, int $quality): void
{
match ($imageType) {
IMAGETYPE_JPEG => imagejpeg($image, $destination, $quality),
IMAGETYPE_PNG => imagepng($image, $destination, (int)round(9 - ($quality * 9 / 100))),
IMAGETYPE_GIF => imagegif($image, $destination),
IMAGETYPE_WEBP => imagewebp($image, $destination, $quality),
default => throw new \RuntimeException('Unsupported image type'),
};
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domain\Media;
enum ImageSize: string
{
case SMALL = 'small';
case MEDIUM = 'medium';
case LARGE = 'large';
case XLARGE = 'xlarge';
public function getWidth(ImageVariantType $type): int
{
return match ([$type, $this]) {
[ImageVariantType::THUMBNAIL, self::SMALL] => 150,
[ImageVariantType::THUMBNAIL, self::MEDIUM] => 300,
[ImageVariantType::GALLERY, self::SMALL] => 400,
[ImageVariantType::GALLERY, self::MEDIUM] => 800,
[ImageVariantType::GALLERY, self::LARGE] => 1200,
[ImageVariantType::HERO, self::SMALL] => 768,
[ImageVariantType::HERO, self::MEDIUM] => 1024,
[ImageVariantType::HERO, self::LARGE] => 1920,
[ImageVariantType::HERO, self::XLARGE] => 2560,
default => throw new \InvalidArgumentException("Invalid combination: {$type->value} - {$this->value}"),
};
}
public function getBreakpoint(): ?int
{
return match ($this) {
self::SMALL => 768,
self::MEDIUM => 1024,
self::LARGE => 1920,
self::XLARGE => null, // Keine Breakpoint-Beschränkung
};
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domain\Media;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'image_slots', idColumn: 'id')]
final readonly class ImageSlot
{
public ?Image $image;
public function __construct(
#[Column(name: 'id', primary: true)]
public int $id,
#[Column(name: 'slot_name')]
public string $slotName,
#[Column(name: 'image_id')]
public string $imageId,
){}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domain\Media;
use App\Framework\Database\EntityManager;
final readonly class ImageSlotRepository
{
public function __construct(
private EntityManager $entityManager
){}
public function getSlots(): array
{
return $this->entityManager->findAll(ImageSlot::class);
}
public function findBySlotName(string $slotName): ImageSlot
{
return $this->entityManager->findOneBy(ImageSlot::class, ['slot_name' => $slotName]);
}
public function findById(string $id): ImageSlot
{
return $this->entityManager->find(ImageSlot::class, $id);
}
public function save(ImageSlot $imageSlot): object
{
return $this->entityManager->save($imageSlot);
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
/**
* Erzeugt HTML-Source-Sets für responsive Bilder
*/
final readonly class ImageSourceSetGenerator
{
/**
* Generiert ein vollständiges Picture-Element mit Source-Tags für verschiedene Formate und Größen
*
* @param Image $image Das Originalbild mit seinen Varianten
* @param string $variantType Der Variantentyp (thumbnail, gallery, hero)
* @param array $attributes Zusätzliche Attribute für das img-Tag (alt, class, etc.)
* @return string HTML Picture-Element
*/
public function generatePictureElement(Image $image, string $variantType = 'gallery', array $attributes = []): string
{
$sources = [];
// Varianten nach Format gruppieren
$variantsByFormat = $this->groupVariantsByFormat($image, $variantType);
// Source-Tags für jedes Format generieren
foreach (['avif', 'webp', 'jpeg'] as $format) {
if (isset($variantsByFormat[$format])) {
$sources[] = $this->generateSourceElement($variantsByFormat[$format], ImageFormat::from($format));
}
}
// Fallback img-Tag
$fallbackImage = $this->getFallbackImage($variantsByFormat, $image);
$attributes['src'] = $fallbackImage->getUrl();
$attributes['width'] = $fallbackImage->width;
$attributes['height'] = $fallbackImage->height;
if (!isset($attributes['alt'])) {
$attributes['alt'] = '';
}
$sources[] = $this->generateImgTag($attributes);
return '<picture>' . implode('', $sources) . '</picture>';
}
/**
* Generiert einen einfachen source-Tag mit srcset
*
* @param array $variants Bildvarianten für ein bestimmtes Format
* @param ImageFormat $format Das Bildformat
* @return string HTML source-Element
*/
private function generateSourceElement(array $variants, ImageFormat $format): string
{
$srcset = [];
$sizes = [];
// Nach Größe sortieren (klein nach groß)
usort($variants, function (ImageVariant $a, ImageVariant $b) {
return $a->width <=> $b->width;
});
foreach ($variants as $variant) {
// URL und Breite für srcset
$srcset[] = $variant->getUrl() . ' ' . $variant->width . 'w';
// Sizes basierend auf Breakpoints
$size = ImageSize::from($variant->size);
$breakpoint = $size->getBreakpoint();
if ($breakpoint !== null) {
$sizes[] = "(max-width: {$breakpoint}px) {$variant->width}px";
}
}
// Größte Variante als Standardgröße hinzufügen
$sizes[] = $variants[count($variants) - 1]->width . 'px';
return sprintf(
'<source type="%s" srcset="%s" sizes="%s">',
$format->getMimeType(),
implode(', ', $srcset),
implode(', ', $sizes)
);
}
/**
* Erzeugt einen img-Tag mit den angegebenen Attributen
*
* @param array $attributes HTML-Attribute für das img-Tag
* @return string HTML img-Element
*/
private function generateImgTag(array $attributes): string
{
$htmlAttributes = [];
foreach ($attributes as $name => $value) {
$htmlAttributes[] = sprintf('%s="%s"', $name, htmlspecialchars((string)$value));
}
return '<img ' . implode(' ', $htmlAttributes) . '>';
}
/**
* Gruppiert Bildvarianten nach Format
*
* @param Image $image Das Originalbild
* @param string $variantType Der Variantentyp
* @return array Varianten gruppiert nach Format
*/
private function groupVariantsByFormat(Image $image, string $variantType): array
{
$variantsByFormat = [];
foreach ($image->variants as $variant) {
if ($variant->variantType === $variantType) {
$variantsByFormat[$variant->format][] = $variant;
}
}
return $variantsByFormat;
}
/**
* Wählt das beste Fallback-Bild aus
*
* @param array $variantsByFormat Varianten gruppiert nach Format
* @param Image $image Das Originalbild
* @return ImageVariant Die beste Fallback-Variante
*/
private function getFallbackImage(array $variantsByFormat, Image $image): ImageVariant
{
// Bevorzugen JPEG als Fallback
if (isset($variantsByFormat['jpeg']) && !empty($variantsByFormat['jpeg'])) {
// Mittlere Größe als Fallback verwenden
$variants = $variantsByFormat['jpeg'];
return $variants[min(1, count($variants) - 1)];
}
// Alternativen, falls kein JPEG verfügbar
foreach (['webp', 'avif'] as $format) {
if (isset($variantsByFormat[$format]) && !empty($variantsByFormat[$format])) {
return $variantsByFormat[$format][0];
}
}
// Wenn keine Varianten gefunden wurden, das Originalbild verwenden
return new ImageVariant(
imageId: $image->ulid,
variantType: 'original',
size: 'original',
format: pathinfo($image->filename, PATHINFO_EXTENSION),
mimeType: $image->mimeType,
fileSize: $image->fileSize,
width: $image->width,
height: $image->height,
filename: $image->filename,
path: $image->path,
);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'image_variants')]
final readonly class ImageVariant
{
/*#[Column(name: 'id', primary: true, autoIncrement: true)]
public int $id;*/
public function __construct(
#[Column(name: 'image_id')]
public string $imageId,
#[Column(name: 'variant_type')]
public string $variantType,
// Todo: Implement Size
#[Column(name: 'size')]
public string $size,
#[Column(name: 'format')]
public string $format,
#[Column(name: 'mime_type')]
public string $mimeType,
#[Column(name: 'file_size')]
public int $fileSize,
#[Column(name: 'width')]
public int $width,
#[Column(name: 'height')]
public int $height,
#[Column(name: 'filename')]
public string $filename,
#[Column(name: 'path')]
public string $path,
#[Column(name: 'id', primary: true, autoIncrement: true)]
public ?int $id = null,
) {}
public function getUrl(): string
{
return '/images/'. $this->filename;
}
public function getVariantType(): ImageVariantType
{
return ImageVariantType::from($this->variantType);
}
public function getSize(): ImageSize
{
return ImageSize::from($this->size);
}
public function getFormat(): ImageFormat
{
return ImageFormat::from($this->format);
}
public function getVariantKey(): string
{
return "{$this->variantType}_{$this->size}_{$this->format}";
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
final readonly class ImageVariantConfig
{
public static function getAllVariants(): array
{
$variants = [];
foreach (ImageVariantType::cases() as $type) {
foreach ($type->getSizes() as $size) {
foreach (ImageFormat::cases() as $format) {
$variants[] = [
'type' => $type,
'size' => $size,
'format' => $format,
'width' => $size->getWidth($type),
];
}
}
}
return $variants;
}
public static function getVariantsForType(ImageVariantType $type): array
{
$variants = [];
foreach ($type->getSizes() as $size) {
foreach (ImageFormat::cases() as $format) {
$variants[] = [
'type' => $type,
'size' => $size,
'format' => $format,
'width' => $size->getWidth($type),
];
}
}
return $variants;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Framework\Database\EntityManager;
final class ImageVariantRepository
{
public function __construct(
private EntityManager $entityManager
){}
public function save(ImageVariant $imageVariant): void
{
$this->entityManager->save($imageVariant);
}
public function findByFilename(string $filename): ?ImageVariant {
return $this->entityManager->findOneBy(ImageVariant::class, ['filename' => $filename]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domain\Media;
enum ImageVariantType: string
{
case THUMBNAIL = 'thumbnail';
case GALLERY = 'gallery';
case HERO = 'hero';
public function getSizes(): array
{
return match ($this) {
self::THUMBNAIL => [
ImageSize::SMALL,
ImageSize::MEDIUM,
],
self::GALLERY => [
ImageSize::SMALL,
ImageSize::MEDIUM,
ImageSize::LARGE,
],
self::HERO => [
ImageSize::SMALL,
ImageSize::MEDIUM,
ImageSize::LARGE,
ImageSize::XLARGE,
],
};
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use Imagick;
use ImagickException;
use ImagickPixel;
/**
* ImageMagick-basierter Bildprozessor für optimale Bildqualität
*/
final readonly class ImagickImageProcessor implements ImageProcessorInterface
{
/**
* @inheritDoc
*/
public function createAllVariants(Image $image): array
{
$variants = [];
foreach (ImageVariantConfig::getAllVariants() as $variantConfig) {
try {
$variant = $this->createVariant($image, $variantConfig);
$variants[] = $variant;
} catch (\Exception $e) {
error_log("Failed to create variant {$variantConfig['type']->value}_{$variantConfig['size']->value}_{$variantConfig['format']->value}: " . $e->getMessage());
}
}
return $variants;
}
/**
* @inheritDoc
*/
public function createVariantsForType(Image $image, ImageVariantType $type): array
{
$variants = [];
foreach (ImageVariantConfig::getVariantsForType($type) as $variantConfig) {
try {
$variant = $this->createVariant($image, $variantConfig);
$variants[] = $variant;
} catch (\Exception $e) {
error_log("Failed to create variant {$variantConfig['type']->value}_{$variantConfig['size']->value}_{$variantConfig['format']->value}: " . $e->getMessage());
}
}
return $variants;
}
/**
* Erstellt eine einzelne Bildvariante
*
* @param Image $image
* @param array $config
* @return ImageVariant
*/
private function createVariant(Image $image, array $config): ImageVariant
{
$sourcePath = $image->path . $image->filename;
/** @var ImageVariantType $type */
/** @var ImageSize $size */
/** @var ImageFormat $format */
$type = $config['type'];
$size = $config['size'];
$format = $config['format'];
$width = $config['width'];
$filename = $this->generateVariantFilename(
$image->filename,
$type->value,
$size->value,
$format->value
);
$destination = $image->path . $filename;
$actualDimensions = $this->processImage($sourcePath, $destination, $width, $format);
$fileSize = filesize($destination);
return new ImageVariant(
imageId: $image->ulid,
variantType: $type->value,
size: $size->value,
format: $format->value,
mimeType: $format->getMimeType(),
fileSize: $fileSize,
width: $actualDimensions['width'],
height: $actualDimensions['height'],
filename: $filename,
path: $image->path,
);
}
/**
* Generiert einen Dateinamen für eine Bildvariante
*/
private function generateVariantFilename(string $originalFilename, string $type, string $size, string $format): string
{
$pathInfo = pathinfo($originalFilename);
return $pathInfo['filename'] . "_{$type}_{$size}.{$format}";
}
/**
* Verarbeitet das Bild mit ImageMagick
*
* @param string $sourcePath
* @param string $destination
* @param int $maxWidth
* @param ImageFormat $format
* @return array{width: int, height: int}
*/
private function processImage(string $sourcePath, string $destination, int $maxWidth, ImageFormat $format): array
{
try {
$imagick = new Imagick($sourcePath);
// Bildorientierung korrigieren (EXIF)
$imagick->autoOrientImage();
// Farbprofil entfernen für kleinere Dateien
$imagick->stripImage();
// Bessere Resampling-Methode
$imagick->setImageResolution(72, 72);
// Original-Dimensionen speichern
$originalWidth = $imagick->getImageWidth();
$originalHeight = $imagick->getImageHeight();
// Skalierung nur wenn nötig
if ($originalWidth > $maxWidth) {
$scale = $maxWidth / $originalWidth;
$newWidth = $maxWidth;
$newHeight = (int)round($originalHeight * $scale);
// Lanczos-Filter für beste Qualität
$imagick->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1);
// Schärfen bei Verkleinerung
$this->applySharpeningAfterResize($imagick, $originalWidth, $newWidth);
} else {
$newWidth = $originalWidth;
$newHeight = $originalHeight;
}
// Format-spezifische Optimierungen
$this->optimizeForFormat($imagick, $format);
// Speichern
$imagick->writeImage($destination);
$imagick->destroy();
return ['width' => $newWidth, 'height' => $newHeight];
} catch (ImagickException $e) {
throw new \RuntimeException('ImageMagick error: ' . $e->getMessage(), 0, $e);
}
}
/**
* Wendet formatspezifische Optimierungen an
*/
private function optimizeForFormat(Imagick $imagick, ImageFormat $format): void
{
match ($format) {
ImageFormat::JPEG => $this->optimizeJpeg($imagick),
ImageFormat::WEBP => $this->optimizeWebP($imagick),
ImageFormat::AVIF => $this->optimizeAvif($imagick),
};
}
/**
* Optimiert JPEG-Einstellungen
*/
private function optimizeJpeg(Imagick $imagick): void
{
$imagick->setImageFormat('jpeg');
$imagick->setImageCompressionQuality(ImageFormat::JPEG->getQuality());
$imagick->setImageCompression(Imagick::COMPRESSION_JPEG);
// Progressive JPEG für bessere Ladezeiten
$imagick->setInterlaceScheme(Imagick::INTERLACE_PLANE);
// Chroma-Subsampling für kleinere Dateien
$imagick->setSamplingFactors(['2x2', '1x1', '1x1']);
}
/**
* Optimiert WebP-Einstellungen
*/
private function optimizeWebP(Imagick $imagick): void
{
$imagick->setImageFormat('webp');
$imagick->setImageCompressionQuality(ImageFormat::WEBP->getQuality());
// WebP-spezifische Optionen
$imagick->setOption('webp:lossless', 'false');
$imagick->setOption('webp:alpha-quality', '85');
$imagick->setOption('webp:method', '4'); // Balancierte Kompression
}
/**
* Optimiert AVIF-Einstellungen
*/
private function optimizeAvif(Imagick $imagick): void
{
// Wenn die ImageMagick-Version AVIF direkt unterstützt
if (in_array('AVIF', Imagick::queryFormats())) {
$imagick->setImageFormat('avif');
$imagick->setImageCompressionQuality(ImageFormat::AVIF->getQuality());
// AVIF-spezifische Optionen, falls unterstützt
$imagick->setOption('avif:speed', '6'); // Balancierte Kompression
} else {
// Fallback: Speichern als WebP wenn AVIF nicht unterstützt wird
$this->optimizeWebP($imagick);
}
}
/**
* Wendet intelligente Schärfung an, um Details zu verbessern
*/
private function applySharpeningAfterResize(Imagick $imagick, int $originalWidth, int $newWidth): void
{
$scaleFactor = $newWidth / $originalWidth;
// Nur bei signifikanter Verkleinerung schärfen
if ($scaleFactor < 0.8) {
// Stärke der Schärfung an die Verkleinerung anpassen
$radius = 0.5;
$sigma = 0.5;
$amount = 0.8 + ((1 - $scaleFactor) * 0.3); // Mehr Schärfung bei größerer Verkleinerung
$threshold = 0.05;
$imagick->unsharpMaskImage($radius, $sigma, $amount, $threshold);
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
final readonly class AddSizeToImageVariantsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$connection->execute("ALTER TABLE image_variants ADD COLUMN size VARCHAR(25) NOT NULL DEFAULT ''");
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("ALTER TABLE image_variants DROP COLUMN size");
}
public function getVersion(): string
{
return "005";
}
public function getDescription(): string
{
return "Add Size to Image Slot Table";
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
final readonly class CreateImageSlotsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS image_slots (
id INT AUTO_INCREMENT,
image_id VARCHAR(26) NOT NULL,
slot_name VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (id)
)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
$connection->query($sql);
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS image_slots");
}
public function getVersion(): string
{
return "004";
}
public function getDescription(): string
{
return "Create Image Slot Table";
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
final class CreateImageVariantsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS image_variants (
id INT AUTO_INCREMENT,
image_id VARCHAR(26) NOT NULL,
variant_type VARCHAR(50) NOT NULL,
format VARCHAR(25) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
file_size BIGINT NOT NULL,
filename VARCHAR(500) NOT NULL,
path VARCHAR(500) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_image_variants_combination (image_id, variant_type, format),
CONSTRAINT fk_image_variants_image_id
FOREIGN KEY (image_id) REFERENCES images(ulid) ON DELETE CASCADE,
INDEX idx_image_variants_lookup (image_id, variant_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
$connection->query($sql);
}
public function down(ConnectionInterface $connection): void
{
$this->dropExistingConstraints($connection);
$connection->execute("DROP TABLE IF EXISTS image_variants");
}
public function getVersion(): string
{
return "003";
}
public function getDescription(): string
{
return "Create Image Variant Table";
}
private function dropExistingConstraints(ConnectionInterface $connection): void
{
try {
// Alle bestehenden Foreign Key Constraints für diese Tabelle finden und entfernen
$constraints = $connection->query("
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'image_variants'
AND REFERENCED_TABLE_NAME IS NOT NULL
")->fetchAll();
foreach ($constraints as $constraint) {
$connection->execute('ALTER TABLE image_variants DROP FOREIGN KEY ' . $constraint['CONSTRAINT_NAME']);
}
} catch (\Exception $e) {
// Ignorieren wenn Tabelle nicht existiert oder keine Constraints vorhanden sind
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
final class CreateImagesTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS images (
ulid VARCHAR(26) NOT NULL,
filename VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
file_size BIGINT NOT NULL,
width INT UNSIGNED NOT NULL,
height INT UNSIGNED NOT NULL,
hash VARCHAR(255) NOT NULL,
path VARCHAR(500) NOT NULL,
alt_text TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (ulid),
UNIQUE KEY uk_images_hash (hash),
UNIQUE KEY uk_images_ulid (ulid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL;
$connection->query($sql);
}
public function down(ConnectionInterface $connection): void
{
$connection->execute("DROP TABLE IF EXISTS images");
}
public function getVersion(): string
{
return "002";
}
public function getDescription(): string
{
return "Create Image Table";
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domain\Media\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
final readonly class UpdateImageVariantsConstraint implements Migration
{
public function up(ConnectionInterface $connection): void
{
// Bestehenden Constraint entfernen
$connection->execute("ALTER TABLE image_variants DROP INDEX uk_image_variants_combination");
// Neuen Constraint mit size-Spalte hinzufügen
$connection->execute("ALTER TABLE image_variants ADD UNIQUE KEY uk_image_variants_combination (image_id, variant_type, size, format)");
}
public function down(ConnectionInterface $connection): void
{
// Zurück zum ursprünglichen Constraint
$connection->execute("ALTER TABLE image_variants DROP INDEX uk_image_variants_combination");
$connection->execute("ALTER TABLE image_variants ADD UNIQUE KEY uk_image_variants_combination (image_id, variant_type, format)");
}
public function getVersion(): string
{
return "006";
}
public function getDescription(): string
{
return "Update unique constraint to include size column";
}
}

View File

@@ -0,0 +1,92 @@
# Media Modul
Dieses Modul verwaltet Bilder und erstellt optimierte Varianten für responsive Webseiten.
## Hauptfunktionen
- Automatische Erstellung von Bildvarianten in verschiedenen Größen und Formaten (AVIF, WebP, JPEG)
- Hochwertige Bildoptimierung mit ImageMagick (oder GD als Fallback)
- Responsive Bildausgabe mit HTML Picture-Element und Source-Sets
## Verwendung
### Erzeugen von Bildvarianten
```php
// Direkter Zugriff (automatische Erkennung der besten Implementierung)
$processor = new ImageProcessor();
// Alle Varianten
$variants = $processor->createAllVariants($image);
// Nur bestimmte Typen
$thumbnails = $processor->createVariantsForType($image, ImageVariantType::THUMBNAIL);
// Alternativ via DI Container
$processor = $container->get(ImageProcessorInterface::class);
```
### Spezifische Implementierungen
```php
// ImageMagick direkt verwenden
$imagickProcessor = new ImagickImageProcessor();
$variants = $imagickProcessor->createAllVariants($image);
// Oder GD
$gdProcessor = new GdImageProcessor();
$variants = $gdProcessor->createAllVariants($image);
// Factory für automatische Auswahl
$optimalProcessor = ImageProcessorFactory::create();
```
### HTML für responsive Bilder generieren
```php
$generator = new ImageSourceSetGenerator();
// Einfache Verwendung
$html = $generator->generatePictureElement($image, 'gallery');
// Mit zusätzlichen Attributen
$html = $generator->generatePictureElement($image, 'hero', [
'alt' => 'Beschreibung des Bildes',
'class' => 'hero-image',
'loading' => 'lazy',
]);
```
## Konfiguration
Die Konfiguration der Bildgrößen und -formate erfolgt über Enums:
- `ImageVariantType`: Definiert die Verwendungszwecke (Thumbnail, Gallery, Hero)
- `ImageSize`: Definiert die verfügbaren Größen und deren Breakpoints
- `ImageFormat`: Definiert die unterstützten Formate und deren Qualitätseinstellungen
## Architektur
- `ImageProcessorInterface`: Gemeinsame Schnittstelle für alle Prozessoren
- `ImagickImageProcessor`: Hochwertige Optimierung mit ImageMagick
- `GdImageProcessor`: Fallback-Implementierung mit GD
- `ImageProcessor`: Adapter für einfachen Zugriff und Abwärtskompatibilität
- `ImageProcessorFactory`: Erstellt optimale Implementierung basierend auf verfügbaren Extensions
## Technische Details
- Automatische Auswahl zwischen ImageMagick und GD je nach Verfügbarkeit
- Optimierte Kompression für jedes Format
- Intelligente Schärfung bei Verkleinerung
- Erhaltung von Transparenz bei unterstützten Formaten
## Beispiel: Vollständiges HTML-Ausgabe
```html
<picture>
<source type="image/avif" srcset="image_gallery_small.avif 400w, image_gallery_medium.avif 800w, image_gallery_large.avif 1200w" sizes="(max-width: 768px) 400px, (max-width: 1024px) 800px, 1200px">
<source type="image/webp" srcset="image_gallery_small.webp 400w, image_gallery_medium.webp 800w, image_gallery_large.webp 1200w" sizes="(max-width: 768px) 400px, (max-width: 1024px) 800px, 1200px">
<source type="image/jpeg" srcset="image_gallery_small.jpeg 400w, image_gallery_medium.jpeg 800w, image_gallery_large.jpeg 1200w" sizes="(max-width: 768px) 400px, (max-width: 1024px) 800px, 1200px">
<img src="image_gallery_medium.jpeg" width="800" height="600" alt="Beschreibung" loading="lazy">
</picture>
```

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Framework\Http\UploadedFile;
use function move_uploaded_file;
final readonly class SaveImageFile
{
public function __invoke(Image $image, string $tempFileName): bool
{
$directory = $image->path;
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
return move_uploaded_file($tempFileName, $image->path . $image->filename);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Entity;
use App\Domain\Meta\ValueObject\MetaData;
class MetaEntry
{
public function __construct(
public ?int $id = null,
public ?string $routePattern = null,
public ?string $entityType = null,
public ?int $entityId = null,
public ?MetaData $metaData = null,
public int $priority = 0,
public bool $active = true,
public ?\DateTimeImmutable $createdAt = null,
public ?\DateTimeImmutable $updatedAt = null,
) {
$this->metaData ??= new MetaData();
$this->createdAt ??= new \DateTimeImmutable();
$this->updatedAt ??= new \DateTimeImmutable();
}
public static function forRoute(string $routePattern, MetaData $metaData, int $priority = 0): self
{
return new self(
routePattern: $routePattern,
metaData: $metaData,
priority: $priority
);
}
public static function forEntity(string $entityType, int $entityId, MetaData $metaData, int $priority = 0): self
{
return new self(
entityType: $entityType,
entityId: $entityId,
metaData: $metaData,
priority: $priority
);
}
public function updateMetaData(MetaData $metaData): void
{
$this->metaData = $metaData;
$this->updatedAt = new \DateTimeImmutable();
}
public function activate(): void
{
$this->active = true;
$this->updatedAt = new \DateTimeImmutable();
}
public function deactivate(): void
{
$this->active = false;
$this->updatedAt = new \DateTimeImmutable();
}
public function isRouteEntry(): bool
{
return $this->routePattern !== null;
}
public function isEntityEntry(): bool
{
return $this->entityType !== null && $this->entityId !== null;
}
public function matchesRoute(string $route): bool
{
if (!$this->isRouteEntry()) {
return false;
}
// Exakte Übereinstimmung
if ($this->routePattern === $route) {
return true;
}
// Pattern-Matching (einfache Wildcard-Unterstützung)
$pattern = str_replace(['*', '{', '}'], ['.*', '(?P<', '>[^/]+)'], $this->routePattern);
return (bool) preg_match('#^' . $pattern . '$#', $route);
}
public function matchesEntity(string $entityType, int $entityId): bool
{
return $this->isEntityEntry()
&& $this->entityType === $entityType
&& $this->entityId === $entityId;
}
public function toArray(): array
{
return [
'id' => $this->id,
'route_pattern' => $this->routePattern,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'meta_data' => $this->metaData?->toArray(),
'priority' => $this->priority,
'active' => $this->active,
'created_at' => $this->createdAt?->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt?->format('Y-m-d H:i:s'),
];
}
}

View File

@@ -0,0 +1,405 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Http\Controller;
use App\Domain\Meta\Entity\MetaEntry;
use App\Domain\Meta\Http\Request\MetaRequest;
use App\Domain\Meta\Repository\MetaRepositoryInterface;
use App\Domain\Meta\Service\MetaManager;
use App\Domain\Meta\Service\MetaTemplateResolver;
use App\Domain\Meta\ValueObject\MetaData;
use App\Framework\Http\JsonResult;
use App\Framework\Http\Status;
use App\Framework\Router\Attributes\Route;
use App\Framework\Router\Method;
final class MetaAdminController
{
public function __construct(
private readonly MetaRepositoryInterface $repository,
private readonly MetaManager $metaManager,
private readonly ?MetaTemplateResolver $templateResolver = null,
) {}
/**
* Listet alle Meta-Einträge auf
*/
#[Route(path: '/admin/meta', method: Method::GET)]
public function listMeta(): JsonResult
{
try {
$page = (int) ($_GET['page'] ?? 1);
$limit = (int) ($_GET['limit'] ?? 50);
$search = $_GET['search'] ?? '';
$offset = ($page - 1) * $limit;
if ($search) {
$entries = $this->repository->search($search, $limit);
$total = count($entries);
} else {
$entries = $this->repository->findAll($limit, $offset);
$total = $this->repository->count();
}
return new JsonResult([
'success' => true,
'data' => array_map(fn($entry) => $entry->toArray(), $entries),
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'pages' => ceil($total / $limit),
]
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage()
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Zeigt einen einzelnen Meta-Eintrag
*/
#[Route(path: '/admin/meta/{id}', method: Method::GET)]
public function getMeta(int $id): JsonResult
{
try {
$entry = $this->repository->findById($id);
if (!$entry) {
return new JsonResult([
'success' => false,
'error' => 'Meta-Eintrag nicht gefunden'
], Status::NOT_FOUND);
}
return new JsonResult([
'success' => true,
'data' => $entry->toArray()
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage()
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Erstellt einen neuen Meta-Eintrag
*/
#[Route(path: '/admin/meta', method: Method::POST)]
public function createMeta(MetaRequest $request): JsonResult
{
try {
// Validierung: Entweder Route oder Entity muss angegeben sein
if (!$request->routePattern && (!$request->entityType || !$request->entityId)) {
return new JsonResult([
'success' => false,
'error' => 'Entweder Route-Pattern oder Entity-Typ/ID muss angegeben werden'
], Status::BAD_REQUEST);
}
// Prüfe auf Duplikate
if ($request->routePattern && $this->repository->routePatternExists($request->routePattern)) {
return new JsonResult([
'success' => false,
'error' => 'Route-Pattern existiert bereits'
], Status::CONFLICT);
}
if ($request->entityType && $request->entityId &&
$this->repository->entityMetaExists($request->entityType, $request->entityId)) {
return new JsonResult([
'success' => false,
'error' => 'Meta-Daten für diese Entity existieren bereits'
], Status::CONFLICT);
}
// Template validieren
if ($this->templateResolver) {
$this->validateTemplates($request);
}
$metaData = new MetaData(
title: $request->title,
description: $request->description,
keywords: $request->getKeywordsAsArray(),
ogTitle: $request->ogTitle,
ogDescription: $request->ogDescription,
ogImage: $request->ogImage,
ogType: $request->ogType,
twitterCard: $request->twitterCard,
twitterSite: $request->twitterSite,
canonical: $request->canonical,
customMeta: $request->customMeta
);
$entry = new MetaEntry(
routePattern: $request->routePattern,
entityType: $request->entityType,
entityId: $request->entityId,
metaData: $metaData,
priority: $request->priority,
active: $request->active
);
$savedEntry = $this->repository->save($entry);
// Cache invalidieren
if ($request->routePattern) {
$this->metaManager->invalidateCache($request->routePattern);
}
return new JsonResult([
'success' => true,
'data' => $savedEntry->toArray()
], Status::CREATED);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage()
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Aktualisiert einen Meta-Eintrag
*/
#[Route(path: '/admin/meta/{id}', method: Method::PUT)]
public function updateMeta(int $id, MetaRequest $request): JsonResult
{
try {
$entry = $this->repository->findById($id);
if (!$entry) {
return new JsonResult([
'success' => false,
'error' => 'Meta-Eintrag nicht gefunden'
], Status::NOT_FOUND);
}
// Prüfe auf Duplikate (außer dem aktuellen Eintrag)
if ($request->routePattern &&
$this->repository->routePatternExists($request->routePattern, $id)) {
return new JsonResult([
'success' => false,
'error' => 'Route-Pattern existiert bereits'
], Status::CONFLICT);
}
if ($request->entityType && $request->entityId &&
$this->repository->entityMetaExists($request->entityType, $request->entityId, $id)) {
return new JsonResult([
'success' => false,
'error' => 'Meta-Daten für diese Entity existieren bereits'
], Status::CONFLICT);
}
// Template validieren
if ($this->templateResolver) {
$this->validateTemplates($request);
}
$metaData = new MetaData(
title: $request->title,
description: $request->description,
keywords: $request->getKeywordsAsArray(),
ogTitle: $request->ogTitle,
ogDescription: $request->ogDescription,
ogImage: $request->ogImage,
ogType: $request->ogType,
twitterCard: $request->twitterCard,
twitterSite: $request->twitterSite,
canonical: $request->canonical,
customMeta: $request->customMeta
);
$entry->routePattern = $request->routePattern;
$entry->entityType = $request->entityType;
$entry->entityId = $request->entityId;
$entry->priority = $request->priority;
$entry->active = $request->active;
$entry->updateMetaData($metaData);
$savedEntry = $this->repository->save($entry);
// Cache invalidieren
$this->metaManager->invalidateAllCache();
return new JsonResult([
'success' => true,
'data' => $savedEntry->toArray()
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage()
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Löscht einen Meta-Eintrag
*/
#[Route(path: '/admin/meta/{id}', method: Method::DELETE)]
public function deleteMeta(int $id): JsonResult
{
try {
$entry = $this->repository->findById($id);
if (!$entry) {
return new JsonResult([
'success' => false,
'error' => 'Meta-Eintrag nicht gefunden'
], Status::NOT_FOUND);
}
$deleted = $this->repository->delete($id);
if ($deleted) {
// Cache invalidieren
$this->metaManager->invalidateAllCache();
return new JsonResult([
'success' => true,
'message' => 'Meta-Eintrag erfolgreich gelöscht'
]);
} else {
return new JsonResult([
'success' => false,
'error' => 'Meta-Eintrag konnte nicht gelöscht werden'
], Status::INTERNAL_SERVER_ERROR);
}
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage()
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Aktiviert/Deaktiviert einen Meta-Eintrag
*/
#[Route(path: '/admin/meta/{id}/toggle', method: Method::POST)]
public function toggleMeta(int $id): JsonResult
{
try {
$entry = $this->repository->findById($id);
if (!$entry) {
return new JsonResult([
'success' => false,
'error' => 'Meta-Eintrag nicht gefunden'
], Status::NOT_FOUND);
}
if ($entry->active) {
$entry->deactivate();
} else {
$entry->activate();
}
$savedEntry = $this->repository->save($entry);
// Cache invalidieren
$this->metaManager->invalidateAllCache();
return new JsonResult([
'success' => true,
'data' => $savedEntry->toArray()
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage()
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Validiert Template-Syntax
*/
#[Route(path: '/admin/meta/validate-template', method: Method::POST)]
public function validateTemplate(): JsonResult
{
try {
$template = $_POST['template'] ?? '';
if (!$this->templateResolver) {
return new JsonResult([
'success' => true,
'message' => 'Template-Validation nicht verfügbar'
]);
}
$errors = $this->templateResolver->validateTemplate($template);
$placeholders = $this->templateResolver->extractPlaceholders($template);
return new JsonResult([
'success' => true,
'data' => [
'valid' => empty($errors),
'errors' => $errors,
'placeholders' => $placeholders
]
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage()
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Cache für Meta-Daten leeren
*/
#[Route(path: '/admin/meta/clear-cache', method: Method::POST)]
public function clearCache(): JsonResult
{
try {
$this->metaManager->invalidateAllCache();
return new JsonResult([
'success' => true,
'message' => 'Meta-Cache erfolgreich geleert'
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage()
], Status::INTERNAL_SERVER_ERROR);
}
}
private function validateTemplates(MetaRequest $request): void
{
if (!$this->templateResolver) {
return;
}
$templates = [
'title' => $request->title,
'description' => $request->description,
'ogTitle' => $request->ogTitle,
'ogDescription' => $request->ogDescription,
'canonical' => $request->canonical,
];
foreach ($templates as $field => $template) {
if ($template) {
$errors = $this->templateResolver->validateTemplate($template);
if (!empty($errors)) {
throw new \InvalidArgumentException("Template-Fehler in {$field}: " . implode(', ', $errors));
}
}
}
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Http\Middleware;
use App\Domain\Meta\Service\MetaManager;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\View\RenderContext;
final readonly class MetaMiddleware
{
public function __construct(
private MetaManager $metaManager,
private ?RenderContext $renderContext = null,
) {}
public function handle(Request $request, callable $next): Response
{
// Meta-Daten automatisch für Request laden
$context = $this->extractContextFromRequest($request);
$meta = $this->metaManager->resolveForRequest($request, $context);
// In RenderContext injizieren (falls verfügbar)
if ($this->renderContext && !$meta->isEmpty()) {
$this->renderContext->setMeta($meta);
}
// Meta-Daten für späteren Zugriff am Request speichern
$request->setAttribute('meta', $meta);
$response = $next($request);
// Für HTML-Responses: Meta-Tags automatisch in <head> injizieren
if ($this->shouldInjectMeta($response)) {
$this->injectMetaIntoResponse($response, $meta);
}
return $response;
}
private function extractContextFromRequest(Request $request): array
{
$context = [];
// Route-Parameter hinzufügen
if ($params = $request->getRouteParameters()) {
$context = array_merge($context, $params);
}
// Query-Parameter hinzufügen
if ($query = $request->getQueryParams()) {
$context['query'] = $query;
}
// User-Info hinzufügen (falls verfügbar)
if ($user = $request->getAttribute('user')) {
$context['user'] = $user;
}
return $context;
}
private function shouldInjectMeta(Response $response): bool
{
$contentType = $response->getHeader('Content-Type') ?? '';
return str_contains($contentType, 'text/html') ||
(empty($contentType) && $this->isHtmlContent($response->getBody()));
}
private function isHtmlContent(string $content): bool
{
return str_contains($content, '<html') || str_contains($content, '<head');
}
private function injectMetaIntoResponse(Response $response, \App\Domain\Meta\ValueObject\MetaData $meta): void
{
$content = $response->getBody();
$metaHtml = $meta->render();
if (empty($metaHtml)) {
return;
}
// Versuche Meta-Tags in <head> zu injizieren
if (preg_match('/<head[^>]*>/i', $content, $matches, PREG_OFFSET_CAPTURE)) {
$headStart = $matches[0][1] + strlen($matches[0][0]);
$newContent = substr_replace($content, "\n" . $metaHtml . "\n", $headStart, 0);
$response->setBody($newContent);
}
// Fallback: Vor </head> einfügen
elseif (stripos($content, '</head>') !== false) {
$newContent = str_ireplace('</head>', $metaHtml . "\n</head>", $content);
$response->setBody($newContent);
}
// Letzter Fallback: Nach <html> einfügen
elseif (preg_match('/<html[^>]*>/i', $content)) {
$newContent = preg_replace('/(<html[^>]*>)/i', '$1' . "\n<head>\n" . $metaHtml . "\n</head>", $content);
$response->setBody($newContent);
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Http\Request;
use App\Framework\Validation\Attributes\Required;
use App\Framework\Validation\Attributes\MaxLength;
use App\Framework\Validation\Attributes\Min;
use App\Framework\Validation\Attributes\Max;
use App\Framework\Validation\Attributes\Url;
final class MetaRequest
{
#[MaxLength(255)]
public ?string $routePattern = null;
#[MaxLength(100)]
public ?string $entityType = null;
#[Min(1)]
public ?int $entityId = null;
#[Required]
#[MaxLength(255)]
public ?string $title = null;
#[MaxLength(500)]
public ?string $description = null;
public ?array $keywords = null;
#[MaxLength(255)]
public ?string $ogTitle = null;
#[MaxLength(500)]
public ?string $ogDescription = null;
#[Url]
#[MaxLength(500)]
public ?string $ogImage = null;
#[MaxLength(50)]
public ?string $ogType = null;
#[MaxLength(50)]
public ?string $twitterCard = null;
#[MaxLength(100)]
public ?string $twitterSite = null;
#[Url]
#[MaxLength(500)]
public ?string $canonical = null;
public ?array $customMeta = null;
#[Min(0)]
#[Max(999)]
public int $priority = 0;
public bool $active = true;
public function getKeywordsAsArray(): ?array
{
if ($this->keywords === null) {
return null;
}
if (is_array($this->keywords)) {
return $this->keywords;
}
if (is_string($this->keywords)) {
return array_filter(array_map('trim', explode(',', $this->keywords)));
}
return null;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Interface;
/**
* Interface für Domain-Objekte, die ihre eigenen Meta-Daten bereitstellen können
*/
interface MetaAware
{
/**
* Gibt den Meta-Titel zurück
*/
public function getMetaTitle(): string;
/**
* Gibt die Meta-Beschreibung zurück
*/
public function getMetaDescription(): string;
/**
* Gibt Meta-Keywords zurück
*/
public function getMetaKeywords(): array;
/**
* Gibt das Open Graph Bild zurück
*/
public function getOgImage(): ?string;
/**
* Gibt den Open Graph Typ zurück (optional)
*/
public function getOgType(): string;
/**
* Gibt die kanonische URL zurück (optional)
*/
public function getCanonicalUrl(): ?string;
}

18
src/Domain/Meta/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Meta-Daten Modul
Dieses Modul bietet eine flexible und mächtige Lösung für die Verwaltung von HTML-Meta-Daten in Ihrer Anwendung.
## Features
- **Datenbankbasierte Meta-Verwaltung** - Alle Meta-Daten werden in der Datenbank gespeichert
- **Route-basierte Meta-Daten** - Meta-Daten können spezifischen Routen zugeordnet werden
- **Entity-basierte Meta-Daten** - Meta-Daten für spezifische Domain-Objekte
- **Template-System** - Dynamische Meta-Daten mit Platzhaltern
- **Automatische Injection** - Meta-Tags werden automatisch in HTML-Responses eingefügt
- **Admin-Interface** - RESTful API für die Verwaltung
- **Cache-Unterstützung** - Performance-Optimierung durch Caching
- **Prioritäts-System** - Kontrolle über Meta-Daten Präzedenz
## Installation
1. **Datenbank-Migration ausführen:**

View File

@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Repository;
use App\Domain\Meta\Entity\MetaEntry;
use App\Domain\Meta\ValueObject\MetaData;
use PDO;
final readonly class DatabaseMetaRepository implements MetaRepositoryInterface
{
public function __construct(
private PDO $pdo
) {}
public function findByRoute(string $route): ?MetaEntry
{
$entries = $this->findAllByRoute($route);
return $entries[0] ?? null;
}
public function findByEntity(string $entityType, int $entityId): ?MetaEntry
{
$stmt = $this->pdo->prepare('
SELECT * FROM meta_entries
WHERE entity_type = ? AND entity_id = ? AND active = 1
ORDER BY priority DESC
LIMIT 1
');
$stmt->execute([$entityType, $entityId]);
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
return $this->hydrateFromRow($row);
}
return null;
}
public function findAllByRoute(string $route): array
{
$stmt = $this->pdo->prepare('
SELECT * FROM meta_entries
WHERE route_pattern IS NOT NULL
AND active = 1
ORDER BY priority DESC, LENGTH(route_pattern) DESC
');
$stmt->execute();
$entries = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$entry = $this->hydrateFromRow($row);
if ($entry->matchesRoute($route)) {
$entries[] = $entry;
}
}
return $entries;
}
public function save(MetaEntry $entry): MetaEntry
{
if ($entry->id === null) {
return $this->insert($entry);
} else {
return $this->update($entry);
}
}
private function insert(MetaEntry $entry): MetaEntry
{
$stmt = $this->pdo->prepare('
INSERT INTO meta_entries (
route_pattern, entity_type, entity_id,
title, description, keywords, og_title, og_description,
og_image, og_type, twitter_card, twitter_site,
canonical, custom_meta, priority, active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
');
$metaArray = $entry->metaData->toArray();
$stmt->execute([
$entry->routePattern,
$entry->entityType,
$entry->entityId,
$metaArray['title'] ?? null,
$metaArray['description'] ?? null,
$metaArray['keywords'] ?? null,
$metaArray['og_title'] ?? null,
$metaArray['og_description'] ?? null,
$metaArray['og_image'] ?? null,
$metaArray['og_type'] ?? null,
$metaArray['twitter_card'] ?? null,
$metaArray['twitter_site'] ?? null,
$metaArray['canonical'] ?? null,
$metaArray['custom_meta'] ?? null,
$entry->priority,
$entry->active ? 1 : 0,
$entry->createdAt->format('Y-m-d H:i:s'),
$entry->updatedAt->format('Y-m-d H:i:s'),
]);
$entry->id = (int) $this->pdo->lastInsertId();
return $entry;
}
private function update(MetaEntry $entry): MetaEntry
{
$stmt = $this->pdo->prepare('
UPDATE meta_entries SET
route_pattern = ?, entity_type = ?, entity_id = ?,
title = ?, description = ?, keywords = ?, og_title = ?,
og_description = ?, og_image = ?, og_type = ?, twitter_card = ?,
twitter_site = ?, canonical = ?, custom_meta = ?,
priority = ?, active = ?, updated_at = ?
WHERE id = ?
');
$metaArray = $entry->metaData->toArray();
$entry->updatedAt = new \DateTimeImmutable();
$stmt->execute([
$entry->routePattern,
$entry->entityType,
$entry->entityId,
$metaArray['title'] ?? null,
$metaArray['description'] ?? null,
$metaArray['keywords'] ?? null,
$metaArray['og_title'] ?? null,
$metaArray['og_description'] ?? null,
$metaArray['og_image'] ?? null,
$metaArray['og_type'] ?? null,
$metaArray['twitter_card'] ?? null,
$metaArray['twitter_site'] ?? null,
$metaArray['canonical'] ?? null,
$metaArray['custom_meta'] ?? null,
$entry->priority,
$entry->active ? 1 : 0,
$entry->updatedAt->format('Y-m-d H:i:s'),
$entry->id,
]);
return $entry;
}
public function delete(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM meta_entries WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
public function findById(int $id): ?MetaEntry
{
$stmt = $this->pdo->prepare('SELECT * FROM meta_entries WHERE id = ?');
$stmt->execute([$id]);
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
return $this->hydrateFromRow($row);
}
return null;
}
public function findAll(int $limit = 50, int $offset = 0): array
{
$stmt = $this->pdo->prepare('
SELECT * FROM meta_entries
ORDER BY created_at DESC
LIMIT ? OFFSET ?
');
$stmt->execute([$limit, $offset]);
$entries = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$entries[] = $this->hydrateFromRow($row);
}
return $entries;
}
public function search(string $query, int $limit = 50): array
{
$stmt = $this->pdo->prepare('
SELECT * FROM meta_entries
WHERE route_pattern LIKE ?
OR title LIKE ?
OR description LIKE ?
ORDER BY created_at DESC
LIMIT ?
');
$searchTerm = "%{$query}%";
$stmt->execute([$searchTerm, $searchTerm, $searchTerm, $limit]);
$entries = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$entries[] = $this->hydrateFromRow($row);
}
return $entries;
}
public function count(): int
{
$stmt = $this->pdo->query('SELECT COUNT(*) FROM meta_entries');
return (int) $stmt->fetchColumn();
}
public function routePatternExists(string $routePattern, ?int $excludeId = null): bool
{
$sql = 'SELECT COUNT(*) FROM meta_entries WHERE route_pattern = ?';
$params = [$routePattern];
if ($excludeId !== null) {
$sql .= ' AND id != ?';
$params[] = $excludeId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn() > 0;
}
public function entityMetaExists(string $entityType, int $entityId, ?int $excludeId = null): bool
{
$sql = 'SELECT COUNT(*) FROM meta_entries WHERE entity_type = ? AND entity_id = ?';
$params = [$entityType, $entityId];
if ($excludeId !== null) {
$sql .= ' AND id != ?';
$params[] = $excludeId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn() > 0;
}
private function hydrateFromRow(array $row): MetaEntry
{
$metaData = MetaData::fromArray([
'title' => $row['title'],
'description' => $row['description'],
'keywords' => $row['keywords'],
'og_title' => $row['og_title'],
'og_description' => $row['og_description'],
'og_image' => $row['og_image'],
'og_type' => $row['og_type'],
'twitter_card' => $row['twitter_card'],
'twitter_site' => $row['twitter_site'],
'canonical' => $row['canonical'],
'custom_meta' => $row['custom_meta'],
]);
return new MetaEntry(
id: (int) $row['id'],
routePattern: $row['route_pattern'],
entityType: $row['entity_type'],
entityId: $row['entity_id'] ? (int) $row['entity_id'] : null,
metaData: $metaData,
priority: (int) $row['priority'],
active: (bool) $row['active'],
createdAt: new \DateTimeImmutable($row['created_at']),
updatedAt: new \DateTimeImmutable($row['updated_at']),
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Repository;
use App\Domain\Meta\Entity\MetaEntry;
use App\Domain\Meta\ValueObject\MetaData;
interface MetaRepositoryInterface
{
/**
* Findet Meta-Daten für eine Route
*/
public function findByRoute(string $route): ?MetaEntry;
/**
* Findet Meta-Daten für eine Entity
*/
public function findByEntity(string $entityType, int $entityId): ?MetaEntry;
/**
* Findet alle aktiven Meta-Einträge für eine Route (sortiert nach Priorität)
*/
public function findAllByRoute(string $route): array;
/**
* Speichert einen Meta-Eintrag
*/
public function save(MetaEntry $entry): MetaEntry;
/**
* Löscht einen Meta-Eintrag
*/
public function delete(int $id): bool;
/**
* Findet einen Meta-Eintrag anhand der ID
*/
public function findById(int $id): ?MetaEntry;
/**
* Findet alle Meta-Einträge (mit Paginierung)
*/
public function findAll(int $limit = 50, int $offset = 0): array;
/**
* Sucht Meta-Einträge nach Pattern
*/
public function search(string $query, int $limit = 50): array;
/**
* Zählt alle Meta-Einträge
*/
public function count(): int;
/**
* Prüft ob ein Route-Pattern bereits existiert
*/
public function routePatternExists(string $routePattern, ?int $excludeId = null): bool;
/**
* Prüft ob eine Entity bereits Meta-Daten hat
*/
public function entityMetaExists(string $entityType, int $entityId, ?int $excludeId = null): bool;
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Service;
use App\Domain\Meta\Repository\MetaRepositoryInterface;
use App\Domain\Meta\ValueObject\MetaData;
use App\Framework\Cache\CacheInterface;
use App\Framework\Http\Request;
final readonly class MetaManager
{
public function __construct(
private MetaRepositoryInterface $repository,
private ?CacheInterface $cache = null,
private ?MetaTemplateResolver $templateResolver = null,
) {}
/**
* Löst Meta-Daten für einen Request auf
*/
public function resolveForRequest(Request $request, array $context = []): MetaData
{
$route = $this->extractRoute($request);
$cacheKey = "meta:{$route}:" . md5(serialize($context));
// Cache-Check
if ($this->cache && ($cached = $this->cache->get($cacheKey))) {
return $cached;
}
$meta = new MetaData();
// 1. Route-basierte Meta-Daten
if ($routeMeta = $this->repository->findByRoute($route)) {
$meta = $meta->merge($routeMeta->metaData);
}
// 2. Entity-basierte Meta-Daten
if ($entityMeta = $this->resolveEntityMeta($request)) {
$meta = $meta->merge($entityMeta->metaData);
}
// 3. Template-Verarbeitung
if ($this->templateResolver && !empty($context)) {
$meta = $this->templateResolver->resolve($meta, $context);
}
// 4. Cache speichern
if ($this->cache) {
$this->cache->set($cacheKey, $meta, 3600);
}
return $meta;
}
/**
* Löst Meta-Daten für eine spezifische Route auf
*/
public function resolveForRoute(string $route, array $context = []): MetaData
{
$meta = new MetaData();
if ($routeMeta = $this->repository->findByRoute($route)) {
$meta = $meta->merge($routeMeta->metaData);
}
if ($this->templateResolver && !empty($context)) {
$meta = $this->templateResolver->resolve($meta, $context);
}
return $meta;
}
/**
* Löst Meta-Daten für eine Entity auf
*/
public function resolveForEntity(string $entityType, int $entityId, array $context = []): MetaData
{
$meta = new MetaData();
if ($entityMeta = $this->repository->findByEntity($entityType, $entityId)) {
$meta = $meta->merge($entityMeta->metaData);
}
if ($this->templateResolver && !empty($context)) {
$meta = $this->templateResolver->resolve($meta, $context);
}
return $meta;
}
/**
* Invalidiert den Cache für eine Route
*/
public function invalidateCache(string $route): void
{
if (!$this->cache) {
return;
}
// Alle Cache-Keys für diese Route löschen
$pattern = "meta:{$route}:*";
$this->cache->deleteByPattern($pattern);
}
/**
* Invalidiert den gesamten Meta-Cache
*/
public function invalidateAllCache(): void
{
if (!$this->cache) {
return;
}
$this->cache->deleteByPattern('meta:*');
}
private function extractRoute(Request $request): string
{
// Extrahiert die Route aus dem Request
$uri = $request->getUri();
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
// Query-Parameter entfernen für einheitliche Cache-Keys
return rtrim($path, '/') ?: '/';
}
private function resolveEntityMeta(Request $request): ?\App\Domain\Meta\Entity\MetaEntry
{
// Versuche Entity-Typ und ID aus der Route zu extrahieren
$routeParams = $request->getRouteParameters() ?? [];
if (isset($routeParams['id'])) {
$entityType = $this->extractEntityTypeFromRoute($this->extractRoute($request));
if ($entityType) {
return $this->repository->findByEntity($entityType, (int)$routeParams['id']);
}
}
return null;
}
private function extractEntityTypeFromRoute(string $route): ?string
{
// Einfache Heuristik: /products/123 -> entityType = "product"
// /blog/456 -> entityType = "blog"
$parts = explode('/', trim($route, '/'));
if (count($parts) >= 2 && is_numeric($parts[1])) {
$entityType = rtrim($parts[0], 's'); // Plural zu Singular
return $entityType;
}
return null;
}
/**
* Erstellt Standard-Meta-Daten basierend auf dem Request
*/
public function createDefaultMeta(Request $request): MetaData
{
$route = $this->extractRoute($request);
$title = $this->generateDefaultTitle($route);
return new MetaData(
title: $title,
description: "Seite: {$route}",
ogTitle: $title,
ogType: 'website',
);
}
private function generateDefaultTitle(string $route): string
{
if ($route === '/') {
return 'Startseite';
}
$parts = explode('/', trim($route, '/'));
$title = ucfirst(str_replace(['-', '_'], ' ', end($parts)));
return $title;
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\Service;
use App\Domain\Meta\ValueObject\MetaData;
final readonly class MetaTemplateResolver
{
/**
* Löst Templates in Meta-Daten auf
*/
public function resolve(MetaData $meta, array $context): MetaData
{
return new MetaData(
title: $this->resolveTemplate($meta->title, $context),
description: $this->resolveTemplate($meta->description, $context),
keywords: $meta->keywords,
ogTitle: $this->resolveTemplate($meta->ogTitle, $context),
ogDescription: $this->resolveTemplate($meta->ogDescription, $context),
ogImage: $this->resolveTemplate($meta->ogImage, $context),
ogType: $meta->ogType,
twitterCard: $meta->twitterCard,
twitterSite: $meta->twitterSite,
canonical: $this->resolveTemplate($meta->canonical, $context),
customMeta: $this->resolveCustomMeta($meta->customMeta ?? [], $context),
);
}
private function resolveTemplate(?string $template, array $context): ?string
{
if ($template === null) {
return null;
}
// Unterstützt sowohl {variable} als auch {{variable}} Syntax
$template = preg_replace_callback('/\{\{?(\w+(?:\.\w+)*)\}?\}/', function($matches) use ($context) {
$key = $matches[1];
$value = $this->getNestedValue($context, $key);
return $value !== null ? (string) $value : $matches[0];
}, $template);
// Unterstützt einfache Funktionen: {upper:variable}, {lower:variable}
$template = preg_replace_callback('/\{(\w+):(\w+(?:\.\w+)*)\}/', function($matches) use ($context) {
$function = $matches[1];
$key = $matches[2];
$value = $this->getNestedValue($context, $key);
if ($value === null) {
return $matches[0];
}
return match($function) {
'upper' => strtoupper((string) $value),
'lower' => strtolower((string) $value),
'title' => ucwords((string) $value),
'truncate' => $this->truncate((string) $value, 160),
'strip_tags' => strip_tags((string) $value),
default => (string) $value,
};
}, $template);
return $template;
}
private function resolveCustomMeta(array $customMeta, array $context): array
{
$resolved = [];
foreach ($customMeta as $name => $content) {
$resolved[$name] = $this->resolveTemplate($content, $context);
}
return $resolved;
}
private function getNestedValue(array $data, string $key): mixed
{
$keys = explode('.', $key);
$value = $data;
foreach ($keys as $k) {
if (is_array($value) && isset($value[$k])) {
$value = $value[$k];
} elseif (is_object($value) && property_exists($value, $k)) {
$value = $value->$k;
} elseif (is_object($value) && method_exists($value, $k)) {
$value = $value->$k();
} elseif (is_object($value) && method_exists($value, 'get' . ucfirst($k))) {
$method = 'get' . ucfirst($k);
$value = $value->$method();
} else {
return null;
}
}
return $value;
}
private function truncate(string $text, int $length, string $suffix = '...'): string
{
if (mb_strlen($text) <= $length) {
return $text;
}
return mb_substr($text, 0, $length - mb_strlen($suffix)) . $suffix;
}
/**
* Validiert Template-Syntax
*/
public function validateTemplate(string $template): array
{
$errors = [];
// Prüfe auf ungeschlossene Klammern
$openBraces = substr_count($template, '{');
$closeBraces = substr_count($template, '}');
if ($openBraces !== $closeBraces) {
$errors[] = 'Ungleiche Anzahl von öffnenden und schließenden Klammern';
}
// Prüfe auf gültige Template-Syntax
preg_match_all('/\{([^}]+)\}/', $template, $matches);
foreach ($matches[1] as $placeholder) {
if (!preg_match('/^(\w+:)?[\w.]+$/', $placeholder)) {
$errors[] = "Ungültige Template-Syntax: {{$placeholder}}";
}
}
return $errors;
}
/**
* Extrahiert alle Platzhalter aus einem Template
*/
public function extractPlaceholders(string $template): array
{
preg_match_all('/\{\{?(\w+(?:\.\w+)*)\}?\}/', $template, $matches);
preg_match_all('/\{(\w+):(\w+(?:\.\w+)*)\}/', $template, $functionMatches);
$placeholders = array_unique(array_merge(
$matches[1] ?? [],
$functionMatches[2] ?? []
));
return $placeholders;
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Domain\Meta\ValueObject;
readonly class MetaData
{
public function __construct(
public ?string $title = null,
public ?string $description = null,
public ?array $keywords = null,
public ?string $ogTitle = null,
public ?string $ogDescription = null,
public ?string $ogImage = null,
public ?string $ogType = null,
public ?string $twitterCard = null,
public ?string $twitterSite = null,
public ?string $canonical = null,
public ?array $customMeta = null,
) {}
public static function fromArray(array $data): self
{
return new self(
title: $data['title'] ?? null,
description: $data['description'] ?? null,
keywords: isset($data['keywords']) ? (is_array($data['keywords']) ? $data['keywords'] : explode(',', $data['keywords'])) : null,
ogTitle: $data['og_title'] ?? null,
ogDescription: $data['og_description'] ?? null,
ogImage: $data['og_image'] ?? null,
ogType: $data['og_type'] ?? null,
twitterCard: $data['twitter_card'] ?? null,
twitterSite: $data['twitter_site'] ?? null,
canonical: $data['canonical'] ?? null,
customMeta: isset($data['custom_meta']) ? (is_array($data['custom_meta']) ? $data['custom_meta'] : json_decode($data['custom_meta'], true)) : null,
);
}
public function merge(MetaData $other): self
{
return new self(
title: $other->title ?? $this->title,
description: $other->description ?? $this->description,
keywords: $other->keywords ?? $this->keywords,
ogTitle: $other->ogTitle ?? $this->ogTitle,
ogDescription: $other->ogDescription ?? $this->ogDescription,
ogImage: $other->ogImage ?? $this->ogImage,
ogType: $other->ogType ?? $this->ogType,
twitterCard: $other->twitterCard ?? $this->twitterCard,
twitterSite: $other->twitterSite ?? $this->twitterSite,
canonical: $other->canonical ?? $this->canonical,
customMeta: array_merge($this->customMeta ?? [], $other->customMeta ?? []),
);
}
public function toArray(): array
{
return array_filter([
'title' => $this->title,
'description' => $this->description,
'keywords' => $this->keywords ? implode(',', $this->keywords) : null,
'og_title' => $this->ogTitle,
'og_description' => $this->ogDescription,
'og_image' => $this->ogImage,
'og_type' => $this->ogType,
'twitter_card' => $this->twitterCard,
'twitter_site' => $this->twitterSite,
'canonical' => $this->canonical,
'custom_meta' => $this->customMeta ? json_encode($this->customMeta) : null,
], fn($value) => $value !== null);
}
public function isEmpty(): bool
{
return $this->title === null
&& $this->description === null
&& empty($this->keywords)
&& $this->ogTitle === null
&& $this->ogDescription === null
&& $this->ogImage === null
&& empty($this->customMeta);
}
public function render(): string
{
if ($this->isEmpty()) {
return '';
}
$html = [];
// Basic Meta Tags
if ($this->title) {
$html[] = '<title>' . htmlspecialchars($this->title, ENT_QUOTES, 'UTF-8') . '</title>';
}
if ($this->description) {
$html[] = '<meta name="description" content="' . htmlspecialchars($this->description, ENT_QUOTES, 'UTF-8') . '">';
}
if ($this->keywords) {
$html[] = '<meta name="keywords" content="' . htmlspecialchars(implode(', ', $this->keywords), ENT_QUOTES, 'UTF-8') . '">';
}
if ($this->canonical) {
$html[] = '<link rel="canonical" href="' . htmlspecialchars($this->canonical, ENT_QUOTES, 'UTF-8') . '">';
}
// Open Graph
if ($this->ogTitle) {
$html[] = '<meta property="og:title" content="' . htmlspecialchars($this->ogTitle, ENT_QUOTES, 'UTF-8') . '">';
}
if ($this->ogDescription) {
$html[] = '<meta property="og:description" content="' . htmlspecialchars($this->ogDescription, ENT_QUOTES, 'UTF-8') . '">';
}
if ($this->ogImage) {
$html[] = '<meta property="og:image" content="' . htmlspecialchars($this->ogImage, ENT_QUOTES, 'UTF-8') . '">';
}
if ($this->ogType) {
$html[] = '<meta property="og:type" content="' . htmlspecialchars($this->ogType, ENT_QUOTES, 'UTF-8') . '">';
}
// Twitter
if ($this->twitterCard) {
$html[] = '<meta name="twitter:card" content="' . htmlspecialchars($this->twitterCard, ENT_QUOTES, 'UTF-8') . '">';
}
if ($this->twitterSite) {
$html[] = '<meta name="twitter:site" content="' . htmlspecialchars($this->twitterSite, ENT_QUOTES, 'UTF-8') . '">';
}
// Custom Meta
if ($this->customMeta) {
foreach ($this->customMeta as $name => $content) {
if (str_starts_with($name, 'og:')) {
$html[] = '<meta property="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . htmlspecialchars($content, ENT_QUOTES, 'UTF-8') . '">';
} else {
$html[] = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . htmlspecialchars($content, ENT_QUOTES, 'UTF-8') . '">';
}
}
}
return implode("\n", $html);
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Exception;
use Exception;
class QrCodeException extends Exception
{
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
use App\Domain\QrCode\ValueObject\Position;
class AlignmentPattern implements PatternInterface
{
// Tabelle der Ausrichtungsmuster-Positionen für Versionen 1-40
private const POSITIONS = [
1 => [],
2 => [6, 18],
3 => [6, 22],
4 => [6, 26],
5 => [6, 30],
6 => [6, 34],
7 => [6, 22, 38],
8 => [6, 24, 42],
9 => [6, 26, 46],
10 => [6, 28, 50],
// ... weitere Versionen hier hinzufügen
];
public function __construct(
private readonly int $version
) {
}
public function apply(array &$matrix, int $size): void
{
if ($this->version === 1) {
return; // Version 1 hat keine Ausrichtungsmuster
}
$positions = self::POSITIONS[$this->version] ?? $this->calculatePositions($size);
foreach ($positions as $centerRow) {
foreach ($positions as $centerCol) {
if ($this->overlapsWithFinderPattern($centerRow, $centerCol, $size)) {
continue;
}
$this->applyPattern($matrix, $centerRow, $centerCol, $size);
}
}
}
private function applyPattern(array &$matrix, int $centerRow, int $centerCol, int $size): void
{
// 5x5 Ausrichtungsmuster
for ($r = -2; $r <= 2; $r++) {
for ($c = -2; $c <= 2; $c++) {
$position = new Position($centerRow + $r, $centerCol + $c);
if ($position->isWithinBounds($size)) {
// Äußerer Rand oder Mittelpunkt
$isBlack = abs($r) === 2 || abs($c) === 2 || ($r === 0 && $c === 0);
$matrix[$centerRow + $r][$centerCol + $c] = $isBlack;
}
}
}
}
private function overlapsWithFinderPattern(int $row, int $col, int $size): bool
{
return ($row <= 8 && $col <= 8) ||
($row <= 8 && $col >= $size - 9) ||
($row >= $size - 9 && $col <= 8);
}
private function calculatePositions(int $size): array
{
// Vereinfachte Berechnung für fehlende Versionen
$positions = [6];
if ($size >= 25) { // Ab Version 2
$positions[] = $size - 7;
}
return $positions;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
/**
* Stellt die Positionstabelle für die Ausrichtungsmuster (Alignment Patterns) zur Verfügung
*/
class AlignmentPatternTable
{
/**
* Tabelle der Alignment Pattern Positionen für alle QR-Code-Versionen
* Werte entsprechen den Mittelpunkten der Alignment Pattern im QR-Code
*/
public const POSITIONS = [
1 => [], // Version 1 hat keine Alignment Patterns
2 => [6, 18],
3 => [6, 22],
4 => [6, 26],
5 => [6, 30],
6 => [6, 34],
7 => [6, 22, 38],
8 => [6, 24, 42],
9 => [6, 26, 46],
10 => [6, 28, 50],
11 => [6, 30, 54],
12 => [6, 32, 58],
13 => [6, 34, 62],
14 => [6, 26, 46, 66],
15 => [6, 26, 48, 70],
16 => [6, 26, 50, 74],
17 => [6, 30, 54, 78],
18 => [6, 30, 56, 82],
19 => [6, 30, 58, 86],
20 => [6, 34, 62, 90],
21 => [6, 28, 50, 72, 94],
22 => [6, 26, 50, 74, 98],
23 => [6, 30, 54, 78, 102],
24 => [6, 28, 54, 80, 106],
25 => [6, 32, 58, 84, 110],
26 => [6, 30, 58, 86, 114],
27 => [6, 34, 62, 90, 118],
28 => [6, 26, 50, 74, 98, 122],
29 => [6, 30, 54, 78, 102, 126],
30 => [6, 26, 52, 78, 104, 130],
31 => [6, 30, 56, 82, 108, 134],
32 => [6, 34, 60, 86, 112, 138],
33 => [6, 30, 58, 86, 114, 142],
34 => [6, 34, 62, 90, 118, 146],
35 => [6, 30, 54, 78, 102, 126, 150],
36 => [6, 24, 50, 76, 102, 128, 154],
37 => [6, 28, 54, 80, 106, 132, 158],
38 => [6, 32, 58, 84, 110, 136, 162],
39 => [6, 26, 54, 82, 110, 138, 166],
40 => [6, 30, 58, 86, 114, 142, 170],
];
/**
* Gibt die Alignment Pattern Positionen für die angegebene QR-Code-Version zurück
*
* @param int $version QR-Code-Version (1-40)
* @return array Array mit den Positionen der Alignment Patterns
*/
public static function getPositions(int $version): array
{
if ($version < 1 || $version > 40) {
return [];
}
return self::POSITIONS[$version];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
class DarkModulePattern implements PatternInterface
{
public function __construct(
private readonly int $version
) {
}
public function apply(array &$matrix, int $size): void
{
// Für Version 1 muss das Dark Module bei (8, 13) sein
if ($this->version === 1) {
$matrix[8][13] = true;
} else {
// Für andere Versionen: Position (4V+9, 8)
$matrix[(4 * $this->version) + 9][8] = true;
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
use App\Domain\QrCode\ValueObject\Position;
class FinderPattern implements PatternInterface
{
public function __construct(
private readonly Position $position
) {
}
public function apply(array &$matrix, int $size): void
{
$row = $this->position->getRow();
$col = $this->position->getColumn();
// 7x7 Finder Pattern
for ($i = 0; $i < 7; $i++) {
for ($j = 0; $j < 7; $j++) {
// Äußerer Rand oder inneres Quadrat
$isBlack = ($i === 0 || $i === 6 || $j === 0 || $j === 6) ||
($i >= 2 && $i <= 4 && $j >= 2 && $j <= 4);
$position = new Position($row + $i, $col + $j);
if ($position->isWithinBounds($size)) {
$matrix[$row + $i][$col + $j] = $isBlack;
}
}
}
// Weißer Separator um das Finder Pattern
$this->applySeparator($matrix, $size);
}
private function applySeparator(array &$matrix, int $size): void
{
$row = $this->position->getRow();
$col = $this->position->getColumn();
// Horizontaler Separator unten
for ($j = 0; $j < 8; $j++) {
$position = new Position($row + 7, $col + $j);
if ($position->isWithinBounds($size)) {
$matrix[$row + 7][$col + $j] = false;
}
}
// Vertikaler Separator rechts
for ($i = 0; $i < 8; $i++) {
$position = new Position($row + $i, $col + 7);
if ($position->isWithinBounds($size)) {
$matrix[$row + $i][$col + 7] = false;
}
}
}
public static function createAll(int $size): array
{
return [
new self(new Position(0, 0)), // Oben links
new self(new Position(0, $size - 7)), // Oben rechts
new self(new Position($size - 7, 0)) // Unten links
];
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
use App\Domain\QrCode\ValueObject\ErrorCorrectionLevel;
class FormatInfoPattern implements PatternInterface
{
// Format-Strings für verschiedene Error Correction Levels und Masken
private const FORMAT_STRINGS = [
'L' => [
0 => '111011111000100',
1 => '111001011110011',
2 => '111110110101010',
3 => '111100010011101',
4 => '110011000101111',
5 => '110001100011000',
6 => '110110001000001',
7 => '110100101110110',
],
'M' => [
0 => '101010000010010',
1 => '101000100100101',
2 => '101111001111100',
3 => '101101101001011',
4 => '100010111111001',
5 => '100000011001110',
6 => '100111110010111',
7 => '100101010100000',
],
'Q' => [
0 => '011010101011111',
1 => '011000001101000',
2 => '011111100110001',
3 => '011101000000110',
4 => '010010010110100',
5 => '010000110000011',
6 => '010111011011010',
7 => '010101111101101',
],
'H' => [
0 => '001011010001001',
1 => '001001110111110',
2 => '001110011100111',
3 => '001100111010000',
4 => '000011101100010',
5 => '000001001010101',
6 => '000110100001100',
7 => '000100000111011',
],
];
public function __construct(
private readonly ErrorCorrectionLevel $level,
private readonly int $maskPattern
) {
}
public function apply(array &$matrix, int $size): void
{
$formatString = self::FORMAT_STRINGS[$this->level->name][$this->maskPattern];
// Format-Info um das obere linke Finder-Pattern
for ($i = 0; $i < 15; $i++) {
$bit = $formatString[$i] === '1';
// Format-Info horizontal
if ($i < 6) {
$matrix[8][$i] = $bit;
} elseif ($i < 8) {
$matrix[8][$i + 1] = $bit;
} elseif ($i === 8) {
$matrix[7][8] = $bit;
} else {
$matrix[14 - $i][8] = $bit;
}
// Format-Info vertikal
if ($i < 8) {
$matrix[$size - 1 - $i][8] = $bit;
} else {
$matrix[8][$size - 15 + $i] = $bit;
}
}
// Für Version 1 muss das Dark Module bei (8, 13) sein
// Dies ist für QR-Code-Scanner wichtig zur Orientierung
$matrix[8][13] = true;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
/**
* Stellt die Format-Information für QR-Codes bereit
*/
class FormatInfoTable
{
/**
* Format-Information BCH-codiert (15 Bits)
* Schlüssel: [EC-Level][Maskenmuster] = 15-Bit BCH-codierte Formatinformation
*/
public const FORMAT_INFO = [
'L' => [
0 => 0b111011111000100,
1 => 0b111001011110011,
2 => 0b111110110101010,
3 => 0b111100010011101,
4 => 0b110011000101111,
5 => 0b110001100011000,
6 => 0b110110001000001,
7 => 0b110100101110110,
],
'M' => [
0 => 0b101010000010010,
1 => 0b101000100100101,
2 => 0b101111001111100,
3 => 0b101101101001011,
4 => 0b100010111111001,
5 => 0b100000011001110,
6 => 0b100111110010111,
7 => 0b100101010100000,
],
'Q' => [
0 => 0b011010101011111,
1 => 0b011000001101000,
2 => 0b011111100110001,
3 => 0b011101000000110,
4 => 0b010010010110100,
5 => 0b010000110000011,
6 => 0b010111011011010,
7 => 0b010101111101101,
],
'H' => [
0 => 0b001011010001001,
1 => 0b001001110111110,
2 => 0b001110011100111,
3 => 0b001100111010000,
4 => 0b000011101100010,
5 => 0b000001001010101,
6 => 0b000110100001100,
7 => 0b000100000111011,
],
];
/**
* Liefert die Formatinformation für das angegebene Error-Correction-Level und Maskenmuster
*
* @param string $ecLevel Error Correction Level (L, M, Q oder H)
* @param int $maskPattern Maskenmuster (0-7)
* @return int 15-Bit-Formatinformation
*/
public static function getFormatInfo(string $ecLevel, int $maskPattern): int
{
if (!isset(self::FORMAT_INFO[$ecLevel]) || !isset(self::FORMAT_INFO[$ecLevel][$maskPattern])) {
// Fallback: L, Maske 0
return self::FORMAT_INFO['L'][0];
}
return self::FORMAT_INFO[$ecLevel][$maskPattern];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
interface PatternInterface
{
/**
* Wendet das Muster auf die übergebene Matrix an
*/
public function apply(array &$matrix, int $size): void;
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
use App\Domain\QrCode\ValueObject\Position;
class TimingPattern implements PatternInterface
{
public function apply(array &$matrix, int $size): void
{
// Horizontales Timing Pattern
for ($i = 0; $i < $size; $i++) {
$matrix[6][$i] = ($i % 2 === 0);
}
// Vertikales Timing Pattern
for ($i = 0; $i < $size; $i++) {
$matrix[$i][6] = ($i % 2 === 0);
}
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Pattern;
/**
* Implementiert das Version Information Pattern für QR-Codes ab Version 7
*/
class VersionInfoPattern implements PatternInterface
{
/**
* Lookup-Tabelle für die Version-Information (BCH-kodiert)
* Diese 18-Bit-Werte enthalten die Versionsinformation mit Fehlerkorrektur
*/
private const VERSION_INFO = [
7 => 0b000111110010010100,
8 => 0b001000010110111100,
9 => 0b001001101010011001,
10 => 0b001010010011010011,
11 => 0b001011101111110110,
12 => 0b001100011101100010,
13 => 0b001101100001000111,
14 => 0b001110011000001101,
15 => 0b001111100100101000,
16 => 0b010000101101111000,
17 => 0b010001010001011101,
18 => 0b010010101000010111,
19 => 0b010011010100110010,
20 => 0b010100100110100110,
21 => 0b010101011010000011,
22 => 0b010110100011001001,
23 => 0b010111011111101100,
24 => 0b011000111011000100,
25 => 0b011001000111100001,
26 => 0b011010111110101011,
27 => 0b011011000010001110,
28 => 0b011100110000011010,
29 => 0b011101001100111111,
30 => 0b011110110101110101,
31 => 0b011111001001010000,
32 => 0b100000100111010101,
33 => 0b100001011011110000,
34 => 0b100010100010111010,
35 => 0b100011011110011111,
36 => 0b100100101100001011,
37 => 0b100101010000101110,
38 => 0b100110101001100100,
39 => 0b100111010101000001,
40 => 0b101000110001101001,
];
private int $version;
public function __construct(int $version)
{
$this->version = $version;
}
public function apply(array &$matrix, int $size): void
{
// Version Information Pattern nur für QR-Codes ab Version 7
if ($this->version < 7) {
return;
}
$versionInfo = self::VERSION_INFO[$this->version] ?? 0;
// Versionsinformation an zwei Positionen einfügen
$this->placeVersionInfo($matrix, $versionInfo); // Platziere oben rechts
$this->placeVersionInfoTransposed($matrix, $versionInfo); // Platziere unten links
}
/**
* Platziert die Versionsinformation unterhalb des Finder Patterns oben rechts
*/
private function placeVersionInfo(array &$matrix, int $versionInfo): void
{
// Position: unterhalb des rechten oberen Finder Patterns
// 3 Zeilen x 6 Spalten = 18 Bits
$row = 0;
$col = $this->version * 4 + 6; // Rechts oben, die exakte Position hängt von der Version ab
// 18 Bits des Versionsinformationsblocks setzen
for ($i = 0; $i < 18; $i++) {
// Bit von rechts nach links extrahieren (LSB zuerst)
$bit = ($versionInfo >> $i) & 1;
// Berechne Position: 6 Bits pro Zeile, 3 Zeilen
$r = $row + ($i / 3);
$c = $col - ($i % 3);
$matrix[(int)$r][(int)$c] = $bit === 1;
}
}
/**
* Platziert die Versionsinformation rechts vom Finder Pattern unten links
*/
private function placeVersionInfoTransposed(array &$matrix, int $versionInfo): void
{
// Position: rechts vom linken unteren Finder Pattern
// 6 Zeilen x 3 Spalten = 18 Bits
$row = $this->version * 4 + 6; // Unten links, die exakte Position hängt von der Version ab
$col = 0;
// 18 Bits des Versionsinformationsblocks setzen
for ($i = 0; $i < 18; $i++) {
// Bit von rechts nach links extrahieren (LSB zuerst)
$bit = ($versionInfo >> $i) & 1;
// Berechne Position: 3 Bits pro Zeile, 6 Zeilen (transponiert)
$r = $row - ($i % 3);
$c = $col + ($i / 3);
$matrix[(int)$r][(int)$c] = $bit === 1;
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode;
use App\Domain\QrCode\Exception\QrCodeException;
use App\Domain\QrCode\ValueObject\ErrorCorrectionLevel;
use App\Domain\QrCode\ValueObject\QrCodeVersion;
readonly class QrCode
{
public function __construct(
private string $data,
private ErrorCorrectionLevel $errorCorrectionLevel = ErrorCorrectionLevel::M,
private ?QrCodeVersion $version = null
) {
if (empty($data)) {
throw new QrCodeException('QR Code Daten dürfen nicht leer sein');
}
}
public function getData(): string
{
return $this->data;
}
public function getErrorCorrectionLevel(): ErrorCorrectionLevel
{
return $this->errorCorrectionLevel;
}
public function getVersion(): ?QrCodeVersion
{
return $this->version;
}
public function withErrorCorrectionLevel(ErrorCorrectionLevel $level): self
{
return new self($this->data, $level, $this->version);
}
public function withVersion(QrCodeVersion $version): self
{
return new self($this->data, $this->errorCorrectionLevel, $version);
}
}

View File

@@ -0,0 +1,80 @@
# QR-Code Modul
Dieses Modul bietet eine einfache API zum Generieren von QR-Codes in verschiedenen Formaten wie SVG, PNG und ASCII.
## Funktionen
- Generierung von QR-Codes in Versionen 1-40
- Unterstützung verschiedener Fehlerkorrektur-Level (L, M, Q, H)
- Automatische Versionsauswahl basierend auf Datenmenge
- Vollständige Reed-Solomon-Fehlerkorrektur-Implementierung
- Export als SVG, PNG oder ASCII-Art
- Anpassbare Modulgröße und Ränder
- Anpassbare Farben für SVG-Output
## Beispiele
### Einfache Verwendung
```php
// QR-Code als SVG generieren
$qrCodeService = new QrCodeService($qrCodeGenerator);
$svg = $qrCodeService->generateSvg('https://example.com');
// QR-Code als PNG mit benutzerdefinierten Einstellungen
$config = new QrCodeConfig(6, 8, '#000000', '#FFFFFF');
$png = $qrCodeService->generatePng('Hallo Welt!', ErrorCorrectionLevel::H, $config);
```
### Erweiterte Anpassung
```php
// Explizite QR-Code-Version angeben
$qrCode = new QrCode('Daten', ErrorCorrectionLevel::Q, new QrCodeVersion(5));
$matrix = $qrCodeGenerator->generate($qrCode);
// QR-Code als ASCII-Art ausgeben (für Debugging)
$ascii = $qrCodeService->generateAscii('Text');
echo $ascii;
```
## Architektur
Das Modul ist nach Domain-Driven Design strukturiert:
- **Entities und Value Objects**: QrCode, QrCodeVersion, ErrorCorrectionLevel, GaloisField, Polynomial, etc.
- **Services**: QrCodeEncoder, QrCodeRenderer, QrCodeGenerator, ReedSolomon, ReedSolomonEncoder
- **Patterns**: FinderPattern, AlignmentPattern, etc.
Die Trennung von Verantwortlichkeiten ermöglicht eine einfache Erweiterung und Wartung.
## Reed-Solomon-Fehlerkorrektur
Die Implementierung der Reed-Solomon-Fehlerkorrektur erfolgt in mehreren Schritten:
1. **Galois-Feld-Arithmetik**: Die `GaloisField`-Klasse implementiert die mathematischen Operationen im Galois-Feld GF(2^8).
2. **Polynom-Operationen**: Die `Polynomial`-Klasse ermöglicht Berechnungen mit Polynomen im Galois-Feld.
3. **Generator-Polynom**: Für die Fehlerkorrektur wird ein spezifisches Generator-Polynom basierend auf der gewünschten Anzahl von Fehlerkorrektur-Bytes erzeugt.
4. **Kodierung**: Der eigentliche Reed-Solomon-Algorithmus teilt die Daten in Blöcke ein, berechnet die Fehlerkorrektur-Bytes für jeden Block und interleaved die Ergebnisse.
Die Implementierung folgt dem Standard-Verfahren für QR-Codes und kann Fehler bis zur Hälfte der Anzahl der Fehlerkorrektur-Bytes korrigieren.
## Kodierungsmodi
Die Implementierung unterstützt folgende Kodierungsmodi gemäß der QR-Code-Spezifikation:
1. **Numerischer Modus**: Optimiert für Ziffern (0-9), packt jeweils 3 Ziffern in 10 Bits.
2. **Alphanumerischer Modus**: Für Ziffern, Großbuchstaben und einige Sonderzeichen, packt jeweils 2 Zeichen in 11 Bits.
3. **Byte-Modus**: Für beliebige 8-Bit-Daten, verwendet 8 Bits pro Zeichen.
4. **Kanji-Modus**: Optimiert für japanische Schriftzeichen (vereinfachte Implementierung).
Der Encoder wählt automatisch den effizientesten Modus basierend auf den Eingabedaten.
## Limitationen
- Kanji-Modus ist nur teilweise implementiert
- Strukturierte Anhänge werden nicht unterstützt
- Keine Unterstützung für Micro-QR-Codes

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Service;
use App\Domain\QrCode\Exception\QrCodeException;
use App\Domain\QrCode\QrCode;
use App\Domain\QrCode\ValueObject\QrCodeVersion;
class QrCodeEncoder
{
// Modus-Indikatoren (4 Bit)
private const MODE_NUMERIC = '0001';
private const MODE_ALPHANUMERIC = '0010';
private const MODE_BYTE = '0100';
private const MODE_KANJI = '1000';
// Zeichensatz für alphanumerische Kodierung
private const ALPHANUMERIC_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
// Character Count Indicator Längen basierend auf QR-Version und Modus
private const CHARACTER_COUNT_BITS = [
// Modus => [Versionsbereich => Bits]
'numeric' => [
[1, 9, 10], // Versionen 1-9: 10 Bits
[10, 26, 12], // Versionen 10-26: 12 Bits
[27, 40, 14], // Versionen 27-40: 14 Bits
],
'alphanumeric' => [
[1, 9, 9], // Versionen 1-9: 9 Bits
[10, 26, 11], // Versionen 10-26: 11 Bits
[27, 40, 13], // Versionen 27-40: 13 Bits
],
'byte' => [
[1, 9, 8], // Versionen 1-9: 8 Bits
[10, 26, 16], // Versionen 10-26: 16 Bits
[27, 40, 16], // Versionen 27-40: 16 Bits
],
'kanji' => [
[1, 9, 8], // Versionen 1-9: 8 Bits
[10, 26, 10], // Versionen 10-26: 10 Bits
[27, 40, 12], // Versionen 27-40: 12 Bits
],
];
/**
* Kodiert die Daten in ein QR-Code-Bitstream
*
* @param QrCode $qrCode QR-Code mit zu kodierenden Daten
* @param QrCodeVersion $version Zu verwendende QR-Code-Version
* @return string Binärer String ('0' und '1')
* @throws QrCodeException Bei Kodierungsproblemen
*/
public function encode(QrCode $qrCode, QrCodeVersion $version): string
{
$data = $qrCode->getData();
// 1. Bestimme den optimalen Modus
$mode = $this->determineEncodingMode($data);
// 2. Füge Mode Indicator hinzu (4 Bits)
$encoded = $this->getModeIndicator($mode);
// 3. Füge Character Count Indicator hinzu
$encoded .= $this->getCharacterCountIndicator(strlen($data), $mode, $version);
// 4. Kodiere die eigentlichen Daten
$encoded .= $this->encodeData($data, $mode);
// 5. Füge Terminator hinzu und fülle auf Byte-Grenze auf
$encoded = $this->addTerminator($encoded, $version, $qrCode->getErrorCorrectionLevel());
$encoded = $this->padToByteBoundary($encoded);
// 6. Fülle mit Pad-Bytes auf die volle Kapazität
$encoded = $this->addPadBytes($encoded, $version, $qrCode->getErrorCorrectionLevel());
return $encoded;
}
/**
* Bestimmt den optimalen Kodierungsmodus für die Daten
*/
private function determineEncodingMode(string $data): string
{
// Prüfe, ob die Daten nur Ziffern enthalten
if (preg_match('/^[0-9]+$/', $data)) {
return 'numeric';
}
// Prüfe, ob die Daten nur alphanumerische Zeichen enthalten
if (preg_match('/^[0-9A-Z $%*+\-.\/:]+$/', $data)) {
return 'alphanumeric';
}
// Kanji-Erkennung ist komplex und wird hier nicht implementiert
// Bei Bedarf könnte hier eine Kanji-Zeichenerkennung erfolgen
// Standardmodus: Byte-Modus
return 'byte';
}
/**
* Liefert den Mode Indicator für den angegebenen Modus
*/
private function getModeIndicator(string $mode): string
{
return match ($mode) {
'numeric' => self::MODE_NUMERIC,
'alphanumeric' => self::MODE_ALPHANUMERIC,
'kanji' => self::MODE_KANJI,
default => self::MODE_BYTE,
};
}
/**
* Erzeugt den Character Count Indicator
*/
private function getCharacterCountIndicator(int $length, string $mode, QrCodeVersion $version): string
{
$versionNumber = $version->getValue();
$bits = 0;
// Bestimme die Anzahl der Bits für den Character Count Indicator
foreach (self::CHARACTER_COUNT_BITS[$mode] as $range) {
if ($versionNumber >= $range[0] && $versionNumber <= $range[1]) {
$bits = $range[2];
break;
}
}
// Konvertiere die Länge in binäre Darstellung mit der richtigen Anzahl von Bits
return str_pad(decbin($length), $bits, '0', STR_PAD_LEFT);
}
/**
* Kodiert die Daten im angegebenen Modus
*/
private function encodeData(string $data, string $mode): string
{
return match ($mode) {
'numeric' => $this->encodeNumeric($data),
'alphanumeric' => $this->encodeAlphanumeric($data),
'kanji' => $this->encodeKanji($data),
default => $this->encodeByte($data),
};
}
/**
* Kodiert Daten im numerischen Modus
* Im numerischen Modus werden jeweils 3 Ziffern zu 10 Bits gruppiert
*/
private function encodeNumeric(string $data): string
{
$result = '';
$length = strlen($data);
for ($i = 0; $i < $length; $i += 3) {
$chunk = substr($data, $i, min(3, $length - $i));
$value = (int)$chunk;
// Bestimme die Anzahl der Bits basierend auf der Chunk-Länge
$numBits = match (strlen($chunk)) {
1 => 4,
2 => 7,
3 => 10,
};
// Konvertiere den Wert in binäre Darstellung
$result .= str_pad(decbin($value), $numBits, '0', STR_PAD_LEFT);
}
return $result;
}
/**
* Kodiert Daten im alphanumerischen Modus
* Im alphanumerischen Modus werden jeweils 2 Zeichen zu 11 Bits gruppiert
*/
private function encodeAlphanumeric(string $data): string
{
$result = '';
$length = strlen($data);
for ($i = 0; $i < $length; $i += 2) {
if ($i + 1 < $length) {
// Zwei Zeichen verarbeiten
$char1 = strpos(self::ALPHANUMERIC_CHARS, $data[$i]);
$char2 = strpos(self::ALPHANUMERIC_CHARS, $data[$i + 1]);
if ($char1 === false || $char2 === false) {
throw new QrCodeException("Ungültiges alphanumerisches Zeichen: {$data[$i]} oder {$data[$i+1]}");
}
$value = $char1 * 45 + $char2;
$result .= str_pad(decbin($value), 11, '0', STR_PAD_LEFT);
} else {
// Einzelnes letztes Zeichen
$char = strpos(self::ALPHANUMERIC_CHARS, $data[$i]);
if ($char === false) {
throw new QrCodeException("Ungültiges alphanumerisches Zeichen: {$data[$i]}");
}
$result .= str_pad(decbin($char), 6, '0', STR_PAD_LEFT);
}
}
return $result;
}
/**
* Kodiert Daten im Byte-Modus
* Im Byte-Modus wird jedes Zeichen als 8-Bit-Wert kodiert
*/
private function encodeByte(string $data): string
{
$result = '';
$length = strlen($data);
for ($i = 0; $i < $length; $i++) {
// Konvertiere das Zeichen in seinen ASCII-Wert
$value = ord($data[$i]);
$result .= str_pad(decbin($value), 8, '0', STR_PAD_LEFT);
}
return $result;
}
/**
* Kodiert Daten im Kanji-Modus
* Dieser Modus ist für japanische Schriftzeichen optimiert
* In dieser vereinfachten Implementierung wird er nicht vollständig unterstützt
*/
private function encodeKanji(string $data): string
{
// Vereinfachte Implementierung - in einer realen Anwendung würde hier
// eine Konvertierung von Kanji-Zeichen in die entsprechenden Werte erfolgen
return $this->encodeByte($data);
}
/**
* Fügt den Terminator (0000) hinzu und füllt auf bis zur maximalen Kapazität
*/
private function addTerminator(string $encoded, QrCodeVersion $version, $errorCorrectionLevel): string
{
$dataCapacityBits = $version->getDataCapacity($errorCorrectionLevel) * 8;
$currentLength = strlen($encoded);
// Berechne, wie viele Terminator-Bits hinzugefügt werden können
$terminatorLength = max(0, min(4, $dataCapacityBits - $currentLength));
// Füge den Terminator hinzu
$encoded .= str_repeat('0', $terminatorLength);
return $encoded;
}
/**
* Füllt den Bitstream auf eine Byte-Grenze (Multiple von 8 Bits) auf
*/
private function padToByteBoundary(string $encoded): string
{
$remainder = strlen($encoded) % 8;
if ($remainder > 0) {
$encoded .= str_repeat('0', 8 - $remainder);
}
return $encoded;
}
/**
* Füllt den Bitstream mit Pad-Bytes auf bis zur maximalen Kapazität
*/
private function addPadBytes(string $encoded, QrCodeVersion $version, $errorCorrectionLevel): string
{
$dataCapacityBits = $version->getDataCapacity($errorCorrectionLevel) * 8;
$currentLength = strlen($encoded);
// Wenn die Daten bereits die Kapazität erreicht haben, nichts tun
if ($currentLength >= $dataCapacityBits) {
return $encoded;
}
// Pad-Bytes-Muster (wechselt zwischen 11101100 und 00010001)
$padBytes = ['11101100', '00010001'];
$padIndex = 0;
// Füge Pad-Bytes hinzu, bis die Kapazität erreicht ist
while ($currentLength < $dataCapacityBits) {
$encoded .= $padBytes[$padIndex];
$padIndex = ($padIndex + 1) % 2;
$currentLength += 8;
}
return $encoded;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Service;
use App\Domain\QrCode\QrCode;
use App\Domain\QrCode\Exception\QrCodeException;
use App\Domain\QrCode\ValueObject\QrCodeMatrix;
use App\Domain\QrCode\ValueObject\QrCodeVersion;
class QrCodeGenerator
{
public function __construct(
private readonly QrCodeEncoder $encoder,
private readonly QrCodeRenderer $renderer
) {
}
/**
* Generiert einen QR-Code aus den gegebenen Daten
* @throws QrCodeException
*/
public function generate(QrCode $qrCode): QrCodeMatrix
{
$version = $this->determineVersion($qrCode);
$encodedData = $this->encoder->encode($qrCode, $version);
return $this->renderer->render($encodedData, $version, $qrCode->getErrorCorrectionLevel());
}
/**
* Bestimmt die optimale QR-Code-Version
* @throws QrCodeException
*/
private function determineVersion(QrCode $qrCode): QrCodeVersion
{
if ($qrCode->getVersion() !== null) {
return $qrCode->getVersion();
}
$dataLength = strlen($qrCode->getData());
// Bei längeren Daten mit größeren Versionen beginnen, um Berechnungszeit zu sparen
$startVersion = match (true) {
$dataLength < 30 => 1,
$dataLength < 100 => 5,
$dataLength < 500 => 15,
default => 25,
};
for ($version = $startVersion; $version <= 40; $version++) {
$qrVersion = new QrCodeVersion($version);
if ($qrVersion->getDataCapacity($qrCode->getErrorCorrectionLevel()) >= $dataLength) {
return $qrVersion;
}
}
throw new QrCodeException('Daten zu groß für QR-Code');
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Service;
use App\Domain\QrCode\ValueObject\MaskPattern;
class QrCodeMasker
{
/**
* Findet die beste Maske für die Matrix
*/
public function findBestMask(array $matrix, int $size): MaskPattern
{
$lowestPenalty = PHP_INT_MAX;
$bestMask = MaskPattern::PATTERN_0;
foreach (MaskPattern::cases() as $mask) {
$testMatrix = $matrix;
$this->applyMask($testMatrix, $size, $mask);
$penalty = $this->calculateMaskPenalty($testMatrix, $size);
if ($penalty < $lowestPenalty) {
$lowestPenalty = $penalty;
$bestMask = $mask;
}
}
return $bestMask;
}
/**
* Wendet eine Maske auf die Matrix an
*/
public function applyMask(array &$matrix, int $size, MaskPattern $maskPattern): void
{
for ($row = 0; $row < $size; $row++) {
for ($col = 0; $col < $size; $col++) {
if ($matrix[$row][$col] === null) {
continue;
}
if (!$this->isFunctionModule($row, $col, $size)) {
if ($maskPattern->shouldMask($row, $col)) {
$matrix[$row][$col] = !$matrix[$row][$col];
}
}
}
}
}
/**
* Berechnet die Strafpunkte für eine maskierte Matrix
*/
public function calculateMaskPenalty(array $matrix, int $size): int
{
$penalty = 0;
// Regel 1: Aufeinanderfolgende Module gleicher Farbe
$penalty += $this->evaluateConsecutiveModulesPenalty($matrix, $size);
// Regel 2: Blöcke gleicher Farbe
$penalty += $this->evaluateSameColorBlocksPenalty($matrix, $size);
// Regel 3: Muster, die Finder-Pattern ähneln
$penalty += $this->evaluateFinderPatternLikePenalty($matrix, $size);
// Regel 4: Ausgewogenheit schwarzer und weißer Module
$penalty += $this->evaluateBalancePenalty($matrix, $size);
return $penalty;
}
/**
* Evaluiert die Strafe für aufeinanderfolgende Module gleicher Farbe
*/
private function evaluateConsecutiveModulesPenalty(array $matrix, int $size): int
{
$penalty = 0;
// Zeilen prüfen
for ($row = 0; $row < $size; $row++) {
$count = 1;
for ($col = 1; $col < $size; $col++) {
if ($matrix[$row][$col] === $matrix[$row][$col - 1]) {
$count++;
} else {
if ($count >= 5) {
$penalty += 3 + ($count - 5);
}
$count = 1;
}
}
if ($count >= 5) {
$penalty += 3 + ($count - 5);
}
}
// Spalten prüfen
for ($col = 0; $col < $size; $col++) {
$count = 1;
for ($row = 1; $row < $size; $row++) {
if ($matrix[$row][$col] === $matrix[$row - 1][$col]) {
$count++;
} else {
if ($count >= 5) {
$penalty += 3 + ($count - 5);
}
$count = 1;
}
}
if ($count >= 5) {
$penalty += 3 + ($count - 5);
}
}
return $penalty;
}
/**
* Evaluiert die Strafe für 2x2 Blöcke gleicher Farbe
*/
private function evaluateSameColorBlocksPenalty(array $matrix, int $size): int
{
$penalty = 0;
for ($row = 0; $row < $size - 1; $row++) {
for ($col = 0; $col < $size - 1; $col++) {
$color = $matrix[$row][$col];
if ($color === $matrix[$row][$col + 1] &&
$color === $matrix[$row + 1][$col] &&
$color === $matrix[$row + 1][$col + 1]) {
$penalty += 3;
}
}
}
return $penalty;
}
/**
* Evaluiert die Strafe für Muster, die wie Finder-Pattern aussehen
*/
private function evaluateFinderPatternLikePenalty(array $matrix, int $size): int
{
$penalty = 0;
// Vereinfachte Implementation
return $penalty;
}
/**
* Evaluiert die Strafe für unausgewogene schwarze/weiße Module
*/
private function evaluateBalancePenalty(array $matrix, int $size): int
{
$darkCount = 0;
$totalCount = $size * $size;
for ($row = 0; $row < $size; $row++) {
for ($col = 0; $col < $size; $col++) {
if ($matrix[$row][$col] === true) {
$darkCount++;
}
}
}
$darkPercentage = $darkCount * 100 / $totalCount;
$deviation = abs($darkPercentage - 50) / 5;
return (int) ($deviation * 10);
}
/**
* Prüft, ob ein Modul ein Funktionsmodul ist (also nicht maskiert werden sollte)
*/
private function isFunctionModule(int $row, int $col, int $size): bool
{
// Finder-Pattern und Separatoren
if (($row < 9 && $col < 9) ||
($row < 9 && $col >= $size - 8) ||
($row >= $size - 8 && $col < 9)) {
return true;
}
// Timing-Pattern
if ($row === 6 || $col === 6) {
return true;
}
// Format-Informationen
if (($row < 9 && $col === 8) || ($row === 8 && $col < 9) ||
($row === 8 && $col >= $size - 8) || ($row >= $size - 8 && $col === 8)) {
return true;
}
// Dark Module bei Version 1
if ($row === 8 && $col === 13) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Service;
use App\Domain\QrCode\Pattern\AlignmentPattern;
use App\Domain\QrCode\Pattern\DarkModulePattern;
use App\Domain\QrCode\Pattern\FinderPattern;
use App\Domain\QrCode\Pattern\FormatInfoPattern;
use App\Domain\QrCode\Pattern\TimingPattern;
use App\Domain\QrCode\Pattern\VersionInfoPattern;
use App\Domain\QrCode\ValueObject\ErrorCorrectionLevel;
use App\Domain\QrCode\ValueObject\MaskPattern;
use App\Domain\QrCode\ValueObject\QrCodeMatrix;
use App\Domain\QrCode\ValueObject\QrCodeVersion;
class QrCodeRenderer
{
public function __construct(
private readonly QrCodeMasker $masker = new QrCodeMasker(),
private readonly ReedSolomon $reedSolomon = new ReedSolomon()
) {
}
/**
* Rendert einen QR-Code basierend auf den kodierten Daten
*/
public function render(string $data, QrCodeVersion $version, ErrorCorrectionLevel $level): QrCodeMatrix
{
$size = $version->getSize();
$matrix = array_fill(0, $size, array_fill(0, $size, null));
// 1. Funktionsmuster hinzufügen
$this->applyFunctionalPatterns($matrix, $version);
// 2. Daten mit Error Correction platzieren
$dataWithEcc = $this->reedSolomon->addErrorCorrection(
$data,
$version->getDataCapacity($level),
$level
);
$this->placeData($matrix, $dataWithEcc, $size);
// 3. Beste Maske finden und anwenden
$bestMask = $this->masker->findBestMask($matrix, $size);
$this->masker->applyMask($matrix, $size, $bestMask);
// 4. Format-Informationen hinzufügen
$formatPattern = new FormatInfoPattern($level, $bestMask->value);
$formatPattern->apply($matrix, $size);
// null-Werte durch false ersetzen
for ($r = 0; $r < $size; $r++) {
for ($c = 0; $c < $size; $c++) {
if ($matrix[$r][$c] === null) {
$matrix[$r][$c] = false;
}
}
}
return new QrCodeMatrix($matrix);
}
/**
* Wendet alle funktionalen Muster auf die Matrix an
*/
private function applyFunctionalPatterns(array &$matrix, QrCodeVersion $version): void
{
$size = $version->getSize();
$versionValue = $version->getValue();
// 1. Finder-Muster
$finderPatterns = FinderPattern::createAll($size);
foreach ($finderPatterns as $pattern) {
$pattern->apply($matrix, $size);
}
// 2. Timing-Muster
$timingPattern = new TimingPattern();
$timingPattern->apply($matrix, $size);
// 3. Ausrichtungsmuster (für Versionen > 1)
if ($versionValue > 1) {
$alignmentPattern = new AlignmentPattern($versionValue);
$alignmentPattern->apply($matrix, $size);
}
// 4. Dunkles Modul
$darkModule = new DarkModulePattern($versionValue);
$darkModule->apply($matrix, $size);
// 5. Versions-Information (für Versionen >= 7)
if ($versionValue >= 7) {
$versionInfoPattern = new VersionInfoPattern($versionValue);
$versionInfoPattern->apply($matrix, $size);
}
}
/**
* Platziert die kodierten Daten in der Matrix
*/
private function placeData(array &$matrix, string $data, int $size): void
{
$dataIndex = 0;
$direction = -1; // Starte mit Aufwärtsbewegung
// Starte von unten rechts und bewege dich im Zickzack-Muster
for ($col = $size - 1; $col > 0; $col -= 2) {
// Timing-Spalte überspringen
if ($col === 6) $col--;
for ($count = 0; $count < $size; $count++) {
$row = $direction === -1 ? $size - 1 - $count : $count;
for ($c = 0; $c < 2; $c++) {
$currentCol = $col - $c;
// Nur in leere Zellen (null) Daten einfügen
if ($row >= 0 && $row < $size && $currentCol >= 0 && $currentCol < $size &&
$matrix[$row][$currentCol] === null) {
if ($dataIndex < strlen($data)) {
$matrix[$row][$currentCol] = ($data[$dataIndex] === '1');
$dataIndex++;
} else {
$matrix[$row][$currentCol] = false;
}
}
}
}
$direction *= -1; // Richtung umkehren
}
}
}

View File

@@ -0,0 +1,486 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Service;
use App\Domain\QrCode\ValueObject\ErrorCorrectionLevel;
/**
* Implementiert Reed-Solomon-Fehlerkorrektur für QR-Codes
*/
class ReedSolomon
{
private ReedSolomonEncoder $encoder;
// Lookup-Tabelle für ECC-Wörter pro Block basierend auf QR-Version und EC-Level
private const ECC_WORDS_PER_BLOCK = [
// Version => [L, M, Q, H]
1 => [7, 10, 13, 17],
2 => [10, 16, 22, 28],
3 => [15, 26, 36, 44],
4 => [20, 36, 52, 64],
5 => [26, 48, 72, 88],
6 => [36, 64, 96, 112],
7 => [40, 72, 108, 130],
8 => [48, 88, 132, 156],
9 => [60, 110, 160, 192],
10 => [72, 130, 192, 224],
11 => [80, 150, 224, 264],
12 => [96, 176, 260, 308],
13 => [104, 198, 288, 352],
14 => [120, 216, 320, 384],
15 => [132, 240, 360, 432],
16 => [144, 280, 408, 480],
17 => [168, 308, 448, 532],
18 => [180, 338, 504, 588],
19 => [196, 364, 546, 650],
20 => [224, 416, 600, 700],
21 => [224, 442, 644, 750],
22 => [252, 476, 690, 816],
23 => [270, 504, 750, 900],
24 => [300, 560, 810, 960],
25 => [312, 588, 870, 1050],
26 => [336, 644, 952, 1110],
27 => [360, 700, 1020, 1200],
28 => [390, 728, 1050, 1260],
29 => [420, 784, 1140, 1350],
30 => [450, 812, 1200, 1440],
31 => [480, 868, 1290, 1530],
32 => [510, 924, 1350, 1620],
33 => [540, 980, 1440, 1710],
34 => [570, 1036, 1530, 1800],
35 => [570, 1064, 1590, 1890],
36 => [600, 1120, 1680, 1980],
37 => [630, 1204, 1770, 2100],
38 => [660, 1260, 1860, 2220],
39 => [720, 1316, 1950, 2310],
40 => [750, 1372, 2040, 2430],
];
// Lookup-Tabelle für die Anzahl der Daten- und ECC-Blöcke
// [Gruppe1Blöcke, Gruppe1Wörter, Gruppe2Blöcke, Gruppe2Wörter]
private const BLOCKS_PER_VERSION = [
1 => [
'L' => [1, 19, 0, 0],
'M' => [1, 16, 0, 0],
'Q' => [1, 13, 0, 0],
'H' => [1, 9, 0, 0],
],
2 => [
'L' => [1, 34, 0, 0],
'M' => [1, 28, 0, 0],
'Q' => [1, 22, 0, 0],
'H' => [1, 16, 0, 0],
],
3 => [
'L' => [1, 55, 0, 0],
'M' => [1, 44, 0, 0],
'Q' => [2, 17, 0, 0],
'H' => [2, 13, 0, 0],
],
4 => [
'L' => [1, 80, 0, 0],
'M' => [2, 32, 0, 0],
'Q' => [2, 24, 0, 0],
'H' => [4, 9, 0, 0],
],
5 => [
'L' => [1, 108, 0, 0],
'M' => [2, 43, 0, 0],
'Q' => [2, 15, 2, 16],
'H' => [2, 11, 2, 12],
],
6 => [
'L' => [2, 68, 0, 0],
'M' => [4, 27, 0, 0],
'Q' => [4, 19, 0, 0],
'H' => [4, 15, 0, 0],
],
7 => [
'L' => [2, 78, 0, 0],
'M' => [4, 31, 0, 0],
'Q' => [2, 14, 4, 15],
'H' => [4, 13, 1, 14],
],
8 => [
'L' => [2, 97, 0, 0],
'M' => [2, 38, 2, 39],
'Q' => [4, 18, 2, 19],
'H' => [4, 14, 2, 15],
],
9 => [
'L' => [2, 116, 0, 0],
'M' => [3, 36, 2, 37],
'Q' => [4, 16, 4, 17],
'H' => [4, 12, 4, 13],
],
10 => [
'L' => [2, 68, 2, 69],
'M' => [4, 43, 1, 44],
'Q' => [6, 19, 2, 20],
'H' => [6, 15, 2, 16],
],
11 => [
'L' => [4, 81, 0, 0],
'M' => [1, 50, 4, 51],
'Q' => [4, 22, 4, 23],
'H' => [3, 12, 8, 13],
],
12 => [
'L' => [2, 92, 2, 93],
'M' => [6, 36, 2, 37],
'Q' => [4, 20, 6, 21],
'H' => [7, 14, 4, 15],
],
13 => [
'L' => [4, 107, 0, 0],
'M' => [8, 37, 1, 38],
'Q' => [8, 20, 4, 21],
'H' => [12, 11, 4, 12],
],
14 => [
'L' => [3, 115, 1, 116],
'M' => [4, 40, 5, 41],
'Q' => [11, 16, 5, 17],
'H' => [11, 12, 5, 13],
],
15 => [
'L' => [5, 87, 1, 88],
'M' => [5, 41, 5, 42],
'Q' => [5, 24, 7, 25],
'H' => [11, 12, 7, 13],
],
16 => [
'L' => [5, 98, 1, 99],
'M' => [7, 45, 3, 46],
'Q' => [15, 19, 2, 20],
'H' => [3, 15, 13, 16],
],
17 => [
'L' => [1, 107, 5, 108],
'M' => [10, 46, 1, 47],
'Q' => [1, 22, 15, 23],
'H' => [2, 14, 17, 15],
],
18 => [
'L' => [5, 120, 1, 121],
'M' => [9, 43, 4, 44],
'Q' => [17, 22, 1, 23],
'H' => [2, 14, 19, 15],
],
19 => [
'L' => [3, 113, 4, 114],
'M' => [3, 44, 11, 45],
'Q' => [17, 21, 4, 22],
'H' => [9, 13, 16, 14],
],
20 => [
'L' => [3, 107, 5, 108],
'M' => [3, 41, 13, 42],
'Q' => [15, 24, 5, 25],
'H' => [15, 15, 10, 16],
],
21 => [
'L' => [4, 116, 4, 117],
'M' => [17, 42, 0, 0],
'Q' => [17, 22, 6, 23],
'H' => [19, 16, 6, 17],
],
22 => [
'L' => [2, 111, 7, 112],
'M' => [17, 46, 0, 0],
'Q' => [7, 24, 16, 25],
'H' => [34, 13, 0, 0],
],
23 => [
'L' => [4, 121, 5, 122],
'M' => [4, 47, 14, 48],
'Q' => [11, 24, 14, 25],
'H' => [16, 15, 14, 16],
],
24 => [
'L' => [6, 117, 4, 118],
'M' => [6, 45, 14, 46],
'Q' => [11, 24, 16, 25],
'H' => [30, 16, 2, 17],
],
25 => [
'L' => [8, 106, 4, 107],
'M' => [8, 47, 13, 48],
'Q' => [7, 24, 22, 25],
'H' => [22, 15, 13, 16],
],
26 => [
'L' => [10, 114, 2, 115],
'M' => [19, 46, 4, 47],
'Q' => [28, 22, 6, 23],
'H' => [33, 16, 4, 17],
],
27 => [
'L' => [8, 122, 4, 123],
'M' => [22, 45, 3, 46],
'Q' => [8, 23, 26, 24],
'H' => [12, 15, 28, 16],
],
28 => [
'L' => [3, 117, 10, 118],
'M' => [3, 45, 23, 46],
'Q' => [4, 24, 31, 25],
'H' => [11, 15, 31, 16],
],
29 => [
'L' => [7, 116, 7, 117],
'M' => [21, 45, 7, 46],
'Q' => [1, 23, 37, 24],
'H' => [19, 15, 26, 16],
],
30 => [
'L' => [5, 115, 10, 116],
'M' => [19, 47, 10, 48],
'Q' => [15, 24, 25, 25],
'H' => [23, 15, 25, 16],
],
31 => [
'L' => [13, 115, 3, 116],
'M' => [2, 46, 29, 47],
'Q' => [42, 24, 1, 25],
'H' => [23, 15, 28, 16],
],
32 => [
'L' => [17, 115, 0, 0],
'M' => [10, 46, 23, 47],
'Q' => [10, 24, 35, 25],
'H' => [19, 15, 35, 16],
],
33 => [
'L' => [17, 115, 1, 116],
'M' => [14, 46, 21, 47],
'Q' => [29, 24, 19, 25],
'H' => [11, 15, 46, 16],
],
34 => [
'L' => [13, 115, 6, 116],
'M' => [14, 46, 23, 47],
'Q' => [44, 24, 7, 25],
'H' => [59, 16, 1, 17],
],
35 => [
'L' => [12, 121, 7, 122],
'M' => [12, 47, 26, 48],
'Q' => [39, 24, 14, 25],
'H' => [22, 15, 41, 16],
],
36 => [
'L' => [6, 121, 14, 122],
'M' => [6, 47, 34, 48],
'Q' => [46, 24, 10, 25],
'H' => [2, 15, 64, 16],
],
37 => [
'L' => [17, 122, 4, 123],
'M' => [29, 46, 14, 47],
'Q' => [49, 24, 10, 25],
'H' => [24, 15, 46, 16],
],
38 => [
'L' => [4, 122, 18, 123],
'M' => [13, 46, 32, 47],
'Q' => [48, 24, 14, 25],
'H' => [42, 15, 32, 16],
],
39 => [
'L' => [20, 117, 4, 118],
'M' => [40, 47, 7, 48],
'Q' => [43, 24, 22, 25],
'H' => [10, 15, 67, 16],
],
40 => [
'L' => [19, 118, 6, 119],
'M' => [18, 47, 31, 48],
'Q' => [34, 24, 34, 25],
'H' => [20, 15, 61, 16],
],
];
public function __construct()
{
$this->encoder = new ReedSolomonEncoder();
}
/**
* Fügt Reed-Solomon-Fehlerkorrektur zu den Daten hinzu
*
* @param string $data Binäre Daten ('0' und '1')
* @param int $dataCapacity Kapazität in Bytes
* @param ErrorCorrectionLevel $level Fehlerkorrektur-Level
* @return string Binäre Daten mit Fehlerkorrektur
*/
public function addErrorCorrection(
string $data,
int $dataCapacity,
ErrorCorrectionLevel $level
): string {
// Konvertiere binäre Daten in Byte-Array
$bytes = $this->binaryToBytes($data);
// Berechne die QR-Version basierend auf der Kapazität
$version = $this->estimateVersionFromCapacity($dataCapacity);
// Bestimme die Anzahl der ECC-Wörter pro Block für diese Version und EC-Level
$eccWordsPerBlock = $this->getEccWordsPerBlock($version, $level);
// Organisiere Daten in Blöcke gemäß QR-Code-Spezifikation
$blocks = $this->organizeDataBlocks($bytes, $version, $level);
// Kodiere jeden Block mit Reed-Solomon
$encodedBlocks = [];
foreach ($blocks as $block) {
$encodedBlocks[] = $this->encoder->encode($block, $eccWordsPerBlock);
}
// Interleave die Blöcke gemäß QR-Code-Spezifikation
$interleavedData = $this->interleaveBlocks($encodedBlocks);
// Konvertiere zurück zu binärer Darstellung
return $this->bytesToBinary($interleavedData);
}
/**
* Konvertiert binäre Daten ('0' und '1') in ein Byte-Array
*/
private function binaryToBytes(string $binary): array
{
$bytes = [];
$length = strlen($binary);
// Verarbeite vollständige Bytes
for ($i = 0; $i < $length; $i += 8) {
if ($i + 8 <= $length) {
$byte = bindec(substr($binary, $i, 8));
$bytes[] = $byte;
} else {
// Letztes unvollständiges Byte mit Nullen auffüllen
$partialByte = substr($binary, $i);
$paddedByte = str_pad($partialByte, 8, '0', STR_PAD_RIGHT);
$bytes[] = bindec($paddedByte);
}
}
return $bytes;
}
/**
* Konvertiert ein Byte-Array zurück in binäre Darstellung
*/
private function bytesToBinary(array $bytes): string
{
$binary = '';
foreach ($bytes as $byte) {
$binary .= str_pad(decbin($byte), 8, '0', STR_PAD_LEFT);
}
return $binary;
}
/**
* Schätzt die QR-Version basierend auf der Kapazität
*/
private function estimateVersionFromCapacity(int $capacity): int
{
// Vereinfachte Schätzung
if ($capacity <= 17) return 1;
if ($capacity <= 32) return 2;
if ($capacity <= 53) return 3;
if ($capacity <= 78) return 4;
if ($capacity <= 106) return 5;
if ($capacity <= 134) return 6;
if ($capacity <= 154) return 7;
if ($capacity <= 192) return 8;
if ($capacity <= 230) return 9;
if ($capacity <= 271) return 10;
// Fallback für größere Versionen
return 10;
}
/**
* Ermittelt die Anzahl der ECC-Wörter pro Block für die angegebene Version und EC-Level
*/
private function getEccWordsPerBlock(int $version, ErrorCorrectionLevel $level): int
{
$index = match ($level) {
ErrorCorrectionLevel::L => 0,
ErrorCorrectionLevel::M => 1,
ErrorCorrectionLevel::Q => 2,
ErrorCorrectionLevel::H => 3,
};
return self::ECC_WORDS_PER_BLOCK[$version][$index] ?? 10; // Fallback-Wert
}
/**
* Organisiert die Daten in Blöcke gemäß QR-Code-Spezifikation
*/
private function organizeDataBlocks(array $data, int $version, ErrorCorrectionLevel $level): array
{
$blocks = [];
// Hol Block-Informationen
$blockInfo = self::BLOCKS_PER_VERSION[$version][$level->name] ?? [1, count($data), 0, 0];
$group1Blocks = $blockInfo[0];
$group1Words = $blockInfo[1];
$group2Blocks = $blockInfo[2];
$group2Words = $blockInfo[3];
$dataIndex = 0;
// Gruppe 1 Blöcke
for ($i = 0; $i < $group1Blocks; $i++) {
$block = array_slice($data, $dataIndex, $group1Words);
$blocks[] = $block;
$dataIndex += $group1Words;
}
// Gruppe 2 Blöcke
for ($i = 0; $i < $group2Blocks; $i++) {
$block = array_slice($data, $dataIndex, $group2Words);
$blocks[] = $block;
$dataIndex += $group2Words;
}
// Wenn nur ein Block, verwende alle Daten
if (empty($blocks)) {
$blocks[] = $data;
}
return $blocks;
}
/**
* Interleaved die kodierten Blöcke gemäß QR-Code-Spezifikation
*/
private function interleaveBlocks(array $blocks): array
{
$result = [];
$maxLength = 0;
// Finde die maximale Blocklänge
foreach ($blocks as $block) {
$maxLength = max($maxLength, count($block));
}
// Interleave Daten- und ECC-Bytes
for ($i = 0; $i < $maxLength; $i++) {
foreach ($blocks as $block) {
if ($i < count($block)) {
$result[] = $block[$i];
}
}
}
return $result;
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\Service;
use App\Domain\QrCode\ValueObject\GaloisField;
use App\Domain\QrCode\ValueObject\Polynomial;
/**
* Reed-Solomon Encoder für QR-Code-Fehlerkorrektur
*/
class ReedSolomonEncoder
{
private GaloisField $field;
public function __construct()
{
$this->field = new GaloisField();
}
/**
* Kodiert die Daten mit Reed-Solomon-Fehlerkorrektur
*
* @param array $data Daten als Array von Bytes
* @param int $eccLength Anzahl der Error-Correction-Codewords
* @return array Kodierte Daten mit ECC-Bytes
*/
public function encode(array $data, int $eccLength): array
{
if ($eccLength === 0) {
return $data;
}
// Erzeuge das Generator-Polynom für die gegebene Anzahl von ECC-Bytes
$generator = Polynomial::buildGenerator($this->field, $eccLength);
// Konvertiere Daten in ein Polynom
$dataPolynomial = new Polynomial($this->field, $data);
// Multipliziere mit x^eccLength, um Platz für ECC-Bytes zu schaffen
$paddedData = array_merge(
array_fill(0, $eccLength, 0),
$dataPolynomial->getCoefficients()
);
$paddedPolynomial = new Polynomial($this->field, $paddedData);
// Dividiere durch das Generator-Polynom, der Rest ist der ECC
$divResult = $paddedPolynomial->divideAndRemainder($generator);
$remainder = $divResult['remainder']->getCoefficients();
// Fülle den Rest mit Nullen auf, falls er zu kurz ist
if (count($remainder) < $eccLength) {
$remainder = array_merge(
array_fill(0, $eccLength - count($remainder), 0),
$remainder
);
}
// Konkateniere Daten und ECC-Bytes
return array_merge($data, $remainder);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
enum ErrorCorrectionLevel: string
{
case L = 'L'; // ~7% Korrektur
case M = 'M'; // ~15% Korrektur
case Q = 'Q'; // ~25% Korrektur
case H = 'H'; // ~30% Korrektur
public function getCapacityReduction(): float
{
return match ($this) {
self::L => 0.07,
self::M => 0.15,
self::Q => 0.25,
self::H => 0.30,
};
}
public function getNumeric(): int
{
return match ($this) {
self::L => 1,
self::M => 0,
self::Q => 3,
self::H => 2,
};
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
/**
* Repräsentiert ein Galois-Feld GF(2^8) für Reed-Solomon-Berechnungen
*/
class GaloisField
{
// Generator-Polynom für GF(2^8)
private const PRIMITIVE_POLY = 0x11D; // x^8 + x^4 + x^3 + x^2 + 1
// Lookup-Tabellen für Multiplikation und Division
private array $expTable = [];
private array $logTable = [];
public function __construct()
{
$this->initTables();
}
/**
* Initialisiert die Lookup-Tabellen für schnelle Berechnungen
*/
private function initTables(): void
{
// Setze Tabellen-Größe
$this->expTable = array_fill(0, 256, 0);
$this->logTable = array_fill(0, 256, 0);
// Berechne die Exponentiation und Logarithmus-Tabellen
$x = 1;
for ($i = 0; $i < 255; $i++) {
$this->expTable[$i] = $x;
$this->logTable[$x] = $i;
// Multiplikation mit x in GF(2^8)
$x = ($x << 1) ^ ($x & 0x80 ? self::PRIMITIVE_POLY : 0);
$x &= 0xFF;
}
// Setze expTable[255] = expTable[0]
$this->expTable[255] = $this->expTable[0];
}
/**
* Addiert zwei Elemente im Galois-Feld
*/
public function add(int $a, int $b): int
{
return $a ^ $b; // In GF(2^8) ist Addition = XOR
}
/**
* Subtrahiert zwei Elemente im Galois-Feld
* In GF(2^8) ist Subtraktion = Addition = XOR
*/
public function subtract(int $a, int $b): int
{
return $this->add($a, $b);
}
/**
* Multipliziert zwei Elemente im Galois-Feld
*/
public function multiply(int $a, int $b): int
{
// Spezialfall: Null-Multiplikation
if ($a === 0 || $b === 0) {
return 0;
}
// Benutze Log-Tabellen für Multiplikation
$logA = $this->logTable[$a];
$logB = $this->logTable[$b];
$sumLog = ($logA + $logB) % 255;
return $this->expTable[$sumLog];
}
/**
* Dividiert zwei Elemente im Galois-Feld
*/
public function divide(int $a, int $b): int
{
if ($b === 0) {
throw new \DivisionByZeroError('Division by zero in Galois field');
}
if ($a === 0) {
return 0;
}
// Benutze Log-Tabellen für Division
$logA = $this->logTable[$a];
$logB = $this->logTable[$b];
$diffLog = ($logA - $logB + 255) % 255;
return $this->expTable[$diffLog];
}
/**
* Berechnet die Potenz eines Elements im Galois-Feld
*/
public function power(int $a, int $power): int
{
if ($a === 0) {
return 0;
}
if ($power === 0) {
return 1;
}
$logA = $this->logTable[$a];
$resultLog = ($logA * $power) % 255;
return $this->expTable[$resultLog];
}
/**
* Liefert die Logarithmus-Tabelle
*/
public function getLogTable(): array
{
return $this->logTable;
}
/**
* Liefert die Exponentiations-Tabelle
*/
public function getExpTable(): array
{
return $this->expTable;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
enum MaskPattern: int
{
case PATTERN_0 = 0; // (row + column) mod 2 == 0
case PATTERN_1 = 1; // row mod 2 == 0
case PATTERN_2 = 2; // column mod 3 == 0
case PATTERN_3 = 3; // (row + column) mod 3 == 0
case PATTERN_4 = 4; // (floor(row/2) + floor(column/3)) mod 2 == 0
case PATTERN_5 = 5; // ((row * column) mod 2) + ((row * column) mod 3) == 0
case PATTERN_6 = 6; // (((row * column) mod 2) + ((row * column) mod 3)) mod 2 == 0
case PATTERN_7 = 7; // (((row + column) mod 2) + ((row * column) mod 3)) mod 2 == 0
public function shouldMask(int $row, int $col): bool
{
return match ($this) {
self::PATTERN_0 => ($row + $col) % 2 === 0,
self::PATTERN_1 => $row % 2 === 0,
self::PATTERN_2 => $col % 3 === 0,
self::PATTERN_3 => ($row + $col) % 3 === 0,
self::PATTERN_4 => (intval($row / 2) + intval($col / 3)) % 2 === 0,
self::PATTERN_5 => ($row * $col) % 2 + ($row * $col) % 3 === 0,
self::PATTERN_6 => (($row * $col) % 2 + ($row * $col) % 3) % 2 === 0,
self::PATTERN_7 => (($row + $col) % 2 + ($row * $col) % 3) % 2 === 0,
};
}
}

View File

@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
/**
* Repräsentiert ein Polynom im Galois-Feld für Reed-Solomon-Berechnungen
*/
class Polynomial
{
private array $coefficients;
public function __construct(
private readonly GaloisField $field,
array $coefficients
) {
// Entferne führende Nullen
$this->coefficients = $this->removeLeadingZeros($coefficients);
// Leeres Polynom = [0]
if (empty($this->coefficients)) {
$this->coefficients = [0];
}
}
/**
* Entfernt führende Nullen aus dem Koeffizienten-Array
*/
private function removeLeadingZeros(array $coefficients): array
{
$i = 0;
while ($i < count($coefficients) - 1 && $coefficients[$i] === 0) {
$i++;
}
return array_slice($coefficients, $i);
}
/**
* Liefert die Koeffizienten des Polynoms
*/
public function getCoefficients(): array
{
return $this->coefficients;
}
/**
* Liefert den Grad des Polynoms
*/
public function getDegree(): int
{
return count($this->coefficients) - 1;
}
/**
* Wertet das Polynom an der Stelle x aus
*/
public function evaluateAt(int $x): int
{
if ($x === 0) {
// f(0) ist der letzte Koeffizient
return $this->coefficients[count($this->coefficients) - 1];
}
$result = 0;
$degree = $this->getDegree();
for ($i = 0; $i <= $degree; $i++) {
// result = result * x + coef[i]
$term = $this->field->multiply(
$result,
$x
);
$result = $this->field->add($term, $this->coefficients[$i]);
}
return $result;
}
/**
* Addiert zwei Polynome
*/
public function add(Polynomial $other): Polynomial
{
if ($this->isZero()) {
return $other;
}
if ($other->isZero()) {
return $this;
}
$thisCoeffs = $this->coefficients;
$otherCoeffs = $other->getCoefficients();
$resultLength = max(count($thisCoeffs), count($otherCoeffs));
$result = array_fill(0, $resultLength, 0);
// Addiere die Koeffizienten
for ($i = 0; $i < count($thisCoeffs); $i++) {
$result[$i + ($resultLength - count($thisCoeffs))] = $thisCoeffs[$i];
}
for ($i = 0; $i < count($otherCoeffs); $i++) {
$idx = $i + ($resultLength - count($otherCoeffs));
$result[$idx] = $this->field->add($result[$idx], $otherCoeffs[$i]);
}
return new Polynomial($this->field, $result);
}
/**
* Multipliziert zwei Polynome
*/
public function multiply(Polynomial $other): Polynomial
{
if ($this->isZero() || $other->isZero()) {
return new Polynomial($this->field, [0]);
}
$thisCoeffs = $this->coefficients;
$otherCoeffs = $other->getCoefficients();
$resultLength = count($thisCoeffs) + count($otherCoeffs) - 1;
$result = array_fill(0, $resultLength, 0);
// Multipliziere die Koeffizienten
for ($i = 0; $i < count($thisCoeffs); $i++) {
for ($j = 0; $j < count($otherCoeffs); $j++) {
$idx = $i + $j;
$product = $this->field->multiply($thisCoeffs[$i], $otherCoeffs[$j]);
$result[$idx] = $this->field->add($result[$idx], $product);
}
}
return new Polynomial($this->field, $result);
}
/**
* Multipliziert das Polynom mit einem Skalar
*/
public function multiplyByScalar(int $scalar): Polynomial
{
if ($scalar === 0) {
return new Polynomial($this->field, [0]);
}
if ($scalar === 1) {
return $this;
}
$result = [];
foreach ($this->coefficients as $coefficient) {
$result[] = $this->field->multiply($coefficient, $scalar);
}
return new Polynomial($this->field, $result);
}
/**
* Dividiert ein Polynom durch ein anderes und gibt Quotient und Rest zurück
*/
public function divideAndRemainder(Polynomial $other): array
{
if ($other->isZero()) {
throw new \DivisionByZeroError('Division by zero polynomial');
}
if ($this->isZero()) {
return [
'quotient' => new Polynomial($this->field, [0]),
'remainder' => new Polynomial($this->field, [0])
];
}
if ($this->getDegree() < $other->getDegree()) {
return [
'quotient' => new Polynomial($this->field, [0]),
'remainder' => $this
];
}
$quotientCoeffs = array_fill(0, $this->getDegree() - $other->getDegree() + 1, 0);
$remainderCoeffs = $this->coefficients;
$denomLeadingTerm = $other->getCoefficients()[0];
for ($i = 0; $i <= $this->getDegree() - $other->getDegree(); $i++) {
if ($remainderCoeffs[$i] === 0) {
continue;
}
$quotientCoeffs[$i] = $this->field->divide($remainderCoeffs[$i], $denomLeadingTerm);
for ($j = 0; $j <= $other->getDegree(); $j++) {
$idx = $i + $j;
$product = $this->field->multiply($quotientCoeffs[$i], $other->getCoefficients()[$j]);
$remainderCoeffs[$idx] = $this->field->subtract($remainderCoeffs[$idx], $product);
}
}
// Extrahiere den Rest
$remainderDegree = $other->getDegree() - 1;
$remainder = array_slice($remainderCoeffs, count($remainderCoeffs) - $remainderDegree - 1);
return [
'quotient' => new Polynomial($this->field, $quotientCoeffs),
'remainder' => new Polynomial($this->field, $remainder)
];
}
/**
* Prüft, ob das Polynom das Nullpolynom ist
*/
public function isZero(): bool
{
return count($this->coefficients) === 1 && $this->coefficients[0] === 0;
}
/**
* Erzeugt ein Generatorpolynom für Reed-Solomon-Codes
*/
public static function buildGenerator(GaloisField $field, int $degree): Polynomial
{
// g(x) = (x-α^0)(x-α^1)...(x-α^(degree-1))
$generator = new Polynomial($field, [1]); // Beginne mit g(x) = 1
for ($i = 0; $i < $degree; $i++) {
// Faktor (x-α^i)
$factor = new Polynomial($field, [
1, // x^1
$field->power(2, $i) // -α^i (In GF(2^m) ist -a = a)
]);
$generator = $generator->multiply($factor);
}
return $generator;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
readonly class Position
{
public function __construct(
private int $row,
private int $column
) {
}
public function getRow(): int
{
return $this->row;
}
public function getColumn(): int
{
return $this->column;
}
public function offset(int $rowOffset, int $columnOffset): self
{
return new self(
$this->row + $rowOffset,
$this->column + $columnOffset
);
}
public function isWithinBounds(int $size): bool
{
return $this->row >= 0 && $this->row < $size &&
$this->column >= 0 && $this->column < $size;
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
readonly class QrCodeConfig
{
/**
* @param int $moduleSize Größe eines QR-Code-Moduls in Pixeln
* @param int $margin Rand um den QR-Code in Modulen
* @param string $foregroundColor Vordergrundfarbe (für SVG)
* @param string $backgroundColor Hintergrundfarbe (für SVG)
*/
public function __construct(
private int $moduleSize = 4,
private int $margin = 4,
private string $foregroundColor = '#000000',
private string $backgroundColor = '#FFFFFF'
) {
}
public function getModuleSize(): int
{
return $this->moduleSize;
}
public function getMargin(): int
{
return $this->margin;
}
public function getForegroundColor(): string
{
return $this->foregroundColor;
}
public function getBackgroundColor(): string
{
return $this->backgroundColor;
}
public function withModuleSize(int $moduleSize): self
{
return new self(
$moduleSize,
$this->margin,
$this->foregroundColor,
$this->backgroundColor
);
}
public function withMargin(int $margin): self
{
return new self(
$this->moduleSize,
$margin,
$this->foregroundColor,
$this->backgroundColor
);
}
public function withColors(string $foreground, string $background): self
{
return new self(
$this->moduleSize,
$this->margin,
$foreground,
$background
);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
readonly class QrCodeMatrix
{
public function __construct(
private array $matrix
) {
}
public function getMatrix(): array
{
return $this->matrix;
}
public function getSize(): int
{
return count($this->matrix);
}
public function getModule(int $row, int $col): bool
{
return $this->matrix[$row][$col] ?? false;
}
public function toSvg(int $moduleSize = 4, int $margin = 4, string $foreground = '#000000', string $background = '#FFFFFF'): string
{
// Stellen Sie sicher, dass der Margin mindestens 4 Module beträgt (Quiet Zone)
$margin = max(4, $margin);
$size = $this->getSize();
$totalSize = ($size + 2 * $margin) * $moduleSize;
$svg = sprintf(
'<svg width="%d" height="%d" viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg">',
$totalSize, $totalSize, $totalSize, $totalSize
);
$svg .= sprintf('<rect width="100%%" height="100%%" fill="%s"/>', $background);
for ($row = 0; $row < $size; $row++) {
for ($col = 0; $col < $size; $col++) {
if ($this->getModule($row, $col)) {
$x = ($col + $margin) * $moduleSize;
$y = ($row + $margin) * $moduleSize;
$svg .= sprintf(
'<rect x="%d" y="%d" width="%d" height="%d" fill="%s"/>',
$x, $y, $moduleSize, $moduleSize, $foreground
);
}
}
}
$svg .= '</svg>';
return $svg;
}
public function toPng(int $moduleSize = 4, int $margin = 4): string
{
// Stellen Sie sicher, dass der Margin mindestens 4 Module beträgt (Quiet Zone)
$margin = max(4, $margin);
$size = $this->getSize();
$totalSize = ($size + 2 * $margin) * $moduleSize;
$image = imagecreate($totalSize, $totalSize);
$white = imagecolorallocate($image, 255, 255, 255);
$black = imagecolorallocate($image, 0, 0, 0);
imagefill($image, 0, 0, $white);
for ($row = 0; $row < $size; $row++) {
for ($col = 0; $col < $size; $col++) {
if ($this->getModule($row, $col)) {
$x1 = ($col + $margin) * $moduleSize;
$y1 = ($row + $margin) * $moduleSize;
$x2 = $x1 + $moduleSize - 1;
$y2 = $y1 + $moduleSize - 1;
imagefilledrectangle($image, $x1, $y1, $x2, $y2, $black);
}
}
}
ob_start();
imagepng($image);
$pngData = ob_get_contents();
ob_end_clean();
imagedestroy($image);
return $pngData;
}
public function toAscii(): string
{
$size = $this->getSize();
$result = '';
for ($row = 0; $row < $size; $row++) {
for ($col = 0; $col < $size; $col++) {
$result .= $this->getModule($row, $col) ? '██' : ' ';
}
$result .= "\n";
}
return $result;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
enum QrCodeMode: int
{
case NUMERIC = 1;
case ALPHANUMERIC = 2;
case BYTE = 4;
case KANJI = 8;
public function getCharacterCountBits(QrCodeVersion $version): int
{
return match ($this) {
self::NUMERIC => match (true) {
$version->getValue() <= 9 => 10,
$version->getValue() <= 26 => 12,
default => 14,
},
self::ALPHANUMERIC => match (true) {
$version->getValue() <= 9 => 9,
$version->getValue() <= 26 => 11,
default => 13,
},
self::BYTE => match (true) {
$version->getValue() <= 9 => 8,
$version->getValue() <= 26 => 16,
default => 16,
},
self::KANJI => match (true) {
$version->getValue() <= 9 => 8,
$version->getValue() <= 26 => 10,
default => 12,
},
};
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Domain\QrCode\ValueObject;
use App\Domain\QrCode\Exception\QrCodeException;
readonly class QrCodeVersion
{
private int $version;
/**
* Datenbyte-Kapazität für QR-Codes (nach QR-Code-Spezifikation)
* Version => [L, M, Q, H] in Bytes
*/
private const DATA_CAPACITY = [
// Version => [L, M, Q, H] (in Bytes)
1 => [19, 16, 13, 9],
2 => [34, 28, 22, 16],
3 => [55, 44, 34, 26],
4 => [80, 64, 48, 36],
5 => [108, 86, 62, 46],
6 => [136, 108, 76, 60],
7 => [156, 124, 88, 66],
8 => [194, 154, 110, 86],
9 => [232, 182, 132, 100],
10 => [274, 216, 154, 122],
11 => [324, 254, 180, 140],
12 => [370, 290, 206, 158],
13 => [428, 334, 244, 180],
14 => [461, 365, 261, 197],
15 => [523, 415, 295, 223],
16 => [589, 453, 325, 253],
17 => [647, 507, 367, 283],
18 => [721, 563, 397, 313],
19 => [795, 627, 445, 341],
20 => [861, 669, 485, 385],
21 => [932, 714, 512, 406],
22 => [1006, 782, 568, 442],
23 => [1094, 860, 614, 464],
24 => [1174, 914, 664, 514],
25 => [1276, 1000, 718, 538],
26 => [1370, 1062, 754, 596],
27 => [1468, 1128, 808, 628],
28 => [1531, 1193, 871, 661],
29 => [1631, 1267, 911, 701],
30 => [1735, 1373, 985, 745],
31 => [1843, 1455, 1033, 793],
32 => [1955, 1541, 1115, 845],
33 => [2071, 1631, 1171, 901],
34 => [2191, 1725, 1231, 961],
35 => [2306, 1812, 1286, 986],
36 => [2434, 1914, 1354, 1054],
37 => [2566, 1992, 1426, 1096],
38 => [2702, 2102, 1502, 1142],
39 => [2812, 2216, 1582, 1222],
40 => [2956, 2334, 1666, 1276],
];
/**
* Kapazitätstabelle für Zeichen in verschiedenen Modi
* Dies sind Näherungswerte für die Anzahl der Zeichen
*/
private const CHARACTER_CAPACITY = [
'numeric' => [ // Numerische Kapazität (nur Ziffern)
1 => [41, 34, 27, 17],
2 => [77, 63, 48, 34],
5 => [255, 202, 144, 106],
10 => [652, 513, 364, 288],
20 => [2061, 1600, 1156, 909],
30 => [4158, 3289, 2358, 1782],
40 => [7089, 5596, 3993, 3057],
],
'alphanumeric' => [ // Alphanumerische Kapazität (Zahlen, Großbuchstaben, einige Symbole)
1 => [25, 20, 16, 10],
2 => [47, 38, 29, 20],
5 => [154, 122, 87, 64],
10 => [395, 311, 220, 174],
20 => [1249, 969, 701, 551],
30 => [2520, 1991, 1429, 1080],
40 => [4296, 3391, 2420, 1852],
],
'byte' => [ // Byte-Kapazität (8-Bit-Bytes)
1 => [17, 14, 11, 7],
2 => [32, 26, 20, 14],
5 => [106, 84, 60, 44],
10 => [271, 213, 151, 119],
20 => [858, 666, 482, 378],
30 => [1732, 1370, 982, 742],
40 => [2953, 2331, 1663, 1273],
],
'kanji' => [ // Kanji-Kapazität (Japanische Schriftzeichen)
1 => [10, 8, 7, 4],
2 => [20, 16, 12, 8],
5 => [65, 52, 37, 27],
10 => [167, 131, 93, 74],
20 => [528, 410, 297, 232],
30 => [1066, 841, 604, 457],
40 => [1817, 1435, 1024, 784],
],
];
public function __construct(int $version)
{
if ($version < 1 || $version > 40) {
throw new QrCodeException('QR Code Version muss zwischen 1 und 40 liegen');
}
$this->version = $version;
}
public function getValue(): int
{
return $this->version;
}
public function getSize(): int
{
return 21 + ($this->version - 1) * 4;
}
/**
* Liefert die Datenbyte-Kapazität für das angegebene Error-Correction-Level
*/
public function getDataCapacity(ErrorCorrectionLevel $level): int
{
$index = match ($level) {
ErrorCorrectionLevel::L => 0,
ErrorCorrectionLevel::M => 1,
ErrorCorrectionLevel::Q => 2,
ErrorCorrectionLevel::H => 3,
};
return self::DATA_CAPACITY[$this->version][$index];
}
/**
* Gibt die Kapazität in Zeichen für einen bestimmten Kodierungsmodus zurück
*/
public function getCharacterCapacity(string $mode, ErrorCorrectionLevel $level): int
{
if (!isset(self::CHARACTER_CAPACITY[$mode])) {
throw new QrCodeException("Unbekannter Kodierungsmodus: $mode");
}
$index = match ($level) {
ErrorCorrectionLevel::L => 0,
ErrorCorrectionLevel::M => 1,
ErrorCorrectionLevel::Q => 2,
ErrorCorrectionLevel::H => 3,
};
// Finde die nächste Version in der Kapazitätstabelle
$capacityVersion = $this->version;
while (!isset(self::CHARACTER_CAPACITY[$mode][$capacityVersion]) && $capacityVersion > 0) {
$capacityVersion--;
}
// Wenn keine passende Version gefunden wurde, verwende die niedrigste
if ($capacityVersion === 0) {
$capacityVersion = min(array_keys(self::CHARACTER_CAPACITY[$mode]));
}
return self::CHARACTER_CAPACITY[$mode][$capacityVersion][$index];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
final readonly class CreateUsersTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$connection->query("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ulid BINARY(16) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL
)"
);
}
public function down(ConnectionInterface $connection): void
{
$connection->query("DROP TABLE users");
}
public function getVersion(): string
{
return "001";
}
public function getDescription(): string
{
return "Create Users Table";
}
}

23
src/Domain/User/User.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domain\User;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'users')]
final readonly class User
{
public function __construct(
#[Column(name: 'id', primary: true)]
public string $id,
#[Column(name: 'name')]
public string $name,
#public ?string $email = null,
/*#[Column(name: 'email')]
public string $email*/
) {}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\ValueObjects;
final readonly class EmailAddress
{
public function __construct(
public string $value
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email address: {$value}");
}
}
public function __toString(): string
{
return $this->value;
}
public function getDomain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function getLocalPart(): string
{
return substr($this->value, 0, strpos($this->value, '@'));
}
}