Monday, 28 January 2019

VBA - Accessibility Meta Code

So I had some code to generate an illustrative tree of Excel window handles as a by-product for some code to get a pointer to Excel via its window handle using the Accessibility API. On StackOverflow, a questioner wanted something similar for Word. One can also imagine a future question wanted similar for PowerPoint. Code to reach all those three applications via their windows handles are in the previous post. However, I wanted to share the meta code because yet another use case may turn up in the future.

Option Explicit
Option Private Module

'******************************************************************************************************
'* Windows API function to traverse windows and grab attributes
'******************************************************************************************************
Private Declare Function FindWindowEx Lib "user32" Alias "FindWindowExA" _
(ByVal hWnd1 As Long, ByVal hwnd2 As Long, ByVal lpsz1 As String, ByVal lpsz2 As String) As Long

Private Declare Function GetWindowText Lib "user32" Alias "GetWindowTextA" _
(ByVal hwnd As Long, ByVal lpString As String, ByVal cch As Long) As Long

Private Declare Function GetClassName Lib "user32" Alias "GetClassNameA" _
(ByVal hwnd As Long, ByVal lpClassName As String, ByVal nMaxCount As Long) As Long

'******************************************************************************************************
'* Windows API function to get IDispatch COM pointer via Accessibility interface
'******************************************************************************************************
Private Declare PtrSafe Function AccessibleObjectFromWindow Lib "oleacc.dll" ( _
  ByVal hwnd As LongPtr, ByVal dwId As Long, ByRef riid As Any, ByRef ppvObject As Object) As Long

'******************************************************************************************************
'* Entry Points
'******************************************************************************************************
Private Sub TestGetAccessibleOfficeObjects()
    Dim shtExcelReport As Excel.Worksheet
    Set shtExcelReport = ThisWorkbook.Worksheets.Item("Sheet2")
    Dim shtWordReport As Excel.Worksheet
    Set shtWordReport = ThisWorkbook.Worksheets.Item("Sheet3")
    
    Dim shtPowerPointReport As Excel.Worksheet
    Set shtPowerPointReport = ThisWorkbook.Worksheets.Item("Sheet4")
    
    'Dim shtOneNoteReport As Excel.Worksheet
    'Set shtOneNoteReport = ThisWorkbook.Worksheets.Item("Sheet5")
    
    
    GetAccessibleOfficeObjects shtExcelReport, "Excel"
    GetAccessibleOfficeObjects shtWordReport, "Word"
    GetAccessibleOfficeObjects shtPowerPointReport, "PowerPoint"
    'OneNote not accessible GetAccessibleOfficeObjects shtOneNoteReport, "OneNote"

End Sub

'******************************************************************************************************
'******************************************************************************************************
'* Main Controlling code
'******************************************************************************************************
'******************************************************************************************************
Private Function GetAccessibleOfficeObjects(ByVal shOutputSheet As Excel.Worksheet, ByVal sApplication As String)

    Dim sApplicationRootClass As String
    
    sApplicationRootClass = VBA.IIf(sApplication = "Excel", "XLMAIN", VBA.IIf(sApplication = "Word", "OpusApp", "Not yet implemented"))
    sApplicationRootClass = VBA.Switch(sApplication = "Excel", "XLMAIN", sApplication = "Word", "OpusApp", _
                sApplication = "PowerPoint", "PPTFrameClass", sApplication = "OneNote", "Framework::CFrame")
    
    Dim xmlHandlesReport As MSXML2.DOMDocument60
    Set xmlHandlesReport = GetWindows(sApplicationRootClass, True, shOutputSheet)
    
    WriteToSheetStart shOutputSheet, xmlHandlesReport.DocumentElement
    
    QueryInterfaceForAccessibility xmlHandlesReport
    
    UpdateCastableHandles xmlHandlesReport
     
    WriteCodeToGetAppObject xmlHandlesReport, sApplication, sApplicationRootClass
End Function

Private Function WriteCodeToGetAppObject(ByVal xmlHandlesReport As MSXML2.DOMDocument60, _
            ByVal sApplication As String, ByVal sApplicationRootClass As String)

    Dim xmlAppNode As MSXML2.IXMLDOMElement
    Set xmlAppNode = xmlHandlesReport.SelectSingleNode("//*[@obj_Application_Name]")
        
    '******************************************************************************************************
    '* Traverse up the tree to the root node, recording the window class
    '******************************************************************************************************
    Dim dicPathUp As Scripting.Dictionary
    Set dicPathUp = New Scripting.Dictionary
    
    Dim xmlTraverser As MSXML2.IXMLDOMElement
    Set xmlTraverser = xmlAppNode
    
    While Not xmlTraverser Is xmlHandlesReport.DocumentElement
        dicPathUp.Add xmlTraverser.getAttribute("class"), 0
        Set xmlTraverser = xmlTraverser.ParentNode
    Wend
    
    '******************************************************************************************************
    '* Reverse the path
    '******************************************************************************************************
    Dim dicPathDown As Scripting.Dictionary
    Set dicPathDown = New Scripting.Dictionary
    Dim lLoop As Long, lLoop2 As Long
    For lLoop = dicPathUp.Count - 1 To 0 Step -1
        
        dicPathDown.Add dicPathUp.Keys()(lLoop), 0
    Next lLoop
    
    '******************************************************************************************************
    '* Now write the code
    '******************************************************************************************************
    Dim dicCode As Scripting.Dictionary
    Set dicCode = New Scripting.Dictionary
    
    Dim sFuncName As String
    sFuncName = "Get" & sApplication & "AppObjectByIAccessible"
    
    dicCode.Add dicCode.Count, "Private Declare PtrSafe Function AccessibleObjectFromWindow Lib ""oleacc.dll"" ( _"
    dicCode.Add dicCode.Count, "    ByVal hwnd As LongPtr, ByVal dwId As Long, ByRef riid As Any, ByRef ppvObject As Object) As Long" & vbNewLine

    dicCode.Add dicCode.Count, "Private Declare Function FindWindowEx Lib ""user32"" Alias ""FindWindowExA"" _"
    dicCode.Add dicCode.Count, "    (ByVal hWnd1 As Long, ByVal hwnd2 As Long, ByVal lpsz1 As String, ByVal lpsz2 As String) As Long" & vbNewLine
    
    dicCode.Add dicCode.Count, "Public Function " & sFuncName & "() As Object"
    dicCode.Add dicCode.Count, "    Dim guid(0 To 3) As Long, acc As Object"
    dicCode.Add dicCode.Count, "    guid(0) = &H20400: guid(1) = &H0: guid(2) = &HC0: guid(3) = &H46000000"
    dicCode.Add dicCode.Count, ""
    dicCode.Add dicCode.Count, "    Dim alHandles(0 to " & dicPathUp.Count & ") As Long"
    
    dicCode.Add dicCode.Count, "    alHandles(0) = FindWindowEx(0, 0, """ & sApplicationRootClass & """, vbNullString)"
    For lLoop = 0 To dicPathDown.Count - 1
        Dim sInputHandle As String
        If lLoop = 0 Then sInputHandle = "lTopHandle" Else sInputHandle = "alHandles(" & lLoop & ")"
        sInputHandle = "alHandles(" & lLoop & ")"
        
        Dim sClass As String
        sClass = dicPathDown.Keys()(lLoop)
        dicCode.Add dicCode.Count, "    alHandles(" & lLoop + 1 & ") = FindWindowEx(" & sInputHandle & ",0,""" & sClass & """,vbNullString) "
    Next
    
    dicCode.Add dicCode.Count, "    If AccessibleObjectFromWindow(alHandles(" & dicPathDown.Count & "), -16&, guid(0), acc) = 0 Then"
    dicCode.Add dicCode.Count, "        Set " & sFuncName & " = acc.Application"
    dicCode.Add dicCode.Count, "    End If"
    dicCode.Add dicCode.Count, "End Function " & vbNewLine
    dicCode.Add dicCode.Count, "Sub Test" & sFuncName
    dicCode.Add dicCode.Count, "    Dim obj As Object"
    dicCode.Add dicCode.Count, "    Set obj = " & sFuncName & "()"
    dicCode.Add dicCode.Count, "    Debug.Print obj.Name"
    dicCode.Add dicCode.Count, "End Sub "
    
    Debug.Print VBA.Join(dicCode.Items, vbNewLine)
End Function

'******************************************************************************************************
'* Global functions
'******************************************************************************************************
Public Function PadHex(ByVal l32Bit As Long) As String
    PadHex = Right$("00000000" & Hex$(l32Bit), 8)
End Function

'******************************************************************************************************
'******************************************************************************************************
'* Windows Recursion Code
'******************************************************************************************************
'******************************************************************************************************
Private Function GetWindows(ByVal sRootClass As String, ByVal bWriteToSheet As Boolean, ByVal ws As Excel.Worksheet) As MSXML2.DOMDocument60
    Debug.Assert Not ws Is Nothing
    
    Dim xmlDocument As MSXML2.DOMDocument60
    Set xmlDocument = New MSXML2.DOMDocument60
    xmlDocument.LoadXML ""
    Debug.Assert xmlDocument.parseError = 0
    
    Dim xmlRoot As MSXML2.IXMLDOMElement
    Set xmlRoot = xmlDocument.DocumentElement
     
    Dim hwnd As Long
    hwnd = FindWindowEx(0, 0, sRootClass, vbNullString)
    
    AdornAttributes hwnd, xmlRoot
    GetWinInfo hwnd&, xmlRoot
    
    Set GetWindows = xmlDocument
End Function

Private Function AdornAttributes(ByVal hwnd As Long, xmlEle As MSXML2.IXMLDOMElement)
    If Not xmlEle Is Nothing Then
        Call xmlEle.setAttribute("hWndHex", PadHex(hwnd))
        Call xmlEle.setAttribute("hWnd", hwnd)
        Call xmlEle.setAttribute("title", GetTitle(hwnd))
        Call xmlEle.setAttribute("class", GetClassName2(hwnd))
    End If
End Function

Private Sub GetWinInfo(hParent As Long, ByVal xmlEle As MSXML2.IXMLDOMElement)
    '* Sub to recursively obtain window handles, classes and text
    '* given a parent window to search
    '* Based on code written by Mark Rowlinson - www.markrowlinson.co.uk - The Programming Emporium
    '* modified to write to Xml document instead of a worksheet
    Dim hwnd As Long
    
    hwnd = FindWindowEx(hParent, 0&, vbNullString, vbNullString)
    While hwnd <> 0
        
        Dim xmlChild As MSXML2.IXMLDOMElement
        Set xmlChild = xmlEle.OwnerDocument.createElement("Window")
        xmlEle.appendChild xmlChild
        
        AdornAttributes hwnd, xmlChild
        
        GetWinInfo hwnd, xmlChild
        
        hwnd = FindWindowEx(hParent, hwnd, vbNullString, vbNullString)
    Wend
End Sub

Private Function GetTitle(ByVal hwnd As Long, Optional ByVal bReportNa As Boolean) As String
    Dim lngRet As Long
    Dim strText As String
    
    strText = String$(100, Chr$(0))
    lngRet = GetWindowText(hwnd, strText, 100)
    If lngRet > 0 Then
        GetTitle = Left$(strText, lngRet)
    Else
        If bReportNa Then
            GetTitle = "N/A"
        End If
    End If
End Function

Private Function GetClassName2(ByVal hwnd As Long)
    Dim lngRet As Long
    Dim strText As String
    
    strText = String$(100, Chr$(0))
    lngRet = GetClassName(hwnd, strText, 100)
    GetClassName2 = Left$(strText, lngRet)
End Function

'******************************************************************************************************
'******************************************************************************************************
'* Write Report To Worksheet code
'******************************************************************************************************
'******************************************************************************************************
Private Function WriteToSheetStart(ByVal ws As Excel.Worksheet, ByVal xmlEle As MSXML2.IXMLDOMElement)
    ws.Cells.Clear
    ws.Activate
    ws.Cells(1, 1).Activate
    WriteToSheet ws, 1, 1, xmlEle
End Function

Private Function WriteToSheet(ByVal ws As Excel.Worksheet, ByVal lRow As Long, ByVal lColumn As Long, ByVal xmlEle As MSXML2.IXMLDOMElement) As Long
    
    Dim hwnd As Long
    hwnd = xmlEle.getAttribute("hWnd")
    
    
    Call xmlEle.setAttribute("rangeAnchor", "'" & ws.Name & "'!" & ws.Cells(lRow, lColumn).AddressLocal)
    Dim rngTest As Excel.Range
    Set rngTest = Application.Range(xmlEle.getAttribute("rangeAnchor"))
    Debug.Assert Not rngTest Is Nothing
    
    
    ws.Cells(lRow, lColumn).Formula = "'" & PadHex(hwnd) & " (" & CStr(hwnd) & ")"
    ws.Cells(lRow, lColumn + 1).Value = xmlEle.getAttribute("title")
    ws.Cells(lRow, lColumn + 2).Value = xmlEle.getAttribute("class")
    
    If xmlEle.ChildNodes.Length > 0 Then
    
        Dim xmlChildWindows As MSXML2.IXMLDOMNodeList
        Set xmlChildWindows = xmlEle.ChildNodes
        
        Dim lLength As Long
        lLength = xmlEle.ChildNodes.Length
        
        Dim lLoop As Long
        For lLoop = 0 To lLength - 1
            
            Dim xmlChild As MSXML2.IXMLDOMElement
            Set xmlChild = xmlEle.ChildNodes.Item(lLoop)
    
            lRow = WriteToSheet(ws, lRow + 1, lColumn + 3, xmlChild)
            
        Next lLoop
    End If
    
    WriteToSheet = lRow
End Function

Private Sub UpdateCastableHandles(ByVal xmlHandlesReport As MSXML2.DOMDocument60)
    Dim rngCastable As Excel.Range
    
    Dim xmlCastable As MSXML2.IXMLDOMNodeList
    Set xmlCastable = xmlHandlesReport.SelectNodes("//*[@ComInterface]")
    Debug.Assert xmlCastable.Length > 0
    
    Dim xmlCastableLoop As MSXML2.IXMLDOMElement
    For Each xmlCastableLoop In xmlCastable
        Set rngCastable = Application.Range(xmlCastableLoop.getAttribute("rangeAnchor"))
        rngCastable.Resize(1, 3).Interior.Color = rgbYellow
        
        rngCastable.EntireColumn.AutoFit
        rngCastable.Offset(0, 1).EntireColumn.AutoFit
        rngCastable.Offset(0, 2).EntireColumn.AutoFit
        
'        Dim rngAutoFitLoop As Excel.Range
'        For Each rngAutoFitLoop In rngCastable.Cells
'            rngAutoFitLoop.EntireColumn.AutoFit
'        Next
        
        rngCastable.AddComment
        rngCastable.Comment.Visible = True
        rngCastable.Comment.Text Text:=xmlCastableLoop.getAttribute("ComInterface")
        
        Dim shp As Shape
        Set shp = rngCastable.Comment.Shape
        
        shp.ScaleWidth 1.07, msoFalse, msoScaleFromTopLeft
        shp.ScaleHeight 0.29, msoFalse, msoScaleFromTopLeft
        shp.IncrementTop -20
        
    Next
    
    Dim xmlApp As MSXML2.IXMLDOMElement
    Set xmlApp = xmlHandlesReport.SelectSingleNode("//*[@obj_Application_Name]")
    
    Debug.Assert Not xmlApp Is Nothing

    Dim rngApp As Excel.Range
    Set rngApp = Application.Range(xmlApp.getAttribute("rangeAnchor"))
    rngApp.Resize(1, 3).Interior.Color = rgbLightGreen
    Debug.Assert Not rngApp Is Nothing
End Sub

'******************************************************************************************************
'******************************************************************************************************
'* Accessibility QueryInterface code
'******************************************************************************************************
'******************************************************************************************************
Private Sub QueryInterfaceForAccessibility(ByVal xmlHandlesReport As MSXML2.DOMDocument60)

    Dim dicHandles As Scripting.Dictionary
    Set dicHandles = AllHandles(xmlHandlesReport.DocumentElement)

    Dim guid(0 To 3) As Long, acc As Object
    guid(0) = &H20400
    guid(1) = &H0
    guid(2) = &HC0
    guid(3) = &H46000000
    
    Dim vHandleLoop As Variant
    For Each vHandleLoop In dicHandles.Keys
        
        Dim xmlNode As MSXML2.IXMLDOMElement
        Set xmlNode = xmlHandlesReport.SelectSingleNode("//Window[@hWnd='" & vHandleLoop & "']")
        
        If Not xmlNode Is Nothing Then
        
            Set acc = Nothing
            
            DoEvents
            Dim lReturnIDisp As Long
            lReturnIDisp = AccessibleObjectFromWindow(vHandleLoop, -16&, guid(0), acc)
            DoEvents
    
            If lReturnIDisp = 0 Then
                Call xmlNode.setAttribute("ComInterface", TypeName(acc))
                
                'Debug.Print PadHex(vHandleLoop), TypeName(acc)
                
                Dim sAppName As String
                sAppName = ""
                
                Dim objApp As Object
                Set objApp = Nothing
                On Error Resume Next
                sAppName = acc.Application.Name
                On Error GoTo 0
                If Len(sAppName) > 0 Then
                    Call xmlNode.setAttribute("obj_Application_Name", sAppName)
                End If
            End If
           
        End If
        Set acc = Nothing
    Next

End Sub

Private Function AllHandles(ByVal xmlEle As MSXML2.IXMLDOMElement) As Scripting.Dictionary
    
    Dim dicHandles As Scripting.Dictionary
    Set dicHandles = New Scripting.Dictionary

    Dim xmlHandlesList As MSXML2.IXMLDOMNodeList
    Set xmlHandlesList = xmlEle.SelectNodes("//*[@hWnd]")

    Dim vLoop As MSXML2.IXMLDOMElement
    For Each vLoop In xmlHandlesList
        dicHandles.Add CLng(vLoop.getAttribute("hWnd")), 0
    Next

    Set AllHandles = dicHandles
End Function

5 comments:

  1. You should point out that to get the code in this post to compile in Excel you need to have references set to:
    Microsoft XML, v6.0
    Microsoft Scripting Runtime

    ReplyDelete
  2. In your function FindWindowEx, it will not work in Win64 because your first 2 parameters are handles but specified as Long instead of LongPtr.

    Likewise, the first parameter in functinons GetWindowTextW and GetClassNameW are handles and should be expressed as LongPtr instead of Long.

    ReplyDelete
  3. This comment has been removed by the author.

    ReplyDelete
  4. This comment has been removed by the author.

    ReplyDelete
  5. Thanks for this posting.
    There is a correction in Function GetWindows that is necessary to parse the xml; of course it cannot Load no Root element:
    Instead of xmlDocument.LoadXML "", do a xmlDocument.LoadXML "".

    For 64bits:
    #If Win64 Then
    Public Declare PtrSafe Function FindWindowEx Lib "user32" Alias "FindWindowExA" (ByVal hWnd1 As LongPtr, ByVal hWnd2 As LongPtr, ByVal lpsz1 As String, ByVal lpsz2 As String) As Long 'LongPtr
    Public Declare PtrSafe Function GetWindowText Lib "user32" Alias "GetWindowTextA" (ByVal hwnd As LongPtr, ByVal lpString As String, ByVal cch As Long) As Long
    Public Declare PtrSafe Function GetClassName Lib "user32" Alias "GetClassNameA" (ByVal hwnd As LongPtr, ByVal lpClassName As String, ByVal nMaxCount As Long) As Long
    #Else
    Public Declare Function FindWindowEx Lib "user32" Alias "FindWindowExA" (ByVal hWnd1 As Long, ByVal hWnd2 As Long, ByVal lpsz1 As String, ByVal lpsz2 As String) As Long
    Public Declare Function GetWindowText Lib "user32" Alias "GetWindowTextA" (ByVal hwnd As Long, ByVal lpString As String, ByVal cch As Long) As Long
    Public Declare Function GetClassName Lib "user32" Alias "GetClassNameA" (ByVal hwnd As Long, ByVal lpClassName As String, ByVal nMaxCount As Long) As Long
    #End If

    ...

    dicCode.Add dicCode.Count, "#If Win64 Then"
    dicCode.Add dicCode.Count, " Public Declare PtrSafe Function FindWindowEx Lib ""user32"" Alias ""FindWindowExA"" _"
    dicCode.Add dicCode.Count, " (ByVal hWnd1 As LongPtr, ByVal hWnd2 As LongPtr, ByVal lpsz1 As String, ByVal lpsz2 As String) As Long"
    dicCode.Add dicCode.Count, "#Else"
    dicCode.Add dicCode.Count, " Private Declare Function FindWindowEx Lib ""user32"" Alias ""FindWindowExA"" _"
    dicCode.Add dicCode.Count, " (ByVal hWnd1 As Long, ByVal hwnd2 As Long, ByVal lpsz1 As String, ByVal lpsz2 As String) As Long"
    dicCode.Add dicCode.Count, "#End If" & vbNewLine

    After that, it works as a charm.

    Thanks again

    ReplyDelete