Your First Report
Build a simple invoice report class from scratch.
This tutorial walks through building a reusable invoice report. By the end you will have a class that takes invoice data, produces a properly laid out PDF, and can be called from anywhere in your application.
Kawa requires Java 17 or later. Make sure your project is set up accordingly before you start.
What we are building
A single-page A4 invoice with:
- A header showing your company name and the invoice number
- A "bill to" block with the client's name and address
- A line items table with quantities and prices
- A totals row at the bottom of the table
- A simple footer with payment terms
Step 1: Create the class
Create a new class and implement KawaDocument. The interface has two required methods: configure for metadata and compose for the actual layout.
import dev.ditsche.kawa.core.*;
import dev.ditsche.kawa.elements.*;
import dev.ditsche.kawa.style.Colors;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
public class InvoiceReport implements KawaDocument {
public record LineItem(String description, int quantity, double unitPrice) {
public double total() { return quantity * unitPrice; }
}
private final String invoiceNumber;
private final LocalDate date;
private final String clientName;
private final String clientAddress;
private final List<LineItem> items;
public InvoiceReport(
String invoiceNumber,
LocalDate date,
String clientName,
String clientAddress,
List<LineItem> items) {
this.invoiceNumber = invoiceNumber;
this.date = date;
this.clientName = clientName;
this.clientAddress = clientAddress;
this.items = items;
}
}Step 2: Set the document metadata
The configure method sets the PDF file metadata. This shows up in the reader's document info panel and in the file name when saving.
private static final DateTimeFormatter DATE_FMT =
DateTimeFormatter.ofPattern("MMMM d, yyyy");
@Override
public void configure(DocumentSettings settings) {
settings.title("Invoice " + invoiceNumber)
.author("ACME GmbH")
.subject("Invoice");
}Step 3: Define the page layout
The compose method receives a PageDefinition. This is where you set the page size, margins, and wire up the header, footer, and content.
@Override
public void compose(PageDefinition page) {
page.size(PageSize.A4)
.marginTop(50).marginBottom(50)
.marginLeft(60).marginRight(60)
.header(this::buildHeader)
.footer(this::buildFooter)
.content(this::buildContent);
}Splitting the layout into private methods (buildHeader, buildFooter, buildContent) keeps
compose readable and makes each section easy to adjust independently.
Step 4: Build the header
The header receives a ColumnElement. Use a RowElement inside it to put your company name on the left and the invoice details on the right.
private static final KawaColor MUTED = Colors.GRAY_500;
private void buildHeader(ColumnElement h) {
h.add(new RowElement(row -> {
row.fillColumn(left -> {
left.text("ACME GmbH").bold().fontSize(16);
left.text("hello@acme.example").fontSize(9).color(MUTED);
});
row.fixedColumn(140, right -> {
right.text("Invoice " + invoiceNumber).bold().fontSize(10).rightAlign();
right.text(date.format(DATE_FMT)).fontSize(9).color(MUTED).rightAlign();
});
}));
h.add(new SeparatorElement().marginTop(8).marginBottom(0));
}Step 5: Add the bill-to block and line items
The content method gets the main ColumnElement for the page. Start with the client block, then add a spacer, then the table.
private void buildContent(ColumnElement c) {
// Bill to
c.item().text("Bill to").fontSize(9).color(MUTED);
c.item().text(clientName).bold().fontSize(11);
c.item().text(clientAddress).fontSize(10).color(MUTED);
c.item().spacer(24);
// Line items
c.item().table(table -> {
table.columns(cols -> {
cols.relative(4); // description gets most of the space
cols.relative(1); // quantity
cols.fixed(90); // unit price
cols.fixed(90); // total
});
table.header(h -> {
h.cell("Description").bold();
h.cell("Qty").bold().centerAlign();
h.cell("Unit price").bold().rightAlign();
h.cell("Total").bold().rightAlign();
});
for (LineItem item : items) {
table.row(r -> {
r.cell(item.description());
r.cell(String.valueOf(item.quantity())).centerAlign();
r.cell(formatMoney(item.unitPrice())).rightAlign();
r.cell(formatMoney(item.total())).rightAlign();
});
}
// Totals row
double grandTotal = items.stream().mapToDouble(LineItem::total).sum();
table.row(r -> {
r.cell("").borderTop(1f, Colors.BLACK);
r.cell("").borderTop(1f, Colors.BLACK);
r.cell("Total").bold().rightAlign().borderTop(1f, Colors.BLACK);
r.cell(formatMoney(grandTotal)).bold().rightAlign().borderTop(1f, Colors.BLACK);
});
})
.cellPadding(7)
.alternateRowColor(Colors.GRAY_50)
.borderColor(Colors.GRAY_300);
}
private static String formatMoney(double amount) {
return String.format("EUR %.2f", amount);
}The totals row uses an empty cell with .borderTop() on each column to draw a dividing line above
the final row without adding a separate separator element.
Step 6: Add the footer
The footer uses the two-argument form so you can access the page number through PageContext.
private void buildFooter(ColumnElement f, PageContext ctx) {
f.add(new SeparatorElement().marginTop(0).marginBottom(6));
f.add(new RowElement(row -> {
row.fillColumn(left ->
left.text("Payment due within 30 days.").fontSize(8).color(MUTED)
);
row.fixedColumn(60, right ->
right.text("Page " + ctx.pageOf()).fontSize(8).color(MUTED).rightAlign()
);
}));
}Step 7: Generate the PDF
Instantiate the report with your data and call generatePdf.
List<InvoiceReport.LineItem> items = List.of(
new InvoiceReport.LineItem("Backend development", 8, 120.00),
new InvoiceReport.LineItem("Code review", 2, 120.00),
new InvoiceReport.LineItem("Deployment setup", 3, 120.00)
);
new InvoiceReport(
"INV-2025-001",
LocalDate.now(),
"TechStart GmbH",
"Berliner Str. 12, 10115 Berlin",
items
).generatePdf("invoice.pdf");The complete class
Here is everything together:
import dev.ditsche.kawa.core.*;
import dev.ditsche.kawa.elements.*;
import dev.ditsche.kawa.style.Colors;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
public class InvoiceReport implements KawaDocument {
public record LineItem(String description, int quantity, double unitPrice) {
public double total() { return quantity * unitPrice; }
}
private static final DateTimeFormatter DATE_FMT =
DateTimeFormatter.ofPattern("MMMM d, yyyy");
private static final KawaColor MUTED = Colors.GRAY_500;
private final String invoiceNumber;
private final LocalDate date;
private final String clientName;
private final String clientAddress;
private final List<LineItem> items;
public InvoiceReport(String invoiceNumber, LocalDate date,
String clientName, String clientAddress,
List<LineItem> items) {
this.invoiceNumber = invoiceNumber;
this.date = date;
this.clientName = clientName;
this.clientAddress = clientAddress;
this.items = items;
}
@Override
public void configure(DocumentSettings settings) {
settings.title("Invoice " + invoiceNumber)
.author("ACME GmbH")
.subject("Invoice");
}
@Override
public void compose(PageDefinition page) {
page.size(PageSize.A4)
.marginTop(50).marginBottom(50)
.marginLeft(60).marginRight(60)
.header(this::buildHeader)
.footer(this::buildFooter)
.content(this::buildContent);
}
private void buildHeader(ColumnElement h) {
h.add(new RowElement(row -> {
row.fillColumn(left -> {
left.text("ACME GmbH").bold().fontSize(16);
left.text("hello@acme.example").fontSize(9).color(MUTED);
});
row.fixedColumn(140, right -> {
right.text("Invoice " + invoiceNumber).bold().fontSize(10).rightAlign();
right.text(date.format(DATE_FMT)).fontSize(9).color(MUTED).rightAlign();
});
}));
h.add(new SeparatorElement().marginTop(8).marginBottom(0));
}
private void buildContent(ColumnElement c) {
c.item().text("Bill to").fontSize(9).color(MUTED);
c.item().text(clientName).bold().fontSize(11);
c.item().text(clientAddress).fontSize(10).color(MUTED);
c.item().spacer(24);
c.item().table(table -> {
table.columns(cols -> {
cols.relative(4);
cols.relative(1);
cols.fixed(90);
cols.fixed(90);
});
table.header(h -> {
h.cell("Description").bold();
h.cell("Qty").bold().centerAlign();
h.cell("Unit price").bold().rightAlign();
h.cell("Total").bold().rightAlign();
});
for (LineItem item : items) {
table.row(r -> {
r.cell(item.description());
r.cell(String.valueOf(item.quantity())).centerAlign();
r.cell(formatMoney(item.unitPrice())).rightAlign();
r.cell(formatMoney(item.total())).rightAlign();
});
}
double grandTotal = items.stream().mapToDouble(LineItem::total).sum();
table.row(r -> {
r.cell("").borderTop(1f, Colors.BLACK);
r.cell("").borderTop(1f, Colors.BLACK);
r.cell("Total").bold().rightAlign().borderTop(1f, Colors.BLACK);
r.cell(formatMoney(grandTotal)).bold().rightAlign().borderTop(1f, Colors.BLACK);
});
})
.cellPadding(7)
.alternateRowColor(Colors.GRAY_50)
.borderColor(Colors.GRAY_300);
}
private void buildFooter(ColumnElement f, PageContext ctx) {
f.add(new SeparatorElement().marginTop(0).marginBottom(6));
f.add(new RowElement(row -> {
row.fillColumn(left ->
left.text("Payment due within 30 days.").fontSize(8).color(MUTED)
);
row.fixedColumn(60, right ->
right.text("Page " + ctx.pageOf()).fontSize(8).color(MUTED).rightAlign()
);
}));
}
private static String formatMoney(double amount) {
return String.format("EUR %.2f", amount);
}
}Where to go next
Now that you have a working report class, here are some natural next steps: