Avertissement préliminaire. Cet article est pédagogique, pas une recette de production. Assembler un PDF/A-3 strictement conforme « from scratch » est un travail de bas niveau non trivial — profil ICC, marquage PDF/A, structure XMP, calcul d'offset xref — que les bibliothèques spécialisées résolvent pour vous. Vous trouverez ici une compréhension des rouages, pas un générateur prêt à l'emploi. Pour la production, allez directement à la version avec bibliothèque.
Une Factur-X est une facture PDF ordinaire en apparence, mais qui est en réalité un PDF/A-3 (ISO 19005-3) contenant un fichier XML embarqué conforme à la norme EN 16931 (syntaxe CII). C'est ce qu'on appelle le format hybride : l'humain lit le PDF, la machine parse le XML. La spécification officielle est publiée par le FNFE-MPE ; sa lecture directe est recommandée avant tout développement sérieux.
Décomposons les trois briques indépendantes à assembler.
1. La brique XML : le fichier CII conforme EN 16931
Pourquoi CII et pas UBL ?
Factur-X (côté français) et ZUGFeRD (côté allemand) utilisent la Cross-Industry Invoice (CII) de l'UN/CEFACT, pas l'UBL de l'OASIS. Les deux sont des syntaxes valides pour EN 16931, mais elles ne sont pas interchangeables.
Les namespaces réels
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
Ces URN ne sont pas des URL accessibles — ce sont des identificateurs stables définis par l'UN/CEFACT. Ne cherchez pas à les résoudre via HTTP.
Squelette MINIMUM (le profil le plus restrictif)
Le profil MINIMUM n'exige que les champs strictement obligatoires de la règle métier EN 16931. Il est utile pour les flux B2B simples ou pour commencer à apprendre.
<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<!-- === En-tête : profil et type de document === -->
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:factur-x.eu:1p0:minimum</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<!-- === Document : numéro, type, date === -->
<rsm:ExchangedDocument>
<ram:ID>FA-2026-00042</ram:ID>
<!-- 380 = facture commerciale (code UNTDID 1001) -->
<ram:TypeCode>380</ram:TypeCode>
<!-- Format de date : 102 = AAAAMMJJ -->
<ram:IssueDateTime>
<udt:DateTimeString format="102">20260627</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<!-- === Transaction commerciale === -->
<rsm:SupplyChainTradeTransaction>
<!-- Vendeur (BT-27 à BT-37) -->
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>Synapx SAS</ram:Name>
<ram:SpecifiedTaxRegistration>
<!-- BT-31 : numéro de TVA -->
<ram:ID schemeID="VA">FR00123456789</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<!-- Acheteur (BT-44) -->
<ram:BuyerTradeParty>
<ram:Name>Client ACME</ram:Name>
</ram:BuyerTradeParty>
</ram:ApplicableHeaderTradeAgreement>
<!-- Livraison : obligatoire même en MINIMUM -->
<ram:ApplicableHeaderTradeDelivery/>
<!-- Règlement : montants HT, TVA, TTC -->
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<!-- BT-109 : total HT -->
<ram:TaxBasisTotalAmount>1000.00</ram:TaxBasisTotalAmount>
<!-- BT-110 : total TVA -->
<ram:TaxTotalAmount currencyID="EUR">200.00</ram:TaxTotalAmount>
<!-- BT-112 : total TTC -->
<ram:GrandTotalAmount>1200.00</ram:GrandTotalAmount>
<!-- BT-115 : montant à payer -->
<ram:DuePayableAmount>1200.00</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>
Note sur le format de date
102. Le code102est un code UNTDID 2379, pas un format PHP. Il signifie que la chaîne est enAAAAMMJJ. N'écrivez jamaisformat="Y-m-d".
Validation du XML seul
Avant d'aller plus loin, validez le XML contre les schémas XSD fournis dans la spec FNFE-MPE et contre les règles métier Schematron (les XSD seuls ne couvrent pas toutes les contraintes EN 16931).
<?php
$xml = new DOMDocument();
$xml->load('factur-x.xml');
if (!$xml->schemaValidate('CrossIndustryInvoice_100pD22B.xsd')) {
foreach (libxml_get_errors() as $error) {
echo $error->message;
}
}
🔎 Pour valider votre XML directement en ligne avant d'aller plus loin : /sdk/FactureX/
2. La brique PDF/A-3 : pourquoi FPDF ne suffit pas
Ce que PDF/A-3 exige (ISO 19005-3)
Un PDF/A-3 n'est pas un simple PDF avec un XML dedans. La norme impose :
| Exigence | Ce que ça implique concrètement |
|---|---|
| Profil ICC embarqué | Un flux ColorSpace avec un profil ICC (sRGB, CMYK…) doit être inclus. |
| Bloc XMP de conformité | Les métadonnées pdfaid:part = 3 et pdfaid:conformance = B (ou U) doivent être présentes dans le flux XMP du catalogue. |
| Pas de chiffrement | Un PDF/A-3 chiffré est invalide. |
| Polices embarquées | Toutes les polices utilisées doivent être sous-settées dans le fichier. |
Marquage /Marked true |
Le catalogue doit déclarer MarkInfo avec Marked true. |
Fichiers attachés via /AF |
L'association du XML se fait via /AF (Associated Files), pas via une simple annotation. |
FPDF produit du PDF 1.x standard. Il ne gère ni le profil ICC, ni le bloc XMP pdfaid, ni le marquage /Marked, ni /AF. Il serait techniquement possible de patcher sa sortie binaire, mais c'est le chemin de la douleur.
Les voies réalistes
Option A — Post-traitement Ghostscript. Vous générez un PDF lisible (FPDF, TCPDF, mPDF…) puis vous le convertissez :
gs \
-dPDFA=3 \
-dBATCH \
-dNOPAUSE \
-sColorConversionStrategy=RGB \
-sDEVICE=pdfwrite \
-dPDFACompatibilityPolicy=1 \
-sOutputFile=output_pdfa3.pdf \
PDFA_def.ps \
input.pdf
Le fichier PDFA_def.ps est fourni avec Ghostscript et doit être adapté (profil ICC, titre). Cette approche fonctionne mais exige que Ghostscript soit installé et peut silencieusement « corriger » des éléments du PDF original. Voir Générer un PDF/A-3 en PHP pour une analyse approfondie.
Option B — Bibliothèque PHP dédiée. La voie raisonnable pour la production : /blog/generer-factur-x-php/.
Option C — Service externe. LibreOffice --headless, wkhtmltopdf avec post-traitement, ou une API tierce.
3. La brique embarquement : XML + métadonnées XMP
C'est ici que réside la spécificité Factur-X par rapport à un simple PDF/A-3. Deux éléments sont obligatoires.
3a. La structure d'embarquement dans le PDF
Un PDF est un graphe d'objets numérotés. Pour embarquer un fichier, vous avez besoin d'au moins trois objets supplémentaires.
L'objet FileSpec (décrit le fichier attaché) :
12 0 obj
<<
/Type /Filespec
/F (factur-x.xml)
/UF (factur-x.xml)
/Desc (Factur-X XML Invoice)
/AFRelationship /Data
/EF << /F 13 0 R /UF 13 0 R >>
>>
endobj
/AFRelationship /Dataest la valeur requise par la spec Factur-X (le XML est la donnée de la facture, pas un document alternatif).
L'objet EmbeddedFile (le contenu binaire du XML) :
13 0 obj
<<
/Type /EmbeddedFile
/Subtype /text#2Fxml
/Params <<
/Size 1842
/ModDate (D:20260627120000+02'00')
>>
/Length 1842
>>
stream
...contenu XML ici...
endstream
endobj
Déclaration dans le catalogue (objet racine du PDF) :
1 0 obj % Catalogue (objet racine)
<<
/Type /Catalog
/Pages 2 0 R
/Names <<
/EmbeddedFiles <<
/Names [(factur-x.xml) 12 0 R]
>>
>>
/AF [12 0 R] % Associated Files : clé spécifique PDF/A-3
>>
endobj
La clé /AF (Associated Files) est définie dans la norme PDF 2.0 et reprise par ISO 19005-3. Sans elle, le fichier est attaché au PDF mais pas associé — les validateurs Factur-X le rejetteront.
3b. Le bloc XMP Factur-X
Le catalogue contient un flux XMP (objet /Metadata). Ce flux doit contenir, en plus du bloc standard PDF/A, une extension de schéma déclarant les métadonnées Factur-X :
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<!-- Conformité PDF/A -->
<rdf:Description rdf:about=""
xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<pdfaid:part>3</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
</rdf:Description>
<!-- Valeurs Factur-X pour CE document -->
<rdf:Description rdf:about=""
xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>
<fx:DocumentType>INVOICE</fx:DocumentType>
<fx:Version>1.0</fx:Version>
<fx:ConformanceLevel>MINIMUM</fx:ConformanceLevel>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
Attention. La valeur de
fx:ConformanceLeveldoit correspondre exactement au profil déclaré dans le XML CII (urn:factur-x.eu:1p0:minimum→MINIMUM). Une incohérence entre les deux est l'une des erreurs les plus fréquentes lors de la validation. La déclaration complète inclut aussi unepdfaExtension:schemasdécrivant les propriétésfx:— voir la spec FNFE-MPE pour le bloc exhaustif.
3c. Injecter tout ça dans un PDF existant
Si vous partez d'un PDF déjà généré et voulez ajouter l'XML et les métadonnées sans ré-encoder l'intégralité du PDF, l'approche consiste à : lire le PDF comme un flux binaire, localiser la table xref et l'objet catalogue, ajouter les nouveaux objets à la fin, puis écrire une cross-reference table incrémentale et un nouveau trailer.
<?php
// Principe — ne pas utiliser tel quel en production
function appendObjectToPdf(string $pdfContent, string $newObjectData): string
{
$startxrefPos = strrpos($pdfContent, 'startxref');
$oldStartxref = (int) trim(substr($pdfContent, $startxrefPos + 9, 20));
preg_match_all('/^(\d+) 0 obj/m', $pdfContent, $matches);
$nextObjNum = max(array_map('intval', $matches[1])) + 1;
$newObjOffset = strlen($pdfContent);
$objContent = "$nextObjNum 0 obj\n$newObjectData\nendobj\n";
$xref = "xref\n$nextObjNum 1\n";
$xref .= str_pad($newObjOffset, 10, '0', STR_PAD_LEFT) . " 00000 n \n";
$trailer = "trailer\n<< /Size " . ($nextObjNum + 1) . " /Prev $oldStartxref >>\n";
$trailer .= "startxref\n" . ($newObjOffset + strlen($objContent)) . "\n%%EOF\n";
return $pdfContent . $objContent . $xref . $trailer;
}
Ce code illustre la mécanique, pas une implémentation sûre. En pratique, modifier un PDF binaire sans parser complet risque de casser la table d'objets — surtout si le PDF source utilise la compression xref stream (PDF 1.5+).
4. Pourquoi c'est difficile en pratique
| Difficulté | Pourquoi c'est piégeux |
|---|---|
| PDF/A-3 natif | Très peu de libs PHP génèrent du vrai PDF/A-3 sans post-traitement externe. |
| Profil ICC | L'intégrer correctement requiert de manipuler des streams binaires. |
| XMP exact | Une seule balise mal fermée ou un namespace manquant invalide la conformité. |
| Table xref incrémentale | Décaler d'un octet casse tout ; aucune erreur visible à l'œil. |
| Validation Schematron | La XSD seule ne valide pas les règles métier EN 16931 (ex. cohérence TVA). |
Conclusion
Vous savez maintenant ce qu'une bibliothèque comme atgp/factur-x fait à votre place : conversion PDF/A-3, injection binaire des objets d'embarquement, génération du bloc XMP, cohérence entre le profil CII et les métadonnées. Ce n'est pas de la magie — c'est du travail de bas niveau que vous venez de parcourir.
Pour la production, utilisez une bibliothèque éprouvée et validée. Le « sans bibliothèque » vous a donné le vocabulaire pour lire un ticket d'erreur de validateur, comprendre ce qu'un outil fait (ou ne fait pas), et déboguer intelligemment.
- Version production avec librairie : /blog/generer-factur-x-php/
- Générer le conteneur : /blog/generer-pdf-a-3-php/
- Valider votre résultat : /sdk/FactureX/
- Comprendre la norme : /blog/factur-x-comprendre-valider/
FAQ
Le profil MINIMUM est-il accepté par l'administration fiscale française ? Factur-X MINIMUM est valide au sens d'EN 16931. Pour la e-facturation obligatoire, vérifiez les exigences de la plateforme agréée que vous utilisez — certaines exigent au minimum le profil BASIC WL.
Puis-je valider le PDF/A-3 sans outil commercial ?
Oui : veraPDF est le validateur de référence open source pour PDF/A.
ZUGFeRD et Factur-X utilisent-ils le même XML ?
Les deux utilisent la syntaxe CII et les mêmes namespaces UN/CEFACT. L'identifiant de profil dans <ram:ID> diffère, ainsi que certains détails XMP. Les fichiers ne sont pas directement interchangeables sans ajustement.
La norme PDF/A-3 autorise-t-elle n'importe quel format de fichier embarqué ? Oui, contrairement à PDF/A-2. PDF/A-3 (ISO 19005-3) lève cette restriction — ce qui rend Factur-X possible.
Pour aller plus loin
- Spec officielle Factur-X — FNFE-MPE, fnfe-mpe.org (document normatif, gratuit)
- ISO 19005-3 — norme PDF/A-3 (accès via ISO/AFNOR)
- veraPDF — validateur PDF/A open source
- UN/CEFACT CII — schémas XSD et documentation (unece.org)
- Générer un PDF/A-3 en PHP
- Factur-X : comprendre et valider
- Générer une Factur-X avec une librairie