...ing logging 4.0

はてなブログに移行しました。D言語の話とかいろいろ。

DFL: タイムチャートを描くサンプルコード

std.csvをデータモデルとして受け取って、タイムチャートを描くTimeChartRendererクラスを、dfl.chartモジュールに追加しました。 2023年末にdfl.printingモジュールを作り始めたときからここを目指していましたが、やっとできましたね。

縦軸は信号名、横軸は時間です。 系列ごとのデジタル信号とアナログ信号の別に応じて、表現方法を切り替えます。

CSVの要件

string csv =
    "Time (ms),D1,D2,D3,D4,A1,A2,A3,A4\n" ~
    "0,0,0,0,0,0,0,0,0\n" ~
    "100,1,0,0,0,5,2,10,2\n" ~
    "200,0,1,0,0,6,3,10,-3\n" ~
    "300,1,1,1,0,7,4,9,4\n" ~
    "400,0,0,1,1,8,5,9,-5\n" ~
    "500,1,0,1,1,9,2,8,6\n" ~
    "600,0,0,0,1,8,3,8,-7\n" ~
    "700,1,1,0,1,7,4,7,8\n" ~
    "800,0,1,0,0,6,5,7,-9\n" ~
    "900,1,0,1,0,5,2,6,10\n" ~
    "1000,0,0,1,0,4,3,6,-10\n" ~
    "1100,1,1,1,0,3,4,5,9\n" ~
    "1200,0,1,0,1,2,5,5,-9\n" ~
    "1300,1,0,0,1,1,2,4,8\n" ~
    "1400,0,0,0,1,0,3,4,-8\n";

上のコードに示したものが、図中のタイムチャートの元になっているCSVです。

TimeChartRendererが取り込むCSVは、ヘッダー行が必須です。1行目がヘッダー行として読み込まれます。

ヘッダー行の1列目は、横軸のタイトルとして使われます。

各レコード行の1列目は、横軸(時間軸)の時間として使われます。 2列目以降は、プロットする値として使われます。

コンストラクタ呼び出し

auto _graph = new TimeChartRenderer!(int,int,int,int,int,int,int,int,int)(csv, 15);

TimeChartRenderer(T...)に与えるテンプレートパラメータは、 ヘッダー行(1行目)を除いたレコード部分(2行目以降)のカラムの型を列挙します。 時間軸である1列目の型も含めます。

コンストラクタの引数には、第1引数には文字列を、第2引数にはレコード数を与えます。 ほかのコンストラクタを使うことで描画するレコード範囲を指定できますが、あまり使うことはないでしょう。

設定できる要素

たくさんありますが上の図で使っているものを列挙します。

  • 図の位置
  • 図のマージン
  • 系列ごとのデジタル/アナログの別、色、1つの系列を描く高さ、目盛りの最小値、目盛りの最大値
  • プロットエリアのパディング(上、下、左、右)
  • プロットエリアの枠の色
  • プロットエリアと横軸エリアの隙間
  • 横軸の有無
  • 横軸の目盛りのピクセル
  • 横軸の目盛りのステップ数(いくつのレコードごとに目盛りを打つか)
  • 横軸の目盛りの長さ(内、外)
  • 横軸の目盛りエリアの高さ
  • 縦軸の有無
  • 縦軸の目盛りエリアの幅
  • 各系列のゼロ点を示す線の有無
  • 背景色

サンプルコード

最後の_table関係のコメントアウトを解除すれば、ウィンドウの右の方に表も描画されます。 ウィンドウを最大化しないと表は見えませんのであしからず。

import dfl;

version(Have_dfl) // For DUB.
{
}
else
{
    pragma(lib, "dfl.lib");
}

class MainForm : Form
{
    alias CustomTimeChartRenderer = TimeChartRenderer!(int,int,int,int,int,int,int,int,int);
    CustomTimeChartRenderer _graph;

    alias CustomTableRenderer = TableRenderer!(int,int,int,int,int,int,int,int,int);
    CustomTableRenderer _table;

    public this()
    {
        this.text = "TimeChartRenderer example";
        this.size = Size(600, 650);

        string csv =
            "Time (ms),D1,D2,D3,D4,A1,A2,A3,A4\n" ~
            "0,0,0,0,0,0,0,0,0\n" ~
            "100,1,0,0,0,5,2,10,2\n" ~
            "200,0,1,0,0,6,3,10,-3\n" ~
            "300,1,1,1,0,7,4,9,4\n" ~
            "400,0,0,1,1,8,5,9,-5\n" ~
            "500,1,0,1,1,9,2,8,6\n" ~
            "600,0,0,0,1,8,3,8,-7\n" ~
            "700,1,1,0,1,7,4,7,8\n" ~
            "800,0,1,0,0,6,5,7,-9\n" ~
            "900,1,0,1,0,5,2,6,10\n" ~
            "1000,0,0,1,0,4,3,6,-10\n" ~
            "1100,1,1,1,0,3,4,5,9\n" ~
            "1200,0,1,0,1,2,5,5,-9\n" ~
            "1300,1,0,0,1,1,2,4,8\n" ~
            "1400,0,0,0,1,0,3,4,-8\n";
        _graph = new CustomTimeChartRenderer(csv, 15);
        _graph.location = Point(50, 50);
        _graph.chartMargins = ChartMargins(50, 50, 50, 50);
        _graph.seriesStyleList[0..4] = TimeChartSeriesStyle(true, Color.blue, 20); // Digital
        _graph.seriesStyleList[4..7] = TimeChartSeriesStyle(false, Color.red, 50, 0, 10); // Analog
        _graph.seriesStyleList[7] = TimeChartSeriesStyle(false, Color.red, 100, -10, 20,); // Analog
        _graph.plotAreaTopPadding = 20;
        _graph.plotAreaBottomPadding = 20;
        _graph.plotAreaLeftPadding = 20;
        _graph.plotAreaRightPadding = 20;
        _graph.plotAreaBoundsColor = Color.black;
        _graph.plotAreaAndHorizontalScaleSpanY = 10;
        _graph.hasHorizontalScale = true;
        _graph.horizontalScaleSpan = 20;
        _graph.horizontalScaleStep = 2;
        _graph.horizontalScaleLineInnerSide = 5;
        _graph.horizontalScaleLineOuterSide = 5;
        _graph.horizontalScaleHeight = 20;
        _graph.hasVerticalScale = true;
        _graph.verticalScaleWidth = 40;
        _graph.hasZeroLine = true;
        _graph.backColor = Color.white;

        _table = new CustomTableRenderer(csv, 15);
        _table.location = Point(600, 50);
        _table.hasHeader = true;
        _table.showHeader = true;
        _table.headerLine = true;
        _table.width[] = 50;
    }

    protected override void onPaint(PaintEventArgs e)
    {
        if (_graph)
            _graph.draw(e.graphics);
        // if (_table)
        //     _table.draw(e.graphics);
    }
}

static this()
{
    Application.enableVisualStyles();
}

void main()
{
    Application.run(new MainForm());
}

今後の課題

  • dfl.printingと組み合わせて使う場合、画面と用紙でdpiが全然違うので、画面に丁度いい大きさで表示できていても、印刷したら小さくなってしまう。簡単にスケールを変更できる仕組みが欲しい。

DFLのダウンロード

github.com

DFL: 折れ線グラフを描くサンプルコード

std.csvをデータモデルとして受け取って、折れ線グラフを描くLineGraphRendererクラスを、dfl.chartモジュールに追加しました。

TableRendererに負けず劣らず、折れ線グラフの見かけを自由に変更できるようにしています。

CSVの要件

上図中のコードに示したものが、同図の大きなグラフの元になっているCSVです。

LineGraphRendererに取り込むCSVは、ヘッダー行を必須としました。 1行目はすべて文字列として読み込まれます。 もしCSVにヘッダー行がなくてカラムの型の不一致があった場合はエラーになるので、 CSVには必ずヘッダー行を付けます。

LineGraphRenderer!(T...)に与えるテンプレートパラメータTは、ヘッダー(1行目)を除いたレコード部分(2行目以降)のカラムの型を列挙します。

横軸ラベルがあるCSV

上図のCSVは、1列目が横軸のラベルになっています。 そういうCSVを描画したいときは、1つ目のテンプレートパラメータをstring型にします。 intの1つ分が系列1つ分になるわけですが、デフォルトで用意してある色数の制約により、 最大16個(横軸のラベルの分を除く)まで列挙できます。

alias CustomLineGraphRenderer = LineGraphRenderer!(string,int,int,int,int,int,int,int,int,int,int,int,int,int,int,int,int);
CustomLineGraphRenderer _graph;

横軸ラベルがないCSV

string csv2 =
    "A,B,C\n" ~ 
    "70,80,80\n" ~ 
    "60,90,80\n" ~ 
    "80,70,80\n" ~ 
    "90,60,80\n";

1列目が横軸のラベルではなく、1列目からデータが入っているCSVの場合は、 テンプレートパラメータにカラムの型をそのまま列挙します。 こちらの場合でも、最大16個まで列挙できます。

alias CustomLineGraphRenderer2 = LineGraphRenderer!(int,int,int);
CustomLineGraphRenderer2 _graph2;

記事冒頭図中の小さなグラフは、このタイプのCSVをマージンだけ設定変更して描画したものです。

コンストラクタ呼び出し

auto graph1 = new LineGraphRenderer!(int,int,int)(/+string+/csv2, /+int+/numRecords); // (1)
auto graph2 = new LineGraphRenderer!(int,int,int)(/+string+/csv2, /+int+/firstRecord, /+int+/lastRecord) // (2)

(1)のとおり、1つ目の引数にstring型を、2つ目の引数にレコード数(ヘッダー行を含まない)を与えます。 コンストラクタをstring引数1つで呼び出してから、this.firstRecord=0、this.lastRecord=numRecords-1とするのと同義です。

当初、コンストラクタ呼び出し後にthis.firstRecordとthis.lastRecordの設定をしないと何も表示されなかったのですが、 必須だったらコンストラクタで与えるべきと思い、一番基本的な用法となるだろう(1)を追加したうえで、 (2)の3引数のコンストラクタを増やしました。

TableRendererにも同様のコンストラクタを追加しました。

サンプルコード

同じCSVをTableRenderにより表形式で描画しているコードをコメントアウトしてあるので、 コメントアウトを取れば表も描画されます。

import dfl;

version(Have_dfl) // For DUB.
{
}
else
{
    pragma(lib, "dfl.lib");
}

class MainForm : Form
{
    alias CustomLineGraphRenderer = LineGraphRenderer!(string,int,int,int,int,int,int,int,int,int,int,int,int,int,int,int,int);
    CustomLineGraphRenderer _graph;

    alias CustomLineGraphRenderer2 = LineGraphRenderer!(int,int,int);
    CustomLineGraphRenderer2 _graph2;

    // alias CustomTableRenderer = TableRenderer!(string,int,int,int,int,int,int,int,int,int,int,int,int,int,int,int);
    // CustomTableRenderer _table;

    // alias CustomTableRenderer2 = TableRenderer!(int,int,int);
    // CustomTableRenderer2 _table2;

    public this()
    {
        this.text = "LineGraphRenderer example";
        this.size = Size(1000, 800);
        string csv =
            "教科,山田,佐藤,井上,田中,木下,藤原,山本,大森,伊藤,高橋,鈴木,中村,小林,松井,木村,近藤\n" ~ 
            "国語,70,80,80,75,68,65,55,48,45,38,35,25,20,10,5,1\n" ~ 
            "算数,60,90,80,75,68,65,55,48,45,38,35,25,20,10,5,1\n" ~ 
            "理科,80,70,80,75,68,65,55,48,45,38,35,25,20,10,5,1\n" ~ 
            "社会,90,60,80,75,68,65,55,48,45,38,35,25,20,10,5,1\n";
        _graph = new CustomLineGraphRenderer(csv, 4);
        _graph.showLegend = true;
        _graph.legendLineHeight = 18;
        _graph.chartMargins = ChartMargins(50, 50, 50, 50);
        _graph.plotPointSize = 10;
        _graph.verticalZeroPosition = VerticalZeroPosition.BOTTOM;
        _graph.plotAreaAndLegendSpanX = 50;
        _graph.plotAreaAndHorizontalScaleSpanY = 10;
        _graph.plotAreaLeftPadding = 20;
        _graph.plotAreaRightPadding = 20;
        _graph.plotAreaHeightOnDisplay = 300;
        _graph.hasHorizontalScale = true;
        _graph.horizontalScaleSpan = 100;
        _graph.horizontalScaleLineInnerSide = 0;
        _graph.horizontalScaleLineOuterSide = 5;
        _graph.horizontalScaleHeight = 12;
        _graph.hasVerticalScale = true;
        _graph.verticalMaxScale = 110;
        _graph.verticalScaleLineOuterSide = 5;
        _graph.verticalScaleLineInnerSide = 0;
        _graph.verticalScaleSpan = 20;
        _graph.verticalScaleWidth = 40;
        _graph.backColor = Color.white;
        _graph.plotAreaBoundsColor = Color.black;
        _graph.plotLineColorPalette[0] = Color.black;
        _graph.plotPointFormList[4..8] = PlotPointForm.CROSS;
        _graph.plotPointFormList[8..12] = PlotPointForm.RECTANGLE;
        _graph.plotPointFormList[12..16] = PlotPointForm.TRIANGLE;
        _graph.relocate = Point(50, 50); // Relocate origin point based on top-left margins.

        string csv2 =
            "A,B,C\n" ~ 
            "70,80,80\n" ~ 
            "60,90,80\n" ~ 
            "80,70,80\n" ~ 
            "90,60,80\n";
        _graph2 = new CustomLineGraphRenderer2(csv2, 4);
        _graph2.chartMargins = ChartMargins(10, 10, 10, 10);
        _graph2.relocate = Point(600, 50);

        // _table = new CustomTableRenderer(csv, 4);
        // _table.location = Point(50, 500);
        // _table.hasHeader = true;
        // _table.showHeader = true;
        // _table.headerLine = true;
        // _table.width[] = 40;

        // _table2 = new CustomTableRenderer2(csv2, 4);
        // _table2.hasHeader = true;
        // _table2.showHeader = true;
        // _table2.headerLine = true;
        // _table2.width[] = 40;
        // _table2.location = Point(680, 200);
    }

    protected override void onPaint(PaintEventArgs e)
    {
        if (_graph)
            _graph.draw(e.graphics);
        if (_graph2)
            _graph2.draw(e.graphics);
        // if (_table)
        //     _table.draw(e.graphics);
        // if (_table2)
        //     _table2.draw(e.graphics);
    }
}

static this()
{
    Application.enableVisualStyles();
}

void main()
{
    Application.run(new MainForm());
}

DFLのダウンロード

github.com

DFL: 表を描画するサンプルコード

作図機能を担うdfl.chartモジュールをDFLに追加しました。

まだ、表を描画するTableRendererクラスしかありませんが、 今後は、線グラフ(LineGraphRenderer)や、印刷モジュールdfl.printingを書くそもそもの目的であった タイムチャート(TimeChartRenderer)を作図する機能を追加したいです。

デフォルト設定で描いた表

描画するレコード番号を指定するfirstRecordとlastRecordだけは、必ず設定する必要があります。 そうしないと、何も表示されません。

それ以外をデフォルト設定で描いたものが次の表です。

  • ヘッダーがないCSVとして読み込み(すべてレコード)
  • 罫線なし

設定変更して書いた表

表の見た目は、色々と変更することができます。

本記事冒頭の画像は、次のコードのとおり、見た目を設定した表になっています。

  • 行の高さを変更
  • 列の幅を変更
  • 文字の左と上のパディングを変更
  • 表の左と上のマージンを変更
  • ヘッダーがあるCSVとして読み込み
  • ヘッダーを表示
  • レコード番号0から3までを表示
  • 文字色を黒に
  • 背景色を白に
  • 罫線色を明るい灰色に
  • ヘッダーの下に罫線あり
  • 表の上下左右に罫線有り
  • レコードとレコードの間に罫線あり
  • カラムとカラムの間に罫線あり
  • ヘッダーのフォントを変更
  • レコードのフォントを変更
import dfl;

version(Have_dfl) // For DUB.
{
}
else
{
    pragma(lib, "dfl.lib");
}

class MainForm : Form
{
    alias CustomTableRenderer = TableRenderer!(string, string, string);
    CustomTableRenderer _table;

    public this()
    {
        this.text = "TableRenderer example";
        this.size = Size(450, 450);
        string csv =
            "ID,Name,Value\n" ~ 
            "1,Kyoto,100\n" ~ 
            "2,Osaka,50\n" ~ 
            "3,Tokyo,20\n" ~ 
            "4,Aomori,10\n";
        _table = new CustomTableRenderer(csv);
        _table.height = 40;
        _table.width[0] = 50;
        _table.width[1] = 80;
        _table.width[2] = 150;
        _table.paddingX = 10;
        _table.paddingY = 12;
        _table.margin = Point(20, 20);
        _table.hasHeader = true; // true : 1st line is header.
        _table.showHeader = true;
        _table.firstRecord = 0;
        _table.lastRecord = 3;
        _table.textColor = Color.black;
        _table.backColor = Color.white;
        _table.lineColor = Color.lightGray;
        _table.headerLine = true;
        _table.topSideLine = true;
        _table.leftSideLine = true;
        _table.bottomSideLine = true;
        _table.rightSideLine = true;
        _table.verticalLine = true;
        _table.horizontalLine = true;
        _table.headerFont = new Font("MS Gothic", 16f, FontStyle.BOLD);
        _table.recordFont = new Font("MS Gothic", 12f, FontStyle.REGULAR);
    }

    protected override void onPaint(PaintEventArgs e)
    {
        if (_table)
            _table.draw(e.graphics);
    }
}

static this()
{
    Application.enableVisualStyles();
}

void main()
{
    Application.run(new MainForm());
}

今後の課題

  • [済] 幅が狭くて文字が長いときに文字がセルからはみ出すので、折返し処理とクリッピング処理を選択できるようにする。

DFLのダウンロード

github.com

DFL: 印刷プレビューダイアログのサンプルコード

印刷プレビューダイアログ PrintPreviewDialog が最低限動くようになったのでGitHubで公開しました。

前回記事で述べたPrintDialogとPageSetupDialogの実装ではコモンダイアログを呼び出していますが、PrintPreviewDialogは自分で実装しました。

ツールバーアイコン

ツールバーのアイコンは自作しましたが、ボタンのビットマップをDFLに埋め込むためにリソールファイルを使うのが面倒だったので、下の方法で手抜きしています。

ImageList _imageList = new ImageList;
_imageList.imageSize = Size(32,32);
_imageList.transparentColor = Color.red;
import std.path;
string bmpPath = dirName(__FILE__) ~ r"\image\previewprintdialog_toolbar.bmp";
_imageList.images.addStrip(new Bitmap(bmpPath));
_toolBar.imageList = _imageList;

印刷範囲の設定

今回のアップデートでhasMorePageプロパティをなくしました。 WinFormsではPrintDocument.print()の呼び出し後に印刷終了をユーザが指示する仕組みですが、 DFLでは印刷する前にPrintRange構造体の配列を作って印刷範囲を設定する方法に絞りました。

PrintRangeひとつで(fromPage,toPage)が表現されて、(1,2)なら1ページ目と2ページ目が印刷範囲になります。 (1,1)なら1ページ目だけが印刷範囲です。 (1,3)なら1ページ目、2ページ目及び3ページ目が印刷範囲です。 このPrintRangeの配列を作るので、[(1,3),(4,4),(7,8)]のようになり、この場合は1、2、3、4、7、8ページが印刷範囲になります。

PrintDocument.print()を呼び出すと、印刷範囲の先頭から順に、 printPageイベントハンドラの引数でページ番号が与えられるので、 そのページ番号に応じてユーザが描画します。

印刷範囲が最後までスキャンされれば印刷処理は自然に終わりますが、 e.cancelプロパティを使えば印刷処理の途中で中断することも引き続き可能です。

その他の変更点

座標系の変換を間違えてばかりだったので、変換を一括して請け負うPrinterUnitConvertを実装しました。

サンプルコード

前回の記事に印刷プレビュー部分を追加しただけなので長いですがそのまま記載します。

import dfl;
import std.conv;

class MainForm : Form
{
    private Button _printButton;     /// 印刷ボタン
    private Button _pageSetupButton; /// ページ設定ボタン
    private Button _previewButton;   /// 印刷プレビューボタン
    
    private PrintDocument _document;           /// 印刷されるドキュメント
    private PageSetupDialog _pageSetupDialog;  /// ページ設定ダイアログ
    private PrintDialog _printDialog;          /// 印刷ダイアログ
    private PrintPreviewDialog _previewDialog; /// 印刷プレビューダイアログ

    this()
    {
        this.text = "Simple print"; // ウィンドウタイトルを設定
        this.size = Size(350, 300); // ウィンドウサイズを設定

        this._document = new PrintDocument(); // 印刷されるドキュメントを生成
        this._document.printRange ~= &doPrintRange; // ダイアログで選択された印刷範囲ごとにドキュメントの状態に応じたページ範囲を設定する
        this._document.beginPrint ~= &doBeginPrint; // 印刷範囲の決定後、全体の印刷開始前に呼ばれる
        this._document.queryPageSettings ~= &doQueryPageSettings; // 各ページのページ設定を決定する前に呼ばれる
        this._document.printPage ~= &doPrintPage; // 各ページの印刷をするときに呼ばれる
        this._document.endPrint ~= &doEndPrint; // 印刷が始まる前に、プリンタドライバへの印刷指示が終わった時点で呼ばれる(非同期処理)

        this._pageSetupDialog = new PageSetupDialog(_document); // 印刷するドキュメントを渡して生成
        this._printDialog = new PrintDialog(_document); // 印刷するドキュメントを渡して生成
        this._previewDialog = new PrintPreviewDialog(_document);  // 印刷するドキュメントを渡して生成

        // ページ設定ボタンの設定
        with(_pageSetupButton = new Button())
        {
            parent = this;
            text = "ページ設定...";
            location = Point(50, 50);
            size = Size(100, 30);
            click ~= &doPageSetupDialog;
        }

        // 印刷ボタンの設定
        with(_printButton = new Button())
        {
            parent = this;
            text = "印刷...";
            location = Point(50, 100);
            size = Size(100, 30);
            click ~= &doPrintDialog;
        }

        // 印刷プレビューボタンの設定
        with(_previewButton = new Button())
        {
            parent = this;
            text = "印刷プレビュー...";
            location = Point(50, 150);
            size = Size(100, 30);
            click ~= &doPrintPreview;
        }
    }

    /// ページ設定ボタンをクリックしたとき
    private void doPageSetupDialog(Control sender, EventArgs e)
    {
        // ページ設定ダイアログの初期設定
        _pageSetupDialog.minMargins = new Margins(100, 100, 100, 100); // 1/100インチ単位。上下左右1インチを余白とする
        _pageSetupDialog.showNetwork = true;      // ネットワークボタンを表示(OSによっては表示されない)
        _pageSetupDialog.showHelp = true;         // ヘルプボタンを表示
        _pageSetupDialog.allowMargins = true;     // 余白を変更可能にする
        _pageSetupDialog.allowOrientation = true; // 用紙方向を変更可能にする
        _pageSetupDialog.allowPaper = true;       // 用紙を変更可能にする
        _pageSetupDialog.allowPrinter = true;     // プリンタボタンを表示(OSによっては表示されない)

        // ページ設定ダイアログを表示
        DialogResult r = _pageSetupDialog.showDialog();
        if (r == DialogResult.OK)
        {
            // ページ設定ダイアログで設定した内容を表示
            string msg = "[";
            msg ~= "minMargins: " ~ to!string(_pageSetupDialog.minMargins) ~ ", ";
            msg ~= "defaultPageSettings: " ~ to!string(_pageSetupDialog.document.printerSettings.defaultPageSettings) ~ "]";
            msgBox(msg, "doPageSetupDialog");
        }
    }

    /// 印刷ボタンをクリックしたとき
    private void doPrintDialog(Control sender, EventArgs e)
    {
        // 印刷ダイアログを表示
        DialogResult r = _printDialog.showDialog();
        if (r == DialogResult.OK)
        {
            // OKボタンを押した後の処理があれば書く
        }
    }

    /// 印刷プレビューボタンをクリックしたとき
    private void doPrintPreview(Control sender, EventArgs e)
    {
        // 印刷プレビューダイアログを表示
        DialogResult r = _previewDialog.showDialog();
        if (r == DialogResult.OK)
        {
            // OKボタンを押した後の処理があれば書く
        }
    }

    ///
    private void doPrintRange(PrintDocument doc, PrintRangeEventArgs e)
    {
        final switch (e.printRange.kind)
        {
        case PrintRangeKind.ALL_PAGES:
            // 印刷ダイアログで「すべて」を選択したときのページ範囲を設定
            e.printRange.addPrintRange(PrintRange(1, 8));
            break;
        case PrintRangeKind.SELECTION:
            // 印刷ダイアログで「選択した部分」を選択したときのページ範囲を設定
            e.printRange.addPrintRange(PrintRange(1, 1));
            break;
        case PrintRangeKind.CURRENT_PAGE:
            // 印刷ダイアログで「現在のページ」を選択したときのページ範囲を設定
            e.printRange.addPrintRange(PrintRange(2, 2));
            break;
        case PrintRangeKind.SOME_PAGES:
            // 印刷ダイアログで「ページ指定」を選択したとき
            // ダイアログからページ範囲をもらうのでここでは何も書かない
        }
    }

    ///
    private void doBeginPrint(PrintDocument doc, PrintEventArgs e)
    {
        // msgBox("プリンタドライバへの印刷指示を開始");
    }

    ///
    private void doQueryPageSettings(PrintDocument doc, QueryPageSettingsEventArgs e)
    {
        // 印刷しようとしているページのページ設定をデフォルトから変更する
        // ここでe.pageSettingsを変更しても次のページには影響しない

        // NOTE: まだ印刷開始後に用紙方向を変更することはできない。このタイミングでResetDC()が必要
        // if (e.currentPage == 2)
        //     e.pageSettings.landscape = true; // 2ページ目だけ用紙を横向きにしたい
    }

    ///
    private void doPrintPage(PrintDocument doc, PrintPageEventArgs e)
    {
        Graphics g = e.graphics;
        int dpiX = e.pageSettings.printerResolution.x; // dpi単位
        int dpiY = e.pageSettings.printerResolution.y; // dpi単位

        // すべてのページに余白を描く
        Rect marginRect = Rect(
            e.marginBounds.x * dpiX / 100, // e.marginBoundsは1/100dpi単位
            e.marginBounds.y * dpiY / 100,
            e.marginBounds.width * dpiX / 100,
            e.marginBounds.height * dpiY / 100);
        g.drawRectangle(new Pen(Color.green, 10), marginRect);

        if (e.currentPage == 1) // 1ページの印刷内容を描画
        {
            string str =
                "PrintDcoument.DocumentName: " ~ to!string(doc.documentName) ~ "\n\n" ~
                "PrintDcoument.defaultPageSettings: " ~ to!string(doc.printerSettings.defaultPageSettings) ~ "\n\n" ~
                "PrintDcoument.printerSettings: " ~ to!string(doc.printerSettings) ~ "\n\n" ~
                "PrintPageEventArgs.pageSettings: " ~ to!string(e.pageSettings) ~ "\n\n" ~ 
                "PrintPageEventArgs.pageBounds: " ~ to!string(e.pageBounds) ~ "\n\n" ~
                "PrintPageEventArgs.marginBounds: " ~ to!string(e.marginBounds);
            Rect paramPrintRect = Rect(
                e.marginBounds.x * dpiX / 100, // e.marginBoundsは1/100dpi単位
                e.marginBounds.y * dpiY / 100,
                e.marginBounds.width * dpiX / 100,
                e.marginBounds.height * dpiY / 100
            );
            g.drawText(
                str,
                new Font("MS Gothic", 8/+pt+/ * dpiX / 72),
                Color.black,
                paramPrintRect
            );
        }
        else if (e.currentPage >= 2 || e.currentPage <= 8) // 2-8ページの印刷内容を描画
        {
            Rect redRect = Rect(1 * dpiX, 1 * dpiY, 1 * dpiX, 1 * dpiY); // 1×1インチの正方形
            redRect.offset(marginRect.x, marginRect.y); // 余白の分だけ座標をオフセット(要改良)
            g.fillRectangle(new SolidBrush(Color.red), redRect);

            Rect blueRect = Rect(dpiX, dpiY, 3 * dpiX, 3 * dpiY); // 3×3インチの正方形
            blueRect.offset(marginRect.x, marginRect.y); // 余白の分だけ座標をオフセット(要改良)
            g.drawRectangle(new Pen(Color.blue, 10), blueRect);

            Rect textRect = Rect(1 * dpiX, 1 * dpiY, 1 * dpiX, 1 * dpiY); // 1×1インチの正方形
            textRect.offset(marginRect.x, marginRect.y); // 余白の分だけ座標をオフセット(要改良)
            g.drawText(
                "ABCDEあいうえお",
                new Font("MS Gothic", 12/+pt+/ * dpiX / 72), // 1ポイントは1/72インチ
                Color.black,
                textRect
            );

            Rect purpleRect = Rect(3 * dpiX, 3 * dpiY, 1 * dpiX, 1 * dpiY); // 1×1インチの正方形
            purpleRect.offset(marginRect.x, marginRect.y); // 余白の分だけ座標をオフセット(要改良)
            g.drawEllipse(new Pen(Color.purple, 10), purpleRect);

            Pen pen = new Pen(Color.black, 10);
            enum lineNum = 20;
            for (int x; x < lineNum; x++)
            {
                g.drawLine(
                    pen,
                    marginRect.x + cast(int)(x / 4.0 * dpiX),
                    e.marginBounds.y * dpiY / 100,
                    marginRect.x + cast(int)((lineNum - x - 1)/4.0 * dpiX),
                    e.marginBounds.bottom * dpiY / 100);
            }
        }
    }

    ///
    private void doEndPrint(PrintDocument doc, PrintEventArgs e)
    {
        // msgBox("印刷を指示しました");
    }
}

static this()
{
    Application.enableVisualStyles();
}

void main()
{
    Application.run(new MainForm());
}

今後の課題

  • PrintPreviewDialogで、Fitモードでないときにスクロールバーを表示できるようにする。
  • PrintPreviewDialogで、Fitモードのときにウィンドウの横幅に対してもFitするようにする。
  • 途中のページから用紙方向を変えられるようにする。 用紙方向を変えるためにはResetDC()を呼べばいいが、 そのためにはDEVMODE構造体を別のところから持ってこないとできない。
  • 印刷(printing)モジュールの上に、タイムチャート描画ライブラリを実装する。

DFLのダウンロード

github.com

DFL: 印刷のサンプルコード

DFLの印刷モジュールがだいたい動くようになったので、試験的に公開しました。

基本的な使い方は、WinFormsのPrintDocument、PrintDialog、PrintSetupDialog関係と大体同じなのですが、 WinFormsでは色々な使い方ができるように汎用性を高めてあるせいか仕様が直感的でなかったので、 アレンジしたところがあります。

サンプルコードの説明

まずは、本記事の末尾にあるサンプルコードの説明です。 実行すると、ページ設定ボタンと印刷ボタンがあるウィンドウが開きます。

ページ設定ボタンをクリックすれば、コモンダイアログのページ設定ダイアログが開かれます。 WinFormsと同じく、このウィンドウにはPageSetupDialogクラスが対応します。

既定のプリンタに応じて、

  • 用紙サイズ
  • 給紙方法
  • 印刷の向き
  • 余白の大きさ

が変更できます。横書きに設定した例は、下図のとおりです。

また、印刷ボタンをクリックすれば、コモンダイアログの印刷ダイアログが開かれます。 こちらもWinFormsと同じく、このウィンドウにはPrintDialogクラスが対応します。

ここでは次の項目が変更できます。

  • 使用するプリンタ
  • ページ範囲(「すべて」、「選択した部分」、「現在のページ」、「ページ指定」のいずれか)
  • 部数

まだ、「ファイルへ出力」、「適用」は、ちゃんと動いていないかもしれません(未確認)。

WinFormsとの違い

  • PrinterSettingsだけが既定のページ設定情報defaultPageSettingsを持ちます。 WinFormsのPrintDocumentは、既定のページ設定情報DefaultPageSettingsを持ちますが、DFLでは直接持たず、 その役割はPrinterSettingsに集約されます。 つまり、PrintDocumentはPrinterSettingsを持ち、PrinterSettingsはPageSettings(defaultPageSettings)を持ちます。
  • DFLでは、印刷範囲の種類は、PrintRange列挙型ではなく、PrintRangeKind列挙型で表します。
  • DFLでは、PrintRangeは構造体であり、一組のページ範囲(fromPageからtoPageまで)を表します。
  • WinFormsでは、印刷範囲をPrinterSettingsがfromPageとtoPageというプロパティで持ちますが、 (1-10,20-30)のような飛び飛びのページ範囲を素直に表現できないので、 DFLでは、印刷範囲をPrinterSettingsが持つPrintRangeSettingsクラスで表します。
  • PrintRangeSettingsクラスは、印刷範囲の種類PrintRangeKindを持ち、また、PrintRange構造体の配列を持ちます。 例えば、印刷ダイアログで「ページ指定」を選択して印刷するときに「1,2-3,4」と入力されると、 (1,1),(2,3),(4,4)の構造に対応したPrintRange構造体の配列が作成されます。
  • WinFormsでは、beginPrint、endPrint、printPage、queryPageSettingsイベントハンドラの4種類がありますが、 DFLでは、PrintDocumentが新しいイベントハンドラprintRangeを持ちます。 DFLでは、印刷の開始前(beginPrintよりも前)に、printRangeイベントハンドラが呼ばれます。 システムはこのときに印刷ダイアログで選択された印刷範囲の種類をユーザーに与えるので、 ユーザーは与えられた印刷範囲の種類に応じて、印刷したいページ番号を設定します。 印刷ダイアログで「ページ指定」が選択された場合は、システムの方が印刷範囲を知っているので、 ユーザー側で印刷範囲を設定する必要はありません。 印刷ダイアログで「すべて」、「現在のページ」、「選択された部分」が選択されたときは、 具体的なページ番号を知っているのはユーザー側でありPrintDocument側なので、このようにしました。
  • DFLでは、PrintPageEventArgsとQueryPageSettingsEventArgsが印刷ページ番号currentPageプロパティを持ちます。 ユーザーはイベントハンドラから与えられた印刷ページ番号currentPageを見て、そのページに印刷したい内容を描画します。
  • WinFormsでは、印刷描画処理の中でhasMorePageプロパティをfalseに設定することで印刷の終了をシステムに伝える仕組みがあります。 DFLでは、ページ範囲を前述のpageRangeイベントハンドラで設定するので、hasMorePageを使わなくても、 ページ範囲の終端まで印刷された時点で自然に印刷が終了します。 今のところ、hasMorePageは残してあります。
  • DFLでは、PageSetupDialogとPrintDialogのコンストラクト時にPrintDocumentを与える必要があります。 システムは、PrintDocumentからPrinterSettingsを取得し、また、PrinterSettingsから(default)PageSettingsを取得します。 WinFormsのように、各Dialogクラスに直接PrinterSettingsクラスとPageSettingsクラスを与えることはできません。
  • 座標系の自動変換にはまだ対応していません。 1/10mm、1/1000mm、1/100インチ、1/100DPIなどの座標系単位が混ざっているので変換を自前でするのが面倒です。
  • 途中のページから用紙方向を変えることはまだできません。

サンプルコード

長くなりますがGitHubの方には上げていないので全文記載します。

import dfl;
import std.conv;

class MainForm : Form
{
    private Button _printButton;     /// 印刷ボタン
    private Button _pageSetupButton; /// ページ設定ボタン
    
    private PrintDocument _document;          /// 印刷されるドキュメント
    private PageSetupDialog _pageSetupDialog; /// ページ設定ダイアログ
    private PrintDialog _printDialog;         /// 印刷ダイアログ

    this()
    {
        this.text = "Simple print"; // ウィンドウタイトルを設定
        this.size = Size(350, 300); // ウィンドウサイズを設定

        this._document = new PrintDocument(); // 印刷されるドキュメントを生成
        this._pageSetupDialog = new PageSetupDialog(_document); // 印刷するドキュメントを渡して生成
        this._printDialog = new PrintDialog(_document); // 印刷するドキュメントを渡して生成

        // ページ設定ボタンの設定
        with(_pageSetupButton = new Button())
        {
            parent = this;
            text = "ページ設定...";
            location = Point(50, 50);
            size = Size(100, 30);
            click ~= &doPageSetupDialog;
        }

        // 印刷ボタンの設定
        with(_printButton = new Button())
        {
            parent = this;
            text = "印刷...";
            location = Point(50, 100);
            size = Size(100, 30);
            click ~= &doPrintDialog;
        }
    }

    /// ページ設定ボタンをクリックしたとき
    private void doPageSetupDialog(Control sender, EventArgs e)
    {
        // ページ設定ダイアログの初期設定
        _pageSetupDialog.minMargins = new Margins(100, 100, 100, 100); // 1/100インチ単位。上下左右1インチを余白とする
        _pageSetupDialog.showNetwork = true;      // ネットワークボタンを表示(OSによっては表示されない)
        _pageSetupDialog.showHelp = true;         // ヘルプボタンを表示
        _pageSetupDialog.allowMargins = true;     // 余白を変更可能にする
        _pageSetupDialog.allowOrientation = true; // 用紙方向を変更可能にする
        _pageSetupDialog.allowPaper = true;       // 用紙を変更可能にする
        _pageSetupDialog.allowPrinter = true;     // プリンタボタンを表示(OSによっては表示されない)

        // ページ設定ダイアログを表示
        DialogResult r = _pageSetupDialog.showDialog();
        if (r == DialogResult.OK)
        {
            // ページ設定ダイアログで設定した内容を表示
            string msg = "[";
            msg ~= "minMargins: " ~ to!string(_pageSetupDialog.minMargins) ~ ", ";
            msg ~= "defaultPageSettings: " ~ to!string(_pageSetupDialog.document.printerSettings.defaultPageSettings) ~ "]";
            msgBox(msg, "doPageSetupDialog");
        }
    }

    /// 印刷ボタンをクリックしたとき
    private void doPrintDialog(Control sender, EventArgs e)
    {
        // 印刷ダイアログの初期設定
        _printDialog.allowSomePages = true; // 「ページ指定」を入力可能にする
        _printDialog.showHelp = true; // ヘルプボタンを表示

        // ダイアログで選択された印刷範囲ごとにドキュメントの状態に応じたページ範囲を設定する
        _printDialog.document.printRange ~= (PrintDocument doc, PrintRangeEventArgs e) {
            final switch (e.printRange.kind)
            {
            case PrintRangeKind.ALL_PAGES:
                // 印刷ダイアログで「すべて」を選択したときのページ範囲を設定
                e.printRange.addPrintRange(PrintRange(1, 2));
                break;
            case PrintRangeKind.SELECTION:
                // 印刷ダイアログで「選択した部分」を選択したときのページ範囲を設定
                e.printRange.addPrintRange(PrintRange(1, 1));
                break;
            case PrintRangeKind.CURRENT_PAGE:
                // 印刷ダイアログで「現在のページ」を選択したときのページ範囲を設定
                e.printRange.addPrintRange(PrintRange(2, 2));
                break;
            case PrintRangeKind.SOME_PAGES:
                // 印刷ダイアログで「ページ指定」を選択したとき
                // ダイアログからページ範囲をもらうのでここでは何も書かない
            }
        };

        // 印刷範囲の決定後、全体の印刷開始前に呼ばれる
        _printDialog.document.beginPrint ~= (PrintDocument doc, PrintEventArgs e) {
            // msgBox("プリンタドライバへの印刷指示を開始");
        };

        // 各ページのページ設定を決定する前に呼ばれる
        _printDialog.document.queryPageSettings ~= (PrintDocument doc, QueryPageSettingsEventArgs e) {
            // 印刷しようとしているページのページ設定をデフォルトから変更する
            // ここでe.pageSettingsを変更しても次のページには影響しない

            // NOTE: まだ印刷開始後に用紙方向を変更することはできない。このタイミングでResetDC()が必要
            // if (e.currentPage == 2)
            //     e.pageSettings.landscape = true; // 2ページ目だけ用紙を横向きにしたい
        };

        // 各ページの印刷をするときに呼ばれる
        _printDialog.document.printPage ~= (PrintDocument doc, PrintPageEventArgs e) {
            Graphics g = e.graphics;
            int dpiX = e.pageSettings.printerResolution.x; // dpi単位
            int dpiY = e.pageSettings.printerResolution.y; // dpi単位

            // すべてのページに余白を描く
            Rect marginRect = Rect(
                e.marginBounds.x * dpiX / 100, // e.marginBoundsは1/100dpi単位
                e.marginBounds.y * dpiY / 100,
                e.marginBounds.width * dpiX / 100,
                e.marginBounds.height * dpiY / 100);
            g.drawRectangle(new Pen(Color.green, 10), marginRect);

            if (e.currentPage == 1) // 1ページの印刷内容を描画
            {
                string str =
                    "PrintDcoument.DocumentName: " ~ to!string(doc.documentName) ~ "\n\n" ~
                    "PrintDcoument.defaultPageSettings: " ~ to!string(doc.printerSettings.defaultPageSettings) ~ "\n\n" ~
                    "PrintDcoument.printerSettings: " ~ to!string(doc.printerSettings) ~ "\n\n" ~
                    "PrintPageEventArgs.pageSettings: " ~ to!string(e.pageSettings) ~ "\n\n" ~ 
                    "PrintPageEventArgs.pageBounds: " ~ to!string(e.pageBounds) ~ "\n\n" ~
                    "PrintPageEventArgs.marginBounds: " ~ to!string(e.marginBounds);
                Rect paramPrintRect = Rect(
                    e.marginBounds.x * dpiX / 100, // e.marginBoundsは1/100dpi単位
                    e.marginBounds.y * dpiY / 100,
                    e.marginBounds.width * dpiX / 100,
                    e.marginBounds.height * dpiY / 100
                );
                g.drawText(
                    str,
                    new Font("MS Gothic", 8/+pt+/ * dpiX / 72),
                    Color.black,
                    paramPrintRect
                );

                e.hasMorePage = true; // 次のページがあるのでtrue
            }
            else if (e.currentPage == 2) // 2ページの印刷内容を描画
            {
                Rect redRect = Rect(1 * dpiX, 1 * dpiY, 1 * dpiX, 1 * dpiY); // 1×1インチの正方形
                redRect.offset(marginRect.x, marginRect.y); // 余白の分だけ座標をオフセット(要改良)
                g.fillRectangle(new SolidBrush(Color.red), redRect);

                Rect blueRect = Rect(dpiX, dpiY, 3 * dpiX, 3 * dpiY); // 3×3インチの正方形
                blueRect.offset(marginRect.x, marginRect.y); // 余白の分だけ座標をオフセット(要改良)
                g.drawRectangle(new Pen(Color.blue, 10), blueRect);

                Rect textRect = Rect(1 * dpiX, 1 * dpiY, 1 * dpiX, 1 * dpiY); // 1×1インチの正方形
                textRect.offset(marginRect.x, marginRect.y); // 余白の分だけ座標をオフセット(要改良)
                g.drawText(
                    "ABCDEあいうえお",
                    new Font("MS Gothic", 12/+pt+/ * dpiX / 72), // 1ポイントは1/72インチ
                    Color.black,
                    textRect
                );

                Rect purpleRect = Rect(3 * dpiX, 3 * dpiY, 1 * dpiX, 1 * dpiY); // 1×1インチの正方形
                purpleRect.offset(marginRect.x, marginRect.y); // 余白の分だけ座標をオフセット(要改良)
                g.drawEllipse(new Pen(Color.purple, 10), purpleRect);

                Pen pen = new Pen(Color.black, 10);
                enum lineNum = 20;
                for (int x; x < lineNum; x++)
                {
                    g.drawLine(
                        pen,
                        marginRect.x + cast(int)(x / 4.0 * dpiX),
                        e.marginBounds.y * dpiY / 100,
                        marginRect.x + cast(int)((lineNum - x - 1)/4.0 * dpiX),
                        e.marginBounds.bottom * dpiY / 100);
                }

                e.hasMorePage = false; // 次のページがないのでfalse
            }
        };

        // 印刷が始まる前に、プリンタドライバへの印刷指示が終わった時点で呼ばれる(非同期処理)
        _printDialog.document.endPrint ~= (PrintDocument doc, PrintEventArgs e) {
            // msgBox("印刷を指示しました");
        };

        // 印刷ダイアログを表示
        DialogResult r = _printDialog.showDialog();
        if (r == DialogResult.OK)
        {
            // OKボタンを押した後の処理があれば書く
        }
    }
}

static this()
{
    Application.enableVisualStyles();
}

void main()
{
    Application.run(new MainForm());
}

今後の課題

  • 座標系の変換が自動でされるようにする。少なくともPrinterUnitConvertとかMarginsConverterとかのヘルパークラスを作って楽したい。
  • 途中のページから用紙方向を変えられるようにする。ResetDC()を呼ぶためにはDEVMODE構造体を別のところから持ってこないとできない。
  • PrintPreviewDialogを実装する。その前にPrintPreviewControlを実装するべきかもしれない。
  • 印刷(printing)モジュールの上に、タイムチャート描画ライブラリを実装する。

DFLのダウンロード

github.com

DFL: 既定プリンターの給紙方法を取得する

年末から、DFLに印刷機能を追加しようとしている。 GDI Print APIという古いAPIを使っているので日本語の資料は色々あるけど、先は長そうだ。 まずは、APIの使い方を調べるため、ページ設定ダイアログと印刷ダイアログを表示したり、印刷するには印刷できたが、 印刷スケールが滅茶苦茶だったりして、まだまともに動作していない。 とりあえず、WinFormsのPageSetupDialogクラスに相当する実装が動くようにしよう。

さて、ページ設定ダイアログにある給紙方法の選択コンボボックスでの選択結果を取得する方法がよく分からなくて詰まっていたが、 なんとか動くようになったので、一旦、ここにまとめておく。 DeviceCapabilities()にDC_BINSやDC_BINNAMESをセットして返ってくる給紙方法番号や給紙方法文字列が、めんどくさい仕様だった。

ページ設定ダイアログで選択された給紙方法番号は、DEVMODE.dmDefaultSourceで得られる。 この番号は、次のとおり定義されている(wingdi.dから引用)。

// DEVMODE.dmDefaultSource
enum : short {
    DMBIN_ONLYONE = 1,
    DMBIN_UPPER   = 1,
    DMBIN_LOWER,
    DMBIN_MIDDLE,
    DMBIN_MANUAL,
    DMBIN_ENVELOPE,
    DMBIN_ENVMANUAL,
    DMBIN_AUTO,
    DMBIN_TRACTOR,
    DMBIN_SMALLFMT,
    DMBIN_LARGEFMT,
    DMBIN_LARGECAPACITY, // = 11
    DMBIN_CASSETTE   = 14,
    DMBIN_FORMSOURCE,
}
enum : short {
    DMBIN_FIRST = DMBIN_UPPER,
    DMBIN_LAST = DMBIN_FORMSOURCE,
    DMBIN_USER = 256,
}

給紙方法番号は、上記のシステム定義のほか、DMBIN_USER(256)以上の番号で、プリンタごとに定義されている。 うちのプリンターの場合は、5種類の給紙方法のうち、4つがユーザー定義で、1つがシステム定義だった。

DeviceCapabilities()にDC_BINSとWORD型の配列バッファを与えると、配列バッファに給紙方法番号が得られる。 つまり、要素が給紙方法番号を表している

[276, 7, 277, 257]

のような配列が得られる。 また、配列の要素数は関数の戻り値で得られる。

DeviceCapabilities()にDC_BINNAMESと文字配列のバッファを与えると、配列バッファに給紙方法文字列が得られる。 この文字列の仕様がめんどくさい。 それぞれの文字列は24文字ごとに書き込まれていて、 1つの文字列は最大24文字までで、null終端されている。 ただし、1つの文字列が24文字のときは、null終端がない。 よって、文字列ごとにwstring型に分割して代入するために、null終端されているのが何文字目かを調べて、文字列長を修正する手間がかかった。

これで、給紙方法番号と給紙方法文字列の対応が得られた。 2つの配列の同じインデックス番号同士の要素が、それぞれ組になる。 値は嘘だが次のようなイメージ。

[276, 7, 277, 257]
["自動", "手差し", "上トレイ", "下トレイ"]

さらにめんどくさいことに、次は、dmDefaultSourceと同じ値(上記の277とか)を、 給紙方法番号配列から検索して、そのインデックス番号(上記なら2)を得る。

給紙方法文字列の配列をそのインデックス番号で引くと、 ページ設定ダイアログに表示されているものと同じ文字列が得られる。

ということで、とりあえず動いていそうなコードは以下のとおり。

PaperSource createPaperSource(HGLOBAL hDevMode, HGLOBAL hDevNames)
{
    DEVMODE* pDevMode = cast(DEVMODE*)GlobalLock(hDevMode);
    scope(exit)
        GlobalUnlock(pDevMode);
    DEVNAMES* pDevNames = cast(DEVNAMES*)GlobalLock(hDevNames);
    scope(exit)
        GlobalUnlock(pDevNames);
    
    // Get printer basic settings.
    string deviceName = fromUnicodez(pDevMode.dmDeviceName.ptr);
    string outputPort = fromUnicodez(cast(wchar*)(cast(ubyte*)pDevNames + pDevNames.wOutputOffset * wchar.sizeof));

    // Get default paper source kind.
    PaperSourceKind sourceKind = {
        if (pDevMode.dmDefaultSource <= DMBIN_LAST) // System defined paper source.
            return cast(PaperSourceKind)pDevMode.dmDefaultSource;
        else if (pDevMode.dmDefaultSource >= DMBIN_USER) // User defined paper source.
            return PaperSourceKind.CUSTOM;
        else
            assert(0);
    }();

    // Get number of paper sources.
    int sourceNum = DeviceCapabilities(toUnicodez(deviceName), "", DC_BINS, null, pDevMode);
    WORD[] sourceBuffer = new WORD[sourceNum];
    DeviceCapabilities(toUnicodez(deviceName), "", DC_BINS, cast(wchar*)sourceBuffer.ptr, pDevMode);
    WORD[] sourceList;
    for (int i = 0; i < sourceNum; i++)
        sourceList ~= sourceBuffer[i];

    // Get name of paper sources.
    enum BINNAME_MAX_LENGTH = 24;
    wchar[] sourceNamesBuffer = new wchar[BINNAME_MAX_LENGTH * sourceNum];
    DeviceCapabilities(toUnicodez(deviceName), toUnicodez(outputPort), DC_BINNAMES, sourceNamesBuffer.ptr, pDevMode);
    // Reference: https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-devicecapabilitiesw
    // Value: DC_BINNAMES
    // Meaning: Retrieves the names of the printer's paper bins.
    //          The pOutput buffer receives an array of string buffers.
    //          Each string buffer is 24 characters long and contains the name of a paper bin.
    //          The return value indicates the number of entries in the array.
    //          The name strings are null-terminated unless the name is 24 characters long.
    //          If pOutput is NULL, the return value is the number of bin entries required.
    wstring[] sourceNameList;
    for (int i = 0; i < sourceNum; i++)
    {
        wchar* w = cast(wchar*)(cast(ubyte*)sourceNamesBuffer + i * BINNAME_MAX_LENGTH * wchar.sizeof);
        int end = -1;
        for (int j = 0; j < BINNAME_MAX_LENGTH; j++)
        {
            if (w[j] == '\0')
            {
                end = j;
                break;
            }
        }
        if (end == -1) // Null terminal is not found.
            sourceNameList ~= w[0..BINNAME_MAX_LENGTH].dup; // TODO: Is it correct?
        else
            sourceNameList ~= w[0..end].dup; // Contains null terminal.
    }

    // Get paper source name.
    // Search index of paper source.
    wstring sourceName = {
        int index = -1;
        for (int i = 0; i < sourceNum; i++)
        {
            if (sourceList[i] == pDevMode.dmDefaultSource)
            {
                index = i;
                break;
            }
        }
        if (index != -1)
            return sourceNameList[index];
        else
            return "no name"w;
    }();
    
    return new PaperSource(sourceKind, to!string(sourceName));
}

備考

  • toUnicodez()の実装は、ここには載っていない。DFLの関数なので、省略している。
  • hDevModeとhDevNamesは、上位の関数内で生成、開放すること。 生成は、ページ設定ダイアログを開いたらシステムがやってくれるので、実際には開放処理だけ書けばいい。

DFL: RichTextBoxのサンプルコード

RichTextBox(リッチエディットコントロール)のサンプルコードです。

全文を貼ると長くなるのでソースはリンク先を参照してください。

サンプルを起動すると下図のテキストが表示されます。

ツールバーには以下のボタンがあり、それぞれRichTextBoxクラスのメソッドに対応しています。

  • Bold: 太字にする。
  • UnderLine: 下線を引く。
  • Font: フォントを設定する。
  • BaseUp: ベースラインを上げる。
  • BaseDown: ベースラインを下げる。
  • F.Color: 文字色を変更する。
  • B.Color: 背景色を変更する。
  • ^X: 上付き文字にする。
  • _X: 下付き文字にする。
  • GetText: テキストを取得してメッセージボックスで表示する。
  • InsText: [Insert Text] というテキストをカーソル位置に挿入する。
  • GetRtf: 選択範囲のRTFデータを取得してメッセージボックスで表示する。
  • GetSelNum: 選択範囲の文字数を取得してメッセージボックスで表示する。
  • SelNum(5): カーソル位置から5文字分を範囲選択する。

テキストを範囲選択後、ツールバーのボタンを使って色々な書式を設定したものが冒頭の画像になります。 メッセージボックスは、全文選択してからGetRtfボタンを押したときのものです。

構造体のアラインメントの問題

1か月悩んだことを書いておきます。

バグったときに構造体のアラインメントを気にしないといけないのは辛い・・・。

1. リンク修飾の範囲が正しく得られない

リッチエディットコントロールの標準機能により、URLの自動検出機能を有効にすると画像のようにURL部分がリンク修飾されます。 ここをクリックするとEN_LINK通知コードが発行されるので、これをウィンドウプロシージャで捕まえるとリンク部分のテキストが得られます。

しかし、本来ならば、ENLINK構造体のメンバのCHARRANGE構造体のメンバであるcpMinとcpMaxで範囲が得られるはずなのですが、なぜか正しい範囲が得られません。 どうしても解決できないのでVisualStudioを使いC++でEN_LINK通知コードを捕まえる処理を書いて挙動を確認してみると、こっちではちゃんと正しい範囲が得られました。

そこで、C++とDのEN_LINK構造体のサイズを比較してみると、C++では52バイト、Dでは56バイトでした。

WPARAMやLPARAMを別の型にキャストして使うのは、Windowsプログラミングではよく出てくる形ですが、DでLIBやDLLを使う場面では、構造体サイズが異なることによってまともに動かないことがあるようです。

下図のように、ENLINK構造体にalign(1):を追記することで構造体サイズがC++と一致し、バグは解消しました。

2. EM_STREAMOUT / EM_STREAMIN メッセージを送ると例外で落ちる

リッチエディットコントロールからRTFを取得する方法はいくつかあるようです。 DFLでは元々EM_STREAMOUTメッセージを使う方法が使われていました。 しかし、動作確認をしてみると全然動いておらず、実行時に謎の例外で落ちます。

こちらも色々と原因を探っていましたが、C++とDでEDITSTREAM構造体のサイズを比較すると異なっており、前述と同じアラインメントの問題でした。

下図のように、EDITSTREAM構造体にalign(1):を追記することで構造体サイズがC++と一致し、バグは解消しました。

変更点

今回のRichTextBoxの改修に伴い、読み込むリッチエディットコントロールのバージョンが3.0以下(Riched20.dll)から4.1以上(Msftedit.dll)に変更されています。

DFLのダウンロード

github.com