.NET Framework 4.8のWPF PrintDialogで任意ページ印刷を実装する方法(範囲指定・飛び飛び2,4,6対応)

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 = trueSelectedPagesEnabled = 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,62/4/6ページカンマ区切り。空白は無視
2-42~4ページハイフンで範囲
1-3,5,8-10連続と飛び飛びの混在実務で一番便利
4,4,22/4ページ(重複除外)重複は自動排除する設計が無難

実装面では次のチェックを入れると事故が減ります。

  • 数字以外(全角、記号、連続カンマなど)の扱い
  • 存在しないページ(0ページ、13ページなど)
  • ページ数がプリンタ設定(用紙/余白/向き)で変わる場合の再計算
  • 入力順を保持して印刷するのか、昇順で印刷するのか

入力ダイアログ(簡易)の例

PrintDialog のUIは拡張できないので、別ウィンドウ(例:SelectedPagesDialog)を用意します。

&lt;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"&gt;

    &lt;Grid Margin="16"&gt;
        &lt;Grid.RowDefinitions&gt;
            &lt;RowDefinition Height="Auto"/&gt;
            &lt;RowDefinition Height="Auto"/&gt;
            &lt;RowDefinition Height="Auto"/&gt;
        &lt;/Grid.RowDefinitions&gt;

        &lt;TextBlock Text="印刷するページを入力してください(例:2,4,6 / 2-4,6)" Margin="0,0,0,8"/&gt;

        &lt;TextBox x:Name="PagesTextBox" Width="360" Margin="0,0,0,8"/&gt;

        &lt;StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right"&gt;
            &lt;Button Content="キャンセル" Width="100" Margin="0,0,8,0" IsCancel="True"/&gt;
            &lt;Button Content="OK" Width="100" IsDefault="True" Click="Ok_Click"/&gt;
        &lt;/StackPanel&gt;
    &lt;/Grid&gt;
&lt;/Window&gt;
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” に寄せるのが、結果的に最短で安定します。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次