Getting text and boxes positioned accurately category 'KB', language C#, created 06-May-2025, version V1.0, by Luc Pattyn with the assistance of GPT-3, a large language model from Google AI |
License: The author hereby grants you a worldwide, non-exclusive license to use and redistribute the files and the source code in the article in any way you see fit, provided you keep the copyright notice in place; when code modifications are applied, the notice must reflect that. The author retains copyright to the article, you may not republish or otherwise make available the article, in whole or in part, without the prior written consent of the author. Disclaimer: This work is provided |
The Graphics
class in .NET provides several Draw
methods for rendering
various shapes and images on a drawing surface like a Panel
. For applications involving text
display and manipulation, such as text editors or components with syntax highlighting, DrawString
is the primary method for drawing text.
private void DrawSingleString(Graphics g, string text, Font font, PointF location, Brush brush) {
g.DrawString(text, font, brush, location);
}
private void pan_Paint1(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
PointF pt = new PointF(x, 20);
DrawSingleString(e.Graphics, text, font, pt, Brushes.Black);
}
}
However, a problem arises when attempting to precisely position or decorate individual text elements due to an unexpected offset in the rendering.
The discrepancy becomes apparent when attempting to draw adjacent text strings without any gaps.
As illustrated in the figure below, while a single string drawn with DrawString
appears as expected,
attempting to draw the same string immediately after it, using the width reported by Graphics.MeasureString
to determine the starting position, results in a noticeable gap between the two text segments.
private void pan_Paint2(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
PointF pt = new PointF(x, 20);
DrawSingleString(e.Graphics, text, font, pt, Brushes.Black);
// trying to append
float w = g.MeasureString(text, font).Width;
pt = new PointF(x+w, 20);
DrawSingleString(e.Graphics, text, font, pt, Brushes.Red);
}
}
This is because MeasureString
returns a bounding box that includes extra padding around the text,
and DrawString
doesn't start rendering at the box's left edge.
Instead, it seems to indent the text, effectively shifting it to the right.
This seemingly minor offset has notable consequences. Besides the concatenation problem we've already observed, there may be more issues arising in text-centric scenarios.
For instance, if the size returned by MeasureString
is used to define the visual boundaries of a
text element, the rendered text might unexpectedly extend beyond these boundaries.
Similarly, if one attempts to precisely position a cursor or an indicator based on character measurements, the offset can lead to the cursor appearing slightly to the right of the expected location.
As clearly demonstrated in the above figure, directly using the widths obtained from Graphics.MeasureString
to position consecutive text elements results in unwanted gaps. To achieve precise concatenation, as seen in the next figure, we
can utilize a method that calculates a more accurate width for a given text string.
The GetStringWidth
method, shown below, determines the actual width the string will occupy when drawn with
Graphics.DrawString
. While MeasureString
returns the size of a rectangle that is sufficiently wide
to hold the text, it is typically wider than the text itself. In contrast, GetStringWidth
calculates the exact
horizontal advance of the drawing cursor after DrawString
renders the specified string.
public static float GetStringWidth(Graphics g, string text, Font font) {
int repeat = 10;
string textLong = string.Concat(Enumerable.Repeat(text, repeat + 1));
SizeF sizeShort = g.MeasureString(text, font);
SizeF sizeLong = g.MeasureString(textLong, font);
return (sizeLong.Width - sizeShort.Width) / repeat;
}
The following code snippet demonstrates how to draw multiple text strings without the extra padding, using the accurate
width to calculate the starting position of each subsequent string directly within the Paint
event:
private void pan_Paint3(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
PointF pt = new PointF(x, 20);
DrawSingleString(e.Graphics, text, font, pt, Brushes.Black);
float w = GetStringWidth(g, text, font);
pt = new PointF(x + w, 20);
DrawSingleString(e.Graphics, text, font, pt, Brushes.LimeGreen);
}
}
In this example, we first draw the initial text segment. Then, we calculate its accurate width using GetStringWidth
and update the pt
(PointF) to the starting position of the next segment. This ensures that the subsequent text is drawn immediately
after the first, achieving precise concatenation without the gaps caused by the inherent padding in MeasureString
.
However, as will be illustrated further on, while the text concatenation is precise, accurately drawing a bounding box around these individual
text chunks still requires addressing the inherent left padding of DrawString
.
To visualize the bounding box reported by Graphics.MeasureString
, one might naively attempt to draw a rectangle using the
measured width and height, with the top-left corner aligned with the DrawString
call as demonstrated in the pan_Paint4
:
private void pan_Paint4(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
PointF pt = new PointF(x, 20);
DrawSingleString(g, text, font, pt, Brushes.Black);
SizeF size = g.MeasureString(text, font);
g.DrawRectangle(Pens.Blue, pt.X, pt.Y, size.Width, size.Height);
}
}
However this approach fails to produce accurate results when dealing with concatenated text due to DrawString
's inherent left
padding, as shown here:
private void pan_Paint5(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
PointF pt = new PointF(x, 20);
DrawSingleString(e.Graphics, text, font, pt, Brushes.Black);
SizeF size = g.MeasureString(text, font);
g.DrawRectangle(Pens.Red, pt.X, pt.Y, size.Width, size.Height);
float w = GetStringWidth(g, text, font);
pt = new PointF(x + w, 20);
DrawSingleString(e.Graphics, text, font, pt, Brushes.LimeGreen);
}
}
To accurately draw visual boundaries, such as rectangles, around precisely positioned text elements, we need to account for this padding.
The GetStringPaddingEstimate
method provides an estimate of this padding:
public static float GetStringPaddingEstimate(Graphics g, Font font) {
int repeat = 10;
string text = new string('=', 20);
string textLong = string.Concat(Enumerable.Repeat(text, repeat + 1));
SizeF sizeShort = g.MeasureString(text, font);
SizeF sizeLong = g.MeasureString(textLong, font);
float stringWidth = (sizeLong.Width - sizeShort.Width) / repeat;
return (sizeShort.Width - stringWidth) / 2;
}
The following examples demonstrate how to use this padding estimate to draw correctly aligned bounding boxes around the segments of precisely concatenated text.
The first example shows a green bounding box correctly enclosing the first "01234" segment.
private void pan_Paint6(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
float padding = GetStringPaddingEstimate(g, font);
PointF pt1 = new PointF(x, 20);
DrawSingleString(g, text, font, pt1, Brushes.Black);
float w = GetStringWidth(g, text, font);
PointF pt2 = new PointF(pt1.X + w, pt1.Y);
DrawSingleString(g, text, font, pt2, Brushes.Black);
PointF pt3 = new PointF(pt2.X + w, pt2.Y);
DrawSingleString(g, text, font, pt3, Brushes.Black);
// Correct box around the first chunk
SizeF size = g.MeasureString(text, font);
g.DrawRectangle(Pens.LimeGreen, pt1.X + padding, pt1.Y, w, size.Height);
}
}
The second example shows a green bounding box correctly enclosing the middle "01234" segment.
private void pan_Paint7(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
float padding = GetStringPaddingEstimate(g, font);
PointF pt1 = new PointF(x, 20);
DrawSingleString(g, text, font, pt1, Brushes.Black);
float w = GetStringWidth(g, text, font);
PointF pt2 = new PointF(pt1.X + w, pt1.Y);
DrawSingleString(g, text, font, pt2, Brushes.Black);
PointF pt3 = new PointF(pt2.X + w, pt2.Y);
DrawSingleString(g, text, font, pt3, Brushes.Black);
// Correct box around the middle chunk
SizeF size = g.MeasureString(text, font);
g.DrawRectangle(Pens.LimeGreen, pt2.X + padding, pt2.Y, w, size.Height);
}
}
The final example shows a green bounding box correctly enclosing the last "01234" segment, with added padding on the right.
private void pan_Paint8(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
float padding = GetStringPaddingEstimate(g, font);
PointF pt1 = new PointF(x, 20);
DrawSingleString(g, text, font, pt1, Brushes.Black);
float w = GetStringWidth(g, text, font);
PointF pt2 = new PointF(pt1.X + w, pt1.Y);
DrawSingleString(g, text, font, pt2, Brushes.Black);
PointF pt3 = new PointF(pt2.X + w, pt2.Y);
DrawSingleString(g, text, font, pt3, Brushes.Black);
// Correct box around the last chunk
SizeF size = g.MeasureString(text, font);
g.DrawRectangle(Pens.LimeGreen, pt3.X + padding, pt3.Y, w + padding, size.Height);
}
}
This refactoring is optional; it does improve code organization and makes the GetStringWidth
and GetStringPaddingEstimate
methods more discoverable and accessible.
Refactoring them into extension methods allows us to call these methods directly on the Graphics
object, as if they were built-in methods.
First, we define a static class to hold the extension methods:
public static class GraphicsExtensions {
public static float GetStringWidth(this Graphics g, string text, Font font) {
int repeat = 10;
string textLong = string.Concat(Enumerable.Repeat(text, repeat + 1));
SizeF sizeShort = g.MeasureString(text, font);
SizeF sizeLong = g.MeasureString(textLong, font);
return (sizeLong.Width - sizeShort.Width) / repeat;
}
public static float GetStringPaddingEstimate(this Graphics g, Font font) {
int repeat = 10;
string text = new string('=', 20);
string textLong = string.Concat(Enumerable.Repeat(text, repeat + 1));
SizeF sizeShort = g.MeasureString(text, font);
SizeF sizeLong = g.MeasureString(text, font);
float stringWidth = (sizeLong.Width - sizeShort.Width) / repeat;
return (sizeShort.Width - stringWidth) / 2;
}
}
Note the this
keyword before the Graphics g
parameter in both method signatures.
This signifies that these are extension methods for the Graphics
class.
With these extension methods in place, we can now rewrite the pan_Paint8
method (and all other
similar methods) in a more concise and readable manner:
With these extension methods in place, we can now rewrite the pan_Paint8 method (and all other similar methods) in a more concise and readable manner:
private void pan_Paint8_WithExtensions(object sender, PaintEventArgs e) {
Graphics g = e.Graphics;
float x = 10;
string text = "01234";
using (Font font = new Font("Consolas", 24f)) {
float padding = g.GetStringPaddingEstimate(font);
PointF pt1 = new PointF(x, 20);
DrawSingleString(g, text, font, pt1, Brushes.Black);
float w = g.GetStringWidth(text, font);
PointF pt2 = new PointF(pt1.X + w, pt1.Y);
DrawSingleString(g, text, font, pt2, Brushes.Black);
PointF pt3 = new PointF(pt2.X + w, pt2.Y);
DrawSingleString(g, text, font, pt3, Brushes.Black);
// Correct box around the last chunk
SizeF size = g.MeasureString(text, font);
g.DrawRectangle(Pens.Green, pt3.X + padding, pt3.Y, w + padding, size.Height);
}
}
The functionality remains the same, but the code is now cleaner and more expressive.
While the GetStringWidth
and GetStringPaddingEstimate
methods provide a practical
solution for addressing the Graphics.DrawString
offset and accurately positioning
text and bounding boxes, it's important to acknowledge their limitations and
potential areas for further consideration.
First, the GetStringPaddingEstimate
method relies on the assumption that the
left padding applied by DrawString is consistent across all characters within
a given font and size. While this holds true for most monospaced fonts, it might
not be entirely accurate for proportional fonts, where character spacing can vary.
In such cases, the estimated padding might need to be adjusted on a per-character
basis for the highest precision.
Second, the examples provided in this article focus on horizontal concatenation and simple rectangular bounding boxes. Applying these methods to more complex layouts, such as vertical or curved text, or more intricate visual decorations, might require further refinement or adaptation of the padding calculation.
Finally, it's worth noting that the observed offset behavior of
Graphics.DrawString
might vary slightly across different operating
systems, graphics drivers, or .NET Framework versions. While the methods presented
here offer a robust workaround, it's always advisable to perform thorough testing
in the target environment to ensure consistent results.
The inherent rightward offset in Graphics.DrawString
can complicate
precise text layout in .NET, leading to gaps in concatenation and misaligned
bounding boxes.
This article presented GetStringWidth
for accurate text width measurement and
GetStringPaddingEstimate
to estimate DrawString's left padding. Using
these methods allows for finer control over text positioning and the accurate
drawing of bounding boxes, crucial for applications demanding precise text
rendering like editors and code viewers.
While acknowledging certain limitations, these workarounds offer a significant
improvement over Graphics.MeasureString
alone, empowering developers to create
more visually accurate and polished user interfaces.
Perceler |
Copyright © 2012, Luc Pattyn |
Last Modified 04-May-2025 |