469 lines
14 KiB
PHP
Executable File
469 lines
14 KiB
PHP
Executable File
<?php
|
|
|
|
/**
|
|
* ODT Template Processing and PDF Generation Class
|
|
*
|
|
* This class provides functionality to process OpenDocument Text (ODT) templates
|
|
* with placeholder substitution and automatic PDF conversion using LibreOffice.
|
|
*
|
|
* REQUIREMENTS:
|
|
* - PHP with shell_exec() enabled
|
|
* - unzip command available in system PATH
|
|
* - LibreOffice (soffice) installed for PDF conversion
|
|
*
|
|
* BASIC USAGE:
|
|
*
|
|
* 1. Create an ODT template file with placeholders
|
|
* 2. Initialize the ODT class with template path
|
|
* 3. Prepare the template (extracts and loads content)
|
|
* 4. Replace placeholders with your data
|
|
* 5. Generate output ODT and PDF files
|
|
*
|
|
* EXAMPLE:
|
|
*
|
|
* $odt = new ODT('/path/to/template.odt');
|
|
*
|
|
* if ($odt->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:
|
|
* <text:placeholder>customer_name</text:placeholder>
|
|
*
|
|
* Formatted placeholders with JSON properties in description:
|
|
* <text:placeholder text:description='{"format":"date"}'>invoice_date</text:placeholder>
|
|
* <text:placeholder text:description='{"format":"currency","decimals":2}'>total_amount</text:placeholder>
|
|
*
|
|
* 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:
|
|
* <text:placeholder text:placeholder-type="table">items</text:placeholder>
|
|
*
|
|
* This will repeat the table row for each item in the 'items' array.
|
|
* Within the repeated row, access item properties with 'item.' prefix:
|
|
* <text:placeholder>item.name</text:placeholder>
|
|
* <text:placeholder>item.price</text:placeholder>
|
|
*
|
|
* The class automatically calculates sums for numeric fields:
|
|
* <text:placeholder>sums.items.price</text:placeholder>
|
|
*
|
|
* 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), '<text:line-break/>', safe($raw, ENT_XML1 | ENT_QUOTES, 'UTF-8'));
|
|
}
|
|
|
|
function break_into_segments($s)
|
|
{
|
|
$segment_handlers = [
|
|
'<table:table-row ' => function(&$cake, &$seg) {
|
|
$rest_of_row = nibble('</table:table-row>', $cake);
|
|
# check whether this segment contains an items marker indicating we should
|
|
# iterate this segment over a list in the data
|
|
$items_marker = '<text:placeholder text:placeholder-type="table">';
|
|
if(strpos($rest_of_row, $items_marker) !== false)
|
|
{
|
|
$r0 = nibble($items_marker, $rest_of_row);
|
|
$items_param = trim(str_replace(['<', '>'], '',
|
|
nibble('</text:placeholder>', $rest_of_row)));
|
|
$seg[] = [
|
|
'type' => 'list',
|
|
'content' => '<table:table-row '.$r0.$rest_of_row.'</table:table-row>',
|
|
'fields' => $items_param];
|
|
}
|
|
else $seg[] = [
|
|
'type' => 'flat',
|
|
'content' => '<table:table-row '.$rest_of_row.'</table:table-row>'];
|
|
},
|
|
];
|
|
$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('<text:placeholder', $sc, $placeholder_found);
|
|
if($placeholder_found)
|
|
{
|
|
$p_attributes = ODT::parse_attributes(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('</text:placeholder>', $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);
|
|
}
|
|
|
|
}
|