* * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. */ /** * \file bericht/lib/bericht_avif.lib.php * \ingroup bericht * \brief Hochgeladene AVIF- und WebP-Bilder in JPEG (deckend) bzw. PNG (transparent) umwandeln. * Einstieg für Upload-Wege: bericht_upload_bild_umwandeln(). * * Anlass (Eddy, 19.10.2026): „es gibt immer mehr Bilder im Netz, die AVIF verwenden". Die * Upload-Wege von Bericht nehmen nur JPG/PNG (Editor zusätzlich PDF) — ein AVIF wurde * abgelehnt, und TCPDF könnte es im Bericht-PDF ohnehin nicht setzen. Deshalb wird es beim * Hochladen umgewandelt, bevor Endung und Name geprüft werden. * * Muster aus KundenKarte 15.207.0 (KB #1698), hier als eigene Kopie ohne Abhängigkeiten, * damit auch Endpunkte ohne bericht.lib.php (mobile_upload.php) sie laden können. * Getestet 19.10.2026: Prod-Container mit ImageMagick 6.9.11 liest AVIF ohne Alphakanal * (transparent → schwarz) → transparentes AVIF nur über GD (Prod-Image mit GD-AVIF seit 19.10.2026). */ /** * Ist die Datei ein AVIF? Am INHALT erkannt (ISO-BMFF `ftyp` mit Marke avif/avis), nicht an der Endung — * Bild-CDNs liefern AVIF auch unter „.jpg" aus. * * @param string $pfad Absoluter Pfad * @return bool */ function bericht_bild_ist_avif($pfad) { $kopf = @file_get_contents($pfad, false, null, 0, 64); if (!is_string($kopf) || strlen($kopf) < 16 || substr($kopf, 4, 4) !== 'ftyp') { return false; } $ende = min(strlen($kopf), max(16, unpack('N', substr($kopf, 0, 4))[1])); $marken = array(substr($kopf, 8, 4)); for ($i = 16; $i + 4 <= $ende; $i += 4) { $marken[] = substr($kopf, $i, 4); } return count(array_intersect($marken, array('avif', 'avis'))) > 0; } /** * Trägt das AVIF einen Alphakanal? Steht als Hilfsbild „auxiliary:alpha" im `meta`-Kasten; nur der wird gelesen. * * @param string $pfad Absoluter Pfad * @return bool */ function bericht_avif_hat_alpha($pfad) { $fh = @fopen($pfad, 'rb'); if (!$fh) { return false; } $alpha = false; while (true) { $kopf = fread($fh, 8); if (!is_string($kopf) || strlen($kopf) < 8) { break; } $groesse = unpack('N', substr($kopf, 0, 4))[1]; $typ = substr($kopf, 4, 4); $kopfLaenge = 8; if ($groesse === 1) { $lang = fread($fh, 8); if (!is_string($lang) || strlen($lang) < 8) { break; } $teile = unpack('N2', $lang); $groesse = $teile[1] * 4294967296 + $teile[2]; $kopfLaenge = 16; } if ($typ === 'meta') { $menge = ($groesse === 0) ? 1048576 : (int) min($groesse - $kopfLaenge, 1048576); $inhalt = $menge > 0 ? (string) fread($fh, $menge) : ''; $alpha = strpos($inhalt, 'auxiliary:alpha') !== false || strpos($inhalt, 'urn:mpeg:hevc:2015:auxid:1') !== false; break; } if ($groesse === 0 || $groesse < $kopfLaenge || fseek($fh, $groesse - $kopfLaenge, SEEK_CUR) !== 0) { break; } } fclose($fh); return $alpha; } /** * AVIF in JPEG (ohne Transparenz) bzw. PNG (mit Transparenz) umwandeln — Ziel frei wählbar. * * ⚠ ImageMagick 6 (Prod) liest AVIF ohne Alphakanal → transparent wird schwarz. Deshalb: ohne Alpha * ImageMagick zuerst (wendet Drehung aus irot/imir an), sonst GD; mit Alpha NUR GD. * Shell-Quoting byte-sicher (escapeshellarg() wirft unter LANG=C Umlaute aus Pfaden, KB #951). * * @param string $quelle AVIF-Datei * @param string $ziel Zieldatei ohne Endung — die Endung (.jpg/.png) hängt die Funktion an * @return string Pfad der erzeugten Datei oder '' (nicht umwandelbar) */ function bericht_avif_in_datei($quelle, $ziel) { $alpha = bericht_avif_hat_alpha($quelle); $ziel .= $alpha ? '.png' : '.jpg'; $q = function ($w) { return "'".str_replace("'", "'\\''", str_replace("\0", '', (string) $w))."'"; }; if (!$alpha && function_exists('exec')) { $aus = array(); $code = 1; @exec('command -v convert 2>/dev/null', $aus, $code); if ($code === 0 && !empty($aus)) { @exec('convert '.$q($quelle.'[0]').' -auto-orient -quality 90 '.$q($ziel).' 2>/dev/null'); if (is_file($ziel) && filesize($ziel) > 0) { return $ziel; } } } if (function_exists('imagecreatefromavif')) { $bild = @imagecreatefromavif($quelle); if ($bild) { if ($alpha) { imagealphablending($bild, false); imagesavealpha($bild, true); $ok = imagepng($bild, $ziel, 6); } else { $ok = imagejpeg($bild, $ziel, 90); } imagedestroy($bild); if ($ok && is_file($ziel) && filesize($ziel) > 0) { return $ziel; } } } @unlink($ziel); return ''; } /** * Hochgeladenes AVIF an Ort und Stelle umwandeln. Das Ergebnis ersetzt die hochgeladene Datei AM SELBEN * tmp-Pfad — move_uploaded_file()/dol_move_uploaded_file() prüfen nur den Pfad, nicht den Inhalt, und bleiben * gültig. Name (neue Endung), Größe und Typ werden im Array nachgezogen. VOR jeder Format-/Endungsprüfung und * vor der Namensbildung aufrufen. * * @param array $datei array('name', 'tmp_name', 'size'[, 'type']) — z. B. $_FILES['x'] per Referenz * @return string '' = kein AVIF oder umgewandelt; sonst Fehlermeldung (transnoentities, auch für JSON) */ function bericht_upload_avif_umwandeln(array &$datei) { global $langs; $tmp = (string) ($datei['tmp_name'] ?? ''); if ($tmp === '' || !is_file($tmp) || !bericht_bild_ist_avif($tmp)) { return ''; } $name = basename((string) ($datei['name'] ?? '')); $alpha = bericht_avif_hat_alpha($tmp); $neu = bericht_avif_in_datei($tmp, $tmp.'.avifneu'); if ($neu === '' || !@rename($neu, $tmp)) { @unlink($neu); // Die Upload-Endpunkte (API, Handy-Upload) laden die Modul-Sprachdatei sonst nicht selbst. $langs->load('bericht@bericht'); return $langs->transnoentities($alpha ? 'BerichtAvifTransparenz' : 'BerichtAvifNichtLesbar', $name); } clearstatcache(true, $tmp); $stamm = pathinfo($name, PATHINFO_FILENAME); $datei['name'] = ($stamm !== '' ? $stamm : 'bild').($alpha ? '.png' : '.jpg'); $datei['size'] = (int) filesize($tmp); $datei['type'] = $alpha ? 'image/png' : 'image/jpeg'; return ''; } /** * Ist die Datei ein WebP? Am INHALT erkannt (RIFF-Container mit Kennung „WEBP"), nicht an der Endung — * Bild-CDNs liefern WebP auch unter „.jpg" aus. * * @param string $pfad Absoluter Pfad * @return bool */ function bericht_bild_ist_webp($pfad) { $kopf = @file_get_contents($pfad, false, null, 0, 12); return is_string($kopf) && strlen($kopf) === 12 && substr($kopf, 0, 4) === 'RIFF' && substr($kopf, 8, 4) === 'WEBP'; } /** * Alphakanal und Animation eines WebP aus dem ersten Chunk, ohne zu dekodieren: * „VP8 " (verlustbehaftet, einfach) hat nie Alpha; „VP8L" trägt das Bit alpha_is_used im Bildkopf * (nach Signatur 0x2f: 14 Bit Breite-1, 14 Bit Höhe-1, dann das Bit); „VP8X" (erweitert) hat Merker im * ersten Byte: 0x10 = Alpha, 0x02 = Animation. * * @param string $pfad Absoluter Pfad * @return array array('alpha' => bool, 'animiert' => bool) */ function bericht_webp_merkmale($pfad) { $m = array('alpha' => false, 'animiert' => false); $kopf = @file_get_contents($pfad, false, null, 0, 25); if (!is_string($kopf) || strlen($kopf) < 21) { return $m; } $chunk = substr($kopf, 12, 4); if ($chunk === 'VP8X') { $merker = ord($kopf[20]); $m['alpha'] = ($merker & 0x10) !== 0; $m['animiert'] = ($merker & 0x02) !== 0; } elseif ($chunk === 'VP8L' && strlen($kopf) >= 25 && ord($kopf[20]) === 0x2f) { $m['alpha'] = ((unpack('V', substr($kopf, 21, 4))[1] >> 28) & 1) === 1; } return $m; } /** * Hochgeladenes WebP an Ort und Stelle in JPEG (deckend) bzw. PNG (transparent) umwandeln — gleiches * Vorgehen wie bericht_upload_avif_umwandeln(). Grund: TCPDF setzt WebP im Bericht-PDF nicht sicher, und * LibreOffice lässt WebP beim ODT→PDF-Export stillschweigend weg (KB #447). GD liest WebP mit Alpha * (lokal und auf Prod vorhanden). Bewegte WebP liest GD nicht — die werden mit Meldung abgelehnt. * * @param array $datei array('name', 'tmp_name', 'size'[, 'type']) — z. B. $_FILES['x'] per Referenz * @return string '' = kein WebP oder umgewandelt; sonst Fehlermeldung (transnoentities, auch für JSON) */ function bericht_upload_webp_umwandeln(array &$datei) { global $langs; $tmp = (string) ($datei['tmp_name'] ?? ''); if ($tmp === '' || !is_file($tmp) || !bericht_bild_ist_webp($tmp)) { return ''; } $name = basename((string) ($datei['name'] ?? '')); $m = bericht_webp_merkmale($tmp); $neu = $tmp.'.webpneu'.($m['alpha'] ? '.png' : '.jpg'); $ok = false; if (!$m['animiert'] && function_exists('imagecreatefromwebp')) { $bild = @imagecreatefromwebp($tmp); if ($bild) { if ($m['alpha']) { imagealphablending($bild, false); imagesavealpha($bild, true); $ok = imagepng($bild, $neu, 6); } else { $ok = imagejpeg($bild, $neu, 90); } imagedestroy($bild); } } if (!$ok || !is_file($neu) || filesize($neu) === 0 || !@rename($neu, $tmp)) { @unlink($neu); $langs->load('bericht@bericht'); return $langs->transnoentities($m['animiert'] ? 'BerichtWebpAnimiert' : 'BerichtWebpNichtLesbar', $name); } clearstatcache(true, $tmp); $stamm = pathinfo($name, PATHINFO_FILENAME); $datei['name'] = ($stamm !== '' ? $stamm : 'bild').($m['alpha'] ? '.png' : '.jpg'); $datei['size'] = (int) filesize($tmp); $datei['type'] = $m['alpha'] ? 'image/png' : 'image/jpeg'; return ''; } /** * Einstieg für alle Upload-Wege: AVIF und WebP vor Endungsprüfung und Namensbildung in JPG/PNG umwandeln. * * @param array $datei $_FILES-Eintrag per Referenz * @return string '' = in Ordnung; sonst Fehlermeldung */ function bericht_upload_bild_umwandeln(array &$datei) { $fehler = bericht_upload_avif_umwandeln($datei); return $fehler !== '' ? $fehler : bericht_upload_webp_umwandeln($datei); }