1.7.4: AVIF-Bilder beim Hochladen in JPG/PNG umwandeln [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 14s
All checks were successful
Deploy bericht / deploy (push) Successful in 14s
Eddy: „es gibt immer mehr Bilder im Netz, die AVIF verwenden". Neue lib/bericht_avif.lib.php erkennt AVIF am Inhalt (auch unter „.jpg") und wandelt es vor der Endungsprüfung um: deckend → JPG, transparent → PNG (nur GD, ImageMagick 6 verliert den Alphakanal; Muster KB #1698). Abgedeckt: Editor-Upload (ajax/upload_extra.php, accept + Dateifilter), PWA-Foto (api/orders.php upload_photo), Handy-Upload per QR (mobile_upload.php; verkleinerte Bilder heißen jetzt .jpg statt der alten Endung). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
15e6f9a5ff
commit
7b69c0f180
11 changed files with 224 additions and 5 deletions
20
ChangeLog.md
20
ChangeLog.md
|
|
@ -1,5 +1,25 @@
|
|||
# Changelog
|
||||
|
||||
## 1.7.4 — 2026-10-19
|
||||
|
||||
### AVIF-Bilder werden beim Hochladen umgewandelt
|
||||
„Es gibt immer mehr Bilder im Netz, die AVIF verwenden" (Eddy, 19.10.2026). Die Upload-Wege nahmen
|
||||
nur JPG/PNG (Editor zusätzlich PDF) und lehnten `.avif` ab. Ein AVIF, das ein Bild-CDN unter „.jpg"
|
||||
ausliefert, kam dagegen durch (geprüft wird nur die Endung) und lag roh als „.jpg" am Beleg — das
|
||||
Bericht-PDF (TCPDF) kann es nicht setzen. Jetzt erkennt `lib/bericht_avif.lib.php` ein AVIF am
|
||||
Inhalt und wandelt es vor der Endungsprüfung um: deckend → JPG, mit Transparenz → PNG (Muster aus
|
||||
KundenKarte, KB #1698).
|
||||
|
||||
- Transparente AVIF wandelt auf Prod GD zu PNG (Prod-Image mit GD-AVIF seit 19.10.2026,
|
||||
dolibarr-image Commit 0cd9d18). ImageMagick 6 nimmt dafür nicht, es verliert den Alphakanal.
|
||||
Die Meldung „Bitte vorerst als PNG speichern" greift nur noch, wo GD kein AVIF kann.
|
||||
- Abgedeckt: Datei-Upload im Editor (`ajax/upload_extra.php`, Dateidialog und Ablegen nehmen
|
||||
jetzt `.avif` an), Foto-Upload der PWA (`api/orders.php?action=upload_photo`), Handy-Upload
|
||||
per QR (`mobile_upload.php`). Dort bekam ein verkleinertes Bild bisher die alte Endung —
|
||||
ein großes AVIF kam als JPEG mit „.avif" an und wurde abgelehnt; jetzt heißt es „.jpg".
|
||||
- Nicht nötig: Scan-Seiten, Unterschriften und Editor-Composites entstehen im Browser als
|
||||
JPEG/PNG, die ODT-Vorlagen im Setup sind keine Bilder.
|
||||
|
||||
## 1.7.3 — 2026-10-14
|
||||
|
||||
### Neuer Auftrag aus der App: kein zufälliger Kundenkontakt mehr
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ Bilder und PDFs lassen sich im Browser annotieren (Pfeile, Kreise, Rechtecke, Te
|
|||
- **Bild einer Seite austauschen** ohne sie zu löschen — Reihenfolge, Notiz und Anmerkungen bleiben erhalten. Bei Raster-Layouts lässt sich jeder Platz einzeln tauschen, direkt auf der Seite anklickbar.
|
||||
- **Ziehen und Ablegen**: Kachel auf die Seitenliste legt neue Seiten an, Kachel auf die Arbeitsfläche ersetzt das Bild der offenen Seite, Kachel auf einen Raster-Platz setzt genau diesen. Dateien vom Rechner auf die Anhänge-Spalte werden hochgeladen.
|
||||
- **Mobiler Upload per QR-Code** — Handy scannt, lädt Fotos direkt in den offenen Bericht (zeitlich begrenzte Token)
|
||||
- **AVIF-Bilder (1.7.4)** — beim Hochladen (Editor, PWA, Handy-Upload) am Inhalt erkannt und in JPG bzw. PNG (mit Transparenz) umgewandelt (`lib/bericht_avif.lib.php`, KB #1698)
|
||||
- **Ablage am Beleg als Beilage (1.7.2)** — Fotos, Notizen, Sprachnotizen, Scans und Bericht-PDFs, die im Belegordner landen, stehen im ECM-Index als `uploaded` (`lib/bericht_index.lib.php`): nur im Reiter „Verknüpfte Dokumente", nie vorne im Kasten der Belegkarte (KB #1641)
|
||||
- **Notizen pro Seite** — werden im finalen PDF unten auf der Seite gedruckt
|
||||
- **Deckblatt aus ODT-Vorlage** mit Platzhaltern (`{auftragsnummer}`, `{kunde_name}`, `{datum}`, …)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ if ($bericht->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden'
|
|||
|
||||
if (empty($_FILES['file']['tmp_name'])) bericht_ajax_fail('Keine Datei hochgeladen');
|
||||
|
||||
// AVIF (aus dem Netz, auch unter „.jpg") vor Endungsprüfung und Namensbildung in JPG/PNG umwandeln
|
||||
require_once __DIR__.'/../lib/bericht_avif.lib.php';
|
||||
$avif_fehler = bericht_upload_avif_umwandeln($_FILES['file']);
|
||||
if ($avif_fehler !== '') bericht_ajax_fail($avif_fehler);
|
||||
|
||||
$origname = dol_sanitizeFileName($_FILES['file']['name']);
|
||||
$ext = strtolower(pathinfo($origname, PATHINFO_EXTENSION));
|
||||
$allowed = array('pdf', 'png', 'jpg', 'jpeg');
|
||||
|
|
|
|||
|
|
@ -227,6 +227,11 @@ if ($action === 'upload_photo' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||
if (!$user->hasRight('bericht', 'write')) api_fail('Schreibrechte fehlen', 403);
|
||||
if (empty($_FILES['file']['tmp_name'])) api_fail('file fehlt');
|
||||
|
||||
// AVIF (Galerie/Teilen aus dem Netz, auch unter „.jpg") vor der Prüfung in JPG/PNG umwandeln
|
||||
require_once __DIR__.'/../lib/bericht_avif.lib.php';
|
||||
$avif_fehler = bericht_upload_avif_umwandeln($_FILES['file']);
|
||||
if ($avif_fehler !== '') api_fail($avif_fehler);
|
||||
|
||||
// Datei validieren
|
||||
$orig = dol_sanitizeFileName($_FILES['file']['name']);
|
||||
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@ document.addEventListener("click", function(e) {
|
|||
print '<hr>';
|
||||
print '<div class="bericht-upload">';
|
||||
print '<label for="bericht-extra-upload" class="butAction" title="Datei vom PC hochladen">📤 '.$langs->trans("BerichtUploadExtra").'</label>';
|
||||
print '<input type="file" id="bericht-extra-upload" style="display:none" accept=".pdf,.png,.jpg,.jpeg,.webp" multiple>';
|
||||
print '<input type="file" id="bericht-extra-upload" style="display:none" accept=".pdf,.png,.jpg,.jpeg,.webp,.avif" multiple>';
|
||||
print '<button type="button" id="btn-show-qr" class="butAction" title="QR-Code für Mobile-Upload erzeugen" style="margin-top:6px;">📱 Mobil hochladen</button>';
|
||||
print '</div>';
|
||||
print '</aside>';
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class modBericht extends DolibarrModules
|
|||
|
||||
$this->editor_name = 'Alles Watt läuft';
|
||||
$this->editor_url = '';
|
||||
$this->version = '1.7.3';
|
||||
$this->version = '1.7.4';
|
||||
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
|
||||
$this->picto = 'fa-file-pdf';
|
||||
|
||||
|
|
|
|||
|
|
@ -2390,13 +2390,14 @@
|
|||
* per Drag & Drop. Fortschritt steht auf der Schaltfläche.
|
||||
*/
|
||||
async function uploadFiles(files) {
|
||||
const allowed = /\.(pdf|png|jpe?g|webp)$/i;
|
||||
// AVIF wandelt der Server beim Hochladen in JPG/PNG um (lib/bericht_avif.lib.php)
|
||||
const allowed = /\.(pdf|png|jpe?g|webp|avif)$/i;
|
||||
const rejected = files.filter(f => !allowed.test(f.name));
|
||||
files = files.filter(f => allowed.test(f.name));
|
||||
|
||||
if (rejected.length) {
|
||||
await dolAlert('Nicht unterstützt und übersprungen:\n' + rejected.map(f => f.name).join('\n')
|
||||
+ '\n\nMöglich sind PDF, JPG, PNG und WebP.');
|
||||
+ '\n\nMöglich sind PDF, JPG, PNG, WebP und AVIF.');
|
||||
}
|
||||
if (!files.length) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ BerichtDeliveryConfirmed = Lieferung bestätigt — PDF wurde an die Expedition
|
|||
BerichtNoShipmentsForOrder = Zu diesem Auftrag gibt es noch keine Lieferungen.
|
||||
BerichtSignDelivery = Lieferung unterschreiben
|
||||
BerichtViewSignedPdf = Unterschriebenes PDF ansehen
|
||||
BerichtAvifNichtLesbar = %s: Das AVIF-Bild ließ sich nicht in JPG umwandeln.
|
||||
BerichtAvifTransparenz = %s: AVIF mit transparentem Hintergrund kann der Server noch nicht umwandeln — der Hintergrund würde schwarz. Bitte vorerst als PNG speichern.
|
||||
Permission50002101 = Berichte lesen
|
||||
Permission50002102 = Berichte erstellen und bearbeiten
|
||||
Permission50002103 = Berichte löschen
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ BerichtDeliveryConfirmed = Delivery confirmed — PDF attached to shipment
|
|||
BerichtNoShipmentsForOrder = This order has no shipments yet.
|
||||
BerichtSignDelivery = Sign delivery
|
||||
BerichtViewSignedPdf = View signed PDF
|
||||
BerichtAvifNichtLesbar = %s: the AVIF image could not be converted to JPG.
|
||||
BerichtAvifTransparenz = %s: the server cannot convert AVIF with a transparent background yet — the background would turn black. Please save it as PNG for now.
|
||||
Permission50002101 = Read reports
|
||||
Permission50002102 = Create and edit reports
|
||||
Permission50002103 = Delete reports
|
||||
|
|
|
|||
172
lib/bericht_avif.lib.php
Normal file
172
lib/bericht_avif.lib.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?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-Bilder in JPEG (deckend) bzw. PNG (transparent) 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 '';
|
||||
}
|
||||
|
|
@ -53,6 +53,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_FILES['file']['tmp_name'])
|
|||
exit;
|
||||
}
|
||||
|
||||
// AVIF (die Seite verkleinert nur Bilder über 2000 px zu JPEG, kleinere kommen roh) vor der
|
||||
// Endungsprüfung in JPG/PNG umwandeln
|
||||
require_once __DIR__.'/lib/bericht_avif.lib.php';
|
||||
$avif_fehler = bericht_upload_avif_umwandeln($_FILES['file']);
|
||||
if ($avif_fehler !== '') {
|
||||
echo json_encode(array('success' => false, 'error' => $avif_fehler));
|
||||
exit;
|
||||
}
|
||||
|
||||
$orig = dol_sanitizeFileName($_FILES['file']['name']);
|
||||
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||
$allowed = array('jpg', 'jpeg', 'png');
|
||||
|
|
@ -1206,7 +1215,9 @@ async function finishScanning() {
|
|||
|
||||
async function uploadFile(file) {
|
||||
const blob = await resizeImage(file, 2000);
|
||||
const fname = file.name || 'photo.jpg';
|
||||
let fname = file.name || 'photo.jpg';
|
||||
// Verkleinert = JPEG aus dem Canvas: Endung angleichen, sonst lehnt der Server z. B. ein „.avif" ab
|
||||
if (blob !== file) fname = fname.replace(/\.[^.]*$/, '') + '.jpg';
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'uploading';
|
||||
|
|
|
|||
Loading…
Reference in a new issue