Search code examples
c#wpfrendertargetbitmap

Why are all the coordinates and sizes weird?


This code produced the following image.

DrawingVisual visual = new DrawingVisual();
DrawingContext ctx = visual.RenderOpen();

FormattedText txt = new FormattedText("45", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Verdana"), 100, Brushes.Red);
ctx.DrawRectangle(Brushes.White, new Pen(Brushes.White, 10), new System.Windows.Rect(0, 0, 400, 400));
ctx.DrawText(txt, new System.Windows.Point((300 - txt.Width)/2, 10));
ctx.Close();

RenderTargetBitmap bity = new RenderTargetBitmap(300, 300, 40, 40, PixelFormats.Default);
bity.Render(visual);
BitmapFrame frame = BitmapFrame.Create(bity);
JpegBitmapEncoder encoder = new JpegBitmapEncoder();
encoder.Frames.Add(frame);
MemoryStream ms = new MemoryStream();
encoder.Save(ms);

test image

If the bitmap is 300x300, why does the white rectangle (0, 0, 400, 400) take up only a small portion of it? Why isn't the text centered?

I'm not even sure what terms to Google. I seek wisdom.


Solution

  • NOTE: adding this after bounty offered in addition to my original answer

    For starters, there is no need for the 400x400 background rectangle because you're only rendering a 300x300 bitmap, so here's the first change:

    ctx.DrawRectangle(Brushes.White, new Pen(Brushes.White, 10), new System.Windows.Rect(0, 0, 300, 300));
    

    With this change in place, the output will be exactly the same, but it simplifies the explanation.

    Where possible and logical, WPF uses DIPs (device-independent pixels) as units of measure rather than pixels. When you do this:

    <Rectangle Width="100" Height="100"/>
    

    You won't necessarily end up with a Rectangle that is 100x100 physical pixels. If your device has more (or fewer) than 96 pixels per physical inch, then you will end up with a different number of physical pixels. 96 pixels per inch is sort of an industry standard, I guess. Modern devices such as smart phones and tablets have far more pixels per physical inch. If WPF used physical pixels as its unit of measure, then the afore-mentioned Rectangle would render smaller on such a device.

    Now, in order to render a bitmap (or JPEG, PNG, GIF, whatever), device-depdendent pixels must be used because it's a rasterized format (not a vector format). And that's what you're specifying when you invoke the RenderTargetBitmap constructor. You're telling it you want the resultant bitmap to be 300x300 physical pixels with a DPI of 40. Since the source has a DPI of 96 (assuming your monitor is industry standard) and the target has a DPI of 40, it must shrink the source to fit the target. Hence, the effect is a shrunken image within your rendered bitmap.

    Now what you really want to do is ensure the source DPI and target DPIs match. It's not as simple as hard-coding 96 because, as discussed, that's just a standard - the source could actually have more or less DPI than that. Unfortunately, WPF doesn't provide a nice way of getting the DPI, which is ridiculous in my opinion. However, you can do a bit of p/invoke to get it:

    public int Dpi
    {
        get
        {
            if (this.dpi == 0)
            {
                var desktopHwnd = new HandleRef(null, IntPtr.Zero);
                var desktopDC = new HandleRef(null, SafeNativeMethods.GetDC(desktopHwnd));
    
                this.dpi = SafeNativeMethods.GetDeviceCaps(desktopDC, 88 /*LOGPIXELSX*/);
    
                if (SafeNativeMethods.ReleaseDC(desktopHwnd, desktopDC) != 1 /* OK */)
                {
                    // log error
                }
            }
    
            return this.dpi;
        }
    }
    
    private static class SafeNativeMethods
    {
        [DllImport("User32.dll")]
        public static extern IntPtr GetDC(HandleRef hWnd);
    
        [DllImport("User32.dll")]
        public static extern int ReleaseDC(HandleRef hWnd, HandleRef hDC);
    
        [DllImport("GDI32.dll")]
        public static extern int GetDeviceCaps(HandleRef hDC, int nIndex);
    }
    

    So now you can change the relevant line of code to this:

    RenderTargetBitmap bity = new RenderTargetBitmap(300, 300, this.Dpi, this.Dpi, PixelFormats.Default);
    

    And it will work regardless of the device on which you're running. You'll always end up with a bitmap that is 300x300 physical pixels, and the source will always fill it exactly.