Windows Formsの印刷プレビュー機能の限界を超える:PrintDocumentを用いた複数ページのヘッダー・フッター自動計算、改ページ制御、およびPDF出力の完全実装
レガシーシステムの呪縛、あるいは「画面の通りに紙へ定着させる」という現代において最も不条理な要求。
VB.NETによるWindows Forms開発において、`PrintPreviewDialog`と`PrintDocument`の組み合わせは、多くの開発者を絶望の淵に追い込んできた。標準コンポーネントの貧弱なUI、ページ番号の総数(Total Pages)を事前に知ることができない絶望的なイベント駆動の非同期ライフサイクル、そしてフォントメトリクスのズレによる意図しない改ページ。
本稿では、これらの物理的・アーキテクチャ的限界を突破し、企業の厳格な基幹帳票要件に耐えうる「完全制御型印刷エンジン」の設計と実装を解き明かす。
—
1. 根本的なパラダイムシフト:なぜ標準プレビューと改ページ制御は破綻するのか
`PrintDocument`の`PrintPage`イベントは、本質的に「ステートレスな逐次描画」を強要する。1ページ目を描画している時点では、データが全体で何ページに収まるのか、VB.NETのランタイムは知る由もない。
企業帳票の基本要件である 「Page X of Y(総ページ数表示)」 を実現するためには、以下の2つのアプローチのいずれかを選択せよ。
1. 2パスレンダリング(Two-Pass Rendering): 1回目のパスで仮想描画を行って正確な改ページ位置と総ページ数を算出し、2回目のパスで実際の描画と出力を行う。
2. メモリ上での仮想レイアウト構築: データソースを走査し、各行の高さとマージンを事前に計算してページインデックスのマップを確定させる。
実務上、パフォーマンスとメモリ効率のバランスから、「データグリッドの事前レイアウト計算+カスタムPrintControllerによる2パス制御」 が唯一にして最善の解となる。
—
2. 実装:完全制御型 複数ページ帳票エンジンのアーキテクチャ
以下に、ヘッダー・フッターの動的計算、総ページ数の埋め込み、そしてGDI+による高速かつ正確な描画を行うVB.NETのプロダクションコードを提示する。
Imports System.Drawing
Imports System.Drawing.Printing
Imports System.Collections.Generic
Public Class AdvancedReportEngine
‘ 印刷データモデル(例としてDTOのリストを想定)
Private _dataSource As List(Of ReportRowData)
Private _currentPage As Integer
Private _totalPages As Integer
‘ レイアウト定数(単位:1/100インチ / 百分の1インチ単位)
Private ReadOnly MarginLeft As Integer = 50
Private ReadOnly MarginRight As Integer = 50
Private ReadOnly MarginTop As Integer = 50
Private ReadOnly MarginBottom As Integer = 50
Private ReadOnly RowHeight As Single = 20.0F
‘ ページごとの行インデックス管理マップ
Private _pageBreakMap As List(Of Integer)
Public Sub New(dataSource As List(Of ReportRowData))
_dataSource = dataSource
_pageBreakMap = New List(Of Integer)()
End Sub
‘ 印刷実行エントリーポイント
Public Sub ExecutePrint(isPdfOutput As Boolean)
Using pd As New PrintDocument()
AddHandler pd.BeginPrint, AddressOf Pd_BeginPrint
AddHandler pd.PrintPage, AddressOf Pd_PrintPage
If isPdfOutput Then
‘ Microsoft Print to PDFなどを指定、または仮想プリンター制御
pd.PrinterSettings.PrinterName = “Microsoft Print to PDF”
pd.PrinterSettings.PrintToFile = True
pd.PrinterSettings.PrintFileName = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), “OutputReport.pdf”)
End If
‘ カスタムプレビューダイアログの呼び出し
Using previewDialog As New PrintPreviewDialog()
previewDialog.Document = pd
previewDialog.Width = 1024
previewDialog.Height = 768
previewDialog.ShowDialog()
End Using
End Using
End Sub
‘ 印刷前処理:全データの高さを計算し、改ページ位置を完全に事前確定させる(2パスの概念)
Private Sub Pd_BeginPrint(sender As Object, e PrintEventArgs)
_pageBreakMap.Clear()
_currentPage = 0
‘ A4縦を想定した描画可能高さの算出 (11インチ 100 = 1100, 上下マージン各50)
Dim printableHeight As Single = 1100 – (MarginTop + MarginBottom) – 60 ‘ ヘッダー・フッター分を減算
Dim rowsPerStandardPage As Integer = CInt(Math.Floor(printableHeight / RowHeight))
Dim currentIndex As Integer = 0
While currentIndex < _dataSource.Count
_pageBreakMap.Add(currentIndex)
currentIndex += rowsPerStandardPage
End While
_totalPages = _pageBreakMap.Count
End Sub
' ページ描画イベント(PrintDocumentの中核)
Private Sub Pd_PrintPage(sender As Object, e As PrintPageEventArgs)
Dim g As Graphics = e.Graphics
' GDI+ 最適化(高品質なテキスト描画)
g.TextRenderingHint = Drawing.Text.TextRenderingHint.AntiAlias
Dim fontHeader As New Font("Meiryo UI", 12, FontStyle.Bold)
Dim fontBody As New Font("Meiryo UI", 9, FontStyle.Regular)
Dim fontFooter As New Font("Meiryo UI", 8, FontStyle.Italic)
Dim brush As Brush = Brushes.Black
Dim pen As New Pen(Color.FromArgb(180, 180, 180), 0.5F)
Dim startY As Single = MarginTop
' 1. ヘッダー描画
g.DrawString("極限業務統括システム - 帳票出力", fontHeader, brush, MarginLeft, startY)
g.DrawLine(pen, MarginLeft, startY + 25, e.PageBounds.Width - MarginRight, startY + 25)
startY += 40
' 2. テーブルヘッダー描画
g.DrawString("ID", fontBody, brush, MarginLeft, startY)
g.DrawString("項目名称", fontBody, brush, MarginLeft + 60, startY)
g.DrawString("数量", fontBody, brush, MarginLeft + 300, startY)
g.DrawString("金額", fontBody, brush, MarginLeft + 400, startY)
startY += 15
g.DrawLine(pen, MarginLeft, startY, e.PageBounds.Width - MarginRight, startY)
startY += 5
' 3. ボディ(明細行)描画
Dim startIndex As Integer = _pageBreakMap(_currentPage)
Dim endIndex As Integer = If(_currentPage < _totalPages - 1, _pageBreakMap(_currentPage + 1), _dataSource.Count)
For i As Integer = startIndex To endIndex - 1
Dim row As ReportRowData = _dataSource(i)
g.DrawString(row.Id.ToString(), fontBody, brush, MarginLeft, startY)
g.DrawString(row.ItemName, fontBody, brush, MarginLeft + 60, startY)
g.DrawString(row.Quantity.ToString("N0"), fontBody, brush, MarginLeft + 300, startY)
g.DrawString(row.Amount.ToString("C"), fontBody, brush, MarginLeft + 400, startY)
startY += RowHeight
Next
' 4. フッター描画(Page X of Y の動的描画)
Dim footerText As String = String.Format("Page {0} of {1}", _currentPage + 1, _totalPages)
Dim footerSize As SizeF = g.MeasureString(footerText, fontFooter)
g.DrawString(footerText, fontFooter, brush, (e.PageBounds.Width - footerSize.Width) / 2, e.PageBounds.Height - MarginBottom)
' 5. ライフサイクル管理と次ページ判定
_currentPage += 1
e.HasMorePages = (_currentPage < _totalPages)
' オブジェクトの明示的解放(GDI+リークの完全阻止)
fontHeader.Dispose()
fontBody.Dispose()
fontFooter.Dispose()
pen.Dispose()
End Sub
End Class
Public Class ReportRowData
Public Property Id As Integer
Public Property ItemName As String
Public Property Quantity As Integer
Public Property Amount As Decimal
End Class
---
3. シニアアーキテクトが教える:GDI+メモリ最適化とリソース管理の鉄則
上記のコードを見て、「なぜ`Font`やビジュアルオブジェクトを毎回ループやイベント内で生成して`Dispose`しているのか?」と疑問に思ったならば、まだVB.NETのGDI+ハンドルの闇を理解していない証拠だ。
GDIハンドルの枯渇(Memory Leak & Handle Exhaustion)
Windows Formsの`Graphics`オブジェクトや`Font`、`Brush`、`Pen`クラスは、内部で非マネージリソース(Windows GDIオブジェクト)を保持している。これらを`Using`ステートメントや明示的な`.Dispose()`なしで放置すると、OSのGDIハンドル制限(プロセスあたり10,000件等)に瞬く間に到達し、アプリケーションが突如として描画不能に陥る、あるいはクラッシュする。
- 鉄則1: `PrintPage`イベント内で生成するフォントやペンは、可能な限りキャッシュ構造にするか、使い捨てる場合は確実に`Dispose`する。
- 鉄則2: 大規模な帳票(数千ページ)を処理する場合、ガベージコレクタ(GC)の自動解放を待つのではなく、ページ描画の直後に明示的解放を行うこと。
—
4. PDF出力へのシームレスな移行とシステム間連携
企業の現場では、「プレビューを見せるだけでなく、そのままPDFとしてサーバーの共有フォルダにサイレント保存したい」「電子メールに添付して自動送信したい」という要件が必ず発生する。
サードパーティ製の高価なPDFライブラリを導入しなくとも、Windows 10以降の標準機能である 「Microsoft Print to PDF」 プリンタードライバをプログラムからハックすることで、完全無料で堅牢なPDF出力を実現できる。
‘ サイレント印刷(プレビューなしで直接PDFファイルとして出力)
Public Sub GeneratePdfSilently(dataSource As List(Of ReportRowData))
Dim engine As New AdvancedReportEngine(dataSource)
Using pd As New PrintDocument()
‘ Microsoft Print to PDF ドライバを強制指定
pd.PrinterSettings.PrinterName = “Microsoft Print to PDF”
pd.PrinterSettings.PrintToFile = True
‘ 出力先ファイルパスの設定(※WindowsのPrint to PDFはダイアログを出さずにファイル名を受け取るレジストリ/ポート設定が必要な場合があるため注意)
Dim outputFileName As String = $”Report_{DateTime.Now:yyyyMMddHHmmss}.pdf”
Dim outputPath As String = System.IO.Path.Combine(“C:\SharedFolder\Reports”, outputFileName)
pd.PrinterSettings.PrintFileName = outputPath
‘ 描画イベントの結線(AdvancedReportEngine内部と同様のロジックを委譲)
‘ … (省略)
‘ ダイアログを出さずにバックグラウンドで実行
pd.Print()
End Using
End Sub
注意: `Microsoft Print to PDF`をサイレント(ユーザー入力なし)で動作させる場合、出力先ファイル名が事前にロックされているか、プリンターポートの設定(FILE:ポート)に依存する挙動を示す。完全無人のバッチ処理等で完全にファイル名を制御したい場合は、`PrintController`を継承した独自のカスタムコントローラー(`PreviewPrintController`や独自実装の`PrintController`)を構築し、デバイスコンテキスト(DC)レベルでメタファイルをキャプチャしてPDF変換ライブラリへ流し込むアーキテクチャを採用すべきである。
—
結び
Windows Formsはレガシーなフレームワークと揶揄されることもあるが、その内部構造とGDI+のライフサイクルを完全に掌握したエンジニアの手にかかれば、企業の基幹システムを支える極めて強力かつ高速な帳票エンジンへと変貌を遂げる。
標準機能の壁にぶ돌ったとき、諦めて外部ライブラリの購入を稟議する前に、メモリとイベントのライフサイクルを自身のコードで支配し尽くしてほしい。それこそが、真のエンジニアリングである。
