feat(cover-generator): enhance cover resolution and redesign layout for improved aesthetics (#2125)

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs
2026-01-05 22:27:26 +01:00
committed by GitHub
parent 34bbe8a14e
commit 9a498da2ba

View File

@ -9,7 +9,7 @@ import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.geom.Rectangle2D;
import java.awt.geom.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@ -21,168 +21,681 @@ import java.util.regex.Pattern;
@Service
public class CoverImageGenerator {
private static final int WIDTH = 250;
private static final int HEIGHT = 350;
private static final int PADDING = 20;
private static final int TITLE_TOP_MARGIN = 40;
private static final int TITLE_PADDING = 15;
private static final int AUTHOR_BOTTOM_MARGIN = 330;
private static final int MAX_LINES = 5;
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final int WIDTH = 1200;
private static final int HEIGHT = 1600;
private static final int SCALE = 2;
private static final double PHI = 1.618033988749;
private static final double PHI_INV = 1.0 / PHI;
private static final double PHI_SQ_INV = 1.0 / (PHI * PHI);
private static final int MAX_TITLE_LINES = 5;
private static final int MAX_AUTHOR_LINES = 2;
private static final int MAX_TITLE_LEN = 200;
private static final int MAX_AUTHOR_LEN = 100;
private static final int MIN_FONT = 36 * SCALE;
private static final Pattern WS = Pattern.compile("\\s+");
public byte[] generateCover(String title, String author) {
BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
Color[] colors = getColorScheme(title);
Color bgColor1 = colors[0];
Color bgColor2 = colors[1];
GradientPaint backgroundGradient = new GradientPaint(
0, 0, bgColor1,
0, HEIGHT, bgColor2
);
g2d.setPaint(backgroundGradient);
g2d.fillRect(0, 0, WIDTH, HEIGHT);
String effectiveTitle = title != null && !title.isBlank() ? title : "Unknown Title";
String effectiveAuthor = author != null && !author.isBlank() ? author : "Unknown Author";
Font titleFont = getTitleFont(effectiveTitle.length());
g2d.setFont(titleFont);
FontMetrics titleFm = g2d.getFontMetrics();
List<String> titleLines = wrapText(effectiveTitle, titleFm);
int titleLineHeight = titleFm.getHeight();
int titleBoxHeight = (titleLines.size() * titleLineHeight) + (TITLE_PADDING * 2);
GradientPaint titleBgGradient = new GradientPaint(
0, TITLE_TOP_MARGIN, new Color(221, 221, 221), // #dddddd
0, TITLE_TOP_MARGIN + titleBoxHeight, new Color(223, 223, 223, 153) // #dfdfdf with 0.6 opacity
);
g2d.setPaint(titleBgGradient);
g2d.fillRect(0, TITLE_TOP_MARGIN, WIDTH, titleBoxHeight);
g2d.setColor(Color.BLACK);
int currentY = TITLE_TOP_MARGIN + TITLE_PADDING + titleFm.getAscent();
for (String line : titleLines) {
g2d.drawString(line, PADDING, currentY);
currentY += titleLineHeight;
}
Font authorFont = getAuthorFont(effectiveAuthor.length());
g2d.setFont(authorFont);
FontMetrics authorFm = g2d.getFontMetrics();
List<String> authorLines = wrapText(effectiveAuthor, authorFm);
int authorLineHeight = authorFm.getHeight();
int authorStartY = AUTHOR_BOTTOM_MARGIN - ((authorLines.size() - 1) * authorLineHeight);
g2d.setColor(Color.BLACK);
for (int i = 0; i < authorLines.size(); i++) {
String line = authorLines.get(i);
Rectangle2D rect = authorFm.getStringBounds(line, g2d);
int x = WIDTH - PADDING - (int) rect.getWidth(); // Right align
g2d.drawString(line, x, authorStartY + (i * authorLineHeight));
}
g2d.dispose();
return writeHighQualityJpeg(image);
return generateCover(title, author, null);
}
private byte[] writeHighQualityJpeg(BufferedImage image) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.95f); // High quality
public byte[] generateCover(String title, String author, String subtitle) {
BufferedImage render = null;
BufferedImage result = null;
Graphics2D g = null;
try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
writer.setOutput(ios);
writer.write(null, new IIOImage(image, null, null), param);
writer.dispose();
return baos.toByteArray();
}
} catch (IOException e) {
log.error("Failed to generate cover image", e);
throw new RuntimeException("Failed to generate cover image", e);
}
}
private Font getTitleFont(int length) {
try {
Font font = new Font("Georgia", Font.BOLD, getTitleFontSize(length));
if (font.getFamily().equals("Georgia")) return font;
String safeTitle = sanitize(title, MAX_TITLE_LEN, "Unknown Title");
String safeAuthor = sanitize(author, MAX_AUTHOR_LEN, "Unknown Author");
String safeSubtitle = sanitize(subtitle, MAX_TITLE_LEN, ""); // Using title length limit for subtitle
int w = WIDTH * SCALE;
int h = HEIGHT * SCALE;
render = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
g = render.createGraphics();
try {
configureGraphics(g);
Palette p = selectPalette(safeTitle);
int margin = calcMargin(w);
renderBaseGradient(g, p, w, h);
renderDepthGradient(g, p, w, h);
renderGlow(g, p, w, h);
renderBokeh(g, p, w, h);
renderTexture(g, w, h);
renderFrame(g, p, w, h);
int titleEnd = renderTitle(g, safeTitle, p, w, h, margin);
int subtitleEnd = titleEnd;
if (safeSubtitle != null && !safeSubtitle.isEmpty()) {
subtitleEnd = renderSubtitle(g, safeSubtitle, p, w, h, margin, titleEnd);
}
renderDivider(g, p, w, subtitleEnd, h);
renderAuthor(g, safeAuthor, p, w, h, margin);
renderVignette(g, w, h);
renderEdges(g, w, h);
} finally {
if (g != null) g.dispose();
}
result = downscale(render);
render.flush();
render = null;
return encodeJpeg(result);
} catch (Exception e) {
log.warn("Failed to load preferred font, using fallback");
log.error("Cover generation failed: {}", title, e);
throw new RuntimeException("Cover generation failed", e);
} finally {
cleanup(g, render, result);
}
return new Font(Font.SERIF, Font.BOLD, getTitleFontSize(length));
}
private int getTitleFontSize(int length) {
if (length < 20) return 36;
if (length < 40) return 30;
return 24;
private record Palette(Color primary, Color secondary, Color tertiary, Color accent,
Color textMain, Color textSub, Color ornament) {}
private int calcMargin(int w) {
int frameOuter = (int) (w * PHI_SQ_INV * 0.12);
int frameInner = (int) (frameOuter * PHI);
return frameInner + (int) (w * 0.04);
}
private Font getAuthorFont(int length) {
if (length < 20) return new Font(Font.SANS_SERIF, Font.PLAIN, 28);
return new Font(Font.SANS_SERIF, Font.PLAIN, 22);
private Palette selectPalette(String title) {
Palette[] palettes = {
new Palette(new Color(20, 30, 55), new Color(40, 55, 90), new Color(30, 42, 72),
new Color(218, 180, 120), new Color(252, 250, 245), new Color(210, 200, 185), new Color(190, 165, 110)),
new Palette(new Color(70, 30, 40), new Color(105, 50, 60), new Color(85, 40, 50),
new Color(230, 205, 175), new Color(255, 252, 248), new Color(225, 215, 200), new Color(200, 175, 145)),
new Palette(new Color(25, 50, 45), new Color(45, 80, 70), new Color(35, 65, 57),
new Color(205, 190, 150), new Color(250, 248, 242), new Color(215, 210, 195), new Color(180, 165, 130)),
new Palette(new Color(45, 30, 65), new Color(75, 50, 100), new Color(60, 40, 82),
new Color(215, 195, 165), new Color(252, 250, 248), new Color(220, 210, 200), new Color(190, 170, 145)),
new Palette(new Color(55, 45, 35), new Color(90, 75, 60), new Color(72, 60, 47),
new Color(210, 185, 145), new Color(255, 252, 245), new Color(230, 220, 200), new Color(185, 165, 125)),
new Palette(new Color(25, 50, 60), new Color(45, 85, 100), new Color(35, 67, 80),
new Color(220, 200, 165), new Color(250, 250, 248), new Color(215, 210, 200), new Color(190, 175, 145)),
new Palette(new Color(40, 40, 45), new Color(65, 67, 73), new Color(52, 52, 58),
new Color(205, 190, 160), new Color(252, 252, 250), new Color(205, 203, 200), new Color(180, 170, 150)),
new Palette(new Color(65, 40, 30), new Color(105, 70, 50), new Color(85, 55, 40),
new Color(225, 200, 160), new Color(255, 250, 242), new Color(230, 220, 200), new Color(200, 180, 145)),
new Palette(new Color(30, 40, 75), new Color(50, 65, 115), new Color(40, 52, 95),
new Color(220, 200, 155), new Color(252, 250, 248), new Color(210, 205, 195), new Color(190, 170, 135)),
new Palette(new Color(50, 55, 40), new Color(80, 90, 65), new Color(65, 72, 52),
new Color(215, 205, 170), new Color(252, 250, 245), new Color(220, 215, 200), new Color(185, 180, 150))
};
return palettes[Math.abs(title.hashCode()) % palettes.length];
}
private static List<String> wrapText(String text, FontMetrics fm) {
private static void renderBaseGradient(Graphics2D g, Palette p, int w, int h) {
float[] fractions = {0f, (float) PHI_SQ_INV, 0.5f, (float) PHI_INV, 1f};
Color[] colors = {darken(p.primary, 0.2f), p.primary, p.tertiary, p.secondary, darken(p.secondary, 0.15f)};
g.setPaint(new LinearGradientPaint(0, 0, w * (float) PHI_INV, h, fractions, colors));
g.fillRect(0, 0, w, h);
}
private void renderDepthGradient(Graphics2D g, Palette p, int w, int h) {
g.setPaint(new RadialGradientPaint(w * 0.5f, h * 0.35f, (float) (h * PHI_INV),
new float[]{0f, 0.5f, 1f},
new Color[]{alpha(lighten(p.tertiary, 0.15f), 45), alpha(p.tertiary, 25), alpha(p.primary, 0)}));
g.fillRect(0, 0, w, h);
g.setPaint(new RadialGradientPaint(w * 0.85f, h * 1.15f, (float) (w * PHI_INV),
new float[]{0f, 0.6f, 1f},
new Color[]{alpha(darken(p.secondary, 0.25f), 55), alpha(darken(p.secondary, 0.15f), 30), alpha(p.secondary, 0)}));
g.fillRect(0, 0, w, h);
g.setPaint(new RadialGradientPaint(w * 0.1f, h * 0.9f, (float) (w * PHI_SQ_INV),
new float[]{0f, 0.5f, 1f},
new Color[]{alpha(darken(p.primary, 0.2f), 40), alpha(p.primary, 20), alpha(p.primary, 0)}));
g.fillRect(0, 0, w, h);
}
private static void renderGlow(Graphics2D g, Palette p, int w, int h) {
int cy = (int) (h * PHI_SQ_INV);
float r = (float) (w * PHI_INV * 1.3);
Color glow = new Color(
Math.min(255, p.accent.getRed() + 55),
Math.min(255, p.accent.getGreen() + 45),
Math.min(255, p.accent.getBlue() + 35));
g.setPaint(new RadialGradientPaint(w / 2f, cy, r,
new float[]{0f, (float) PHI_SQ_INV, (float) PHI_INV, 1f},
new Color[]{alpha(glow, 32), alpha(glow, 20), alpha(glow, 10), alpha(glow, 0)}));
g.fillRect(0, 0, w, h);
g.setPaint(new RadialGradientPaint(w / 2f, (float) (h * (1 - PHI_SQ_INV * 0.45)), r * 0.65f,
new float[]{0f, 0.5f, 1f},
new Color[]{alpha(Color.WHITE, 14), alpha(Color.WHITE, 7), alpha(Color.WHITE, 0)}));
g.fillRect(0, 0, w, h);
}
private void renderBokeh(Graphics2D g, Palette p, int w, int h) {
Composite orig = g.getComposite();
int seed = p.primary.getRGB();
for (int i = 0; i < 15; i++) {
int px = ((seed * (i + 1) * 17) % w + w) % w;
int py = ((seed * (i + 1) * 31) % h + h) % h;
int size = Math.max(1, ((25 + Math.abs((seed * (i + 1) * 7) % 60)) * SCALE));
float opacity = 0.018f + Math.abs((seed * (i + 1) * 3) % 25) * 0.0012f;
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
Color bokehColor = (i % 3 == 0) ? lighten(p.accent, 0.25f) : (i % 3 == 1) ? Color.WHITE : lighten(p.ornament, 0.2f);
g.setPaint(new RadialGradientPaint(px, py, size,
new float[]{0f, 0.6f, 1f},
new Color[]{alpha(bokehColor, 90), alpha(bokehColor, 35), alpha(bokehColor, 0)}));
g.fillOval(px - size, py - size, size * 2, size * 2);
}
g.setComposite(orig);
}
private static void renderTexture(Graphics2D g, int w, int h) {
Composite orig = g.getComposite();
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.018f));
for (int y = 0; y < h; y += 2) {
for (int x = 0; x < w; x += 2) {
int n = (x * 17 + y * 31) % 29;
if (n < 10) {
g.setColor(n < 5 ? Color.WHITE : Color.BLACK);
g.fillRect(x, y, 1, 1);
}
}
}
g.setComposite(orig);
}
private void renderFrame(Graphics2D g, Palette p, int w, int h) {
int outer = (int) (w * PHI_SQ_INV * 0.12);
int inner = (int) (outer * PHI);
g.setColor(alpha(p.ornament, 28));
g.setStroke(new BasicStroke(10f));
g.drawRect(outer - 4, outer - 4, w - (outer - 4) * 2, h - (outer - 4) * 2);
g.setColor(alpha(p.ornament, 95));
g.setStroke(new BasicStroke(2.5f));
g.drawRect(outer, outer, w - outer * 2, h - outer * 2);
g.setColor(alpha(p.ornament, 55));
g.setStroke(new BasicStroke(1f));
g.drawRect(inner, inner, w - inner * 2, h - inner * 2);
renderCorners(g, p, w, h, outer);
renderAccents(g, p, w, h, outer);
}
private static void renderCorners(Graphics2D g, Palette p, int w, int h, int m) {
g.setColor(alpha(p.ornament, 105));
g.setStroke(new BasicStroke(2.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
int size = (int) (m * PHI);
int[][] corners = {{m, m, 0}, {w - m, m, 90}, {w - m, h - m, 180}, {m, h - m, 270}};
for (int[] c : corners) renderCorner(g, c[0], c[1], size, c[2]);
}
private static void renderCorner(Graphics2D g, int x, int y, int size, int angle) {
AffineTransform saved = g.getTransform();
g.translate(x, y);
g.rotate(Math.toRadians(angle));
Path2D path = new Path2D.Float();
path.moveTo(0, size);
path.lineTo(0, size * PHI_SQ_INV);
path.quadTo(0, 0, size * PHI_SQ_INV, 0);
path.lineTo(size, 0);
g.draw(path);
Path2D inner = new Path2D.Float();
inner.moveTo(size * 0.15, size * PHI_INV);
inner.quadTo(size * 0.15, size * 0.15, size * PHI_INV, size * 0.15);
g.draw(inner);
g.fillOval(-5, -5, 10, 10);
g.fillOval((int) (size * 0.92), -4, 8, 8);
g.fillOval(-4, (int) (size * 0.92), 8, 8);
g.setTransform(saved);
}
private static void renderAccents(Graphics2D g, Palette p, int w, int h, int m) {
g.setColor(alpha(p.ornament, 65));
g.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
int len = (int) (m * PHI);
g.drawLine(w / 2 - len, m, w / 2 + len, m);
g.fillOval(w / 2 - 5, m - 5, 10, 10);
g.drawLine(w / 2 - len, h - m, w / 2 + len, h - m);
g.fillOval(w / 2 - 5, h - m - 5, 10, 10);
g.drawLine(m, h / 2 - len, m, h / 2 + len);
g.fillOval(m - 5, h / 2 - 5, 10, 10);
g.drawLine(w - m, h / 2 - len, w - m, h / 2 + len);
g.fillOval(w - m - 5, h / 2 - 5, 10, 10);
}
private int renderTitle(Graphics2D g, String title, Palette p, int w, int h, int margin) {
int maxW = w - margin * 2;
int bottomBound = (int) (h * PHI_INV);
String text = title.toUpperCase();
Font font = resolveFont(g, text, maxW, titleSize(title.length()), MAX_TITLE_LINES, true);
g.setFont(font);
FontMetrics fm = g.getFontMetrics();
List<String> lines = wrapText(text, fm, maxW, MAX_TITLE_LINES);
int lineH = (int) (fm.getHeight() * 1.12);
int totalH = lines.size() * lineH;
int availableH = bottomBound - margin - totalH;
int startY = margin + (int) (availableH * PHI_SQ_INV) + fm.getAscent();
startY = Math.max(startY, margin + fm.getAscent());
float tracking = 0.05f;
int lastY = startY;
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
int lw = trackedWidth(fm, line, tracking);
int x = (w - lw) / 2;
int y = startY + i * lineH;
if (y + fm.getDescent() > bottomBound) break;
renderText(g, line, x, y, tracking, p.textMain, true);
lastY = y;
}
return lastY + fm.getDescent() + lineH / 2;
}
private static void renderDivider(Graphics2D g, Palette p, int w, int titleEnd, int h) {
int minY = titleEnd + (int) (h * 0.02);
int maxY = (int) (h * (1 - PHI_SQ_INV * 0.7));
int cy = minY + (int) ((maxY - minY) * PHI_SQ_INV);
int cx = w / 2;
int lineLen = (int) (w * PHI_SQ_INV * 0.38);
for (int dir : new int[]{-1, 1}) {
int sx = cx + dir * 50;
int ex = cx + dir * (50 + lineLen);
g.setPaint(new GradientPaint(sx, cy, alpha(p.ornament, 115), ex, cy, alpha(p.ornament, 25)));
g.setStroke(new BasicStroke(2.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.drawLine(sx, cy, ex, cy);
}
g.setColor(alpha(p.ornament, 130));
g.setStroke(new BasicStroke(2f));
int ds = 14;
Path2D diamond = new Path2D.Float();
diamond.moveTo(cx, cy - ds);
diamond.lineTo(cx + ds, cy);
diamond.lineTo(cx, cy + ds);
diamond.lineTo(cx - ds, cy);
diamond.closePath();
g.draw(diamond);
int is = 6;
Path2D inner = new Path2D.Float();
inner.moveTo(cx, cy - is);
inner.lineTo(cx + is, cy);
inner.lineTo(cx, cy + is);
inner.lineTo(cx - is, cy);
inner.closePath();
g.fill(inner);
g.fillOval(cx - 50 - 5, cy - 5, 10, 10);
g.fillOval(cx + 50 - 5, cy - 5, 10, 10);
g.fillOval(cx - 32 - 4, cy - 4, 8, 8);
g.fillOval(cx + 32 - 4, cy - 4, 8, 8);
}
private void renderAuthor(Graphics2D g, String author, Palette p, int w, int h, int margin) {
int maxW = w - margin * 2;
int bottomBound = h - margin;
String[] authors = author.split(",");
StringBuilder formattedAuthors = new StringBuilder();
for (int i = 0; i < authors.length; i++) {
if (i > 0) formattedAuthors.append("\n").append(authors[i].trim());
else formattedAuthors.append(authors[i].trim());
}
Font authorFont = resolveFont(g, formattedAuthors.toString(), maxW, authorSize(formattedAuthors.length()), MAX_AUTHOR_LINES, false);
g.setFont(authorFont);
FontMetrics authorFm = g.getFontMetrics();
String[] authorLinesArray = formattedAuthors.toString().split("\n");
List<String> lines = new ArrayList<>();
String[] words = WHITESPACE_PATTERN.split(text);
StringBuilder currentLine = new StringBuilder();
for (String line : authorLinesArray) {
lines.add(line.trim());
}
for (String word : words) {
if (currentLine.isEmpty()) {
currentLine.append(word);
int authorLineH = (int) (authorFm.getHeight() * 1.18);
int authorTotalH = lines.size() * authorLineH;
// Remove the "by" prefix for multiple authors
boolean showByPrefix = lines.size() == 1 && author.length() < 45 && !author.contains(",");
Font byFont = authorFont.deriveFont(authorFont.getSize() * 0.58f);
FontMetrics byFm = g.getFontMetrics(byFont);
String byText = "— by —";
int byHeight = showByPrefix ? byFm.getHeight() : 0;
int bySpacing = showByPrefix ? (int) (authorLineH * PHI_SQ_INV * 0.8) : 0;
int totalBlockH = byHeight + bySpacing + authorTotalH;
int blockBottom = (int) (h * (1 - PHI_SQ_INV * 0.32));
blockBottom = Math.min(blockBottom, bottomBound);
int blockTop = blockBottom - totalBlockH;
int minTop = (int) (h * PHI_INV) + margin;
if (blockTop < minTop) blockTop = minTop;
int currentY = blockTop;
if (showByPrefix) {
g.setFont(byFont);
int byW = byFm.stringWidth(byText);
int byX = (w - byW) / 2;
int byY = currentY + byFm.getAscent();
g.setColor(new Color(0, 0, 0, 35));
g.drawString(byText, byX + 3, byY + 3);
g.setColor(alpha(p.ornament, 155));
g.drawString(byText, byX, byY);
currentY = byY + byFm.getDescent() + bySpacing;
}
g.setFont(authorFont);
float tracking = 0.08f;
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
if (!line.isEmpty()) { // Only render non-empty lines
int lw = trackedWidth(authorFm, line, tracking);
int x = (w - lw) / 2;
int y = currentY + authorFm.getAscent() + i * authorLineH;
if (y + authorFm.getDescent() > bottomBound) break;
renderText(g, line, x, y, tracking, p.textSub, false);
}
}
}
private int renderSubtitle(Graphics2D g, String subtitle, Palette p, int w, int h, int margin, int titleEnd) {
int maxW = w - margin * 2;
int bottomBound = h - margin;
Font subtitleFont = resolveFont(g, subtitle, maxW, subtitleSize(subtitle.length()), 2, false);
g.setFont(subtitleFont);
FontMetrics subtitleFm = g.getFontMetrics();
List<String> lines = wrapText(subtitle, subtitleFm, maxW, 2);
int subtitleLineH = (int) (subtitleFm.getHeight() * 1.12);
// Position subtitle below the title with some spacing
int spacing = (int) (subtitleLineH * 0.5);
int startY = titleEnd + spacing + subtitleFm.getAscent();
g.setFont(subtitleFont);
float tracking = 0.06f;
int lastY = startY;
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
int lw = trackedWidth(subtitleFm, line, tracking);
int x = (w - lw) / 2;
int y = startY + i * subtitleLineH;
if (y + subtitleFm.getDescent() > bottomBound) break;
renderText(g, line, x, y, tracking, p.textSub, false);
lastY = y;
}
return lastY + subtitleFm.getDescent();
}
private int subtitleSize(int len) {
if (len < 12) return 130 * SCALE;
if (len < 22) return 110 * SCALE;
if (len < 35) return 90 * SCALE;
return 75 * SCALE;
}
private void renderText(Graphics2D g, String text, int x, int y, float tracking, Color color, boolean isTitle) {
FontMetrics fm = g.getFontMetrics();
int tp = (int) (fm.getHeight() * tracking);
int[][] shadows = isTitle
? new int[][]{{8, 8, 14}, {5, 5, 28}, {2, 2, 45}}
: new int[][]{{5, 5, 18}, {2, 2, 35}};
for (int[] s : shadows) {
g.setColor(new Color(0, 0, 0, s[2]));
drawTracked(g, text, x + s[0], y + s[1], tp);
}
g.setColor(color);
drawTracked(g, text, x, y, tp);
if (isTitle) {
g.setColor(alpha(Color.WHITE, 22));
drawTracked(g, text, x, y - 1, tp);
}
}
private void drawTracked(Graphics2D g, String text, int x, int y, int tracking) {
FontMetrics fm = g.getFontMetrics();
int cx = x;
for (char c : text.toCharArray()) {
g.drawString(String.valueOf(c), cx, y);
cx += fm.charWidth(c) + tracking;
}
}
private int trackedWidth(FontMetrics fm, String text, float tracking) {
int tp = (int) (fm.getHeight() * tracking);
int w = 0;
for (char c : text.toCharArray()) w += fm.charWidth(c) + tp;
return Math.max(0, w - tp);
}
private static void renderVignette(Graphics2D g, int w, int h) {
float r = (float) (Math.sqrt(w * w + h * h) / 2 * 1.12);
g.setPaint(new RadialGradientPaint(w / 2f, h / 2f, r,
new float[]{0f, (float) PHI_INV, (float) (PHI_INV + PHI_SQ_INV * 0.25), 0.9f, 1f},
new Color[]{alpha(Color.BLACK, 0), alpha(Color.BLACK, 0), alpha(Color.BLACK, 15),
alpha(Color.BLACK, 50), alpha(Color.BLACK, 85)}));
g.fillRect(0, 0, w, h);
g.setPaint(new GradientPaint(0, 0, alpha(Color.WHITE, 14), 0, (int) (h * PHI_SQ_INV), alpha(Color.WHITE, 0)));
g.fillRect(0, 0, w, (int) (h * PHI_SQ_INV));
}
private static void renderEdges(Graphics2D g, int w, int h) {
g.setColor(alpha(Color.BLACK, 50));
g.setStroke(new BasicStroke(3f));
g.drawRect(0, 0, w - 1, h - 1);
g.setColor(alpha(Color.WHITE, 10));
g.drawRect(3, 3, w - 7, h - 7);
}
private static String sanitize(String input, int max, String fallback) {
if (input == null || input.isBlank()) return fallback;
String s = input.trim();
return s.length() > max ? s.substring(0, max) : s;
}
private void configureGraphics(Graphics2D g) {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
}
private static Color alpha(Color c, int a) {
return new Color(c.getRed(), c.getGreen(), c.getBlue(), Math.min(255, Math.max(0, a)));
}
private static Color darken(Color c, float f) {
return new Color(Math.max(0, (int) (c.getRed() * (1 - f))),
Math.max(0, (int) (c.getGreen() * (1 - f))),
Math.max(0, (int) (c.getBlue() * (1 - f))));
}
private Color lighten(Color c, float f) {
return new Color(Math.min(255, (int) (c.getRed() + (255 - c.getRed()) * f)),
Math.min(255, (int) (c.getGreen() + (255 - c.getGreen()) * f)),
Math.min(255, (int) (c.getBlue() + (255 - c.getBlue()) * f)));
}
private Font resolveFont(Graphics2D g, String text, int maxW, int startSize, int maxLines, boolean isTitle) {
String[] serif = {"Palatino Linotype", "Garamond", "Georgia", "Book Antiqua"};
String[] sans = {"Optima", "Gill Sans", "Helvetica Neue", "Calibri"};
String[] preferred = isTitle ? serif : sans;
String fallback = isTitle ? Font.SERIF : Font.SANS_SERIF;
int style = isTitle ? Font.BOLD : Font.PLAIN;
String selected = fallback;
for (String name : preferred) {
Font test = new Font(name, style, startSize);
if (test.getFamily().equalsIgnoreCase(name)) {
selected = name;
break;
}
}
float tracking = isTitle ? 0.05f : 0.08f;
for (int size = startSize; size >= MIN_FONT; size -= 2) {
Font font = new Font(selected, style, size);
g.setFont(font);
FontMetrics fm = g.getFontMetrics();
List<String> lines = wrapText(text, fm, maxW, maxLines);
if (lines.stream().allMatch(l -> trackedWidth(fm, l, tracking) <= maxW)) return font;
}
return new Font(selected, style, MIN_FONT);
}
private List<String> wrapText(String text, FontMetrics fm, int maxW, int maxLines) {
List<String> lines = new ArrayList<>();
StringBuilder cur = new StringBuilder();
for (String word : WS.split(text.trim())) {
if (fm.stringWidth(word) > maxW) {
if (!cur.isEmpty()) {
lines.add(cur.toString().trim());
cur = new StringBuilder();
}
lines.addAll(breakWord(word, fm, maxW));
continue;
}
if (cur.isEmpty()) {
cur.append(word);
} else if (fm.stringWidth(cur + " " + word) <= maxW) {
cur.append(" ").append(word);
} else {
int currentWidth = fm.stringWidth(currentLine.toString());
int spaceWidth = fm.stringWidth(" ");
int wordWidth = fm.stringWidth(word);
if (currentWidth + spaceWidth + wordWidth <= 210) {
currentLine.append(" ").append(word);
} else {
lines.add(currentLine.toString().trim());
currentLine = new StringBuilder(word);
}
lines.add(cur.toString().trim());
cur = new StringBuilder(word);
}
}
if (!currentLine.isEmpty()) {
lines.add(currentLine.toString().trim());
}
if (lines.size() > MAX_LINES) {
List<String> truncated = lines.subList(0, MAX_LINES);
String lastLine = truncated.get(MAX_LINES - 1);
if (fm.stringWidth(lastLine + "...") > 210) {
while (lastLine.length() > 3 && fm.stringWidth(lastLine + "...") > 210) {
lastLine = lastLine.substring(0, lastLine.length() - 1);
}
}
truncated.set(MAX_LINES - 1, lastLine.trim() + "...");
return truncated;
if (!cur.isEmpty()) lines.add(cur.toString().trim());
if (lines.size() > maxLines) {
lines = new ArrayList<>(lines.subList(0, maxLines));
String last = lines.get(maxLines - 1);
while (!last.isEmpty() && fm.stringWidth(last + "") > maxW)
last = last.substring(0, last.length() - 1).trim();
lines.set(maxLines - 1, last + "");
}
return lines;
}
private Color[] getColorScheme(String title) {
int hash = Math.abs((title != null ? title : "").hashCode());
Color[][] schemes = {
{new Color(85, 119, 102), new Color(102, 153, 136)}, // Green (Original)
{new Color(102, 119, 153), new Color(136, 153, 187)}, // Blue
{new Color(153, 102, 119), new Color(187, 136, 153)}, // Purple
{new Color(153, 153, 102), new Color(187, 187, 136)}, // Olive
{new Color(102, 153, 153), new Color(136, 187, 187)}, // Teal
{new Color(119, 102, 153), new Color(153, 136, 187)} // Indigo
};
return schemes[hash % schemes.length];
private List<String> breakWord(String word, FontMetrics fm, int maxW) {
List<String> parts = new ArrayList<>();
StringBuilder cur = new StringBuilder();
for (char c : word.toCharArray()) {
if (fm.stringWidth(cur.toString() + c) > maxW && !cur.isEmpty()) {
parts.add(cur.toString());
cur = new StringBuilder();
}
cur.append(c);
}
if (!cur.isEmpty()) parts.add(cur.toString());
return parts;
}
}
private int titleSize(int len) {
if (len < 8) return 150 * SCALE;
if (len < 15) return 130 * SCALE;
if (len < 25) return 110 * SCALE;
if (len < 40) return 92 * SCALE;
if (len < 60) return 78 * SCALE;
if (len < 85) return 66 * SCALE;
return 56 * SCALE;
}
private int authorSize(int len) {
if (len < 12) return 78 * SCALE;
if (len < 22) return 68 * SCALE;
if (len < 35) return 58 * SCALE;
return 50 * SCALE;
}
private BufferedImage downscale(BufferedImage src) {
BufferedImage dst = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g = dst.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.drawImage(src, 0, 0, WIDTH, HEIGHT, null);
return dst;
} finally {
g.dispose();
}
}
private byte[] encodeJpeg(BufferedImage img) {
ImageWriter writer = null;
ImageOutputStream ios = null;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
writer = ImageIO.getImageWritersByFormatName("jpg").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.95f);
ios = ImageIO.createImageOutputStream(baos);
writer.setOutput(ios);
writer.write(null, new IIOImage(img, null, null), param);
return baos.toByteArray();
} catch (IOException e) {
throw new RuntimeException("JPEG encoding failed", e);
} finally {
if (writer != null) writer.dispose();
if (ios != null) try { ios.close(); } catch (IOException ignored) {}
}
}
private void cleanup(Graphics2D g, BufferedImage... images) {
if (g != null) try { g.dispose(); } catch (Exception ignored) {}
for (BufferedImage img : images)
if (img != null) try { img.flush(); } catch (Exception ignored) {}
}
}