feat: Phase 6 — Client-WYSIWYG via Composite-PNG + Text-BG + Dark-Input-Fix
All checks were successful
Deploy bericht / deploy (push) Successful in 2s
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:
parent
4dad496788
commit
195942a2f9
7 changed files with 260 additions and 47 deletions
|
|
@ -1,8 +1,16 @@
|
|||
<?php
|
||||
/* Speichert Fabric.js-JSON für eine Seite + ggf. Notiz.
|
||||
* POST: pageid, fabric_json, note, token
|
||||
/* Speichert Fabric.js-JSON + Notiz + optional Composite-PNG für eine Seite.
|
||||
*
|
||||
* 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 DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
|
||||
|
||||
global $db, $user;
|
||||
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 = (($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));
|
||||
|
|
|
|||
|
|
@ -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="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="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>';
|
||||
// Zoom
|
||||
print '<button type="button" id="btn-zoom-out" title="Verkleinern (Zoom -)">🔍−</button>';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
170
js/editor.js
170
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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue