From 8cd8ec58ab79e6889510dbf832e9e3e7643fa0dd Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Sun, 26 Apr 2026 17:41:36 +0200 Subject: [PATCH] =?UTF-8?q?Initiales=20Commit=20=E2=80=94=20ProductImageTa?= =?UTF-8?q?gs=20v0.1.0=20[deploy]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Produktbilder via Hook-System in ODT/PDF-Vorlagen einbetten. Vollständiges Modul mit Admin-UI, Cache, Mehrsprachigkeit (de/en). --- .forgejo/workflows/deploy.yml | 63 +++ .gitignore | 12 + CHANGELOG.md | 52 ++ COPYING | 2 + README.md | 70 +++ about.html | 3 + admin/setup.php | 126 +++++ class/actions_productimagetags.class.php | 482 ++++++++++++++++++ class/productimage.class.php | 307 +++++++++++ core/modules/modProductImageTags.class.php | 161 ++++++ .../functions_productimagetags.lib.php | 25 + doc/TEMPLATE_ANLEITUNG.md | 130 +++++ langs/de_DE/productimagetags.lang | 39 ++ langs/en_US/productimagetags.lang | 39 ++ lib/odtimages.lib.php | 215 ++++++++ modulebuilder.txt | 1 + productimagetagsindex.php | 39 ++ tags.txt | 0 18 files changed, 1766 insertions(+) create mode 100644 .forgejo/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 COPYING create mode 100644 README.md create mode 100644 about.html create mode 100644 admin/setup.php create mode 100644 class/actions_productimagetags.class.php create mode 100644 class/productimage.class.php create mode 100644 core/modules/modProductImageTags.class.php create mode 100644 core/substitutions/functions_productimagetags.lib.php create mode 100644 doc/TEMPLATE_ANLEITUNG.md create mode 100644 langs/de_DE/productimagetags.lang create mode 100644 langs/en_US/productimagetags.lang create mode 100644 lib/odtimages.lib.php create mode 100644 modulebuilder.txt create mode 100644 productimagetagsindex.php create mode 100644 tags.txt diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..c270ba1 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de65799 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Cache-Verzeichnis +/cache/ +*.cache + +# Temporäre Dateien +*.tmp +*.log + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aabbc9b --- /dev/null +++ b/CHANGELOG.md @@ -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_}` 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 `//` (Default seit Dolibarr 11+) als auch im alten `///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 ``-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. diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..f57ad6d --- /dev/null +++ b/COPYING @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3f4e3c --- /dev/null +++ b/README.md @@ -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 `` 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//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) diff --git a/about.html b/about.html new file mode 100644 index 0000000..5d3b369 --- /dev/null +++ b/about.html @@ -0,0 +1,3 @@ +

ProductImageTags

+

Produktbilder in Angebots- und Auftrags-ODT-Vorlagen einbinden.

+

Copyright © 2026 Eduard Wisch <data@data-it-solution.de> — GPL v3+

diff --git a/admin/setup.php b/admin/setup.php new file mode 100644 index 0000000..0476814 --- /dev/null +++ b/admin/setup.php @@ -0,0 +1,126 @@ + + * + * 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 '
'; +print ''; +print ''; + +print ''; +print ''; + +foreach ($constants as $key => $meta) { + $current = getDolGlobalString($key, $meta['default']); + print ''; + print ''; + print ''; + print ''; + print ''; +} +print '
'.$langs->trans('Parameter').''.$langs->trans('Value').''.$langs->trans('Description').'
'.$langs->trans($meta['label']).''.$langs->trans($meta['help']).'
'; +print '
'; +print '
'; + +// Cache-Info + Clean-Button +print '

'; +$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 '
'.$langs->trans('CacheDir').': '.dol_escape_htmltag($cacheDir).'
'; +print '
'.$langs->trans('CachedFiles').': '.$fileCount.'
'; + +print '
'; +print ''; +print ''; +print '
'; +print $langs->trans('CacheClearOlderThanDays').' '; +print ''; +print '
'; +print '
'; + +llxFooter(); +$db->close(); diff --git a/class/actions_productimagetags.class.php b/class/actions_productimagetags.class.php new file mode 100644 index 0000000..6f3abac --- /dev/null +++ b/class/actions_productimagetags.class.php @@ -0,0 +1,482 @@ + + * + * 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 + */ + 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 '' + .'' + .''; + } + + /* --------------------------------------------------------------------- */ + /* 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] ?? ''; + } +} diff --git a/class/productimage.class.php b/class/productimage.class.php new file mode 100644 index 0000000..f022f12 --- /dev/null +++ b/class/productimage.class.php @@ -0,0 +1,307 @@ + + * + * 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): // (ref-basiert, ohne /photos/) + // - ALT (PRODUCT_USE_OLD_PATH_FOR_PHOTO): ///photos/ + // Wir prüfen beides und nehmen den existierenden Ordner. + $candidates = array(); + // Neu (Default, Dolibarr product.class.php:6593): + get_exdir() + sanitize(ref) + // get_exdir() liefert bei Produkten bereits "/", 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): ///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)), + ); + } +} diff --git a/core/modules/modProductImageTags.class.php b/core/modules/modProductImageTags.class.php new file mode 100644 index 0000000..2c0a735 --- /dev/null +++ b/core/modules/modProductImageTags.class.php @@ -0,0 +1,161 @@ + + * + * 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); + } +} diff --git a/core/substitutions/functions_productimagetags.lib.php b/core/substitutions/functions_productimagetags.lib.php new file mode 100644 index 0000000..f08473d --- /dev/null +++ b/core/substitutions/functions_productimagetags.lib.php @@ -0,0 +1,25 @@ + */ + +/** + * \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). +} diff --git a/doc/TEMPLATE_ANLEITUNG.md b/doc/TEMPLATE_ANLEITUNG.md new file mode 100644 index 0000000..5a0f1f1 --- /dev/null +++ b/doc/TEMPLATE_ANLEITUNG.md @@ -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. diff --git a/langs/de_DE/productimagetags.lang b/langs/de_DE/productimagetags.lang new file mode 100644 index 0000000..f851a7e --- /dev/null +++ b/langs/de_DE/productimagetags.lang @@ -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. diff --git a/langs/en_US/productimagetags.lang b/langs/en_US/productimagetags.lang new file mode 100644 index 0000000..25e8a35 --- /dev/null +++ b/langs/en_US/productimagetags.lang @@ -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. diff --git a/lib/odtimages.lib.php b/lib/odtimages.lib.php new file mode 100644 index 0000000..ee9a66a --- /dev/null +++ b/lib/odtimages.lib.php @@ -0,0 +1,215 @@ + + * + * 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 = str_replace('', $entry.'', $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 = '' + .'' + .''; + + $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); +} diff --git a/modulebuilder.txt b/modulebuilder.txt new file mode 100644 index 0000000..8bbf391 --- /dev/null +++ b/modulebuilder.txt @@ -0,0 +1 @@ +productimagetags - Produktbilder in ODT-Vorlagen - 2026-04-24 diff --git a/productimagetagsindex.php b/productimagetagsindex.php new file mode 100644 index 0000000..22171a0 --- /dev/null +++ b/productimagetagsindex.php @@ -0,0 +1,39 @@ + + * + * 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; diff --git a/tags.txt b/tags.txt new file mode 100644 index 0000000..e69de29