* GPL v3+ */ /** * Hilfs-Funktionen für das Bericht-Modul. */ require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; /** * Erzeugt die Tab-Header-Leiste für die Editor-Seite (Übersicht / Editor). * * @param Bericht $object * @return array */ function bericht_prepare_head($object) { global $langs, $conf; $langs->load("bericht@bericht"); $h = 0; $head = array(); $head[$h][0] = dol_buildpath('/bericht/bericht_card.php', 1).'?id='.$object->id; $head[$h][1] = $langs->trans("BerichtEditor"); $head[$h][2] = 'editor'; $h++; return $head; } /** * Lädt das Parent-Objekt anhand des element_type. * * @return CommonObject|null */ function bericht_fetch_parent(DoliDB $db, $element_type, $id) { if ($element_type === 'invoice') { require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php'; $o = new Facture($db); } elseif ($element_type === 'order') { require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; $o = new Commande($db); } elseif ($element_type === 'propal') { require_once DOL_DOCUMENT_ROOT.'/comm/propal/class/propal.class.php'; $o = new Propal($db); } else { return null; } if ($o->fetch($id) <= 0) return null; $o->fetch_thirdparty(); if (method_exists($o, 'fetch_optionals')) { $o->fetch_optionals(); } return $o; } /** * Mappt einen element_type-Code auf den Dolibarr-internen Element-Namen * für das Verzeichnis der Anhänge (multidir_output). */ function bericht_element_to_dir_key($element_type) { return array( 'invoice' => 'facture', 'order' => 'commande', 'propal' => 'propal', )[$element_type] ?? null; } /** * Sammelt alle Anhänge eines Parent-Objekts UND der direkt verknüpften Objekte * (z. B. der Auftrag/Angebot, die mit dieser Rechnung verknüpft sind). * * @return array Liste mit ['source','source_id','source_ref','filename','fullpath','relpath','size','mime','date'] */ function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_type) { global $conf; $result = array(); // 1) Eigene Anhänge $dir_key = bericht_element_to_dir_key($element_type); if ($dir_key && !empty($conf->{$dir_key}->multidir_output[$parent->entity])) { $upload_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref); if (is_dir($upload_dir)) { $files = dol_dir_list($upload_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$'); foreach ($files as $f) { $result[] = array( 'source' => $element_type, 'source_id' => $parent->id, 'source_ref' => $parent->ref, 'filename' => $f['name'], 'fullpath' => $f['fullname'], 'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']), 'size' => $f['size'], 'mime' => dol_mimetype($f['name']), 'date' => $f['date'], ); } } } // 2) Verknüpfte Objekte: order, propal — auch deren Anhänge anbieten $parent->fetchObjectLinked(); $linked_types = array('commande' => 'order', 'propal' => 'propal', 'facture' => 'invoice'); foreach ($linked_types as $linked_dolibarr_type => $linked_module_type) { if (empty($parent->linkedObjects[$linked_dolibarr_type])) continue; foreach ($parent->linkedObjects[$linked_dolibarr_type] as $lobj) { if (empty($conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity])) continue; $linked_dir = $conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity].'/'.dol_sanitizeFileName($lobj->ref); if (!is_dir($linked_dir)) continue; $files = dol_dir_list($linked_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$'); foreach ($files as $f) { $result[] = array( 'source' => $linked_module_type, 'source_id' => $lobj->id, 'source_ref' => $lobj->ref, 'filename' => $f['name'], 'fullpath' => $f['fullname'], 'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']), 'size' => $f['size'], 'mime' => dol_mimetype($f['name']), 'date' => $f['date'], ); } } } return $result; } /** * Liest die Auftragsnummer aus dem Parent-Objekt. * Reihenfolge: extrafield 'auftragsnummer' → ref_client → ref */ function bericht_get_auftragsnummer(CommonObject $parent) { if (!empty($parent->array_options['options_auftragsnummer'])) { return $parent->array_options['options_auftragsnummer']; } if (!empty($parent->ref_client)) { return $parent->ref_client; } return $parent->ref; } /** * Listet alle ODT-Templates im Templates-Verzeichnis auf. * * @return string[] Dateinamen */ function bericht_list_templates() { $dir = DOL_DATA_ROOT.'/bericht/templates'; if (!is_dir($dir)) return array(); $files = scandir($dir); return array_values(array_filter($files, function ($f) { return preg_match('/\.odt$/i', $f); })); } /** * Sicherer absolute-Pfad-Resolver für Dateien unterhalb DOL_DATA_ROOT. * Verhindert Path-Traversal. */ function bericht_resolve_data_path($relpath) { $base = realpath(DOL_DATA_ROOT); $full = realpath($base.'/'.$relpath); if ($full === false) return null; if (strpos($full, $base) !== 0) return null; return $full; } function bericht_hex_to_rgb($hex) { $hex = ltrim($hex, '#'); if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; if (strlen($hex) !== 6) return array(255, 0, 0); return array(hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))); } /** * Rendert Fabric.js-Annotationen in ein TCPDF-Objekt. * Nutzt die wichtigsten Shape-Typen: rect, circle/ellipse, line, path, text, group (Pfeil). */ function bericht_burn_annotations($pdf, $fabric_json, $x, $y, $w, $h) { if (empty($fabric_json)) return; $data = json_decode($fabric_json, true); if (!is_array($data) || empty($data['objects'])) return; $cw = $data['width'] ?? null; $ch = $data['height'] ?? null; if (!$cw || !$ch) return; $sx = $w / $cw; $sy = $h / $ch; foreach ($data['objects'] as $obj) { $stroke = isset($obj['stroke']) ? bericht_hex_to_rgb($obj['stroke']) : array(255, 0, 0); $sw = ($obj['strokeWidth'] ?? 2) * $sx; $pdf->SetDrawColor($stroke[0], $stroke[1], $stroke[2]); $pdf->SetLineWidth(max(0.2, $sw)); $type = $obj['type'] ?? ''; $ox = ($obj['left'] ?? 0) * $sx + $x; $oy = ($obj['top'] ?? 0) * $sy + $y; $ow = ($obj['width'] ?? 0) * ($obj['scaleX'] ?? 1) * $sx; $oh = ($obj['height'] ?? 0) * ($obj['scaleY'] ?? 1) * $sy; switch ($type) { case 'rect': $pdf->Rect($ox, $oy, $ow, $oh, 'D'); break; case 'circle': case 'ellipse': $rx = ($obj['rx'] ?? ($ow / 2)) * $sx; $ry = ($obj['ry'] ?? ($oh / 2)) * $sy; $pdf->Ellipse($ox + $rx, $oy + $ry, $rx, $ry, 0, 0, 360, 'D'); break; case 'line': $x1 = ($obj['x1'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx; $y1 = ($obj['y1'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy; $x2 = ($obj['x2'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx; $y2 = ($obj['y2'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy; $pdf->Line($x1, $y1, $x2, $y2); break; case 'path': if (!empty($obj['path']) && is_array($obj['path'])) { $prev = null; foreach ($obj['path'] as $seg) { if (!is_array($seg) || count($seg) < 3) continue; $cmd = $seg[0]; if ($cmd === 'M') { $prev = array($seg[1] * $sx + $x, $seg[2] * $sy + $y); } elseif (($cmd === 'L' || $cmd === 'Q') && $prev) { $px = $seg[1] * $sx + $x; $py = $seg[2] * $sy + $y; $pdf->Line($prev[0], $prev[1], $px, $py); $prev = array($px, $py); } } } break; case 'group': // Pfeil = Group(Line + Triangle) — als Pfeillinie rendern if (!empty($obj['objects']) && is_array($obj['objects'])) { $angle = ($obj['angle'] ?? 0) * M_PI / 180; $startX = $ox; $startY = $oy; // Länge aus den Children abschätzen $len = 0; foreach ($obj['objects'] as $child) { if (($child['type'] ?? '') === 'line') { $lx2 = ($child['x2'] ?? 0) - ($child['x1'] ?? 0); $len = max($len, abs($lx2)); } } $len *= $sx * ($obj['scaleX'] ?? 1); $endX = $startX + cos($angle) * $len; $endY = $startY + sin($angle) * $len; $pdf->Line($startX, $startY, $endX, $endY); // Pfeilspitze $headLen = max(2, $sw * 4); $a1 = $angle + M_PI - 0.4; $a2 = $angle + M_PI + 0.4; $pdf->Line($endX, $endY, $endX + cos($a1) * $headLen, $endY + sin($a1) * $headLen); $pdf->Line($endX, $endY, $endX + cos($a2) * $headLen, $endY + sin($a2) * $headLen); } break; case 'i-text': case 'text': case 'textbox': $fontsize = max(6, ($obj['fontSize'] ?? 16) * $sx * 2.83); $pdf->SetFont(bericht_ensure_hack_font(), '', $fontsize); $pdf->SetTextColor($stroke[0], $stroke[1], $stroke[2]); $pdf->Text($ox, $oy + $fontsize * 0.35, $obj['text'] ?? ''); break; } } } /** * Rendert ein ODT-Template als PDF (Deckblatt). * Nutzt Dolibarrs odtphp + LibreOffice headless für die Konvertierung. * * @return string|null Pfad zum erzeugten PDF oder null */ function bericht_render_cover_for_preview($template_path, $bericht, $parent, $tempdir) { 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 */ /** * Registriert den Hack-Font (Eddys Corporate-Font) in TCPDF und gibt den Font-Key zurück. * Konvertiert die TTFs beim ersten Aufruf via TCPDF_FONTS::addTTFfont ins TCPDF-Font-Verzeichnis. * Fallback auf 'helvetica' wenn etwas schiefgeht. * * @return string Font-Key für $pdf->SetFont() */ function bericht_ensure_hack_font() { static $cached = null; if ($cached !== null) return $cached; if (!class_exists('TCPDF_FONTS')) { $cached = 'helvetica'; return $cached; } $dir = dirname(__DIR__).'/fonts'; $files = array( '' => $dir.'/Hack-Regular.ttf', 'B' => $dir.'/Hack-Bold.ttf', 'I' => $dir.'/Hack-Italic.ttf', 'BI' => $dir.'/Hack-BoldItalic.ttf', ); try { $key = null; foreach ($files as $style => $path) { if (!file_exists($path)) continue; $k = TCPDF_FONTS::addTTFfont($path, 'TrueTypeUnicode', '', 32); if ($style === '' && $k) $key = $k; } $cached = $key ?: 'helvetica'; } catch (Throwable $e) { $cached = 'helvetica'; } return $cached; } /** * Rendert eine HTML-Notiz (CKEditor-Output) mit TCPDF::writeHTMLCell * in einen reservierten Bereich unterhalb des Bildes. */ function bericht_write_note_html($pdf, $html, $x, $y, $w, $h) { if (empty($html)) return; // CKEditor liefert

— TCPDF kann das direkt $pdf->SetFont(bericht_ensure_hack_font(), '', 9); $pdf->SetTextColor(40, 40, 40); // Border 0, kein Auto-Pagebreak damit die Notiz im reservierten Bereich bleibt $autoPB = $pdf->getAutoPageBreak(); $bMargin = $pdf->getBreakMargin(); $pdf->SetAutoPageBreak(false, 0); $pdf->writeHTMLCell($w, $h, $x, $y, $html, 0, 1, false, true, 'L', true); $pdf->SetAutoPageBreak($autoPB, $bMargin); } 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 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; // Notiz-Bereich reservieren wenn eine Notiz existiert $note_h = 0; if (!empty($page->note)) { // ca. 1/4 Seite für HTML-Notizen, aber mindestens 20mm, max 80mm $note_h = max(20, min(80, $pageH * 0.25)); } $maxW = $pageW - 2 * $margin; $maxH = $pageH - 2 * $margin - $note_h - ($note_h ? 4 : 0); 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)) { // Notiz direkt unter dem Bild rendern — kein SetY(-20), keine auto-Page-Break $note_y = $margin + ($ih && $iw ? ($ih * min($maxW / $iw, $maxH / $ih)) : $maxH) + 4; bericht_write_note_html($pdf, $page->note, $margin, $note_y, $maxW, $note_h); } return; } } $layout = $page->layout ?: 'single'; // Spezial-Fall: reine Titel-Seite (kein Bild, nur großer Titel zentriert) if ($layout === 'title_only' || ($layout === 'single' && empty($page->source_path) && !empty($page->title))) { $pdf->AddPage($ori, $fmt); $pageW = $pdf->getPageWidth(); $pageH = $pdf->getPageHeight(); $pdf->SetFont(bericht_ensure_hack_font(), 'B', 32); $pdf->SetTextColor(40, 40, 40); $pdf->SetY($pageH / 2 - 20); $pdf->MultiCell(0, 20, $page->title, 0, 'C'); if (!empty($page->note)) { $pdf->Ln(12); $pdf->SetFont(bericht_ensure_hack_font(), '', 12); $pdf->MultiCell(0, 8, $page->note, 0, 'C'); } return; } /* ----- Vorher/Nachher-Layout ----- */ if ($layout === 'before_after') { $imgs = $page->getImages(); if (empty($imgs)) return; $pdf->AddPage($ori, $fmt); $pageW = $pdf->getPageWidth(); $pageH = $pdf->getPageHeight(); $margin = 10; $title_h = !empty($page->title) ? 12 : 0; if ($title_h) { $pdf->SetFont(bericht_ensure_hack_font(), 'B', 16); $pdf->SetY($margin); $pdf->MultiCell(0, $title_h, $page->title, 0, 'C'); } // Zwei Bilder nebeneinander mit VORHER/NACHHER Labels $label_h = 8; $gap = 6; $top = $margin + $title_h + 4; $usable_h = $pageH - $top - 10 - $label_h; // -10 für Notiz, -label_h für Beschriftung $w_each = ($pageW - 2 * $margin - $gap) / 2; foreach (array(array('Vorher', 0), array('Nachher', 1)) as $slot_info) { list($label, $slot) = $slot_info; if (!isset($imgs[$slot])) continue; $full = bericht_resolve_data_path($imgs[$slot]['source_path']); if (!$full || !file_exists($full)) continue; list($iw, $ih) = @getimagesize($full); if (!$iw || !$ih) continue; $x0 = $margin + $slot * ($w_each + $gap); // Label $pdf->SetFont(bericht_ensure_hack_font(), 'B', 11); $pdf->SetXY($x0, $top); $pdf->Cell($w_each, $label_h, $label, 0, 0, 'C'); // Bild darunter $ratio = min($w_each / $iw, $usable_h / $ih); $w = $iw * $ratio; $h = $ih * $ratio; $x = $x0 + ($w_each - $w) / 2; $y = $top + $label_h + ($usable_h - $h) / 2; $pdf->Image($full, $x, $y, $w, $h); } bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $pageW, $pageH); if (!empty($page->note)) { bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18); } return; } /* ----- Multi-Image Grid ----- */ if ($layout !== 'single') { $imgs = $page->getImages(); if (empty($imgs)) return; $pdf->AddPage($ori, $fmt); $pageW = $pdf->getPageWidth(); $pageH = $pdf->getPageHeight(); // Titel oben $title_h = 0; if (!empty($page->title)) { $pdf->SetFont(bericht_ensure_hack_font(), 'B', 16); $pdf->SetY(10); $pdf->MultiCell(0, 10, $page->title, 0, 'C'); $title_h = 14; } $rects = BerichtPage::slotRects($layout, $pageW, $pageH - $title_h, 10 + $title_h); 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)) { bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18); } 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)) { bericht_write_note_html($pdf, $page->note, 10, $pdf->getPageHeight() - 22, $pdf->getPageWidth() - 20, 18); } } /** * 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; $odt_loader = DOL_DOCUMENT_ROOT.'/includes/odtphp/odf.php'; if (!file_exists($odt_loader)) return null; require_once $odt_loader; try { $odf = new Odf($template_path, array('PATH_TO_TMP' => $tempdir)); $vars = array( 'auftragsnummer' => $bericht->auftragsnummer ?: '', 'angebotsnummer' => $parent->array_options['options_angebotsnummer'] ?? '', 'rechnungsnummer' => $parent->ref ?? '', 'kunde_name' => $parent->thirdparty->name ?? '', 'kunde_adresse' => trim(($parent->thirdparty->address ?? '')."\n".($parent->thirdparty->zip ?? '').' '.($parent->thirdparty->town ?? '')), 'datum' => dol_print_date(dol_now(), 'day'), 'beschreibung' => $parent->array_options['options_beschreibung'] ?? '', 'hinweis' => $parent->array_options['options_hinweis'] ?? '', 'bericht_titel' => $bericht->titel ?? '', 'ersteller' => $user->getFullName($langs ?? null) ?: $user->login, ); foreach ($vars as $k => $v) { try { $odf->setVars($k, $v, true, 'UTF-8'); } catch (Throwable $e) {} } $odt_out = $tempdir.'/cover_'.$bericht->id.'_'.uniqid().'.odt'; $odf->saveToDisk($odt_out); $lobin = getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '/usr/bin/libreoffice'); $cmd = escapeshellcmd($lobin) .' --headless --convert-to pdf --outdir '.escapeshellarg($tempdir).' '.escapeshellarg($odt_out).' 2>&1'; @shell_exec($cmd); $pdf_out = preg_replace('/\.odt$/i', '.pdf', $odt_out); return file_exists($pdf_out) ? $pdf_out : null; } catch (Throwable $e) { return null; } }