All checks were successful
Deploy bericht / deploy (push) Successful in 13s
Der Editor bot WebP an, der Server lehnte es mit „Dateityp nicht erlaubt" ab; ein WebP unter „.jpg" kam roh durch. bericht_upload_bild_umwandeln() wandelt jetzt AVIF und WebP vor der Endungsprüfung um (deckend → JPG, transparent → PNG über GD). Bewegte WebP werden mit Meldung abgelehnt. Grund für die Umwandlung: Bericht-PDF/LibreOffice setzen WebP nicht sicher (KB #447). Alle drei Upload-Wege (Editor, PWA-Foto, Handy-Upload), in der Testinstanz über den Editor geprüft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
9.7 KiB
PHP
272 lines
9.7 KiB
PHP
<?php
|
|
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
|
*
|
|
* 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);
|
|
}
|