Recevoir un PDF Factur-X, c’est recevoir deux documents en un : un PDF lisible par l’humain et un fichier XML structuré lisible par la machine. La valeur de Factur-X pour l’automatisation comptable réside entièrement dans ce XML — c’est lui qui permet d’intégrer la facture dans un ERP sans ressaisie manuelle, de rapprocher automatiquement facture et bon de commande, et d’archiver les données dans un format pérenne.
Mais le XML n’est pas simplement “à côté” du PDF. Il est embarqué à l’intérieur du fichier PDF, selon les règles de la norme PDF/A-3. L’extraire nécessite de comprendre comment les fichiers associés fonctionnent dans un PDF, et de choisir le bon outil selon votre langage et votre contexte.
Cet article couvre l’intégralité du processus : de l’extraction brute du XML à son parsing, en passant par la validation et l’intégration dans un pipeline automatisé.
Pour le contexte général de la réforme et du pipeline de validation, voir Facturation électronique 2026 : le guide technique complet. Pour la structure du conteneur PDF/A-3 lui-même, voir PDF/A-3 pour Factur-X : checklist de conformité.
Comment le XML est embarqué dans le PDF
Un fichier Factur-X est un PDF/A-3 (ISO 19005-3) qui contient un fichier XML CII en tant que fichier associé (embedded file). Ce n’est pas une annotation, pas un commentaire, pas un champ de formulaire — c’est une entrée dans le name tree EmbeddedFiles du catalogue PDF.
La structure interne du PDF, vue depuis le catalogue :
Catalog
└── Names
└── EmbeddedFiles (name tree)
└── "factur-x.xml"
└── Filespec
├── /F (factur-x.xml)
├── /UF (factur-x.xml)
├── /AFRelationship /Alternative
├── /Desc (Factur-X XML invoice)
└── /EF
└── /F → stream (contenu XML brut)
Trois éléments sont significatifs pour l’extraction :
Le nom du fichier identifie quel standard a produit le PDF :
| Standard | Nom du fichier embarqué |
|---|---|
| Factur-X 1.x | factur-x.xml |
| ZUGFeRD 2.1+ | factur-x.xml ou zugferd-invoice.xml |
| ZUGFeRD 2.0 | zugferd-invoice.xml |
| ZUGFeRD 1.0 | ZUGFeRD-invoice.xml |
L’attribut /AFRelationship indique la relation entre le fichier embarqué et le document PDF :
/Alternative— le fichier est une représentation alternative du contenu (Factur-X 1.x / ZUGFeRD 2.1+, ainsi que ZUGFeRD 1.0 et 2.0)/Source— utilisé dans certaines implémentations comme source du document
Le stream dans /EF/F contient le XML brut, encodé en UTF-8. C’est ce flux qu’il faut extraire.
Un PDF peut contenir plusieurs fichiers embarqués (logo, annexes, certificats). Le fichier Factur-X est identifiable par son nom (factur-x.xml ou variantes ZUGFeRD) et son type MIME (text/xml ou application/xml).
Extraction par outils en ligne de commande
Pour une extraction ponctuelle — vérifier le contenu d’un PDF reçu, diagnostiquer un problème, faire un test rapide — les outils CLI sont la méthode la plus directe.
pdfdetach (poppler-utils)
pdfdetach fait partie du paquet poppler-utils, disponible sur la plupart des distributions Linux et via Homebrew sur macOS.
# Lister les fichiers embarqués dans un PDF
pdfdetach -list facture.pdf
# Sortie typique :
# 1 embedded files
# 1: factur-x.xml
# Extraire tous les fichiers embarqués dans le répertoire courant
pdfdetach -saveall facture.pdf
# Extraire un fichier spécifique par nom
pdfdetach -savefile factur-x.xml facture.pdf
# Extraire un fichier spécifique par index (1-based)
pdfdetach -save 1 facture.pdf
Installation :
# Debian / Ubuntu
sudo apt install poppler-utils
# macOS (Homebrew)
brew install poppler
# Alpine (Docker)
apk add poppler-utils
mutool (MuPDF)
mutool fait partie de MuPDF, un moteur PDF alternatif à Poppler. Sa commande extract récupère les fichiers embarqués.
# Lister les objets du PDF (filtrer les embedded files)
mutool info facture.pdf
# Extraire tous les fichiers embarqués
mutool extract facture.pdf
# Les fichiers sont nommés d'après leur identifiant d'objet PDF
# Ex: facture-0042.xml — renommer si nécessaire
Installation :
# Debian / Ubuntu
sudo apt install mupdf-tools
# macOS (Homebrew)
brew install mupdf-tools
Vérification rapide après extraction
Une fois le fichier extrait, vérifier qu’il s’agit bien d’un XML CII Factur-X :
# Vérifier que le fichier est du XML bien formé
xmllint --noout factur-x.xml && echo "XML bien formé"
# Vérifier l'élément racine (doit être CrossIndustryInvoice)
head -5 factur-x.xml
# Attendu : <rsm:CrossIndustryInvoice xmlns:rsm=...
# Afficher le numéro de facture (BT-1)
xmllint --xpath \
'//*[local-name()="ExchangedDocument"]/*[local-name()="ID"]/text()' \
factur-x.xml
Extraction en Python
Python est le langage le plus outillé pour manipuler les PDF Factur-X. Deux bibliothèques couvrent les besoins : pypdf pour l’extraction bas niveau, et factur-x pour une extraction de plus haut niveau avec détection automatique.
Avec pypdf
pypdf (successeur de PyPDF2) donne accès aux fichiers embarqués via la propriété attachments du PdfReader.
from pypdf import PdfReader
from pathlib import Path
def extract_facturx_xml(pdf_path: str) -> bytes | None:
"""Extrait le XML Factur-X/ZUGFeRD d'un PDF.
Retourne le contenu XML brut (bytes) ou None si aucun
fichier Factur-X n'est trouvé.
"""
known_names = {
"factur-x.xml",
"zugferd-invoice.xml",
"ZUGFeRD-invoice.xml",
}
reader = PdfReader(pdf_path)
# reader.attachments est un dict {nom: [bytes, ...]}
for name, data_list in reader.attachments.items():
if name in known_names:
if not data_list or len(data_list[0]) == 0:
raise ValueError(
f"Fichier '{name}' présent mais contenu vide (stream 0 bytes)"
)
return data_list[0]
return None
# Utilisation
xml_bytes = extract_facturx_xml("facture-recue.pdf")
if xml_bytes is None:
print("Ce PDF ne contient pas de fichier Factur-X embarqué.")
else:
# Sauvegarder le XML extrait
Path("factur-x.xml").write_bytes(xml_bytes)
print(f"XML extrait : {len(xml_bytes)} bytes")
Installation :
pip install pypdf
Avec la bibliothèque factur-x
La bibliothèque factur-x (maintenue par Akretion) fournit une fonction dédiée get_facturx_xml_from_pdf qui gère automatiquement la détection du nom de fichier et le décodage.
from facturx import get_facturx_xml_from_pdf
def extract_with_facturx_lib(pdf_path: str) -> tuple[bytes, str]:
"""Extrait le XML et détecte le profil Factur-X.
Retourne un tuple (xml_bytes, flavor) où flavor est
'factur-x', 'zugferd' ou similaire.
"""
with open(pdf_path, "rb") as f:
xml_bytes, flavor = get_facturx_xml_from_pdf(f)
return xml_bytes, flavor
# Utilisation
xml_bytes, flavor = extract_with_facturx_lib("facture-recue.pdf")
print(f"Format détecté : {flavor}")
print(f"XML : {len(xml_bytes)} bytes")
Installation :
pip install factur-x
L’avantage de cette bibliothèque est qu’elle gère les variantes de nommage (Factur-X, ZUGFeRD 1.0, ZUGFeRD 2.x) et renvoie le “flavor” détecté. L’inconvénient est qu’elle tire des dépendances supplémentaires (lxml, PyPDF2).
Extraction en Java
Dans l’écosystème Java, deux bibliothèques sont établies pour l’extraction de fichiers embarqués dans les PDF : Apache PDFBox (usage générique) et Mustangproject (spécifiquement conçu pour Factur-X/ZUGFeRD).
Avec Apache PDFBox
PDFBox donne accès aux embedded files via le catalogue du document.
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
public class FacturXExtractor {
private static final Set<String> KNOWN_NAMES = Set.of(
"factur-x.xml",
"zugferd-invoice.xml",
"ZUGFeRD-invoice.xml"
);
/**
* Extrait le XML Factur-X/ZUGFeRD d'un PDF.
*
* @param pdfPath chemin vers le fichier PDF
* @return contenu XML en bytes, ou null si absent
*/
public static byte[] extractFacturXXml(String pdfPath) throws IOException {
try (PDDocument document = PDDocument.load(new File(pdfPath))) {
PDDocumentNameDictionary names = document.getDocumentCatalog()
.getNames();
if (names == null) {
return null;
}
PDEmbeddedFilesNameTreeNode embeddedFiles = names
.getEmbeddedFiles();
if (embeddedFiles == null) {
return null;
}
Map<String, PDComplexFileSpecification> entries =
embeddedFiles.getNames();
if (entries == null) {
return null;
}
for (Map.Entry<String, PDComplexFileSpecification> entry
: entries.entrySet()) {
String name = entry.getKey();
if (KNOWN_NAMES.contains(name)) {
PDEmbeddedFile embeddedFile = entry.getValue()
.getEmbeddedFile();
if (embeddedFile == null) {
throw new IOException(
"Fichier '" + name + "' déclaré mais stream absent"
);
}
byte[] data = embeddedFile.toByteArray();
if (data.length == 0) {
throw new IOException(
"Fichier '" + name + "' présent mais vide (0 bytes)"
);
}
return data;
}
}
}
return null;
}
public static void main(String[] args) throws IOException {
if (args.length < 1) {
System.err.println("Usage: FacturXExtractor <fichier.pdf>");
System.exit(1);
}
byte[] xml = extractFacturXXml(args[0]);
if (xml == null) {
System.err.println("Aucun fichier Factur-X trouvé dans le PDF.");
System.exit(1);
}
Path output = Path.of("factur-x.xml");
Files.write(output, xml);
System.out.printf("XML extrait : %d bytes → %s%n",
xml.length, output);
}
}
Dépendance Maven :
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.4</version>
</dependency>
Avec Mustangproject
Mustangproject est une bibliothèque Java spécifiquement conçue pour ZUGFeRD et Factur-X. Elle fournit un accès de plus haut niveau.
import org.mustangproject.ZUGFeRD.ZUGFeRDImporter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class MustangExtractor {
public static void main(String[] args) throws IOException {
if (args.length < 1) {
System.err.println("Usage: MustangExtractor <fichier.pdf>");
System.exit(1);
}
ZUGFeRDImporter importer = new ZUGFeRDImporter(args[0]);
// Extraction du XML brut
String xml = importer.getUTF8();
if (xml == null || xml.isEmpty()) {
System.err.println("Aucun XML Factur-X/ZUGFeRD trouvé.");
System.exit(1);
}
// Métadonnées détectées
System.out.printf("Version détectée : %s%n", importer.getVersion());
System.out.printf("Profil : %s%n", importer.getProfile());
// Sauvegarde
Path output = Path.of("factur-x.xml");
Files.writeString(output, xml);
System.out.printf("XML extrait : %d caractères → %s%n",
xml.length(), output);
}
}
Dépendance Maven :
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>library</artifactId>
<version>2.15.2</version>
</dependency>
Mustangproject détecte automatiquement la version (ZUGFeRD 1.0, 2.x, Factur-X) et le profil. C’est la bibliothèque Java recommandée pour les projets spécifiquement orientés facturation électronique.
Extraction en PHP
Pour les applications web en PHP (Dolibarr, WooCommerce, Laravel, Symfony), deux bibliothèques permettent d’accéder aux fichiers embarqués d’un PDF.
Avec Smalot/PdfParser
PdfParser parse la structure interne du PDF et donne accès aux objets, y compris les embedded files.
<?php
declare(strict_types=1);
require_once 'vendor/autoload.php';
use Smalot\PdfParser\Parser;
/**
* Extrait le XML Factur-X/ZUGFeRD d'un fichier PDF.
*
* @param string $pdfPath Chemin vers le fichier PDF
* @return string|null Contenu XML ou null si absent
* @throws RuntimeException Si le fichier est déclaré mais vide
*/
function extractFacturXXml(string $pdfPath): ?string
{
$knownNames = [
'factur-x.xml',
'zugferd-invoice.xml',
'ZUGFeRD-invoice.xml',
];
$parser = new Parser();
$pdf = $parser->parseFile($pdfPath);
$filespecs = $pdf->getObjectsByType('Filespec');
foreach ($filespecs as $filespec) {
$details = $filespec->getDetails();
$filename = $details['F'] ?? $details['UF'] ?? null;
if ($filename === null || !in_array($filename, $knownNames, true)) {
continue;
}
// Récupérer le stream du fichier embarqué
$embeddedFile = $filespec->get('EF');
if ($embeddedFile === null) {
throw new RuntimeException(
"Fichier '{$filename}' déclaré mais aucun stream EF"
);
}
$stream = $embeddedFile->get('F');
if ($stream === null) {
throw new RuntimeException(
"Fichier '{$filename}' : stream /EF/F absent"
);
}
$content = $stream->getContent();
if (strlen($content) === 0) {
throw new RuntimeException(
"Fichier '{$filename}' présent mais contenu vide"
);
}
return $content;
}
return null;
}
// Utilisation
$xml = extractFacturXXml('facture-recue.pdf');
if ($xml === null) {
echo "Ce PDF ne contient pas de fichier Factur-X.\n";
exit(1);
}
file_put_contents('factur-x.xml', $xml);
printf("XML extrait : %d bytes\n", strlen($xml));
Installation :
composer require smalot/pdfparser
Limitation : PdfParser est un parser PHP pur. Il gère la majorité des PDF mais peut rencontrer des difficultés avec certains PDF fortement compressés ou utilisant des flux de cross-reference. Tester avec vos PDF réels avant de déployer en production.
Alternative : extraction via CLI depuis PHP
Si PdfParser ne suffit pas pour certains PDF, une approche fiable consiste à appeler pdfdetach depuis PHP :
<?php
declare(strict_types=1);
/**
* Extrait le XML Factur-X via pdfdetach (poppler-utils).
* Nécessite poppler-utils installé sur le serveur.
*/
function extractViaCliTool(string $pdfPath): ?string
{
$tempDir = sys_get_temp_dir() . '/facturx_' . uniqid('', true);
mkdir($tempDir, 0700, true);
try {
$escapedPdf = escapeshellarg($pdfPath);
$escapedDir = escapeshellarg($tempDir);
// Extraire tous les fichiers embarqués
$command = "pdfdetach -saveall -o {$escapedDir} {$escapedPdf} 2>&1";
exec($command, $output, $returnCode);
if ($returnCode !== 0) {
return null;
}
// Chercher le fichier Factur-X parmi les extraits
$knownNames = [
'factur-x.xml',
'zugferd-invoice.xml',
'ZUGFeRD-invoice.xml',
];
foreach ($knownNames as $name) {
$filePath = $tempDir . '/' . $name;
if (file_exists($filePath)) {
$content = file_get_contents($filePath);
if ($content === false || strlen($content) === 0) {
throw new RuntimeException(
"Fichier '{$name}' extrait mais vide"
);
}
return $content;
}
}
return null;
} finally {
// Nettoyage systématique du répertoire temporaire
array_map('unlink', glob($tempDir . '/*') ?: []);
rmdir($tempDir);
}
}
Parser le XML extrait
Une fois le XML extrait, l’étape suivante est de lire les données métier. Le XML Factur-X est un document CII (Cross-Industry Invoice) avec des namespaces spécifiques.
Les namespaces CII
Chaque élément du XML appartient à un namespace identifié par un préfixe :
| Préfixe | Rôle |
|---|---|
rsm | Racine du document |
ram | Entités métier (vendeur, acheteur, lignes, totaux) |
qdt | Types qualifiés |
udt | Types de base (dates, montants) |
Les URIs de namespace correspondantes :
rsm → urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100
ram → urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100
qdt → urn:un:unece:uncefact:data:standard:QualifiedDataType:100
udt → urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100
L’élément racine est toujours <rsm:CrossIndustryInvoice>. Un XML qui ne commence pas par cet élément (dans ce namespace) n’est pas un document CII valide.
Extraire les Business Terms clés
Les Business Terms (BT) les plus utiles pour l’automatisation comptable, avec leur XPath CII :
| BT | Description | XPath CII |
|---|---|---|
| BT-1 | Numéro de facture | …/ram:ID |
| BT-2 | Date d’émission | …/ram:IssueDateTime/udt:DateTimeString |
| BT-3 | Type de facture | …/ram:TypeCode |
| BT-5 | Code devise | …/ram:InvoiceCurrencyCode |
| BT-27 | Nom du vendeur | …/ram:SellerTradeParty/ram:Name |
| BT-44 | Nom de l’acheteur | …/ram:BuyerTradeParty/ram:Name |
| BT-112 | Total TTC | …/ram:GrandTotalAmount |
XPaths complets depuis la racine rsm:CrossIndustryInvoice :
BT-1 → rsm:ExchangedDocument/ram:ID
BT-2 → rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString
BT-3 → rsm:ExchangedDocument/ram:TypeCode
BT-5 → …/ram:ApplicableHeaderTradeSettlement/ram:InvoiceCurrencyCode
BT-27 → …/ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:Name
BT-44 → …/ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty/ram:Name
BT-112 → …/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount
Pour la cartographie complète des champs obligatoires, voir Champs obligatoires EN16931 : cartographie et mapping ERP.
Détecter le profil Factur-X
Le profil est déclaré dans l’élément GuidelineSpecifiedDocumentContextParameter. Sa valeur est une URN qui identifie le profil exact :
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
Les URN par profil Factur-X 1.x :
| Profil | URN GuidelineID |
|---|---|
| MINIMUM | urn:factur-x.eu:1p0:minimum |
| BASIC WL | urn:factur-x.eu:1p0:basicwl |
| BASIC | voir note ci-dessous |
| EN16931 | urn:cen.eu:en16931:2017 |
| EXTENDED | voir note ci-dessous |
Les URNs complètes pour BASIC et EXTENDED incluent le préfixe CEN :
- BASIC :
urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic - EXTENDED :
urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended
Pour ZUGFeRD 2.x, les URN utilisent le préfixe urn:zugferd.de:2p1: ou urn:zugferd.de:2p0: selon la version.
Exemple complet de parsing en Python
from lxml import etree
# Namespaces CII
NS = {
"rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
"ram": "urn:un:unece:uncefact:data:standard:"
"ReusableAggregateBusinessInformationEntity:100",
"qdt": "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
"udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
}
# Mapping profil : suffixe URN → nom lisible
PROFILE_MAP = {
"minimum": "MINIMUM",
"basicwl": "BASIC WL",
"basic": "BASIC",
"en16931": "EN 16931",
"extended": "EXTENDED",
}
def text(root: etree._Element, xpath: str) -> str | None:
"""Extrait le texte du premier noeud correspondant au XPath."""
nodes = root.xpath(xpath, namespaces=NS)
if nodes:
return nodes[0].text
return None
def detect_profile(guideline_id: str) -> str:
"""Détecte le profil Factur-X depuis l'URN GuidelineID."""
lower = guideline_id.lower()
for suffix, label in PROFILE_MAP.items():
if suffix in lower:
return label
return f"Inconnu ({guideline_id})"
def parse_facturx(xml_bytes: bytes) -> dict:
"""Parse un XML CII Factur-X et retourne les données clés."""
root = etree.fromstring(xml_bytes)
# Vérifier l'élément racine
expected_tag = (
"{urn:un:unece:uncefact:data:standard:"
"CrossIndustryInvoice:100}CrossIndustryInvoice"
)
if root.tag != expected_tag:
raise ValueError(
f"Élément racine inattendu : {root.tag} "
f"(attendu : CrossIndustryInvoice)"
)
# Détecter le profil
guideline_id = text(
root,
"rsm:ExchangedDocumentContext"
"/ram:GuidelineSpecifiedDocumentContextParameter"
"/ram:ID"
)
profile = detect_profile(guideline_id) if guideline_id else "Non détecté"
# Préfixe XPath pour les sections trade
trade = "rsm:SupplyChainTradeTransaction"
agreement = f"{trade}/ram:ApplicableHeaderTradeAgreement"
settlement = f"{trade}/ram:ApplicableHeaderTradeSettlement"
summary = f"{settlement}/ram:SpecifiedTradeSettlementHeaderMonetarySummation"
return {
"profile": profile,
"guideline_id": guideline_id,
"invoice_number": text(root, "rsm:ExchangedDocument/ram:ID"),
"issue_date": text(
root,
"rsm:ExchangedDocument"
"/ram:IssueDateTime/udt:DateTimeString"
),
"type_code": text(root, "rsm:ExchangedDocument/ram:TypeCode"),
"currency": text(root, f"{settlement}/ram:InvoiceCurrencyCode"),
"seller_name": text(
root, f"{agreement}/ram:SellerTradeParty/ram:Name"
),
"buyer_name": text(
root, f"{agreement}/ram:BuyerTradeParty/ram:Name"
),
"total_without_vat": text(
root, f"{summary}/ram:TaxBasisTotalAmount"
),
"vat_amount": text(root, f"{summary}/ram:TaxTotalAmount"),
"total_with_vat": text(root, f"{summary}/ram:GrandTotalAmount"),
"amount_due": text(root, f"{summary}/ram:DuePayableAmount"),
}
# Utilisation
with open("factur-x.xml", "rb") as f:
xml_bytes = f.read()
data = parse_facturx(xml_bytes)
print(f"Profil : {data['profile']}")
print(f"Facture n° : {data['invoice_number']}")
print(f"Date : {data['issue_date']}")
print(f"Vendeur : {data['seller_name']}")
print(f"Acheteur : {data['buyer_name']}")
print(f"Total TTC : {data['total_with_vat']} {data['currency']}")
print(f"Montant dû : {data['amount_due']} {data['currency']}")
Note sur le format de date : en CII, le champ udt:DateTimeString utilise le format AAAAMMJJ (ex: 20260623) avec l’attribut format="102". Ce n’est pas ISO 8601 — il faut le convertir pour l’exploiter dans un ERP. Voir Champs obligatoires EN16931 pour les détails du mapping.
Valider après extraction
Extraire le XML ne suffit pas. Un fichier XML structurellement correct peut contenir des données sémantiquement invalides (montants incohérents, codes pays inexistants, champs conditionnellement obligatoires manquants). Deux niveaux de validation sont nécessaires.
XSD : validation structurelle
Le XSD vérifie que le document respecte le schéma CII D22B — la présence des éléments obligatoires, les types de données, l’ordre des séquences, les namespaces.
from lxml import etree
from pathlib import Path
def validate_xsd(xml_bytes: bytes, xsd_path: str) -> list[str]:
"""Valide un XML CII contre le XSD Factur-X.
Retourne une liste vide si valide, sinon la liste des erreurs.
"""
xsd_doc = etree.parse(xsd_path)
schema = etree.XMLSchema(xsd_doc)
xml_doc = etree.fromstring(xml_bytes)
if schema.validate(xml_doc):
return []
return [str(error) for error in schema.error_log]
errors = validate_xsd(xml_bytes, "CrossIndustryInvoice_100pD22B.xsd")
if errors:
print(f"{len(errors)} erreur(s) XSD :")
for error in errors:
print(f" - {error}")
else:
print("Validation XSD : OK")
Schematron : validation sémantique
Le Schematron vérifie les règles métier EN16931 (codes BR-*) — cohérence arithmétique des totaux, codes devise ISO 4217, présence conditionnelle de champs selon le contexte fiscal.
La validation Schematron est plus complexe à mettre en place en local (elle nécessite un processeur XSLT 2.0 ou un compilateur Schematron). Pour le détail des erreurs BR-* et le workflow de debug, voir Valider EN16931/Factur-X : XSD vs Schematron, erreurs BR-*.
Via l’API FacturX
Pour une validation complète sans infrastructure locale (XSD + Schematron EN16931 + règles nationales BR-FR-*), l’API FacturX valide le XML extrait en une requête :
# Valider le XML extrait
curl -X POST https://facturxapi.com/api/v1/validate \
-H "X-API-Key: votre-cle-api" \
-F "[email protected]"
import httpx
def validate_via_api(xml_bytes: bytes, api_key: str) -> dict:
"""Valide un XML Factur-X via l'API FacturX."""
response = httpx.post(
"https://facturxapi.com/api/v1/validate",
headers={"X-API-Key": api_key},
files={"file": ("factur-x.xml", xml_bytes, "text/xml")},
timeout=30.0,
)
response.raise_for_status()
return response.json()
L’API retourne un rapport structuré avec les erreurs XSD, les violations Schematron (avec le code BR-* et le message explicatif), et le profil détecté.
Pipeline complet : réception, extraction, validation, stockage
Voici un exemple de pipeline complet en Python qui automatise le traitement d’un PDF Factur-X reçu — de l’extraction du XML jusqu’au stockage des données structurées.
"""Pipeline de traitement des PDF Factur-X reçus.
Workflow :
1. Extraire le XML du PDF
2. Valider le XML (API)
3. Parser les données métier
4. Stocker les résultats
"""
import json
import logging
import sys
from dataclasses import dataclass, asdict
from datetime import date
from pathlib import Path
import httpx
from pypdf import PdfReader
from lxml import etree
logger = logging.getLogger(__name__)
NS = {
"rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
"ram": "urn:un:unece:uncefact:data:standard:"
"ReusableAggregateBusinessInformationEntity:100",
"udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
}
FACTURX_FILENAMES = {
"factur-x.xml",
"zugferd-invoice.xml",
"ZUGFeRD-invoice.xml",
}
@dataclass
class InvoiceData:
"""Données extraites d'une facture Factur-X."""
source_pdf: str
profile: str
invoice_number: str
issue_date: str
type_code: str
currency: str | None
seller_name: str | None
buyer_name: str | None
total_with_vat: str | None
amount_due: str | None
validation_passed: bool
validation_errors: list[str]
def extract_xml(pdf_path: Path) -> bytes:
"""Étape 1 : extraire le XML du PDF."""
reader = PdfReader(str(pdf_path))
for name, data_list in reader.attachments.items():
if name in FACTURX_FILENAMES:
if not data_list or len(data_list[0]) == 0:
raise ValueError(f"'{name}' présent mais vide")
logger.info("XML trouvé : %s (%d bytes)", name, len(data_list[0]))
return data_list[0]
raise ValueError(f"Aucun fichier Factur-X dans {pdf_path.name}")
def validate_xml(xml_bytes: bytes, api_key: str) -> tuple[bool, list[str]]:
"""Étape 2 : valider le XML via l'API FacturX."""
try:
response = httpx.post(
"https://facturxapi.com/api/v1/validate",
headers={"X-API-Key": api_key},
files={"file": ("factur-x.xml", xml_bytes, "text/xml")},
timeout=30.0,
)
response.raise_for_status()
result = response.json()
is_valid = result.get("valid", False)
errors = [
e.get("message", str(e))
for e in result.get("errors", [])
]
return is_valid, errors
except httpx.HTTPError as exc:
logger.error("Erreur API validation : %s", exc)
return False, [f"Erreur API : {exc}"]
def parse_xml(xml_bytes: bytes) -> dict:
"""Étape 3 : parser les données métier."""
root = etree.fromstring(xml_bytes)
def txt(xpath: str) -> str | None:
nodes = root.xpath(xpath, namespaces=NS)
return nodes[0].text if nodes else None
trade = "rsm:SupplyChainTradeTransaction"
agreement = f"{trade}/ram:ApplicableHeaderTradeAgreement"
settlement = f"{trade}/ram:ApplicableHeaderTradeSettlement"
summary = (
f"{settlement}"
f"/ram:SpecifiedTradeSettlementHeaderMonetarySummation"
)
# Détection du profil
guideline_id = txt(
"rsm:ExchangedDocumentContext"
"/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID"
) or ""
profile_map = {
"minimum": "MINIMUM",
"basicwl": "BASIC WL",
"basic": "BASIC",
"en16931": "EN 16931",
"extended": "EXTENDED",
}
profile = "Inconnu"
for key, label in profile_map.items():
if key in guideline_id.lower():
profile = label
break
return {
"profile": profile,
"invoice_number": txt("rsm:ExchangedDocument/ram:ID") or "",
"issue_date": txt(
"rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString"
) or "",
"type_code": txt("rsm:ExchangedDocument/ram:TypeCode") or "",
"currency": txt(f"{settlement}/ram:InvoiceCurrencyCode"),
"seller_name": txt(
f"{agreement}/ram:SellerTradeParty/ram:Name"
),
"buyer_name": txt(
f"{agreement}/ram:BuyerTradeParty/ram:Name"
),
"total_with_vat": txt(f"{summary}/ram:GrandTotalAmount"),
"amount_due": txt(f"{summary}/ram:DuePayableAmount"),
}
def process_invoice(pdf_path: Path, api_key: str) -> InvoiceData:
"""Pipeline complet de traitement d'une facture reçue."""
# 1. Extraction
logger.info("Extraction XML de %s", pdf_path.name)
xml_bytes = extract_xml(pdf_path)
# 2. Validation
logger.info("Validation du XML extrait")
is_valid, errors = validate_xml(xml_bytes, api_key)
if not is_valid:
logger.warning(
"Validation échouée (%d erreur(s))", len(errors)
)
# 3. Parsing (même si la validation a échoué — on extrait ce qu'on peut)
logger.info("Parsing des données métier")
data = parse_xml(xml_bytes)
# 4. Construction du résultat
return InvoiceData(
source_pdf=pdf_path.name,
validation_passed=is_valid,
validation_errors=errors,
**data,
)
def main() -> None:
"""Point d'entrée : traite les PDF passés en argument."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
if len(sys.argv) < 2:
print("Usage: python pipeline.py <facture1.pdf> [facture2.pdf ...]")
sys.exit(1)
api_key = "votre-cle-api" # En production : variable d'environnement
output_dir = Path("output")
output_dir.mkdir(exist_ok=True)
for pdf_file in sys.argv[1:]:
pdf_path = Path(pdf_file)
if not pdf_path.exists():
logger.error("Fichier introuvable : %s", pdf_path)
continue
try:
invoice = process_invoice(pdf_path, api_key)
# Stocker le résultat en JSON
output_file = output_dir / f"{pdf_path.stem}.json"
output_file.write_text(
json.dumps(asdict(invoice), indent=2, ensure_ascii=False),
encoding="utf-8",
)
status = "VALIDE" if invoice.validation_passed else "INVALIDE"
logger.info(
"%s — %s n°%s — %s",
status,
invoice.seller_name or "vendeur inconnu",
invoice.invoice_number,
pdf_path.name,
)
except ValueError as exc:
logger.error("Échec traitement %s : %s", pdf_path.name, exc)
except Exception:
logger.exception("Erreur inattendue pour %s", pdf_path.name)
if __name__ == "__main__":
main()
Ce pipeline est conçu pour être adapté : remplacer le stockage JSON par une insertion en base de données, ajouter un envoi vers un ERP via webhook, ou intégrer dans un worker qui surveille un répertoire de réception.
Cas limites et erreurs courantes
Le PDF ne contient aucun fichier embarqué
Un PDF standard n’est pas un PDF Factur-X. L’absence de fichier embarqué n’est pas une erreur — c’est simplement un PDF classique. Le code d’extraction doit gérer ce cas proprement (retourner None, pas lever une exception non gérée).
Diagnostic rapide :
pdfdetach -list facture.pdf
# Si aucun fichier listé → pas de Factur-X
Le PDF contient plusieurs fichiers embarqués
Un PDF/A-3 peut contenir plusieurs fichiers associés : le XML Factur-X, mais aussi des logos, des annexes PDF, des fichiers CSV de détail. La stratégie d’identification :
- Par nom : chercher
factur-x.xml,zugferd-invoice.xmlouZUGFeRD-invoice.xml - Par type MIME : filtrer sur
text/xmlouapplication/xml - Par AFRelationship : le fichier avec
/Dataou/Alternativeest typiquement le XML de facturation
Ne pas extraire aveuglément le premier fichier trouvé — vérifier le nom.
Encodage UTF-8 BOM
Certains générateurs produisent un XML avec un BOM (Byte Order Mark) UTF-8 : les trois octets 0xEF 0xBB 0xBF en début de fichier. La plupart des parsers XML gèrent le BOM sans problème, mais certains outils (validation XSD, comparaison textuelle) peuvent échouer.
Détection et suppression :
def strip_utf8_bom(xml_bytes: bytes) -> bytes:
"""Supprime le BOM UTF-8 si présent."""
if xml_bytes[:3] == b"\xef\xbb\xbf":
return xml_bytes[3:]
return xml_bytes
# Détecter un BOM en CLI
hexdump -C factur-x.xml | head -1
# Si commence par "ef bb bf" → BOM présent
Différences de nommage ZUGFeRD vs Factur-X
Factur-X et ZUGFeRD sont deux spécifications qui partagent le même format technique (CII dans PDF/A-3). Les différences pratiques pour l’extraction :
| Aspect | Factur-X | ZUGFeRD 2.1+ | ZUGFeRD 2.0 | ZUGFeRD 1.0 |
|---|---|---|---|---|
| Nom du fichier | factur-x.xml | factur-x.xml ou zugferd-invoice.xml | zugferd-invoice.xml | ZUGFeRD-invoice.xml |
| URN GuidelineID | urn:factur-x.eu:... | urn:cen.eu:en16931:... ou urn:zugferd.de:2p1:... | urn:zugferd.de:2p0:... | urn:ferd:... |
| Schéma XSD | CII D22B | CII D22B | CII D16B | CII D14B |
Un extracteur robuste doit gérer les trois noms de fichier. Le contenu XML est ensuite parsable de la même manière — les XPath des Business Terms sont identiques entre Factur-X et ZUGFeRD 2.x.
Stream vide ou PDF corrompu
Certains générateurs créent l’entrée dans le name tree EmbeddedFiles mais n’écrivent pas le stream (0 bytes). Le fichier apparaît dans la liste des pièces jointes mais son contenu est vide. Toujours vérifier la taille du contenu extrait avant de tenter le parsing XML.
xml_bytes = extract_facturx_xml(pdf_path)
if xml_bytes is not None and len(xml_bytes) == 0:
raise ValueError("XML déclaré mais stream vide — PDF probablement mal généré")
Ressources
- Facturation électronique 2026 : le guide technique complet — contexte réglementaire et architecture
- PDF/A-3 pour Factur-X : checklist de conformité — le conteneur PDF qui enveloppe le XML
- Valider EN16931/Factur-X : XSD vs Schematron, erreurs BR-* — validation après extraction
- Champs obligatoires EN16931 : cartographie et mapping ERP — correspondance Business Terms et XPath CII
- Python et Factur-X : générer et valider — génération de Factur-X (le chemin inverse)