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;