月別アーカイブ: 2022年8月

TDirectory.GetFiles Function

「特定ファイルの名称をフルパス付きで取得したい!」

例えば、特定のフォルダ内にある拡張子が「jpg」のファイルの名称と、そのファイルまでのフルパスをイッキに全部!取得したい・・・なんて場合の覚書。

1.特定のフォルダ内にあるJpeg画像名を全部取得
2.サブフォルダがあったらその中も探す
3.ListBoxに高速で項目を追加する方法
4.まとめ
5.お願いとお断り

1.特定のフォルダ内にあるJpeg画像名を全部取得

ある特定のフォルダにある拡張子が「jpg」のファイル全てについて、そこまでのフルパスを取得してListBoxに表示する方法は次の通り。

FormにButtonとListBoxを一つずつ追加して、以下を記述。

implementation

uses
  System.Types,
  System.IOUtils,
  System.StrUtils,
  System.Masks,
  Vcl.FileCtrl,
  System.UITypes;

  //System.TypesはTStringDynArrayを使うために追加
  //System.IOUtilsはTDirectory.TFilterPredicateを使うために追加
  //System.StrUtilsはSplitStringを使うために追加
  //System.MasksはMatchesMaskを使うために追加
  //Vcl.FileCtrlはSelectDirectoryを使うために追加
  //System.UITypesはMessageDlgを使うために追加

{$R *.dfm}

procedure TForm1.ButtonXClick(Sender: TObject);
var
  //フォルダの選択
  iStartFolder: string;
  iDirectories: TArray<string>;
  //ファイルリストの取得
  FileNames: TStringDynArray;
  strFileName: String;

  //ファイルを検索
  function MyGetFiles(const Path, Masks: string): TStringDynArray;
  var
    MaskArray: TStringDynArray;
    Predicate: TDirectory.TFilterPredicate;
  begin
    MaskArray := SplitString(Masks, ';');
    Predicate :=
      function(const Path: string; const SearchRec: TSearchRec): Boolean
      var
        Mask: string;
      begin
        for Mask in MaskArray do
          if MatchesMask(SearchRec.Name, Mask) then
            exit(True);
        exit(False);
      end;
    Result := TDirectory.GetFiles(Path, Predicate);
  end;
begin
  //フォルダを選択
  iStartFolder:=ExtractFilePath(Application.ExeName);
  if SelectDirectory(iStartFolder, iDirectories,
    [sdHidePinnedPlaces, sdNoDereferenceLinks, sdForceShowHidden,
    sdAllowMultiselect], 'フォルダを選択してください', 'Folder', 'Ok') then
  begin
    FileNames:=MyGetFiles(iDirectories[0], '*.jpg');
    for strFileName in FileNames do
    begin
      ListBox1.Items.Add(strFileName);
    end;
  end else begin
    //確認(キャンセルされた時に何かしたい場合)
    MessageDlg('キャンセルされました', mtInformation, [mbOk] , 0);
  end;
end;

たくさんのファイルを扱う場合には、ファイルへのPathとそれぞれに異なるファイル名の処理がまず問題になるが、上記の方法を用いれば、用意したListBoxに指定した拡張子のファイルのみ、フルパス付きでリストが作成される。TListBoxを表示する必要がなければVisibleプロパティをFalseにしておけば、その存在はまったく気にならないし、あとは、使いたい時にItemIndexやItems.Countを参照するだけでOKだから、Pathとファイル名の取得についてはもうなぁーんにも気にすることがなくなり、非常に便利!

例えば、以下の通り。

  //ファイルの数だけLoopする
  for i := 0 to ListBox1.Items.Count-1 do
  begin
    ShowMessage(ListBox1.Items[i]);
  end;

2.サブフォルダがあったらその中も探す

上の例のTDirectory.GetFiles関数では検索する際、サブフォルダを無視しているが、引数の指定を次のように変更すれば、サブフォルダ内も検索できる。

procedure TForm1.ButtonXClick(Sender: TObject);
var
  //フォルダの選択
  iStartFolder: string;
  iDirectories: TArray<string>;
  //ファイルリストの取得
  FileNames: TStringDynArray;
  strFileName: String;
  //検索するファイルの拡張子を指定
  SearchPattern: string;
  //サブフォルダも検索
  Option: TSearchOption;
begin
  //初期化
  ListBox1.Items.Clear;
  //フォルダを選択
  iStartFolder:=ExtractFilePath(Application.ExeName);
  if SelectDirectory(iStartFolder, iDirectories,
    [sdHidePinnedPlaces, sdNoDereferenceLinks, sdForceShowHidden,
    sdAllowMultiselect], 'フォルダを選択してください', 'Folder', 'Ok') then
  begin
    SearchPattern:= '*.jpg';
    //検索モード
    //Option:= TSearchOption.soTopDirectoryOnly; //指定フォルダ直下のみ
    Option:= TSearchOption.soAllDirectories; //サブフォルダ内も検索
    //指定拡張子のファイル名をフルパス付きで取得
    FileNames:= TDirectory.GetFiles(iDirectories[0], SearchPattern, Option);
    for strFileName in FileNames do
    begin
      ListBox1.Items.Add(strFileName);
    end;
  end else begin
    //確認(キャンセルされた時に何かしたい場合)
    MessageDlg('キャンセルされました', mtInformation, [mbOk] , 0);
  end;
end;

どうやらTDirectory.GetFiles関数はいろんな引数を指定できるらしい。
せっかくだから調べてみた。

System.IOUtils.TDirectory.GetFiles

https://docwiki.embarcadero.com/Libraries/Sydney/ja/System.IOUtils.TDirectory.GetFiles
以下、上記Webサイトより引用

class function GetFiles(const Path: string): TStringDynArray;
class function GetFiles(const Path: string;  const Predicate: TFilterPredicate): TStringDynArray;
class function GetFiles(const Path, SearchPattern: string): TStringDynArray;
class function GetFiles(const Path, SearchPattern: string;  const Predicate: TFilterPredicate): TStringDynArray;
class function GetFiles(const Path, SearchPattern: string;  const SearchOption: TSearchOption): TStringDynArray; overload; static;
class function GetFiles(const Path, SearchPattern: string;  const SearchOption: TSearchOption; const Predicate: TFilterPredicate): TStringDynArray; overload; static;
class function GetFiles(const Path: string;  const SearchOption: TSearchOption; const Predicate: TFilterPredicate): TStringDynArray; overload; static;

こんなにあったんだ。びっくり☆

もし、検索対象フォルダが決まっているのであれば、さらに簡単に・・・
(サブフォルダまで検索するか・どうかはOptionを切り替えて指定)

procedure TForm1.ButtonXClick(Sender: TObject);
var
  Path:string;
  SearchPattern:string;
  Option:TSearchOption;
  FileNames:TStringDynArray;
  FileName:String;
begin
  //初期化
  ListBox1.Items.Clear;
  //検索先のPathは指定
  Path:=ExtractFilePath(Application.ExeName) + 'Data';
  //ファイル名に一致する検索パターン
  SearchPattern:='*.jpg';
  //検索モード
  //Option:=TSearchOption.soTopDirectoryOnly; //指定フォルダ直下のみ
  Option:=TSearchOption.soAllDirectories; //サブフォルダ内も検索
  //ファイルのリストを作成
  FileNames:=TDirectory.GetFiles(Path, SearchPattern, Option);
  for FileName in FileNames do
  begin
    ListBox1.Items.Add(FileName);
  end;
end;

3.ListBoxに高速で項目を追加する方法

ファイルのリスト作成をより一層高速化するには、以下のようにリストをAddするfor文の前後にListBox1.Items.BeginUpdate / EndUpdateを入れると良いそうで・・・
(TListBoxのStyleプロパティがlbStandardの場合)

  ListBox1.Items.BeginUpdate;
  for FileName in FileNames do
  begin
    ListBox1.Items.Add(FileName);
  end;
  ListBox1.Items.EndUpdate;

あくまでもMyPC環境での値だが、1000枚のJpeg画像の名称を取得するのに、UpDate命令なしの場合が5回平均で120ms、UpDate命令ありの場合が同じく5回平均で17ms。

たった1000枚でこれだけの差。ファイルの数が増えれば増えるほど、その差が大きくなるのは自明の理。

これはTListBoxだけでなく、TMemo等のDelphiのコンポーネントに多数の項目を追加または変更する際に共通して起きる現象で、発生理由は、変更のたびに画面が再描画されるためとのこと。例えば、今回のようにListBoxに多数の項目を追加すると追加項目数分の再描画が発生し、項目数が多いほど処理時間が必要に。

そこでアイテムに変更を加える前に、 BeginUpdateを呼び出し、すべての変更が完了したら、EndUpdateを呼び出して変更を画面に表示するように指定すると画面の再描画が抑止され、処理時間は大幅に短縮される・・・ということで、大量の項目の追加・変更を行うときは忘れずにUpDate命令を使ったほうがよさそう。

ちなみにTMemoの場合であれば・・・

  Memo1.Lines.BeginUpdate;
  for i := 1 to 5000 do
  begin
    Memo1.Lines.Add('あいうえお');
  end;
  Memo1.Lines.EndUpdate;

つまり、BeginUpdateが画面更新の停止で、EndUpdateが再開ってことでいいのかな?
私はそんなふうに理解しました☆

4.まとめ

ある特定のフォルダ内にある、特定の拡張子を持つファイルのリストを作成したい場合は、TDirectory.GetFiles関数が便利。
取得したフルパス付きのファイル名はListBoxに入れておけば、ItemIndexやItems.Countを参照するだけで具体的な名前やPathを気にせずに使えてこれも便利。
ListBoxに大量に項目を追加する時は、BeginUpDateとEndUpDateを忘れずに使おう! というお話でした。

5.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

InputQuery Where Only Numeric Values Can Be Entered

「数値のみ入力可能なInputQuery」

ユーザーからの数値入力を受け取って動作するプログラムを作成した。このような場合には、以前からInputQueryを使用してきた(自前のDialogを作成したこともあった)が、今回、「数値だけ入力可能」なInputQueryを作成してみた。これは、その覚書。

1.入力をチェックして数値のみの入力を実現
2.MyInputQueryを作る
3.まとめ
4.お願いとお断り

1.入力をチェックして数値のみの入力を実現

Delphiでユーザーからの入力を受け取るプログラムを作る時、戻り値がString型のInputBox関数や、Boolean型のInputQuery関数を使う。僕はこれまでユーザーが「どのボタンを押したのか?」がはっきりわかるInputQuery関数を多用してきた(対し、InputBox関数ではデフォルトで設定しておいた文字列が返る)。

procedure TForm1.Button1Click(Sender: TObject);
var
  Ret:string;
begin
  if InputQuery('InputQuery', '値を入力:', Ret) then
  begin
    //OKボタンがクリックされた時

  end else begin
    //キャンセルボタンがクリックされた時(ESCキーで閉じた場合もFalseになる)

  end;
end;
InputQuery実行時の画面

今回、多くの画像の印刷を実行するプログラムの中で、何ページめの画像を印刷するのか、ユーザーに指定してもらう必要があり、そこで、やはりInputQuery関数を利用した。

以前から、このようなシーンで「数値のみ入力可能」なInputQuery関数が欲しいなー、とずっと思ってきたんだけど、とりあえず、プログラムの完成を急ぎたくて、そのたびに、この問題は先送りにされてきた。 僕の中で、もう長いこと ずっと・・・。

Excel VBAで帳票印刷を行う時は、「ちゃっちゃ」っと次のようなFormを作成して

コード書いてないのにEnterキーでFocusまで移動する・・・

「ちゃっちゃ」っと次のコードを書いて・・・

Private Sub CommandButton1_Click()

    Dim PrintNo1 As Integer
    Dim PrintNo2 As Integer
    Dim i As Integer
    
    If UserForm1.TextBox1.Text = "" Then
        MsgBox ("開始番号を半角数字で入力してください。")
        TextBox1.SetFocus
        Exit Sub
    End If
    
    If UserForm1.TextBox2.Text = "" Then
        MsgBox ("終了番号を半角数字で入力してください。")
        TextBox2.SetFocus
        Exit Sub
    End If
    
    PrintNo1 = UserForm1.TextBox1.Text
    PrintNo2 = UserForm1.TextBox2.Text
    i = PrintNo1

    For i = PrintNo1 To PrintNo2
        Range("A2").Select
        ActiveCell.FormulaR1C1 = i
        Range("B6:AB38").Select
    
        ActiveSheet.PageSetup.PrintArea = "$B$6:$AB$38"
        ActiveWindow.SelectedSheets.PrintOut Copies:=1, Collate:=True
    Next i
    
    Range("A2").Select

End Sub

Private Sub CommandButton2_Click()
    'キャンセルボタンがクリックされた場合
    Unload UserForm1
    Exit Sub
End Sub

で、FormのTextBoxをクリックして・・・

TextBox1を選択

IME ModeプロパティをDisableに設定。

IMEは絶対利用できないようにDisableを指定

あとは実行!
サク・・・っと印刷して終わり。みたいな感じで、VBAならカンタンなんだけど、これをDelphiでやるとなると、全然できるんだけど、でも、ちょっと・・・めんどくさい。

今回も、「数値のみ入力可能なInputQuery」は(現時点で)実現できないから、とりあえず、ユーザーが入力した値をチェック(数値であるか・どうか)して対応してしまった・・・。

それが次のコード。

implementation

uses
  System.UITypes;

{$R *.dfm}

procedure TForm1.Button1Click(Sender: TObject);
var
  Ret: string;
  intNum: integer;
  //全角 -> 半角に変換
  Chr: array [0..255] of char;
begin
  if InputQuery('印刷', 'ページを指定', Ret) then
  begin
    //OKボタンがクリックされた時
    //全角->半角変換
    //全角だった場合は半角数字に変換、すでに半角のものは半角のまま
    //半角にできない文字、たとえばひらがな等は変換されない
    Winapi.Windows.LCMapString(
      GetUserDefaultLCID(),
      LCMAP_HALFWIDTH,
      PChar(Ret),  //変換する文字列
      Length(Ret)+1,  //サイズ
      chr,  //変換結果
      Sizeof(chr)  //サイズ
      );
      Ret := Chr;
    //数値であるかチェック
    if TryStrToInt(Ret, intNum) then
    begin
      //数値である
      j := StrToInt(Ret);
      //本当に使える数値か、さらにチェック
      if (j = 0) or (j > 印刷ページの上限値) then
      begin
        MessageDlg('入力された値は印刷できない番号です。'+#13#10+
        '処理を中止します。', mtInformation, [mbOk] , 0);
        Exit;
      end else begin
        // j の値を使って印刷実行
        ・・・ 省略 ・・・
      end;
    end else begin
      MessageDlg('入力された値は数値ではありません!'+#13#10+
        '処理を中止します。', mtInformation, [mbOk] , 0);
      Exit;
    end;
  end;
end;

まぁ、確かにこれで目的は実現できてるから、イイっちゃイイんだけど・・・。
なんか、スマートじゃない・・・気がして。
あと出しジャンケンみたいで・・・

そこで、今回だけは逃げずに自分と戦うことにしました☆

2.MyInputQueryを作る

とりあえずの目標はVBAでやったように、InputQueryのテキストボックスのIMEモードをDisableに設定すること。

Google先生に訊いたら、次の情報を教えてくれた。

Vcl.StdCtrls.TCustomEdit.NumbersOnly

https://docwiki.embarcadero.com/Libraries/Sydney/ja/Vcl.StdCtrls.TCustomEdit.NumbersOnly

Delphi2009からNumbersOnlyプロパティがTEditに実装されたとのこと。で、さらに、そのTEditを内部に抱えているInputQueryの正体については、次のサイト他で情報をGet!

InputQueryについて

https://www.petitmonte.com/bbs/answers?question_id=4867

どうやらいちばんの問題解決方法は、InputQueryのソースコードをそのままコピペして(挙動不審にならないように)名前を変更し、それぞれの目的が実現できるようにコードを修正することのようだ。

上記Webサイト他に掲載されていたInputQueryのコードを読むと、自分でもなんとかなりそうだったので、さっそくやってみることにした。

で、書いたのが次のコード(ほとんどコピペですが・・・)。

//Formのメンバーにはしていません。
//名前は MyInputQuery に変更
function MyInputQuery(const ACaption, APrompt: string;
  var Value: string): Boolean;
var
  Form: TForm;
  Prompt: TLabel;
  Edit: TEdit;
  DialogUnits: TPoint;
  ButtonTop, ButtonWidth, ButtonHeight: Integer;

  function GetAveCharSize(Canvas: TCanvas): TPoint;
  var
    I: Integer;
    Buffer: array[0..51] of Char;
  begin
    for I := 0 to 25 do Buffer[I] := Chr(I + Ord('A'));
    for I := 0 to 25 do Buffer[I + 26] := Chr(I + Ord('a'));
    GetTextExtentPoint(Canvas.Handle, Buffer, 52, TSize(Result));
    Result.X := Result.X div 52;
  end;

begin
  Result := False;
  Form := TForm.Create(Application);
  with Form do begin
    try
      Canvas.Font := Font;
      DialogUnits := GetAveCharSize(Canvas);
      BorderStyle := bsDialog;
      Caption := ACaption;
      ClientWidth := MulDiv(180, DialogUnits.X, 4);
      Position := poScreenCenter;

      Prompt := TLabel.Create(Form);
      with Prompt do
      begin
        Parent := Form;
        Caption := APrompt;
        Left := MulDiv(8, DialogUnits.X, 4);
        Top := MulDiv(8, DialogUnits.Y, 8);
        Constraints.MaxWidth := MulDiv(164, DialogUnits.X, 4);
        WordWrap := True;
      end;
      Edit := TEdit.Create(Form);
      with Edit do
      begin
        Parent := Form;
        Left := Prompt.Left;
        Top := Prompt.Top + Prompt.Height + 5;
        Width := MulDiv(164, DialogUnits.X, 4);
        MaxLength := 255;
        Text := Value;
        SelectAll;

        //Password入力用にInputQueryを使用するための設定(Password Mask)
        //EditコントロールではPasswordCharに設定した文字が
        //入力した文字の代わりに表示される(デフォルトは'#0')
        //パスワードマスクするなら
        //PasswordChar:= '*';
        //これでマスクしなくなる('#0'として文字列化しないこと)
        PasswordChar := #0;

        //Delphi2009からTEditにNumbersOnlyプロパティ(数字だけを入力可能にする)が
        //実装されているそうなので、せっかくだからTrueにしてみた!
        //全角文字の「123」も「数値である」と判断してくれます・・・
        NumbersOnly := True;

        //IMEは使用不可(この1行がどうしても書きたかった!)
        ImeMode := imDisable;

        //文字位置
        //Alignment := taCenter;
        Alignment := taLeftJustify;
        //Alignment := taRightJustify;

      end;
      ButtonTop := Edit.Top + Edit.Height + 15;
      ButtonWidth := MulDiv(50, DialogUnits.X, 4);
      ButtonHeight := MulDiv(14, DialogUnits.Y, 8);
      with TButton.Create(Form) do
      begin
        Parent := Form;
        Caption := 'OK';
        ModalResult := mrOk;
        Default := True;
        SetBounds(MulDiv(38, DialogUnits.X, 4), ButtonTop, ButtonWidth,
          ButtonHeight);
      end;
      with TButton.Create(Form) do
      begin
        Parent := Form;
        Caption := 'キャンセル';
        ModalResult := mrCancel;
        Cancel := True;
        SetBounds(MulDiv(92, DialogUnits.X, 4), Edit.Top + Edit.Height + 15,
          ButtonWidth, ButtonHeight);
        Form.ClientHeight := Top + Height + 13;
      end;
      if ShowModal = mrOk then
      begin
        Value := Edit.Text;
        Result := True;
      end;
    finally
      Form.Free;
    end;
  end;
end;

このInputQueryをボタンクリックで呼び出します。

procedure TForm1.Button1Click(Sender: TObject);
var
  Ret:string;
begin
  if MyInputQuery('Dialog Caption', 'Please Enter the number:', Ret) then
  begin
    ShowMessage('Entered: '+ Ret);
  end else begin
    ShowMessage('False!');
  end;
end;

上のコードを実行すると・・・

InputQueryのTextBoxではIMEモードは変更できない
Form上のEditコントロールではIMEモードの切り替えが可能

これで、ずっと夢だった「数値のみ入力可能な」InputQueryが完成しました☆
さらに工夫すれば、入力できる数値の範囲を制限したりすることもできると思います。

また、次のWebサイトではマウスポインタの近くにInputQueryを表示させるという技も紹介されていました。

.InputQueryのポップアップ位置

https://www.petitmonte.com/bbs/answers?question_id=6520

3.まとめ

数値のみ入力可能なInputQueryを実現するには、InputQueryのソースコードをコピペして、名前を変更したInputQuery関数(例:MyInputQuery)を作成し、その中でやりたいことを書いていけばイイということ。

ここでは、「IMEModeの設定」を主に、それに加えて「パスワードマスク、NumbersOnlyプロパティ、文字位置(Alignment)」等の設定を行ってみた。

4.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

Mouse Down Event Usage Example

「MouseDownイベントの活用例」

手書き答案をスキャナーで読み込んで採点するプログラムを書いた。Gridコントロールへの入力に対し、各々の解答欄の画像に ○ や × や得点を表示できるようにしたら、合計点がなければ採点済み答案とは言えないコトに気付いた。でも、それを「どこ」に記入するかは答案ごとに違うし、採点ミスがあった場合は個別の修正に対応する必要もある。そこでMouseDownイベントが役に立ったというお話。

1.合計点はいつ・どこに書く?
2.画像に ○ や × それから得点も表示
3.「やっぱりココ!」に対応
4.まとめ
5.お願いとお断り

1.合計点はいつ・どこに書く?

多くの場合、それは答案の右上か、右下に書かれている。左上や左下、まして「ど真ん中」ってのはまず見たコトない・・・ケド。とりあえず、合計点を書く場所は、まったく採点者の自由で、法的に「ココじゃなきゃダメ」って、決められているなんて話は聞かない。だから、合計点を書く(プログラム的には「置く」の方がしっくりするが)場所は、採点者が「ココ!」ってクリックした位置にすることにした。100%自由ってステキ。

それから採点者も人間である以上、当然のように間違える。採点ミスがあれば、もちろん合計点も変わる。・・・ってコトは、PCと協働作業する以上、合計点の計算はPCに任せるからイイとして、それを答案画像に「二度と修正できない」カタチで「埋め込んで」しまうわけにはいかない。合計点は、返却用の答案画像を印刷するときに、「どっかから持ってきて」、答案画像の上に一時的に「置く」くらいがちょうどイイ。

・・・ということで、基本方針だけを決め、新しいチャレンジがはじまった!

2.画像に や × それから得点も表示

最初は正誤の表示(○と×)だけだったけれど、得点も表示することにした

上のような画面に、スキャンした答案画像から設問ごとにかき集めた解答欄を表示してイッキに採点する。上のように全員が同じ解答なら得点の一括入力も可能だ。採点が済んだら、読み込み元の答案画像とは別に用意した返却用の答案画像に、集めてきた時とは逆のアルゴリズムで書き戻す。

答案に書き戻してみたところ(もっとズレるかと思ったが案外ずれてない)

で、決定的に足りないモノがあることに気づく・・・。ここまでやって「合計点がない」というのは、仏作って魂入れず & 画竜点睛を欠く & ツメが甘い & 九仞の功を一簣に虧く 以外の何者でもない。日本語の豊かさに感動しつつ、もっと簡単な言い方をすれば、プログラムは、どう考えても不完全。元よりこれを売るつもりはまったくナイけど(買うヒトがいるとも思えない)、合計点が「出ない」採点プログラムなんて詐欺だ。

なにより、この答案は、なんだかさみしい・・・

恐るべし。合計点の存在感。

・・・ということで、合計点も入れることに。

3.「やっぱりココ!」に対応

StringGridに見えない列を1つ追加して、ここに計算した合計点を書き込んでおけばいつでも印刷に使える。合計点の計算そのものはカンタン。何も問題はない。

var
  i, j, k:integer;
begin
  //初期化
  k:=0;
  //合計点を計算
  for i := 1 to StringGrid1.RowCount-1 do
  begin
    for j := 1 to ( 解答欄の数 ) do
    begin
      if StringGrid1.Cells[j,i] <> '' then
      begin
        k := K + StrToInt(StringGrid1.Cells[j, i]);
      end;
    end;
    //StringGrid.Cells[, ]
    StringGrid1.Cells[( 解答欄の数 ) + 1, i]:= IntToStr(k);
    //初期化
    k:=0;
  end;

これで合計点の準備はOK!
あとは「いつ」印刷するか・・・ってか、採点ミスがあった時、カンタンに合計点も修正できるようにしなければならない。これは結構、難しい。合計点を答案画像に画像データとして埋め込んでしまうと、まず修正はできない。どぉーするか・・・

よく考えたら(よく考えなくても)、この解答用紙には合計点を記入する場所すらない。まぁもともとがマークシートで、そこに無理やり手書き用の解答欄を付け加えたのがほんとうだから、ないのが当然なんだが。

こうなったらもう、面倒なことは全部やめて、答案画像を印刷する直前に、採点者が適当に「ココ!」ってクリックした場所に合計点を置いて、印刷はするけど、その後、画像は保存しない仕様で行こう。

印刷日が異なると、ビミョー(ヒトによっては大きく)に合計点印刷位置がズレる・・・という問題?は、「気にしない」ことにしよう。法的には何の問題もない。返却された答案に合計点が「ある」ことが大切なのだ。

フォントの大きさは、得点入力のところで使った指定をそのまま使えばイイ。

FontのSizeは50

これでアルゴリズムは決まり。あとは採点者に「ココ!」って指定してもらうプログラムをDelphiで書くだけ。でも、その「ココ!」はきっと、やっぱりもぉちょっと上とか、左とか、位置指定をやり直したい場合が絶対あるよなー。どぉするか・・・

TImageの上でマウスのボタンを押すたびにMouseDownイベントが起こるから、コレをうまく活用すればイイ。きっとそれだなー。で、書いたのがコレです。

合計点を印刷したい場所をクリックするとサンプル99を表示
procedure TFormCollaboration.Image1MouseDown(Sender: TObject;
  Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
var
  //表示倍率
  rate:Double;
  ・・・ 省略 ・・・

  //普通の四捨五入を行う関数を設定
  function Roundoff(X: Extended): Longint;
  begin
    if x >= 0 then Result := Trunc(x + 0.5)
              else Result := Trunc(x - 0.5);
  end;

  //合計点印刷位置の座標を取得する手続き
  procedure GetXY(iX,iY:Integer);
  begin
    //合計点印刷位置の座標を取得
    iX:= Roundoff(iX/(TrackBar1.Position/100));
    iY:= Roundoff(iY/(TrackBar1.Position/100));
    //表示倍率を計算(答案画像は縮小してWidth1000にセットしている)
    rate:= 1000/Image1.Picture.Bitmap.Width;
    合計点のX座標:= Trunc(iX/rate);
    合計点のY座標:= Trunc(iY/rate);
    //矩形を描画
    with Image1 do
    begin
      //Canvas.Brush.Style:= bsClear;  //Pythonを使っていない時はこれでOK!
      //Pythonを使っている時は明示的に書く(Python.pasにもbsClearが定義されている)
      Canvas.Brush.Style:= Vcl.Graphics.bsClear;  
      Canvas.Pen.Color:=clRed;
      Canvas.Pen.Width:=3;
      //矩形を描画
      (サンプル99はLabelのCaption).Font.Size:=StrToInt(ComboBox.Text);
      Canvas.Rectangle(合計点のX座標, 
                       合計点のY座標, 
                       合計点のX座標 + Label.Width, 
                       合計点のY座標 + Label.Height);
      //サンプル合計点を描画
      Canvas.Font.Color:=clRed;
      Canvas.Font.Size:=StrToInt(ComboBox.Text);
      Canvas.TextOut(合計点のX座標, 合計点のY座標, Label.Caption);
    end;
  end;

begin
  //座標を指定する手続きを呼び出し
  GetXY(X,Y);
  //Information
  if MessageDlg('印刷位置は、この位置でよろしいですか?' + #13#10 + #13#10 +
    '(左寄せ・数字はサンプル。矩形は印刷されません。)', 
    mtInformation, [mbYes, mbNo], 0) = mrYes then
  begin
    //[はい]が選ばれた時
    //案内
    MessageDlg('印刷ボタンをクリックしてください。', mtInformation,[mbOK],0);
    //バルーンヒントを表示
    BalloonHint1.Title := '印刷ボタン';
    BalloonHint1.Description := 'ココです!';
    BalloonHint1.HideAfter := 3000; //表示時間(単位:ms)
    BalloonHint1.ShowHint(button.ClientToScreen(CenterPoint(button.ClientRect)));
    //案内アイコンも追加
    BalloonHint1.ImageIndex := 0;
    //カーソルを元に戻す
    Screen.Cursor:=crDefault;
    Image1.Visible:=False;
    Image1.Picture.Assign(nil);
    //SetFocus
    button.Enabled:=True;
    button.SetFocus;
  end else begin
    //[いいえ]が選ばれた時
    with Image1 do
    begin
      //Canvas.Brush.Style:=bsClear;  //Pythonを使っていない時はこれでOK!
      //Pythonを使っている時(Python.pasにもbsClearが定義されている)
      Canvas.Brush.Style:=Vcl.Graphics.bsClear;  
      Canvas.Pen.Color:=clWhite;
      Canvas.Pen.Width:=3;
      //矩形を描画
      (サンプル99はLabelのCaption).Font.Size:=StrToInt(ComboBox.Text);
      Canvas.Rectangle(合計点のX座標, 
                       合計点のY座標, 
                       合計点のX座標 + Label.Width, 
                       合計点のY座標 + Label.Height);
      //サンプル合計点を描画
      Canvas.Font.Color:=clWhite;
      Canvas.Font.Size:=StrToInt(ComboBox.Text);
      Canvas.TextOut(合計点のX座標, 合計点のY座標, Label.Caption);
    end;
  end;
end;

ユーザーが「ココ!じゃない」=「いいえ」を選択した場合は、サンプルとして表示した合計点を消去しなければならない。Undoの実装方法をいろいろ調べてみたのだが、よくわからない。で、思いついたのが上の方法。「いいえ」が選択された場合は、サンプルを「赤」じゃなくて「白」で書いちゃう。正直、完全に消えるわけじゃなくて、なぜか、よく見るとうっすらと赤が残っているけれど、気にしない。これで全然イケます。

合計点も印刷できるようになりました!

【追記 20221003】

合計点はサンプル「99」ではなく、個々の合計点を取得して表示できるよう、プログラムを修正しました。下のリンク先をご参照ください。

4.まとめ

そこにTImageがあれば、彼はいつでも OnMouseDown を待ち続けているから、このイベントをうまく利用すれば、再帰的な処理(?)が実現できてしまう。画像として保存したくは「ない」んだけれど、印刷時には「ちょっとイジりたい」時にはこんな方法もあります・・・というお話でした。

5.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。ここに記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

To Be Broken

その一瞬で、君に夢中になったんだ。
今でも、忘れない・・・。
初めて君を見た、あの日。

あれから、もう20年が過ぎた。

カタログを手にしただけでうれしかった。
毎晩、何度も、眺めた。

思い出のカタログは、今も、手元にある。

開発責任者を務めた湯川伸次郎さんが、『2002年、「奇跡の名車」フェアレディZはこうして復活した』(講談社+α新書)を出版してくださった。

こんなすごい、ハードカバーのカタログが、タダで、何冊ももらえた理由を
ずっと知りたかった僕は、湯川さんの本を、即日、入手して読んだ。

命を削るような苦悩の中で、湯川さんがご自身と戦ったことを初めて知った。
僕の中で、謎だったことは、全部、「感謝」に変わった。

Yearモデルを出すってことすら、とてつもなく大変なことだったんだ・・・。
僕はずっと、ドキドキしていただけ・・・だった・・・のに。

振り返れば、僕のクルマ人生は NISSAN とともにあった。
初めて乗ったクルマは Skyline Japan(譲ったトモダチが今も乗ってる)。
その後の430セドリックも、思い出は深いけれど・・・、

人生の1/3をともに過ごした z33 は、「最高のパートナー」。
彼女は酔って吐いたりしてたけど、それは僕の運転のせい。

君のカタチは「スポーツカー」の Rule そのもの。でも、その輝きは・・・ 違ったんだ。

Rule is existing.
それに頼りそうになる時もあるけど・・・

君が教えてくれた輝きは・・・
To Be Broken.

君こそが・・・
Best of The Best.

z33
いつも、君と。

今も・・・
そして、 いつまでも。

風の中 君を探して

きれいな空
夏を忘れたみたいに
空気が乾いてる・・・

今日は風になりたいって、思った。
16の頃から、ずっと
憧れていた 風に。

だから握った Key は、z33 じゃなくて
古いバイクの Key.

若かった あの日。
KAWASAKI が創ってくれた ・・・
夢をカタチにしたようなオートバイ。

30年が、一瞬のように過ぎてしまって
今があるけれど・・・
夢のカタチは、何にも変わらない。

もう、ラインアップされることのない
空冷4気筒DOHCエンジンの咆哮が
僕は、たまらなく好き・・・。

きらめく 風の中
君を 探して ・・・

How to use the StatusBar

「StatusBarの使い方」

これまでStatusBarを使う時は、FormCreate 時にその SimplePanel プロパティを True に設定して、あとは必要なところで SimpleText に表示したい文字列を指定したり、ProgressBar の Parent を StatusBar にして、時間のかかる処理の進捗状況を示したりしてきた。が、今回、いろいろあって TStatusPanels を使って、StatusBar に表示する文字の色を変えたり、背景色を変更する方法を学んだ。これは、その覚書。

1.いちばんカンタンな使い方(SimplePanel:=True;)
2.TStatusPanelsを使う(SimplePanel:=False;)
3.こんなこともできた! (その1) (その2)
4.まとめ
5.お願いとお断り

1.いちばんカンタンな使い方

StatusBar のいちばんカンタンな使い方は、SimplePanel プロパティを True (デフォルトは False )にして、StatusBar の SimpleText プロパティに表示したい文字列を指定する方法だ。

以下、いちばんカンタンな使い方の例。

新しいVCLアプリケーションを作成し、適当に名前を付けてプロジェクトを保存して、パレットにStatusと入力、表示されたTStatusBarをダブルクリックする。

FormにTStatusBarを追加したところ

Formのいちばん下に(自動的に位置は指定される)StatusBar1が追加される。

次に、Form をクリックして選択し、オブジェクト インスペクタのイベントタブをクリックして、OnCreate イベントの右の空白部分をダブルクリック。FormCreate 手続きを作成する。で、忘れないうちに次の1行を入力する。

procedure TForm1.FormCreate(Sender: TObject);
begin
  //設定
  StatusBar1.SimplePanel:= True;
end;

StatusBar1.SimplePanel:= True; とすることで、StatusBar1.SimpleText に指定した文字列がそのまま自動的にStatusBarに表示されるようになる(SimplePanel のデフォルト設定は False なので、これを True に変更しておかないと Statusbar への描画は StatusBar1DrawPanel イベントに全て自前で記述しなければ「行われない!」)。

あとは使いたいシーンで、SimpleTextに代入した文字列を表示するようコードを書けばOK! 例えば、「進捗状況:」と表示するなら、FormにButtonを1個追加して、Button1Click手続きを作成し、以下のように記述。

procedure TForm1.Button1Click(Sender: TObject);
begin
  //表示
  StatusBar1.SimpleText:= '進捗状況:';
end;

実行(F9)すれば ・・・

StatusBarに文字列を表示

進捗状況を表示するコントロールと言えば ProgressBar。これをFormに追加して、FormCreate 及び Button1Click の各手続きに以下のコードを記述(赤文字が追加するコード)。

procedure TForm1.FormCreate(Sender: TObject);
var
  w: integer;
begin

  //設定
  StatusBar1.SimplePanel:=True;

  //文字列の長さを取得
  w:=StatusBar1.Canvas.TextWidth('進捗状況:');

  //ProgressBarの設定
  with ProgressBar1 do begin
    Parent  := StatusBar1;
    Top     := 2;  //表示位置の調整
    Left    := w;
    Height  := StatusBar1.Height-2;
    Width := StatusBar1.Width-20;
    Visible :=False;
  end;

end;
procedure TForm1.Button1Click(Sender: TObject);
begin
  //表示
  StatusBar1.SimpleText:= '進捗状況:';
  ProgressBar1.Visible:=True;

end;

保存(Ctrl+S)して、実行(F9)

ボタンをクリックすると、StatusBarに文字列とProgressBarが表示される

せっかくここまで書いたのですから、ProgressBarに進捗状況を表示しましょう。以下のコードをButton1Click手続きに追加します(赤文字が追加するコード)。

procedure TForm1.Button1Click(Sender: TObject);
var
  i: integer;
begin
  //表示
  StatusBar1.SimpleText:= '進捗状況:';

  //ProgressBarの設定
  ProgressBar1.Visible:=True;
  ProgressBar1.Position:=0;
  ProgressBar1.Max:=100;

  for i:= 0 to 100 do
  begin
    ProgressBar1.Position:= i; // -> MAXまで表示されない
    Sleep(25);
    Application.ProcessMessages;
  end;
  ProgressBar1.Visible:= False;
  ShowMessage('Done!');

end;

保存(Ctrl+S)して、実行(F9)してみるとわかるのですが、このコードには問題があります。上にすでにコメントとして書いてある通り、ProgressBarのPositionが100まで表示されないのです。機能的には、「処理が進んでいることがユーザーに伝わればいい」ので、それほど大きな問題ではありませんが、表示できるものならやはり100まで表示できたほうが気持ちイイ。

実は少し工夫すれば100まできちんと表示できます。以前、どこかWeb上の情報で知ったのですが、Loopの中で増加する i の値をそのままProgressBarのPositionに代入せず、現在の i 値より「1大きな値をまず代入」し、その後、「1小さい値をセットしなおす」という技です。どなたが考えた技か知りませんが、これで100まできちんと表示できます。以下に、そのコードを示します(赤文字が追加するコード)。

procedure TForm1.Button1Click(Sender: TObject);
var
  i, j:integer;
begin
  //表示
  StatusBar1.SimpleText:= '進捗状況:';

  //ProgressBarの設定
  ProgressBar1.Visible:=True;
  ProgressBar1.Position:=0;
  ProgressBar1.Max:=100;
  //初期化
  j:= 0;
  for i:= 0 to 100 do
  begin
    //ProgressBar1.Position:= i; // -> MAXまで表示されない

    // 100まで表示するコード
    inc(j);
    If ProgressBar1.Position < ProgressBar1.Max Then
    begin
      //目的の値より一つ大きくしてから、目的の値にする
      ProgressBar1.Position:= j + 1;
      ProgressBar1.Position:= j;
    end else begin
      //最大値にする時
      //最大値を1つ増やしてから、元に戻す
      ProgressBar1.Max:= 100 + 1;
      ProgressBar1.Position:= j + 1;
      ProgressBar1.Max:= 100;
      ProgressBar1.Position:= j;
    end;
    Sleep(25);
    Application.ProcessMessages;
  end;
  ProgressBar1.Visible:= False;
  ShowMessage('Done!');

end;

さらにLabelを使って、次のような表示の追加も可能です。FormにLabelを1つ追加して、次のコードを FormCreate と Button1Click の各手続きに追加します(赤文字が追加するコード)。

Labelを1つFormに追加
procedure TForm1.FormCreate(Sender: TObject);
var
  w: integer;
begin

  //設定
  StatusBar1.SimplePanel:= True;

  //文字列の長さを取得
  w:=StatusBar1.Canvas.TextWidth('進捗状況:');

  //ProgressBarの設定
  with ProgressBar1 do begin
    Parent  := StatusBar1;
    Top     := 2;  //表示位置の調整
    Left    := w;
    Height  := StatusBar1.Height - 2;
    Width := StatusBar1.Width - 20;
    Visible := False;
  end;

  //LabelをProgressBarの中心に表示
  Label1.Parent:= ProgressBar1;  // ParentがStatusBarでないことに注意する
  Label1.AutoSize:= False;
  Label1.Transparent:= True;
  Label1.Caption:= '';
  Label1.Visible:= False;

end;
procedure TForm1.Button1Click(Sender: TObject);
var
  i, j:integer;
begin

  //進捗状況を表示するLabelの設定
  Label1.Visible:=True;
  Label1.Top:= 0;
  Label1.Left:= 0;
  Label1.Width:= ProgressBar1.ClientWidth;
  Label1.Height:= ProgressBar1.ClientHeight;
  Label1.Alignment:= taCenter;
  Label1.Layout:= tlCenter;

  //表示
  StatusBar1.SimpleText:= '進捗状況:';

  //ProgressBarの設定
  ProgressBar1.Visible:= True;
  ProgressBar1.Position:= 0;
  ProgressBar1.Max:= 100;
  //初期化
  j:= 0;
  for i:= 0 to 100 do
  begin
    //ProgressBar1.Position:= i; // -> MAXまで表示されない

    //値を増やす
    inc(j);
    If ProgressBar1.Position < ProgressBar1.Max Then
    begin
      //目的の値より一つ大きくしてから、目的の値にする
      ProgressBar1.Position:= j + 1;
      ProgressBar1.Position:= j;
    end else begin
      //最大値にする時
      //最大値を1つ増やしてから、元に戻す
      ProgressBar1.Max:= 100 + 1;
      ProgressBar1.Position:= j + 1;
      ProgressBar1.Max:= 100;
      ProgressBar1.Position:= j;
    end;

    //数値でも進捗状況を表示
    Label1.Caption:= '( '+IntToStr(j-1)+' %)';

    Sleep(25);
    Application.ProcessMessages;
  end;
  ProgressBar1.Visible:= False;
  ShowMessage('Done!');

end;

保存(Ctrl+S)して、実行(F9)。

数値でも進捗状況を表示

Button1Click手続きの最後にある「ProgressBar1.Visible:= False;」をコメント化して表示の状況を確認することもできます。「Label1.Visible:=False;」としなくてもLabelが非表示になるのは、Label1のParentを「Label1.Parent:=ProgressBar1;」としているためだと思われます。

【ヒントをStatusBarに表示する】

引用先には、設定したヒントをことごとくStatusBarに表示するという技が紹介されていました。StatusBar1.SimplePanel プロパティを True に設定して使うのであれば、内容もシンプルで、実際に使えるテクニックだと思います。

ヒントをステータスバーに表示するようなアプリケーションのためのコード

http://hp.vector.co.jp/authors/VA015850/delphi/fragments/AppHint.html

以上、いちばんカンタンなStatusBarの使い方でした!

2.TStatusPanelsを使う方法

2つめが、StatusBarがもともと持っているPanelを1つ、または複数個設置して、各々のPanelに対して描画の指示を出すという方法です。

新しいプロジェクトを作成し、FormにStatusBarを1つ、追加します。で、これを選択したまま、オブジェクト インスペクタのPanelsプロパティの(TStatusPanels)の右の…をクリックすると、次のような別Windowが表示されます。

(TstatusPanels)の右にある…をクリック

左上の「新規追加」アイコンを2回クリックして、StatusPanelを2つ追加します。

StatusPanelを2つ追加したところ

ここではStatusBarに表示する文字の文字色を変えたり、背景色を変更してみたいと思います。「0-TStatusPanel」をクリックして選択し、オブジェクト インスペクタのStyleプロパティをデフォルトの「psText」から「psOwnerDraw」へ変更します。

0-TStatusPanelを選択
StyleプロパティをpsOwnerDrawに変更

同じ操作を「1-TStatusPanel」にも行います。
これでStatusBarへの描画は「すべて自力で行う」ことになります。
設定したら右上の閉じるボタンをクリックして、設定画面を閉じます。

TStatusPanelをコードで設定する場合は、FormCreate手続きに以下の内容を入力します(赤文字が追加するコード)。

procedure TForm1.FormCreate(Sender: TObject);
var
  w:integer;
begin

  //コードで設定
  //StyleをpsOwnerDrawに変更
  StatusBar1.Panels[0].Style:=psOwnerDraw;
  StatusBar1.Panels[0].Text:='Information:';

  //Panel[0]の幅の調整(表示する予定の文字列で初期化)
  w:= StatusBar1.Canvas.TextWidth('Information:');
  StatusBar1.Panels[0].Width:= w + 15;
  StatusBar1.Refresh;

  //StyleをpsOwnerDrawに変更
  StatusBar1.Panels[1].Style:= psOwnerDraw;
  StatusBar1.Panels[1].Text:= '最初の表示';

end;

保存(Ctrl+S)して、実行(F9)。

よく見ると、StatusBarに StatusBar1.Panels[0] と StatusBar1.Panels[1] があることがわかります。コードで設定したはずの文字列は、Styleプロパティを psOwnerDraw へ変更したので当然表示されていません。

描画コードを設定して、文字列が表示されるようにします。
StatusBarをクリックして選択し、オブジェクト インスペクタのOnDrawPanelプロパティ右側の空欄をダブルクリックして StatusBar1DrawPanel 手続きを作成し、次のコードを入力します。

StatusBar1DrawPanel 手続きを作成
procedure TForm1.StatusBar1DrawPanel(StatusBar: TStatusBar; Panel: TStatusPanel;
  const Rect: TRect);
var
  ARect: TRect;
begin

  if Panel = StatusBar.Panels[0] then
  begin

    //文字色(このPanel内のみ有効となるようだ)
    StatusBar1.Canvas.Font.Color:= clBlack;

    //背景色(このPanel内のみ有効となるようだ)
    StatusBar1.Canvas.Brush.Color:= clBtnFace;

    //矩形を取得
    ARect:= Rect;

    //表示位置(中央寄せ)
    DrawText(StatusBar1.Canvas.Handle, PChar(Panel.Text), -1, ARect,
      DT_CENTER or DT_VCENTER or DT_SINGLELINE);

  end;

  if Panel=StatusBar.Panels[1] then
  begin

    //文字の表示(このPanel内のみ有効となるようだ)
    StatusBar1.Canvas.Font.Color:= clBlack;

    //背景色(このPanel内のみ有効となるようだ)
    StatusBar1.Canvas.Brush.Color:= clBtnFace;

    //矩形を取得
    ARect:= Rect;

    //表示位置(左寄せ)
    DrawText(StatusBar1.Canvas.Handle, PChar(Panel.Text), -1, ARect,
      DT_LEFT or DT_VCENTER or DT_SINGLELINE);

  end;

end;

保存(Ctrl+S)して、実行(F9)。

StatusBar1DrawPanel手続きに描画コードを書いたので文字列が表示される

文字列の色と背景色を変更してみましょう。Panels[0]の方だけ、コードを少し変更します。

  if Panel = StatusBar.Panels[0] then
  begin

    //文字色(このPanel内のみ有効となるようだ)
    //StatusBar1.Canvas.Font.Color:= clBlack;
    StatusBar1.Canvas.Font.Color:= clBlue;

    //背景色(このPanel内のみ有効となるようだ)
    //StatusBar1.Canvas.Brush.Color:= clBtnFace;
    StatusBar1.Canvas.Brush.Color:= clAqua;

    //矩形を取得
    ARect:= Rect;

    //表示位置(中央寄せ)
    DrawText(StatusBar1.Canvas.Handle, PChar(Panel.Text), -1, ARect,
      DT_CENTER or DT_VCENTER or DT_SINGLELINE);

  end;

保存(Ctrl+S)して、実行(F9)。

ボタンをクリックするとPanel[1]の文字列が変わるようにしてみます。
FormにButtonを1つ、追加して次のコードを書きこます。

procedure TForm1.Button1Click(Sender: TObject);
begin
  StatusBar1.Panels[1].Text:= '2番目の表示';
end;

保存(Ctrl+S)して、実行(F9)。

描画更新(StatusBar1.Refresh等)の命令は必要ないようだ

最初に紹介したように、進捗状況の表示に使うのであれば、Panel[1]の方にProgressBarを設定すればOKです。やり方は、次の通り(FormにProgressBarとLabelとButtonを1つずつ、追加してからコードを書きます)。

まず、ボタンをクリックした時の処理。こちらは全部新規に書きます。

procedure TForm1.Button2Click(Sender: TObject);
var
  i, j: integer;
  w: integer;
begin

  //進捗状況を表示するLabelの設定
  Label1.Visible:= True;
  Label1.Top:= 0;
  Label1.Left:= 0;
  Label1.Width:= ProgressBar1.ClientWidth;
  Label1.Height:= ProgressBar1.ClientHeight;
  Label1.Alignment:= taCenter;
  Label1.Layout:= tlCenter;
  Label1.Font.Color:= clBlue;

  //表示する文字列を設定
  StatusBar1.Panels[0].Text:= '進捗状況:';
  //Panel[0]の幅の調整(表示する予定の文字列で初期化)
  w:=StatusBar1.Canvas.TextWidth('進捗状況:');
  StatusBar1.Panels[0].Width:= w + 10;
  StatusBar1.Refresh;

  //Panels[1]の文字列は初期化
  StatusBar1.Panels[1].Text:= '';

  //ProgressBarの設定
  ProgressBar1.Visible:= True;
  ProgressBar1.Position:= 0;
  ProgressBar1.Max:= 100;
  //初期化
  j:= 0;
  for i:= 0 to 100 do
  begin
    //ProgressBar1.Position:=i; // -> MAXまで表示されない
    //値を増やす時
    j:= j + 1;
    If ProgressBar1.Position < ProgressBar1.Max Then
    begin
      //目的の値より一つ大きくしてから、目的の値にする
      ProgressBar1.Position:= j + 1;
      ProgressBar1.Position:= j;
    end else begin
      //最大値にする時
      //最大値を1つ増やしてから、元に戻す
      ProgressBar1.Max:= 100 + 1;
      ProgressBar1.Position:= j + 1;
      ProgressBar1.Max:= 100;
      ProgressBar1.Position:= j;
    end;
    //処理件数を表示
    Label1.Caption:='( '+IntToStr(j-1)+' %)';
    Sleep(25);
    Application.ProcessMessages;
  end;
  ProgressBar1.Visible:= False;
  StatusBar1.Panels[1].Text:= '終了!';
  ShowMessage('Done!');

end;

FormCreate手続きに処理を追加(赤文字の部分)。

procedure TForm1.FormCreate(Sender: TObject);
var
  w:integer;
begin

  //コードで設定
  //StyleをpsOwnerDrawに変更
  StatusBar1.Panels[0].Style:= psOwnerDraw;
  StatusBar1.Panels[0].Text:= 'Information:';

  //Panel[0]の幅の調整(表示する予定の文字列で初期化)
  w:= StatusBar1.Canvas.TextWidth('Information:');
  StatusBar1.Panels[0].Width:= w + 15;
  StatusBar1.Refresh;

  //StyleをpsOwnerDrawに変更
  StatusBar1.Panels[1].Style:= psOwnerDraw;
  StatusBar1.Panels[1].Text:= '最初の表示';

  //進行状況バーをステータスバーに配置する
  ProgressBar1.Parent:=StatusBar1;
  ProgressBar1.Visible:=False;

  //LabelをProgressBarの中心に表示
  Label1.Parent:=ProgressBar1;
  Label1.AutoSize:=False;
  Label1.Transparent:=True;
  Label1.Caption:='';
  Label1.Visible:=False;

end;

StatusBar1DrawPanel手続きの if Panel=StatusBar.Panels[1] then 部分にもProgressBarに関する処理を追加します(赤文字の部分)。

  if Panel=StatusBar.Panels[1] then
  begin

    //文字の表示(このPanel内のみ有効となるようだ)
    StatusBar1.Canvas.Font.Color:= clBlack;

    //背景色(このPanel内のみ有効となるようだ)
    StatusBar1.Canvas.Brush.Color:= clBtnFace;

    //矩形を取得
    ARect:= Rect;

    //表示位置(左寄せ)
    DrawText(StatusBar1.Canvas.Handle, PChar(Panel.Text), -1, ARect,
      DT_LEFT or DT_VCENTER or DT_SINGLELINE);

    //ProgressBarの位置を設定
    With ProgressBar1 do begin
      Top:=Rect.Top;
      Left:=Rect.Left;
      Width:=Rect.Right - Rect.Left - 15;
      Height:=Rect.Bottom - Rect.Top;
    end;

  end;

保存(Ctrl+S)して、実行(F9)。

進捗状況の表示に関する限り、「これがいちばんイイ」と私は思います。

以上がPanelを複数使用して、各々のPanelに対して文字色や背景色を設定する方法の解説です。

3.こんなこともできた! (その1)

他にも実現方法はたくさんあると思いますが、ユーザーに対して最初の1回だけは「とても重要な案内表示を行いたい」場合もあると思います。で、ユーザーが理解したことを(OKボタンを押すなどして)表明したら、もうその表示はプログラムが終了するまで出さない・・・みたいな処理をStatusBarを使って書いてみました。

ボタン3をクリックすると1回だけ案内を表示(OKするとプログラム終了まで表示されない)

FormにButtonを2つ追加します。ここで解説に使ったFormをそのまま使う場合は、新しく追加したボタンはButton3とButton4になります。Button4のNameは「btnOK」、Captionは「OK」に、Widthは「40」、Visibleは「False」に変更します。で、次のコードを記述します。

  private
    { Private 宣言 }
    boolInfo: boolean;

表示の制御に使用するフラグをグローバル変数として宣言しました。続けてButton3をクリックした時の処理を書きます。

procedure TForm1.Button3Click(Sender: TObject);
var
  w:integer;
begin
  if not boolInfo then
  begin
    //制御用フラグを既読扱いに設定
    boolInfo:=True;
    StatusBar1.Panels[1].Text:= '最初の1回だけは表示したい案内';
    //OKボタンを表示する位置を設定
    w:= StatusBar1.Canvas.TextWidth('最初の1回だけは表示したい案内');
    btnOK.SetBounds(
      StatusBar1.Panels[0].Width + w + 5, 0, 40, StatusBar1.ClientHeight);
    btnOK.Visible:=True;
    StatusBar1.Refresh;
  end;
end;

FormCreate手続きに追加する処理です。

procedure TForm1.FormCreate(Sender: TObject);
var
  w:integer;
begin

  ・・・ 省略 ・・・

  //案内表示の制御用フラグ
  boolInfo:= False;

  //OKボタン
  btnOK.Parent:= StatusBar1;
  btnOK.Visible:= False;

end;

StatusBar1DrawPanel手続きの中の if Panel=StatusBar.Panels[1] then の内容を次のように変更します。

  if Panel=StatusBar.Panels[1] then
  begin

    //文字の表示(このPanel内のみ有効となるようだ)
    if not boolInfo then
    begin
      StatusBar1.Canvas.Font.Color:= clBlack;
    end else begin
      StatusBar1.Canvas.Font.Color:= clBlue;
    end;

    //背景色(このPanel内のみ有効となるようだ)
    if not boolInfo then
    begin
      StatusBar1.Canvas.Brush.Color:= clBtnFace;
    end else begin
      StatusBar1.Canvas.Brush.Color:= clAqua;
    end;

    ・・・ 省略 ・・・

  end;

グローバル変数を使用するので、Button1、2が押された時の処理にも制御用変数の初期化を忘れずに追加します。

procedure TForm1.Button1Click(Sender: TObject);
begin
  //制御用フラグを初期化
  boolInfo:= False;
  //表示
  StatusBar1.Panels[1].Text:= '2番目の表示';
end;
procedure TForm1.Button2Click(Sender: TObject);
begin
  //制御用フラグを初期化
  boolInfo:= False;
  
  ・・・ 省略 ・・・
end;

最後に、ユーザーが表示された案内を読み、内容を理解したことを「OK」ボタンを押したというイベントから受け取ります。そのOKボタンの処理です。

procedure TForm1.btnOKClick(Sender: TObject);
begin
  StatusBar1.Canvas.Brush.Color:= clBtnFace;
  StatusBar1.Canvas.Font.Color:= clBlack;
  StatusBar1.Panels[1].Text:= '';
  btnOK.Visible:= False;
end;

これで他の動作モード(Button1や2をクリック)に切り替えない限り、1回OKをクリックすると、もうButton3を何度クリックしても案内は表示されなくなります。

以上、こんなこともできた!(その1) でした。

3.こんなこともできた(その2)

Panelを1つだけ用意して、次のように設定することで、文字列だけでなく、文字の色や背景色の変更も比較的簡単に実現できます。設定方法は次の通り。

StatusBarのSimplePanelは「False」に設定(デフォルト設定のまま)
StatusBarにPanelを1つ追加、StyleをpsOwnerDrawに設定

重要な設定のポイントは2つ。
(1)StatusBarのSimplePanelは「False」に設定(デフォルト設定のまま)
(2)追加したPanelのStyleはpsOwnerDrawに変更(デフォルト設定はpsText)
さらに、これをコードでも設定しておけば、見た目にもわかりやすいと思います。

procedure TForm1.FormCreate(Sender: TObject);
begin
  //SimplePanelの設定
  StatusBar1.SimplePanel:= False;
  //StyleをpsOwnerDrawに変更
  StatusBar1.Panels[0].Style:= psOwnerDraw;
end;

準備ができたら、次の2つの手続きを作成します。

procedure TForm1.Button1Click(Sender: TObject);
begin
  //描画内容の指定
  StatusBar1.SimpleText:='文字列の表示&文字色及び背景色の変更をSimpleTextで実行';
end;
procedure TForm1.StatusBar1DrawPanel(StatusBar: TStatusBar; Panel: TStatusPanel;
  const Rect: TRect);
begin
  if Panel = StatusBar.Panels[0] then
  begin
    //Fontの色
    StatusBar1.Canvas.Font.Color:=clBlue;
    //背景色
    StatusBar1.Canvas.Brush.Color:=clAqua;
    //描画コード
    StatusBar1.Canvas.TextOut(Rect.left, Rect.top, StatusBar1.SimpleText);
  end;
end;

保存(Crtl+S)して、実行(F9)。

文字だけでは気付いてもらえないかな?・・・みたいな時に使えます!

記述するコードも少なく、比較的簡単に「色の変更」を実装できる方法なのではないか・・・と思います。ただし、さらに色の変更を追加したい場合などには、注意が必要です。それは何かというと、「色の変更の設定はStatusBar1DrawPanel手続きの中で行う」ということです。以下のコードは動作しません。

// 注意:このコードは検証用です。正しく動作しません!
procedure TForm1.Button1Click(Sender: TObject);
begin
  //Fontの色
  StatusBar1.Canvas.Font.Color:=clBlue;
  //背景色
  StatusBar1.Canvas.Brush.Color:=clAqua;
  //描画内容の指定
  StatusBar1.SimpleText:='文字列の表示&文字色及び背景色の変更をSimpleTextで実行';
end;

procedure TForm1.StatusBar1DrawPanel(StatusBar: TStatusBar; Panel: TStatusPanel;
  const Rect: TRect);
begin
  if Panel = StatusBar.Panels[0] then
  begin
    //描画コード
    StatusBar1.Canvas.TextOut(Rect.left, Rect.top, StatusBar1.SimpleText);
    //この手続きの中で更新を実行してはいけない(検証用に追加、すべて誤りです)
    //StatusBar1.Refresh;
    //StatusBar1.Repaint;
    //StatusBar1.Update;
    //StatusBar1.Invalidate;
  end;
end;

【参考】Refreshメソッド

Refresh メソッドを呼び出すと,速やかにコントロールを再描画できます。Refresh は Repaint メソッドを呼び出します。Refresh と Repaint メソッドとは互換性があります。

https://docwiki.embarcadero.com/Libraries/Sydney/ja/Vcl.Controls.TControl.Refresh

保存(Crtl+S)して、実行(F9)。

テキストは表示されるが、文字色も背景色も変更されない

ですから、Buttonをもう一つ追加して、押したボタンによって表示される文字や背景の色を変更したい場合は、グローバル変数「例 ColorMode: boolean;」を準備して、Falseなら青、Trueなら赤にする等、さらなる工夫が必要です。以下、その場合の例です。

  private
    { Private 宣言 }
    ColorMode: boolean;

procedure TForm1.FormCreate(Sender: TObject);
begin
  //SimplePanelの設定
  StatusBar1.SimplePanel:= False;
  //StyleをpsOwnerDrawに変更
  StatusBar1.Panels[0].Style:= psOwnerDraw;
  //デフォルトの文字色は青
  ColorMode:= False;
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  //色の設定はSimpleTextへの文字列の代入より先に行うこと!
  //理由:SimpleTextへの文字列の代入即DrawPanel手続きが実行されるため
  //色は青
  ColorMode:= False;
  //描画内容の指定
  StatusBar1.SimpleText:= '文字列の表示&文字色及び背景色の変更をSimpleTextで実行';
end;

procedure TForm1.Button2Click(Sender: TObject);
begin
  //色は赤
  ColorMode:=True;
  //描画内容の指定
  StatusBar1.SimpleText:= 'SimpleTextで文字列の表示&文字色及び背景色の変更を実行';
end;

procedure TForm1.StatusBar1DrawPanel(StatusBar: TStatusBar; Panel: TStatusPanel;
  const Rect: TRect);
begin
  if Panel = StatusBar.Panels[0] then
  begin
    if not ColorMode then
    begin
      //Fontの色
      StatusBar1.Canvas.Font.Color:= clBlue;
      //背景色
      StatusBar1.Canvas.Brush.Color:= clAqua;
    end else begin
      //Fontの色
      StatusBar1.Canvas.Font.Color:= clRed;
      //背景色
      StatusBar1.Canvas.Brush.Color:= clWebViolet;
    end;
    //描画コード
    StatusBar1.Canvas.TextOut(Rect.left, Rect.top, StatusBar1.SimpleText);
  end;
end;

保存(Crtl+S)して、実行(F9)。

Button1をクリックしたところ
Button2をクリックしたところ

さらに複数の色を使いたい場合は、TRadioGroup等を利用して、そこで色をユーザーに選択してもらい、StatusBar1DrawPanel 手続きでは、if文ではなくcase文を利用し、その中で RadioGroup の ItemIndex を参照して色を変更する方法が考えられます。

TRadioGroupを利用する場合は、StatusBar1.Refreshも実行する必要があるようです(実行するタイミングも重要!-> StatusBar1DrawPanel 手続きではなく、ButtonClickの中で行う。Refreshを実行する場所を間違えると、これがエンエンと呼び出され続け、画面の描画が非常に重たくなり、通常1つしか選択できないはずのオプションボタンが同時に複数選択状態で表示されるなど、TRadioGroupの挙動もおかしくなります)。

以下に、TRadioGroupを利用する場合の例を示します。

FormにRadioGroupを2つ、Buttonを1つ追加します

RadioGroup1のCaptionは「文字色を選択」、Columnsは「1」、Itemsは「黒、青、赤」、RadioGroup2のCaptionは「背景色を選択」、Columnsは「1」、Itemsは「clBtnFace、clAqua、clWebViolet」をそれぞれ指定します。

新しく追加したButton3をクリックした時のコードです。

procedure TForm1.Button3Click(Sender: TObject);
begin
  //描画内容の指定
  StatusBar1.SimpleText:='文字列の表示&文字色及び背景色の変更を実行';
  //更新
  StatusBar1.Refresh;
end;

StatusBar1DrawPanel手続きのコードは、次のようになります。

procedure TForm1.StatusBar1DrawPanel(StatusBar: TStatusBar; Panel: TStatusPanel;
  const Rect: TRect);
begin
  if Panel = StatusBar.Panels[0] then
  begin

    { //コメント化 -> 前に使った Button1 も Button2 もクリックしない前提です
    if not ColorMode then
    begin
      //Fontの色
      StatusBar1.Canvas.Font.Color:=clBlue;
      //背景色
      StatusBar1.Canvas.Brush.Color:=clAqua;
    end else begin
      //Fontの色
      StatusBar1.Canvas.Font.Color:=clRed;
      //背景色
      StatusBar1.Canvas.Brush.Color:=clWebViolet;
    end;
    }

    //文字色
    case RadioGroup1.ItemIndex of
      0:StatusBar1.Canvas.Font.Color:= clBlack;
      1:StatusBar1.Canvas.Font.Color:= clBlue;
      2:StatusBar1.Canvas.Font.Color:= clRed;
    end;

    //背景色
    case RadioGroup2.ItemIndex of
      0:StatusBar1.Canvas.Brush.Color:= clBtnFace;
      1:StatusBar1.Canvas.Brush.Color:= clAqua;
      2:StatusBar1.Canvas.Brush.Color:= clWebViolet;
    end;

    //描画コード
    StatusBar1.Canvas.TextOut(Rect.left, Rect.top, StatusBar1.SimpleText);

    //更新(ここに書いてはいけない)
    //StatusBar1.Refresh;
    //StatusBar1.Repaint;
    //StatusBar1.Update;
    //StatusBar1.Invalidate;

  end;
end;

保存(Crtl+S)して、実行(F9)。

文字色は「黒」、背景色は「clBtnFace」を指定
文字色は「青」、背景色は「clAqua」を指定
文字色は「赤」、背景色は「clWebViolet」を指定
文字色は「黒」、背景色は「clAqua」を指定

StatusBarのPanelを利用して、そのStyleプロパティを「psOwnerDraw」に設定し、描画を自前で行う場合には広範囲に(コードの実行順等まで含む)様々な注意が必要です。
以上が「こんなこともできた!(その2)」の内容です。

4.まとめ

StatusBarの使い方はSimplePanel:= True; として、SimpleTextに指定した文字列を表示するというカンタンな方法と、デフォルト設定のSimplePanel:= False; のまま、Panelsプロパティで複数個のPanelを設定して、描画はStatusBar1DrawPanel手続きに自分で記述する方法の2種類があり、文字列の表示と色の変更だけならPanelを1個用紙して、SimpleTextを利用する、中間的な方法で「文字列の表示」と「表示色の変更」の両方を実装できる。

さらに複数の色を使いたい場合はRadioGroup等を利用して設定できるが、この場合は描画をRefreshする必要がある。StatusBar1.Refreshは、StatusBar1DrawPanel 手続きの中で呼び出してはいけない。

5.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

Link Image click position with Grid control

「画像のクリック位置とグリッドコントロールの連動」

画像を表示したTImageの任意の1点をクリックしたら、そのY座標に応じてStringGridコントロールの適切なセルを選択するようなプログラムが書けないかなー。・・・と思って、実際に書いてみたら、あまりにも簡単に実現できてしまったお話。

1.やりたかったこと
2.作成したプログラム
3.まとめ
4.お願いとお断り

1.やりたかったこと

次のようなGUIのある採点プログラムを作成した。プログラムはスキャナーで読み込んだ複数枚の答案画像から、設問ごとに解答欄をかき集めて表示するもの(答案画像への書き戻しも可能)。で、採点作業をより一層効率的に行うには、各々の解答欄画像をクリックした際、自動的に採点結果を入力するGridコントロールのセルが選択されたらイイなーと思ったことが、この連動プログラムを書こうとしたきっかけです。

画像をクリックした時に得られるY座標を元に、Gridコントロールの最適なセルを選択したい!

2.作成したプログラム

まず、思ったことはTImageのOnMouseDownイベントを利用すればイイ(画像上でクリックした位置のX, Y座標が取得できる)ということです。早速、imgAnswerという名前をつけたTImageをクリックして選択し、OnMouseDownイベントの右側をダブルクリックして、imgAnswerMouseDown手続きを作成。

次のようなコードを書いてみました!

procedure TFormCollaboration.imgAnswerMouseDown(Sender: TObject;
  Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
begin
  //imgAnswerの高さは、解答欄1つの高さ × 解答用紙数 だから
  //クリックした場所の(Y座標 div 解答欄1つの高さ) + 1 が解答用紙の番号になるはず
  OutPutDebugString(PChar(IntToStr((Y div 解答欄1つの高さ) + 1)));   
end;

実行(F9)して、確認。

No,5の解答欄をクリックすると、確かに「5」と表示されている! やった☆

必要と思われる変数 i, j を宣言して・・・
あとは Gridコントロールへ SetFocus するコードを書くだけ!

procedure TFormCollaboration.imgAnswerMouseDown(Sender: TObject;
  Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
var
  i,j:integer;
begin
  //imgAnswerの高さは、解答欄1つの高さ × 解答用紙数 だから
  //クリックした場所の(Y座標 div 解答欄1つの高さ) + 1 が解答用紙の番号になるはず
  OutPutDebugString(PChar(IntToStr((Y div 解答欄1つの高さ) + 1)));
  i:= (Y div 解答欄1つの高さ) + 1;
  //SetFocus
  j:= StrToInt(現在選択している解答欄の番号);
  StringGrid1.Col:= j;
  StringGrid1.Row:= i;
  StringGrid1.SetFocus;   
end;

上のコードでは、わかりやすさのため変数(記号)ではなく、そのかわりに「解答欄1つの高さ」等のように説明の文字列で記述しています。また、採点ミスを防止する観点から、Gridコントロールに表示する入力可能な列は、現在選択(=画像として表示)している解答欄に対応する1列のみとしています。

これで理想としていたカタチの採点プログラムに一歩近づけたように思います。ユーザーに案内したい採点方法は、サっと見て全員が良く出来ている設問であれば、最初に全員正解として得点を一括自動入力し、誤りの解答のみ、解答欄画像を見ながらチェックしてクリック、自動選択された採点欄のセルにゼロ(得点)を入力する方法です。

3.まとめ

案ずるよりナントカといいますが、ほんとうにその通りで、内心、ずっとできるかなー? なんて思っていたことが、案外、基本的な知識だけで簡単に実現できてしまいました!

//Integer 型で、割り算をしたいときは、整数除算の演算を行う div を使う
A div B  //A ÷ B の商が取得できる

あとは、やりたいことを実現するアルゴリズムを考えるだけですね!
プログラミングしていて、いちばんしあわせで、楽しい時間を過ごせました☆

【注意!】
画像の表示倍率の変更が伴う場合には、表示倍率に合わせてプログラムの変更が必要
です。完璧には動作しませんが、ある程度使える(と思われる)表示倍率の変更に対応するコードは次の通りです(クリック位置が画像の下へ行くほど、誤差が大きくなります)。

procedure TFormCollaboration.imgAnswerMouseDown(Sender: TObject;
  Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
var
  i,j,w:integer;
  k:double;
begin
  //imgAnswerの高さは、解答欄1つの高さ × 解答用紙数 だから
  //クリックした場所の(Y座標 div 解答欄1つの高さ) + 1 が解答用紙の番号になるはず
  OutPutDebugString(PChar(IntToStr((Y div 解答欄1つの高さ) + 1)));
  i:= (Y div 解答欄1つの高さ) + 1;
  //表示倍率が100%の時
  if Edit1.Text='100' then
  begin
    i:=(Y div 解答欄1つの高さ)+1;  //このアルゴリズムは記録として残したい
  end else begin
    //表示倍率が100%ではない時
    k:= 解答欄1つの高さ * (StrToFloat(Edit1.Text)/100);
    i:=Ceil(Float(Y)/k);  // <- とりあえずこれで使えるカモ?
  end;

4.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

The width of the TImage was not the width of the Graphic

「TImageの幅はグラフィックの幅じゃなかった」

1つのTImageに、様々に組み合わせた画像を、次々に表示して、その幅(と高さ)が頻繁に変わるようなプログラムを書いた。プログラムがほぼ完成?に近づいた段階で、画像の要素の組み合わせ方を変えるとTImageの幅が、画像全体の幅の変更に追随しなくなる現象に遭遇。原因はプログラムの記述ミス。これは、それを繰り返さないための備忘録。

1.最初に結論
2.練習プログラムで問題を再現
3.まとめ
4.お願いとお断り

1.最初に結論

TImage は、「グラフィックスを表示する」ためのコントロールであって、「描画そのものはグラフィッククラス側で行う必要」があり、TImage は「位置決めと描画タイミングの面倒を見ているだけ」とのこと。

つまり、TImageが内部に抱えているグラフィックスクラスに対して、適切な指示を与えてあげないとプログラムは予期した通りに動作しないよ!・・・ということになります。

そのことを教えてくれたのが、次の2つのWebサイトの情報です。心から感謝です。

TImageのリサイズ時に気をつけること

https://vogelbarsch.com/2016-01-13-212108/

TImage

http://tknakamuri.web.fc2.com/vcl2-12.htm

結論として、TImageに複数の画像を埋め込んで、切ったり、貼ったり、幅や高さを変更して使用する場合、TImage内部のグラフィックスクラスにTBitMapを使うのであれば(私の場合は全部そうです)「TImageが内部にBitMapを抱えていることを忘れてはいけない」ということです。

では、「TImageが内部にBitMapを抱えていることを忘れる」とどうなるのか? その具体例を次に示します。

作成しているプログラムは、100枚程度の答案画像をスキャナーで読み込み、ここから設問ごとに(1問ずつ)解答欄を切り出して1枚の画像に合成し、横に置いたGridコントロールに採点結果を入力。その後、採点結果はCSVファイル(必要であればExcelで作成した採点Sheetも利用可)に保存し、採点結果(○×)をつけた画像も元の答案画像に埋め込んで印刷して返却できるようにする・・・というもの(答案そのものは重要な証拠として保管し、適切な時期にシュレッダーにかけて処分するような運用を予定しています)。

スキャナーで読み込んだ答案画像(マークシートは別のプログラムで採点)

当初は、設問の解答欄のみを切り出して表示する機能のみ実装するつもりでしたが、「個人識別も可能にして欲しい(解答者の理解度や弱点を知るため、誰の解答か、すぐわかるようにして欲しい)」という要望があり、これに応えるため、解答欄の横に答案から切り出してきた組・番号・氏名等も表示できる機能を付け加えました。

解答者の氏名を表示しない(個人識別なし)で動作させた画面がこちらです。

解答欄の画像のみ切り出して合成
個人識別を行わない設定

この時のTImageの幅を確認すると、次の通り「628」でした。

次に、「個人識別」にチェックを入れ、解答欄の左に組・番号・氏名等を表示するモードに切り替えて動作させます。

数値15は垂直方向の表示位置です
解答欄が半分しか表示されていない・・・

なのに、TImageの幅は・・・

大きくなってるー!!
なんでー???

悪いのは確かに私なんですが、この時点で原因はまるでわかりません。
問題を作り出しているのはもちろん自分が書いたコードです。次に、個人識別なしの場合の誤りを含むコードを示します。ナニがいけないのか、お気づきになりますでしょうか?
(imgAnswerは、画像を表示しているTImageの名前です)

  //描画(修正前の誤りを含むコード)
  imgAnswer.Height := (解答欄1個の高さ) * ListBox1.Items.Count;
  imgAnswer.Width := 解答欄1個の幅;
  imgAnswer.Canvas.CopyRect(DestRect, Image1.canvas, SrcRect);

そうです。TImageの高さと幅をいじっています。冒頭で述べた通り、TImageは表示する画像の「位置決めと描画タイミングの面倒を見ているだけ」なのです。重要なのは、そのTImageが内部に抱えているTBitMapです。こちらの高さと幅を設定し忘れています。おそらく画像の切り出しに注意が集中しすぎ、(TImageの幅と高さを設定すればOK!)と勘違いして、TBitMapのことまでアタマがまわらなかったのだと思います。

こんな恥ずかしい間違いを私以外にする人がいるとは思えませんが・・・。もしかしたら、どこかにおひと方くらい、同じ問題で悩む方がいるかもしれません。その方の一助にでもなれば幸いと、あえて誤りを公開する次第です。

  //描画(修正後)
  imgAnswer.Picture.Bitmap.Height := (解答欄1個の高さ) * ListBox1.Items.Count;
  imgAnswer.Picture.Bitmap.Width := 解答欄1個の幅;
  imgAnswer.Canvas.CopyRect(DestRect, Image1.canvas, SrcRect);

で、描画手続きの最後に以下のコードでTImageの高さと幅を設定。Visible も True に。

  //表示
  imgAnswer.Height:=imgAnswer.Picture.Bitmap.Height;
  imgAnswer.Width:=imgAnswer.Picture.Bitmap.Width;
  imgAnswer.Visible:=True;
正しく表示されるようになりました☆

現象を確認してから、問題の本質に気付き、プログラムを修正するまで2時間少々かかりました。修正しながら、この問題そのものに「既視感」を感じたことも事実であり、もしかしたら(はっきり覚えていないだけで)、私は以前にも今回と同じ問題で悩んだことがあるのかもしれません。いや、きっとあります。問題解決の重要なヒントとさせていただいた上記Webサイト様の情報にも確かな既視感がありましたから。

なお、本題に関係ない部分ですが、採点のマーキング位置と個人識別情報の表示位置は細かな指定ができる設計にしてあります。上記画像も「ちゃんと」すれば、次の画像のようにもう少し見やすく変更できます。これが自動で出来ればスゴイのですが・・・

2.練習プログラムで問題を再現

FormにTImage1つと、Buttonを2つおいて、ボタンをクリックすると表示される画像を切り替える練習プログラムで問題を再現してみます。

点線で囲まれた部分がTImage(幅と高さは右下のハンドルを適当にドラッグしたままの状態で設定)

で、TImageに表示する画像は次の2枚。

Picture01.jpg
Picture02.jpg(幅を1/2にした画像)

Button1をクリックした時の手続き

procedure TForm1.Button1Click(Sender: TObject);
var
  pFileName:string;
  //GDI+を利用する
  Graphics:TGPGraphics;
  GPbmp:TGPBitmap;
begin

  //Image(メモリ)を初期化
  Image1.Picture.Assign(nil);  //縦横比が変化しないと、再描画されないことがあった!

  //表示する画像のファイル名を取得
  pFileName:=ExtractFilePath(Application.ExeName)+'Data\Picture01.jpg';

  //オブジェクトを生成
  GPbmp:=TGPBitmap.Create(pFileName);
  //回転・反転ともになし
  GPbmp.RotateFlip(RotateNoneFlipNone);

  //描画
  Image1.Picture.Bitmap.Width:=GPbmp.GetWidth;
  Image1.Picture.Bitmap.Height:=GPbmp.GetHeight;
  Image1.Picture.Bitmap.PixelFormat:=pf24bit;
  Graphics:=TGPGraphics.Create(Image1.Picture.Bitmap.Canvas.Handle);
  try
    //イメージを表示
    Graphics.DrawImage(GPbmp, 0, 0, GPbmp.GetWidth, GPbmp.GetHeight);
    Image1.AutoSize:=True;
  finally
    GPbmp.Free;
    graphics.Free;
  end;

  //画像を表示
  Image1.Visible:=True;

end;

button2をクリックした時の手続きも同様に記述して(画像の名前を「Picture02.jpg」に変更するだけです)、動作を確認。まず、button1をクリック。

画像が表示される

続けて、button2をクリック。

こちらも問題なし(予期した通りに動作する)

TImageの幅の変更は適切に処理され、期待した通りに動作することが確認できました。
次に、button1 クリック時のコードを誤りのあるものに変更。
ついでにGDI+のビットマップの幅と、Image1の幅も表示して比較できるように設定。

  //Image1.Picture.Bitmap.Width:=GPbmp.GetWidth;
  //Image1.Picture.Bitmap.Height:=GPbmp.GetHeight;

  //誤ったコード
  Image1.Width:=GPbmp.GetWidth;
  Image1.Height:=GPbmp.GetHeight;
  //Freeする前にGPbmpの幅を見ておく
  ShowMessage(GPbmp.GetWidth.ToString);

  Image1.Picture.Bitmap.PixelFormat:=pf24bit;
  Graphics:=TGPGraphics.Create(Image1.Picture.Bitmap.Canvas.Handle);
  try
    ・・・(略)・・・
  finally
    GPbmp.Free;
    graphics.Free;
  end;

  //画像を表示
  Image1.Visible:=True;
  //TImageの幅を確認
  ShowMessage(IntToStr(Image1.Width));

上記設定で動かしてみると、ShowMessage(GPbmp.GetWidth.ToString) の結果は・・・

GDI+のBitMapに画像が読み込まれている証拠

続けて、Image1の幅はどうなっているかというと・・・

予期した通りゼロ

Image1のPicture.Graphicに代入されるTBitmapがカラだから、結果としてImage1の幅が変更されない? もちろん、画像も表示されません。

誤りのあるコードで、button1をクリックした結果

ShowMessageではなく、OutPutDebugString関数を使えば、次のようにメッセージを表示せずに確認することもできます。

  //ShowMessage(GPbmp.GetWidth.ToString);
  OutPutDebugString(PChar(GPbmp.GetWidth.ToString));

  //ShowMessage(IntToStr(Image1.Width));
  OutPutDebugString(PChar(IntToStr(Image1.Width)));

実行(F9)でイベントログにOutPutDebugString関数の結果が出力される。

GPbmp.GetWidthが「468」、Image1.Widthが「0」であることがわかる

私の場合、いつもはShowMessage関数で確認して、消去するか、そのままコメントアウトしてしまうことが多いのですが・・・。

なぜ、この例を追加したかというと、問題の原因がどこにあるのかわからない時は、「基本に返る」ことの大切さを、この間違い探しの作業を通じてしみじみ実感したからです。

実は自分の書いたコードのどこに誤りがあるのか、当初はまったくわからず(見当すらつかず)、少しずつコードを追いかけてようやくImageの幅がおかしなコトになっているという点に気が付きました。

で、幅の設定を手掛かりにして、GDI+で画像を読み込む手続きの部分を見直した時、

  Image1.Picture.Bitmap.Width:=GPbmp.GetWidth;
  Image1.Picture.Bitmap.Height:=GPbmp.GetHeight;

(あれっ なんかついて る・・・)という違和感を感じたのです。

画像の読み込みでは、Picture.Bitmap があるのに、
画像の合成部分には、確か、それがなかった・・・

やっと、わかったー(解決)

基本に戻るということの大切さを、しみじみ実感しました。
で、あらためてTImageの幅について勉強し直した結果、上記Webサイト様の情報に行きつき、問題の本質が明らかになった次第です。

しかし、またココで新たな謎が・・・

ShowMessage(GPbmp.GetWidth.ToString);

この1行、実行にすごく時間がかかる気が・・・
なんでかなー???

3.まとめ

TImageは、そのPicture.Graphicに読み込んだ画像を表示するVisual Component Library (VCL)。TImage は「位置決めと描画タイミングの面倒を見ているだけ」で、「描画そのものはグラフィッククラス側で行う必要」がある。だから高さや幅の設定も・・・

  Image1.Picture.Bitmap.Width:=GPbmp.GetWidth;
  Image1.Picture.Bitmap.Height:=GPbmp.GetHeight;

TImageが内部に抱えているBitmapに対して行う必要がある。

4.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

When The Cancel Button is Pressed

「キャンセルボタンが押された時は?」

JpegファイルをTImageに読み込み、プリンターのプロパティを設定して印刷するとき、設定画面にある「OKボタン」ではなく、「キャンセルボタン」が「押された時」の対応方法がわからずハマった話。

1.つくった練習プログラム
2.キャンセルボタンが押されたら・・・?
3.TOpenDialogなら・・・
4.解決方法を知る
5.まとめ
6.お願いとお断り

1.つくった練習プログラム

Formに最低限必要なVCLコンポーネントを以下のように配置。
(右上にある「Printerを選択」のVCLはTLabel(のCaption)。これはなくてもOK。)

Formの幅は810、高さは480に設定

GUIを作成後、Mr.XRAYさんのWebサイトにあった情報を参照して、プリンタのプロパティのダイアログを表示して印刷設定する方法で、練習プログラムを書いてみた。Mr.XRAYさんのWebサイトにはいつも本当にお世話になっています。詳細かつ丁寧なご説明に心より感謝申し上げます。

013_プリンタのプロパティ設定

http://mrxray.on.coocan.jp/Delphi/plSamples/013_SetPrinterProperty.htm#04

グローバル変数の設定は、{ Private 宣言 } に以下の通り記述。

type
  TForm1 = class(TForm)
    Image1: TImage;
    ListBox1: TListBox;
    ComboBox1: TComboBox;
    Button1: TButton;
    Label1: TLabel;
    CheckBox1: TCheckBox;
    procedure FormCreate(Sender: TObject);
    procedure ComboBox1Select(Sender: TObject);
    procedure Button1Click(Sender: TObject);
  private
    { Private 宣言 }
    ADevice     : array[0..MAX_PATH - 1] of Char;
    ADriver     : array[0..MAX_PATH - 1] of Char;
    APort       : array[0..MAX_PATH - 1] of Char;
    ADeviceMode : THandle;
  public
    { Public 宣言 }
  end;

「プリンタのプロパティ」として表示したいダイアログは、TPrintDialog ダイアログ or TPrinterSetupDialog ダイアログを表示した際に、その画面にある「プロパティ」ボタンをクリックすると表示されるダイアログ。(自分的には、これが「かゆいところに手が届く」いちばんイイ!ダイアログじゃないかなー みたいな気がして・・・)

もっと正直な、ほんとうのところを言うと、コントロールパネル → ハードウェアとサウンドの「デバイスとプリンターの表示」で表示されるプリンタを右クリックして出てくるサブメニューにある「プリンターのプロパティ」をクリックして表示される「詳細設定」タブのある「(プリンター名)のプロパティ」の画面を出したかったんだけど、コレを出す方法がわからなかった。
(もちろん、今でもわかりません。わかる方、Web上のどこか、いつまでも、ずっと残る場所に情報を書いていただけるとすごくうれしいです!)

Printerの選択肢を表示する部分は・・・

procedure TForm1.FormCreate(Sender: TObject);
//プリンタのリストを取得してComboBox1にセット
begin
  ComboBox1.Items.Clear;
  ComboBox1.Items.Assign(Printer.Printers);
  ComboBox1.ItemIndex := Printer.PrinterIndex;
  //プリンタを初期化
  ComboBox1Select(nil);  //このprocedureはまだ作ってないから当然エラーになる!
end;

エラーは無視。そのままForm上のComboBox1をクリックして、表示されるオブジェクトインスペクタの OnSelect の右の空欄をダブルクリックして表示される新しいプロシージャに以下の内容を記述。

procedure TForm1.ComboBox1Select(Sender: TObject);
begin
  //選択したプリンタをアクティブなプリンタに設定
  Printer.PrinterIndex := ComboBox1.ItemIndex;
  //ADeviceModeには変更前のプリンタの情報が格納されている
  //その他の値は現在(変更後)のプリンタの情報
  Printer.GetPrinter(ADevice, ADriver, APort, ADeviceMode);
  //ADeviceMode初期化
  Printer.SetPrinter(ADevice, ADriver, APort, 0);
  //ADeviceModeが新しいプリンタドライバの値になる
  Printer.GetPrinter(ADevice, ADriver, APort, ADeviceMode);
end;

プログラムを上書き保存(ComboBox1Select(nil);のエラーはここで解消される)。

で、最初に書いた(キャンセルボタンが押された場合の処理がない)印刷部分のプログラムは以下の通り(usesへの追加内容とButton1Click)。
EXEのある場所にDataフォルダを作成しておき、そこに印刷したいJpegファイルを入れておく。プログラムはこのJpegファイル1枚1枚へのPathをListBox1にセットして、セットされたItemの数だけ印刷Loopをまわすという内容。
なお、Jpegファイルの読み込みはGDI+を使用している。

implementation

uses
  Winapi.GDIPAPI, Winapi.GDIPOBJ, Winapi.GDIPUTIL,
  System.IOUtils, System.UITypes,
  Printers, Winapi.WinSpool,
  System.Types, Vcl.FileCtrl, System.StrUtils, System.Masks,
  Winapi.CommDlg;

  //Vcl.Imaging.jpeg, Winapi.MMSystem, Vcl.axCtrls,は削除した
  //System.UITypesはMessageDlgを使用するために追加
  //Printersは印刷を実行するために追加
  //Winapi.WinSpoolはPrinterPropertyを表示して印刷するために追加
  //System.IOUtils, System.Types, Vcl.FileCtrl, System.StrUtils, System.Masksは
  //ファイル名取得用の関数を使うために必要
  //Winapi.CommDlgはTPrintDlgの表示に必要

{$R *.dfm}
procedure TForm1.Button1Click(Sender: TObject);
var
  //PrinterプロパティのDialogを表示
  DevMode  : PDeviceMode;
  Mode     : Cardinal;
  hPrinter : THandle;
  //矩形
  rect:TRect;
  //印刷関連
  j:integer;
  //for フォルダの選択
  iStartFolder: string;
  iDirectories: TArray<string>;
  FileNames:TStringDynArray;  // -> ローカル変数でなければならない
  //TStringDynArrayの使用にはusesにTypesが必要
  strFileName, strSavePath:string;
  //for 画像読み込み
  graphics:TGPGraphics;
  bmp:TGPBitmap;

  //拡張子の異なるファイルをそれぞれ検索
  function MyGetFiles(const Path, Masks: string): TStringDynArray;
  var
    MaskArray: TStringDynArray;
    Predicate: TDirectory.TFilterPredicate;
  begin
    MaskArray := SplitString(Masks, ';');
    Predicate :=
      function(const Path: string; const SearchRec: TSearchRec): Boolean
      var
        Mask: string;
      begin
        for Mask in MaskArray do
          if MatchesMask(SearchRec.Name, Mask) then
            exit(True);
        exit(False);
      end;
    Result := TDirectory.GetFiles(Path, Predicate);
  end;

begin

  //プロパティの設定Dialogの表示がチェックされていたら実行
  //これを設定しない場合は、デフォルトの設定で印刷される(A4・縦とか)
  if CheckBox1.Checked then
  begin

    //FDeviceModeのメモリをロックしDEVMODE構造体のポインタを取得
    DevMode:=GlobalLock(ADeviceMode);

    try

      //対象のプリンタのハンドルを取得
      if Winapi.WinSpool.OpenPrinter(ADevice, hPrinter, nil) then begin
        try
          //このLModeの値によって,ダイアログ表示かプロパティの設定かが決まる
          Mode:=DM_IN_PROMPT or DM_OUT_BUFFER or DM_IN_BUFFER;
          //必要な場合は、ここで DevModeを修正する
          //例:A4・横の設定にしてからダイアログを表示する
          with DevMode^ do begin
            dmPaperSize:=DMPAPER_A4;  //A4
            dmOrientation:=DMORIENT_LANDSCAPE;  //横
          end;
          //プリンターのプロパティ設定Dialogを表示
          //キャンセルボタンが押された場合の処理なし(これを何とかしたい
          DocumentProperties(Handle, hPrinter, ADevice, DevMode^, DevMode^, Mode);
        finally
          //閉じる
          ClosePrinter(hPrinter);
        end;
      end;
    finally
      //ロック解除
      GlobalUnlock(ADeviceMode);
    end;

  end;

  //設定を反映させる
  Printer.SetPrinter(ADevice, ADriver, APort, ADeviceMode);

  //印刷実行
  //表示に設定
  Image1.Visible:=True;
  //ListBoxも初期化
  ListBox1.Items.Clear;
  //表示位置を設定
  Image1.Left:=10;
  Image1.Top:=10;

  //フォルダの選択
  MessageDlg('OKをクリックするとフォルダ選択ダイアログが表示されます。'+#13#10+#13#10+
      '採点したいデータのあるフォルダを選択してください。', mtInformation, [mbOk] , 0);

  iStartFolder:=ExtractFilePath(Application.ExeName);
  if SelectDirectory(iStartFolder, iDirectories,
    [sdHidePinnedPlaces, sdNoDereferenceLinks, sdForceShowHidden,
    sdAllowMultiselect], 'フォルダを選択してください', 'Folder', 'Ok') then
  begin

    //開いた時のPathを採点結果の保存先Pathとして取得しておく
    strSavePath:=iDirectories[0];

    //拡張子が'jpg'のファイルの名前を取得してListBoxへ格納
    FileNames:=MyGetFiles(iDirectories[0], '*.jpg');
    for strFileName in FileNames do
    begin
      ListBox1.Items.Add(strFileName);
    end;

    //答案の枚数分Loopする
    for j := 0 to ListBox1.Items.Count-1 do
    begin

      //画像の初期化
      Image1.Picture:=nil;

      //GDI+で読む
      bmp:=TGPBitmap.Create(ListBox1.Items[j]);
      Image1.Picture.Bitmap.Width:=bmp.GetWidth;
      Image1.Picture.Bitmap.Height:=bmp.GetHeight;
      Image1.Picture.Bitmap.PixelFormat:=pf24bit;
      graphics:=TGPGraphics.Create(Image1.Picture.Bitmap.Canvas.Handle);

      try
        Graphics.DrawImage(bmp, 0, 0, bmp.GetWidth, bmp.GetHeight);
        Image1.Align:=alNone;
        Image1.Center:=True;  //Imageの中央に表示
        Image1.AutoSize:=False; //サイズは固定しない
        //StretchとProportionalをセットで指定して、伸縮と縦横比維持を両立
        Image1.Stretch:=False; //Image枠内収まるよう画像の大きさを変更しない
        Image1.Proportional:=True;  //画像の縦横比を維持
      finally
        bmp.Free;
        graphics.Free;
      end;

      //処理の表示を止めないおまじない
      Application.ProcessMessages;

      //印刷実行
      //if MessageDlg('印刷しますか?',mtConfirmation,[mbYes,mbNo],0) = mrYes then
      //begin
        //ページを印刷する
        with Printer do
        begin
          {
          if j=0 then
          begin
            BeginDoc;
          end else begin
            NewPage;
          end;
          }
          BeginDoc;
          //大きさを指定
          rect.Top    := 0;
          rect.Left   := 0;
          rect.Bottom := Trunc(( PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
          rect.Right  := PageWidth;
          //ファイルを描画
          Printer.Canvas.StretchDraw(rect, Image1.Picture.Graphic);
          {
          if j=ListBox1.Items.Count-1 then
          begin
            EndDoc;
          end;
          }
          EndDoc;
        end;
      //end;
    end;
  end;

end;

【20220830 追記】
印刷部分の動作検証は、普段愛用している印刷ユーティリティソフトのFinePrintで行いました(FinePrintは、株式会社NSDさんが販売している多機能プリンタドライバで、ページを縮小してのまとめ印刷等が簡単に行える非常に使いやすいソフトです)。FinePrintに出力している分については、上記コードで全ページがきちんと出力されるのですが、実際に業務で使用しているプリンタを指定して出力すると、ページが「抜ける」大変困った現象が発生することを確認しました。そこで、まとめて印刷せず、1枚毎にプリンタへ出力するようコードを修正してあります。

【20220901追記】
次のWebサイトに紹介されている方法を、上記印刷プログラムに対して試したところ、印刷の「抜け」が発生しなくなりました。貴重な情報をご提供くださいました中村 様、本当にありがとうございました。

ビットマップがプリンタに印刷できない

http://tknakamuri.web.fc2.com/tips004.htm

最後の印刷部分は、メタファイルに流し込む等は行わず、TImageのグラフィックをそのままプリンターのCanvasに大きさを合わせて描画している。

この方法は、次のWebサイトで紹介されていたものを、複数枚印刷用にコードを変更して利用させていただきました。ありがとうございました!

Delphi/400 Tips プログラムからの印刷 の 画像の印刷

https://www.migaro.co.jp/contents/products/delphi400/tips/introduction/4_19/01/02.html

プリンターのプロパティ設定を行わずに印刷すると(当たり前だが)、プリンタのデフォルト設定がそのまま適用される。

プリンターのプロパティ設定を行って印刷すると(当たり前だが)、変更した設定が適用されて印刷が実行される。

何の問題もない。これで出来上がり。OK!・・・でも、よかったんだけど、

2.キャンセルボタンが押されたら・・・?

ふと気になって、プリンターのプロパティ設定画面にある「キャンセルボタン」をクリックしてみた。

設定Dialog画面は確かにキャンセルされるんだけど・・・
プログラムがそのまま先へ進んでしまう・・・。
(気持ち的にはキャンセルした時点でExitして欲しいところだ)

んじゃ、キャンセルボタンが押されたらExitすればイイ。
で、その処理はどう書くの?

えぇっ ボク、知らないよー (T_T)
ここで、またハマりました!!

3.TOpenDialogなら・・・

よく使うTOpenDialogなら、こうするのが定番(・・・だと思う)

begin
  if OpenDialog1.Execute then
  begin
    ShowMessage(OpenDialog1.FileName);
  end else begin
    ShowMessage('キャンセル');
  end;
end;

でも、DocumentProperties( )には .Execute はありませんでした。これで、さらに(T_T)

×:DocumentProperties(Handle, hPrinter, ADevice, DevMode^, DevMode^, Mode).Execute

なんだか、面白いコトになってキタ!

4.解決方法を知る

Google先生しか頼りになるヒトはいませんから、さっそくカタカタきいてみます。
(実際には、最終的な解決に至るまでには、かなりの時間を費やしたのですが・・・)

Delphi DocumentProperties関数 ・・・というキーワードでお伺いをたてると、上から3番目に次の情報がHit!

Googleの検索結果から引用

この解説の下の方に、以下の記事がありました☆

関数がプロパティ シートを表示する場合、戻り値は、ユーザーが選択するボタンに応じて IDOK または IDCANCEL になります。

https://docs.microsoft.com/ja-jp/windows/win32/printdocs/documentproperties

あったー!!

さっそくこの情報を元にプログラムを書き換えます。

//DocumentProperties(Handle, hPrinter, ADevice, DevMode^, DevMode^, Mode);

//キャンセルボタンに対応
if DocumentProperties(
  Handle, hPrinter, ADevice, DevMode^, DevMode^, Mode) = IDCANCEL then Exit;

あっけないくらい、カンタンに解決☆
(やったー! コレでまたBlog書けるー!! ・・・みたいな気も)

5.まとめ

DocumentProperties関数は、キャンセルボタンが押されるとIDCANCELが戻り値として返る。これを利用してExitするには・・・

//キャンセルボタンに対応
if DocumentProperties( 略 ) = IDCANCEL then Exit;

6.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

Bitmap Conversion

「ビットマップ変換」

TImageに画像を読み込んで、変更を加え、その画像をGDI+で高速に保存しようとした。
しかし、VCLのTBitmapをGDI+のBitmapへ変換する方法がわからず、半日以上試行錯誤。これはその時の記録。

0.Bitmapを変換したい理由
1.わかったこと
2.まとめ
3.お願いとお断り

0.Bitmapを変換したい理由

GDI+を使うようになる前は、次のようなコードでJpegファイルを保存していた。

var
  Jpeg: TJPEGImage;
  S:string;
begin
  S := ChangeFileExt(画像ファイルへのPath, '.jpg');
  Jpeg := TJPEGImage.Create;
  try
    Jpeg.Assign(Image1.Picture.Bitmap);
    Jpeg.Compress;
    Jpeg.SaveToFile(S);
  finally
    Screen.Cursor := crDefault;
    Jpeg.Free;
  end;
end;

1枚とか、ごくわずかな枚数の画像を保存するだけなら、この方法でも特に問題はないんだが、何十枚もの画像を頻繁に処理することを繰り返すような場合には、やはりGDI+を使って高速に処理したくなる。例えば、次のような場合だ。

マークシートと手書きの解答がセットになった解答用紙をスキャナーで読み取り、その手書きの解答欄を矩形選択して切り取り、(1)なら(1)だけ!以下の画像のように人数分集めて表示 & 採点するプログラムを作りたいとき(実際に今、作っている)。

矩形で切り抜いて表示、採点までは無事にプログラミングできた☆

手書き答案をPCと協働で採点するプロジェクト

ハマったのは、ココからだ。採点したら、各々の解答欄を、解答用紙の元の場所に戻したい。矩形で切り取って集めて1枚の画像に仕立てたアルゴリズムを再利用し、読み込み元Rectと書き込み先Rect、及び読み取り元Imageと書き込み先Imageをそれぞれ逆にすれば、各々の解答欄は「ばっちり元の場所に戻る」ハズだ。・・・と考え、実際にその処理を書いてみた。

ところが赤い丸印(採点済みのマーク)を付加して変更を加えたTImage(VCL)のTBitmapをGDI+のBitmapへ変換する方法がどぉーしてもわからない。走召!困った。。。

        //GDI+で元の答案画像を読み込んでおく
        bmp:=TGPBitmap.Create(答案の画像ファイル);
        Image1.Picture.Bitmap.Width:=bmp.GetWidth;
        Image1.Picture.Bitmap.Height:=bmp.GetHeight;
        Image1.Picture.Bitmap.PixelFormat:=pf24bit;
        graphics:=TGPGraphics.Create(Image1.Picture.Bitmap.Canvas.Handle);

        try  
          //イメージを表示
          graphics.DrawImage(bmp, 0, 0, bmp.GetWidth, bmp.GetHeight);

            ・・・ 採点結果の書き戻し準備処理 ・・・

          //採点した解答欄の矩形を元の答案画像へ書き戻し(これは成功する)
          Image1.Canvas.CopyRect(DestRect, Image2.canvas, SrcRect);
        finally
          bmp.Free;
          graphics.Free;
        end;

        //書き戻した答案画像はできてるけど、まだファイルになってない!
        //・・・ってか、それをファイルにする処理をここに書きたい・・・んだけど
        bmp:=TGPBitmap.Create(ココの書き方がわからない);
        //Graphics:=TGPGraphics.Create(Image1.Picture.Bitmap.Canvas.Handle);
        Graphics:=TGPGraphics.Create(Image1.Canvas.Handle);
        try
          Graphics.DrawImage(bmp,0,0);
          ・・・

上のコードで、TGPBitmap.Create( )の( )の中にナニを入れたらいいのか、この部分がどうしてもわからず、かなり苦しんだ。仕方がないからGDI+での保存はいったんあきらめて、Jpeg.Assign(Image1.Picture.Bitmap)の方法を試してみることにする。すると、思っていた通り、たった10枚の答案でも とぉーっても 遅いのだ。

数十枚(最大100枚程度を想定している)の答案の解答欄一つひとつについて、採点後に元の答案画像への書き戻しを行うことを考えると、Jpeg.SaveToFileメソッドはどうしても使う気になれない。保存方法は何がナンでも高速なGDI+を使いたい。

長くなったけど、これがビットマップを変換したい理由なのです。

1.わかったこと

いろいろ調べたり、思い出したりして、試行錯誤。

Image1.Picture.SaveToFile(画像の保存先Path);

GDI+とは何の関係もないけれど、上のコードも試したが、これはエラーに。

bmp:=TGPBitmap.Create(ココの書き方がわからない);
                  ↓
bmp:=TGPBitmap.Create(画像の保存先Pathを入れてみた);

エラーにはならないけど、変更したImageは当然保存されない。
(あたりまえです。元画像を読み込んで、そのまま上書きコピーしてるだけだもん。)

時間だけがゆっくりと過ぎて行く。
このプログラムの完成を待ってる人もいないけど、教えてくれる人もいない。

Google先生も調子悪そうだ。
何を尋ねても、わかってることしか表示してくれない。

僕は、DelphiのIDEではなく、NanaTreeの画面を「ぼー」っと見つめていた。

NanaTreeは、オープンソースのフリーソフト(階層化テキストエディター)で、僕はこのNanaTreeに、これまでに調べたことや、学んだことを備忘録として記録、資料化している。10年以上前から書き溜めてきた、お金では買えない、僕のたからものだ。

僕とDelphi(Object Pascal)の、これまでのすべてが、ここに詰まっている・・・。

ふと、下から3番目のノードが目に留まった。

VCL TBitmap から

なんだって、その先は? あわててノードをクリックする。
すると、目に飛び込んできたのは・・・

VCL TBitmap から GDI+ Bitmap へ

そのものズバリがあったー!!

「灯台元暗し」とはまさにこのこと。

オレ、調べてたんだ・・・。これまで使ったことがなかっただけで・・・。
少し前の記事になりますが、NanaTreeに記録した資料の元の記事がこちらです。

めもニャンだむ

junkiの日常雑感・日記・プログラミングのランダムメモ

VCL TBitmap から GDI+ Bitmap への転送を試した。

TGPBitmap クラスのコンストラクタのうち
constructor Create(stream:IStream;..);reintroduce;overload;
を使う。

まず、VCL TBitmap オブジェクトの内容を SaveToStream() によって、TMemoryStream にセーブする。次に、TStreamAdapter クラスのインスタンスを作成して、TMemoryStream オブジェクトを IStream に変換し、上記のコンストラクタで作成する。

URL:http://blog.livedoor.jp/junki560/archives/21910595.htmlより引用

junkiさん、ほんとうにありがとうございます。
TMemoryStreamを介してVCLビットマップのデータをGDI+に渡せるのですね!

さっそく書いたのが次のコード。

implementation

uses
  System.IOUtils,
  Winapi.GDIPAPI, Winapi.GDIPOBJ, Winapi.GDIPUTIL;

  //System.IOUtilsはPathから拡張子を取得するTPath.GetExtensionを使うために追加
  //GDIPAPI, GDIPOBJ はGDI+を利用した描画に資料するために必要
  //GDIPUTILを宣言すればGetEncoderClsid関数を利用してGUIDを取得できる

{$R *.dfm}

var
  ・・・
  //VCL TBitmapからGDI+ Bitmapへ変換
  Graphics:TGPGraphics;
  srcBMP:TBitmap;
  dstBMP:TGPBitmap;
  stream:TMemoryStream;
  //拡張子を取得する
  dotExt, strExt:string;
  //GetEncoderClsid関数の利用とTGUIDを使用するには、usesにWinapi.GDIPUTILが必要
  ImgGUID:TGUID;
begin

  //解答欄の矩形を元の答案画像へ書き戻し(内容が変更されたImageができる)
  Image1.Canvas.CopyRect(DestRect, Image2.canvas, SrcRect);

  //保存(VCL TBitmap -> GDI+ Bitmap)
  srcBMP:=TBitmap.Create;
  srcBMP.Width:=Image1.Width;
  srcBMP.Height:=Image1.Height;
  srcBMP.Assign(Image1.Picture.graphic);
  //データ受け渡し用のストリームを生成して保存
  stream:=TMemoryStream.Create;
  srcbmp.SaveToStream(stream);
  //保存GDI+のBMPを生成
  dstbmp:=TGPBitmap.Create(TStreamAdapter.Create(stream));
  //引数の指定をImage1.Picture.Bitmap.Canvas.Handleに変更
  //Graphics:=TGPGraphics.Create(Image1.Canvas.Handle);
  Graphics:=TGPGraphics.Create(Image1.Picture.Bitmap.Canvas.Handle);
  try
    Graphics.DrawImage(dstbmp,0,0);
    //拡張子を小文字に変換して取得(.XXX形式:Dotが付いている)
    dotExt:=LowerCase(TPath.GetExtension(画像の保存先Path));
    //JPEG & TIFFに対応する
    if dotExt='.jpg' then begin
      strExt:='jpeg';
    end else begin
      if dotExt='.tif' then begin
        strExt:='tiff';
      end else begin
        strExt:=StringReplace(dotExt,'.','',[rfReplaceAll, rfIgnoreCase]);
      end;
    end;
    //指定された拡張子を付けて保存
    if GetEncoderClsid('image/'+strExt, ImgGUID) >= 0 then
    begin
      //20220930訂正 bmp -> dstbmp
      //bmp.Save(ChangeFileExt(画像の保存先Path, dotExt), ImgGUID);
      dstbmp.Save(ChangeFileExt(画像の保存先Path, dotExt), ImgGUID);
    end;
  finally
    Graphics.Free;
    srcbmp.Free;
    dstBMP.Free;
    stream.Free;
  end;

end;

ちなみに uses は・・・(参考まで)

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants,
  System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs,
  Vcl.ExtCtrls, Vcl.StdCtrls, Vcl.Imaging.Jpeg, Vcl.Grids, Vcl.ComCtrls,
  System.IOUtils, System.Types, Winapi.ShellAPI, System.UITypes,
  System.IniFiles, System.Masks, Vcl.Filectrl, System.StrUtils,
  Ipl, OpenCV, Winapi.GDIPAPI, Winapi.GDIPOBJ, Winapi.GDIPUTIL,
  System.Win.ComObj, PythonEngine, Vcl.PythonGUIInputOutput, System.ImageList,
  Vcl.ImgList;

  //GDIPAPI, GDIPOBJ はGDI+を利用した描画に資料するために必要
  //usesにWinapi.GDIPUTILを宣言すればGetEncoderClsid関数を利用してGUIDを取得できる
  //System.Win.ComObjはExcelのxlsmファイルの読み書きに必要

TMemoryStreamを使えば、間に「ファイル」というかたちを入れないでBMPデータをGDI+のBitmapに渡すことができるなんて知りませんでした。これで扱う画像の数が何十枚あっても超高速!に保存処理を実行できます。

GDI+ の bitmap クラス(TGPBitmap)と graphics クラスを使って、画像ファイルから VCL の TBitmap へイメージを読み込むことはもうできてるから、これで画像ファイルからGDI+を使って高速にデータを読み取り、VCL Imageに表示し、必要な変更を加えたりした後、VCL TBitmapからGDI+のTGPBitmapにデータを移して高速に保存処理を行う、GDI+の利点を最大限に活用したプログラムが書けるようになった。

2.まとめ

(1)VCLのTBitmapのデータをまずTMemoryStreamに保存する。

var
  //VCL TBitmapからGDI+ Bitmapへ変換
  srcBMP:TBitmap;
  dstBMP:TGPBitmap;
  stream:TMemoryStream;
begin
  //保存(VCL TBitmap -> GDI+ Bitmap)
  srcBMP:=TBitmap.Create;
  srcBMP.Width:=Image1.Width;
  srcBMP.Height:=Image1.Height;
  srcBMP.Assign(Image1.Picture.graphic);
  //データ受け渡し用のストリームを生成して保存
  stream:=TMemoryStream.Create;
  srcbmp.SaveToStream(stream);
  ・・・
end;

(2)GDI+のTGPBitmapに入れるには TStreamAdapter.Create(stream) を使う。

  //GDI+のBMPを生成
  dstbmp:=TGPBitmap.Create(TStreamAdapter.Create(stream));
  //Graphics:=TGPGraphics.Create(Image1.Picture.Bitmap.Canvas.Handle);
  Graphics:=TGPGraphics.Create(Image1.Canvas.Handle);
  try
    Graphics.DrawImage(dstbmp,0,0);

(3)最後に使用したBitmapとMemoryStreamのオブジェクトを解放(Free)する。

  finally
    Graphics.Free;
    srcbmp.Free;
    dstBMP.Free;
    stream.Free;
  end;

3.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。