bericht/lib/bericht.lib.php
Eduard Wisch 6949702f5a Foto-Upload: identische Datei kein zweites Mal ablegen (v1.6.0) [deploy]
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>
2026-08-28 18:41:50 +02:00

1645 lines
66 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}