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 code 102 est un code UNTDID 2379, pas un format PHP. Il signifie que la chaîne est en AAAAMMJJ. N'écrivez jamais format="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 /Data est 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:ConformanceLevel doit correspondre exactement au profil déclaré dans le XML CII (urn:factur-x.eu:1p0:minimumMINIMUM). 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 une pdfaExtension:schemas décrivant les propriétés fx: — 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.


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