Skip to content

Commit f53fd76

Browse files
brunoborgesCopilot
andcommitted
blog: Replacing Apache Batik with Graphics2D for OG card generation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ee32f8e commit f53fd76

File tree

1 file changed

+241
-0
lines changed

1 file changed

+241
-0
lines changed
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Replacing Apache Batik with Graphics2D: How We Made Our Java OG Card Generator Faster Than Python
2+
3+
*March 2026 · [javaevolved.github.io](https://javaevolved.github.io)*
4+
5+
---
6+
7+
[Java Evolved](https://javaevolved.github.io) is a static site showcasing 112 modern Java patterns across 11 categories — each with a side-by-side "old vs modern" code comparison. For every pattern, we generate an Open Graph card: a 1200×630 PNG image used when links are shared on social media.
8+
9+
This is the story of how we replaced Apache Batik with plain `Graphics2D`, split a monolithic script into modules using JBang, and tuned the JVM to squeeze out every last bit of performance — ending up faster than Python.
10+
11+
## The starting point: Batik
12+
13+
Our OG card generator was a single 400-line Java file (`generateog.java`) run via [JBang](https://www.jbang.dev). It built an SVG string for each card, then used Apache Batik's `PNGTranscoder` to rasterize it to PNG:
14+
15+
```java
16+
//DEPS org.apache.xmlgraphics:batik-transcoder:1.18
17+
//DEPS org.apache.xmlgraphics:batik-codec:1.18
18+
19+
static void svgToPng(String svgContent, Path pngPath) throws Exception {
20+
var input = new TranscoderInput(new StringReader(svgContent));
21+
try (var out = new BufferedOutputStream(Files.newOutputStream(pngPath))) {
22+
var transcoder = new PNGTranscoder();
23+
transcoder.addTranscodingHint(PNGTranscoder.KEY_WIDTH, (float) W * 2);
24+
transcoder.addTranscodingHint(PNGTranscoder.KEY_HEIGHT, (float) H * 2);
25+
transcoder.transcode(input, new TranscoderOutput(out));
26+
}
27+
}
28+
```
29+
30+
This worked, but Batik is a heavyweight library. It brings in the full AWT/Swing graphics pipeline, XML parsers, and codec libraries. The fat JAR was over 10 MB. And it was **slower than our equivalent Python script using cairosvg** — a thin wrapper around the native Cairo C library.
31+
32+
We had an irony on our hands: a site about modern Java patterns had a Java tool that couldn't outperform Python.
33+
34+
## The insight: we don't need SVG→PNG
35+
36+
Our OG cards are simple layouts — rounded rectangles, text, solid fills. We were building an SVG string by hand, then asking Batik to parse it back into a graphics model and rasterize it. That's a round trip through XML parsing for geometry we already knew.
37+
38+
Java's `Graphics2D` API can draw all of this directly to a `BufferedImage`:
39+
40+
```java
41+
var img = new BufferedImage(W * SCALE, H * SCALE, BufferedImage.TYPE_INT_RGB);
42+
var g = img.createGraphics();
43+
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
44+
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB);
45+
g.scale(SCALE, SCALE);
46+
47+
// Draw directly — no SVG intermediary
48+
drawCard(g, snippet);
49+
50+
g.dispose();
51+
ImageIO.write(img, "PNG", outputPath.toFile());
52+
```
53+
54+
No SVG parsing. No Batik. No external dependencies for PNG output — just the JDK.
55+
56+
We still generate SVG files (for the web), but PNG rendering is now pure `Graphics2D`.
57+
58+
## The refactoring: JBang multi-source
59+
60+
The original 400-line monolith mixed colors, dimensions, content loading, syntax highlighting, SVG generation, PNG conversion, and font management all in one implicit class. We split it into 7 focused source files using JBang's `//SOURCES` directive:
61+
62+
```java
63+
///usr/bin/env jbang "$0" "$@" ; exit $?
64+
//JAVA 25
65+
//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3
66+
//DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3
67+
//SOURCES og/Palette.java
68+
//SOURCES og/Layout.java
69+
//SOURCES og/ContentLoader.java
70+
//SOURCES og/SyntaxHighlighter.java
71+
//SOURCES og/SvgRenderer.java
72+
//SOURCES og/PngRenderer.java
73+
//SOURCES og/FontManager.java
74+
```
75+
76+
Each file lives in the `og` package under `html-generators/og/`:
77+
78+
| File | Responsibility |
79+
|------|---------------|
80+
| `Palette.java` | Color constants (hex strings + `Color.decode()` helper) |
81+
| `Layout.java` | Dimensions, font sizing, line fitting |
82+
| `ContentLoader.java` | JSON/YAML parsing, `Snippet` record |
83+
| `SyntaxHighlighter.java` | Java tokenizer → `List<Token>` (shared by SVG + PNG) |
84+
| `SvgRenderer.java` | SVG string generation |
85+
| `PngRenderer.java` | Direct `Graphics2D` PNG rendering |
86+
| `FontManager.java` | Font downloading and registration |
87+
88+
The `SyntaxHighlighter` is the key shared abstraction. Instead of producing SVG `<tspan>` fragments directly, it returns a `List<Token>` where each token has text and an optional color:
89+
90+
```java
91+
public record Token(String text, String color) {}
92+
```
93+
94+
The SVG renderer converts tokens to `<tspan>` elements. The PNG renderer draws them with `Graphics2D`:
95+
96+
```java
97+
static void drawTokens(Graphics2D g, List<Token> tokens, int x, int y,
98+
Font codeFont, FontRenderContext frc) {
99+
float curX = x;
100+
for (var token : tokens) {
101+
g.setColor(token.color() != null
102+
? Palette.color(token.color())
103+
: Palette.color(Palette.SYN_DEFAULT));
104+
g.drawString(token.text(), curX, y);
105+
curX += (float) codeFont.getStringBounds(token.text(), frc).getWidth();
106+
}
107+
}
108+
```
109+
110+
## Font loading: a subtle gotcha
111+
112+
Our first `Graphics2D` attempt rendered text with the wrong fonts. The labels showed □ rectangles instead of ✗ and ✓ symbols.
113+
114+
The problem: Java's `Font.createFont()` registers all physical fonts with `style=0` (PLAIN), regardless of their actual weight. When you write `new Font("Inter", Font.BOLD, 24)`, Java looks for a font in the "Inter" family with `BOLD` style — but the registered "Inter Bold" has `style=0`. Java falls back to algorithmic bolding of the Regular weight, or worse, to a system font that lacks the Unicode characters you need.
115+
116+
The fix: load fonts directly from the `.ttf` files:
117+
118+
```java
119+
public static Font getFont(String filename, float size) {
120+
var path = FONT_CACHE.resolve(filename);
121+
return Font.createFont(Font.TRUETYPE_FONT, path.toFile()).deriveFont(size);
122+
}
123+
```
124+
125+
Then use exact physical fonts:
126+
127+
```java
128+
var titleFont = FontManager.getFont("Inter-Bold.ttf", 24f);
129+
var labelFont = FontManager.getFont("Inter-SemiBold.ttf", 11f);
130+
var codeFont = FontManager.getFont("JetBrainsMono-Regular.ttf", (float) fontSize);
131+
```
132+
133+
## GC profiling: humongous allocations
134+
135+
Running with `-Xlog:gc:stderr` revealed the OG generator triggered **34 GC events** with repeated "G1 Humongous Allocation" warnings:
136+
137+
```
138+
GC(5) Pause Young (Concurrent Start) (G1 Humongous Allocation) 238M->138M(516M)
139+
GC(7) Pause Young (Concurrent Start) (G1 Humongous Allocation) 234M->90M(516M)
140+
```
141+
142+
Each `BufferedImage` at 2× resolution is 2400×1260×4 bytes ≈ **12 MB** — larger than half the default G1 region size. G1 treats these as "humongous" objects requiring special allocation paths.
143+
144+
The fix was trivially simple: **reuse a single `BufferedImage`** across all 112 cards.
145+
146+
```java
147+
// Allocated once, reused for every card
148+
static final BufferedImage SHARED_IMG =
149+
new BufferedImage(W * SCALE, H * SCALE, BufferedImage.TYPE_INT_RGB);
150+
```
151+
152+
Each card clears the image with a white fill before drawing. Result: **34 GCs → 1 GC**.
153+
154+
## JIT tuning: lowering the C2 threshold
155+
156+
With only 112 iterations, the JVM's C2 compiler (which normally triggers after ~10,000 invocations) never kicks in. The hot methods — tokenization, font metrics, `drawString` — run in C1-compiled (or interpreted) code.
157+
158+
Lowering the C2 threshold to 100 invocations lets the optimizing compiler engage early:
159+
160+
```
161+
-XX:Tier4CompileThreshold=100
162+
```
163+
164+
| Threshold | Time (112 cards) | Delta |
165+
|-----------|-----------------|-------|
166+
| Default (~10K) | 10.02s | baseline |
167+
| **100** | **9.32s** | **−7%** |
168+
| 1 | 9.78s | −2% |
169+
170+
Threshold=100 is the sweet spot: C2 compiles the hot loop just in time for the bulk of the work. Threshold=1 wastes time compiling methods before they're warm.
171+
172+
We added this flag to all `java -jar` execution calls in our CI workflows. It doesn't affect AOT training runs (which need default JIT behavior for proper class profiling).
173+
174+
## The results
175+
176+
### Local benchmarks (Apple M1 Max, 112 patterns)
177+
178+
**OG Card Generator — Steady-State (avg of 5 runs):**
179+
180+
| Method | Time |
181+
|--------|------|
182+
| **Fat JAR (Graphics2D)** | **10.82s** |
183+
| Fat JAR + AOT | 11.04s |
184+
| JBang (from source) | 11.62s |
185+
| Python (cairosvg) | 14.10s |
186+
187+
**HTML Generator — Steady-State (avg of 5 runs):**
188+
189+
| Method | Time |
190+
|--------|------|
191+
| **Fat JAR** | **7.47s** |
192+
| Fat JAR + AOT | 8.00s |
193+
| JBang | 11.63s |
194+
| Python | 31.52s |
195+
196+
### What we shipped
197+
198+
| Before | After |
199+
|--------|-------|
200+
| Batik transcoder + codec deps | Zero rendering deps (pure JDK) |
201+
| 10+ MB fat JAR | 2.6 MB fat JAR |
202+
| 1 monolithic file (400 lines) | 7 modular source files |
203+
| 34 GC events per run | 1 GC event per run |
204+
| Slower than Python | **24% faster than Python** |
205+
206+
### Why AOT cache doesn't help here
207+
208+
You might notice the AOT cache (`-XX:AOTCache`) shows no improvement over plain JAR execution. That's expected: JEP 483 AOT cache pre-loads classes from a training run, eliminating class-loading overhead on subsequent runs. But for our generator, class loading is already fast (~0.5s). The bottleneck is `ImageIO.write()` — pure CPU-bound PNG compression — which no amount of class pre-loading can speed up.
209+
210+
## What we learned
211+
212+
1. **Don't SVG-to-PNG when you can draw directly.** If you control the layout, `Graphics2D` + `ImageIO` is simpler, faster, and dependency-free.
213+
214+
2. **Java's `Font.createFont()` registers everything as style=0.** Load fonts from files with `deriveFont()` instead of relying on `new Font(name, style, size)`.
215+
216+
3. **Reuse large objects.** A 12 MB `BufferedImage` per card is a humongous allocation in G1. One shared buffer, cleared each iteration, drops GC events from 34 to 1.
217+
218+
4. **Lower `-XX:Tier4CompileThreshold` for short-lived CLI apps.** With only ~100 iterations, the C2 compiler needs a nudge to engage before the work is done.
219+
220+
5. **Profile before tuning.** We tested Epsilon GC, Shenandoah, ZGC, Serial GC, various heap sizes, and G1 region sizes. None moved the needle. The bottleneck was always PNG compression — a CPU-bound operation that no GC or heap configuration can improve.
221+
222+
6. **JBang `//SOURCES` makes multi-file Java scripts practical.** No build tool, no `pom.xml` — just list your source files and run.
223+
224+
## Try it yourself
225+
226+
```bash
227+
# Generate all 112 OG cards
228+
jbang html-generators/generateog.java
229+
230+
# Generate a single card
231+
jbang html-generators/generateog.java language/type-inference-with-var
232+
233+
# Build JAR + AOT for fastest execution
234+
jbang export fatjar --force --output html-generators/generateog.jar html-generators/generateog.java
235+
java -XX:AOTCacheOutput=html-generators/generateog.aot -jar html-generators/generateog.jar
236+
java -XX:Tier4CompileThreshold=100 -XX:AOTCache=html-generators/generateog.aot -jar html-generators/generateog.jar
237+
```
238+
239+
The full source is at [github.com/javaevolved/javaevolved.github.io](https://github.com/javaevolved/javaevolved.github.io) under `html-generators/`.
240+
241+
CI benchmark results: [Actions run #22563953466](https://github.com/javaevolved/javaevolved.github.io/actions/runs/22563953466).

0 commit comments

Comments
 (0)