Initiales Commit — ProductImageTags v0.1.0 [deploy]
All checks were successful
Deploy ProductImageTags / deploy (push) Successful in 9s

Produktbilder via Hook-System in ODT/PDF-Vorlagen einbetten.
Vollständiges Modul mit Admin-UI, Cache, Mehrsprachigkeit (de/en).
This commit is contained in:
Eduard Wisch 2026-04-26 17:41:36 +02:00
commit 8cd8ec58ab
18 changed files with 1766 additions and 0 deletions

View file

@ -0,0 +1,63 @@
name: Deploy ProductImageTags
on:
push:
tags:
- 'v*'
branches:
- main
jobs:
deploy:
runs-on: docker
if: startsWith(github.ref, 'refs/tags/v') || contains(github.event.head_commit.message, '[deploy]')
steps:
- name: Checkout
run: |
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
"https://token:${{ secrets.GIT_TOKEN }}@git.data-it-solution.de/${GITHUB_REPOSITORY}.git" .
- name: Deploy nach Dolibarr
run: |
DEPLOY_PATH="/mnt/appdata/firma/dolibarr-202509/modules/productimagetags"
REF="${GITHUB_REF#refs/*/}"
echo "Deploye ${REF} nach ${DEPLOY_PATH} ..."
if [ -d "$DEPLOY_PATH" ]; then
find "$DEPLOY_PATH" -mindepth 1 -not -path '*/.git/*' -not -name '.git' -delete 2>/dev/null || true
else
mkdir -p "$DEPLOY_PATH"
fi
rsync -a \
--exclude='.git' \
--exclude='.forgejo' \
--exclude='.gitignore' \
--exclude='CLAUDE.md' \
./ "$DEPLOY_PATH/"
echo "Deployment erfolgreich: ${REF} -> ${DEPLOY_PATH}"
ls -la "$DEPLOY_PATH/core/modules/"
- name: Notify Success
if: success()
run: |
wget -q -O- \
--header="Authorization: ${{ secrets.NTFY_AUTH }}" \
--header="Title: ProductImageTags deployt" \
--header="Priority: high" \
--header="Tags: white_check_mark" \
--post-data="ProductImageTags ${GITHUB_REF#refs/*/} auf Prod deployt" \
https://notify.data-it-solution.de/vk-builds || true
- name: Notify Failure
if: failure()
run: |
wget -q -O- \
--header="Authorization: ${{ secrets.NTFY_AUTH }}" \
--header="Title: ProductImageTags Deploy fehlgeschlagen" \
--header="Priority: urgent" \
--header="Tags: rotating_light" \
--post-data="Deploy fehlgeschlagen — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" \
https://notify.data-it-solution.de/vk-builds || true

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# Cache-Verzeichnis
/cache/
*.cache
# Temporäre Dateien
*.tmp
*.log
# IDE
.idea/
.vscode/
*.swp

52
CHANGELOG.md Normal file
View file

@ -0,0 +1,52 @@
# Changelog — ProductImageTags
## 0.1.0 (2026-04-24)
Erste lauffähige Version — Bilder erscheinen live in Angebots-/Auftrags-PDFs.
### Features
- Hook-Kontext `odtgeneration`, drei Hooks:
- `ODTSubstitutionLine` — setzt pro Zeile einen eindeutigen Marker `{_pit_img_<uniqid>}` ins substitutionarray, puffert Bildinfo in Instance-Property.
- `beforeODTSave` — ruft für jeden gepufferten Marker `$odfHandler->setImage($key, $path, $ratio)` auf (offizielle odfphp-API, gleicher Weg wie EPCQR für `{qrcode}`). Ist der **zuverlässige Einbau-Punkt**, weil bei `MAIN_ODT_AS_PDF` der LibreOffice-Convert zu PDF direkt nach `saveToDisk` läuft und `afterODTCreation` zu spät kommt.
- `afterODTCreation` — ZIP-Post-Processing als Fallback, nur relevant wenn `MAIN_ODT_AS_PDF` aus ist.
- Bild-Helper (`ProductImage`-Klasse):
- Findet Produktbilder sowohl im neuen Layout `<base>/<ref>/<dateien>` (Default seit Dolibarr 11+) als auch im alten `<base>/<id-digits>/<id>/photos/` (`PRODUCT_USE_OLD_PATH_FOR_PHOTO=1`).
- Skaliert On-the-fly mit GD, Cache-Key = Hash aus mtime+size+targetPx → Bildaustausch invalidiert Cache automatisch.
- **Cache immer als PNG** (WebP wird von LibreOffice-PDF-Export bei `soffice --convert-to pdf` stillschweigend weggelassen).
- Transparenz wird auf konfigurierbaren Hintergrund (default `#ffffff`) geflattet, damit dunkle ODT-Hintergründe nicht durchscheinen.
- Dezenter Rahmen optional (`PRODUCTIMAGETAGS_IMAGE_BORDER_WIDTH/COLOR`).
- Platzhalter:
- Zeilen-Loop: `{line_product_image}` (echtes Bild), `{line_product_image_path}` (Pfad als Text), `{line_product_has_image}` (1/0).
- Detailseiten-Loop `[!-- BEGIN product_details --]`: `{pd_image_large}`, `{pd_image_1..4}`, `{pd_has_image_*}`, `{pd_ref}`, `{pd_label}`, `{pd_description}`, `{pd_price_ht/ttc}`, `{pd_qty}`, `{pd_weight}`, `{pd_dimensions}`, `{pd_position}`, `{pd_image_count}`.
- Admin-UI `/custom/productimagetags/admin/setup.php` — symlink-sicherer Loader nach DeliveryInvoiceAddress-Muster.
- Konfig-Settings (Admin):
- `PRODUCTIMAGETAGS_LINE_MAXPX` (Default 300) — Bild-Langseite in Pixeln für Zeile.
- `PRODUCTIMAGETAGS_LINE_RATIO` (Default 1.0) — setImage-Ratio.
- `PRODUCTIMAGETAGS_LINE_MAXCM` (Default 2.5) — Obergrenze in cm; Ratio wird automatisch so reduziert, dass die Langseite <= MaxCm bleibt.
- `PRODUCTIMAGETAGS_IMAGE_BG_COLOR`, `PRODUCTIMAGETAGS_IMAGE_BORDER_WIDTH`, `PRODUCTIMAGETAGS_IMAGE_BORDER_COLOR`.
- Detailseite: `PRODUCTIMAGETAGS_DETAIL_COUNT`, `PRODUCTIMAGETAGS_DETAIL_SMALL_MAXPX`, `PRODUCTIMAGETAGS_DETAIL_LARGE_MAXPX`, `PRODUCTIMAGETAGS_DETAIL_SKIP_EMPTY`.
- `PRODUCTIMAGETAGS_CACHE_DAYS` + Button zum Cache-Leeren.
- Deutsch + Englisch Lang-Dateien, README, Template-Anleitung unter `doc/TEMPLATE_ANLEITUNG.md`.
### Getestete Dokumenttypen
- Angebot (Propal) — **funktioniert**, Bild erscheint im PDF.
- Auftrag (Commande) — technisch baugleich, nicht im Testlauf durchexerziert.
- Rechnung/Lieferschein — laufen technisch mit.
### Wichtige Erkenntnisse aus der Integration
- **setImage in beforeODTSave** ist der richtige Einbau-Weg, exakt wie EPCQR es für QR-Codes macht. afterODTCreation ist bei `MAIN_ODT_AS_PDF` zu spät (LibreOffice hat das PDF schon, bevor der Hook läuft).
- Dolibarr-Core reicht im `ODTSubstitutionLine`-Hook den Segment `$listlines` **nicht** in `$parameters` durch — daher Marker + Post-Hook-Ersetzung statt direktem Segment-setImage.
- `$odfHandler->setImage()` löst den Platzhalter über `Odf::setVars()` auf, welches `strpos($contentXml, '{key}')` prüft. Silent fail wenn der Tag nicht gefunden wird (z.B. durch `<text:span>`-Aufsplittung bei Formatierung).
- **WebP + LibreOffice PDF-Export = Bild wird still ignoriert.** Deshalb immer PNG-Cache.
- Action-Klasse-Bug: wenn `beforeODTSave` nach `$odf->setSegment('product_details')` eine Exception fängt und `return 0` macht, läuft `callSetImageForAllMarkers()` dahinter nicht — Zeilen-Bilder fehlen dann im PDF obwohl der Marker gesetzt wurde.
- NixOS: `soffice` muss im PATH des httpd-Service liegen (`systemd.services.httpd.path = [ pkgs.libreoffice ];`), sonst `retval=127` beim ODT→PDF-Export.
### Template-Vorlagen-Tipps (nicht Code)
- Schmale Bildspalte (3 cm) ganz links, vertikal oben, Absatz **zentriert** (Strg+E).
- Min-row-height nicht setzen — Zeilen ohne Bild sollen sich kompakt an den Inhalt anpassen.
- Bild-Hintergrund-Kachel optional: `#F8F8F8` (248,248,248) in der Bildzelle.
### Bekannte offene Punkte
- Detailseiten-Loop `[!-- BEGIN product_details --]` ist code-seitig fertig, aber in den Test-Templates noch nicht integriert.
- Kein Git-Repo / Remote — Stand ist lokal im SMB-Ordner.
- KB-Eintrag #434 „Bild in ODT-Vorlage einbauen" ist überholt: beschriebt Reflection-Workarounds als primären Weg — in der Praxis funktioniert setImage zuverlässig.

2
COPYING Normal file
View file

@ -0,0 +1,2 @@
This module is licensed under GNU General Public License v3 or later.
See https://www.gnu.org/licenses/gpl-3.0.html for the full license text.

70
README.md Normal file
View file

@ -0,0 +1,70 @@
# ProductImageTags
Dolibarr-Modul, das **Produktbilder in Angebots- und Auftrags-ODTs** einbindet — damit der Kunde mit den Augen kauft.
## Funktionen
- **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)
- 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
## Installation
1. Repo nach `htdocs/custom/productimagetags/` kopieren oder symlinken
2. Home → Setup → Module → **ProductImageTags** aktivieren
3. Einstellungen unter _Setup_ anpassen (Bildgrößen, Cache)
4. ODT-Template anpassen (siehe [doc/TEMPLATE_ANLEITUNG.md](doc/TEMPLATE_ANLEITUNG.md))
## Verfügbare Platzhalter
### Innerhalb `[!-- BEGIN lines --]`
| Platzhalter | Bedeutung |
|---|---|
| `{line_product_image}` | **Echtes Bild** (draw:frame), wird pro Zeile automatisch eingefügt. Im Template nicht als Dummy-Bild setzen — als Text-Platzhalter lassen. Das Bild erscheint an der Stelle des Platzhalters in der Größe des Originals × `PRODUCTIMAGETAGS_LINE_RATIO`. |
| `{line_product_image_path}` | Absoluter Dateisystem-Pfad zum skalierten Hauptbild (als Text, falls du den Pfad irgendwo brauchst) |
| `{line_product_has_image}` | `1` wenn ein Bild existiert, sonst `0` (für IF-Blöcke) |
Das Zeilen-Bild nutzt einen **Marker+Post-Processing-Workaround**, weil Dolibarrs odfphp-Library Segment-Bilder beim `mergeSegment` nicht sauber zum Odf-Handler propagiert. Wir ersetzen den Marker im `content.xml` per Reflection nach dem Core-Merge durch ein echtes `<draw:frame>` und registrieren das Bild direkt am Odf-Handler — funktioniert ohne Core-Patch.
### Innerhalb `[!-- BEGIN product_details --]` (eigener Loop)
| Platzhalter | Bedeutung |
|---|---|
| `{pd_position}` | Laufende Nummer auf der Detailseite |
| `{pd_ref}` | Produkt-Referenz |
| `{pd_label}` | Produkt-Bezeichnung |
| `{pd_description}` | Beschreibung (HTML-getrimmt) |
| `{pd_price_ht}` / `{pd_price_ttc}` | Preise formatiert |
| `{pd_qty}` | Menge aus der Angebots-/Auftragszeile |
| `{pd_weight}` | Gewicht + Einheit |
| `{pd_dimensions}` | L × B × H mit Einheit |
| `{pd_image_count}` | Anzahl der tatsächlich vorhandenen Bilder |
| `{pd_image_large}` | Großes Hauptbild (Dummy-Frame im Template) |
| `{pd_image_1}``{pd_image_4}` | Bis zu 4 kleine Bilder |
| `{pd_has_image_1}``{pd_has_image_4}` | `1`/`0` Flags für IF-Blöcke |
## Konfiguration
- `PRODUCTIMAGETAGS_LINE_MAXPX` — Langseite Zeilen-Bild (Default 300)
- `PRODUCTIMAGETAGS_LINE_RATIO` — setImage-Ratio Zeilen-Bild (Default 1.0)
- `PRODUCTIMAGETAGS_DETAIL_COUNT` — Max. Anzahl kleiner Detailbilder (Default 4)
- `PRODUCTIMAGETAGS_DETAIL_SMALL_MAXPX` — Langseite kleine Bilder (Default 600)
- `PRODUCTIMAGETAGS_DETAIL_LARGE_MAXPX` — Langseite großes Bild (Default 1200)
- `PRODUCTIMAGETAGS_DETAIL_SKIP_EMPTY` — Produkte ohne Bilder im Loop überspringen (Default 1)
- `PRODUCTIMAGETAGS_CACHE_DAYS` — Cache-Lebensdauer Tage (Default 30)
## 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`, …
## Bekannte Limitationen
- **PDF-Skalierung**: LibreOffice kann bei ODT→PDF-Konvertierung dünne Bildkanten wegglätten. Wir skalieren Originale, sodass das normalerweise kein Problem ist.
- **Anker des Zeilen-Bildes**: aktuell fest `as-char` (wird als Zeichen in den Text eingebettet). Damit fließt das Bild mit der Zellhöhe mit. Für andere Anker-Typen wäre ein Template-eigener Dummy-Frame nötig — kommt bei Bedarf nach.
## Lizenz
GPL v3+ (siehe COPYING)

3
about.html Normal file
View file

@ -0,0 +1,3 @@
<h1>ProductImageTags</h1>
<p>Produktbilder in Angebots- und Auftrags-ODT-Vorlagen einbinden.</p>
<p>Copyright © 2026 Eduard Wisch &lt;data@data-it-solution.de&gt; — GPL v3+</p>

126
admin/setup.php Normal file
View file

@ -0,0 +1,126 @@
<?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 admin/setup.php
* \ingroup productimagetags
* \brief Admin-Seite für ProductImageTags: Bildgrößen, Cache, Verhalten.
*/
// Dolibarr-Env laden (Standard-Loader, symlink-sicher)
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; }
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
require_once __DIR__.'/../class/productimage.class.php';
global $langs, $user, $conf;
$langs->loadLangs(array("admin", "productimagetags@productimagetags"));
if (!$user->admin) accessforbidden();
$action = GETPOST('action', 'aZ09');
// Einstellbare Konstanten
$constants = array(
'PRODUCTIMAGETAGS_LINE_MAXPX' => array('label' => 'LineImageMaxPx', 'default' => '300', 'help' => 'LineImageMaxPxHelp'),
'PRODUCTIMAGETAGS_LINE_RATIO' => array('label' => 'LineImageRatio', 'default' => '1.0', 'help' => 'LineImageRatioHelp'),
'PRODUCTIMAGETAGS_LINE_MAXCM' => array('label' => 'LineImageMaxCm', 'default' => '2.5', 'help' => 'LineImageMaxCmHelp'),
'PRODUCTIMAGETAGS_IMAGE_BG_COLOR' => array('label' => 'ImageBgColor', 'default' => '#ffffff', 'help' => 'ImageBgColorHelp'),
'PRODUCTIMAGETAGS_IMAGE_BORDER_WIDTH' => array('label' => 'ImageBorderWidth', 'default' => '1', 'help' => 'ImageBorderWidthHelp'),
'PRODUCTIMAGETAGS_IMAGE_BORDER_COLOR' => array('label' => 'ImageBorderColor', 'default' => '#cccccc', 'help' => 'ImageBorderColorHelp'),
'PRODUCTIMAGETAGS_DETAIL_COUNT' => array('label' => 'DetailImageCount', 'default' => '4', 'help' => 'DetailImageCountHelp'),
'PRODUCTIMAGETAGS_DETAIL_SMALL_MAXPX' => array('label' => 'DetailSmallMaxPx', 'default' => '600', 'help' => 'DetailSmallMaxPxHelp'),
'PRODUCTIMAGETAGS_DETAIL_LARGE_MAXPX' => array('label' => 'DetailLargeMaxPx', 'default' => '1200', 'help' => 'DetailLargeMaxPxHelp'),
'PRODUCTIMAGETAGS_DETAIL_SKIP_EMPTY' => array('label' => 'DetailSkipEmpty', 'default' => '1', 'help' => 'DetailSkipEmptyHelp'),
'PRODUCTIMAGETAGS_CACHE_DAYS' => array('label' => 'CacheDays', 'default' => '30', 'help' => 'CacheDaysHelp'),
);
// Speichern
if ($action === 'save') {
foreach (array_keys($constants) as $key) {
$value = GETPOST($key, 'alphanohtml');
dolibarr_set_const($db, $key, $value, 'chaine', 0, '', $conf->entity);
}
setEventMessages($langs->trans('SettingsSaved'), null, 'mesgs');
header("Location: ".$_SERVER['PHP_SELF']);
exit;
}
// Cache leeren
if ($action === 'clearcache') {
$imageHelper = new ProductImage($db);
$days = (int) GETPOST('days', 'int');
$deleted = $imageHelper->cleanCache($days >= 0 ? $days : 0);
setEventMessages($langs->trans('CacheClearedCount', $deleted), null, 'mesgs');
header("Location: ".$_SERVER['PHP_SELF']);
exit;
}
llxHeader('', $langs->trans('ProductImageTagsSetup'));
print load_fiche_titre($langs->trans('ProductImageTagsSetup'), '', 'fa-image');
// Einstellungen
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="save">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><td>'.$langs->trans('Parameter').'</td><td>'.$langs->trans('Value').'</td><td>'.$langs->trans('Description').'</td></tr>';
foreach ($constants as $key => $meta) {
$current = getDolGlobalString($key, $meta['default']);
print '<tr class="oddeven">';
print '<td>'.$langs->trans($meta['label']).'</td>';
print '<td><input type="text" name="'.$key.'" value="'.dol_escape_htmltag($current).'" size="12"></td>';
print '<td class="opacitymedium">'.$langs->trans($meta['help']).'</td>';
print '</tr>';
}
print '</table>';
print '<br><div class="center"><input type="submit" class="button" value="'.$langs->trans('Save').'"></div>';
print '</form>';
// Cache-Info + Clean-Button
print '<br><br>';
$imageHelper = new ProductImage($db);
$cacheDir = $imageHelper->getCacheDir();
$fileCount = is_dir($cacheDir) ? count(array_diff(scandir($cacheDir), array('.', '..'))) : 0;
print load_fiche_titre($langs->trans('Cache'), '', '');
print '<div>'.$langs->trans('CacheDir').': <code>'.dol_escape_htmltag($cacheDir).'</code></div>';
print '<div>'.$langs->trans('CachedFiles').': '.$fileCount.'</div>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="clearcache">';
print '<div class="center" style="margin-top:10px;">';
print $langs->trans('CacheClearOlderThanDays').' <input type="number" name="days" value="'.(int) getDolGlobalString('PRODUCTIMAGETAGS_CACHE_DAYS', 30).'" min="0" size="4"> ';
print '<input type="submit" class="button" value="'.$langs->trans('ClearCache').'">';
print '</div>';
print '</form>';
llxFooter();
$db->close();

View file

@ -0,0 +1,482 @@
<?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 class/actions_productimagetags.class.php
* \ingroup productimagetags
* \brief Hook-Klasse für Produktbilder in ODT-Dokumenten.
*
* Arbeitet nach dem EPCQR-Muster (siehe Epcqr/doc/BILDER_IN_ODT.md):
* 1. ODTSubstitutionLine setzt Text-Marker im Zeilen-Substitutionsarray
* 2. beforeODTSave befüllt das Detail-Segment mit Variablen + Marker
* 3. afterODTCreation entpackt die fertige ODT, ersetzt alle Marker durch
* draw:frame-XML und pflegt Pictures/ + manifest.xml dann neu verpacken.
*
* Dies ist der einzige zuverlässige Weg, weil setImage() in Zeilen-Loops und
* beim nachgelagerten Core-Handling Probleme macht.
*/
require_once __DIR__.'/productimage.class.php';
require_once __DIR__.'/../lib/odtimages.lib.php';
class ActionsProductImageTags
{
/** @var DoliDB */
public $db;
/** @var string */
public $error = '';
/** @var string[] */
public $errors = array();
/**
* Marker-Puffer, der während ODTSubstitutionLine und beforeODTSave gefüllt
* und in afterODTCreation abgearbeitet wird.
*
* @var array<string,array{path:string,ratio:float,maxCm:float,anchor:string}>
*/
private $pendingMarkers = array();
/**
* @param DoliDB $db
*/
public function __construct($db)
{
$this->db = $db;
}
/* --------------------------------------------------------------------- */
/* Hook 1: pro Zeile */
/* --------------------------------------------------------------------- */
/**
* Setzt pro Produktzeile:
* - {line_product_image} eindeutiger Text-Marker (wird später durch Bild ersetzt)
* - {line_product_image_path} Dateipfad als Text
* - {line_product_has_image} "1"/"0"
*
* @param array $parameters
* @param CommonObject $object
* @param string $action
* @param HookManager $hookmanager
* @return int 0
*/
public function ODTSubstitutionLine($parameters, &$object, &$action, $hookmanager)
{
if (!isModEnabled('productimagetags')) return 0;
dol_syslog('PIT: ODTSubstitutionLine fk_product='.(isset($parameters['line']->fk_product) ? $parameters['line']->fk_product : 'none'), LOG_INFO);
$line = $parameters['line'] ?? null;
$path = '';
$marker = '';
if (!empty($line) && !empty($line->fk_product)) {
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
$product = new Product($this->db);
if ($product->fetch((int) $line->fk_product) > 0) {
$helper = new ProductImage($this->db);
$targetPx = (int) getDolGlobalString('PRODUCTIMAGETAGS_LINE_MAXPX', 300);
$resolved = $helper->getPrimaryImage($product, $targetPx);
dol_syslog('PIT: product='.$product->ref.' image='.($resolved ?: 'NULL'), LOG_INFO);
if ($resolved) {
$path = $resolved;
$marker = $this->registerMarker(
$resolved,
(float) getDolGlobalString('PRODUCTIMAGETAGS_LINE_RATIO', 1.0),
(float) getDolGlobalString('PRODUCTIMAGETAGS_LINE_MAXCM', 2.5),
'as-char'
);
}
}
}
if (isset($parameters['substitutionarray'])) {
$parameters['substitutionarray']['line_product_image'] = $marker;
$parameters['substitutionarray']['line_product_image_path'] = $path;
$parameters['substitutionarray']['line_product_has_image'] = $path ? '1' : '0';
}
return 0;
}
/* --------------------------------------------------------------------- */
/* Hook 2: Detailseiten-Segment */
/* --------------------------------------------------------------------- */
/**
* Füllt [!-- BEGIN product_details --] mit Variablen + Bild-Markern
* (Bilder werden erst in afterODTCreation eingefügt).
*
* @param array $parameters
* @param CommonObject $object
* @param string $action
* @return int 0
*/
public function beforeODTSave($parameters, &$object, &$action)
{
if (!isModEnabled('productimagetags')) return 0;
if (empty($parameters['odfHandler'])) return 0;
$odf = $parameters['odfHandler'];
$invoice = $parameters['object'] ?? $object;
if (empty($invoice) || empty($invoice->lines)) return 0;
$segment = null;
try {
$segment = $odf->setSegment('product_details');
} catch (Exception $e) {
// Template hat kein product_details-Segment → Detailseite überspringen,
// aber setImage für Zeilen-Marker muss trotzdem laufen (siehe Ende Methode).
dol_syslog('PIT: kein product_details-Segment im Template, nur Zeilen-Bilder.', LOG_INFO);
}
if ($segment !== null) {
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/price.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/functions.lib.php';
$helper = new ProductImage($this->db);
$maxImg = max(0, (int) getDolGlobalString('PRODUCTIMAGETAGS_DETAIL_COUNT', 4));
$smallMaxPx = max(100, (int) getDolGlobalString('PRODUCTIMAGETAGS_DETAIL_SMALL_MAXPX', 600));
$largeMaxPx = max(100, (int) getDolGlobalString('PRODUCTIMAGETAGS_DETAIL_LARGE_MAXPX', 1200));
$skipEmpty = (int) getDolGlobalString('PRODUCTIMAGETAGS_DETAIL_SKIP_EMPTY', 1) ? true : false;
$largeMaxCm = (float) getDolGlobalString('PRODUCTIMAGETAGS_DETAIL_LARGE_MAXCM', 12.0);
$smallMaxCm = (float) getDolGlobalString('PRODUCTIMAGETAGS_DETAIL_SMALL_MAXCM', 6.0);
$pos = 0;
foreach ($invoice->lines as $line) {
if (empty($line->fk_product)) continue;
$product = new Product($this->db);
if ($product->fetch((int) $line->fk_product) <= 0) continue;
$images = $helper->getImages($product, $maxImg, $smallMaxPx);
if ($skipEmpty && empty($images)) continue;
$pos++;
$vars = array(
'pd_position' => (string) $pos,
'pd_ref' => (string) $product->ref,
'pd_label' => (string) $product->label,
'pd_description' => dol_string_nohtmltag((string) $product->description, 1),
'pd_price_ht' => price((float) $product->price),
'pd_price_ttc' => price((float) $product->price_ttc),
'pd_qty' => (string) $line->qty,
'pd_weight' => !empty($product->weight) ? trim($product->weight.' '.$this->weightUnitLabel($product->weight_units ?? null)) : '',
'pd_dimensions' => $this->formatDimensions($product),
'pd_image_count' => (string) count($images),
);
foreach ($vars as $k => $v) $this->trySetVar($segment, $k, $v);
// Has-Image-Flags
for ($i = 1; $i <= $maxImg; $i++) {
$this->trySetVar($segment, 'pd_has_image_'.$i, isset($images[$i - 1]) ? '1' : '0');
}
// Großes Hauptbild als Marker
$largePath = $helper->getPrimaryImage($product, $largeMaxPx);
$this->trySetVar(
$segment,
'pd_image_large',
$largePath ? $this->registerMarker($largePath, 1.0, $largeMaxCm, 'as-char') : ''
);
// Kleine Bilder als Marker
for ($i = 1; $i <= $maxImg; $i++) {
$value = '';
if (isset($images[$i - 1])) {
$value = $this->registerMarker($images[$i - 1], 1.0, $smallMaxCm, 'as-char');
}
$this->trySetVar($segment, 'pd_image_'.$i, $value);
}
try { $segment->merge(); } catch (Exception $e) { /* ok */ }
}
try { $odf->mergeSegment($segment); } catch (Exception $e) {
dol_syslog('ProductImageTags: mergeSegment failed: '.$e->getMessage(), LOG_INFO);
}
} // Ende if ($segment !== null)
// WICHTIG: Bei MAIN_ODT_AS_PDF läuft exportAsAttachedPDF direkt nach
// diesem Hook. Deshalb ersetzen wir die Marker JETZT per offizieller
// Odf::setImage-API — genau wie EPCQR es für {qrcode} macht.
$this->callSetImageForAllMarkers($odf);
return 0;
}
/**
* Ruft für jeden registrierten Marker $odf->setImage($key, $path, $ratio) auf.
*
* odfphp's offizielle setImage-API:
* - registriert das Bild in $odf->images ( Pictures/ + manifest.xml)
* - setzt vars[{$key}] = draw:frame-XML ( content.xml beim saveToDisk)
*
* @param object $odf Odf-Handler
* @return void
*/
private function callSetImageForAllMarkers($odf): void
{
if (empty($this->pendingMarkers)) return;
$ok = 0;
$err = 0;
foreach ($this->pendingMarkers as $marker => $info) {
$key = $info['key'] ?? '';
$path = $info['path'] ?? '';
if (empty($key) || empty($path) || !file_exists($path)) { $err++; continue; }
// Effektive Ratio ermitteln. Wenn maxCm gesetzt ist, rechnen wir die
// Ratio so, dass die Langseite maxCm nicht überschreitet. Das ist
// benutzerfreundlicher als mit Ratio-Dezimalwerten zu hantieren.
$ratio = (float)($info['ratio'] ?? 1.0);
$maxCm = (float)($info['maxCm'] ?? 0.0);
if ($maxCm > 0 && file_exists($path)) {
$size = @getimagesize($path);
if ($size !== false) {
$pxLong = max($size[0], $size[1]);
if ($pxLong > 0) {
// Odf::PIXEL_TO_CM (≈ 0.026) * pxLong = cm bei ratio 1.0
$px2cm = defined('Odf::PIXEL_TO_CM') ? Odf::PIXEL_TO_CM : 0.026;
$cmAtRatio1 = $pxLong * $px2cm;
$ratio = $cmAtRatio1 > 0 ? min($ratio, $maxCm / $cmAtRatio1) : $ratio;
}
}
}
try {
$odf->setImage($key, $path, $ratio);
$ok++;
} catch (Exception $e) {
dol_syslog('PIT: setImage fail key='.$key.' err='.$e->getMessage(), LOG_INFO);
$err++;
}
}
dol_syslog('PIT: setImage ok='.$ok.' err='.$err.' total='.count($this->pendingMarkers), LOG_INFO);
}
/**
* Ersetzt die registrierten Marker direkt im contentXml des Odf-Handlers
* (vor saveToDisk / exportAsAttachedPDF) und registriert die Bilder in
* $odf->images, damit saveToDisk sie in Pictures/ schreibt + manifest.xml
* aktualisiert.
*
* @param object $odf Odf-Handler
* @return void
*/
private function replaceMarkersInOdfHandler($odf): void
{
if (empty($this->pendingMarkers)) return;
try {
$ref = new ReflectionClass($odf);
$cProp = $ref->getProperty('contentXml'); $cProp->setAccessible(true);
$iProp = $ref->getProperty('images'); $iProp->setAccessible(true);
$content = $cProp->getValue($odf);
$images = $iProp->getValue($odf);
if (!is_array($images)) $images = array();
$replaced = 0;
foreach ($this->pendingMarkers as $marker => $info) {
if (strpos($content, $marker) === false) continue;
$path = $info['path'] ?? '';
if (empty($path) || !file_exists($path)) {
$content = str_replace($marker, '', $content);
continue;
}
$frameXml = $this->buildDrawFrameXml($marker, $info);
if ($frameXml === null) {
$content = str_replace($marker, '', $content);
continue;
}
$content = str_replace($marker, $frameXml, $content);
// Bild-Registrierung im Odf-Handler (saveToDisk schreibt dann Pictures/ + manifest)
$images[$path] = basename($path);
$replaced++;
}
if ($replaced > 0) {
$cProp->setValue($odf, $content);
$iProp->setValue($odf, $images);
dol_syslog('PIT: replaceMarkersInOdfHandler '.$replaced.' Marker ersetzt', LOG_INFO);
}
} catch (Exception $e) {
dol_syslog('PIT: replaceMarkersInOdfHandler failed: '.$e->getMessage(), LOG_INFO);
}
}
/**
* Baut ein draw:frame-XML (cm-Maße bei 96 DPI, maxCm-Cap).
*
* @param string $marker Marker-Text (wird in draw:name genutzt für Eindeutigkeit)
* @param array $info ['path','ratio','maxCm','anchor']
* @return string|null
*/
private function buildDrawFrameXml(string $marker, array $info)
{
$path = $info['path'];
$size = @getimagesize($path);
if ($size === false) return null;
list($wPx, $hPx) = $size;
$ratio = isset($info['ratio']) ? (float) $info['ratio'] : 1.0;
$maxCm = isset($info['maxCm']) ? (float) $info['maxCm'] : 0.0;
$anchor = $info['anchor'] ?? 'as-char';
$wCm = round(($wPx / 96) * 2.54 * $ratio, 3);
$hCm = round(($hPx / 96) * 2.54 * $ratio, 3);
if ($maxCm > 0 && ($wCm > $maxCm || $hCm > $maxCm)) {
$scale = min($maxCm / max($wCm, 0.001), $maxCm / max($hCm, 0.001));
$wCm = round($wCm * $scale, 3);
$hCm = round($hCm * $scale, 3);
}
$drawName = 'pit_'.substr(preg_replace('/[^a-zA-Z0-9]/', '', $marker), 0, 24);
$pic = basename($path);
return '<draw:frame draw:style-name="fr1" draw:name="'.$drawName.'" '
.'text:anchor-type="'.htmlspecialchars($anchor, ENT_XML1 | ENT_QUOTES).'" '
.'svg:width="'.$wCm.'cm" svg:height="'.$hCm.'cm" draw:z-index="3">'
.'<draw:image xlink:href="Pictures/'.htmlspecialchars($pic, ENT_XML1 | ENT_QUOTES).'" '
.'xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>'
.'</draw:frame>';
}
/* --------------------------------------------------------------------- */
/* Hook 3: Nach ODT-Erstellung — Bilder per ZIP einsetzen */
/* --------------------------------------------------------------------- */
/**
* Ersetzt alle registrierten Marker in der fertigen ODT-Datei durch
* draw:frame-Elemente (ZIP-Manipulation nach EPCQR-Muster).
*
* @param array $parameters (muss 'file' enthalten)
* @param CommonObject $object
* @param string $action
* @return int 0
*/
public function afterODTCreation($parameters, &$object, &$action)
{
if (!isModEnabled('productimagetags')) return 0;
dol_syslog('PIT: afterODTCreation file='.($parameters['file'] ?? 'none').' markers='.count($this->pendingMarkers), LOG_INFO);
if (empty($parameters['file'])) return 0;
$file = $parameters['file'];
if (pathinfo($file, PATHINFO_EXTENSION) !== 'odt') return 0;
if (empty($this->pendingMarkers)) return 0;
$ok = productimagetags_odtReplaceMarkers($file, $this->pendingMarkers);
if ($ok) {
dol_syslog('ProductImageTags: '.count($this->pendingMarkers).' Marker ersetzt in '.$file, LOG_INFO);
} else {
dol_syslog('ProductImageTags: Marker-Ersetzung fehlgeschlagen für '.$file, LOG_INFO);
}
$this->pendingMarkers = array();
return 0;
}
/* --------------------------------------------------------------------- */
/* Helper */
/* --------------------------------------------------------------------- */
/**
* Legt einen neuen eindeutigen Platzhalter im odfphp-Format {_pit_img_xxx}
* an, puffert die Bilddaten und gibt den Marker als Text zurück.
*
* Der Marker kommt als Text in das substitutionarray und wird vom Core in
* content.xml eingesetzt. In beforeODTSave rufen wir $odfHandler->setImage()
* mit dem Key (ohne Klammern) auf odfphp's offizielle API ersetzt dann
* {_pit_img_xxx} durch draw:frame und registriert das Bild korrekt.
*
* @param string $path Absoluter Pfad (bereits skaliert)
* @param float $ratio Skalierungsfaktor
* @param float $maxCm Obergrenze Langseite in cm (0 = keine; wird bei
* setImage nicht direkt unterstützt greift nur beim
* ZIP-Fallback afterODTCreation)
* @param string $anchor Anker-Typ (derzeit nicht anpassbar odfphp::setImage
* nutzt fest as-char)
* @return string Marker-Text zum Einsetzen in das Template
*/
private function registerMarker(string $path, float $ratio, float $maxCm, string $anchor): string
{
$key = '_pit_img_'.str_replace('.', '', uniqid('', true));
$marker = '{'.$key.'}';
$this->pendingMarkers[$marker] = array(
'key' => $key,
'path' => $path,
'ratio' => $ratio,
'maxCm' => $maxCm,
'anchor' => $anchor,
);
return $marker;
}
/**
* setVars mit verschluckten Exceptions.
*
* @param object $segment
* @param string $key
* @param string $value
*/
private function trySetVar($segment, string $key, string $value): void
{
try {
$segment->setVars($key, $value, true, 'UTF-8');
} catch (Exception $e) {
// Platzhalter nicht im Template → ignorieren
}
}
/**
* L × B × H mit Einheit.
*
* @param Product $product
* @return string
*/
private function formatDimensions(Product $product): string
{
$parts = array();
if (!empty($product->length)) $parts[] = 'L '.$product->length;
if (!empty($product->width)) $parts[] = 'B '.$product->width;
if (!empty($product->height)) $parts[] = 'H '.$product->height;
if (empty($parts)) return '';
$unit = $this->lengthUnitLabel($product->length_units ?? null);
return implode(' × ', $parts).($unit !== '' ? ' '.$unit : '');
}
/**
* Wandelt Dolibarrs numerische Längeneinheit (0=m, -1=dm, -2=cm, -3=mm, )
* in eine lesbare Abkürzung um. Leerer String wenn unbekannt/leer.
*
* @param mixed $code Numerischer Code oder null
* @return string
*/
/**
* Wandelt Dolibarrs numerische Gewichtseinheit (0=kg, -3=g, -6=mg, 3=t)
* in eine lesbare Abkürzung um.
*
* @param mixed $code
* @return string
*/
private function weightUnitLabel($code): string
{
if ($code === null || $code === '') return '';
$map = array('3' => 't', '0' => 'kg', '-3' => 'g', '-6' => 'mg');
$key = (string)(int)$code;
return $map[$key] ?? '';
}
private function lengthUnitLabel($code): string
{
if ($code === null || $code === '') return '';
$map = array(
'3' => 'km', '0' => 'm', '-1' => 'dm', '-2' => 'cm',
'-3' => 'mm', '-6' => 'µm', '-9' => 'nm',
);
$key = (string)(int)$code;
return $map[$key] ?? '';
}
}

View file

@ -0,0 +1,307 @@
<?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 class/productimage.class.php
* \ingroup productimagetags
* \brief Produktbilder auflisten, skalieren und cachen.
*/
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';
/**
* Bild-Helper für Produkte. Liefert skalierte, gecachte Kopien der
* Produkt-Fotos (alphabetisch sortiert), damit ODT-Dokumente klein bleiben.
*/
class ProductImage
{
/** @var DoliDB */
public $db;
/** @var string Cache-Verzeichnis */
private $cacheDir;
/**
* @param DoliDB $db Datenbank-Handler
*/
public function __construct($db)
{
global $conf;
$this->db = $db;
$this->cacheDir = DOL_DATA_ROOT.($conf->entity > 1 ? '/'.$conf->entity : '').'/productimagetags/cache';
if (!is_dir($this->cacheDir)) {
dol_mkdir($this->cacheDir);
}
}
/**
* Liefert bis zu $max skalierte Bildpfade eines Produkts.
*
* @param Product $product Produkt-Objekt (muss bereits gefetcht sein)
* @param int $max Maximale Anzahl Bilder (0 = unbegrenzt)
* @param int $targetPx Langseite des Zielbildes in Pixel
* @return string[] Absolute Pfade zu skalierten Bildern (leeres Array wenn keine Bilder)
*/
public function getImages(Product $product, int $max = 4, int $targetPx = 800): array
{
$sources = $this->listSourceImages($product);
if (empty($sources)) {
return array();
}
if ($max > 0) {
$sources = array_slice($sources, 0, $max);
}
$result = array();
foreach ($sources as $src) {
$scaled = $this->getScaledCopy($product, $src, $targetPx);
if (!empty($scaled) && file_exists($scaled)) {
$result[] = $scaled;
}
}
return $result;
}
/**
* Liefert das erste (alphabetisch) Produktbild skaliert.
*
* @param Product $product Produkt
* @param int $targetPx Langseite des Zielbildes in Pixel
* @return string|null Pfad oder null wenn kein Bild vorhanden
*/
public function getPrimaryImage(Product $product, int $targetPx = 800)
{
$images = $this->getImages($product, 1, $targetPx);
return !empty($images) ? $images[0] : null;
}
/**
* Löscht Cache-Dateien älter als $days Tage.
*
* @param int $days Alter in Tagen
* @return int Anzahl gelöschter Dateien
*/
public function cleanCache(int $days = 30): int
{
if (!is_dir($this->cacheDir)) {
return 0;
}
$threshold = time() - ($days * 86400);
$deleted = 0;
foreach (scandir($this->cacheDir) as $file) {
if ($file === '.' || $file === '..') continue;
$path = $this->cacheDir.'/'.$file;
if (is_file($path) && filemtime($path) < $threshold) {
if (@unlink($path)) $deleted++;
}
}
return $deleted;
}
/**
* Liefert das Cache-Verzeichnis (für Admin-UI).
*
* @return string
*/
public function getCacheDir(): string
{
return $this->cacheDir;
}
/* --------------------------------------------------------------------- */
/* Interne Helfer */
/* --------------------------------------------------------------------- */
/**
* Listet alle Originalbilder eines Produkts, alphabetisch sortiert.
*
* @param Product $product Produkt
* @return string[] Absolute Pfade zu den Originalen
*/
private function listSourceImages(Product $product): array
{
global $conf;
$entity = !empty($product->entity) ? $product->entity : $conf->entity;
$baseDir = isset($conf->product->multidir_output[$entity])
? $conf->product->multidir_output[$entity]
: $conf->product->dir_output;
if (empty($baseDir)) {
return array();
}
// Dolibarr kennt zwei Ordner-Layouts für Produktbilder:
// - NEU (Default): <base>/<ref>/<dateien> (ref-basiert, ohne /photos/)
// - ALT (PRODUCT_USE_OLD_PATH_FOR_PHOTO): <base>/<id-digits>/<id>/photos/
// Wir prüfen beides und nehmen den existierenden Ordner.
$candidates = array();
// Neu (Default, Dolibarr product.class.php:6593): <base> + get_exdir() + sanitize(ref)
// get_exdir() liefert bei Produkten bereits "<ref>/", wir dürfen ref NICHT nochmal anhängen.
$refSub = get_exdir(0, 0, 0, 0, $product, 'product');
$candidates[] = rtrim($baseDir, '/').'/'.trim($refSub, '/');
// Sicherheitsnetz: wenn get_exdir was anderes liefert, auch direkt sanitize(ref)
$candidates[] = rtrim($baseDir, '/').'/'.dol_sanitizeFileName($product->ref);
// Alt (PRODUCT_USE_OLD_PATH_FOR_PHOTO): <base>/<id-digits>/<id>/photos
$idSub = get_exdir($product->id, 2, 0, 0, $product, 'product');
$candidates[] = rtrim($baseDir, '/').'/'.rtrim($idSub, '/').'/photos';
$photoDir = null;
foreach ($candidates as $cand) {
$cand = preg_replace('#/+#', '/', $cand);
if (is_dir($cand)) {
$photoDir = $cand;
break;
}
}
if ($photoDir === null) {
return array();
}
// dol_dir_list sortiert stabil alphabetisch. Thumbnails (thumbs-Unterordner + _small/_mini-Suffixe) ausschließen.
$files = dol_dir_list($photoDir, 'files', 0, '', array('\.meta$', '_small\.', '_mini\.', '_preview'), 'name', SORT_ASC);
if (empty($files)) {
return array();
}
$result = array();
foreach ($files as $file) {
$fullPath = $file['fullname'];
if (image_format_supported($fullPath) > 0) {
$result[] = $fullPath;
}
}
return $result;
}
/**
* Liefert eine auf $targetPx Langseite skalierte Kopie des Quellbildes.
* Cache-Key über Quell-mtime+size+Zielgröße.
*
* @param Product $product Produkt (für Präfix im Dateinamen)
* @param string $srcPath Absoluter Pfad zum Original
* @param int $targetPx Langseite in Pixel
* @return string|null Pfad zur Cache-Datei (oder Original wenn Skalierung fehlschlägt)
*/
private function getScaledCopy(Product $product, string $srcPath, int $targetPx)
{
if (!file_exists($srcPath)) {
return null;
}
// Kein Resize wenn Ziel <= 0 (dann Original)
if ($targetPx <= 0) {
return $srcPath;
}
$srcExt = strtolower(pathinfo($srcPath, PATHINFO_EXTENSION));
if (!in_array($srcExt, array('jpg', 'jpeg', 'png', 'gif', 'webp'))) {
return $srcPath;
}
// Cache IMMER als PNG: LibreOffice-PDF-Export hat mit WebP Probleme
// (stillschweigend weggelassen). PNG ist für alle Viewer stabil.
$outExt = 'png';
$stat = @stat($srcPath);
$hashInput = $srcPath.'|'.($stat ? $stat['mtime'].'|'.$stat['size'] : '').'|'.$targetPx;
$hash = substr(md5($hashInput), 0, 12);
$cacheFile = $this->cacheDir.'/prod'.((int) $product->id).'_'.$hash.'.'.$outExt;
if (file_exists($cacheFile)) {
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;
}
// 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)));
}
// 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 $srcPath;
}
return $cacheFile;
}
/**
* Wandelt #rrggbb in [r, g, b] um. Fallback: [255, 255, 255].
*
* @param string $hex Hex-Farbcode
* @return int[]
*/
private function hexToRgb(string $hex): array
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
}
if (strlen($hex) !== 6 || !ctype_xdigit($hex)) return array(255, 255, 255);
return array(
hexdec(substr($hex, 0, 2)),
hexdec(substr($hex, 2, 2)),
hexdec(substr($hex, 4, 2)),
);
}
}

View file

@ -0,0 +1,161 @@
<?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.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
/**
* \defgroup productimagetags Module ProductImageTags
* \brief Produktbilder in Angebots-/Auftrags-ODTs.
*
* \file htdocs/productimagetags/core/modules/modProductImageTags.class.php
* \ingroup productimagetags
*/
include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php';
/**
* Modul-Descriptor für ProductImageTags.
*
* Stellt ODT-Platzhalter für Produktbilder bereit:
* - Zeilen-Platzhalter {line_product_image} innerhalb [!-- BEGIN lines --]
* - Eigenes Segment [!-- BEGIN product_details --] für Detailseiten
*/
class modProductImageTags extends DolibarrModules
{
/**
* Constructor.
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
global $conf, $langs;
$this->db = $db;
// Eindeutige Modul-ID (Wiki-Reserve: https://wiki.dolibarr.org/index.php/List_of_modules_id)
$this->numero = 600001;
$this->rights_class = 'productimagetags';
$this->family = "products";
$this->module_position = '91';
$this->name = preg_replace('/^mod/i', '', get_class($this));
$this->description = "ProductImageTagsDescription";
$this->descriptionlong = "ProductImageTagsDescription";
$this->editor_name = 'Alles Watt läuft';
$this->editor_url = 'https://git.data-it-solution.de/data-it/productimagetags';
$this->editor_squarred_logo = '';
$this->version = '0.1.0';
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
$this->picto = 'fa-image';
$this->module_parts = array(
'triggers' => 0,
'login' => 0,
'substitutions' => 1,
'menus' => 0,
'tpl' => 0,
'barcode' => 0,
'models' => 0,
'printing' => 0,
'theme' => 0,
'css' => array(),
'js' => array(),
'hooks' => array(
'data' => array(
'odtgeneration',
),
),
'moduleforexternal' => 0,
'websitetemplates' => 0,
'captcha' => 0,
);
$this->dirs = array(
"/productimagetags/temp",
"/productimagetags/cache",
);
$this->config_page_url = array("setup.php@productimagetags");
$this->hidden = getDolGlobalInt('MODULE_PRODUCTIMAGETAGS_DISABLED');
$this->depends = array();
$this->requiredby = array();
$this->conflictwith = array();
$this->langfiles = array("productimagetags@productimagetags");
$this->phpmin = array(7, 4);
$this->need_dolibarr_version = array(19, -3);
$this->need_javascript_ajax = 0;
$this->warnings_activation = array();
$this->warnings_activation_ext = array();
// Default-Konstanten beim Aktivieren
$this->const = array(
0 => array('PRODUCTIMAGETAGS_LINE_MAXPX', 'chaine', '300', 'Zielauflösung Zeilen-Bild (Langseite px)', 0),
1 => array('PRODUCTIMAGETAGS_LINE_RATIO', 'chaine', '1.0', 'setImage-Ratio für Zeilen-Bild', 0),
10=> array('PRODUCTIMAGETAGS_LINE_MAXCM', 'chaine', '2.5', 'Max. Langseite des Zeilen-Bildes in cm (0=aus)', 0),
11=> array('PRODUCTIMAGETAGS_IMAGE_BG_COLOR', 'chaine', '#ffffff', 'Hintergrundfarbe (flattet Transparenz)', 0),
12=> array('PRODUCTIMAGETAGS_IMAGE_BORDER_WIDTH', 'chaine', '1', 'Rahmenbreite in Pixeln (0=kein Rahmen)', 0),
13=> array('PRODUCTIMAGETAGS_IMAGE_BORDER_COLOR', 'chaine', '#cccccc', 'Rahmenfarbe (hex)', 0),
2 => array('PRODUCTIMAGETAGS_DETAIL_COUNT', 'chaine', '4', 'Max. Anzahl kleiner Bilder pro Detailseite', 0),
3 => array('PRODUCTIMAGETAGS_DETAIL_SMALL_MAXPX', 'chaine', '600', 'Zielauflösung kleine Detailbilder (px)', 0),
4 => array('PRODUCTIMAGETAGS_DETAIL_LARGE_MAXPX', 'chaine', '1200', 'Zielauflösung großes Detailbild (px)', 0),
5 => array('PRODUCTIMAGETAGS_DETAIL_SKIP_EMPTY', 'chaine', '1', 'Produkte ohne Bilder im Detail-Loop überspringen', 0),
6 => array('PRODUCTIMAGETAGS_CACHE_DAYS', 'chaine', '30', 'Cache-Lebensdauer skalierter Bilder in Tagen', 0),
);
if (!isModEnabled("productimagetags")) {
$conf->productimagetags = new stdClass();
$conf->productimagetags->enabled = 0;
}
$this->tabs = array();
$this->dictionaries = array();
$this->boxes = array();
$this->cronjobs = array();
$this->rights = array();
$this->menu = array();
}
/**
* Aktivierung.
*
* @param string $options Aktivierungsoptionen
* @return int<-1,1> 1=ok, <=0=Fehler
*/
public function init($options = '')
{
$this->remove($options);
$sql = array();
return $this->_init($sql, $options);
}
/**
* Deaktivierung.
*
* @param string $options Deaktivierungsoptionen
* @return int<-1,1> 1=ok, <=0=Fehler
*/
public function remove($options = '')
{
$sql = array();
return $this->_remove($sql, $options);
}
}

View file

@ -0,0 +1,25 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de> */
/**
* \file core/substitutions/functions_productimagetags.lib.php
* \ingroup productimagetags
* \brief Stub für Dolibarr-Substitutions-Mechanik. Die eigentliche Arbeit
* erledigen die Hooks in class/actions_productimagetags.class.php.
*
* Diese Datei muss existieren, damit module_parts['substitutions'] = 1 funktioniert.
*/
/**
* Ergänzt das globale Substitutions-Array um ProductImageTags-Konstanten.
*
* @param array &$substitutionarray Referenz auf Substitutions-Array
* @param Translate $langs Sprach-Handler
* @param CommonObject|null $object Aktuelles Objekt (optional)
* @return void
*/
function productimagetags_completesubstitutionarray(&$substitutionarray, $langs, $object)
{
// Platz für globale Platzhalter (aktuell keiner nötig — Detail- und Zeilen-Platzhalter
// werden in den Hooks gesetzt, nicht hier).
}

130
doc/TEMPLATE_ANLEITUNG.md Normal file
View file

@ -0,0 +1,130 @@
# ODT-Template-Anleitung für ProductImageTags
Diese Anleitung beschreibt, wie du eine bestehende Angebots- oder Auftragsvorlage (ODT) so erweiterst, dass Produktbilder erscheinen.
## Vorbereitung
1. Basis-Template aus Dolibarr kopieren:
- Angebot: `install/doctemplates/proposals/template_proposal.odt`
- Auftrag: `install/doctemplates/orders/template_order.odt`
2. Kopie in LibreOffice Writer öffnen.
3. Neuen Namen geben, z.B. `template_proposal_bilder.odt`.
## Schritt 1 — Zeilen-Tabelle (Bild in jeder Produktzeile)
Füge in deiner Zeilen-Tabelle (im Bereich `[!-- BEGIN lines --]...[!-- END lines --]`) eine zusätzliche Spalte **„Bild"** ein und schreibe in diese Zelle schlicht den Text-Platzhalter:
```
{line_product_image}
```
**Wichtig:** _Kein_ Dummy-Bild einfügen. Einfach den Text so stehen lassen. Unser Modul ersetzt den Platzhalter nach dem Core-Merge durch ein echtes Bild. Die Bildgröße steuerst du über:
- `PRODUCTIMAGETAGS_LINE_MAXPX` (Admin) — Pixel-Langseite des Originals, default 300
- `PRODUCTIMAGETAGS_LINE_RATIO` (Admin) — cm-Skalierungsfaktor, default 1.0
Kombination aus beidem ergibt die sichtbare Bildgröße. Bei 300 px × ratio 1.0 landest du bei ca. 7.8 cm — das kannst du per Ratio auf z.B. 0.3 reduzieren (ca. 2.3 cm, passt in eine Zeile).
Weitere Platzhalter in den Zeilen (optional):
- `{line_product_image_path}` — Pfad zum skalierten Bild (als Text)
- `{line_product_has_image}``1` / `0` für IF-Blöcke
Beispiel für „Bild oder Strich anzeigen":
```
[!-- IF {line_product_has_image} --]
{line_product_image}
[!-- ELSE {line_product_has_image} --]
[!-- ENDIF {line_product_has_image} --]
```
## Schritt 2 — Detailseite anbauen
Nach der Summen-Tabelle einen **Seitenumbruch** einfügen und dann den Detailseiten-Loop definieren:
```
[!-- BEGIN product_details --]
{pd_position}. {pd_ref} — {pd_label}
[Hier Dummy-Bild einfügen, siehe Schritt 3]
Beschreibung:
{pd_description}
Preis: {pd_price_ht}
Menge: {pd_qty}
Maße: {pd_dimensions}
Gewicht: {pd_weight}
(Seitenumbruch vor dem END-Tag, damit jedes Produkt eine eigene Seite bekommt)
[!-- END product_details --]
```
Wichtig:
- Der BEGIN/END-Tag muss in einem eigenen Absatz stehen (nicht in einer Tabellenzelle).
- Seitenumbruch innerhalb des Blocks = neue Seite pro Produkt.
## Schritt 3 — Dummy-Bilder einsetzen (der entscheidende Schritt)
ODT kennt keine „Bildplatzhalter" als Text — stattdessen setzt man ein echtes Dummy-Bild ein und gibt ihm einen Namen, den unser Modul erkennt.
### Für das große Bild (`pd_image_large`)
1. Beliebiges Dummy-PNG einfügen (z.B. ein weißes 800×600-Bild)
2. Größe und Position einstellen, wie das echte Bild später erscheinen soll
3. **Rechtsklick auf das Bild → Eigenschaften → Tab „Optionen" → Name**: `pd_image_large` eintragen
4. Im Tab „Typ" den Anker auf **„Als Zeichen"** oder **„An Zeichen"** setzen (robustester Anker für setImage)
5. OK
### Für kleine Bilder (`pd_image_1` bis `pd_image_4`)
Analog, aber mit Namen `pd_image_1`, `pd_image_2` usw.
Beispiel-Layout 2×2-Grid:
```
+----------------+----------------+
| pd_image_1 | pd_image_2 |
+----------------+----------------+
| pd_image_3 | pd_image_4 |
+----------------+----------------+
```
Tipp: Grid am besten mit einer 2×2-Tabelle ohne Rahmen bauen und in jede Zelle ein Dummy-Bild stecken.
## Schritt 4 — Leere Slots ausblenden
Wenn ein Produkt weniger als 4 Bilder hat, möchtest du die leeren Slots nicht zeigen. Nutze dafür die Has-Image-Flags:
```
[!-- IF {pd_has_image_2} --]
[Hier Dummy-Bild pd_image_2 einfügen]
[!-- ELSE {pd_has_image_2} --]
[!-- ENDIF {pd_has_image_2} --]
```
## Schritt 5 — Template in Dolibarr einspielen
1. Datei speichern als `.odt`
2. In Dolibarr hochladen: **Setup → Modules → Angebote/Aufträge → Documents → Model ODT** → Template-Upload
3. Beim Generieren des ODT dieses Template auswählen
## Typische Fallstricke
- **Platzhalter wird als Text angezeigt, nicht ersetzt**: oft hat LibreOffice im Hintergrund zusätzliches Markup eingefügt. Lösung: Platzhalter markieren, `Strg+M` (alle Formatierungen entfernen), dann den Platzhalter neu schreiben.
- **Bild verändert sich nicht**: der Name im Bild-Eigenschaften-Dialog stimmt nicht exakt mit dem Platzhalter-Namen überein (Groß/Klein, Unterstriche).
- **Layout zerschossen im PDF**: Anker „An Zeichen" statt „An Absatz" verwenden.
- **Modul tut nichts**: nach Template-Änderung Modul kurz aus- und wieder einschalten (Dolibarr-Cache).
## Verifikation
Nach Deployment:
1. Ein Testprodukt mit 3 Bildern anlegen
2. Angebot mit diesem Produkt erstellen
3. ODT generieren, prüfen:
- Großes Bild = alphabetisch erstes Bild des Produkts
- 3 kleine Bilder befüllt, 4. Slot leer (wenn `pd_has_image_4`-IF genutzt)
4. PDF-Export via LibreOffice testen — keine abgeschnittenen Ränder
Bei Problemen: `PRODUCTIMAGETAGS_DETAIL_SKIP_EMPTY` = 0 setzen, dann werden alle Produkte gelistet (auch ohne Bilder) — hilft beim Debuggen.

View file

@ -0,0 +1,39 @@
# ProductImageTags — Deutsche Übersetzungen
ModuleProductImageTagsName=Produktbilder in ODT
ModuleProductImageTagsDesc=Stellt ODT-Platzhalter für Produktbilder in Angeboten und Aufträgen bereit.
ProductImageTagsDescription=Produktbilder in Angebots- und Auftrags-ODT-Vorlagen einbinden. Unterstützt einen Zeilen-Bildpfad-Platzhalter und einen eigenen Detailseiten-Loop.
ProductImageTagsSetup=Konfiguration Produktbilder-Tags
ProductImageTagsAbout=Über ProductImageTags
# Setup
LineImageMaxPx=Zeilen-Bild: Langseite (px)
LineImageMaxPxHelp=Bildgröße in Pixeln (Langseite) für Bilder in der Zeilen-Tabelle. Default 300.
LineImageRatio=Zeilen-Bild: Ratio
LineImageRatioHelp=setImage-Skalierungsfaktor. 1.0 = wie Original in cm gerechnet. Wird durch MaxCm noch begrenzt.
LineImageMaxCm=Zeilen-Bild: Max. Langseite (cm)
LineImageMaxCmHelp=Obergrenze der Bildgröße im Dokument in Zentimetern. Default 2.5. Auf 0 setzen um nur Ratio zu nutzen.
ImageBgColor=Hintergrundfarbe
ImageBgColorHelp=Hex-Farbe (z.B. #ffffff). Transparenz der Bilder wird mit dieser Farbe gefüllt, damit Dark-Theme-Hintergründe nicht durchscheinen.
ImageBorderWidth=Rahmenbreite (px)
ImageBorderWidthHelp=Breite des Rahmens um das Bild in Pixeln. 0 = kein Rahmen. Default 1.
ImageBorderColor=Rahmenfarbe
ImageBorderColorHelp=Hex-Farbe des Rahmens, z.B. #cccccc für hellgrau.
DetailImageCount=Detailseite: Max. Anzahl kleiner Bilder
DetailImageCountHelp=Wie viele kleine Bilder (pd_image_1..N) maximal pro Produkt. Default 4.
DetailSmallMaxPx=Detailseite: Langseite kleine Bilder (px)
DetailSmallMaxPxHelp=Bildgröße in Pixeln für die kleinen Detail-Bilder. Default 600.
DetailLargeMaxPx=Detailseite: Langseite großes Bild (px)
DetailLargeMaxPxHelp=Bildgröße in Pixeln für das große Haupt-Detail-Bild (pd_image_large). Default 1200.
DetailSkipEmpty=Produkte ohne Bilder überspringen
DetailSkipEmptyHelp=1 = Produkte ohne Fotos erscheinen nicht in der Detailseiten-Schleife. 0 = alle Produkte mit leeren Slots.
CacheDays=Cache-Lebensdauer (Tage)
CacheDaysHelp=Wie lange skalierte Bildkopien im Cache bleiben, bevor sie vom Aufräumen entfernt werden können.
# Cache-Seite
Cache=Bild-Cache
CacheDir=Cache-Verzeichnis
CachedFiles=Gecachte Dateien
CacheClearOlderThanDays=Dateien älter als (Tage) löschen:
ClearCache=Cache leeren
CacheClearedCount=%s Cache-Dateien gelöscht.
SettingsSaved=Einstellungen gespeichert.

View file

@ -0,0 +1,39 @@
# ProductImageTags — English translations
ModuleProductImageTagsName=Product Images in ODT
ModuleProductImageTagsDesc=Provides ODT placeholders for product images in proposals and orders.
ProductImageTagsDescription=Embed product images in proposal and order ODT templates. Supports a line image path placeholder and a dedicated product details loop.
ProductImageTagsSetup=Product Image Tags Setup
ProductImageTagsAbout=About ProductImageTags
# Setup
LineImageMaxPx=Line image: long edge (px)
LineImageMaxPxHelp=Image size (long edge in pixels) for images shown in the line table. Default 300.
LineImageRatio=Line image: ratio
LineImageRatioHelp=setImage scaling factor. 1.0 = original pixels-to-cm. Capped by MaxCm.
LineImageMaxCm=Line image: max long edge (cm)
LineImageMaxCmHelp=Upper bound for the image's long edge in the document, in centimeters. Default 2.5. Set to 0 to disable.
ImageBgColor=Background color
ImageBgColorHelp=Hex color (e.g. #ffffff). Transparency is flattened to this color so dark ODT backgrounds don't show through.
ImageBorderWidth=Border width (px)
ImageBorderWidthHelp=Border width around the image in pixels. 0 = no border. Default 1.
ImageBorderColor=Border color
ImageBorderColorHelp=Hex color of the border, e.g. #cccccc for light gray.
DetailImageCount=Details page: max small images
DetailImageCountHelp=How many small images (pd_image_1..N) per product at most. Default 4.
DetailSmallMaxPx=Details page: long edge small images (px)
DetailSmallMaxPxHelp=Image size (pixels) for small detail images. Default 600.
DetailLargeMaxPx=Details page: long edge large image (px)
DetailLargeMaxPxHelp=Image size (pixels) for the main large detail image (pd_image_large). Default 1200.
DetailSkipEmpty=Skip products without images
DetailSkipEmptyHelp=1 = products without photos don't show up in the details loop. 0 = all products, with empty slots.
CacheDays=Cache lifetime (days)
CacheDaysHelp=How long scaled image copies stay cached before cleanup can remove them.
# Cache page
Cache=Image cache
CacheDir=Cache directory
CachedFiles=Cached files
CacheClearOlderThanDays=Delete files older than (days):
ClearCache=Clear cache
CacheClearedCount=%s cache files deleted.
SettingsSaved=Settings saved.

215
lib/odtimages.lib.php Normal file
View file

@ -0,0 +1,215 @@
<?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/odtimages.lib.php
* \ingroup productimagetags
* \brief ZIP-basierte Bildintegration in ODT-Dateien (afterODTCreation).
*
* Angelehnt an EPCQR (doc/BILDER_IN_ODT.md + functions_epcqr.lib.php).
* Erweitert für N Marker mit einzelnen Pfaden + Größen, damit in einem
* Rutsch alle Zeilen- und Detailseiten-Bilder ersetzt werden können.
*/
/**
* Ersetzt eine Liste von Text-Markern im ODT (ZIP) durch draw:frame-Elemente,
* kopiert die Bilder in Pictures/ und aktualisiert manifest.xml.
*
* @param string $odfFilePath Absoluter Pfad zur ODT-Datei (wird in-place modifiziert)
* @param array $markers Liste der Ersetzungen:
* [
* 'marker_text' => [
* 'path' => '/absoluter/pfad/zum/bild.png',
* 'ratio' => 1.0, // optional, default 1.0
* 'maxCm' => 6.0, // optional, Max-Größe in cm
* 'anchor' => 'as-char', // optional, default as-char
* ],
* ...
* ]
* @return bool true bei Erfolg, false sonst
*/
function productimagetags_odtReplaceMarkers(string $odfFilePath, array $markers): bool
{
if (!file_exists($odfFilePath)) {
dol_syslog('ProductImageTags ODT: Datei fehlt: '.$odfFilePath, LOG_ERR);
return false;
}
if (empty($markers)) return true;
$tmpDir = sys_get_temp_dir().'/pit_odt_'.uniqid();
if (!@mkdir($tmpDir)) {
dol_syslog('ProductImageTags ODT: tmpDir-Fehler '.$tmpDir, LOG_ERR);
return false;
}
$success = false;
try {
// 1. ODT entpacken
$zip = new ZipArchive();
if ($zip->open($odfFilePath) !== true) {
dol_syslog('ProductImageTags ODT: ZIP-Öffnen fehlgeschlagen', LOG_ERR);
productimagetags_rmTree($tmpDir);
return false;
}
$zip->extractTo($tmpDir);
$zip->close();
// 2. content.xml + manifest.xml laden
$contentPath = $tmpDir.'/content.xml';
$manifestPath = $tmpDir.'/META-INF/manifest.xml';
if (!is_file($contentPath) || !is_file($manifestPath)) {
dol_syslog('ProductImageTags ODT: content.xml/manifest.xml fehlt', LOG_ERR);
productimagetags_rmTree($tmpDir);
return false;
}
$content = file_get_contents($contentPath);
$manifest = file_get_contents($manifestPath);
// 3. Pictures-Verzeichnis anlegen falls nicht vorhanden
$picturesDir = $tmpDir.'/Pictures';
if (!is_dir($picturesDir)) {
mkdir($picturesDir);
}
// 4. Marker durchgehen
$changed = false;
$addedManifest = array(); // Schutz vor doppeltem Manifest-Eintrag
foreach ($markers as $markerText => $info) {
if (strpos($content, $markerText) === false) continue;
$srcPath = $info['path'] ?? '';
if (empty($srcPath) || !file_exists($srcPath)) {
$content = str_replace($markerText, '', $content);
$changed = true;
continue;
}
$ratio = isset($info['ratio']) ? (float) $info['ratio'] : 1.0;
$maxCm = isset($info['maxCm']) ? (float) $info['maxCm'] : 0.0;
$anchor = $info['anchor'] ?? 'as-char';
// Bildgröße ermitteln
$imageInfo = @getimagesize($srcPath);
if ($imageInfo === false) {
dol_syslog('ProductImageTags ODT: getimagesize fehlgeschlagen für '.$srcPath, LOG_WARNING);
$content = str_replace($markerText, '', $content);
$changed = true;
continue;
}
list($wPx, $hPx) = $imageInfo;
// Pixel → cm (96 DPI, wie LibreOffice-Default)
$wCm = round(($wPx / 96) * 2.54 * $ratio, 3);
$hCm = round(($hPx / 96) * 2.54 * $ratio, 3);
// Max-Größe begrenzen (proportional)
if ($maxCm > 0 && ($wCm > $maxCm || $hCm > $maxCm)) {
$scale = min($maxCm / max($wCm, 0.001), $maxCm / max($hCm, 0.001));
$wCm = round($wCm * $scale, 3);
$hCm = round($hCm * $scale, 3);
}
// Bilddateiname eindeutig machen (verhindert Kollisionen bei mehreren Markern)
$ext = strtolower(pathinfo($srcPath, PATHINFO_EXTENSION)) ?: 'png';
$hash = substr(md5($markerText.'|'.$srcPath), 0, 10);
$picName = 'pit_'.$hash.'.'.$ext;
$destPath = $picturesDir.'/'.$picName;
if (!file_exists($destPath)) {
@copy($srcPath, $destPath);
}
// Manifest-Eintrag (falls noch nicht drin)
if (!isset($addedManifest[$picName]) && strpos($manifest, 'Pictures/'.$picName.'"') === false) {
$mimeType = $imageInfo['mime'] ?? ('image/'.$ext);
$entry = '<manifest:file-entry manifest:media-type="'.htmlspecialchars($mimeType, ENT_XML1 | ENT_QUOTES).'" '
.'manifest:full-path="Pictures/'.htmlspecialchars($picName, ENT_XML1 | ENT_QUOTES).'"/>';
$manifest = str_replace('</manifest:manifest>', $entry.'</manifest:manifest>', $manifest);
$addedManifest[$picName] = true;
}
// draw:frame-XML (eindeutiger Name pro Vorkommen — wichtig: nur ein Tag, weil Marker unique pro Vorkommen sein muss)
$drawName = 'pit_'.$hash;
$frameXml = '<draw:frame draw:style-name="fr1" draw:name="'.htmlspecialchars($drawName, ENT_XML1 | ENT_QUOTES).'" '
.'text:anchor-type="'.htmlspecialchars($anchor, ENT_XML1 | ENT_QUOTES).'" '
.'svg:width="'.$wCm.'cm" svg:height="'.$hCm.'cm" draw:z-index="3">'
.'<draw:image xlink:href="Pictures/'.htmlspecialchars($picName, ENT_XML1 | ENT_QUOTES).'" '
.'xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>'
.'</draw:frame>';
$content = str_replace($markerText, $frameXml, $content);
$changed = true;
}
if (!$changed) {
productimagetags_rmTree($tmpDir);
return true; // nichts zu tun, aber ok
}
// 5. Geänderte Dateien zurückschreiben
file_put_contents($contentPath, $content);
file_put_contents($manifestPath, $manifest);
// 6. ODT neu packen — mimetype MUSS zuerst und unkomprimiert sein (ODT-Spec)
$newZip = new ZipArchive();
if ($newZip->open($odfFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
dol_syslog('ProductImageTags ODT: Neu-ZIP-Anlegen fehlgeschlagen', LOG_ERR);
productimagetags_rmTree($tmpDir);
return false;
}
// mimetype zuerst, unkomprimiert
if (is_file($tmpDir.'/mimetype')) {
$newZip->addFile($tmpDir.'/mimetype', 'mimetype');
$newZip->setCompressionName('mimetype', ZipArchive::CM_STORE);
}
// Rest via RecursiveIterator, mimetype überspringen
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($files as $fobj) {
if (!$fobj->isFile()) continue;
$filePath = $fobj->getRealPath();
$relativePath = str_replace('\\', '/', substr($filePath, strlen($tmpDir) + 1));
if ($relativePath === 'mimetype') continue;
$newZip->addFile($filePath, $relativePath);
}
$newZip->close();
$success = true;
} catch (Exception $e) {
dol_syslog('ProductImageTags ODT: Exception '.$e->getMessage(), LOG_ERR);
}
productimagetags_rmTree($tmpDir);
return $success;
}
/**
* Hilfsfunktion: Verzeichnis rekursiv löschen.
*
* @param string $dir Verzeichnis
*/
function productimagetags_rmTree(string $dir): void
{
if (!is_dir($dir)) return;
$entries = @scandir($dir);
if ($entries === false) return;
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') continue;
$full = $dir.'/'.$entry;
if (is_dir($full)) {
productimagetags_rmTree($full);
} else {
@unlink($full);
}
}
@rmdir($dir);
}

1
modulebuilder.txt Normal file
View file

@ -0,0 +1 @@
productimagetags - Produktbilder in ODT-Vorlagen - 2026-04-24

39
productimagetagsindex.php Normal file
View file

@ -0,0 +1,39 @@
<?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 productimagetagsindex.php
* \ingroup productimagetags
* \brief Weiterleitung auf die Admin-Konfiguration des Moduls.
* Wird von Dolibarr erwartet, wenn das Modul ein Top-Menu hat
* oder aus dem Modul-Center angesprungen wird.
*/
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; }
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) $res = @include "../main.inc.php";
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Include of main fails");
header("Location: ".DOL_URL_ROOT.'/custom/productimagetags/admin/setup.php');
exit;

0
tags.txt Normal file
View file