WPF(.NET Framework 4.8)でレポート印刷を実装すると、「ページ範囲(2–4)」は比較的すぐ実現できますが、「2,4,6」のような飛び飛びページ指定は PrintDialog の仕様に引っかかりやすいポイントです。この記事では UserPages / SelectedPages の違いを整理し、標準ダイアログでできない理由と、実務で使える現実的な実装パターンを具体例つきで解説します。
やりたいこと:範囲指定と、飛び飛び指定
想定シナリオは次のとおりです。
- .NET Framework 4.8 ターゲットの WPF アプリ
- 12ページのレポート(FixedDocument / FlowDocument / 独自 paginator いずれでも)を印刷
- 現状は「全ページ印刷」のみ
- 追加したい要件:
- ページ範囲(例:2–4)だけ印刷
- 飛び飛びページ(例:2,4,6)だけ印刷
結論:標準 PrintDialog だけで「2,4,6」を受け取ることはできない
WPF の 標準 PrintDialog には、ユーザーが「2,4,6」のような 非連続ページのリスト を入力するUIが用意されていません。さらに、仮に SelectedPagesEnabled を true にしてダイアログに「Selection(選択)」を出しても、そこから「2,4,6」の入力値を返してくれるプロパティは存在しません。
このため、飛び飛びページ印刷を実現するには発想を切り替えて、次のどれかを選ぶのが現実解です。
- 方式A:別ダイアログ でページ番号(2,4,6 / 2-4,6 など)を入力させて印刷
- 方式B:サムネイル付きプレビュー でページをクリック選択させて印刷
- 方式C:PrintDialog は プリンタ設定専用 と割り切り、ページ選択は自前UIで完結させる
UserPages と SelectedPages の違いを誤解しない
ここを取り違えると遠回りになります。ポイントは「Selection(選択)」は ページ番号入力 ではなく、一般に「現在選択中のコンテンツ(文字列や領域など)を印刷する」ための概念だということです。
| 項目 | UserPages(ページ範囲) | SelectedPages(Selection) |
|---|---|---|
| 表示条件 | UserPageRangeEnabled = true | SelectedPagesEnabled = true |
| 想定用途 | 「2–4」のような連続範囲 | 「選択中の内容」を印刷(ページ番号指定ではない) |
| 入力UI | ページ範囲の入力欄が出る | ページ番号入力欄は出ない(標準では) |
| 取得手段 | PageRangeSelection == UserPages のとき PageRange で取得 | PageRangeSelection == SelectedPages になっても、ページ番号リストを返すプロパティがない |
つまり、「SelectedPagesEnabled を true にしたら 2,4,6 を取れるはず」という期待がそもそも仕様とズレています。WPF 標準 PrintDialog を “ページ番号リスト入力UI” として拡張することもできません(ネイティブの共通ダイアログをラップしているため、TextBox を差し込むような拡張は基本的に不可です)。
まずは基礎:ページ範囲(2–4)印刷の定石
範囲指定ができているとのことですが、記事として実装全体像も載せておきます。ポイントは以下です。
UserPageRangeEnabled = trueを有効にするPageRangeSelection == UserPagesのときPageRangeから from/to を読む- 元の
DocumentPaginatorをラップして、指定範囲だけを返す paginator を作る - 最終的には
PrintDialog.PrintDocument(paginator, ...)で印刷する(複数ページを安定して1ジョブにまとめやすい)
PageRange(連続範囲)用 paginator の例
using System;
using System.Windows;
using System.Windows.Documents;
public sealed class PageRangeDocumentPaginator : DocumentPaginator
{
private readonly DocumentPaginator _source;
private readonly int _startIndex; // 0-based
private readonly int _count;
public PageRangeDocumentPaginator(DocumentPaginator source, int pageFrom, int pageTo)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (pageFrom < 1) throw new ArgumentOutOfRangeException(nameof(pageFrom));
if (pageTo < pageFrom) throw new ArgumentOutOfRangeException(nameof(pageTo));
_source = source;
// 1-based を 0-based に変換
_startIndex = pageFrom - 1;
_count = (pageTo - pageFrom) + 1;
}
public override DocumentPage GetPage(int pageNumber)
{
// pageNumber は 0..(_count-1)
return _source.GetPage(_startIndex + pageNumber);
}
public override bool IsPageCountValid => true;
public override int PageCount => _count;
public override Size PageSize
{
get => _source.PageSize;
set => _source.PageSize = value;
}
public override IDocumentPaginatorSource Source => _source.Source;
}
呼び出し側の流れ(UserPages)
var printDialog = new System.Windows.Controls.PrintDialog
{
UserPageRangeEnabled = true,
SelectedPagesEnabled = true // 後述:Selection をトリガーとして使うなら true
};
bool? ok = printDialog.ShowDialog();
if (ok != true) return;
DocumentPaginator paginator = ((IDocumentPaginatorSource)reportDocument).DocumentPaginator;
// 用紙サイズに合わせる(ここがズレるとページ数や見た目が変わる)
paginator.PageSize = new Size(printDialog.PrintableAreaWidth, printDialog.PrintableAreaHeight);
// ページ数が遅延計算のタイプなら先に確定させる(FlowDocument 系で重要)
paginator.ComputePageCount();
switch (printDialog.PageRangeSelection)
{
case PageRangeSelection.AllPages:
printDialog.PrintDocument(paginator, "Print Report");
break;
case PageRangeSelection.UserPages:
int from = printDialog.PageRange.PageFrom;
int to = printDialog.PageRange.PageTo;
var rangePaginator = new PageRangeDocumentPaginator(paginator, from, to);
printDialog.PrintDocument(rangePaginator, "Print Report (Range)");
break;
// SelectedPages は後述(標準だけでは 2,4,6 を取得できない)
}
ここまでは “標準機能の範囲内” で完結します。一方で、飛び飛び指定はこの延長では解けません。
なぜ PrintDialog から「2,4,6」を取得できないのか
ユーザー視点では「Selection のところにページ番号を入力できるのでは?」と感じますが、WPF の標準 PrintDialog は次の制約があります。
- Selection 用のページ番号入力欄が存在しない
- ページ番号リストを返すプロパティが存在しない
- WPF の PrintDialog は OS の共通印刷ダイアログを呼び出すラッパーであり、内部UIに TextBox を追加するような拡張ポイントがない
そのため、「SelectedPagesEnabled をオンにして、ユーザーが 2,4,6 を入力し、それをどこかのプロパティで受け取る」という設計自体が成立しません。設計としては、ページ選択はアプリ側のUIで集めるのが正攻法です。
方式A:ページ番号入力ダイアログを自作して飛び飛び印刷する
最も導入コストが低く、既存の印刷処理に組み込みやすいのがこの方法です。ユーザーに「2,4,6」や「2-4,6,10」のような形式で入力してもらい、アプリ側で解釈して印刷します。
UXとして先に決めたい入力仕様
後で揉めないように、まず “許可する書式” を明文化するのがおすすめです。
| 例 | 意味 | 備考 |
|---|---|---|
| 2,4,6 | 2/4/6ページ | カンマ区切り。空白は無視 |
| 2-4 | 2~4ページ | ハイフンで範囲 |
| 1-3,5,8-10 | 連続と飛び飛びの混在 | 実務で一番便利 |
| 4,4,2 | 2/4ページ(重複除外) | 重複は自動排除する設計が無難 |
実装面では次のチェックを入れると事故が減ります。
- 数字以外(全角、記号、連続カンマなど)の扱い
- 存在しないページ(0ページ、13ページなど)
- ページ数がプリンタ設定(用紙/余白/向き)で変わる場合の再計算
- 入力順を保持して印刷するのか、昇順で印刷するのか
入力ダイアログ(簡易)の例
PrintDialog のUIは拡張できないので、別ウィンドウ(例:SelectedPagesDialog)を用意します。
<Window x:Class="YourApp.SelectedPagesDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="ページ指定" SizeToContent="WidthAndHeight"
WindowStartupLocation="CenterOwner">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="印刷するページを入力してください(例:2,4,6 / 2-4,6)" Margin="0,0,0,8"/>
<TextBox x:Name="PagesTextBox" Width="360" Margin="0,0,0,8"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="キャンセル" Width="100" Margin="0,0,8,0" IsCancel="True"/>
<Button Content="OK" Width="100" IsDefault="True" Click="Ok_Click"/>
</StackPanel>
</Grid>
</Window>
public partial class SelectedPagesDialog : Window
{
public string InputText => PagesTextBox.Text;
public SelectedPagesDialog()
{
InitializeComponent();
}
private void Ok_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
}
}
「2,4,6 / 2-4,6」文字列をページ配列に変換する
ここが肝です。単純に Split(',') して int.Parse するだけだと、範囲指定や入力ミスに弱いので、範囲(ハイフン)も解釈できる関数にしておくと運用が楽になります。
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
public static class PageListParser
{
/// <summary>
/// "1-3,5,8" のような指定を 0-based ページインデックスのリストに変換する。
/// </summary>
public static List<int> ParseToZeroBasedIndices(string text, int minPage, int maxPage)
{
if (string.IsNullOrWhiteSpace(text))
throw new ArgumentException("ページ指定が空です。例:2,4,6 または 2-4,6", nameof(text));
var set = new SortedSet<int>(); // 昇順&重複排除(入力順を維持したいなら別実装に)
var tokens = text.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var raw in tokens)
{
var token = raw.Trim();
if (token.Length == 0)
continue;
// 範囲指定(例:2-4)
int hyphen = token.IndexOf('-');
if (hyphen >= 0)
{
var parts = token.Split(new[] { '-' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
throw new FormatException($"範囲指定の形式が不正です: '{token}'(例:2-4)");
int start = ParsePositiveInt(parts[0].Trim(), token);
int end = ParsePositiveInt(parts[1].Trim(), token);
if (start > end)
throw new FormatException($"範囲指定の開始と終了が逆です: '{token}'");
for (int p = start; p <= end; p++)
{
ValidateRange(p, minPage, maxPage);
set.Add(p - 1); // 0-based
}
}
else
{
int p = ParsePositiveInt(token, token);
ValidateRange(p, minPage, maxPage);
set.Add(p - 1);
}
}
if (set.Count == 0)
throw new ArgumentException("有効なページ指定がありません。例:2,4,6 または 2-4,6");
return set.ToList();
}
private static int ParsePositiveInt(string s, string context)
{
if (!int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
throw new FormatException($"数値として解釈できません: '{context}'");
if (value <= 0)
throw new FormatException($"ページ番号は1以上で指定してください: '{context}'");
return value;
}
private static void ValidateRange(int page, int minPage, int maxPage)
{
if (page < minPage || page > maxPage)
throw new ArgumentOutOfRangeException(nameof(page), $"ページ {page} は範囲外です({minPage}~{maxPage})。");
}
}
飛び飛びページだけを返す DocumentPaginator を作る
印刷は、ページごとに PrintVisual を連打するより、paginator をラップして PrintDocument で一括印刷が安定しやすいです(スプーラ上も1ジョブになり、ページ順や改ページをコントロールしやすい)。
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Documents;
public sealed class SelectedPagesDocumentPaginator : DocumentPaginator
{
private readonly DocumentPaginator _source;
private readonly IReadOnlyList<int> _pageMap; // 0-based: 出力ページ番号 => 元ページ番号
public SelectedPagesDocumentPaginator(DocumentPaginator source, IReadOnlyList<int> pageMap)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_pageMap = pageMap ?? throw new ArgumentNullException(nameof(pageMap));
if (_pageMap.Count == 0) throw new ArgumentException("pageMap が空です。", nameof(pageMap));
}
public override DocumentPage GetPage(int pageNumber)
{
// pageNumber は 0..(PageCount-1)
int originalIndex = _pageMap[pageNumber];
return _source.GetPage(originalIndex);
}
public override bool IsPageCountValid => true;
public override int PageCount => _pageMap.Count;
public override Size PageSize
{
get => _source.PageSize;
set => _source.PageSize = value;
}
public override IDocumentPaginatorSource Source => _source.Source;
}
PrintDialog の「Selection」をトリガーにして自前入力を出す
質問の流れ(SelectedPagesEnabled = true にすると Selection が出る)を活かすなら、「ユーザーが Selection を選んだら、自前のページ入力を出す」という運用ができます。重要なのは、Selection の中でページ番号を取ろうとしないことです。
var printDialog = new System.Windows.Controls.PrintDialog
{
UserPageRangeEnabled = true,
SelectedPagesEnabled = true
};
bool? ok = printDialog.ShowDialog();
if (ok != true) return;
DocumentPaginator paginator = ((IDocumentPaginatorSource)reportDocument).DocumentPaginator;
paginator.PageSize = new Size(printDialog.PrintableAreaWidth, printDialog.PrintableAreaHeight);
paginator.ComputePageCount();
int totalPages = paginator.PageCount;
if (printDialog.PageRangeSelection == PageRangeSelection.AllPages)
{
printDialog.PrintDocument(paginator, "Print Report");
}
else if (printDialog.PageRangeSelection == PageRangeSelection.UserPages)
{
var range = printDialog.PageRange;
var rangePaginator = new PageRangeDocumentPaginator(paginator, range.PageFrom, range.PageTo);
printDialog.PrintDocument(rangePaginator, "Print Report (Range)");
}
else if (printDialog.PageRangeSelection == PageRangeSelection.SelectedPages)
{
var dialog = new SelectedPagesDialog { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() != true) return;
// 例: "2,4,6" / "2-4,6"
var indices = PageListParser.ParseToZeroBasedIndices(dialog.InputText, minPage: 1, maxPage: totalPages);
var selectedPaginator = new SelectedPagesDocumentPaginator(paginator, indices);
printDialog.PrintDocument(selectedPaginator, "Print Report (Selected Pages)");
}
この方式は「既存の PrintDialog の流れを大きく壊さずに実現できる」のが強みです。一方で、ユーザーは PrintDialog 上でページ番号入力できると誤解しやすいので、ボタンの近くに説明を出す、もしくは “Selection” を使わずにアプリ側で「飛び飛び指定」ボタンを用意するなど、UI上の配慮も入れると問い合わせが減ります。
方式B:サムネイル付きプレビューでクリック選択する
ページ番号入力は慣れている人には速い反面、ミスが起きやすいのも事実です。レポート用途で「このページとこのページだけ出したい」が頻発するなら、サムネイル選択UIが強力です。
大まかな流れは次のとおりです。
- 元の
DocumentPaginatorから各ページのDocumentPageを取り出す DocumentPage.VisualをVisualBrushにしてサムネイル表示する- クリックで選択状態をトグルし、選択ページだけを印刷対象にする
- 印刷は
- 選択ページのインデックスで
SelectedPagesDocumentPaginatorを作ってPrintDocument、または - 選択ページを貼り付けた
FixedDocumentを新規生成してPrintDocument
- 選択ページのインデックスで
サムネイルモデル(例)
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
public sealed class PageThumbnailItem : INotifyPropertyChanged
{
public int PageNumber { get; } // 1-based 表示用
public VisualBrush Brush { get; }
private bool _isSelected;
public bool IsSelected
{
get => _isSelected;
set { _isSelected = value; OnPropertyChanged(); }
}
public PageThumbnailItem(int pageNumber, VisualBrush brush)
{
PageNumber = pageNumber;
Brush = brush;
}
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
プレビュー画面(XAML例:ItemsControl で一覧)
WordPress で全文を載せるには長くなるので要点だけの例です。実際はスクロールや仮想化(VirtualizingStackPanel)も検討してください。
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Pages}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Click="Toggle_Click" Margin="8">
<StackPanel>
<Border BorderThickness="3">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="BorderBrush" Value="Red"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Rectangle Width="160" Height="220" Fill="{Binding Brush}"/>
</Border>
<TextBlock Text="{Binding PageNumber}" HorizontalAlignment="Center" Margin="0,6,0,0"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
VisualBrush でのサムネイル生成のポイント
VisualBrushは「元 Visual を参照して描画」します。ページ数が多いとメモリ・描画負荷が増えるので、必要に応じて「表示分だけ生成」「Bitmap にレンダリングして軽量化」などを検討します。- 印刷時に「FixedPage を FixedPage にネスト」すると例外になるケースがあります。サムネイルや再構成用途では、VisualBrush を Rectangle の Fill に貼る作り方が扱いやすいです。
- プレビュー時と印刷時で PageSize が変わると、ページ数・レイアウトが変わることがあります。PrintDialog でプリンタ設定が確定した後に page count を確定し、プレビューもその設定で作り直すのが安全です。
選択ページだけを印刷する(プレビュー方式でも paginator ラップが堅い)
方式Bでも、最終的に「選択インデックスの配列」を作れるので、方式Aで作った SelectedPagesDocumentPaginator をそのまま使えます。
var selectedIndices = viewModel.Pages
.Where(x => x.IsSelected)
.Select(x => x.PageNumber - 1) // 0-based
.ToList();
if (selectedIndices.Count == 0)
{
MessageBox.Show("印刷するページが選択されていません。");
return;
}
var selectedPaginator = new SelectedPagesDocumentPaginator(paginator, selectedIndices);
printDialog.PrintDocument(selectedPaginator, "Print Report (Selected Pages)");
この方式の強みは、ユーザーが “内容を見て選べる” ことです。弱みは、プレビュー生成コスト(描画・メモリ)とUI実装の工数が増える点です。レポートが12ページ程度なら十分現実的ですが、数百ページ級になると仮想化や遅延生成が必須になります。
方式C:PrintDialog はプリンタ設定だけに使い、ページ指定UIは自前で完結させる
実務ではこの割り切りが一番きれいに収まることが多いです。つまり、標準の「All / Pages / Selection」フローに寄せるのではなく、アプリ側で次のような印刷UIを持ちます。
- 印刷対象の選択(全ページ / 範囲 / 飛び飛び / クリック選択)
- 入力チェック(範囲外・重複・空など)
- 最後に PrintDialog を出してプリンタ・用紙・向き・部数だけ決める
- 確定した PrintTicket/PrintableAreaWidth に合わせて paginator を再計算して印刷
この構成にすると、標準 PrintDialog の「Selection」表記にユーザーが引っ張られないので、問い合わせや誤操作が減ります。特に社内システム・業務アプリは、標準ダイアログの用語よりも「飛び飛び」「指定ページ」という業務用語を前面に出した方が通じやすいケースが多いです。
実装の落とし穴と、安定稼働のためのチェックリスト
「動いた」から「運用で困らない」へ寄せるために、よくハマる点をまとめます。
ページ数はプリンタ設定で変わる
PrintableAreaWidth/Height、余白、向き(縦/横)が変わると、ページング結果が変わります。以下の順序を守ると事故が減ります。
- PrintDialog を表示してプリンタ設定を確定
paginator.PageSizeをPrintableAreaWidth/Heightに合わせるpaginator.ComputePageCount()などでページ数を確定- そのページ数を使って入力チェック(最大ページ)を行う
入力のエラーメッセージは具体的に
「入力が不正です」だけだとユーザーは直せません。例えば次のように返すと解決が早いです。
- 「ページ 13 は範囲外です(1~12)。」
- 「範囲指定の形式が不正です: ‘3-‘(例:2-4)」
- 「ページ番号は1以上で指定してください: ‘0’」
重複ページの扱いを決める
同じページを2回印刷したい業務要件は稀なので、多くの場合は「重複は除外」が便利です。一方、入力順を尊重したい場合は SortedSet ではなく List を使い、重複の扱いを明示してください(例:重複は除外しつつ入力順維持、など)。
印刷APIは PrintVisual より PrintDocument 寄りが扱いやすい
PrintVisual は単発の Visual を出す用途には便利ですが、複数ページを “ひとまとまりの印刷ジョブ” として扱うなら、DocumentPaginator を整形して PrintDocument に渡す構成がブレにくいです。
まとめ:PrintDialog の仕様を前提に、アプリ側でページ選択を持つ
- UserPages はページ範囲入力であり、
PageRangeから「2–4」のような連続範囲を取得できる - SelectedPages(Selection) はページ番号リスト入力ではなく、WPF 標準 PrintDialog から「2,4,6」を取得する手段はない
- 飛び飛び印刷は、別ダイアログ入力 または プレビューでクリック選択 に切り替えるのが現実的
- 実装は、元の
DocumentPaginatorをラップして、必要なページだけ返す paginator を作り、PrintDocumentで印刷する構成が堅い
「標準 PrintDialog だけで完結させる」ことにこだわるほど難易度が上がる領域なので、仕様を割り切って “ページ選択は自前UI” に寄せるのが、結果的に最短で安定します。

コメント