diff --git a/src/java.desktop/share/classes/sun/font/GlyphLayout.java b/src/java.desktop/share/classes/sun/font/GlyphLayout.java index 851201fe347..5af383911e7 100644 --- a/src/java.desktop/share/classes/sun/font/GlyphLayout.java +++ b/src/java.desktop/share/classes/sun/font/GlyphLayout.java @@ -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> 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); } } diff --git a/src/java.desktop/share/classes/sun/font/HBShaper.java b/src/java.desktop/share/classes/sun/font/HBShaper.java index 1e0b918ce54..e7f3e6178fa 100644 --- a/src/java.desktop/share/classes/sun/font/HBShaper.java +++ b/src/java.desktop/share/classes/sun/font/HBShaper.java @@ -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; diff --git a/src/java.desktop/share/classes/sun/font/SunLayoutEngine.java b/src/java.desktop/share/classes/sun/font/SunLayoutEngine.java index 99d3987e5fd..d6937c667ef 100644 --- a/src/java.desktop/share/classes/sun/font/SunLayoutEngine.java +++ b/src/java.desktop/share/classes/sun/font/SunLayoutEngine.java @@ -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, diff --git a/src/java.desktop/share/native/libfontmanager/HBShaper.c b/src/java.desktop/share/native/libfontmanager/HBShaper.c index 1da79bd78ed..0e3ac4dd01e 100644 --- a/src/java.desktop/share/native/libfontmanager/HBShaper.c +++ b/src/java.desktop/share/native/libfontmanager/HBShaper.c @@ -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); diff --git a/src/java.desktop/share/native/libfontmanager/HBShaper_Panama.c b/src/java.desktop/share/native/libfontmanager/HBShaper_Panama.c index f6f4c357c31..c197fc568fc 100644 --- a/src/java.desktop/share/native/libfontmanager/HBShaper_Panama.c +++ b/src/java.desktop/share/native/libfontmanager/HBShaper_Panama.c @@ -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); diff --git a/src/java.desktop/share/native/libfontmanager/hb-jdk-font-p.cc b/src/java.desktop/share/native/libfontmanager/hb-jdk-font-p.cc index 1e5db4e0adf..00f6fef75f5 100644 --- a/src/java.desktop/share/native/libfontmanager/hb-jdk-font-p.cc +++ b/src/java.desktop/share/native/libfontmanager/hb-jdk-font-p.cc @@ -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; } diff --git a/src/java.desktop/share/native/libfontmanager/hb-jdk-font.cc b/src/java.desktop/share/native/libfontmanager/hb-jdk-font.cc index 6e370dec182..e3713db72cc 100644 --- a/src/java.desktop/share/native/libfontmanager/hb-jdk-font.cc +++ b/src/java.desktop/share/native/libfontmanager/hb-jdk-font.cc @@ -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); } diff --git a/src/java.desktop/share/native/libfontmanager/hb-jdk-p.h b/src/java.desktop/share/native/libfontmanager/hb-jdk-p.h index 47d36e8b02f..cf36c76ba23 100644 --- a/src/java.desktop/share/native/libfontmanager/hb-jdk-p.h +++ b/src/java.desktop/share/native/libfontmanager/hb-jdk-p.h @@ -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, diff --git a/src/java.desktop/share/native/libfontmanager/hb-jdk.h b/src/java.desktop/share/native/libfontmanager/hb-jdk.h index bf58bbf60e1..ce6a952b956 100644 --- a/src/java.desktop/share/native/libfontmanager/hb-jdk.h +++ b/src/java.desktop/share/native/libfontmanager/hb-jdk.h @@ -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. */ diff --git a/test/jdk/java/awt/Graphics2D/DrawString/GposTest.java b/test/jdk/java/awt/Graphics2D/DrawString/GposTest.java new file mode 100644 index 00000000000..7e98c366061 --- /dev/null +++ b/test/jdk/java/awt/Graphics2D/DrawString/GposTest.java @@ -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 GPOS table spec + */ +public class GposTest { + + /** + *

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. + * + *

Glyphs: + * + *

+ * + *

GPOS entries: + * + *

+ * + * p>The following FontForge Python script was used to generate this font: + * + *
+     * 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)
+     * 
+ */ + 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 + } + } +}