Raiju/includes/ai-labels.php
2026-10-25 06:05:42 +03:00

263 lines
12 KiB
PHP

<?php
/**
* AI disclosure labels for media (EU AI Act, Art. 50).
*
* Images that were created or modified with AI carry a label wherever Raiju
* renders them, shown when a visitor lingers on the image and always available to
* screen readers (alt text). The label kind ('generated' or 'modified') is stored per
* media file in content/media-meta.json and managed in the media library; the
* media API carries it along when files are renamed, moved, trashed or restored.
*
* Rendering: raijuAiLabelHtml($src) returns the label markup for an image source
* (empty for unlabelled files). editableImage(), editableImageSplit() and the core
* blocks call it automatically. The label artwork lives in css/ai-labels/: English
* ("AI generated"/"AI modified") on every page by default; with Settings → Language →
* AI labels = page language, German pages use the German artwork ("KI generiert"). The
* alt text always follows the page language. In edit mode, js/inline-editor.js keeps
* labels in sync with image changes (window.RJ_AI_LABELS).
*/
require_once __DIR__ . '/json-store.php';
const RAIJU_AI_LABEL_KINDS = ['generated', 'modified'];
function raijuMediaMetaPath(): string {
return defined('RAIJU_MEDIA_META_PATH') ? RAIJU_MEDIA_META_PATH : dirname(__DIR__) . '/content/media-meta.json';
}
/** Stored media metadata: [type => [relative name => ['ai' => kind]]]. */
function raijuMediaMeta(bool $reload = false): array {
static $cache = null;
if ($cache === null || $reload) {
$path = raijuMediaMetaPath();
$data = is_file($path) ? json_decode((string)file_get_contents($path), true) : [];
$cache = is_array($data) ? $data : [];
}
return $cache;
}
/** Media type and name relative to the type folder for a public source, e.g. /assets/images/a/b.webp. */
function raijuMediaRefFromSrc(string $src): ?array {
$path = rawurldecode((string)(parse_url($src, PHP_URL_PATH) ?? ''));
foreach (['image' => 'assets/images/', 'video' => 'assets/videos/'] as $type => $marker) {
$position = strpos($path, $marker);
if ($position === false) continue;
$name = substr($path, $position + strlen($marker));
if ($name === '' || str_contains($name, '..') || str_starts_with($name, '/')) return null;
return ['type' => $type, 'name' => $name];
}
return null;
}
/** Label kind of a media file: 'generated', 'modified' or ''. */
function raijuMediaAiLabelFor(string $type, string $name): string {
$kind = raijuMediaMeta()[$type][$name]['ai'] ?? '';
return in_array($kind, RAIJU_AI_LABEL_KINDS, true) ? $kind : '';
}
/** Label kind for a public image or video source. */
function raijuMediaAiLabel(string $src): string {
$ref = raijuMediaRefFromSrc($src);
return $ref ? raijuMediaAiLabelFor($ref['type'], $ref['name']) : '';
}
/** Set ('generated', 'modified') or remove ('') the label of a media file. */
function raijuSetMediaAiLabel(string $type, string $name, string $kind): bool {
$kind = in_array($kind, RAIJU_AI_LABEL_KINDS, true) ? $kind : '';
if ($kind === '' && !isset(raijuMediaMeta()[$type][$name])) return true;
$ok = raijuJsonUpdate(raijuMediaMetaPath(), function (array &$data) use ($type, $name, $kind) {
if ($kind !== '') {
$data[$type][$name]['ai'] = $kind;
return true;
}
unset($data[$type][$name]['ai']);
raijuMediaMetaPrune($data, $type, $name);
return true;
});
raijuMediaMeta(true);
return $ok;
}
/**
* Source and licence of an imported image: media-meta[type][name]['credit'].
* Keys: source, author, authorUrl, sourceUrl, license, attribution, importedAt, importedBy.
*/
function raijuSetMediaCredit(string $type, string $name, array $credit): bool {
$clean = [];
foreach (['source', 'author', 'authorUrl', 'sourceUrl', 'license', 'attribution', 'importedAt', 'importedBy'] as $key) {
if (isset($credit[$key]) && is_string($credit[$key]) && $credit[$key] !== '') {
$clean[$key] = mb_substr($credit[$key], 0, 300, 'UTF-8');
}
}
$ok = raijuJsonUpdate(raijuMediaMetaPath(), function (array &$data) use ($type, $name, $clean) {
if ($clean) $data[$type][$name]['credit'] = $clean; else unset($data[$type][$name]['credit']);
raijuMediaMetaPrune($data, $type, $name);
return true;
});
raijuMediaMeta(true);
return $ok;
}
function raijuMediaCreditFor(string $type, string $name): array {
$credit = raijuMediaMeta()[$type][$name]['credit'] ?? [];
return is_array($credit) ? $credit : [];
}
/** Carry metadata along when a media file is renamed, moved, trashed or restored. */
function raijuMoveMediaMeta(string $fromType, string $fromName, string $toType, string $toName): bool {
if (!isset(raijuMediaMeta()[$fromType][$fromName]) || ($fromType === $toType && $fromName === $toName)) return true;
$ok = raijuJsonUpdate(raijuMediaMetaPath(), function (array &$data) use ($fromType, $fromName, $toType, $toName) {
if (!isset($data[$fromType][$fromName])) return false;
$data[$toType][$toName] = $data[$fromType][$fromName];
unset($data[$fromType][$fromName]);
raijuMediaMetaPrune($data, $fromType, $fromName);
return true;
});
raijuMediaMeta(true);
return $ok;
}
/** Forget the metadata of a deleted file, or of a whole namespace when $name is null. */
function raijuForgetMediaMeta(string $type, ?string $name = null): bool {
$meta = raijuMediaMeta();
if ($name === null ? !isset($meta[$type]) : !isset($meta[$type][$name])) return true;
$ok = raijuJsonUpdate(raijuMediaMetaPath(), function (array &$data) use ($type, $name) {
if ($name === null) {
unset($data[$type]);
return true;
}
unset($data[$type][$name]);
raijuMediaMetaPrune($data, $type, $name);
return true;
});
raijuMediaMeta(true);
return $ok;
}
function raijuMediaMetaPrune(array &$data, string $type, string $name): void {
if (isset($data[$type][$name]) && empty($data[$type][$name])) unset($data[$type][$name]);
if (isset($data[$type]) && empty($data[$type])) unset($data[$type]);
}
/**
* Best-effort detection from embedded provenance: the IPTC digital source type,
* written to XMP or C2PA manifests by image generators and generative editing.
*/
function raijuDetectAiLabel(string $file): string {
$size = @filesize($file);
if (!$size || $size > 16 * 1024 * 1024) return '';
$bytes = (string)@file_get_contents($file);
if (stripos($bytes, 'digitalsourcetype/compositeWithTrainedAlgorithmicMedia') !== false
|| stripos($bytes, 'digitalsourcetype/compositeSynthetic') !== false) {
return 'modified';
}
if (stripos($bytes, 'digitalsourcetype/trainedAlgorithmicMedia') !== false) {
return 'generated';
}
return '';
}
/** Two-letter language of the rendered page. */
function raijuAiLabelLanguage(?string $lang = null): string {
$lang = $lang ?? ($GLOBALS['currentLang'] ?? (defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'));
return strtolower(substr((string)$lang, 0, 2));
}
/** Accessible label text in the page language. */
function raijuAiLabelText(string $kind, ?string $lang = null): string {
$texts = [
'cs' => ['generated' => 'Vytvořeno pomocí AI', 'modified' => 'Upraveno pomocí AI'],
'de' => ['generated' => 'KI-generiert', 'modified' => 'KI-modifiziert'],
'en' => ['generated' => 'AI-generated', 'modified' => 'AI-modified'],
'es' => ['generated' => 'Generado con IA', 'modified' => 'Modificado con IA'],
'fr' => ['generated' => 'Généré par IA', 'modified' => 'Modifié par IA'],
'it' => ['generated' => 'Generato con IA', 'modified' => 'Modificato con IA'],
'pl' => ['generated' => 'Wygenerowano przez AI', 'modified' => 'Zmodyfikowano przez AI'],
'pt' => ['generated' => 'Gerado por IA', 'modified' => 'Modificado por IA'],
'tr' => ['generated' => 'Yapay zekâ ile oluşturuldu', 'modified' => 'Yapay zekâ ile değiştirildi'],
];
$lang = raijuAiLabelLanguage($lang);
return ($texts[$lang] ?? $texts['en'])[$kind] ?? '';
}
/**
* Label artwork mode (settings.json → general.aiLabelArtwork): 'en' shows the English
* artwork on every page (default), 'page' the German artwork on German pages.
*/
function raijuAiLabelArtworkMode(bool $reload = false): string {
static $mode = null;
if ($mode === null || $reload) {
$path = defined('SETTINGS_PATH') ? SETTINGS_PATH : dirname(__DIR__) . '/content/settings.json';
$settings = is_file($path) ? json_decode((string)file_get_contents($path), true) : [];
$mode = is_array($settings) && ($settings['general']['aiLabelArtwork'] ?? '') === 'page' ? 'page' : 'en';
}
return $mode;
}
/** Label markup for an image source; empty when the file carries no AI label. */
function raijuAiLabelHtml(string $src, ?string $lang = null): string {
$kind = raijuMediaAiLabel($src);
return $kind === '' ? '' : raijuAiLabelMarkup($kind, $lang);
}
/** Label markup for a label kind: English artwork, or German on German pages if the site opts in. */
function raijuAiLabelMarkup(string $kind, ?string $lang = null): string {
if (!in_array($kind, RAIJU_AI_LABEL_KINDS, true)) return '';
$lang = raijuAiLabelLanguage($lang);
$artwork = (raijuAiLabelArtworkMode() === 'page' && $lang === 'de' ? 'de' : 'en') . '-' . $kind;
// Width at the default height of 22px, from the artwork's aspect ratio
$widths = ['de-generated' => 111, 'de-modified' => 122, 'en-generated' => 114, 'en-modified' => 102];
$GLOBALS['raijuAiLabelsRendered'] = true;
$base = (string)($GLOBALS['basePath'] ?? '');
$text = htmlspecialchars(raijuAiLabelText($kind, $lang), ENT_QUOTES, 'UTF-8');
return '<span class="rj-ai-label rj-ai-label--' . $kind . '">'
. '<img class="rj-ai-label__badge" src="' . htmlspecialchars($base . 'css/ai-labels/' . $artwork . '.svg', ENT_QUOTES, 'UTF-8')
. '" width="' . $widths[$artwork] . '" height="22" alt="' . $text . '" decoding="async"></span>';
}
/**
* Image markup followed by its label. With $frame, a labelled image and its label
* share a positioned frame, so the label sits on the image even when text follows.
*/
function raijuAiLabelAttach(string $imageHtml, string $src, bool $frame = false): string {
$label = raijuAiLabelHtml($src);
if ($label === '') return $imageHtml;
return $frame
? '<span class="rj-ai-media">' . $imageHtml . $label . '</span>'
: $imageHtml . $label;
}
/** Wrap already rendered image markup (image + label) in a label frame when it carries a label. */
function raijuAiLabelFrame(string $html): string {
return str_contains($html, 'class="rj-ai-label ') ? '<span class="rj-ai-media">' . $html . '</span>' : $html;
}
/**
* Label data for the inline editor (window.RJ_AI_LABELS): labelled files and the label
* markup per kind, so labels follow image swaps and media library changes right away.
*/
function raijuAiLabelEditorConfig(?string $lang = null): array {
$files = [];
foreach (['image', 'video'] as $type) {
foreach (array_keys((array)(raijuMediaMeta()[$type] ?? [])) as $name) {
$kind = raijuMediaAiLabelFor($type, (string)$name);
if ($kind !== '') $files[$type][(string)$name] = $kind;
}
}
// Templates only: they must not count as labels rendered on the page
$rendered = $GLOBALS['raijuAiLabelsRendered'] ?? false;
$markup = [];
foreach (RAIJU_AI_LABEL_KINDS as $kind) {
$markup[$kind] = raijuAiLabelMarkup($kind, $lang);
}
$GLOBALS['raijuAiLabelsRendered'] = $rendered;
return ['files' => (object)array_map(fn($names) => (object)$names, $files), 'markup' => $markup];
}
/** Whether a label was rendered on this page (loads js/ai-labels.js). */
function raijuAiLabelsRendered(): bool {
return !empty($GLOBALS['raijuAiLabelsRendered']);
}