Skip to content

[Win32] Fix tab stop width in GDI+ text rendering - #3518

Open
HeikoKlare wants to merge 1 commit into
eclipse-platform:masterfrom
HeikoKlare:tab-stop-gdip-width
Open

[Win32] Fix tab stop width in GDI+ text rendering#3518
HeikoKlare wants to merge 1 commit into
eclipse-platform:masterfrom
HeikoKlare:tab-stop-gdip-width

Conversation

@HeikoKlare

Copy link
Copy Markdown
Contributor

This is an extract of #3100, which removes the GDI fallback in GC.drawText() entirely. That change makes the GDI+ text rendering path the default for all text, which turns the tab stop defect described below from a rarely reachable corner case into something every consumer of DRAW_TAB would hit. It is therefore worth fixing separately and up front.

What is wrong

GC.drawText() on Win32 renders text in one of three ways, and all three must agree on how wide a SWT.DRAW_TAB tab stop is:

  1. Plain GDI (non-advanced GC) → OS.DrawText() with DT_EXPANDTABS
  2. GDI-computed glyphs, drawn by GDI+ (advanced GC, ordinary text) → tab stops computed in Java
  3. GDI+ text layout (advanced GC, text containing characters GDI has no glyph for) → drawTextGDIP()

Paths 1 and 2 use eight times the font's average character width. Path 2 does so explicitly:

int tabWidth = lptm.tmAveCharWidth * 8;
...
drawX = x + (((drawX - x) / tabWidth) + 1) * tabWidth;

Path 3, however, derived its tab stop from the width of a single space glyph:

float[] tabs = (flags & SWT.DRAW_TAB) != 0 ? new float[]{measureSpace(data.gdipFont, format) * 8} : new float[1];

Those are two different metrics, not two ways of computing the same one. In a proportional font a space is roughly half the average character width, so tab stops in path 3 came out about half as wide. The original commit message for this change blamed "different engines computing the same metric differently" — the measurements below disproved that, and the description was corrected: GDI+ measures the space glyph almost identically to GDI (e.g. Segoe UI 9pt: 3.3 px vs. 3 px). The bug is the choice of metric.

Eight average character widths is not an arbitrary SWT convention, it is what Win32 itself does:

  • TabbedTextOut(): "If the nTabPositions parameter is zero and lpnTabStopPositions is NULL, tabs are expanded to eight times the average character width."
  • DRAWTEXTPARAMS.iTabLength: "The size of each tab stop, in units equal to the average character width."
  • Raymond Chen, Notes on DrawText and tab stops: "Tab stops default to every eight average character widths", measured "relative to the left edge of the formatting rectangle" — matching the x-relative computation in path 2.
  • Wine implements DrawTextExW() as tabwidth = tm.tmAveCharWidth * tabstop (default tabstop = 8) with plen = ((plen/tabwidth)+1)*tabwidth — character for character the formula used by path 2.

How the change fixes it

drawText() already computes a TEXTMETRIC for the current font before deciding which path to take. That metric is now passed on to drawTextGDIP() and used for the tab stops, so path 3 uses the same value as paths 1 and 2:

float[] tabs = (flags & SWT.DRAW_TAB) != 0 ? new float[]{lptm.tmAveCharWidth * 8} : new float[1];

The now-unused measureSpace() helper is removed. The metric is coherent with the font GDI+ actually uses: in the common case the TEXTMETRIC comes from the same HFONT the GDI+ font was created from, and in the font-substitution case (!Font_IsAvailable) data.hGDIFont is round-tripped from the GDI+ font via Font_GetLogFontW(), so it still describes the substituted font.

GCWin32Tests gains four parameterized tests, run over both proportional and monospace fonts. They pin down the tab stop width itself (eight average character widths), its consistency across the rendering paths, and two properties that must hold for either engine: consecutive tabs advance by whole tab stops, and text following a tab starts at the same tab stop regardless of what precedes it. They were verified to fail on the unfixed code for every font, including the monospace ones.

Users drawing tabbed text through an advanced GC will see tab stops become wider — that is, correct, and matching what the same text renders as without setAdvanced(true).

What the history of this code tells us

This is not a new judgment call — it restores a decision the project already made, and repairs a regression:

  • Sept 2009 — bug 289244, "[Graphics] GDI+ drawText is not consistent with GDI" (commit 63863d93c2, Silenio Quarti). This commit deleted the measureSpace(...) * 8 line and replaced it with int tabWidth = lptm.tmAveCharWidth * 8;. In other words, the value this PR switches to is SWT's own answer to precisely the question "how do we make GDI+ tabs consistent with GDI".
  • 2010 — bug 305815, "GC could not draw Chinese text when setAdvanced(true)" (commit 7e842a36af). To fix Chinese text rendering, this re-introduced the pre-289244 GDI+ code verbatim as a fallback path for strings GDI cannot handle — resurrecting measureSpace() and its tab stop line along with it. The 2009 fix was silently undone for that path.

So the defect has been latent for ~16 years. Two things kept it from being noticed:

  1. The path is rarely taken. Only strings containing characters GDI has no glyph for reach drawTextGDIP(), so most applications never see it — until [Win32] Remove obsolete fallback for drawing text without GDI+ #3100 makes it the only path.
  2. It is invisible in monospace fonts. There the space width and the average character width nearly coincide (see the measurements), so exactly the fonts one would reach for when testing tab alignment are the ones that hide the bug.

Worth noting for reviewers: the fallback path from bug 305815 is a verbatim copy of decade-old code. It is worth assuming that other fixes made to the GDI path between 2009 and 2010 were lost in the same way, and that this may not be the only such discrepancy.

Measurements

Measured on Windows 11, 96 DPI, by reproducing drawTextGDIP()'s tab stop handling standalone (direct Gdip calls against a shipped SWT build), so that the old and the new value could be compared against what Win32 DrawText(DT_EXPANDTABS) actually produces. All values are the width in pixels that a single leading tab expands to.

Font 8 * tmAveCharWidth Win32 DrawText GDI+ before GDI+ after
Segoe UI 9 48 48 26.3 48.0
Segoe UI 12 72 72 35.1 72.0
Tahoma 12 56 56 40.0 56.0
Times New Roman 9 40 40 24.0 40.0
Arial 12 56 56 35.6 56.0
Verdana 16 88 88 59.1 88.0
Courier New 9 (monospace) 56 56 57.6 56.0
Consolas 12 (monospace) 72 72 70.4 72.0

Across all 21 font/size combinations tested:

  • Win32 DrawText's tab width equals 8 * tmAveCharWidth exactly — the documented convention is also the observed behaviour.
  • The new GDI+ value reproduces DrawText exactly, not merely within a tolerance.
  • The old value was off by up to 51 % for proportional fonts, but by only 1–3 % for monospace fonts — which is the concrete evidence for why this went unnoticed.

Two further properties were confirmed empirically and are covered by the added tests:

  • GDI+ StringFormat::SetTabStops(0, 1, {w}) places stops at w, 2w, 3w, …, i.e. identical semantics to path 2's ((position / w) + 1) * w. The value swap does not change the geometry model.
  • Tab stops are relative to the text origin in both engines, so text following a tab lands in the same column regardless of what precedes the tab.

One risk was explicitly ruled out: Graphics_MeasureString() is not affected by the GDI+ world transform, so the previous space-based value was equally transform-independent and replacing it with a device-pixel metric introduces no regression under GC.setTransform(). This is consistent with createGdipFont(), which already neutralises the world transform when deriving the LOGFONT for GetTextMetrics.

Snippet

Snippet395 is added as a manual/visual companion to the automated tests. It renders a series of text properties one row per property, and offers a checkbox that switches between plain GDI and GDI+ (advanced) rendering at runtime without restarting, so the two engines can be compared directly. Besides the two tab rows it also covers underline/strikeout, mnemonics, kerning, mirroring and a range of scripts, which makes it useful well beyond this fix — in particular for reviewing #3100.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Test Results

  212 files  ± 0    212 suites  ±0   27m 36s ⏱️ +42s
4 917 tests +20  4 893 ✅ +20   24 💤 ±0  0 ❌ ±0 
7 088 runs  +20  6 920 ✅ +20  168 💤 ±0  0 ❌ ±0 

Results for commit b222ad5. ± Comparison against base commit 83e249e.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request fixes an inconsistency in Win32 GC.drawText() when tabs are expanded under the GDI+ text-layout path, aligning tab stop width with Win32/GDI behavior (8× average character width) and adding Win32 tests plus a manual snippet to validate/compare rendering.

Changes:

  • Win32: make GDI+ text-layout (drawTextGDIP) use TEXTMETRIC.tmAveCharWidth * 8 for SWT.DRAW_TAB tab stops (and remove the now-unused space-measurement helper).
  • Win32 tests: add parameterized coverage to pin down tab stop width and ensure consistency across GDI vs GDI+ rendering.
  • Examples: add Snippet395 and register it in the snippet index (plus exclude it from GTK/Cocoa snippet classpaths).

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet395.java Adds a Windows-focused manual/visual comparison snippet for GDI vs GDI+ text rendering behaviors (tabs, mnemonics, scripts, mirroring, etc.).
examples/org.eclipse.swt.snippets/Snippets.md Registers Snippet395 in the snippet index with a preview link.
examples/org.eclipse.swt.snippets/.classpath_gtk Excludes Snippet395 from the GTK snippet classpath (platform-specific snippet).
examples/org.eclipse.swt.snippets/.classpath_cocoa Excludes Snippet395 from the Cocoa snippet classpath (platform-specific snippet).
bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/graphics/GC.java Fixes GDI+ tab stop width to match Win32 convention and removes unused helper.
bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/graphics/GCWin32Tests.java Adds parameterized tests to lock in correct tab stop semantics across rendering paths.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread examples/org.eclipse.swt.snippets/Snippets.md Outdated
Comment thread examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet395.java Outdated
@HeikoKlare
HeikoKlare force-pushed the tab-stop-gdip-width branch from a11aa17 to df7a0b1 Compare August 15, 2026 12:12
The GDI+ based text rendering path in GC.drawText() derived the DRAW_TAB
tab stop width from the width of a single space glyph, while the GDI
based path and plain (non-advanced) GDI both use eight times the font's
average character width, which is the convention Win32's own DrawText()
and TabbedTextOut() follow.

Those are two different metrics, not two ways of computing the same one:
in proportional fonts a space is roughly half the average character
width, so tab stops came out about half as wide whenever the GDI+ path
was taken. In monospace fonts the two nearly coincide, which is why the
discrepancy was easy to miss.

The GDI+ path is changed to reuse the GDI text metric already computed
by its caller, so that tab stops are sized consistently no matter which
path renders the text. This restores the behaviour established for the
GDI path in bug 289244 ("GDI+ drawText is not consistent with GDI"),
which the GDI+ fallback path reintroduced in bug 305815 had missed.

Add tests that pin down the tab stop width itself (eight average
character widths), its consistency across both rendering paths, and two
properties that must hold for either engine: consecutive tabs advance by
whole tab stops, and text following a tab starts at the same tab stop
regardless of what precedes the tab. They are measured from a leading
tab, which expands to exactly one tab stop and so is not diluted by the
slightly different glyph advances of the two engines, and cover both
proportional and monospace fonts.

Add Snippet395 as a manual/visual companion to the automated tests: it
renders a series of text properties, one row per property, and offers a
checkbox to switch between plain GDI and GDI+ (advanced) rendering at
runtime, without restarting, to visually compare the resulting output.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
@HeikoKlare
HeikoKlare force-pushed the tab-stop-gdip-width branch from df7a0b1 to b222ad5 Compare August 15, 2026 12:25
@HeikoKlare
HeikoKlare marked this pull request as ready for review August 15, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants