Editor-Ueberarbeitung 1.5.0: Anhaenge-Kacheln, echte Seitengeometrie, Arbeiten ohne Reload [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 13s

Anhaenge-Spalte: Kachelraster mit Server-Thumbnails statt Dateinamen-Liste (Bilder per GD,
PDFs per PDF.js), Grossansicht per Klick, Auswahlreihenfolge sichtbar, Sortierung/Filter
(Aufnahmezeit per eigenem EXIF-Parser, da im Prod-Container ohne exif-Modul), ziehbare
Spaltenbreite, Kachelgroesse, Drag & Drop auf Seitenliste/Arbeitsflaeche/Raster-Plaetze.

Arbeitstisch zeigt jetzt Kopf-, Inhalts- und Fussbereich passend zur echten Seitengeometrie
(bericht_page_geometry()) - Editor und PDF-Erzeugung rechnen mit denselben Werten, das PDF
setzt die Arbeitsflaeche 1:1 statt sie ein zweites Mal einzupassen.

Neu: Bild einer Seite austauschen (auch einzelne Raster-Plaetze), Seiten mehrfach markieren
und sammeln loeschen/duplizieren, Autosave mit Verlassen-Warnung, Tastenkuerzel, kein
location.reload() mehr nach Hinzufuegen/Loeschen/Hochladen (ajax/fragments.php).

Nebenbei behoben: dol_dir_list() ohne mode=1 (Dateigroesse leer), PDF.js-Worker-Pfad ohne
/custom-Praefix, PDFs liefen als Download statt inline, Groessen-Select blieb leer,
Mehrfach-Upload nahm nur die erste Datei, Race-Condition bei schnellem Seitenwechsel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-08-22 00:33:20 +02:00
parent 63a23b4124
commit 4102ebfe5b
15 changed files with 3640 additions and 300 deletions

View file

@ -1,5 +1,123 @@
# Changelog
## 1.5.0 — 2026-08-21
### Anhänge-Spalte: Miniaturen statt Dateinamen
- Die linke Spalte im Editor ist jetzt ein **Kachelraster mit Vorschaubildern**. Bei Fotos mit
Namen wie `IMG_20260812_141233_HDR.jpg` war in der 260 px schmalen Spalte vorher nicht zu
erkennen, welches Bild gemeint ist.
- Neuer Endpoint **`ajax/attachment_thumb.php`**: erzeugt Miniaturen serverseitig mit GD,
legt sie unter `bericht/thumbs/` ab und liefert sie mit ETag aus. Die Originale (Handyfotos
mit mehreren MB) werden für die Spalte nicht mehr geladen.
EXIF-Ausrichtung wird berücksichtigt — mit eigenem Mini-Parser, weil das `exif`-Modul im
Prod-Container nicht geladen ist.
- **PDF-Anhänge** bekommen Seite 1 als Miniatur (PDF.js, erst beim Scrollen gerendert).
- **Klick auf eine Kachel öffnet die Großansicht** (`✕`/Esc schließt): Bilder mit Zoom, Pan und
Wischen, PDFs im PDF-Viewer des Browsers.
- Auswahl per Kachel-Checkbox, die **Reihenfolge ist sichtbar (① ② ③)** und bestimmt jetzt auch
die Platzverteilung im Raster-Layout (vorher die Reihenfolge im Dokument).
- Bereits verwendete Anhänge sind mit ✓ markiert, „alle"-Umschalter je Herkunft, Auswahlzähler,
Umschalter zwischen Kachel- und Listenansicht (wird gemerkt).
### Arbeitstisch zeigt die echte Seite
- Der Editor stellt jetzt **Blatt mit Kopfbereich, Seitenrändern und Fußbereich** dar; die
Arbeitsfläche ist exakt der Bereich, der gedruckt wird.
- Neue gemeinsame Geometrie-Quelle `bericht_page_geometry()` — Editor und PDF-Erzeugung
rechnen mit denselben Werten (Rand 10 mm, Kopf 30 mm, Fuß 16 mm, Notizzone 25 %).
- **Das PDF setzt die Arbeitsfläche 1:1 ein.** Vorher wurde das komplette Blatt noch einmal in
den Satzspiegel eingepasst, der Inhalt also ein zweites Mal verkleinert — daher der breite
Rand und der Unterschied zur Vorschau.
- Alle Layout-Zweige (Einzelbild, Raster, Vorher/Nachher, Notiz) nutzen dieselbe Geometrie.
Seiten ohne gespeichertes Composite liefen vorher über den Kopfbereich mit Logo und Titel.
- Umschalter „📐 Rand" blendet die Zonen aus.
- Ältere Composites (Blatt-Proportion) werden erkannt und proportional eingepasst statt verzerrt.
### Bild einer Seite austauschen
- Neu: **`ajax/replace_page_source.php`**. Über „🔄" auf der Seitenminiatur lässt sich das Bild
einer bestehenden Seite ersetzen — Reihenfolge, Notiz und Anmerkungen bleiben erhalten.
Bisher musste man die Seite löschen und neu anhängen, wodurch sie ans Ende rutschte.
- Bei Raster-Seiten ist der einzelne Platz austauschbar. Das dafür nötige `set_slot_image.php`
existierte bereits, hatte aber keine Bedienung.
- Finalisierte Berichte werden serverseitig abgelehnt.
### Arbeiten ohne ständiges Neuladen
- Hinzufügen, Löschen, Hochladen, Bildtausch und der QR-Upload luden bisher die **ganze Seite neu**.
Der Editor sprang dabei auf Seite 1 zurück und der Zoom war weg — bei 20 Fotos hintereinander
entsprechend zäh. Jetzt werden nur noch die betroffenen Listen aktualisiert.
- Neuer Endpoint **`ajax/fragments.php`** liefert Seiten- und Anhängeliste als HTML. Gerendert wird
über `bericht_print_page_list()` / `bericht_print_attachment_list()` — dieselben Funktionen, die
auch die Karte aufbauen, damit beide Darstellungen nicht auseinanderlaufen.
- Nach dem Finalisieren wird weiterhin neu geladen: der Bericht wechselt den Status.
### Automatisches Speichern
- Änderungen werden **4 Sekunden nach der letzten Aktion automatisch gespeichert**, außerdem beim
Seitenwechsel und beim Wegschalten des Browser-Tabs. Bisher gab es nur „💾 Entwurf speichern".
- In der Fußleiste steht der Stand („● nicht gespeichert" / „✓ gespeichert"), beim Verlassen der
Seite mit offenen Änderungen warnt der Browser.
### Sortieren und Filtern der Anhänge
- Sortierung nach **Aufnahmezeit** (auf/ab), Name oder Größe — bei Baustellenfotos ist die zeitliche
Reihenfolge meist genau die richtige. Filter: alle · nur Bilder · nur PDFs · noch nicht verwendet.
- Die Aufnahmezeit steht unter der Kachel. Ausgelesen wird sie mit einem **eigenen EXIF-Parser**,
weil das `exif`-Modul im Prod-Container nicht geladen ist; gegen das PHP-Modul geprüft, identische
Werte. Ohne EXIF gilt das Dateidatum.
### Bedienung: Tastenkürzel, Spaltenbreite, Ziehen und Ablegen
- **Tastenkürzel**: `V` Auswahl, `P` Zeichnen, `R` Rechteck, `K` Kreis, `A` Pfeil, `T` Text,
`Entf` löscht die Auswahl, `Strg+Z`/`Strg+Y` Rückgängig/Wiederholen, `Strg+S` speichern,
`↑`/`↓` blättert durch die Seiten. In Eingabefeldern und bei offenem Dialog greifen sie nicht;
das Hintergrundbild lässt sich mit `Entf` nicht versehentlich entfernen.
- **Anhänge-Spalte in der Breite ziehbar** (150620 px, Doppelklick auf die Trennlinie setzt zurück)
und über „«" einklappbar — die Arbeitsfläche wird dabei neu gezeichnet.
- **Kachelgröße** in drei Stufen über „⤢".
- **Ziehen und Ablegen**: Kachel auf die Seitenliste legt neue Seiten an, Kachel auf die
Arbeitsfläche ersetzt das Bild der offenen Seite, Kachel auf einen Raster-Platz setzt genau
diesen. Dateien vom Rechner auf die Anhänge-Spalte werden hochgeladen.
### Seiten: mehrere auf einmal, verdoppeln
- Klick auf die **Seitennummer** markiert eine Seite, Umschalt erweitert bis dorthin. Über die
Sammelleiste lassen sich markierte Seiten **verdoppeln** oder **löschen**.
- Neuer Endpoint **`ajax/page_bulk.php`**. Kopien werden direkt hinter ihrem Original einsortiert,
nach dem Löschen wird die Reihenfolge lückenlos neu nummeriert. Anmerkungen, Notiz und — bei
Rastern — die Bilder in den Plätzen werden mitkopiert; das gespeicherte Seitenbild bewusst nicht,
sonst zeigten Original und Kopie auf dieselbe Datei.
### Raster-Plätze direkt auf der Seite
- Bei Raster-Layouts liegt über der Arbeitsfläche ein **Gitter mit einer Schaltfläche je Platz**
(bei Vorher/Nachher entsprechend beschriftet). Ein Klick setzt das links ausgewählte Bild, ein
abgelegtes Bild setzt genau diesen Platz. Bisher ließen sich Raster nur beim Anlegen befüllen —
der Endpoint dafür existierte, hatte aber keine Bedienung.
- Die Plätze reagieren nur im Auswahlmodus; sobald ein Zeichenwerkzeug aktiv ist, haben die
Werkzeuge Vorrang.
### Behobene Fehler
- `dol_dir_list()` wurde ohne `$mode = 1` aufgerufen — `size` und `date` blieben leer, in der
Anhängeliste stand „ Bytes" ohne Zahl.
- Der PDF.js-Worker-Pfad war ohne `/custom`-Präfix hartkodiert. PDF.js fiel auf den
„fake worker" zurück und rendert seitdem wieder im Worker statt auf dem Hauptthread.
- PDF-Anhänge wurden über `document.php` **ohne** `attachment=0` verlinkt und dadurch
heruntergeladen statt angezeigt.
- Die Größen-Auswahl einer Seite blieb leer: der DB-Wert `1` traf die Option `1.0` nicht.
- Der Datei-Upload verarbeitete nur die erste Datei einer Mehrfachauswahl.
- Eingabefelder im Editor nutzen jetzt `--inputbackgroundcolor`/`--inputbordercolor`. Die
Modul-Doku hielt die Variablen für im AwlDark nicht vorhanden — sie sind gesetzt (KB #512).
- **Schneller Seitenwechsel konnte Inhalte vertauschen**: zwei Durchläufe von `loadPage()` liefen
ineinander — der zweite speicherte die noch nicht umgeschaltete Seite ein weiteres Mal, und beide
zeichneten in denselben Canvas. Dabei konnte die Notiz der einen Seite bei der anderen landen.
Seitenwechsel laufen jetzt nacheinander; ein überholter Durchlauf bricht ab.
- Der Dialog beim Löschen eines Anhangs sagt jetzt, dass die Datei **endgültig aus den Dokumenten
des Belegs** verschwindet, und warnt, wenn sie im Bericht in Gebrauch ist.
## 1.4.0 — 2026-08-15
### Kein Stunden-Dummy mehr beim Direkt-Freigeben

151
ROADMAP.md Normal file
View file

@ -0,0 +1,151 @@
# ROADMAP — Modul Bericht
Stand: 2026-08-21 · Version im Repo: 1.5.0
---
## 1. Anhänge-Spalte (links im Editor) — Hauptbaustelle
Ausgangslage (Eddy, 21.08.2026): *„die Dateinamen können in dem schmalen Fenster nicht ganz
angezeigt werden, ich habe Schwierigkeiten die richtigen Bilder auszusuchen."*
Die Spalte ist 260 px breit und zeigt pro Datei nur eine Zeile `🖼 langer_dateiname… 2,3 MB 🗑️`.
Bei 2040 Baustellenfotos mit Namen wie `IMG_20260812_141233_HDR.jpg` ist das unbrauchbar.
### 1.1 Miniatur-Vorschau statt Dateiname ✅ 1.5.0
- [x] Kachel-Raster (2 Spalten) statt Liste
- [x] Server-Thumbnails für Bilder (GD, gecacht) — Original-Handyfotos werden **nicht** mehr voll geladen
- [x] PDF-Anhänge: Seite 1 als Miniatur (PDF.js, clientseitig, lazy)
- [x] Klick auf Miniatur → Lightbox (Zoom/Pan/Swipe), `✕` bzw. Esc schließt — PDFs im Vollbild-Viewer
- [x] Dateiname zweizeilig unter der Kachel + voller Name als Tooltip
### 1.2 Auswahl bedienbar machen ✅ 1.5.0
- [x] Checkbox als Overlay auf der Kachel (großes Klickziel), Klick auf die Namenszeile wählt ebenfalls aus
- [x] Auswahl-Reihenfolge sichtbar (① ② ③) — bestimmt die Slot-Reihenfolge im Grid-Layout
(vorher: DOM-Reihenfolge, also nicht steuerbar)
- [x] „Alle / Keine" pro Herkunfts-Gruppe + Zähler „3 ausgewählt"
- [x] Bereits im Bericht verwendete Anhänge werden markiert (✓-Badge, ausgegraut)
### 1.3 Sortierung und Filter ✅ 1.5.0
- [x] Sortierung nach **Aufnahmezeit** (auf- und absteigend), Name, Größe — jeweils innerhalb der
Herkunfts-Gruppe, damit die Zuordnung zum Beleg erhalten bleibt
- [x] Filter: alle · nur Bilder · nur PDFs · noch nicht verwendet
- [x] Aufnahmezeit steht unter der Kachel (`12.08. 11:48 · 56 Kb`)
- [x] EXIF-Parser in PHP (`bericht_jpeg_taken_at`), weil das `exif`-Modul im Container
`90-Dolibarr-Prod-Custom` **nicht geladen** ist. Gegen das PHP-Modul geprüft: identische Werte.
Fallback auf das Dateidatum, wenn kein EXIF vorhanden ist.
- [x] Auswahl von Sortierung/Filter/Ansicht wird gemerkt (localStorage)
### 1.4 Spaltenbreite, Kachelgröße, Ziehen und Ablegen ✅ 1.5.0
- [x] **Spaltenbreite ziehbar** — Trennlinie zwischen Anhängen und Arbeitsfläche (150620 px),
Doppelklick setzt auf 260 px zurück; Breite wird gemerkt
- [x] **Ein-/ausklappbar** über „«" in der Kopfzeile — eingeklappt bleiben 26 px stehen
- [x] **Kachelgröße** klein / mittel / groß über „⤢" (in der kleinsten Stufe entfällt die Größenangabe)
- [x] **Kachel auf die Seitenliste ziehen** → neue Seite(n). Sind mehrere Kacheln angekreuzt und man
zieht eine davon, kommen alle mit.
- [x] **Kachel auf die Arbeitsfläche ziehen** → ersetzt das Bild der offenen Seite
- [x] **Kachel auf einen Raster-Platz ziehen** → setzt genau diesen Platz
- [x] **Dateien vom Rechner auf die Spalte ziehen** → Upload (nicht unterstützte Typen werden
benannt und übersprungen)
- [x] SortableJS bekam `group: {put: false}` — sonst streiten sich Sortierung und Ablegen um die
gleichen Ereignisse
---
## 2. Seitenansicht ("Arbeitstisch") ✅ 1.5.0
Vorher zeigte der Editor ein A4-Blatt, auf dem das Foto formatfüllend lag. Beim PDF wurde
dieses ganze Blatt **nochmal** in den Satzspiegel eingepasst — der Inhalt schrumpfte also ein
zweites Mal, und die Vorschau entsprach nicht dem Ergebnis. Seiten, die nie im Editor
gespeichert wurden, liefen zudem über den Kopfbereich (Logo/Titel) hinweg.
- [x] Eine gemeinsame Geometrie-Quelle: `bericht_page_geometry($format, $orientation, $has_note)`
in `lib/bericht.lib.php` — liefert Seitenmaß, Ränder (10 mm), Kopfbereich (30 mm),
Fußbereich (16 mm), Notizzone und den Inhaltsbereich. Editor **und** PDF rechnen damit.
- [x] Arbeitsfläche = Inhaltsbereich. Drumherum das Blatt mit schraffierter Kopf-/Fußzone
(das, was TCPDF selbst druckt) und einer Notizzone, sobald die Seite eine Notiz hat.
- [x] PDF setzt das Composite 1:1 in den Inhaltsbereich statt es erneut einzupassen
- [x] Alle Layout-Zweige der PDF-Erzeugung (Einzelbild, Raster, Vorher/Nachher, Notiz)
auf dieselbe Geometrie umgestellt — vorher rechneten sie mit `margin = 10` ab
Blattkante und schoben Bilder unter den Kopfbereich
- [x] Raster-Vorschau (`ajax/page_image.php`) rendert im Format des Inhaltsbereichs
- [x] Umschalter „📐 Rand" in der Werkzeugleiste (Zustand wird gemerkt)
- [x] Rückwärtskompatibel: Composites aus der Zeit davor haben Blatt-Proportion und werden
an der Proportion erkannt und proportional eingepasst statt verzerrt
---
## 3. Bild einer Seite austauschen ✅ 1.5.0
- [x] `ajax/replace_page_source.php` — tauscht die Quelle einer Seite; Reihenfolge, Notiz und
Anmerkungen bleiben erhalten (vorher: Seite löschen und hinten neu anhängen)
- [x] Raster-Seiten: einzelner Platz austauschbar (nutzt das vorhandene `set_slot_image.php`,
für das es bis dahin keine Bedienung gab)
- [x] Bedienung: „🔄" auf der Seitenminiatur → Statusleiste am unteren Rand → links auf das
Ersatzbild klicken. Esc bricht ab. Bei Rastern vorher den Platz (1…n) wählen.
- [x] Finalisierte Berichte werden serverseitig abgewiesen
---
## 4. Editor / Arbeitsablauf
- [x] **Kein `location.reload()` mehr** ✅ 1.5.0 — Hinzufügen, Löschen, Hochladen, Bildtausch und
der QR-Upload aktualisieren nur noch die betroffenen Listen. Neuer Endpoint
`ajax/fragments.php` liefert Seiten- und Anhängeliste als HTML; gerendert wird über
`bericht_print_page_list()` / `bericht_print_attachment_list()`, dieselben Funktionen wie beim
Seitenaufbau. Der Editor bleibt auf der bearbeiteten Seite, Zoom und Ansicht bleiben erhalten.
Einzige Ausnahme: nach dem Finalisieren wird bewusst neu geladen, weil der Bericht den Status
wechselt und die ganze Karte anders aussieht.
- [x] **Autosave** ✅ 1.5.0 — 4 Sekunden nach der letzten Änderung wird gespeichert, ebenso beim
Seitenwechsel und beim Wegschalten des Tabs. Zustand steht in der Fußleiste
(„● nicht gespeichert" / „✓ gespeichert"), beim Verlassen der Seite warnt der Browser.
Wichtig: das Aufbauen einer Seite (und Zoomen) löst Fabric-Events aus, zählt aber nicht als
Änderung — sonst stünde direkt nach dem Öffnen „nicht gespeichert" da.
- [x] **Tastenkürzel** ✅ 1.5.0 — V/P/R/K/A/T für die Werkzeuge, Entf löscht die Auswahl (das
Hintergrundbild ist ausgenommen), Strg+Z/Y, Strg+S, ↑/↓ blättert durch die Seiten.
In Eingabefeldern und bei offenem Dialog oder offener Großansicht greifen sie nicht.
Die Kürzel stehen in den Tooltips der Schaltflächen.
- [x] **Seitenliste: Mehrfachauswahl** ✅ 1.5.0 — Klick auf die Seitennummer markiert, Umschalt
erweitert bis dorthin. Sammelleiste mit Verdoppeln (⧉), Löschen (🗑️) und Aufheben (Esc).
Neuer Endpoint `ajax/page_bulk.php`; Kopien landen direkt hinter ihrem Original
(`bericht_reorder_with_copies()`), nach dem Löschen wird lückenlos neu nummeriert.
`composite_path` wird bewusst **nicht** mitkopiert — sonst zeigten beide Seiten auf dieselbe
Datei und das Bearbeiten der einen überschriebe die andere.
- [x] **Raster-Plätze direkt anklicken** ✅ 1.5.0 — über der Arbeitsfläche liegt ein Gitter mit
einer Schaltfläche je Platz (bei Vorher/Nachher entsprechend beschriftet). Klick setzt das
links ausgewählte Bild, Ablegen per Drag setzt das gezogene. Die Aufteilung entspricht
`BerichtPage::slotRectsInBox()`. Die Plätze reagieren nur im Auswahlmodus — beim Zeichnen
haben die Werkzeuge Vorrang, sonst käme man auf Raster-Seiten nicht mehr zum Zeichnen.
- [x] **Seitenwechsel serialisiert** ✅ 1.5.0 — beim schnellen Durchklicken überlappten sich zwei
Durchläufe von `loadPage()`: der zweite speicherte die noch nicht umgeschaltete Seite ein
zweites Mal, und beide rendern in denselben Canvas — so konnte die Notiz der einen Seite bei
der anderen landen. Jeder Aufruf bekommt jetzt eine Nummer und bricht ab, sobald ein neuerer
gestartet ist.
## 5. Kleinkram / Fixes
- [x] **Mehrfach-Upload**: `bindExtraUpload` verarbeitete nur `inp.files[0]` — Mehrfachauswahl im
Dateidialog war möglich, es kam aber nur eine Datei an ✅ 1.5.0
- [x] Upload-Fortschritt ✅ 1.5.0 — der Knopf zeigt beim Mehrfach-Upload „⏳ 3 / 8"
- [x] `att-delete` löscht die Datei wirklich aus den Dokumenten des Belegs ✅ 1.5.0 — der Dialog
sagt das jetzt deutlich und warnt zusätzlich, wenn die Datei im Bericht in Gebrauch ist.
---
## Erledigt
### 1.5.0 — 2026-08-21
Vollständig abgearbeitet. Anhänge-Kachelraster mit Miniaturen (1.11.4), Server-Thumbnail-Endpoint,
PDF-Lightbox, Arbeitstisch mit echter Seitengeometrie (2), Bildaustausch auf bestehenden Seiten (3),
Arbeiten ohne Neuladen, Autosave, Tastenkürzel, Sammelaktionen auf Seiten und Raster-Plätze (4).
Nebenbei gefundene und behobene Fehler:
- `dol_dir_list()` wurde ohne `$mode = 1` aufgerufen — dadurch waren `size` und `date` leer
und in der Anhängeliste stand „ Bytes" ohne Zahl
- PDF.js-Worker-Pfad war hartkodiert ohne `/custom`-Präfix → PDF.js lief im „fake worker"-Modus
auf dem Hauptthread (spürbar langsamer beim Rendern)
- `document.php` wurde ohne `attachment=0` verlinkt → PDFs wurden heruntergeladen statt angezeigt
- Größen-Auswahl der Seite blieb leer, weil der DB-Wert `1` die Option `1.0` nicht traf
- Mehrfach-Upload nahm nur die erste Datei
- `--inputbackgroundcolor` wurde im Modul als „im AwlDark nicht vorhanden" behandelt; die
Variable existiert (KB #512, per DevTools bestätigt) — Editor-Eingabefelder nutzen jetzt
dieselben Farben wie Dolibarr selbst

55
ajax/attachment_thumb.php Normal file
View file

@ -0,0 +1,55 @@
<?php
/* Liefert eine verkleinerte Vorschau (Thumbnail) einer Anhang-Datei.
*
* GET: relpath Pfad relativ zu DOL_DATA_ROOT (wie in bericht_collect_attachments)
* w Maximale Kantenlaenge in Pixel (Default 260, max 800)
*
* Nur Bilder (jpeg/png/gif/webp). PDFs werden clientseitig per PDF.js gerendert,
* weil im Prod-Container weder Imagick noch eine garantierte shell_exec-Freigabe
* zur Verfuegung steht.
*
* Thumbnails werden in DOL_DATA_ROOT/bericht/thumbs/ zwischengespeichert und ueber
* die mtime der Quelldatei invalidiert.
*/
$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 __DIR__.'/../lib/bericht.lib.php';
if (!$user->hasRight('bericht', 'read')) accessforbidden();
$relpath = GETPOST('relpath', 'alpha');
// GETPOST 'alpha' wuerde Slashes behalten, aber Umlaute/Leerzeichen in Dateinamen
// killen - deshalb den Rohwert nehmen und selbst absichern.
if (isset($_GET['relpath'])) $relpath = $_GET['relpath'];
$maxside = GETPOSTINT('w');
if ($maxside <= 0) $maxside = 260;
if ($maxside > 800) $maxside = 800;
if ($relpath === '' || strpos($relpath, "\0") !== false) { http_response_code(400); exit; }
$full = bericht_resolve_data_path($relpath);
if (!$full || !is_file($full)) { http_response_code(404); exit; }
$thumb = bericht_attachment_thumb($full, $maxside);
if ($thumb === null) { http_response_code(415); exit; }
// Bedingtes GET: spart bei 40 Kacheln jede Menge Traffic
$etag = '"'.md5($full.'|'.filemtime($full).'|'.$maxside).'"';
header('ETag: '.$etag);
header('Cache-Control: private, max-age=86400');
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
http_response_code(304);
exit;
}
header('Content-Type: image/jpeg');
header('Content-Length: '.strlen($thumb));
echo $thumb;

51
ajax/fragments.php Normal file
View file

@ -0,0 +1,51 @@
<?php
/* Liefert Teilstuecke der Editor-Oberflaeche als HTML, damit nach dem Hinzufuegen,
* Loeschen oder Hochladen kein kompletter Seiten-Reload noetig ist (der warf den
* Editor jedes Mal auf Seite 1 zurueck und verwarf den Zoom).
*
* GET: berichtid, what = pages | attachments | both
* Antwort: { success, pages_html?, attachments_html?, page_count }
*/
require_once __DIR__.'/_inc.php';
global $db, $user, $langs, $conf;
$berichtid = GETPOSTINT('berichtid');
$what = GETPOST('what', 'alpha');
if (!in_array($what, array('pages', 'attachments', 'both'), true)) $what = 'both';
if (!$berichtid) bericht_ajax_fail('berichtid fehlt');
$bericht = new Bericht($db);
if ($bericht->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
$langs->loadLangs(array('bericht@bericht', 'other'));
$out = array();
$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
$out['page_count'] = count($pages);
if ($what === 'pages' || $what === 'both') {
ob_start();
bericht_print_page_list($pages);
$out['pages_html'] = ob_get_clean();
}
if ($what === 'attachments' || $what === 'both') {
$parent = bericht_fetch_parent($db, $bericht->element_type, $bericht->fk_element);
if (!$parent) bericht_ajax_fail('Verknüpftes Objekt nicht gefunden', 404);
$attachments = bericht_collect_attachments($db, $parent, $bericht->element_type);
$used_paths = bericht_used_source_paths($db, $bericht->id);
$by_source = array();
foreach ($attachments as $a) {
$by_source[$a['source'].':'.$a['source_ref']][] = $a;
}
ob_start();
bericht_print_attachment_list($by_source, $used_paths);
$out['attachments_html'] = ob_get_clean();
$out['attachment_count'] = count($attachments);
}
bericht_ajax_ok($out);

105
ajax/page_bulk.php Normal file
View file

@ -0,0 +1,105 @@
<?php
/* Sammelaktionen auf Seiten eines Berichts.
*
* POST: berichtid, action = delete | duplicate, pageids = JSON-Array, token
*
* Ersetzt das seitenweise Loeschen, wenn mehrere Seiten markiert sind, und
* legt beim Duplizieren eine Kopie direkt hinter dem Original an (inklusive
* Anmerkungen, Notiz und bei Rastern der Bilder in den Plaetzen).
*/
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$berichtid = (int) ($_POST['berichtid'] ?? 0);
$action = (string) ($_POST['action'] ?? '');
$ids_raw = (string) ($_POST['pageids'] ?? '[]');
if (!$berichtid) bericht_ajax_fail('berichtid fehlt');
if (!in_array($action, array('delete', 'duplicate'), true)) bericht_ajax_fail('Unbekannte Aktion');
$ids = json_decode($ids_raw, true);
if (!is_array($ids) || empty($ids)) bericht_ajax_fail('Keine Seiten ausgewählt');
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
if (empty($ids)) bericht_ajax_fail('Keine gültigen Seiten');
$b = new Bericht($db);
if ($b->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
if ((int) $b->status === Bericht::STATUS_FINAL) {
bericht_ajax_fail('Der Bericht ist finalisiert und kann nicht mehr geändert werden', 403);
}
// Nur Seiten dieses Berichts anfassen
$in = implode(',', $ids);
$check = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page"
." WHERE rowid IN (".$in.") AND fk_bericht = ".((int) $berichtid));
if (!$check) bericht_ajax_fail($db->lasterror());
$valid = array();
while ($r = $db->fetch_object($check)) $valid[] = (int) $r->rowid;
if (count($valid) !== count($ids)) bericht_ajax_fail('Seiten gehören nicht zu diesem Bericht', 403);
$db->begin();
if ($action === 'delete') {
$sql = "DELETE FROM ".$db->prefix()."bericht_page WHERE rowid IN (".implode(',', $valid).")";
if (!$db->query($sql)) {
$db->rollback();
bericht_ajax_fail($db->lasterror());
}
bericht_renumber_pages($db, $berichtid);
$db->commit();
bericht_ajax_ok(array('deleted' => count($valid)));
}
/* --- Duplizieren --- */
$pages = BerichtPage::fetchAllForBericht($db, $berichtid);
$by_id = array();
foreach ($pages as $p) $by_id[(int) $p->id] = $p;
$created = 0;
foreach ($valid as $pid) {
if (!isset($by_id[$pid])) continue;
$src = $by_id[$pid];
$copy = new BerichtPage($db);
$copy->fk_bericht = $berichtid;
// Vorlaeufig ans Ende; die endgueltige Reihenfolge macht die Neunummerierung
$copy->page_order = 9000 + (int) $src->page_order;
$copy->source_type = $src->source_type;
$copy->source_path = $src->source_path;
$copy->source_page = $src->source_page;
$copy->rotation = $src->rotation;
$copy->fabric_json = $src->fabric_json;
$copy->note = $src->note;
$copy->title = $src->title;
$copy->layout = $src->layout;
$copy->image_scale = $src->image_scale;
$copy->image_align = $src->image_align;
$newid = $copy->create();
if ($newid <= 0) {
$db->rollback();
bericht_ajax_fail('Kopie konnte nicht angelegt werden');
}
// Bilder der Plaetze mitkopieren (Raster-Layouts)
if ($src->layout !== 'single') {
foreach ($src->getImages() as $img) {
if ($copy->setSlotImage($img['slot'], $img['source_path'], $img['rotation']) <= 0) {
$db->rollback();
bericht_ajax_fail('Bilder der Kopie konnten nicht angelegt werden');
}
}
}
// composite_path bewusst NICHT kopieren: die Kopie erzeugt beim ersten
// Speichern ihr eigenes, sonst zeigen beide Seiten auf dieselbe Datei
// und das Bearbeiten der einen ueberschreibt die andere.
$created++;
}
// Kopien direkt hinter ihr Original einsortieren
bericht_reorder_with_copies($db, $berichtid, $valid);
$db->commit();
bericht_ajax_ok(array('created' => $created));

View file

@ -30,6 +30,16 @@ if (!$row) { http_response_code(404); exit; }
// Bei Multi-Image-Seiten: serverseitig ein Composite-Bild rendern (PNG)
if ($row->layout !== 'single') {
// Format/Orientierung des Berichts holen, damit die Vorschau dieselbe
// Proportion hat wie der Inhaltsbereich im Editor und im PDF
$bfmt = 'A4'; $bori = 'P';
$bq = $db->query("SELECT b.page_format, b.page_orientation FROM ".$db->prefix()."bericht b"
." INNER JOIN ".$db->prefix()."bericht_page p ON p.fk_bericht = b.rowid"
." WHERE p.rowid = ".((int) $pageid));
if ($bq && ($brow = $db->fetch_object($bq))) {
if (in_array($brow->page_format, array('A4','A3','A5','Letter'), true)) $bfmt = $brow->page_format;
if (in_array($brow->page_orientation, array('P','L'), true)) $bori = $brow->page_orientation;
}
$imgs_q = $db->query("SELECT slot, source_path FROM ".$db->prefix()."bericht_page_image WHERE fk_page = ".((int) $pageid)." ORDER BY slot");
if (!$imgs_q) { http_response_code(500); exit; }
$imgs = array();
@ -38,7 +48,7 @@ if ($row->layout !== 'single') {
}
if (empty($imgs)) { http_response_code(404); exit; }
$composite = bericht_render_grid_png($row->layout, $imgs);
$composite = bericht_render_grid_png($row->layout, $imgs, bericht_page_geometry($bfmt, $bori, false));
if (!$composite) { http_response_code(500); exit; }
header('Content-Type: image/png');
header('Cache-Control: no-store');
@ -58,39 +68,37 @@ readfile($full);
/**
* Erstellt ein Composite-PNG für eine Multi-Image-Seite (Editor-Vorschau).
*/
function bericht_render_grid_png($layout, $imgs)
function bericht_render_grid_png($layout, $imgs, array $g)
{
// A4 Hochformat als Grundfläche, 150 DPI
$W = 1240; // ~A4 @ 150dpi
$H = 1754;
$margin = 40;
$gap = 16;
// Grundfläche = Inhaltsbereich der Seite bei ~150 DPI, damit die Vorschau
// exakt der Fläche entspricht, die das PDF später bedruckt.
$px_per_mm = 150 / 25.4;
$W = (int) round($g['contentW'] * $px_per_mm);
$H = (int) round($g['contentH'] * $px_per_mm);
$gap = (int) round(4 * $px_per_mm);
$canvas = imagecreatetruecolor($W, $H);
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 255, 255, 255));
// Slot-Berechnung (gleiche Logik wie BerichtPage::slotRects, aber in Pixel)
$iw = $W - 2 * $margin;
$ih = $H - 2 * $margin - 60; // 60px für Notiz-Bereich
// Slot-Aufteilung wie BerichtPage::slotRectsInBox, hier in Pixel
$rects = array();
if ($layout === 'grid_2') {
$h = ($ih - $gap) / 2;
for ($i = 0; $i < 2; $i++) $rects[] = array($margin, $margin + $i * ($h + $gap), $iw, $h);
} elseif ($layout === 'grid_2v') {
$w = ($iw - $gap) / 2;
for ($i = 0; $i < 2; $i++) $rects[] = array($margin + $i * ($w + $gap), $margin, $w, $ih);
$h = ($H - $gap) / 2;
for ($i = 0; $i < 2; $i++) $rects[] = array(0, $i * ($h + $gap), $W, $h);
} elseif ($layout === 'grid_2v' || $layout === 'before_after') {
$w = ($W - $gap) / 2;
for ($i = 0; $i < 2; $i++) $rects[] = array($i * ($w + $gap), 0, $w, $H);
} elseif ($layout === 'grid_4') {
$w = ($iw - $gap) / 2;
$h = ($ih - $gap) / 2;
$w = ($W - $gap) / 2;
$h = ($H - $gap) / 2;
for ($r = 0; $r < 2; $r++) for ($c = 0; $c < 2; $c++) {
$rects[] = array($margin + $c * ($w + $gap), $margin + $r * ($h + $gap), $w, $h);
$rects[] = array($c * ($w + $gap), $r * ($h + $gap), $w, $h);
}
} elseif ($layout === 'grid_6') {
$w = ($iw - 2 * $gap) / 3;
$h = ($ih - $gap) / 2;
$w = ($W - 2 * $gap) / 3;
$h = ($H - $gap) / 2;
for ($r = 0; $r < 2; $r++) for ($c = 0; $c < 3; $c++) {
$rects[] = array($margin + $c * ($w + $gap), $margin + $r * ($h + $gap), $w, $h);
$rects[] = array($c * ($w + $gap), $r * ($h + $gap), $w, $h);
}
}

View file

@ -0,0 +1,66 @@
<?php
/* Tauscht das Quellbild einer bestehenden Seite aus.
*
* POST: pageid, relpath, mime, token
* slot (optional) bei Grid-Layouts der zu ersetzende Slot (0-basiert)
*
* Seitenreihenfolge, Notiz und Annotationen bleiben erhalten; nur die Quelle
* wird ersetzt. Damit muss man eine falsch gewaehlte Seite nicht loeschen und
* hinten neu anhaengen.
*/
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$pageid = (int) ($_POST['pageid'] ?? 0);
$relpath = (string) ($_POST['relpath'] ?? '');
$mime = (string) ($_POST['mime'] ?? '');
$slot = isset($_POST['slot']) && $_POST['slot'] !== '' ? (int) $_POST['slot'] : null;
if (!$pageid || $relpath === '') bericht_ajax_fail('Parameter fehlen');
$full = bericht_resolve_data_path($relpath);
if (!$full || !file_exists($full)) bericht_ajax_fail('Datei nicht gefunden', 404);
$res = $db->query("SELECT rowid, fk_bericht, COALESCE(layout,'single') AS layout"
." FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
if (!$res || !($row = $db->fetch_object($res))) bericht_ajax_fail('Seite nicht gefunden', 404);
// Bericht darf nicht finalisiert sein
$b = new Bericht($db);
if ($b->fetch((int) $row->fk_bericht) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
if ((int) $b->status === Bericht::STATUS_FINAL) {
bericht_ajax_fail('Der Bericht ist finalisiert und kann nicht mehr geändert werden', 403);
}
if ($row->layout !== 'single' && $slot !== null) {
// Grid: nur den einen Slot ersetzen
if (strpos($mime, 'image') !== 0 && $mime !== '') {
bericht_ajax_fail('In Raster-Layouts sind nur Bilder möglich');
}
$slots = BerichtPage::slotCountForLayout($row->layout);
if ($slot < 0 || $slot >= $slots) bericht_ajax_fail('Ungültiger Slot');
$p = new BerichtPage($db);
$p->id = (int) $row->rowid;
$p->fk_bericht = (int) $row->fk_bericht;
$p->layout = $row->layout;
if ($p->setSlotImage($slot, $relpath, 0) > 0) {
bericht_ajax_ok(array('slot' => $slot));
}
bericht_ajax_fail('DB-Fehler beim Ersetzen des Slots');
}
// Single: source_path/source_type der Seite ersetzen
$source_type = (strpos($mime, 'pdf') !== false || preg_match('/\.pdf$/i', $relpath)) ? 'pdf' : 'image';
$sql = "UPDATE ".$db->prefix()."bericht_page SET"
." source_path = '".$db->escape($relpath)."',"
." source_type = '".$db->escape($source_type)."',"
." source_page = 1,"
." rotation = 0"
." WHERE rowid = ".((int) $pageid);
if ($db->query($sql)) {
bericht_ajax_ok(array('source_type' => $source_type));
}
bericht_ajax_fail('DB-Fehler beim Ersetzen der Seite');

View file

@ -308,6 +308,8 @@ document.addEventListener("click", function(e) {
/*
* MODUS B: Editor bestehender Bericht wird bearbeitet
*/
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
$attachments = bericht_collect_attachments($db, $parent, $element);
$templates = bericht_list_templates();
@ -336,6 +338,14 @@ document.addEventListener("click", function(e) {
'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1),
'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1),
'save_as_template' => dol_buildpath('/bericht/ajax/save_as_template.php', 1),
'replace_page_source' => dol_buildpath('/bericht/ajax/replace_page_source.php', 1),
'fragments' => dol_buildpath('/bericht/ajax/fragments.php', 1),
'page_bulk' => dol_buildpath('/bericht/ajax/page_bulk.php', 1),
'pdf_worker' => dol_buildpath('/bericht/js/lib/pdf.worker.min.js', 1),
),
'geometry' => array(
'plain' => bericht_page_geometry($bericht->page_format ?: 'A4', $bericht->page_orientation ?: 'P', false),
'note' => bericht_page_geometry($bericht->page_format ?: 'A4', $bericht->page_orientation ?: 'P', true),
),
'lang' => array(
'undo' => $langs->trans("BerichtUndo"),
@ -375,46 +385,118 @@ document.addEventListener("click", function(e) {
if (count($vs) > 1) $version_links = ' &nbsp; <span class="opacitymedium small">Versionen: '.implode(' · ', $vs).'</span>';
}
print '<table class="border centpercent"><tr>';
print '<td class="titlefield">'.$langs->trans("Ref").'</td><td>'.dol_escape_htmltag($bericht->ref).' <span class="opacitymedium">v'.((int) $bericht->version).'</span>'.$version_links.'</td>';
print '<td class="titlefield">'.$langs->trans("BerichtAuftragsnummer").'</td><td>'.dol_escape_htmltag($bericht->auftragsnummer).'</td>';
print '</tr><tr>';
print '<td>'.$langs->trans("BerichtTitle").'</td><td><input type="text" name="titel" value="'.dol_escape_htmltag($bericht->titel).'" size="40"></td>';
print '<td>'.$langs->trans("BerichtTemplate").'</td><td><select name="template_odt">';
// --- Kopfzeile: Identität des Berichts auf einen Blick ---
$is_final = ((int) $bericht->status === Bericht::STATUS_FINAL);
$parent_link = '';
if (!empty($parent->id)) {
$parent_link = $parent->getNomUrl(1);
}
$cust_link = '';
if (!empty($parent->socid)) {
$soc = new Societe($db);
if ($soc->fetch($parent->socid) > 0) $cust_link = $soc->getNomUrl(1);
}
print '<div class="bericht-meta-head">';
print '<div class="bericht-meta-ident">';
print '<span class="bericht-meta-icon">📄</span>';
print '<span class="bericht-meta-ref">'.dol_escape_htmltag($bericht->ref).'</span>';
print '<span class="bericht-meta-version">v'.((int) $bericht->version).'</span>';
print '<span class="bericht-badge '.($is_final ? 'badge-final' : 'badge-draft').'">'
.($is_final ? 'Finalisiert' : 'Entwurf').'</span>';
if ($version_links) print '<span class="bericht-meta-versions">'.$version_links.'</span>';
print '</div>';
print '<div class="bericht-meta-facts">';
if ($cust_link) {
print '<span class="bericht-fact"><span class="bericht-fact-label">Kunde</span>'.$cust_link.'</span>';
}
if ($parent_link) {
print '<span class="bericht-fact"><span class="bericht-fact-label">Beleg</span>'.$parent_link.'</span>';
}
if ($bericht->auftragsnummer) {
print '<span class="bericht-fact"><span class="bericht-fact-label">'.$langs->trans("BerichtAuftragsnummer").'</span>'
.'<strong>'.dol_escape_htmltag($bericht->auftragsnummer).'</strong></span>';
}
print '<span class="bericht-fact"><span class="bericht-fact-label">Seiten</span><strong id="meta-page-count">'.count($pages).'</strong></span>';
print '</div>';
print '</div>'; // .bericht-meta-head
// --- Bearbeitbare Felder ---
print '<div class="bericht-meta-fields">';
print '<div class="bericht-field grow">';
print '<label for="meta-titel">'.$langs->trans("BerichtTitle").'</label>';
print '<input type="text" id="meta-titel" name="titel" value="'.dol_escape_htmltag($bericht->titel).'">';
print '</div>';
print '<div class="bericht-field">';
print '<label for="meta-template">'.$langs->trans("BerichtTemplate").'</label>';
print '<select id="meta-template" name="template_odt">';
print '<option value="">— '.$langs->trans("BerichtNoTemplate").' —</option>';
foreach ($templates as $tpl) {
$sel = ($tpl === $bericht->template_odt) ? ' selected' : '';
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).'</option>';
}
print '</select></td>';
print '</tr><tr>';
// Format und Orientation
print '<td>Format</td><td>';
print '</select>';
print '</div>';
print '<div class="bericht-field">';
print '<label for="meta-format">Seitenformat</label>';
print '<div class="bericht-field-row">';
print '<select id="meta-format" name="page_format" title="Seitenformat des PDFs">';
foreach (array('A4', 'A3', 'A5', 'Letter') as $fmt) {
$sel = ($fmt === $bericht->page_format) ? ' selected' : '';
print '<option value="'.$fmt.'"'.$sel.'>'.$fmt.'</option>';
}
print '</select>';
print ' &nbsp; ';
print '<select id="meta-orientation" name="page_orientation" title="Hoch- oder Querformat">';
foreach (array('P' => 'Hochformat', 'L' => 'Querformat') as $k => $v) {
$sel = ($k === $bericht->page_orientation) ? ' selected' : '';
print '<option value="'.$k.'"'.$sel.'>'.$v.'</option>';
}
print '</select>';
print '</td>';
print '<td colspan="2"><span class="opacitymedium small">Wird beim Finalisieren angewendet</span></td>';
print '</tr></table>';
print '</div>';
print '<span class="bericht-field-hint">Wird beim Finalisieren angewendet</span>';
print '</div>';
print '</div>'; // .bericht-meta-fields
print '</form>';
print '</div>';
// Hauptlayout: links Anhänge, Mitte Editor, rechts Seiten
print '<div class="bericht-layout">';
// LINKS: Anhänge-Browser
// LINKS: Anhänge-Browser (Kachelraster mit Miniaturen)
$used_paths = bericht_used_source_paths($db, $bericht->id);
print '<aside class="bericht-attachments">';
print '<div class="bericht-att-header">';
print '<h4>'.$langs->trans("BerichtAvailableAttachments").'</h4>';
print '<div class="bericht-att-viewswitch">';
print '<button type="button" class="att-view-btn active" data-view="grid" title="Kachelansicht mit Vorschaubildern">▦</button>';
print '<button type="button" class="att-view-btn" data-view="list" title="Kompakte Listenansicht">☰</button>';
print '<button type="button" id="att-size-btn" title="Kachelgröße wechseln (klein / mittel / groß)">⤢</button>';
print '<button type="button" id="att-collapse-btn" title="Anhänge-Spalte einklappen — mehr Platz für die Seite">«</button>';
print '</div>';
print '</div>';
// Sortierung + Filter der Anhänge (wirkt innerhalb jeder Herkunfts-Gruppe)
print '<div class="bericht-att-tools">';
print '<select id="att-sort" title="Reihenfolge der Anhänge">';
print '<option value="taken">Aufnahmezeit ↑</option>';
print '<option value="taken_desc">Aufnahmezeit ↓</option>';
print '<option value="name">Name</option>';
print '<option value="size_desc">Größe ↓</option>';
print '</select>';
print '<select id="att-filter" title="Nur bestimmte Dateitypen zeigen">';
print '<option value="all">Alle</option>';
print '<option value="image">Nur Bilder</option>';
print '<option value="pdf">Nur PDFs</option>';
print '<option value="unused">Noch nicht verwendet</option>';
print '</select>';
print '</div>';
$by_source = array();
foreach ($attachments as $a) {
$key = $a['source'].':'.$a['source_ref'];
@ -423,41 +505,11 @@ document.addEventListener("click", function(e) {
if (empty($by_source)) {
print '<div class="opacitymedium small">'.$langs->trans("None").'</div>';
} else {
foreach ($by_source as $key => $files) {
list($src, $ref) = explode(':', $key, 2);
print '<div class="bericht-att-group"><div class="bericht-att-group-title">'.dol_escape_htmltag($ref).'</div>';
foreach ($files as $f) {
$is_image = (strpos($f['mime'], 'image') === 0);
$icon = $is_image ? '🖼' : ((strpos($f['mime'], 'pdf') !== false) ? '📄' : '📎');
print '<div class="bericht-att-item">';
print '<input type="checkbox" class="att-check" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-mime="'.dol_escape_htmltag($f['mime']).'" title="Auswählen">';
bericht_print_attachment_list($by_source, $used_paths);
if ($is_image) {
// Bild: Klickbar für Viewer
// relpath ist z.B. "facture/FA2025-0001/image.jpg" - erstes Segment ist modulepart
$relpath_parts = explode('/', $f['relpath'], 2);
$modulepart = $relpath_parts[0];
$file_in_module = $relpath_parts[1] ?? '';
$img_url = DOL_URL_ROOT.'/viewimage.php?modulepart='.urlencode($modulepart).'&file='.urlencode($file_in_module).'&entity='.$conf->entity;
print '<span class="att-preview" data-url="'.dol_escape_htmltag($img_url).'" data-filename="'.dol_escape_htmltag($f['filename']).'" data-group="'.dol_escape_htmltag($key).'" title="Klicken zum Vergrössern">';
print '<span class="att-icon">'.$icon.'</span>';
print '<span class="att-name" title="'.dol_escape_htmltag($f['filename']).'">'.dol_escape_htmltag($f['filename']).'</span>';
print '</span>';
} else {
// Andere Dateien: nicht klickbar
print '<span class="att-icon">'.$icon.'</span>';
print '<span class="att-name" title="'.dol_escape_htmltag($f['filename']).'">'.dol_escape_htmltag($f['filename']).'</span>';
}
print '<span class="att-size opacitymedium small">'.dol_print_size($f['size']).'</span>';
if ($user->hasRight('bericht', 'write')) {
print '<button type="button" class="att-delete" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-source-ref="'.dol_escape_htmltag($f['source_ref']).'" title="Diese Datei aus dem Anhang löschen">🗑️</button>';
}
print '</div>';
}
print '</div>';
}
print '<div class="bericht-add-selected" style="margin-top:6px;">';
print '<div class="bericht-add-selected">';
print '<div class="att-selection-info"><span id="att-selected-count">0</span> ausgewählt'
.' <button type="button" id="att-clear-selection" title="Auswahl aufheben">zurücksetzen</button></div>';
print '<label class="opacitymedium small" style="display:block;margin-bottom:4px;">Layout für die Auswahl:</label>';
print '<select id="add-selected-layout" style="width:100%;margin-bottom:6px;">';
print '<option value="single">Einzeln (1 Bild pro Seite)</option>';
@ -467,33 +519,36 @@ document.addEventListener("click", function(e) {
print '<option value="grid_6">6 pro Seite (3x2)</option>';
print '<option value="before_after">Vorher / Nachher (2 pro Seite)</option>';
print '</select>';
print '<button type="button" id="btn-add-selected" class="butAction" style="width:100%;" title="Alle ausgewählten Bilder mit dem gewählten Layout in den Bericht übernehmen">'.$langs->trans("BerichtAddSelectedToReport").'</button>';
print '<button type="button" id="btn-add-selected" class="butAction" style="width:100%;" title="Alle ausgewählten Bilder in der angezeigten Reihenfolge (① ② ③) mit dem gewählten Layout übernehmen">'.$langs->trans("BerichtAddSelectedToReport").'</button>';
print '</div>';
}
print '<hr>';
print '<div class="bericht-upload">';
print '<label for="bericht-extra-upload" class="butAction" title="Datei vom PC hochladen">📤 '.$langs->trans("BerichtUploadExtra").'</label>';
print '<input type="file" id="bericht-extra-upload" style="display:none" accept=".pdf,.png,.jpg,.jpeg">';
print '<input type="file" id="bericht-extra-upload" style="display:none" accept=".pdf,.png,.jpg,.jpeg,.webp" multiple>';
print '<button type="button" id="btn-show-qr" class="butAction" title="QR-Code für Mobile-Upload erzeugen" style="margin-top:6px;">📱 Mobil hochladen</button>';
print '</div>';
print '</aside>';
// Ziehbare Trennlinie: Breite der Anhänge-Spalte anpassen
print '<div id="bericht-splitter" class="bericht-splitter" title="Breite der Anhänge-Spalte ziehen (Doppelklick setzt zurück)"></div>';
// MITTE: PDF.js + Fabric.js Editor
print '<main class="bericht-canvas-area">';
print '<div class="bericht-toolbar">';
print '<button type="button" class="tool-btn" data-tool="select" title="'.$langs->trans("BerichtToolSelect").'">↖</button>';
print '<button type="button" class="tool-btn" data-tool="draw" title="'.$langs->trans("BerichtToolDraw").'">✏️</button>';
print '<button type="button" class="tool-btn" data-tool="rect" title="'.$langs->trans("BerichtToolRect").'">▭</button>';
print '<button type="button" class="tool-btn" data-tool="circle" title="'.$langs->trans("BerichtToolCircle").'">○</button>';
print '<button type="button" class="tool-btn" data-tool="arrow" title="'.$langs->trans("BerichtToolArrow").'">↗</button>';
print '<button type="button" class="tool-btn" data-tool="text" title="'.$langs->trans("BerichtToolText").'">T</button>';
print '<button type="button" class="tool-btn" data-tool="select" title="'.$langs->trans("BerichtToolSelect").' (V)">↖</button>';
print '<button type="button" class="tool-btn" data-tool="draw" title="'.$langs->trans("BerichtToolDraw").' (P)">✏️</button>';
print '<button type="button" class="tool-btn" data-tool="rect" title="'.$langs->trans("BerichtToolRect").' (R)">▭</button>';
print '<button type="button" class="tool-btn" data-tool="circle" title="'.$langs->trans("BerichtToolCircle").' (K)">○</button>';
print '<button type="button" class="tool-btn" data-tool="arrow" title="'.$langs->trans("BerichtToolArrow").' (A)">↗</button>';
print '<button type="button" class="tool-btn" data-tool="text" title="'.$langs->trans("BerichtToolText").' (T)">T</button>';
print '<span class="sep"></span>';
print '<label title="Linien-/Textfarbe">'.$langs->trans("BerichtColor").': <input type="color" id="tool-color" value="#ff0000" title="Farbe wählen"></label>';
print '<label title="Linien-/Strichstärke in Pixel">'.$langs->trans("BerichtStrokeWidth").': <input type="range" id="tool-stroke" min="1" max="20" value="3" title="Strichstärke"></label>';
print '<span class="sep"></span>';
print '<button type="button" id="btn-undo" title="'.$langs->trans("BerichtUndo").'">↶</button>';
print '<button type="button" id="btn-redo" title="'.$langs->trans("BerichtRedo").'">↷</button>';
print '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").'">🗑️</button>';
print '<button type="button" id="btn-undo" title="'.$langs->trans("BerichtUndo").' (Strg+Z)">↶</button>';
print '<button type="button" id="btn-redo" title="'.$langs->trans("BerichtRedo").' (Strg+Y)">↷</button>';
print '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").' (Entf)">🗑️</button>';
// ====== Zweite Zeile ======
print '<div class="row-break"></div>';
@ -547,11 +602,21 @@ document.addEventListener("click", function(e) {
.'<option value="bottomleft">Unten links</option>'
.'<option value="bottomright">Unten rechts</option>'
.'</select></label>';
print '<button type="button" id="btn-toggle-margins" class="active" title="Seitenränder und Kopf-/Fußbereich ein- oder ausblenden">📐 Rand</button>';
print '</div>';
print '<div class="bericht-canvas-wrap">';
// Das Blatt: Kopfzone (Logo/Titel), Inhaltsbereich (= Arbeitsfläche), Fusszone.
// Die Arbeitsfläche entspricht 1:1 dem Bereich, der später gedruckt wird.
print '<div id="bericht-sheet" class="bericht-sheet">';
print '<div class="sheet-zone sheet-header"><span class="sheet-zone-label">Kopfbereich — Logo, Titel, Firmenname</span></div>';
print '<div class="sheet-body">';
print '<canvas id="pdf-canvas"></canvas>';
print '<canvas id="fabric-canvas" class="fabric-overlay"></canvas>';
print '</div>';
print '<div id="sheet-note-zone" class="sheet-zone sheet-note" style="display:none;"><span class="sheet-zone-label">Notiz</span></div>';
print '<div class="sheet-zone sheet-footer"><span class="sheet-zone-label">Fusszeile — Seitenzahl</span></div>';
print '</div>';
print '</div>';
print '<div class="bericht-page-note">';
print '<label>'.$langs->trans("BerichtNoteHint").'</label>';
// DolEditor (CKEditor) — wird im PDF mit writeHTMLCell gerendert
@ -567,31 +632,15 @@ document.addEventListener("click", function(e) {
print '<h4>'.$langs->trans("BerichtPages").' (<span id="page-count">'.count($pages).'</span>)</h4>';
print '<button type="button" id="btn-toggle-thumb-bg" title="Hell/Dunkel">🌓</button>';
print '</div>';
print '<div id="bericht-page-list" class="page-list paper-light">';
foreach ($pages as $idx => $p) {
// Ist das eine gehärtete Unterschrift? (hat .meta.json neben der source_path)
$is_signature = false;
if ($p->source_path && strpos($p->note ?? '', 'Unterschrift') === 0) {
$full_check = DOL_DATA_ROOT.'/'.$p->source_path;
if (file_exists($full_check.'.meta.json')) {
$is_signature = true;
}
}
print '<div class="page-thumb'.($is_signature ? ' page-signature' : '').'" data-pageid="'.$p->id.'" data-order="'.$p->page_order.'">';
print '<div class="page-thumb-paper">';
print '<canvas class="thumb-canvas"></canvas>';
// Sammelaktionen — erscheinen, sobald Seiten markiert sind (Klick auf die Nummer)
print '<div id="bericht-page-bulk" class="bericht-page-bulk" style="display:none;">';
print '<span><strong id="page-sel-count">0</strong> markiert</span>';
print '<button type="button" id="btn-pages-duplicate" title="Markierte Seiten verdoppeln — die Kopie kommt direkt dahinter">⧉</button>';
print '<button type="button" id="btn-pages-delete" title="Markierte Seiten löschen">🗑️</button>';
print '<button type="button" id="btn-pages-unselect" title="Markierung aufheben (Esc)">✕</button>';
print '</div>';
print '<div class="page-thumb-label"><span class="page-num">'.($idx + 1).'</span>'
.($is_signature ? ' <span class="sig-badge" title="Unterschrift">🔒</span>' : '')
.'</div>';
print '<div class="page-thumb-actions">';
if ($is_signature) {
print '<button type="button" class="thumb-verify" title="Unterschrift prüfen" data-pageid="'.$p->id.'">🔍</button>';
}
print '<button type="button" class="thumb-del" title="'.$langs->trans("BerichtDeletePage").'">🗑️</button>';
print '</div>';
print '</div>';
}
bericht_print_page_list($pages);
print '</div>';
print '</aside>';
@ -599,7 +648,8 @@ document.addEventListener("click", function(e) {
// Footer-Aktionen
print '<div class="bericht-actions">';
print '<button type="button" id="btn-save-draft" class="butAction" title="Aktuellen Stand als Entwurf speichern">💾 '.$langs->trans("BerichtSaveDraft").'</button>';
print '<button type="button" id="btn-save-draft" class="butAction" title="Aktuellen Stand als Entwurf speichern (Strg+S) — es wird ohnehin automatisch gespeichert">💾 '.$langs->trans("BerichtSaveDraft").'</button>';
print '<span id="bericht-save-state" class="save-state" title="Änderungen werden nach kurzer Pause automatisch gespeichert"></span>';
print '<button type="button" id="btn-preview" class="butAction" title="PDF-Vorschau ansehen ohne zu finalisieren">👁️ Vorschau</button>';
print '<button type="button" id="btn-save-as-template" class="butAction" title="Aktuellen Bericht als wiederverwendbare Vorlage speichern">📋 Als Vorlage</button>';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=new_version&berichtid='.$bericht->id.'&token='.newToken().'" '
@ -615,6 +665,18 @@ document.addEventListener("click", function(e) {
print '</div>'; // .bericht-editor
// Statusleiste für den Bild-Austausch — wird sichtbar, sobald auf einer
// Seite "🔄" geklickt wurde. Fest am unteren Rand, damit sie beim Scrollen
// durch die Fotos nicht verschwindet.
print '<div id="bericht-replace-hint" class="bericht-replace-hint" style="display:none;">';
print '<span class="replace-hint-icon">🔄</span>';
print '<span class="replace-hint-text">Ersatzbild für <strong id="replace-page-label">Seite</strong> wählen — links auf ein Bild klicken</span>';
print '<span id="replace-slot-picker" class="replace-slot-picker" style="display:none;">';
print '<span class="replace-slot-label">Welcher Platz?</span><span id="replace-slot-buttons"></span>';
print '</span>';
print '<button type="button" id="btn-cancel-replace" title="Austausch abbrechen (Esc)">Abbrechen (Esc)</button>';
print '</div>';
// PDF-Vorschau-Modal
print '<div id="bericht-preview-modal" class="bericht-modal" style="display:none;">';
print ' <div class="bericht-modal-backdrop"></div>';

View file

@ -481,6 +481,51 @@ class BerichtPage
* Berechnet die Slot-Rectangles innerhalb einer Seite (mm) für ein Grid-Layout.
* @return array [['x'=>..,'y'=>..,'w'=>..,'h'=>..], ...]
*/
/**
* Verteilt die Slots eines Layouts in ein vorgegebenes Rechteck (mm).
*
* Anders als slotRects() rechnet das NICHT von der Seitenkante aus, sondern
* innerhalb des Inhaltsbereichs so, wie ihn der Editor darstellt.
*
* @return array [['x'=>..,'y'=>..,'w'=>..,'h'=>..], ...]
*/
public static function slotRectsInBox($layout, $x, $y, $w, $h, $gap = 4)
{
$rects = array();
if ($layout === 'grid_2') {
$rh = ($h - $gap) / 2;
for ($i = 0; $i < 2; $i++) {
$rects[] = array('x' => $x, 'y' => $y + $i * ($rh + $gap), 'w' => $w, 'h' => $rh);
}
} elseif ($layout === 'grid_2v' || $layout === 'before_after') {
$rw = ($w - $gap) / 2;
for ($i = 0; $i < 2; $i++) {
$rects[] = array('x' => $x + $i * ($rw + $gap), 'y' => $y, 'w' => $rw, 'h' => $h);
}
} elseif ($layout === 'grid_4') {
$rw = ($w - $gap) / 2;
$rh = ($h - $gap) / 2;
for ($r = 0; $r < 2; $r++) {
for ($c = 0; $c < 2; $c++) {
$rects[] = array('x' => $x + $c * ($rw + $gap), 'y' => $y + $r * ($rh + $gap), 'w' => $rw, 'h' => $rh);
}
}
} elseif ($layout === 'grid_6') {
$rw = ($w - 2 * $gap) / 3;
$rh = ($h - $gap) / 2;
for ($r = 0; $r < 2; $r++) {
for ($c = 0; $c < 3; $c++) {
$rects[] = array('x' => $x + $c * ($rw + $gap), 'y' => $y + $r * ($rh + $gap), 'w' => $rw, 'h' => $rh);
}
}
} else {
$rects[] = array('x' => $x, 'y' => $y, 'w' => $w, 'h' => $h);
}
return $rects;
}
public static function slotRects($layout, $pageW, $pageH, $margin = 10, $gap = 4)
{
$rects = array();

View file

@ -27,7 +27,7 @@ class modBericht extends DolibarrModules
$this->editor_name = 'Alles Watt läuft';
$this->editor_url = '';
$this->version = '1.4.0';
$this->version = '1.5.0';
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
$this->picto = 'fa-file-pdf';

View file

@ -5,16 +5,162 @@
.bericht-overview-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
.bericht-editor { padding-top: 10px; }
.bericht-meta { margin-bottom: 12px; }
/* ================= Kopfbereich des Editors =================
Werte aus dem AwlDark-Theme mit Light-Fallback (KB #512). */
.bericht-meta {
margin-bottom: 14px;
background: var(--colorbacklinepair1, #fff);
border: 1px solid var(--colorbacklinebreak, #e3e3e7);
border-radius: 6px;
overflow: hidden;
}
/* --- obere Zeile: Ref, Version, Status, Eckdaten --- */
.bericht-meta-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 18px;
padding: 10px 14px;
background: var(--colorbacktitle1, #f5f5f5);
border-bottom: 1px solid var(--colorbacklinebreak, #e3e3e7);
}
.bericht-meta-ident {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
min-width: 0;
}
.bericht-meta-icon { font-size: 17px; line-height: 1; }
.bericht-meta-ref {
font-size: 16px;
font-weight: 600;
letter-spacing: 0.2px;
color: var(--colortexttitlenotab, #2a5d8f);
}
.bericht-meta-version {
font-size: 11px;
color: var(--colortexttitlenotab2, #777);
border: 1px solid var(--colorbacklinebreak, #ddd);
border-radius: 10px;
padding: 1px 7px;
}
.bericht-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 3px 9px;
border-radius: 10px;
white-space: nowrap;
}
.badge-draft { background: #8a6d3b; color: #fff; }
.badge-final { background: #2e8b57; color: #fff; }
.bericht-meta-versions { font-size: 11px; color: var(--colortexttitlenotab2, #777); }
.bericht-meta-versions a { color: var(--colortextlink, #1d99f3); }
.bericht-meta-facts {
display: flex; flex-wrap: wrap; align-items: center;
gap: 6px 22px;
font-size: 12px;
}
.bericht-fact { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; }
.bericht-fact-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--colortexttitlenotab2, #888);
}
.bericht-fact strong { font-weight: 600; color: var(--colortext, #222); }
/* --- untere Zeile: bearbeitbare Felder --- */
.bericht-meta-fields {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 18px;
padding: 12px 14px;
}
.bericht-field { display: flex; flex-direction: column; gap: 4px; min-width: 170px; }
.bericht-field.grow { flex: 1 1 260px; }
.bericht-field label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--colortexttitlenotab2, #888);
}
.bericht-field-row { display: flex; gap: 6px; }
.bericht-editor .bericht-field input[type=text],
.bericht-editor .bericht-field select {
background: var(--inputbackgroundcolor, #fff);
color: var(--colortext, #222);
border: 1px solid var(--inputbordercolor, #ccc);
border-radius: 4px;
padding: 6px 9px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
}
.bericht-editor .bericht-field input[type=text]:focus,
.bericht-editor .bericht-field select:focus {
outline: none;
border-color: var(--btncolorborderhover, #3daee9);
box-shadow: 0 0 0 2px rgba(61,174,233,0.25);
}
.bericht-field-hint {
font-size: 10px;
color: var(--colortexttitlenotab2, #888);
font-style: italic;
}
.bericht-layout {
display: grid;
/* minmax(0, 1fr) verhindert, dass die mittlere Spalte über die verfügbare
/* Erste Spalte ist ziehbar (--att-width, per Splitter gesetzt).
minmax(0, 1fr) verhindert, dass die mittlere Spalte über die verfügbare
Breite hinaus wächst, wenn der Canvas-Inhalt zu breit wird */
grid-template-columns: 260px minmax(0, 1fr) 220px;
--att-width: 260px;
grid-template-columns: var(--att-width) 6px minmax(0, 1fr) 220px;
gap: 12px;
align-items: start;
}
/* Eingeklappt: die Spalte verschwindet, die Arbeitsfläche bekommt den Platz */
.bericht-layout.att-collapsed { grid-template-columns: 26px 6px minmax(0, 1fr) 220px; }
.bericht-layout.att-collapsed .bericht-attachments > *:not(.bericht-att-header) { display: none; }
.bericht-layout.att-collapsed .bericht-att-header h4,
.bericht-layout.att-collapsed .bericht-att-viewswitch > *:not(#att-collapse-btn) { display: none; }
.bericht-layout.att-collapsed .bericht-attachments { padding: 6px 3px; overflow: hidden; }
.bericht-layout.att-collapsed .bericht-att-header { justify-content: center; }
.bericht-layout.att-collapsed #att-collapse-btn { margin-bottom: 0; }
/* ---------- Ziehbare Trennlinie ---------- */
.bericht-splitter {
align-self: stretch;
min-height: 120px;
cursor: col-resize;
border-radius: 3px;
background: var(--colorbacklinebreak, #ddd);
opacity: 0.35;
transition: opacity 0.15s ease;
}
.bericht-splitter:hover,
.bericht-splitter.dragging { opacity: 1; background: var(--btncolorborderhover, #3daee9); }
/* Während des Ziehens darf nichts markiert werden */
body.bericht-resizing { cursor: col-resize; user-select: none; }
#att-collapse-btn {
background: transparent;
border: 1px solid var(--colorboxbordertitle1, #555);
color: var(--colortext, inherit);
border-radius: 3px;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 3px 6px;
opacity: 0.6;
}
#att-collapse-btn:hover { opacity: 1; background: var(--colorbacklinepairchecked, #337ab7); color: #fff; }
.bericht-attachments,
.bericht-pages {
@ -62,32 +208,471 @@
color: var(--colortextlink, #555);
}
/* ---------- Anhänge: Kopfzeile + Ansicht-Umschalter ---------- */
.bericht-att-header {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
}
.bericht-att-header h4 { margin: 0 0 8px 0; }
.bericht-att-viewswitch { display: flex; gap: 2px; margin-bottom: 8px; }
.att-view-btn {
background: transparent;
border: 1px solid var(--colorboxbordertitle1, #555);
color: var(--colortext, inherit);
border-radius: 3px;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 3px 6px;
opacity: 0.6;
}
.att-view-btn.active, .att-view-btn:hover { opacity: 1; background: var(--colorbacklinepairchecked, #337ab7); color: #fff; }
#att-size-btn {
background: transparent;
border: 1px solid var(--colorboxbordertitle1, #555);
color: var(--colortext, inherit);
border-radius: 3px;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 3px 6px;
opacity: 0.6;
}
#att-size-btn:hover { opacity: 1; background: var(--colorbacklinepairchecked, #337ab7); color: #fff; }
/* ---------- Anhänge: Sortierung + Filter ---------- */
.bericht-att-tools {
display: flex;
gap: 5px;
margin-bottom: 8px;
}
.bericht-att-tools select {
flex: 1;
min-width: 0;
background: var(--inputbackgroundcolor, #fff);
color: var(--colortext, #222);
border: 1px solid var(--inputbordercolor, #ccc);
border-radius: 3px;
font-size: 11px;
padding: 3px 4px;
}
/* ---------- Anhänge: Gruppen ---------- */
.bericht-att-group { margin-bottom: 10px; }
.bericht-att-group-title {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
font-weight: 600;
font-size: 11px;
opacity: 0.7;
opacity: 0.75;
padding: 4px 0;
margin-bottom: 6px;
border-bottom: 1px solid var(--colorboxbordertitle1, #ddd);
}
.bericht-att-item {
.att-group-ref { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.att-group-toggle {
flex: 0 0 auto;
background: transparent;
border: 1px solid var(--colorboxbordertitle1, #555);
color: var(--colortext, inherit);
border-radius: 3px;
cursor: pointer;
font-size: 10px;
padding: 1px 5px;
opacity: 0.7;
}
.att-group-toggle:hover { opacity: 1; background: var(--colorbackhmenu1, #337ab7); color: #fff; }
/* ---------- Anhänge: Kachelraster ---------- */
/* Kachelgröße: klein / mittel / groß (Schalter ⤢) */
#bericht-att-list { --tile-min: 96px; --tile-h: 84px; }
#bericht-att-list[data-tilesize="s"] { --tile-min: 68px; --tile-h: 58px; }
#bericht-att-list[data-tilesize="l"] { --tile-min: 130px; --tile-h: 118px; }
.att-view-grid .bericht-att-tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--tile-min, 96px), 1fr));
gap: 6px;
}
.att-view-grid .bericht-att-item {
position: relative;
border: 1px solid var(--colorboxbordertitle1, #444);
border-radius: 4px;
overflow: hidden;
background: var(--colorbackbody, #23232a);
color: var(--colortext, inherit);
cursor: default;
}
.att-view-grid .bericht-att-item:hover { border-color: var(--colorbackhmenu1, #337ab7); }
.att-view-grid .bericht-att-item.selected {
border-color: var(--colorbackhmenu1, #337ab7);
box-shadow: 0 0 0 2px var(--colorbackhmenu1, #337ab7) inset;
}
.att-view-grid .bericht-att-item.is-used { opacity: 0.55; }
.att-view-grid .bericht-att-item.is-used:hover { opacity: 1; }
.att-view-grid .att-thumb {
position: relative;
display: block;
width: 100%;
/* feste Kachelhöhe -> gleichmäßiges Raster, egal ob Hoch- oder Querformat */
height: var(--tile-h, 84px);
background: var(--colorbacktitle1, #1c1c22);
cursor: zoom-in;
overflow: hidden;
}
.att-view-grid .att-thumb-img,
.att-view-grid .att-thumb-canvas {
width: 100%;
height: 100%;
object-fit: cover; /* Kachel füllen, Ausschnitt zeigen */
display: block;
}
/* PDFs: oberen Seitenbereich formatfüllend — der Briefkopf macht das Dokument erkennbar */
.att-view-grid .att-thumb-canvas { object-fit: cover; object-position: top center; background: #fff; }
.att-view-grid .att-thumb-img[data-loaded="0"] { opacity: 0; }
.att-thumb-fallback {
display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%; font-size: 24px; opacity: 0.5;
}
.att-type-badge {
position: absolute; right: 3px; bottom: 3px;
background: rgba(0,0,0,0.72); color: #fff;
font-size: 9px; font-weight: 600; letter-spacing: 0.4px;
padding: 1px 4px; border-radius: 2px;
pointer-events: none;
}
.att-used-badge {
position: absolute; right: 3px; top: 3px;
background: #2e8b57; color: #fff;
font-size: 10px; line-height: 14px;
width: 15px; height: 15px; text-align: center;
border-radius: 50%;
pointer-events: none;
}
/* Auswahl: echte Checkbox verstecken, Label ist das Klickziel auf der Kachel */
.att-view-grid .att-check { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.att-view-grid .att-check-ui {
position: absolute; left: 4px; top: 4px;
width: 18px; height: 18px;
border: 2px solid rgba(255,255,255,0.85);
border-radius: 3px;
background: rgba(0,0,0,0.45);
cursor: pointer;
z-index: 2;
display: flex; align-items: center; justify-content: center;
}
.att-view-grid .att-check-ui:hover { background: rgba(0,0,0,0.7); }
.att-view-grid .att-check:checked + .att-check-ui {
background: var(--colorbackhmenu1, #337ab7);
border-color: #fff;
}
.att-order {
color: #fff; font-size: 10px; font-weight: 700; line-height: 1;
}
.att-view-grid .att-delete {
position: absolute; right: 2px; top: 2px;
background: rgba(0,0,0,0.55);
border: none; border-radius: 3px;
color: #fff;
cursor: pointer;
opacity: 0;
padding: 1px 3px;
font-size: 11px;
z-index: 2;
}
.att-view-grid .bericht-att-item:hover .att-delete { opacity: 0.8; }
.att-view-grid .att-delete:hover { opacity: 1; color: #ff6b6b; background: rgba(0,0,0,0.8); }
/* wenn schon verwendet, sitzt das ✓-Badge oben rechts — Löschen daneben */
.att-view-grid .bericht-att-item.is-used .att-delete { right: 21px; }
#bericht-att-list[data-tilesize="s"] .att-name { -webkit-line-clamp: 1; line-clamp: 1; }
#bericht-att-list[data-tilesize="s"] .att-size { display: none; }
.att-view-grid .att-meta {
padding: 3px 4px 4px 4px;
font-size: 10px;
line-height: 1.25;
cursor: pointer; /* Klick auf die Beschriftung wählt aus */
}
.att-view-grid .att-name {
display: -webkit-box;
-webkit-line-clamp: 2; /* zwei Zeilen, dann Ellipse */
-webkit-box-orient: vertical;
line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
.att-view-grid .att-size { display: block; font-size: 9px; opacity: 0.55; }
/* ---------- Anhänge: kompakte Listenansicht (alter Look) ---------- */
.att-view-list .bericht-att-tiles { display: block; }
.att-view-list .bericht-att-item {
position: relative;
display: flex; align-items: center; gap: 6px;
padding: 4px 2px; font-size: 12px;
padding: 3px 2px; font-size: 12px;
color: var(--colortext, inherit);
}
.bericht-att-item:hover { background: rgba(255,255,255,0.05); }
.att-delete {
.att-view-list .bericht-att-item:hover { background: rgba(255,255,255,0.05); }
.att-view-list .bericht-att-item.selected { background: rgba(51,122,183,0.25); }
.att-view-list .bericht-att-item.is-used { opacity: 0.6; }
.att-view-list .att-thumb {
flex: 0 0 auto; width: 28px; height: 28px;
background: var(--colorbacktitle1, #1c1c22);
border-radius: 3px; overflow: hidden; cursor: zoom-in;
position: relative;
}
.att-view-list .att-thumb-img,
.att-view-list .att-thumb-canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
.att-view-list .att-thumb-canvas { object-position: top center; background: #fff; }
.att-view-list .att-type-badge { font-size: 7px; right: 1px; bottom: 1px; padding: 0 2px; }
.att-view-list .att-used-badge { display: none; }
.att-view-list .att-thumb-fallback { font-size: 14px; }
.att-view-list .att-check { flex: 0 0 auto; position: static; opacity: 1; width: auto; height: auto; pointer-events: auto; }
.att-view-list .att-check-ui { display: none; }
.att-view-list .att-meta {
flex: 1; min-width: 0;
display: flex; align-items: baseline; gap: 6px;
cursor: pointer;
}
.att-view-list .att-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.att-view-list .att-size { font-size: 10px; opacity: 0.6; flex: 0 0 auto; }
.att-view-list .att-delete {
flex: 0 0 auto;
background: transparent; border: none; cursor: pointer;
opacity: 0.4; padding: 2px 4px; font-size: 13px;
}
.att-view-list .att-delete:hover { opacity: 1; color: #d9534f; }
/* ---------- Raster-Plätze auf der Arbeitsfläche ---------- */
.bericht-slot-overlay {
position: absolute;
/* Der Fabric-Container liegt auf z-index 10 — das Gitter muss darüber */
z-index: 11;
pointer-events: none; /* nur die Plätze selbst reagieren */
}
/* Beim Zeichnen haben die Werkzeuge Vorrang */
.bericht-slot-overlay.slots-off .bericht-slot { pointer-events: none; opacity: 0.3; }
.bericht-slot {
position: absolute;
pointer-events: auto;
background: transparent;
border: 1px dashed rgba(61,174,233,0.55);
border-radius: 3px;
cursor: pointer;
padding: 0;
transition: background 0.12s ease, border-color 0.12s ease;
}
.bericht-slot:hover,
.bericht-slot.drop-active {
background: rgba(61,174,233,0.18);
border-color: #3daee9;
border-style: solid;
}
.slot-badge {
position: absolute;
left: 4px;
top: 4px;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 1;
padding: 3px 6px;
border-radius: 3px;
opacity: 0.75;
}
.bericht-slot:hover .slot-badge { opacity: 1; background: #3daee9; color: #10151a; }
/* ---------- Seitenliste: Markierung + Sammelaktionen ---------- */
.bericht-page-bulk {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding: 5px 7px;
background: var(--colorbacklinepairchecked, #1e5774);
border: 1px solid var(--btncolorborderhover, #3daee9);
border-radius: 4px;
font-size: 11px;
color: var(--colortext, #fff);
}
.bericht-page-bulk strong { color: #fff; }
.bericht-page-bulk button {
background: rgba(0,0,0,0.3);
border: 1px solid var(--btncolorborderhover, #3daee9);
color: var(--colortext, #fff);
border-radius: 3px;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 3px 7px;
}
.bericht-page-bulk button:hover { background: rgba(0,0,0,0.55); }
#btn-pages-unselect { margin-left: auto; }
.page-thumb .page-num { cursor: pointer; padding: 0 4px; border-radius: 3px; }
.page-thumb .page-num:hover { background: var(--btncolorborderhover, #3daee9); color: #10151a; }
.page-thumb.page-selected {
outline: 2px solid var(--btncolorborderhover, #3daee9);
outline-offset: 1px;
}
.page-thumb.page-selected .page-num {
background: var(--btncolorborderhover, #3daee9);
color: #10151a;
font-weight: 700;
}
/* ---------- Ziehen und Ablegen ---------- */
.bericht-att-item.dragging { opacity: 0.45; }
body.bericht-dragging-attachment .att-thumb { cursor: grabbing; }
/* Aktives Ablegeziel */
.drop-active {
outline: 2px dashed var(--btncolorborderhover, #3daee9);
outline-offset: -3px;
background: var(--colorbacklinepairchecked, rgba(61,174,233,0.12));
}
#bericht-page-list.drop-active { position: relative; border-radius: 4px; }
#bericht-page-list.drop-active::before {
content: attr(data-drop-hint);
position: sticky;
top: 0;
z-index: 3;
display: block;
margin-bottom: 6px;
padding: 6px 8px;
background: var(--btncolorborderhover, #3daee9);
color: #10151a;
font-size: 11px;
font-weight: 600;
text-align: center;
border-radius: 3px;
}
.bericht-canvas-wrap.drop-active::after {
content: "Bild dieser Seite ersetzen";
position: absolute;
left: 50%;
top: 12px;
transform: translateX(-50%);
z-index: 6;
padding: 6px 12px;
background: var(--btncolorborderhover, #3daee9);
color: #10151a;
font-size: 12px;
font-weight: 600;
border-radius: 3px;
pointer-events: none;
}
.bericht-attachments.drop-active::after {
content: "Dateien hier ablegen zum Hochladen";
position: sticky;
bottom: 0;
display: block;
margin-top: 6px;
padding: 6px 8px;
background: var(--btncolorborderhover, #3daee9);
color: #10151a;
font-size: 11px;
font-weight: 600;
text-align: center;
border-radius: 3px;
}
/* ---------- Speicherstand in der Fußleiste ---------- */
.save-state {
font-size: 11px;
margin-left: 4px;
align-self: center;
white-space: nowrap;
}
.save-state.dirty { color: #f67400; }
.save-state.saved { color: #2e8b57; }
/* ---------- Austauschmodus: Ersatzbild für eine Seite wählen ---------- */
.bericht-replace-hint {
/* fest am unteren Bildschirmrand: der Austausch ist ein Modus, und der
muss sichtbar bleiben, auch wenn man durch 40 Fotos scrollt */
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1200;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
padding: 10px 18px;
background: var(--colorbacklinepairchecked, #1e5774);
border-top: 2px solid var(--btncolorborderhover, #3daee9);
box-shadow: 0 -3px 12px rgba(0,0,0,0.35);
font-size: 12px;
color: var(--colortext, #fff);
}
.replace-hint-icon { font-size: 15px; }
.replace-hint-text strong { color: #fff; }
.replace-slot-label { opacity: 0.75; margin-right: 6px; }
.replace-slot-picker { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.replace-slot-btn {
background: rgba(0,0,0,0.3);
border: 1px solid var(--btncolorborderhover, #3daee9);
color: var(--colortext, #fff);
border-radius: 3px;
cursor: pointer;
font-size: 11px;
line-height: 1;
padding: 4px 8px;
}
.replace-slot-btn:hover { background: rgba(0,0,0,0.5); }
.replace-slot-btn.active { background: var(--btncolorborderhover, #3daee9); color: #10151a; font-weight: 700; }
#btn-cancel-replace {
margin-left: auto;
background: transparent;
border: 1px solid var(--colorbacklinebreak, #555);
color: var(--colortext, inherit);
border-radius: 3px;
cursor: pointer;
font-size: 10px;
padding: 3px 8px;
}
#btn-cancel-replace:hover { background: rgba(0,0,0,0.35); }
/* Im Austauschmodus: Kacheln als Ziel kennzeichnen */
body.bericht-replacing .att-thumb { cursor: crosshair; }
body.bericht-replacing .att-view-grid .bericht-att-item:hover {
border-color: var(--btncolorborderhover, #3daee9);
box-shadow: 0 0 0 2px var(--btncolorborderhover, #3daee9) inset;
}
body.bericht-replacing .att-view-grid .att-check-ui,
body.bericht-replacing .att-delete { display: none; }
/* Die Seite, deren Bild ersetzt wird */
#bericht-page-list .page-thumb.replace-target {
outline: 2px solid var(--btncolorborderhover, #3daee9);
outline-offset: 1px;
}
.thumb-replace {
background: transparent;
border: none;
cursor: pointer;
opacity: 0.4;
opacity: 0.45;
padding: 2px 4px;
font-size: 13px;
font-size: 12px;
}
.thumb-replace:hover { opacity: 1; }
/* ---------- Auswahl-Info über dem Übernehmen-Button ---------- */
.att-selection-info {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
font-size: 11px;
padding: 4px 0;
margin-top: 6px;
border-top: 1px solid var(--colorboxbordertitle1, #444);
}
#att-selected-count { font-weight: 700; }
#att-clear-selection {
background: transparent; border: none; cursor: pointer;
color: var(--colortextlink, #6ea8dc);
font-size: 10px; text-decoration: underline; padding: 0;
}
.att-delete:hover { opacity: 1; color: #d9534f; }
.att-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.att-size { font-size: 10px; opacity: 0.6; }
.att-icon { font-size: 14px; }
.bericht-upload { margin-top: 10px; }
@ -208,18 +793,74 @@
align-items: center;
padding: 40px 20px;
}
/* ---------- Das Blatt: Kopfzone, Inhaltsbereich, Fußzone ----------
Die Zonen bilden ab, was TCPDF später selbst druckt (Logo/Titel oben,
Seitenzahl unten). Der Canvas dazwischen ist 1:1 der Druckbereich. */
.bericht-sheet {
background: #fff;
box-shadow: 0 2px 14px rgba(0,0,0,0.45);
box-sizing: border-box;
display: flex;
flex-direction: column;
/* Höhen/Breiten/Paddings setzt JS aus der echten Seitengeometrie */
}
.bericht-sheet .sheet-body { position: relative; }
.sheet-zone {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
/* dezent schraffiert: gehört zur Seite, ist aber nicht bearbeitbar */
background: repeating-linear-gradient(
45deg,
rgba(0,0,0,0.035) 0 6px,
rgba(0,0,0,0.07) 6px 12px
);
}
.sheet-header { border-bottom: 1px dashed rgba(0,0,0,0.28); }
.sheet-footer { border-top: 1px dashed rgba(0,0,0,0.28); }
.sheet-note {
background: rgba(61,174,233,0.10);
border: 1px dashed rgba(0,0,0,0.28);
}
.sheet-zone-label {
font-size: 10px;
color: rgba(0,0,0,0.45);
letter-spacing: 0.3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 6px;
pointer-events: none;
}
/* Ränder ausgeblendet (Toolbar-Schalter 📐) oder bei eingebundenen PDF-Seiten */
.bericht-sheet.zones-hidden { padding-left: 0 !important; padding-right: 0 !important; width: auto !important; }
.bericht-sheet.zones-hidden .sheet-zone { display: none !important; }
/* --colorbackhmenu1 ist im AwlDark fast identisch mit dem Toolbar-Grau
die "ausgewählt"-Farbe des Themes hebt sich dagegen sichtbar ab. */
#btn-toggle-margins.active {
background: var(--colorbacklinepairchecked, #337ab7);
border-color: var(--btncolorborderhover, #3daee9);
color: #fff;
}
/* WICHTIG: kein max-width auf #pdf-canvas, sonst weicht display- von buffer-size ab
und das Fabric-Overlay rutscht weg. Stattdessen rendert JS auf passende Größe. */
#pdf-canvas {
background: #fff;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
display: block;
}
/* Leerer Zustand: A4 Hochformat anzeigen, damit der Canvas nicht winzig wirkt */
.bericht-canvas-wrap.empty #pdf-canvas {
/* Leerer Zustand: Blatt in A4 anzeigen, damit der Bereich nicht winzig wirkt */
.bericht-canvas-wrap.empty .bericht-sheet {
width: 100%;
max-width: 900px;
aspect-ratio: 1 / 1.414; /* DIN A4 */
}
.bericht-canvas-wrap.empty #pdf-canvas {
width: 100%;
aspect-ratio: 190 / 251; /* Inhaltsbereich A4 hoch */
height: auto;
}
.bericht-canvas-wrap.empty::after {
@ -269,15 +910,17 @@
outline-offset: -1px;
}
/* Generische Input-Styles im Editor — Fallback damit alles zum Dark-Theme passt */
/* Generische Input-Styles im Editor.
--inputbackgroundcolor/--inputbordercolor SIND im AwlDark gesetzt (KB #512,
per DevTools nachgeprüft) dieselben Werte, die Dolibarr für eigene Felder nutzt. */
.bericht-editor textarea,
.bericht-editor input[type="text"],
.bericht-editor input[type="number"],
.bericht-editor input[type="email"],
.bericht-editor input[type="search"] {
background: var(--colorbackbody, #2a2a30);
color: var(--colortext, #e0e0e0);
border: 1px solid var(--colorboxbordertitle1, #555);
background: var(--inputbackgroundcolor, #fff);
color: var(--colortext, #222);
border: 1px solid var(--inputbordercolor, #ccc);
border-radius: 4px;
padding: 8px 10px;
font-size: 14px;
@ -422,8 +1065,10 @@
.bericht-setup-section h3 { margin-top: 0; color: var(--colortextlink, inherit); }
@media (max-width: 1100px) {
.bericht-layout { grid-template-columns: 1fr; }
.bericht-layout,
.bericht-layout.att-collapsed { grid-template-columns: 1fr; }
.bericht-attachments, .bericht-pages { max-height: 300px; }
.bericht-splitter { display: none; }
}
/* PDF-Vorschau-Modal */

View file

@ -219,31 +219,14 @@
to { transform: rotate(360deg); }
}
/* ========== Anhang-Vorschau klickbar machen ========== */
.att-preview {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
flex: 1;
min-width: 0;
padding: 2px 4px;
border-radius: 3px;
transition: background 0.15s ease;
}
.att-preview:hover {
background: rgba(0, 0, 0, 0.08);
}
.att-preview .att-icon {
flex-shrink: 0;
}
.att-preview .att-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* ========== PDF-Anhang im Vollbild ========== */
.bericht-viewer-pdf {
display: none;
width: 92vw;
height: 92vh;
border: none;
border-radius: 4px;
background: #fff;
}
/* ========== Mobile Anpassungen ========== */

File diff suppressed because it is too large Load diff

View file

@ -65,6 +65,7 @@
<div class="bericht-viewer-container">
<div class="bericht-viewer-loading">Laden...</div>
<img class="bericht-viewer-image" src="" alt="" draggable="false">
<iframe class="bericht-viewer-pdf" src="about:blank" title="PDF-Vorschau"></iframe>
</div>
<button type="button" class="bericht-viewer-close" title="Schliessen (Esc)">&times;</button>
<button type="button" class="bericht-viewer-nav prev" title="Vorheriges Bild (←)">&#8249;</button>
@ -80,6 +81,7 @@
this.container = this.overlay.querySelector('.bericht-viewer-container');
this.img = this.overlay.querySelector('.bericht-viewer-image');
this.pdfFrame = this.overlay.querySelector('.bericht-viewer-pdf');
this.closeBtn = this.overlay.querySelector('.bericht-viewer-close');
this.prevBtn = this.overlay.querySelector('.bericht-viewer-nav.prev');
this.nextBtn = this.overlay.querySelector('.bericht-viewer-nav.next');
@ -118,6 +120,7 @@
// Mausrad-Zoom
this.container.addEventListener('wheel', (e) => {
if (this.isPdf()) return; // PDF-Viewer des Browsers zoomt selbst
e.preventDefault();
const delta = e.deltaY > 0 ? -0.2 : 0.2;
const rect = this.img.getBoundingClientRect();
@ -127,9 +130,9 @@
}, { passive: false });
// Touch-Events
this.container.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
this.container.addEventListener('touchmove', (e) => this.handleTouchMove(e), { passive: false });
this.container.addEventListener('touchend', (e) => this.handleTouchEnd(e));
this.container.addEventListener('touchstart', (e) => { if (!this.isPdf()) this.handleTouchStart(e); }, { passive: false });
this.container.addEventListener('touchmove', (e) => { if (!this.isPdf()) this.handleTouchMove(e); }, { passive: false });
this.container.addEventListener('touchend', (e) => { if (!this.isPdf()) this.handleTouchEnd(e); });
// Maus-Drag (für Desktop)
this.img.addEventListener('mousedown', (e) => this.handleMouseDown(e));
@ -189,7 +192,8 @@
document.removeEventListener('keydown', this.boundKeyHandler);
document.body.style.overflow = '';
// Bild-Source leeren (Speicher freigeben)
// Quellen leeren (Speicher freigeben, PDF-Rendering stoppen)
this.pdfFrame.src = 'about:blank';
setTimeout(() => {
if (!this.isOpen) this.img.src = '';
}, 300);
@ -240,11 +244,33 @@
this.loadingEl.style.display = 'block';
this.loadingEl.textContent = 'Laden...';
if (current.type === 'pdf') {
// PDFs im eingebauten Browser-Viewer: der kann scrollen, zoomen, drucken.
this.img.style.display = 'none';
this.img.src = '';
this.pdfFrame.style.display = 'block';
this.pdfFrame.src = current.url;
this.zoomBtns.style.display = 'none';
this.loadingEl.style.display = 'none';
return;
}
this.pdfFrame.style.display = 'none';
this.pdfFrame.src = 'about:blank';
this.zoomBtns.style.display = '';
this.img.style.display = '';
this.img.style.opacity = '0';
this.img.src = current.url;
this.img.alt = current.title || 'Bild ' + (this.currentIndex + 1);
}
/** Ist der aktuell angezeigte Eintrag ein PDF? */
isPdf() {
const cur = this.images[this.currentIndex];
return !!(cur && cur.type === 'pdf');
}
/**
* UI aktualisieren (Counter, Titel, Nav-Buttons)
*/

View file

@ -482,7 +482,7 @@ function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_
if ($dir_key && !empty($conf->{$dir_key}->multidir_output[$parent->entity])) {
$upload_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref);
if (is_dir($upload_dir)) {
$files = dol_dir_list($upload_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$');
$files = dol_dir_list($upload_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$', 'name', SORT_ASC, 1);
foreach ($files as $f) {
$result[] = array(
'source' => $element_type,
@ -494,6 +494,7 @@ function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_
'size' => $f['size'],
'mime' => dol_mimetype($f['name']),
'date' => $f['date'],
'taken_at' => bericht_file_taken_at($f['fullname'], $f['date']),
);
}
}
@ -508,7 +509,7 @@ function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_
if (empty($conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity])) continue;
$linked_dir = $conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity].'/'.dol_sanitizeFileName($lobj->ref);
if (!is_dir($linked_dir)) continue;
$files = dol_dir_list($linked_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$');
$files = dol_dir_list($linked_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$', 'name', SORT_ASC, 1);
foreach ($files as $f) {
$result[] = array(
'source' => $linked_module_type,
@ -520,6 +521,7 @@ function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_
'size' => $f['size'],
'mime' => dol_mimetype($f['name']),
'date' => $f['date'],
'taken_at' => bericht_file_taken_at($f['fullname'], $f['date']),
);
}
}
@ -571,6 +573,595 @@ function bericht_resolve_data_path($relpath)
return $full;
}
/**
* Nummeriert die Seiten eines Berichts lückenlos von 1 an durch.
* Nach dem Löschen bleiben sonst Lücken in page_order stehen.
*/
function bericht_renumber_pages(DoliDB $db, $berichtid)
{
$res = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page"
." WHERE fk_bericht = ".((int) $berichtid)." ORDER BY page_order ASC, rowid ASC");
if (!$res) return -1;
$order = 1;
while ($r = $db->fetch_object($res)) {
$db->query("UPDATE ".$db->prefix()."bericht_page SET page_order = ".((int) $order)
." WHERE rowid = ".((int) $r->rowid));
$order++;
}
return 1;
}
/**
* Sortiert frisch angelegte Kopien direkt hinter ihr jeweiliges Original.
*
* Die Kopien wurden mit page_order 9000+n angelegt; hier bekommen sie ihren
* endgültigen Platz. Erkannt werden sie über gleiche Quelle und Layout.
*
* @param int[] $originals IDs der duplizierten Seiten
*/
function bericht_reorder_with_copies(DoliDB $db, $berichtid, array $originals)
{
$res = $db->query("SELECT rowid, page_order, source_path, layout FROM ".$db->prefix()."bericht_page"
." WHERE fk_bericht = ".((int) $berichtid)." ORDER BY page_order ASC, rowid ASC");
if (!$res) return -1;
$rows = array();
while ($r = $db->fetch_object($res)) $rows[] = $r;
// Kopien (page_order >= 9000) heraustrennen und ihrem Original zuordnen
$normal = array();
$copies = array();
foreach ($rows as $r) {
if ((int) $r->page_order >= 9000) $copies[] = $r;
else $normal[] = $r;
}
$sequence = array();
foreach ($normal as $r) {
$sequence[] = (int) $r->rowid;
if (!in_array((int) $r->rowid, $originals, true)) continue;
// passende Kopie suchen (gleiche Quelle + Layout, noch nicht einsortiert)
foreach ($copies as $k => $c) {
if ($c->source_path === $r->source_path && $c->layout === $r->layout) {
$sequence[] = (int) $c->rowid;
unset($copies[$k]);
break;
}
}
}
// Falls eine Kopie nicht zugeordnet werden konnte: ans Ende
foreach ($copies as $c) $sequence[] = (int) $c->rowid;
$order = 1;
foreach ($sequence as $rowid) {
$db->query("UPDATE ".$db->prefix()."bericht_page SET page_order = ".((int) $order)
." WHERE rowid = ".((int) $rowid));
$order++;
}
return 1;
}
/**
* Rendert die Anhänge-Kacheln (Inhalt von #bericht-att-list).
*
* Steckt in einer Funktion, damit derselbe Code die Erstausgabe der Karte und
* die AJAX-Aktualisierung bedient sonst laufen beide Darstellungen auseinander.
*
* @param array $by_source Anhänge, gruppiert nach "source:ref"
* @param array $used_paths Set relpath => true der bereits verwendeten Dateien
*/
function bericht_print_attachment_list(array $by_source, array $used_paths)
{
global $user;
print '<div id="bericht-att-list" class="att-view-grid">';
$tile_no = 0;
foreach ($by_source as $key => $files) {
list($src, $ref) = explode(':', $key, 2);
print '<div class="bericht-att-group" data-group="'.dol_escape_htmltag($key).'">';
print '<div class="bericht-att-group-title">';
print '<span class="att-group-ref" title="'.dol_escape_htmltag($ref).'">'.dol_escape_htmltag($ref).'</span>';
print '<button type="button" class="att-group-toggle" data-group="'.dol_escape_htmltag($key).'" title="Alle Dateien dieser Herkunft aus-/abwählen">alle</button>';
print '</div>';
print '<div class="bericht-att-tiles">';
foreach ($files as $f) {
$is_image = (strpos($f['mime'], 'image') === 0);
$is_pdf = (strpos($f['mime'], 'pdf') !== false);
$is_used = !empty($used_paths[$f['relpath']]);
$urls = bericht_attachment_urls($f['relpath'], $is_image);
$tile_no++;
$cid = 'attc-'.$tile_no;
$taken = !empty($f['taken_at']) ? (int) $f['taken_at'] : 0;
print '<div class="bericht-att-item'.($is_used ? ' is-used' : '').'"'
.' data-relpath="'.dol_escape_htmltag($f['relpath']).'"'
.' data-mime="'.dol_escape_htmltag($f['mime']).'"'
.' data-group="'.dol_escape_htmltag($key).'"'
.' data-name="'.dol_escape_htmltag(strtolower($f['filename'])).'"'
.' data-size="'.((int) $f['size']).'"'
.' data-kind="'.($is_image ? 'image' : ($is_pdf ? 'pdf' : 'other')).'"'
.' data-taken="'.$taken.'">';
// Miniatur — Bild direkt vom Thumb-Endpoint, PDF wird per PDF.js gerendert
print '<div class="att-thumb'.($is_image ? '' : ($is_pdf ? ' is-pdf' : ' is-other')).'"'
.' data-view-url="'.dol_escape_htmltag($urls['view']).'"'
.' data-type="'.($is_image ? 'image' : ($is_pdf ? 'pdf' : 'other')).'"'
.' data-filename="'.dol_escape_htmltag($f['filename']).'"'
.' title="'.dol_escape_htmltag($f['filename']).' — klicken zum Vergrössern">';
if ($is_image) {
print '<img class="att-thumb-img" loading="lazy" alt="" src="'.dol_escape_htmltag($urls['thumb']).'">';
} elseif ($is_pdf) {
print '<canvas class="att-thumb-canvas" data-pdf-url="'.dol_escape_htmltag($urls['view']).'"></canvas>';
print '<span class="att-type-badge">PDF</span>';
} else {
print '<span class="att-thumb-fallback">📎</span>';
}
if ($is_used) print '<span class="att-used-badge" title="Diese Datei ist bereits im Bericht">✓</span>';
print '</div>';
// Auswahl — Checkbox liegt als grosses Ziel auf der Kachel
print '<input type="checkbox" class="att-check" id="'.$cid.'"'
.' data-relpath="'.dol_escape_htmltag($f['relpath']).'"'
.' data-mime="'.dol_escape_htmltag($f['mime']).'" title="Auswählen">';
print '<label class="att-check-ui" for="'.$cid.'" title="Auswählen"><span class="att-order"></span></label>';
if ($user->hasRight('bericht', 'write')) {
print '<button type="button" class="att-delete" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-source-ref="'.dol_escape_htmltag($f['source_ref']).'" title="Diese Datei aus dem Anhang löschen">🗑️</button>';
}
print '<div class="att-meta">';
print '<span class="att-name" title="'.dol_escape_htmltag($f['filename']).'">'.dol_escape_htmltag($f['filename']).'</span>';
$meta_line = dol_print_size($f['size'], 1, 1);
if ($taken) {
$meta_line = dol_print_date($taken, '%d.%m. %H:%M').' · '.$meta_line;
}
print '<span class="att-size opacitymedium" title="'
.($taken ? 'Aufnahme: '.dol_print_date($taken, 'dayhour') : '').'">'.$meta_line.'</span>';
print '</div>';
print '</div>'; // .bericht-att-item
}
print '</div>'; // .bericht-att-tiles
print '</div>'; // .bericht-att-group
}
print '</div>'; // #bericht-att-list
}
/**
* Rendert die Seiten-Miniaturen (Inhalt von #bericht-page-list).
*/
function bericht_print_page_list(array $pages)
{
global $user, $langs;
print '<div id="bericht-page-list" class="page-list paper-light">';
foreach ($pages as $idx => $p) {
// Ist das eine gehärtete Unterschrift? (hat .meta.json neben der source_path)
$is_signature = false;
if ($p->source_path && strpos($p->note ?? '', 'Unterschrift') === 0) {
$full_check = DOL_DATA_ROOT.'/'.$p->source_path;
if (file_exists($full_check.'.meta.json')) {
$is_signature = true;
}
}
print '<div class="page-thumb'.($is_signature ? ' page-signature' : '').'" data-pageid="'.$p->id.'"'
.' data-order="'.$p->page_order.'" data-layout="'.dol_escape_htmltag($p->layout ?: 'single').'">';
print '<div class="page-thumb-paper">';
print '<canvas class="thumb-canvas"></canvas>';
print '</div>';
print '<div class="page-thumb-label"><span class="page-num" title="Klicken zum Markieren (Umschalt für einen Bereich)">'.($idx + 1).'</span>'
.($is_signature ? ' <span class="sig-badge" title="Unterschrift">🔒</span>' : '')
.'</div>';
print '<div class="page-thumb-actions">';
if ($is_signature) {
print '<button type="button" class="thumb-verify" title="Unterschrift prüfen" data-pageid="'.$p->id.'">🔍</button>';
} elseif ($user->hasRight('bericht', 'write')) {
print '<button type="button" class="thumb-replace" title="Bild dieser Seite austauschen — Reihenfolge, Notiz und Anmerkungen bleiben erhalten">🔄</button>';
}
print '<button type="button" class="thumb-del" title="'.$langs->trans("BerichtDeletePage").'">🗑️</button>';
print '</div>';
print '</div>';
}
}
/**
* Seitengeometrie in mm die EINE Quelle für Editor und PDF.
*
* Der Editor stellt genau den Inhaltsbereich als Arbeitsfläche dar; die
* PDF-Erzeugung setzt das dort entstandene Composite-PNG unveraendert in
* dieselben Koordinaten. Dadurch entspricht die Vorschau dem Ergebnis
* (vorher wurde das ganze Blatt nochmal in den Satzspiegel eingepasst und
* der Inhalt damit ein zweites Mal verkleinert).
*
* @param string $format A4|A3|A5|Letter
* @param string $orientation P|L
* @param bool $has_note Seite hat eine Notiz -> unten Platz reservieren
* @return array{pageW:float,pageH:float,mL:float,mR:float,mT:float,mB:float,noteH:float,contentX:float,contentY:float,contentW:float,contentH:float}
*/
function bericht_page_geometry($format = 'A4', $orientation = 'P', $has_note = false)
{
$sizes = array(
'A4' => array(210.0, 297.0),
'A3' => array(297.0, 420.0),
'A5' => array(148.0, 210.0),
'Letter' => array(215.9, 279.4),
);
list($w, $h) = isset($sizes[$format]) ? $sizes[$format] : $sizes['A4'];
if ($orientation === 'L') { $tmp = $w; $w = $h; $h = $tmp; }
// mT = 30: darüber druckt TCPDF Logo, Titel und die Trennlinie (y=27mm).
// mB = 16: Fusszeile mit Seitennummer.
$mL = 10.0; $mR = 10.0; $mT = 30.0; $mB = 16.0;
$noteH = 0.0;
if ($has_note) {
$noteH = max(20.0, min(80.0, $h * 0.25));
}
$contentW = $w - $mL - $mR;
$contentH = $h - $mT - $mB - $noteH - ($noteH ? 4.0 : 0.0);
return array(
'pageW' => $w, 'pageH' => $h,
'mL' => $mL, 'mR' => $mR, 'mT' => $mT, 'mB' => $mB,
'noteH' => $noteH,
'contentX' => $mL, 'contentY' => $mT,
'contentW' => $contentW, 'contentH' => $contentH,
);
}
/**
* Baut die Anzeige-URLs zu einem Anhang.
*
* Das erste Segment des relpath ist gleichzeitig der Dolibarr-modulepart
* (commande, facture, propale, expedition, ...) so bleibt die Rechtepruefung
* beim Ausliefern in Dolibarrs eigenem document.php/viewimage.php.
*
* @param string $relpath Pfad relativ zu DOL_DATA_ROOT
* @param bool $is_image
* @return array{view:string,thumb:string}
*/
function bericht_attachment_urls($relpath, $is_image)
{
global $conf;
$parts = explode('/', $relpath, 2);
$modulepart = $parts[0];
$file_in_module = isset($parts[1]) ? $parts[1] : '';
$view = DOL_URL_ROOT.($is_image ? '/viewimage.php' : '/document.php')
.'?modulepart='.urlencode($modulepart)
.'&file='.urlencode($file_in_module)
.'&entity='.$conf->entity
// attachment=0 -> Content-Disposition: inline. Ohne das schickt document.php
// ein "attachment" und der Browser laedt das PDF herunter statt es anzuzeigen.
.($is_image ? '' : '&attachment=0');
$thumb = $is_image
? dol_buildpath('/bericht/ajax/attachment_thumb.php', 1).'?relpath='.rawurlencode($relpath).'&w=260'
: '';
return array('view' => $view, 'thumb' => $thumb);
}
/**
* Alle Quellpfade, die in diesem Bericht bereits als Seite (oder Grid-Slot) verwendet werden.
* Damit lassen sich in der Anhaenge-Spalte schon benutzte Dateien markieren.
*
* @return array Set: relpath => true
*/
function bericht_used_source_paths(DoliDB $db, $berichtid)
{
$used = array();
$sql = "SELECT p.source_path FROM ".$db->prefix()."bericht_page p WHERE p.fk_bericht = ".((int) $berichtid);
$res = $db->query($sql);
if ($res) {
while ($r = $db->fetch_object($res)) {
if (!empty($r->source_path)) $used[$r->source_path] = true;
}
}
$sql = "SELECT i.source_path FROM ".$db->prefix()."bericht_page_image i"
." INNER JOIN ".$db->prefix()."bericht_page p ON p.rowid = i.fk_page"
." WHERE p.fk_bericht = ".((int) $berichtid);
$res = $db->query($sql);
if ($res) {
while ($r = $db->fetch_object($res)) {
if (!empty($r->source_path)) $used[$r->source_path] = true;
}
}
return $used;
}
/**
* Erzeugt (und cached) ein JPEG-Thumbnail einer Bilddatei.
*
* @param string $full Absoluter Pfad zur Quelldatei (bereits geprueft)
* @param int $maxside Maximale Kantenlaenge in Pixel
* @return string|null JPEG-Binaerdaten oder null wenn kein unterstuetztes Bild
*/
function bericht_attachment_thumb($full, $maxside = 260)
{
$info = @getimagesize($full);
if (!$info) return null;
$supported = array(IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF);
if (defined('IMAGETYPE_WEBP')) $supported[] = IMAGETYPE_WEBP;
if (!in_array($info[2], $supported, true)) return null;
// Cache: Schluessel enthaelt mtime, damit ein ersetztes Original neu gerendert wird
$cache_dir = DOL_DATA_ROOT.'/bericht/thumbs';
$cache_file = $cache_dir.'/'.sha1($full.'|'.filemtime($full).'|'.$maxside).'.jpg';
if (is_file($cache_file)) {
$cached = @file_get_contents($cache_file);
if ($cached !== false && $cached !== '') return $cached;
}
if (!extension_loaded('gd')) return null;
// Speicher-Notbremse: 4 Byte pro Pixel plus Zielbild
$needed = $info[0] * $info[1] * 4 * 1.3;
$limit = bericht_memory_limit_bytes();
if ($limit > 0 && $needed > $limit * 0.7) return null;
$src = null;
if ($info[2] === IMAGETYPE_JPEG) $src = @imagecreatefromjpeg($full);
elseif ($info[2] === IMAGETYPE_PNG) $src = @imagecreatefrompng($full);
elseif ($info[2] === IMAGETYPE_GIF) $src = @imagecreatefromgif($full);
elseif (defined('IMAGETYPE_WEBP') && $info[2] === IMAGETYPE_WEBP && function_exists('imagecreatefromwebp')) {
$src = @imagecreatefromwebp($full);
}
if (!$src) return null;
// Handy-Fotos: EXIF-Orientierung anwenden, damit die Kachel so aussieht wie
// die Grossansicht (Browser drehen JPEGs anhand des EXIF-Tags selbst).
if ($info[2] === IMAGETYPE_JPEG) {
$orient = bericht_jpeg_orientation($full);
if ($orient > 1) {
$rotated = bericht_apply_orientation($src, $orient);
if ($rotated) { imagedestroy($src); $src = $rotated; }
}
}
$sw = imagesx($src);
$sh = imagesy($src);
$ratio = min($maxside / $sw, $maxside / $sh, 1);
$dw = max(1, (int) round($sw * $ratio));
$dh = max(1, (int) round($sh * $ratio));
$dst = imagecreatetruecolor($dw, $dh);
// Transparenz (PNG/GIF) auf Weiss legen — das Thumbnail wird JPEG
imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255));
imagecopyresampled($dst, $src, 0, 0, 0, 0, $dw, $dh, $sw, $sh);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 82);
imagedestroy($dst);
$data = ob_get_clean();
if (!is_dir($cache_dir)) dol_mkdir($cache_dir);
if (is_dir($cache_dir)) @file_put_contents($cache_file, $data);
return $data;
}
/**
* Aufnahmezeitpunkt einer Datei: EXIF wenn vorhanden, sonst das Dateidatum.
* Ergebnis wird pro Request zwischengespeichert.
*/
function bericht_file_taken_at($fullpath, $fallback_date = null)
{
static $cache = array();
if (isset($cache[$fullpath])) return $cache[$fullpath];
$ts = null;
if (preg_match('/\.jpe?g$/i', $fullpath)) {
$ts = bericht_jpeg_taken_at($fullpath);
}
if (!$ts) $ts = $fallback_date ? (int) $fallback_date : @filemtime($fullpath);
return $cache[$fullpath] = ($ts ?: null);
}
/**
* Liest den Aufnahmezeitpunkt (EXIF DateTimeOriginal) aus einer JPEG-Datei.
*
* Bei Baustellenfotos ist die Aufnahmereihenfolge meist genau die Reihenfolge,
* in der die Bilder in den Bericht sollen. Eigener Parser, weil das exif-Modul
* im Prod-Container nicht geladen ist.
*
* @return int|null Unix-Timestamp oder null
*/
function bericht_jpeg_taken_at($file)
{
if (function_exists('exif_read_data')) {
$e = @exif_read_data($file);
foreach (array('DateTimeOriginal', 'DateTimeDigitized', 'DateTime') as $k) {
if (!empty($e[$k])) {
$ts = bericht_parse_exif_date($e[$k]);
if ($ts) return $ts;
}
}
return null;
}
$fh = @fopen($file, 'rb');
if (!$fh) return null;
$result = null;
if (fread($fh, 2) === "\xFF\xD8") {
while (!feof($fh)) {
$marker = fread($fh, 2);
if (strlen($marker) < 2 || $marker[0] !== "\xFF") break;
$type = ord($marker[1]);
if ($type === 0xD8 || ($type >= 0xD0 && $type <= 0xD9)) continue;
$lenraw = fread($fh, 2);
if (strlen($lenraw) < 2) break;
$len = unpack('n', $lenraw)[1] - 2;
if ($len <= 0) break;
if ($type !== 0xE1) { fseek($fh, $len, SEEK_CUR); continue; }
$app1 = fread($fh, $len);
if (strncmp($app1, "Exif\0\0", 6) !== 0) break;
$tiff = substr($app1, 6);
if (strlen($tiff) < 8) break;
$le = (substr($tiff, 0, 2) === 'II');
$fmt_s = $le ? 'v' : 'n';
$fmt_l = $le ? 'V' : 'N';
$ifd0 = unpack($fmt_l, substr($tiff, 4, 4))[1];
// Tag 0x0132 (DateTime) in IFD0, 0x9003 (DateTimeOriginal) im Exif-SubIFD
$found = bericht_exif_scan_ifd($tiff, $ifd0, $fmt_s, $fmt_l, array(0x0132), array(0x8769));
if (!empty($found['sub'])) {
$orig = bericht_exif_scan_ifd($tiff, $found['sub'], $fmt_s, $fmt_l, array(0x9003, 0x9004), array());
if (!empty($orig['value'])) $found['value'] = $orig['value'];
}
if (!empty($found['value'])) $result = bericht_parse_exif_date($found['value']);
break;
}
}
fclose($fh);
return $result;
}
/**
* Durchsucht ein IFD nach ASCII-Tags und einem optionalen Sub-IFD-Zeiger.
*
* @param int[] $wanted Tags, deren ASCII-Wert gesucht wird
* @param int[] $subtags Tags, die auf ein weiteres IFD zeigen (LONG)
* @return array{value:?string,sub:?int}
*/
function bericht_exif_scan_ifd($tiff, $offset, $fmt_s, $fmt_l, array $wanted, array $subtags)
{
$out = array('value' => null, 'sub' => null);
if ($offset + 2 > strlen($tiff)) return $out;
$count = unpack($fmt_s, substr($tiff, $offset, 2))[1];
for ($i = 0; $i < $count; $i++) {
$off = $offset + 2 + $i * 12;
if ($off + 12 > strlen($tiff)) break;
$tag = unpack($fmt_s, substr($tiff, $off, 2))[1];
if (in_array($tag, $subtags, true)) {
$out['sub'] = unpack($fmt_l, substr($tiff, $off + 8, 4))[1];
continue;
}
if (!in_array($tag, $wanted, true) || $out['value'] !== null) continue;
$len = unpack($fmt_l, substr($tiff, $off + 4, 4))[1];
// ASCII-Werte über 4 Byte stehen ausgelagert, der Eintrag hält den Offset
$val_off = ($len > 4) ? unpack($fmt_l, substr($tiff, $off + 8, 4))[1] : ($off + 8);
if ($val_off + $len > strlen($tiff)) continue;
$out['value'] = rtrim(substr($tiff, $val_off, $len), "\0");
}
return $out;
}
/** "2026:08:12 14:12:33" -> Unix-Timestamp */
function bericht_parse_exif_date($raw)
{
if (!preg_match('/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/', trim($raw), $m)) return null;
return @mktime((int) $m[4], (int) $m[5], (int) $m[6], (int) $m[2], (int) $m[3], (int) $m[1]) ?: null;
}
/**
* Liest nur das EXIF-Orientation-Tag aus einer JPEG-Datei.
*
* Eigener Mini-Parser, weil das exif-Modul im Prod-Container NICHT geladen ist
* (verifiziert 2026-08-21 in 90-Dolibarr-Prod-Custom) sonst haetten Kacheln
* lokal und auf Prod eine unterschiedliche Drehung.
*
* @return int 1..8, 1 = keine Drehung noetig
*/
function bericht_jpeg_orientation($file)
{
if (function_exists('exif_read_data')) {
$e = @exif_read_data($file);
return (is_array($e) && !empty($e['Orientation'])) ? (int) $e['Orientation'] : 1;
}
$fh = @fopen($file, 'rb');
if (!$fh) return 1;
$result = 1;
// JPEG-Segmente durchlaufen bis APP1 (Exif) gefunden ist
if (fread($fh, 2) === "\xFF\xD8") {
while (!feof($fh)) {
$marker = fread($fh, 2);
if (strlen($marker) < 2 || $marker[0] !== "\xFF") break;
$type = ord($marker[1]);
if ($type === 0xD8 || ($type >= 0xD0 && $type <= 0xD9)) continue;
$lenraw = fread($fh, 2);
if (strlen($lenraw) < 2) break;
$len = unpack('n', $lenraw)[1] - 2;
if ($len <= 0) break;
if ($type !== 0xE1) { fseek($fh, $len, SEEK_CUR); continue; }
$app1 = fread($fh, $len);
if (strncmp($app1, "Exif\0\0", 6) !== 0) break;
$tiff = substr($app1, 6);
if (strlen($tiff) < 8) break;
$le = (substr($tiff, 0, 2) === 'II'); // Byte-Order
$fmt_s = $le ? 'v' : 'n'; // 16 Bit
$fmt_l = $le ? 'V' : 'N'; // 32 Bit
$ifd = unpack($fmt_l, substr($tiff, 4, 4))[1];
if ($ifd + 2 > strlen($tiff)) break;
$count = unpack($fmt_s, substr($tiff, $ifd, 2))[1];
for ($i = 0; $i < $count; $i++) {
$off = $ifd + 2 + $i * 12;
if ($off + 12 > strlen($tiff)) break;
$tag = unpack($fmt_s, substr($tiff, $off, 2))[1];
if ($tag !== 0x0112) continue; // Orientation
$val = unpack($fmt_s, substr($tiff, $off + 8, 2))[1];
if ($val >= 1 && $val <= 8) $result = $val;
break;
}
break;
}
}
fclose($fh);
return $result;
}
/**
* Dreht/spiegelt ein GD-Bild gemaess EXIF-Orientation.
*
* @return resource|GdImage|null
*/
function bericht_apply_orientation($img, $orient)
{
switch ((int) $orient) {
case 2: imageflip($img, IMG_FLIP_HORIZONTAL); return $img;
case 3: return imagerotate($img, 180, 0);
case 4: imageflip($img, IMG_FLIP_VERTICAL); return $img;
case 5: imageflip($img, IMG_FLIP_HORIZONTAL); return imagerotate($img, -90, 0);
case 6: return imagerotate($img, -90, 0);
case 7: imageflip($img, IMG_FLIP_HORIZONTAL); return imagerotate($img, 90, 0);
case 8: return imagerotate($img, 90, 0);
}
return null;
}
/**
* memory_limit in Bytes (0 = unbegrenzt).
*/
function bericht_memory_limit_bytes()
{
$v = trim((string) ini_get('memory_limit'));
if ($v === '' || $v === '-1') return 0;
$unit = strtolower(substr($v, -1));
$num = (float) $v;
if ($unit === 'g') $num *= 1024 * 1024 * 1024;
elseif ($unit === 'm') $num *= 1024 * 1024;
elseif ($unit === 'k') $num *= 1024;
return (int) $num;
}
function bericht_hex_to_rgb($hex)
{
$hex = ltrim($hex, '#');
@ -741,28 +1332,35 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
$full = bericht_resolve_data_path($page->composite_path);
if ($full && file_exists($full)) {
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
// Margins: oben Platz für Header (Logo/Titel/Firmenname), unten für Footer
$mL = 10; $mR = 10; $mT = 30; $mB = 16;
// Notiz-Bereich reservieren wenn eine Notiz existiert
$note_h = 0;
if (!empty($page->note)) {
$note_h = max(20, min(80, $pageH * 0.25));
}
$maxW = $pageW - $mL - $mR;
$maxH = $pageH - $mT - $mB - $note_h - ($note_h ? 4 : 0);
list($iw, $ih) = @getimagesize($full);
if ($iw && $ih) {
$ratio = min($maxW / $iw, $maxH / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
$x = ($pageW - $w) / 2;
$y = $mT;
$pdf->Image($full, $x, $y, $w, $h);
// Das Composite ist exakt die Arbeitsfläche des Editors und hat
// bereits die Proportion des Inhaltsbereichs — also 1:1 dort
// einsetzen, NICHT noch einmal einpassen. Sonst schrumpft der
// Inhalt ein zweites Mal und die Vorschau stimmt nicht.
$g = bericht_page_geometry($fmt, $ori, !empty($page->note));
// Composites aus der Zeit vor der WYSIWYG-Umstellung zeigen das ganze
// Blatt (A4-Proportion) statt nur den Inhaltsbereich. Die wuerden beim
// 1:1-Einsetzen verzerrt — daher an der Proportion erkennen und
// stattdessen proportional einpassen.
list($ciw, $cih) = @getimagesize($full);
$target_ratio = $g['contentW'] / $g['contentH'];
$is_current = ($ciw && $cih) ? (abs(($ciw / $cih) - $target_ratio) / $target_ratio < 0.02) : true;
if ($is_current) {
$pdf->Image($full, $g['contentX'], $g['contentY'], $g['contentW'], $g['contentH']);
} else {
$r = min($g['contentW'] / $ciw, $g['contentH'] / $cih);
$cw = $ciw * $r; $ch = $cih * $r;
$pdf->Image($full,
$g['contentX'] + ($g['contentW'] - $cw) / 2,
$g['contentY'] + ($g['contentH'] - $ch) / 2,
$cw, $ch);
}
if (!empty($page->note)) {
$note_y = $mT + ($ih && $iw ? ($ih * min($maxW / $iw, $maxH / $ih)) : $maxH) + 4;
bericht_write_note_html($pdf, $page->note, $mL, $note_y, $maxW, $note_h);
$note_y = $g['contentY'] + $g['contentH'] + 4;
bericht_write_note_html($pdf, $page->note, $g['contentX'], $note_y, $g['contentW'], $g['noteH']);
}
return;
}
@ -794,19 +1392,21 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
// Inhaltsbereich wie im Editor — oben bleibt der Kopfbereich frei
$g = bericht_page_geometry($fmt, $ori, !empty($page->note));
$margin = $g['contentX'];
$title_h = !empty($page->title) ? 12 : 0;
if ($title_h) {
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16);
$pdf->SetY($margin);
$pdf->SetY($g['contentY']);
$pdf->MultiCell(0, $title_h, $page->title, 0, 'C');
}
// Zwei Bilder nebeneinander mit VORHER/NACHHER Labels
$label_h = 8;
$gap = 6;
$top = $margin + $title_h + 4;
$usable_h = $pageH - $top - 10 - $label_h; // -10 für Notiz, -label_h für Beschriftung
$w_each = ($pageW - 2 * $margin - $gap) / 2;
$top = $g['contentY'] + $title_h + ($title_h ? 4 : 0);
$usable_h = $g['contentY'] + $g['contentH'] - $top - $label_h;
$w_each = ($g['contentW'] - $gap) / 2;
foreach (array(array('Vorher', 0), array('Nachher', 1)) as $slot_info) {
list($label, $slot) = $slot_info;
@ -830,7 +1430,9 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
}
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $pageW, $pageH);
if (!empty($page->note)) {
bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18);
$gn = bericht_page_geometry($fmt, $ori, true);
bericht_write_note_html($pdf, $page->note, $gn['contentX'],
$gn['contentY'] + $gn['contentH'] + 4, $gn['contentW'], $gn['noteH']);
}
return;
}
@ -844,15 +1446,22 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
// Titel oben
// Titel oben — unterhalb des Kopfbereichs
$g = bericht_page_geometry($fmt, $ori, !empty($page->note));
$title_h = 0;
if (!empty($page->title)) {
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16);
$pdf->SetY(10);
$pdf->SetY($g['contentY']);
$pdf->MultiCell(0, 10, $page->title, 0, 'C');
$title_h = 14;
}
$rects = BerichtPage::slotRects($layout, $pageW, $pageH - $title_h, 10 + $title_h);
$rects = BerichtPage::slotRectsInBox(
$layout,
$g['contentX'],
$g['contentY'] + $title_h,
$g['contentW'],
$g['contentH'] - $title_h
);
foreach ($imgs as $img) {
$slot = $img['slot'];
@ -874,7 +1483,9 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
// Notiz unten
if (!empty($page->note)) {
bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18);
$gn = bericht_page_geometry($fmt, $ori, true);
bericht_write_note_html($pdf, $page->note, $gn['contentX'],
$gn['contentY'] + $gn['contentH'] + 4, $gn['contentW'], $gn['noteH']);
}
return;
}
@ -890,9 +1501,10 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$maxW = ($pageW - 2 * $margin);
$maxH = ($pageH - 2 * $margin - 10); // Notiz unten
// Gleiche Geometrie wie der Editor: Kopf- und Fussbereich bleiben frei
$g = bericht_page_geometry($fmt, $ori, !empty($page->note));
$maxW = $g['contentW'];
$maxH = $g['contentH'];
// image_scale: Anteil der maximalen Größe
$scale = max(0.2, min(1.0, (float) ($page->image_scale ?: 1.0)));
@ -901,8 +1513,8 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
$ratio = min($availW / $iw, $availH / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
// image_align bestimmt die Position
list($x, $y) = bericht_align_position($page->image_align ?: 'fit', $w, $h, $pageW, $pageH, $margin);
// image_align bestimmt die Position innerhalb des Inhaltsbereichs
list($x, $y) = bericht_align_position($page->image_align ?: 'fit', $w, $h, $g);
$pdf->Image($full, $x, $y, $w, $h);
bericht_burn_annotations($pdf, $page->fabric_json, $x, $y, $w, $h);
@ -918,32 +1530,36 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
}
if (!empty($page->note)) {
bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18);
$gn = bericht_page_geometry($fmt, $ori, true);
bericht_write_note_html($pdf, $page->note, $gn['contentX'],
$gn['contentY'] + $gn['contentH'] + 4, $gn['contentW'], $gn['noteH']);
}
}
/**
* Berechnet die x/y-Position für ein Bild auf einer Seite anhand des image_align-Werts.
*/
function bericht_align_position($align, $w, $h, $pageW, $pageH, $margin = 10)
function bericht_align_position($align, $w, $h, array $g)
{
$usableW = $pageW - 2 * $margin;
$usableH = $pageH - 2 * $margin - 10; // Notiz unten
$x0 = $g['contentX'];
$y0 = $g['contentY'];
$cw = $g['contentW'];
$ch = $g['contentH'];
switch ($align) {
case 'topleft':
return array($margin, $margin);
return array($x0, $y0);
case 'topright':
return array($pageW - $margin - $w, $margin);
return array($x0 + $cw - $w, $y0);
case 'bottomleft':
return array($margin, $margin + $usableH - $h);
return array($x0, $y0 + $ch - $h);
case 'bottomright':
return array($pageW - $margin - $w, $margin + $usableH - $h);
return array($x0 + $cw - $w, $y0 + $ch - $h);
case 'center':
return array(($pageW - $w) / 2, $margin + ($usableH - $h) / 2);
return array($x0 + ($cw - $w) / 2, $y0 + ($ch - $h) / 2);
case 'fit':
default:
return array(($pageW - $w) / 2, $margin);
return array($x0 + ($cw - $w) / 2, $y0);
}
}