prepare()) { * $data = [ * 'customer_name' => 'John Doe', * 'invoice_date' => time(), * 'total_amount' => 1250.50, * 'items' => [ * ['name' => 'Product A', 'price' => 500.00, 'qty' => 1], * ['name' => 'Product B', 'price' => 750.50, 'qty' => 1] * ] * ]; * * $odt->replace_placeholders($data); * $pdf_path = $odt->create_output('/path/to/output.odt'); * * if ($pdf_path) { * echo "PDF created: " . $pdf_path; * } else { * echo "Error: " . $odt->error_msg; * } * } else { * echo "Error: " . $odt->error_msg; * } * * PLACEHOLDER SYNTAX: * * In your ODT template, use LibreOffice placeholders (Insert > Field > Other > Variables > User Field) * or direct placeholder markup: * * Simple placeholders: * customer_name * * Formatted placeholders with JSON properties in description: * invoice_date * total_amount * * SUPPORTED FORMATS: * * - "date" - Formats timestamp as date (uses cfg('date_format') or custom template) * - "time" - Formats timestamp as time (uses cfg('time_format') or custom template) * - "datetime" - Formats timestamp as datetime (uses cfg('datetime_format') or custom template) * - "currency" - Formats number as currency with € symbol * - "number" - Formats number with specified decimal places * - "duration" - Converts seconds to "Xh Ymin" format * * FORMAT OPTIONS: * * - "template": Custom date/time format string (e.g., "Y-m-d H:i:s") * - "decimals": Number of decimal places for currency/number formatting * - "default": Default value if placeholder data is empty * - "emptyifzero": Show empty string if numeric value is zero * - "delsegmentifzero": Delete entire segment if numeric value is zero * - "newline": Add newlines "before", "after", or "before,after" the value * * LISTS AND TABLES: * * For repeating table rows, use a special placeholder in a table row: * items * * This will repeat the table row for each item in the 'items' array. * Within the repeated row, access item properties with 'item.' prefix: * item.name * item.price * * The class automatically calculates sums for numeric fields: * sums.items.price * * ERROR HANDLING: * * Always check the return values and $odt->error_msg for error details: * - prepare() returns false on failure * - create_output() returns false on failure, PDF path on success * - Check $odt->error_msg for specific error messages * * PROPERTIES: * * - $template_name: Path to the ODT template file * - $content_xml: Loaded and processed content.xml from ODT * - $temp_dir: Temporary directory for ODT extraction * - $error_msg: Last error message * - $odt_filename: Path to generated ODT file * - $pdf_filename: Path to generated PDF file * - $debug_output: Debug output from shell commands * * NOTES: * * - Temporary files are automatically cleaned up in destructor * - PDF conversion requires LibreOffice to be installed and accessible * - The class assumes UTF-8 encoding for all text content * - Newlines in data are converted to ODT line breaks * - All string values are properly escaped for XML */ class ODT { public $template_name; public $content_xml; public $temp_dir; public $error_msg; public $odt_filename; public $pdf_filename; public $debug_output; function __construct($template_name) { $this->template_name = $template_name; if(!file_exists($this->template_name)) { $this->error_msg = 'Template not found: '.$template_name; } $this->temp_dir = '/tmp/'.uniqid(); } /** * Check if all required command line tools are available and working * @return array Array with 'status' (bool) and 'messages' (array of status messages) */ static function check_requirements() { $results = [ 'status' => true, 'messages' => [] ]; $unzip_check = shell_exec('which unzip 2>/dev/null'); if (empty(trim($unzip_check))) { $results['status'] = false; $results['messages'][] = 'ERROR: unzip command not found in PATH'; } else { $results['messages'][] = 'OK: unzip found at ' . trim($unzip_check); $unzip_version = shell_exec('unzip -v 2>&1 | head -1'); if ($unzip_version) { $results['messages'][] = 'INFO: ' . trim($unzip_version); } } $zip_check = shell_exec('which zip 2>/dev/null'); if (empty(trim($zip_check))) { $results['status'] = false; $results['messages'][] = 'ERROR: zip command not found in PATH'; } else { $results['messages'][] = 'OK: zip found at ' . trim($zip_check); } $soffice_check = shell_exec('which soffice 2>/dev/null') || ''; if (empty(trim($soffice_check))) { $results['status'] = false; $results['messages'][] = 'ERROR: LibreOffice (soffice) not found in PATH'; } else { $results['messages'][] = 'OK: LibreOffice found at ' . trim($soffice_check); $soffice_version = shell_exec('soffice --version 2>&1'); if ($soffice_version) { $results['messages'][] = 'INFO: ' . trim($soffice_version); } $headless_test = shell_exec('timeout 10 soffice --headless --help 2>&1') || ''; if (strpos($headless_test, 'headless') !== false || strpos($headless_test, 'convert-to') !== false) { $results['messages'][] = 'OK: LibreOffice headless mode is working'; } else { $results['status'] = false; $results['messages'][] = 'ERROR: LibreOffice headless mode test failed'; $results['messages'][] = 'DEBUG: ' . trim($headless_test); } } if (!is_dir('/tmp')) { $results['status'] = false; $results['messages'][] = 'ERROR: /tmp directory does not exist'; } elseif (!is_writable('/tmp')) { $results['status'] = false; $results['messages'][] = 'ERROR: /tmp directory is not writable'; } else { $results['messages'][] = 'OK: /tmp directory exists and is writable'; } $test_temp_dir = '/tmp/odt_test_' . uniqid(); if (!mkdir($test_temp_dir, 0777, true)) { $results['status'] = false; $results['messages'][] = 'ERROR: Cannot create temporary directories in /tmp'; } else { $results['messages'][] = 'OK: Can create temporary directories'; rmdir($test_temp_dir); } return $results; } static function parse_attributes($tag) { $attributes = []; while($tag != '') { $a_name = nibble('="', $tag); $a_value = nibble('"', $tag); $attributes[trim($a_name)] = trim($a_value); } return $attributes; } function prepare() { // Create the temporary directory if (!mkdir($this->temp_dir, 0777, true)) { $this->error_msg = 'Failed to create temp directory'; return false; } // Unzip the template into the temp directory using shell_exec() $command = 'unzip ' . escapeshellarg($this->template_name) . ' -d ' . escapeshellarg($this->temp_dir) . ' 2>&1'; $output = shell_exec($command); if ($output === null) { $this->error_msg = 'Failed to unzip the template'; return false; } // Load content.xml into memory $content_file = $this->temp_dir . '/content.xml'; if (!file_exists($content_file)) { $this->error_msg = 'content.xml not found'; return false; } $this->content_xml = file_get_contents($content_file); if ($this->content_xml === false) { $this->error_msg = 'Failed to read content.xml'; return false; } return true; } function odt_entities($raw) { return str_replace(chr(10), '', safe($raw, ENT_XML1 | ENT_QUOTES, 'UTF-8')); } function break_into_segments($s) { $segment_handlers = [ ' function(&$cake, &$seg) { $rest_of_row = nibble('', $cake); # check whether this segment contains an items marker indicating we should # iterate this segment over a list in the data $items_marker = ''; if(strpos($rest_of_row, $items_marker) !== false) { $r0 = nibble($items_marker, $rest_of_row); $items_param = trim(str_replace(['<', '>'], '', nibble('', $rest_of_row))); $seg[] = [ 'type' => 'list', 'content' => '', 'fields' => $items_param]; } else $seg[] = [ 'type' => 'flat', 'content' => '']; }, ]; $seg = []; while($s != '') { $seg_start_found = false; $sc = nibble(array_keys($segment_handlers), $s, $seg_start_found); $seg[] = ['type' => 'flat', 'content' => $sc]; if($seg_start_found !== false) { $segment_handlers[$seg_start_found]($s, $seg); } } return($seg); } function run_segment($seg, $params) { $result = ''; $sc = $seg['content']; while($sc != '') { $placeholder_found = false; $result .= nibble('', $sc)); $p_prop = []; if(isset($p_attributes['text:description']) && $p_attributes['text:description']) { if(substr($p_attributes['text:description'], 0, 1) == '{') { $decoded = json_decode( str_replace('"', '"', $p_attributes['text:description']), true); if($decoded !== null) { $p_prop = $decoded; } } else { $p_prop['format'] = $p_attributes['text:description']; } } $p_content = str_replace(array('>', '<'), '', nibble('', $sc)); $placeholder_key = $p_content; $value = first($params[$placeholder_key] ?? null, $p_prop['default'] ?? null, ''); switch($p_prop['format'] ?? '') { case('date'): { if($value == 0) $value = '-'; else $value = date(first($p_prop['template'], cfg('date_format')), intval($value)); } break; case('time'): { if($value == 0) $value = '-'; else $value = date(first($p_prop['template'], cfg('time_format')), intval($value)); } break; case('datetime'): { if($value == 0) $value = '-'; else $value = date(first($p_prop['template'], cfg('datetime_format')), intval($value)); } break; case('currency'): { $value = number_format(floatval($value), first($p_prop['decimals'] ?? null, 2), ',', '').' €'; } break; case('number'): { $value = number_format(floatval($value), first($p_prop['decimals'] ?? null, 2), ',', ''); } break; case('duration'): { $value = intval($value); $h = floor($value/3600); $value -= $h*3600; $m = floor($value/60); $value -= $m*60; $s = $value; $value = ''; if($h > 0) $value .= $h.'h '; $value .= $m.'min'; } break; } if(($p_prop['emptyifzero'] ?? false) && floatval($value) == 0) $value = ''; if(($p_prop['delsegmentifzero'] ?? false) && floatval($value) == 0) return(''); if(!is_string($value)) $value = trim($value); if(($p_prop['newline'] ?? false) && $value != '') { if(str_contains($p_prop['newline'], 'before')) $value = chr(10).$value; if(str_contains($p_prop['newline'], 'after')) $value = $value.chr(10); } if(!is_array($value)) $result .= $this->odt_entities($value); } } return $result; } function replace_placeholders(&$params) { $result = ''; foreach($this->break_into_segments($this->content_xml) as $seg) { if($seg['type'] == 'list') { if(isset($params[$seg['fields']]) && is_array($params[$seg['fields']])) { foreach($params[$seg['fields']] as $item) { $np = $params; foreach($item as $k => $v) { $np['item.'.$k] = $v; $params['sums.'.$seg['fields'].'.'.$k] += floatval($v); } $result .= $this->run_segment($seg, $np); } } } else { $result .= $this->run_segment($seg, $params); } } return $this->content_xml = $result; } function create_output($out_filename) { $this->odt_filename = $out_filename; $content_file = $this->temp_dir . '/content.xml'; if (file_put_contents($content_file, $this->content_xml) === false) { $this->error_msg = 'Failed to write content.xml'; return false; // } $this->debug_output = trim( shell_exec('cd '.escapeshellarg($this->temp_dir).' ; zip -r ../tmp.odt * 2>&1')); $zip_output = dirname($this->temp_dir).'/tmp.odt'; if(!file_exists($zip_output)) { $this->error_msg = 'Failed to create tmp ODT file (shell "'.$this->debug_output.'")'; return false; } if(file_exists($out_filename)) { unlink($out_filename); } shell_exec('mv '.escapeshellarg($zip_output).' '.escapeshellarg($out_filename)); if (!file_exists($out_filename)) { $this->error_msg = 'Failed to create ODT file "'.$out_filename.'"'; return false; // } $this->pdf_filename = $pdf_output = pathinfo($out_filename, PATHINFO_DIRNAME) . '/' . pathinfo($out_filename, PATHINFO_FILENAME) . '.pdf'; $command = 'HOME=/tmp ; soffice --headless --convert-to pdf ' . escapeshellarg($out_filename) . ' --outdir ' . escapeshellarg(pathinfo($out_filename, PATHINFO_DIRNAME)) . ' 2>&1'; $this->debug_output = shell_exec($command); if (!file_exists($pdf_output)) { $this->error_msg = 'PDF conversion failed'; return false; // } return $pdf_output; } function __destruct() { $this->delete_directory($this->temp_dir); } function delete_directory($dir) { if (!is_dir($dir)) return; $items = array_diff(scandir($dir), ['.', '..']); foreach ($items as $item) { $path = "$dir/$item"; is_dir($path) ? $this->delete_directory($path) : unlink($path); } rmdir($dir); } }