- ZUGFeRD/Factur-X XML-Generierung (EN16931) - XRechnung 3.0 Unterstützung - PDF-Einbettung (echtes ZUGFeRD-PDF) - Option XML nach Einbettung zu löschen - ODT-Template Unterstützung - E-Mail Anhang Funktion - XML-Vorschau Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
327 lines
9.8 KiB
PHP
327 lines
9.8 KiB
PHP
<?php
|
|
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
/**
|
|
* \file class/pdfembedder.class.php
|
|
* \ingroup exportzugferd
|
|
* \brief Embeds ZUGFeRD/Factur-X XML into PDF
|
|
*/
|
|
|
|
/**
|
|
* Class ZugferdPdfEmbedder
|
|
* Embeds ZUGFeRD XML into existing PDF files to create PDF/A-3 compliant ZUGFeRD PDFs
|
|
*/
|
|
class ZugferdPdfEmbedder
|
|
{
|
|
/**
|
|
* @var string Last error message
|
|
*/
|
|
public $error = '';
|
|
|
|
/**
|
|
* @var string[] Error messages
|
|
*/
|
|
public $errors = array();
|
|
|
|
/**
|
|
* Embed XML file into existing PDF
|
|
*
|
|
* @param string $pdfFile Path to the PDF file
|
|
* @param string $xmlFile Path to the XML file to embed
|
|
* @param string $profile ZUGFeRD profile (MINIMUM, BASIC, EN16931, XRECHNUNG, EXTENDED)
|
|
* @return bool True on success, false on error
|
|
*/
|
|
public function embedXmlInPdf($pdfFile, $xmlFile, $profile = 'EN16931')
|
|
{
|
|
global $conf;
|
|
|
|
dol_syslog("ZugferdPdfEmbedder::embedXmlInPdf called - PDF: $pdfFile, XML: $xmlFile", LOG_INFO);
|
|
|
|
if (!file_exists($pdfFile)) {
|
|
$this->error = 'PDF file not found: ' . $pdfFile;
|
|
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
|
|
if (!file_exists($xmlFile)) {
|
|
$this->error = 'XML file not found: ' . $xmlFile;
|
|
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
|
|
// Load TCPDF first (required by TCPDI)
|
|
if (!class_exists('TCPDF')) {
|
|
$tcpdfPath = DOL_DOCUMENT_ROOT . '/includes/tecnickcom/tcpdf/tcpdf.php';
|
|
if (file_exists($tcpdfPath)) {
|
|
require_once $tcpdfPath;
|
|
} else {
|
|
$this->error = 'TCPDF library not found';
|
|
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check if TCPDI is available
|
|
if (!class_exists('TCPDI')) {
|
|
$tcpdiPath = DOL_DOCUMENT_ROOT . '/includes/tcpdi/tcpdi.php';
|
|
if (file_exists($tcpdiPath)) {
|
|
require_once $tcpdiPath;
|
|
} else {
|
|
$this->error = 'TCPDI library not found';
|
|
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Read XML content
|
|
$xmlContent = file_get_contents($xmlFile);
|
|
if ($xmlContent === false) {
|
|
$this->error = 'Could not read XML file';
|
|
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: Creating TCPDI object", LOG_DEBUG);
|
|
|
|
// Create new PDF with embedded XML
|
|
$pdf = new TCPDI('P', 'mm', 'A4', true, 'UTF-8');
|
|
dol_syslog("ZugferdPdfEmbedder: TCPDI object created", LOG_DEBUG);
|
|
|
|
// Disable header and footer
|
|
$pdf->setPrintHeader(false);
|
|
$pdf->setPrintFooter(false);
|
|
|
|
// Set PDF/A-3 mode for ZUGFeRD compliance (only if method exists - TCPDI may not have it)
|
|
if (method_exists($pdf, 'setPDFA')) {
|
|
$pdf->setPDFA(true);
|
|
}
|
|
|
|
// Set document information
|
|
$pdf->SetCreator('Dolibarr ExportZugferd');
|
|
$pdf->SetAuthor('Dolibarr ERP/CRM');
|
|
$pdf->SetTitle('ZUGFeRD Invoice');
|
|
$pdf->SetSubject('Electronic Invoice with embedded ZUGFeRD XML');
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: Setting source file: $pdfFile", LOG_DEBUG);
|
|
|
|
// Import existing PDF pages
|
|
$pageCount = $pdf->setSourceFile($pdfFile);
|
|
dol_syslog("ZugferdPdfEmbedder: Importing $pageCount pages", LOG_DEBUG);
|
|
|
|
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
|
|
dol_syslog("ZugferdPdfEmbedder: Importing page $pageNo", LOG_DEBUG);
|
|
$templateId = $pdf->importPage($pageNo);
|
|
$size = $pdf->getTemplateSize($templateId);
|
|
|
|
// TCPDI returns 'w' and 'h' keys, not 'width' and 'height'
|
|
$width = isset($size['w']) ? $size['w'] : (isset($size['width']) ? $size['width'] : 210);
|
|
$height = isset($size['h']) ? $size['h'] : (isset($size['height']) ? $size['height'] : 297);
|
|
$orientation = isset($size['orientation']) ? $size['orientation'] : ($width > $height ? 'L' : 'P');
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: Page $pageNo size: {$width}x{$height}, orientation: $orientation", LOG_DEBUG);
|
|
|
|
// Add page with same size as original
|
|
$pdf->AddPage($orientation, array($width, $height));
|
|
$pdf->useTemplate($templateId);
|
|
}
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: All pages imported", LOG_DEBUG);
|
|
|
|
// Determine the attachment filename based on profile
|
|
$attachmentName = $this->getAttachmentFilename($profile);
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: Adding embedded file for $attachmentName", LOG_DEBUG);
|
|
|
|
// Embed XML using the correct ZUGFeRD method: only in EmbeddedFiles name tree
|
|
// NOT as a FileAttachment annotation (which causes double listing)
|
|
$this->addEmbeddedFile($pdf, $xmlFile, $attachmentName);
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: Embedded file added", LOG_DEBUG);
|
|
|
|
// Create temporary file for output
|
|
$tempFile = $pdfFile . '.tmp';
|
|
|
|
// Output the modified PDF
|
|
$pdf->Output($tempFile, 'F');
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: Temp file created: $tempFile", LOG_DEBUG);
|
|
|
|
// Replace original with modified version
|
|
if (file_exists($tempFile)) {
|
|
if (filesize($tempFile) > 0) {
|
|
// Replace original
|
|
if (!rename($tempFile, $pdfFile)) {
|
|
$this->error = 'Could not replace original PDF file';
|
|
@unlink($tempFile);
|
|
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: Successfully embedded XML into PDF: " . $pdfFile, LOG_INFO);
|
|
return true;
|
|
} else {
|
|
$this->error = 'Generated PDF is empty';
|
|
@unlink($tempFile);
|
|
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
} else {
|
|
$this->error = 'Failed to create temporary PDF file';
|
|
dol_syslog("ZugferdPdfEmbedder: " . $this->error, LOG_ERR);
|
|
return false;
|
|
}
|
|
|
|
} catch (Exception $e) {
|
|
$this->error = 'PDF embedding failed: ' . $e->getMessage();
|
|
dol_syslog("ZugferdPdfEmbedder error: " . $e->getMessage(), LOG_ERR);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the standard attachment filename for the profile
|
|
*
|
|
* @param string $profile ZUGFeRD profile
|
|
* @return string Filename
|
|
*/
|
|
private function getAttachmentFilename($profile)
|
|
{
|
|
switch (strtoupper($profile)) {
|
|
case 'XRECHNUNG':
|
|
return 'xrechnung.xml';
|
|
case 'MINIMUM':
|
|
case 'BASIC':
|
|
case 'BASIC WL':
|
|
case 'EN16931':
|
|
case 'EXTENDED':
|
|
default:
|
|
return 'factur-x.xml';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Embed XML content directly into PDF (alternative method)
|
|
*
|
|
* @param string $pdfFile Path to the PDF file
|
|
* @param string $xmlContent XML content to embed
|
|
* @param string $xmlFilename Filename for the embedded XML (e.g., factur-x.xml)
|
|
* @param string $profile ZUGFeRD profile
|
|
* @return bool True on success, false on error
|
|
*/
|
|
public function embedXmlContentInPdf($pdfFile, $xmlContent, $xmlFilename = 'factur-x.xml', $profile = 'EN16931')
|
|
{
|
|
// Create temporary XML file
|
|
$tempXmlFile = sys_get_temp_dir() . '/' . $xmlFilename;
|
|
|
|
if (file_put_contents($tempXmlFile, $xmlContent) === false) {
|
|
$this->error = 'Could not create temporary XML file';
|
|
return false;
|
|
}
|
|
|
|
$result = $this->embedXmlInPdf($pdfFile, $tempXmlFile, $profile);
|
|
|
|
// Clean up temporary file
|
|
@unlink($tempXmlFile);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Check if embedding is possible
|
|
*
|
|
* @return bool True if libraries are available
|
|
*/
|
|
public static function isEmbeddingAvailable()
|
|
{
|
|
// Check for TCPDF
|
|
$tcpdfAvailable = class_exists('TCPDF') || file_exists(DOL_DOCUMENT_ROOT . '/includes/tecnickcom/tcpdf/tcpdf.php');
|
|
|
|
// Check for TCPDI
|
|
$tcpdiAvailable = class_exists('TCPDI') || file_exists(DOL_DOCUMENT_ROOT . '/includes/tcpdi/tcpdi.php');
|
|
|
|
return $tcpdfAvailable && $tcpdiAvailable;
|
|
}
|
|
|
|
/**
|
|
* Get ZUGFeRD conformance level string for XMP metadata
|
|
*
|
|
* @param string $profile Profile name
|
|
* @return string Conformance level
|
|
*/
|
|
private function getConformanceLevel($profile)
|
|
{
|
|
switch (strtoupper($profile)) {
|
|
case 'MINIMUM':
|
|
return 'MINIMUM';
|
|
case 'BASIC WL':
|
|
case 'BASICWL':
|
|
return 'BASIC WL';
|
|
case 'BASIC':
|
|
return 'BASIC';
|
|
case 'EN16931':
|
|
return 'EN 16931';
|
|
case 'EXTENDED':
|
|
return 'EXTENDED';
|
|
case 'XRECHNUNG':
|
|
return 'EN 16931';
|
|
default:
|
|
return 'EN 16931';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add embedded file to PDF without creating a FileAttachment annotation
|
|
* This ensures the file is only listed once in the EmbeddedFiles name tree
|
|
*
|
|
* @param TCPDI $pdf PDF object
|
|
* @param string $filepath Path to the file to embed
|
|
* @param string $filename Name for the embedded file
|
|
* @return void
|
|
*/
|
|
private function addEmbeddedFile($pdf, $filepath, $filename)
|
|
{
|
|
// Access TCPDF's internal embeddedfiles array directly
|
|
// This avoids creating a FileAttachment annotation which causes double listing
|
|
$reflection = new ReflectionClass($pdf);
|
|
|
|
// Get the 'n' property (object counter)
|
|
$nProp = $reflection->getProperty('n');
|
|
$nProp->setAccessible(true);
|
|
$n = $nProp->getValue($pdf);
|
|
|
|
// Get the embeddedfiles array
|
|
$efProp = $reflection->getProperty('embeddedfiles');
|
|
$efProp->setAccessible(true);
|
|
$embeddedfiles = $efProp->getValue($pdf);
|
|
|
|
// Add the file to the embeddedfiles array
|
|
$embeddedfiles[$filename] = array(
|
|
'f' => ++$n,
|
|
'n' => ++$n,
|
|
'file' => $filepath
|
|
);
|
|
|
|
// Update the object counter
|
|
$nProp->setValue($pdf, $n);
|
|
|
|
// Update the embeddedfiles array
|
|
$efProp->setValue($pdf, $embeddedfiles);
|
|
|
|
dol_syslog("ZugferdPdfEmbedder: Added $filename to embeddedfiles array", LOG_DEBUG);
|
|
}
|
|
}
|