Matching System.Drawing
Egami can draw the way System.Drawing does on Windows: UI text as Graphics.DrawString (GDI+) and TextRenderer (GDI) draw
it, and shapes, gradients and scaled images as Graphics does, for images that replace ones made with them, such as buttons in
emails. Every setting is opt-in, and each comes from measurements of GDI and GDI+ on Windows; Fonts and text explains
the text settings. Egami.SystemDrawing applies all of them for you.
| GDI+ | Egami |
|---|---|
TextRenderingHint.ClearTypeGridFit |
Font.WithGdiPlusLook() with CanvasOptions.GdiPlus |
PixelOffsetMode.None, Default, HighSpeed / Half, HighQuality |
CanvasOptions.PixelOffset.None / Half (the default) |
SmoothingMode.AntiAlias, HighQuality |
CanvasOptions.Antialiasing.GdiPlus; SmoothingMode.None is Paint.Antialias = false |
LinearGradientBrush.GammaCorrection |
GradientInterpolation.GdiPlusGamma |
PathGradientBrush |
a Mesh fanned from the brush's CenterPoint |
InterpolationMode |
the ResampleFilter.GdiPlus* members, per draw call (see Image scaling) |
CompositingQuality.HighQuality, GammaCorrected |
CanvasOptions.ColorSpace = ColorSpace.Linear |
CanvasOptions.GdiPlus sets GDI+'s defaults for text, pixel offset and antialiasing. It does not choose a resample
filter: that belongs to each DrawImage or ImagePaint call's SamplingOptions, so pass ResampleFilter.GdiPlusBilinear there for
GDI+'s default InterpolationMode.
GDI+ ClearTypeGridFit
Font.WithGdiPlusLook() and CanvasOptions.GdiPlus together give GDI+'s ClearTypeGridFit look.
- The font's TrueType instructions run for GDI+, with Microsoft's ClearType compatibility rules (
FontHinting.Full,TrueTypeTarget.GdiPlus). - Advances are grid-fitted (
FontMetrics.GridFitted), asDrawStringlays out text with the defaultStringFormat, and glyph origins sit on whole pixels. GDI+ rounds an advance up from about 0.458 of a pixel rather than 0.5, and the GDI+ target does the same. - With
StringFormat.GenericTypographicGDI+ pulls lines back toward their design width. UseFontMetrics.GridFittedToDesignfor that: a run that hinted advances would make more than 2.1 pixels wider than designed keeps its design advances, rounded up to sixteenths of a pixel. - LCD edging uses GDI+'s filter (
LcdFilter.Gdi) and sampling (LcdSampling.Gdi): six on-or-off samples per pixel across the stripes, on each pixel's centre line, so every channel's coverage is a whole number of sixths. - The blend uses GDI+'s gamma:
TextGamma1.4, which is 1 +Graphics.TextContrast/ 10 at the default contrast of 4. WithLcdSampling.Gdiit goes through GDI+'s 8-bit gamma tables, as measured: the drawn values equal GDI+'s for every coverage on every gray background tested.
// Text that looks like System.Drawing's Graphics.DrawString with TextRenderingHint.ClearTypeGridFit: the font's own hints
// and grid-fitted advances, GDI+'s ClearType filter, sampling and gamma.
Font label = new Font(face, 16).WithGdiPlusLook();
using (RasterCanvas<Rgb8> canvas = button.CreateCanvas(CanvasOptions.GdiPlus))
{
float width = label.MeasureText("Start the course").Advance;
canvas.DrawText("Start the course", new Vector2(MathF.Round((200 - width) / 2), 26), label, Rgba8.White);
}
// A different Graphics.TextContrast (0 to 12) is a different text gamma: 1 + contrast / 10.
CanvasOptions contrast8 = CanvasOptions.GdiPlus with { TextGamma = 1 + (8 / 10f) };
For a white Trebuchet MS label at 16 pixels on a black or a #0078d4 button, the result is within 0.5% of GDI+'s brightness. The
mean per-pixel difference is 0.67 levels out of 255 on black and 0.38 on blue, against 9.5 and 5.4 for Egami's default text.
Some fonts carry black-and-white bitmaps for small sizes, such as Calibri at 12, 13, 15, 16, 17 and 19 pixels, and GDI+ draws
those instead of the outlines. Font.WithGdiPlusLook() turns on Font.EmbeddedBitmaps, so Egami does too: Calibri's glyphs at
those sizes equal GDI+'s pixel for pixel.
Placing text in a box
DrawString with a layout rectangle and StringFormat.LineAlignment places a line by GDI+'s own rule, which can differ from
centring on the font's ascent and descent by more than a pixel. GdiPlusText.Baseline gives the baseline it uses, for one line
in a box:
- The line is a cell tall: the ascent and descent
FontFamily.GetCellAscentandGetCellDescentreport (the OS/2 Windows metrics, or the typographic ones when the font sets USE_TYPO_METRICS), plus 1/8 em with the defaultStringFormat. GDI+'s line spacing does not count for one line. LineAlignmentputs that cell at the box's top, middle or bottom.- The baseline is
Font.GdiPlusAscentbelow the cell's top. WithClearTypeGridFitthe top is first rounded to a whole pixel, and the ascent is whole pixels too: the cell ascent rounded, or the font's VDMX maximum at the size when that is larger. For Trebuchet MS at 16 pixels that is 17 pixels, against a cell ascent of 15.02.
// Centred in a box as DrawString with StringFormat { Alignment = Center, LineAlignment = Center } puts it.
Font label = new Font(face, 16).WithGdiPlusLook();
var box = new RectF(0, 0, 200, 36);
float baseline = GdiPlusText.Baseline(label, box, VerticalAlign.Center);
using (RasterCanvas<Rgb8> canvas = button.CreateCanvas(CanvasOptions.GdiPlus))
{
float width = label.MeasureText("Start the course").Advance;
canvas.DrawText("Start the course", new Vector2(MathF.Round((box.Width - width) / 2), baseline), label, Rgba8.White);
}
// The ascent it used: whole pixels for a grid-fitted font.
float ascent = label.GdiPlusAscent;
Against GDI+, 9,720 of 9,720 baselines are equal (10 fonts, 11 to 24 pixels, nine box heights at three fractional positions,
both formats and all three alignments), and the button above needs no vertical shift to match. Egami's own
Canvas.DrawText with a rectangle and VerticalAlign keeps its own centring.
Shapes
Two canvas settings match what Graphics does with shapes at its defaults, SmoothingMode.AntiAlias and
PixelOffsetMode.None. CanvasOptions.GdiPlus sets both.
PixelOffset.Noneputs whole coordinates on pixel centres, as GDI+'sNone,DefaultandHighSpeedoffset modes do. Paths, their paints and clips move half a pixel right and down. Text moves only when it is unhinted, just as GDI+ moves itsAntiAliastext but not its grid-fitted text. Images go onto GDI+'s grid, where source and destination pixel centres line up: an image scaled by s moves (1 − s) / 2 pixels, and one drawn 1:1 does not move.PixelOffset.Half, Egami's default, is GDI+'sHalf.Antialiasing.GdiPlussamples likeAntiAliasandHighQuality, which are the same mode. Each pixel has 8 samples across and 4 down, and a sample on a left or top edge is inside. Pens of 1.5 device pixels or less draw as GDI+'s thin lines: one pixel thick across the line's major axis, at full strength, with pointed ends and no caps.
// Shapes as System.Drawing draws them at its defaults (SmoothingMode.AntiAlias, PixelOffsetMode.None): whole coordinates on
// pixel centres, GDI+'s 8 by 4 samples a pixel, and pens up to 1.5 px drawn as its one-pixel thin lines.
// CanvasOptions.GdiPlus sets both too.
var options = new CanvasOptions { PixelOffset = PixelOffset.None, Antialiasing = Antialiasing.GdiPlus };
using (RasterCanvas<Rgb8> canvas = raster.CreateCanvas(options))
{
canvas.FillOval(new RectF(4, 4, 20, 20), new SolidPaint(new Rgba8(0, 90, 200, 255)));
canvas.DrawLine(new Vector2(30, 6), new Vector2(60, 26), new Stroke(0.5f), new SolidPaint(Rgba8.Black));
}
On rectangles, polygons, ovals and lines at fractional positions, the mean difference from GDI+ is 0.12 levels, down from 1.94 with Egami's closest settings before. Rectangles and polygons match to the level. Ovals and round caps still differ a little, because GDI+ flattens curves its own way.
Gradients
Egami's gradients already match GDI+'s within a fraction of a level when they mix in the stored values, as LinearGradientBrush
does by default. Its Blend, InterpolationColors and WrapMode (Tile is TileMode.Repeat, TileFlipX is Mirror) map to
stops and tile modes.
GammaCorrection = trueisGradientInterpolation.GdiPlusGamma: 16 steps between each pair of colors, each mixed in gamma-2.2 light and rounded to 8 bits.- A
PathGradientBrushis a fan of triangles from itsCenterPointto the flattened path, with the surround colors at the path's points and the center color at the center: draw it as aMesh. GDI+'s defaultCenterPointis the mean of the path's points, which for an ellipse is not its middle.
// LinearGradientBrush with GammaCorrection = true: GDI+'s 16 gamma-2.2 steps between the colors.
var brush = new LinearGradient(new Vector2(0, 0), new Vector2(256, 0), new GradientStop(0, Rgba8.Black), new GradientStop(1, Rgba8.White))
{
Interpolation = GradientInterpolation.GdiPlusGamma,
};
using (RasterCanvas<Rgb8> canvas = raster.CreateCanvas())
{
canvas.FillRect(new RectF(0, 0, 256, 16), brush);
}
Image scaling
Graphics.DrawImage scales with its InterpolationMode. Each mode has a ResampleFilter of its own, recovered from
DrawImage's impulse responses:
| InterpolationMode | ResampleFilter |
|---|---|
| Default, Low, Bilinear | GdiPlusBilinear: two pixels at every scale, so shrinking skips pixels |
| Bicubic | GdiPlusBicubic: Keys cubic (a = −0.5), four pixels at every scale |
| HighQualityBilinear | GdiPlusHighQualityBilinear: the tent, widened when shrinking, convolved with a one-pixel box |
| High, HighQualityBicubic | GdiPlusHighQualityBicubic: Keys cubic (a = −1), widened when shrinking, convolved with a one-pixel box |
| NearestNeighbor | Nearest |
With PixelOffsetMode.Half, GDI+ uses Egami's grid. With its default offset, source and destination pixel centres sit on whole
coordinates, so a scale s moves the image by (1 − s) / 2 destination pixels against Egami's grid; CanvasOptions.PixelOffset.None
does the same. Over both offsets, DrawImage and Egami's canvas are 0.38 levels apart on average, down from 2.53 with Egami's
closest filters before.
CompositingQuality.HighQuality and GammaCorrected blend in linear light, which is CanvasOptions.ColorSpace = ColorSpace.Linear
(within 0.1 levels of GDI+). The other qualities blend the stored values, Egami's default.
Metafiles
Egami.Metafiles' EMF+ player follows the records' interpolation modes with the GdiPlus* filters and their gamma-corrected
gradients with GdiPlusGamma. Smoothing and compositing apply to the whole canvas, so set them for the playback through
MetafileRenderOptions.CanvasOptions, for example CanvasOptions.GdiPlus. With PixelOffset.None there, the player
leaves the pixel offset to the canvas.
GDI ClearType
For TextRenderer or GDI's ExtTextOut:
- Set
Font.HintingTarget = TrueTypeTarget.GdiandFont.Metrics = FontMetrics.Compatible. GDI's compatible widths equalGetCharABCWidthsfor 9,688 of 9,690 glyph sizes measured. - Set
TextGammato the system's ClearType contrast divided by 1000. That is 1.2 when it is 1200, and Windows' default is 1.4.
DrawString's layout
Graphics.DrawString at a point, with the default StringFormat, starts the text 1/6 em right of the point, rounded to a whole
pixel. The first baseline sits the font's ascent below the point. MeasureString adds about 0.275 em on each side and 1/8 em to
the height. StringFormat.GenericTypographic drops all of this padding. These rules belong to System.Drawing's layout, not to
the text, so Egami keeps them in a sample rather than in the library:
// Graphics.DrawString(text, font, brush, x, y) with the default StringFormat starts the text 1/6 em right of the point (to a
// whole pixel) with the first baseline the font's ascent below it; StringFormat.GenericTypographic drops that padding.
// MeasureString with the default format adds about 0.275 em on each side and 1/8 em to the height (measured on Windows).
public static Vector2 DrawStringOrigin(Font font, Vector2 point, bool typographic = false)
=> new(point.X + (typographic ? 0 : MathF.Round(font.Size / 6)), point.Y + font.Extents.Ascent);
public static (float Width, float Height) MeasureStringSize(Font font, string text, bool typographic = false)
{
TextMeasure m = font.MeasureText(text);
return typographic ? (m.Advance, m.Height) : (m.Advance + (0.55f * font.Size), m.Height + (font.Size / 8));
}