feat: Phase 6 — Client-WYSIWYG via Composite-PNG + Text-BG + Dark-Input-Fix
All checks were successful
Deploy bericht / deploy (push) Successful in 2s

Paradigmen-Wechsel: Editor rendert bei jedem Save sein Fabric-Canvas
als PNG und lädt es hoch. PDF nutzt dieses PNG 1:1 statt die Shapes
serverseitig nachzuzeichnen.

Damit ist garantiert: was du im Editor siehst, ist EXAKT das was im PDF
landet. Alle Pfeil/Text/Shape-Rendering-Bugs zwischen Fabric-JSON und
PHP-Nachzeichnung sind Geschichte.

Kernänderungen:

1. DB: Neue Spalte bericht_page.composite_path (Migration im init())
2. ajax/save_annotations.php: nimmt multipart file 'composite' entgegen,
   speichert es unter bericht/work/<fkb>/composite_<pid>.png
3. lib/bericht.lib.php: bericht_render_page_to_pdf prüft composite_path
   zuerst — wenn vorhanden, wird eine Seite mit genau diesem PNG als
   volles Bild gerendert, fertig. Fallback auf alte Logik bei alten
   Berichten ohne Composite.
4. editor.js renderImage: Quellbild wird NICHT mehr auf pdfCanvas
   gezeichnet, sondern als fabric.Image ins Fabric-Canvas geladen —
   ZIEHBAR, SKALIERBAR, ROTIERBAR wie jedes andere Objekt.
   Mehrere Bilder auf einer Seite kein Problem mehr.
5. editor.js savePageAnnotations: nach Shape-State wird toDataURL
   mit multiplier:2 aufgerufen, PNG-Blob hochgeladen zusammen mit
   fabric_json (für spätere Edits) und note.
6. editor.js loadPage: wenn fabric_json existiert, wird dieses
   clientseitig wieder eingeladen (inkl. eingebettete Bilder) — das
   Quell-Bild wird nicht mehr neu aus der Quelle geholt. Bei leerer
   Seite läuft der alte Render-Flow.

Phase 6 Bonus — Text mit Hintergrund:
- Neuer color-picker 'BG:' in der Toolbar + 'Ø'-Button (kein BG)
- Fabric IText bekommt textBackgroundColor + padding:6
- Bei selektiertem Text-Objekt wird BG live angewendet
- Dataset-Flag 'active' toggelt zwischen ein/aus

Dark-Input-Fix:
- Textarea in .bericht-page-note nutzte --inputbackgroundcolor
  (existiert in awl-dark nicht → Fallback #fff = weiße Fläche mit
  schwarzer Schrift auf Dark-Theme)
- Jetzt: --colorbackbody + --colortext + --colorboxbordertitle1
- Generischer Input-Style für alle Text-Eingaben in .bericht-editor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-09 13:26:57 +02:00
parent 4dad496788
commit 195942a2f9
7 changed files with 260 additions and 47 deletions

View file

@ -1,8 +1,16 @@
<?php <?php
/* Speichert Fabric.js-JSON für eine Seite + ggf. Notiz. /* Speichert Fabric.js-JSON + Notiz + optional Composite-PNG für eine Seite.
* POST: pageid, fabric_json, note, token *
* POST:
* pageid Seiten-ID
* fabric_json Fabric-Serialisierung (für späteren Edit)
* note Notiz für den PDF-Footer
* rotation Rotation 0/90/180/270
* composite (optional) multipart/form-data file: PNG der kompletten Seite
* token
*/ */
require_once __DIR__.'/_inc.php'; require_once __DIR__.'/_inc.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
global $db, $user; global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403); if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
@ -15,15 +23,38 @@ $note = (string) ($_POST['note'] ?? '');
$rotation = (int) ($_POST['rotation'] ?? 0); $rotation = (int) ($_POST['rotation'] ?? 0);
$rotation = (($rotation % 360) + 360) % 360; $rotation = (($rotation % 360) + 360) % 360;
// Page laden // Page laden und fk_bericht ermitteln
$res = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid)); $res = $db->query("SELECT rowid, fk_bericht FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
if (!$res || !$db->fetch_object($res)) bericht_ajax_fail('Page nicht gefunden', 404); 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 " // Composite-PNG speichern wenn hochgeladen
."fabric_json = ".($fabric !== '' ? "'".$db->escape($fabric)."'" : "NULL")."," $composite_relpath = null;
."note = ".($note !== '' ? "'".$db->escape($note)."'" : "NULL")."," if (!empty($_FILES['composite']['tmp_name']) && is_uploaded_file($_FILES['composite']['tmp_name'])) {
."rotation = ".((int) $rotation) $workdir = DOL_DATA_ROOT.'/bericht/work/'.$fk_bericht;
." WHERE rowid = ".((int) $pageid); 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()); if (!$db->query($sql)) bericht_ajax_fail('DB-Fehler: '.$db->lasterror());
bericht_ajax_ok(); bericht_ajax_ok(array('composite_saved' => $composite_relpath !== null));

View file

@ -452,6 +452,8 @@ if (!$bericht) {
print '<label class="text-tool-option" title="Schriftgröße in Pixel">Größe: <input type="number" id="tool-fontsize" min="8" max="120" value="24" style="width:60px" title="Schriftgröße"></label>'; print '<label class="text-tool-option" title="Schriftgröße in Pixel">Größe: <input type="number" id="tool-fontsize" min="8" max="120" value="24" style="width:60px" title="Schriftgröße"></label>';
print '<label class="text-tool-option" title="Fett"><input type="checkbox" id="tool-bold" title="Fett"> <b>B</b></label>'; print '<label class="text-tool-option" title="Fett"><input type="checkbox" id="tool-bold" title="Fett"> <b>B</b></label>';
print '<label class="text-tool-option" title="Kursiv"><input type="checkbox" id="tool-italic" title="Kursiv"> <i>I</i></label>'; print '<label class="text-tool-option" title="Kursiv"><input type="checkbox" id="tool-italic" title="Kursiv"> <i>I</i></label>';
print '<label class="text-tool-option" title="Text-Hintergrundfarbe">BG: <input type="color" id="tool-bgcolor" value="#ffffff"></label>';
print '<button type="button" id="tool-bg-off" class="text-tool-option" title="Kein Hintergrund" style="padding:4px 8px;">Ø</button>';
print '<span class="sep"></span>'; print '<span class="sep"></span>';
// Zoom // Zoom
print '<button type="button" id="btn-zoom-out" title="Verkleinern (Zoom -)">🔍−</button>'; print '<button type="button" id="btn-zoom-out" title="Verkleinern (Zoom -)">🔍−</button>';

View file

@ -410,6 +410,7 @@ class BerichtPage
public $fabric_json; public $fabric_json;
public $note; public $note;
public $title; // Optional Titel/Zwischentitel der Seite public $title; // Optional Titel/Zwischentitel der Seite
public $composite_path; // Phase 6: vom Editor generiertes PNG der gesamten Seite
public $layout = 'single'; public $layout = 'single';
public $image_scale = 1.0; public $image_scale = 1.0;
public $image_align = 'fit'; public $image_align = 'fit';
@ -572,10 +573,17 @@ class BerichtPage
public static function fetchAllForBericht(DoliDB $db, $fk_bericht) public static function fetchAllForBericht(DoliDB $db, $fk_bericht)
{ {
$list = array(); $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," $sql = "SELECT rowid, fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note, title,"
." COALESCE(layout, 'single') AS layout," ." COALESCE(layout, 'single') AS layout,"
." COALESCE(image_scale, 1.0) AS image_scale," ." 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" ." FROM ".$db->prefix()."bericht_page"
." WHERE fk_bericht = ".((int) $fk_bericht) ." WHERE fk_bericht = ".((int) $fk_bericht)
." ORDER BY page_order ASC, rowid ASC"; ." ORDER BY page_order ASC, rowid ASC";
@ -593,6 +601,7 @@ class BerichtPage
$p->fabric_json = $obj->fabric_json; $p->fabric_json = $obj->fabric_json;
$p->note = $obj->note; $p->note = $obj->note;
$p->title = $obj->title; $p->title = $obj->title;
$p->composite_path = $obj->composite_path ?? null;
$p->layout = $obj->layout; $p->layout = $obj->layout;
$p->image_scale = (float) $obj->image_scale; $p->image_scale = (float) $obj->image_scale;
$p->image_align = $obj->image_align; $p->image_align = $obj->image_align;

View file

@ -160,6 +160,11 @@ class modBericht extends DolibarrModules
// Phase 5.3: Versionierung // Phase 5.3: Versionierung
"ALTER TABLE ".$this->db->prefix()."bericht ADD COLUMN version INT DEFAULT 1", "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", "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 // Phase 5.9: Materialliste pro Auftrag
"CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material (" "CREATE TABLE IF NOT EXISTS ".$this->db->prefix()."bericht_material ("
."rowid INT AUTO_INCREMENT PRIMARY KEY," ."rowid INT AUTO_INCREMENT PRIMARY KEY,"

View file

@ -213,16 +213,46 @@
.bericht-page-note { margin-top: 8px; } .bericht-page-note { margin-top: 8px; }
.bericht-page-note label { .bericht-page-note label {
display: block; display: block;
color: var(--colortextbackhmenu, inherit); color: var(--colortext, #e0e0e0);
font-size: 12px; font-size: 12px;
margin-bottom: 4px; margin-bottom: 4px;
} }
.bericht-page-note textarea { .bericht-page-note textarea {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
background: var(--inputbackgroundcolor, #fff); background: var(--colorbackbody, #2a2a30);
color: var(--inputtextcolor, #000); color: var(--colortext, #e0e0e0);
border: 1px solid var(--colorboxbordertitle1, #ccc); 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; } .bericht-pages-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }

View file

@ -158,8 +158,33 @@
fabricCanvas.clear(); fabricCanvas.clear();
document.getElementById('page-note').value = ''; document.getElementById('page-note').value = '';
await loadPageMeta(); // setzt currentPageRotation falls vorhanden await loadPageMeta(); // setzt currentPageRotation + ggf. loadedFabricJson
await rerenderCurrent(); // rendert mit Rotation
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) { 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 blob = new Blob([arrayBuffer], { type: mime });
const url = URL.createObjectURL(blob); 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'); return new Promise((res) => {
ctx.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height); fabric.Image.fromURL(url, (fabricImg) => {
ctx.save(); // Wenn schon ein Bild-Objekt im Canvas ist (nach re-render),
ctx.translate(pdfCanvas.width / 2, pdfCanvas.height / 2); // altes entfernen bevor neues rein kommt
ctx.rotate(currentPageRotation * Math.PI / 180); const existing = fabricCanvas.getObjects().find(o => o.bgImage === true);
const dw = img.width * ratio; if (existing) fabricCanvas.remove(existing);
const dh = img.height * ratio;
ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
// 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); URL.revokeObjectURL(url);
res(); res();
}; }, { crossOrigin: 'anonymous' });
img.src = url;
}); });
resizeFabricToCanvas();
} }
function resizeFabricToCanvas() { function resizeFabricToCanvas() {
@ -267,14 +316,14 @@
}); });
} }
let loadedFabricJson = null;
async function loadPageMeta() { async function loadPageMeta() {
loadedFabricJson = null;
try { try {
const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId); const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId);
if (!r.ok) return; if (!r.ok) return;
const data = await r.json(); const data = await r.json();
if (data.fabric_json) { if (data.fabric_json) loadedFabricJson = data.fabric_json;
fabricCanvas.loadFromJSON(data.fabric_json, () => fabricCanvas.renderAll());
}
if (data.note) document.getElementById('page-note').value = data.note; if (data.note) document.getElementById('page-note').value = data.note;
if (typeof data.rotation !== 'undefined' && data.rotation !== null) { if (typeof data.rotation !== 'undefined' && data.rotation !== null) {
currentPageRotation = parseInt(data.rotation, 10) || 0; currentPageRotation = parseInt(data.rotation, 10) || 0;
@ -289,7 +338,6 @@
if (lEl) lEl.value = currentPageLayout; if (lEl) lEl.value = currentPageLayout;
if (sEl) sEl.value = currentPageScale.toString(); if (sEl) sEl.value = currentPageScale.toString();
if (aEl) aEl.value = currentPageAlign; if (aEl) aEl.value = currentPageAlign;
// Single-only Felder ein/ausblenden
document.querySelectorAll('.single-only').forEach(el => { document.querySelectorAll('.single-only').forEach(el => {
el.style.display = (currentPageLayout === 'single') ? '' : 'none'; el.style.display = (currentPageLayout === 'single') ? '' : 'none';
}); });
@ -392,6 +440,31 @@
boldChk.addEventListener('change', applyTextProps); boldChk.addEventListener('change', applyTextProps);
italicChk.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 // Bei Selektion eines Text-Objekts die Toolbar-Werte synchronisieren
fabricCanvas.on('selection:created', syncTextToolbar); fabricCanvas.on('selection:created', syncTextToolbar);
fabricCanvas.on('selection:updated', syncTextToolbar); fabricCanvas.on('selection:updated', syncTextToolbar);
@ -479,12 +552,17 @@
const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24; const fs = parseInt(document.getElementById('tool-fontsize').value, 10) || 24;
const bold = document.getElementById('tool-bold').checked; const bold = document.getElementById('tool-bold').checked;
const ital = document.getElementById('tool-italic').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…', { const t = new fabric.IText('Text…', {
left: p.x, top: p.y, left: p.x, top: p.y,
fontFamily: ff, fontSize: fs, fontFamily: ff, fontSize: fs,
fontWeight: bold ? 'bold' : 'normal', fontWeight: bold ? 'bold' : 'normal',
fontStyle: ital ? 'italic' : 'normal', fontStyle: ital ? 'italic' : 'normal',
fill: color fill: color,
textBackgroundColor: bgColor || '',
padding: bgColor ? 6 : 0,
}); });
fabricCanvas.add(t); fabricCanvas.add(t);
fabricCanvas.setActiveObject(t); fabricCanvas.setActiveObject(t);
@ -612,9 +690,39 @@
const fd = new FormData(); const fd = new FormData();
fd.append('token', cfg.token); fd.append('token', cfg.token);
fd.append('pageid', currentPageId); 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('note', document.getElementById('page-note').value || '');
fd.append('rotation', currentPageRotation); 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 r = await fetch(cfg.urls.save_annotations, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
if (showMessage && data.success) toast('Seite gespeichert'); if (showMessage && data.success) toast('Seite gespeichert');

View file

@ -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) 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'; $layout = $page->layout ?: 'single';
// Spezial-Fall: reine Titel-Seite (kein Bild, nur großer Titel zentriert) // Spezial-Fall: reine Titel-Seite (kein Bild, nur großer Titel zentriert)