Improved font rendering for ITALIC and BOLD to remove glyph artifacts

This commit is contained in:
bigguy345 committed 2026-03-13 03:26:06 +02:00
1 parent f8a7241747
commit f6f19c0e48
2 files changed
+93 -10

No files matched your search

@@ -78,13 +78,13 @@ public class GlyphCache {
private static final int GLYPH_PADDING = 2;
/**
* Spacing (in pixels) inserted between adjacent glyphs in the temporary string image during rasterization.
* Each glyph is shifted right by GLYPH_SPACING * glyphIndex to prevent kerning from merging adjacent glyphs.
* This must be >= 2 * GLYPH_PADDING + 1 so that when padded bounding boxes are extracted for adjacent glyphs,
* they never overlap. Without sufficient spacing, bold/italic glyphs (which have wider ink bounds) can bleed
* fragments of neighbouring glyphs into each other's cached texture regions.
* Minimum spacing (in pixels) inserted between adjacent glyphs in the temporary string image during
* rasterization. Each glyph is shifted right by glyphSpacing * glyphIndex to prevent kerning from
* merging adjacent glyphs. This base value is used for plain/bold styles. For italic fonts, the
* spacing is increased dynamically by {@link #computeGlyphSpacing(Font)} to account for the
* horizontal overhang caused by the italic slant.
*/
private static final int GLYPH_SPACING = 2 * GLYPH_PADDING + 1;
private static final int GLYPH_SPACING_BASE = 2 * GLYPH_PADDING + 1;
/**
* Transparent (alpha zero) white background color for use with BufferedImage.clearRect().
@@ -158,6 +158,36 @@ public class GlyphCache {
return GLYPH_PADDING;
}
/**
* Compute the inter-glyph spacing needed for a given font when rasterizing into the temporary
* string image. For plain and bold fonts, the base spacing (2*GLYPH_PADDING+1) is sufficient.
* For italic fonts, the slant causes the top of each glyph to lean horizontally, and at large
* raster sizes (e.g. 54pt at scale 3) this overhang can be 8+ pixels — far exceeding the base
* spacing's 1-pixel effective gap. Without adequate spacing, adjacent italic glyphs' rendered
* pixels overlap in the string image, and narrow glyphs like 'I'/'i' capture neighboring
* fragments as visible dot artifacts in their cached texture.
*
* The italic overhang is computed as: lineHeight * |italicAngle|. Java2D's standard italic
* angle is ~0.2 (approx 12°). A safety factor of 1.5× is applied since antialiasing can
* produce faint alpha slightly beyond the geometric slant boundary.
*/
private int computeGlyphSpacing(Font font) {
float italicAngle = font.getItalicAngle();
if (italicAngle == 0 && font.isItalic()) {
italicAngle = -0.2f; // Java2D synthetic italic default shear
}
if (italicAngle == 0) {
return GLYPH_SPACING_BASE;
}
java.awt.font.LineMetrics lm = font.getLineMetrics("I", fontRenderContext);
float lineHeight = lm.getAscent() + lm.getDescent();
int italicOverhang = (int) Math.ceil(lineHeight * Math.abs(italicAngle) * 1.5f);
return Math.max(GLYPH_SPACING_BASE, 2 * GLYPH_PADDING + italicOverhang + 1);
}
/**
* Intermediate data array for use with textureImage.getRgb().
@@ -481,6 +511,7 @@ public class GlyphCache {
int numGlyphs = vector.getNumGlyphs(); /* Length of the GlyphVector */
Rectangle dirty = null; /* Total area within texture that needs to be updated with glTexSubImage2D() */
boolean vectorRendered = false; /* True if entire GlyphVector was rendered into stringImage */
int glyphSpacing = computeGlyphSpacing(font);
for (int index = 0; index < numGlyphs; index++) {
int charIndex = start + vector.getGlyphCharIndex(index);
@@ -497,6 +528,20 @@ public class GlyphCache {
continue;
}
/*
* Skip whitespace glyphs that have no visible ink (spaces, tabs, etc.). These characters
* contribute only advance width, not visible pixels. Without this check, GLYPH_PADDING
* expansion would create a small non-zero rect for the empty glyph, and the blit from
* stringImage could capture stray antialiased fringe pixels from neighboring glyphs,
* causing whitespace to render as visible dots.
*/
{
Rectangle glyphBounds = vector.getGlyphPixelBounds(index, fontRenderContext, 0, 0);
if (glyphBounds.width == 0 || glyphBounds.height == 0) {
continue;
}
}
/*
* The only way to get glyph shapes with font hinting is to draw the entire glyph vector into a
* temporary BufferedImage, and then bit blit the individual glyphs based on their bounding boxes
@@ -515,7 +560,7 @@ public class GlyphCache {
*/
for (int i = 0; i < numGlyphs; i++) {
Point2D pos = vector.getGlyphPosition(i);
pos.setLocation(pos.getX() + GLYPH_SPACING * i, pos.getY());
pos.setLocation(pos.getX() + glyphSpacing * i, pos.getY());
vector.setGlyphPosition(i, pos);
}
@@ -537,8 +582,16 @@ public class GlyphCache {
allocateStringImage(width, height);
}
/* Erase the region where the string will get drawn (expanded for hinting overflow) */
stringGraphics.clearRect(0, 0, vectorBounds.width, vectorBounds.height);
/*
* Clear the ENTIRE stringImage, not just the vectorBounds area. The stringImage is reused
* across calls and may be larger than the current vectorBounds. Individual glyph pixel bounds
* (from getGlyphPixelBounds()) can differ slightly from the aggregate vector bounds
* (from getPixelBounds()) due to integer rounding. After GLYPH_PADDING expansion, a glyph's
* padded rect could extend 1-2 pixels beyond the vectorBounds area, capturing stale pixels
* from a previous render. This caused tiny dot artifacts above capital letters whose ink
* extends to the top of the line bounds. Clearing the full image eliminates this class of bugs.
*/
stringGraphics.clearRect(0, 0, stringImage.getWidth(), stringImage.getHeight());
/* Draw string with opaque white color and baseline adjustment so the upper-left corner of the image is at (0,0) */
stringGraphics.drawGlyphVector(vector, -vectorBounds.x, -vectorBounds.y);
@@ -555,6 +608,30 @@ public class GlyphCache {
Rectangle rect = vector.getGlyphPixelBounds(index, fontRenderContext, -vectorBounds.x, -vectorBounds.y);
rect.grow(GLYPH_PADDING, GLYPH_PADDING);
/*
* Clamp the padded rect to the stringImage bounds. Due to rounding differences between
* getPixelBounds() and getGlyphPixelBounds(), a padded glyph rect can occasionally extend
* beyond the image edge. Reading pixels outside the image would cause an exception or
* capture garbage data.
*/
if (rect.x < 0) {
rect.width += rect.x; // shrink width by the overshoot
rect.x = 0;
}
if (rect.y < 0) {
rect.height += rect.y; // shrink height by the overshoot
rect.y = 0;
}
if (rect.x + rect.width > stringImage.getWidth()) {
rect.width = stringImage.getWidth() - rect.x;
}
if (rect.y + rect.height > stringImage.getHeight()) {
rect.height = stringImage.getHeight() - rect.y;
}
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
/* If the current line in cache image is full, then advance to the next line */
if (cachePosX + rect.width + GLYPH_BORDER > TEXTURE_WIDTH) {
cachePosX = GLYPH_BORDER;
@@ -611,6 +611,12 @@ public class StringCache {
/* Select the current glyph's texture information and horizontal layout position within this string */
Glyph glyph = entry.glyphs[glyphIndex];
GlyphCache.Entry texture = glyph.texture;
/* Skip glyphs with no cached texture (whitespace characters like spaces and tabs) */
if (texture == null) {
continue;
}
int glyphX = glyph.x + glyph.inkOffsetX;
/*
@@ -698,7 +704,7 @@ public class StringCache {
Glyph glyph = entry.glyphs[glyphIndex];
/* The strike/underlines are drawn beyond the glyph's width to include the extra space between glyphs */
int glyphSpace = glyph.advance - glyph.texture.width;
int glyphSpace = glyph.texture != null ? glyph.advance - glyph.texture.width : 0;
/* Draw underline under glyph if the style is enabled */
if ((renderStyle & ColorCode.UNDERLINE) != 0) {