diff --git a/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/graphics/GCWin32Tests.java b/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/graphics/GCWin32Tests.java
index 47b20458042..f6a76fd3d09 100644
--- a/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/graphics/GCWin32Tests.java
+++ b/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/graphics/GCWin32Tests.java
@@ -18,6 +18,7 @@
import java.util.*;
import java.util.concurrent.*;
+import java.util.function.*;
import java.util.stream.*;
import org.eclipse.swt.*;
@@ -68,6 +69,29 @@ public void drawnElementsShouldScaleUpToTheRightZoomLevel() {
assertEquals("Drawn elements should scale to the right value", gc.getGCData().lineWidth, gc.getLineWidth() * scalingFactor, 0);
}
+ /**
+ * Regression test for
+ * https://github.com/eclipse-platform/eclipse.platform.swt/issues/3091: an
+ * advanced GC using a font with an underline (or strikeout) decoration drew
+ * no ink at all, i.e. the text was lost entirely rather than merely losing
+ * its decoration.
+ */
+ @Test
+ public void drawTextWithUnderlinedFontRendersVisibleInk() {
+ Display display = Display.getDefault();
+ FontData underlinedFontData = display.getSystemFont().getFontData()[0];
+ underlinedFontData.data.lfUnderline = 1;
+ Font font = new Font(display, underlinedFontData);
+ Image image = new Image(display, 100, 100);
+ try {
+ assertTrue(renderTextAndCountNonWhitePixels(image, font, "Hello World") > 0,
+ "an advanced GC must draw visible ink for text in an underlined font");
+ } finally {
+ image.dispose();
+ font.dispose();
+ }
+ }
+
/**
* Verifies that underline and strikeout styles requested via a font's
* {@link FontData} are preserved when GDI+ cannot find the font family and
@@ -122,28 +146,192 @@ public void fallbackFontPreservesUnderlineAndStrikeout() {
}
private static int renderTextAndCountNonWhitePixels(Image target, Font font, String text) {
- GC testGC = new GC(target);
+ // advanced mode is required so that font style flags (underline,
+ // strikeout) are applied during rendering
+ return renderTextAndCountNonWhitePixels(target, font, text, SWT.DRAW_DELIMITER | SWT.DRAW_TAB, SWT.NONE, true);
+ }
+
+ /**
+ * U+FFFE is a Unicode non-character that no standard font has a glyph for.
+ * Appending it to a string makes an advanced GC lay that string out with
+ * GDI+ instead of letting GDI compute the glyph positions.
+ *
+ * Since GDI+ text layout became the default for advanced GCs, this is no
+ * longer strictly required. It is kept deliberately so that the tab stop
+ * tests exercise the GDI+ layout path irrespective of the state of the
+ * {@code useGDITextRenderingWithGDIP} system property, which exists to
+ * switch back to GDI-computed glyph positions.
+ */
+ private static final String UNSUPPORTED_GLYPH = String.valueOf((char) 0xFFFE);
+
+ /**
+ * Fonts covering both ends of the space-width/average-character-width ratio:
+ * in proportional fonts a space is roughly half the average character width,
+ * while in monospace fonts the two nearly coincide. A tab stop derived from
+ * the space width therefore only misbehaves noticeably for the proportional
+ * ones, so both kinds have to be covered.
+ */
+ private static Stream tabStopTestFonts() {
+ return Stream.of("Segoe UI", "Arial", "Times New Roman", "Courier New", "Consolas");
+ }
+
+ /**
+ * The extents of the GDI and the GDI+ path are each rounded up to whole
+ * pixels independently, so measurements derived from a difference of two
+ * extents may legitimately be off by one pixel.
+ */
+ private static final int ROUNDING_TOLERANCE = 1;
+
+ /**
+ * Verifies that a tab is expanded to eight times the font's average
+ * character width, which is the convention Win32's own {@code DrawText()}
+ * and {@code TabbedTextOut()} follow: {@code TabbedTextOut()} is documented
+ * to expand tabs to "eight times the average character width" by default,
+ * and {@code DRAWTEXTPARAMS.iTabLength} is documented to be measured "in
+ * units equal to the average character width".
+ *
+ * This pins down the constant the GDI+ path is expected to reproduce.
+ */
+ @ParameterizedTest
+ @MethodSource("tabStopTestFonts")
+ public void tabStopWidthEqualsEightAverageCharacterWidths(String fontName) {
+ Display display = Display.getDefault();
+ Image image = new Image(display, 400, 100);
+ Font font = new Font(display, fontName, 12, SWT.NORMAL);
+ GC gc = new GC(image);
try {
- testGC.setAdvanced(true); // required so font style flags (underline, strikeout) are applied during rendering
- testGC.setBackground(new Color(255, 255, 255));
- testGC.fillRectangle(target.getBounds());
- testGC.setForeground(new Color(0, 0, 0));
- testGC.setFont(font);
- testGC.drawText(text, 5, 5);
+ gc.setFont(font);
+ int averageCharacterWidth = gc.getFontMetrics().handle.tmAveCharWidth;
+ assertWithinRoundingTolerance(8 * averageCharacterWidth, measureTabStopWidth(gc),
+ "a tab must be expanded to eight average character widths for font " + fontName);
} finally {
- testGC.dispose();
+ gc.dispose();
+ font.dispose();
+ image.dispose();
}
- ImageData imageData = target.getImageData(DPIUtil.getDeviceZoom());
- int count = 0;
- for (int y = 0; y < imageData.height; y++) {
- for (int x = 0; x < imageData.width; x++) {
- RGB rgb = imageData.palette.getRGB(imageData.getPixel(x, y));
- if (rgb.red != 255 || rgb.green != 255 || rgb.blue != 255) {
- count++;
- }
+ }
+
+ /**
+ * Verifies that tab stops are expanded to the same width no matter whether
+ * text is rendered via plain GDI or via GDI+.
+ *
+ * Both paths must use eight times the font's average character width
+ * ({@code TEXTMETRIC.tmAveCharWidth}). The GDI+ path used to derive its tab
+ * stop width from the width of a single space glyph instead. That is a
+ * different metric, not merely a differently computed one: in proportional
+ * fonts a space is roughly half the average character width, so tab stops
+ * came out about half as wide whenever that path was taken.
+ */
+ @ParameterizedTest
+ @MethodSource("tabStopTestFonts")
+ public void tabStopWidthIsConsistentBetweenGdiAndGdipRendering(String fontName) {
+ Display display = Display.getDefault();
+ Image image = new Image(display, 400, 100);
+ Font font = new Font(display, fontName, 12, SWT.NORMAL);
+ try {
+ int gdiTabStopWidth = withGC(image, font, false, GCWin32Tests::measureTabStopWidth);
+ int gdipTabStopWidth = withGC(image, font, true, GCWin32Tests::measureTabStopWidth);
+ assertWithinRoundingTolerance(gdiTabStopWidth, gdipTabStopWidth,
+ "GDI+ rendering must expand a tab to the same width as GDI rendering for font " + fontName);
+ } finally {
+ font.dispose();
+ image.dispose();
+ }
+ }
+
+ /**
+ * Verifies that consecutive tabs advance to consecutive tab stops instead of
+ * collapsing into a single one, i.e. that the tab stop width repeats. GDI
+ * derives the repetition from its own {@code (position / width + 1) * width}
+ * calculation, whereas GDI+ gets a single tab stop distance passed to
+ * {@code StringFormat::SetTabStops} and repeats it internally; this asserts
+ * that both arrive at the same layout.
+ */
+ @ParameterizedTest
+ @MethodSource("tabStopTestFonts")
+ public void consecutiveTabsAdvanceByWholeTabStops(String fontName) {
+ Display display = Display.getDefault();
+ Image image = new Image(display, 800, 100);
+ Font font = new Font(display, fontName, 12, SWT.NORMAL);
+ try {
+ for (boolean advanced : new boolean[] { false, true }) {
+ int twoTabStops = withGC(image, font, advanced,
+ gc -> measureTabAdvance(gc, "\t\t") );
+ int oneTabStop = withGC(image, font, advanced, GCWin32Tests::measureTabStopWidth);
+ assertWithinRoundingTolerance(2 * oneTabStop, twoTabStops,
+ "two tabs must advance by two tab stops for font " + fontName
+ + " (advanced=" + advanced + ")");
}
+ } finally {
+ font.dispose();
+ image.dispose();
+ }
+ }
+
+ /**
+ * Verifies that a tab advances to the next tab stop rather than adding a
+ * fixed amount of space, so that text following a tab starts at the same
+ * column regardless of what precedes the tab within the same tab stop.
+ */
+ @ParameterizedTest
+ @MethodSource("tabStopTestFonts")
+ public void textAfterTabStartsAtSameTabStopRegardlessOfPrecedingText(String fontName) {
+ Display display = Display.getDefault();
+ Image image = new Image(display, 400, 100);
+ Font font = new Font(display, fontName, 12, SWT.NORMAL);
+ try {
+ for (boolean advanced : new boolean[] { false, true }) {
+ int withoutPrefix = withGC(image, font, advanced, gc -> measureExtent(gc, "\tB"));
+ int withPrefix = withGC(image, font, advanced, gc -> measureExtent(gc, "A\tB"));
+ assertWithinRoundingTolerance(withoutPrefix, withPrefix,
+ "text following a tab must start at the same tab stop no matter what precedes the tab, "
+ + "for font " + fontName + " (advanced=" + advanced + ")");
+ }
+ } finally {
+ font.dispose();
+ image.dispose();
+ }
+ }
+
+ /**
+ * Returns the width of a single tab stop, measured as the advance a leading
+ * tab adds. A leading tab always expands to exactly one tab stop, so unlike
+ * a tab in the middle of a string this measurement is not diluted by the
+ * slightly different glyph advances of the GDI and the GDI+ text layout
+ * engine.
+ */
+ private static int measureTabStopWidth(GC gc) {
+ return measureTabAdvance(gc, "\t");
+ }
+
+ /**
+ * Returns the horizontal advance the given leading tabs add to the extent of
+ * the text that follows them.
+ */
+ private static int measureTabAdvance(GC gc, String leadingTabs) {
+ return measureExtent(gc, leadingTabs + "B") - measureExtent(gc, "B");
+ }
+
+ private static int measureExtent(GC gc, String text) {
+ // measure in pixels to keep the comparison free of the point/pixel
+ // conversion the public API applies at non-100% zoom levels
+ return gc.textExtentInPixels(text + UNSUPPORTED_GLYPH, SWT.DRAW_TAB).x;
+ }
+
+ private static void assertWithinRoundingTolerance(int expected, int actual, String message) {
+ assertTrue(Math.abs(actual - expected) <= ROUNDING_TOLERANCE,
+ message + " (expected " + expected + ", was " + actual + ")");
+ }
+
+ private static int withGC(Image target, Font font, boolean advanced, ToIntFunction measurement) {
+ GC gc = new GC(target);
+ try {
+ gc.setFont(font);
+ gc.setAdvanced(advanced);
+ return measurement.applyAsInt(gc);
+ } finally {
+ gc.dispose();
}
- return count;
}
/**
@@ -210,4 +398,238 @@ private static Stream zoomAndHeightArguments() {
return Arrays.stream(zooms).boxed()
.flatMap(zoom -> Arrays.stream(heights).mapToObj(height -> Arguments.of(zoom, height)));
}
+
+ /**
+ * Verifies that an advanced GC applies a font's underline and strikeout
+ * decoration for every combination of weight and slant, not just for the
+ * plain, non-bold, non-italic case. Decorated text must produce more ink
+ * than the same text in the same weight and slant without decoration.
+ */
+ @ParameterizedTest(name = "{3}")
+ @MethodSource("styleDecorationCombinations")
+ public void drawTextRendersFontDecorationForStyleCombinations(int styleBits, boolean underline,
+ boolean strikeout, String description) {
+ Display display = Display.getDefault();
+ FontData decoratedFontData = display.getSystemFont().getFontData()[0];
+ decoratedFontData.setStyle(styleBits);
+ if (underline) decoratedFontData.data.lfUnderline = 1;
+ if (strikeout) decoratedFontData.data.lfStrikeOut = 1;
+ Font decoratedFont = new Font(display, decoratedFontData);
+ FontData plainFontData = display.getSystemFont().getFontData()[0];
+ plainFontData.setStyle(styleBits);
+ Font plainFont = new Font(display, plainFontData);
+ Image image = new Image(display, 150, 100);
+ try {
+ int pixelsWithDecoration = renderTextAndCountNonWhitePixels(image, decoratedFont, "Hello");
+ int pixelsWithoutDecoration = renderTextAndCountNonWhitePixels(image, plainFont, "Hello");
+
+ assertTrue(pixelsWithDecoration > pixelsWithoutDecoration,
+ "Text decorated with " + description + " must produce more ink than the undecorated text "
+ + "(decorated: " + pixelsWithDecoration + ", undecorated: " + pixelsWithoutDecoration + ")");
+ } finally {
+ decoratedFont.dispose();
+ plainFont.dispose();
+ image.dispose();
+ }
+ }
+
+ private static Stream styleDecorationCombinations() {
+ return Stream.of(
+ Arguments.of(SWT.NORMAL, true, false, "normal weight + underline"),
+ Arguments.of(SWT.BOLD, true, false, "bold + underline"),
+ Arguments.of(SWT.ITALIC, false, true, "italic + strikeout"),
+ Arguments.of(SWT.BOLD | SWT.ITALIC, true, true, "bold + italic + underline + strikeout")
+ );
+ }
+
+ /**
+ * Verifies that an advanced GC underlines the mnemonic (accelerator)
+ * character requested via {@link SWT#DRAW_MNEMONIC}, which must produce
+ * visibly more ink than the same text without a mnemonic.
+ */
+ @Test
+ public void drawTextMnemonicUnderlineAddsVisibleInk() {
+ Display display = Display.getDefault();
+ Font font = display.getSystemFont();
+ Image image = new Image(display, 150, 60);
+ try {
+ int pixelsWithMnemonic = renderTextAndCountNonWhitePixels(image, font, "&File",
+ SWT.DRAW_MNEMONIC | SWT.DRAW_TRANSPARENT, SWT.NONE, true);
+ int pixelsWithoutMnemonic = renderTextAndCountNonWhitePixels(image, font, "File",
+ SWT.DRAW_TRANSPARENT, SWT.NONE, true);
+
+ assertTrue(pixelsWithMnemonic > pixelsWithoutMnemonic,
+ "Mnemonic underline should add visible ink (with mnemonic: " + pixelsWithMnemonic
+ + ", without: " + pixelsWithoutMnemonic + ")");
+ } finally {
+ image.dispose();
+ }
+ }
+
+ /**
+ * Verifies that mirrored ({@link SWT#RIGHT_TO_LEFT}) text drawn by an
+ * advanced GC covers about the same area as the same mirrored text drawn by
+ * a plain, non-advanced GC, which serves as the reference.
+ */
+ @Test
+ public void drawTextMirroredStyleRendersInkAreaComparableToGdi() {
+ Display display = Display.getDefault();
+ Font font = display.getSystemFont();
+ Image image = new Image(display, 200, 60);
+ try {
+ Rectangle gdipInkBounds = renderTextAndGetInkBounds(image, font, "Hello World",
+ SWT.DRAW_TRANSPARENT, SWT.RIGHT_TO_LEFT, true);
+ Rectangle gdiInkBounds = renderTextAndGetInkBounds(image, font, "Hello World",
+ SWT.DRAW_TRANSPARENT, SWT.RIGHT_TO_LEFT, false);
+
+ assertAll(
+ () -> assertNotNull(gdipInkBounds, "GDI+ mirrored rendering must draw visible text"),
+ () -> assertNotNull(gdiInkBounds, "GDI mirrored rendering must draw visible text")
+ );
+ // Widths may legitimately differ a bit (different layout engines), but a
+ // gross regression (e.g. text collapsed to a sliver, or drawn far wider
+ // because mirroring was applied twice) would fall well outside this range.
+ assertWithinTolerance("mirrored text ink width", gdipInkBounds.width, gdiInkBounds.width, 0.4);
+ } finally {
+ image.dispose();
+ }
+ }
+
+ private static Stream complexScriptsAndCharsets() {
+ return Stream.of(
+ Arguments.of("Arabic", "\u0645\u0631\u062d\u0628\u0627"),
+ Arguments.of("Hebrew", "\u05e9\u05dc\u05d5\u05dd"),
+ Arguments.of("CJK (Chinese)", "\u4f60\u597d\u4e16\u754c"),
+ Arguments.of("Cyrillic", "\u041f\u0440\u0438\u0432\u0435\u0442"),
+ Arguments.of("Greek", "\u0393\u03b5\u03b9\u03ac \u03c3\u03bf\u03c5"),
+ Arguments.of("Combining diacritics", "e\u0301clat")
+ );
+ }
+
+ /**
+ * Verifies that an advanced GC renders visible ink for text in a variety of
+ * scripts and charsets. This is a smoke test only: exact glyph shaping and
+ * positioning is left to the text layout engine, but text must never be
+ * silently dropped.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("complexScriptsAndCharsets")
+ public void drawTextComplexScriptsAndCharsetsRenderVisibleInk(String description, String text) {
+ Display display = Display.getDefault();
+ Font font = display.getSystemFont();
+ Image image = new Image(display, 200, 60);
+ try {
+ int renderedPixels = renderTextAndCountNonWhitePixels(image, font, text);
+
+ assertTrue(renderedPixels > 0, "GDI+ rendering must draw visible ink for " + description);
+ } finally {
+ image.dispose();
+ }
+ }
+
+ /**
+ * Verifies that a kerning-sensitive string drawn by an advanced GC comes
+ * out about as wide as the same string drawn by a plain, non-advanced GC,
+ * which serves as the reference. Minor differences in advances and kerning
+ * are expected, whereas glyphs collapsing onto each other or a roughly
+ * doubled width would indicate a real layout defect.
+ */
+ @Test
+ public void drawTextKerningSensitiveTextWidthIsComparableToGdi() {
+ Display display = Display.getDefault();
+ Font font = display.getSystemFont();
+ Image image = new Image(display, 300, 60);
+ String kerningSensitiveText = "AVATAR WAVE To Yes";
+ try {
+ Rectangle gdipInkBounds = renderTextAndGetInkBounds(image, font, kerningSensitiveText,
+ SWT.DRAW_TRANSPARENT, SWT.NONE, true);
+ Rectangle gdiInkBounds = renderTextAndGetInkBounds(image, font, kerningSensitiveText,
+ SWT.DRAW_TRANSPARENT, SWT.NONE, false);
+
+ assertAll(
+ () -> assertNotNull(gdipInkBounds, "GDI+ rendering must draw visible text"),
+ () -> assertNotNull(gdiInkBounds, "GDI rendering must draw visible text")
+ );
+ assertWithinTolerance("kerning-sensitive text ink width", gdipInkBounds.width, gdiInkBounds.width, 0.3);
+ } finally {
+ image.dispose();
+ }
+ }
+
+ /**
+ * Asserts that {@code actual} is within {@code (1 +/- tolerance)} times
+ * {@code expected}, i.e. flags gross deviations (roughly halved/doubled or
+ * worse) while tolerating the minor differences that are expected between
+ * the text layout of an advanced and a non-advanced GC.
+ */
+ private static void assertWithinTolerance(String description, int actual, int expected, double tolerance) {
+ int lowerBound = (int) Math.floor(expected * (1 - tolerance));
+ int upperBound = (int) Math.ceil(expected * (1 + tolerance));
+ assertTrue(actual >= lowerBound && actual <= upperBound,
+ "Expected " + description + " (" + actual + ") to be within " + (int) (tolerance * 100)
+ + "% of the reference value (" + expected + "), i.e. in [" + lowerBound + ", " + upperBound + "]");
+ }
+
+ private static int renderTextAndCountNonWhitePixels(Image target, Font font, String text, int drawFlags,
+ int gcStyle, boolean advanced) {
+ renderText(target, font, text, drawFlags, gcStyle, advanced);
+ return countNonWhitePixels(target);
+ }
+
+ private static Rectangle renderTextAndGetInkBounds(Image target, Font font, String text, int drawFlags,
+ int gcStyle, boolean advanced) {
+ renderText(target, font, text, drawFlags, gcStyle, advanced);
+ return inkBounds(target);
+ }
+
+ private static void renderText(Image target, Font font, String text, int drawFlags, int gcStyle,
+ boolean advanced) {
+ GC testGC = new GC(target, gcStyle);
+ try {
+ testGC.setAdvanced(advanced);
+ testGC.setBackground(new Color(255, 255, 255));
+ testGC.fillRectangle(target.getBounds());
+ testGC.setForeground(new Color(0, 0, 0));
+ testGC.setFont(font);
+ testGC.drawText(text, 5, 5, drawFlags);
+ } finally {
+ testGC.dispose();
+ }
+ }
+
+ private static int countNonWhitePixels(Image target) {
+ ImageData imageData = target.getImageData(DPIUtil.getDeviceZoom());
+ int count = 0;
+ for (int y = 0; y < imageData.height; y++) {
+ for (int x = 0; x < imageData.width; x++) {
+ RGB rgb = imageData.palette.getRGB(imageData.getPixel(x, y));
+ if (rgb.red != 255 || rgb.green != 255 || rgb.blue != 255) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Returns the bounding box of all non-white pixels in the given image, or
+ * {@code null} if the image is entirely white (i.e. nothing was drawn).
+ */
+ private static Rectangle inkBounds(Image target) {
+ ImageData imageData = target.getImageData(DPIUtil.getDeviceZoom());
+ int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, maxX = -1, maxY = -1;
+ for (int y = 0; y < imageData.height; y++) {
+ for (int x = 0; x < imageData.width; x++) {
+ RGB rgb = imageData.palette.getRGB(imageData.getPixel(x, y));
+ if (rgb.red != 255 || rgb.green != 255 || rgb.blue != 255) {
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+ if (maxX < 0) return null;
+ return new Rectangle(minX, minY, maxX - minX + 1, maxY - minY + 1);
+ }
}
diff --git a/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/graphics/GC.java b/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/graphics/GC.java
index 525cf88296d..45414f64354 100644
--- a/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/graphics/GC.java
+++ b/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/graphics/GC.java
@@ -110,6 +110,24 @@ public final class GC extends Resource {
static final float[] LINE_DASHDOT_ZERO = new float[]{9, 6, 3, 6};
static final float[] LINE_DASHDOTDOT_ZERO = new float[]{9, 3, 3, 3, 3, 3};
+ private static final String USE_GDI_TEXT_RENDERING_WITH_GDIP = "org.eclipse.swt.internal.win32.useGDITextRenderingWithGDIP";
+
+ /**
+ * Whether text is laid out by GDI and only drawn by GDI+, instead of being
+ * laid out by GDI+ itself, which restores the behavior that was in place
+ * before GDI+ text layout became the default.
+ *
+ * This is only a safety net for unexpected text rendering regressions, so
+ * that consumers can fall back to the previous behavior instead of having
+ * to downgrade SWT. It may be removed at any point in time and must not be
+ * relied upon.
+ *
+ * Evaluated once per GC rather than per drawing operation, so that reading
+ * the system property does not add cost to text drawing, while a newly
+ * created GC still picks up a value changed at runtime.
+ */
+ private final boolean useGdiTextLayoutWithGdip = Boolean.getBoolean(USE_GDI_TEXT_RENDERING_WITH_GDIP);
+
/**
* Prevents uninitialized instances from being created outside the package.
*/
@@ -2854,7 +2872,20 @@ private void drawTextInPixels (String string, int x, int y, int flags) {
OS.SetBkMode(handle, oldBkMode);
}
-private boolean useGDIP (long hdc, char[] buffer) {
+/**
+ * Decides whether GDI+ lays out the text itself (Graphics_DrawString) or
+ * whether the glyphs and their positions are computed by GDI and only drawn
+ * by GDI+ (Graphics_DrawDriverString). Note that both cases draw with GDI+,
+ * so this only selects which engine performs the layout.
+ *
+ * Unless the legacy GDI text layout is requested, GDI+ always lays out the
+ * text itself and the glyph inspection below is not reached. Both are to be
+ * removed together with the fallback.
+ */
+private boolean useGdipTextLayout(long hdc, char[] buffer) {
+ if (!useGdiTextLayoutWithGdip) {
+ return true;
+ }
short[] glyphs = new short[buffer.length];
OS.GetGlyphIndices(hdc, buffer, buffer.length, glyphs, OS.GGI_MARK_NONEXISTING_GLYPHS);
for (int i = 0; i < glyphs.length; i++) {
@@ -2882,11 +2913,11 @@ void drawText(long gdipGraphics, String string, int x, int y, int flags, Point s
if (hFont != 0) oldFont = OS.SelectObject(hdc, hFont);
TEXTMETRIC lptm = new TEXTMETRIC();
OS.GetTextMetrics(hdc, lptm);
- boolean gdip = useGDIP(hdc, chars);
+ boolean gdip = useGdipTextLayout(hdc, chars);
if (hFont != 0) OS.SelectObject(hdc, oldFont);
Gdip.Graphics_ReleaseHDC(gdipGraphics, hdc);
if (gdip) {
- drawTextGDIP(gdipGraphics, string, x, y, flags, size == null, size);
+ drawTextGDIP(gdipGraphics, string, x, y, flags, size == null, size, lptm);
return;
}
int i = 0, start = 0, end = 0, drawX = x, drawY = y, width = 0, mnemonicIndex = -1;
@@ -3056,7 +3087,7 @@ private RectF drawText(long gdipGraphics, char[] buffer, int start, int length,
return bounds;
}
-private void drawTextGDIP(long gdipGraphics, String string, int x, int y, int flags, boolean draw, Point size) {
+private void drawTextGDIP(long gdipGraphics, String string, int x, int y, int flags, boolean draw, Point size, TEXTMETRIC lptm) {
boolean needsBounds = !draw || (flags & SWT.DRAW_TRANSPARENT) == 0;
char[] buffer;
if ((flags & SWT.DRAW_DELIMITER) == 0) {
@@ -3078,7 +3109,15 @@ private void drawTextGDIP(long gdipGraphics, String string, int x, int y, int fl
int formatFlags = Gdip.StringFormat_GetFormatFlags(format) | Gdip.StringFormatFlagsMeasureTrailingSpaces;
if ((data.style & SWT.MIRRORED) != 0) formatFlags |= Gdip.StringFormatFlagsDirectionRightToLeft;
Gdip.StringFormat_SetFormatFlags(format, formatFlags);
- float[] tabs = (flags & SWT.DRAW_TAB) != 0 ? new float[]{measureSpace(data.gdipFont, format) * 8} : new float[1];
+ // Use the same tab stop width as the GDI-based text rendering path: 8 * the
+ // font's average character width, which is what Win32's own DrawText() and
+ // TabbedTextOut() use by default. This used to be 8 * the width of a single
+ // space glyph, which is a different metric rather than a differently
+ // computed one: in proportional fonts a space is roughly half the average
+ // character width, so tab stops came out about half as wide whenever this
+ // path was taken. (In monospace fonts the two nearly coincide, which is why
+ // the discrepancy was easy to miss.)
+ float[] tabs = (flags & SWT.DRAW_TAB) != 0 ? new float[]{lptm.tmAveCharWidth * 8} : new float[1];
Gdip.StringFormat_SetTabStops(format, 0, tabs.length, tabs);
int hotkeyPrefix = (flags & SWT.DRAW_MNEMONIC) != 0 ? Gdip.HotkeyPrefixShow : Gdip.HotkeyPrefixNone;
if ((flags & SWT.DRAW_MNEMONIC) != 0 && (data.uiState & OS.UISF_HIDEACCEL) != 0) hotkeyPrefix = Gdip.HotkeyPrefixHide;
@@ -4658,13 +4697,6 @@ public boolean isDisposed() {
return handle == 0;
}
-private float measureSpace(long font, long format) {
- PointF pt = new PointF();
- RectF bounds = new RectF();
- Gdip.Graphics_MeasureString(data.gdipGraphics, new char[]{' '}, 1, font, pt, format, bounds);
- return bounds.Width;
-}
-
/**
* Sets the receiver to always use the operating system's advanced graphics
* subsystem for all graphics operations if the argument is true.
diff --git a/examples/org.eclipse.swt.snippets/.classpath_cocoa b/examples/org.eclipse.swt.snippets/.classpath_cocoa
index bcad73af751..6f6d5d56841 100644
--- a/examples/org.eclipse.swt.snippets/.classpath_cocoa
+++ b/examples/org.eclipse.swt.snippets/.classpath_cocoa
@@ -2,6 +2,6 @@
-
+
diff --git a/examples/org.eclipse.swt.snippets/.classpath_gtk b/examples/org.eclipse.swt.snippets/.classpath_gtk
index bcad73af751..6f6d5d56841 100644
--- a/examples/org.eclipse.swt.snippets/.classpath_gtk
+++ b/examples/org.eclipse.swt.snippets/.classpath_gtk
@@ -2,6 +2,6 @@
-
+
diff --git a/examples/org.eclipse.swt.snippets/Snippets.md b/examples/org.eclipse.swt.snippets/Snippets.md
index 08cd32f387c..9f3e1becfdd 100644
--- a/examples/org.eclipse.swt.snippets/Snippets.md
+++ b/examples/org.eclipse.swt.snippets/Snippets.md
@@ -198,6 +198,7 @@ To contribute a new snippet, [create a snippet contribution as a pull request](h
- [draw 2 polylines with different line attributes](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet252.java) – [(preview)](https://github.com/eclipse-platform/eclipse.platform.swt/blob/master/examples/org.eclipse.swt.snippets/previews/Snippet252.png "Preview for Snippet 252")
- [draw lines with configurable line width, scaling and rotation](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet381.java) – [(preview)](https://github.com/eclipse-platform/eclipse.platform.swt/blob/master/examples/org.eclipse.swt.snippets/previews/Snippet381.png "Preview for Snippet 381")
- [crop and scale images via source and destination values](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet389.java) – [(preview)](https://github.com/eclipse-platform/eclipse.platform.swt/blob/master/examples/org.eclipse.swt.snippets/previews/Snippet389.png "Preview for Snippet 389")
+- [render different types of text with GDI vs. GDI+ (Windows-only)](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet395.java) – [(preview)](https://github.com/eclipse-platform/eclipse.platform.swt/blob/master/examples/org.eclipse.swt.snippets/previews/Snippet395.png "Preview for Snippet 395")
### **Gesture, Touch support**
- [create a shell and listen for TouchEvents](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet352.java)
diff --git a/examples/org.eclipse.swt.snippets/previews/Snippet395.png b/examples/org.eclipse.swt.snippets/previews/Snippet395.png
new file mode 100644
index 00000000000..3dc24d77a77
Binary files /dev/null and b/examples/org.eclipse.swt.snippets/previews/Snippet395.png differ
diff --git a/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet395.java b/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet395.java
new file mode 100644
index 00000000000..1c69195d593
--- /dev/null
+++ b/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet395.java
@@ -0,0 +1,252 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Vector Informatik GmbH and others.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.swt.snippets;
+
+import java.util.*;
+import java.util.concurrent.atomic.*;
+
+import org.eclipse.swt.*;
+import org.eclipse.swt.custom.*;
+import org.eclipse.swt.graphics.*;
+import org.eclipse.swt.layout.*;
+import org.eclipse.swt.widgets.*;
+
+/*
+ * Windows plain GDI vs. GDI+ text rendering snippet.
+ *
+ * On Windows, GC.drawText() renders text in one of two ways: with plain GDI
+ * (OS.DrawText) whenever the GC is not advanced, and with GDI+ once
+ * GC.setAdvanced(true) is active. The two engines compute text layout
+ * independently, so kerning, tab stop width, mnemonic underlining and
+ * bidi/mirroring can all come out differently depending on which one draws.
+ *
+ * This snippet renders a series of text properties, one row per property, and
+ * lets the rendering path be switched at runtime, so that the results can be
+ * compared visually without restarting the process:
+ * - "Use GDI+ (advanced) rendering" calls GC.setAdvanced() and re-renders
+ * every row, switching between plain GDI and GDI+.
+ * - "Use legacy GDI text rendering" toggles the
+ * org.eclipse.swt.internal.win32.useGDITextRenderingWithGDIP system property,
+ * restoring the previous behavior of having GDI compute the glyph positions.
+ * It only matters while GDI+/advanced rendering is enabled and is disabled
+ * otherwise.
+ *
+ * The rows labelled "unsupported glyph (U+FFFE)" append U+FFFE, a Unicode
+ * non-character that no standard font has a glyph for. Strings containing such
+ * a character are always laid out by GDI+ itself rather than having their glyph
+ * positions computed by GDI, which is what makes the GDI+ tab stop and
+ * decoration handling observable. The trailing box-shaped glyph drawn for
+ * U+FFFE itself is expected.
+ *
+ * What to expect while toggling:
+ * - The two tab rows must expand tabs to the same column width in every
+ * combination.
+ * - "Mnemonic" shows an underlined "F" in every combination (it does not
+ * depend on font-level decoration).
+ * - "Kerning pair", the tab rows and "Mirrored / RTL" may differ slightly in
+ * spacing/positioning between the engines, but should never render blank,
+ * wildly stretched/compressed, or with overlapping glyphs.
+ * - The script/charset rows (Arabic, Hebrew, CJK, Cyrillic, Greek, combining
+ * diacritics) should render recognisable, visible glyphs in every
+ * combination.
+ * - "Underlined"/"Strikeout"/"Bold + underlined" render their decoration with
+ * plain GDI and with GDI+, and go blank only with legacy GDI text rendering
+ * enabled, unless U+FFFE forces GDI+'s own text layout. See
+ * https://github.com/eclipse-platform/eclipse.platform.swt/issues/3091 .
+ *
+ * On platforms other than Windows, GC.setAdvanced() does not select a
+ * different text rendering engine and the system property has no effect, so
+ * all rows render identically regardless of the checkbox state.
+ *
+ * For a list of all SWT example snippets see
+ * http://www.eclipse.org/swt/snippets/
+ */
+public class Snippet395 {
+
+ static final String USE_GDI_TEXT_RENDERING_WITH_GDIP_PROPERTY =
+ "org.eclipse.swt.internal.win32.useGDITextRenderingWithGDIP";
+
+ /** One row of the comparison: a label, the text properties to apply, and how to draw it. */
+ record TextRow(String label, int fontStyle, boolean underline, boolean strikeout, String text, int drawFlags,
+ int gcStyle) {
+ TextRow(String label, String text) {
+ this(label, SWT.NORMAL, false, false, text, SWT.DRAW_TRANSPARENT, SWT.NONE);
+ }
+ }
+
+ /**
+ * Renders one fresh sample {@link Image} per row, either with plain GDI
+ * ({@code advanced == false}) or with GDI+ ({@code advanced == true}), in
+ * the latter case reflecting whatever the
+ * {@link #USE_GDI_TEXT_RENDERING_WITH_GDIP_PROPERTY} system property is set
+ * to right now. Callers are responsible for disposing the previous set of
+ * images returned by an earlier call.
+ */
+ private static Map renderSamples(Display display, java.util.List rows,
+ Map fonts, int sampleWidth, int sampleHeight, boolean advanced) {
+ Map samples = new HashMap<>();
+ for (TextRow row : rows) {
+ Image sample = new Image(display, sampleWidth, sampleHeight);
+ GC gc = new GC(sample, row.gcStyle());
+ try {
+ gc.setAdvanced(advanced);
+ gc.setBackground(new Color(255, 255, 255));
+ gc.fillRectangle(sample.getBounds());
+ gc.setForeground(new Color(0, 0, 0));
+ gc.setFont(fonts.get(row));
+ gc.drawText(row.text(), 5, 5, row.drawFlags());
+ } finally {
+ gc.dispose();
+ }
+ samples.put(row, sample);
+ }
+ return samples;
+ }
+
+ @SuppressWarnings("restriction")
+ public static void main(String[] args) {
+ // U+FFFE has no glyph in any standard font. Appending it to a string makes
+ // GC.drawText() lay out that whole string with GDI+ instead of computing
+ // the glyph positions with GDI, so the rows using it always show what
+ // GDI+'s own text layout does with the respective text property.
+ String unsupportedGlyph = String.valueOf((char) 0xFFFE);
+
+ java.util.List rows = new ArrayList<>();
+ rows.add(new TextRow("Plain text", "Hello World"));
+ rows.add(new TextRow("Bold", SWT.BOLD, false, false, "Hello World", SWT.DRAW_TRANSPARENT, SWT.NONE));
+ rows.add(new TextRow("Italic", SWT.ITALIC, false, false, "Hello World", SWT.DRAW_TRANSPARENT, SWT.NONE));
+ rows.add(new TextRow("Underlined", SWT.NORMAL, true, false, "Hello World", SWT.DRAW_TRANSPARENT, SWT.NONE));
+ rows.add(new TextRow("Strikeout", SWT.NORMAL, false, true, "Hello World", SWT.DRAW_TRANSPARENT, SWT.NONE));
+ rows.add(new TextRow("Bold + underlined", SWT.BOLD, true, false, "Hello World", SWT.DRAW_TRANSPARENT,
+ SWT.NONE));
+ rows.add(new TextRow("Underlined + unsupported glyph (U+FFFE)", SWT.NORMAL, true, false,
+ "Hi" + unsupportedGlyph, SWT.DRAW_TRANSPARENT, SWT.NONE));
+ rows.add(new TextRow("Mnemonic (accelerator underline)", SWT.NORMAL, false, false, "&File",
+ SWT.DRAW_MNEMONIC | SWT.DRAW_TRANSPARENT, SWT.NONE));
+ rows.add(new TextRow("Tab-separated columns", SWT.NORMAL, false, false, "A\tB\tC", SWT.DRAW_TAB,
+ SWT.NONE));
+ rows.add(new TextRow("Tab-separated columns + unsupported glyph (U+FFFE)", SWT.NORMAL, false, false,
+ "A\tB\tC" + unsupportedGlyph, SWT.DRAW_TAB, SWT.NONE));
+ rows.add(new TextRow("Kerning pair", "AVATAR WAVE To Yes"));
+ rows.add(new TextRow("Mirrored / RTL", SWT.NORMAL, false, false, "Hello World", SWT.DRAW_TRANSPARENT,
+ SWT.RIGHT_TO_LEFT));
+ rows.add(new TextRow("Arabic", "\u0645\u0631\u062d\u0628\u0627 \u0628\u0627\u0644\u0639\u0627\u0644\u0645"));
+ rows.add(new TextRow("Hebrew", "\u05e9\u05dc\u05d5\u05dd \u05e2\u05d5\u05dc\u05dd"));
+ rows.add(new TextRow("CJK (Chinese)", "\u4f60\u597d\u4e16\u754c"));
+ rows.add(new TextRow("Cyrillic", "\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440"));
+ rows.add(new TextRow("Greek", "\u0393\u03b5\u03b9\u03ac \u03c3\u03bf\u03c5 \u039a\u03cc\u03c3\u03bc\u03b5"));
+ rows.add(new TextRow("Combining diacritics", "e\u0301clat na\u0308\u0131ve"));
+
+ Display display = new Display();
+ Shell shell = new Shell(display);
+ shell.setLayout(new GridLayout());
+ shell.setText("Text rendering comparison");
+
+ Label info = new Label(shell, SWT.WRAP);
+ info.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+
+ Button advancedCheckbox = new Button(shell, SWT.CHECK | SWT.WRAP);
+ advancedCheckbox.setText("Use GDI+ (advanced) rendering - GC.setAdvanced(true); "
+ + "uncheck to compare against plain GDI (GC.setAdvanced(false))");
+ advancedCheckbox.setSelection(true);
+ advancedCheckbox.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+
+ Button legacyCheckbox = new Button(shell, SWT.CHECK | SWT.WRAP);
+ legacyCheckbox.setText("Use legacy GDI text rendering (org.eclipse.swt.internal.win32."
+ + "useGDITextRenderingWithGDIP = true) - historical, pre-#3091-fix behavior."
+ + "\nThe legacy fallback is only an escape hatch for the transition to GDI+ text rendering and is to"
+ + " be removed in a future release, at which point this checkbox will have no effect anymore.");
+ legacyCheckbox.setSelection(Boolean.getBoolean(USE_GDI_TEXT_RENDERING_WITH_GDIP_PROPERTY));
+ legacyCheckbox.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ // The property only ever affects the advanced/GDI+ code path, so the
+ // checkbox is meaningless (and disabled) while advanced rendering is off.
+ legacyCheckbox.setEnabled(advancedCheckbox.getSelection());
+
+ ScrolledComposite scroller = new ScrolledComposite(shell, SWT.V_SCROLL | SWT.BORDER);
+ scroller.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+ scroller.setExpandHorizontal(true);
+ scroller.setExpandVertical(true);
+
+ int rowHeight = 42;
+ int labelWidth = 260;
+ int sampleWidth = 320;
+ int sampleHeight = rowHeight - 4;
+
+ Canvas canvas = new Canvas(scroller, SWT.NONE);
+ canvas.setSize(labelWidth + sampleWidth + 20, rows.size() * rowHeight + 10);
+ scroller.setContent(canvas);
+ scroller.setMinSize(canvas.getSize());
+
+ Font systemFont = display.getSystemFont();
+ Map fonts = new HashMap<>();
+ for (TextRow row : rows) {
+ FontData fontData = systemFont.getFontData()[0];
+ fontData.setStyle(row.fontStyle());
+ if (row.underline()) fontData.data.lfUnderline = 1;
+ if (row.strikeout()) fontData.data.lfStrikeOut = 1;
+ fonts.put(row, new Font(display, fontData));
+ }
+
+ // Mutable holder so the checkbox listeners can swap in a freshly rendered
+ // set of samples (and dispose the previous ones) whenever a toggle changes.
+ // Starts out empty; the initial samples are rendered by the refresh below.
+ AtomicReference