🕌

mael を VBA に移植する試み

に公開

mael とは

mael とは、Markdown ファイルから、Excel ファイルを生成できる Python パッケージです。

参考文献

テストケースを Excel にしたいなら、以下の方が良いかもしれません。

なぜ VBA に移植しようと考えたか

いまどき VBA かよ、といのはごもっともですが…。

  • 職場のセキュリティポリシー上、Python やモジュールをインストールできないため。
  • VBA であれば、配布先に追加のプログラムを配布する必要がない。

どこまで再現するか

  • yaml の処理はハードルが上がりそう -> config は Excel ブックにする?
    • とりあえず config 周りは後回し
  • 列幅は Excel での単位と一緒にしたい
  • 列の型は、StringList の二種類があるが、両方に対応するのか?
    • まずは String のみに対応する。
  • mael init に当たる操作を実現するか? -> とりあえず実装しない
  • まずは markdown から Excel シートに転記する部分を再現したい
    • mael では複数のファイルを変換できるが、とりあえず単一ファイルの変換を実現する。

技術的課題

VBA で正規表現を扱うには?

参照設定で Microsoft VBScript Regular Expressions 5.5 を追加し、RegExp オブジェクトを使用する。

参考文献

VBA で BOM なし UTF-8 なテキストファイルを読み込むには?

参照設定で Microsoft ActiveX Data Objects X.X Library を追加し、ADODB.Stream オブジェクトを使用する。

参考文献

VBA で動的リストや辞書を扱うには?

組み込みの CollectionDictionary を使うか、.NET の System.Collections.ArrayList を使う。

参考文献

VBA でクラスを利用するには?

クラスモジュール を利用する。

参考文献

VBA で列挙型を利用するには?

Enum ステートメントを使う。

参考文献

Doxygen でのドキュメント化を考慮する

VB.NET 用のフィルタを流用する。いくつか種類があるが、awk 版 doxygen-vb-filter の使用を考慮する。

コメントは、VB.NET Style を採用する。

参考文献

移植作業

変換のメイン処理部分を確認

mael の mael/main.py を確認し、引数に build が指定された場合の処理 を確認する。

すると以下の記述で import された mael/excel_builder.pyconvert が呼び出されていることが分かる。

from .excel_builder import convert

Excel への転記は composer のお仕事らしい

mael/excel_builder.pyconvert最終行 では、mael/composer.py で定義されている ExcelComposer クラスの compose メソッドが呼び出されている。

セルの内容を管理するのは StepItem クラス

mael/excel_builder.pyStepItem クラス がセルの内容を管理するクラスである。

このクラスには、4つのプロパティと2つのメソッドがある。

プロパティ 役割
title 列のタイトル
type 列のタイプ
content_lines 内容の行リスト
content_items 内容の項目リスト
メソッド 役割
add_content_line typeに応じて content_linesadd_content_items に内容を加える
get_content typeに応じて content_lines を結合した文字列か add_content_items を返す

てか、Python はメソッドの返却型を複数指定できるのか…

若干、実装が異なるが、VBA で書くとこんな感じかな…

''' <summary>Class StepItem</sammary>

Option Explicit

Public title As String
Private itemType As ValueType
Private contentLines As Collection
Private contentItems As Collection

''' <summary>Constructor</sammary>
Private Sub Class_Initialize()
    title = ""
    itemType = TYPE_STRING
    Set contentLines = New Collection
    Set contentItems = New Collection
End Sub

Public Sub Init(title_ As String, type_ As ValueType)
    title = title_
    itemType = type_
End Sub

''' <summary>Add line of Content</sammary>
''' <params id="content"></param>
Public Sub AddContentLine(content As String)
    If itemType = TYPE_STRING Then
        contentLines.Add (content)
    ElseIf itemType = TYPE_LIST Then
        contentItems.Add (content)
    End If
End Sub

''' <summary>Get lines of Content</sammary>
''' <returns>lines of Content</returns>
Public Function GetContent() As Collection
    If itemType = TYPE_STRING Then
        Set GetContent = contentLines
    ElseIf itemType = TYPE_LIST Then
        Set GetContent = contentItems
    End If
End Function

まずは markdown から1行ずつ読み出しセルに転記してみる

'''<summary>Convert Markdown to Sheet</summary>
'''<params id="filePath"></params>
Sub Convert(filePath As String)
    Dim adoStream As New ADODB.Stream
    Dim rowNumber As Long
    Dim line As String
    
    With adoStream
        .Type = adTypeText
        .Charset = "UTF-8"
        .LineSeparator = adLF
        
        .Open
        .LoadFromFile filePath
        
        rowNumber = 1
        Do Until .EOS
            line = .ReadText(-2)
            Cells(rowNumber, 1).Value = line
            rowNumber = rowNumber + 1
        Loop
            
        .Close
    End With
End SubSub Convert(filePath As String)
    Dim adoStream As New ADODB.Stream
    Dim rowNumber As Long
    Dim line As String
    
    With adoStream
        .Open
        .Type = adTypeText
        .Charset = "UTF-8"
        .LineSeparator = adLF
        .LoadFromFile filePath
        
        rowNumber = 1
        Do Until .EOS
            line = .ReadText(-2)
            Cells(rowNumber, 1).Value = line
            rowNumber = rowNumber + 1
        Loop
            
        .Close
    End With
End Sub

テスト仕様書をGitで管理する: MarkdownからExcelを作る #テストシナリオの例にある markdown ファイルを読み込ませると下図のようになる。

文書のタイトルを取得してシートを追加する部分を作成してみる

mael/excel_builder.py167行目から始まる処理を移植する。

まず、シート名は使えない文字があのでそれを正規化する関数を用意する。使えない文字を検索したら、Google AI がコード例を出してくれたので流用する。

''' <summary>Normalize Sheet Name</sammary>
Function EscapeSheetName(ByRef sheetName As String) As String
    Dim reservedChars As String
    Dim i As Long
    Dim escapedName As String

    reservedChars = "\/?*" & Chr(34) & "<>" & "|"
    escapedName = sheetName

    For i = 1 To Len(reservedChars)
        escapedName = Replace(escapedName, Mid(reservedChars, i, 1), "_")
    Next i

    EscapeSheetName = escapedName
End Function

よく見たら、テストコードまで含まれていた。

Sub TestEscapeSheetName()
    Dim sheetName As String
    sheetName = "テストシート\/?*" & Chr(34) & "<>|"
    Debug.Print EscapeSheetName(sheetName) ' Output: テストシート_____"____
End Sub

前項のコードで Do Until .EOS ブロックの前に以下のコードを追加した。

        ' set name
        Do Until .EOS
            line = .ReadText(-2)
            
            With New RegExp
                .Pattern = "^#[^#]\s*(\S.*)\s*$"
                Set mc = .Execute(line)
                If mc.Count > 0 Then
                    ActiveWorkbook.Sheets.Add
                    ActiveSheet.Name = EscapeSheetName(mc(0).SubMatches(0))
                    Exit Do
                End If
            End With
        Loop
        
        If .EOS Then
            MsgBox "Can not find title."
            Exit Sub
        End If

Summary の処理

mael/excel_builder.py178行目202行目までの処理を移植する。

        ' set summary
        Dim hasSammary As Boolean
        hasSammary = False
        Do Until .EOS
            line = .ReadText(-2)
            
            With New RegExp
                .Pattern = "^##\s*Summary\s*$"
                If .Test(line) Then
                    hasSammary = True
                    Exit Do
                End If
            End With
        Loop
               
        If hasSammary Then
            With Cells(rowNumber, 1)
                .Value = "Summary"
                .Font.Bold = True
                rowNumber = rowNumber + 2
            End With
        End If
        
        ' read summary lines
        Do Until .EOS
            line = .ReadText(-2)
            
            With New RegExp
                .Pattern = "^##\s*(List|Steps|Rows)\s*$"
                If .Test(line) Then
                    rowNumber = rowNumber + 1
                    Exit Do
                End If
            End With
            
            Cells(rowNumber, 1).Value = RTrim(line)
            rowNumber = rowNumber + 1
        Loop

表の内容にあたる部分の処理

続いて、表の内容にあたる部分を steps という Collection 型の変数に積んでいく。

        ' read steps
        Dim steps As New Collection
        Dim stepDict As New Scripting.Dictionary
        Dim columns As New Scripting.Dictionary
        Dim item As StepItem
        Dim title As String
        Set item = Nothing
        Do While True
            line = .ReadText(-2)
            
            If .EOS Then
                If Not item Is Nothing Then
                    stepDict.Add item.title, item.GetContent()
                End If
                If stepDict.Count > 0 Then
                    steps.Add stepDict
                End If
                Exit Do
            End If
            
            With New RegExp
                .Pattern = "^\s*---\s*$"
                If .Test(line) Then
                    If Not item Is Nothing Then
                        stepDict.Add item.title, item.GetContent()
                        Set item = Nothing
                    End If
                    If stepDict.Count > 0 Then
                        steps.Add stepDict
                        Set stepDict = New Scripting.Dictionary
                    End If
                    GoTo CONTINUE
                End If
                
                .Pattern = "^#{3,}\s*(\S.*\S|\S)\s*$"
                Set mc = .Execute(line)
                If mc.Count > 0 Then
                    If Not item Is Nothing Then
                        stepDict.Add item.title, item.GetContent()
                    End If
                    
                    title = mc(0).SubMatches(0)
                    If Not columns.Exists(title) Then
                        columns.Add title, ""
                    End If
                    
                    Set item = New StepItem
                    item.Init title, TYPE_STRING
                    GoTo CONTINUE
                End If
                
                If Not item Is Nothing Then
                    item.AddContentLine (RTrim(line))
                End If
            End With
            'Cells(rowNumber, 1).Value = line
            'rowNumber = rowNumber + 1]
CONTINUE:
        Loop

そして、実際の表に転記する処理を実装。

        Dim key As Variant
        Dim colNumber As Long
        colNumber = 1
        For Each key In columns.Keys
            titleRow = rowNumber
            With Cells(rowNumber, colNumber)
                .Value = key
                .Font.Bold = True
                .HorizontalAlignment = xlCenter
            End With
            colNumber = colNumber + 1
        Next
        
        rowNumber = rowNumber + 1
        
        Dim obj As Object
        Dim content As Collection
        For Each stepDict In steps
            colNumber = 1
            For Each key In columns.Keys
                If stepDict.Exists(key) Then
                    Set content = stepDict.item(key)
                    Cells(rowNumber, colNumber).Value = JoinCollection(content)
                End If
                colNumber = colNumber + 1
            Next
            rowNumber = rowNumber + 1
        Next

最後に罫線を引く。

        ' format borders
        With Range(Cells(titleRow, 1), Cells(rowNumber - 1, columns.Count))
            For index = xlEdgeLeft To xlInsideHorizontal
                With .Borders(index)
                    .LineStyle = xlContinuous
                    .ColorIndex = 0
                    .TintAndShade = 0
                    .Weight = xlThin
                End With
            Next
        End With

Convert 呼び出し部分の実装

ユーザーに変換したい markdow ファイルを選択させ、Convert プロシージャを呼ぶ。

'''<summary>Control Build Process</summary>
Sub Build()
    Dim filePath As Variant
    
    filePath = Application.GetOpenFilename("markdown,*.md")
    
    If filePath = False Then
        Exit Sub
    End If
    
    Convert CStr(filePath)
End Sub

リボンをつける

リボンを編集しようと思い、Office Custom UI Editor をダウンロードしようと思ったら、いつの間にか、Public archive にされてた… 😅

Office RibbonX Editor が、Office Custom UI Editor から fork したらしいのでそちらを利用することにする。

Microsoft の Copilot にアイコンを作成してもらった。

リボンから実行できるように Build プロシージャに引数を追加する。

Sub Build(control As IRibbonControl)

そして、customUI.xml を作成する。

<customUI xmlns="http://schemas.microsoft.com/office/2006/01/customui">
	<ribbon>
		<tabs>
			<tab id="maelTab" label="mael" insertAfterMso="TabView">
				<group id="maelGroup" label="maelForVBA">
					<button id="BuildButton1" label="Build" size="large" onAction="Build" image="Build" />
				</group>
			</tab>
		</tabs>
	</ribbon>
</customUI>

できたリボンは、こんな感じ。

現状の変換結果

テスト仕様書をGitで管理する: MarkdownからExcelを作る #テストシナリオの例にある markdown ファイルを読み込ませると下図のようになる。

近いうちに column_config に対応して、本来の出力に近づけたい。

GitHub のリポジトリ

yasumichi/maelForVBA: mael for VBA is a partial port of mael.

ここまでの成果をバージョン 0.1 として、プレリリースした。

追記

予期せぬ markdown ファイルが与えられた場合に異常終了するバグを修正し、0.1.1 をプレリリースした。

GitHubで編集を提案

Discussion