From Prompt to PDF: Build an AI-Powered White Paper Generator with Quarkus
Use Quarkus, LangChain4j, Ollama, and iText to create stunning marketing PDFs entirely in Java and fully offline.
As a product manager, I know how difficult it can be to translate technical features into compelling, audience-friendly content. Writing a good white paper takes time, coordination, and often multiple review cycles. It's tempting, especially with generative AI within reach, to wonder: Could I just automate the whole thing?
That curiosity led me to build this project.
What started as an experiment quickly turned into a hands-on exploration: Could I push a local LLM, powered entirely by Java and Quarkus, to generate a professional-looking white paper from a few form fields and a single click? No cloud APIs, no complex tooling. Just Java, LangChain4j, Ollama, and iText 7.
Let’s be clear: the output won’t rival a polished asset from your product marketing team. This isn’t about replacing human copywriters or brand guardians. It’s a joyful prototype, a creative experiment, and a very real demonstration of how far local LLMs and Quarkus have come.
Push the button. See what happens.
Prerequisites
Make sure you have the following installed before starting:
JDK 17 or later
Maven 3.8+
Recommended: Ollama running locally: Install from ollama.com and run.
ollama pull llama3.3:latest
This pulls the Llama 3 model you’ll be using locally for text generation. Quarkus can also generate a Dev Service container based on llama.cpp but the native install gives me a better experience. Quarkus still wires it seamlessly and transparently into your application so you have the prime developer experience.
A note on Llama3.3: This is a large model (43GB) to run locally and you will need a very up to date machine for this. I am running it on an M4 with 64GB RAM. If this does not work for you, fall back to a llama3:latest or something similar. I just wanted to push the quality of the response a little.
Bootstrap the Project
Generate a new Quarkus project with the necessary extensions using Maven:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=ai-whitepaper-generator \
-Dextensions="rest-jackson,quarkus-langchain4j-ollama"
cd ai-whitepaper-generator
Add iText Community to your pom.xml
under <dependencies>
. Learn more on the iText website.
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-core</artifactId>
<version>9.2.0</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>bouncy-castle-adapter</artifactId>
<version>9.2.0</version>
</dependency>
This gives us access to advanced layout and styling features for PDF rendering.
iText 7 is a powerful PDF library, but its use is governed by the terms of the AGPLv3 license when used freely. The AGPLv3 (Affero General Public License version 3) is a strong copyleft license, which means that if you use iText in your application and distribute it—either as a downloadable binary or a SaaS offering over a network—you must also make your entire application's source code available under the same AGPLv3 terms. This makes it unsuitable for proprietary or closed-source use unless you obtain a commercial license from iText Software. For developers building open-source applications or internal tools not exposed to users over a network, AGPLv3 may be acceptable. Otherwise, commercial licensing is recommended to avoid legal and compliance risks. More details are available at iText’s licensing page.
As usual, you can grab the whole project from my Github repository and get started from the code.
Connect to the Local LLM
In src/main/resources/application.properties
, configure your Ollama model:
quarkus.langchain4j.ollama.chat-model.model-name=llama3.3:latest
quarkus.langchain4j.ollama.timeout=360s
This ensures LangChain4j knows which model to use. We also drastically increase the timeout to make sure our machines can handle the large model.
Define the AI Writing Service
Create the interface that LangChain4j will turn into an LLM-backed service.
src/main/java/com/example/WhitePaperAiService.java
package com.example;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService
public interface WhitePaperAiService {
@SystemMessage("""
You are a professional technical writer for a technology company called 'Innovatech'.
Your task is to generate the content for a compelling white paper.
The tone should be professional, informative, and slightly formal.
Generate a title, a 2-3 paragraph introduction, and 3-4 sections explaining the key features.
Conclude with a summary paragraph. Use markdown for headings (e.g., '# Title', '## Section').
Do not include any pre-amble or post-amble, just the white paper content itself.
""")
@UserMessage("""
Generate a white paper for the product named '{{productName}}'.
The target audience is: {{targetAudience}}.
The key features to highlight are:
{{features}}
""")
String generateWhitePaperContent(String productName, String targetAudience, String features);
}
LangChain4j uses @SystemMessage
to establish tone and structure, and @UserMessage
to inject user data into the prompt.
Format Output with iText
Let’s convert that markdown-style LLM output into a polished PDF.
Create the PDF generator service:
src/main/java/com/example/PdfGeneratorService.java
package com.example;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import com.itextpdf.io.image.ImageDataFactory;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.layout.Canvas;
import com.itextpdf.layout.ColumnDocumentRenderer;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Image;
import com.itextpdf.layout.element.List;
import com.itextpdf.layout.element.ListItem;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.properties.HorizontalAlignment;
import com.itextpdf.layout.properties.TextAlignment;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PdfGeneratorService {
// Font resources
private static final String REGULAR_FONT_PATH = "IBMPlexSans-Regular.ttf";
private static final String BOLD_FONT_PATH = "IBMPlexSans-Bold.ttf";
private static final String LOGO_PATH = "logo.png";
private static final String FONT_ENCODING = "Identity-H";
// Page layout constants
private static final float PAGE_MARGIN = 36f;
private static final float COLUMN_GAP = 20f;
private static final float HEADER_HEIGHT = 80f;
private static final float FOOTER_HEIGHT = 60f;
// Logo dimensions
private static final float HEADER_LOGO_WIDTH = 150f;
private static final float HEADER_LOGO_HEIGHT = 50f;
private static final float FOOTER_LOGO_WIDTH = 100f;
private static final float FOOTER_LOGO_HEIGHT = 30f;
// Typography
private static final int H1_FONT_SIZE = 22;
private static final int H2_FONT_SIZE = 14;
private static final int BODY_FONT_SIZE = 10;
private static final int H2_MARGIN_TOP = 15;
private static final int PARAGRAPH_INDENT = 12;
// Markdown prefixes
private static final String H1_PREFIX = "# ";
private static final String H2_PREFIX = "## ";
private static final String LIST_PREFIX = "* ";
public byte[] createWhitePaperPdf(String content) throws Exception {
validateContent(content);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PdfWriter writer = new PdfWriter(baos);
PdfDocument pdfDoc = new PdfDocument(writer);
Document document = new Document(pdfDoc, PageSize.A4);
FontHolder fonts = loadFonts();
Image logoImage = loadLogo();
setupDocument(document);
processContent(document, content, fonts);
addFooterLogos(pdfDoc, logoImage);
document.close();
return baos.toByteArray();
}
}
private void validateContent(String content) {
if (content == null || content.trim().isEmpty()) {
throw new IllegalArgumentException("Content cannot be null or empty");
}
}
private FontHolder loadFonts() throws IOException {
try {
PdfFont regularFont = createFontFromResource(REGULAR_FONT_PATH);
PdfFont boldFont = createFontFromResource(BOLD_FONT_PATH);
return new FontHolder(regularFont, boldFont);
} catch (Exception e) {
throw new IOException("Failed to load fonts", e);
}
}
private PdfFont createFontFromResource(String fontPath) throws IOException {
URL fontResource = getClass().getClassLoader().getResource(fontPath);
if (fontResource == null) {
throw new IOException("Font resource not found: " + fontPath);
}
try (InputStream fontStream = fontResource.openStream()) {
return PdfFontFactory.createFont(fontStream.readAllBytes(), FONT_ENCODING);
}
}
private Image loadLogo() {
URL logoResource = getClass().getClassLoader().getResource(LOGO_PATH);
if (logoResource == null) {
return null;
}
try {
Image logoImage = new Image(ImageDataFactory.create(logoResource));
logoImage.scaleToFit(HEADER_LOGO_WIDTH, HEADER_LOGO_HEIGHT);
logoImage.setHorizontalAlignment(HorizontalAlignment.CENTER);
return logoImage;
} catch (Exception e) {
// Log warning and continue without logo
return null;
}
}
private void setupDocument(Document document) {
Rectangle[] columns = createColumnLayout();
document.setRenderer(new ColumnDocumentRenderer(document, columns));
}
private Rectangle[] createColumnLayout() {
float availableWidth = PageSize.A4.getWidth() - (2 * PAGE_MARGIN);
float columnWidth = (availableWidth - COLUMN_GAP) / 2;
float contentHeight = PageSize.A4.getHeight() - HEADER_HEIGHT - FOOTER_HEIGHT;
return new Rectangle[]{
new Rectangle(PAGE_MARGIN, HEADER_HEIGHT, columnWidth, contentHeight),
new Rectangle(PAGE_MARGIN + columnWidth + COLUMN_GAP, HEADER_HEIGHT, columnWidth, contentHeight)
};
}
private void processContent(Document document, String content, FontHolder fonts) {
String[] lines = content.split("\n");
List currentList = null;
for (String line : lines) {
currentList = processLine(document, line, fonts, currentList);
}
// Add any remaining list
if (currentList != null) {
document.add(currentList);
}
}
private List processLine(Document document, String line, FontHolder fonts, List currentList) {
if (line.startsWith(H1_PREFIX)) {
currentList = addPendingList(document, currentList);
addHeading1(document, line, fonts);
} else if (line.startsWith(H2_PREFIX)) {
currentList = addPendingList(document, currentList);
addHeading2(document, line, fonts);
} else if (line.startsWith(LIST_PREFIX)) {
currentList = addListItem(currentList, line, fonts);
} else if (!line.trim().isEmpty()) {
currentList = addPendingList(document, currentList);
addParagraph(document, line, fonts);
}
return currentList;
}
private List addPendingList(Document document, List currentList) {
if (currentList != null) {
document.add(currentList);
}
return null;
}
private void addHeading1(Document document, String line, FontHolder fonts) {
Paragraph heading = new Paragraph(line.substring(H1_PREFIX.length()))
.setFont(fonts.bold)
.setFontSize(H1_FONT_SIZE)
.setTextAlignment(TextAlignment.CENTER);
document.add(heading);
}
private void addHeading2(Document document, String line, FontHolder fonts) {
Paragraph heading = new Paragraph(line.substring(H2_PREFIX.length()))
.setFont(fonts.bold)
.setFontSize(H2_FONT_SIZE)
.setMarginTop(H2_MARGIN_TOP);
document.add(heading);
}
private List addListItem(List currentList, String line, FontHolder fonts) {
if (currentList == null) {
currentList = new List();
currentList.setFont(fonts.regular).setFontSize(BODY_FONT_SIZE);
}
ListItem item = new ListItem();
item.add(new Paragraph(line.substring(LIST_PREFIX.length())));
item.setTextAlignment(TextAlignment.JUSTIFIED);
currentList.add(item);
return currentList;
}
private void addParagraph(Document document, String line, FontHolder fonts) {
Paragraph paragraph = new Paragraph(line)
.setFont(fonts.regular)
.setFontSize(BODY_FONT_SIZE)
.setTextAlignment(TextAlignment.JUSTIFIED)
.setFirstLineIndent(PARAGRAPH_INDENT);
document.add(paragraph);
}
private void addFooterLogos(PdfDocument pdfDoc, Image logoImage) {
if (logoImage == null) {
return;
}
try {
URL logoResource = getClass().getClassLoader().getResource(LOGO_PATH);
for (int i = 1; i <= pdfDoc.getNumberOfPages(); i++) {
addFooterLogo(pdfDoc.getPage(i), logoResource);
}
} catch (Exception e) {
// Log warning and continue without footer logos
}
}
private void addFooterLogo(PdfPage page, URL logoResource) {
try {
PdfCanvas canvas = new PdfCanvas(page);
Rectangle footerRect = new Rectangle(
PAGE_MARGIN,
20,
PageSize.A4.getWidth() - (2 * PAGE_MARGIN),
40
);
try (Canvas footerCanvas = new Canvas(canvas, footerRect)) {
Image footerLogo = new Image(ImageDataFactory.create(logoResource));
footerLogo.scaleToFit(FOOTER_LOGO_WIDTH, FOOTER_LOGO_HEIGHT);
footerLogo.setHorizontalAlignment(HorizontalAlignment.CENTER);
footerCanvas.add(footerLogo);
}
} catch (Exception e) {
// Log warning and continue
}
}
private static class FontHolder {
private final PdfFont regular;
private final PdfFont bold;
public FontHolder(PdfFont regular, PdfFont bold) {
this.regular = regular;
this.bold = bold;
}
}
}
Be sure to place a sample logo image at src/main/resources/logo.png
. I’ve generated a fictional company logo with ChatGPT and placed it into my repository. Also make sure you have:
IBMPlexSans-Bold.ttf
IBMPlexSans-Regular.ttf
downloaded and placed in src/main/resources/.
Expose the REST API
Create a simple record to represent the incoming data:
src/main/java/com/example/WhitePaperRequest.java
package com.example;
package com.example;
public record WhitePaperRequest(String productName, String features, String targetAudience) {
}
Rename the GreetingResource to and replace with;
src/main/java/com/example/WhitePaperResource.java
package com.example;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/whitepaper")
public class WhitePaperResource {
@Inject
WhitePaperAiService aiService;
@Inject
PdfGeneratorService pdfService;
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces("application/pdf")
public Response generateWhitePaper(WhitePaperRequest request) {
try {
String content = aiService.generateWhitePaperContent(
request.productName(),
request.targetAudience(),
request.features());
byte[] pdf = pdfService.createWhitePaperPdf(content);
return Response.ok(pdf)
.header("Content-Disposition", "attachment; filename=Innovatech_White_Paper.pdf")
.build();
} catch (Exception e) {
return Response.serverError().entity(e.getMessage()).build();
}
}
}
Build a Clean Frontend
Put this into src/main/resources/META-INF/resources/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI White Paper Generator</title>
<style>
<!-- omitted -->
</style>
</head>
<body>
<div class="container">
<h1>AI White Paper Generator</h1>
<form id="paperForm">
<div class="form-group">
<label for="productName">Product Name:</label>
<input type="text" id="productName" value="QuantumLeap AI Engine" required>
</div>
<div class="form-group">
<label for="targetAudience">Target Audience:</label>
<input type="text" id="targetAudience" value="CTOs and R&D Leads in Fortune 500 companies" required>
</div>
<div class="form-group">
<label for="features">Key Features (one per line):</label>
<textarea id="features" required>Real-time predictive analytics
Scalable neural network architecture
Natural Language Processing with 99% accuracy
Automated data ingestion and cleaning</textarea>
</div>
<button type="submit" id="submitBtn">Generate PDF</button>
<div id="loader">Generating, please wait... This may take a minute.</div>
</form>
</div>
<script>
const form = document.getElementById('paperForm');
const submitBtn = document.getElementById('submitBtn');
const loader = document.getElementById('loader');
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
loader.style.display = 'block';
const requestData = {
productName: document.getElementById('productName').value,
targetAudience: document.getElementById('targetAudience').value,
features: document.getElementById('features').value
};
try {
const response = await fetch('/whitepaper', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(`Server error: ${response.statusText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = 'Innovatech_White_Paper.pdf';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (error) {
console.error('Error:', error);
alert('Failed to generate PDF. Please check the console for details.');
} finally {
submitBtn.disabled = false;
loader.style.display = 'none';
}
});
</script>
</body>
</html>
This frontend allows users to submit product details and download the generated white paper as a PDF. No build tools required.
Run the Application
Launch your Quarkus app:
./mvnw quarkus:dev
Navigate to http://localhost:8080, fill out the form, and click "Generate PDF".
Your browser will download a fresh white paper written by your local LLM and styled with iText.
Where to Go Next
This app is a solid foundation for:
Auto-branding documents per customer.
Adding charts and structured data using iText's advanced layout system.
Uploading custom prompts or templates.
Serving your AI document service in a Kubernetes-based platform like OpenShift.
You now have an end-to-end document generation pipeline entirely powered by Java, Quarkus, and a local LLM.
Next time someone says "Can you write the white paper?", you just smile and click.