images: Datenmodell, Textarchiv, Herkunftsprüfung und gemeinsamer Uploader für Formular v2 (Bildkennzeichnung Block A)

- press_release_images: Kundenangabe/Portalkennzeichnung getrennt, Herkunftsbefund, Beweisoriginal, Snapshot-Pfade, published_ever_at, rules_version, Fokus/Letterbox; Bestand unangetastet
- legal_texts (insert-only) mit img-confirm-v1 (alter Wortlaut), v2 (AGB 0.6), v3 (AGB 0.7, noch ohne Datum); image-rights:backfill-confirmations
- image_rights_confirmations polymorph, insert-only, IP als versionierter HMAC; Löschung nur für nie veröffentlichte Uploads
- ImageProvenanceInspector (Imagick/exif): IPTC, XMP, EXIF; algorithmicallyEnhanced nur Vermerk; unlesbare Datei = error
- PressReleaseImageUploader: Original vor Derivaten, Hash-Bindung, Titelbild-Regel unter Sperre, Dateien bei Fehlern zurückgerollt; Admin-Upload ohne Bestätigung
- ImageService: kein Rückfall auf die Quelldatei, og-Variante, Fokuspunkt und no_crop

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:22:05 +00:00
parent b624f80d7f
commit e9bdff3ad3
36 changed files with 2631 additions and 30 deletions

View file

@ -0,0 +1,77 @@
<?php
use App\Enums\ImageProvenanceStatus;
use App\Services\Image\ImageProvenanceInspector;
/**
* Bildkennzeichnung A.5: echte Fixture-Dateien (tests/Fixtures/images,
* erzeugt mit generate-fixtures.php), keine Fake-Bilder.
*/
function provenanceFixture(string $name): string
{
return base_path('tests/Fixtures/images/'.$name);
}
test('a camera jpeg with classic iptc yields an unsigned credit suggestion and no ai suggestion', function () {
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('camera-iptc.jpg'));
expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata)
->and($result->creditSuggestion())->toBe('Musterfirma GmbH / Anna Beispiel')
->and($result->derived['credit_suggestion_source'])->toBe('credit')
->and($result->suggestedAiDeclaration())->toBeNull()
->and($result->enforcedAiDeclaration())->toBeNull()
->and($result->iptc['byline'])->toBe('Anna Beispiel')
->and($result->iptc['copyright_notice'])->toBe('© 2026 Musterfirma GmbH')
->and($result->fileSha256)->toBe(hash_file('sha256', provenanceFixture('camera-iptc.jpg')));
});
test('xmp trainedAlgorithmicMedia as full iptc uri suggests ai_generated but never locks in phase one', function () {
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('xmp-ai-generated.jpg'));
expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata)
->and($result->iptc['digital_source_type'])->toBe('trainedAlgorithmicMedia')
->and($result->suggestedAiDeclaration()?->value)->toBe('ai_generated')
->and($result->enforcedAiDeclaration())->toBeNull()
->and($result->creditSuggestion())->toBe('KI-Bild / Musterfirma');
});
test('algorithmicallyEnhanced only sets the enhancement hint and takes the creator list as credit', function () {
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('xmp-enhanced.jpg'));
expect($result->suggestedAiDeclaration())->toBeNull()
->and($result->derived['enhancement_hint'])->toBeTrue()
->and($result->creditSuggestion())->toBe('Max Mustermann')
->and($result->derived['credit_suggestion_source'])->toBe('creator');
});
test('a png without metadata is absent, not an error', function () {
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('plain.png'));
expect($result->status)->toBe(ImageProvenanceStatus::Absent)
->and($result->creditSuggestion())->toBeNull()
->and($result->error)->toBeNull();
});
test('webp xmp digitalCapture suggests original and falls back to the copyright notice for the credit', function () {
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('webp-capture.webp'));
expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata)
->and($result->suggestedAiDeclaration()?->value)->toBe('original')
->and($result->creditSuggestion())->toBe('© Beispiel AG')
->and($result->derived['credit_suggestion_source'])->toBe('copyright_notice');
});
test('an unreadable file reports error instead of pretending there is no metadata', function () {
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('broken.jpg'));
expect($result->status)->toBe(ImageProvenanceStatus::Error)
->and($result->error)->not->toBeNull()
->and($result->toArray()['error']['class'])->toBe('ImagickException');
});
test('the result serialises with status, tool and duration for the image row', function () {
$array = (new ImageProvenanceInspector)->inspect(provenanceFixture('camera-iptc.jpg'))->toArray();
expect($array)->toHaveKeys(['status', 'file_sha256', 'tool', 'duration_ms', 'iptc', 'exif', 'derived'])
->and($array['tool'])->toStartWith('imagick ');
});

View file

@ -0,0 +1,101 @@
<?php
use App\Enums\ConfirmationChannel;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
function makeConfirmationFor(PressReleaseImage $image): ImageRightsConfirmation
{
return ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $image->press_release_id,
'company_id' => $image->pressRelease->company_id,
'user_id' => $image->pressRelease->user_id,
'channel' => ConfirmationChannel::Form,
'legal_text_key' => 'img-confirm-v2',
'text_snapshot' => 'Ich bestätige …',
'agb_version' => '0.6',
'file_sha256' => $image->original_sha256,
'declaration_snapshot' => ['copyright' => $image->copyright],
'ip_hmac' => ImageRightsConfirmation::hashIp('203.0.113.7'),
'user_agent' => 'PestBrowser/1.0',
'confirmed_at' => now(),
'created_at' => now(),
]);
}
test('confirmations are insert-only', function () {
$image = PressReleaseImage::factory()->rulesV2()->create();
$confirmation = makeConfirmationFor($image);
expect(fn () => $confirmation->update(['agb_version' => '0.7']))->toThrow(LogicException::class)
->and(fn () => $confirmation->delete())->toThrow(LogicException::class)
->and(ImageRightsConfirmation::query()->count())->toBe(1);
});
test('only the purge for never published uploads may remove a confirmation', function () {
$image = PressReleaseImage::factory()->rulesV2()->create();
$confirmation = makeConfirmationFor($image);
$confirmation->purgeForUnpublished();
expect(ImageRightsConfirmation::query()->count())->toBe(0);
});
test('a hard deleted image leaves its confirmation with the snapshot in place', function () {
$image = PressReleaseImage::factory()->rulesV2()->create();
$confirmation = makeConfirmationFor($image);
$pressReleaseId = $image->press_release_id;
$image->forceDelete();
$confirmation->refresh();
expect($confirmation->subject_id)->toBe($image->getKey())
->and($confirmation->press_release_id)->toBe($pressReleaseId)
->and($confirmation->file_sha256)->toBe($image->original_sha256)
->and($confirmation->declaration_snapshot['copyright'])->toBe($image->copyright);
});
test('a hard deleted press release nulls the reference but keeps the row', function () {
$pressRelease = PressRelease::factory()->create();
$image = PressReleaseImage::factory()->for($pressRelease)->rulesV2()->create();
$confirmation = makeConfirmationFor($image);
$pressRelease->forceDelete();
expect($confirmation->fresh())->not->toBeNull()
->and($confirmation->fresh()?->press_release_id)->toBeNull();
});
test('the ip derivation is keyed, versioned and not a plain hash', function () {
config()->set('images.confirmation_ip_key', 'geheim-1');
$first = ImageRightsConfirmation::hashIp('203.0.113.7');
config()->set('images.confirmation_ip_key', 'geheim-2');
$second = ImageRightsConfirmation::hashIp('203.0.113.7');
expect($first)->toStartWith('v1:')
->and($first)->not->toBe($second)
->and($first)->not->toContain(hash('sha256', '203.0.113.7'))
->and(ImageRightsConfirmation::hashIp(null))->toBeNull();
});
test('the image exposes its latest valid confirmation bound to the current file hash', function () {
$image = PressReleaseImage::factory()->rulesV2()->create();
expect($image->hasValidRightsConfirmation())->toBeFalse()
->and($image->isLegacyRules())->toBeFalse();
makeConfirmationFor($image);
expect($image->fresh()->hasValidRightsConfirmation())->toBeTrue();
// Bildtausch: neuer Hash, alte Bestätigung zählt nicht mehr (F09).
$image->update(['original_sha256' => hash('sha256', 'neue-datei')]);
expect($image->fresh()->hasValidRightsConfirmation())->toBeFalse()
->and(PressReleaseImage::factory()->legacy()->create()->isLegacyRules())->toBeTrue();
});

View file

@ -0,0 +1,257 @@
<?php
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Enums\ImageProvenanceStatus;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
Storage::fake('local');
$this->seed(LegalDocumentSeeder::class);
$this->seed(LegalTextSeeder::class);
});
function uploaderFixture(string $name): UploadedFile
{
$source = base_path('tests/Fixtures/images/'.$name);
$copy = tempnam(sys_get_temp_dir(), 'pk-upload-');
copy($source, $copy);
return new UploadedFile($copy, $name, mime_content_type($copy), null, true);
}
function uploaderSubmission(array $overrides = []): ImageSubmission
{
return new ImageSubmission(...array_merge([
'copyright' => 'Musterfirma GmbH',
'aiDeclaration' => AiImageDeclaration::Original,
'rightsConfirmed' => true,
'legalTextKey' => 'img-confirm-v2',
], $overrides));
}
function uploaderActor(PressRelease $pressRelease): ImageUploadActor
{
return ImageUploadActor::form($pressRelease->user, '203.0.113.9', 'PestBrowser/1.0');
}
test('a form upload stores original, derivatives, provenance and a confirmation bound to the same hash', function () {
$pressRelease = PressRelease::factory()->create();
$upload = uploaderFixture('camera-iptc.jpg');
$expectedHash = hash_file('sha256', $upload->getRealPath());
$image = app(PressReleaseImageUploader::class)->store(
$upload,
$pressRelease,
uploaderSubmission(['creditFromSuggestion' => true]),
uploaderActor($pressRelease),
expectedSha256: $expectedHash,
agbUrl: 'https://presseecho.test/de/agb',
);
expect($image->rules_version)->toBe(config('images.rules_version'))
->and($image->original_sha256)->toBe($expectedHash)
->and($image->original_path)->toStartWith('press-release-originals/'.$pressRelease->id.'/')
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and(Storage::disk('local')->exists($image->path))->toBeTrue()
->and($image->variants)->toHaveKeys(['thumb', 'medium', 'large', 'cover', 'og'])
->and($image->path)->not->toBe($image->original_path)
->and($image->provenance_status)->toBe(ImageProvenanceStatus::UnsignedMetadata)
->and($image->provenance['derived']['credit_suggestion'])->toBe('Musterfirma GmbH / Anna Beispiel')
->and($image->credit_source?->value)->toBe('metadata')
->and($image->ai_declaration)->toBe(AiImageDeclaration::Original)
->and($image->ai_declaration_user)->toBe(AiImageDeclaration::Original)
->and($image->ai_declaration_source)->toBe(AiDeclarationSource::Customer)
->and($image->ai_declaration_locked)->toBeFalse()
->and($image->rights_confirmed_at)->not->toBeNull()
->and($image->published_ever_at)->toBeNull()
->and($image->is_preview)->toBeTrue();
$confirmation = ImageRightsConfirmation::query()->sole();
expect($confirmation->file_sha256)->toBe($expectedHash)
->and($confirmation->legal_text_key)->toBe('img-confirm-v2')
->and($confirmation->agb_version)->toBe('0.6')
->and($confirmation->user_id)->toBe($pressRelease->user_id)
->and($confirmation->company_id)->toBe($pressRelease->company_id)
->and($confirmation->channel->value)->toBe('form')
->and($confirmation->text_snapshot)->toContain('https://presseecho.test/de/agb')
->and($confirmation->ip_hmac)->toStartWith('v1:')
->and($confirmation->declaration_snapshot['provenance_status'])->toBe('unsigned_metadata')
->and($image->fresh()->hasValidRightsConfirmation())->toBeTrue();
});
test('unsigned ai metadata only prefills: a lower user choice is stored as customer choice with a deviation mark', function () {
$pressRelease = PressRelease::factory()->create();
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('xmp-ai-generated.jpg'),
$pressRelease,
uploaderSubmission(['aiDeclaration' => AiImageDeclaration::Original]),
uploaderActor($pressRelease),
);
expect($image->ai_declaration)->toBe(AiImageDeclaration::Original)
->and($image->ai_declaration_source)->toBe(AiDeclarationSource::Customer)
->and($image->ai_declaration_locked)->toBeFalse()
->and($image->provenance['deviated_from_prefill'])->toBeTrue()
->and($image->provenance['derived']['ai_declaration_suggestion'])->toBe('ai_generated');
});
test('accepting the prefilled ai suggestion is recorded as iptc_prefill', function () {
$pressRelease = PressRelease::factory()->create();
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('xmp-ai-generated.jpg'),
$pressRelease,
uploaderSubmission(['aiDeclaration' => AiImageDeclaration::AiGenerated]),
uploaderActor($pressRelease),
);
expect($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated)
->and($image->ai_declaration_source)->toBe(AiDeclarationSource::IptcPrefill)
->and($image->provenance)->not->toHaveKey('deviated_from_prefill');
});
test('a changed file between preflight and store is rejected and leaves no files behind', function () {
$pressRelease = PressRelease::factory()->create();
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
expectedSha256: hash('sha256', 'andere-datei'),
))->toThrow(ImageUploadException::class);
expect(Storage::disk('local')->allFiles())->toBe([])
->and($pressRelease->images()->count())->toBe(0);
});
test('a database failure after the files were written removes original and derivatives again', function () {
$pressRelease = PressRelease::factory()->create();
DB::listen(function ($query): void {
if (str_starts_with(strtolower($query->sql), 'insert into "press_release_images"')) {
throw new RuntimeException('simulierter DB-Fehler');
}
});
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
))->toThrow(RuntimeException::class, 'simulierter DB-Fehler');
expect(Storage::disk('local')->allFiles())->toBe([])
->and(ImageRightsConfirmation::query()->count())->toBe(0);
});
test('the one title image rule holds inside the locked transaction', function () {
$pressRelease = PressRelease::factory()->create();
$uploader = app(PressReleaseImageUploader::class);
$uploader->store(uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease));
expect(fn () => $uploader->store(uploaderFixture('camera-iptc.jpg'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease)))
->toThrow(ImageUploadException::class);
expect($pressRelease->images()->count())->toBe(1)
->and(count(Storage::disk('local')->allFiles('press-release-originals')))->toBe(1);
});
test('without the confirmation toggle nothing is stored', function () {
$pressRelease = PressRelease::factory()->create();
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(['rightsConfirmed' => false]),
uploaderActor($pressRelease),
))->toThrow(ImageUploadException::class);
expect(Storage::disk('local')->allFiles())->toBe([]);
});
test('an outdated confirmation text key is rejected with the current key in the message', function () {
$pressRelease = PressRelease::factory()->create();
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(['legalTextKey' => 'img-confirm-v1']),
uploaderActor($pressRelease),
))->toThrow(ImageUploadException::class, 'img-confirm-v2');
});
test('an admin upload stores no confirmation and waits for the customer', function () {
$pressRelease = PressRelease::factory()->create();
$admin = User::factory()->create();
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(['rightsConfirmed' => false, 'legalTextKey' => null]),
ImageUploadActor::admin($admin, '203.0.113.1', 'AdminBrowser'),
);
expect(ImageRightsConfirmation::query()->count())->toBe(0)
->and($image->rights_confirmed_at)->toBeNull()
->and($image->customer_confirmation_requested_at)->not->toBeNull()
->and($image->awaitsCustomerConfirmation())->toBeTrue();
});
test('oversized uploads are refused before any processing', function () {
$pressRelease = PressRelease::factory()->create();
config()->set('images.max_pixels_per_side', 32);
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('camera-iptc.jpg'), // 64 × 48 px
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
))->toThrow(ImageUploadException::class, 'Pixel');
expect(Storage::disk('local')->allFiles())->toBe([]);
});
test('an upload to an already published press release is marked as ever published', function () {
$pressRelease = PressRelease::factory()->published()->create();
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
);
expect($image->published_ever_at)->not->toBeNull();
});
test('a failed derivative generation never leaves the source file public', function () {
$pressRelease = PressRelease::factory()->create();
$upload = uploaderFixture('broken.jpg');
expect(fn () => app(PressReleaseImageUploader::class)->store(
$upload,
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
))->toThrow(ImageUploadException::class);
expect(Storage::disk('local')->allFiles())->toBe([]);
});

View file

@ -0,0 +1,93 @@
<?php
use App\Models\ImageRightsConfirmation;
use App\Models\LegalDocument;
use App\Models\LegalText;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageRightsTextRenderer;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Tests\TestCase;
test('the seeder registers the three confirmation texts idempotently and never rewrites one', function () {
/** @var TestCase $this */
$this->seed(LegalTextSeeder::class);
$this->seed(LegalTextSeeder::class);
expect(LegalText::query()->count())->toBe(3);
$v2 = LegalText::findByKey('img-confirm-v2');
expect($v2?->body_sha256)->toBe(hash('sha256', (string) $v2?->body))
->and($v2?->bound_agb_version)->toBe('0.6')
->and(LegalText::findByKey('img-confirm-v3')?->effective_from)->toBeNull();
});
test('currentFor picks the text bound to the active agb version and ignores fassungen without a date', function () {
/** @var TestCase $this */
$this->seed(LegalTextSeeder::class);
expect(LegalText::currentFor(LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, '0.6')?->key)->toBe('img-confirm-v2')
->and(LegalText::currentFor(LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, '0.7'))->toBeNull()
->and(LegalText::findByKey('img-confirm-v2')?->isCurrentFor('0.6'))->toBeTrue()
->and(LegalText::findByKey('img-confirm-v1')?->isCurrentFor('0.6'))->toBeFalse();
});
test('legal texts are immutable', function () {
/** @var TestCase $this */
$this->seed(LegalTextSeeder::class);
$text = LegalText::findByKey('img-confirm-v2');
expect(fn () => $text->update(['body' => 'geändert']))->toThrow(LogicException::class)
->and(fn () => $text->delete())->toThrow(LogicException::class);
});
test('the renderer resolves agb version, date and url into the long and short wording', function () {
/** @var TestCase $this */
$this->seed(LegalDocumentSeeder::class);
$this->seed(LegalTextSeeder::class);
$renderer = app(ImageRightsTextRenderer::class);
$text = $renderer->current();
expect($text?->key)->toBe('img-confirm-v2');
$long = $renderer->renderLong($text, 'https://presseecho.test/de/agb');
$short = $renderer->renderShort($text, 'https://presseecho.test/de/agb');
expect($long)->toContain('Fassung vom 3. Juli 2026 (Version 0.6, https://presseecho.test/de/agb)')
->toContain('presseecho.de und businessportal24.com')
->toContain('§ 8 und § 9')
->not->toContain('{agb_version}')
->and($short)->toContain('Fassung vom 3. Juli 2026')
->and(LegalDocument::currentAgbVersion())->toBe('0.6');
});
test('the backfill command gives every old confirmation a protocol row pointing at img-confirm-v1', function () {
/** @var TestCase $this */
$this->seed(LegalTextSeeder::class);
$pressRelease = PressRelease::factory()->create();
$confirmed = PressReleaseImage::factory()->for($pressRelease)->create(['rights_confirmed_at' => now()->subMonth()]);
PressReleaseImage::factory()->for($pressRelease)->legacy()->create();
$this->artisan('image-rights:backfill-confirmations', ['--dry-run' => true])
->expectsOutputToContain('Bilder mit alter Bestätigung ohne Protokoll: 1')
->assertSuccessful();
expect(ImageRightsConfirmation::query()->count())->toBe(0);
$this->artisan('image-rights:backfill-confirmations')->assertSuccessful();
$this->artisan('image-rights:backfill-confirmations')->assertSuccessful();
$confirmation = $confirmed->rightsConfirmations()->sole();
expect(ImageRightsConfirmation::query()->count())->toBe(1)
->and($confirmation->legal_text_key)->toBe('img-confirm-v1')
->and($confirmation->agb_version)->toBe('0.6')
->and($confirmation->user_id)->toBe($pressRelease->user_id)
->and($confirmation->company_id)->toBe($pressRelease->company_id)
->and($confirmation->confirmed_at->toDateTimeString())->toBe($confirmed->rights_confirmed_at->toDateTimeString())
->and($confirmation->declaration_snapshot['backfilled'])->toBeTrue()
->and($confirmation->text_snapshot)->toBe(LegalText::findByKey('img-confirm-v1')?->body);
});

View file

@ -0,0 +1,4 @@
ЪьЪЮПiц│?°·Р┌╪~
 й█ФЪвфB÷Н╡┘T┐шЮ╞ф²В╖│r┤@╥╕Е)Jb2В≥╦9щ\╔X∙│╤4Ч░фX█нАAр╠Г╬QЩ#ВS⌠>9Ъб█bАся3<LвВ·лэOл╟о0╬ДП}eqхсЛрgцц}┤
ё{У╣Jьyф°уM╥* а≈{Г┌>мй▓║ЦЖБm@р╝'T@2Е!IBцb%┤ы]т@┼Щ]┴CE^r ├;
ж÷°█ФЩп

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

View file

@ -0,0 +1,85 @@
<?php
/**
* Erzeugt die Fixture-Dateien für die Herkunftsprüfung (Bildkennzeichnung
* A.5) einmalig mit Imagick. Aufruf: php tests/Fixtures/images/generate-fixtures.php
*
* Die Dateien werden eingecheckt; das Skript dient nur der Nachvollziehbarkeit.
*/
declare(strict_types=1);
$dir = __DIR__;
function iptcBlock(array $datasets): string
{
// IPTC-IIM: 0x1C, Record 2, Dataset, Länge (16 Bit), Daten.
$block = '';
foreach ($datasets as $dataset => $values) {
foreach ((array) $values as $value) {
$block .= "\x1C\x02".chr($dataset).pack('n', strlen($value)).$value;
}
}
return $block;
}
function xmp(array $description): string
{
return '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
.'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
.'<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/" xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/">'
.implode('', $description)
.'</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
}
function make(string $file, string $format, ?string $iptc = null, ?string $xmp = null): void
{
$image = new Imagick;
$image->newImage(64, 48, new ImagickPixel('#4a6fa5'), $format);
$image->setImageFormat($format);
if ($iptc !== null) {
$image->setImageProfile('iptc', $iptc);
}
if ($xmp !== null) {
$image->setImageProfile('xmp', $xmp);
}
$image->writeImage($file);
$image->clear();
echo "written: {$file}\n";
}
// 1. Kamera-JPEG mit klassischem IPTC (Credit, By-line, Copyright).
make("{$dir}/camera-iptc.jpg", 'jpeg', iptcBlock([
110 => 'Musterfirma GmbH / Anna Beispiel',
80 => 'Anna Beispiel',
116 => '© 2026 Musterfirma GmbH',
]));
// 2. JPEG mit XMP: KI-generiert (volle IPTC-URI) und Credit als Element.
make("{$dir}/xmp-ai-generated.jpg", 'jpeg', null, xmp([
'<Iptc4xmpExt:DigitalSourceType>http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia</Iptc4xmpExt:DigitalSourceType>',
'<photoshop:Credit>KI-Bild / Musterfirma</photoshop:Credit>',
]));
// 3. JPEG mit XMP algorithmicallyEnhanced (Kurzform) und dc:creator-Liste.
make("{$dir}/xmp-enhanced.jpg", 'jpeg', null, xmp([
'<Iptc4xmpExt:DigitalSourceType>algorithmicallyEnhanced</Iptc4xmpExt:DigitalSourceType>',
'<dc:creator><rdf:Seq><rdf:li>Max Mustermann</rdf:li></rdf:Seq></dc:creator>',
]));
// 4. PNG ohne Metadaten.
make("{$dir}/plain.png", 'png');
// 5. WebP mit XMP als Attribute (digitalCapture, dc:rights).
make("{$dir}/webp-capture.webp", 'webp', null, xmp([
'<Iptc4xmpExt:DigitalSourceType>digitalCapture</Iptc4xmpExt:DigitalSourceType>',
'<dc:rights><rdf:Alt><rdf:li xml:lang="x-default">© Beispiel AG</rdf:li></rdf:Alt></dc:rights>',
]));
// 6. Beschädigte Datei: JPEG-Header, dann Müll.
file_put_contents("{$dir}/broken.jpg", "\xFF\xD8\xFF\xE0".random_bytes(200));
echo "written: {$dir}/broken.jpg\n";

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B