Module 2 - Générateur PDF/A-3 et XMP Factur-X
Ce module prépare le PDF/A-3 et les métadonnées XMP Factur-X attendues par FNFE/veraPDF.
On part d’un XML déjà valide, puis on le joint au PDF et on déclare la relation PDF ↔ XML via XMP. Ce lien est indispensable pour que les validateurs reconnaissent un Factur-X conforme.
Objectif : générer un PDF/A-3 conforme qui embarque le XML Factur-X avec /AFRelationship /Alternative.
Objectif
Générer un PDF/A-3 qui embarque le XML Factur-X et expose les champs fx:* requis.
Étape 1 - Créer le générateur PDF/A-3
On ajoute une classe dédiée qui orchestre la génération du PDF et l’attachement du XML.
final class FacturXPdfGenerator
{
public function generatePdfA3WithAttachment(
string $facturxXml,
?string $bodyHtml = null,
string $filename = 'factur-x.xml',
string $conformanceLevel = 'BASIC',
string $facturxVersion = '1.0',
): string {
// On verra les étapes juste après.
}
}
Pourquoi PDF/A-3 + XMP ?
Le PDF est l’artefact lisible humainement, l’XML Factur-X est la charge utile machine.
Le format Factur-X impose un PDF/A-3 avec une pièce jointe XML déclarée et des métadonnées XMP (fx:*) pour que les plateformes puissent relier le PDF et le XML.
Étape 2 - Forcer /AFRelationship /Alternative
TCPDF ne pose pas toujours ce champ, pourtant exigé pour Factur-X. On le force via un override ciblé.
class FacturXTcpdf extends \TCPDF
{
protected function _putEmbeddedFiles(): void
{
if ($this->pdfa_mode && $this->pdfa_version != 3) {
return;
}
reset($this->embeddedfiles);
foreach ($this->embeddedfiles as $filename => $filedata) {
// ... (lecture du contenu)
$out = $this->_getobj($filedata['f'])."\n";
$out .= '<</Type /Filespec /F '.$this->_datastring($filename, $filedata['f']);
$out .= ' /UF '.$this->_datastring($filename, $filedata['f']);
$out .= ' /AFRelationship /Alternative';
$out .= ' /EF <</F '.$filedata['n'].' 0 R>> >>';
// ... (stream EmbeddedFile)
}
}
}
/AFRelationship /Alternative indique que le fichier attaché est l’alternative structurée du contenu PDF. Sans lui, les validateurs rejettent souvent le PDF/A-3.
Étape 3 - Générer le PDF et joindre l’XML
On construit le PDF, on ajoute le HTML, puis on joint l’XML Factur-X.
public function generatePdfA3WithAttachment(
string $facturxXml,
?string $bodyHtml = null,
string $filename = 'factur-x.xml',
string $conformanceLevel = 'BASIC',
string $facturxVersion = '1.0',
): string {
$conformanceLevel = strtoupper($conformanceLevel);
$pdf = new FacturXTcpdf(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false, 3);
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->SetMargins(15, 15, 15);
$pdf->SetAutoPageBreak(true, 15);
$pdf->AddPage();
$pdf->writeHTML($bodyHtml ?? '<h1>Factur-X</h1><p>XML joint.</p>', true, false, true, false, '');
$pdf->EmbedFileFromString($filename, $facturxXml);
$this->addFacturXXmpMetadata($pdf, $filename, $conformanceLevel, $facturxVersion);
return $pdf->Output('', 'S');
}
Étape 4 - Ajouter les métadonnées XMP
Les champs XMP relient le PDF à l’XML et donnent le niveau de conformité.
private function addFacturXXmpMetadata(FacturXTcpdf $pdf, string $filename, string $conformanceLevel, string $facturxVersion): void
{
// Extension schema fx
$pdf->setExtraXMPPdfaextension(<<<XML
<rdf:li rdf:parseType="Resource">
<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
<!-- propriétés fx:DocumentType, fx:DocumentFileName, fx:Version, fx:ConformanceLevel -->
</rdf:li>
XML);
// Valeurs fx
$pdf->setExtraXMPRDF(<<<XML
<rdf:Description rdf:about="" xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
<fx:DocumentType>INVOICE</fx:DocumentType>
<fx:DocumentFileName>{$filename}</fx:DocumentFileName>
<fx:Version>{$facturxVersion}</fx:Version>
<fx:ConformanceLevel>{$conformanceLevel}</fx:ConformanceLevel>
</rdf:Description>
XML);
}
Champs XMP et rôle
fx:DocumentType: type de document (INVOICE).fx:DocumentFileName: nom du XML attaché pour le lien PDF ↔ XML.fx:Version: version Factur-X (1.0 ici).fx:ConformanceLevel: profil de conformité (ex. BASIC, EXTENDED).
Check-list PDF/A-3
/AFRelationship /Alternativeprésent sur l’attachement.fx:ConformanceLevel: valeur cohérente avec le profil choisi. Valeurs acceptées :MINIMUM,BASIC WL,BASIC,EXTENDED.- PDF version 1.7, police de base (helvetica) ou polices embarquées si besoin.
- Pas de post-traitement Ghostscript dans cette stack (TCPDF suffit ici).
Passe au Module 3 pour ajouter le lecteur et les tests.
Fichier complet à copier (src/Service/FacturX/FacturXPdfGenerator.php)
<?php
declare(strict_types=1);
namespace App\Service\FacturX;
use TCPDF;
/**
* TCPDF dérivé pour forcer AFRelationship=Alternative (Factur-X) sur l’embedded file.
*/
class FacturXTcpdf extends \TCPDF
{
protected function _putEmbeddedFiles(): void
{
if ($this->pdfa_mode && $this->pdfa_version != 3) {
return;
}
reset($this->embeddedfiles);
foreach ($this->embeddedfiles as $filename => $filedata) {
$data = false;
if (isset($filedata['file']) && !empty($filedata['file'])) {
$data = $this->getCachedFileContents($filedata['file']);
} elseif ($filedata['content'] && !empty($filedata['content'])) {
$data = $filedata['content'];
}
if ($data !== false) {
$rawsize = \strlen($data);
if ($rawsize > 0) {
$this->efnames[$filename] = $filedata['f'].' 0 R';
$out = $this->_getobj($filedata['f'])."\n";
$out .= '<</Type /Filespec /F '.$this->_datastring($filename, $filedata['f']);
$out .= ' /UF '.$this->_datastring($filename, $filedata['f']);
$out .= ' /AFRelationship /Alternative';
$out .= ' /EF <</F '.$filedata['n'].' 0 R>> >>';
$out .= "\n".'endobj';
$this->_out($out);
$filter = '';
if ($this->compress) {
$data = gzcompress($data);
$filter .= ' /Filter /FlateDecode';
}
if ($this->pdfa_version == 3) {
$filter .= ' /Subtype /text#2Fxml';
}
$stream = $this->_getrawstream($data, $filedata['n']);
$out = $this->_getobj($filedata['n'])."\n";
$out .= '<< /Type /EmbeddedFile'.$filter.' /Length '.\strlen($stream).' /Params <</Size '.$rawsize.'>> >>';
$out .= ' stream'."\n".$stream."\n".'endstream';
$out .= "\n".'endobj';
$this->_out($out);
}
}
}
}
}
class FacturXPdfGenerator
{
/**
* Génère un PDF/A-3 avec un XML Factur-X embarqué.
*
* @param string $facturxXml contenu XML Factur-X (ZUGFeRD v2)
* @param string|null $bodyHtml contenu HTML simple à rendre dans le PDF
* @param string $filename Nom du fichier attaché dans le PDF (ex: factur-x.xml).
*
* @return string contenu binaire du PDF
*/
public function generatePdfA3WithAttachment(
string $facturxXml,
?string $bodyHtml = null,
string $filename = 'factur-x.xml',
string $conformanceLevel = 'BASIC',
string $facturxVersion = '1.0',
): string {
$conformanceLevel = strtoupper($conformanceLevel);
// pdfa=3 pour autoriser les pièces jointes (Factur-X)
$pdf = new FacturXTcpdf(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false, 3);
$pdf->SetCreator('FacturX Demo');
$pdf->SetAuthor('FacturX Demo');
$pdf->SetTitle('Factur-X PDF/A-3');
$pdf->SetSubject('Factur-X invoice');
$pdf->SetKeywords('Factur-X;ZUGFeRD;Invoice');
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
// PDF/A-3b : version 1.7 + balises d’archivage.
$pdf->setPdfVersion('1.7');
$pdf->SetMargins(15, 15, 15);
$pdf->SetAutoPageBreak(true, 15);
$pdf->SetFont('helvetica', '', 10);
$pdf->AddPage();
$pdf->writeHTML($bodyHtml ?? '<h1>Factur-X</h1><p>XML joint en pièce attachée.</p>', true, false, true, false, '');
// Attachement du XML avec AFRelationship=Alternative (voir override _putEmbeddedFiles()).
$pdf->EmbedFileFromString($filename, $facturxXml);
$this->addFacturXXmpMetadata($pdf, $filename, $conformanceLevel, $facturxVersion);
return $pdf->Output('', 'S'); // S = return as string
}
/**
* Ajoute les métadonnées XMP requises par Factur-X (DocumentType, Version, etc.).
*/
private function addFacturXXmpMetadata(FacturXTcpdf $pdf, string $filename, string $conformanceLevel, string $facturxVersion): void
{
$pdf->setExtraXMPPdfaextension(
<<<XML
\t\t\t\t\t<rdf:li rdf:parseType="Resource">
\t\t\t\t\t\t<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
\t\t\t\t\t\t<pdfaSchema:prefix>fx</pdfaSchema:prefix>
\t\t\t\t\t\t<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
\t\t\t\t\t\t<pdfaSchema:property>
\t\t\t\t\t\t\t<rdf:Seq>
\t\t\t\t\t\t\t\t<rdf:li rdf:parseType="Resource">
\t\t\t\t\t\t\t\t\t<pdfaProperty:name>DocumentType</pdfaProperty:name>
\t\t\t\t\t\t\t\t\t<pdfaProperty:valueType>Text</pdfaProperty:valueType>
\t\t\t\t\t\t\t\t\t<pdfaProperty:category>external</pdfaProperty:category>
\t\t\t\t\t\t\t\t\t<pdfaProperty:description>Type de document Factur-X</pdfaProperty:description>
\t\t\t\t\t\t\t\t</rdf:li>
\t\t\t\t\t\t\t\t<rdf:li rdf:parseType="Resource">
\t\t\t\t\t\t\t\t\t<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
\t\t\t\t\t\t\t\t\t<pdfaProperty:valueType>Text</pdfaProperty:valueType>
\t\t\t\t\t\t\t\t\t<pdfaProperty:category>external</pdfaProperty:category>
\t\t\t\t\t\t\t\t\t<pdfaProperty:description>Nom du fichier XML embarqué</pdfaProperty:description>
\t\t\t\t\t\t\t\t</rdf:li>
\t\t\t\t\t\t\t\t<rdf:li rdf:parseType="Resource">
\t\t\t\t\t\t\t\t\t<pdfaProperty:name>Version</pdfaProperty:name>
\t\t\t\t\t\t\t\t\t<pdfaProperty:valueType>Text</pdfaProperty:valueType>
\t\t\t\t\t\t\t\t\t<pdfaProperty:category>external</pdfaProperty:category>
\t\t\t\t\t\t\t\t\t<pdfaProperty:description>Version Factur-X / ZUGFeRD</pdfaProperty:description>
\t\t\t\t\t\t\t\t</rdf:li>
\t\t\t\t\t\t\t\t<rdf:li rdf:parseType="Resource">
\t\t\t\t\t\t\t\t\t<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
\t\t\t\t\t\t\t\t\t<pdfaProperty:valueType>Text</pdfaProperty:valueType>
\t\t\t\t\t\t\t\t\t<pdfaProperty:category>external</pdfaProperty:category>
\t\t\t\t\t\t\t\t\t<pdfaProperty:description>Niveau de conformité (MINIMUM, BASIC, EXTENDED, ...)</pdfaProperty:description>
\t\t\t\t\t\t\t\t</rdf:li>
\t\t\t\t\t\t\t</rdf:Seq>
\t\t\t\t\t\t</pdfaSchema:property>
\t\t\t\t\t</rdf:li>
XML
);
$pdf->setExtraXMPRDF(
<<<XML
<rdf:Description rdf:about=""
xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
<fx:DocumentType>INVOICE</fx:DocumentType>
<fx:DocumentFileName>{$filename}</fx:DocumentFileName>
<fx:Version>{$facturxVersion}</fx:Version>
<fx:ConformanceLevel>{$conformanceLevel}</fx:ConformanceLevel>
</rdf:Description>
XML
);
}
}