Technique

PHP mPDF : corriger le XML 0 bytes en PDF/A-3 Factur-X (SetAssociatedFiles)

15 min de lecture Par FacturX API

Le XML Factur-X embarqué par mPDF est vide (0 bytes) ? Diagnostic du bug SetAssociatedFiles, compression, timing — et 4 solutions PHP testées pour produire un PDF/A-3 conforme.

Le symptôme est toujours le même : vous générez un PDF/A-3 avec mPDF, vous attachez votre fichier factur-x.xml, le PDF s’ouvre correctement, la pièce jointe apparaît dans le panneau des pièces jointes d’Acrobat Reader — mais quand vous l’extrayez, elle fait 0 octets. Le flux est vide. Le XML est présent dans la structure du PDF mais son contenu a disparu.

Ce bug est une source majeure de factures Factur-X invalides dans l’écosystème PHP. Il affecte les ERP open source (Dolibarr, certains modules PrestaShop), les systèmes de facturation sur mesure, et toute application qui utilise mPDF pour produire des conteneurs PDF/A-3.

Ce guide décrit la cause exacte du problème, comment le reproduire, et quatre chemins de correction — du patch mPDF direct jusqu’au remplacement complet de la chaîne de génération.

Pour le contexte général sur les exigences PDF/A-3 pour Factur-X, voir PDF/A-3 pour Factur-X : checklist de conformité.

Ce que PDF/A-3 exige pour les fichiers embarqués

PDF/A-3 (ISO 19005-3) est le seul niveau PDF/A qui autorise les pièces jointes de type arbitraire. C’est ce qui permet d’embarquer un fichier XML CII dans un PDF. Mais l’attachement ne se résume pas à “ajouter un fichier” — la structure interne du PDF doit respecter plusieurs contraintes.

La structure d’un fichier embarqué conforme :

/Type /Filespec
/F (factur-x.xml)
/UF (factur-x.xml)
/AFRelationship /Alternative
/Desc (Factur-X XML invoice)
/EF <<
  /F << /Type /EmbeddedFile
        /Subtype /text#2Fxml
        /Length 2847
        /Params << /Size 2847 /ModDate (D:20260623120000+02'00') >>
        stream ... endstream
     >>
  /UF (référence identique à /F)
>>

Les points critiques :

  1. Le flux (stream) du fichier embarqué doit contenir les données réelles. Un Length de 0 ou un stream vide invalide le document.
  2. Le sous-type MIME doit être text/xml (encodé en PDF name comme /text#2Fxml).
  3. AFRelationship doit correspondre à la version ciblée (/Alternative pour Factur-X 1.x).
  4. Le dictionnaire Names/EmbeddedFiles du catalogue PDF doit référencer le Filespec.
  5. Le tableau AF (Associated Files) du catalogue doit aussi référencer le Filespec.

Quand mPDF produit un fichier avec un XML de 0 bytes, c’est le point 1 qui échoue : la structure est correcte mais le flux est vide.

Pourquoi mPDF produit un XML de 0 bytes

Le problème vient de la façon dont mPDF gère l’écriture des flux de fichiers embarqués par rapport au cycle de vie du document PDF.

L’architecture interne de mPDF

mPDF génère le PDF en mémoire via un buffer. Le cycle de génération suit cet ordre :

  1. new \Mpdf\Mpdf([...]) — initialisation
  2. WriteHTML() — rendu du contenu HTML
  3. SetAssociatedFiles() — déclaration des fichiers embarqués
  4. Output() — sérialisation finale du PDF

Le problème se situe dans l’étape 4. Lors de Output(), mPDF appelle sa méthode interne _putresources() qui écrit les objets PDF dans l’ordre suivant :

  • Images
  • Polices
  • Objets de formulaire
  • Fichiers embarqués (associated files)
  • Profil ICC et OutputIntents
  • Métadonnées XMP

Mais dans certaines versions de mPDF et certaines configurations, le flux du fichier embarqué est écrit avec un contenu vide. Les causes identifiées :

Cause 1 : Compression du flux qui vide le contenu

Quand la compression Zlib est active (c’est le défaut), mPDF compresse les flux avec gzcompress(). Si le contenu du fichier associé n’a pas été correctement chargé en mémoire au moment de la compression, le résultat est un flux compressé de 0 bytes.

Cela arrive notamment quand le contenu du fichier associé n’est pas correctement chargé en mémoire au moment de la sérialisation du flux PDF — par exemple en raison d’un conflit d’encodage, d’une version de mPDF avec un bug connu, ou d’une interaction avec d’autres options de configuration (compression, timing d’écriture).

Cause 2 : Appel de SetAssociatedFiles() après le rendu

Si SetAssociatedFiles() est appelé après WriteHTML() mais que le buffer interne a déjà été finalisé (par exemple via un appel intermédiaire qui déclenche _putpages()), le fichier est enregistré dans les métadonnées mais son contenu n’est jamais écrit dans le flux.

Cause 3 : Confusion entre les clés du tableau SetAssociatedFiles()

Le paramètre path est officiellement supporté par mPDF pour référencer un fichier sur disque. Cependant, le bug XML 0 bytes peut survenir indépendamment de l’utilisation de path ou content. Les causes réelles incluent : l’encodage du contenu XML, la version spécifique de mPDF, ou des interactions avec d’autres options de configuration (mode PDF/A, compression). Passer le contenu directement via la clé content reste la méthode la plus fiable car elle élimine les problèmes potentiels de lecture de fichier au moment de la sérialisation.

Reproduire le bug

Voici le code minimal qui déclenche le problème dans mPDF 8.x :

<?php
// composer require mpdf/mpdf

require_once __DIR__ . '/vendor/autoload.php';

$mpdf = new \Mpdf\Mpdf([
    'mode' => 'utf-8',
    'format' => 'A4',
    'PDFA' => true,
    'PDFAauto' => true,
]);

$html = '<h1>Facture F-2026-0042</h1><p>Montant TTC : 1 200,00 EUR</p>';
$mpdf->WriteHTML($html);

// Configuration qui peut déclencher le bug dans certaines versions de mPDF
$mpdf->SetAssociatedFiles([
    [
        'name' => 'factur-x.xml',
        'mime' => 'text/xml',
        'description' => 'Factur-X XML invoice',
        'AFRelationship' => 'Alternative',
        'path' => __DIR__ . '/factur-x.xml',  // path est supporté mais moins fiable que content
    ]
]);

$mpdf->Output(__DIR__ . '/facture-output.pdf', \Mpdf\Output\Destination::FILE);

Vérification du résultat :

<?php
// Vérification rapide avec un script PHP
// ATTENTION : cette méthode par regex fonctionne sur les PDF non compressés.
// Pour les PDF avec compression de flux (FlateDecode), extraire le fichier
// embarqué avec un outil dédié (pypdf, pdfdetach) est plus fiable.
$pdfContent = file_get_contents(__DIR__ . '/facture-output.pdf');

// Chercher le stream du fichier embarqué
if (preg_match('/\/F\s*\(factur-x\.xml\)/', $pdfContent)) {
    echo "Filespec présent dans le PDF\n";
} else {
    echo "ERREUR: Filespec absent\n";
}

// Chercher la taille du flux embarqué (fiable uniquement sans compression)
if (preg_match('/\/Type\s*\/EmbeddedFile.*?\/Length\s+(\d+)/s', $pdfContent, $matches)) {
    $length = (int) $matches[1];
    echo "Taille du flux embarqué : {$length} bytes\n";
    if ($length === 0) {
        echo "BUG CONFIRMÉ : flux XML vide (0 bytes)\n";
    }
} else {
    echo "ERREUR: EmbeddedFile non trouvé\n";
}

Pour un diagnostic fiable sur tous les PDF (y compris compressés), utiliser Python :

from pypdf import PdfReader

reader = PdfReader("facture-output.pdf")
for name, data_list in reader.attachments.items():
    for data in data_list:
        print(f"{name}: {len(data)} bytes")
# Output attendu si bug présent :
# factur-x.xml: 0 bytes

Fix 1 : Utilisation correcte de mPDF

Le premier chemin de correction est de rester avec mPDF mais de maximiser la fiabilité de l’écriture du flux embarqué. Bien que le paramètre path soit officiellement supporté, passer le contenu brut du XML via la clé content est la méthode la plus fiable pour éviter les problèmes de flux vide. Contrôler la compression aide aussi au diagnostic.

<?php

require_once __DIR__ . '/vendor/autoload.php';

// Lire le contenu XML en mémoire AVANT de créer le PDF
$xmlContent = file_get_contents(__DIR__ . '/factur-x.xml');
if ($xmlContent === false) {
    throw new \RuntimeException('Impossible de lire factur-x.xml');
}

$mpdf = new \Mpdf\Mpdf([
    'mode' => 'utf-8',
    'format' => 'A4',
    'PDFA' => true,
    'PDFAauto' => true,
]);

$html = '<h1>Facture F-2026-0042</h1><p>Montant TTC : 1 200,00 EUR</p>';
$mpdf->WriteHTML($html);

// Fix : passer le contenu brut, pas le chemin
$mpdf->SetAssociatedFiles([
    [
        'name' => 'factur-x.xml',
        'mime' => 'text/xml',
        'description' => 'Factur-X XML invoice',
        'AFRelationship' => 'Alternative',
        'content' => $xmlContent,  // contenu brut du XML
    ]
]);

$mpdf->Output(__DIR__ . '/facture-corrigee.pdf', \Mpdf\Output\Destination::FILE);

Points d’attention :

  • La clé du tableau est content, pas path ni file. La documentation de mPDF n’est pas toujours explicite sur ce point.
  • Le XML doit être lu en mémoire avant l’appel à SetAssociatedFiles(). Ne pas utiliser de référence tardive.
  • SetAssociatedFiles() doit être appelé après WriteHTML() mais avant Output(). Cet ordre est non-négociable.
  • Si le bug persiste malgré l’utilisation de content, désactiver la compression interne pour diagnostiquer :
$mpdf = new \Mpdf\Mpdf([
    'mode' => 'utf-8',
    'format' => 'A4',
    'PDFA' => true,
    'PDFAauto' => true,
    'compress' => false,  // désactiver la compression pour diagnostic
]);

Si le XML apparaît correctement avec compress => false mais pas avec compress => true, le problème est dans la gestion Zlib de mPDF pour les flux de fichiers embarqués. Dans ce cas, les alternatives ci-dessous sont plus fiables.

Avertissement important : même avec cette correction, mPDF n’est pas garanti de produire un PDF/A-3 pleinement conforme. Les discussions sur le dépôt GitHub de mPDF indiquent que les fichiers produits sont parfois identifiés comme PDF/A-1b par VeraPDF, même quand le mode PDFA => true est activé. Validez systématiquement avec VeraPDF avant de déployer en production.

Fix 2 : Utiliser la bibliothèque atgp/factur-x

La bibliothèque atgp/factur-x (namespace Atgp\FacturX) est dédiée à la génération de fichiers Factur-X. Elle gère elle-même la couche PDF/A-3 en s’appuyant sur FPDI pour manipuler des PDF existants.

Son approche est différente de mPDF : au lieu de générer le PDF de zéro, elle prend un PDF existant (généré par n’importe quel outil) et y injecte le XML Factur-X avec les métadonnées PDF/A-3 correctes.

composer require atgp/factur-x
<?php

require_once __DIR__ . '/vendor/autoload.php';

use Atgp\FacturX\Facturx;

// Étape 1 : générer le PDF visuel avec n'importe quel outil
// (mPDF, TCPDF, wkhtmltopdf, Chromium headless, etc.)
$pdfContent = file_get_contents(__DIR__ . '/facture-visuelle.pdf');
$xmlContent = file_get_contents(__DIR__ . '/factur-x.xml');

if ($pdfContent === false || $xmlContent === false) {
    throw new \RuntimeException('Fichiers source introuvables');
}

// Étape 2 : générer le PDF/A-3 Factur-X
$facturx = new Facturx();
$facturxPdf = $facturx->generateFacturx(
    $pdfContent,
    $xmlContent,
    Facturx::PROFIL_EN16931  // ou PROFIL_BASIC, PROFIL_MINIMUM, etc.
);

file_put_contents(__DIR__ . '/facture-facturx.pdf', $facturxPdf);

Avantages de cette approche :

  • La séparation entre génération visuelle et conformité PDF/A-3 est nette. Vous pouvez utiliser mPDF, TCPDF, Dompdf, wkhtmltopdf ou tout autre outil pour le rendu HTML-vers-PDF, sans vous soucier de la conformité PDF/A-3.
  • La bibliothèque gère l’attachement du XML, la validation XSD, les métadonnées XMP Factur-X (namespace fx:, extension schema PDF/A), l’AFRelationship et le dictionnaire EmbeddedFiles. Cependant, la conformité PDF/A-3 complète (profils ICC, polices embarquées) dépend aussi du PDF source fourni en entrée et du moteur PDF utilisé en amont.
  • Le flux XML est garanti non-vide car la bibliothèque écrit le contenu directement dans le stream PDF.

Limites :

  • La bibliothèque repose sur FPDI, qui a ses propres contraintes (ne supporte pas tous les PDF en entrée, notamment les PDF chiffrés ou avec des formulaires complexes).
  • Le PDF en entrée doit déjà contenir les polices embarquées et être relativement propre. Un PDF avec des polices système non embarquées produira un PDF/A-3 invalide au niveau des polices, même si la couche Factur-X est correcte.

Fix 3 : Utiliser horstoeko/zugferd

La bibliothèque horstoeko/zugferd est l’alternative utilisée par InvoiceNinja. Elle prend une approche différente : elle génère le XML à partir d’une API PHP, puis l’embarque dans un PDF existant.

composer require horstoeko/zugferd

Pour la partie embarquement du XML dans le PDF (si vous avez déjà votre XML généré) :

<?php

require_once __DIR__ . '/vendor/autoload.php';

use horstoeko\zugferd\ZugferdDocumentPdfMerger;

$xmlContent = file_get_contents(__DIR__ . '/factur-x.xml');
$pdfPath = __DIR__ . '/facture-visuelle.pdf';
$outputPath = __DIR__ . '/facture-zugferd.pdf';

if ($xmlContent === false) {
    throw new \RuntimeException('Fichier XML introuvable');
}

// Merger le XML dans le PDF existant
$merger = new ZugferdDocumentPdfMerger($xmlContent, $pdfPath);
$merger->generateDocument();
$merger->saveDocument($outputPath);

Si vous voulez aussi générer le XML Factur-X via l’API de la bibliothèque :

<?php

require_once __DIR__ . '/vendor/autoload.php';

use horstoeko\zugferd\ZugferdDocumentBuilder;
use horstoeko\zugferd\ZugferdProfiles;

// Construire le document XML EN16931
$builder = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_EN16931);

$builder
    ->setDocumentInformation('F-2026-0042', '380', new \DateTime('2026-06-23'))
    ->setDocumentBuyerReference('PO-2026-1234')
    ->addDocumentNote('Facture de test')
    ->setDocumentSupplyChainEvent(new \DateTime('2026-06-23'));

// Vendeur
$builder
    ->setDocumentSeller('Mon Entreprise SAS')
    ->addDocumentSellerTaxRegistration('VA', 'FR12345678901')
    ->setDocumentSellerAddress('12 rue de la Paix', '', '', '75002', 'Paris', 'FR')
    ->setDocumentSellerContact('Service facturation', '', '0142000000', '', '[email protected]');

// Acheteur
$builder
    ->setDocumentBuyer('Client SARL')
    ->setDocumentBuyerAddress('8 avenue des Champs', '', '', '75008', 'Paris', 'FR');

// Conditions de paiement
$builder
    ->addDocumentPaymentMean(30)  // virement
    ->setDocumentPaymentTerms('Paiement à 30 jours');

// Ligne de facture
$builder
    ->addNewPosition('1')
    ->setDocumentPositionProductDetails('Service de conseil', '', 'SC-001')
    ->setDocumentPositionNetPrice(1000.00)
    ->setDocumentPositionQuantity(1, 'HUR')
    ->addDocumentPositionTax('S', 'VAT', 20.0)
    ->setDocumentPositionLineSummation(1000.00);

// Totaux
$builder
    ->setDocumentSummation(1200.00, 1200.00, 1000.00, 0.00, 0.00, 1000.00, 200.00)
    ->addDocumentTax('S', 'VAT', 1000.00, 200.00, 20.0);

// Générer le XML
$xmlContent = $builder->getContent();

// Merger dans le PDF
$merger = new \horstoeko\zugferd\ZugferdDocumentPdfMerger(
    $xmlContent,
    __DIR__ . '/facture-visuelle.pdf'
);
$merger->generateDocument();
$merger->saveDocument(__DIR__ . '/facture-finale.pdf');

Avantages :

  • Bibliothèque activement maintenue, utilisée en production par InvoiceNinja et d’autres projets.
  • API de construction du XML qui assure la conformité structurelle (namespace, profils, types).
  • Le merger PDF/A-3 gère les métadonnées XMP et le profil ICC.

Limites :

  • L’API de construction du XML est verbeuse. Pour des factures complexes, le code devient long.
  • Le support des profils EXTENDED est moins mature que pour EN16931.

Pour les erreurs de validation spécifiques à InvoiceNinja, voir InvoiceNinja et Factur-X : valider ses factures avant la réforme 2026.

Fix 4 : Post-traitement avec FPDI pour ré-embarquer le XML

Si vous avez un PDF généré par mPDF (ou tout autre outil) avec un XML de 0 bytes, vous pouvez le corriger en post-traitement avec FPDI, sans régénérer le PDF.

composer require setasign/fpdi
composer require setasign/fpdf
<?php

require_once __DIR__ . '/vendor/autoload.php';

use setasign\Fpdi\Fpdi;

/**
 * Ré-embarque un fichier XML dans un PDF existant en remplacement
 * d'un attachement vide ou corrompu.
 *
 * ATTENTION : cette approche ne garantit pas à elle seule la conformité
 * PDF/A-3 complète (métadonnées XMP, ICC, etc.). Elle corrige uniquement
 * le flux du fichier embarqué.
 */
function reembedXmlInPdf(string $pdfPath, string $xmlPath, string $outputPath): void
{
    $xmlContent = file_get_contents($xmlPath);
    if ($xmlContent === false) {
        throw new \RuntimeException("Impossible de lire : {$xmlPath}");
    }

    $pdf = new Fpdi();

    // Importer toutes les pages du PDF source
    $pageCount = $pdf->setSourceFile($pdfPath);
    for ($i = 1; $i <= $pageCount; $i++) {
        $templateId = $pdf->importPage($i);
        $size = $pdf->getTemplateSize($templateId);
        $pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
        $pdf->useTemplate($templateId);
    }

    $pdf->Output('F', $outputPath);

    // À ce stade, le PDF est régénéré sans l'attachement.
    // L'embarquement du XML doit être fait via une manipulation
    // bas niveau du PDF ou via atgp/factur-x sur le PDF produit.
}

// Utilisation combinée : FPDI pour nettoyer + atgp/factur-x pour ré-embarquer
$cleanPdf = __DIR__ . '/facture-clean.pdf';
reembedXmlInPdf(
    __DIR__ . '/facture-bug-0bytes.pdf',
    __DIR__ . '/factur-x.xml',
    $cleanPdf
);

// Ré-embarquer proprement avec atgp/factur-x
$facturx = new \Atgp\FacturX\Facturx();
$result = $facturx->generateFacturx(
    file_get_contents($cleanPdf),
    file_get_contents(__DIR__ . '/factur-x.xml'),
    \Atgp\FacturX\Facturx::PROFIL_EN16931
);
file_put_contents(__DIR__ . '/facture-finale.pdf', $result);

// Nettoyer le fichier intermédiaire
unlink($cleanPdf);

Cette approche en deux étapes est utile quand vous avez un pipeline existant basé sur mPDF que vous ne pouvez pas facilement remplacer : vous gardez mPDF pour le rendu visuel, puis vous corrigez la couche PDF/A-3 en post-traitement.

Valider que le XML n’est pas vide

Après correction, il faut vérifier que le flux du fichier embarqué contient bien le XML. Voici un script de validation complet :

<?php

/**
 * Vérifie qu'un PDF contient un fichier XML embarqué non vide.
 * Ce script fait un diagnostic basique sans dépendance externe.
 * LIMITATION : cette approche par regex ne fonctionne que sur les PDF
 * non compressés. Pour les PDF avec FlateDecode, utiliser pypdf ou
 * pdfdetach pour extraire et vérifier le fichier embarqué.
 * Pour une validation PDF/A-3 complète, utiliser VeraPDF.
 */
function validateEmbeddedXml(string $pdfPath): array
{
    $result = [
        'fileExists' => false,
        'filespecFound' => false,
        'streamLength' => 0,
        'xmlParsable' => false,
        'errors' => [],
    ];

    if (!file_exists($pdfPath)) {
        $result['errors'][] = "Fichier introuvable : {$pdfPath}";
        return $result;
    }

    $result['fileExists'] = true;
    $content = file_get_contents($pdfPath);

    // Vérifier la présence du Filespec factur-x.xml
    if (preg_match('/\/F\s*\(factur-x\.xml\)/', $content)) {
        $result['filespecFound'] = true;
    } else {
        $result['errors'][] = 'Filespec factur-x.xml absent du PDF';
        return $result;
    }

    // Extraire la taille déclarée du flux embarqué
    if (preg_match(
        '/\/Type\s*\/EmbeddedFile.*?\/Length\s+(\d+)/s',
        $content,
        $matches
    )) {
        $result['streamLength'] = (int) $matches[1];
    }

    if ($result['streamLength'] === 0) {
        $result['errors'][] = 'Flux du fichier embarqué vide (0 bytes) — le bug est présent';
        return $result;
    }

    return $result;
}

// Utilisation
$diagnostic = validateEmbeddedXml(__DIR__ . '/facture-finale.pdf');

echo "=== Diagnostic PDF Factur-X ===\n";
echo "Fichier présent : " . ($diagnostic['fileExists'] ? 'OUI' : 'NON') . "\n";
echo "Filespec factur-x.xml : " . ($diagnostic['filespecFound'] ? 'OUI' : 'NON') . "\n";
echo "Taille du flux : {$diagnostic['streamLength']} bytes\n";

if (!empty($diagnostic['errors'])) {
    echo "\nERREURS :\n";
    foreach ($diagnostic['errors'] as $error) {
        echo "  - {$error}\n";
    }
} else {
    echo "\nRésultat : flux XML non vide. Valider avec VeraPDF pour conformité complète.\n";
}

Pour une validation complète (PDF/A-3 + XSD + Schematron) :

# Validation PDF/A-3 avec VeraPDF
docker run --rm -v "$PWD":/work verapdf/cli \
  --flavour 3b \
  --format json \
  /work/facture-finale.pdf | jq '.jobs[0].validationResult.isCompliant'

# Validation complète via l'API FacturX (PDF/A-3 + XSD + Schematron)
curl -X POST https://facturxapi.com/api/v1/validate \
  -H "X-API-Key: votre-cle-api" \
  -F "[email protected]"

Autres pièges PHP courants en PDF/A-3

Le bug du XML 0 bytes n’est pas le seul problème que rencontrent les développeurs PHP avec PDF/A-3. Voici les autres causes fréquentes d’échec.

Profil ICC manquant ou invalide

PDF/A-3 exige un profil colorimétrique ICC dans le dictionnaire OutputIntents quand des espaces colorimétriques non calibrés sont utilisés (DeviceRGB, DeviceGray). mPDF inclut un profil sRGB par défaut quand PDFA => true, mais d’autres bibliothèques ne le font pas.

Symptôme typique avec VeraPDF :

clause 6.2.3 — The document catalog shall include a DestOutputProfile key

En TCPDF, le profil ICC doit être ajouté explicitement :

// TCPDF : ajout du profil ICC
$iccProfile = file_get_contents(__DIR__ . '/sRGB_IEC61966-2-1.icc');
if ($iccProfile === false) {
    throw new \RuntimeException('Profil ICC introuvable');
}

$tcpdf->setColorProfile($iccProfile);

Le fichier sRGB_IEC61966-2-1.icc est disponible sur le site de l’International Color Consortium (ICC). Ne pas utiliser un profil CMYK si votre PDF utilise des couleurs RGB.

Métadonnées XMP incomplètes

Les métadonnées XMP doivent contenir à la fois les propriétés PDF/A standard et les propriétés Factur-X dans le namespace fx:. L’oubli le plus fréquent est l’absence de la description d’extension de schéma PDF/A pour le namespace fx:.

Sans cette description, les propriétés fx:DocumentFileName, fx:DocumentType, fx:Version et fx:ConformanceLevel sont présentes dans le XMP mais le fichier n’est pas formellement conforme à PDF/A-3. VeraPDF signale :

clause 6.6.2.4 — Extension schema ... is not defined

La description d’extension doit contenir la déclaration de chaque propriété du namespace fx: avec son type et sa description. Les exemples de référence se trouvent dans le dépôt GitHub officiel de la spécification Factur-X.

pdfaid:part absent ou incorrect

mPDF avec PDFA => true et PDFAauto => true est censé déclarer pdfaid:part = 3 dans les métadonnées XMP. En pratique, certaines versions déclarent pdfaid:part = 1, produisant un PDF identifié comme PDF/A-1 par les validateurs — ce qui interdit les pièces jointes.

Vérification rapide :

<?php
$content = file_get_contents('facture.pdf');
if (preg_match('/pdfaid:part>(\d+)</', $content, $matches)) {
    $part = (int) $matches[1];
    echo "PDF/A-{$part} déclaré\n";
    if ($part !== 3) {
        echo "ERREUR: Factur-X nécessite PDF/A-3, pas PDF/A-{$part}\n";
    }
} else {
    echo "ERREUR: aucune déclaration pdfaid:part trouvée\n";
}

OutputIntent avec le mauvais sous-type

Le dictionnaire OutputIntents doit contenir une entrée avec S égal à GTS_PDFA1. Ce nom est le même pour toutes les versions de PDF/A (1, 2 et 3) — c’est une source de confusion, mais GTS_PDFA1 est bien la valeur correcte même pour PDF/A-3.

Certaines bibliothèques ou configurations Ghostscript écrivent GTS_PDFA (sans le 1) ou GTS_PDFA3, ce qui invalide le document.

Tableau comparatif des approches

CritèremPDF (corrigé)atgp/factur-xhorstoeko/zugferdFPDI + atgp
Génère le PDF visuelOuiNon (PDF en entrée)Non (PDF en entrée)Non (PDF en entrée)
Génère le XML CIINonNonOui (API builder)Non
Gère PDF/A-3PartielOuiOuiOui
Métadonnées XMP fx:NonOuiOuiOui
Extension schema XMPNonOuiOuiOui
Profil ICCAuto (sRGB)AutoAutoDépend du PDF source
Fiabilité du flux XMLVariableFiableFiableFiable
Maintenance activeOuiOuiOuiOui

Recommandation : pour un nouveau projet, utiliser mPDF (ou tout autre outil) pour le rendu visuel HTML-vers-PDF, puis atgp/factur-x ou horstoeko/zugferd pour la couche PDF/A-3 Factur-X. Séparer le rendu visuel de la conformité PDF/A-3 évite toute une catégorie de bugs.

En résumé

Le bug du XML 0 bytes dans mPDF peut avoir plusieurs causes : encodage du contenu, version de mPDF, compression qui vide le flux, ou timing incorrect de SetAssociatedFiles() par rapport au cycle de vie du document. La correction directe dans mPDF fonctionne (passer le contenu via la clé content, appeler dans le bon ordre), mais la fiabilité à long terme dépend de la version de mPDF et de sa conformité PDF/A-3 réelle.

L’approche la plus robuste en PHP est de séparer les responsabilités : un outil pour le PDF visuel, un autre pour l’embarquement PDF/A-3 conforme. Les bibliothèques atgp/factur-x et horstoeko/zugferd remplissent ce second rôle de façon fiable.

Pour valider le résultat après correction :

  1. Vérification rapide : extraire le fichier embarqué et vérifier qu’il n’est pas vide (script PHP ci-dessus ou PyPDF)
  2. Validation PDF/A-3 : VeraPDF avec --flavour 3b
  3. Validation Factur-X complète : XSD + Schematron via l’API FacturX ou un validateur local

Ressources

#php #mpdf #pdf-a3 #factur-x #xml #bug #tcpdf