Automating Conference Assets with Java: The Quarkus System Behind jChampions 2026
Build the real workflow that generates speaker and talk cards automatically using Quarkus, Renarde, and PostgreSQL
Every year, the jChampions Conference brings together some of the most passionate, community-driven Java experts on the planet. I’ve been helping out a little with the design side of the conference since last year, mostly building the speaker card templates, playing with layout, and making sure everything looks good when it goes out on social.
It was fun at the start. Until the speaker list grew.
And the time zones changed.
And bios were updated.
And new profile photos arrived.
And we discovered someone needed forty variants of a banner.
At some point, it stopped being design work and became… manual data entry. So this year, I decided to fix that. With Quarkus.
This special edition hands-on tutorial walks you through the exact system we now use to generate all speaker cards, talk cards, and social banners for jChampions 2026.
You’ll learn how to:
Import the official Excel schedule
Store speakers and talks in PostgreSQL
Automatically download and attach profile photos
Render speaker and talk cards as PNG images using HTML templates
Generate all cards in a single batch
Serve a simple speaker directory website
This is the full system behind the scenes.
Let’s build it.
Create the Project
We start with a clean Quarkus project. No Maven XML buried somewhere. Just a modern CLI. You can’t follow along completely today. I skipped some helper classes and it is messy anyway. So go, grab the full blown example from my Github if you really want to look at all the code.
quarkus create app org.acme:speaker-cards \
--extension=”renarde,renarde-pdf,hibernate-orm-panache,jdbc-postgresql,rest-jackson,web-bundler,io.quarkiverse.poi:quarkus-poi” \
--no-code
cd speaker-cardsThis bootstraps a complete Quarkus application with:
Renarde (server-side MVC)
Renarde PDF (HTML → PNG renderer)
JPA + Panache
PostgreSQL driver
RESTEasy Reactive + Jackson
Web Bundler for frontend assets
And Apache POI for the XSLX parsing of the Sessionize export
I did also add a teensy little frontend that uses bootstrap and bootstrap-icons. We load these dependencies through https://mvnpm.org/. Add the two dependencies to your pom.xml
<dependency>
<groupId>org.mvnpm</groupId>
<artifactId>bootstrap</artifactId>
<version>5.3.8</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.mvnpm</groupId>
<artifactId>bootstrap-icons</artifactId>
<version>1.13.1</version>
<scope>provided</scope>
</dependency>Project Overview
Before diving into implementation, here’s a quick mental model of what we’re building.
Inputs
SelectedWithSchedule.xlsx— the official jChampions schedule spreadsheet exported from SessionizeSpeaker profile picture URLs
Static assets (background images, fonts)
Outputs
Speaker cards (1280x720 PNG)
Talk cards (1280x720 PNG)
Social cards (1080x1080 PNG)
A browsable local speaker directory website
Architecture Flow
Excel → Import Service → PostgreSQL → Qute Template → Renarde PDF → PNG BannerConfigure the Application
Open src/main/resources/application.properties and set up:
quarkus.web-bundler.bundle.app=true
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%dev.quarkus.datasource.dev-ui.allow-sql=trueFor development, we want Quarkus to recreate the schema automatically.
In production, you’ll switch to migrations instead.
Create the Data Model
The Speaker and Talk entities are directly derived from the data we need.
package org.acme.model;
@Entity
public class Speaker extends PanacheEntityBase {
@Id
public UUID id;
public String firstName;
@NotBlank
public String lastName;
public String title;
@JdbcTypeCode(Types.LONGVARCHAR)
@NotBlank
@Length(max = 10000)
public String biography;
public String company;
@URL
public String companyURL;
@URL
public String blogURL;
public String twitterAccount;
public String linkedInAccount;
public String githubAccount;
public String email;
@ManyToMany(mappedBy = “speakers”)
public List<Talk> talks = new ArrayList<Talk>();
public Date lastUpdated;
@PreUpdate
@PrePersist
public void prePersist() {
lastUpdated = Date.from(Instant.now());
}
@Override
public String toString() {
return firstName + “ “ + lastName;
}
}Uses `PanacheEntityBase` for simplified JPA operations
UUID as primary key for speakers
Many-to-many relationship with talks
Automatic timestamp updates with `@PrePersist` and `@PreUpdate`
Validation annotations for data integrity
package org.acme.model;
@Entity
public class Talk extends PanacheEntityBase {
@Id
public Long id;
public String title;
// At least one description must be filled
@JdbcTypeCode(Types.LONGVARCHAR)
@Length(max = 10000)
public String description;
public String date;
public String estTime;
public String cetTime;
public String scheduledDuration;
@URL
public String liveLink;
@JoinTable(name = “talk_speaker”, joinColumns = @JoinColumn(name = “talk_id”), inverseJoinColumns = @JoinColumn(name = “speakers_id”))
@ManyToMany
public List<Speaker> speakers = new ArrayList<Speaker>();
}Long ID for talks (from external system)
Stores times in multiple timezones (EST and CET)
Join table for many-to-many relationship
These entities are ready for Quarkus + Panache out of the box.
Build the Excel Importer
The importer reads the official jChampions spreadsheet, extracts all session metadata, converts time zones (EST → CET), and creates or updates database records. And also grabs the speaker images and stores them in a folder so we can use them later.
package org.acme.startup;
@ApplicationScoped
public class ImportFromCSV {
private static final Logger LOG = Logger.getLogger(ImportFromCSV.class);
// XLSX column indices from SelectedWithSchedule.xlsx
// EST timezone (America/New_York)
private static final ZoneId EST_ZONE = ZoneId.of(”America/New_York”);
// CET timezone (Europe/Paris)
private static final ZoneId CET_ZONE = ZoneId.of(”Europe/Paris”);
// Time format for database: “HH:mm”
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern(”HH:mm”);
// Date format for database: “yyyy-MM-dd”
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern(”yyyy-MM-dd”);
void onStart(@Observes StartupEvent ev) {
LOG.info(”XLSX Import startup observer initialized. Import will run when explicitly triggered.”);
}
@Transactional
public void importFromCSV(String xlsxFilePath) {
LOG.infof(”Starting XLSX import from: %s”, xlsxFilePath);
Path path = Paths.get(xlsxFilePath);
if (!Files.exists(path)) {
LOG.errorf(”XLSX file not found: %s”, xlsxFilePath);
return;
}
try (FileInputStream fis = new FileInputStream(xlsxFilePath);
Workbook workbook = new XSSFWorkbook(fis)) {
Sheet sheet = workbook.getSheetAt(0);
Iterator<Row> rowIterator = sheet.iterator();
// Skip header row
if (rowIterator.hasNext()) {
Row headerRow = rowIterator.next();
LOG.infof(”XLSX header found with %d columns”, headerRow.getLastCellNum());
}
int rowNumber = 0;
while (rowIterator.hasNext()) {
Row row = rowIterator.next();
rowNumber++;
try {
String[] fields = extractRowData(row);
processRow(fields);
} catch (Exception e) {
LOG.errorf(e, “Error processing row %d: %s”, rowNumber, e.getMessage());
}
}
LOG.infof(”XLSX import completed. Processed %d rows”, rowNumber);
} catch (Exception e) {
LOG.errorf(e, “Error reading XLSX file: %s”, xlsxFilePath);
}
}
private String[] extractRowData(Row row) {
List<String> fields = new ArrayList<>();
// We need at least 23 columns (0-22) for Profile Picture
int lastColumn = Math.max(row.getLastCellNum(), 23);
for (int i = 0; i < lastColumn; i++) {
Cell cell = row.getCell(i);
fields.add(getCellValueAsString(cell));
}
return fields.toArray(new String[0]);
}
private String getCellValueAsString(Cell cell) {
if (cell == null) {
return “”;
}
switch (cell.getCellType()) {
case STRING:
return cell.getStringCellValue();
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {
// Return ISO format date-time string
return cell.getLocalDateTimeCellValue().toString();
}
return String.valueOf((long) cell.getNumericCellValue());
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
case FORMULA:
return cell.getCellFormula();
case BLANK:
return “”;
default:
return “”;
}
}
private void processRow(String[] fields) {
Talk talk = createOrUpdateTalk(fields);
Speaker speaker = createOrUpdateSpeaker(fields);
// Establish bidirectional relationship
if (speaker != null && talk != null) {
if (!talk.speakers.contains(speaker)) {
talk.speakers.add(speaker);
speaker.talks.add(talk);
talk.persist();
speaker.persist();
}
}
}
private Speaker createOrUpdateSpeaker(String[] fields) {
String speakerIdStr = fields[COL_SPEAKER_ID].trim();
if (speakerIdStr.isEmpty()) {
return null;
}
try {
UUID speakerId = UUID.fromString(speakerIdStr);
Speaker speaker = Speaker.findById(speakerId);
boolean isNew = (speaker == null);
if (isNew) {
speaker = new Speaker();
speaker.id = speakerId;
}
speaker.firstName = emptyToNull(fields[COL_FIRST_NAME]);
speaker.lastName = emptyToNull(fields[COL_LAST_NAME]);
speaker.title = emptyToNull(fields[COL_TAG_LINE]);
speaker.biography = emptyToNull(fields[COL_BIO]);
speaker.star = false;
if (isNew) {
speaker.persist();
}
// Download profile picture if URL is provided
String profilePictureUrl = emptyToNull(fields[COL_PROFILE_PICTURE]);
if (profilePictureUrl != null) {
downloadProfilePicture(speakerId, profilePictureUrl);
}
LOG.debugf(”%s speaker: %s %s (%s)”, isNew ? “Persisted” : “Updated”,
speaker.firstName, speaker.lastName, speakerId);
return speaker;
} catch (Exception e) {
LOG.errorf(e, “Error creating speaker: %s”, e.getMessage());
return null;
}
}
private Talk createOrUpdateTalk(String[] fields) {
String sessionIdStr = fields[COL_SESSION_ID].trim();
if (sessionIdStr.isEmpty()) {
return null;
}
try {
Long sessionId = Long.parseLong(sessionIdStr);
Talk talk = Talk.findById(sessionId);
boolean isNew = (talk == null);
if (isNew) {
// For new talks, title is required
String title = fields[COL_TITLE].trim();
if (title.isEmpty()) {
LOG.warnf(”Skipping new talk with session ID %d: title is empty”, sessionId);
return null;
}
talk = new Talk();
talk.id = sessionId;
talk.title = title;
talk.description = emptyToNull(fields[COL_DESCRIPTION]);
talk.scheduledDuration = emptyToNull(fields[COL_SCHEDULED_DURATION]);
talk.liveLink = emptyToNull(fields[COL_LIVE_LINK]);
String scheduledAt = fields[COL_SCHEDULED_AT].trim();
if (!scheduledAt.isEmpty()) {
try {
// Parse ISO format date-time from Excel
LocalDateTime estDateTime = LocalDateTime.parse(scheduledAt);
ZonedDateTime estZoned = estDateTime.atZone(EST_ZONE);
ZonedDateTime cetZoned = estZoned.withZoneSameInstant(CET_ZONE);
talk.date = cetZoned.format(DATE_FORMAT);
talk.estTime = estZoned.format(TIME_FORMAT);
talk.cetTime = cetZoned.format(TIME_FORMAT);
} catch (Exception e) {
LOG.warnf(”Could not parse date ‘%s’ for talk %d”, scheduledAt, sessionId);
}
}
talk.persist();
LOG.debugf(”Persisted talk: %s (%d)”, talk.title, sessionId);
} else {
// For existing talks, just return the found talk
LOG.debugf(”Found existing talk: %s (%d) with %d speaker(s)”, talk.title, sessionId,
talk.speakers.size());
}
return talk;
} catch (Exception e) {
LOG.errorf(e, “Error creating talk: %s”, e.getMessage());
return null;
}
}
private String emptyToNull(String value) {
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private void downloadProfilePicture(UUID speakerId, String profilePictureUrl) {
if (profilePictureUrl == null || profilePictureUrl.isEmpty()) {
return;
}
try {
// Determine file extension from URL
String extension = “jpg”; // default
int lastDot = profilePictureUrl.lastIndexOf(’.’);
if (lastDot > 0) {
String urlExt = profilePictureUrl.substring(lastDot + 1).toLowerCase();
// Remove query parameters if any
int queryIndex = urlExt.indexOf(’?’);
if (queryIndex > 0) {
urlExt = urlExt.substring(0, queryIndex);
}
if (urlExt.equals(”jpg”) || urlExt.equals(”jpeg”) || urlExt.equals(”png”) || urlExt.equals(”gif”)) {
extension = urlExt;
}
}
// Target path: src/main/resources/META-INF/speaker/{Speaker Id}.{ext}
Path resourcesPath = Paths.get(”src/main/resources/META-INF/speaker”);
Files.createDirectories(resourcesPath);
Path imagePath = resourcesPath.resolve(speakerId.toString() + “.” + extension);
// Check if file already exists
if (Files.exists(imagePath)) {
LOG.debugf(”Profile picture already exists for speaker %s, skipping download”, speakerId);
return;
}
// Download image
LOG.infof(”Downloading profile picture for speaker %s from %s”, speakerId, profilePictureUrl);
URI uri = URI.create(profilePictureUrl);
try (InputStream in = uri.toURL().openStream()) {
Files.copy(in, imagePath);
LOG.infof(”Downloaded profile picture for speaker %s to %s”, speakerId, imagePath);
}
} catch (Exception e) {
LOG.errorf(e, “Error downloading profile picture for speaker %s from %s”, speakerId, profilePictureUrl);
}
}
}Key responsibilities:
Read Excel using Apache POI
Create/update
SpeakerCreate/update
TalkMap many-to-many relationships
Download and store profile pictures
Normalize date/time handling
This is the “magic” that keeps the conference data consistent.
Build the Import Controller
We also need to make sure to take a quick look at the Renarde Controller that exposes the REST endpoints:
package org.acme.rest;
@Path(”/api/import”)
public class Import extends Controller {
private static final Logger LOG = Logger.getLogger(Import.class);
@Inject
ImportFromCSV importFromCSV;
@GET
@Path(”/csv/{path:.*}”)
@Produces(MediaType.TEXT_PLAIN)
@Transactional
public Response importCSVFromPath(@jakarta.ws.rs.PathParam(”path”) String path) {
try {
importFromCSV.importFromCSV(path);
return Response.ok(”CSV import completed successfully. Check logs for details.”).build();
} catch (Exception e) {
LOG.errorf(e, “Error during CSV import from path: %s”, path);
return Response.serverError()
.entity(”Error during CSV import: “ + e.getMessage())
.build();
}
}
}Run an import like:
curl “http://localhost:8080/api/import/csv/SelectedWithSchedule.xlsx”Once this is done, your database contains the full jChampions 2026 schedule.
Oh, yes! This is not the most beautiful code you will ever see. Because I hacked it together and it still says CSV and not XSLT, but I think you already figured that I am doing stuff like this on the weekends :)
Generating Speaker and Talk Cards
This is the part everyone sees. These are the cards we publish on:
Twitter
LinkedIn
Mastodon
Blogs
Conference announcements
Internal tracking boards
We generate PNG banners from HTML templates.
Quarkus Renarde PDF handles conversion with no external Chrome/Playwright dependency.
The first thing we need are the templates. We have three. A speaker banner, a talk banner and a social banner. They all kind of follow the same idea. Some css magic and some images and some data.
<body>
<!-- Top logo - Duke -->
<div id=”top-logo”>
<img src=”/static/images/duke_champ.png” alt=”Java Champions Duke” />
</div>
<!-- Conference banner -->
<div class=”conference-banner”>
jChampions Conference
</div>
<!-- Speaker photo with gradient border -->
<div class=”speaker-photo-wrapper”>
<div class=”speaker-photo-inner”
{#if speaker.id}
style=”background-image: url(’{uri:Banner.speakerPhoto(speaker.id)}’)”
{#else}
style=”background-color: #ff9800;”
{/if}
></div>
</div>
<!-- Speaker info -->
<div class=”speaker-info”>
<!-- Role -->
{#if speaker.title}
<div id=”role”>{speaker.title}</div>
{/if}
<!-- Speaker name -->
<div id=”name”>{speaker.firstName} {speaker.lastName}</div>
<!-- Talk title -->
{#if talk != null && talk.title}
<div id=”talk-title”>{talk.title}</div>
{/if}
</div>
<!-- Event time - bottom centered -->
{#if talk != null && talk.estTime && talk.cetTime}
<div class=”event-block” id=”event-time”>
⏰ {str:formatDate(talk.date)} | {str:formatTime(talk.cetTime)}-{str:addHour(talk.cetTime)} CET | {str:formatTime(talk.estTime)}-{str:addHour(talk.estTime)} EST
</div>
{/if}
</body>You can sneak at the full code in the repository if you like. But you’d most likely just create your own version anyway.
Speaker Card Renderings
What is missing now is the speaker card controller.
package org.acme.rest;
public class Banner extends Controller {
@Inject
BannerGenerationService bannerService;
@CheckedTemplate
public static class Templates {
public static native TemplateInstance speakerBanner(Speaker speaker, Talk talk);
public static native TemplateInstance talkBanner(Talk talk);
public static native TemplateInstance speakerSocial(Speaker speaker, Talk talk);
}
@Path(”/speaker-banner”)
@Transactional
public TemplateInstance speakerBanner(@RestPath UUID id) {
Speaker speaker = Speaker.findById(id);
notFoundIfNull(speaker);
// Load talks relationship
Talk talk = null;
if (speaker.talks != null && !speaker.talks.isEmpty()) {
talk = speaker.talks.get(0);
}
return Templates.speakerBanner(speaker, talk);
}
@Produces(Pdf.IMAGE_PNG)
@Path(”/speaker-banner/{id}.png”)
@Transactional
public TemplateInstance banner(@RestPath UUID id) {
Speaker speaker = Speaker.findById(id);
notFoundIfNull(speaker);
// Load talks relationship
Talk talk = null;
if (speaker.talks != null && !speaker.talks.isEmpty()) {
talk = speaker.talks.get(0);
}
return Templates.speakerBanner(speaker, talk);
}
@Produces(Pdf.IMAGE_PNG)
@Path(”/speaker-social/{id}.png”)
@Transactional
public TemplateInstance speakerSocialBanner(@RestPath UUID id) {
Speaker speaker = Speaker.findById(id);
notFoundIfNull(speaker);
// Load talks relationship
Talk talk = null;
if (speaker.talks != null && !speaker.talks.isEmpty()) {
talk = speaker.talks.get(0);
}
return Templates.speakerSocial(speaker, talk);
}
@Path(”/talk-banner”)
@Transactional
public TemplateInstance talkBanner(@RestPath Long id) {
Talk talk = Talk.findById(id);
notFoundIfNull(talk);
// Ensure speakers are loaded by accessing the list
if (talk.speakers != null) {
// Force lazy loading by iterating
for (Speaker speaker : talk.speakers) {
speaker.id.toString(); // Access a field to ensure it’s loaded
}
}
return Templates.talkBanner(talk);
}
@Produces(Pdf.IMAGE_PNG)
@Path(”/talk-banner/{id}.png”)
@Transactional
public TemplateInstance talkBannerPng(@RestPath Long id) {
Talk talk = Talk.findById(id);
notFoundIfNull(talk);
// Ensure speakers are loaded by accessing the list
if (talk.speakers != null) {
// Force lazy loading by iterating
for (Speaker speaker : talk.speakers) {
speaker.id.toString(); // Access a field to ensure it’s loaded
}
}
return Templates.talkBanner(talk);
}
@GET
@Path(”/speaker-photo/{id}”)
@Produces({”image/png”, “image/jpeg”})
public Response speakerPhoto(@RestPath UUID id, Request request) {
Speaker speaker = Speaker.findById(id);
notFoundIfNull(speaker);
// Try to find speaker image in resources/META-INF/speaker/{id}.{ext}
String[] extensions = { “.jpg”, “.png”, “.jpeg” };
String resourcePath = null;
String mimeType = null;
for (String ext : extensions) {
String testPath = “/META-INF/speaker/” + id + ext;
InputStream testStream = getClass().getResourceAsStream(testPath);
if (testStream != null) {
try {
testStream.close();
} catch (Exception e) {
// Ignore
}
resourcePath = testPath;
// Determine MIME type based on extension
if (ext.equals(”.png”)) {
mimeType = “image/png”;
} else {
mimeType = “image/jpeg”;
}
break;
}
}
// If no image found, fall back to duke_cool.png
if (resourcePath == null) {
seeOther(”/static/images/duke_cool.png”);
return null; // seeOther will redirect
}
// Read the image file
try (InputStream imageStream = getClass().getResourceAsStream(resourcePath)) {
if (imageStream == null) {
seeOther(”/static/images/duke_cool.png”);
return null;
}
byte[] bytes = imageStream.readAllBytes();
return Response.ok(bytes, mimeType).build();
} catch (Exception e) {
throw new RuntimeException(”Error reading speaker image”, e);
}
}
@GET
@Path(”/api/banners/generate-all”)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public BannerGenerationResult generateAllBanners(@RestQuery String outputDir) {
if (outputDir != null && !outputDir.trim().isEmpty()) {
return bannerService.generateAllBanners(outputDir.trim());
} else {
return bannerService.generateAllSpeakerBanners();
}
}
@POST
@Path(”/api/banners/generate-speakers”)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public BannerGenerationResult generateSpecificBanners(List<UUID> speakerIds) {
return bannerService.generateSpeakerBanners(speakerIds);
}
}`@Produces(Pdf.IMAGE_PNG)` converts HTML template to PNG
Renarde’s PDF extension handles the conversion
Type-safe templates with `@CheckedTemplate`
This is kind of the “one-shot” magic. It generates individual images for speaker and talk ids.
Batch Generation for the Entire Conference
This is the real time-saver.
A single endpoint generates every combination:
All speaker cards
All talk cards
All social cards
Stored in structured directories
output/
├── speaker/
├── talks/
└── social/The BannerGenerationService performs HTTP calls against the rendering endpoints, retrieves the PNGs, and stores them.
This replaced hours of manual capture and export work.
package org.acme.service;
@ApplicationScoped
public class BannerGenerationService {
private static final Logger LOG = Logger.getLogger(BannerGenerationService.class);
@ConfigProperty(name = “quarkus.http.port”, defaultValue = “8080”)
int httpPort;
@ConfigProperty(name = “quarkus.http.host”, defaultValue = “localhost”)
String httpHost;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
/**
* Generate banners for all speakers in the database
*/
@Transactional
public BannerGenerationResult generateAllSpeakerBanners() {
List<Speaker> speakers = Speaker.listAll();
LOG.infof(”Starting banner generation for %d speakers”, speakers.size());
BannerGenerationResult result = new BannerGenerationResult();
for (Speaker speaker : speakers) {
try {
byte[] bannerData = generateSpeakerBannerData(speaker);
result.addSuccess(speaker.id, speaker.toString(), bannerData.length);
LOG.infof(”Generated banner for %s (%s)”, speaker.toString(), speaker.id);
} catch (Exception e) {
result.addFailure(speaker.id, speaker.toString(), e.getMessage());
LOG.errorf(e, “Failed to generate banner for %s (%s)”, speaker.toString(), speaker.id);
}
}
LOG.infof(”Banner generation completed: %d successful, %d failed”,
result.getSuccessCount(), result.getFailureCount());
return result;
}
/**
* Generate banners for specific speaker IDs
*/
@Transactional
public BannerGenerationResult generateSpeakerBanners(List<UUID> speakerIds) {
LOG.infof(”Starting banner generation for %d specific speakers”, speakerIds.size());
BannerGenerationResult result = new BannerGenerationResult();
for (UUID speakerId : speakerIds) {
Speaker speaker = Speaker.findById(speakerId);
if (speaker == null) {
result.addFailure(speakerId, “Unknown”, “Speaker not found”);
continue;
}
try {
byte[] bannerData = generateSpeakerBannerData(speaker);
result.addSuccess(speaker.id, speaker.toString(), bannerData.length);
LOG.infof(”Generated banner for %s (%s)”, speaker.toString(), speaker.id);
} catch (Exception e) {
result.addFailure(speaker.id, speaker.toString(), e.getMessage());
LOG.errorf(e, “Failed to generate banner for %s (%s)”, speaker.toString(), speaker.id);
}
}
return result;
}
/**
* Generate banners for all speakers and save to filesystem
*/
@Transactional
public BannerGenerationResult generateAndSaveAllBanners(String outputDirectory) {
List<Speaker> speakers = Speaker.listAll();
LOG.infof(”Starting banner generation and save for %d speakers to %s”, speakers.size(), outputDirectory);
// Create output directory if it doesn’t exist
Path outputPath = Paths.get(outputDirectory);
try {
Files.createDirectories(outputPath);
} catch (IOException e) {
throw new RuntimeException(”Failed to create output directory: “ + outputDirectory, e);
}
BannerGenerationResult result = new BannerGenerationResult();
for (Speaker speaker : speakers) {
try {
byte[] bannerData = generateSpeakerBannerData(speaker);
// Save to file
String filename = String.format(”%s_%s_%s.png”,
sanitizeFilename(speaker.firstName),
sanitizeFilename(speaker.lastName),
speaker.id.toString());
Path filePath = outputPath.resolve(filename);
Files.write(filePath, bannerData);
result.addSuccess(speaker.id, speaker.toString(), bannerData.length);
result.addSavedFile(filePath.toString());
LOG.infof(”Generated and saved banner for %s (%s) to %s”, speaker.toString(), speaker.id, filePath);
} catch (Exception e) {
result.addFailure(speaker.id, speaker.toString(), e.getMessage());
LOG.errorf(e, “Failed to generate banner for %s (%s)”, speaker.toString(), speaker.id);
}
}
return result;
}
/**
* Generate all banners (speaker banners, talk banners, and speaker social banners) and save to filesystem
*
* @param outputDirectory Base output directory
* @return BannerGenerationResult with all generation results
*/
@Transactional
public BannerGenerationResult generateAllBanners(String outputDirectory) {
List<Speaker> speakers = Speaker.listAll();
List<Talk> talks = Talk.listAll();
LOG.infof(”Starting banner generation for %d speakers and %d talks to %s”,
speakers.size(), talks.size(), outputDirectory);
// Create output directories if they don’t exist
Path outputPath = Paths.get(outputDirectory);
Path speakerDir = outputPath.resolve(”speaker”);
Path talksDir = outputPath.resolve(”talks”);
Path socialDir = outputPath.resolve(”social”);
try {
Files.createDirectories(speakerDir);
Files.createDirectories(talksDir);
Files.createDirectories(socialDir);
} catch (IOException e) {
throw new RuntimeException(”Failed to create output directories: “ + outputDirectory, e);
}
BannerGenerationResult result = new BannerGenerationResult();
// Generate speaker banners: outputDir/speaker/<speakerID>.png
for (Speaker speaker : speakers) {
try {
byte[] bannerData = generateSpeakerBannerData(speaker);
Path filePath = speakerDir.resolve(speaker.id.toString() + “.png”);
Files.write(filePath, bannerData);
result.addSuccess(speaker.id, speaker.toString(), bannerData.length);
result.addSavedFile(filePath.toString());
LOG.infof(”Generated speaker banner for %s (%s) to %s”, speaker.toString(), speaker.id, filePath);
} catch (Exception e) {
result.addFailure(speaker.id, speaker.toString(), e.getMessage());
LOG.errorf(e, “Failed to generate speaker banner for %s (%s)”, speaker.toString(), speaker.id);
}
}
// Generate talk banners: outputDir/talks/<talkID>.png
for (Talk talk : talks) {
try {
byte[] bannerData = generateTalkBannerData(talk);
Path filePath = talksDir.resolve(talk.id.toString() + “.png”);
Files.write(filePath, bannerData);
result.addSuccess(null, talk.title, bannerData.length);
result.addSavedFile(filePath.toString());
LOG.infof(”Generated talk banner for %s (%d) to %s”, talk.title, talk.id, filePath);
} catch (Exception e) {
result.addFailure(null, talk.title, e.getMessage());
LOG.errorf(e, “Failed to generate talk banner for %s (%d)”, talk.title, talk.id);
}
}
// Generate speaker social banners: outputDir/social/<speaker.lastName>_<speaker.firstName>.png
for (Speaker speaker : speakers) {
try {
byte[] bannerData = generateSpeakerSocialBannerData(speaker);
String filename = String.format(”%s_%s.png”,
sanitizeFilename(speaker.lastName),
sanitizeFilename(speaker.firstName));
Path filePath = socialDir.resolve(filename);
Files.write(filePath, bannerData);
result.addSuccess(speaker.id, speaker.toString(), bannerData.length);
result.addSavedFile(filePath.toString());
LOG.infof(”Generated social banner for %s (%s) to %s”, speaker.toString(), speaker.id, filePath);
} catch (Exception e) {
result.addFailure(speaker.id, speaker.toString(), e.getMessage());
LOG.errorf(e, “Failed to generate social banner for %s (%s)”, speaker.toString(), speaker.id);
}
}
LOG.infof(”Banner generation completed: %d successful, %d failed”,
result.getSuccessCount(), result.getFailureCount());
return result;
}
/**
* Generate banners asynchronously for better performance
*/
@Transactional
public CompletableFuture<BannerGenerationResult> generateAllSpeakerBannersAsync() {
return CompletableFuture.supplyAsync(() -> generateAllSpeakerBanners());
}
/**
* Generate banner data for a single speaker by calling the existing PNG endpoint
*/
private byte[] generateSpeakerBannerData(Speaker speaker) throws Exception {
String url = String.format(”http://%s:%d/speaker-banner/%s.png”, httpHost, httpPort, speaker.id);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new RuntimeException(String.format(”Failed to generate banner for speaker %s. HTTP %d”,
speaker.id, response.statusCode()));
}
return response.body();
}
/**
* Generate banner data for a single talk by calling the existing PNG endpoint
*/
private byte[] generateTalkBannerData(Talk talk) throws Exception {
String url = String.format(”http://%s:%d/talk-banner/%d.png”, httpHost, httpPort, talk.id);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new RuntimeException(String.format(”Failed to generate banner for talk %d. HTTP %d”,
talk.id, response.statusCode()));
}
return response.body();
}
/**
* Generate social banner data for a single speaker by calling the existing PNG endpoint
*/
private byte[] generateSpeakerSocialBannerData(Speaker speaker) throws Exception {
String url = String.format(”http://%s:%d/speaker-social/%s.png”, httpHost, httpPort, speaker.id);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new RuntimeException(String.format(”Failed to generate social banner for speaker %s. HTTP %d”,
speaker.id, response.statusCode()));
}
return response.body();
}
/**
* Sanitize filename to remove invalid characters
*/
private String sanitizeFilename(String filename) {
if (filename == null) return “unknown”;
return filename.replaceAll(”[^a-zA-Z0-9._-]”, “_”);
}
/**
* Generate banners in parallel for better performance
*/
@Transactional
public BannerGenerationResult generateAllSpeakerBannersParallel() {
List<Speaker> speakers = Speaker.listAll();
LOG.infof(”Starting parallel banner generation for %d speakers”, speakers.size());
ExecutorService executor = Executors.newFixedThreadPool(
Math.min(speakers.size(), Runtime.getRuntime().availableProcessors()));
BannerGenerationResult result = new BannerGenerationResult();
try {
List<CompletableFuture<Void>> futures = speakers.stream()
.map(speaker -> CompletableFuture.runAsync(() -> {
try {
byte[] bannerData = generateSpeakerBannerData(speaker);
synchronized (result) {
result.addSuccess(speaker.id, speaker.toString(), bannerData.length);
}
LOG.infof(”Generated banner for %s (%s)”, speaker.toString(), speaker.id);
} catch (Exception e) {
synchronized (result) {
result.addFailure(speaker.id, speaker.toString(), e.getMessage());
}
LOG.errorf(e, “Failed to generate banner for %s (%s)”, speaker.toString(), speaker.id);
}
}, executor))
.collect(Collectors.toList());
// Wait for all tasks to complete
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
} finally {
executor.shutdown();
}
LOG.infof(”Parallel banner generation completed: %d successful, %d failed”,
result.getSuccessCount(), result.getFailureCount());
return result;
}
}Uses HTTP client to call banner endpoints
Saves generated PNGs to filesystem
Organizes output into subdirectories (speaker/, talks/, social/)
Tracks success/failure for each banner
Simple Speaker Directory Website
For fun, I also created a simple Renarde-based front-end website
with:
Bootstrap bundle
Clean speaker listing
Easy navigation
It’s useful when validating the import and layout.
Frontend assets are bundled with the Web Bundler extension.
Run the Whole Syste
Start Quarkus Dev Mode
quarkus devImport the schedule
curl “http://localhost:8080/api/import/csv/SelectedWithSchedule.xlsx”Generate everything
curl “http://localhost:8080/api/banners/generate-all?outputDir=./jchampions-banners”Open the speaker directory
http://localhost:8080/At this point, you have the full system running locally.
The conference is run entirely by volunteers.
Everyone has a day job.
Automating this workflow saves dozens of hours for me and makes it a lot easier to tweak templates, regenerate everything in seconds, and ship polished visual assets without spending late-night hours in Figma.
Quarkus handled this beautifully.
Want to See These Speakers Live?
jChampions Conference 2026 is packed with incredible talks from legends across the Java ecosystem.
Oh, and of course there’s one particular talk you can not miss:
👉 Register today and don’t miss a single sessions: https://jchampionsconf.com/
The content is worth it.
The community is worth it.
And now the speaker cards look good too.





