8269888: Thai text rendered incorrectly using some AffineTransform-derived fonts

Reviewed-by: prr, psadhukhan
This commit is contained in:
Daniel Gredler 2026-06-03 22:07:46 +00:00
parent b328a326b1
commit 7ebfc031bc
10 changed files with 485 additions and 93 deletions

View File

@ -95,7 +95,6 @@ public final class GlyphLayout {
private Point2D.Float _pt;
private FontStrikeDesc _sd;
private float[] _mat;
private float ptSize;
private int _typo_flags;
private int _offset;
@ -125,35 +124,27 @@ public final class GlyphLayout {
}
private static final class SDCache {
private final AffineTransform dtx;
private final AffineTransform gtx;
private final Point2D.Float delta;
private final AffineTransform ftx;
private final FontStrikeDesc sd;
private SDCache(Font font, FontRenderContext frc) {
// !!! add getVectorTransform and hasVectorTransform to frc? then
// we could just skip this work...
dtx = frc.getTransform();
AffineTransform dtx = frc.getTransform();
dtx.setTransform(dtx.getScaleX(), dtx.getShearY(),
dtx.getShearX(), dtx.getScaleY(),
0, 0);
float ptSize = font.getSize2D();
if (font.isTransformed()) {
gtx = font.getTransform();
gtx.scale(ptSize, ptSize);
delta = new Point2D.Float((float)gtx.getTranslateX(),
(float)gtx.getTranslateY());
gtx.setTransform(gtx.getScaleX(), gtx.getShearY(),
gtx.getShearX(), gtx.getScaleY(),
0, 0);
gtx.preConcatenate(dtx);
} else {
delta = ZERO_DELTA;
gtx = new AffineTransform(dtx);
gtx.scale(ptSize, ptSize);
}
ftx = font.getTransform();
ftx.scale(ptSize, ptSize);
AffineTransform gtx = new AffineTransform(dtx);
gtx.concatenate(ftx);
gtx.setTransform(gtx.getScaleX(), gtx.getShearY(),
gtx.getShearX(), gtx.getScaleY(),
0, 0);
/* Similar logic to that used in SunGraphics2D.checkFontInfo().
* Whether a grey (AA) strike is needed is size dependent if
@ -168,8 +159,6 @@ public final class GlyphLayout {
sd = new FontStrikeDesc(dtx, gtx, font.getStyle(), aa, fm);
}
private static final Point2D.Float ZERO_DELTA = new Point2D.Float();
private static
SoftReference<WeakHashMap<SDKey, SDCache>> cacheRef;
@ -287,12 +276,12 @@ public final class GlyphLayout {
// use cache now - can we use the strike cache for this?
SDCache txinfo = SDCache.get(font, frc);
_mat[0] = (float)txinfo.gtx.getScaleX();
_mat[1] = (float)txinfo.gtx.getShearY();
_mat[2] = (float)txinfo.gtx.getShearX();
_mat[3] = (float)txinfo.gtx.getScaleY();
_pt.setLocation(txinfo.delta);
ptSize = font.getSize2D();
_mat[0] = (float) txinfo.ftx.getScaleX();
_mat[1] = (float) txinfo.ftx.getShearY();
_mat[2] = (float) txinfo.ftx.getShearX();
_mat[3] = (float) txinfo.ftx.getScaleY();
_pt.setLocation(txinfo.ftx.getTranslateX(),
txinfo.ftx.getTranslateY());
int lim = offset + count;
@ -576,7 +565,7 @@ public final class GlyphLayout {
void layout() {
_textRecord.start = start;
_textRecord.limit = limit;
SunLayoutEngine.layout(font, script, _sd, _mat, ptSize, gmask, start - _offset, _textRecord,
SunLayoutEngine.layout(font, script, _sd, _mat, gmask, start - _offset, _textRecord,
_typo_flags | eflags, _pt, _gvdata);
}
}

View File

@ -199,7 +199,6 @@ public class HBShaper {
dispose_face_handle = tmp3;
FunctionDescriptor shapeDesc = FunctionDescriptor.ofVoid(
JAVA_FLOAT, // ptSize
ADDRESS, // matrix
ADDRESS, // face
ADDRESS, // chars
@ -287,7 +286,6 @@ public class HBShaper {
JAVA_INT, // offset
JAVA_FLOAT, // startX
JAVA_FLOAT, // startX
JAVA_FLOAT, // devScale
JAVA_INT, // charCount
JAVA_INT, // glyphCount
ADDRESS, // glyphInfo
@ -434,7 +432,6 @@ public class HBShaper {
static void shape(
Font2D font2D,
FontStrike fontStrike,
float ptSize,
float[] mat,
MemorySegment hbface,
char[] text,
@ -465,7 +462,7 @@ public class HBShaper {
MemorySegment chars = arena.allocateFrom(JAVA_CHAR, text);
jdk_hb_shape_handle.invokeExact(
ptSize, matrix, hbface, chars, text.length,
matrix, hbface, chars, text.length,
script, offset, limit,
baseIndex, startX, startY, flags, slot,
hb_jdk_font_funcs_struct,
@ -575,7 +572,6 @@ public class HBShaper {
int offset,
float startX,
float startY,
float devScale,
int charCount,
int glyphCount,
MemorySegment /* hb_glyph_info_t* */ glyphInfo,
@ -586,7 +582,7 @@ public class HBShaper {
Point2D.Float startPt = scopedVars.get().point();
float x=0, y=0;
float advX, advY;
float scale = 1.0f / HBFloatToFixedScale / devScale;
float scale = 1.0f / HBFloatToFixedScale;
int initialCount = gvdata._count;

View File

@ -64,10 +64,9 @@ public final class SunLayoutEngine {
static {
String prop = System.getProperty("sun.font.layout.ffm", "true");
useFFM = "true".equals(prop);
}
public static void layout(Font2D font, int script, FontStrikeDesc desc, float[] mat, float ptSize, int gmask,
public static void layout(Font2D font, int script, FontStrikeDesc desc, float[] mat, int gmask,
int baseIndex, TextRecord tr, int typo_flags,
Point2D.Float pt, GVData data) {
@ -75,7 +74,7 @@ public final class SunLayoutEngine {
if (useFFM) {
MemorySegment face = HBShaper.getFace(font);
if (face != null) {
HBShaper.shape(font, strike, ptSize, mat, face,
HBShaper.shape(font, strike, mat, face,
tr.text, data, script,
tr.start, tr.limit, baseIndex, pt,
typo_flags, gmask);
@ -83,7 +82,7 @@ public final class SunLayoutEngine {
} else {
long pFace = getFacePtr(font);
if (pFace != 0) {
shape(font, strike, ptSize, mat, pFace,
shape(font, strike, mat, pFace,
tr.text, data, script,
tr.start, tr.limit, baseIndex, pt,
typo_flags, gmask);
@ -93,7 +92,7 @@ public final class SunLayoutEngine {
/* Native method to invoke harfbuzz layout engine */
private static native boolean
shape(Font2D font, FontStrike strike, float ptSize, float[] mat,
shape(Font2D font, FontStrike strike, float[] mat,
long pFace,
char[] chars, GVData data,
int script, int offset, int limit,

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2015, 2025, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2015, 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@ -72,12 +72,12 @@ jboolean storeGVData(JNIEnv* env,
jobject gvdata, jint slot,
jint baseIndex, int offset, jobject startPt,
int charCount, int glyphCount, hb_glyph_info_t *glyphInfo,
hb_glyph_position_t *glyphPos, float devScale) {
hb_glyph_position_t *glyphPos) {
int i, needToGrow;
float x=0, y=0;
float startX, startY, advX, advY;
float scale = 1.0f / HBFloatToFixedScale / devScale;
float scale = 1.0f / HBFloatToFixedScale;
unsigned int* glyphs;
float* positions;
int initialCount, glyphArrayLen, posArrayLen, maxGlyphs, storeadv, maxStore;
@ -197,7 +197,6 @@ JDKFontInfo*
createJDKFontInfo(JNIEnv *env,
jobject font2D,
jobject fontStrike,
jfloat ptSize,
jfloatArray matrix) {
@ -209,18 +208,11 @@ JDKFontInfo*
fi->font2D = font2D;
fi->fontStrike = fontStrike;
(*env)->GetFloatArrayRegion(env, matrix, 0, 4, fi->matrix);
fi->ptSize = ptSize;
fi->xPtSize = euclidianDistance(fi->matrix[0], fi->matrix[1]);
fi->yPtSize = euclidianDistance(fi->matrix[2], fi->matrix[3]);
if (getenv("HB_NODEVTX") != NULL) {
fi->devScale = fi->xPtSize / fi->ptSize;
} else {
fi->devScale = 1.0f;
}
return fi;
}
#define TYPO_KERN 0x00000001
#define TYPO_LIGA 0x00000002
#define TYPO_RTL 0x80000000
@ -229,7 +221,6 @@ JNIEXPORT jboolean JNICALL Java_sun_font_SunLayoutEngine_shape
(JNIEnv *env, jclass cls,
jobject font2D,
jobject fontStrike,
jfloat ptSize,
jfloatArray matrix,
jlong pFace,
jcharArray text,
@ -259,16 +250,13 @@ JNIEXPORT jboolean JNICALL Java_sun_font_SunLayoutEngine_shape
unsigned int buflen;
JDKFontInfo *jdkFontInfo =
createJDKFontInfo(env, font2D, fontStrike, ptSize, matrix);
createJDKFontInfo(env, font2D, fontStrike, matrix);
if (!jdkFontInfo) {
return JNI_FALSE;
}
jdkFontInfo->env = env; // this is valid only for the life of this JNI call.
jdkFontInfo->font2D = font2D;
jdkFontInfo->fontStrike = fontStrike;
hbface = (hb_face_t*) jlong_to_ptr(pFace);
hbfont = hb_jdk_font_create(hbface, jdkFontInfo, NULL);
hbfont = hb_jdk_font_create(hbface, jdkFontInfo);
buffer = hb_buffer_create();
hb_buffer_set_script(buffer, getHBScriptCode(script));
@ -305,8 +293,7 @@ JNIEXPORT jboolean JNICALL Java_sun_font_SunLayoutEngine_shape
glyphPos = hb_buffer_get_glyph_positions(buffer, &buflen);
ret = storeGVData(env, gvdata, slot, baseIndex, offset, startPt,
limit - offset, glyphCount, glyphInfo, glyphPos,
jdkFontInfo->devScale);
limit - offset, glyphCount, glyphInfo, glyphPos);
hb_buffer_destroy (buffer);
hb_font_destroy(hbfont);

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2023, 2025, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2023, 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@ -64,7 +64,6 @@ static float euclidianDistance(float a, float b)
#define TYPO_RTL 0x80000000
JDKEXPORT void jdk_hb_shape(
float ptSize,
float *matrix,
void* pFace,
unsigned short *chars,
@ -93,16 +92,12 @@ JDKEXPORT void jdk_hb_shape(
char* kern = (flags & TYPO_KERN) ? "kern" : "-kern";
char* liga = (flags & TYPO_LIGA) ? "liga" : "-liga";
unsigned int buflen;
float devScale = 1.0f;
if (getenv("HB_NODEVTX") != NULL) {
float xPtSize = euclidianDistance(matrix[0], matrix[1]);
devScale = xPtSize / ptSize;
}
float xPtSize = euclidianDistance(matrix[0], matrix[1]);
float yPtSize = euclidianDistance(matrix[2], matrix[3]);
hbface = (hb_face_t*)pFace;
hbfont = jdk_font_create_hbp(hbface,
ptSize, devScale, NULL,
xPtSize, yPtSize,
font_funcs);
buffer = hb_buffer_create();
@ -132,7 +127,7 @@ JDKEXPORT void jdk_hb_shape(
glyphPos = hb_buffer_get_glyph_positions(buffer, &buflen);
(*store_layout_results_fn)
(slot, baseIndex, offset, startX, startY, devScale,
(slot, baseIndex, offset, startX, startY,
charCount, glyphCount, glyphInfo, glyphPos);
hb_buffer_destroy (buffer);

View File

@ -241,8 +241,7 @@ JDKEXPORT void HBDisposeFace(hb_face_t* face) {
hb_font_t* jdk_font_create_hbp(
hb_face_t* face,
float ptSize, float devScale,
hb_destroy_func_t destroy,
float xPtSize, float yPtSize,
hb_font_funcs_t *font_funcs) {
hb_font_t *font;
@ -253,8 +252,8 @@ hb_font_t* jdk_font_create_hbp(
NULL,
(hb_destroy_func_t)_do_nothing);
hb_font_set_scale(font,
HBFloatToFixed(ptSize*devScale),
HBFloatToFixed(ptSize*devScale));
HBFloatToFixed(xPtSize),
HBFloatToFixed(yPtSize));
return font;
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2015, 2024, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2015, 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@ -107,7 +107,6 @@ hb_jdk_get_glyph_h_advance (hb_font_t *font HB_UNUSED,
return 0;
}
fadv = env->GetFloatField(pt, sunFontIDs.xFID);
fadv *= jdkFontInfo->devScale;
env->DeleteLocalRef(pt);
return HBFloatToFixed(fadv);
@ -398,8 +397,7 @@ JNIEXPORT void JNICALL Java_sun_font_SunLayoutEngine_disposeFace(JNIEnv *env,
} // extern "C"
static hb_font_t* _hb_jdk_font_create(hb_face_t* face,
JDKFontInfo *jdkFontInfo,
hb_destroy_func_t destroy) {
JDKFontInfo *jdkFontInfo) {
hb_font_t *font;
@ -408,14 +406,13 @@ static hb_font_t* _hb_jdk_font_create(hb_face_t* face,
_hb_jdk_get_font_funcs (),
jdkFontInfo, (hb_destroy_func_t) _do_nothing);
hb_font_set_scale (font,
HBFloatToFixed(jdkFontInfo->ptSize*jdkFontInfo->devScale),
HBFloatToFixed(jdkFontInfo->ptSize*jdkFontInfo->devScale));
HBFloatToFixed(jdkFontInfo->xPtSize),
HBFloatToFixed(jdkFontInfo->yPtSize));
return font;
}
hb_font_t* hb_jdk_font_create(hb_face_t* hbFace,
JDKFontInfo *jdkFontInfo,
hb_destroy_func_t destroy) {
JDKFontInfo *jdkFontInfo) {
return _hb_jdk_font_create(hbFace, jdkFontInfo, destroy);
return _hb_jdk_font_create(hbFace, jdkFontInfo);
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2023, 2025, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2023, 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@ -53,20 +53,16 @@ extern "C" {
hb_font_t* jdk_font_create_hbp(
hb_face_t* face,
float ptSize, float devScale,
hb_destroy_func_t destroy,
float xPtSize, float yPtSize,
hb_font_funcs_t* font_funcs);
typedef void (*store_layoutdata_func_t)
(int slot, int baseIndex, int offset,
float startX, float startY, float devScale,
float startX, float startY,
int charCount, int glyphCount,
hb_glyph_info_t *glyphInfo, hb_glyph_position_t *glyphPos);
JDKEXPORT void jdk_hb_shape(
float ptSize,
float *matrix,
void* pFace,
unsigned short* chars,

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2015, 2025, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2015, 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@ -39,10 +39,8 @@ typedef struct JDKFontInfo_Struct {
jobject font2D;
jobject fontStrike;
float matrix[4];
float ptSize;
float xPtSize;
float yPtSize;
float devScale; // How much applying the full glyph tx scales x distance.
} JDKFontInfo;
@ -62,8 +60,7 @@ typedef struct JDKFontInfo_Struct {
hb_font_t *
hb_jdk_font_create(hb_face_t* hbFace,
JDKFontInfo* jdkFontInfo,
hb_destroy_func_t destroy);
JDKFontInfo* jdkFontInfo);
/* Makes an hb_font_t use JDK internally to implement font functions. */

View File

@ -0,0 +1,437 @@
/*
* Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
/*
* @test
* @bug 8269888
* @summary Test for correct GPOS font table handling when drawing text.
*/
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.font.TextAttribute;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.util.Base64;
import java.util.Map;
import javax.imageio.ImageIO;
/**
* Tests that fonts with GPOS tables are handled correctly when drawing text. The
* GPOS table is an OpenType font table which can be used to adjust glyph positions
* in certain contexts. This table is sometimes used in fonts which support Thai
* script, in order to place vowel glyphs above or below consonant glyphs.
*
* @see <a href="https://learn.microsoft.com/en-us/typography/opentype/spec/gpos">GPOS table spec</a>
*/
public class GposTest {
/**
* <p>Font created for this test which contains a number of GPOS entries which
* adjust glyph positions in ways which can be checked programmatically. The
* glyphs render as different basic geometric shapes, so that the layout can
* also be checked visually if necessary. These geometric shapes are different
* for each character, but they have the same overall width and height.
*
* <p>Glyphs:
*
* <ul>
* <li> a : renders as a square
* <li> b : renders as a cross
* <li> c : renders as an X mark
* <li> d : renders as a tringale pointing up
* <li> e : renders as a triangle pointing down
* <li> f : renders as a triangle pointing left
* <li> g : renders as a triangle pointing right
* </ul>
*
* <p>GPOS entries:
*
* <ul>
* <li> ab : second glyph is moved back to the same space as the first glyph
* <li> ac : second glyph is moved back to the same space as the first glyph
* <li> ad : second glyph is moved back to the same space as the first glyph
* <li> ade : second and third glyphs are moved back to the same space as the first glyph
* <li> af : second glyph is moved back and above the first glyph
* </ul>
*
* p>The following FontForge Python script was used to generate this font:
*
* <pre>
* import fontforge
* import base64
*
* SIZE = 800
* THICKNESS = 80
* PAD = int(THICKNESS / 2)
* WIDTH = SIZE + (2 * PAD)
*
* def square(font, char):
* glyph = font.createChar(ord(char))
* pen = glyph.glyphPen()
* pen.moveTo((PAD, PAD))
* pen.lineTo((PAD, PAD + SIZE))
* pen.lineTo((PAD + SIZE, PAD + SIZE))
* pen.lineTo((PAD + SIZE, PAD))
* pen.closePath()
* glyph.stroke('circular', THICKNESS)
* glyph.width = WIDTH
* pen = None
* return glyph
*
* def cross(font, char):
* glyph = font.createChar(ord(char))
* pen = glyph.glyphPen()
* pen.moveTo((PAD, PAD + SIZE/2))
* pen.lineTo((PAD + SIZE, PAD + SIZE/2))
* pen.closePath()
* pen.moveTo((PAD + SIZE/2, PAD))
* pen.lineTo((PAD + SIZE/2, PAD + SIZE))
* pen.closePath()
* glyph.stroke('circular', THICKNESS)
* glyph.width = WIDTH
* pen = None
* return glyph
*
* def x_mark(font, char):
* glyph = font.createChar(ord(char))
* pen = glyph.glyphPen()
* pen.moveTo((PAD, PAD))
* pen.lineTo((PAD + SIZE, PAD + SIZE))
* pen.closePath()
* pen.moveTo((PAD, PAD + SIZE))
* pen.lineTo((PAD + SIZE, PAD))
* pen.closePath()
* glyph.stroke('circular', THICKNESS)
* glyph.width = WIDTH
* pen = None
* return glyph
*
* def triangle_up(font, char):
* glyph = font.createChar(ord(char))
* pen = glyph.glyphPen()
* pen.moveTo((PAD, PAD))
* pen.lineTo((PAD + SIZE, PAD))
* pen.lineTo((PAD + SIZE/2, PAD + SIZE))
* pen.closePath()
* glyph.stroke('circular', THICKNESS)
* glyph.width = WIDTH
* pen = None
* return glyph
*
* def triangle_down(font, char):
* glyph = font.createChar(ord(char))
* pen = glyph.glyphPen()
* pen.moveTo((PAD, PAD + SIZE))
* pen.lineTo((PAD + SIZE, PAD + SIZE))
* pen.lineTo((PAD + SIZE/2, PAD))
* pen.closePath()
* glyph.stroke('circular', THICKNESS)
* glyph.width = WIDTH
* pen = None
* return glyph
*
* def triangle_left(font, char):
* glyph = font.createChar(ord(char))
* pen = glyph.glyphPen()
* pen.moveTo((PAD + SIZE, PAD + SIZE))
* pen.lineTo((PAD + SIZE, PAD))
* pen.lineTo((PAD, PAD + SIZE/2))
* pen.closePath()
* glyph.stroke('circular', THICKNESS)
* glyph.width = WIDTH
* pen = None
* return glyph
*
* def triangle_right(font, char):
* glyph = font.createChar(ord(char))
* pen = glyph.glyphPen()
* pen.moveTo((PAD, PAD))
* pen.lineTo((PAD, PAD + SIZE))
* pen.lineTo((PAD + SIZE, PAD + SIZE/2))
* pen.closePath()
* glyph.stroke('circular', THICKNESS)
* glyph.width = WIDTH
* pen = None
* return glyph
*
* # create font
*
* font = fontforge.font()
* font.encoding = 'UnicodeFull'
* font.design_size = 16
* font.em = 2048
* font.ascent = 1638
* font.descent = 410
* font.familyname = 'GPOSTest'
* font.fontname = 'GPOSTest'
* font.fullname = 'GPOSTest'
* font.copyright = ''
* font.autoWidth(0, 0, 2048)
*
* # create glyphs in font
*
* space = font.createChar(0x20)
* space.width = WIDTH
*
* a = square(font, 'a') # a -> renders as square
* b = cross(font, 'b') # b -> renders as cross
* c = x_mark(font, 'c') # c -> renders as X mark
* d = triangle_up(font, 'd') # d -> renders as triangle pointing up
* e = triangle_down(font, 'e') # e -> renders as triangle pointing down
* f = triangle_left(font, 'f') # f -> renders as triangle pointing left
* g = triangle_right(font, 'g') # g -> renders as triangle pointing right
*
* # GPOS pair adjustment
* # a b -> square cross, but the cross is moved back over the square
* # therefore the drawn area for "a" should match the drawn area for "ab"
*
* font.addLookup('lu1', 'gpos_pair', (), (('kern',(('latn',('dflt')),)),))
* font.addLookupSubtable('lu1', 'subtable1')
* a.addPosSub('subtable1', 'b', 0, 0, -WIDTH, 0, 0, 0, 0, 0)
*
* # GPOS pair adjustment
* # a f -> square leftware-triangle, but the triangle is moved back and above the square
* # therefore the drawn area for "af" should be twice the height as for "a", but same width
*
* a.addPosSub('subtable1', 'f', 0, 0, 0, 0, -WIDTH, WIDTH, 0, 0)
*
* # GPOS cursive attachment
* # a c -> square x-mark, but the x-mark is moved back over the square
* # therefore the drawn area for "a" should match the drawn area for "ac"
*
* font.addLookup('lu2', 'gpos_cursive', (), (('curs',(('latn',('dflt')),)),))
* font.addLookupSubtable('lu2', 'subtable2')
* font.addAnchorClass('subtable2', 'class2')
* a.addAnchorPoint('class2', 'exit', 0, 0)
* c.addAnchorPoint('class2', 'entry', 0, 0)
*
* # GPOS mark-to-base attachment
* # a d -> square upward-triangle, but the triangle is moved back over the square
* # therefore the drawn area for "a" should match the drawn area for "ad"
*
* font.addLookup('lu3', 'gpos_mark2base', (), (('mark',(('latn',('dflt')),)),))
* font.addLookupSubtable('lu3', 'subtable3')
* font.addAnchorClass('subtable3', 'class3')
* a.addAnchorPoint('class3', 'base', 0, 0)
* d.addAnchorPoint('class3', 'mark', 0, 0)
*
* # GPOS mark-to-mark attachment
* # a d e -> square upward-triangle downward-triangle, but all superimposed
* # therefore the drawn area for "a" should match the drawn area for "ade"
* # builds on the "ad" mark-to-base attachment defined above
*
* font.addLookup('lu4', 'gpos_mark2mark', (), (('mkmk',(('latn',('dflt')),)),))
* font.addLookupSubtable('lu4', 'subtable4')
* font.addAnchorClass('subtable4', 'class4')
* d.addAnchorPoint('class4', 'basemark', 0, 0)
* e.addAnchorPoint('class4', 'mark', 0, 0)
*
* # save font to file
*
* ttf = 'test.ttf' # TrueType
* t64 = 'test.ttf.txt' # TrueType Base64
*
* font.generate(ttf)
*
* with open(ttf, 'rb') as f1:
* encoded = base64.b64encode(f1.read())
* with open(t64, 'wb') as f2:
* f2.write(encoded)
* </pre>
*/
private static final String TTF_BYTES = "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";
public static void main(String[] args) throws Exception {
float size = 22;
byte[] ttfBytes = Base64.getDecoder().decode(TTF_BYTES);
ByteArrayInputStream ttfStream = new ByteArrayInputStream(ttfBytes);
Font unscaled = Font.createFont(Font.TRUETYPE_FONT, ttfStream)
.deriveFont(size)
.deriveFont(Map.of(TextAttribute.KERNING, TextAttribute.KERNING_ON));
BufferedImage image = new BufferedImage(2000, 2000, BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g2d = image.createGraphics();
g2d.setRenderingHints(Map.of(
RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON));
try {
for (int scale = 1; scale <= 10; scale++) {
// we're checking 4 fonts, verifying correct behavior:
// 1. a font scaled uniformly via font size
// 2. a font scaled uniformly via AffineTransform
// 3. a font scaled 2x taller via AffineTransform
// 4. a font without extra scaling
AffineTransform at1 = AffineTransform.getScaleInstance(scale, scale);
AffineTransform at2 = AffineTransform.getScaleInstance(scale, scale * 2);
Font scaled1 = unscaled.deriveFont(size * scale);
Font scaled2 = unscaled.deriveFont(at1);
Font scaled3 = unscaled.deriveFont(at2);
Dimension dim = measure(image, g2d, scaled1, "a");
Dimension tall2 = new Dimension(dim.width, dim.height * 2);
Dimension tall4 = new Dimension(dim.width, dim.height * 4);
Dimension tall8 = new Dimension(dim.width, dim.height * 8);
Dimension orig = measure(image, g2d, unscaled, "a");
Dimension orig2 = new Dimension(orig.width, orig.height * 2);
checkSizes(image, g2d, unscaled, orig, orig2, "unscaled");
checkSizes(image, g2d, scaled1, dim, tall2, "scaled 1");
checkSizes(image, g2d, scaled2, dim, tall2, "scaled 2");
checkSizes(image, g2d, scaled3, tall2, tall4, "scaled 3");
g2d.setTransform(at1);
checkSizes(image, g2d, unscaled, dim, tall2, "unscaled with G2D 1");
g2d.setTransform(at2);
checkSizes(image, g2d, unscaled, tall2, tall4, "unscaled with G2D 2");
g2d.setTransform(AffineTransform.getScaleInstance(1, 2));
checkSizes(image, g2d, scaled1, tall2, tall4, "scaled 1 with G2D 3");
checkSizes(image, g2d, scaled2, tall2, tall4, "scaled 2 with G2D 3");
checkSizes(image, g2d, scaled3, tall4, tall8, "scaled 3 with G2D 3");
g2d.setTransform(new AffineTransform()); // reset
}
} finally {
g2d.dispose();
}
}
private static void checkSizes(BufferedImage image, Graphics2D g2d,
Font font, Dimension normal, Dimension tall,
String scenario) {
// individual glyphs
checkSize(image, g2d, font, "a", normal, scenario);
checkSize(image, g2d, font, "b", normal, scenario);
checkSize(image, g2d, font, "c", normal, scenario);
checkSize(image, g2d, font, "d", normal, scenario);
checkSize(image, g2d, font, "e", normal, scenario);
checkSize(image, g2d, font, "f", normal, scenario);
checkSize(image, g2d, font, "g", normal, scenario);
// GPOS combinations
checkSize(image, g2d, font, "ab", normal, scenario);
checkSize(image, g2d, font, "ac", normal, scenario);
checkSize(image, g2d, font, "ad", normal, scenario);
checkSize(image, g2d, font, "ade", normal, scenario);
checkSize(image, g2d, font, "af", tall, scenario);
}
private static void checkSize(BufferedImage image, Graphics2D g2d,
Font font, String text, Dimension expected,
String scenario) {
int maxWidthVariance = Math.max((int) Math.ceil(expected.width * 0.05), 1);
int maxHeightVariance = Math.max((int) Math.ceil(expected.height * 0.05), 1);
Dimension actual = measure(image, g2d, font, text);
if (actual == null ||
Math.abs(actual.width - expected.width) > maxWidthVariance ||
Math.abs(actual.height - expected.height) > maxHeightVariance) {
String id = scenario + " " + text;
saveImage(id, image);
throw new RuntimeException(id + ": " + actual + " != " + expected);
}
}
private static Dimension measure(BufferedImage image, Graphics2D g2d,
Font font, String text) {
int width = image.getWidth();
int height = image.getHeight();
Point2D.Float center = new Point2D.Float(width / 2, height / 2);
try {
// ensure we draw in the center, even if G2D is scaled
g2d.getTransform().createInverse().transform(center, center);
} catch (NoninvertibleTransformException e) {
throw new RuntimeException(e);
}
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, width, height);
g2d.setColor(Color.BLACK);
g2d.setFont(font);
g2d.drawString(text, center.x, center.y);
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
int[] rowPixels = new int[width];
for (int y = 0; y < height; y++) {
image.getRGB(0, y, width, 1, rowPixels, 0, width);
for (int x = 0; x < width; x++) {
boolean white = (rowPixels[x] == -1);
if (!white) {
if (x < minX) {
minX = x;
}
if (y < minY) {
minY = y;
}
if (x > maxX) {
maxX = x;
}
if (y > maxY) {
maxY = y;
}
}
}
}
if (minX != Integer.MAX_VALUE &&
minY != Integer.MAX_VALUE &&
maxX != Integer.MIN_VALUE &&
maxY != Integer.MIN_VALUE) {
return new Dimension(maxX - minX + 1, maxY - minY + 1);
} else {
return null;
}
}
private static void saveImage(String name, BufferedImage image) {
try {
String dir = System.getProperty("test.classes", ".");
String path = dir + File.separator + name + ".png";
File file = new File(path);
ImageIO.write(image, "png", file);
} catch (Exception e) {
// we tried, and that's enough
}
}
}