Aller au contenu

Génération de PDF par lots via Connect avec suivi de progression

Pilote le traitement d’une liste de documents jusqu’à leur achèvement depuis un seul processus client via NextPDF Connect, la distribution autonome du moteur sous forme de service HTTP. Ce recipe soumet chaque requête de rendu au point de terminaison de jobs asynchrones POST /api/v1/jobs, interroge chaque job avec GET /api/v1/jobs/{id} jusqu’à ce qu’il atteigne un état terminal, lit, pour chaque job, les champs status et progress signalés par le serveur, puis télécharge chaque PDF terminé depuis GET /api/v1/jobs/{id}/result.

Le cycle de vie d’un job est fixe et restreint. Un job est pending, puis running, puis atteint exactement un état terminal : completed, failed ou cancelled. La réponse de statut contient un entier progress de 0 à 100 lorsque le serveur le suit, et chaque réponse de sondage non terminale inclut un en-tête Retry-After qui t’indique combien de temps attendre avant la requête suivante. Identifie chaque soumission avec un Idempotency-Key afin qu’une soumission réessayée renvoie le même job plutôt que de lancer un second rendu.

Ce recipe adopte l’approche bas niveau, au plus près du transport. Il utilise directement la surface REST et ne suppose aucun kit de développement logiciel (SDK) spécifique à un langage, de sorte que le même flux se transpose vers n’importe quel client HTTP.

Côté serveur, c’est la distribution Connect standard :

Fenêtre de terminal
composer require nextpdf/server

Le client PHP de l’exemple de production ci-dessous utilise un client HTTP et des fabriques de messages conformes à PSR-18 et PSR-17. Installe les implémentations que ton projet utilise déjà, par exemple :

Fenêtre de terminal
composer require psr/http-client psr/http-factory

La surface des jobs asynchrones sépare la soumission de la récupération. Tu ne gardes pas une longue connexion HTTP ouverte par document. À la place, tu soumets un job, tu reçois un identifiant, puis tu interroges un point de terminaison de statut peu coûteux jusqu’à ce que le job se termine. Ce modèle rend le lot maîtrisable : le client suit N jobs indépendants à la fois, sans N connexions bloquées.

Trois points de terminaison portent le flux :

  • POST /api/v1/jobs accepte le même corps de requête de rendu que le point de terminaison synchrone /api/v1/render : un page_size, une orientation, et un tableau operations ordonné. Il renvoie 201 Created pour un nouveau job, ou 200 OK lorsqu’une Idempotency-Key correspond à un job déjà soumis.
  • GET /api/v1/jobs/{id} renvoie l’enregistrement actuel du job. Pour un job non terminal, il définit aussi un en-tête Retry-After (le serveur utilise un intervalle de 2 secondes) et un champ poll_url. Respecte l’en-tête plutôt que d’interroger dans une boucle serrée.
  • GET /api/v1/jobs/{id}/result renvoie le PDF terminé avec le type application/pdf. Il renvoie 409 Conflict si le job n’a pas atteint completed, donc ne l’appelle qu’une fois que le sondage de statut a confirmé l’état terminal.

Chaque réponse réussie utilise la même enveloppe : un objet data avec les champs du job, et un objet meta avec le request_id, le timestamp, le duration_ms, et l’api_version. Les champs du job à lire se trouvent sous data : data.status, data.progress, data.job_id, et, sur un job terminé, data.result_url.

Point important sur la version actuelle. Le serveur traite un job soumis de manière synchrone avant de répondre au POST ; en pratique, la réponse de soumission peut donc déjà porter un status terminal, et le résultat peut être prêt dès le premier sondage. Le contrat de sondage et de progression documenté ici est la forme stable de l’API. Le serveur la garde inchangée pendant que le backend de traitement évolue vers un pool de workers en file d’attente, de sorte qu’un client écrit pour interroger est correct aujourd’hui et le restera après ce changement. Garde la boucle de sondage. Ne suppose pas que la première réponse est non terminale, et ne suppose pas non plus qu’elle est terminale.

La surface REST des jobs asynchrones de Connect, définie par le document OpenAPI du serveur et le routage JobHandler :

  • POST /api/v1/jobs : soumet un job de rendu. En-tête de requête Idempotency-Key facultatif. Le corps est une requête de rendu (operations est obligatoire et doit contenir au moins une opération). Réponses : 201 pour un nouveau job, 200 pour un rejeu idempotent, 422 pour un corps invalide, 409 pour un conflit d’idempotence, 429 en cas de limitation par le débit.
  • GET /api/v1/jobs/{id} : interroge le statut. Réponse 200 avec l’enregistrement du job ; en-tête Retry-After présent tant qu’il n’est pas terminal ; 404 si le job n’existe pas ou appartient à un autre client.
  • GET /api/v1/jobs/{id}/result : télécharge le PDF. 200 application/pdf lorsque completed ; 409 lorsqu’il n’est pas encore terminé ; 404 s’il est inconnu.
  • DELETE /api/v1/jobs/{id} : annule un job pending ou running, ou supprime un job completed (204).

L’enregistrement du job sous data contient ces champs, exactement tels que le serveur les sérialise.

  • job_id : l’identifiant (un préfixe job_ et 24 caractères hexadécimaux).
  • status : l’un de pending, running, completed, failed, cancelled. Les deux premiers sont non terminaux ; les trois derniers sont terminaux.
  • created_at, et, une fois définis, started_at et completed_at : des horodatages ISO-8601.
  • progress : un entier de 0 à 100, présent uniquement lorsque le serveur le suit pour le job ; absent (à traiter comme inconnu) sinon.
  • error : une chaîne de message, présente uniquement sur un job failed.
  • result_url : présent uniquement sur un job completed ; le chemin vers le téléchargement du résultat.
  • poll_url : présent uniquement tant que le job n’est pas terminal.

L’authentification est un jeton porteur dans l’en-tête Authorization : Authorization: Bearer npk_live_{kid}_{secret}.

Cet exemple mène un job de bout en bout au niveau du transport, pour te montrer les trois appels et les champs qu’ils renvoient. Il soumet, interroge une fois, puis télécharge. L’exemple de production ci-dessous ajoute la boucle de lots, l’attente Retry-After et une gestion complète des erreurs.

Fenêtre de terminal
# 1. Submit an async render job. Capture the job_id from data.job_id.
curl -sS -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: invoice-2026-04-0001" \
-d '{"page_size":"A4","orientation":"portrait","operations":[{"type":"add_text","text":"Invoice 0001"}]}'
# 2. Poll status. Read data.status and data.progress; honour Retry-After.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
# 3. Once data.status is "completed", download the PDF binary.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-0001.pdf

Voici un client autonome. Il soumet un lot de requêtes de rendu, plafonne le nombre de jobs simultanément en vol, interroge chaque job à la cadence demandée par le serveur via Retry-After, rapporte le champ progress signalé par le serveur, télécharge chaque PDF terminé et consigne les échecs. Il utilise un client HTTP PSR-18 et des fabriques PSR-17 (le contrat de transport sur lequel les recipes Connect s’alignent), et il intercepte l’exception la plus spécifique que chaque appel peut lever : Psr\Http\Client\ClientExceptionInterface pour un échec de transport, et un BatchJobException typé pour une réponse serveur qui empêche le lot de continuer. Aucun bloc catch n’est vide. Chacun journalise puis relance l’exception, ou enregistre un résultat défini.

Remplace la liste $documents en ligne par tes propres entrées. Injecte le client HTTP et les fabriques concrètes de ton projet là où le constructeur attend les interfaces PSR.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Raised when a Connect job response prevents the batch from proceeding.
*
* Distinct from the PSR-18 transport exception: this means the request was
* delivered and the server answered, but the answer is one the batch
* cannot act on (a non-success status code, or a job that ended in a
* terminal failure).
*/
final class BatchJobException extends RuntimeException
{
}
/**
* Drives a batch of async render jobs over the NextPDF Connect REST surface.
*
* The client submits each render request, polls every job on the cadence
* the server requests through Retry-After, and downloads each completed
* PDF. It enforces bounded concurrency so a large batch never opens more
* in-flight jobs than the host should track at once.
*/
final readonly class ConnectBatchRunner
{
/**
* @param non-empty-string $baseUrl Connect base URL, no trailing slash
* @param non-empty-string $bearerToken Connect API key (npk_live_...)
* @param positive-int $maxInFlight Concurrent jobs cap
* @param positive-int $maxPolls Per-job poll attempts before giving up
*/
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
private string $baseUrl,
private string $bearerToken,
private int $maxInFlight = 8,
private int $maxPolls = 150,
) {}
/**
* Render every document in the batch and write each completed PDF.
*
* @param array<non-empty-string, array<string, mixed>> $documents
* Map of stable document key to render request body. The key
* doubles as the Idempotency-Key, so a re-run of the same batch
* does not duplicate server-side work.
* @param non-empty-string $outputDir Directory for the written PDFs
*
* @throws BatchJobException When the batch cannot proceed at all
* @throws ClientExceptionInterface When the transport cannot send a request
*
* @return array<non-empty-string, string> Map of document key to a
* human-readable outcome line
*/
public function run(array $documents, string $outputDir): array
{
$this->assertWritableDir($outputDir);
$outcomes = [];
// Process in bounded windows so the in-flight job count never
// exceeds the configured cap, regardless of batch size.
foreach (array_chunk($documents, $this->maxInFlight, preserve_keys: true) as $window) {
$jobIds = [];
foreach ($window as $key => $body) {
$jobIds[$key] = $this->submit($key, $body);
}
foreach ($jobIds as $key => $jobId) {
$record = $this->pollToTerminal($jobId);
$outcomes[$key] = $this->finish($key, $record, $outputDir);
}
}
return $outcomes;
}
/**
* Submit one render job and return its identifier.
*
* @param non-empty-string $idempotencyKey Stable per-document key
* @param array<string, mixed> $body Render request body
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return non-empty-string The job_id from data.job_id
*/
private function submit(string $idempotencyKey, array $body): string
{
$request = $this->requestFactory
->createRequest('POST', $this->baseUrl . '/api/v1/jobs')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken)
->withHeader('Content-Type', 'application/json')
->withHeader('Idempotency-Key', $idempotencyKey)
->withBody($this->streamFactory->createStream($this->encode($body)));
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
// 201 new job; 200 idempotent replay. Anything else stops the batch.
if ($status !== 201 && $status !== 200) {
throw new BatchJobException(
sprintf('Submit for "%s" returned HTTP %d.', $idempotencyKey, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobId = $data['job_id'] ?? null;
if (!is_string($jobId) || $jobId === '') {
throw new BatchJobException(
sprintf('Submit for "%s" returned no job_id.', $idempotencyKey),
);
}
return $jobId;
}
/**
* Poll one job until it reaches a terminal state.
*
* Honours the Retry-After header on every non-terminal poll. Gives up
* after maxPolls attempts and reports the wait as a failure so the
* batch records it rather than blocking forever.
*
* @param non-empty-string $jobId
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return array<string, mixed> The terminal job record (data object)
*/
private function pollToTerminal(string $jobId): array
{
$url = $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId);
for ($attempt = 0; $attempt < $this->maxPolls; $attempt++) {
$request = $this->requestFactory
->createRequest('GET', $url)
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new BatchJobException(
sprintf('Poll for job "%s" returned HTTP %d.', $jobId, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobStatus = is_string($data['status'] ?? null) ? $data['status'] : 'unknown';
$progress = is_int($data['progress'] ?? null) ? $data['progress'] : null;
$this->logProgress($jobId, $jobStatus, $progress);
// Terminal states: completed, failed, cancelled.
if (in_array($jobStatus, ['completed', 'failed', 'cancelled'], strict: true)) {
return $data;
}
// Non-terminal: wait the interval the server asked for.
$this->waitRetryAfter($response->getHeaderLine('Retry-After'));
}
throw new BatchJobException(
sprintf('Job "%s" did not finish within %d polls.', $jobId, $this->maxPolls),
);
}
/**
* Act on a terminal job record: download a completed PDF, or report.
*
* @param non-empty-string $key Document key
* @param array<string, mixed> $record Terminal job record (data object)
* @param non-empty-string $outputDir Where to write the PDF
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return string A human-readable outcome line
*/
private function finish(string $key, array $record, string $outputDir): string
{
$jobStatus = is_string($record['status'] ?? null) ? $record['status'] : 'unknown';
$jobId = is_string($record['job_id'] ?? null) ? $record['job_id'] : '';
if ($jobStatus !== 'completed') {
// A failed job carries an error message; surface it, do not swallow.
$error = is_string($record['error'] ?? null) ? $record['error'] : 'no detail';
return sprintf('%s -> %s (%s)', $key, $jobStatus, $error);
}
$path = rtrim($outputDir, '/\\') . DIRECTORY_SEPARATOR . $key . '.pdf';
$this->download($jobId, $path);
return sprintf('%s -> completed, written to %s', $key, $path);
}
/**
* Download a completed job result and write it to a server-derived path.
*
* @param non-empty-string $jobId
* @param non-empty-string $path Caller-controlled output path
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*/
private function download(string $jobId, string $path): void
{
$request = $this->requestFactory
->createRequest('GET', $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId) . '/result')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new BatchJobException(
sprintf('Result download for job "%s" returned HTTP %d.', $jobId, $response->getStatusCode()),
);
}
$bytes = $response->getBody()->__toString();
if (!str_starts_with($bytes, '%PDF')) {
throw new BatchJobException(
sprintf('Result for job "%s" is not a PDF.', $jobId),
);
}
if (file_put_contents($path, $bytes) === false) {
throw new BatchJobException(sprintf('Could not write result to "%s".', $path));
}
}
/**
* Sleep for the server-requested interval, with a safe floor and ceiling.
*/
private function waitRetryAfter(string $retryAfter): void
{
$seconds = ctype_digit($retryAfter) ? (int) $retryAfter : 2;
// Clamp to a sane band so a hostile header cannot stall or busy-loop.
$seconds = max(1, min(30, $seconds));
sleep($seconds);
}
/**
* Emit a progress line. Replace with your logger.
*/
private function logProgress(string $jobId, string $jobStatus, ?int $progress): void
{
$pct = $progress === null ? 'n/a' : $progress . '%';
fwrite(STDERR, sprintf("[%s] status=%s progress=%s\n", $jobId, $jobStatus, $pct));
}
/**
* Decode a response envelope and return its data object.
*
* @throws BatchJobException When the body is not the expected envelope
*
* @return array<string, mixed>
*/
private function decodeData(string $json): array
{
try {
/** @var mixed $decoded */
$decoded = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BatchJobException('Response body is not valid JSON.', previous: $e);
}
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
throw new BatchJobException('Response is missing the data envelope.');
}
/** @var array<string, mixed> $data */
$data = $decoded['data'];
return $data;
}
/**
* @param array<string, mixed> $body
*
* @throws BatchJobException
*/
private function encode(array $body): string
{
try {
return json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (JsonException $e) {
throw new BatchJobException('Render request body is not encodable.', previous: $e);
}
}
/**
* @param non-empty-string $dir
*
* @throws BatchJobException
*/
private function assertWritableDir(string $dir): void
{
if (!is_dir($dir) || !is_writable($dir)) {
throw new BatchJobException(sprintf('Output directory "%s" is not writable.', $dir));
}
}
}
// ---------------------------------------------------------------------------
// Wiring. Provide your project's concrete PSR-18 client and PSR-17 factories.
// ---------------------------------------------------------------------------
/** @var ClientInterface $httpClient */
/** @var RequestFactoryInterface $requestFactory */
/** @var StreamFactoryInterface $streamFactory */
$baseUrl = getenv('NEXTPDF_CONNECT_URL');
$token = getenv('NEXTPDF_CONNECT_TOKEN');
if ($baseUrl === false || $baseUrl === '' || $token === false || $token === '') {
fwrite(STDERR, "Set NEXTPDF_CONNECT_URL and NEXTPDF_CONNECT_TOKEN.\n");
exit(2);
}
/** @var array<non-empty-string, array<string, mixed>> $documents */
$documents = [
'invoice-0001' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0001'],
],
],
'invoice-0002' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0002'],
],
],
];
$runner = new ConnectBatchRunner(
httpClient: $httpClient,
requestFactory: $requestFactory,
streamFactory: $streamFactory,
baseUrl: rtrim($baseUrl, '/'),
bearerToken: $token,
maxInFlight: 8,
);
try {
$outcomes = $runner->run($documents, getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: sys_get_temp_dir());
} catch (BatchJobException $e) {
fwrite(STDERR, 'Batch stopped: ' . $e->getMessage() . "\n");
exit(1);
} catch (ClientExceptionInterface $e) {
fwrite(STDERR, 'Transport failure: ' . $e->getMessage() . "\n");
exit(1);
}
foreach ($outcomes as $line) {
echo $line, "\n";
}

STDOUT attendu, une ligne par document ; les chemins dépendent de ton répertoire de sortie :

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Lis les champs du job sous data, pas au niveau supérieur. Chaque réponse réussie est placée dans une enveloppe { "data": ..., "meta": ... }. data.status et data.progress sont les champs sur lesquels tu agis ; meta porte le request_id pour la corrélation avec le support.
  • progress peut être absent. Le serveur n’inclut progress que lorsqu’il le suit pour ce job. Traite un champ manquant comme « inconnu », pas comme zéro, et pilote ta boucle à partir de status, qui est toujours présent.
  • La soumission peut déjà être terminale. Dans la version actuelle, le serveur effectue le rendu de manière synchrone avant de répondre au POST, donc la réponse de soumission peut porter status: completed et le résultat peut être prêt dès le premier sondage. Ta boucle de sondage doit accepter un état terminal dès la tentative zéro plutôt que d’exiger d’abord un pending.
  • Respecte Retry-After. Les réponses de statut non terminales incluent Retry-After (un intervalle de 2 secondes). Interroger plus vite gaspille des requêtes et t’expose à un 429. Borne la valeur dans une plage raisonnable plutôt que de lui faire aveuglément confiance.
  • /result avant l’achèvement est un 409. N’appelle le point de terminaison de résultat qu’après que le sondage de statut a montré completed. Un 409 Conflict signifie que le job n’est pas terminé ; ce n’est pas une erreur de transport.
  • Idempotency-Key empêche le travail dupliqué. Une soumission réessayée avec la même clé renvoie le job d’origine (200 au lieu de 201). Utilise une clé stable par document pour qu’un réessai réseau ne lance jamais un second rendu. Une clé réutilisée avec un corps différent est un conflit 409.
  • Les jobs sont limités à leur propriétaire. Un job soumis sous une clé d’API est invisible pour une autre ; un GET entre propriétaires renvoie 404, et non 403. Interroge avec la même clé d’API que celle utilisée pour la soumission.
  • Un job failed porte un message error. Lis data.error sur un statut terminal failed et consigne-le. Ne réessaie pas aveuglément.

Le coût d’un lot est la somme des rendus plus la surcharge de sondage. Deux leviers côté client le contrôlent. D’abord, borne la concurrence : le plafond maxInFlight détermine combien de jobs sont suivis à la fois, ce qui maintient le nombre de requêtes ouvertes et la mémoire du client constants, quelle que soit la taille du lot. Aligne-le sur le nombre de workers du serveur, pas plus ; plus de jobs en vol que de workers ne fait qu’allonger l’attente en file de chaque job. Ensuite, respecte l’intervalle de sondage : chaque sondage est une lecture de statut peu coûteuse, mais une boucle serrée multiplie le volume de requêtes et déclenche le limiteur de débit. Le Retry-After de 2 secondes du serveur est la bonne valeur par défaut, et le runner le borne dans une plage de 1 à 30 secondes pour qu’un seul job lent ne puisse ni boucler à vide ni bloquer la fenêtre.

Pour de très grands lots, traite par fenêtres (le runner utilise array_chunk) plutôt que de tout soumettre d’emblée. Cela borne à la fois l’état suivi par le client et la profondeur de file du serveur, de sorte qu’un lot malformé ou surdimensionné échoue à l’intérieur d’une fenêtre plutôt qu’après des milliers de soumissions.

  • Garde le jeton porteur hors des journaux et des URL. La clé d’API circule uniquement dans l’en-tête Authorization. Ne la place jamais dans une chaîne de requête, une ligne de journal ou un artefact écrit. Le runner consigne le job_id et le status, jamais l’identifiant secret.
  • Dérive les chemins de sortie à partir de clés contrôlées côté client. Le runner construit chaque chemin de sortie à partir de la clé de document choisie par ton code, jointe à un répertoire de sortie fixe, jamais à partir d’une valeur renvoyée par le serveur. N’interpole pas un champ de job dans un chemin du système de fichiers : cela ouvrirait la voie à une traversée de chemin.
  • Valide les octets téléchargés. Le runner vérifie un 200 de /result et l’en-tête %PDF avant d’écrire le fichier. Un statut de téléchargement réussi ne prouve pas à lui seul que le corps est un PDF.
  • Traite le résultat comme non fiable jusqu’à inspection. Un job terminé signifie que le serveur a généré des octets, pas que ces octets sont sûrs à transmettre. Fais passer les résultats par une étape d’inspection structurelle avant de les remettre à un client ou à un système en aval.
  • Utilise une clé à moindre privilège. La surface de jobs asynchrones relève du rendu de niveau Core. Attribue au lot une clé dont la portée se limite exactement aux opérations dont il a besoin, et renouvelle-la selon le calendrier défini par ta politique de gestion des secrets.
  • Borne le budget de sondage. maxPolls empêche un job bloqué de retenir le client indéfiniment. Le lot enregistre le dépassement de délai comme un résultat plutôt que de bloquer, ce qui empêche un seul job défaillant de priver les autres du service.

Ce recipe n’émet aucune revendication normative de conformité à des normes. Il utilise les points de terminaison REST des jobs asynchrones de NextPDF Connect (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) et lit les champs de l’enregistrement du job définis par le serveur (status, progress, error, result_url, poll_url). La vérification de l’en-tête %PDF sur un résultat téléchargé confirme seulement que la réponse commence par le marqueur PDF ; ce n’est pas une détermination de validité ou de conformité. Pour une vérification de conformité aux normes sur un ensemble de documents, utilise l’outil de conformité par lots Enterprise. Voir Vérification de conformité aux normes par lots via Connect, qui décrit une surface différente des jobs de rendu couverts ici.