* 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); } elseif ($element_type === 'shipment') { require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php'; $o = new Expedition($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; } /** * Liefert die Expedition zu einer Lieferschein-ID und holt zusaetzlich den verknuepften Auftrag (commande) * ueber llx_element_element. Resultat enthaelt: expedition, commande (optional), thirdparty. * * @return array|null ['expedition'=>Expedition,'commande'=>Commande|null,'thirdparty'=>Societe|null] */ function bericht_fetch_shipment_with_order(DoliDB $db, $expedition_id) { require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php'; require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; $shipment = new Expedition($db); if ($shipment->fetch((int) $expedition_id) <= 0) return null; $shipment->fetch_thirdparty(); if (method_exists($shipment, 'fetch_optionals')) $shipment->fetch_optionals(); // Verknuepfung commande → shipping (commande ist source, shipping ist target) $commande = null; $sql = "SELECT fk_source FROM ".$db->prefix()."element_element" ." WHERE sourcetype = 'commande' AND targettype = 'shipping'" ." AND fk_target = ".((int) $expedition_id) ." LIMIT 1"; $r = $db->query($sql); if ($r && ($row = $db->fetch_object($r))) { $cmd = new Commande($db); if ($cmd->fetch((int) $row->fk_source) > 0) { $cmd->fetch_thirdparty(); if (method_exists($cmd, 'fetch_optionals')) $cmd->fetch_optionals(); $commande = $cmd; } } return array( 'expedition' => $shipment, 'commande' => $commande, 'thirdparty' => $shipment->thirdparty ?: ($commande ? $commande->thirdparty : null), ); } /** * Sucht das aktuell auf einer Expedition abgelegte Lieferschein-PDF. * Liefert den absoluten Pfad oder null wenn nicht vorhanden. * * Mit $include_signed=false (Default) wird das "-signed.pdf" (das von uns gestempelte * Resultat) ausgeschlossen — fuer die PWA-Vorschau wollen wir das Original, nicht den * bereits signierten Lieferschein. * * @param bool $include_signed Wenn true, koennen -signed.pdf-Dateien als Treffer dienen */ function bericht_get_shipment_pdf(DoliDB $db, $shipment, $include_signed = false) { global $conf, $user, $langs; require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; if (empty($shipment) || empty($shipment->ref)) return null; $ref_sane = dol_sanitizeFileName($shipment->ref); $shipment_dir = $conf->expedition->multidir_output[$shipment->entity ?: $conf->entity].'/sending/'.$ref_sane; if (!is_dir($shipment_dir)) { $shipment_dir = $conf->expedition->multidir_output[$shipment->entity ?: $conf->entity].'/'.$ref_sane; } $is_signed = function ($path) { return (bool) preg_match('/-signed\.pdf$/i', $path); }; // 1) Bevorzugt .pdf direkt $candidate = $shipment_dir.'/'.$ref_sane.'.pdf'; if (file_exists($candidate) && ($include_signed || !$is_signed($candidate))) return $candidate; // 2) Wenn nichts da: generieren lassen if (!is_dir($shipment_dir) || empty(glob($shipment_dir.'/*.pdf'))) { $template = getDolGlobalString('EXPEDITION_ADDON_PDF', 'merou'); try { $shipment->generateDocument($template, $langs); } catch (Throwable $e) { return null; } } // 3) PDFs im Verzeichnis filtern — signed ausschließen falls nicht erwünscht if (is_dir($shipment_dir)) { $pdfs = glob($shipment_dir.'/*.pdf') ?: array(); if (!$include_signed) { $pdfs = array_values(array_filter($pdfs, function ($f) use ($is_signed) { return !$is_signed($f); })); } if (!empty($pdfs)) { // Neueste zuerst (frisch generiert > alt) usort($pdfs, function ($a, $b) { return filemtime($b) <=> filemtime($a); }); return $pdfs[0]; } } return null; } /** * Liefert die Box-Geometrie fuer ein Lieferschein-Template. * Nimmt zuerst llx_bericht_signature_box (template_name match), sonst Default-JSON-Konstante. * * @return array ['page'=>'first'|'last'|int, 'x_mm'=>float, 'y_mm'=>float, 'w_mm'=>float, 'h_mm'=>float, 'label'=>string] */ /** * Rendert ein ODT-Lieferschein-Template mit Substitution: * - Bild-Platzhalter "signature" (im ODT als Bild-Name gesetzt) → Kunden-Unterschrifts-PNG * - Textvariablen werden via Odf::setVars befuellt: * {signer_name}, {signed_at}, {gps}, {kunde_name}, {kunde_adresse}, {shipment_ref}, {order_ref} * - LibreOffice headless konvertiert ODT → PDF * * @param string $odt_template_path absoluter Pfad zum Quell-ODT (doctemplates/shipments/*.odt) * @param string $signature_png absoluter Pfad zum Unterschrifts-PNG * @param array $meta Substitutions-Daten: signer_name, signed_at, gps_lat, gps_lon, shipment, order, kunde * @param string $out_pdf absoluter Zielpfad fuer das fertige PDF * * @return bool true bei Erfolg */ function bericht_render_signed_shipment_odt($odt_template_path, $signature_png, array $meta, $out_pdf, &$debug = '') { if (!file_exists($odt_template_path)) { $debug = 'ODT-Template fehlt: '.$odt_template_path; return false; } if (!file_exists($signature_png)) { $debug = 'Signatur-PNG fehlt: '.$signature_png; return false; } $odt_loader = DOL_DOCUMENT_ROOT.'/includes/odtphp/odf.php'; if (!file_exists($odt_loader)) { $debug = 'odf.php-Loader fehlt: '.$odt_loader; return false; } require_once $odt_loader; $tempdir = DOL_DATA_ROOT.'/bericht/temp'; if (!is_dir($tempdir)) dol_mkdir($tempdir); try { $odf = new Odf($odt_template_path, array('PATH_TO_TMP' => $tempdir)); // Text-Platzhalter {signature} im ODT durch ein draw:frame mit der Kunden-Unterschrift ersetzen. // setImage() rechnet Pixel × PIXEL_TO_CM × ratio — ratio steuert die Endgroesse im PDF. // 1.0 = 1:1 bei 96dpi (zu gross fuer Signaturen). 0.3 ergibt ca. 6×3 cm bei 800×400 Canvas. $ratio = (float) getDolGlobalString('BERICHT_SIGNATURE_IMAGE_RATIO', '0.35'); try { $odf->setImage('signature', $signature_png, $ratio); } catch (Throwable $e) { // Wenn {signature}-Platzhalter im ODT fehlt, ist das ein Konfigurationsproblem — // wir loggen es aber rendern trotzdem (die Textvars werden weiter ersetzt). dol_syslog('bericht_render_signed_shipment_odt: setImage(signature) failed: '.$e->getMessage(), LOG_WARNING); } // Textvariablen — werden nur ersetzt wenn sie im ODT als {key} stehen, sonst ignoriert. $shipment = $meta['shipment'] ?? null; $order = $meta['order'] ?? null; $kunde = $meta['kunde'] ?? null; $gps_str = (isset($meta['gps_lat'], $meta['gps_lon']) && $meta['gps_lat'] !== null && $meta['gps_lon'] !== null) ? sprintf('%.6f, %.6f', $meta['gps_lat'], $meta['gps_lon']) : ''; // Auftragsnummer: zuerst Extrafeld der Expedition (Eddys Computed-Field), // dann Fallback Extrafeld der Commande, dann ref_client, dann commande->ref. $auftragsnummer = ''; if ($shipment && !empty($shipment->array_options['options_auftragsnummer'])) { $auftragsnummer = $shipment->array_options['options_auftragsnummer']; } elseif ($order && !empty($order->array_options['options_auftragsnummer'])) { $auftragsnummer = $order->array_options['options_auftragsnummer']; } elseif ($order && !empty($order->ref_client)) { $auftragsnummer = $order->ref_client; } elseif ($order) { $auftragsnummer = $order->ref; } $vars = array( 'signer_name' => (string) ($meta['signer_name'] ?? ''), 'signed_at' => (string) ($meta['signed_at'] ?? ''), 'gps' => $gps_str, 'shipment_ref' => $shipment ? $shipment->ref : '', 'order_ref' => $order ? $order->ref : '', 'auftragsnummer' => $auftragsnummer, 'kunde_name' => $kunde->name ?? '', 'kunde_adresse' => $kunde ? trim(($kunde->address ?? '')."\n".(($kunde->zip ?? '').' '.($kunde->town ?? ''))) : '', 'datum' => dol_print_date(dol_now(), 'day'), ); // Alle Expedition-Extrafelder als {options_} zusaetzlich bereitstellen // (deckt Sonderfaelle ab wenn Eddy weitere Extrafelder im ODT nutzen will) if ($shipment && !empty($shipment->array_options) && is_array($shipment->array_options)) { foreach ($shipment->array_options as $k => $v) { if (is_scalar($v) || $v === null) $vars[$k] = (string) ($v ?? ''); } } foreach ($vars as $k => $v) { try { $odf->setVars($k, $v, true, 'UTF-8'); } catch (Throwable $e) { /* nicht alle Keys muessen im ODT existieren */ } } $odt_out = $tempdir.'/signed_shipment_'.uniqid().'.odt'; $odf->saveToDisk($odt_out); if (!file_exists($odt_out)) { $debug = 'saveToDisk hat kein ODT erzeugt: '.$odt_out; return false; } // LibreOffice headless ODT → PDF // Pfad ermitteln: BERICHT_LIBREOFFICE_BIN oder via 'which' $lobin = getDolGlobalString('BERICHT_LIBREOFFICE_BIN', ''); if (!$lobin || !is_executable($lobin)) { // Try 'soffice' (Dolibarr-Standard) und 'libreoffice' foreach (array('soffice', 'libreoffice') as $candidate) { $found = @trim(@shell_exec('command -v '.escapeshellarg($candidate).' 2>/dev/null')); if ($found && is_executable($found)) { $lobin = $found; break; } } } if (!$lobin) { $debug = 'LibreOffice-Binary nicht gefunden. Setze BERICHT_LIBREOFFICE_BIN auf vollen Pfad zu soffice.'; return false; } $tmp_outdir = $tempdir.'/lo_'.uniqid(); dol_mkdir($tmp_outdir); // -env:UserInstallation noetig damit LO unter Apache-User starten kann (sonst /root/.config-Konflikte) $user_profile = $tempdir.'/lo_profile_'.uniqid(); dol_mkdir($user_profile); $cmd = escapeshellcmd($lobin) .' --headless' .' -env:UserInstallation=file://'.escapeshellarg($user_profile) .' --convert-to pdf --outdir '.escapeshellarg($tmp_outdir) .' '.escapeshellarg($odt_out).' 2>&1'; $lo_out = @shell_exec($cmd); $pdf_generated = preg_replace('/\.odt$/i', '.pdf', basename($odt_out)); $pdf_full = $tmp_outdir.'/'.$pdf_generated; $ok = false; if (file_exists($pdf_full)) { $out_dir = dirname($out_pdf); if (!is_dir($out_dir)) dol_mkdir($out_dir); $ok = @rename($pdf_full, $out_pdf); if (!$ok) $ok = @copy($pdf_full, $out_pdf); if (!$ok) $debug = 'rename/copy PDF nach Zielpfad fehlgeschlagen: '.$out_pdf; } else { $debug = 'LibreOffice hat kein PDF erzeugt. Befehl: '.$cmd.' | Output: '.substr((string) $lo_out, 0, 500); } // Aufraeumen @unlink($odt_out); if (is_dir($tmp_outdir)) { foreach (glob($tmp_outdir.'/*') as $f) @unlink($f); @rmdir($tmp_outdir); } if (is_dir($user_profile)) { // rekursiv loeschen, LO erzeugt Subfolder @exec('rm -rf '.escapeshellarg($user_profile)); } return $ok && file_exists($out_pdf); } catch (Throwable $e) { $debug = 'Exception: '.$e->getMessage().' @ '.basename($e->getFile()).':'.$e->getLine(); dol_syslog('bericht_render_signed_shipment_odt: '.$debug, LOG_ERR); return false; } } /** * Stempelt eine PNG-Signatur (plus Metadaten-Label) in ein vorhandenes Lieferschein-PDF. * Nutzt FPDI zum Importieren der Originalseiten und TCPDF zum Daraufschreiben. * Die Box-Geometrie kommt aus llx_bericht_signature_box (template_name match) oder Default. * * @param string $src_pdf absoluter Pfad zum Original-Lieferschein-PDF * @param string $signature_png absoluter Pfad zum PNG der Unterschrift * @param array $box ['page','x_mm','y_mm','w_mm','h_mm','label'] * @param array $meta ['signer_name','signed_at','gps_lat','gps_lon','shipment_ref'] * @param string $out_pdf absoluter Pfad fuer die Ausgabe * * @return bool true bei Erfolg */ function bericht_stamp_signature_on_pdf($src_pdf, $signature_png, array $box, array $meta, $out_pdf) { if (!file_exists($src_pdf) || !file_exists($signature_png)) return false; // FPDI bei Bedarf laden if (!class_exists('\\setasign\\Fpdi\\Tcpdf\\Fpdi')) { $candidates = array( DOL_DOCUMENT_ROOT.'/includes/setasign/vendor/autoload.php', DOL_DOCUMENT_ROOT.'/includes/fpdi/autoload.php', DOL_DOCUMENT_ROOT.'/includes/setasign/vendor/setasign/fpdi/src/Tcpdf/Fpdi.php', ); foreach ($candidates as $c) { if (file_exists($c)) { require_once $c; break; } } } if (!class_exists('\\setasign\\Fpdi\\Tcpdf\\Fpdi')) return false; try { $pdf = new \setasign\Fpdi\Tcpdf\Fpdi(); $pdf->setPrintHeader(false); $pdf->setPrintFooter(false); $pdf->setAutoPageBreak(false, 0); $pageCount = $pdf->setSourceFile($src_pdf); // Ziel-Seite auswaehlen $target_page = 1; if (is_numeric($box['page'])) { $target_page = max(1, min($pageCount, (int) $box['page'])); } elseif ($box['page'] === 'first') { $target_page = 1; } else { // 'last' oder beliebiger nicht-numerischer Wert $target_page = $pageCount; } for ($p = 1; $p <= $pageCount; $p++) { $tpl = $pdf->importPage($p); $size = $pdf->getTemplateSize($tpl); $orient = ($size['width'] > $size['height']) ? 'L' : 'P'; $pdf->AddPage($orient, array($size['width'], $size['height'])); $pdf->useTemplate($tpl); if ($p === $target_page) { $x = (float) $box['x_mm']; $y = (float) $box['y_mm']; $w = (float) $box['w_mm']; $h = (float) $box['h_mm']; $label = $box['label'] ?? 'Unterschrift Kunde'; // Rahmen der Box (zart, mehr als Hilfsmarkierung) $pdf->SetDrawColor(120, 120, 120); $pdf->SetLineWidth(0.15); $pdf->Rect($x, $y, $w, $h, 'D'); // Label oberhalb der Box $pdf->SetFont('helvetica', 'B', 8); $pdf->SetTextColor(80, 80, 80); $pdf->SetXY($x, max(0, $y - 4)); $pdf->Cell($w, 3.5, $label, 0, 0, 'L'); // PNG hinein, mit Padding und proportionaler Skalierung $pad = 2; $sigBoxW = max(1, $w - 2 * $pad); $sigBoxH = max(1, $h - 2 * $pad - 6); // 6 mm fuer Footer-Text list($iw, $ih) = @getimagesize($signature_png); if ($iw && $ih) { $ratio = min($sigBoxW / $iw, $sigBoxH / $ih); $dw = $iw * $ratio; $dh = $ih * $ratio; $dx = $x + ($w - $dw) / 2; $dy = $y + $pad; $pdf->Image($signature_png, $dx, $dy, $dw, $dh, 'PNG'); } // Trennlinie unter der Unterschrift $sepY = $y + $h - 6; $pdf->SetDrawColor(180, 180, 180); $pdf->Line($x + $pad, $sepY, $x + $w - $pad, $sepY); // Footer-Text: Name + Datum + GPS $pdf->SetFont('helvetica', '', 6.5); $pdf->SetTextColor(60, 60, 60); $pdf->SetXY($x + $pad, $sepY + 0.5); $line1 = trim(($meta['signer_name'] ?? '') . ($meta['signed_at'] ? ' · '.$meta['signed_at'] : '')); $pdf->Cell($w - 2 * $pad, 3, $line1, 0, 0, 'L'); if (!empty($meta['gps_lat']) && !empty($meta['gps_lon'])) { $pdf->SetXY($x + $pad, $sepY + 3); $pdf->Cell($w - 2 * $pad, 3, sprintf('GPS: %.6f, %.6f', $meta['gps_lat'], $meta['gps_lon']), 0, 0, 'L'); } } } $pdf->Output($out_pdf, 'F'); return file_exists($out_pdf); } catch (Throwable $e) { dol_syslog('bericht_stamp_signature_on_pdf: '.$e->getMessage(), LOG_ERR); return false; } } function bericht_get_signature_box(DoliDB $db, $template_name = '') { global $conf; if ($template_name !== '') { $sql = "SELECT page, x_mm, y_mm, w_mm, h_mm, label FROM ".$db->prefix()."bericht_signature_box" ." WHERE entity = ".((int) $conf->entity) ." AND template_name = '".$db->escape($template_name)."'" ." LIMIT 1"; $r = $db->query($sql); if ($r && ($row = $db->fetch_object($r))) { return array( 'page' => $row->page, 'x_mm' => (float) $row->x_mm, 'y_mm' => (float) $row->y_mm, 'w_mm' => (float) $row->w_mm, 'h_mm' => (float) $row->h_mm, 'label' => $row->label ?: 'Unterschrift Kunde', ); } } // Default aus Konstante $json = getDolGlobalString('BERICHT_SIGNATURE_BOX_DEFAULT', ''); $def = $json ? json_decode($json, true) : null; if (!is_array($def)) { $def = array('page' => 'last', 'x_mm' => 120, 'y_mm' => 230, 'w_mm' => 70, 'h_mm' => 35, 'label' => 'Unterschrift Kunde'); } return $def; } /** * 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)$', 'name', SORT_ASC, 1, 1); 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'], 'taken_at' => bericht_file_taken_at($f['fullname'], $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)$', 'name', SORT_ASC, 1, 1); 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'], 'taken_at' => bericht_file_taken_at($f['fullname'], $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; } /** * Nummeriert die Seiten eines Berichts lückenlos von 1 an durch. * Nach dem Löschen bleiben sonst Lücken in page_order stehen. */ function bericht_renumber_pages(DoliDB $db, $berichtid) { $res = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page" ." WHERE fk_bericht = ".((int) $berichtid)." ORDER BY page_order ASC, rowid ASC"); if (!$res) return -1; $order = 1; while ($r = $db->fetch_object($res)) { $db->query("UPDATE ".$db->prefix()."bericht_page SET page_order = ".((int) $order) ." WHERE rowid = ".((int) $r->rowid)); $order++; } return 1; } /** * Sortiert frisch angelegte Kopien direkt hinter ihr jeweiliges Original. * * Die Kopien wurden mit page_order 9000+n angelegt; hier bekommen sie ihren * endgültigen Platz. Erkannt werden sie über gleiche Quelle und Layout. * * @param int[] $originals IDs der duplizierten Seiten */ function bericht_reorder_with_copies(DoliDB $db, $berichtid, array $originals) { $res = $db->query("SELECT rowid, page_order, source_path, layout FROM ".$db->prefix()."bericht_page" ." WHERE fk_bericht = ".((int) $berichtid)." ORDER BY page_order ASC, rowid ASC"); if (!$res) return -1; $rows = array(); while ($r = $db->fetch_object($res)) $rows[] = $r; // Kopien (page_order >= 9000) heraustrennen und ihrem Original zuordnen $normal = array(); $copies = array(); foreach ($rows as $r) { if ((int) $r->page_order >= 9000) $copies[] = $r; else $normal[] = $r; } $sequence = array(); foreach ($normal as $r) { $sequence[] = (int) $r->rowid; if (!in_array((int) $r->rowid, $originals, true)) continue; // passende Kopie suchen (gleiche Quelle + Layout, noch nicht einsortiert) foreach ($copies as $k => $c) { if ($c->source_path === $r->source_path && $c->layout === $r->layout) { $sequence[] = (int) $c->rowid; unset($copies[$k]); break; } } } // Falls eine Kopie nicht zugeordnet werden konnte: ans Ende foreach ($copies as $c) $sequence[] = (int) $c->rowid; $order = 1; foreach ($sequence as $rowid) { $db->query("UPDATE ".$db->prefix()."bericht_page SET page_order = ".((int) $order) ." WHERE rowid = ".((int) $rowid)); $order++; } return 1; } /** * Rendert die Anhänge-Kacheln (Inhalt von #bericht-att-list). * * Steckt in einer Funktion, damit derselbe Code die Erstausgabe der Karte und * die AJAX-Aktualisierung bedient — sonst laufen beide Darstellungen auseinander. * * @param array $by_source Anhänge, gruppiert nach "source:ref" * @param array $used_paths Set relpath => true der bereits verwendeten Dateien */ function bericht_print_attachment_list(array $by_source, array $used_paths) { global $user; print '
'; $tile_no = 0; foreach ($by_source as $key => $files) { list($src, $ref) = explode(':', $key, 2); print '
'; print '
'; print ''.dol_escape_htmltag($ref).''; print ''; print '
'; print '
'; foreach ($files as $f) { $is_image = (strpos($f['mime'], 'image') === 0); $is_pdf = (strpos($f['mime'], 'pdf') !== false); $is_used = !empty($used_paths[$f['relpath']]); $urls = bericht_attachment_urls($f['relpath'], $is_image); $tile_no++; $cid = 'attc-'.$tile_no; $taken = !empty($f['taken_at']) ? (int) $f['taken_at'] : 0; print '
'; // Miniatur — Bild direkt vom Thumb-Endpoint, PDF wird per PDF.js gerendert print '
'; if ($is_image) { print ''; } elseif ($is_pdf) { print ''; print 'PDF'; } else { print '📎'; } if ($is_used) print ''; print '
'; // Auswahl — Checkbox liegt als grosses Ziel auf der Kachel print ''; print ''; if ($user->hasRight('bericht', 'write')) { print ''; } print '
'; print ''.dol_escape_htmltag($f['filename']).''; $meta_line = dol_print_size($f['size'], 1, 1); if ($taken) { $meta_line = dol_print_date($taken, '%d.%m. %H:%M').' · '.$meta_line; } print ''.$meta_line.''; print '
'; print '
'; // .bericht-att-item } print '
'; // .bericht-att-tiles print '
'; // .bericht-att-group } print '
'; // #bericht-att-list } /** * Rendert die Seiten-Miniaturen (Inhalt von #bericht-page-list). */ function bericht_print_page_list(array $pages) { global $user, $langs; print '
'; foreach ($pages as $idx => $p) { // Ist das eine gehärtete Unterschrift? (hat .meta.json neben der source_path) $is_signature = false; if ($p->source_path && strpos($p->note ?? '', 'Unterschrift') === 0) { $full_check = DOL_DATA_ROOT.'/'.$p->source_path; if (file_exists($full_check.'.meta.json')) { $is_signature = true; } } print '
'; print '
'; print ''; print '
'; print '
'.($idx + 1).'' .($is_signature ? ' 🔒' : '') .'
'; print '
'; if ($is_signature) { print ''; } elseif ($user->hasRight('bericht', 'write')) { print ''; } print ''; print '
'; print '
'; } } /** * Seitengeometrie in mm — die EINE Quelle für Editor und PDF. * * Der Editor stellt genau den Inhaltsbereich als Arbeitsfläche dar; die * PDF-Erzeugung setzt das dort entstandene Composite-PNG unveraendert in * dieselben Koordinaten. Dadurch entspricht die Vorschau dem Ergebnis * (vorher wurde das ganze Blatt nochmal in den Satzspiegel eingepasst und * der Inhalt damit ein zweites Mal verkleinert). * * @param string $format A4|A3|A5|Letter * @param string $orientation P|L * @param bool $has_note Seite hat eine Notiz -> unten Platz reservieren * @return array{pageW:float,pageH:float,mL:float,mR:float,mT:float,mB:float,noteH:float,contentX:float,contentY:float,contentW:float,contentH:float} */ function bericht_page_geometry($format = 'A4', $orientation = 'P', $has_note = false) { $sizes = array( 'A4' => array(210.0, 297.0), 'A3' => array(297.0, 420.0), 'A5' => array(148.0, 210.0), 'Letter' => array(215.9, 279.4), ); list($w, $h) = isset($sizes[$format]) ? $sizes[$format] : $sizes['A4']; if ($orientation === 'L') { $tmp = $w; $w = $h; $h = $tmp; } // mT = 30: darüber druckt TCPDF Logo, Titel und die Trennlinie (y=27mm). // mB = 16: Fusszeile mit Seitennummer. $mL = 10.0; $mR = 10.0; $mT = 30.0; $mB = 16.0; $noteH = 0.0; if ($has_note) { $noteH = max(20.0, min(80.0, $h * 0.25)); } $contentW = $w - $mL - $mR; $contentH = $h - $mT - $mB - $noteH - ($noteH ? 4.0 : 0.0); return array( 'pageW' => $w, 'pageH' => $h, 'mL' => $mL, 'mR' => $mR, 'mT' => $mT, 'mB' => $mB, 'noteH' => $noteH, 'contentX' => $mL, 'contentY' => $mT, 'contentW' => $contentW, 'contentH' => $contentH, ); } /** * Baut die Anzeige-URLs zu einem Anhang. * * Das erste Segment des relpath ist gleichzeitig der Dolibarr-modulepart * (commande, facture, propale, expedition, ...) — so bleibt die Rechtepruefung * beim Ausliefern in Dolibarrs eigenem document.php/viewimage.php. * * @param string $relpath Pfad relativ zu DOL_DATA_ROOT * @param bool $is_image * @return array{view:string,thumb:string} */ function bericht_attachment_urls($relpath, $is_image) { global $conf; $parts = explode('/', $relpath, 2); $modulepart = $parts[0]; $file_in_module = isset($parts[1]) ? $parts[1] : ''; $view = DOL_URL_ROOT.($is_image ? '/viewimage.php' : '/document.php') .'?modulepart='.urlencode($modulepart) .'&file='.urlencode($file_in_module) .'&entity='.$conf->entity // attachment=0 -> Content-Disposition: inline. Ohne das schickt document.php // ein "attachment" und der Browser laedt das PDF herunter statt es anzuzeigen. .($is_image ? '' : '&attachment=0'); $thumb = $is_image ? dol_buildpath('/bericht/ajax/attachment_thumb.php', 1).'?relpath='.rawurlencode($relpath).'&w=260' : ''; return array('view' => $view, 'thumb' => $thumb); } /** * Alle Quellpfade, die in diesem Bericht bereits als Seite (oder Grid-Slot) verwendet werden. * Damit lassen sich in der Anhaenge-Spalte schon benutzte Dateien markieren. * * @return array Set: relpath => true */ function bericht_used_source_paths(DoliDB $db, $berichtid) { $used = array(); $sql = "SELECT p.source_path FROM ".$db->prefix()."bericht_page p WHERE p.fk_bericht = ".((int) $berichtid); $res = $db->query($sql); if ($res) { while ($r = $db->fetch_object($res)) { if (!empty($r->source_path)) $used[$r->source_path] = true; } } $sql = "SELECT i.source_path FROM ".$db->prefix()."bericht_page_image i" ." INNER JOIN ".$db->prefix()."bericht_page p ON p.rowid = i.fk_page" ." WHERE p.fk_bericht = ".((int) $berichtid); $res = $db->query($sql); if ($res) { while ($r = $db->fetch_object($res)) { if (!empty($r->source_path)) $used[$r->source_path] = true; } } return $used; } /** * Erzeugt (und cached) ein JPEG-Thumbnail einer Bilddatei. * * @param string $full Absoluter Pfad zur Quelldatei (bereits geprueft) * @param int $maxside Maximale Kantenlaenge in Pixel * @return string|null JPEG-Binaerdaten oder null wenn kein unterstuetztes Bild */ function bericht_attachment_thumb($full, $maxside = 260) { $info = @getimagesize($full); if (!$info) return null; $supported = array(IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF); if (defined('IMAGETYPE_WEBP')) $supported[] = IMAGETYPE_WEBP; if (!in_array($info[2], $supported, true)) return null; // Cache: Schluessel enthaelt mtime, damit ein ersetztes Original neu gerendert wird $cache_dir = DOL_DATA_ROOT.'/bericht/thumbs'; $cache_file = $cache_dir.'/'.sha1($full.'|'.filemtime($full).'|'.$maxside).'.jpg'; if (is_file($cache_file)) { $cached = @file_get_contents($cache_file); if ($cached !== false && $cached !== '') return $cached; } if (!extension_loaded('gd')) return null; // Speicher-Notbremse: 4 Byte pro Pixel plus Zielbild $needed = $info[0] * $info[1] * 4 * 1.3; $limit = bericht_memory_limit_bytes(); if ($limit > 0 && $needed > $limit * 0.7) return null; $src = null; if ($info[2] === IMAGETYPE_JPEG) $src = @imagecreatefromjpeg($full); elseif ($info[2] === IMAGETYPE_PNG) $src = @imagecreatefrompng($full); elseif ($info[2] === IMAGETYPE_GIF) $src = @imagecreatefromgif($full); elseif (defined('IMAGETYPE_WEBP') && $info[2] === IMAGETYPE_WEBP && function_exists('imagecreatefromwebp')) { $src = @imagecreatefromwebp($full); } if (!$src) return null; // Handy-Fotos: EXIF-Orientierung anwenden, damit die Kachel so aussieht wie // die Grossansicht (Browser drehen JPEGs anhand des EXIF-Tags selbst). if ($info[2] === IMAGETYPE_JPEG) { $orient = bericht_jpeg_orientation($full); if ($orient > 1) { $rotated = bericht_apply_orientation($src, $orient); if ($rotated) { imagedestroy($src); $src = $rotated; } } } $sw = imagesx($src); $sh = imagesy($src); $ratio = min($maxside / $sw, $maxside / $sh, 1); $dw = max(1, (int) round($sw * $ratio)); $dh = max(1, (int) round($sh * $ratio)); $dst = imagecreatetruecolor($dw, $dh); // Transparenz (PNG/GIF) auf Weiss legen — das Thumbnail wird JPEG imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255)); imagecopyresampled($dst, $src, 0, 0, 0, 0, $dw, $dh, $sw, $sh); imagedestroy($src); ob_start(); imagejpeg($dst, null, 82); imagedestroy($dst); $data = ob_get_clean(); if (!is_dir($cache_dir)) dol_mkdir($cache_dir); if (is_dir($cache_dir)) @file_put_contents($cache_file, $data); return $data; } /** * Aufnahmezeitpunkt einer Datei: EXIF wenn vorhanden, sonst das Dateidatum. * Ergebnis wird pro Request zwischengespeichert. */ function bericht_file_taken_at($fullpath, $fallback_date = null) { static $cache = array(); if (isset($cache[$fullpath])) return $cache[$fullpath]; $ts = null; if (preg_match('/\.jpe?g$/i', $fullpath)) { $ts = bericht_jpeg_taken_at($fullpath); } if (!$ts) $ts = $fallback_date ? (int) $fallback_date : @filemtime($fullpath); return $cache[$fullpath] = ($ts ?: null); } /** * Liest den Aufnahmezeitpunkt (EXIF DateTimeOriginal) aus einer JPEG-Datei. * * Bei Baustellenfotos ist die Aufnahmereihenfolge meist genau die Reihenfolge, * in der die Bilder in den Bericht sollen. Eigener Parser, weil das exif-Modul * im Prod-Container nicht geladen ist. * * @return int|null Unix-Timestamp oder null */ function bericht_jpeg_taken_at($file) { if (function_exists('exif_read_data')) { $e = @exif_read_data($file); foreach (array('DateTimeOriginal', 'DateTimeDigitized', 'DateTime') as $k) { if (!empty($e[$k])) { $ts = bericht_parse_exif_date($e[$k]); if ($ts) return $ts; } } return null; } $fh = @fopen($file, 'rb'); if (!$fh) return null; $result = null; if (fread($fh, 2) === "\xFF\xD8") { while (!feof($fh)) { $marker = fread($fh, 2); if (strlen($marker) < 2 || $marker[0] !== "\xFF") break; $type = ord($marker[1]); if ($type === 0xD8 || ($type >= 0xD0 && $type <= 0xD9)) continue; $lenraw = fread($fh, 2); if (strlen($lenraw) < 2) break; $len = unpack('n', $lenraw)[1] - 2; if ($len <= 0) break; if ($type !== 0xE1) { fseek($fh, $len, SEEK_CUR); continue; } $app1 = fread($fh, $len); if (strncmp($app1, "Exif\0\0", 6) !== 0) break; $tiff = substr($app1, 6); if (strlen($tiff) < 8) break; $le = (substr($tiff, 0, 2) === 'II'); $fmt_s = $le ? 'v' : 'n'; $fmt_l = $le ? 'V' : 'N'; $ifd0 = unpack($fmt_l, substr($tiff, 4, 4))[1]; // Tag 0x0132 (DateTime) in IFD0, 0x9003 (DateTimeOriginal) im Exif-SubIFD $found = bericht_exif_scan_ifd($tiff, $ifd0, $fmt_s, $fmt_l, array(0x0132), array(0x8769)); if (!empty($found['sub'])) { $orig = bericht_exif_scan_ifd($tiff, $found['sub'], $fmt_s, $fmt_l, array(0x9003, 0x9004), array()); if (!empty($orig['value'])) $found['value'] = $orig['value']; } if (!empty($found['value'])) $result = bericht_parse_exif_date($found['value']); break; } } fclose($fh); return $result; } /** * Durchsucht ein IFD nach ASCII-Tags und einem optionalen Sub-IFD-Zeiger. * * @param int[] $wanted Tags, deren ASCII-Wert gesucht wird * @param int[] $subtags Tags, die auf ein weiteres IFD zeigen (LONG) * @return array{value:?string,sub:?int} */ function bericht_exif_scan_ifd($tiff, $offset, $fmt_s, $fmt_l, array $wanted, array $subtags) { $out = array('value' => null, 'sub' => null); if ($offset + 2 > strlen($tiff)) return $out; $count = unpack($fmt_s, substr($tiff, $offset, 2))[1]; for ($i = 0; $i < $count; $i++) { $off = $offset + 2 + $i * 12; if ($off + 12 > strlen($tiff)) break; $tag = unpack($fmt_s, substr($tiff, $off, 2))[1]; if (in_array($tag, $subtags, true)) { $out['sub'] = unpack($fmt_l, substr($tiff, $off + 8, 4))[1]; continue; } if (!in_array($tag, $wanted, true) || $out['value'] !== null) continue; $len = unpack($fmt_l, substr($tiff, $off + 4, 4))[1]; // ASCII-Werte über 4 Byte stehen ausgelagert, der Eintrag hält den Offset $val_off = ($len > 4) ? unpack($fmt_l, substr($tiff, $off + 8, 4))[1] : ($off + 8); if ($val_off + $len > strlen($tiff)) continue; $out['value'] = rtrim(substr($tiff, $val_off, $len), "\0"); } return $out; } /** "2026:08:12 14:12:33" -> Unix-Timestamp */ function bericht_parse_exif_date($raw) { if (!preg_match('/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/', trim($raw), $m)) return null; return @mktime((int) $m[4], (int) $m[5], (int) $m[6], (int) $m[2], (int) $m[3], (int) $m[1]) ?: null; } /** * Liest nur das EXIF-Orientation-Tag aus einer JPEG-Datei. * * Eigener Mini-Parser, weil das exif-Modul im Prod-Container NICHT geladen ist * (verifiziert 2026-08-21 in 90-Dolibarr-Prod-Custom) — sonst haetten Kacheln * lokal und auf Prod eine unterschiedliche Drehung. * * @return int 1..8, 1 = keine Drehung noetig */ function bericht_jpeg_orientation($file) { if (function_exists('exif_read_data')) { $e = @exif_read_data($file); return (is_array($e) && !empty($e['Orientation'])) ? (int) $e['Orientation'] : 1; } $fh = @fopen($file, 'rb'); if (!$fh) return 1; $result = 1; // JPEG-Segmente durchlaufen bis APP1 (Exif) gefunden ist if (fread($fh, 2) === "\xFF\xD8") { while (!feof($fh)) { $marker = fread($fh, 2); if (strlen($marker) < 2 || $marker[0] !== "\xFF") break; $type = ord($marker[1]); if ($type === 0xD8 || ($type >= 0xD0 && $type <= 0xD9)) continue; $lenraw = fread($fh, 2); if (strlen($lenraw) < 2) break; $len = unpack('n', $lenraw)[1] - 2; if ($len <= 0) break; if ($type !== 0xE1) { fseek($fh, $len, SEEK_CUR); continue; } $app1 = fread($fh, $len); if (strncmp($app1, "Exif\0\0", 6) !== 0) break; $tiff = substr($app1, 6); if (strlen($tiff) < 8) break; $le = (substr($tiff, 0, 2) === 'II'); // Byte-Order $fmt_s = $le ? 'v' : 'n'; // 16 Bit $fmt_l = $le ? 'V' : 'N'; // 32 Bit $ifd = unpack($fmt_l, substr($tiff, 4, 4))[1]; if ($ifd + 2 > strlen($tiff)) break; $count = unpack($fmt_s, substr($tiff, $ifd, 2))[1]; for ($i = 0; $i < $count; $i++) { $off = $ifd + 2 + $i * 12; if ($off + 12 > strlen($tiff)) break; $tag = unpack($fmt_s, substr($tiff, $off, 2))[1]; if ($tag !== 0x0112) continue; // Orientation $val = unpack($fmt_s, substr($tiff, $off + 8, 2))[1]; if ($val >= 1 && $val <= 8) $result = $val; break; } break; } } fclose($fh); return $result; } /** * Dreht/spiegelt ein GD-Bild gemaess EXIF-Orientation. * * @return resource|GdImage|null */ function bericht_apply_orientation($img, $orient) { switch ((int) $orient) { case 2: imageflip($img, IMG_FLIP_HORIZONTAL); return $img; case 3: return imagerotate($img, 180, 0); case 4: imageflip($img, IMG_FLIP_VERTICAL); return $img; case 5: imageflip($img, IMG_FLIP_HORIZONTAL); return imagerotate($img, -90, 0); case 6: return imagerotate($img, -90, 0); case 7: imageflip($img, IMG_FLIP_HORIZONTAL); return imagerotate($img, 90, 0); case 8: return imagerotate($img, 90, 0); } return null; } /** * memory_limit in Bytes (0 = unbegrenzt). */ function bericht_memory_limit_bytes() { $v = trim((string) ini_get('memory_limit')); if ($v === '' || $v === '-1') return 0; $unit = strtolower(substr($v, -1)); $num = (float) $v; if ($unit === 'g') $num *= 1024 * 1024 * 1024; elseif ($unit === 'm') $num *= 1024 * 1024; elseif ($unit === 'k') $num *= 1024; return (int) $num; } 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($pdf), '', $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($pdf = null) { // Der Font wird in BerichtPdfTrait::berichtInit registriert; hier nur Key zurückgeben. if ($pdf && isset($pdf->hack_font_key) && $pdf->hack_font_key) { return $pdf->hack_font_key; } return 'helvetica'; } /** * 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; $font = bericht_ensure_hack_font($pdf); $pdf->SetFont($font, '', 9); $pdf->SetTextColor(40, 40, 40); $autoPB = $pdf->getAutoPageBreak(); $bMargin = $pdf->getBreakMargin(); $pdf->SetAutoPageBreak(false, 0); // HTML in Font-Wrapper packen, damit TCPDF den Hack-Font für den ganzen Block nutzt $wrapped = ''.$html.''; $pdf->writeHTMLCell($w, $h, $x, $y, $wrapped, 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); // Das Composite ist exakt die Arbeitsfläche des Editors und hat // bereits die Proportion des Inhaltsbereichs — also 1:1 dort // einsetzen, NICHT noch einmal einpassen. Sonst schrumpft der // Inhalt ein zweites Mal und die Vorschau stimmt nicht. $g = bericht_page_geometry($fmt, $ori, !empty($page->note)); // Composites aus der Zeit vor der WYSIWYG-Umstellung zeigen das ganze // Blatt (A4-Proportion) statt nur den Inhaltsbereich. Die wuerden beim // 1:1-Einsetzen verzerrt — daher an der Proportion erkennen und // stattdessen proportional einpassen. list($ciw, $cih) = @getimagesize($full); $target_ratio = $g['contentW'] / $g['contentH']; $is_current = ($ciw && $cih) ? (abs(($ciw / $cih) - $target_ratio) / $target_ratio < 0.02) : true; if ($is_current) { $pdf->Image($full, $g['contentX'], $g['contentY'], $g['contentW'], $g['contentH']); } else { $r = min($g['contentW'] / $ciw, $g['contentH'] / $cih); $cw = $ciw * $r; $ch = $cih * $r; $pdf->Image($full, $g['contentX'] + ($g['contentW'] - $cw) / 2, $g['contentY'] + ($g['contentH'] - $ch) / 2, $cw, $ch); } if (!empty($page->note)) { $note_y = $g['contentY'] + $g['contentH'] + 4; bericht_write_note_html($pdf, $page->note, $g['contentX'], $note_y, $g['contentW'], $g['noteH']); } 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($pdf), '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($pdf), '', 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(); // Inhaltsbereich wie im Editor — oben bleibt der Kopfbereich frei $g = bericht_page_geometry($fmt, $ori, !empty($page->note)); $margin = $g['contentX']; $title_h = !empty($page->title) ? 12 : 0; if ($title_h) { $pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16); $pdf->SetY($g['contentY']); $pdf->MultiCell(0, $title_h, $page->title, 0, 'C'); } // Zwei Bilder nebeneinander mit VORHER/NACHHER Labels $label_h = 8; $gap = 6; $top = $g['contentY'] + $title_h + ($title_h ? 4 : 0); $usable_h = $g['contentY'] + $g['contentH'] - $top - $label_h; $w_each = ($g['contentW'] - $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($pdf), '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)) { $gn = bericht_page_geometry($fmt, $ori, true); bericht_write_note_html($pdf, $page->note, $gn['contentX'], $gn['contentY'] + $gn['contentH'] + 4, $gn['contentW'], $gn['noteH']); } 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 — unterhalb des Kopfbereichs $g = bericht_page_geometry($fmt, $ori, !empty($page->note)); $title_h = 0; if (!empty($page->title)) { $pdf->SetFont(bericht_ensure_hack_font($pdf), 'B', 16); $pdf->SetY($g['contentY']); $pdf->MultiCell(0, 10, $page->title, 0, 'C'); $title_h = 14; } $rects = BerichtPage::slotRectsInBox( $layout, $g['contentX'], $g['contentY'] + $title_h, $g['contentW'], $g['contentH'] - $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)) { $gn = bericht_page_geometry($fmt, $ori, true); bericht_write_note_html($pdf, $page->note, $gn['contentX'], $gn['contentY'] + $gn['contentH'] + 4, $gn['contentW'], $gn['noteH']); } 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(); // Gleiche Geometrie wie der Editor: Kopf- und Fussbereich bleiben frei $g = bericht_page_geometry($fmt, $ori, !empty($page->note)); $maxW = $g['contentW']; $maxH = $g['contentH']; // 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 innerhalb des Inhaltsbereichs list($x, $y) = bericht_align_position($page->image_align ?: 'fit', $w, $h, $g); $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)) { $gn = bericht_page_geometry($fmt, $ori, true); bericht_write_note_html($pdf, $page->note, $gn['contentX'], $gn['contentY'] + $gn['contentH'] + 4, $gn['contentW'], $gn['noteH']); } } /** * Berechnet die x/y-Position für ein Bild auf einer Seite anhand des image_align-Werts. */ function bericht_align_position($align, $w, $h, array $g) { $x0 = $g['contentX']; $y0 = $g['contentY']; $cw = $g['contentW']; $ch = $g['contentH']; switch ($align) { case 'topleft': return array($x0, $y0); case 'topright': return array($x0 + $cw - $w, $y0); case 'bottomleft': return array($x0, $y0 + $ch - $h); case 'bottomright': return array($x0 + $cw - $w, $y0 + $ch - $h); case 'center': return array($x0 + ($cw - $w) / 2, $y0 + ($ch - $h) / 2); case 'fit': default: return array($x0 + ($cw - $w) / 2, $y0); } } 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; } } /** * Sucht im Zielordner eine inhaltlich identische Datei zur hochgeladenen Datei. * * Hintergrund: Der Foto-Upload ist bewusst at-least-once (KB #840) — geht die Antwort * nach dem Speichern verloren, wiederholt die PWA den Upload, und dasselbe Foto lag * danach doppelt im Auftragsordner. Dasselbe passiert, wenn nach einem abgebrochenen * Durchgang dieselbe Auswahl noch einmal hochgeladen wird (real am 28.08.2026 an * Auftrag (PROV105): 34 Dateien, davon 11 identische Wiederholungen). * Mit dem Inhalts-Vergleich wird der Upload idempotent, ohne die Queue-Semantik * anzutasten: der Aufrufer meldet den Treffer als Erfolg zurueck. * * Vergleich zuerst ueber die Dateigroesse (billig), erst bei Gleichstand ueber md5. * * @param string $dir Zielverzeichnis (absoluter Pfad) * @param string $tmpfile Hochgeladene temporaere Datei * @return string|null Dateiname der bereits vorhandenen identischen Datei, sonst null */ function bericht_find_identical_file($dir, $tmpfile) { if (!is_dir($dir) || !is_readable($tmpfile)) return null; $size = filesize($tmpfile); if ($size === false || $size <= 0) return null; $files = scandir($dir); if ($files === false) return null; $hash = null; foreach ($files as $f) { if ($f === '.' || $f === '..') continue; $full = $dir.'/'.$f; if (!is_file($full)) continue; if (filesize($full) !== $size) continue; // andere Groesse = anderer Inhalt if ($hash === null) $hash = md5_file($tmpfile); // erst hashen, wenn ein Kandidat da ist if ($hash === false) return null; if (md5_file($full) === $hash) return $f; } return null; }