bericht/lib/bericht.lib.php
Eduard Wisch 195942a2f9
All checks were successful
Deploy bericht / deploy (push) Successful in 2s
feat: Phase 6 — Client-WYSIWYG via Composite-PNG + Text-BG + Dark-Input-Fix
Paradigmen-Wechsel: Editor rendert bei jedem Save sein Fabric-Canvas
als PNG und lädt es hoch. PDF nutzt dieses PNG 1:1 statt die Shapes
serverseitig nachzuzeichnen.

Damit ist garantiert: was du im Editor siehst, ist EXAKT das was im PDF
landet. Alle Pfeil/Text/Shape-Rendering-Bugs zwischen Fabric-JSON und
PHP-Nachzeichnung sind Geschichte.

Kernänderungen:

1. DB: Neue Spalte bericht_page.composite_path (Migration im init())
2. ajax/save_annotations.php: nimmt multipart file 'composite' entgegen,
   speichert es unter bericht/work/<fkb>/composite_<pid>.png
3. lib/bericht.lib.php: bericht_render_page_to_pdf prüft composite_path
   zuerst — wenn vorhanden, wird eine Seite mit genau diesem PNG als
   volles Bild gerendert, fertig. Fallback auf alte Logik bei alten
   Berichten ohne Composite.
4. editor.js renderImage: Quellbild wird NICHT mehr auf pdfCanvas
   gezeichnet, sondern als fabric.Image ins Fabric-Canvas geladen —
   ZIEHBAR, SKALIERBAR, ROTIERBAR wie jedes andere Objekt.
   Mehrere Bilder auf einer Seite kein Problem mehr.
5. editor.js savePageAnnotations: nach Shape-State wird toDataURL
   mit multiplier:2 aufgerufen, PNG-Blob hochgeladen zusammen mit
   fabric_json (für spätere Edits) und note.
6. editor.js loadPage: wenn fabric_json existiert, wird dieses
   clientseitig wieder eingeladen (inkl. eingebettete Bilder) — das
   Quell-Bild wird nicht mehr neu aus der Quelle geholt. Bei leerer
   Seite läuft der alte Render-Flow.

Phase 6 Bonus — Text mit Hintergrund:
- Neuer color-picker 'BG:' in der Toolbar + 'Ø'-Button (kein BG)
- Fabric IText bekommt textBackgroundColor + padding:6
- Bei selektiertem Text-Objekt wird BG live angewendet
- Dataset-Flag 'active' toggelt zwischen ein/aus

Dark-Input-Fix:
- Textarea in .bericht-page-note nutzte --inputbackgroundcolor
  (existiert in awl-dark nicht → Fallback #fff = weiße Fläche mit
  schwarzer Schrift auf Dark-Theme)
- Jetzt: --colorbackbody + --colortext + --colorboxbordertitle1
- Generischer Input-Style für alle Text-Eingaben in .bericht-editor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
2026-04-09 13:26:57 +02:00

561 lines
21 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);
} else {
return null;
}
if ($o->fetch($id) <= 0) return null;
$o->fetch_thirdparty();
if (method_exists($o, 'fetch_optionals')) {
$o->fetch_optionals();
}
return $o;
}
/**
* Mappt einen element_type-Code auf den Dolibarr-internen Element-Namen
* für das Verzeichnis der Anhänge (multidir_output).
*/
function bericht_element_to_dir_key($element_type)
{
return array(
'invoice' => 'facture',
'order' => 'commande',
'propal' => 'propal',
)[$element_type] ?? null;
}
/**
* Sammelt alle Anhänge eines Parent-Objekts UND der direkt verknüpften Objekte
* (z. B. der Auftrag/Angebot, die mit dieser Rechnung verknüpft sind).
*
* @return array Liste mit ['source','source_id','source_ref','filename','fullpath','relpath','size','mime','date']
*/
function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_type)
{
global $conf;
$result = array();
// 1) Eigene Anhänge
$dir_key = bericht_element_to_dir_key($element_type);
if ($dir_key && !empty($conf->{$dir_key}->multidir_output[$parent->entity])) {
$upload_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref);
if (is_dir($upload_dir)) {
$files = dol_dir_list($upload_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$');
foreach ($files as $f) {
$result[] = array(
'source' => $element_type,
'source_id' => $parent->id,
'source_ref' => $parent->ref,
'filename' => $f['name'],
'fullpath' => $f['fullname'],
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']),
'size' => $f['size'],
'mime' => dol_mimetype($f['name']),
'date' => $f['date'],
);
}
}
}
// 2) Verknüpfte Objekte: order, propal — auch deren Anhänge anbieten
$parent->fetchObjectLinked();
$linked_types = array('commande' => 'order', 'propal' => 'propal', 'facture' => 'invoice');
foreach ($linked_types as $linked_dolibarr_type => $linked_module_type) {
if (empty($parent->linkedObjects[$linked_dolibarr_type])) continue;
foreach ($parent->linkedObjects[$linked_dolibarr_type] as $lobj) {
if (empty($conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity])) continue;
$linked_dir = $conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity].'/'.dol_sanitizeFileName($lobj->ref);
if (!is_dir($linked_dir)) continue;
$files = dol_dir_list($linked_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$');
foreach ($files as $f) {
$result[] = array(
'source' => $linked_module_type,
'source_id' => $lobj->id,
'source_ref' => $lobj->ref,
'filename' => $f['name'],
'fullpath' => $f['fullname'],
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']),
'size' => $f['size'],
'mime' => dol_mimetype($f['name']),
'date' => $f['date'],
);
}
}
}
return $result;
}
/**
* Liest die Auftragsnummer aus dem Parent-Objekt.
* Reihenfolge: extrafield 'auftragsnummer' → ref_client → ref
*/
function bericht_get_auftragsnummer(CommonObject $parent)
{
if (!empty($parent->array_options['options_auftragsnummer'])) {
return $parent->array_options['options_auftragsnummer'];
}
if (!empty($parent->ref_client)) {
return $parent->ref_client;
}
return $parent->ref;
}
/**
* Listet alle ODT-Templates im Templates-Verzeichnis auf.
*
* @return string[] Dateinamen
*/
function bericht_list_templates()
{
$dir = DOL_DATA_ROOT.'/bericht/templates';
if (!is_dir($dir)) return array();
$files = scandir($dir);
return array_values(array_filter($files, function ($f) {
return preg_match('/\.odt$/i', $f);
}));
}
/**
* Sicherer absolute-Pfad-Resolver für Dateien unterhalb DOL_DATA_ROOT.
* Verhindert Path-Traversal.
*/
function bericht_resolve_data_path($relpath)
{
$base = realpath(DOL_DATA_ROOT);
$full = realpath($base.'/'.$relpath);
if ($full === false) return null;
if (strpos($full, $base) !== 0) return null;
return $full;
}
function bericht_hex_to_rgb($hex)
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
if (strlen($hex) !== 6) return array(255, 0, 0);
return array(hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2)));
}
/**
* Rendert Fabric.js-Annotationen in ein TCPDF-Objekt.
* Nutzt die wichtigsten Shape-Typen: rect, circle/ellipse, line, path, text, group (Pfeil).
*/
function bericht_burn_annotations($pdf, $fabric_json, $x, $y, $w, $h)
{
if (empty($fabric_json)) return;
$data = json_decode($fabric_json, true);
if (!is_array($data) || empty($data['objects'])) return;
$cw = $data['width'] ?? null;
$ch = $data['height'] ?? null;
if (!$cw || !$ch) return;
$sx = $w / $cw;
$sy = $h / $ch;
foreach ($data['objects'] as $obj) {
$stroke = isset($obj['stroke']) ? bericht_hex_to_rgb($obj['stroke']) : array(255, 0, 0);
$sw = ($obj['strokeWidth'] ?? 2) * $sx;
$pdf->SetDrawColor($stroke[0], $stroke[1], $stroke[2]);
$pdf->SetLineWidth(max(0.2, $sw));
$type = $obj['type'] ?? '';
$ox = ($obj['left'] ?? 0) * $sx + $x;
$oy = ($obj['top'] ?? 0) * $sy + $y;
$ow = ($obj['width'] ?? 0) * ($obj['scaleX'] ?? 1) * $sx;
$oh = ($obj['height'] ?? 0) * ($obj['scaleY'] ?? 1) * $sy;
switch ($type) {
case 'rect':
$pdf->Rect($ox, $oy, $ow, $oh, 'D');
break;
case 'circle':
case 'ellipse':
$rx = ($obj['rx'] ?? ($ow / 2)) * $sx;
$ry = ($obj['ry'] ?? ($oh / 2)) * $sy;
$pdf->Ellipse($ox + $rx, $oy + $ry, $rx, $ry, 0, 0, 360, 'D');
break;
case 'line':
$x1 = ($obj['x1'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx;
$y1 = ($obj['y1'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy;
$x2 = ($obj['x2'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx;
$y2 = ($obj['y2'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy;
$pdf->Line($x1, $y1, $x2, $y2);
break;
case 'path':
if (!empty($obj['path']) && is_array($obj['path'])) {
$prev = null;
foreach ($obj['path'] as $seg) {
if (!is_array($seg) || count($seg) < 3) continue;
$cmd = $seg[0];
if ($cmd === 'M') {
$prev = array($seg[1] * $sx + $x, $seg[2] * $sy + $y);
} elseif (($cmd === 'L' || $cmd === 'Q') && $prev) {
$px = $seg[1] * $sx + $x;
$py = $seg[2] * $sy + $y;
$pdf->Line($prev[0], $prev[1], $px, $py);
$prev = array($px, $py);
}
}
}
break;
case 'group':
// Pfeil = Group(Line + Triangle) — als Pfeillinie rendern
if (!empty($obj['objects']) && is_array($obj['objects'])) {
$angle = ($obj['angle'] ?? 0) * M_PI / 180;
$startX = $ox; $startY = $oy;
// Länge aus den Children abschätzen
$len = 0;
foreach ($obj['objects'] as $child) {
if (($child['type'] ?? '') === 'line') {
$lx2 = ($child['x2'] ?? 0) - ($child['x1'] ?? 0);
$len = max($len, abs($lx2));
}
}
$len *= $sx * ($obj['scaleX'] ?? 1);
$endX = $startX + cos($angle) * $len;
$endY = $startY + sin($angle) * $len;
$pdf->Line($startX, $startY, $endX, $endY);
// Pfeilspitze
$headLen = max(2, $sw * 4);
$a1 = $angle + M_PI - 0.4;
$a2 = $angle + M_PI + 0.4;
$pdf->Line($endX, $endY, $endX + cos($a1) * $headLen, $endY + sin($a1) * $headLen);
$pdf->Line($endX, $endY, $endX + cos($a2) * $headLen, $endY + sin($a2) * $headLen);
}
break;
case 'i-text':
case 'text':
case 'textbox':
$fontsize = max(6, ($obj['fontSize'] ?? 16) * $sx * 2.83);
$pdf->SetFont('helvetica', '', $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
*/
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 — ein Bild über die ganze Seite. Keine Server-Shape-Logik nötig.
if (!empty($page->composite_path)) {
$full = bericht_resolve_data_path($page->composite_path);
if ($full && file_exists($full)) {
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$maxW = $pageW - 2 * $margin;
$maxH = $pageH - 2 * $margin - (empty($page->note) ? 0 : 10);
list($iw, $ih) = @getimagesize($full);
if ($iw && $ih) {
$ratio = min($maxW / $iw, $maxH / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
$x = ($pageW - $w) / 2;
$y = $margin;
$pdf->Image($full, $x, $y, $w, $h);
}
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
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('helvetica', '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('helvetica', '', 12);
$pdf->MultiCell(0, 8, $page->note, 0, 'C');
}
return;
}
/* ----- Vorher/Nachher-Layout ----- */
if ($layout === 'before_after') {
$imgs = $page->getImages();
if (empty($imgs)) return;
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$title_h = !empty($page->title) ? 12 : 0;
if ($title_h) {
$pdf->SetFont('helvetica', 'B', 16);
$pdf->SetY($margin);
$pdf->MultiCell(0, $title_h, $page->title, 0, 'C');
}
// Zwei Bilder nebeneinander mit VORHER/NACHHER Labels
$label_h = 8;
$gap = 6;
$top = $margin + $title_h + 4;
$usable_h = $pageH - $top - 10 - $label_h; // -10 für Notiz, -label_h für Beschriftung
$w_each = ($pageW - 2 * $margin - $gap) / 2;
foreach (array(array('Vorher', 0), array('Nachher', 1)) as $slot_info) {
list($label, $slot) = $slot_info;
if (!isset($imgs[$slot])) continue;
$full = bericht_resolve_data_path($imgs[$slot]['source_path']);
if (!$full || !file_exists($full)) continue;
list($iw, $ih) = @getimagesize($full);
if (!$iw || !$ih) continue;
$x0 = $margin + $slot * ($w_each + $gap);
// Label
$pdf->SetFont('helvetica', '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)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
return;
}
/* ----- Multi-Image Grid ----- */
if ($layout !== 'single') {
$imgs = $page->getImages();
if (empty($imgs)) return;
$pdf->AddPage($ori, $fmt);
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
// Titel oben
$title_h = 0;
if (!empty($page->title)) {
$pdf->SetFont('helvetica', 'B', 16);
$pdf->SetY(10);
$pdf->MultiCell(0, 10, $page->title, 0, 'C');
$title_h = 14;
}
$rects = BerichtPage::slotRects($layout, $pageW, $pageH - $title_h, 10 + $title_h);
foreach ($imgs as $img) {
$slot = $img['slot'];
if (!isset($rects[$slot])) continue;
$r = $rects[$slot];
$full = bericht_resolve_data_path($img['source_path']);
if (!$full || !file_exists($full)) continue;
list($iw, $ih) = @getimagesize($full);
if (!$iw || !$ih) continue;
$ratio = min($r['w'] / $iw, $r['h'] / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
$x = $r['x'] + ($r['w'] - $w) / 2;
$y = $r['y'] + ($r['h'] - $h) / 2;
$pdf->Image($full, $x, $y, $w, $h);
}
// Annotationen über volle Seite
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $pageW, $pageH);
// Notiz unten
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
return;
}
/* ----- Single ----- */
$full = bericht_resolve_data_path($page->source_path);
if (!$full || !file_exists($full)) return;
if ($page->source_type === 'image' || preg_match('/\.(png|jpe?g)$/i', $full)) {
$pdf->AddPage($ori, $fmt);
list($iw, $ih) = @getimagesize($full);
if (!$iw || !$ih) return;
$pageW = $pdf->getPageWidth();
$pageH = $pdf->getPageHeight();
$margin = 10;
$maxW = ($pageW - 2 * $margin);
$maxH = ($pageH - 2 * $margin - 10); // Notiz unten
// image_scale: Anteil der maximalen Größe
$scale = max(0.2, min(1.0, (float) ($page->image_scale ?: 1.0)));
$availW = $maxW * $scale;
$availH = $maxH * $scale;
$ratio = min($availW / $iw, $availH / $ih);
$w = $iw * $ratio; $h = $ih * $ratio;
// image_align bestimmt die Position
list($x, $y) = bericht_align_position($page->image_align ?: 'fit', $w, $h, $pageW, $pageH, $margin);
$pdf->Image($full, $x, $y, $w, $h);
bericht_burn_annotations($pdf, $page->fabric_json, $x, $y, $w, $h);
} elseif ($fpdi_loaded && preg_match('/\.pdf$/i', $full)) {
try {
$pdf->setSourceFile($full);
$tpl = $pdf->importPage($page->source_page ?: 1);
$size = $pdf->getTemplateSize($tpl);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $size['width'], $size['height']);
} catch (Throwable $e) { /* skip */ }
}
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
}
/**
* Berechnet die x/y-Position für ein Bild auf einer Seite anhand des image_align-Werts.
*/
function bericht_align_position($align, $w, $h, $pageW, $pageH, $margin = 10)
{
$usableW = $pageW - 2 * $margin;
$usableH = $pageH - 2 * $margin - 10; // Notiz unten
switch ($align) {
case 'topleft':
return array($margin, $margin);
case 'topright':
return array($pageW - $margin - $w, $margin);
case 'bottomleft':
return array($margin, $margin + $usableH - $h);
case 'bottomright':
return array($pageW - $margin - $w, $margin + $usableH - $h);
case 'center':
return array(($pageW - $w) / 2, $margin + ($usableH - $h) / 2);
case 'fit':
default:
return array(($pageW - $w) / 2, $margin);
}
}
function bericht_render_cover_internal($template_path, $bericht, $parent, $tempdir)
{
global $user, $langs;
$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;
}
}