Kurzform → Vorschlag */ private const SOURCE_TYPE_SUGGESTIONS = [ 'trainedAlgorithmicMedia' => AiImageDeclaration::AiGenerated->value, 'compositeWithTrainedAlgorithmicMedia' => AiImageDeclaration::AiEdited->value, 'digitalCapture' => AiImageDeclaration::Original->value, 'negativeFilm' => AiImageDeclaration::Original->value, 'positiveFilm' => AiImageDeclaration::Original->value, 'print' => AiImageDeclaration::Original->value, 'algorithmicallyEnhanced' => null, ]; public function inspect(string $absolutePath): ProvenanceResult { $started = hrtime(true); $sha256 = (string) hash_file('sha256', $absolutePath); $tool = $this->toolVersion(); try { // Eine Datei, die Imagick nicht einmal öffnen kann, ist „nicht // prüfbar", nicht „ohne Herkunftsdaten" (Feedback F10). $profiles = $this->readProfiles($absolutePath); $iptc = $this->readIptc($absolutePath, $profiles['iptc']); $xmp = $this->readXmp($profiles['xmp']); $exif = $this->readExif($absolutePath); $merged = [ 'digital_source_type' => $xmp['digital_source_type'] ?? null, 'creator' => $xmp['creator'] !== [] ? $xmp['creator'] : $iptc['byline_list'], 'credit' => $xmp['credit'] ?? $iptc['credit'], 'byline' => $iptc['byline'], 'copyright_notice' => $xmp['rights'] ?? $iptc['copyright_notice'], ]; $derived = $this->derive($merged, $exif); $hasMetadata = $merged['digital_source_type'] !== null || $merged['creator'] !== [] || $merged['credit'] !== null || $merged['byline'] !== null || $merged['copyright_notice'] !== null || $exif['artist'] !== null || $exif['copyright'] !== null; $durationMs = $this->elapsedMs($started); if ($durationMs > (int) config('images.provenance_timeout_seconds', 10) * 1000) { return $this->errorResult($sha256, $tool, $durationMs, 'timeout', 'Herkunftsprüfung hat das Zeitbudget überschritten.'); } return new ProvenanceResult( status: $hasMetadata ? ImageProvenanceStatus::UnsignedMetadata : ImageProvenanceStatus::Absent, fileSha256: $sha256, iptc: $merged, exif: $exif, derived: $derived, toolVersion: $tool, durationMs: $durationMs, ); } catch (Throwable $exception) { return $this->errorResult($sha256, $tool, $this->elapsedMs($started), $exception::class, Str::limit($exception->getMessage(), 200)); } } /** * @param array{digital_source_type: string|null, creator: list, credit: string|null, byline: string|null, copyright_notice: string|null} $iptc * @param array{artist: string|null, copyright: string|null} $exif * @return array{ai_declaration_suggestion: string|null, credit_suggestion: string|null, credit_suggestion_source: string|null, credit_truncated: bool, enhancement_hint: bool} */ private function derive(array $iptc, array $exif): array { $sourceType = $iptc['digital_source_type']; $suggestion = $sourceType !== null ? (self::SOURCE_TYPE_SUGGESTIONS[$sourceType] ?? null) : null; // Reihenfolge des Nachweis-Vorschlags (Vorschlag 3.3, Feedback F12): // Credit → dc:creator → By-line → EXIF Artist → CopyrightNotice. $candidates = [ 'credit' => $iptc['credit'], 'creator' => $iptc['creator'] !== [] ? implode(', ', $iptc['creator']) : null, 'byline' => $iptc['byline'], 'exif_artist' => $exif['artist'], 'copyright_notice' => $iptc['copyright_notice'], ]; $creditSuggestion = null; $creditSource = null; foreach ($candidates as $source => $candidate) { if (filled($candidate)) { $creditSuggestion = trim((string) $candidate); $creditSource = $source; break; } } $truncated = $creditSuggestion !== null && mb_strlen($creditSuggestion) > self::CREDIT_MAX_LENGTH; return [ 'ai_declaration_suggestion' => $suggestion, 'credit_suggestion' => $truncated ? mb_substr($creditSuggestion, 0, self::CREDIT_MAX_LENGTH) : $creditSuggestion, 'credit_suggestion_source' => $creditSource, 'credit_truncated' => $truncated, 'enhancement_hint' => $sourceType === 'algorithmicallyEnhanced', ]; } /** * Öffnet die Datei einmal mit Imagick und liest die eingebetteten * IPTC- und XMP-Profile. Wirft, wenn die Datei kein lesbares Bild ist. * * @return array{iptc: string|null, xmp: string|null} */ private function readProfiles(string $absolutePath): array { if (! class_exists(Imagick::class)) { throw new \RuntimeException('Imagick ist nicht verfügbar.'); } $imagick = new Imagick; try { $imagick->readImage($absolutePath); $profiles = $imagick->getImageProfiles('*', true); } finally { $imagick->clear(); } return [ 'iptc' => is_string($profiles['iptc'] ?? null) && $profiles['iptc'] !== '' ? $profiles['iptc'] : null, 'xmp' => is_string($profiles['xmp'] ?? null) && trim($profiles['xmp']) !== '' ? $profiles['xmp'] : null, ]; } /** * IPTC-IIM: bei JPEG bevorzugt über den APP13-Block (getimagesize), * sonst über das Imagick-Profil. Geparst mit iptcparse(). * * @return array{credit: string|null, byline: string|null, byline_list: list, copyright_notice: string|null} */ private function readIptc(string $absolutePath, ?string $profile): array { $empty = ['credit' => null, 'byline' => null, 'byline_list' => [], 'copyright_notice' => null]; $block = $profile; $info = []; if (@getimagesize($absolutePath, $info) !== false && isset($info['APP13'])) { $block = $info['APP13']; } if ($block === null) { return $empty; } $parsed = @iptcparse($block); if (! is_array($parsed)) { return $empty; } $first = fn (string $key): ?string => isset($parsed[$key][0]) ? $this->cleanText($parsed[$key][0]) : null; $bylines = array_values(array_filter(array_map(fn ($value) => $this->cleanText((string) $value), $parsed['2#080'] ?? []))); return [ 'credit' => $first('2#110'), 'byline' => $bylines[0] ?? null, 'byline_list' => $bylines, 'copyright_notice' => $first('2#116'), ]; } /** * XMP aus dem Imagick-Profil (JPEG, PNG, WebP). Ausgewertet werden * photoshop:Credit, dc:creator, dc:rights und Iptc4xmpExt:DigitalSourceType, * jeweils als Element oder als Attribut von rdf:Description. * * @return array{credit: string|null, creator: list, rights: string|null, digital_source_type: string|null} */ private function readXmp(?string $xmp): array { $empty = ['credit' => null, 'creator' => [], 'rights' => null, 'digital_source_type' => null]; if ($xmp === null) { return $empty; } $document = new DOMDocument; $previous = libxml_use_internal_errors(true); $loaded = $document->loadXML($xmp, LIBXML_NONET | LIBXML_NOENT); libxml_clear_errors(); libxml_use_internal_errors($previous); if (! $loaded) { return $empty; } $xpath = new DOMXPath($document); $xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'); $xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/'); $xpath->registerNamespace('photoshop', 'http://ns.adobe.com/photoshop/1.0/'); $xpath->registerNamespace('Iptc4xmpExt', 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/'); $text = function (string $query) use ($xpath): ?string { $nodes = $xpath->query($query); return $nodes !== false && $nodes->length > 0 ? $this->cleanText($nodes->item(0)->textContent) : null; }; $list = function (string $query) use ($xpath): array { $nodes = $xpath->query($query); $values = []; if ($nodes !== false) { foreach ($nodes as $node) { $value = $this->cleanText($node->textContent); if ($value !== null) { $values[] = $value; } } } return $values; }; $creator = $list('//dc:creator//rdf:li'); if ($creator === [] && ($single = $text('//rdf:Description/@dc:creator')) !== null) { $creator = [$single]; } return [ 'credit' => $text('//photoshop:Credit') ?? $text('//rdf:Description/@photoshop:Credit'), 'creator' => $creator, 'rights' => $text('//dc:rights//rdf:li') ?? $text('//rdf:Description/@dc:rights'), 'digital_source_type' => $this->normalizeSourceType( $text('//Iptc4xmpExt:DigitalSourceType') ?? $text('//rdf:Description/@Iptc4xmpExt:DigitalSourceType'), ), ]; } /** * @return array{artist: string|null, copyright: string|null} */ private function readExif(string $absolutePath): array { $empty = ['artist' => null, 'copyright' => null]; if (! function_exists('exif_read_data')) { return $empty; } $mime = (string) (@mime_content_type($absolutePath) ?: ''); if (! in_array($mime, ['image/jpeg', 'image/tiff'], true)) { return $empty; } $data = @exif_read_data($absolutePath, 'IFD0', true); if (! is_array($data)) { return $empty; } return [ 'artist' => $this->cleanText((string) ($data['IFD0']['Artist'] ?? '')), 'copyright' => $this->cleanText((string) ($data['IFD0']['Copyright'] ?? '')), ]; } /** * Vollständige IPTC-URIs und Kurzformen auf die Kurzform bringen * (Feedback F03: nicht nur unqualifizierte Werte vergleichen). */ private function normalizeSourceType(?string $value): ?string { if ($value === null) { return null; } $value = trim($value); if (Str::startsWith($value, [self::IPTC_NS, str_replace('http://', 'https://', self::IPTC_NS)])) { $value = Str::afterLast($value, '/'); } return $value !== '' ? $value : null; } private function cleanText(string $value): ?string { $value = trim(preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? ''); if ($value !== '' && ! mb_check_encoding($value, 'UTF-8')) { $value = mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1'); } return $value !== '' ? $value : null; } private function toolVersion(): string { $imagick = class_exists(Imagick::class) ? Imagick::getVersion()['versionString'] : 'no-imagick'; $imagick = trim(Str::before(Str::after($imagick, 'ImageMagick '), ' ')) ?: 'imagick'; return sprintf('imagick %s / exif %s', $imagick, function_exists('exif_read_data') ? 'yes' : 'no'); } private function elapsedMs(int $startedNs): int { return (int) round((hrtime(true) - $startedNs) / 1_000_000); } private function errorResult(string $sha256, string $tool, int $durationMs, string $class, string $message): ProvenanceResult { return new ProvenanceResult( status: ImageProvenanceStatus::Error, fileSha256: $sha256, iptc: ['digital_source_type' => null, 'creator' => [], 'credit' => null, 'byline' => null, 'copyright_notice' => null], exif: ['artist' => null, 'copyright' => null], derived: ['ai_declaration_suggestion' => null, 'credit_suggestion' => null, 'credit_suggestion_source' => null, 'credit_truncated' => false, 'enhancement_hint' => false], toolVersion: $tool, durationMs: $durationMs, error: ['class' => $class, 'message' => $message], ); } }