diff --git a/ajax/save_annotations.php b/ajax/save_annotations.php
index 8fa4653..115daab 100644
--- a/ajax/save_annotations.php
+++ b/ajax/save_annotations.php
@@ -1,8 +1,16 @@
hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
@@ -15,15 +23,38 @@ $note = (string) ($_POST['note'] ?? '');
$rotation = (int) ($_POST['rotation'] ?? 0);
$rotation = (($rotation % 360) + 360) % 360;
-// Page laden
-$res = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
-if (!$res || !$db->fetch_object($res)) bericht_ajax_fail('Page nicht gefunden', 404);
+// Page laden und fk_bericht ermitteln
+$res = $db->query("SELECT rowid, fk_bericht FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
+if (!$res || !($row = $db->fetch_object($res))) bericht_ajax_fail('Page nicht gefunden', 404);
+$fk_bericht = (int) $row->fk_bericht;
-$sql = "UPDATE ".$db->prefix()."bericht_page SET "
- ."fabric_json = ".($fabric !== '' ? "'".$db->escape($fabric)."'" : "NULL").","
- ."note = ".($note !== '' ? "'".$db->escape($note)."'" : "NULL").","
- ."rotation = ".((int) $rotation)
- ." WHERE rowid = ".((int) $pageid);
+// Composite-PNG speichern wenn hochgeladen
+$composite_relpath = null;
+if (!empty($_FILES['composite']['tmp_name']) && is_uploaded_file($_FILES['composite']['tmp_name'])) {
+ $workdir = DOL_DATA_ROOT.'/bericht/work/'.$fk_bericht;
+ if (!is_dir($workdir)) dol_mkdir($workdir);
+ $filename = 'composite_'.$pageid.'.png';
+ $target = $workdir.'/'.$filename;
+ if (move_uploaded_file($_FILES['composite']['tmp_name'], $target)) {
+ $composite_relpath = str_replace(DOL_DATA_ROOT.'/', '', $target);
+ }
+}
+
+// Prüfen ob composite_path-Spalte existiert
+$has_composite = false;
+$ccheck = $db->query("SHOW COLUMNS FROM ".$db->prefix()."bericht_page LIKE 'composite_path'");
+if ($ccheck && $db->num_rows($ccheck) > 0) $has_composite = true;
+
+$sets = array(
+ "fabric_json = ".($fabric !== '' ? "'".$db->escape($fabric)."'" : "NULL"),
+ "note = ".($note !== '' ? "'".$db->escape($note)."'" : "NULL"),
+ "rotation = ".((int) $rotation),
+);
+if ($composite_relpath !== null && $has_composite) {
+ $sets[] = "composite_path = '".$db->escape($composite_relpath)."'";
+}
+
+$sql = "UPDATE ".$db->prefix()."bericht_page SET ".implode(', ', $sets)." WHERE rowid = ".((int) $pageid);
if (!$db->query($sql)) bericht_ajax_fail('DB-Fehler: '.$db->lasterror());
-bericht_ajax_ok();
+bericht_ajax_ok(array('composite_saved' => $composite_relpath !== null));
diff --git a/bericht_card.php b/bericht_card.php
index 33fb827..d0c1cfb 100644
--- a/bericht_card.php
+++ b/bericht_card.php
@@ -452,6 +452,8 @@ if (!$bericht) {
print '';
print '';
print '';
+ print '';
+ print '';
print '';
// Zoom
print '';
diff --git a/class/bericht.class.php b/class/bericht.class.php
index da37c99..44da18f 100644
--- a/class/bericht.class.php
+++ b/class/bericht.class.php
@@ -410,6 +410,7 @@ class BerichtPage
public $fabric_json;
public $note;
public $title; // Optional Titel/Zwischentitel der Seite
+ public $composite_path; // Phase 6: vom Editor generiertes PNG der gesamten Seite
public $layout = 'single';
public $image_scale = 1.0;
public $image_align = 'fit';
@@ -572,10 +573,17 @@ class BerichtPage
public static function fetchAllForBericht(DoliDB $db, $fk_bericht)
{
$list = array();
+ // composite_path kann noch fehlen falls Migration nicht durchgelaufen — defensiv
+ $has_composite = false;
+ $ccheck = $db->query("SHOW COLUMNS FROM ".$db->prefix()."bericht_page LIKE 'composite_path'");
+ if ($ccheck && $db->num_rows($ccheck) > 0) $has_composite = true;
+
+ $composite_col = $has_composite ? 'composite_path' : "NULL AS composite_path";
$sql = "SELECT rowid, fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note, title,"
." COALESCE(layout, 'single') AS layout,"
." COALESCE(image_scale, 1.0) AS image_scale,"
- ." COALESCE(image_align, 'fit') AS image_align"
+ ." COALESCE(image_align, 'fit') AS image_align,"
+ ." ".$composite_col
." FROM ".$db->prefix()."bericht_page"
." WHERE fk_bericht = ".((int) $fk_bericht)
." ORDER BY page_order ASC, rowid ASC";
@@ -593,6 +601,7 @@ class BerichtPage
$p->fabric_json = $obj->fabric_json;
$p->note = $obj->note;
$p->title = $obj->title;
+ $p->composite_path = $obj->composite_path ?? null;
$p->layout = $obj->layout;
$p->image_scale = (float) $obj->image_scale;
$p->image_align = $obj->image_align;
diff --git a/core/modules/modBericht.class.php b/core/modules/modBericht.class.php
index 5390f6b..85f170d 100644
--- a/core/modules/modBericht.class.php
+++ b/core/modules/modBericht.class.php
@@ -160,6 +160,11 @@ class modBericht extends DolibarrModules
// Phase 5.3: Versionierung
"ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN version INT DEFAULT 1",
"ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN fk_bericht_parent INT DEFAULT NULL",
+ // Phase 6: Composite-PNG (Client-WYSIWYG). Wenn gesetzt, wird die Seite
+ // komplett aus diesem einen PNG gerendert statt aus source_path + fabric_json.
+ // Der Editor rendert sein Fabric-Canvas bei jedem Save zu einem PNG und
+ // lädt es hoch — damit ist PDF-Output identisch mit Editor-Anzeige.
+ "ALTER TABLE ".$this->db->prefix()."bericht_page ADD COLUMN composite_path VARCHAR(512) DEFAULT NULL",
// Phase 5.9: Materialliste pro Auftrag
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material ("
."rowid INT AUTO_INCREMENT PRIMARY KEY,"
diff --git a/css/bericht.css b/css/bericht.css
index f0662eb..d45763d 100644
--- a/css/bericht.css
+++ b/css/bericht.css
@@ -213,16 +213,46 @@
.bericht-page-note { margin-top: 8px; }
.bericht-page-note label {
display: block;
- color: var(--colortextbackhmenu, inherit);
+ color: var(--colortext, #e0e0e0);
font-size: 12px;
margin-bottom: 4px;
}
.bericht-page-note textarea {
width: 100%;
box-sizing: border-box;
- background: var(--inputbackgroundcolor, #fff);
- color: var(--inputtextcolor, #000);
- border: 1px solid var(--colorboxbordertitle1, #ccc);
+ background: var(--colorbackbody, #2a2a30);
+ color: var(--colortext, #e0e0e0);
+ border: 1px solid var(--colorboxbordertitle1, #555);
+ border-radius: 4px;
+ padding: 10px 12px;
+ font-size: 14px;
+ font-family: inherit;
+ min-height: 60px;
+ resize: vertical;
+}
+.bericht-page-note textarea:focus {
+ outline: 2px solid var(--colortextlink, #7aa2f7);
+ outline-offset: -1px;
+}
+
+/* Generische Input-Styles im Editor — Fallback damit alles zum Dark-Theme passt */
+.bericht-editor textarea,
+.bericht-editor input[type="text"],
+.bericht-editor input[type="number"],
+.bericht-editor input[type="email"],
+.bericht-editor input[type="search"] {
+ background: var(--colorbackbody, #2a2a30);
+ color: var(--colortext, #e0e0e0);
+ border: 1px solid var(--colorboxbordertitle1, #555);
+ border-radius: 4px;
+ padding: 8px 10px;
+ font-size: 14px;
+ font-family: inherit;
+}
+.bericht-editor textarea:focus,
+.bericht-editor input:focus {
+ outline: 2px solid var(--colortextlink, #7aa2f7);
+ outline-offset: -1px;
}
.bericht-pages-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
diff --git a/js/editor.js b/js/editor.js
index 3c44e25..81a3350 100644
--- a/js/editor.js
+++ b/js/editor.js
@@ -158,8 +158,33 @@
fabricCanvas.clear();
document.getElementById('page-note').value = '';
- await loadPageMeta(); // setzt currentPageRotation falls vorhanden
- await rerenderCurrent(); // rendert mit Rotation
+ await loadPageMeta(); // setzt currentPageRotation + ggf. loadedFabricJson
+
+ if (loadedFabricJson) {
+ // Existierender State wird geladen — enthält bereits Bild, Shapes, Text etc.
+ try {
+ const parsed = (typeof loadedFabricJson === 'string') ? JSON.parse(loadedFabricJson) : loadedFabricJson;
+ // Canvas-Dimensionen aus dem geladenen JSON übernehmen und skalieren
+ const target = getTargetCanvasWidth();
+ const pageAspect = 1 / 1.414;
+ const isLandscape = currentPageRotation === 90 || currentPageRotation === 270;
+ pdfCanvas.width = target;
+ pdfCanvas.height = isLandscape ? Math.round(target * pageAspect) : Math.round(target / pageAspect);
+ const ctx = pdfCanvas.getContext('2d');
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, pdfCanvas.width, pdfCanvas.height);
+ resizeFabricToCanvas();
+ await new Promise(res => {
+ fabricCanvas.loadFromJSON(parsed, () => { fabricCanvas.renderAll(); res(); });
+ });
+ } catch (e) {
+ console.warn('Fabric-JSON restore fehlgeschlagen, fallback auf Neu-Render:', e);
+ await rerenderCurrent();
+ }
+ } else {
+ // Erster Besuch der Seite: Quellbild frisch als Fabric-Image laden
+ await rerenderCurrent();
+ }
}
/**
@@ -208,36 +233,60 @@
}
async function renderImage(arrayBuffer, mime) {
+ // Phase 6: Das Quell-Bild kommt als ZIEHBARES Fabric-Image-Objekt in
+ // den Canvas, nicht mehr als festes Hintergrund-Bild. User kann es
+ // verschieben, skalieren, rotieren wie jedes andere Fabric-Objekt.
+
+ const target = getTargetCanvasWidth();
+ // A4-Hochformat als Canvas-Grundfläche
+ const pageAspect = 1 / 1.414; // Breite:Höhe
+ const isLandscape = currentPageRotation === 90 || currentPageRotation === 270;
+ const canvasW = target;
+ const canvasH = isLandscape ? Math.round(target * pageAspect) : Math.round(target / pageAspect);
+
+ // pdfCanvas wird nur noch weiße Fläche (für Fabric-Overlay-Positionierung)
+ pdfCanvas.width = canvasW;
+ pdfCanvas.height = canvasH;
+ const ctx = pdfCanvas.getContext('2d');
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvasW, canvasH);
+
+ resizeFabricToCanvas();
+
+ // Bild als Fabric-Image laden
const blob = new Blob([arrayBuffer], { type: mime });
const url = URL.createObjectURL(blob);
- await new Promise((res) => {
- const img = new Image();
- img.onload = () => {
- const target = getTargetCanvasWidth();
- // Bei 90/270° werden Breite und Höhe getauscht
- const rotated = (currentPageRotation === 90 || currentPageRotation === 270);
- const srcW = rotated ? img.height : img.width;
- const srcH = rotated ? img.width : img.height;
- const ratio = target / srcW;
- pdfCanvas.width = Math.round(srcW * ratio);
- pdfCanvas.height = Math.round(srcH * ratio);
- const ctx = pdfCanvas.getContext('2d');
- ctx.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height);
- ctx.save();
- ctx.translate(pdfCanvas.width / 2, pdfCanvas.height / 2);
- ctx.rotate(currentPageRotation * Math.PI / 180);
- const dw = img.width * ratio;
- const dh = img.height * ratio;
- ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh);
- ctx.restore();
+ return new Promise((res) => {
+ fabric.Image.fromURL(url, (fabricImg) => {
+ // Wenn schon ein Bild-Objekt im Canvas ist (nach re-render),
+ // altes entfernen bevor neues rein kommt
+ const existing = fabricCanvas.getObjects().find(o => o.bgImage === true);
+ if (existing) fabricCanvas.remove(existing);
+ // Markiere dieses Objekt als "Hintergrundbild" (bleibt aber editierbar)
+ fabricImg.bgImage = true;
+
+ // Auf Canvas-Größe skalieren (contain)
+ const imgRatio = Math.min(canvasW / fabricImg.width, canvasH / fabricImg.height);
+ fabricImg.scale(imgRatio);
+ fabricImg.set({
+ left: (canvasW - fabricImg.width * imgRatio) / 2,
+ top: (canvasH - fabricImg.height * imgRatio) / 2,
+ angle: currentPageRotation,
+ selectable: true,
+ hasControls: true,
+ hasBorders: true,
+ lockRotation: false,
+ });
+ // Ziehbares Hintergrundbild ganz nach hinten
+ fabricCanvas.add(fabricImg);
+ fabricImg.sendToBack();
+ fabricCanvas.requestRenderAll();
URL.revokeObjectURL(url);
res();
- };
- img.src = url;
+ }, { crossOrigin: 'anonymous' });
});
- resizeFabricToCanvas();
}
function resizeFabricToCanvas() {
@@ -267,14 +316,14 @@
});
}
+ let loadedFabricJson = null;
async function loadPageMeta() {
+ loadedFabricJson = null;
try {
const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId);
if (!r.ok) return;
const data = await r.json();
- if (data.fabric_json) {
- fabricCanvas.loadFromJSON(data.fabric_json, () => fabricCanvas.renderAll());
- }
+ if (data.fabric_json) loadedFabricJson = data.fabric_json;
if (data.note) document.getElementById('page-note').value = data.note;
if (typeof data.rotation !== 'undefined' && data.rotation !== null) {
currentPageRotation = parseInt(data.rotation, 10) || 0;
@@ -289,7 +338,6 @@
if (lEl) lEl.value = currentPageLayout;
if (sEl) sEl.value = currentPageScale.toString();
if (aEl) aEl.value = currentPageAlign;
- // Single-only Felder ein/ausblenden
document.querySelectorAll('.single-only').forEach(el => {
el.style.display = (currentPageLayout === 'single') ? '' : 'none';
});
@@ -392,6 +440,31 @@
boldChk.addEventListener('change', applyTextProps);
italicChk.addEventListener('change', applyTextProps);
+ // Text-Hintergrund
+ const bgEl = document.getElementById('tool-bgcolor');
+ const bgOff = document.getElementById('tool-bg-off');
+ if (bgEl) {
+ bgEl.dataset.active = 'on';
+ bgEl.addEventListener('input', () => {
+ bgEl.dataset.active = 'on';
+ const sel = fabricCanvas.getActiveObject();
+ if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) {
+ sel.set({ textBackgroundColor: bgEl.value, padding: 6 });
+ fabricCanvas.requestRenderAll();
+ }
+ });
+ }
+ if (bgOff) {
+ bgOff.addEventListener('click', () => {
+ if (bgEl) bgEl.dataset.active = 'off';
+ const sel = fabricCanvas.getActiveObject();
+ if (sel && (sel.type === 'i-text' || sel.type === 'text' || sel.type === 'textbox')) {
+ sel.set({ textBackgroundColor: '', padding: 0 });
+ fabricCanvas.requestRenderAll();
+ }
+ });
+ }
+
// Bei Selektion eines Text-Objekts die Toolbar-Werte synchronisieren
fabricCanvas.on('selection:created', syncTextToolbar);
fabricCanvas.on('selection:updated', syncTextToolbar);
@@ -479,12 +552,17 @@
const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24;
const bold = document.getElementById('tool-bold').checked;
const ital = document.getElementById('tool-italic').checked;
+ const bgEl = document.getElementById('tool-bgcolor');
+ const bgActive = bgEl && bgEl.dataset.active !== 'off';
+ const bgColor = bgActive ? bgEl.value : '';
const t = new fabric.IText('Text…', {
left: p.x, top: p.y,
fontFamily: ff, fontSize: fs,
fontWeight: bold ? 'bold' : 'normal',
fontStyle: ital ? 'italic' : 'normal',
- fill: color
+ fill: color,
+ textBackgroundColor: bgColor || '',
+ padding: bgColor ? 6 : 0,
});
fabricCanvas.add(t);
fabricCanvas.setActiveObject(t);
@@ -612,9 +690,39 @@
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('pageid', currentPageId);
- fd.append('fabric_json', JSON.stringify(fabricCanvas.toJSON()));
+ // Fabric-JSON MIT Hintergrundbild (bgImage-Objekte werden serialisiert)
+ fd.append('fabric_json', JSON.stringify(fabricCanvas.toJSON(['bgImage'])));
fd.append('note', document.getElementById('page-note').value || '');
fd.append('rotation', currentPageRotation);
+
+ // Phase 6: Composite-PNG der gesamten Seite generieren und mitschicken
+ try {
+ // Selektion aufheben damit keine Controls gerendert werden
+ const active = fabricCanvas.getActiveObject();
+ if (active) fabricCanvas.discardActiveObject();
+
+ // Weißer Hintergrund für den Composite, damit transparente Bereiche nicht schwarz werden
+ const origBg = fabricCanvas.backgroundColor;
+ fabricCanvas.backgroundColor = '#ffffff';
+ fabricCanvas.renderAll();
+
+ const dataUrl = fabricCanvas.toDataURL({
+ format: 'png',
+ quality: 0.92,
+ multiplier: 2, // 2x für bessere PDF-Qualität
+ });
+
+ // Wiederherstellen
+ fabricCanvas.backgroundColor = origBg;
+ if (active) fabricCanvas.setActiveObject(active);
+ fabricCanvas.renderAll();
+
+ const blob = await (await fetch(dataUrl)).blob();
+ fd.append('composite', blob, 'composite.png');
+ } catch (e) {
+ console.warn('Composite-PNG konnte nicht erzeugt werden:', e);
+ }
+
const r = await fetch(cfg.urls.save_annotations, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (showMessage && data.success) toast('Seite gespeichert');
diff --git a/lib/bericht.lib.php b/lib/bericht.lib.php
index 0a8e276..0b2c0af 100644
--- a/lib/bericht.lib.php
+++ b/lib/bericht.lib.php
@@ -307,6 +307,34 @@ function bericht_render_cover_for_preview($template_path, $bericht, $parent, $te
*/
function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
{
+ // Phase 6: Wenn der Editor ein Composite-PNG hochgeladen hat, nutzen wir das
+ // direkt — ein Bild über die ganze Seite. Keine Server-Shape-Logik nötig.
+ if (!empty($page->composite_path)) {
+ $full = bericht_resolve_data_path($page->composite_path);
+ if ($full && file_exists($full)) {
+ $pdf->AddPage($ori, $fmt);
+ $pageW = $pdf->getPageWidth();
+ $pageH = $pdf->getPageHeight();
+ $margin = 10;
+ $maxW = $pageW - 2 * $margin;
+ $maxH = $pageH - 2 * $margin - (empty($page->note) ? 0 : 10);
+ list($iw, $ih) = @getimagesize($full);
+ if ($iw && $ih) {
+ $ratio = min($maxW / $iw, $maxH / $ih);
+ $w = $iw * $ratio; $h = $ih * $ratio;
+ $x = ($pageW - $w) / 2;
+ $y = $margin;
+ $pdf->Image($full, $x, $y, $w, $h);
+ }
+ if (!empty($page->note)) {
+ $pdf->SetY(-20);
+ $pdf->SetFont('helvetica', 'I', 9);
+ $pdf->MultiCell(0, 5, $page->note, 0, 'L');
+ }
+ return;
+ }
+ }
+
$layout = $page->layout ?: 'single';
// Spezial-Fall: reine Titel-Seite (kein Bild, nur großer Titel zentriert)