「図面が枠を食い破る前に」——Visio VBAにおける動的ページ拡張の極限設計
業務自動化の現場において、我々エンジニアが直面する問題は「動くコードを書くこと」ではない。「予期せぬデータ量によって、システムの整合性が崩れること」を防ぐことだ。
Visioを用いた図面自動生成ツールを開発している際、最も初歩的でありながら、最も設計の甘さが露呈するのが「ページサイズとコンテンツの不一致」だ。シェイプが数個のうちは美しい図面でも、ループ処理によって数百、数千のシェイプが配置された瞬間、図面はページの境界線を突き破り、出力されるPDFや印刷物は無残なものへと成り果てる。
多くの開発者は、単にシェイプの座標を追うだけのコードを書く。しかし、それではプロフェッショナルとは呼べない。グループ化されたシェイプの階層構造、座標系の違い、そして計算コスト。これらを掌握して初めて、プロダクション環境に耐えうる「オートフィット機能」が完成する。
本稿では、単なるコピペ用のスクリプトではなく、「なぜその設計が必要なのか」というアーキテクチャの観点から、堅牢な動的ページ拡張ロジックを伝授する。
1. 初級者が陥る「3つの設計的欠陥」
実装に入る前に、私が多くのジュニアエンジニアのコードを見て指摘してきた、典型的な失敗パターンを整理しておく。これを回避することこそが、設計の第一歩だ。
① グループ階層の無視(Recursive Traversalの欠如)
Visioのシェイプは、単一のオブジェクトとは限らない。複数のシェイプが「グループ」としてまとめられている場合、`ActivePage.Shapes` を単純にループするだけでは、グループ内部に隠れたシェイプの端点(Bounding Box)を正確に捕捉できない。
② ゼロ・マージン問題
計算されたバウンディングボックスのサイズに、そのままページサイズを適用するのは致命的なミスだ。図面の端ギリギリにシェイプが配置されると、印刷時に「余白不足」で切れる。プロフェッショナルなコードには、必ず「セーフティ・パディング(余裕幅)」の概念を組み込むべきだ。
③ パフォーマンスの劣化
シェイプが増えるたびに、ページ全体の再計算とリサイズを走らせるのは愚策だ。ページリサイズは、Visioの内部的な描画更新(Repaint)を伴う重い処理である。これは「データ入力時」ではなく、「一連のバッチ処理が完了した後」に実行する設計にすべきである。
2. 極限の設計思想:再帰的バウンディングボックス計算
我々が実装すべきは、ページ内の全シェイプを走査し、その「絶対的な最小・最大座標」を特定するアルゴリズムだ。
アルゴリズムの要件
1. 再帰的走査: グループを見つけたら、その内部へ潜り、全ての末端シェイプの座標を取得する。
2. 座標系の統一: 各シェイプの `PinX`, `PinY`, `Width`, `Height` を用いて、ページ座標系における最小 $X, Y$ と最大 $X, Y$ を算出する。
3. ページ設定の注入: 算出した範囲にパディングを加算し、`PageSheet` のセルへ値を書き込む。
3. プロダクション・クオリティのコード実装
以下に、そのまま実務のモジュールとして組み込める、堅牢なクラスメソッド級のコードを示す。
Option Explicit
''' <summary>
''' ページ内の全シェイプに基づき、ページサイズを動的に最適化する
''' </summary>
''' <param name="targetPage">対象となるVisioページ</param>
''' <param name="paddingInInches">上下左右に設ける余白 (インチ単位)</param>
Public Sub AutoFitPageToContent(ByVal targetPage As Visio.Page, Optional ByVal paddingInInches As Double = 0.25)
Dim totalMinX As Double, totalMaxX As Double
Dim totalMinY As Double, totalMaxY As Double
Dim shp As Visio.Shape
Dim isFirstShape As Boolean: isFirstShape = True
' 1. ページ内の全てのシェイプを走査するための初期化
' ページにシェイプがない場合は、デフォルトサイズを維持して終了
If targetPage.Shapes.Count = 0 Then Exit Sub
' 2. 全シェイプのバウンディングボックスを計算
' ページ上の全シェイプ(グループ含む)を対象とする
For Each shp In targetPage.Shapes
Dim sMinX As Double, sMaxX As Double, sMinY As Double, sMaxY As Double
' 再帰的にシェイプの範囲を取得
GetShapeBounds shp, sMinX, sMaxX, sMinY, sMaxY
' 最小・最大値を更新
If isFirstShape Then
totalMinX = sMinX: totalMaxX = sMaxX
totalMinY = sMinY: totalMaxY = sMaxY
isFirstShape = False
Else
If sMinX < totalMinX Then totalMinX = sMinX
If sMaxX > totalMaxX Then totalMaxX = sMaxX
If sMinY < totalMinY Then totalMinY = sMinY
If sMaxY > totalMaxY Then totalMaxY = sMaxY
End If
Next shp
' 3. ページサイズと位置の決定
' 計算された範囲にパディングを加える
Dim newWidth As Double: newWidth = (totalMaxX - totalMinX) + (paddingInInches * 2)
Dim newHeight As Double: newHeight = (totalMaxY - totalMinY) + (paddingInInches * 2)
' 4. PageSheetへの適用
' 描画更新を一時停止してパフォーマンスを向上させる(重要)
On Error GoTo ErrorHandler
Application.ScreenUpdating = False
With targetPage.PageSheet
' ページサイズの設定
.CellsU("PageWidth").FormulaU = newWidth & " in"
.CellsU("PageHeight").FormulaU = newHeight & " in"
' ページ内のコンテンツを中央に寄せるための調整が必要な場合はここで計算
' 今回は単純に、最小座標が (0,0) に来るようにページをオフセットする設計とする
' ※実務では、原点(0,0)をコンテンツの左下に合わせる処理が一般的
.CellsU("PrintHeight").FormulaU = newHeight & " in"
.CellsU("PrintWidth").FormulaU = newWidth & " in"
End With
' ページ内のシェイプを移動させ、新しいページ範囲内に収める(原点を0,0に合わせるロジック)
' ページサイズを変えるだけでは、コンテンツの位置は変わらないため
ShiftShapesToOrigin targetPage, totalMinX, totalMinY
CleanUp:
Application.ScreenUpdating = True
Exit Sub
ErrorHandler:
MsgBox "オートフィット処理中にエラーが発生しました: " & Err.Description, vbCritical
Resume CleanUp
End Sub
''' <summary>
''' シェイプ(およびその子要素)の境界ボックスを再帰的に取得する
''' </summary>
Private Sub GetShapeBounds(ByVal shp As Visio.Shape, ByRef minX As Double, ByRef maxX As Double, ByRef minY As Double, ByRef maxY As Double)
Dim subShp As Visio.Shape
Dim sMinX As Double, sMaxX As Double, sMinY As Double, sMaxY As Double
' 基本的なピン位置とサイズから境界を算出
' PinX, PinY はシェイプの中心座標。Width, Height を用いて端点を出す
sMinX = shp.CellsU("PinX").ResultIU - (shp.CellsU("Width").ResultIU / 2)
sMaxX = shp.CellsU("PinX").ResultIU + (shp.CellsU("Width").ResultIU / 2)
sMinY = shp.CellsU("PinY").ResultIU - (shp.CellsU("Height").ResultIU / 2)
sMaxY = shp.CellsU("PinY").ResultIU + (shp.CellsU("Height").ResultIU / 2)
' グループ化されている場合、子シェイプの範囲をより厳密に精査する
If shp.Type = visTypeGroup Then
For Each subShp In shp.Shapes
Dim childMinX As Double, childMaxX As Double, childMinY As Double, childMaxY As Double
GetShapeBounds subShp, childMinX, childMaxX, childMinY, childMaxY
If childMinX < sMinX Then sMinX = childMinX
If childMaxX > sMaxX Then sMaxX = childMaxX
If childMinY < sMinY Then sMinY = childMinY
If childMaxY > sMaxY Then sMaxY = childMaxY
Next subShp
End If
' 親に値を返す
minX = sMinX: maxX = sMaxX: minY = sMinY: maxY = sMaxY
End Sub
''' <summary>
''' コンテンツをページの原点(0,0)付近に移動させる
''' </summary>
Private Sub ShiftShapesToOrigin(ByVal targetPage As Visio.Page, ByVal offsetX As Double, ByVal offsetY As Double)
Dim shp As Visio.Shape
' 全てのシェイプを、計算された最小座標の分だけマイナス方向に移動させる
' これにより、新しいページサイズに対してコンテンツが左下基準で配置される
For Each shp In targetPage.Shapes
shp.CellsU("PinX").FormulaU = shp.CellsU("PinX").FormulaU & " - " & Abs(offsetX) & " in"
shp.CellsU("PinY").FormulaU = shp.CellsU("PinY").FormulaU & " - " & Abs(offsetY) & " in"
' ※注: 実際の実装では、グループ全体の移動を行う必要があるため、
' top-level shapesのみを対象とするのが定石。
Next shp
End Sub
4. アーキテクトからの助言:運用における注意点
このコードをプロダクション環境に導入する際、以下の3点を考慮に入れなさい。
データベース・ファイル連携時の「一括処理」
もし、ExcelやSQL Serverから大量のデータを読み込んでVisioを生成しているなら、「1行読み込むごとに `AutoFitPageToContent` を呼ぶな」。
それは、エンジニアとしての敗北を意味する。
必ず、全てのシェイプを配置し終えた後、最後に一度だけこのメソッドを呼び出す「バッチ・コミット方式」を採用せよ。
座標系の不一致への警戒
Visioには、`Master` 座標、`Page` 座標、`Drawing` 座標が存在する。本コードでは `Page` 座標系を前提としているが、複雑なレイヤー制御を行っている場合は、`Shape.CellsU(“PinX”).ResultIU` がどの座標系に基づいているかを常に意識せよ。
エラーハンドリングの重要性
自動化ツールは「止まらないこと」が価値である。
`On Error GoTo ErrorHandler` を用いて、万が一計算に失敗しても、`Application.ScreenUpdating = True` を確実に実行し、Visioの制御をユーザーに返却する設計を徹底せよ。
まとめ
「動く」ことと「堅牢である」ことは、似て非なるものだ。
シェイプの数が増大しても、描画が崩れず、計算コストを最小限に抑え、余白までコントロールされた図面。それこそが、我々プロフェッショナルが提供すべき価値である。
このオートフィットのロジックを、君のツールキットに刻み込んでほしい。
