Editor-Ueberarbeitung 1.5.0: Anhaenge-Kacheln, echte Seitengeometrie, Arbeiten ohne Reload [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 13s
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:
parent
63a23b4124
commit
4102ebfe5b
15 changed files with 3640 additions and 300 deletions
118
ChangeLog.md
118
ChangeLog.md
|
|
@ -1,5 +1,123 @@
|
||||||
# Changelog
|
# 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** (150–620 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
|
## 1.4.0 — 2026-08-15
|
||||||
|
|
||||||
### Kein Stunden-Dummy mehr beim Direkt-Freigeben
|
### Kein Stunden-Dummy mehr beim Direkt-Freigeben
|
||||||
|
|
|
||||||
151
ROADMAP.md
Normal file
151
ROADMAP.md
Normal 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 20–40 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 (150–620 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.1–1.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
55
ajax/attachment_thumb.php
Normal 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
51
ajax/fragments.php
Normal 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
105
ajax/page_bulk.php
Normal 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));
|
||||||
|
|
@ -30,6 +30,16 @@ if (!$row) { http_response_code(404); exit; }
|
||||||
|
|
||||||
// Bei Multi-Image-Seiten: serverseitig ein Composite-Bild rendern (PNG)
|
// Bei Multi-Image-Seiten: serverseitig ein Composite-Bild rendern (PNG)
|
||||||
if ($row->layout !== 'single') {
|
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");
|
$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; }
|
if (!$imgs_q) { http_response_code(500); exit; }
|
||||||
$imgs = array();
|
$imgs = array();
|
||||||
|
|
@ -38,7 +48,7 @@ if ($row->layout !== 'single') {
|
||||||
}
|
}
|
||||||
if (empty($imgs)) { http_response_code(404); exit; }
|
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; }
|
if (!$composite) { http_response_code(500); exit; }
|
||||||
header('Content-Type: image/png');
|
header('Content-Type: image/png');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
|
|
@ -58,39 +68,37 @@ readfile($full);
|
||||||
/**
|
/**
|
||||||
* Erstellt ein Composite-PNG für eine Multi-Image-Seite (Editor-Vorschau).
|
* 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
|
// Grundfläche = Inhaltsbereich der Seite bei ~150 DPI, damit die Vorschau
|
||||||
$W = 1240; // ~A4 @ 150dpi
|
// exakt der Fläche entspricht, die das PDF später bedruckt.
|
||||||
$H = 1754;
|
$px_per_mm = 150 / 25.4;
|
||||||
$margin = 40;
|
$W = (int) round($g['contentW'] * $px_per_mm);
|
||||||
$gap = 16;
|
$H = (int) round($g['contentH'] * $px_per_mm);
|
||||||
|
$gap = (int) round(4 * $px_per_mm);
|
||||||
|
|
||||||
$canvas = imagecreatetruecolor($W, $H);
|
$canvas = imagecreatetruecolor($W, $H);
|
||||||
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 255, 255, 255));
|
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 255, 255, 255));
|
||||||
|
|
||||||
// Slot-Berechnung (gleiche Logik wie BerichtPage::slotRects, aber in Pixel)
|
// Slot-Aufteilung wie BerichtPage::slotRectsInBox, hier in Pixel
|
||||||
$iw = $W - 2 * $margin;
|
|
||||||
$ih = $H - 2 * $margin - 60; // 60px für Notiz-Bereich
|
|
||||||
|
|
||||||
$rects = array();
|
$rects = array();
|
||||||
if ($layout === 'grid_2') {
|
if ($layout === 'grid_2') {
|
||||||
$h = ($ih - $gap) / 2;
|
$h = ($H - $gap) / 2;
|
||||||
for ($i = 0; $i < 2; $i++) $rects[] = array($margin, $margin + $i * ($h + $gap), $iw, $h);
|
for ($i = 0; $i < 2; $i++) $rects[] = array(0, $i * ($h + $gap), $W, $h);
|
||||||
} elseif ($layout === 'grid_2v') {
|
} elseif ($layout === 'grid_2v' || $layout === 'before_after') {
|
||||||
$w = ($iw - $gap) / 2;
|
$w = ($W - $gap) / 2;
|
||||||
for ($i = 0; $i < 2; $i++) $rects[] = array($margin + $i * ($w + $gap), $margin, $w, $ih);
|
for ($i = 0; $i < 2; $i++) $rects[] = array($i * ($w + $gap), 0, $w, $H);
|
||||||
} elseif ($layout === 'grid_4') {
|
} elseif ($layout === 'grid_4') {
|
||||||
$w = ($iw - $gap) / 2;
|
$w = ($W - $gap) / 2;
|
||||||
$h = ($ih - $gap) / 2;
|
$h = ($H - $gap) / 2;
|
||||||
for ($r = 0; $r < 2; $r++) for ($c = 0; $c < 2; $c++) {
|
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') {
|
} elseif ($layout === 'grid_6') {
|
||||||
$w = ($iw - 2 * $gap) / 3;
|
$w = ($W - 2 * $gap) / 3;
|
||||||
$h = ($ih - $gap) / 2;
|
$h = ($H - $gap) / 2;
|
||||||
for ($r = 0; $r < 2; $r++) for ($c = 0; $c < 3; $c++) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
66
ajax/replace_page_source.php
Normal file
66
ajax/replace_page_source.php
Normal 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');
|
||||||
232
bericht_card.php
232
bericht_card.php
|
|
@ -308,6 +308,8 @@ document.addEventListener("click", function(e) {
|
||||||
/*
|
/*
|
||||||
* MODUS B: Editor — bestehender Bericht wird bearbeitet
|
* MODUS B: Editor — bestehender Bericht wird bearbeitet
|
||||||
*/
|
*/
|
||||||
|
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
|
||||||
|
|
||||||
$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
|
$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
|
||||||
$attachments = bericht_collect_attachments($db, $parent, $element);
|
$attachments = bericht_collect_attachments($db, $parent, $element);
|
||||||
$templates = bericht_list_templates();
|
$templates = bericht_list_templates();
|
||||||
|
|
@ -336,6 +338,14 @@ document.addEventListener("click", function(e) {
|
||||||
'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1),
|
'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1),
|
||||||
'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1),
|
'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1),
|
||||||
'save_as_template' => dol_buildpath('/bericht/ajax/save_as_template.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(
|
'lang' => array(
|
||||||
'undo' => $langs->trans("BerichtUndo"),
|
'undo' => $langs->trans("BerichtUndo"),
|
||||||
|
|
@ -375,46 +385,118 @@ document.addEventListener("click", function(e) {
|
||||||
if (count($vs) > 1) $version_links = ' <span class="opacitymedium small">Versionen: '.implode(' · ', $vs).'</span>';
|
if (count($vs) > 1) $version_links = ' <span class="opacitymedium small">Versionen: '.implode(' · ', $vs).'</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
print '<table class="border centpercent"><tr>';
|
// --- Kopfzeile: Identität des Berichts auf einen Blick ---
|
||||||
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>';
|
$is_final = ((int) $bericht->status === Bericht::STATUS_FINAL);
|
||||||
print '<td class="titlefield">'.$langs->trans("BerichtAuftragsnummer").'</td><td>'.dol_escape_htmltag($bericht->auftragsnummer).'</td>';
|
$parent_link = '';
|
||||||
print '</tr><tr>';
|
if (!empty($parent->id)) {
|
||||||
print '<td>'.$langs->trans("BerichtTitle").'</td><td><input type="text" name="titel" value="'.dol_escape_htmltag($bericht->titel).'" size="40"></td>';
|
$parent_link = $parent->getNomUrl(1);
|
||||||
print '<td>'.$langs->trans("BerichtTemplate").'</td><td><select name="template_odt">';
|
}
|
||||||
|
$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>';
|
print '<option value="">— '.$langs->trans("BerichtNoTemplate").' —</option>';
|
||||||
foreach ($templates as $tpl) {
|
foreach ($templates as $tpl) {
|
||||||
$sel = ($tpl === $bericht->template_odt) ? ' selected' : '';
|
$sel = ($tpl === $bericht->template_odt) ? ' selected' : '';
|
||||||
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).'</option>';
|
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).'</option>';
|
||||||
}
|
}
|
||||||
print '</select></td>';
|
print '</select>';
|
||||||
print '</tr><tr>';
|
print '</div>';
|
||||||
// Format und Orientation
|
|
||||||
print '<td>Format</td><td>';
|
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">';
|
print '<select id="meta-format" name="page_format" title="Seitenformat des PDFs">';
|
||||||
foreach (array('A4', 'A3', 'A5', 'Letter') as $fmt) {
|
foreach (array('A4', 'A3', 'A5', 'Letter') as $fmt) {
|
||||||
$sel = ($fmt === $bericht->page_format) ? ' selected' : '';
|
$sel = ($fmt === $bericht->page_format) ? ' selected' : '';
|
||||||
print '<option value="'.$fmt.'"'.$sel.'>'.$fmt.'</option>';
|
print '<option value="'.$fmt.'"'.$sel.'>'.$fmt.'</option>';
|
||||||
}
|
}
|
||||||
print '</select>';
|
print '</select>';
|
||||||
print ' ';
|
|
||||||
print '<select id="meta-orientation" name="page_orientation" title="Hoch- oder Querformat">';
|
print '<select id="meta-orientation" name="page_orientation" title="Hoch- oder Querformat">';
|
||||||
foreach (array('P' => 'Hochformat', 'L' => 'Querformat') as $k => $v) {
|
foreach (array('P' => 'Hochformat', 'L' => 'Querformat') as $k => $v) {
|
||||||
$sel = ($k === $bericht->page_orientation) ? ' selected' : '';
|
$sel = ($k === $bericht->page_orientation) ? ' selected' : '';
|
||||||
print '<option value="'.$k.'"'.$sel.'>'.$v.'</option>';
|
print '<option value="'.$k.'"'.$sel.'>'.$v.'</option>';
|
||||||
}
|
}
|
||||||
print '</select>';
|
print '</select>';
|
||||||
print '</td>';
|
print '</div>';
|
||||||
print '<td colspan="2"><span class="opacitymedium small">Wird beim Finalisieren angewendet</span></td>';
|
print '<span class="bericht-field-hint">Wird beim Finalisieren angewendet</span>';
|
||||||
print '</tr></table>';
|
print '</div>';
|
||||||
|
|
||||||
|
print '</div>'; // .bericht-meta-fields
|
||||||
print '</form>';
|
print '</form>';
|
||||||
print '</div>';
|
print '</div>';
|
||||||
|
|
||||||
// Hauptlayout: links Anhänge, Mitte Editor, rechts Seiten
|
// Hauptlayout: links Anhänge, Mitte Editor, rechts Seiten
|
||||||
print '<div class="bericht-layout">';
|
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 '<aside class="bericht-attachments">';
|
||||||
|
print '<div class="bericht-att-header">';
|
||||||
print '<h4>'.$langs->trans("BerichtAvailableAttachments").'</h4>';
|
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();
|
$by_source = array();
|
||||||
foreach ($attachments as $a) {
|
foreach ($attachments as $a) {
|
||||||
$key = $a['source'].':'.$a['source_ref'];
|
$key = $a['source'].':'.$a['source_ref'];
|
||||||
|
|
@ -423,41 +505,11 @@ document.addEventListener("click", function(e) {
|
||||||
if (empty($by_source)) {
|
if (empty($by_source)) {
|
||||||
print '<div class="opacitymedium small">'.$langs->trans("None").'</div>';
|
print '<div class="opacitymedium small">'.$langs->trans("None").'</div>';
|
||||||
} else {
|
} else {
|
||||||
foreach ($by_source as $key => $files) {
|
bericht_print_attachment_list($by_source, $used_paths);
|
||||||
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">';
|
|
||||||
|
|
||||||
if ($is_image) {
|
print '<div class="bericht-add-selected">';
|
||||||
// Bild: Klickbar für Viewer
|
print '<div class="att-selection-info"><span id="att-selected-count">0</span> ausgewählt'
|
||||||
// relpath ist z.B. "facture/FA2025-0001/image.jpg" - erstes Segment ist modulepart
|
.' <button type="button" id="att-clear-selection" title="Auswahl aufheben">zurücksetzen</button></div>';
|
||||||
$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 '<label class="opacitymedium small" style="display:block;margin-bottom:4px;">Layout für die Auswahl:</label>';
|
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 '<select id="add-selected-layout" style="width:100%;margin-bottom:6px;">';
|
||||||
print '<option value="single">Einzeln (1 Bild pro Seite)</option>';
|
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="grid_6">6 pro Seite (3x2)</option>';
|
||||||
print '<option value="before_after">Vorher / Nachher (2 pro Seite)</option>';
|
print '<option value="before_after">Vorher / Nachher (2 pro Seite)</option>';
|
||||||
print '</select>';
|
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 '</div>';
|
||||||
}
|
}
|
||||||
print '<hr>';
|
print '<hr>';
|
||||||
print '<div class="bericht-upload">';
|
print '<div class="bericht-upload">';
|
||||||
print '<label for="bericht-extra-upload" class="butAction" title="Datei vom PC hochladen">📤 '.$langs->trans("BerichtUploadExtra").'</label>';
|
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 '<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 '</div>';
|
||||||
print '</aside>';
|
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
|
// MITTE: PDF.js + Fabric.js Editor
|
||||||
print '<main class="bericht-canvas-area">';
|
print '<main class="bericht-canvas-area">';
|
||||||
print '<div class="bericht-toolbar">';
|
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="select" title="'.$langs->trans("BerichtToolSelect").' (V)">↖</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="draw" title="'.$langs->trans("BerichtToolDraw").' (P)">✏️</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="rect" title="'.$langs->trans("BerichtToolRect").' (R)">▭</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="circle" title="'.$langs->trans("BerichtToolCircle").' (K)">○</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="arrow" title="'.$langs->trans("BerichtToolArrow").' (A)">↗</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="text" title="'.$langs->trans("BerichtToolText").' (T)">T</button>';
|
||||||
print '<span class="sep"></span>';
|
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-/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 '<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 '<span class="sep"></span>';
|
||||||
print '<button type="button" id="btn-undo" title="'.$langs->trans("BerichtUndo").'">↶</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").'">↷</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").'">🗑️</button>';
|
print '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").' (Entf)">🗑️</button>';
|
||||||
|
|
||||||
// ====== Zweite Zeile ======
|
// ====== Zweite Zeile ======
|
||||||
print '<div class="row-break"></div>';
|
print '<div class="row-break"></div>';
|
||||||
|
|
@ -547,11 +602,21 @@ document.addEventListener("click", function(e) {
|
||||||
.'<option value="bottomleft">Unten links</option>'
|
.'<option value="bottomleft">Unten links</option>'
|
||||||
.'<option value="bottomright">Unten rechts</option>'
|
.'<option value="bottomright">Unten rechts</option>'
|
||||||
.'</select></label>';
|
.'</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>';
|
||||||
print '<div class="bericht-canvas-wrap">';
|
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="pdf-canvas"></canvas>';
|
||||||
print '<canvas id="fabric-canvas" class="fabric-overlay"></canvas>';
|
print '<canvas id="fabric-canvas" class="fabric-overlay"></canvas>';
|
||||||
print '</div>';
|
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 '<div class="bericht-page-note">';
|
||||||
print '<label>'.$langs->trans("BerichtNoteHint").'</label>';
|
print '<label>'.$langs->trans("BerichtNoteHint").'</label>';
|
||||||
// DolEditor (CKEditor) — wird im PDF mit writeHTMLCell gerendert
|
// 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 '<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 '<button type="button" id="btn-toggle-thumb-bg" title="Hell/Dunkel">🌓</button>';
|
||||||
print '</div>';
|
print '</div>';
|
||||||
print '<div id="bericht-page-list" class="page-list paper-light">';
|
|
||||||
foreach ($pages as $idx => $p) {
|
// Sammelaktionen — erscheinen, sobald Seiten markiert sind (Klick auf die Nummer)
|
||||||
// Ist das eine gehärtete Unterschrift? (hat .meta.json neben der source_path)
|
print '<div id="bericht-page-bulk" class="bericht-page-bulk" style="display:none;">';
|
||||||
$is_signature = false;
|
print '<span><strong id="page-sel-count">0</strong> markiert</span>';
|
||||||
if ($p->source_path && strpos($p->note ?? '', 'Unterschrift') === 0) {
|
print '<button type="button" id="btn-pages-duplicate" title="Markierte Seiten verdoppeln — die Kopie kommt direkt dahinter">⧉</button>';
|
||||||
$full_check = DOL_DATA_ROOT.'/'.$p->source_path;
|
print '<button type="button" id="btn-pages-delete" title="Markierte Seiten löschen">🗑️</button>';
|
||||||
if (file_exists($full_check.'.meta.json')) {
|
print '<button type="button" id="btn-pages-unselect" title="Markierung aufheben (Esc)">✕</button>';
|
||||||
$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>';
|
|
||||||
print '</div>';
|
print '</div>';
|
||||||
print '<div class="page-thumb-label"><span class="page-num">'.($idx + 1).'</span>'
|
bericht_print_page_list($pages);
|
||||||
.($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>';
|
|
||||||
}
|
|
||||||
print '</div>';
|
print '</div>';
|
||||||
print '</aside>';
|
print '</aside>';
|
||||||
|
|
||||||
|
|
@ -599,7 +648,8 @@ document.addEventListener("click", function(e) {
|
||||||
|
|
||||||
// Footer-Aktionen
|
// Footer-Aktionen
|
||||||
print '<div class="bericht-actions">';
|
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-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 '<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().'" '
|
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
|
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
|
// PDF-Vorschau-Modal
|
||||||
print '<div id="bericht-preview-modal" class="bericht-modal" style="display:none;">';
|
print '<div id="bericht-preview-modal" class="bericht-modal" style="display:none;">';
|
||||||
print ' <div class="bericht-modal-backdrop"></div>';
|
print ' <div class="bericht-modal-backdrop"></div>';
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,51 @@ class BerichtPage
|
||||||
* Berechnet die Slot-Rectangles innerhalb einer Seite (mm) für ein Grid-Layout.
|
* Berechnet die Slot-Rectangles innerhalb einer Seite (mm) für ein Grid-Layout.
|
||||||
* @return array [['x'=>..,'y'=>..,'w'=>..,'h'=>..], ...]
|
* @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)
|
public static function slotRects($layout, $pageW, $pageH, $margin = 10, $gap = 4)
|
||||||
{
|
{
|
||||||
$rects = array();
|
$rects = array();
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class modBericht extends DolibarrModules
|
||||||
|
|
||||||
$this->editor_name = 'Alles Watt läuft';
|
$this->editor_name = 'Alles Watt läuft';
|
||||||
$this->editor_url = '';
|
$this->editor_url = '';
|
||||||
$this->version = '1.4.0';
|
$this->version = '1.5.0';
|
||||||
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
|
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
|
||||||
$this->picto = 'fa-file-pdf';
|
$this->picto = 'fa-file-pdf';
|
||||||
|
|
||||||
|
|
|
||||||
691
css/bericht.css
691
css/bericht.css
|
|
@ -5,16 +5,162 @@
|
||||||
.bericht-overview-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
|
.bericht-overview-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
|
||||||
|
|
||||||
.bericht-editor { padding-top: 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 {
|
.bericht-layout {
|
||||||
display: grid;
|
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 */
|
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;
|
gap: 12px;
|
||||||
align-items: start;
|
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-attachments,
|
||||||
.bericht-pages {
|
.bericht-pages {
|
||||||
|
|
@ -62,32 +208,471 @@
|
||||||
color: var(--colortextlink, #555);
|
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 { margin-bottom: 10px; }
|
||||||
.bericht-att-group-title {
|
.bericht-att-group-title {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.7;
|
opacity: 0.75;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
border-bottom: 1px solid var(--colorboxbordertitle1, #ddd);
|
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;
|
display: flex; align-items: center; gap: 6px;
|
||||||
padding: 4px 2px; font-size: 12px;
|
padding: 3px 2px; font-size: 12px;
|
||||||
color: var(--colortext, inherit);
|
color: var(--colortext, inherit);
|
||||||
}
|
}
|
||||||
.bericht-att-item:hover { background: rgba(255,255,255,0.05); }
|
.att-view-list .bericht-att-item:hover { background: rgba(255,255,255,0.05); }
|
||||||
.att-delete {
|
.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;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.4;
|
opacity: 0.45;
|
||||||
padding: 2px 4px;
|
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; }
|
.bericht-upload { margin-top: 10px; }
|
||||||
|
|
||||||
|
|
@ -208,18 +793,74 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 40px 20px;
|
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
|
/* 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. */
|
und das Fabric-Overlay rutscht weg. Stattdessen rendert JS auf passende Größe. */
|
||||||
#pdf-canvas {
|
#pdf-canvas {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
/* Leerer Zustand: A4 Hochformat anzeigen, damit der Canvas nicht winzig wirkt */
|
/* Leerer Zustand: Blatt in A4 anzeigen, damit der Bereich nicht winzig wirkt */
|
||||||
.bericht-canvas-wrap.empty #pdf-canvas {
|
.bericht-canvas-wrap.empty .bericht-sheet {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 900px;
|
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;
|
height: auto;
|
||||||
}
|
}
|
||||||
.bericht-canvas-wrap.empty::after {
|
.bericht-canvas-wrap.empty::after {
|
||||||
|
|
@ -269,15 +910,17 @@
|
||||||
outline-offset: -1px;
|
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 textarea,
|
||||||
.bericht-editor input[type="text"],
|
.bericht-editor input[type="text"],
|
||||||
.bericht-editor input[type="number"],
|
.bericht-editor input[type="number"],
|
||||||
.bericht-editor input[type="email"],
|
.bericht-editor input[type="email"],
|
||||||
.bericht-editor input[type="search"] {
|
.bericht-editor input[type="search"] {
|
||||||
background: var(--colorbackbody, #2a2a30);
|
background: var(--inputbackgroundcolor, #fff);
|
||||||
color: var(--colortext, #e0e0e0);
|
color: var(--colortext, #222);
|
||||||
border: 1px solid var(--colorboxbordertitle1, #555);
|
border: 1px solid var(--inputbordercolor, #ccc);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
@ -422,8 +1065,10 @@
|
||||||
.bericht-setup-section h3 { margin-top: 0; color: var(--colortextlink, inherit); }
|
.bericht-setup-section h3 { margin-top: 0; color: var(--colortextlink, inherit); }
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@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-attachments, .bericht-pages { max-height: 300px; }
|
||||||
|
.bericht-splitter { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PDF-Vorschau-Modal */
|
/* PDF-Vorschau-Modal */
|
||||||
|
|
|
||||||
|
|
@ -219,31 +219,14 @@
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Anhang-Vorschau klickbar machen ========== */
|
/* ========== PDF-Anhang im Vollbild ========== */
|
||||||
.att-preview {
|
.bericht-viewer-pdf {
|
||||||
display: inline-flex;
|
display: none;
|
||||||
align-items: center;
|
width: 92vw;
|
||||||
gap: 4px;
|
height: 92vh;
|
||||||
cursor: pointer;
|
border: none;
|
||||||
flex: 1;
|
border-radius: 4px;
|
||||||
min-width: 0;
|
background: #fff;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Mobile Anpassungen ========== */
|
/* ========== Mobile Anpassungen ========== */
|
||||||
|
|
|
||||||
1577
js/editor.js
1577
js/editor.js
File diff suppressed because it is too large
Load diff
|
|
@ -65,6 +65,7 @@
|
||||||
<div class="bericht-viewer-container">
|
<div class="bericht-viewer-container">
|
||||||
<div class="bericht-viewer-loading">Laden...</div>
|
<div class="bericht-viewer-loading">Laden...</div>
|
||||||
<img class="bericht-viewer-image" src="" alt="" draggable="false">
|
<img class="bericht-viewer-image" src="" alt="" draggable="false">
|
||||||
|
<iframe class="bericht-viewer-pdf" src="about:blank" title="PDF-Vorschau"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="bericht-viewer-close" title="Schliessen (Esc)">×</button>
|
<button type="button" class="bericht-viewer-close" title="Schliessen (Esc)">×</button>
|
||||||
<button type="button" class="bericht-viewer-nav prev" title="Vorheriges Bild (←)">‹</button>
|
<button type="button" class="bericht-viewer-nav prev" title="Vorheriges Bild (←)">‹</button>
|
||||||
|
|
@ -80,6 +81,7 @@
|
||||||
|
|
||||||
this.container = this.overlay.querySelector('.bericht-viewer-container');
|
this.container = this.overlay.querySelector('.bericht-viewer-container');
|
||||||
this.img = this.overlay.querySelector('.bericht-viewer-image');
|
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.closeBtn = this.overlay.querySelector('.bericht-viewer-close');
|
||||||
this.prevBtn = this.overlay.querySelector('.bericht-viewer-nav.prev');
|
this.prevBtn = this.overlay.querySelector('.bericht-viewer-nav.prev');
|
||||||
this.nextBtn = this.overlay.querySelector('.bericht-viewer-nav.next');
|
this.nextBtn = this.overlay.querySelector('.bericht-viewer-nav.next');
|
||||||
|
|
@ -118,6 +120,7 @@
|
||||||
|
|
||||||
// Mausrad-Zoom
|
// Mausrad-Zoom
|
||||||
this.container.addEventListener('wheel', (e) => {
|
this.container.addEventListener('wheel', (e) => {
|
||||||
|
if (this.isPdf()) return; // PDF-Viewer des Browsers zoomt selbst
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.2 : 0.2;
|
const delta = e.deltaY > 0 ? -0.2 : 0.2;
|
||||||
const rect = this.img.getBoundingClientRect();
|
const rect = this.img.getBoundingClientRect();
|
||||||
|
|
@ -127,9 +130,9 @@
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
// Touch-Events
|
// Touch-Events
|
||||||
this.container.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
|
this.container.addEventListener('touchstart', (e) => { if (!this.isPdf()) this.handleTouchStart(e); }, { passive: false });
|
||||||
this.container.addEventListener('touchmove', (e) => this.handleTouchMove(e), { passive: false });
|
this.container.addEventListener('touchmove', (e) => { if (!this.isPdf()) this.handleTouchMove(e); }, { passive: false });
|
||||||
this.container.addEventListener('touchend', (e) => this.handleTouchEnd(e));
|
this.container.addEventListener('touchend', (e) => { if (!this.isPdf()) this.handleTouchEnd(e); });
|
||||||
|
|
||||||
// Maus-Drag (für Desktop)
|
// Maus-Drag (für Desktop)
|
||||||
this.img.addEventListener('mousedown', (e) => this.handleMouseDown(e));
|
this.img.addEventListener('mousedown', (e) => this.handleMouseDown(e));
|
||||||
|
|
@ -189,7 +192,8 @@
|
||||||
document.removeEventListener('keydown', this.boundKeyHandler);
|
document.removeEventListener('keydown', this.boundKeyHandler);
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
|
|
||||||
// Bild-Source leeren (Speicher freigeben)
|
// Quellen leeren (Speicher freigeben, PDF-Rendering stoppen)
|
||||||
|
this.pdfFrame.src = 'about:blank';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.isOpen) this.img.src = '';
|
if (!this.isOpen) this.img.src = '';
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
@ -240,11 +244,33 @@
|
||||||
|
|
||||||
this.loadingEl.style.display = 'block';
|
this.loadingEl.style.display = 'block';
|
||||||
this.loadingEl.textContent = 'Laden...';
|
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.style.opacity = '0';
|
||||||
this.img.src = current.url;
|
this.img.src = current.url;
|
||||||
this.img.alt = current.title || 'Bild ' + (this.currentIndex + 1);
|
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)
|
* UI aktualisieren (Counter, Titel, Nav-Buttons)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -482,7 +482,7 @@ function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_
|
||||||
if ($dir_key && !empty($conf->{$dir_key}->multidir_output[$parent->entity])) {
|
if ($dir_key && !empty($conf->{$dir_key}->multidir_output[$parent->entity])) {
|
||||||
$upload_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref);
|
$upload_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref);
|
||||||
if (is_dir($upload_dir)) {
|
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) {
|
foreach ($files as $f) {
|
||||||
$result[] = array(
|
$result[] = array(
|
||||||
'source' => $element_type,
|
'source' => $element_type,
|
||||||
|
|
@ -494,6 +494,7 @@ function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_
|
||||||
'size' => $f['size'],
|
'size' => $f['size'],
|
||||||
'mime' => dol_mimetype($f['name']),
|
'mime' => dol_mimetype($f['name']),
|
||||||
'date' => $f['date'],
|
'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;
|
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);
|
$linked_dir = $conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity].'/'.dol_sanitizeFileName($lobj->ref);
|
||||||
if (!is_dir($linked_dir)) continue;
|
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) {
|
foreach ($files as $f) {
|
||||||
$result[] = array(
|
$result[] = array(
|
||||||
'source' => $linked_module_type,
|
'source' => $linked_module_type,
|
||||||
|
|
@ -520,6 +521,7 @@ function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_
|
||||||
'size' => $f['size'],
|
'size' => $f['size'],
|
||||||
'mime' => dol_mimetype($f['name']),
|
'mime' => dol_mimetype($f['name']),
|
||||||
'date' => $f['date'],
|
'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;
|
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)
|
function bericht_hex_to_rgb($hex)
|
||||||
{
|
{
|
||||||
$hex = ltrim($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);
|
$full = bericht_resolve_data_path($page->composite_path);
|
||||||
if ($full && file_exists($full)) {
|
if ($full && file_exists($full)) {
|
||||||
$pdf->AddPage($ori, $fmt);
|
$pdf->AddPage($ori, $fmt);
|
||||||
$pageW = $pdf->getPageWidth();
|
|
||||||
$pageH = $pdf->getPageHeight();
|
// Das Composite ist exakt die Arbeitsfläche des Editors und hat
|
||||||
// Margins: oben Platz für Header (Logo/Titel/Firmenname), unten für Footer
|
// bereits die Proportion des Inhaltsbereichs — also 1:1 dort
|
||||||
$mL = 10; $mR = 10; $mT = 30; $mB = 16;
|
// einsetzen, NICHT noch einmal einpassen. Sonst schrumpft der
|
||||||
// Notiz-Bereich reservieren wenn eine Notiz existiert
|
// Inhalt ein zweites Mal und die Vorschau stimmt nicht.
|
||||||
$note_h = 0;
|
$g = bericht_page_geometry($fmt, $ori, !empty($page->note));
|
||||||
if (!empty($page->note)) {
|
|
||||||
$note_h = max(20, min(80, $pageH * 0.25));
|
// Composites aus der Zeit vor der WYSIWYG-Umstellung zeigen das ganze
|
||||||
}
|
// Blatt (A4-Proportion) statt nur den Inhaltsbereich. Die wuerden beim
|
||||||
$maxW = $pageW - $mL - $mR;
|
// 1:1-Einsetzen verzerrt — daher an der Proportion erkennen und
|
||||||
$maxH = $pageH - $mT - $mB - $note_h - ($note_h ? 4 : 0);
|
// stattdessen proportional einpassen.
|
||||||
list($iw, $ih) = @getimagesize($full);
|
list($ciw, $cih) = @getimagesize($full);
|
||||||
if ($iw && $ih) {
|
$target_ratio = $g['contentW'] / $g['contentH'];
|
||||||
$ratio = min($maxW / $iw, $maxH / $ih);
|
$is_current = ($ciw && $cih) ? (abs(($ciw / $cih) - $target_ratio) / $target_ratio < 0.02) : true;
|
||||||
$w = $iw * $ratio; $h = $ih * $ratio;
|
|
||||||
$x = ($pageW - $w) / 2;
|
if ($is_current) {
|
||||||
$y = $mT;
|
$pdf->Image($full, $g['contentX'], $g['contentY'], $g['contentW'], $g['contentH']);
|
||||||
$pdf->Image($full, $x, $y, $w, $h);
|
} 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)) {
|
if (!empty($page->note)) {
|
||||||
$note_y = $mT + ($ih && $iw ? ($ih * min($maxW / $iw, $maxH / $ih)) : $maxH) + 4;
|
$note_y = $g['contentY'] + $g['contentH'] + 4;
|
||||||
bericht_write_note_html($pdf, $page->note, $mL, $note_y, $maxW, $note_h);
|
bericht_write_note_html($pdf, $page->note, $g['contentX'], $note_y, $g['contentW'], $g['noteH']);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -794,19 +1392,21 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
|
||||||
$pdf->AddPage($ori, $fmt);
|
$pdf->AddPage($ori, $fmt);
|
||||||
$pageW = $pdf->getPageWidth();
|
$pageW = $pdf->getPageWidth();
|
||||||
$pageH = $pdf->getPageHeight();
|
$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;
|
$title_h = !empty($page->title) ? 12 : 0;
|
||||||
if ($title_h) {
|
if ($title_h) {
|
||||||
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16);
|
$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');
|
$pdf->MultiCell(0, $title_h, $page->title, 0, 'C');
|
||||||
}
|
}
|
||||||
// Zwei Bilder nebeneinander mit VORHER/NACHHER Labels
|
// Zwei Bilder nebeneinander mit VORHER/NACHHER Labels
|
||||||
$label_h = 8;
|
$label_h = 8;
|
||||||
$gap = 6;
|
$gap = 6;
|
||||||
$top = $margin + $title_h + 4;
|
$top = $g['contentY'] + $title_h + ($title_h ? 4 : 0);
|
||||||
$usable_h = $pageH - $top - 10 - $label_h; // -10 für Notiz, -label_h für Beschriftung
|
$usable_h = $g['contentY'] + $g['contentH'] - $top - $label_h;
|
||||||
$w_each = ($pageW - 2 * $margin - $gap) / 2;
|
$w_each = ($g['contentW'] - $gap) / 2;
|
||||||
|
|
||||||
foreach (array(array('Vorher', 0), array('Nachher', 1)) as $slot_info) {
|
foreach (array(array('Vorher', 0), array('Nachher', 1)) as $slot_info) {
|
||||||
list($label, $slot) = $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);
|
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $pageW, $pageH);
|
||||||
if (!empty($page->note)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -844,15 +1446,22 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
|
||||||
$pageW = $pdf->getPageWidth();
|
$pageW = $pdf->getPageWidth();
|
||||||
$pageH = $pdf->getPageHeight();
|
$pageH = $pdf->getPageHeight();
|
||||||
|
|
||||||
// Titel oben
|
// Titel oben — unterhalb des Kopfbereichs
|
||||||
|
$g = bericht_page_geometry($fmt, $ori, !empty($page->note));
|
||||||
$title_h = 0;
|
$title_h = 0;
|
||||||
if (!empty($page->title)) {
|
if (!empty($page->title)) {
|
||||||
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16);
|
$pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16);
|
||||||
$pdf->SetY(10);
|
$pdf->SetY($g['contentY']);
|
||||||
$pdf->MultiCell(0, 10, $page->title, 0, 'C');
|
$pdf->MultiCell(0, 10, $page->title, 0, 'C');
|
||||||
$title_h = 14;
|
$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) {
|
foreach ($imgs as $img) {
|
||||||
$slot = $img['slot'];
|
$slot = $img['slot'];
|
||||||
|
|
@ -874,7 +1483,9 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
|
||||||
|
|
||||||
// Notiz unten
|
// Notiz unten
|
||||||
if (!empty($page->note)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -890,9 +1501,10 @@ function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
|
||||||
|
|
||||||
$pageW = $pdf->getPageWidth();
|
$pageW = $pdf->getPageWidth();
|
||||||
$pageH = $pdf->getPageHeight();
|
$pageH = $pdf->getPageHeight();
|
||||||
$margin = 10;
|
// Gleiche Geometrie wie der Editor: Kopf- und Fussbereich bleiben frei
|
||||||
$maxW = ($pageW - 2 * $margin);
|
$g = bericht_page_geometry($fmt, $ori, !empty($page->note));
|
||||||
$maxH = ($pageH - 2 * $margin - 10); // Notiz unten
|
$maxW = $g['contentW'];
|
||||||
|
$maxH = $g['contentH'];
|
||||||
|
|
||||||
// image_scale: Anteil der maximalen Größe
|
// image_scale: Anteil der maximalen Größe
|
||||||
$scale = max(0.2, min(1.0, (float) ($page->image_scale ?: 1.0)));
|
$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);
|
$ratio = min($availW / $iw, $availH / $ih);
|
||||||
$w = $iw * $ratio; $h = $ih * $ratio;
|
$w = $iw * $ratio; $h = $ih * $ratio;
|
||||||
|
|
||||||
// image_align bestimmt die Position
|
// image_align bestimmt die Position innerhalb des Inhaltsbereichs
|
||||||
list($x, $y) = bericht_align_position($page->image_align ?: 'fit', $w, $h, $pageW, $pageH, $margin);
|
list($x, $y) = bericht_align_position($page->image_align ?: 'fit', $w, $h, $g);
|
||||||
|
|
||||||
$pdf->Image($full, $x, $y, $w, $h);
|
$pdf->Image($full, $x, $y, $w, $h);
|
||||||
bericht_burn_annotations($pdf, $page->fabric_json, $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)) {
|
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.
|
* 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;
|
$x0 = $g['contentX'];
|
||||||
$usableH = $pageH - 2 * $margin - 10; // Notiz unten
|
$y0 = $g['contentY'];
|
||||||
|
$cw = $g['contentW'];
|
||||||
|
$ch = $g['contentH'];
|
||||||
|
|
||||||
switch ($align) {
|
switch ($align) {
|
||||||
case 'topleft':
|
case 'topleft':
|
||||||
return array($margin, $margin);
|
return array($x0, $y0);
|
||||||
case 'topright':
|
case 'topright':
|
||||||
return array($pageW - $margin - $w, $margin);
|
return array($x0 + $cw - $w, $y0);
|
||||||
case 'bottomleft':
|
case 'bottomleft':
|
||||||
return array($margin, $margin + $usableH - $h);
|
return array($x0, $y0 + $ch - $h);
|
||||||
case 'bottomright':
|
case 'bottomright':
|
||||||
return array($pageW - $margin - $w, $margin + $usableH - $h);
|
return array($x0 + $cw - $w, $y0 + $ch - $h);
|
||||||
case 'center':
|
case 'center':
|
||||||
return array(($pageW - $w) / 2, $margin + ($usableH - $h) / 2);
|
return array($x0 + ($cw - $w) / 2, $y0 + ($ch - $h) / 2);
|
||||||
case 'fit':
|
case 'fit':
|
||||||
default:
|
default:
|
||||||
return array(($pageW - $w) / 2, $margin);
|
return array($x0 + ($cw - $w) / 2, $y0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue