feat: Phase 1.4 + 1.5 — Multi-Image Grids und Bildgröße komplett
All checks were successful
Deploy bericht / deploy (push) Successful in 1s

DB-Klasse:
- BerichtPage::getImages() liest llx_bericht_page_image
- BerichtPage::setSlotImage() / clearImages()
- BerichtPage::slotCountForLayout()
- BerichtPage::slotRects() berechnet Slot-Positionen für 1/2/2v/4/6
- create()/update()/fetchAllForBericht() inkludieren layout/scale/align

Endpoints (alle im Bericht-Modul):
- ajax/save_page_options.php — speichert layout, image_scale, image_align
- ajax/create_grid_page.php — erstellt Multi-Image-Seite mit gewähltem Layout
- ajax/set_slot_image.php — setzt einzelnes Bild eines Slots
- ajax/page_meta.php liefert layout/scale/align mit
- ajax/page_image.php rendert Composite-PNG (GD) für Multi-Image-Seiten

UI:
- Layout-Dropdown in 3. Toolbar-Zeile (Single/Grid 2/2v/4/6)
- Bildgröße-Dropdown (100/70/50/30%) — single-only
- Position-Dropdown (Anpassen/Zentriert/Ecken) — single-only
- 'Als Grid hinzufügen'-Buttons in der Anhänge-Liste (▭▭ ▯▯ ▦ ▦▦)
- Auto-Sync der single-only-Felder beim Layout-Wechsel

Rendering:
- bericht_render_page_to_pdf() in lib/bericht.lib.php — zentrale
  Render-Funktion für Single + Grid + PDF-Quelle + image_scale + align
- bericht_align_position() für die 6 Align-Modi
- generate_pdf + preview_pdf nutzen die gemeinsame Funktion (DRY)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-08 22:33:44 +02:00
parent 0fbfb1bf27
commit 06cd70d4a3
12 changed files with 597 additions and 85 deletions

49
ajax/create_grid_page.php Normal file
View file

@ -0,0 +1,49 @@
<?php
/* Erstellt eine neue Multi-Image-Seite mit gewähltem Layout
* und setzt mehrere Bilder gleich rein.
* POST: berichtid, layout, relpaths[] (JSON-Array), token
*/
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$berichtid = (int) ($_POST['berichtid'] ?? 0);
$layout = (string) ($_POST['layout'] ?? 'grid_4');
$relpaths_raw = (string) ($_POST['relpaths'] ?? '[]');
$relpaths = json_decode($relpaths_raw, true);
if (!is_array($relpaths) || empty($relpaths)) bericht_ajax_fail('Keine Bilder ausgewählt');
$valid_layouts = array('grid_2', 'grid_2v', 'grid_4', 'grid_6');
if (!in_array($layout, $valid_layouts, true)) $layout = 'grid_4';
$bericht = new Bericht($db);
if ($bericht->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
// Höchste page_order ermitteln
$res = $db->query("SELECT COALESCE(MAX(page_order),0) AS m FROM ".$db->prefix()."bericht_page WHERE fk_bericht = ".((int) $berichtid));
$next_order = ($res && ($o = $db->fetch_object($res))) ? ((int) $o->m) + 1 : 1;
// Neue Page anlegen — dummy source_path, da Bilder über page_image kommen
$page = new BerichtPage($db);
$page->fk_bericht = $berichtid;
$page->page_order = $next_order;
$page->source_type = 'grid';
$page->source_path = ''; // bei grid leer
$page->layout = $layout;
$page->image_scale = 1.0;
$page->image_align = 'fit';
if ($page->create() <= 0) bericht_ajax_fail('Page-Insert fehlgeschlagen');
// Bilder in Slots verteilen
$slot_count = BerichtPage::slotCountForLayout($layout);
$slot = 0;
foreach ($relpaths as $rel) {
if ($slot >= $slot_count) break;
$full = bericht_resolve_data_path($rel);
if (!$full || !file_exists($full)) continue;
$page->setSlotImage($slot, $rel, 0);
$slot++;
}
bericht_ajax_ok(array('pageid' => $page->id, 'created_slots' => $slot));

View file

@ -78,45 +78,7 @@ if (!empty($bericht->template_odt)) {
// --- Seiten ---
foreach ($pages as $page) {
$full = bericht_resolve_data_path($page->source_path);
if (!$full || !file_exists($full)) continue;
if ($page->source_type === 'image' || preg_match('/\.(png|jpe?g)$/i', $full)) {
// Bild als Seite im konfigurierten Format/Orientation
$pdf->AddPage($ori, $fmt);
list($iw, $ih) = @getimagesize($full);
if ($iw && $ih) {
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$maxW = $pageW - 2 * $margin;
$maxH = $pageH - 2 * $margin - 10; // 10mm für Notiz unten
$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);
bericht_burn_annotations($pdf, $page->fabric_json, $x, $y, $w, $h);
}
} elseif ($fpdi_loaded && preg_match('/\.pdf$/i', $full)) {
try {
$pdf->setSourceFile($full);
$tpl = $pdf->importPage($page->source_page ?: 1);
$size = $pdf->getTemplateSize($tpl);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
// Annotationen über volle Seitenfläche
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $size['width'], $size['height']);
} catch (Throwable $e) {
// Seite überspringen
}
}
// Notiz unten
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded);
}
// --- Speichern ---

View file

@ -23,11 +23,29 @@ if (!$user->hasRight('bericht', 'read')) accessforbidden();
$pageid = GETPOSTINT('pageid');
if (!$pageid) { http_response_code(400); exit; }
$res = $db->query("SELECT source_type, source_path FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
$res = $db->query("SELECT source_type, source_path, COALESCE(layout,'single') AS layout FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
if (!$res) { http_response_code(500); exit; }
$row = $db->fetch_object($res);
if (!$row) { http_response_code(404); exit; }
// Bei Multi-Image-Seiten: serverseitig ein Composite-Bild rendern (PNG)
if ($row->layout !== 'single') {
$imgs_q = $db->query("SELECT slot, source_path FROM ".$db->prefix()."bericht_page_image WHERE fk_page = ".((int) $pageid)." ORDER BY slot");
if (!$imgs_q) { http_response_code(500); exit; }
$imgs = array();
while ($r = $db->fetch_object($imgs_q)) {
$imgs[(int) $r->slot] = $r->source_path;
}
if (empty($imgs)) { http_response_code(404); exit; }
$composite = bericht_render_grid_png($row->layout, $imgs);
if (!$composite) { http_response_code(500); exit; }
header('Content-Type: image/png');
header('Cache-Control: no-store');
echo $composite;
exit;
}
$full = bericht_resolve_data_path($row->source_path);
if (!$full || !file_exists($full)) { http_response_code(404); exit; }
@ -36,3 +54,75 @@ header('Content-Type: '.$mime);
header('Content-Length: '.filesize($full));
header('Cache-Control: private, max-age=300');
readfile($full);
/**
* Erstellt ein Composite-PNG für eine Multi-Image-Seite (Editor-Vorschau).
*/
function bericht_render_grid_png($layout, $imgs)
{
// A4 Hochformat als Grundfläche, 150 DPI
$W = 1240; // ~A4 @ 150dpi
$H = 1754;
$margin = 40;
$gap = 16;
$canvas = imagecreatetruecolor($W, $H);
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 255, 255, 255));
// Slot-Berechnung (gleiche Logik wie BerichtPage::slotRects, aber in Pixel)
$iw = $W - 2 * $margin;
$ih = $H - 2 * $margin - 60; // 60px für Notiz-Bereich
$rects = array();
if ($layout === 'grid_2') {
$h = ($ih - $gap) / 2;
for ($i = 0; $i < 2; $i++) $rects[] = array($margin, $margin + $i * ($h + $gap), $iw, $h);
} elseif ($layout === 'grid_2v') {
$w = ($iw - $gap) / 2;
for ($i = 0; $i < 2; $i++) $rects[] = array($margin + $i * ($w + $gap), $margin, $w, $ih);
} elseif ($layout === 'grid_4') {
$w = ($iw - $gap) / 2;
$h = ($ih - $gap) / 2;
for ($r = 0; $r < 2; $r++) for ($c = 0; $c < 2; $c++) {
$rects[] = array($margin + $c * ($w + $gap), $margin + $r * ($h + $gap), $w, $h);
}
} elseif ($layout === 'grid_6') {
$w = ($iw - 2 * $gap) / 3;
$h = ($ih - $gap) / 2;
for ($r = 0; $r < 2; $r++) for ($c = 0; $c < 3; $c++) {
$rects[] = array($margin + $c * ($w + $gap), $margin + $r * ($h + $gap), $w, $h);
}
}
foreach ($rects as $i => $r) {
list($rx, $ry, $rw, $rh) = $r;
// Slot-Hintergrund + Border
$border = imagecolorallocate($canvas, 200, 200, 200);
imagerectangle($canvas, $rx, $ry, $rx + $rw, $ry + $rh, $border);
if (!isset($imgs[$i])) continue;
$full = bericht_resolve_data_path($imgs[$i]);
if (!$full || !file_exists($full)) continue;
$info = @getimagesize($full);
if (!$info) continue;
$src = null;
if ($info[2] === IMAGETYPE_JPEG) $src = @imagecreatefromjpeg($full);
elseif ($info[2] === IMAGETYPE_PNG) $src = @imagecreatefrompng($full);
if (!$src) continue;
$sw = $info[0]; $sh = $info[1];
$ratio = min($rw / $sw, $rh / $sh);
$dw = (int) ($sw * $ratio);
$dh = (int) ($sh * $ratio);
$dx = (int) ($rx + ($rw - $dw) / 2);
$dy = (int) ($ry + ($rh - $dh) / 2);
imagecopyresampled($canvas, $src, $dx, $dy, 0, 0, $dw, $dh, $sw, $sh);
imagedestroy($src);
}
ob_start();
imagepng($canvas, null, 6);
imagedestroy($canvas);
return ob_get_clean();
}

View file

@ -7,7 +7,9 @@ global $db;
$pageid = (int) ($_GET['pageid'] ?? 0);
if (!$pageid) bericht_ajax_fail('pageid fehlt');
$res = $db->query("SELECT fabric_json, note, rotation FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
$res = $db->query("SELECT fabric_json, note, rotation, COALESCE(layout,'single') AS layout,"
." COALESCE(image_scale,1.0) AS image_scale, COALESCE(image_align,'fit') AS image_align"
." FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
if (!$res) bericht_ajax_fail($db->lasterror());
$row = $db->fetch_object($res);
if (!$row) bericht_ajax_fail('Page nicht gefunden', 404);
@ -16,4 +18,7 @@ bericht_ajax_ok(array(
'fabric_json' => $row->fabric_json,
'note' => $row->note,
'rotation' => (int) $row->rotation,
'layout' => $row->layout,
'image_scale' => (float) $row->image_scale,
'image_align' => $row->image_align,
));

View file

@ -86,46 +86,9 @@ if (!empty($bericht->template_odt)) {
}
}
// Seiten — Logik aus generate_pdf.php duplizieren (vereinfacht: nur Bilder + PDF + Annotationen)
// Seiten via gemeinsamer Render-Funktion
foreach ($pages as $page) {
$full = bericht_resolve_data_path($page->source_path);
if (!$full || !file_exists($full)) continue;
if ($page->source_type === 'image' || preg_match('/\.(png|jpe?g)$/i', $full)) {
$pdf->AddPage($ori, $fmt);
list($iw, $ih) = @getimagesize($full);
if ($iw && $ih) {
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$maxW = $pageW - 2 * $margin;
$maxH = $pageH - 2 * $margin - 10;
$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 (function_exists('bericht_burn_annotations')) {
bericht_burn_annotations($pdf, $page->fabric_json, $x, $y, $w, $h);
}
}
} elseif ($fpdi_loaded && preg_match('/\.pdf$/i', $full)) {
try {
$pdf->setSourceFile($full);
$tpl = $pdf->importPage($page->source_page ?: 1);
$size = $pdf->getTemplateSize($tpl);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
if (function_exists('bericht_burn_annotations')) {
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $size['width'], $size['height']);
}
} catch (Throwable $e) { /* skip */ }
}
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded);
}
// Direkt im Browser anzeigen — kein File schreiben, kein ECM

View file

@ -0,0 +1,32 @@
<?php
/* Speichert Layout / image_scale / image_align einer Seite.
* POST: pageid, layout, image_scale, image_align, token
*/
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$pageid = (int) ($_POST['pageid'] ?? 0);
if (!$pageid) bericht_ajax_fail('pageid fehlt');
$layout = $_POST['layout'] ?? 'single';
$scale = (float) ($_POST['image_scale'] ?? 1.0);
$align = $_POST['image_align'] ?? 'fit';
$valid_layouts = array('single', 'grid_2', 'grid_2v', 'grid_4', 'grid_6');
if (!in_array($layout, $valid_layouts, true)) $layout = 'single';
$valid_align = array('fit', 'center', 'topleft', 'topright', 'bottomleft', 'bottomright');
if (!in_array($align, $valid_align, true)) $align = 'fit';
$scale = max(0.2, min(1.0, $scale));
$sql = "UPDATE ".$db->prefix()."bericht_page SET "
."layout = '".$db->escape($layout)."',"
."image_scale = ".((float) $scale).","
."image_align = '".$db->escape($align)."'"
." WHERE rowid = ".((int) $pageid);
if (!$db->query($sql)) bericht_ajax_fail($db->lasterror());
bericht_ajax_ok(array('layout' => $layout, 'image_scale' => $scale, 'image_align' => $align));

32
ajax/set_slot_image.php Normal file
View file

@ -0,0 +1,32 @@
<?php
/* Setzt das Bild eines Slots in einer Multi-Image-Seite.
* POST: pageid, slot, relpath, token
*/
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$pageid = (int) ($_POST['pageid'] ?? 0);
$slot = (int) ($_POST['slot'] ?? 0);
$relpath = (string) ($_POST['relpath'] ?? '');
if (!$pageid || $slot < 0 || empty($relpath)) bericht_ajax_fail('Parameter fehlen');
$full = bericht_resolve_data_path($relpath);
if (!$full || !file_exists($full)) bericht_ajax_fail('Datei nicht gefunden', 404);
// Page laden
$page_obj = null;
$pages_res = $db->query("SELECT rowid, fk_bericht, layout FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
if (!$pages_res || !($row = $db->fetch_object($pages_res))) bericht_ajax_fail('Seite nicht gefunden', 404);
$p = new BerichtPage($db);
$p->id = $row->rowid;
$p->fk_bericht = $row->fk_bericht;
$p->layout = $row->layout;
if ($p->setSlotImage($slot, $relpath, 0) > 0) {
bericht_ajax_ok();
}
bericht_ajax_fail('DB-Fehler');

View file

@ -262,6 +262,9 @@ if (!$bericht) {
'preview_pdf' => dol_buildpath('/bericht/ajax/preview_pdf.php', 1),
'delete_attachment'=> dol_buildpath('/bericht/ajax/delete_attachment.php', 1),
'save_meta' => dol_buildpath('/bericht/ajax/save_meta.php', 1),
'save_page_options'=> dol_buildpath('/bericht/ajax/save_page_options.php', 1),
'create_grid_page' => dol_buildpath('/bericht/ajax/create_grid_page.php', 1),
'set_slot_image' => dol_buildpath('/bericht/ajax/set_slot_image.php', 1),
),
'lang' => array(
'undo' => $langs->trans("BerichtUndo"),
@ -350,7 +353,14 @@ if (!$bericht) {
}
print '</div>';
}
print '<button type="button" id="btn-add-selected" class="butAction">'.$langs->trans("BerichtAddSelectedToReport").'</button>';
print '<button type="button" id="btn-add-selected" class="butAction" title="Jedes ausgewählte Bild als eigene Seite">'.$langs->trans("BerichtAddSelectedToReport").'</button>';
print '<div class="bericht-grid-buttons" style="margin-top:6px;">';
print '<span class="opacitymedium small">Mehrere Bilder als Grid:</span><br>';
print '<button type="button" class="btn-add-grid" data-layout="grid_2" title="2 Bilder (oben/unten)">▭▭</button> ';
print '<button type="button" class="btn-add-grid" data-layout="grid_2v" title="2 Bilder (links/rechts)">▯▯</button> ';
print '<button type="button" class="btn-add-grid" data-layout="grid_4" title="2x2 Grid">▦</button> ';
print '<button type="button" class="btn-add-grid" data-layout="grid_6" title="3x2 Grid">▦▦</button>';
print '</div>';
}
print '<hr>';
print '<div class="bericht-upload">';
@ -400,6 +410,30 @@ if (!$bericht) {
print '<span class="sep"></span>';
print '<button type="button" id="btn-rotate-left" title="'.$langs->trans("BerichtRotateLeft").' (Seite 90° nach links drehen)">⟲</button>';
print '<button type="button" id="btn-rotate-right" title="'.$langs->trans("BerichtRotateRight").' (Seite 90° nach rechts drehen)">⟳</button>';
// ====== Dritte Zeile: Layout & Bildgröße der aktuellen Seite ======
print '<div class="row-break"></div>';
print '<label title="Seiten-Layout: Single = 1 Bild, Grid = mehrere Bilder">Layout: <select id="page-layout">'
.'<option value="single">Single (1 Bild)</option>'
.'<option value="grid_2">2 Bilder (oben/unten)</option>'
.'<option value="grid_2v">2 Bilder (links/rechts)</option>'
.'<option value="grid_4">4 Bilder (2x2)</option>'
.'<option value="grid_6">6 Bilder (3x2)</option>'
.'</select></label>';
print '<label class="single-only" title="Wie groß das Bild auf der Seite gedruckt wird (nur Single)">Größe: <select id="page-imgscale">'
.'<option value="1.0">Vollseite (100%)</option>'
.'<option value="0.7">Groß (70%)</option>'
.'<option value="0.5">Mittel (50%)</option>'
.'<option value="0.3">Klein (30%)</option>'
.'</select></label>';
print '<label class="single-only" title="Position auf der Seite (nur bei kleineren Bildern)">Position: <select id="page-imgalign">'
.'<option value="fit">Anpassen</option>'
.'<option value="center">Zentriert</option>'
.'<option value="topleft">Oben links</option>'
.'<option value="topright">Oben rechts</option>'
.'<option value="bottomleft">Unten links</option>'
.'<option value="bottomright">Unten rechts</option>'
.'</select></label>';
print '</div>';
print '<div class="bericht-canvas-wrap">';
print '<canvas id="pdf-canvas"></canvas>';

View file

@ -248,6 +248,16 @@ class Bericht extends CommonObject
/**
* Eine Seite eines Berichts.
*
* Layout-Modi:
* single = ein Bild/PDF direkt auf source_path
* grid_2 = 2 Bilder nebeneinander (über llx_bericht_page_image)
* grid_4 = 2x2 Grid
* grid_6 = 3x2 Grid
*
* Bei single werden image_scale + image_align angewendet:
* image_scale: 1.0 / 0.7 / 0.5 / 0.3
* image_align: fit / center / topleft / topright / bottomleft / bottomright
*/
class BerichtPage
{
@ -261,16 +271,124 @@ class BerichtPage
public $rotation;
public $fabric_json;
public $note;
public $layout = 'single';
public $image_scale = 1.0;
public $image_align = 'fit';
public function __construct(DoliDB $db)
{
$this->db = $db;
}
/**
* Liefert die Bilder einer Multi-Image-Seite (layout != single).
* @return array [['rowid'=>..., 'slot'=>..., 'source_path'=>..., 'rotation'=>...], ...]
*/
public function getImages()
{
$list = array();
if ($this->layout === 'single') return $list;
$sql = "SELECT rowid, slot, source_path, rotation FROM ".$this->db->prefix()."bericht_page_image"
." WHERE fk_page = ".((int) $this->id)
." ORDER BY slot ASC";
$res = $this->db->query($sql);
if (!$res) return $list;
while ($obj = $this->db->fetch_object($res)) {
$list[] = array(
'rowid' => (int) $obj->rowid,
'slot' => (int) $obj->slot,
'source_path' => $obj->source_path,
'rotation' => (int) $obj->rotation,
);
}
return $list;
}
/**
* Setzt das Bild eines Slots (überschreibt vorhandenes).
*/
public function setSlotImage($slot, $source_path, $rotation = 0)
{
$this->db->query("DELETE FROM ".$this->db->prefix()."bericht_page_image"
." WHERE fk_page = ".((int) $this->id)." AND slot = ".((int) $slot));
$sql = "INSERT INTO ".$this->db->prefix()."bericht_page_image (fk_page, slot, source_path, rotation)"
." VALUES (".((int) $this->id).", ".((int) $slot).", '".$this->db->escape($source_path)."', ".((int) $rotation).")";
return $this->db->query($sql) ? 1 : -1;
}
public function clearImages()
{
return $this->db->query("DELETE FROM ".$this->db->prefix()."bericht_page_image WHERE fk_page = ".((int) $this->id)) ? 1 : -1;
}
/**
* Anzahl Slots eines Layouts.
*/
public static function slotCountForLayout($layout)
{
return array(
'single' => 1,
'grid_2' => 2,
'grid_2v' => 2,
'grid_4' => 4,
'grid_6' => 6,
)[$layout] ?? 1;
}
/**
* Berechnet die Slot-Rectangles innerhalb einer Seite (mm) für ein Grid-Layout.
* @return array [['x'=>..,'y'=>..,'w'=>..,'h'=>..], ...]
*/
public static function slotRects($layout, $pageW, $pageH, $margin = 10, $gap = 4)
{
$rects = array();
$iw = $pageW - 2 * $margin;
$ih = $pageH - 2 * $margin - 10; // 10mm Notiz unten
if ($layout === 'grid_2') {
// 2 Bilder horizontal: 1 oben, 1 unten
$h = ($ih - $gap) / 2;
for ($i = 0; $i < 2; $i++) {
$rects[] = array('x' => $margin, 'y' => $margin + $i * ($h + $gap), 'w' => $iw, 'h' => $h);
}
} elseif ($layout === 'grid_2v') {
// 2 Bilder vertikal nebeneinander
$w = ($iw - $gap) / 2;
for ($i = 0; $i < 2; $i++) {
$rects[] = array('x' => $margin + $i * ($w + $gap), 'y' => $margin, 'w' => $w, 'h' => $ih);
}
} elseif ($layout === 'grid_4') {
$w = ($iw - $gap) / 2;
$h = ($ih - $gap) / 2;
for ($r = 0; $r < 2; $r++) {
for ($c = 0; $c < 2; $c++) {
$rects[] = array(
'x' => $margin + $c * ($w + $gap),
'y' => $margin + $r * ($h + $gap),
'w' => $w, 'h' => $h
);
}
}
} elseif ($layout === 'grid_6') {
$w = ($iw - 2 * $gap) / 3;
$h = ($ih - $gap) / 2;
for ($r = 0; $r < 2; $r++) {
for ($c = 0; $c < 3; $c++) {
$rects[] = array(
'x' => $margin + $c * ($w + $gap),
'y' => $margin + $r * ($h + $gap),
'w' => $w, 'h' => $h
);
}
}
}
return $rects;
}
public function create()
{
$sql = "INSERT INTO ".$this->db->prefix()."bericht_page ("
."fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note"
."fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note, layout, image_scale, image_align"
.") VALUES ("
.((int) $this->fk_bericht).","
.((int) $this->page_order).","
@ -279,7 +397,10 @@ class BerichtPage
.($this->source_page !== null ? (int) $this->source_page : "NULL").","
.((int) ($this->rotation ?? 0)).","
.($this->fabric_json !== null ? "'".$this->db->escape($this->fabric_json)."'" : "NULL").","
.($this->note ? "'".$this->db->escape($this->note)."'" : "NULL")
.($this->note ? "'".$this->db->escape($this->note)."'" : "NULL").","
."'".$this->db->escape($this->layout ?: 'single')."',"
.((float) ($this->image_scale ?: 1.0)).","
."'".$this->db->escape($this->image_align ?: 'fit')."'"
.")";
$res = $this->db->query($sql);
if (!$res) return -1;
@ -293,7 +414,10 @@ class BerichtPage
."page_order = ".((int) $this->page_order).","
."rotation = ".((int) ($this->rotation ?? 0)).","
."fabric_json = ".($this->fabric_json !== null ? "'".$this->db->escape($this->fabric_json)."'" : "NULL").","
."note = ".($this->note ? "'".$this->db->escape($this->note)."'" : "NULL")
."note = ".($this->note ? "'".$this->db->escape($this->note)."'" : "NULL").","
."layout = '".$this->db->escape($this->layout ?: 'single')."',"
."image_scale = ".((float) ($this->image_scale ?: 1.0)).","
."image_align = '".$this->db->escape($this->image_align ?: 'fit')."'"
." WHERE rowid = ".((int) $this->id);
return $this->db->query($sql) ? 1 : -1;
}
@ -306,7 +430,10 @@ class BerichtPage
public static function fetchAllForBericht(DoliDB $db, $fk_bericht)
{
$list = array();
$sql = "SELECT rowid, fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note"
$sql = "SELECT rowid, fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note,"
." COALESCE(layout, 'single') AS layout,"
." COALESCE(image_scale, 1.0) AS image_scale,"
." COALESCE(image_align, 'fit') AS image_align"
." FROM ".$db->prefix()."bericht_page"
." WHERE fk_bericht = ".((int) $fk_bericht)
." ORDER BY page_order ASC, rowid ASC";
@ -323,6 +450,9 @@ class BerichtPage
$p->rotation = (int) $obj->rotation;
$p->fabric_json = $obj->fabric_json;
$p->note = $obj->note;
$p->layout = $obj->layout;
$p->image_scale = (float) $obj->image_scale;
$p->image_align = $obj->image_align;
$list[] = $p;
}
return $list;

View file

@ -63,6 +63,23 @@
.bericht-upload { margin-top: 10px; }
.bericht-grid-buttons {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--colorboxbordertitle1, #444);
}
.bericht-grid-buttons button {
background: var(--colorbacktitle1, #2a2a30);
color: var(--colortext, #ddd);
border: 1px solid var(--colorboxbordertitle1, #555);
border-radius: 3px;
padding: 4px 8px;
margin: 2px;
cursor: pointer;
font-size: 14px;
}
.bericht-grid-buttons button:hover { background: var(--colorbackhmenu1, #337ab7); color: #fff; }
/* Mittlere Spalte: Editor-Canvas bewusst dunkler Hintergrund (auch im Light-Mode),
* weil ein Canvas auf grauer Fläche besser sichtbar ist.
*/

View file

@ -36,6 +36,9 @@
let currentPageRotation = 0; // 0 / 90 / 180 / 270
let currentPageBuffer = null; // ArrayBuffer der aktuellen Quelle
let currentPageMime = '';
let currentPageLayout = 'single';
let currentPageScale = 1.0;
let currentPageAlign = 'fit';
let currentZoom = 1.0; // 1.0 = 100% (Container-Fit), 0.5..3.0
let fabricCanvas = null;
const pdfCanvas = document.getElementById('pdf-canvas');
@ -268,6 +271,20 @@
if (typeof data.rotation !== 'undefined' && data.rotation !== null) {
currentPageRotation = parseInt(data.rotation, 10) || 0;
}
if (data.layout) currentPageLayout = data.layout;
if (data.image_scale) currentPageScale = parseFloat(data.image_scale);
if (data.image_align) currentPageAlign = data.image_align;
// Toolbar-Selects synchronisieren
const lEl = document.getElementById('page-layout');
const sEl = document.getElementById('page-imgscale');
const aEl = document.getElementById('page-imgalign');
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';
});
} catch (e) { /* ok */ }
}
@ -314,6 +331,36 @@
document.getElementById('btn-zoom-out').addEventListener('click', () => setZoom(currentZoom - 0.25));
document.getElementById('btn-zoom-reset').addEventListener('click', () => setZoom(1.0));
// Layout / Bildgröße / Align — pro Seite
const layoutEl = document.getElementById('page-layout');
const scaleEl = document.getElementById('page-imgscale');
const alignEl = document.getElementById('page-imgalign');
async function savePageOptions() {
if (!currentPageId) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('pageid', currentPageId);
fd.append('layout', layoutEl.value);
fd.append('image_scale', scaleEl.value);
fd.append('image_align', alignEl.value);
await fetch(cfg.urls.save_page_options, { method: 'POST', body: fd });
}
if (layoutEl) layoutEl.addEventListener('change', async () => {
currentPageLayout = layoutEl.value;
document.querySelectorAll('.single-only').forEach(el => {
el.style.display = (currentPageLayout === 'single') ? '' : 'none';
});
await savePageOptions();
});
if (scaleEl) scaleEl.addEventListener('change', async () => {
currentPageScale = parseFloat(scaleEl.value);
await savePageOptions();
});
if (alignEl) alignEl.addEventListener('change', async () => {
currentPageAlign = alignEl.value;
await savePageOptions();
});
// Schrift-Optionen für Text-Tool / selektierte Texte
const fontFamily = document.getElementById('tool-fontfamily');
const fontSize = document.getElementById('tool-fontsize');
@ -648,6 +695,36 @@
});
}
// Grid-Buttons: ausgewählte Bilder als eine Multi-Image-Seite hinzufügen
document.querySelectorAll('.btn-add-grid').forEach(b => {
b.addEventListener('click', async () => {
const layout = b.dataset.layout;
const checks = document.querySelectorAll('.att-check:checked');
if (!checks.length) {
alert('Bitte zuerst Bilder ankreuzen');
return;
}
const slotCount = { grid_2: 2, grid_2v: 2, grid_4: 4, grid_6: 6 }[layout] || 4;
const relpaths = Array.from(checks)
.filter(c => c.dataset.mime.startsWith('image'))
.slice(0, slotCount)
.map(c => c.dataset.relpath);
if (!relpaths.length) {
alert('Bitte mindestens ein Bild ankreuzen (PDFs sind in Grids nicht unterstützt)');
return;
}
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('berichtid', cfg.berichtid);
fd.append('layout', layout);
fd.append('relpaths', JSON.stringify(relpaths));
const r = await fetch(cfg.urls.create_grid_page, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (data.success) location.reload();
else alert('Fehler: ' + (data.error || 'unbekannt'));
});
});
// Lösch-Buttons in der Anhänge-Liste
document.querySelectorAll('.att-delete').forEach(btn => {
btn.addEventListener('click', async (e) => {

View file

@ -295,6 +295,127 @@ function bericht_render_cover_for_preview($template_path, $bericht, $parent, $te
return bericht_render_cover_internal($template_path, $bericht, $parent, $tempdir);
}
/**
* Rendert eine BerichtPage in das aktuelle TCPDF-Objekt.
* Behandelt: single (mit image_scale/align), grid_2, grid_2v, grid_4, grid_6, PDF-Quelle.
*
* @param TCPDF $pdf
* @param BerichtPage $page
* @param string $ori 'P' oder 'L'
* @param string $fmt A4/A3/A5/Letter
* @param bool $fpdi_loaded
*/
function bericht_render_page_to_pdf($pdf, $page, $ori, $fmt, $fpdi_loaded)
{
$layout = $page->layout ?: 'single';
/* ----- Multi-Image Grid ----- */
if ($layout !== 'single') {
$imgs = $page->getImages();
if (empty($imgs)) return;
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$rects = BerichtPage::slotRects($layout, $pageW, $pageH);
foreach ($imgs as $img) {
$slot = $img['slot'];
if (!isset($rects[$slot])) continue;
$r = $rects[$slot];
$full = bericht_resolve_data_path($img['source_path']);
if (!$full || !file_exists($full)) continue;
list($iw, $ih) = @getimagesize($full);
if (!$iw || !$ih) continue;
$ratio = min($r['w'] / $iw, $r['h'] / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
$x = $r['x'] + ($r['w'] - $w) / 2;
$y = $r['y'] + ($r['h'] - $h) / 2;
$pdf->Image($full, $x, $y, $w, $h);
}
// Annotationen über volle Seite
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $pageW, $pageH);
// Notiz unten
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
return;
}
/* ----- Single ----- */
$full = bericht_resolve_data_path($page->source_path);
if (!$full || !file_exists($full)) return;
if ($page->source_type === 'image' || preg_match('/\.(png|jpe?g)$/i', $full)) {
$pdf->AddPage($ori, $fmt);
list($iw, $ih) = @getimagesize($full);
if (!$iw || !$ih) return;
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$maxW = ($pageW - 2 * $margin);
$maxH = ($pageH - 2 * $margin - 10); // Notiz unten
// image_scale: Anteil der maximalen Größe
$scale = max(0.2, min(1.0, (float) ($page->image_scale ?: 1.0)));
$availW = $maxW * $scale;
$availH = $maxH * $scale;
$ratio = min($availW / $iw, $availH / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
// image_align bestimmt die Position
list($x, $y) = bericht_align_position($page->image_align ?: 'fit', $w, $h, $pageW, $pageH, $margin);
$pdf->Image($full, $x, $y, $w, $h);
bericht_burn_annotations($pdf, $page->fabric_json, $x, $y, $w, $h);
} elseif ($fpdi_loaded && preg_match('/\.pdf$/i', $full)) {
try {
$pdf->setSourceFile($full);
$tpl = $pdf->importPage($page->source_page ?: 1);
$size = $pdf->getTemplateSize($tpl);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $size['width'], $size['height']);
} catch (Throwable $e) { /* skip */ }
}
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
}
/**
* 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)
{
$usableW = $pageW - 2 * $margin;
$usableH = $pageH - 2 * $margin - 10; // Notiz unten
switch ($align) {
case 'topleft':
return array($margin, $margin);
case 'topright':
return array($pageW - $margin - $w, $margin);
case 'bottomleft':
return array($margin, $margin + $usableH - $h);
case 'bottomright':
return array($pageW - $margin - $w, $margin + $usableH - $h);
case 'center':
return array(($pageW - $w) / 2, $margin + ($usableH - $h) / 2);
case 'fit':
default:
return array(($pageW - $w) / 2, $margin);
}
}
function bericht_render_cover_internal($template_path, $bericht, $parent, $tempdir)
{
global $user, $langs;