Technique

Extraire l'XML d'un PDF Factur-X : extraction, parsing et validation automatisée (Python, Java, PHP, CLI)

17 min de lecture Par FacturX API

Guide technique pour extraire le XML CII d'un PDF Factur-X ou ZUGFeRD reçu : pdfdetach, pypdf, PDFBox, PdfParser. Parsing des Business Terms, détection du profil, validation XSD et Schematron.

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 :

StandardNom du fichier embarqué
Factur-X 1.xfactur-x.xml
ZUGFeRD 2.1+factur-x.xml ou zugferd-invoice.xml
ZUGFeRD 2.0zugferd-invoice.xml
ZUGFeRD 1.0ZUGFeRD-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éfixeRôle
rsmRacine du document
ramEntités métier (vendeur, acheteur, lignes, totaux)
qdtTypes qualifiés
udtTypes 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 :

BTDescriptionXPath CII
BT-1Numéro de facture…/ram:ID
BT-2Date d’émission…/ram:IssueDateTime/udt:DateTimeString
BT-3Type de facture…/ram:TypeCode
BT-5Code devise…/ram:InvoiceCurrencyCode
BT-27Nom du vendeur…/ram:SellerTradeParty/ram:Name
BT-44Nom de l’acheteur…/ram:BuyerTradeParty/ram:Name
BT-112Total 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 :

ProfilURN GuidelineID
MINIMUMurn:factur-x.eu:1p0:minimum
BASIC WLurn:factur-x.eu:1p0:basicwl
BASICvoir note ci-dessous
EN16931urn:cen.eu:en16931:2017
EXTENDEDvoir 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%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 :

  1. Par nom : chercher factur-x.xml, zugferd-invoice.xml ou ZUGFeRD-invoice.xml
  2. Par type MIME : filtrer sur text/xml ou application/xml
  3. Par AFRelationship : le fichier avec /Data ou /Alternative est 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 :

AspectFactur-XZUGFeRD 2.1+ZUGFeRD 2.0ZUGFeRD 1.0
Nom du fichierfactur-x.xmlfactur-x.xml ou zugferd-invoice.xmlzugferd-invoice.xmlZUGFeRD-invoice.xml
URN GuidelineIDurn:factur-x.eu:...urn:cen.eu:en16931:... ou urn:zugferd.de:2p1:...urn:zugferd.de:2p0:...urn:ferd:...
Schéma XSDCII D22BCII D22BCII D16BCII 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

#factur-x #xml #extraction #pdf #python #java #php #automatisation