Note: This one is mostly Claude-code vibecoded...
A small Java library for generating text-oriented "index card" images from HTML
and CSS. You describe a card as a snippet of HTML plus a stylesheet, and
indexcard renders it to an in-memory
java.awt.image.BufferedImage
that you can serve, store, or post-process however you like.
Rendering is done with the pure-Java Flying Saucer
engine, so there are no native dependencies and no external browser — but note
that means the supported styling is CSS 2.1 (block/inline layout, float,
tables, borders, backgrounds, fonts), not modern CSS3 (no flexbox or grid).
- Java 21 or later
Artifact coordinates are com.mchange:indexcard.
Maven:
<dependency>
<groupId>com.mchange</groupId>
<artifactId>indexcard</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>Gradle:
implementation 'com.mchange:indexcard:0.0.1-SNAPSHOT'Mill:
mvn"com.mchange:indexcard:0.0.1-SNAPSHOT"import com.mchange.indexcard.Generator;
import java.awt.image.BufferedImage;
import java.nio.file.Path;
public class Demo {
public static void main(String[] args) throws Exception {
String html =
"<h1>Index Card</h1>" +
"<p>Hello, <b>world</b>. A bit of text and a rounded border.</p>";
String css =
"#indexcard {" +
" width: 320px; height: 190px;" +
" background: #fffbe6;" +
" border: 2px solid #333; border-radius: 12px;" +
" padding: 14px; box-sizing: border-box;" +
" font-family: sans-serif;" +
"}" +
"h1 { font-size: 20px; margin: 0 0 8px 0; }" +
"p { font-size: 14px; margin: 0; line-height: 1.4; }";
BufferedImage card = Generator.generate(html, css);
Generator.writePng(card, Path.of("card.png"));
}
}Your HTML becomes the contents of a <div id="indexcard"> inside the body of a
dummy document, and your CSS becomes that document's stylesheet — so styling the
#indexcard element (as above) styles the card as a whole. The innerHtml need
not be well-formed XML; it is normalized before rendering.
All methods are static, on com.mchange.indexcard.Generator.
| Method | Purpose |
|---|---|
BufferedImage generate(String innerHtml, String styleCss) |
Render a card to an image at native (1×) resolution. |
BufferedImage generate(String innerHtml, String styleCss, double scale) |
Render a card supersampled by scale for crisper output (see Resolution). |
byte[] toPng(BufferedImage img) |
Encode an image as PNG bytes (e.g. to serve over HTTP). |
void writePng(BufferedImage img, OutputStream out) |
Write an image as PNG to a stream (the stream is not closed). |
void writePng(BufferedImage img, Path path) |
Write an image as PNG to a file. |
BufferedImage centerImageOnTransparentCanvas(BufferedImage original, int width, int height) |
Place an image centered on a fixed-size transparent canvas (see Sizing). |
The returned image always uses an alpha channel (TYPE_INT_ARGB), so anything
your CSS leaves unpainted — outside rounded corners, or with no background — is
transparent. Text is rendered with antialiasing.
Serving a card over HTTP, for example, is just:
byte[] png = Generator.toPng(Generator.generate(html, css));
response.setContentType("image/png");
response.getOutputStream().write(png);You control the dimensions of the image entirely through CSS — typically by
setting width and height on #indexcard. The image is laid out in a wide
viewport and then cropped to the painted (non-transparent) pixels.
For a card with a background and/or a border, the whole declared box is painted,
so the image comes out at exactly the width × height you asked for.
There is one caveat: a card with neither a background nor a border — just
text floating on a transparent canvas — has only its glyphs painted, so the
image is cropped tight to the bounding box of the text. Any declared
width/height and surrounding padding is trimmed away as transparent
margin. If you want a fixed-size image in that case, render normally and then
center the result on a canvas of the size you intended:
BufferedImage tight = Generator.generate(html, css); // cropped to text
BufferedImage fixed = Generator.centerImageOnTransparentCanvas(tight, 320, 190);CSS dimensions are interpreted in CSS pixels, so a card sized width: 320px in
CSS produces a 320-pixel-wide image at native resolution. For a small or narrow
card that can look low-resolution, especially on high-DPI screens.
To get crisper output, keep the CSS sizes small and pass a scale factor:
layout still happens in CSS pixels (your 320px stays logically 320px wide), but
the image is rasterized onto a buffer scale times larger in each dimension,
with text and edges re-rendered at that higher resolution.
// A 320x190 CSS card rendered onto a 960x570 image (3x), same layout, crisper.
BufferedImage hi = Generator.generate(html, css, 3.0);This is preferable to simply enlarging everything in CSS, which would also change
wrapping and layout. scale only affects the pixel resolution of the result.
Because rendering uses Flying Saucer, styling is limited to CSS 2.1, and there are a few engine-specific behaviors worth knowing.
If an element has box-sizing: border-box and its text is right- or
center-aligned, Flying Saucer aligns the text to the border edge rather than
the content edge — so the element's padding on that side is ignored, and the
text sits flush against the border. Left-aligned text is unaffected.
The fix is to not use box-sizing: border-box on the affected element (the
default content-box aligns correctly):
/* Problem: the right-aligned credits end up flush against the border. */
* { box-sizing: border-box; }
#indexcard { width: 380px; border: 2px solid black; padding: 5px; }
#indexcard .credits { text-align: right; }
/* Fix: drop border-box; right-aligned text now respects the padding. */
#indexcard { width: 366px; border: 2px solid black; padding: 5px; }
#indexcard .credits { text-align: right; }Note that with the default content-box, width is the content width, so the
card's outer size is width + 2×padding + 2×border. To hit a specific outer
width W, set width: (W − 2×border − 2×padding).
Even without box-sizing: border-box, Flying Saucer does not position
text-align: right (or center) text reliably. Right-aligned lines may fail to
share a common right edge, may fall short of or collide with the container's
border, and may ignore its right padding — and the effect is inconsistent within
a single block (some lines behave, others don't). It is especially pronounced in
blocks containing long, URL-like tokens, but the misalignment is present more
generally.
Worse, right-alignment can also prevent long content from wrapping: Flying
Saucer mis-measures the available width in right-align mode, keeps a long URL on
a single line, and lets it overflow the border — where the same URL wraps cleanly
within the card under text-align: left.
There is no reliable CSS workaround to make right-alignment come out clean. Things that seem like they should help do not:
- Reducing the font size does not help — greedy wrapping still produces edge-filling lines.
- Adding
padding-right(ormargin-right) to the element only changes where text wraps; the lines are still aligned inconsistently. - Splitting each line into its own element does not make the right edges line up.
If you need clean, predictable output, use text-align: left. Left-alignment
positions reliably: consistent left edges, padding respected on both sides. If
you want the text to sit toward the right of the card, put it in a narrower block
positioned on the right (e.g. via margin-left) but keep text-align: left
inside that block.
Flying Saucer only breaks long strings at certain characters (such as / and
.) and does not support the CSS3 wrapping controls (overflow-wrap,
word-wrap, word-break), nor <wbr> or zero-width spaces. A token with no
break opportunities that is wider than its content box will overflow rather than
wrap. If you display arbitrary URLs, prefer to shorten or elide them (e.g. strip
the https:// scheme, or truncate the middle) rather than relying on wrapping.
The project builds with Mill via the bundled
bootstrap script (./mill), so you don't need Mill installed:
./mill compile # compile the library
./mill test # run the test suite
./mill publishLocal # install to your local repositoryApache License 2.0.