> ## Content Index
> Fetch the complete content index at: https://www.sfeir.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# L'imprimerie de la taverne : tickets et factures avec JasperReports et Quarkus
- URL: https://www.sfeir.dev/back/limprimerie-de-la-taverne-tickets-et-factures-avec-jasperreports-et-quarkus/
- Published: 2026-11-06T09:23:38.000Z
- Updated: 2026-11-06T09:23:38.000Z
- Description: À la taverne The Falling Whale, les commandes s’accumulent et les clients réclament leurs factures. Avec JasperReports et Quarkus, transformons ce chaos en tickets, PDF et exports Excel, sans perdre le fil entre données Java et mise en page.
- Author: Erwan Le Tutour
- Tags: Back, Java, quarkus, jasper, reporting

Il est 22h passées, à *The Falling Whale*. À la Table du Dragon, un nain repousse sa chope vide et plisse les yeux devant l'addition que Brunehilde vient de griffonner au charbon sur un bout d'écorce : "Quarante-cinq pièces d'or et trente d'argent ? J'ai bu deux chopes, pas trois !". Brunehilde soupire. Impossible de prouver quoi que ce soit : l'écorce est déjà à moitié effacée.

Le lendemain matin, rebelote, mais de l'autre côté du comptoir. Le livreur de la Brasserie Barbe-de-Fer dépose quatre tonneaux et tend un parchemin froissé, couvert de ratures, qui fait office de facture. Le tavernier cherche la TVA, ne la trouve pas, et range le tout dans un tiroir où dorment déjà une trentaine de parchemins du même genre.

Une taverne qui sert des centaines d'aventuriers par soir ne peut pas fonctionner comme ça. Il lui faut une **imprimerie** : de quoi remettre un vrai ticket de caisse à chaque client, et produire des factures propres, paginées, avec un récapitulatif de TVA lisible par n'importe quel intendant.

Dans le monde réel, cette imprimerie porte un nom un peu austère : **l'éditique**, c'est-à-dire la production automatisée de documents (tickets, factures, relevés, contrats). Et dans l'écosystème Java, l'outil historique pour ça s'appelle **JasperReports**. Voyons comment l'installer dans notre taverne [Quarkus](https://www.sfeir.dev/quarkus-definition/).

## Pourquoi pas Qute ?

La question est légitime. La taverne sait déjà afficher des pages grâce à [Qute](https://www.sfeir.dev/back/qute-le-scribe-du-donjon-pour-afficher-la-taverne-avec-quarkus/), son scribe attitré. Pourquoi ne pas lui demander d'écrire aussi les tickets ?

Parce que Qute produit du **texte** (HTML, JSON, e-mails...), alors qu'une facture est un **document mis en page** : un format de papier, des marges, des sauts de page, un en-tête de colonnes qui se répète en haut de chaque page, un "Page 2 / 3" en pied de page. On pourrait générer du HTML puis le convertir en PDF, mais on se retrouve vite à se battre avec le CSS d'impression.

JasperReports raisonne directement en termes de pages et de bandes : un titre, un en-tête de colonnes, une bande de détail répétée pour chaque ligne, un pied de page, un résumé. C'est exactement le vocabulaire d'une imprimerie. Et un même rapport rempli peut être exporté en PDF, en XLSX, en HTML ou en CSV, même si chaque format a ses propres contraintes de rendu : un CSV reste une représentation tabulaire, pas un document paginé, et un modèle pensé pour un ticket thermique ne donnera pas automatiquement un tableur bien conçu.

Si Qute est le scribe qui écrit à la volée, JasperReports est le typographe qui compose ses plaques une bonne fois pour toutes.

## Installer la presse

Le projet [Quarkiverse](https://github.com/quarkiverse/quarkus-jasperreports?ref=sfeir.dev) propose une [extension](Quarkus - La forge aux extensions) dédiée. Une dépendance suffit :

```xml
<dependency>
    <groupId>io.quarkiverse.jasperreports</groupId>
    <artifactId>quarkus-jasperreports</artifactId>
    <version>1.4.3</version>
</dependency>

```

Dans cette version, l'extension embarque **JasperReports 7.1.0** (un `mvn dependency:tree` le confirme, et c'est à revérifier à chaque montée de version) ainsi que tout ce qu'il faut autour : l'export PDF, l'export Excel, les codes-barres, et surtout le module de polices `jasperreports-fonts`, dont on reparlera.

Mais son vrai apport est ailleurs : elle **compile les rapports pendant le build**. Un rapport JasperReports se décrit dans un fichier `.jrxml` (le modèle, en XML), qui doit être compilé en `.jasper` (un objet de rapport compilé et sérialisé) avant de pouvoir être rempli. Sans l'extension, on fait cette compilation au démarrage de l'application, ou pire, à chaque requête. Avec elle, tout est fait par Maven :

```properties
quarkus.jasperreports.build.source=src/main/jasperreports
quarkus.jasperreports.build.destination=jasperreports

```

Ces deux valeurs sont celles par défaut, mais je les laisse en clair pour qu'on voie bien le chemin : les `.jrxml` vivent dans `src/main/jasperreports/`, et les `.jasper` compilés atterrissent sur le classpath dans `jasperreports/`. Si un `.jrxml` contient une erreur, c'est le **build** qui échoue, pas le premier client qui demande son ticket. C'est la philosophie de Quarkus appliquée à l'éditique : tout ce qui peut être fait à la compilation doit l'être.

Attention à ne pas en attendre plus qu'elle n'en promet : la compilation valide la structure du modèle et la syntaxe de ses expressions Java, pas les données. Un champ qui renvoie `null`, un libellé trop long pour sa zone ou un montant mal arrondi ne se verront qu'au remplissage. Même chose pour le mode natif : l'extension vise la compatibilité GraalVM/Mandrel, mais les expressions, les ressources, les polices et les exporteurs restent à tester dans l'image réellement déployée. Les exemples de cet article ont été validés en JVM uniquement.

## Le registre : des getters, pas des records

Avant d'imprimer quoi que ce soit, il faut des données. JasperReports se nourrit d'une **source de données** (`JRDataSource`), et la plus simple de toutes est `JRBeanCollectionDataSource` : on lui donne une liste d'objets Java, et chaque champ du rapport est lu sur l'objet courant.

Petit piège : cette source lit les champs selon les **conventions JavaBean**. Un champ `libelle` dans le rapport appelle `getLibelle()` sur l'objet. Les accesseurs des records Java (`libelle()`) ne sont pas reconnus. Les classes du modèle sont donc de bons vieux POJO à getters :

```java
public class LigneCommande {

    private final String libelle;
    private final Integer quantite;
    private final BigDecimal prixUnitaire;

    // constructeur et getters...

    public BigDecimal getMontant() {
        return prixUnitaire.multiply(BigDecimal.valueOf(quantite));
    }
}

```

Le corollaire est plutôt agréable : tout getter devient un champ utilisable dans le rapport, y compris les getters **calculés**. `getMontant()` sur une ligne, `getTotalTtc()` ou `getRecapTva()` sur une facture : le calcul reste en Java, testable unitairement, et le rapport se contente d'afficher.

Deux règles pour que ça reste sain. D'abord, ces getters doivent rester simples, déterministes et peu coûteux : JasperReports peut les appeler plusieurs fois pendant le remplissage, ce n'est pas l'endroit pour une requête en base. Ensuite, toute la logique financière (arrondis, taux, cas limites) se décide en Java, avec des `BigDecimal` et un `RoundingMode` explicite. Le rapport **restitue** des montants, il ne les décide jamais.

## La presse : `ImprimerieService`

Côté Java, toute l'imprimerie tient dans un seul service. Il charge les rapports compilés, les remplit avec des paramètres et une source de données, puis exporte le résultat.

```java
private JasperReport charger(String nom) {
    return rapports.computeIfAbsent(nom, n -> {
        String emplacement = "jasperreports/" + n + ".jasper";
        try (InputStream flux = Thread.currentThread().getContextClassLoader()
                .getResourceAsStream(emplacement)) {
            if (flux == null) {
                throw new IllegalStateException("Rapport compilé introuvable : " + emplacement);
            }
            return (JasperReport) JRLoader.loadObject(flux);
        } catch (JRException | IOException e) {
            throw new IllegalStateException("Impossible de charger : " + emplacement, e);
        }
    });
}

```

Le test `flux == null` n'est pas décoratif : `getResourceAsStream()` renvoie `null` quand la ressource n'existe pas, et sans ce contrôle on récolterait une `NullPointerException` anonyme au fond de `JRLoader`, au lieu d'un message qui nomme le rapport manquant.

Chaque plaque est chargée une seule fois puis gardée en mémoire. Un rapport compilé est un objet de conception réutilisable, que l'on peut conserver en cache plutôt que de le recharger à chaque requête : c'est exactement le rôle de notre `ConcurrentHashMap`. Les documents produits, eux, ne sont jamais mis en cache : chaque PDF est généré à la demande (on en parlait déjà au moment de [décider quoi mettre sur l'ardoise](https://www.sfeir.dev/back/lardoise-magique-au-dessus-du-comptoir-utilisation-du-cache-dans-quarkus/)). Attention toutefois, le service renvoie un `byte[]` : le temps de la requête, le document complet est bien matérialisé en mémoire. C'est parfait pour des tickets et des factures de quelques dizaines de kilo-octets. Pour des documents volumineux, on exporterait plutôt vers un flux, un fichier temporaire ou un stockage objet, en maîtrisant alors la durée de vie et le nettoyage de ces ressources.

Le remplissage et l'export tiennent en quelques lignes :

```java
public byte[] imprimerTicket(Commande commande) {
    Map<String, Object> parametres = parametresTaverne();
    parametres.put("NUMERO", commande.getNumero());
    parametres.put("TABLE", commande.getTable());
    parametres.put("SERVEUR", commande.getServeur());
    parametres.put("DATE_COMMANDE", commande.getDate());
    parametres.put("PAIEMENTS", commande.getPaiements());
    JasperPrint print = remplir("ticket-de-caisse", parametres,
            new JRBeanCollectionDataSource(commande.getLignes()));
    return exporterPdf(print);
}

private Map<String, Object> parametresTaverne() {
    Map<String, Object> parametres = new HashMap<>();
    parametres.put(JRParameter.REPORT_LOCALE, Locale.FRANCE);
    parametres.put("NOM_TAVERNE", NOM_TAVERNE);
    parametres.put("ADRESSE_TAVERNE", ADRESSE_TAVERNE);
    return parametres;
}

```

Deux notions à bien distinguer :

- Les **paramètres** (`$P{...}` dans le rapport) sont des valeurs uniques pour tout le document : le nom de la taverne, le numéro de commande, la table.
- Les **champs** (`$F{...}`) viennent de la source de données et changent à chaque ligne : ici, chaque `LigneCommande` du ticket.

Le paramètre `REPORT_LOCALE` mérite une mention : c'est lui qui fait sortir `45,30` plutôt que `45.30`, et des dates à la française.

## Le ticket de caisse : un rouleau de 80 mm

Premier document : le ticket que Brunehilde aurait dû tendre au nain. Une imprimante thermique imprime sur un rouleau de 80 mm de large, soit environ 226 points, et la longueur du ticket dépend du nombre de consommations. Dans le `.jrxml` :

```xml
<jasperReport name="ticket-de-caisse" language="java"
              pageWidth="226" pageHeight="842" columnWidth="210"
              leftMargin="8" rightMargin="8" topMargin="8" bottomMargin="8"
              ignorePagination="true">

```

`ignorePagination="true"` est la clé. Le `pageHeight` n'est alors plus qu'une valeur de conception : au remplissage, JasperReports ne coupe jamais en pages et produit **une seule page dont la hauteur est celle du contenu**. Pas besoin de me croire sur parole, il suffit de mesurer la `MediaBox` des PDF générés : le ticket de la Table du Dragon (3 consommations, 2 règlements) fait 226 × 284 points, celui du Coin du Barde (1 pinte, 1 règlement) 226 × 244\. Aucun des deux ne fait les 842 points déclarés. Un test le vérifie, d'ailleurs.

Le PDF s'adapte donc tout seul. L'imprimante, c'est une autre histoire : selon le pilote, une imprimante thermique peut attendre un format de page fixe, couper le papier à une longueur prédéfinie ou imprimer en flux continu. Avant de brancher l'imprimerie sur un vrai comptoir, il faut vérifier comment le couple pilote-imprimante gère ces pages de hauteur variable.

Le corps du ticket est une bande `detail`, répétée pour chaque ligne de la commande :

```xml
<detail>
    <band height="14" splitType="Stretch">
        <element kind="textField" x="0" y="1" width="28" height="12" hTextAlign="Right">
            <expression><![CDATA[$F{quantite} + " x"]]></expression>
        </element>
        <element kind="textField" x="34" y="1" width="106" height="12" textAdjust="StretchHeight">
            <expression><![CDATA[$F{libelle}]]></expression>
        </element>
        <element kind="textField" x="140" y="1" width="70" height="12"
                 pattern="#,##0.00 po" hTextAlign="Right">
            <expression><![CDATA[$F{montant}]]></expression>
        </element>
    </band>
</detail>

```

Le motif `#,##0.00 po` affiche les montants en pièces d'or. Et le total ? Pas besoin de le passer depuis Java : une **variable** de rapport s'en charge.

```xml
<variable name="TOTAL" calculation="Sum" class="java.math.BigDecimal">
    <expression><![CDATA[$F{montant}]]></expression>
</variable>

```

### Attention, nouveau format de plaques

Si vous avez déjà croisé du JRXML, ce `<element kind="textField">` vous a peut-être surpris. **JasperReports 7 a changé le format XML des `.jrxml`** : là où la v6 écrivait `<textField><reportElement .../><textFieldExpression>...`, la v7 écrit `<element kind="textField"><expression>...`. Les concepts, eux, n'ont pas bougé : paramètres, champs, variables et bandes fonctionnent comme avant. C'est la syntaxe des fichiers qui change, et un modèle v6 doit être migré avant d'être compilé par la v7, par exemple en l'ouvrant puis en le réenregistrant dans Jaspersoft Studio 7\. C'est le premier piège quand on suit un tutoriel trouvé en ligne, la grande majorité date de l'ancien format.

## Le composant list : le détail du règlement

Revenons au nain de la Table du Dragon. Il a payé 40 pièces d'or... et une dent de wyverne, estimée à 5,30 pièces par Brunehilde. Le ticket doit détailler ces deux règlements.

Problème : la source de données du ticket, ce sont déjà les consommations. Pour afficher une **seconde collection** dans le même rapport, JasperReports propose deux outils : le sous-rapport, qu'on verra juste après, et le **composant list**, plus léger.

Le composant list s'appuie sur un `dataset` secondaire, déclaré en tête du rapport :

```xml
<dataset name="Paiements">
    <field name="mode" class="java.lang.String"/>
    <field name="montant" class="java.math.BigDecimal"/>
</dataset>
<parameter name="PAIEMENTS" class="java.util.List"/>

```

Puis, dans le `summary` du ticket, on place le composant et on lui indique où trouver ses données :

```xml
<element kind="component" x="0" y="46" width="210" height="12">
    <component kind="list" printOrder="Vertical">
        <datasetRun subDataset="Paiements">
            <dataSourceExpression><![CDATA[
                new net.sf.jasperreports.engine.data.JRBeanCollectionDataSource($P{PAIEMENTS})
            ]]></dataSourceExpression>
        </datasetRun>
        <contents height="12">
            <element kind="textField" x="8" y="0" width="132" height="12">
                <expression><![CDATA["• " + $F{mode}]]></expression>
            </element>
            <element kind="textField" x="140" y="0" width="70" height="12"
                     pattern="#,##0.00 po" hTextAlign="Right">
                <expression><![CDATA[$F{montant}]]></expression>
            </element>
        </contents>
    </component>
</element>

```

Le bloc `contents` est répété pour chaque paiement. Pas de fichier supplémentaire, pas de compilation séparée : tout vit dans le ticket. Le résultat, c'est enfin un ticket que personne ne peut contester :

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/10/image-33.png)

Trois chopes, noir sur blanc. Le nain peut toujours râler, mais il râlera devant une preuve.

## La facture fournisseur : l'art du sous-rapport

Deuxième document, plus ambitieux : la facture A4 de la Brasserie Barbe-de-Fer. On y trouve un en-tête (fournisseur, client, dates), un tableau de lignes potentiellement long, un récapitulatif de TVA par taux, et les totaux.

C'est le terrain de jeu idéal pour le **sous-rapport** : un rapport à part entière (son propre `.jrxml`, sa propre mise en page, son propre en-tête de colonnes) inséré dans un rapport maître. La facture en utilise deux, et elle les branche chacun d'une manière différente, pour montrer les deux approches.

Le rapport maître reçoit une source de données d'un seul élément, la facture elle-même, et déclare ses collections comme des champs de type `java.util.List` :

```xml
<field name="lignes" class="java.util.List"/>
<field name="recapTva" class="java.util.List"/>

```

### Premier cas : le sous-rapport passé en paramètre

Pour les lignes, c'est le Java qui charge le sous-rapport compilé et le confie au maître :

```java
parametres.put("SOUS_RAPPORT_LIGNES", charger("facture-lignes"));

```

Le maître le déclare comme un paramètre de type `JasperReport` et l'utilise dans un élément `subreport` :

```xml
<element kind="subreport" x="0" y="2" width="555" height="20">
    <expression><![CDATA[$P{SOUS_RAPPORT_LIGNES}]]></expression>
    <dataSourceExpression><![CDATA[
        new net.sf.jasperreports.engine.data.JRBeanCollectionDataSource($F{lignes})
    ]]></dataSourceExpression>
    <returnValue toVariable="NB_LIGNES" subreportVariable="REPORT_COUNT"/>
</element>

```

L'avantage : le chargement passe par notre cache, et le Java garde la main sur le sous-rapport à utiliser (on pourrait imaginer un gabarit de lignes différent selon le fournisseur).

### Deuxième cas : le sous-rapport trouvé par son chemin

Pour le récapitulatif de TVA, on laisse JasperReports se débrouiller seul :

```xml
<element kind="subreport" x="0" y="28" width="270" height="20">
    <expression><![CDATA["jasperreports/facture-recap-tva.jasper"]]></expression>
    <dataSourceExpression><![CDATA[
        new net.sf.jasperreports.engine.data.JRBeanCollectionDataSource($F{recapTva})
    ]]></dataSourceExpression>
    <parameter name="NUMERO_FACTURE">
        <expression><![CDATA[$F{numero}]]></expression>
    </parameter>
</element>

```

Quand l'expression est une chaîne, JasperReports la résout comme une ressource : il la cherche sur le classpath, là où l'extension a justement déposé les `.jasper`. Côté Java, rien à faire. C'est plus simple, mais le rapport devient dépendant de l'emplacement des fichiers compilés et du packaging : si la ressource n'est pas là où on l'attend, l'erreur surgit au remplissage, au fond de JasperReports. L'approche par paramètre, qui passe par `charger()`, rend le chargement explicite et centralise les erreurs. C'est celle que je privilégierais en production, la seconde étant surtout pratique pour les rapports conçus et testés depuis Jaspersoft Studio.

### Faire passer des messages entre maître et sous-rapport

Les deux extraits ci-dessus cachent deux mécanismes très pratiques.

**Dans le sens descendant**, l'élément `<parameter>` transmet une valeur du maître au sous-rapport. Le récapitulatif de TVA reçoit ainsi le numéro de la facture et l'affiche sous son tableau : "Détail de la TVA de la facture FAC-BDF-2026-117". Le sous-rapport doit, bien sûr, déclarer ce paramètre de son côté (`<parameter name="NUMERO_FACTURE" class="java.lang.String"/>`). Attention, un sous-rapport ne voit **pas** les paramètres de son maître : tout ce dont il a besoin doit lui être transmis explicitement.

**Dans le sens montant**, `<returnValue>` permet au sous-rapport de renvoyer une de ses variables au maître. Ici, on récupère la variable intégrée `REPORT_COUNT` (le nombre de lignes traitées) dans une variable du maître :

```xml
<variable name="NB_LIGNES" calculation="System" class="java.lang.Integer"/>

```

La facture peut alors afficher "Lignes facturées : 3" en bas de page, sans que le Java ait compté quoi que ce soit. C'est le même mécanisme qu'on utiliserait pour remonter un sous-total calculé dans le sous-rapport.

Et pour une facture sans aucune ligne ? Le cas mérite un test, parce qu'un sous-rapport sans données n'est, par défaut, pas imprimé du tout... et ne renvoie alors rien. Ici, le sous-rapport des lignes déclare `whenNoDataType="AllSectionsNoDetail"` : il est rempli même vide, son `REPORT_COUNT` vaut 0, et la facture affiche bien "Lignes facturées : 0" plutôt qu'un `null` disgracieux. Un test construit une facture vide pour verrouiller ce comportement : si quelqu'un retire l'attribut un jour, il cassera.

### Une démonstration, pas un logiciel de facturation

Une précision avant d'aller plus loin. Cette facture est un exemple pédagogique, pas un document conforme aux obligations comptables et fiscales. Dans un vrai système, les taux, les bases, les montants de TVA, les règles d'arrondi et les mentions obligatoires viennent de données métier validées, et dépendent du régime de chaque entreprise. Le rapport, lui, se contente de les restituer fidèlement. C'est d'ailleurs pour ça que tout le calcul vit dans `FactureFournisseur` et non dans le `.jrxml`.

### La pagination, offerte

La facture de la Meunerie du Val-Doré compte 40 lignes de farines et de grains. Elle déborde naturellement sur une deuxième page, et c'est là que le sous-rapport montre tout son intérêt : son `columnHeader` (Réf., Désignation, Qté...) est **répété automatiquement** en haut de la page 2.

Pour le "Page X / Y" du pied de page, il faut une petite astuce : au moment où la page 1 est imprimée, JasperReports ne sait pas encore combien il y aura de pages. On découpe donc en deux champs :

```xml
<element kind="textField" x="400" y="6" width="125" height="14" hTextAlign="Right">
    <expression><![CDATA["Page " + $V{PAGE_NUMBER}]]></expression>
</element>
<element kind="textField" x="525" y="6" width="30" height="14" evaluationTime="Report">
    <expression><![CDATA[" / " + $V{PAGE_NUMBER}]]></expression>
</element>

```

Le second champ est évalué en fin de rapport (`evaluationTime="Report"`), quand `PAGE_NUMBER` contient le nombre total de pages. Il est ensuite reporté sur toutes les pages déjà produites.

Ce genre d'astuce se vérifie sur le PDF exporté, pas à l'œil sur le `.jrxml`. Les tests extraient le texte page par page et contrôlent "Page 1 / 1" sur la facture du brasseur, puis "Page 1 / 2" sur la première page de la meunerie et "Page 2 / 2" sur la seconde. Ils vérifient au passage que l'en-tête "Désignation" figure bien sur les deux pages : c'est le `columnHeader` du **sous-rapport** des lignes qui se répète, pas celui du maître.

## Le relevé des fournisseurs : une liste dans chaque bande

Dernier document, pour l'intendant : le relevé mensuel. Pour chaque fournisseur, la liste de ses factures du mois, un sous-total, et un total général en bas.

La source de données principale est une liste de `ReleveFournisseur` (un fournisseur et ses factures). Chaque fournisseur occupe une bande de détail, et à l'intérieur, un composant list parcourt les factures **du fournisseur courant** :

```xml
<datasetRun subDataset="Factures">
    <dataSourceExpression><![CDATA[
        new net.sf.jasperreports.engine.data.JRBeanCollectionDataSource($F{factures})
    ]]></dataSourceExpression>
</datasetRun>

```

C'est la même mécanique que pour les paiements du ticket, à une différence près : la collection vient d'un **champ** (`$F{factures}`) et non d'un paramètre. Elle change donc à chaque bande. La Brasserie Barbe-de-Fer affiche sa facture, la Fromagerie des Collines ses deux factures, et une variable `Sum` sur `totalTtc` cumule le total général de 11 704,03 pièces d'or. Au moins, l'intendant sait à quoi s'attendre.

## Sous-rapport ou composant list ?

Après ces trois documents, la question revient forcément : quand choisir l'un ou l'autre ?

| Besoin                                               | Composant list | Sous-rapport         |
| ---------------------------------------------------- | -------------- | -------------------- |
| Répéter quelques champs dans un rapport              | ✅ idéal        | possible, mais lourd |
| Fichier séparé, réutilisable dans plusieurs rapports | ❌              | ✅                    |
| En-tête de colonnes répété à chaque saut de page     | ❌              | ✅                    |
| Bandes propres (title, summary, groupes...)          | ❌              | ✅                    |
| Renvoyer une valeur au rapport maître                | ❌              | ✅ returnValue        |

La règle que j'applique : **le composant list tant que c'est une simple énumération**, le sous-rapport dès que le bloc a sa propre mise en page ou doit vivre sur plusieurs pages. Les paiements d'un ticket sont une énumération ; les lignes d'une facture de 40 articles, non.

## Les polices : le piège classique

Un détail qui n'en est pas un. Tous les rapports de la taverne déclarent un style par défaut :

```xml
<style name="Base" default="true" fontName="DejaVu Sans" fontSize="9.0"/>

```

Sans police explicitement embarquée, JasperReports utilise les polices de la machine qui génère le PDF. Sur le poste du développeur, tout va bien. Sur un conteneur Linux minimaliste en production, on obtient au mieux des accents remplacés par des carrés, au pire une exception du type *"Font 'SansSerif' is not available to the JVM"*.

La solution est de fournir les polices via une **font extension** JasperReports. Bonne nouvelle : `quarkus-jasperreports` embarque `jasperreports-fonts`, qui contient la famille **DejaVu**. Il suffit de l'utiliser partout dans les rapports : la police est alors incorporée au PDF, ce qui stabilise fortement le rendu d'une machine à l'autre. Fortement, pas parfaitement : le lecteur PDF, l'imprimante ou un autre format d'export (XLSX, HTML) peuvent encore introduire des écarts.

Côté natif, la documentation de l'extension signale que certaines images minimales (UBI minimal, par exemple) doivent embarquer des bibliothèques système comme `freetype` et `fontconfig` pour que le rendu des polices fonctionne. Encore une raison de tester dans l'image de production, pas seulement sur son poste.

## Le comptoir : servir les documents en REST

Il reste à mettre tout ça à disposition via une [API REST](https://www.sfeir.dev/rest-definition/). Rien de bien sorcier, si ce n'est le choix de l'en-tête `Content-Disposition` :

```java
@GET
@Path("/commandes/{numero}/ticket")
@Produces("application/pdf")
public Response ticket(@PathParam("numero") String numero) {
    return archives.trouverCommande(numero)
            .map(commande -> Response.ok(imprimerie.imprimerTicket(commande))
                    .type("application/pdf")
                    .header("Content-Disposition",
                            "inline; filename=\"" + nomDeFichier("ticket", commande.getNumero(), "pdf") + "\"")
                    .build())
            .orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build());
}

static String nomDeFichier(String prefixe, String numero, String extension) {
    String numeroAssaini = numero == null ? "" : numero.replaceAll("[^A-Za-z0-9-]", "_");
    return prefixe + "-" + numeroAssaini + "." + extension;
}

```

On ne recopie jamais tel quel le paramètre de chemin dans un en-tête HTTP. Ici, il ne pourrait de toute façon y arriver que s'il correspond à une commande existante, mais deux précautions valent mieux qu'une : le nom de fichier est construit à partir du numéro **archivé**, puis assaini. Un guillemet, un retour à la ligne ou une barre oblique deviennent de simples `_` et ne peuvent plus casser l'en-tête.

- `inline` demande au navigateur d'**afficher** le document : parfait pour un ticket qu'on consulte.
- `attachment` déclenche un **téléchargement** : c'est le choix retenu pour les factures, qu'on archive.

Et puisqu'un rapport rempli (`JasperPrint`) est indépendant du format de sortie, la facture s'exporte aussi en tableur pour l'intendant, via `?format=xlsx`. Seul l'exporteur change :

```java
JRXlsxExporter exporteur = new JRXlsxExporter();
exporteur.setExporterInput(new SimpleExporterInput(print));
exporteur.setExporterOutput(new SimpleOutputStreamExporterOutput(sortie));
exporteur.exportReport();

```

Petite subtilité, vérifiée dans les jars de la 7.1.0 : `JRXlsxExporter` (format `.xlsx`) se trouve dans le cœur de JasperReports (`net.sf.jasperreports.engine.export.ooxml`), alors que le module `jasperreports-excel-poi` ne fournit que `JRXlsExporter`, pour l'ancien format `.xls`.

Les routes de l'imprimerie :

| Route                                      | Document                   | Disposition |
| ------------------------------------------ | -------------------------- | ----------- |
| GET /taverne/commandes/{numero}/ticket     | Ticket de caisse (PDF)     | inline      |
| GET /taverne/factures/{numero}             | Facture fournisseur (PDF)  | attachment  |
| GET /taverne/factures/{numero}?format=xlsx | Facture fournisseur (XLSX) | attachment  |
| GET /taverne/fournisseurs/releve           | Relevé mensuel (PDF)       | inline      |

## Vérifier les épreuves : tester un PDF

Un imprimeur relit ses épreuves avant de lancer le tirage. Pour nos documents, c'est [PDFBox](https://pdfbox.apache.org/?ref=sfeir.dev) qui joue le relecteur : il relit le PDF produit et en extrait le texte, sur lequel on peut écrire des assertions.

```java
@Test
@DisplayName("Le composant list du ticket détaille chaque règlement, troc compris")
void ticketDetailleLesPaiements() throws IOException {
    PdfLu ticket = lirePdf(imprimerie.imprimerTicket(commande("CMD-0042")));

    assertContient(ticket.texte(),
            "Règlement :",
            "Pièces d'or 40,00 po",
            "Troc : dent de wyverne 5,30 po");
}

```

Deux pièges à connaître.

Le premier : avec `Locale.FRANCE`, le séparateur de milliers n'est pas une espace classique mais une **espace insécable fine** (U+202F). "1 378,69" dans le PDF ne correspond donc pas à "1 378,69" tapé au clavier dans le test. La méthode `lirePdf` normalise ces caractères avant toute assertion.

Le second est plus vicieux : **PDFBox lit aussi le texte qui déborde de son cadre**. Pendant le développement, le libellé "TOTAL GÉNÉRAL (4 factures)" du relevé était trop large pour sa zone et s'affichait tronqué en "TOTAL GÉNÉRAL (4". Le test passait quand même, parce que le texte complet est bien présent dans le fichier. L'extraction de texte vérifie le contenu, pas la mise en page.

PDFBox sait pourtant lire bien plus que du texte, et quelques contrôles structurels comblent une partie du trou :

- **La géométrie** : `page.getMediaBox()` donne les dimensions de chaque page. C'est ce qui prouve que le ticket fait 226 points de large et que sa hauteur suit le contenu.
- **La pagination** : `setStartPage()` et `setEndPage()` extraient le texte page par page, de quoi vérifier un "Page 2 / 2" à la bonne place.
- **Les polices** : on parcourt les ressources de chaque page et on vérifie que chaque `PDFont` est embarquée (`isEmbedded()`) et appartient bien à la famille DejaVu.

Côté tableur, Apache POI (déjà fourni par l'extension) relit le classeur XLSX pour vérifier que les cellules contiennent les bonnes valeurs, au-delà de la simple signature `PK` d'une archive ZIP.

Reste la mise en page pure, qu'aucune assertion ne remplace vraiment. Le minimum est un coup d'œil systématique aux épreuves sur quelques documents représentatifs : un ticket court, un ticket long, une facture sur plusieurs pages et un relevé avec des libellés longs. L'étape suivante serait une comparaison d'images (le PDF rendu en PNG, comparé à une référence), mais c'est une autre histoire.

## Pour la route

L'imprimerie de *The Falling Whale* tourne désormais à plein régime. Brunehilde tend un ticket à chaque client, le tavernier range des factures lisibles, et l'intendant reçoit son relevé mensuel, en PDF ou en tableur selon son humeur.

Côté technique, retenons quelques règles. On compile les plaques au build avec l'extension Quarkiverse. On garde les calculs en Java, dans des POJO à getters. On choisit le composant list pour les énumérations et le sous-rapport pour les blocs qui ont leur propre mise en page. On embarque ses polices. Et on relit ses épreuves.

Un dernier conseil en passant, vérifié avec la version 1.4.3 de l'extension : en mode dev, les `.jrxml` sont compilés au **démarrage** de Quarkus, mais une modification d'un rapport n'est pas détectée à chaud. Modifier le pied de page du ticket puis rappeler l'URL renvoie l'ancien texte, sans la moindre ligne de recompilation dans les logs, et le `.jasper` du dossier `target` ne bouge pas. Le rapport n'est recompilé que lorsque Quarkus redémarre pour une autre raison, par exemple après la modification d'une classe Java. L'extension ne recompile alors que les `.jrxml` plus récents que leur `.jasper`. Après une retouche de mise en page, il faut donc relancer `quarkus:dev` (ou toucher une classe Java), sinon l'application continue de servir l'ancienne version. Le comportement peut évoluer avec les versions de l'extension : en cas de doute, les logs de `JasperReportsProcessor` disent clairement ce qui a été compilé, et quand.

## Et si l'imprimerie travaillait de nuit ?

Pour l'instant, chaque document est imprimé à la demande, quand un client ou l'intendant le réclame. Mais le relevé mensuel, lui, a tout d'un travail de fond : il ne concerne personne en particulier et pourrait être produit une fois pour toutes, chaque fin de mois.

Ça ressemble furieusement au travail du brasseur de [la taverne nocturne](https://www.sfeir.dev/back/la-taverne-nocturne-traiter-les-flux-avec-quarkus-batch/). Un job batch qui génère les relevés de tous les fournisseurs pendant que la salle dort, les archive, et les envoie au petit matin à l'intendant par [corbeau messager](https://www.sfeir.dev/back/la-voliere-aux-corbeaux-messagers-introduction-a-kafka-dans-quarkus/) : de quoi faire tourner les presses une prochaine nuit.