diff --git a/ajax/create_grid_page.php b/ajax/create_grid_page.php
new file mode 100644
index 0000000..5a8cf1c
--- /dev/null
+++ b/ajax/create_grid_page.php
@@ -0,0 +1,49 @@
+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));
diff --git a/ajax/generate_pdf.php b/ajax/generate_pdf.php
index 0ce166c..5d5829b 100644
--- a/ajax/generate_pdf.php
+++ b/ajax/generate_pdf.php
@@ -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 ---
diff --git a/ajax/page_image.php b/ajax/page_image.php
index b9ba5df..7532619 100644
--- a/ajax/page_image.php
+++ b/ajax/page_image.php
@@ -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();
+}
diff --git a/ajax/page_meta.php b/ajax/page_meta.php
index 17d0614..c0cb025 100644
--- a/ajax/page_meta.php
+++ b/ajax/page_meta.php
@@ -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,
));
diff --git a/ajax/preview_pdf.php b/ajax/preview_pdf.php
index 7eb9cc6..0a2ff76 100644
--- a/ajax/preview_pdf.php
+++ b/ajax/preview_pdf.php
@@ -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
diff --git a/ajax/save_page_options.php b/ajax/save_page_options.php
new file mode 100644
index 0000000..55989bd
--- /dev/null
+++ b/ajax/save_page_options.php
@@ -0,0 +1,32 @@
+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));
diff --git a/ajax/set_slot_image.php b/ajax/set_slot_image.php
new file mode 100644
index 0000000..24f36b3
--- /dev/null
+++ b/ajax/set_slot_image.php
@@ -0,0 +1,32 @@
+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');
diff --git a/bericht_card.php b/bericht_card.php
index eb7eeeb..a09edb6 100644
--- a/bericht_card.php
+++ b/bericht_card.php
@@ -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 '';
}
- print '';
+ print '';
+ print '
';
+ print 'Mehrere Bilder als Grid:
';
+ print ' ';
+ print ' ';
+ print ' ';
+ print '';
+ print '
';
}
print '
';
print '';
@@ -400,6 +410,30 @@ if (!$bericht) {
print '
';
print '
';
print '
';
+
+ // ====== Dritte Zeile: Layout & Bildgröße der aktuellen Seite ======
+ print '
';
+ print '
';
+ print '
';
+ print '
';
print '
';
print '';
print '';
diff --git a/class/bericht.class.php b/class/bericht.class.php
index e6aac2f..1f404e6 100644
--- a/class/bericht.class.php
+++ b/class/bericht.class.php
@@ -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;
diff --git a/css/bericht.css b/css/bericht.css
index 96b6072..c774549 100644
--- a/css/bericht.css
+++ b/css/bericht.css
@@ -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.
*/
diff --git a/js/editor.js b/js/editor.js
index 4b4e61e..648a50a 100644
--- a/js/editor.js
+++ b/js/editor.js
@@ -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) => {
diff --git a/lib/bericht.lib.php b/lib/bericht.lib.php
index d1c97a3..ade7342 100644
--- a/lib/bericht.lib.php
+++ b/lib/bericht.lib.php
@@ -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;