Tuesday, 21 May 2019

VBA - Reflection - use Python to write your enumeration helper functions

This is second blog post demonstrating how we can use Python code to leverage a C++ reflection API with respect to a VBA project and thus confer the capabilities of reflection to a VBA programmer where no native VBA functionality exists. In other words, reflection is not normally available to a VBA developer but with some clever code we can fix that. The use case this time is converting enumerations to strings (and back).

Click here for separate Youtube window

So in the previous post I highlighted how there is no in built VBA language feature to give the string equivalent of an enumeration value; and that one had to write a helper function. But, the helper function has to be kept synchronised which can be a little painful. Luckily, we can use some more Python reflection code to help with this.

You are strongly advised to first read the prior article on Python reflection where a simpler version of the diagram below is introduced. In this post, we continue to flesh out our diagram with more hyperlinks for methods and structures we're going to use. New on this diagram is the ITypeLib interface (rightmost box). One can acquire a reference to a class's containing type library (VBA project) via the class's ITypeInfo interface by calling ITypeInfo::GetContainingTypeLib.

Being able to acquire the containing type library (VBA project) is an important advance. In the prior reflection blog post we got run-time type information (RTTI) for a single VBA class instance. But now we have the capability to interrogate the whole type library (VBA project). In this post I will give code which will find all the enumerations and write some helper functions to convert enumeration values to and from strings. I'm sure, I will blog other use cases that will make use of 'type-library-wide' information.

Another addition to the diagram below is the link to the VARDESC structure. The documentation for that structure is unwelcoming (to a VBA programmer at least), don't worry that documentation is typically for C++ programmers. We will be using a Python layer called pythoncom authored by Tim Golden. The Python layer is beautiful to work with. Thanks Tim!

AddRef IUnknown QueryInterface Release GetTypeInfoCount IDispatch GetTypeInfo GetIDsOfNames Invoke User-defined Foo Bar Baz AddressOfMember CreateInstance GetContainingTypeLib GetDllEntry GetDocumentation GetFuncDesc => ITypeInfo GetIDsOfNames GetImplTypeFlags GetMops GetNames GetRefTypeInfo GetRefTypeOfImplType GetTypeAttr GetTypeComp GetVarDesc Invoke ReleaseFuncDesc ReleaseTypeAttr ReleaseVarDesc FindName GetDocumentation => ITypeLib GetLibAttr GetTypeComp GetTypeInfo => FUNCDESC GetTypeInfoCount GetTypeInfoOfGuid GetTypeInfoType IsName ReleaseTLibAttr => TYPEATTR => VARDESC

Python Reflection code to query a Type Library (VBA Project) for all enumerations

So we are in a position to give the strategy for listing all the enumerations in a type library (VBA Project) given a VBA class instance from that type library.

  1. Get the IDispatch interface pointer for given VBA class
  2. Get the class's ITypeInfo interface pointer by calling IDispatch::GetTypeInfo
  3. Get the type library's (VBA Project's) ITypeLib interface pointer by calling ITypeInfo::GetContainingTypeLib
  4. Get the total count of types of the type library (VBA Project) by calling ITypeLib::GetTypeInfoCount to set up a For loop
  5. Use a For loop to iterate over all the types in the type library (VBA Project)
  6. On each iteration call ITypeLib::GetTypeInfo to get the ITypeInfo interface pointer for each type
  7. Test the type's Typekind to see if it an enumeration
  8. For each enumeration run our code to generate some VBA enumeration helpers function

PythonVBAEnumHelper.py, houses the Python COM Gateway class

So this is the Python code. It needs to be run at least once from a command line with administrator rights so that the registry can be updated. Once registered then simple use VBA.CreateObject("PythonInVBA.PythonVBAEnumHelper") to instantiate this Python class and then call the WriteMyEnumHelpers method.

import pythoncom

class PythonVBAEnumHelper(object):
    _reg_clsid_ = "{232D07E5-4BCE-4FB9-93DC-2F6B58B809F7}"
    _reg_progid_ = 'PythonInVBA.PythonVBAEnumHelper'
    _public_methods_ = ['ClearLog','ReadEnums','ReadEnum','WriteMyEnumHelpers','WriteMyEnumHelper'] 
    _public_attrs_ = ['Log']
    _readonly_attrs_ = ['Log']

    def __init__(self):
        self.Log = ""

    def ClearLog(self):
        self.Log = ""

    def GetTypeLibrary(self, o):
        try:
            pt = str(type(o))
            if pt == "<class 'win32com.client.CDispatch'>":
                ti = o._lazydata_[0]
            elif pt == "<class 'PyIDispatch'>" :
                ti = o.GetTypeInfo()
            else:
                self.Log += "called with type " + pt + " no attempt to acquire typeinfo\n"
                return None

            self.Log += "Acquired typeinfo:" + ti.GetDocumentation(-1)[0] + "\n"
            typelib = ti.GetContainingTypeLib()[0]
            self.Log += "Acquired containing typelib:" + typelib.GetDocumentation(-1)[0] + "\n"

            return typelib

        except Exception as e:
            self.Log += "Error: " + str(e) + "\n"


    def WriteMyEnumHelpers(self,o):
        try:
            helpers = []
            enums = self.ReadEnums(o,True)
            if not (enums is None):
                for enum in enums:
                    helper = self.WriteMyEnumHelper(enum)

                    if not (helper is None):
                        helpers.append(helper)

                return list(helpers)

        except Exception as e:
            self.Log += "Error: " + str(e) + "\n"

    def WriteMyEnumHelper(self,enum):
        try:
            if not (enum is None):

                vbaStringToEnumFuncName = enum[0] + "StringToEnum"
                vbaStringToEnumFuncSrc = "Public Function " + vbaStringToEnumFuncName + "(s As String) As " + enum[0] + "\n\t" + vbaStringToEnumFuncName + " = "

                vbaEnumToStringFuncName = enum[0] + "EnumToString"
                vbaEnumToStringFuncSrc = "Public Function " + vbaEnumToStringFuncName + "(e As " + enum[0] + ") As String\n\t" + vbaEnumToStringFuncName + " = "
                
                stringToEnumSwitch = ""
                enumToStringSwitch = ""

                srcArray = ""
                for enumMem in enum[2]:
                    if (stringToEnumSwitch != ""):
                        stringToEnumSwitch+=", "
                        enumToStringSwitch+=", "

                    stringToEnumSwitch+= "s = \"" + enumMem[0] + "\", " + str(enumMem[1])
                    enumToStringSwitch+= "e = " + str(enumMem[1]) + ", \"" + enumMem[0] + "\""

                vbaEnumToStringFuncSrc+=" Switch(" + enumToStringSwitch + ")\nEnd Function\n"
                vbaStringToEnumFuncSrc+=" Switch(" + stringToEnumSwitch + ")\nEnd Function\n"

                return (vbaEnumToStringFuncSrc + vbaStringToEnumFuncSrc)
            else:
                return "Something went wrong"
        except Exception as e:
            self.Log += "Error: " + str(e) + "\n"



    def ReadEnums(self, o, readMembers):
        try:
            typelib = self.GetTypeLibrary(o)
            if not (typelib is None):

                enums = [[]]

                for index in range(0, typelib.GetTypeInfoCount()):
                    ti = typelib.GetTypeInfo(index)
                    ta = ti.GetTypeAttr()
                    tk = ta.typekind
                    if tk == 0:  # 0=ENUMERATION
                        self.Log += "Found enum:" + ti.GetDocumentation(-1)[0] + "\n"
                        members = None
                        if (readMembers):
                            members = self.ReadEnum(o,index)
                        tup = (ti.GetDocumentation(-1)[0],index, members)
                        enums.append(tup)

                return enums

        except Exception as e:
            self.Log += "Error: " + str(e) + "\n"

    def ReadEnum(self, o, index):
        try:
            typelib = self.GetTypeLibrary(o)
            
            if not (typelib is None):
                ti = typelib.GetTypeInfo(index)
                ta = ti.GetTypeAttr()
                count = ta.cVars
                enumMems = []
                self.Log += "Enum count:" + str(count) + "n"
                for memberIndex in range(0, count):
                    varDesc = ti.GetVarDesc(memberIndex)
                    enumMems.append((ti.GetDocumentation(varDesc.memid)[0],varDesc.value))

                return list(enumMems)

        except Exception as e:
            self.Log += "Error: " + str(e) + "\n"


def RegisterThis():
    print("Registering COM servers...")
    import win32com.server.register
    win32com.server.register.UseCommandLine(PythonVBAEnumHelper)


if __name__ == '__main__':
    RegisterThis()
    print("End of execution")

VBA classes

I have contrived to split the enums over two classes. This is to demonstrate that the Python code can interrogate all Instancing '2 - PublicNotCreatable' classes in a type library (VBA Project) given just a single class instances. So forgive me if this looks a little odd. Also, do please note that enums defined is either a (i) standard module or (ii) a class with Instancing '1 - Private' will not be found. So you'll need two separate classes, Enums and MoreEnums both with Instancing '2 - PublicNotCreatable'.

The Enums VBA class

Option Explicit
'* Instancing must be set to '2 - PublicNotCreatable'

Public Enum Cars
    BMW
    Ford
    Lotus
    'Ferrari
End Enum

The MoreEnums VBA class

Option Explicit
'* Instancing must be set to '2 - PublicNotCreatable'

Public Enum MyColor
    Red = 1
    Green
    Blue
    'Yellow
    'Purple
End Enum

The VBA Client code

So the Python code does the clever stuff with reflection but we still need some VBA client code to call into the Python COM server.

Option Explicit

Private Function WriteEnumsHelpers(ByVal oAnyPublic2VBAClass As Object)
    
    Static oHelper As Object
    If oHelper Is Nothing Then Set oHelper = VBA.CreateObject("PythonInVBA.PythonVBAEnumHelper")
    oHelper.ClearLog
    If oAnyPublic2VBAClass Is Nothing Then Err.Raise vbObjectError, "", "#Null oAnyPublic2VBAClass!"

    On Error GoTo PythonComInteropErrorHandler
    WriteEnumsHelpers = oHelper.WriteMyEnumHelpers(oAnyPublic2VBAClass)
    'Debug.Print oHelper.Log
    
    Exit Function
PythonComInteropErrorHandler:
    If Err.Number = 98 Then
        Err.Raise vbObject, "", "#oVBAClass of type '" & TypeName(oAnyPublic2VBAClass) & "' must have Instancing '2 - PublicNotCreatable'!"
    Else
        Debug.Print Err.Description, Hex$(Err.Number), Err.Source
        Debug.Print "Log:" & oHelper.Log
    End If

End Function

Private Sub TestWriteEnumsHelpers()

    Dim oAnyPublic2VBAClass As Object
    Set oAnyPublic2VBAClass = New Enums

    Debug.Print VBA.Join(WriteEnumsHelpers(oAnyPublic2VBAClass), vbNewLine)
    
End Sub

Sample output

So the Python code actually generates VBA code for copying and pasting into the VBA project to help with your enumeration to string (and back again) logic.

Public Function CarsEnumToString(e As Cars) As String
    CarsEnumToString =  Switch(e = 0, "BMW", e = 1, "Ford", e = 2, "Lotus")
End Function
Public Function CarsStringToEnum(s As String) As Cars
    CarsStringToEnum =  Switch(s = "BMW", 0, s = "Ford", 1, s = "Lotus", 2)
End Function

Public Function MyColorEnumToString(e As MyColor) As String
    MyColorEnumToString =  Switch(e = 1, "Red", e = 2, "Green", e = 3, "Blue")
End Function
Public Function MyColorStringToEnum(s As String) As MyColor
    MyColorStringToEnum =  Switch(s = "Red", 1, s = "Green", 2, s = "Blue", 3)
End Function

Thursday, 16 May 2019

VBA - Enumeration to strings

It has been asked on Stack Overflow is there is an inbuilt way to convert a VBA enum to a string, i.e. to get a string representation of the value, like there is in C#. The answer is no. But one can write a helper function with the enum values stored in an array. (UPDATE: and in this follow-up post I give Python code to write it for you!)

In the code below I have four examples. In the first two the values are sequential, they differ only in that one is zero-based and the other isn't.

The third example is a binary flag based enumeration where the values are not mutually exclusive but instead building blocks for a composite indicator. This requires a helper function to convert the value to binary with modular division.

The fourth example is to catch all other cases because it uses a Switch statement to find the index of the correct string in the array and is less efficient.

However, all three examples require the enumeration definition to be synchronized to the array of strings. This might be considered a pain, I wonder if there is anything we can do to salve this pain?

Option Explicit

'* a sequential example zero based
Public Enum Cars
    BMW
    Ford
    Lotus
End Enum

'* a sequential example non-zero based
Public Enum MyColor
    Red = 1
    Green
    Blue
End Enum

'* a binary flag based
Public Enum ParamFlags
    FIN = 1
    FOUT = 2
    FLCID = 4
    FRETVAL = 8
    FOPT = 16
    FHASDEFAULT = 32
    FHASCUSTDATA = 64
End Enum

'* non sequential, non binary flags
Public Enum PrimeNumbers
    First = 2
    Second = 3
    Third = 5
    Fourth = 7
    Fifth = 11
End Enum

Public Function CarsEnumToString(e As Cars)
    CarsEnumToString = Array("BMW", "Ford", "Lotus")(e)
End Function

Public Function MyColorEnumToString(e As MyColor)
    MyColorEnumToString = Array("Red", "Green", "Blue")(e - 1)
End Function

Public Function ParamFlagsEnumToString(e As ParamFlags)
    ParamFlagsEnumToString = ToBinary(e, Array("FIN", "FOUT", "FLCID", "FRETVAL", "FOPT", "FHASDEFAULT", "FHASCUSTDATA"))
End Function

Public Function PrimeNumbersEnumToString(e As PrimeNumbers) As String
    PrimeNumbersEnumToString = Array("First", "Second", "Third", "Fourth", "Fifth")(Switch(e = 2, 0, e = 3, 1, e = 5, 2, e = 7, 3, e = 11, 4))
End Function


Function ToBinary(ByVal lFlags As Long, ByRef vNames As Variant)
    Dim dic As Scripting.Dictionary
    Set dic = New Scripting.Dictionary

    Dim lIndex As Long

    While lFlags > 0
        If lFlags Mod 2 = 1 Then dic.Add dic.Count, vNames(lIndex)
        
        lFlags = lFlags \ 2
        lIndex = lIndex + 1
    Wend
        
    ToBinary = VBA.Join(dic.Items, " | ")
End Function

Sub Test()
    Debug.Assert CarsEnumToString(BMW) = "BMW"
    Debug.Assert CarsEnumToString(Ford) = "Ford"
    Debug.Assert CarsEnumToString(Lotus) = "Lotus"

    Debug.Assert MyColorEnumToString(Red) = "Red"
    Debug.Assert MyColorEnumToString(Green) = "Green"
    Debug.Assert MyColorEnumToString(Blue) = "Blue"

    Dim e As ParamFlags
    e = FIN + FOUT + FLCID + FOPT + FHASDEFAULT + FHASCUSTDATA

    Debug.Assert ParamFlagsEnumToString(e) = "FIN | FOUT | FLCID | FOPT | FHASDEFAULT | FHASCUSTDATA"

    Debug.Assert PrimeNumbersEnumToString(2) = "First"
    Debug.Assert PrimeNumbersEnumToString(11) = "Fifth"

End Sub

Monday, 13 May 2019

VBA - Reflection, with help from Python

In a previous post this month, I wrote that VBA does not have reflection that allows some fancy dependency injection mechanism. This is strictly true of VBA itself but VBA is a COM artefact and reflection interfaces are available as part of the venerable COM specification. Luckily, with the help of some Python we can call some of these reflection interfaces on a VBA class.

Click here for separate Youtube window

WARNING - No warranty

WARNING: what follows is a little known technique which I have not run in a production environment; so use at your risk. No warranty is given for any code in this blog post, nor for any blog post. But do let us know how you get on if you chose to use it by commenting below.

The high-level use-case

In this post I'll show enough Python reflection code to drive a better dependency injection mechanism. The high level logic is easy enough to express: all I need is to check a VBA class instance for a certain method named "InjectDependencies" and if found return a list of argument names which will determine what to inject. Seems simple enough but it requires delving into COM reflection interfaces which are typically unknown to a VBA developer.

Strategy - acquiring ITypeInfo from an IDispatch method and calling ITypeInfo methods

In the diagram below the left hand box shows the virtual function table of a VBA class, the class only has three user defined methods, Foo, Bar and Baz but as it is a COM class it also has an implementation of IUnknown. Also because VBA classes can be late bound we know they support IDispatch, the methods of which come after the methods of IUnknown. Then come the user-defined methods, so Foo is in fact the 8th method in this virtual function table.

I want to attract your attention to the IDispatch methods. Maybe you're already familiar with the IDispatch methods GetIDsOfNames and Invoke; these implement late binding. Less well known, I would argue, are the other two IDispatch methods GetTypeInfoCount and GetTypeInfo but I believe these methods deserve high praise and more attention as they return run time type information (RTTI). In fact, I believe these methods are what allow rich reports on a late bound object's properties in the Locals or Watch windows.

In fact, GetTypeInfoCount is just a guard which returns 0 or 1. The real method is GetTypeInfo which returns a pointer to ITypeInfo. Our code will acquire a pointer to ITypeInfo by calling IDispatch::GetTypeInfo.

The Dual Interface and the need to hop from one ITypeInfo to another

If you have worked with the C# reflection classes you will probably smirk at the ITypeInfo interfaces particularly in this next case. A COM class can have dual interface meaning it can be accessed either via IDispatch or through its virtual function table (vtable) interface. Thus, dual interfaces require two separate ITypeInfo interface instances. IDispatch::GetTypeInfo returns a Dispatch ITypeInfo interface instance but we hop to ITypeInfo for the vtable interface instance with a call to GetRefTypeOfImplType and then GetRefTypeInfo. Please don't ask me to defend this API design.

Getting the TypeAttr structure

There is plenty of information on the TypeAttr structure which is acquired by calling ITypeInfo::GetTypeAttr. The Python library will handle releasing the structure's memory. On the TypeAttr structure, we are interested in cFuncs which is the count of functions (aka methods).

Looping through function descriptions and then acquiring parameter names

Knowing the count of functions/methods we can loop through and for each method acquire the function description, FUNCDESC, structure. (Again, the Python layer will release the structure for us.) We call GetDocumentation to examine the method name (first element on the returned tuple). If we find "InjectDependencies" then we take note of the index number; later we call GetNames to return the list of arguments.

AddRef IUnknown QueryInterface Release GetTypeInfoCount IDispatch GetTypeInfo GetIDsOfNames Invoke User-defined Foo Bar Baz AddressOfMember CreateInstance GetContainingTypeLib GetDllEntry GetDocumentation GetFuncDesc => ITypeInfo GetIDsOfNames GetImplTypeFlags GetMops GetNames GetRefTypeInfo GetRefTypeOfImplType GetTypeAttr GetTypeComp GetVarDesc Invoke ReleaseFuncDesc ReleaseTypeAttr ReleaseVarDesc => FUNCDESC => TYPEATTR

The Python Com Server

So what follows is a Python COM Server aka gateway class, I have placed many examples of these on this blog. But for those new to this, this will implement a COM server that is create-able from VBA using CreateObject(). In the class below the code maintains a log because it can be quite difficult to communicate what is going on without a log.

The strategy outlined above is implemented in ReadParams() which takes a single parameter (the self keyword is what holds the state of a a Python class instance), o which should be a VBA class instance. Please pass a real VBA class and not a document class such as ThisWorkbook or Sheet1. Also for the VBA class the Instancing needs to be '2 - PublicNotCreatable' for reasons I have yet to confirm.

The opening lines of ReadParams() are defensive type checking. The key call is GetTypeInfo() which returns a pointer to ITypeInfo. If you know the strategy, the rest of the code should be easy to follow.

If you have written .NET reflection code in C# then you are lucky, it is very intuitive and easy to understand. The COM reflection interfaces are very far from intuitive, IMHO; especially if you are writing in C++. The COM reflection API can be quite painful. So be thankful that some Python contributors (Tim Golden et al.) have added a layer to insulate you from the C++ complexities. For more information here is the Python documentation.

Upon re-reading my code, I think a few lines could be taken out to make it tighter, perhaps quit the loop early if a method match is found.

Finally, on a COM interop note, Python tuples need to be converted to lists for passing back to the calling VBA, where they are marshalled to a Variant array.

import pythoncom

class PythonDependencyInjectionHelper(object):
    _reg_clsid_ = "{2AFE4143-AC58-4D1F-A172-9D20C917D13A}"
    _reg_progid_ = 'PythonInVBA.PythonDependencyInjectionHelper'
    _public_methods_ = ['ReadParams','ClearLog'] 
    _public_attrs_ = ['Log']
    _readonly_attrs_ = ['Log']

    def __init__(self):
        self.Log = ""

    def ClearLog(self):
        self.Log = ""

    def ReadParams(self, o):
        try:
            pt = str(type(o))
            if pt == "<class 'win32com.client.CDispatch'>":
                ti = o._lazydata_[0]
            elif pt == "<class 'PyIDispatch'>" :
                ti = o.GetTypeInfo()
            else:
                self.Log += "called with type " + pt + " no attempt to acquire typeinfo\n"
                return None

            self.Log += "Acquired typeinfo:" + ti.GetDocumentation(-1)[0] + "\n"

            ta = ti.GetTypeAttr()
            tk = ta.typekind
            if tk == 4:

                try:
                    tivt = ti.GetRefTypeInfo(ti.GetRefTypeOfImplType(-1))
                    tavt = tivt.GetTypeAttr()
                    
                except Exception as ex:
                    raise Exception("Error whilst acquiring vtable interface " + str(ex))
                try:
                    idx = -1
                    for funcIdx in range(0, tavt.cFuncs):
                        fd = tivt.GetFuncDesc(funcIdx)
                        if (ti.GetDocumentation(fd.memid)[0] == "InjectDependencies"):
                            idx = funcIdx
                except Exception as ex:
                       raise Exception("Error whilst looping through functions " + str(ex))
                
                if (idx != -1):
                    fd = tivt.GetFuncDesc(idx)
                    return list(tivt.GetNames(fd.memid)[1:])

        except Exception as e:
            self.Log += "Error: " + str(e) + "\n"


def RegisterThis():
    print("Registering COM servers...")
    import win32com.server.register
    win32com.server.register.UseCommandLine(PythonDependencyInjectionHelper)


if __name__ == '__main__':
    RegisterThis()
    print("End of execution")

The TaxCalculator business logic class

Lets give some client VBA code, enough to drive the demo so I have a business logic class called TaxCalculator which has a method called InjectDependencies() which has two arguments oLogger and oIdentity. It has some simple business logic but that's not the real focus, the real focus is the injection of dependencies.

Option Explicit

Private mobjLogger As Object
Private mobjIdentity As Object

Public Sub InjectDependencies(oLogger, oIdentity)
    Set mobjLogger = oLogger
    Set mobjIdentity = oIdentity
End Sub

Public Function CalculateTax(ByVal lAmount As Long) As Long
    If mobjIdentity.HasPermission("Taxcalc") Then
        CalculateTax = lAmount * 0.2
        mobjLogger.Log "Authorised, Calculated tax at 20%"
    Else
        mobjLogger.Log "Not authorised to run this"
    End If
End Function

The Calling VBA Code

Private Function ReadDependencies(ByVal oVBAClass As Object) As Variant

    ReadDependencies = CreateObject("Scripting.Dictionary").Keys  '# sets a default return value for error cases
    Static oHelper As Object
    If oHelper Is Nothing Then Set oHelper = VBA.CreateObject("PythonInVBA.PythonDependencyInjectionHelper")
    oHelper.ClearLog
    If oVBAClass Is Nothing Then Err.Raise vbObjectError, "", "#Null oVBAClass!"

    On Error GoTo PythonComInteropErrorHandler
    Dim vDependencies
    vDependencies = oHelper.ReadParams(oVBAClass)
    If Not IsNull(vDependencies) Then ReadDependencies = vDependencies
    
    
    Exit Function
PythonComInteropErrorHandler:
    If Err.Number = 98 Then
        Err.Raise vbObject, "", "#oVBAClass of type '" & TypeName(oVBAClass) & "' must have Instancing '2 - PublicNotCreatable'!"
    Else
        Debug.Print Err.Description, Hex$(Err.Number), Err.Source
        Debug.Print "Log:" & oHelper.Log
    End If

End Function

Private Sub TestReadDependencies()

    Dim objVBAClass As Object
    Set objVBAClass = New TaxCalculator

    Dim vDependencies
    vDependencies = ReadDependencies(objVBAClass)

    Debug.Print Join(vDependencies, vbNewLine)
End Sub

VBA - Sheet Diagram to SVG

The VBA code below will inspect the text, hyperlinks and borders of an Excel range on a worksheet and convert to SVG (Scalable Vector Graphics) for use in an HTML context.

I wanted more diagrams on my blog and I knew I wanted to use SVG but I did not want to wrestle with an HTML artwork package such as InkScape. I felt the Excel worksheet grid is a perfectly good way to layout a diagram so Excel is my authoring tool. You can see an example of a diagram converted to SVG, below is a screenshot of the original Excel worksheet. Even further below is the source code.

Note: to get working in a blog I have had to remove the namespaces. It is nice to see hyperlinks working albeit I used javascript when then the anchor element did not render.

AddRef IUnknown QueryInterface Release GetTypeInfoCount IDispatch GetTypeInfo GetIDsOfNames Invoke User-defined Foo Bar Baz AddressOfMember CreateInstance GetContainingTypeLib GetDllEntry GetDocumentation GetFuncDesc => ITypeInfo GetIDsOfNames GetImplTypeFlags GetMops GetNames GetRefTypeInfo GetRefTypeOfImplType GetTypeAttr GetTypeComp GetVarDesc Invoke ReleaseFuncDesc ReleaseTypeAttr ReleaseVarDesc => FUNCDESC => TYPEATTR


Option Explicit

'* Tools->References
'* Microsoft Scripting Runtime
'* Microsoft Xml v6.0
'*

Sub Test()

    Dim fso As Scripting.FileSystemObject
    Set fso = New Scripting.FileSystemObject
    
    Dim sSVGPath As String
    sSVGPath = "N:\InterfaceDiagram6.svg"
    
    Dim txtOut As Scripting.TextStream
    Set txtOut = fso.CreateTextFile(sSVGPath)
    txtOut.WriteLine "<?xml version=""1.0"" encoding=""UTF-8"" standalone=""no""?>"
    txtOut.WriteLine "<svg:svg id='Simon1' xmlns:svg=""http://www.w3.org/2000/svg"" xmlns:xlink='http://www.w3.org/1999/xlink'>"
    txtOut.WriteLine "<svg:g  id='Simon2' transform='scale(2)' >"

    Dim wb As Excel.Workbook
    Set wb = ThisWorkbook 'Set wb = Workbooks(1)
    
    Dim sht1 As Excel.Worksheet
    Set sht1 = wb.Worksheets.Item("Sheet1")

    Dim vRegions As Variant
    vRegions = Array(Array(sht1.Range("b4:c13"), "Group1"), _
                    Array(sht1.Range("d2:f20"), "Group2"), _
                    Array(sht1.Range("g2:h20"), "Group3"))
                    
                    
    Test2 txtOut, vRegions


    txtOut.WriteLine "</svg:g >"
    txtOut.WriteLine "</svg:svg>"

    txtOut.Close
    Set txtOut = Nothing

    Dim dom As MSXML2.DOMDocument60
    Set dom = New MSXML2.DOMDocument60
    
    Debug.Assert dom.Load(sSVGPath)

    Debug.Assert dom.parseError = 0

End Sub


Sub Test2(txtOut, ByVal vRegions As Variant)

    Dim vRegionsLoop
    For Each vRegionsLoop In vRegions
                
        Dim rng As Excel.Range
        Set rng = vRegionsLoop(0)
        
        Dim sRegionId
        sRegionId = vRegionsLoop(1)
        
        txtOut.WriteLine "<svg:g id='" & sRegionId & "' >"
        
        Dim rngLoop As Excel.Range
        For Each rngLoop In rng.Cells
            txtOut.Write BordersToSvg(rngLoop)
            txtOut.Write TextToSvg(rngLoop)
        Next
    
        txtOut.WriteLine "</svg:g >"
    Next

End Sub


Function TextToSvg(ByVal rng As Excel.Range) As String
    Debug.Assert rng.Rows.Count = 1
    Debug.Assert rng.Columns.Count = 1

    Dim sText As String
    sText = rng.Value2

    If Len(sText) > 0 Then
    
        Dim fill As String
        fill = "fill:#000000"
    
        If rng.Hyperlinks.Count > 0 Then
            Dim lnk As Excel.Hyperlink
            Set lnk = rng.Hyperlinks.Item(1)
            
            Dim javascript As String
            javascript = " ondblclick=""window.open(&quot;" & lnk.Address & "&quot;)"" " & vbNewLine
            javascript = javascript & " onmouseover=""this.style['fill']='#ffc000';console.log(this.style['fill']);"" " & vbNewLine
            javascript = javascript & " onmouseout=""this.style['fill']='#2288bb';console.log(this.style['fill']);"" " & vbNewLine
            fill = "fill:#2288bb"
        End If
    
        Dim fnt As Excel.Font
        Set fnt = rng.Font
        
        Dim fntPx As Long
        fntPx = fnt.Size * 1 ' 4# / 3#
        
        
        Dim fntStyle As String
        fntStyle = "font-style:normal;font-weight:normal;font-size:" & fntPx & "px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;" & fill & ";fill-opacity:1;stroke:none"
    
        Dim x
        x = rng.Left
        
        Dim y
        y = rng.Top + rng.Height
    
        Dim sId As String
        sId = VBA.Replace(rng.Address, "$", "")

        Dim s As String
        s = vbNewLine
        s = s & "<svg:text id='" & sId & "txt' x='" & x & "' y='" & y & "' style='" & fntStyle & "' " & javascript & " >" & vbNewLine
        s = s & "<svg:tspan id='" & sId & "tspan'  x='" & x + 1.25 & "' y='" & y - 2 & "' >" & sText & "</svg:tspan>" & vbNewLine
        s = s & "</svg:text>" & vbNewLine
    End If
    TextToSvg = s

End Function

Function BordersToSvg(ByVal rng As Excel.Range) As String
    Debug.Assert rng.Rows.Count = 1
    Debug.Assert rng.Columns.Count = 1
    
    Dim dicKeyedByStyle As Scripting.Dictionary
    Set dicKeyedByStyle = New Scripting.Dictionary
    
    Dim vEdges As Variant
    vEdges = Array(xlEdgeLeft, xlEdgeTop, xlEdgeBottom, xlEdgeRight)
    
    Dim vEdges2 As Variant
    vEdges2 = Array("xlEdgeLeft", "xlEdgeTop", "xlEdgeBottom", "xlEdgeRight")
    
    Dim lEdgeLoop As Long
    For lEdgeLoop = 7 To 10
        
        Dim brd As Excel.Border
        Set brd = rng.Borders.Item(lEdgeLoop)
        
        If Not IsNull(brd.TintAndShade) Then
            
            Dim sLine As String
            sLine = ""
            
            If lEdgeLoop = xlEdgeBottom Then
                sLine = "M " & rng.Left & "," & rng.Top + rng.Height & " L " & rng.Left + rng.Width & "," & rng.Top + rng.Height
            ElseIf lEdgeLoop = xlEdgeTop Then
                sLine = "M " & rng.Left & "," & rng.Top & " L " & rng.Left + rng.Width & "," & rng.Top
            ElseIf lEdgeLoop = xlEdgeLeft Then
                sLine = "M " & rng.Left & "," & rng.Top & " L " & rng.Left & "," & rng.Top + rng.Height
            ElseIf lEdgeLoop = xlEdgeRight Then
                sLine = "M " & rng.Left + rng.Width & "," & rng.Top & " L " & rng.Left + rng.Width & "," & rng.Top + rng.Height
            End If
            
            Dim sStyleKey As String
            sStyleKey = "stroke-width:1;stroke:#" & Right$("000000" & Hex$(brd.Color), 6) & ";" & VBA.IIf(brd.LineStyle <> 1, "stroke-miterlimit:4;stroke-dasharray:2,2;stroke-dashoffset:0", "")
            
            If dicKeyedByStyle.Exists(sStyleKey) Then
                dicKeyedByStyle(sStyleKey) = dicKeyedByStyle(sStyleKey) & " " & sLine
            Else
                dicKeyedByStyle(sStyleKey) = sLine
            End If
        End If
    Next
    
    Dim lStyleLoop As Long
    For lStyleLoop = 0 To dicKeyedByStyle.Count - 1
    
        Dim vUniqueStyle As Variant
        vUniqueStyle = dicKeyedByStyle.Keys()(lStyleLoop)
        
        Dim sId As String
        sId = "id=""" & VBA.Replace(rng.Address, "$", "") & "_" & lStyleLoop & """"
        
        Dim sStyle As String
        sStyle = " style=""" & vUniqueStyle & """ "
        
        Dim sSvgHtml As String
        sSvgHtml = "<svg:path " & sId & sStyle & " d=""" & dicKeyedByStyle(vUniqueStyle) & """/>"
    
        BordersToSvg = BordersToSvg & sSvgHtml & vbNewLine
    Next

End Function





Thursday, 2 May 2019

VBA - Dependency Injection

I've been reading about Dependency Injection and I was wondering what it would look like in VBA. Let's start with the Wikipedia definition ...

"In software engineering, dependency injection is a technique whereby one object (or static method) supplies the dependencies of another object. A dependency is an object that can be used (a service). An injection is the passing of a dependency to a dependent object (a client) that would use it. The service is made part of the client's state. Passing the service to the client, rather than allowing a client to build or find the service, is the fundamental requirement of the pattern."

Design decisions

It seems there are frameworks available to help implement this pattern in C#. I do not believe that they exist for VBA; not that I'd use them if they did exist. I want a simple implementation.

Also, I want to avoid the proliferation of interfaces because they can clutter the project explorer in VBA. Also, it seems odd to me that to facilitate loose coupling between two classes one has to define a third abstract class. Can't we achieve loose coupling using IDispatch, i.e. declaring variable objects to be of type Object? Well I choose to use IDispatch, let's see how it goes.

The Modules

In the example I have two business logic classes, TaxCalculator and TaxBiller. I have two logger classes, TempFileLogger and ImmediateWindowLogger which both support the Log method and so are interchangeable. I also have a DevelopmentIdentity class to supply identity services, I have omitted a substitute which queries ActiveDirectory. There is some client code. Finally, there is a modDependencyInjector module which holds a pool of services in a dictionary.

There are no separate interfaces as noted above. That is a design decision to prevent proliferation of classes (we already have five!).

The Logger classes, ImmediateWindowLogger and TempFileLogger

The logger classes provides logging services.

The ImmediateWindowLogger class is simple for illustrative purposes. It has a single method which takes the message to log.

Option Explicit

Public Sub Log(ByVal sMsg As String)
    Debug.Print sMsg
End Sub

The TempFileLogger class is simple shares the same interface, one single method. So the two are interchangeable. TempFileLogger differs in that it writes to a temporary file.

Option Explicit

Private msTempFile As String
Private moFSO As Scripting.FileSystemObject

Private Sub Class_Initialize()
    msTempFile = Environ$("temp") & "\LogFile.txt"
    Set moFSO = New Scripting.FileSystemObject
    If moFSO.FileExists(msTempFile) Then Kill msTempFile '* remove previous
    Debug.Print "Logging to:"; msTempFile
End Sub

Public Sub Log(ByVal sMsg As String)
    Dim oTxt As Scripting.TextStream
    
    If moFSO.FileExists(msTempFile) Then
        Set oTxt = moFSO.OpenTextFile(msTempFile, ForAppending)
    Else
        Set oTxt = moFSO.OpenTextFile(msTempFile, ForWriting, True)
    End If
    oTxt.WriteLine sMsg
    oTxt.Close
    Set oTxt = Nothing
End Sub

The Identity class, DevelopmentIdentity

This class provides identity services but the given class DevelopmentIdentity is a mock class. A proper identity services class would probably query ActiveDirectory but that is beyond the scope of this blog post. Here is the DevelopmentIdentity class

Option Explicit

Public Function HasPermission(ByVal sGroupName As String) As Boolean
    HasPermission = True ' grant all to a developer
End Function

Public Function LogonUsername() As String
    LogonUsername = Application.UserName
End Function

The Dependency Injector module, modDependencyInjector

So the point about dependency injection is that the business classes do not assemble their dependencies (services) themselves; it is done externally. But they have to be decided somewhere, that is why we have a separate module, modDependencyInjector, which maintains a collection (I have chosen Scripting.Dictionary) of services waiting to be injected into any new business logic classes. In the listing below one can see that one chooses Logger class and not both (which breaks the Dictionary). In a better example, a configuration file could be read to determine which services class to select.

The dependency injection mechanism is implemented in InjectDependenciesToDict() where a request dictionary is populated; this is called from a class's constructor. Technically this isn't injection; in C# a framework could read a class's meta data and be clever in its mechanism. As we are using the venerable VBA then such as clever mechanism is beyond reach.

Option Explicit

Private mdicServices As Scripting.Dictionary

Public Property Get Services() As Scripting.Dictionary
    
    If mdicServices Is Nothing Then
        Set mdicServices = New Scripting.Dictionary
        
        '* choose a logger
        mdicServices.Add "Logger", New ImmediateWindowLogger
        'mdicServices.Add "Logger", New TempFileLogger
        
        '* choose an identity service
        mdicServices.Add "Identity", New DevelopmentIdentity
        
    End If
    Set Services = mdicServices
End Property

Sub InjectDependenciesToDict(ByVal dicDependenciesOfNewObject As Scripting.Dictionary, _
            vDependencies As Variant)
    
    Dim dicServices As Scripting.Dictionary
    Set dicServices = Services
    
    Dim vDependency As Variant
    For Each vDependency In vDependencies
        If dicServices.Exists(vDependency) Then
            Set dicDependenciesOfNewObject.Item(vDependency) = dicServices.Item(vDependency)
        End If
    Next
    
End Sub

The Business Logic classes, TaxCalculator and TaxBiller

In the business logic classes below (whose purpose is self-explanatory, I hope) one can see the services being requested in the class constructor's Class_Initialize(). The returned services are kept in a class level dictionary. To access a service in the body of the class one calls into the Item method of that dictionary. So to get the logging service one writes mdicServices("Logger") and to get the identity service one writes mdicServices("Identity"). Note, that Item is called implicitly as it is the default method.

Here is the TaxCalculator class

Option Explicit

Private mdicServices As New Scripting.Dictionary

Private Sub Class_Initialize()
    InjectDependenciesToDict mdicServices, Array("Logger", "Identity")
End Sub

Public Function CalculateTax(ByVal lAmount As Long) As Long
    If mdicServices("Identity").HasPermission("Taxcalc") Then
        CalculateTax = lAmount * 0.2
        mdicServices("Logger").Log "Authorised, Calculated tax at 20%"
    Else
        mdicServices("Logger").Log "Not authorised to run this"
    End If
End Function

Here is the TaxBiller class

Option Explicit

Private mdicServices As New Scripting.Dictionary

Private Sub Class_Initialize()
    InjectDependenciesToDict mdicServices, Array("Logger")
End Sub

Public Sub SendTaxBill(ByVal lAmount As Long)
    mdicServices("Logger").Log "Bill for $" & lAmount & " sent to Mr Simpson"
End Sub

Some client code

So we need to illustrate how easy it is to call this code. In the client code below, one can see the dependency injection is completely absent and thus unobtrusive.

Option Explicit

Sub Test()

    Dim oTaxCalculator As TaxCalculator
    Set oTaxCalculator = New TaxCalculator

    Dim oTaxBiller As TaxBiller
    Set oTaxBiller = New TaxBiller

    Dim lTaxable As Long
    lTaxable = 50
    
    Dim lBill As Long
    lBill = oTaxCalculator.CalculateTax(lTaxable)
    oTaxBiller.SendTaxBill lBill

End Sub

Final thoughts

The term Dependency injection implies some mechanism takes over the instantiation and initialization of a class whereas the above code does not do that. Nevertheless, services are decoupled so the objective is achieved. Enjoy!

Monday, 15 April 2019

IDTExtensibility2 - Addin, catching the Excel Application

I was revising the topic of writing a VBA addin using the IDTExtensibility2 interface and it struck me that the Excel.Application object is not passed in. Instead, in the argument list for OnConnect the first argument is of type VBE. This makes sense because the addin is likely to add menus to the menu bars of the development environment. However, I think it would have been nice to also pass the Excel.Application object because the addin may well want to add workbooks etc.

I set about remedying the this and hits some problems. I solved this problems along the and I am happy to share working code below.

So acquiring the running instance of Excel turned out to be quite challenging. I had hoped using the System.Diagnostics.Process class would give me easy access but it didn't; even the MainWindowHandle returned the VBE's development window instead Excel's main window. Eventually, I had to grab the process id and then look for Excel windows registered as children of the desktop window and match the owning process id. Once in possession of an XLMAIN handle I grab the XLDESK child handle and then in turn its EXCEL7 child handle. Finally, we can pass the EXCEL7 handle to the AccessibleObjectFromWindow() to acquire an IDispatch interface to an Excel workbook's window and then we navigate to the Excel.Application object. All this extra code is housed in the Accessibility class. N.B. this method is dependent of at least one workbook being present (even if it is a .XLAM hidden workbook).

The C# source code follows, this is a C# Assembly DLL project. It does have some extra registration entries, see underneath.


using System;
using System.Runtime.InteropServices;
using System.ComponentModel;
using Xl = Microsoft.Office.Interop.Excel;  // Add reference to Microsoft Excel type library
using System.Diagnostics;
using Extensibility; // Add reference to 'C:\Program Files (x86)\Common Files\microsoft shared\MSEnv\PublicAssemblies\extensibility.dll'
using Microsoft.Vbe.Interop;

/* In Project Properties Build tab, check the checkbox 'Register for COM Interop' */
/* In Project Properties Debug tab, edit 'Start external program' to C:\Program Files\Microsoft Office 15\root\office15\excel.exe */

namespace VBAIDEAddin
{

    [
        ComVisible(true),
        Guid("1D5F24D3-E24C-4283-AAA9-AAED51B146F7"), 
        ProgId("VBAIDEAddin.Connect"),
        ClassInterface(ClassInterfaceType.None),
        ComDefaultInterface(typeof(IDTExtensibility2)),
        EditorBrowsable(EditorBrowsableState.Never)
    ]
    public class Class1 : IDTExtensibility2
    {
        private VBE _vbe;
        Xl.Application xlApp = null;
        private AddIn _AddIn;

        public void OnConnection(object Application, ext_ConnectMode ConnectMode, object AddInInst, ref Array custom)
        {
            try
            {
                _vbe = (VBE)Application; // cast to strongly typed
                _AddIn = (AddIn)AddInInst;

                if (Accessibility.GetCurrentProcess(ref xlApp))
                {
                    xlApp.StatusBar = "addin was here";
                }

                switch (ConnectMode)
                {
                    case Extensibility.ext_ConnectMode.ext_cm_Startup:
                        break;
                    case Extensibility.ext_ConnectMode.ext_cm_AfterStartup:
                        InitializeAddIn();

                        break;
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }
        }

        public void OnDisconnection(ext_DisconnectMode RemoveMode, ref Array custom)
        {
            switch (RemoveMode)
            {
                case ext_DisconnectMode.ext_dm_UserClosed:
                    ShutdownAddIn();
                    break;

                case ext_DisconnectMode.ext_dm_HostShutdown:
                    // some hosts do not call OnBeginShutdown: this mitigates it.
                    ShutdownAddIn();
                    break;
            }
        }

        public void OnAddInsUpdate(ref Array custom)
        { /*throw new NotImplementedException(); */  }

        public void OnStartupComplete(ref Array custom)
        { /*throw new NotImplementedException(); */  }

        public void OnBeginShutdown(ref Array custom)
        { /*throw new NotImplementedException(); */  }

        private void ShutdownAddIn()
        {  }

        private void InitializeAddIn()
        {  }
    }


    public class Accessibility
    {
        [DllImport("user32.dll", SetLastError = true)]
        static extern IntPtr FindWindowEx(IntPtr hwndParent, IntPtr hwndChildAfter, string lpszClass, string lpszWindow);

        [DllImport("oleacc.dll", SetLastError = true)]
        internal static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint id, ref Guid iid,
                                                    [In, Out, MarshalAs(UnmanagedType.IUnknown)] ref object ppvObject);

        [DllImport("kernel32.dll")]
        static extern uint GetCurrentProcessId();

        [DllImport("user32.dll")]
        static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId);

        public static bool GetCurrentProcess(ref Xl.Application appRetVal)
        {
            bool bRetVal = false;
            appRetVal = null;
            Process currentProcess = Process.GetCurrentProcess();
            if (currentProcess.MainModule.FileName.EndsWith("excel.exe", StringComparison.CurrentCultureIgnoreCase))
            {
                bRetVal = GetXlMainByProcessId(currentProcess.Id, ref appRetVal);
            }
            return bRetVal;
        }

        static bool GetXlMainByProcessId(int processId, ref Xl.Application appRetVal)
        {
            bool bRetVal = false;
            appRetVal = null;

            IntPtr hWndXlMain = FindWindowEx(IntPtr.Zero, IntPtr.Zero, "XLMAIN", null);
            while (hWndXlMain != IntPtr.Zero)
            {
                int hOwnerProcess = 0;
                GetWindowThreadProcessId(hWndXlMain, out hOwnerProcess);
                if (processId == (int)hOwnerProcess)
                {
                    IntPtr lHwndDesk = FindWindowEx(hWndXlMain, IntPtr.Zero, "XLDESK", "");
                    if (lHwndDesk != IntPtr.Zero)
                    {
                        IntPtr lHwndExcel7 = FindWindowEx(lHwndDesk, IntPtr.Zero, "EXCEL7", null);
                        if (lHwndExcel7 != IntPtr.Zero)
                        {
                            Guid IID_IDispatch = new Guid("{00020400-0000-0000-C000-000000000046}");
                            const uint OBJID_NATIVEOM = 0xFFFFFFF0;
                            object app = null;
                            if (AccessibleObjectFromWindow(lHwndExcel7, OBJID_NATIVEOM, ref IID_IDispatch, ref app) == 0)
                            {
                                dynamic appWindow = app;
                                appRetVal = appWindow.Application;
                                return true;
                            }
                        }
                    }
                }
                hWndXlMain = FindWindowEx(IntPtr.Zero, hWndXlMain, "XLMAIN", null);
            }
            return bRetVal;
        }
    }
}

Here are the extra registry entries. Copy it to notepad and save off as a .reg file then double click on it to write entries to registry. If you are using this code for your own addin then please change the GUIDs (as they are meant to be unique).


Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\Software\Microsoft\VBA\VBE\6.0\Addins\VBAIDEAddin.Connect]
"CommandLineSafe"=dword:00000000
"Description"="Cut down version"
"LoadBehavior"=dword:00000000
"FriendlyName"="VBAIDEAddin"


[HKEY_CLASSES_ROOT\CLSID\1D5F24D3-E24C-4283-AAA9-AAED51B146F7]
@="VBAIDEAddin.Connect"

[HKEY_CLASSES_ROOT\CLSID\1D5F24D3-E24C-4283-AAA9-AAED51B146F7\Implemented Categories]

[HKEY_CLASSES_ROOT\CLSID\1D5F24D3-E24C-4283-AAA9-AAED51B146F7\InprocServer32]
@="mscoree.dll"
"ThreadingModel"="Both"
"Class"="VBAIDEAddin.Connect"
"Assembly"="YourAssemblyNameFullTypeName"
"RuntimeVersion"="v4.0.30319"
"CodeBase"="file:///C:/Users/Simon/source/repos/VBAIDEAddin/VBAIDEAddin/bin/Debug/VBAIDEAddin.dll"

[HKEY_CLASSES_ROOT\CLSID\1D5F24D3-E24C-4283-AAA9-AAED51B146F7\ProgId]
@="VBAIDEAddin.Connect"

Sunday, 14 April 2019

Multiple instances of Excel

Past versions of Excel will create new processes when asked and one can go look at the Task Manager for proof. Of late Excel will merge instances. For most people this is probably a good feature. However if you want to restore process isolation then one has to fiddle in the registry.

This Microsoft Support article shows how to add a registry setting but it does not work for me!

In the meantime I have discovered that holding the ALT key down whilst launching new Excel instance throws a dialog box saying "Do you want to start a new instance of Excel? (Yes/No)", this is shown in this Microsoft article. This is a good workaround for the time being but I would like the registry setting to work.

if you happen to know please add a comment below or tweet me @excel_developer or you can answer the StackOverflow question I have opened .

On StackOverflow I have been advised from that a command line Excel.exe /x – will generate new instance.

VBA - Windows API - Find all windows by class and then filter by process id

I needed some code to find all the top windows of Excel, that is of class XLMAIN and then filter these by process id. This is because elsewhere I am writing some C# code to acquire an instance of Excel via its window handles but all I have been given is a process id. Because I like to test logic in VBA I have written a VBA version. I am happy to share.

To begin, I use FindWindowEx to find all the windows I am interested in. All Excel instances have a main window of a class XLMAIN, and all of these have a parent window of the desktop. So supply zero for the first argument to signify the desktop and supply XLMAIN as the third argument, the fourth argument ignored. The second argument is used but varies, it allows us to loop through multiple results, we supply the previous result to get the next result. So I wrote a function to collect these to a collection, the function is called AllWindowsByClass(). The function is paramterised so that it can find windows of other class types.

Once in possession of a collection of window handles, I want to filter by a process id that I have been given. GetWindowThreadProcessId is the windows API that is best for this. In the function FilterWindowHandlesByProcessId() I loop through the a collection of handles and filter them to a new collection.

Enjoy!


Option Explicit
Option Private Module

Private Declare Function FindWindowEx Lib "user32" Alias "FindWindowExA" (ByVal hWndParent As Long, _
ByVal hWndChildAfter As Long, ByVal lpszClass As String, ByVal lpszWindow As String) As Long

Private Declare Function GetWindowThreadProcessId Lib "user32.dll" (ByVal hwnd As Long, lpdwProcessId As Long) As Long

Private Function AllWindowsByClass(ByVal sClass As String) As VBA.Collection
    Dim colRet As VBA.Collection
    Set colRet = New VBA.Collection
    
    Dim hWndParent As Long
    
    hWndParent = FindWindowEx(0, 0, sClass, vbNullString)
    While hWndParent <> 0
        colRet.Add hWndParent
        hWndParent = FindWindowEx(0, hWndParent, sClass, vbNullString)
    
    Wend
    
    Set AllWindowsByClass = colRet
End Function


Private Function FilterWindowHandlesByProcessId(ByVal colWindowsHandles As VBA.Collection, ByVal lFilterProcessId As Long) As VBA.Collection
    Dim colRet As VBA.Collection
    Set colRet = New VBA.Collection

    Dim lLoop As Long
    For lLoop = 1 To colWindowsHandles.Count
        Dim lWinHandle As Long
        lWinHandle = colWindowsHandles.Item(lLoop)
    
        Dim lProcessId As Long
        GetWindowThreadProcessId lWinHandle, lProcessId
        
        If lProcessId = lFilterProcessId Then
            colRet.Add lWinHandle
        End If
    Next

    Set FilterWindowHandlesByProcessId = colRet
End Function



Here are some test functions but they have hard coded values that were valid for me and I determined whilst looking at Spy++ and other Windows diagnostic tools. Nevertheless they demonstrate how to call the above functions.



'************************************************************************************************
'* TEST FUNCTIONS
'************************************************************************************************

Private Sub TestAllWindowsByClass()
    Dim col As VBA.Collection
    Set col = AllWindowsByClass("XLMAIN")
    Debug.Assert col.Count = 2  'may differ for you!
End Sub

Private Sub TestFilterWindowHandlesByProcessId()
    
    Dim colWinHandles As VBA.Collection
    Set colWinHandles = AllWindowsByClass("XLMAIN")

    Debug.Assert colWinHandles.Count = 2

    Dim lTestProcessId As Long
    lTestProcessId = 24272 ' a process currently running on my PC, probably will differ for you!

    Dim colFiltered As VBA.Collection
    Set colFiltered = FilterWindowHandlesByProcessId(colWinHandles, lTestProcessId)

    Debug.Assert colFiltered.Count = 1
End Sub

Saturday, 16 March 2019

VBA - Picking Nice Graph Axes

Have you ever pondered writing your own chart logic? Unlikely if you use Excel VBA given Excel's excellent chart support. However, I have been looking at writing a XAML graph control but I wanted to road test some logic in VBA first. The first problem I encountered is how to pick nice numbers for your graph axis, the algorithm is not obvious and I had to google before I found a popular answer on Stack Overflow.

So below I have given a VBA equivalent, sort of. The original given code was quite classy when it needn't be given that we are not holding state. I think a functional implementation is more appropriate and so I have written the VBA as a series of functions for a standard module. Also, I wanted to call from the worksheet.

One other point to note is that I have been influenced by Python of late; in Python one can return a tuple from a function which is a plural of return values. In VBA, we can use the Array() function to pack values into an equivalent of a tuple, a one-dimensional variant array.

Option Explicit

'* with thanks to
'http://erison.blogspot.com/2011/07/algorithm-for-optimal-scaling-on-chart.html
'https://stackoverflow.com/questions/8506881/nice-label-algorithm-for-charts-with-minimum-ticks/16363437#16363437

Public Function CalculateNiceScale(ByVal dMin As Double, ByVal dMax As Double, Optional maxTicks As Long = 10)

    Dim tickSpacing As Double
    Dim range As Double
    Dim niceMin As Double
    Dim niceMax As Double
    Dim tickCount As Double

    range = niceNum(dMax - dMin, False)
    tickSpacing = niceNum(range / (maxTicks - 1), True)
    niceMin = Math_Floor(dMin / tickSpacing) * tickSpacing
    niceMax = Math_Ceiling(dMax / tickSpacing) * tickSpacing
    
    tickCount = (niceMax - niceMin) / tickSpacing
    
    CalculateNiceScale = Array(tickSpacing, niceMin, niceMax, tickCount, dMin, dMax, maxTicks)
    
End Function

Private Function Math_Floor(ByVal dIn As Double) As Double
    Math_Floor = Application.WorksheetFunction.Floor(dIn, 1)
End Function

Private Function Math_Ceiling(ByVal dIn As Double) As Double
    Math_Ceiling = Application.WorksheetFunction.Ceiling(dIn, 1)
End Function

Private Function Math_Log10(ByVal dIn As Double) As Double
    Math_Log10 = Log(Abs(dIn)) / Log(10)
End Function


Private Function niceNum(range As Double, round As Boolean) As Double
    Dim exponent As Double ' /** exponent of range */
    Dim fraction As Double ' /** fractional part of range */
    Dim niceFraction As Double ' /** nice, rounded fraction */

    exponent = Math_Floor(Math_Log10(Abs(range)))
    fraction = range / 10 ^ exponent

    If round Then
        If fraction < 1.5 Then
            niceFraction = 1
        ElseIf fraction < 3 Then
            niceFraction = 2
        ElseIf fraction < 7 Then
            niceFraction = 5
        Else
            niceFraction = 10
        End If
    Else
        If fraction <= 1 Then
            niceFraction = 1
        ElseIf fraction <= 2 Then
            niceFraction = 2
        ElseIf fraction <= 5 Then
            niceFraction = 5
        Else
            niceFraction = 10
        End If
    End If
    
    niceNum = niceFraction * 10 ^ exponent

End Function

Sub testCalculateNiceScale()
    Dim v
    v = CalculateNiceScale(-0.085, 0.173)
    PrintResults v
End Sub


Sub testCalculateNiceScale2()
    Dim v
    v = CalculateNiceScale(1.2813, 1.331, 8)
    PrintResults v

    v = CalculateNiceScale(1.2813, 1.331)
    PrintResults v
End Sub

Sub PrintResults(v)
    Debug.Print
    Debug.Print "min:" & v(4)
    Debug.Print "max:" & v(5)
    Debug.Print "maxTicks:" & v(6)
    Debug.Print "TickSpacing:" & v(0)
    Debug.Print "NiceMin:" & v(1)
    Debug.Print "NiceMax:" & v(2)
    Debug.Print "TickCount:" & CInt(v(3))
End Sub