Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GraphicsContext does not use alpha channel properly when rendering fonts/text #1840

Open
kdschlosser opened this issue Nov 6, 2020 · 18 comments

Comments

@kdschlosser
Copy link
Contributor

Operating system: MSW 7 x64
wxPython version & source: 4.1.0 msw (phoenix) wxWidgets 3.1.4, pypi
Python version & source: 3.7.5 Stackless 3.7 (tags/v3.7.5-slp:f7925f2a02, Oct 20 2019, 15:28:53) [MSC v.1916 64 bit (AMD64)]

Description of the problem: when rendering text using a GraphicsContext instance the alpha channel causes a change in the brightness but not a change in opacity

Code Example (MSW only)
import ctypes.wintypes
import ctypes
import wx

HANDLE = ctypes.wintypes.HANDLE
LPWSTR = ctypes.wintypes.LPWSTR
HRESULT = ctypes.HRESULT
LONG = ctypes.wintypes.LONG
HWND = ctypes.wintypes.HWND
INT = ctypes.wintypes.INT
HDC = ctypes.wintypes.HDC
HGDIOBJ = ctypes.wintypes.HGDIOBJ
BOOL = ctypes.wintypes.BOOL
DWORD = ctypes.wintypes.DWORD
UBYTE = ctypes.c_ubyte
COLORREF = DWORD

GWL_EXSTYLE = -20
WS_EX_LAYERED = 0x00080000
ULW_ALPHA = 0x00000002
AC_SRC_OVER = 0x00000000
AC_SRC_ALPHA = 0x00000001


def RGB(r, g, b):
    return COLORREF(r | (g << 8) | (b << 16))


class POINT(ctypes.Structure):
    _fields_ = [
        ('x', LONG),
        ('y', LONG)
    ]


class SIZE(ctypes.Structure):
    _fields_ = [
        ('cx', LONG),
        ('cy', LONG)
    ]


class BLENDFUNCTION(ctypes.Structure):
    _fields_ = [
        ('BlendOp', UBYTE),
        ('BlendFlags', UBYTE),
        ('SourceConstantAlpha', UBYTE),
        ('AlphaFormat', UBYTE)
    ]

byref = ctypes.byref

kernel32 = ctypes.windll.Kernel32

GetTempPathW = kernel32.GetTempPathW
GetTempPathW.restype = DWORD
GetTempPathW.argtypes = [DWORD, LPWSTR]


gdi32 = ctypes.windll.Gdi32

# HDC CreateCompatibleDC(
#   HDC hdc
# );
CreateCompatibleDC = gdi32.CreateCompatibleDC
CreateCompatibleDC.restype = HDC

# HGDIOBJ SelectObject(
#   HDC     hdc,
#   HGDIOBJ h
# );
SelectObject = gdi32.SelectObject
SelectObject.restype = HGDIOBJ

# BOOL DeleteDC(
#   HDC hdc
# );

DeleteDC = gdi32.DeleteDC
DeleteDC.restype = BOOL


shell32 = ctypes.windll.Shell32

SHGetFolderPathW = shell32.SHGetFolderPathW
SHGetFolderPathW.restype = HRESULT
SHGetFolderPathW.argtypes = [HWND, INT, HANDLE, DWORD, LPWSTR]


user32 = ctypes.windll.User32

# LONG GetWindowLongW(
#   HWND hWnd,
#   int  nIndex
# )

GetWindowLongW = user32.GetWindowLongW
GetWindowLongW.restype = LONG

# LONG SetWindowLongW(
#   HWND hWnd,
#   int  nIndex,
#   LONG dwNewLong
# );
SetWindowLongW = user32.SetWindowLongW
SetWindowLongW.restype = LONG

# HDC GetDC(
#   HWND hWnd
# );
GetDC = user32.GetDC
GetDC.restype = HDC

# HWND GetDesktopWindow();
GetDesktopWindow = user32.GetDesktopWindow
GetDesktopWindow.restype = HWND

# BOOL UpdateLayeredWindow(
#   HWND          hWnd,
#   HDC           hdcDst,
#   POINT         *pptDst,
#   SIZE          *psize,
#   HDC           hdcSrc,
#   POINT         *pptSrc,
#   COLORREF      crKey,
#   BLENDFUNCTION *pblend,
#   DWORD         dwFlags
# );
UpdateLayeredWindow = user32.UpdateLayeredWindow
UpdateLayeredWindow.restype = BOOL

import math


class AlphaFrame(wx.Frame):
    _xml = None
    _vehicle = None

    def __init__(self, parent=None, size=(800, 800), style=wx.TRANSPARENT_WINDOW):
        wx.Frame.__init__(
            self,
            parent,
            -1,
            style=(
                wx.NO_BORDER |
                wx.FRAME_NO_TASKBAR |
                style
            )
        )

        self.SetSize(size)
        self.Bind(wx.EVT_ERASE_BACKGROUND, lambda x: None)

    def Draw(self):
        width, height = self.GetClientSize()
        bmp = wx.Bitmap.FromRGBA(width, height)
        dc = wx.MemoryDC()
        dc.SelectObject(bmp)

        gc = wx.GraphicsContext.Create(dc)

        gc.SetPen(gc.CreatePen(wx.Pen(wx.Colour(255, 0, 0, 175), 10)))
        # gc.SetBrush(gc.CreateBrush(wx.Brush(wx.Colour(0, 0, 255, 100))))

        gc.DrawRoundedRectangle(20, 20, width - 40, height / 2, 5)

        text = 'This is a transparent frame'
        text_len = len(text)

        text_width, char_height = self.GetFullTextExtent(text)[:2]

        radius = (min(width, height) * 0.90) / 2

        circumference = radius * math.pi * 2
        angle_range = 320.0 - 190.0
        avg_char_width = text_width / text_len
        angle_ratio = angle_range / 360.0
        arc_length = circumference * angle_ratio
        num_steps = arc_length / avg_char_width
        angle_spacing = angle_range / num_steps

        pixels_per_degree = circumference / 360.0

        angle = 190.0 + (angle_spacing / 2)

        font = self.GetFont()
        font.SetStyle(wx.FONTSTYLE_ITALIC)
        font.MakeBold()
        font.SetFractionalPointSize(font.GetFractionalPointSize() * 3)
        gc.SetFont(font, wx.Colour(0, 0, 0, 1))

        center_x = width / 2.0
        center_y = height / 2.0

        for char in list(text):
            char_width = gc.GetFullTextExtent(char)[0]
            angle_offset = (char_width / 2) / pixels_per_degree

            spacing = (angle_range / text_len) + angle_offset

            for _ in range(2):
                radians = math.radians(angle - angle_offset)

                cos = math.cos(radians)
                sin = math.sin(radians)
                x = center_x + (radius * cos)
                y = center_y + (radius * sin)

                text_radians = math.radians(angle - angle_offset + 90.0)

                gc.DrawText(char, x, y, -text_radians)
                angle_offset -= 0.3

            angle += spacing

        angle = 190.0 + (angle_spacing / 2)
        gc.SetFont(font, wx.Colour(0, 255, 0, 200))

        for char in list(text):

            char_width = gc.GetFullTextExtent(char)[0]
            angle_offset = (char_width / 2) / pixels_per_degree

            spacing = (angle_range / text_len) + angle_offset
            radians = math.radians(angle - angle_offset - 1)

            cos = math.cos(radians)
            sin = math.sin(radians)
            x = center_x + (radius * cos)
            y = center_y + (radius * sin)

            text_radians = math.radians(angle - angle_offset + 90.0)

            gc.DrawText(char, x, y, -text_radians)

            angle += spacing

        dc.SelectObject(wx.NullBitmap)
        gc.Destroy()
        del gc

        dc.Destroy()
        del dc

        self.Render(bmp)


    def Render(self, bmp, transparency=255):
        x, y = self.GetPosition()
        hndl = self.GetHandle()
        style = GetWindowLongW(HWND(hndl), INT(GWL_EXSTYLE))
        SetWindowLongW(HWND(hndl), INT(GWL_EXSTYLE), LONG(style | WS_EX_LAYERED))

        hdcDst = GetDC(GetDesktopWindow())
        hdcSrc = CreateCompatibleDC(HDC(hdcDst))

        pptDst = POINT(int(x), int(y))
        psize = SIZE(bmp.GetWidth(), bmp.GetHeight())
        pptSrc = POINT(0, 0)
        crKey = RGB(0, 0, 0)

        pblend = BLENDFUNCTION(AC_SRC_OVER, 0, transparency, AC_SRC_ALPHA)

        SelectObject(HDC(hdcSrc), HGDIOBJ(bmp.GetHandle()))
        UpdateLayeredWindow(
            HWND(hndl),
            HDC(hdcDst),
            byref(pptDst),
            byref(psize),
            HDC(hdcSrc),
            byref(pptSrc),
            crKey,
            byref(pblend),
            DWORD(ULW_ALPHA)
        )

        DeleteDC(HDC(hdcDst))
        DeleteDC(HDC(hdcSrc))


app = wx.App()

frame = AlphaFrame()
frame.Show()
frame.Draw()
app.MainLoop()
@Metallicow
Copy link
Contributor

Metallicow commented Nov 7, 2020

I'm not exactly sure why it is giving me errors about sending a wx.GraphicsFont. It appears you got something screwed up since it is accepting a wx.Font.

  1. I see that you are not using a PaintEvent.
  2. You are using FromRGBA might be causing issues.
  3. You are using a MemoryDC.

... so not exactly sure what the real problem is.

If you modify my TransparentPaintWindow Sample you can indeed see that the GraphicsFont alpha is working correctly.

transparentFontColour

I think somehow something got screwed up, since you are using your ctypes stuff...

@Metallicow
Copy link
Contributor

Metallicow commented Nov 7, 2020

Depending on the results you want, you can do like I did in the previous sample where I used your mswalpha for the transparent frame, and then create a 2nd float on parent frame for the mswalpha affected one and use a region for the text and then set the transparency on the frame. See this sample #1544 (comment)
That I think should allow you to use a PaintEvent and might work like you are expecting... Tho antialiasing may or may not work properly on the edges...

@kdschlosser
Copy link
Contributor Author

You have to use FromRGBA otherwise the bitmap will not have an alpha channel... the MemoryDC is only being used for the purposes of selecting the bitmap. I am creating a GraphicsContext instance from the MemoryDC instance. This allows me to render to the bitmap using the Graphics context.

As you stated, The GraphicsContext.SetFont method should not be allowing a wxFont instance to be passed to it.

The only thing the ctypes stuff does is it draws the bitmap to the screen. That is it. The font is being rendered to the bitmap without an alpha channel and this is how it gets drawn on the screen by the ctypes stuff. The problem is NOT with that portion of the code.

There is a GraphicsContext.CreateFont method and I have tried using this as well but the results are exactly the same.

gc.SetFont(gc.CreateFont(font, wx.Colour(0, 255, 0, 100)))

I would have thought that this would utilize the alpha channel properly but it does not.

@Metallicow
Copy link
Contributor

Metallicow commented Nov 7, 2020

This old mailing list sample converted to phoenix might help.

Shaped Text Frame - Click to expand
import wx

class ShapedText(wx.Frame):
    def __init__(self, text="Scrolling text!"):
        wx.Frame.__init__(self, None, style=
                wx.FRAME_SHAPED | wx.NO_BORDER | wx.FRAME_NO_TASKBAR | wx.STAY_ON_TOP)

        if not self.IsDoubleBuffered():
            self.SetDoubleBuffered(True)  # Reduce flicker.

        # Set up the timer which will move the text and paint the screen.
        self.timer = wx.Timer(self)
        self.Bind(wx.EVT_TIMER, self.OnTimer, source=self.timer)
        self.timer.Start(10)

        # Make sure we are using our custom paint handler.
        self.Bind(wx.EVT_PAINT, self.OnPaint)

        screenwidth = wx.SystemSettings.GetMetric(wx.SYS_SCREEN_X)
        screenheight = wx.SystemSettings.GetMetric(wx.SYS_SCREEN_Y)

        framewidth = int(screenheight * .75)
        frameheight = 100

        framex = int(screenwidth/2 - framewidth/2)
        framey = screenheight - 150

        # Create the bitmap.
        self.textFont = wx.Font(36, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
        self.bmp = wx.Bitmap(framewidth, frameheight)
        self.SetScrolledString(text)
        self.SetWindowShape()

        self.SetPosition((framex, framey))
        self.SetSize((framewidth, self.textHeight+5))

        self.SetTransparent(100)

    def SetScrolledString(self, txt):
        self.scrolledStr = txt
        self.textOffset = 0
        # Calculate the text width.
        memDC = wx.MemoryDC()
        memDC.SetFont(self.textFont)
        self.textWidth, self.textHeight, heightLine = memDC.GetFullMultiLineTextExtent(self.scrolledStr)
        memDC.Destroy()

    def SetWindowShape(self):
        # Use the bitmap's mask to determine the region.
        r = wx.Region(self.bmp)
        self.SetShape(r)

    def OnPaint(self, event):
        # Draw the bitmap on the screen.
        dc = wx.PaintDC(self)
        dc.DrawBitmap(self.bmp, 0, 0, useMask=True)

    def OnTimer(self, event):
        # Draw the text on the bitmap, set a mask, and paint.
        x, y = self.Size
        memDC = wx.MemoryDC(self.bmp)
        memDC.SetPen(wx.WHITE_PEN)
        memDC.DrawRectangle(0, 0, x, y)
        memDC.SetFont(self.textFont)
        memDC.DrawText(self.scrolledStr, x-self.textOffset, 0)
        memDC.Destroy()

        mask = wx.Mask(self.bmp, wx.WHITE)
        self.bmp.SetMask(mask)  # Comment this line to disable transparency.

        self.textOffset += 1

        # Stop the timer when the text has scrolled the entire way across.
        # print(self.textOffset-self.Size[0], self.textWidth)
        if self.textOffset - x == self.textWidth:
            # print(self.textOffset-self.Size[0], textWidth)
            self.timer.Stop()
            self.Destroy()

        self.SetWindowShape()
        self.Refresh()


if __name__ == "__main__":
    app = wx.App()
    frame = ShapedText()
    frame.Show(True)
    app.MainLoop()

@kdschlosser
Copy link
Contributor Author

nope that is not going to support alpha channels

@RobinD42
Copy link
Member

Some implementation details that may help, if you haven't already dug into the relevant C++ code: In the GDI+ GC backend the colour value is used to create a gdi+ SolidBrush, and that is then held by the wxGDIPlusFontData class along with the gdi+ Font object. The Font and Brush are then used when calling the gdi+ DrawString API. There really isn't a lot of complexity there, just a lot of little things working together to tie the two APIs together.

The alpha value is used when creating the brush, but I don't know why it wouldn't be using it. I do know that GDI+ has some holes in its functionality, perhaps drawing text with a partially transparent brush is one of them? Have you tried the Direct2D GC backend? If you're just interested in getting a bitmap out of this then using the Cairo backend would probably be an option as well.

@kdschlosser
Copy link
Contributor Author

@RobinD42

I have also tried to wrap MemoryDC with GCDC and GraphicsContext with GCDC and the results are the same.
Now.. If I create a GraphicsContext instance from a PaintDC it does render properly.

I am guessing I am probably going to have to learn more about Cairo

@RobinD42
Copy link
Member

RobinD42 commented Nov 19, 2020

The GCDC is still going to use the default renderer backend, which on Windows is GDI+, so I wouldn't expect any difference when using the GCDC vs what you were doing before. To use a non-default you need to explicitly create the renderer and then use it to create the GraphicsContext and related objects. For example, something like this:

        if 'wxMSW' in wx.PlatformInfo:
            renderer = wx.GraphicsRenderer.GetDirect2DRenderer() # or GetCairoRenderer
        else:
            renderer = wx.GraphicsRenderer.GetDefaultRenderer()
        ctx = renderer.CreateContext(dc)

If wanted, you can create the renderer once and reuse the same one throughout the application's lifetime.

There is also a module implementing a GraphicsContext-like in Python using Cairo, in wx.lib.Graphics. If you want to use the Cairo API directly then the wx.lib.wxcairo package provides some code to help you use Cairo on wx.DCs, wx.Bitmaps, etc.

@kdschlosser
Copy link
Contributor Author

@RobinD42

If I use the Direct2D renderer as you have shown above. Nothing gets drawn.

@srirams
Copy link

srirams commented Feb 7, 2022

I have the same issue (plus the text looks quite bad).

This is what it looks like (text is drawn on a WS_EX_LAYERED window, with a white window behind it)
Capture2

To fix it, we have the use SetTextRenderingHint to TextRenderingHintAntiAliasGridFit. This fixes both the alpha channel not applying and the the text rendering.
Capture

Unfortunately I couldn't find a way to call SetTextRenderingHint in wx, so had to use a .dll with

extern "C" __declspec(dllexport) int SetTextRenderingHint(void* v, int renderingHint) {
	Gdiplus::Graphics* g = reinterpret_cast<Gdiplus::Graphics*>(v);
	return g->SetTextRenderingHint(static_cast<Gdiplus::TextRenderingHint>(renderingHint));
}

and ctypes in python

        stdc = ctypes.cdll.LoadLibrary(R"PyWxGdiPlus.dll")
        TextRenderingHintAntiAliasGridFit = 3
        r = stdc.SetTextRenderingHint(ctypes.c_void_p(gc.GetNativeContext().__int__()), TextRenderingHintAntiAliasGridFit)

edit: and I don't believe this is a wx bug, same problem happens using gdiplus directly.

@kdschlosser
Copy link
Contributor Author

edit: and I don't believe this is a wx bug, same problem happens using gdiplus directly.

This is good to know.

You should be able to make those function calls without having to make a dll.

something along these lines.

import ctypes

GpStatus = ctypes.HRESULT


gdiplus = ctypes.windll.gdiplus
GdipSetTextRenderingHint = gdiplus.GdipSetTextRenderingHint
GdipSetTextRenderingHint.restype = GpStatus

and then in a paint event

TextRenderingHintAntiAliasGridFit = 3

def OnPaint(self, event):
    # Create paint DC
    dc = wx.PaintDC(self)

    # Create graphics context from it
    gc = wx.GraphicsContext.Create(dc)
    if gc:
        GdipSetTextRenderingHint(ctypes.byref(gc.GetNativeContext()), TextRenderingHintAntiAliasGridFit)

something like that. I have not tested the above but it would be something really similar I would imagine.

If you look at the Windows SDK header file um\gdiplusgraphics.h and at line 199 is the method SetTextRenderingHint

Status SetTextRenderingHint(IN TextRenderingHint newMode)
    {
        return SetStatus(DllExports::GdipSetTextRenderingHint(nativeGraphics,
                                                          newMode));
    }

and all the method is doing is calling the exported GdipSetTextRenderingHint function. So it is a matter of passing the native context to the exported function.

If you look at um\gdiplusflat.h at line 1615 you have the following code.

GpStatus WINGDIPAPI
GdipSetTextRenderingHint(GpGraphics *graphics, TextRenderingHint mode);

In you code example you are getting the handle of the native context and creating the GpGraphics instance in c code using that handle then calling the method which in turn calls the exported function. Since there is already an instance of the native context that can be gotten on the python side of things there should be a way to pass that native context to the exported function directly without having to compile a dll to do it for us,

@kdschlosser
Copy link
Contributor Author

code correction.

TextRenderingHintAntiAliasGridFit = 3

def OnPaint(self, event):
    # Create paint DC
    dc = wx.PaintDC(self)

    # Create graphics context from it
    gc = wx.GraphicsContext.Create(dc)
    if gc:
        GdipSetTextRenderingHint(gc.GetNativeContext(), TextRenderingHintAntiAliasGridFit)

I think that should work. GetNativeContext returns a pointer to GpGraphics I believe.

@srirams
Copy link

srirams commented Feb 7, 2022

Thanks! Didn't realize there was a wrapper api for gdiplus.

I can't seem to get the parameters right for the call however. GetNativeContext() gives a <class 'sip.voidptr'>

so this should work:
GdipSetTextRenderingHint(ctypes.c_void_p(gc.GetNativeContext().__int__()), TextRenderingHintAntiAliasGridFit)

however I'm getting a InvalidParameter result.

This:
GdipSetTextRenderingHint(gc.GetNativeContext(), TextRenderingHintAntiAliasGridFit)

results in:
[ctypes.ArgumentError: argument 1: <class 'TypeError'>: Don't know how to convert parameter 1]

@kdschlosser
Copy link
Contributor Author

OK try wrapping the NativeContex in ctypes.byref()

@kdschlosser
Copy link
Contributor Author

You can also try doing this

p = ctypes.c_void_p(gc.GetNativeContext().__int__())
 GdipSetTextRenderingHint(ctypes.byref(p), TextRenderingHintAntiAliasGridFit)

@kdschlosser
Copy link
Contributor Author

I have another.

Here are 4 ways you can try it

p = gc.GetNativeContext()
 GdipSetTextRenderingHint(ctypes.byref(p), TextRenderingHintAntiAliasGridFit)
p = ctypes.c_void_p(gc.GetNativeContext().__int__())
 GdipSetTextRenderingHint(ctypes.byref(p), TextRenderingHintAntiAliasGridFit)
p = ctypes.cast(gc.GetNativeContext(), ctypes.POINTER(ctypes.c_void_p))
 GdipSetTextRenderingHint(p, TextRenderingHintAntiAliasGridFit)
p = ctypes.cast(gc.GetNativeContext(), ctypes.POINTER(ctypes.c_void_p))
 GdipSetTextRenderingHint(ctypes.byref(p), TextRenderingHintAntiAliasGridFit)

@srirams
Copy link

srirams commented Feb 7, 2022

No, it doesn't work...

GdipSetTextRenderingHint expects a Gdiplus::GpGraphics* and what we have is Gdiplus::Graphics*, and I'm not sure how to convert between the two

@kdschlosser
Copy link
Contributor Author

not sure then.

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

No branches or pull requests

4 participants