diff --git a/ChangeLog.md b/ChangeLog.md index ece5535..0a3d0a4 100644 --- a/ChangeLog.md +++ b/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 diff --git a/README.md b/README.md index d9ca48f..6ae9c32 100644 --- a/README.md +++ b/README.md @@ -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}`, …) diff --git a/ajax/upload_extra.php b/ajax/upload_extra.php index d5609ed..7ad73ee 100644 --- a/ajax/upload_extra.php +++ b/ajax/upload_extra.php @@ -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'); diff --git a/api/orders.php b/api/orders.php index e3fa02c..4c52a83 100644 --- a/api/orders.php +++ b/api/orders.php @@ -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)); diff --git a/bericht_card.php b/bericht_card.php index d994a2c..e3d4623 100644 --- a/bericht_card.php +++ b/bericht_card.php @@ -525,7 +525,7 @@ document.addEventListener("click", function(e) { print '
'; print '
'; print ''; - print ''; + print ''; print ''; print '
'; print ''; diff --git a/core/modules/modBericht.class.php b/core/modules/modBericht.class.php index c62e2ab..bd134f9 100644 --- a/core/modules/modBericht.class.php +++ b/core/modules/modBericht.class.php @@ -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'; diff --git a/js/editor.js b/js/editor.js index cf85f05..357846b 100644 --- a/js/editor.js +++ b/js/editor.js @@ -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; diff --git a/langs/de_DE/bericht.lang b/langs/de_DE/bericht.lang index f401672..b16e163 100644 --- a/langs/de_DE/bericht.lang +++ b/langs/de_DE/bericht.lang @@ -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 diff --git a/langs/en_US/bericht.lang b/langs/en_US/bericht.lang index 406e3c2..5b4c96d 100644 --- a/langs/en_US/bericht.lang +++ b/langs/en_US/bericht.lang @@ -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 diff --git a/lib/bericht_avif.lib.php b/lib/bericht_avif.lib.php new file mode 100644 index 0000000..6115ea4 --- /dev/null +++ b/lib/bericht_avif.lib.php @@ -0,0 +1,172 @@ + + * + * 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 ''; +} diff --git a/mobile_upload.php b/mobile_upload.php index cc430a5..89018bd 100644 --- a/mobile_upload.php +++ b/mobile_upload.php @@ -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';