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> samplesHolder = new AtomicReference<>(Map.of()); + + Runnable refresh = () -> { + boolean advanced = advancedCheckbox.getSelection(); + legacyCheckbox.setEnabled(advanced); + boolean legacyFallback = advanced && legacyCheckbox.getSelection(); + System.setProperty(USE_GDI_TEXT_RENDERING_WITH_GDIP_PROPERTY, Boolean.toString(legacyFallback)); + + Map old = samplesHolder.getAndSet( + renderSamples(display, rows, fonts, sampleWidth, sampleHeight, advanced)); + old.values().forEach(Image::dispose); + + shell.setText("Text rendering comparison (advanced = " + advanced + + ", legacy GDI text rendering = " + legacyFallback + ")"); + info.setText("GC.setAdvanced(" + advanced + "); system property " + + USE_GDI_TEXT_RENDERING_WITH_GDIP_PROPERTY + " = " + legacyFallback + + "\nToggle the checkboxes below to compare plain GDI vs. GDI+ text rendering, and - while" + + " GDI+ is enabled - the default vs. the legacy text rendering path." + + " See the source comment for what to expect per row."); + shell.layout(true, true); + canvas.redraw(); + }; + + advancedCheckbox.addListener(SWT.Selection, e -> refresh.run()); + legacyCheckbox.addListener(SWT.Selection, e -> refresh.run()); + refresh.run(); + + canvas.addPaintListener(e -> { + GC gc = e.gc; + int y = 5; + for (TextRow row : rows) { + gc.drawText(row.label(), 5, y + (rowHeight - 4) / 4, SWT.DRAW_TRANSPARENT); + gc.drawImage(samplesHolder.get().get(row), labelWidth, y); + y += rowHeight; + } + }); + + canvas.addDisposeListener(e -> { + samplesHolder.get().values().forEach(Image::dispose); + fonts.values().forEach(Font::dispose); + }); + + shell.setSize(700, 500); + shell.open(); + while (!shell.isDisposed()) { + if (!display.readAndDispatch()) { + display.sleep(); + } + } + display.dispose(); + } +}