Compare commits

..

2 commits

Author SHA1 Message Date
a026d3f16e AVIF-Produktfotos im Angebot (0.1.2) [deploy]
All checks were successful
Deploy ProductImageTags / deploy (push) Successful in 9s
Eddy: „es gibt immer mehr Bilder im Netz, die AVIF verwenden“. Ein AVIF im
Produktordner fehlte bisher im PDF: „.avif“ zählte nicht als Bild, ein AVIF
unter „.jpg“ (Bild-CDNs) scheiterte am JPEG-Loader und ging roh ins ODT.

- lib/avif.lib.php: Erkennung am Inhalt (ftyp avif/avis), Alpha-Erkennung,
  Umwandlung in JPG (ImageMagick, sonst GD) bzw. PNG mit Transparenz nur
  über GD (ImageMagick 6 auf Prod verliert den Alphakanal)
- listSourceImages: AVIF zählt als Kandidat in der Galerie-Reihenfolge
- getScaledCopy: AVIF über eine Zwischendatei im Cache, dann wie jedes
  andere Foto skaliert und als PNG gecacht; Zwischendatei wird gelöscht,
  das rohe AVIF kommt nie ins Dokument (auch nicht bei Langseite 0)
- veraltete „alphabetisch“-Kommentare an die Galerie-Reihenfolge angepasst

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:45:38 +02:00
6c1d922994 Nachtrag 0.1.1: Version und README zur Bildauswahl
Die Versionsnummer im Deskriptor stand nach dem Fix 0.1.1 noch auf 0.1.0,
der README-Abschnitt „Bild-Auswahl“ beschrieb noch die alphabetische
Auswahl. Beides auf den deployten Stand gebracht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:34:13 +02:00
5 changed files with 262 additions and 65 deletions

View file

@ -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

View file

@ -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
@ -58,7 +59,15 @@ Das Zeilen-Bild nutzt einen **Marker+Post-Processing-Workaround**, weil Dolibarr
## Bild-Auswahl
Das „Hauptbild" eines Produkts ist das **alphabetisch erste** in `documents/produit/<ref-ordner>/photos/`. Wer steuern will, welches Bild Hauptbild ist: Dateien mit Nummernprefix benennen, z.B. `01_front.jpg`, `02_detail.jpg`, …
Das „Hauptbild" eines Produkts (Zeilen-Bild und großes Detailbild) wird **genau in der Reihenfolge gewählt, die Dolibarr im Produktkärtchen anzeigt** — nicht alphabetisch. Sortiert wird wie in `Product::show_photos()`:
1. **Cover** (falls im ECM gesetzt — im Produkt-UI normalerweise nicht setzbar, daher meist ohne Wirkung)
2. **Position** aus `llx_ecm_files` (die per Drag&Drop festgelegte Galerie-Reihenfolge)
3. alphabetisch als Fallback (nicht indexierte Dateien landen am Ende)
**So steuerst du, welches Bild im Angebot erscheint:** Produkt öffnen → Reiter **„Dokumente"** → das gewünschte Bild per Hoch-/Runter-Pfeil bzw. Drag&Drop **an Position 1** ziehen. Danach ggf. im Modul-Setup den **Cache leeren**, damit nicht eine ältere skalierte Kopie greift.
> Quelle der Reihenfolge ist `dol_dir_list_in_database()` auf `llx_ecm_files` (`position`, `cover`) in `class/productimage.class.php::listSourceImages`. Vor v0.1.1 wurde rein alphabetisch nach Dateiname sortiert — dadurch landete ein willkürliches Bild im PDF.
## Bekannte Limitationen

View file

@ -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;
}
/**

View file

@ -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.0';
$this->version = '0.1.2';
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);

135
lib/avif.lib.php Normal file
View file

@ -0,0 +1,135 @@
<?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 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 '';
}