resolveTemplate(String gameSystemId, boolean npc) {
+ /** Résout le GameSystem UNE fois (templates PNJ/ennemi + type d'acteur en sont tirés). */
+ private GameSystemJpaEntity resolveGameSystem(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return null;
try {
- return gameSystemRepo.findById(Long.parseLong(gameSystemId))
- .map(gs -> npc ? gs.getNpcTemplate() : gs.getEnemyTemplate())
- .orElse(null);
- } catch (NumberFormatException ex) {
- return null;
- }
- }
-
- private String resolveActorType(String gameSystemId) {
- if (gameSystemId == null || gameSystemId.isBlank()) return null;
- try {
- return gameSystemRepo.findById(Long.parseLong(gameSystemId))
- .map(GameSystemJpaEntity::getFoundryActorType)
- .orElse(null);
+ return gameSystemRepo.findById(Long.parseLong(gameSystemId)).orElse(null);
} catch (NumberFormatException ex) {
return null;
}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java
new file mode 100644
index 0000000..53c29ed
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java
@@ -0,0 +1,611 @@
+package com.loremind.infrastructure.transfer.pdf;
+
+import com.loremind.domain.files.ports.FileStorage;
+import com.loremind.domain.images.ports.ImageStorage;
+import com.loremind.domain.shared.template.FieldType;
+import com.loremind.domain.shared.template.TemplateField;
+import com.loremind.infrastructure.persistence.entity.*;
+import com.loremind.infrastructure.persistence.jpa.*;
+import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.*;
+
+/**
+ * Export d'UNE campagne en livret PDF (joli document imprimable) : structure narrative
+ * (arcs/quetes/scenes), PNJ & ennemis (bestiaire), lore, et battlemaps en illustration.
+ *
+ * Rendu XHTML+CSS -> PDF via openhtmltopdf (100 % JVM). Les images sont rincees,
+ * redimensionnees et re-encodees en JPEG (ImageIO + decodeur WebP TwelveMonkeys) puis
+ * embarquees en data-URI : garantit le rendu (WebP compris) et borne la taille du PDF.
+ */
+@Service
+public class PdfExportService {
+
+ private static final Logger log = LoggerFactory.getLogger(PdfExportService.class);
+
+ /** Cotes max (px) avant re-encodage : portraits compacts, battlemaps/illustrations larges. */
+ private static final int PORTRAIT_MAX = 700;
+ private static final int ILLUSTRATION_MAX = 1500;
+
+ private final CampaignJpaRepository campaignRepo;
+ private final ArcJpaRepository arcRepo;
+ private final ChapterJpaRepository chapterRepo;
+ private final SceneJpaRepository sceneRepo;
+ private final NpcJpaRepository npcRepo;
+ private final EnemyJpaRepository enemyRepo;
+ private final GameSystemJpaRepository gameSystemRepo;
+ private final ImageJpaRepository imageRepo;
+ private final StoredFileJpaRepository storedFileRepo;
+ private final LoreNodeJpaRepository loreNodeRepo;
+ private final PageJpaRepository pageRepo;
+ private final TemplateJpaRepository templateRepo;
+ private final ImageStorage imageStorage;
+ private final FileStorage fileStorage;
+
+ public PdfExportService(CampaignJpaRepository campaignRepo, ArcJpaRepository arcRepo,
+ ChapterJpaRepository chapterRepo, SceneJpaRepository sceneRepo,
+ NpcJpaRepository npcRepo, EnemyJpaRepository enemyRepo,
+ GameSystemJpaRepository gameSystemRepo, ImageJpaRepository imageRepo,
+ StoredFileJpaRepository storedFileRepo,
+ LoreNodeJpaRepository loreNodeRepo, PageJpaRepository pageRepo,
+ TemplateJpaRepository templateRepo, ImageStorage imageStorage,
+ FileStorage fileStorage) {
+ this.campaignRepo = campaignRepo;
+ this.arcRepo = arcRepo;
+ this.chapterRepo = chapterRepo;
+ this.sceneRepo = sceneRepo;
+ this.npcRepo = npcRepo;
+ this.enemyRepo = enemyRepo;
+ this.gameSystemRepo = gameSystemRepo;
+ this.imageRepo = imageRepo;
+ this.storedFileRepo = storedFileRepo;
+ this.loreNodeRepo = loreNodeRepo;
+ this.pageRepo = pageRepo;
+ this.templateRepo = templateRepo;
+ this.imageStorage = imageStorage;
+ this.fileStorage = fileStorage;
+ }
+
+ /** Nom de la campagne (pour le nom de fichier). @throws NoSuchElementException si absente. */
+ public String campaignName(String campaignId) {
+ return campaign(campaignId).getName();
+ }
+
+ /** Construit le PDF complet de la campagne. @throws NoSuchElementException si absente. */
+ public byte[] export(String campaignId) {
+ CampaignJpaEntity campaign = campaign(campaignId);
+ String xhtml = buildXhtml(campaign);
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ PdfRendererBuilder builder = new PdfRendererBuilder();
+ builder.useFastMode();
+ builder.withHtmlContent(xhtml, null);
+ builder.toStream(out);
+ builder.run();
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new UncheckedIOException("Echec de la generation du PDF", e);
+ }
+ }
+
+ private CampaignJpaEntity campaign(String campaignId) {
+ return campaignRepo.findById(Long.parseLong(campaignId))
+ .orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId));
+ }
+
+ // ====================================================================== XHTML
+
+ private String buildXhtml(CampaignJpaEntity campaign) {
+ List npcTemplate = resolveTemplate(campaign.getGameSystemId(), true);
+ List enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false);
+
+ StringBuilder body = new StringBuilder();
+ StringBuilder bookmarks = new StringBuilder();
+
+ cover(body, campaign);
+ narrative(body, bookmarks, campaign);
+ personas(body, bookmarks, "part-npcs", "Personnages non-joueurs (PNJ)", npcEntries(campaign), npcTemplate, false);
+ personas(body, bookmarks, "part-enemies", "Bestiaire", enemyEntries(campaign), enemyTemplate, true);
+ lore(body, bookmarks, campaign);
+
+ return "\n"
+ + "\n"
+ + "\n"
+ + "" + bookmarks + "\n"
+ + "" + body + "";
+ }
+
+ private void cover(StringBuilder b, CampaignJpaEntity c) {
+ b.append("");
+ b.append("
Livret de campagne
");
+ b.append("
").append(esc(c.getName())).append("
");
+ if (notBlank(c.getDescription())) {
+ b.append("
").append(multiline(c.getDescription())).append("
");
+ }
+ b.append("
");
+ }
+
+ // ----- Structure narrative : arcs -> quetes -> scenes -----
+
+ private void narrative(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) {
+ // Tri par ORDRE manuel (glisser-déposer) — cohérent avec l'arbre et les cartes.
+ List arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder);
+ if (arcs.isEmpty()) return;
+
+ b.append("Structure narrative
");
+ bm.append(bookmark("Structure narrative", "part-narrative", () -> {
+ StringBuilder sub = new StringBuilder();
+ for (ArcJpaEntity arc : arcs) sub.append(bookmark(arc.getName(), "arc-" + arc.getId(), null));
+ return sub.toString();
+ }));
+
+ for (ArcJpaEntity arc : arcs) {
+ b.append("")
+ .append("Arc")
+ .append(esc(arc.getName())).append("
");
+ illustrations(b, arc.getIllustrationImageIds());
+ block(b, "Description", arc.getDescription());
+ block(b, "Themes", arc.getThemes());
+ block(b, "Enjeux", arc.getStakes());
+ block(b, "Recompenses", arc.getRewards());
+ block(b, "Resolution", arc.getResolution());
+ block(b, "Notes MJ", arc.getGmNotes());
+
+ for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) {
+ b.append("
")
+ .append("Quête")
+ .append(esc(ch.getName())).append("
");
+ illustrations(b, ch.getIllustrationImageIds());
+ block(b, "Description", ch.getDescription());
+ block(b, "Objectifs joueurs", ch.getPlayerObjectives());
+ block(b, "Enjeux narratifs", ch.getNarrativeStakes());
+ block(b, "Notes MJ", ch.getGmNotes());
+
+ for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) {
+ scene(b, sc);
+ }
+ b.append("
");
+ }
+ b.append("
");
+ }
+ }
+
+ private void scene(StringBuilder b, SceneJpaEntity sc) {
+ b.append("")
+ .append("Scène")
+ .append(esc(sc.getName())).append("
");
+ block(b, "Lieu", sc.getLocation());
+ block(b, "Moment", sc.getTiming());
+ block(b, "Ambiance", sc.getAtmosphere());
+ block(b, "Narration joueur", sc.getPlayerNarration());
+ block(b, "Notes secretes MJ", sc.getGmSecretNotes());
+ block(b, "Choix & consequences", sc.getChoicesConsequences());
+ block(b, "Difficulte du combat", sc.getCombatDifficulty());
+ illustrations(b, sc.getIllustrationImageIds());
+ // Battlemap (image uniquement ; les videos ne sont pas rendables en PDF).
+ String battlemap = fileImageUri(sc.getBattlemapMediaFileId());
+ if (battlemap != null) {
+ b.append("
");
+ }
+ b.append("
");
+ }
+
+ // ----- PNJ / Ennemis (groupes par dossier) -----
+
+ /** Une fiche persona generique pour la mise en page (PNJ ou ennemi). */
+ private record PersonaRow(int order, String name, String folder, String level, String portraitId,
+ Map values, Map> keyValueValues,
+ Map> imageValues, Map foundryStats) {}
+
+ private List npcEntries(CampaignJpaEntity c) {
+ List out = new ArrayList<>();
+ for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(c.getId())) {
+ out.add(new PersonaRow(n.getOrder(), n.getName(), n.getFolder(), null, n.getPortraitImageId(),
+ n.getValues(), n.getKeyValueValues(), n.getImageValues(), null));
+ }
+ return out;
+ }
+
+ private List enemyEntries(CampaignJpaEntity c) {
+ List out = new ArrayList<>();
+ for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(c.getId())) {
+ out.add(new PersonaRow(e.getOrder(), e.getName(), e.getFolder(), e.getLevel(), e.getPortraitImageId(),
+ e.getValues(), e.getKeyValueValues(), e.getImageValues(), e.getFoundryStats()));
+ }
+ return out;
+ }
+
+ private void personas(StringBuilder b, StringBuilder bm, String partId, String title,
+ List rows, List template, boolean enemy) {
+ if (rows.isEmpty()) return;
+ b.append("").append(esc(title)).append("
");
+ bm.append(bookmark(title, partId, null));
+
+ // Groupement par dossier (dossiers tries, non-classes en dernier).
+ Map> byFolder = new TreeMap<>();
+ List ungrouped = new ArrayList<>();
+ for (PersonaRow r : rows) {
+ String f = r.folder() != null ? r.folder().trim() : "";
+ if (f.isEmpty()) ungrouped.add(r);
+ else byFolder.computeIfAbsent(f, k -> new ArrayList<>()).add(r);
+ }
+ for (Map.Entry> e : byFolder.entrySet()) {
+ b.append("").append(esc(e.getKey().replace("/", " / "))).append("
");
+ e.getValue().sort(java.util.Comparator.comparingInt(PersonaRow::order));
+ for (PersonaRow r : e.getValue()) personaCard(b, r, template, enemy);
+ }
+ if (!ungrouped.isEmpty()) {
+ if (!byFolder.isEmpty()) b.append("Sans dossier
");
+ ungrouped.sort(java.util.Comparator.comparingInt(PersonaRow::order));
+ for (PersonaRow r : ungrouped) personaCard(b, r, template, enemy);
+ }
+ }
+
+ private void personaCard(StringBuilder b, PersonaRow r, List template, boolean enemy) {
+ // Mise en page en TABLE (portrait | contenu) : openhtmltopdf gere mal le float
+ // + overflow (le texte se superposait au portrait).
+ b.append("");
+ String portrait = imageUri(r.portraitId(), PORTRAIT_MAX);
+ if (portrait != null) {
+ b.append(".append(portrait).append("\"/) | ");
+ }
+ b.append("");
+ b.append(" ").append(esc(r.name()));
+ if (notBlank(r.level())) b.append(" Niv. ").append(esc(r.level())).append("");
+ b.append(" ");
+
+ renderFields(b, template, r.values(), r.keyValueValues(), r.imageValues(), null);
+
+ if (enemy && r.foundryStats() != null) {
+ Map clean = cleanStats(r.foundryStats());
+ if (!clean.isEmpty()) {
+ b.append("Statistiques ");
+ for (Map.Entry s : clean.entrySet()) {
+ b.append("| ").append(esc(s.getKey())).append(" | ")
+ .append(esc(s.getValue())).append(" | ");
+ }
+ b.append(" ");
+ }
+ }
+ b.append(" |
");
+ }
+
+ /** Valeurs de stats considerees comme "vides"/bruit (masquees dans le livret). */
+ private static final Set STAT_NOISE_VALUES = Set.of("0", "0.0", "false", "none", "null", "", "-", "—");
+ /** Premier segment de chemin a elaguer du libelle (purement structurel). */
+ private static final Set STAT_DROP_PREFIX = Set.of("attributes", "details", "system", "data");
+
+ /**
+ * Nettoie le snapshot de stats Foundry pour le livret : retire le bruit (valeurs
+ * 0/false/none/vides, options techniques type rollMode) et humanise les clés
+ * ("attributes.hp.value" -> "Hp value"). Conserve l'ordre alphabetique des clés.
+ */
+ private static Map cleanStats(Map stats) {
+ Map out = new LinkedHashMap<>();
+ for (Map.Entry e : new TreeMap<>(stats).entrySet()) {
+ String key = e.getKey();
+ String val = e.getValue();
+ if (key == null || val == null) continue;
+ String v = val.trim();
+ if (STAT_NOISE_VALUES.contains(v.toLowerCase())) continue;
+ String lk = key.toLowerCase();
+ if (lk.contains("rollmode") || lk.endsWith(".defaultrollmode")) continue;
+ out.put(humanizeStatKey(key), v);
+ }
+ return out;
+ }
+
+ /** "attributes.hp.value" -> "Hp value" ; "details.creatureType" -> "Creature type". */
+ private static String humanizeStatKey(String key) {
+ String[] parts = key.split("\\.");
+ int start = (parts.length > 1 && STAT_DROP_PREFIX.contains(parts[0].toLowerCase())) ? 1 : 0;
+ String s = String.join(" ", Arrays.asList(parts).subList(start, parts.length));
+ s = s.replaceAll("([a-z0-9])([A-Z])", "$1 $2").toLowerCase().trim(); // camelCase -> mots
+ return s.isEmpty() ? key : Character.toUpperCase(s.charAt(0)) + s.substring(1);
+ }
+
+ // ----- Lore (pages groupees par dossier) -----
+
+ private void lore(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) {
+ String loreId = campaign.getLoreId();
+ if (loreId == null || loreId.isBlank()) return;
+ Long lid;
+ try { lid = Long.parseLong(loreId); } catch (NumberFormatException ex) { return; }
+
+ List pages = pageRepo.findByLoreId(lid);
+ if (pages.isEmpty()) return;
+
+ b.append("Lore
");
+ bm.append(bookmark("Lore", "part-lore", null));
+
+ // Chemins de dossiers (LoreNode) + templates par id.
+ Map nodes = new HashMap<>();
+ for (LoreNodeJpaEntity n : loreNodeRepo.findByLoreId(lid)) nodes.put(n.getId(), n);
+ Map templates = new HashMap<>();
+ for (TemplateJpaEntity t : templateRepo.findByLoreId(lid)) templates.put(t.getId(), t);
+
+ Map> byPath = new TreeMap<>();
+ for (PageJpaEntity p : pages) {
+ byPath.computeIfAbsent(nodePath(p.getNodeId(), nodes), k -> new ArrayList<>()).add(p);
+ }
+ for (Map.Entry> e : byPath.entrySet()) {
+ String label = e.getKey().isEmpty() ? "Sans dossier" : e.getKey();
+ b.append("").append(esc(label)).append("
");
+ e.getValue().sort(java.util.Comparator.comparingInt(PageJpaEntity::getOrder));
+ for (PageJpaEntity p : e.getValue()) {
+ b.append("")
+ .append(esc(p.getTitle())).append("
");
+ List
tpl = p.getTemplateId() != null
+ ? fieldsOf(templates.get(p.getTemplateId())) : null;
+ renderFields(b, tpl, p.getValues(), p.getKeyValueValues(), p.getImageValues(), p.getTableValues());
+ b.append(" ");
+ }
+ }
+ }
+
+ private String nodePath(Long nodeId, Map nodes) {
+ if (nodeId == null) return "";
+ Deque parts = new ArrayDeque<>();
+ Long cur = nodeId;
+ int guard = 0;
+ while (cur != null && guard++ < 30) {
+ LoreNodeJpaEntity n = nodes.get(cur);
+ if (n == null) break;
+ if (n.getName() != null) parts.addFirst(n.getName());
+ cur = n.getParentId();
+ }
+ return String.join(" / ", parts);
+ }
+
+ // ----- Rendu des champs pilotes par template -----
+
+ private void renderFields(StringBuilder b, List template, Map values,
+ Map> keyValueValues,
+ Map> imageValues,
+ Map>> tableValues) {
+ // Repli : pas de template -> paires brutes cle=valeur.
+ if (template == null || template.isEmpty()) {
+ if (values != null) {
+ for (Map.Entry e : values.entrySet()) block(b, e.getKey(), e.getValue());
+ }
+ return;
+ }
+ for (TemplateField f : template) {
+ if (f == null || f.getName() == null || f.getType() == null) continue;
+ FieldType type = f.getType();
+ switch (type) {
+ case TEXT, NUMBER -> block(b, f.getName(), values != null ? values.get(f.getName()) : null);
+ case KEY_VALUE_LIST -> {
+ Map inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null;
+ List labels = f.getLabels();
+ if (inner != null && labels != null) {
+ StringBuilder rows = new StringBuilder();
+ for (String label : labels) {
+ String v = inner.get(label);
+ if (notBlank(v)) rows.append("| ").append(esc(label)).append(" | ")
+ .append(esc(v)).append(" |
");
+ }
+ if (rows.length() > 0) {
+ b.append("").append(esc(f.getName()))
+ .append("
").append(rows)
+ .append("
");
+ }
+ }
+ }
+ case IMAGE -> {
+ List ids = imageValues != null ? imageValues.get(f.getName()) : null;
+ illustrations(b, ids);
+ }
+ case TABLE -> {
+ List