【実務・中級編】Windows Formsの印刷プレビュー機能の限界を超える:PrintDocumentを用いた複数ページのヘッダー・フッター自動計算、改ページ制御、およびPDF出力の完全実装 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク

Windows Formsの印刷プレビュー機能の限界を超える:PrintDocumentを用いた複数ページのヘッダー・フッター自動計算、改ページ制御、およびPDF出力の完全実装

業務アプリケーション開発において、避けて通れないのが「帳票印刷」だ。
標準の `PrintPreviewDialog` や `PrintDialog` をそのまま配置し、「はい、おしまい」と実装していないだろうか?

実務の現場では、次のような厳格な要件が容赦なく降りかかる。

  • 「データ量に応じて動的に改ページし、最終ページまで正確な『総ページ数』をヘッダーに表示したい」
  • 「明細の途中で中途半端な改ページをさせず、グループ単位で美しく収めたい」
  • 「プリンター出力だけでなく、ペーパーレス化のためにそのままPDFとしても保存させたい」

標準コントロールの表面的なプロパティをいじっているだけでは、これらの要求の前にあっさり破綻する。
今回は、`System.Drawing.Printing.PrintDocument` を完全に掌握し、バグの温床となる「二度描き」を排除した堅牢な複数ページ制御、ヘッダー・フッターの動的計算、そして実務で即戦力となるPDF(Microsoft Print to PDF等を活用した)出力の極意を伝授する。

—

1. なぜ標準の印刷機能は実務で破綻するのか?

多くの開発者が陥る罠は、`PrintPage` イベントの中でデータベースからデータを都度読み込みながら、その場の勢いで描画座標(`e.Graphics`)を計算してしまうことだ。

これには致命的な欠陥がある。
1. 総ページ数が分からない:プレビュー画面の時点で「1 / ? ページ」と表示されるか、無理やり全ページを先読みしてメモリリークを引き起こす。
2. 改ページ位置のズレ:フォントのメトリクスやプリンターの解像度の違いにより、プレビューと実印刷でレイアウトが微妙にズレる。
3. トランザクションとデータの不整合:描画中にデータが変化した場合、ページ間で辻褄が合わなくなる。

勝利の方程式:レイアウト計算と描画の完全分離

プロフェッショナルな帳票アーキテクチャの基本は、「レイアウトパス(事前計算)」と「レンダーパス(実際の描画)」の分離にある。
事前に「どのデータがどのページに配置されるか」のインデックスをすべてメモリ上に確定させ、その設計図に基づいて `PrintPage` は淡々と描画するだけにする。これがバグをゼロにする唯一の解だ。

—

2. プロダクションコード:完全実装クラス

以下のコードは、ヘッダー・フッターの自動計算、動的な改ページ制御、そして複数ページ対応の描画をカプセル化した、そのままプロジェクトに投入できる堅牢な帳票レンダラーの実装例だ。

Imports System.Drawing
Imports System.Drawing.Printing
Imports System.Collections.Generic

Public Class InvoiceReportManager
‘ 印刷対象のデータモデル(仮)
Public Class InvoiceItem
Public Property ItemName As String
Public Property Quantity As Integer
Public Property UnitPrice As Decimal
Public ReadOnly Property Amount As Decimal
Get
NewGuid()
Return Quantity UnitPrice
End Get
End Property
End Class

Private _items As List(Of InvoiceItem)
Private _currentPageIndex As Integer
‘ ページごとの開始インデックスを保持する設計図
Private _pageBreakIndices As List(Of Integer)

‘ 描画設定
Private ReadOnly _printFont As New Font(“Meiryo UI”, 10)
Private ReadOnly _headerFont As New Font(“Meiryo UI”, 16, FontStyle.Bold)
Private ReadOnly _boldFont As New Font(“Meiryo UI”, 10, FontStyle.Bold)

Private Const MarginTop As Integer = 50
Private Const MarginBottom As Integer = 50
Private Const MarginLeft As Integer = 50
Private Const MarginRight As Integer = 50
Private Const RowHeight As Integer = 25

Public Sub New(items As List(Of InvoiceItem))
_items = items
_pageBreakIndices = New List(Of Integer)()
End Sub

”’

”’ 印刷プレビューおよび実行のメインエントリー
”’

Public Sub ShowPreview()
Dim pd As New PrintDocument()

‘ ページ設定(A4縦、余白設定など)
pd.DefaultPageSettings.Landscape = False
pd.DefaultPageSettings.Margins = New Margins(MarginLeft, MarginRight, MarginTop, MarginBottom)

‘ イベントハンドラの登録
AddHandler pd.BeginPrint, AddressOf OnBeginPrint
AddHandler pd.PrintPage, AddressOf OnPrintPage

‘ プレビューダイアログの設定と表示
Dim previewDlg As New PrintPreviewDialog()
previewDlg.Document = pd
previewDlg.Width = 900
previewDlg.Height = 1100
previewDlg.ShowDialog()
End Sub

”’

”’ 印刷開始前フック:ここで全ページのレイアウト設計図(改ページ位置)を事前計算する
> ”’

Private Sub OnBeginPrint(sender As Object, e PrintEventArgs)
_pageBreakIndices.Clear()
_pageBreakIndices.Add(0) ‘ 1ページ目の開始位置

Dim g As Graphics = Graphics.FromHwnd(IntPtr.Zero)
‘ 印刷可能領域の高さを算出(ピクセル単位)
‘ ※実際にはPrinterSettingsから取得するのが安全だが、ここでは簡略化のためA4固定値ベースで計算
Dim printableHeight As Integer = 1100 – (MarginTop + MarginBottom)

‘ ヘッダーとフッターの高さを除いた明細エリアの高さ
Dim headerAreaHeight As Integer = 100
Dim footerAreaHeight As Integer = 50
Dim availableHeightForRows As Integer = printableHeight – headerAreaHeight – footerAreaHeight

Dim rowsPerPages As Integer = availableHeightForRows \ RowHeight

Dim currentIndex As Integer = 0
While currentIndex < _items.Count currentIndex += rowsPerPages If currentIndex < _items.Count Then _pageBreakIndices.Add(currentIndex) End If End While _currentPageIndex = 0 End Sub '''

”’ ページ描画イベント:設計図通りに描画するだけ(ロジックを書かない)
”’

Private Sub OnPrintPage(sender As Object, e As PrintPageEventArgs)
Dim g As Graphics = e.Graphics
Dim bounds As Rectangle = e.MarginBounds

‘ 1. ヘッダーの描画
DrawHeader(g, bounds)

‘ 2. 明細行の描画
Dim startIndex As Integer = _pageBreakIndices(_currentPageIndex)
Dim endIndex As Integer = If(_currentPageIndex < _pageBreakIndices.Count - 1, _pageBreakIndices(_currentPageIndex + 1), _items.Count) DrawRows(g, bounds, startIndex, endIndex) ' 3. フッター(ページ番号・総ページ数)の描画 DrawFooter(g, bounds, _currentPageIndex + 1, _pageBreakIndices.Count) ' 4. 次ページが存在するか判定 _currentPageIndex += 1 e.HasMorePages = (_currentPageIndex < _pageBreakIndices.Count) End Sub Private Sub DrawHeader(g As Graphics, bounds As Rectangle) g.DrawString("ご請求書", _headerFont, Brushes.Black, bounds.Left, bounds.Top) g.DrawLine(Pens.Black, bounds.Left, bounds.Top + 40, bounds.Right, bounds.Top + 40) End Sub Private Sub DrawRows(g As Graphics, bounds As Rectangle, startIndex As Integer, endIndex As Integer) Dim currentY As Integer = bounds.Top + 60 ' テーブルヘッダー g.DrawString("品名", _boldFont, Brushes.Black, bounds.Left, currentY) g.DrawString("数量", _boldFont, Brushes.Black, bounds.Left + 300, currentY) g.DrawString("単価", _boldFont, Brushes.Black, bounds.Left + 400, currentY) g.DrawString("金額", _boldFont, Brushes.Black, bounds.Left + 500, currentY) currentY += 30 ' 明細ループ For i As Integer = startIndex To endIndex - 1 Dim item = _items(i) g.DrawString(item.ItemName, _printFont, Brushes.Black, bounds.Left, currentY) g.DrawString(item.Quantity.ToString("N0"), _printFont, Brushes.Black, bounds.Left + 300, currentY) g.DrawString(item.UnitPrice.ToString("C0"), _printFont, Brushes.Black, bounds.Left + 400, currentY) g.DrawString(item.Amount.ToString("C0"), _printFont, Brushes.Black, bounds.Left + 500, currentY) currentY += RowHeight Next End Sub Private Sub DrawFooter(g As Graphics, bounds As Rectangle, currentPage As Int32, totalPages As Int32) Dim footerY As Integer = bounds.Bottom - 30 Dim pageString As String = $"{currentPage} / {totalPages} ページ" ' ページ番号を右寄せで描画 Dim stringSize As SizeF = g.MeasureString(pageString, _printFont) g.DrawString(pageString, _printFont, Brushes.Black, bounds.Right - stringSize.Width, footerY) End Sub End Class ---

3. コードの急所:なぜこの設計が強いのか?

上記のコードには、現場で生き残るためのアーキテクチャ上のこだわりが詰まっている。

1. `OnBeginPrint` でのページ分割事前計算
`PrintPage` が何回呼ばれようとも、描画対象のインデックスマップ(`_pageBreakIndices`)は最初に一度だけ計算される。これにより、プレビュー画面でページを行ったり来たりしても描画のズレや無限ループといった致命的なバグが構造的に起きない。
2. 総ページ数の動的バインディング
「全何ページ中、いま何ページ目か」という情報は、フッター描画時に `_pageBreakIndices.Count` を参照するだけで正確にレンダリングされる。

—

4. 応用編:PDF出力への拡張(仮想プリンターの活用)

「プレビューや印刷だけでなく、そのままPDFファイルとして保存させたい」という要件は、昨今のペーパーレス化の波において必須だ。
実は、Windows 10 / 11 に標準搭載されている Microsoft Print to PDF を指名して `PrintDocument` を走らせることで、追加のサードパーティ製ライブラリ(高価な有償PDFライブラリなど)を一切導入することなく、完璧なPDFを出力できる。

PDFサイレント出力の実装スニペット

Public Sub ExportToPdf(filePath As String)
Dim pd As New PrintDocument()

‘ プリンター名に「Microsoft Print to PDF」を指定
pd.PrinterSettings.PrinterName = “Microsoft Print to PDF”

‘ ファイルダイアログをスキップして直接ファイルに出力する場合の設定
‘ (※Microsoft Print to PDFは通常ダイアログが出るため、レジストリや一時ファイル名制御が必要な場合あり)
pd.PrinterSettings.PrintToFile = True
pd.PrinterSettings.PrintFileName = filePath

‘ イベントハンドラ(前述のクラスのものと共通化可能)
AddHandler pd.BeginPrint, AddressOf OnBeginPrint
AddHandler pd.PrintPage, AddressOf OnPrintPage

‘ 印刷実行(実体はPDFファイル生成)
pd.Print()
End Sub

> 実務上の注意点: `Microsoft Print to PDF` は環境によってプリンター名が英語(”Microsoft Print to PDF”)だったり日本語(”Microsoft Print to PDF” だが内部識別子が異なる場合)だったりする。安全に実装する場合は、`PrinterSettings.InstalledPrinters` から名前を部分一致で検索して動的にアサインするロジックを挟むこと。

—

5. データベース連携・ファイル連携時の鉄則

帳票データを扱う際、バックエンドのデータベースやCSVファイルとの連携でやりがちなミスがある。

  • メモリの枯渇(OutOfMemoryException)に備える

数万件の明細データを一度に `List(Of T)` に詰め込んで印刷しようとすると、GDI+のハンドル枯渇やメモリ圧迫を招く。数千件を超える大規模帳票の場合は、ページ単位、あるいはチャンク単位でデータをフェッチする「仮想スクロール型印刷ストリーム」の設計を取り入れるべきだ。

  • フォントの依存性

開発者のPCに入っている特殊なフォントをコード内で指定すると、クライアント端末やサーバーサイド(Windows Serverなど)で実行した際にフォントが代替され、レイアウトが崩壊する。「メイリオ」「MS ゴシック」「Yu Gothic」など、OS標準で確実に存在するユニバーサルフォントを選択すること。

—

総括

Windows Formsにおける `PrintDocument` は、一見するとレガシーで扱いづらいAPIに思えるかもしれない。しかし、「レイアウトの事前計算(プレパレーション)」 と 「描画の純粋化」 の原則さえ守れば、企業の厳格な監査要件や複雑なレイアウト要求にも完全に応えることができる。

標準機能の限界に泣き寝入りするのは今日で終わりにしよう。洗練された設計コードで、あなたのアプリケーションの品質を一段上のステージへと引き上げてほしい。

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