An Auftrag (PROV105) lagen 34 Fotos, davon 11 exakte Wiederholungen: eine Serie lief bei ausgeschaltetem Display nur halb durch, der zweite Anlauf lud alles noch einmal hoch. - bericht_find_identical_file() vergleicht die hochgeladene Datei gegen den Zielordner (Groesse als Vorfilter, md5 nur bei Kandidaten) - eingebaut in beide Upload-Wege: api/orders.php?action=upload_photo (PWA) und mobile_upload.php (Token-Link) - bei Treffer wird nichts gespeichert, die Antwort traegt duplicate:true UND den relpath der vorhandenen Datei — ohne relpath wertet die PWA den Upload als nicht bestaetigt und wiederholt ihn endlos (lib/api.js) Funktion isoliert geprueft: identischer Inhalt wird gefunden, neuer Inhalt und gleiche Groesse mit anderem Inhalt liefern null (kein Fehlalarm). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1645 lines
66 KiB
PHP
1645 lines
66 KiB
PHP
<?php
|
||
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
||
* 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 <ref>.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_<name>} 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 '<div id="bericht-att-list" class="att-view-grid">';
|
||
$tile_no = 0;
|
||
foreach ($by_source as $key => $files) {
|
||
list($src, $ref) = explode(':', $key, 2);
|
||
print '<div class="bericht-att-group" data-group="'.dol_escape_htmltag($key).'">';
|
||
print '<div class="bericht-att-group-title">';
|
||
print '<span class="att-group-ref" title="'.dol_escape_htmltag($ref).'">'.dol_escape_htmltag($ref).'</span>';
|
||
print '<button type="button" class="att-group-toggle" data-group="'.dol_escape_htmltag($key).'" title="Alle Dateien dieser Herkunft aus-/abwählen">alle</button>';
|
||
print '</div>';
|
||
print '<div class="bericht-att-tiles">';
|
||
|
||
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 '<div class="bericht-att-item'.($is_used ? ' is-used' : '').'"'
|
||
.' data-relpath="'.dol_escape_htmltag($f['relpath']).'"'
|
||
.' data-mime="'.dol_escape_htmltag($f['mime']).'"'
|
||
.' data-group="'.dol_escape_htmltag($key).'"'
|
||
.' data-name="'.dol_escape_htmltag(strtolower($f['filename'])).'"'
|
||
.' data-size="'.((int) $f['size']).'"'
|
||
.' data-kind="'.($is_image ? 'image' : ($is_pdf ? 'pdf' : 'other')).'"'
|
||
.' data-taken="'.$taken.'">';
|
||
|
||
// Miniatur — Bild direkt vom Thumb-Endpoint, PDF wird per PDF.js gerendert
|
||
print '<div class="att-thumb'.($is_image ? '' : ($is_pdf ? ' is-pdf' : ' is-other')).'"'
|
||
.' data-view-url="'.dol_escape_htmltag($urls['view']).'"'
|
||
.' data-type="'.($is_image ? 'image' : ($is_pdf ? 'pdf' : 'other')).'"'
|
||
.' data-filename="'.dol_escape_htmltag($f['filename']).'"'
|
||
.' title="'.dol_escape_htmltag($f['filename']).' — klicken zum Vergrössern">';
|
||
if ($is_image) {
|
||
print '<img class="att-thumb-img" loading="lazy" alt="" src="'.dol_escape_htmltag($urls['thumb']).'">';
|
||
} elseif ($is_pdf) {
|
||
print '<canvas class="att-thumb-canvas" data-pdf-url="'.dol_escape_htmltag($urls['view']).'"></canvas>';
|
||
print '<span class="att-type-badge">PDF</span>';
|
||
} else {
|
||
print '<span class="att-thumb-fallback">📎</span>';
|
||
}
|
||
if ($is_used) print '<span class="att-used-badge" title="Diese Datei ist bereits im Bericht">✓</span>';
|
||
print '</div>';
|
||
|
||
// Auswahl — Checkbox liegt als grosses Ziel auf der Kachel
|
||
print '<input type="checkbox" class="att-check" id="'.$cid.'"'
|
||
.' data-relpath="'.dol_escape_htmltag($f['relpath']).'"'
|
||
.' data-mime="'.dol_escape_htmltag($f['mime']).'" title="Auswählen">';
|
||
print '<label class="att-check-ui" for="'.$cid.'" title="Auswählen"><span class="att-order"></span></label>';
|
||
|
||
if ($user->hasRight('bericht', 'write')) {
|
||
print '<button type="button" class="att-delete" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-source-ref="'.dol_escape_htmltag($f['source_ref']).'" title="Diese Datei aus dem Anhang löschen">🗑️</button>';
|
||
}
|
||
|
||
print '<div class="att-meta">';
|
||
print '<span class="att-name" title="'.dol_escape_htmltag($f['filename']).'">'.dol_escape_htmltag($f['filename']).'</span>';
|
||
$meta_line = dol_print_size($f['size'], 1, 1);
|
||
if ($taken) {
|
||
$meta_line = dol_print_date($taken, '%d.%m. %H:%M').' · '.$meta_line;
|
||
}
|
||
print '<span class="att-size opacitymedium" title="'
|
||
.($taken ? 'Aufnahme: '.dol_print_date($taken, 'dayhour') : '').'">'.$meta_line.'</span>';
|
||
print '</div>';
|
||
|
||
print '</div>'; // .bericht-att-item
|
||
}
|
||
print '</div>'; // .bericht-att-tiles
|
||
print '</div>'; // .bericht-att-group
|
||
}
|
||
print '</div>'; // #bericht-att-list
|
||
}
|
||
|
||
/**
|
||
* Rendert die Seiten-Miniaturen (Inhalt von #bericht-page-list).
|
||
*/
|
||
function bericht_print_page_list(array $pages)
|
||
{
|
||
global $user, $langs;
|
||
|
||
print '<div id="bericht-page-list" class="page-list paper-light">';
|
||
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 '<div class="page-thumb'.($is_signature ? ' page-signature' : '').'" data-pageid="'.$p->id.'"'
|
||
.' data-order="'.$p->page_order.'" data-layout="'.dol_escape_htmltag($p->layout ?: 'single').'">';
|
||
print '<div class="page-thumb-paper">';
|
||
print '<canvas class="thumb-canvas"></canvas>';
|
||
print '</div>';
|
||
print '<div class="page-thumb-label"><span class="page-num" title="Klicken zum Markieren (Umschalt für einen Bereich)">'.($idx + 1).'</span>'
|
||
.($is_signature ? ' <span class="sig-badge" title="Unterschrift">🔒</span>' : '')
|
||
.'</div>';
|
||
print '<div class="page-thumb-actions">';
|
||
if ($is_signature) {
|
||
print '<button type="button" class="thumb-verify" title="Unterschrift prüfen" data-pageid="'.$p->id.'">🔍</button>';
|
||
} elseif ($user->hasRight('bericht', 'write')) {
|
||
print '<button type="button" class="thumb-replace" title="Bild dieser Seite austauschen — Reihenfolge, Notiz und Anmerkungen bleiben erhalten">🔄</button>';
|
||
}
|
||
print '<button type="button" class="thumb-del" title="'.$langs->trans("BerichtDeletePage").'">🗑️</button>';
|
||
print '</div>';
|
||
print '</div>';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 = '<span style="font-family:'.$font.';font-size:9pt;">'.$html.'</span>';
|
||
$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;
|
||
}
|