【実務・中級編】【上級者向け】PDF出力エンジンが生成するPDFのしおり(アウトライン)をVBAでカスタマイズする高度な制御 – Visio VBA解析バイブル

スポンサーリンク

Visio標準PDF出力の「限界」を超える:なぜ今、外部連携アーキテクチャが必要なのか

Microsoft Visioは、仕様書、インフラ構成図、プラント配管図(P&ID)、フローチャートなど、エンタープライズ領域における巨大なドキュメントの基盤として広く君臨しています。そして、これらの図面を他部門や顧客に納品する際、最終アウトプットとして選ばれるのはほぼ100%「PDFフォーマット」です。

しかし、大規模なVSDXファイルをPDFへ変換する際、多くの開発者が打ちぶつかる決定的な「壁」が存在します。それがPDFの「しおり(Outline / Bookmark)」構造の柔軟性の欠如です。

Visio標準のVBAメソッドである `Document.ExportAsFixedFormat` は、非常に高速かつ優秀なPDFレンダリングエンジンですが、出力されるしおりは「ページ名がフラットに並ぶだけ」という極めて貧弱な仕様にとどまります。

【Visio標準出力のしおり構造】
├─ 01_全体構成図
├─ 02_ネットワーク階層
├─ 03_サーバー詳細_A
└─ 04_サーバー詳細_B

【本来あるべき業務仕様のしおり構造】
└─ 第1章 システム概要
├─ 1.1 全体構成図 (Page: 1)
└─ 第2章 ネットワーク設計
├─ 2.1 幹線ネットワーク (Page: 2)
└─ 2.2 サーバー群詳細
├─ サーバーA (Page: 3)
└─ サーバーB (Page: 4)

100ページを超える設計書PDFにおいて、階層化されていない「ただのページ一覧」は非機能要件(ユーザビリティ)を著しく損ないます。

本記事では、Visio VBAの内部完結に固執せず、「Visio VBAによる図面構造の解析・メタデータ抽出」と「外部ポストアセンブラによるPDFアウトラインの再構築」を疎結合に組み合わせた、エンタープライズグレードの自動化アーキテクチャを完全解説します。

—

堅牢な処理を保証する3層アーキテクチャ設計

バグのない堅牢な自動化ツールを構築するためには、単一のVBAマクロ内にすべての処理を詰め込む「スパゲティコード」を排除しなければなりません。特にPDFのバイナリ構造(XRefテーブルやCosオブジェクト)をVBA単体で無理に操作しようとすることは、メモリリークと保守性の壊滅を招きます。

本ソリューションでは、以下の3層アーキテクチャを採用します。

+——————————————————-+
| Layer 1: Visio VBA Engine |
| – 図面(VSDX)の走査、シェイプ/ページ階層メタデータ抽出 |
| – Visio標準エンジンによる「無地PDF」の一時出力 |
| – 抽出したしおり構造を JSON/XML へシリアライズ |
+——————————————————-+
│
▼ (IPC: 引数 & ファイル経由)
+——————————————————-+
| Layer 2: External Post-Processing Engine |
| – Python (pypdf) または PowerShell (iText) スクリプト |
| – JSONメタデータを読み込み、一時PDFのしおりツリーを挿入 |
+——————————————————-+
│
▼
+——————————————————-+
| Layer 3: Output Artifact |
| – 完全な階層付きしおりを備えた納品用PDFの生成 |
+——————————————————-+

設計の要点と利点

1. 責務の完全分離(SoC): Visio VBAはVisioオブジェクトモデルの操作(図面解析)に専念し、PDFの構造書き換えは信頼実績のある外部ライブラリに委任する。
2. 非同期および同期制御の厳格化: VBAから外部プロセスを呼び出す際、`WScript.Shell` の同期実行制御(`WaitOnReturn = True`)を行い、終了コード(Exit Code)と標準エラーを正しくハンドリングする。
3. ファイルロックの回避: Visioが出力したPDFを外部プロセスが読み込み、別名(あるいは完了後に上書き)保存することで、ファイル記述子(File Descriptor)の競合を完全に防ぐ。

—

【実装編1】Visio VBA:階層構造の解析とメタデータ出力

まずはVisio VBA側の実装です。
以下のコードは、図面内の各ページ、および特定のカスタムプロパティ(User定義セルやShape.Text)から「階層レベル」「タイトル」「ページ番号」を取得し、JSON構造テキストを出力すると同時に、ベースとなる一次PDFを出力します。

事前準備(VBA参照設定)

VBAエディタの `ツール` -> `参照設定` から以下を有効化してください:

  • Microsoft Scripting Runtime (`scrrun.dll`)

生産用VBAモジュール (`mod_PdfExporter.bas`)

Attribute VB_Name = “mod_PdfExporter”
Option Explicit

‘ ==============================================================================
‘ システム名: Visio Advanced PDF Exporter
‘ 説明: Visio図面から階層構造メタデータを抽出し、標準PDFを出力後に
‘ 外部Pythonスクリプトを呼び出してしおりを動的再構築する。
‘ ==============================================================================

Public Sub ExportPdfWithCustomBookmarks()
On Error GoTo ErrorHandler

Dim vsoDoc As Visio.Document
Set vsoDoc = Visio.ActiveDocument

If vsoDoc Is Nothing Then
MsgBox “アクティブなVisioドキュメントが存在しません。”, vbExclamation, “エラー”
Exit Sub
End If

‘ アプリケーション最適化(画面描画・イベント抑制)
Dim originalScreenUpdating As Boolean
Dim originalEnableEvents As Boolean
originalScreenUpdating = Visio.Application.ScreenUpdating
originalEnableEvents = Visio.Application.EventsEnabled

Visio.Application.ScreenUpdating = False
Visio.Application.EventsEnabled = False

‘ 出力パスの設定
Dim basePath As String
basePath = vsoDoc.Path
If basePath = “” Then
MsgBox “ドキュメントを保存してから実行してください。”, vbExclamation, “エラー”
GoTo CleanUp
End If

Dim rawPdfPath As String
Dim finalPdfPath As String
Dim jsonPath As String
Dim scriptPath As String

rawPdfPath = basePath & “temp_raw.pdf”
finalPdfPath = basePath & Replace(vsoDoc.Name, “.vsdx”, “.pdf”, , , vbTextCompare)
jsonPath = basePath & “bookmarks.json”
scriptPath = basePath & “inject_bookmarks.py”

‘ 1. メタデータ(JSON)の抽出構築
Debug.Print “階層メタデータを抽出中…”
Dim jsonContent As String
jsonContent = ExtractBookmarkMetadataJson(vsoDoc)

‘ JSONファイルの物理保存
WriteTextFile jsonPath, jsonContent

‘ 2. Visio標準機能で一次PDFを出力
Debug.Print “一次PDFを出力中…”
vsoDoc.ExportAsFixedFormat _
FixedFormat:=visFixedFormatPDF, _
OutputFileName:=rawPdfPath, _
Intent:=visDocExIntentScreen, _
PrintRange:=visPrintAll, _
DocInfo:=True, _
KeepNoLineProps:=False, _
DocToPageForPrint:=False, _
IncludeBackground:=False

‘ 3. Pythonポストアセンブラのインフラ生成(スクリプト自動吐き出し)
GeneratePythonProcessorScript scriptPath

‘ 4. 外部プロセスの同期実行とハンドリング
Debug.Print “外部プロセスでしおりを注入中…”
Dim cmd As String
‘ Python実行環境を呼び出し(環境に合わせてpythonのパスを調整)
cmd = “python “”” & scriptPath & “”” “”” & rawPdfPath & “”” “”” & jsonPath & “”” “”” & finalPdfPath & “”””

Dim exitCode As Long
exitCode = ExecuteExternalCommand(cmd)

If exitCode = 0 Then
MsgBox “高機能しおり付きPDFの生成が完了しました。” & vbCrLf & finalPdfPath, vbInformation, “成功”
Else
MsgBox “PDFしおりの再構築プロセスでエラーが発生しました。Exit Code: ” & exitCode, vbCritical, “処理失敗”
End If

CleanUp:
‘ 一時ファイルのクリーンアップ
DeleteFileIfExists rawPdfPath
DeleteFileIfExists jsonPath
DeleteFileIfExists scriptPath

‘ 環境設定のリセット
Visio.Application.ScreenUpdating = originalScreenUpdating
Visio.Application.EventsEnabled = originalEnableEvents
Exit Sub

ErrorHandler:
MsgBox “致命的エラーが発生しました: ” & Err.Description, vbCritical, “実行時エラー”
Resume CleanUp
End Sub

‘ ——————————————————————————
‘ 関数: 図面からしおり階層構造を解析しJSON文字列を構築
‘ ——————————————————————————
Private Function ExtractBookmarkMetadataJson(ByRef vsoDoc As Visio.Document) As String
Dim pageObj As Visio.Page
Dim jsonBuilder As String
jsonBuilder = “[” & vbCrLf

Dim isFirst As Boolean
isFirst = True

Dim pageIndex As Long
pageIndex = 1

For Each pageObj In vsoDoc.Pages
‘ 背景ページは除外
If pageObj.Background = 0 Then
Dim pageTitle As String
Dim indentLevel As Long

pageTitle = pageObj.Name
indentLevel = 1 ‘ デフォルト階層

‘ Userセルまたはページ名命名規則から階層(レベル)を判定するロジック
‘ 例: ページ名が “1.1_xxx” の場合はLevel 2, “1.1.1_xxx” はLevel 3とする高度判定
If pageObj.PageSheet.CellExists(“User.BookmarkLevel”, 0) <> 0 Then
indentLevel = pageObj.PageSheet.Cells(“User.BookmarkLevel”).ResultInt(visNone, 0)
Else
‘ 命名規則ベースの自動プレフィックス解析
indentLevel = EvaluateLevelFromName(pageTitle)
End If

If Not isFirst Then jsonBuilder = jsonBuilder & “,” & vbCrLf

‘ JSON要素の追加 (Title, Level, PageNumber)
jsonBuilder = jsonBuilder & ” {”
jsonBuilder = jsonBuilder & “””title””: “”” & EscapeJsonString(pageTitle) & “””, ”
jsonBuilder = jsonBuilder & “””level””: ” & indentLevel & “, ”
jsonBuilder = jsonBuilder & “””page””: ” & pageIndex
jsonBuilder = jsonBuilder & “}”

isFirst = False
pageIndex = pageIndex + 1
End If
Next pageObj

jsonBuilder = jsonBuilder & vbCrLf & “]”
ExtractBookmarkMetadataJson = jsonBuilder
End Function

‘ ——————————————————————————
‘ 補助関数: ページ名から階層レベルを推測解析 (例: “1.2.1 タイトル” -> Level 3)
‘ ——————————————————————————
Private Function EvaluateLevelFromName(ByVal nameStr As String) As Long
Dim dotCount As Long
Dim i As Long
dotCount = 0

‘ 最初のスペースまでのドットの数をカウント
Dim firstSpace As Long
firstSpace = InStr(nameStr, ” “)
If firstSpace = 0 Then firstSpace = Len(nameStr)

For i = 1 To firstSpace
If Mid(nameStr, i, 1) = “.” Then
dotCount = dotCount + 1
End If
Next i

EvaluateLevelFromName = dotCount + 1
End Function

‘ ——————————————————————————
‘ 補助関数: JSONエスケープ処理
‘ ——————————————————————————
Private Function EscapeJsonString(ByVal str As String) As String
str = Replace(str, “\”, “\\”)
str = Replace(str, “”””, “\”””)
str = Replace(str, vbCr, “”)
str = Replace(str, vbLf, “”)
EscapeJsonString = str
End Function

‘ ——————————————————————————
‘ 補助関数: テキストファイル書き込み (UTF-8)
‘ ——————————————————————————
Private Sub WriteTextFile(ByVal filePath As String, ByVal content As String)
Dim stream As Object
Set stream = CreateObject(“ADODB.Stream”)
stream.Type = 2 ‘ adTypeText
stream.Charset = “UTF-8”
stream.Open
stream.WriteText content
stream.SaveToFile filePath, 2 ‘ adSaveCreateOverWrite
stream.Close
End Sub

‘ ——————————————————————————
‘ 補助関数: 外部プロセス同期実行
‘ ——————————————————————————
Private Function ExecuteExternalCommand(ByVal command As String) As Long
Dim wsh As Object
Set wsh = CreateObject(“WScript.Shell”)

‘ ウィンドウ非表示(0)、同期実行(True)
Dim exitCode As Long
exitCode = wsh.Run(“cmd.exe /c ” & command, 0, True)
ExecuteExternalCommand = exitCode
End Function

‘ ——————————————————————————
‘ 補助関数: ファイル削除
‘ ——————————————————————————
Private Sub DeleteFileIfExists(ByVal filePath As String)
Dim fso As Object
Set fso = CreateObject(“Scripting.FileSystemObject”)
If fso.FileExists(filePath) Then
On Error Resume Next
fso.DeleteFile filePath, True
On Error GoTo 0
End If
End Sub

—

【実装編2】Pythonポストアセンブラによるしおり(Outline)の動的構造化

次に、Visio VBAから動的に出力(あるいはあらかじめサーバー/ローカルに配置)され、呼び出される処理スクリプトの実装です。ここでは、標準的かつ堅牢なPDFライブラリである `pypdf`(旧PyPDF2の最新標準)を使用します。

このスクリプトは、VBAが吐き出した `bookmarks.json` のフラットなレベル指定リストを読み込み、再帰的なツリー構造(スタックマシン)に展開しながらPDFのアウトラインに注入します。

VBAから自動生成されるポストアセンブラ (`inject_bookmarks.py`)

Visio VBA内の `GeneratePythonProcessorScript` サブルーチンにより、以下のPythonコードが自動的に現場で組み上げられます。

‘ ——————————————————————————
‘ VBA内部でPythonポストアセンブラコードを自動構築するサブルーチン
‘ ——————————————————————————
Private Sub GeneratePythonProcessorScript(ByVal scriptPath As String)
Dim pyCode As String
pyCode = “import sys” & vbCrLf & _
“import json” & vbCrLf & _
“from pypdf import PdfReader, PdfWriter” & vbCrLf & _
“” & vbCrLf & _
“def rebuild_bookmarks(raw_pdf, json_path, output_pdf):” & vbCrLf & _
” reader = PdfReader(raw_pdf)” & vbCrLf & _
” writer = PdfWriter()” & vbCrLf & _
” writer.append(reader)” & vbCrLf & _
“” & vbCrLf & _
” with open(json_path, ‘r’, encoding=’utf-8′) as f:” & vbCrLf & _
” bookmarks = json.load(f)” & vbCrLf & _
“” & vbCrLf & _
” # スタックを用いて親Bookmarkオブジェクトの参照を記録 (Level -> ParentItem)” & vbCrLf & _
” parent_stack = {0: None}” & vbCrLf & _
“” & vbCrLf & _
” for item in bookmarks:” & vbCrLf & _
” title = item[‘title’]” & vbCrLf & _
” level = item[‘level’]” & vbCrLf & _
” page_idx = item[‘page’] – 1 # 0-indexed” & vbCrLf & _
“” & vbCrLf & _
” # 適切な親を取得 (自身のレベルより上位の直近親)” & vbCrLf & _
” parent_level = level – 1″ & vbCrLf & _
” while parent_level > 0 and parent_level not in parent_stack:” & vbCrLf & _
” parent_level -= 1″ & vbCrLf & _
” ” & vbCrLf & _
” parent_item = parent_stack.get(parent_level, None)” & vbCrLf & _
“” & vbCrLf & _
” # しおりの追加” & vbCrLf & _
” new_bookmark = writer.add_bookmark(” & vbCrLf & _
” title=title,” & vbCrLf & _
” page_number=page_idx,” & vbCrLf & _
” parent=parent_item” & vbCrLf & _
” )” & vbCrLf & _
” ” & vbCrLf & _
” # 現在のレベルの最新参照としてスタックに保存” & vbCrLf & _
” parent_stack[level] = new_bookmark” & vbCrLf & _
“” & vbCrLf & _
” with open(output_pdf, ‘wb’) as f_out:” & vbCrLf & _
” writer.write(f_out)” & vbCrLf & _
“” & vbCrLf & _
“if __name__ == ‘__main__’:” & vbCrLf & _
” if len(sys.argv) < 4:" & vbCrLf & _ " sys.exit(1)" & vbCrLf & _ " rebuild_bookmarks(sys.argv[1], sys.argv[2], sys.argv[3])" & vbCrLf & _ " sys.exit(0)" WriteTextFile scriptPath, pyCode End Sub ---

開発現場で頻出する「落とし穴」とその解法

このハイブリッドアーキテクチャをプロダクション環境に導入する際、直面するトラブルシューティングと対策を提示します。

1. Adobe Acrobat / Reader によるファイルロック(排他制御)

現象: 外部プロセスが `final_output.pdf` に書き込もうとした際、ユーザーが既にそのPDFを閲覧中だとプロセスがクラッシュ(Permission Error)する。
対策:
VBA側の呼び出し前に、対象ファイルを開けるかテストする関数(`Open for Binary Access Read Write`)を挟み、ファイルがロックされていれば警告を出して処理を安全に割り込み停止させます。

2. Python未インストール環境へのデプロイ(ポータビリティ問題)

現象: クライアントPCにPythonおよび `pypdf` がインストールされていない。
対策:
1. PyInstaller を用いて、上記のPython処理を単一の実行可能ファイル(`PdfOutlineInjector.exe`)にコンパイルし、社内共有フォルダまたはツール配布パッケージに同梱する。
2. PowerShell環境が標準で提供されているWindows環境であれば、PowerShell + iText7 DLL (NuGet) を組み合わせてVBAからPowerShellスクリプトを実行させる手法へ置き換える。

3. 大規模図面(数百ページ)でのVisio描画オーバーヘッド

現象: ページの走査時にVisioがUI描画を頻繁に更新し、処理時間が膨れ上がる。
対策:
VBAコード内で示した通り、処理開始時に必ず以下を実行してください。

Visio.Application.ScreenUpdating = False
Visio.Application.EventsEnabled = False

さらに、特定のページ内のシェイプを全件ループ走査する場合は、`page.Shapes` の全件走査ではなく、`page.PageSheet`(ページプロパティ)に必要なメタデータを持たせる設計にすることで、メモリ消費と走査時間を100分の一以下に抑制できます。

—

エンジニアとしての高みへ

Visio VBAの真価は、「Visioの閉じた世界の中で処理を完結させること」にはありません。Visioを強力な「ドキュメントのビジュアルデータベース(メタデータリポジトリ)」として捉え、外部のモダンなテキスト/バイナリ処理エンジンと組み合わせることにこそあります。

今回紹介した「VBAによるメタデータ抽出 + ポストアセンブラによる物理フォーマット最適化」というパターンは、PDFのしおり制御にとどまらず、以下のような高度なシステム拡張に応用可能です:

  • 図面内の特定シェイプ(機器など)をクリックした際に、別ページの詳細図へ飛ぶPDF内ハイパーリンクの全自動リンク張り
  • セキュリティ分類ラベルに基づく、特定の図面ページの動的フィルタリング&ページ結合出力
  • CADデータ由来のグラフィックに対する、メタデータ付き高精細SVG/HTML5インタラクティブマップ変換

アーキテクチャを正しく分離し、適切な技術を適切なレイヤーに配置する――この原則を徹底することで、あなたの作成する自動化ツールは「単なるマクロ」から「堅牢なエンタープライズソリューション」へと昇華します。ぜひ現場の業務改革に役立ててください。

タイトルとURLをコピーしました