diff --git a/CHANGELOG.md b/CHANGELOG.md index da54be6..eb6d3c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog — ProductImageTags +## 0.1.2 (2026-10-19) + +### Behoben +- **AVIF-Produktfotos fehlten im Angebot.** Anlass (Eddy): „es gibt immer mehr Bilder im + Netz, die AVIF verwenden". Dolibarr legt ein hochgeladenes AVIF unverändert in den + Produktordner. Bisher zählte `.avif` in `listSourceImages` nicht als Bild (Dolibarrs + `image_format_supported()` kennt die Endung nicht), und ein AVIF unter `.jpg` (so liefern + es Bild-CDNs aus) scheiterte in `getScaledCopy` am JPEG-Loader und ging roh ins ODT. +- Jetzt wird AVIF **am Inhalt** erkannt (`ftyp`-Marke `avif`/`avis`, neue `lib/avif.lib.php`), + zählt als Kandidat in der Galerie-Reihenfolge und wird vor dem Skalieren in eine + Zwischendatei im Cache umgewandelt: deckend → JPG (ImageMagick, sonst GD), mit Transparenz → + PNG nur über GD (ImageMagick 6 auf Prod verliert den Alphakanal, der Hintergrund würde + schwarz). Danach läuft es wie jedes andere Foto (skaliert, auf die Hintergrundfarbe + geflattet, als PNG in den Cache), die Zwischendatei wird gelöscht. Ein AVIF geht auch bei + Langseite 0 („Original") nie roh ins Dokument. +- Transparente AVIF werden auf Prod per GD zu PNG (GD mit AVIF im Prod-Image seit + `dolibarr-image` Commit 0cd9d18). Wo GD kein AVIF kann, bleibt ein transparentes AVIF + weg (Eintrag im Dolibarr-Log) statt mit schwarzem Hintergrund im PDF zu landen. + ## 0.1.1 (2026-06-18) ### Behoben diff --git a/README.md b/README.md index 9c7dfc7..610a6d1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Dolibarr-Modul, das **Produktbilder in Angebots- und Auftrags-ODTs** einbindet - **Zeilen-Platzhalter** `{line_product_image_path}` und `{line_product_has_image}` innerhalb `[!-- BEGIN lines --]` - **Eigenes Detailseiten-Segment** `[!-- BEGIN product_details --]` mit einem großen und bis zu 4 kleinen Bildern je Produkt, Beschreibung, Preisen, Maßen - **On-the-fly Skalierung** der Bilder auf konfigurierbare Maximalgröße, gecacht (Bildtausch invalidiert den Cache automatisch) +- **AVIF-Fotos** (auch unter „.jpg" aus Bild-CDNs) werden am Inhalt erkannt und vor dem Skalieren in JPG bzw. — mit Transparenz — PNG umgewandelt (`lib/avif.lib.php`; transparente nur über GD mit AVIF, ImageMagick 6 verliert den Alphakanal) - Konfigurierbar über Admin-UI (Bildgrößen, Anzahl, Cache-Dauer, Skip-Empty) - Greift für Angebot (propal) und Auftrag (commande); Rechnung/Lieferschein technisch vorbereitet, kann später freigeschaltet werden diff --git a/class/productimage.class.php b/class/productimage.class.php index fdaab04..a72c1ea 100644 --- a/class/productimage.class.php +++ b/class/productimage.class.php @@ -17,10 +17,12 @@ require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/images.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; +require_once __DIR__.'/../lib/avif.lib.php'; /** * Bild-Helper für Produkte. Liefert skalierte, gecachte Kopien der - * Produkt-Fotos (alphabetisch sortiert), damit ODT-Dokumente klein bleiben. + * Produkt-Fotos (in Dolibarrs Galerie-Reihenfolge), damit ODT-Dokumente klein bleiben. + * AVIF-Fotos werden dabei in JPG/PNG umgewandelt (lib/avif.lib.php). */ class ProductImage { @@ -72,7 +74,7 @@ class ProductImage } /** - * Liefert das erste (alphabetisch) Produktbild skaliert. + * Liefert das erste Produktbild (Galerie-Reihenfolge) skaliert. * * @param Product $product Produkt * @param int $targetPx Langseite des Zielbildes in Pixel @@ -122,7 +124,7 @@ class ProductImage /* --------------------------------------------------------------------- */ /** - * Listet alle Originalbilder eines Produkts, alphabetisch sortiert. + * Listet alle Originalbilder eines Produkts (inkl. AVIF) in Dolibarrs Galerie-Reihenfolge. * * @param Product $product Produkt * @return string[] Absolute Pfade zu den Originalen @@ -194,7 +196,9 @@ class ProductImage $indexed = array(); foreach ($files as $file) { $fullPath = $file['fullname']; - if (image_format_supported($fullPath) <= 0) { + // AVIF zählt mit, obwohl Dolibarrs image_format_supported() die Endung „.avif" nicht kennt — + // am Inhalt erkannt, damit auch AVIF unter fremder Endung greift. Umgewandelt wird in getScaledCopy(). + if (image_format_supported($fullPath) <= 0 && !productimagetags_bild_ist_avif($fullPath)) { continue; } $name = $file['name']; @@ -238,13 +242,21 @@ class ProductImage if (!file_exists($srcPath)) { return null; } + + // AVIF am INHALT erkennen, nicht an der Endung: Bild-CDNs liefern AVIF auch als „.jpg" aus, dann + // scheiterte unten der JPEG-Loader und das Original ging roh ins ODT. AVIF läuft deshalb immer über + // den Cache (auch ohne Skalierung), das Original kommt nie ins Dokument. + $isAvif = productimagetags_bild_ist_avif($srcPath); + // Wenn AVIF nicht umwandelbar: lieber kein Bild als das rohe AVIF + $ohneCache = $isAvif ? null : $srcPath; + // Kein Resize wenn Ziel <= 0 (dann Original) - if ($targetPx <= 0) { + if ($targetPx <= 0 && !$isAvif) { return $srcPath; } - $srcExt = strtolower(pathinfo($srcPath, PATHINFO_EXTENSION)); - if (!in_array($srcExt, array('jpg', 'jpeg', 'png', 'gif', 'webp'))) { + $srcExt = $isAvif ? 'avif' : strtolower(pathinfo($srcPath, PATHINFO_EXTENSION)); + if (!$isAvif && !in_array($srcExt, array('jpg', 'jpeg', 'png', 'gif', 'webp'))) { return $srcPath; } @@ -261,69 +273,91 @@ class ProductImage return $cacheFile; } - $size = @getimagesize($srcPath); - if ($size === false) { - return $srcPath; - } - list($w, $h) = $size; - - // In GD laden (Quelle kann jpeg/png/gif/webp sein) - $img = null; - switch ($srcExt) { - case 'jpg': case 'jpeg': $img = @imagecreatefromjpeg($srcPath); break; - case 'png': $img = @imagecreatefrompng($srcPath); break; - case 'gif': $img = @imagecreatefromgif($srcPath); break; - case 'webp': $img = function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : null; break; - } - if (!$img) { - // Fallback: 1:1-Kopie mit PNG-Endung — könnte ein WebP mit PNG-Header werden, - // also lieber Quelle zurück - return $srcPath; + // AVIF zuerst in eine Zwischendatei im Cache wandeln (JPG, mit Transparenz PNG), danach wie jedes + // andere Foto weiter. Die Zwischendatei wird nach dem Skalieren wieder gelöscht (finally unten) — + // im Cache bleibt nur das fertige PNG, dessen Schlüssel wie bisher an Quelle+mtime+Größe hängt. + $loadPath = $srcPath; + $zwischen = ''; + if ($isAvif) { + // PID im Namen: parallele PDF-Erzeugungen kommen sich nicht in die Quere + $zwischen = productimagetags_avif_in_datei($srcPath, $this->cacheDir.'/prod'.((int) $product->id).'_'.$hash.'_avif'.getmypid()); + if ($zwischen === '') { + dol_syslog('ProductImageTags: AVIF ließ sich nicht umwandeln (weder ImageMagick noch GD mit AVIF): '.$srcPath, LOG_WARNING); + return null; + } + $loadPath = $zwischen; + $srcExt = strtolower(pathinfo($zwischen, PATHINFO_EXTENSION)); } - // Zielgröße - $longSide = max($w, $h); - if ($longSide <= $targetPx) { - $newW = $w; $newH = $h; - } elseif ($w >= $h) { - $newW = $targetPx; - $newH = max(1, (int) round($h * ($targetPx / $w))); - } else { - $newH = $targetPx; - $newW = max(1, (int) round($w * ($targetPx / $h))); - } + try { + $size = @getimagesize($loadPath); + if ($size === false) { + return $ohneCache; + } + list($w, $h) = $size; - // Hintergrundfarbe (weiß default) — vereinheitlicht das Bild - $bgHex = getDolGlobalString('PRODUCTIMAGETAGS_IMAGE_BG_COLOR', '#ffffff'); - list($bgR, $bgG, $bgB) = $this->hexToRgb($bgHex); + // In GD laden (Quelle kann jpeg/png/gif/webp sein, AVIF ist hier schon jpg/png) + $img = null; + switch ($srcExt) { + case 'jpg': case 'jpeg': $img = @imagecreatefromjpeg($loadPath); break; + case 'png': $img = @imagecreatefrompng($loadPath); break; + case 'gif': $img = @imagecreatefromgif($loadPath); break; + case 'webp': $img = function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($loadPath) : null; break; + } + if (!$img) { + // Fallback: 1:1-Kopie mit PNG-Endung — könnte ein WebP mit PNG-Header werden, + // also lieber Quelle zurück + return $ohneCache; + } - $dst = imagecreatetruecolor($newW, $newH); - // Hintergrund mit konfigurierter Farbe fluten (opak, keine Transparenz nötig) - $bg = imagecolorallocate($dst, $bgR, $bgG, $bgB); - imagefilledrectangle($dst, 0, 0, $newW - 1, $newH - 1, $bg); - // Transparenz des Originals per alpha-blending auf den opaken Hintergrund legen - imagealphablending($dst, true); - imagecopyresampled($dst, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + // Zielgröße (Ziel <= 0 kommt nur bei AVIF hier an: Originalgröße, nur umgewandelt) + $longSide = max($w, $h); + if ($targetPx <= 0 || $longSide <= $targetPx) { + $newW = $w; $newH = $h; + } elseif ($w >= $h) { + $newW = $targetPx; + $newH = max(1, (int) round($h * ($targetPx / $w))); + } else { + $newH = $targetPx; + $newW = max(1, (int) round($w * ($targetPx / $h))); + } - // Rahmen (falls konfiguriert) - $borderW = (int) getDolGlobalString('PRODUCTIMAGETAGS_IMAGE_BORDER_WIDTH', 1); - if ($borderW > 0) { - $borderHex = getDolGlobalString('PRODUCTIMAGETAGS_IMAGE_BORDER_COLOR', '#cccccc'); - list($brR, $brG, $brB) = $this->hexToRgb($borderHex); - $border = imagecolorallocate($dst, $brR, $brG, $brB); - for ($i = 0; $i < $borderW; $i++) { - imagerectangle($dst, $i, $i, $newW - 1 - $i, $newH - 1 - $i, $border); + // Hintergrundfarbe (weiß default) — vereinheitlicht das Bild + $bgHex = getDolGlobalString('PRODUCTIMAGETAGS_IMAGE_BG_COLOR', '#ffffff'); + list($bgR, $bgG, $bgB) = $this->hexToRgb($bgHex); + + $dst = imagecreatetruecolor($newW, $newH); + // Hintergrund mit konfigurierter Farbe fluten (opak, keine Transparenz nötig) + $bg = imagecolorallocate($dst, $bgR, $bgG, $bgB); + imagefilledrectangle($dst, 0, 0, $newW - 1, $newH - 1, $bg); + // Transparenz des Originals per alpha-blending auf den opaken Hintergrund legen + imagealphablending($dst, true); + imagecopyresampled($dst, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + + // Rahmen (falls konfiguriert) + $borderW = (int) getDolGlobalString('PRODUCTIMAGETAGS_IMAGE_BORDER_WIDTH', 1); + if ($borderW > 0) { + $borderHex = getDolGlobalString('PRODUCTIMAGETAGS_IMAGE_BORDER_COLOR', '#cccccc'); + list($brR, $brG, $brB) = $this->hexToRgb($borderHex); + $border = imagecolorallocate($dst, $brR, $brG, $brB); + for ($i = 0; $i < $borderW; $i++) { + imagerectangle($dst, $i, $i, $newW - 1 - $i, $newH - 1 - $i, $border); + } + } + + $ok = @imagepng($dst, $cacheFile, 6); + imagedestroy($dst); + imagedestroy($img); + + if (!$ok) { + return $ohneCache; + } + return $cacheFile; + } finally { + if ($zwischen !== '') { + @unlink($zwischen); } } - - $ok = @imagepng($dst, $cacheFile, 6); - imagedestroy($dst); - imagedestroy($img); - - if (!$ok) { - return $srcPath; - } - return $cacheFile; } /** diff --git a/core/modules/modProductImageTags.class.php b/core/modules/modProductImageTags.class.php index 3aa86b4..044a3eb 100644 --- a/core/modules/modProductImageTags.class.php +++ b/core/modules/modProductImageTags.class.php @@ -57,7 +57,7 @@ class modProductImageTags extends DolibarrModules $this->editor_url = 'https://git.data-it-solution.de/data-it/productimagetags'; $this->editor_squarred_logo = ''; - $this->version = '0.1.1'; + $this->version = '0.1.2'; $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); diff --git a/lib/avif.lib.php b/lib/avif.lib.php new file mode 100644 index 0000000..ba76c25 --- /dev/null +++ b/lib/avif.lib.php @@ -0,0 +1,135 @@ + + * + * 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 lib/avif.lib.php + * \ingroup productimagetags + * \brief AVIF-Produktfotos erkennen und in JPG/PNG umwandeln. + * + * Immer mehr Bilder aus dem Netz sind AVIF — Bild-CDNs liefern sie auch unter „.jpg" aus. Dolibarr legt sie + * beim Hochladen unverändert in den Produktordner. Ohne Umwandlung fehlte ein AVIF-Foto im Angebots-/Auftrags- + * PDF: „.avif" zählt für Dolibarrs image_format_supported() nicht als Bild, und ein AVIF unter „.jpg" scheitert + * am JPEG-Loader von GD. Das Modul lädt nichts hoch — hier steht deshalb nur der lesende Teil: Erkennen am + * Inhalt, Alpha-Erkennung, Umwandlung in eine Zwischendatei, die productimage.class.php dann wie jedes andere + * Foto skaliert und als PNG in den Cache legt. + */ + +/** + * 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 productimagetags_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 productimagetags_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 productimagetags_avif_in_datei($quelle, $ziel) +{ + $alpha = productimagetags_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 ''; +}