Delphi」カテゴリーアーカイブ

プログラミング言語Delphi関連のコーディングTips

PDFファイルの向きを変更したい!

複合機のスキャナーで A3 縦型の原稿をスキャンすると、A3 横置きの PDF ファイルとして出力・・・ つまり、縦型原稿は「横向きに回転された状態でデータ化」されます。

A3 縦置き原稿をそのまま(横向きにしないで)スキャンできる(一般ピーポーが使用できる)複合機は、僕が知る限り、多分ないんじゃないか・・・と思います。表示した際の見た目を A3 縦置きにしたい場合は、後で回転させれば事が足りるわけで、A3 縦でも横でもスキャンできるスキャナーは、普通に考えて、その必要性が感じられません。

ただ、その「回転のひと手間」が問題となる場合を除いては・・・

この PDF ファイルを「そのまま印刷して利用する」のであれば、もちろん何も問題など生じませんが、紙媒体でなく、そのまま電子データとして、例えば、重い障害のある方が iPad の Goodnotes で読み込んで利用するような場合、正しい方向に戻す(=回転させる)ひと手間が(その方から見れば余計に)必要です。

たかが「ひと手間」ですが、この「ひと手間」が「ある」と「ない」とでは、当該 PDF ファイルを受け取った方の「気持ち」は大きく違ってくるのではないでしょうか?

しかも、それが毎回のことになると・・・

そのような観点から、手軽に PDF ファイルの向きを変換して、その状態を保存できるプログラムがないかと探してみたのですが、Web 上にデータをアップロードする必要があったり、例えその問題はクリアできても通信環境によっては、変換に「ちょっと我慢できないくらいの時間」を要したり、はたまたローカル環境 Only で作業できたとしても「単に向きを変換」するだけの工程の手順が、正直、とても使いにくいと感じてしまったり・・・、

「ただ向きを変える」それだけのことで、探し当てたどの方法を使っても、こんなにイライラするのであれば、(自分の知識と技術だけで PDF ファイルの向きを変更するプログラムなど、間違っても書けるわけがありませんので)サードパーティー製(?)ソフトウェアを使わせていただいて PDF ファイルを好きな向きに変更して保存できるプログラムを、自分で書けばいいのではないか? と思った次第です。

幸いなことに、僕の隣には Delphi がいてくれます。無料で使える Python 環境でも、この目標は実現できるともちろん感じましたが、こと GUI を用いて、誰に対しても優しいプログラムを書くなら、やっぱり Delphi です。それより、何より、エラーが出ないプログラム書くなら、絶対 Delphi です。

そんな理由から、PDF ファイルの向きの変換に特化したプログラムを書くことにしました!

【もくじ】

1.PDFtk Server
2.GUI を設計
3.ShellExecute で回転を実行
4.Path の表示方法を改良
5.CreateProcess で回転を実行
6.回転の実際
7.プログラムのダウンロード
8.お願いとお断り

1.PDFtk Server

自分の技術では PDF ファイルの内容をどうこうすることは到底できません。中身がどうなっているのかも、以前、ちょっとだけ勉強したことはあるのですが、今は全部忘れました。でも、他人様のお作りになられたとても良い Tool がたくさん公開されています。PDF ファイル操作のユーティリティは多数ありますが、あれこれダウンロードして実際に試用させていただき、今回は PDFtk Server を使わせていただくことにしました。

この PDFtk Server ですが、プラットフォームは、Windows、macOS、Linux に対応しており、PDF ファイルのマージ・分割・回転・その他、幅広い PDF 操作をコマンドラインで実行できる ユーティリティであるとのこと。

この「コマンドラインで実行」する部分を「 GUI 」から実行できるように、Delphi の力を借りて、インターフェイスを作ります。ただ、問題はライセンスです。

PDFtk Server のライセンスは、GNU GPL バージョン2 なので、非商用の個人利用であれば無償で使用可能です。ただし、GPLの下では自分のソフトウェアに PDFtk Server を同梱して、そのソフトウェアを配布する場合には、ソースコードの公開義務などが適用されますので、今回作成するソフトウェアでは PDFtk Server が動作に必要なことを明示して、利用者の責任で PDFtk Server のダウンロード他、プログラムの動作に必要な環境の整備を行ってもらう形をとりたいと思います。

2.GUI と Path の表示

Delphi の VCL を使えば、(慣れも必要ですが)ほんの数分で次のようなグラフィカル・ユーザー・インターフェイス(GUI)の作成が可能です(図は、プログラム実行時のものです)。

デフォルトでは、Form は最大化して表示されるようにしましたので、手動で幅と高さを変更しました。


操作方法は、回転させたい PDF ファイルを選択して、回転方向を選ぶ(オプションボタンをクリックする)だけです。回転を実行するボタンをクリックしなくても、回転方向を選んだだけで即回転が実行される機能を実現するチェックボックスも用意しました。

【注意】このプログラムは、ページを指定しての回転は実行することができません。

当初、回転した状態のプレビューを表示するような方向性も考えたのですが、たった3パターンの回転しかありませんし、ファイルの保存にもそれほど時間はかからない(何百ページもあるような PDF 文書はそもそも想定外で動作確認しておりませんので、それが必要な場合は利用者様各自の責任で検証作業を行っていただき、その結果に応じました運用をお願い申し上げます)ので、やや乱暴かもしれませんが、プログラムはオプションボタンをクリックするごとに回転を実行し、ファイルを固有の名称で(上書き)保存してしまう仕様としました。

責任逃れというわけではありませんが、処理が継続中であることを示すため、回転処理の手続き実行中は、マウスカーソルが待機状態になるよう try 文を入れてあります。

※ このプログラムでは、諸般の事情から try 文の中で待機状態を設定しています。

procedure TForm1.Button2Click(Sender: TObject);
begin
  //カーソルを待機状態に変更
  Screen.Cursor := crHourGlass;
  try 
    //処理を実行
    ・・・
  finally
    //カーソルを元の状態に変更
    Screen.Cursor := crDefault;
  end;
end;

オプションボタンをクリックした際の手続きは・・・

procedure TForm1.RadioGroup1Click(Sender: TObject);
begin
  Button2.Enabled:=True;
  if CheckBox1.Checked then
  begin
    Button2.Click;
  end;
end;

「回転実行」ボタン(=Button2)をクリックしたことにしてしまっています。

3.ShellExecute で回転を実行

で、最初に書いた PDF ファイルの回転手続きは・・・

  private
    { Private 宣言 }
    strSrcPDFName, strDstPDFName:string;
    PDFTK_PATH:string;
    //長いPath文字列の途中部分を省略して表示(どのコントロールでも使える汎用版に書き直したコード)
    function FitPathWithMiddleEllipsis(const FilePath: string; AFont: TFont; MaxWidth: Integer): string;

procedure TForm1.Button2Click(Sender: TObject);
var
  InputFile, OutputFile, RotateArg, strCommandLine: string;
begin

  PDFTK_PATH := ExtractFilePath(Application.ExeName)+'pdftk.exe';

  if not FileExists(PDFTK_PATH) then
  begin
    StatusBar1.SimpleText := 'pdftk.exe が見つかりません';
    Exit;
  end;

  InputFile := strSrcPDFName;
  if not FileExists(InputFile) then
  begin
    StatusBar1.SimpleText := 'PDFファイルが存在しません';
    Exit;
  end;

  case RadioGroup1.ItemIndex of
    0: RotateArg := 'west';  // 270°
    1: RotateArg := 'south'; // 180°
    2: RotateArg := 'east';  // 90°
  else
    StatusBar1.SimpleText := '回転方向を選択してください';
    Exit;
  end;

  //OutputFile := strDstPDFName;
  OutputFile := ChangeFileExt(strDstPDFName, '') + '_'+RotateArg+'.pdf';
  strDstPDFName:= OutputFile;

  //コマンド生成
  strCommandLine := Format('"%s" "%s" cat 1-end%s output "%s"', [
    PDFTK_PATH, InputFile, RotateArg, OutputFile
  ]);

  //実行(ダブルクオートでコマンド全体を囲む)
  if ShellExecute(0, 'open', 'cmd.exe', PChar('/C "' + Command + '"'), nil, SW_HIDE) <= 32 then
  begin
    StatusBar1.SimpleText := 'pdftk の実行に失敗しました';
  end else begin
    //長いPath文字列の途中を省略して表示(Create時にStatusBar1.SimplePanel:=True;あり)
    StatusBar1.SimpleText := FitPathWithMiddleEllipsis(
      OutputFile, StatusBar1.Font, StatusBar1.ClientWidth);

    //Application.ProcessMessages;
    Sleep(500); // 0.5秒待機
    //プレビューにPDFを表示(WebBrowser経由)
    WebBrowser1.Navigate('file:///' + StringReplace(OutputFile, '\', '/', [rfReplaceAll]));
  end;
end;

0.5 秒ほど待機時間を入れて、プレビューが失敗しないようにしています。なので、ちょっと処理が重たい感じにはなっちゃってますが、自分的には許容範囲かと・・・。

4.Path の表示方法を改良

この手続きの中で「長い文字列の途中を省略して表示」する FitPathWithMiddleEllipsis 関数を使っていますが、これは前回の記事でご紹介したものをさらに改良したものです。

前回の記事で使った FitPathWithMiddleEllipsis 関数は、TEdit と TLabel のみに対応したものでしたが、今回は StatusBar1 の SimpleText に Path 文字列を表示したかったので、次のように設計を変更し、汎用性を高めた新しい FitPathWithMiddleEllipsis 関数を使いました。

どのように汎用性を高めたかと言うと、つまり、やりたいことは「コントロールの表示幅に合わせた省略文字列を作る」ことだけ!なので、必要なのは「表示フォントと表示幅」です。そのため引数で指定するのは TControl ではなく、(「表示したい文字列」に加え)「Canvas.Font」と「最大幅(ピクセル)」にして、これを(関数側で用意した Canvas へ)渡すようにすれば、コントロール種別への依存をなくせます。こうすればどんな UI コントロールにもこの関数を適用できます。

前回、この関数は単一の手続き内から呼び出せる形式としましたが、今回は複数の手続きから呼び出して利用できるよう、Form のメンバーとして作成しました。

  private
    { Private 宣言 }
    ・・・
    //長いPath文字列の途中部分を省略して表示(どのコントロールでも使える汎用版」に書き直したコード)
    function FitPathWithMiddleEllipsis(const FilePath: string; AFont: TFont; MaxWidth: Integer): string;

関数を Private 部に宣言して、Shift+Ctrl+Cを押して、次の内容を記述します。

function TForm1.FitPathWithMiddleEllipsis(const FilePath: string; AFont: TFont;
  MaxWidth: Integer): string;
var
  Bitmap: TBitmap;
  Canvas: TCanvas;
  Ellipsis: string;
  DirPart, FilePart, DrivePart: string;
  Parts: TArray<string>;
  i, LeftCount, RightCount: Integer;
  TestPath: string;

  function MeasureTextWidth(const S: string): Integer;
  begin
    Result := Canvas.TextWidth(S);
  end;
begin
  Bitmap := TBitmap.Create;
  try
    Canvas := Bitmap.Canvas;
    Canvas.Font.Assign(AFont);

    Ellipsis := '...'+PathDelim;

    //全部入る場合
    if MeasureTextWidth(FilePath) <= MaxWidth then
      Exit(FilePath);

    //ファイル部分とディレクトリ部分を分離
    FilePart := ExtractFileName(FilePath);
    DirPart  := ExtractFilePath(FilePath);
    DrivePart := ExtractFileDrive(FilePath);

    //パスのディレクトリ部分を分解(ドライブ部分は除外)
    Parts := DirPart.Substring(Length(DrivePart) + 1).Split([PathDelim], TStringSplitOptions.ExcludeEmpty);

    //初期状態は全部表示
    TestPath := IncludeTrailingPathDelimiter(DirPart) + FilePart;

    //左右を削っていくアプローチ
    LeftCount := 0; //先頭から残すディレクトリ数
    RightCount := Length(Parts); //後ろから残すディレクトリ数

    while (LeftCount < Length(Parts)) and (MeasureTextWidth(TestPath) > MaxWidth) do
    begin
      Inc(LeftCount);
      TestPath := DrivePart + PathDelim;

      if LeftCount > 0 then
        TestPath := TestPath + Parts[0] + PathDelim;

      if LeftCount < Length(Parts) then
        TestPath := TestPath + Ellipsis;

      if RightCount > 0 then
      begin
        for i := Length(Parts) - RightCount to High(Parts) do
          if i >= 0 then
            TestPath := TestPath + Parts[i] + PathDelim;
      end;

      TestPath := TestPath + FilePart;
      Dec(RightCount);
      if RightCount < 0 then RightCount := 0;
    end;

    //収まる長さで返す
    Result := TestPath;

    //それでも収まらなければ中央省略だけで返す
    if MeasureTextWidth(Result) > MaxWidth then
    begin
      Result := Copy(FilePath, 1, 1) + '...' + Copy(FilePath, Length(FilePath), 1);
    end;

  finally
    Bitmap.Free;
  end;
end;

で、TEdit に表示したい場合は・・・

Edit1.Text:= FitPathWithMiddleEllipsis(strSrcPDFName, Edit1.Font, Edit1.ClientWidth);

TStatusBar に表示したい場合は・・・

StatusBar1.SimpleText := FitPathWithMiddleEllipsis(
      strDstPDFName, StatusBar1.Font, StatusBar1.ClientWidth);

ちなみに、ここで使っている TStatusBar は、次のように FormCreate 手続きで SimplePanel := True に設定しています。

procedure TForm1.FormCreate(Sender: TObject);
begin
  StatusBar1.SimplePanel := True;
  //Formを最大化して表示(幅も最大化される)
  Form1.WindowState:=wsMaximized;
end;

SimplePanel := True としていない場合は・・・(この場合の動作は未確認です!)

StatusBar1.SimpleText := 
  FitPathWithMiddleEllipsis(strDstPDFName, StatusBar1.Font, StatusBar1.Panels[0].Width);

・・・でしょうか?

さらに(今回のプログラムでは使用していませんが)TLabel に表示したい場合は・・・

Label1.Caption := FitPathWithMiddleEllipsis(strDstPDFName, Label1.Font, Label1.Width);

この関数に「表示したい文字列」と「コントロールのFont」と「コントロールの幅」を引数として渡してあげれば(余程コントロールの幅が狭くならない限り)末尾のファイル名と拡張子が見えるように Path の途中を省略する形で、長い Path 文字列を表示してくれます。

実行してみました!

長い Path が途中 … と省略され、末尾のファイル名と拡張子部分は表示されています。


コントロールが異なると、パスの区切り文字の表記が¥マークと \(バックスラッシュ)になるのは、それぞれのコントールの Font の違いによるものと思われます・・・。

5.CreateProcess で回転を実行

プログラムの設計当初、PDF ファイルの回転処理は先に記載した通り ShellExecute で実行していたのですが、プログラムの動作検証を行った際、200ページ以上ある PDF ファイルを回転元ファイルに指定したら、回転に失敗してしまいました。

ShellExecute では、何が起きて回転に失敗してしまったのかが皆目わかりませんので、原因を究明すべく、回転処理の実行( PDFtk Server の呼び出しと実行部分)を CreateProcess に変更し、エラーが発生した場合はメッセージを PDFtk Server から取得して表示できるよう、次のようにプログラムを修正しました。

  private
    { Private 宣言 }
    //PDFtkでコマンドを実行
    function RunPdftk(const ExePath, Params: string; out OutputStr: string): Boolean;

procedure TForm1.Button2Click(Sender: TObject);
var
  InputFile, OutputFile, RotateArg: string;
  Params, Msg:string;
  strMsg: string;
begin

  //カーソルを待機状態に変更
  Screen.Cursor:=crHourGlass;

  //CreateProcessで実行

  try
    PDFTK_PATH := ExtractFilePath(Application.ExeName) + 'pdftk.exe';

    if not FileExists(PDFTK_PATH) then
    begin
      StatusBar1.SimpleText := 'pdftk.exe が見つかりません';
      Exit;
    end;

    InputFile := strSrcPDFName;
    if not FileExists(InputFile) then
    begin
      StatusBar1.SimpleText := 'PDFファイルが存在しません';
      Exit;
    end;

    case RadioGroup1.ItemIndex of
      0: RotateArg := 'west';  // 270°
      1: RotateArg := 'south'; // 180°
      2: RotateArg := 'east';  // 90°
    else
      StatusBar1.SimpleText := '回転方向を選択してください';
      Exit;
    end;

    OutputFile := ChangeFileExt(strDstPDFName, '') + '_' + RotateArg + '.pdf';
    strDstPDFName := OutputFile;

    //end%sが正しい(end %sとしないこと:半角スペースは不要)
    Params := Format('"%s" cat 1-end%s output "%s"', [
      InputFile, RotateArg, OutputFile
    ]);

    if RunPdftk(PDFTK_PATH, Params, Msg) then
    begin
      StatusBar1.SimpleText := FitPathWithMiddleEllipsis(
        OutputFile, StatusBar1.Font, StatusBar1.ClientWidth);

      if Msg.Trim <> '' then
      begin
        //ShowMessage('pdftk 出力: ' + Msg);
        //コピーできるメッセージを表示する
        strMsg:= 'pdftk 出力: ' + Msg + #13#10 + #13#10 +
        '"Copied to clipboard"';
        //Clipboard.AsText := strMsg;  // クリップボードにコピー
        Clipboard.AsText := Msg;
        //ShowMonospaceMessage(strMsg);
        //ShowMessage(strMsg);
        Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
      end;

      Sleep(500);
      WebBrowser1.Navigate('file:///' + StringReplace(OutputFile, '\', '/', [rfReplaceAll]));
    end
    else
    begin
      StatusBar1.SimpleText := 'pdftk の実行に失敗しました';
      if Msg.Trim <> '' then
        ShowMessage('エラー詳細: ' + Msg);
    end;
  finally
    //名前を元に戻しておく!
    strSrcPDFName := OpenDialog1.FileName;
    strDstPDFName := StringReplace(strSrcPDFName, 'SrcPDF', 'DstPDF', [rfReplaceAll, rfIgnoreCase]);
    Screen.Cursor := crDefault;
  end;

end;

function TForm1.RunPdftk(const ExePath, Params: string;
  out OutputStr: string): Boolean;
var
  SI: TStartupInfo;
  PI: TProcessInformation;
  SA: TSecurityAttributes;
  StdOutRead, StdOutWrite: THandle;
  Buffer: array[0..1023] of Byte;
  BytesRead: DWORD;
  OutputBytes: TBytes;
  CmdLine: string;
begin
  //Result := False;
  OutputStr := '';

  if not FileExists(ExePath) then
    raise Exception.CreateFmt('実行ファイルが見つかりません: %s', [ExePath]);

  ZeroMemory(@SA, SizeOf(SA));
  SA.nLength := SizeOf(SA);
  SA.bInheritHandle := True;

  if not CreatePipe(StdOutRead, StdOutWrite, @SA, 0) then
    RaiseLastOSError;
  try
    try
      SetHandleInformation(StdOutRead, HANDLE_FLAG_INHERIT, 0);

      ZeroMemory(@SI, SizeOf(SI));
      SI.cb := SizeOf(SI);
      SI.dwFlags := STARTF_USESHOWWINDOW or STARTF_USESTDHANDLES;
      SI.wShowWindow := SW_HIDE;
      SI.hStdOutput := StdOutWrite;
      SI.hStdError := StdOutWrite;

      ZeroMemory(@PI, SizeOf(PI));

      CmdLine := Format('"%s" %s', [ExePath, Params]);

      if not CreateProcess(
        nil, PChar(CmdLine), nil, nil, True,
        CREATE_NO_WINDOW, nil, nil, SI, PI) then
        RaiseLastOSError;

      CloseHandle(StdOutWrite);

      SetLength(OutputBytes, 0);
      repeat
        if not ReadFile(StdOutRead, Buffer, SizeOf(Buffer), BytesRead, nil) then
          Break;
        if BytesRead > 0 then
        begin
          //W1024 符号付型と符号無し型の演算による、オペランドの拡張」と警告される
          //SetLength(OutputBytes, Length(OutputBytes) + BytesRead);
          //対策1:BytesRead を明示的に Integer にキャストする
          SetLength(OutputBytes, Length(OutputBytes) + Integer(BytesRead));
          //対策2:Length を NativeInt にキャストする(より安全か?)
          //SetLength(OutputBytes, NativeInt(Length(OutputBytes)) + NativeInt(BytesRead));

          //W1024 符号付型と符号無し型の演算による、オペランドの拡張」と警告される
          //Move(Buffer[0], OutputBytes[Length(OutputBytes) - BytesRead], BytesRead);
          //対策1:BytesRead を明示的に Integer にキャストする
          Move(Buffer[0], OutputBytes[Length(OutputBytes) - Integer(BytesRead)], BytesRead);
        end;
      until BytesRead = 0;

      WaitForSingleObject(PI.hProcess, INFINITE);
      CloseHandle(PI.hProcess);
      CloseHandle(PI.hThread);

      if Length(OutputBytes) > 0 then
        OutputStr := TEncoding.UTF8.GetString(OutputBytes);

      Result := True;

    except
      on E: Exception do
      begin
        OutputStr := E.Message;
        Result := False;
      end;
    end;
  finally
    CloseHandle(StdOutRead);
  end;
end;

PDFtk Server の実行は、RunPdftk 関数側で行っています。

こうして CreateProcess での PDFtk Server の呼び出しに実行方法を変更し、何か問題が発生した場合には PDFtk Server 側からのエラーメッセージを取得して表示するようにできました。早速、先ほど回転に失敗した巨大な PDF ファイルを再度指定して、回転を実行してみました。

【わかったこと その①】

1つめは、問題の発生というより、正しくは、エラーの「真」の原因です。

“OWNER PASSWORD REQUIRED” と書いてあります・・・。

ぎぎぎ

( 効果音的歯軋り )

僕は、ただ、PDF を回転させようと・・・ 思っただけ・・・ なのですが、さんざん・・・ ほんとに 散々 苦労してたどり着いた 真実 は・・・

想像を遥かに絶するものでありました。

回転対象の PDF ファイルには、なんとパスワードが設定・・・ されていて当然でした。

・・・ と言うのも、もっともな理由があります。

正直に言うと、PDF ファイルを回転させるという今回のプログラムの動作検証に際し、手近に巨大な PDF ファイルが「なかった」ので、Web から簡単に入手できる 巨大 PDF ファイルはないか?と考え、思いをめぐらしたところ、すぐに思いついたのが「もう10年以上愛用しているプリンターの取扱説明書」でありました・・・ ので、さっそく愛用の 〇〇〇 社製プリンターの取扱説明書を Web から笑顔でダウンロード(何回目かなー?)して、この回転実験に使ったまではよかったのですが・・・、10年も使ったんだから許してもらえるだろうとわけのわかんないことを言い訳に、ラクしようとしたバチが当たったようです( 思いついた時は・・・ 実に!いい思いつきだと思ったのですが )。やはり、その動機が不純すぎました。

でも・・・ よく考えればこの「オーナーパスワード設定」があるのは当然です。取扱説明書、『なんでもできますー!!』みたいに勝手に書き換えられたら、それこそたいへんなコトになりますから・・・。

いやはや、これはもう・・・

手の出しようがないエラーでした!!

ま、原因がわかれば、わからないよりイイです(T_T)

ほとんど、七転八倒+四苦八苦 & いつも四面楚歌ばかり聞こえる人生(=ほぼ被害妄想)ですが、その中で学んだ最重要事項『転んでもタダでは起きるな』を、ここでもまた実践するのみです。

ぐやぐや なんじをいかんせん・・・

よくよく考えれば・・・(よくよく考えなくても・・・)

【わかったこと その②】

今回はタマタマ「手の出しようがないエラー」だったからよかった ♪ ものの、これが「手の出しようがある」エラーだった場合、OK をクリックする前に、エラーメッセージを暗記するか、「文字列」として写し取る(=メモする)必要があります。しかし、紙等に写し取るのは(自分的には)激しく面倒ですし、それより何より、このエラーメッセージはドラッグ等して、そのままクリップボードへコピーすることが、ShowMessage 関数の仕様上、出来ません!!

ちなみに、暗記はさらに無理です。

(そうだ。そのままコピペできたら・・・)

それこそ、全プログラマーの悲願です。

そう・・・

The universal wish of programmers.

それはまた・・・

The ultimate goal of all programmers.

そして、それこそは・・・

Every programmer’s long-cherished dream !

まさに、それを実現するべき時こそ、『今』です。

で、つくったのがコレ!

TMemo を Form に置いて Align := alClient としているだけですが・・・


もちろん、OK その他のボタンは、見渡すかぎり、どこにもありません。が・・・ ボタンがないかわりに・・・

好きな範囲を指定して、右クリックでコピーできます!

Delphi すごぉーイ!

( GUI が作れる全言語で、問題なく作成可能と思われますが・・・ )

OK ボタンなんて、どうせあってもただクリックするだけなんですから、その代替機能は Form 右上の「閉じる」ボタンにおまかせして、それよりエラーの原因テキストのコピペが出来れば、この際よしとしようではありませんか、皆さん!

僕は、もちろん「よし」としました☆

次が、その「エラーの原因メッセージをコピーできるようにする」コードです(表示する Form の幅と高さも自動で調整して表示するようにしてありますが、必要に応じて手動でさらに調整することも可能です)。

  private
    { Private 宣言 }
    strSrcPDFName, strDstPDFName:string;
    PDFTK_PATH:string;
    //PDFにオーナーパスワードがかかっているか調べる関数
    function IsOwnerPasswordRequired(const PdfPath, PdftkPath: string; out Output: string): Boolean;

procedure TForm1.Button1Click(Sender: TObject);
var
  OwnerPwdNeeded: Boolean;
  strMsg: string;
  strOutPut: string;

  //コピー可能なエラーメッセージを表示
  procedure ShowMonospaceMessage(const Msg: string);
  var
    Form: TForm;
    Memo: TMemo;
    CharWidth, CharHeight, MaxLineLength, LinesCount, I: Integer;
    MarginWidth, MarginHeight: Integer;
    Canvas: TCanvas;
  begin
    Form := TForm.Create(nil);
    try
      Form.Caption := 'The Real Truth Behind The Error!';
      Form.Position := poScreenCenter;

      Memo := TMemo.Create(Form);
      Memo.Parent := Form;
      Memo.Align := alClient;
      Memo.Lines.Text := Msg;
      Memo.ReadOnly := True;
      Memo.Font.Name := 'Consolas';
      Memo.Font.Size := 10;

      Form.HandleNeeded;
      Canvas := Form.Canvas;
      Canvas.Font.Assign(Memo.Font);

      CharWidth := Canvas.TextWidth('M');
      CharHeight := Canvas.TextHeight('M');

      MaxLineLength := 0;
      for I := 0 to Memo.Lines.Count - 1 do
        if Length(Memo.Lines[I]) > MaxLineLength then
          MaxLineLength := Length(Memo.Lines[I]);

      LinesCount := Memo.Lines.Count;

      //必要に応じて手動で Form の幅と高さを調整
      MarginWidth := 100;
      MarginHeight := 40;

      Form.ClientWidth := CharWidth * MaxLineLength + 10;
      Form.ClientHeight := CharHeight * LinesCount + 10;

      Form.Width := Form.ClientWidth + MarginWidth;
      Form.Height := Form.ClientHeight + MarginHeight;

      Form.ShowModal;
    finally
      Form.Free;
    end;
  end;

begin

  //ここで待機状態にしてもカーソルがすぐ元に戻ってしまう。
  //Screen.Cursor := crHourGlass;

  try
    ・・・ イロイロ設定 ・・・
    if OpenDialog1.Execute then
    begin
      ・・・ イロイロ設定 ・・・
      Screen.Cursor := crHourGlass;
      Application.ProcessMessages;
      try
        OwnerPwdNeeded := IsOwnerPasswordRequired(strSrcPDFName, PDFTK_PATH, strOutPut);
        if OwnerPwdNeeded then
        begin
          Screen.Cursor := crDefault;  // 必ず戻す
          strMsg := 'このPDFにはオーナーパスワードが設定されています。' + sLineBreak +
            strOutPut + sLineBreak +
            '処理を中止します。';
          ShowMonospaceMessage(strMsg);
          Exit;
        end;
      except
        on E: Exception do
        begin
          Screen.Cursor := crDefault;  // 必ず戻す
          strMsg := 'エラー: ' + E.Message;
          ShowMonospaceMessage(strMsg);
          Exit;
        end;
      end;
      ・・・ イロイロ設定 ・・・
    end;

  finally
    Screen.Cursor := crDefault;
  end;
end;

function TForm1.IsOwnerPasswordRequired(const PdfPath, PdftkPath: string; out Output: string): Boolean;
var
  CmdLine: string;
begin
  Result := False;

  if not FileExists(PdfPath) then
    raise Exception.Create('PDFファイルが存在しません。');

  if not FileExists(PdftkPath) then
    raise Exception.Create('pdftk.exeが見つかりません。');

  //pdftkのdump_dataコマンドでPDF情報を取得
  CmdLine := Format('"%s" "%s" dump_data', [PdftkPath, PdfPath]);

  if RunCommandAndGetOutput(CmdLine, Output) then
  begin
    //オーナーパスワードが必要ならエラーメッセージに含まれることが多い
    if Pos('OWNER PASSWORD REQUIRED', UpperCase(Output)) > 0 then
      Result := True;
  end
  else
    raise Exception.Create('pdftkの実行に失敗しました。');
end;

まぁ、イロイロありましたが、エラーメッセージだけはコピペできるようになりました☆

てか、ここでふと思ったのですが、
何もそこまでしなくても、Clipboard.AsText を使って、単に

uses
  Vcl.Clipbrd;

  strMsg := 'このPDFにはオーナーパスワードが設定されています。' + sLineBreak +
    strOutPut + sLineBreak +
    'クリップボードにエラーの内容を送信して、処理を中止します。';
  Clipboard.AsText:= strMsg;        // クリップボードにコピー
  ShowMessage(strMsg);
  Exit;

・・・としておいて、これを実行すれば、


「OK」をクリックして、メモ帳に貼り付けてみました。

この仕様の方がより親切でしょうか?

より、短く・・・

Clipboard.AsText := strOutPut;

なら・・・

エラーの核心部分のみ表示することも可能かと。


Delphi 12 Athens 以降では、 MessageDlg 関数で「警告」と「エラー」以外のアイコンが表示されなくなってしまいました。この Blog の過去記事にも書きましたが、これは Microsoft 社の UI ガイドライン変更に準拠した仕様変更によるものらしいのですが、ある日、突然、それまでずっと使い続けてきた MessageDlg 関数から「 i 」などのアイコンが消えてしまったあの時の衝撃、何か大切なものを失ったような、たまらない寂寥感が胸に広がったことを今も MessageDlg という文字を見る度に思い出します。

別に Microsoft 様の UI ガイドライン変更に反旗を翻すというような大それた意図はなく、ただメッセージにアイコンを表示したくてたまらなかった僕は必死で MessageDlg 関数の代替手段を探し、Application.MessageBox 関数がまだ生きていることを知って狂喜乱舞したのでした。・・・なので、最終的には、やっぱりいちばんのお気に入り Application.MessageBox 関数で・・・

  strMsg := 'このPDFにはオーナーパスワードが設定されています。' + sLineBreak +
    strOutPut + sLineBreak +
    'クリップボードにエラーの内容を送信して、処理を中止します。';
  Clipboard.AsText := strOutPut;
  Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);

だから、これが、僕の本当の理想かな?

古い人間ですので、Win32 API が好きなんです。


・・・

最終的にと言いながら、独自性にこだわって、それでもやっぱり TMemo も 「コピー」ボタンも必要なんだという場合には・・・

    { Private 宣言 }
    strMsg: string;
    procedure GetErrorMessage(Sender: TObject);

implementation

uses
  Vcl.Clipbrd;

{$R *.dfm}

procedure TForm1.Button2Click(Sender: TObject);
var
  dlg: TForm;
  btnCopy, btnClose: TButton;
  memoMsg: TMemo;
begin

  //エラーメッセージ
  strMsg := 'Error: Failed to open PDF file:' + sLineBreak +
            'C:\Users\XXX\Win32\Release\SrcPDF\TEST.PDF' + sLineBreak +
            'OWNER PASSWORD REQUIRED, but not given (or incorrect)' + sLineBreak +
            'Done.  Input errors, so no output created.';

  dlg := TForm.Create(nil);
  try
    dlg.Caption := 'メッセージ';
    dlg.Width := 400;
    dlg.Height := 240;
    dlg.Position := poScreenCenter;

    memoMsg := TMemo.Create(dlg);
    memoMsg.Parent := dlg;
    memoMsg.Left := 20;
    memoMsg.Top := 20;
    memoMsg.Width := dlg.ClientWidth - 40;
    memoMsg.Height := 120;
    memoMsg.ReadOnly := True;
    memoMsg.ScrollBars := ssVertical;
    memoMsg.Lines.Text := strMsg;

    btnCopy := TButton.Create(dlg);
    btnCopy.Parent := dlg;
    btnCopy.Caption := 'コピー';
    btnCopy.Left := 80;
    btnCopy.Top := 160;
    btnCopy.OnClick := GetErrorMessage;

    btnClose := TButton.Create(dlg);
    btnClose.Parent := dlg;
    btnClose.Caption := '閉じる';
    btnClose.Left := 200;
    btnClose.Top := 160;
    btnClose.ModalResult := mrClose;

    dlg.ShowModal;
  finally
    dlg.Free;
  end;
end;

procedure TForm1.GetErrorMessage(Sender: TObject);
begin
  Clipboard.AsText := strMsg;
end;

上のようにすれば・・・


やっぱりアイコンがないと・・・ という場合は、さらに

uses
  Vcl.Clipbrd, Vcl.ExtCtrls;

procedure TForm1.Button3Click(Sender: TObject);
var
  dlg: TForm;
  btnCopy, btnClose: TButton;
  memoMsg: TMemo;
  imgIcon: TImage;
begin
  strMsg := 'Error: Failed to open PDF file:' + sLineBreak +
            'C:\Users\XXX\Win32\Release\SrcPDF\TEST.PDF' + sLineBreak +
            'OWNER PASSWORD REQUIRED, but not given (or incorrect)' + sLineBreak +
            'Done.  Input errors, so no output created.';

  dlg := TForm.Create(nil);
  try
    dlg.Caption := 'エラー';
    dlg.Width := 420;
    dlg.Height := 260;
    dlg.Position := poScreenCenter;

    //アイコン追加
    imgIcon := TImage.Create(dlg);
    imgIcon.Parent := dlg;
    imgIcon.Left := 20;
    imgIcon.Top := 20;
    imgIcon.Width := 32;
    imgIcon.Height := 32;
    imgIcon.Picture.Icon.Handle := LoadIcon(0, IDI_ERROR); // Windows標準エラーアイコン

    //メモ表示
    memoMsg := TMemo.Create(dlg);
    memoMsg.Parent := dlg;
    memoMsg.Left := imgIcon.Left + imgIcon.Width + 10;
    memoMsg.Top := 20;
    memoMsg.Width := dlg.ClientWidth - imgIcon.Width - 50;
    memoMsg.Height := 120;
    memoMsg.ReadOnly := True;
    memoMsg.ScrollBars := ssVertical;
    memoMsg.Lines.Text := strMsg;

    //コピーボタン
    btnCopy := TButton.Create(dlg);
    btnCopy.Parent := dlg;
    btnCopy.Caption := 'コピー';
    btnCopy.Left := 80;
    btnCopy.Top := 160;
    btnCopy.OnClick := GetErrorMessage;

    //閉じるボタン
    btnClose := TButton.Create(dlg);
    btnClose.Parent := dlg;
    btnClose.Caption := '閉じる';
    btnClose.Left := 200;
    btnClose.Top := 160;
    btnClose.ModalResult := mrClose;

    dlg.ShowModal;
  finally
    dlg.Free;
  end;
end;

上のコードを実行すれば・・・


なんだか、記事の内容が本来意図した方向とずいぶん逸れてしまいました。なので、このへんで元に戻ります。

6.回転の実際

はるか上の方で、すでに示していますが、実際に PDF の回転を行った様子です。
結論から言えば、「ただ、コレがしたかった・・・ だけ」なのですが、今回もまた、なんか凄くたくさんのことに出会った気がします・・・。

最初に、左へ回転した場合です。


次に、上下反転です。


最後に、右へ回転した場合です。


連続して回転させることは、このプログラムでは考えておりません。・・・と言うか、このプログラムの仕様上、その必要性がありません。また、元の PDF ファイルは、これまたプログラムの仕様上、無加工で Src フォルダに残っていますので、「元に戻す」処理も、このプログラムには、もちろんありません。

7.プログラムのダウンロード

あくまでも自分用に作ったものですが、PDFtk Server 関連のファイルを除いたプログラム一式を以下からダウンロードできます。なお、ダウンロードとご使用にあたっては、免責事項及び使用条件への同意が必要です。免責事項及び使用条件の詳細は付属の License.txt 及び Readme.txt をご覧ください。

また、動作には PDFtk Server が必要です。

PDFtk Server のダウンロードサイト :https://www.pdflabs.com/tools/pdftk-server/

上記 Web サイトより、ダウンロードした pdftk_server-2.02-win-setup.exe をダブルクリックして起動すると、デフォルト設定では C:\Program Files (x86)\PDFtk Server にインストールが行われます。

PDFtk Server の利用にあたり、動作やライセンス内容についての詳細は、必ず公式サイトおよびライセンス文書をご確認ください。

インストール後、C:\Program Files (x86)\PDFtk Server\bin にある pdftk.exe を PDF_Rotator.exe があるPDF_Rotator フォルダ内へコピーしてください。

【プログラムが正常動作するために必要なフォルダ構成です】

PDF_Rotator\
 ├ DstPDF
 ├ SrcPDF
 ├ PDF_Rotator.exe
 ├ pdftk.exe
 ├ License.txt
 └ Readme.txt

PDF_Rotator フォルダは、下記リンク先からダウンロードできる PDF_Rotator.zip を展開すると生成されます。

回転させたい PDF ファイルは必ず SrcPDF フォルダ内に準備してください。なお、プログラムは起動時に SrcPDF フォルダ及び DstPDF フォルダの有無を調査し、それらが存在しない場合は exe と同じ階層に自動的に SrcPDF フォルダ及び DstPDF フォルダを生成します。予めご承知おきください。

PDF_Rotator.exe をダブルクリックして起動後、回転させたい PDF ファイルを選択し、回転方向を指定してください。デフォルト設定では、回転方向の指定と同時に PDF ファイルの回転と保存が行われます。回転後の PDF ファイルは、左へ回転した場合は「元のファイル名_west.pdf」、上下反転した場合は「元のファイル名_south.pdf」、右へ回転した場合は「元のファイル名_east.pdf」のように北を上とした場合の方角が付加されて DstPDF フォルダ内に保存されます。

なお、プログラムの初回起動時には、Windows Defender SmartScreen による警告画面が表示されます。この警告画面に関する詳細は、当 Blog の次の過去記事をご参照ください。

8.お願いとお断り

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

また、pdftk.exe 他、PDFtk Server 関連のファイルを同梱した状態での PDF_Rotator.exe の再配布を禁じます。PDF_Rotator.exe を再配布される場合は、PDFtk Server 関連のファイルはすべて削除し、PDF_Rotator.zip に添付した License.txt 及び Readme.txt を必ず添付してください。

MTSファイルをMP4に変換したい!

仕事で SD カードに保存した MTS ファイルを扱う機会が増えました。使い終わったら不要なファイルは即消去しますが、後日再び利用するものは、わりと自由に使える NAS に MTS 形式のままコピー(=保存)していた ・・・ のですが、さすがに数が増えてくると( このままでいいのかなー )みたいな気が。

ファイルサイズが 10 GB を超えてくると、SD カードから NAS へコピーするにしても時間がかかるし、再利用する際に使うのはノート PC なので、ファイル容量に見合うほど高画質でなくても構わないはずですし、それより何より、休日、何もすることがなくてヒマ なので、MTS 形式の動画ファイルを より容量の小さい MP4 形式に変換するプログラムを書いてみることにしました。

てか、何よりも、ほんとはずっと、前から、やってみたかった・・・こと。なので・・・ *(^_^)*♪

動作には別途 FFmpeg.exe が必要です。
( FFmpeg.exe は MTStoMP4.zip に同梱しておりません)

【もくじ】

1.MTS って何?
2.MP4 に変換
3.動作確認用のコード
4.プログレスバーも表示
5.文字列の一部を省略(…)して表示
6.プログラムのダウンロード
7.まとめ
8.お願いとお断り

1.MTS って何?

まずは、ここから勉強します。

ソニー・パナソニックが共同開発した高画質動画を効率よく記録するための仕様がAVCHD(Advanced Video Codec High Definition)で、この方式で記録された動画ファイルの実体が MTS ファイルなんだそうです。

MTS は、MPEG Transport Stream の略で、主にビデオカメラで録画した高画質動画を保存するためのファイルとして利用されており、このファイルの映像部分で使用される圧縮方式(コーデック)が高画質かつ高圧縮の H.264 であるとのこと。

一言で言うと、MTS は、H.264で圧縮された動画を保存する「入れ物(ファイル形式)」のひとつで、映像の他に音声や字幕などの情報も一緒に保存されているファイルコンテナ。

ファイルコンテナと言えば思い浮かぶのは、JR の貨物列車です。

MTS や MP4 の詳しい仕組みについては、まったくわかりませんが、貨物列車に様々な色や形のコンテナが積載されているように、映像や音声を各ファイルそれぞれの方法で乗っけていることだけは理解できます。その載せ方の工夫次第で、貨物の重さや列車の長さが変わってくるということなのでしょう。

2.MP4 に変換

もちろん、わざわざ自分でプログラムなんて書かなくても、MTS ファイルを MP4 ファイルに変換する方法はいくらでもあります。有名なところでは、無料で使える「HandBrake」がありますし、さらに身近なところでは、Windows10 / 11のフォトでも変換できるようです。

僕は HandBrake は実際に使ったことがありますが、フォトでの変換は試したことがありません。

今回やってみたかったのは、これまた有名な「 FFmpeg 」(動画処理のツール)を使ったファイルコンテナの変換プログラムの作成です。前にも書きましたが、どうせヒマだし、FFmpeg は以前にもいろんなところで使ったことがあって、「期待通りに動作した記憶しかない」ので、今回もきっとうまく行く♪と思えたことと、それより何より Delphi で「なんかしてないと落ち着かない」のです(=これはきっと、僕の心の病です)。

3.動作確認用のコード

最初の一歩は、FFmpeg のダウンロードと準備。

ダウンロードサイト : https://ffmpeg.org/download.html

上記リンク先の「Get packages & executable files」にある Windows のマーク上をポイント(or クリック)すると表示される「Windows builds from gyan.dev」のリンク先ページからダウンロードすればよいのですが、いろいろな FFmpeg があって迷いました。

まず、「git master builds」と「release builds」いずれを選択すればいいのか?

今回の使用目的は、最新の機能のテストとか、そんなんじゃなくて、とにかく安定して動作するバージョンが欲しいので、「release builds」の方を選択。

で、latest release を見ると、選択肢が4つ。

・ffmpeg-release-essentials.7z
・ffmpeg-release-essentials.zip
・ffmpeg-release-full.7z
・ffmpeg-release-full-shared.7z

Essentials は、Win7 以降の OS に対応した最小限の機能のみを搭載した軽量な FFmpeg で、Full は 全機能搭載のWin10 以降用、Full Shared は、Full の DLL 版とのこと。

ここで重要になってくるのがライセンスです。

FFmpeg は、ビルド種別によりそのライセンスが異なります。最もライセンス的に無難な選択は、LGPL v2.1+ が適用される「Release Essentials Build(LGPLビルド)」だと思います。

LGPL v2.1+は、「 FFmpeg を改変せずにそのまま使い、アプリとは動的リンク( exe を呼び出す方式)で接続( = ユーザーが FFmpeg を差し替えられるように設定)し、FFmpeg のライセンス表記を Readme.txt 等に表示」すれば商用利用も可能で、クローズドソースでも OK というライセンス形態なので、今回作成したいプログラムでは、勉強を兼ね、公開に耐えうる仕様とするため「ffmpeg-release-essentials.zip」をダウンロードして、アプリケーションの exe と同じ場所にffmpeg という名前のフォルダを作成し、zip ファイルを展開した内容を一式コピペして、プログラムから FFmpeg.exe を直接呼び出して利用したいと思います。

具体的なフォルダとファイルの構成(位置関係)は、次の通りです。

MTStoMP4\
 ├ Dst
 ├ FFmpeg\bin\ffmpeg.exe
 ├ Src
 ├ Readme.txt
 └ MTStoMP4.exe

早速、次の GUI を Delphi で作成しました。

「テスト」ボタンは動作確認用(動作確認後に削除する予定)。


実際には「変換実行」ボタンをクリックするとプログレスバーを表示して変換作業の進捗状況を可視化する予定なのですが、そこに行きつく前に FFmpeg の動画変換機能を使えるようにならないといけません。なので、取り敢えず、「テスト」ボタンを準備し、そのクリックイベントの中で、コマンドプロンプトを表示して変換の動作確認を行えるようなテスト用のプログラムを書いてみます。

まず、変換元のファイルを選択する部分(ファイル選択ボタンをクリックした場合)の手続きの作成から始めました。

Form 上に TOpenDialog をひとつ準備して、次のコードを書きます。変換元の MTS 形式の動画ファイルは、exe と同じ場所に Src という名前のフォルダを作成して、そこに保存しておく前提です。また、変換先のファイルは、こちらも exe と同じ場所に Dst という名前のフォルダを作成し、そちらへ拡張子に mp4 を指定して書き出すよう、予め、変換先ファイルパスとして準備( Label のキャプションとして表示)しておきます。このように設定したのは、FFmpeg は変換先ファイルの拡張子を見て自動的に出力フォーマットを判別する仕様だからです。

procedure TForm1.Button1Click(Sender: TObject);
begin
  OpenDialog1.Filter := '動画ファイル (*.MTS;*.mp4;*.avi;*.mkv)|*.MTS;*.mp4;*.avi;*.mkv|すべてのファイル (*.*)|*.*';
  OpenDialog1.Title := '動画ファイルを選択してください';
  OpenDialog1.InitialDir:=ExtractFilePath(Application.ExeName)+'Src';
  if OpenDialog1.Execute then
  begin
    Edit1.Text:=OpenDialog1.FileName;
    Label1.Caption:=ExtractFilePath(Application.ExeName)+
      'Dst\'+ChangeFileExt(ExtractFileName(OpenDialog1.FileName), '')+'.mp4';
  end;
end;

上記コードの動作を確認します。実行時の画面は次の通りです。

思った通りに動作しました☆
まぁ、ここはそんなに難しいところではありませんが、「幸先よし」と感じます。


ただ、ちょっと気になったのが変換元ファイルの Path 文字列が長くて TEdit からはみ出している部分です。ここは後からなんとかしたいと思います。

変換に際して指定できるパラメータは3つです。

1つめが CRF 値です。CRF は Constant Rate Factor の略で、これは動画の品質を一定に保ちつつ、ファイルサイズを自動的に調整するために設定するパラメータで、0 ~ 51 までの数値で指定します。数値が小さいほど高画質ですがファイルサイズも大きくなり、数値が大きいほど低画質になりますがファイルサイズは小さくなります。デフォルトで使用する値は 23 のようです。

2つめがプリセット指定で、これは FFmpeg の H.264( libx264 )エンコーダーで使われる「圧縮処理の速度と効率のバランス」を設定するパラメータです。エンコードの速度(=処理時間)と圧縮効率(=ファイルサイズ)のトレードオフを制御します。

ultrafast → superfast → veryfast → faster → fast → medium(デフォルト) → slow → slower → veryslow → placebo の 10 段階の設定が可能で、より右側のパラメータほど処理速度が増加し、ファイルサイズは小さくなります(逆に言えば、左側のパラメータほど処理速度が速く、ファイルサイズは大きくなります)。すべてを試すヒマはないので、取りあえず medium で動作確認することにします。

3つめが AudioBitrate で、これは1秒あたりの音声のデータ量を指定する値です。もちろん、値が大きいほど音質が良くなりますが、ファイルサイズも大きくなります。単位は kbps(キロビット毎秒)です。

で、様々な問題点をクリアしながら最終的に完成したのが次のコードです。動作状況の確認が目的なので、ShellExecute 関数の引数には /K を指定してコマンドプロンプトが自動で閉じないようにしています。また、上記3つのパラメータはわかりやすさを優先し、コード内で直接「値」を指定しています。

procedure TForm1.ButtonXClick(Sender: TObject);
var
  FFmpegPath, Command: string;
  AudioBitrate, VideoCRF: Integer;
  strPreset: string;
  InputFile, OutputFile: string;
begin
  //明示的にエスケープ('ffmpeg\bin\ffmpeg.exe' の中の \b が「バックスペース」として扱われる危険を排除)
  FFmpegPath :=
    IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName)) + 'ffmpeg\\bin\\ffmpeg.exe';

  //もしくは PathDelim を使う
  //FFmpegPath := IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))
  //            + 'ffmpeg' + PathDelim + 'bin' + PathDelim + 'ffmpeg.exe';

  //ファイルパスを安全な形式(8.3形式)で取得
  InputFile := ExtractShortPathName(Edit1.Text);
  //ExtractShortPathName関数は存在しないファイルを指定すると空文字列を返すことに注意する。
  //変換先の mp4形式の動画ファイルはプログラムの実行後に生成され、実行時には存在しない!
  OutputFile := Label1.Caption;

  //CRF(0~51)
  VideoCRF := 23;

  //プリセット(ultrafast, superfast, medium, slow, veryslow など)
  strPreset := 'slow';

  //数値の変数(単位はkbps)
  AudioBitrate := 192;

  //-ac 2 を追加して、5.1ch → 2ch ステレオ に変換して出力
  //5.1ch(サラウンド)をうまく処理できない場合があるようです。
  //この場合、変換された mp4ファイルが無音になってしまいます(ハマりました)。
  Command := Format(
    '"%s" -i "%s" -map 0:v -map 0:a -vcodec libx264 -acodec aac -ac 2 -b:a %dk -crf %d -preset %s -y "%s"',
    [FFmpegPath, InputFile, AudioBitrate, VideoCRF, strPreset, OutputFile]
  );

  //コマンドはダブルクォートで囲む(コマンド全体を1つの文字列として渡す)
  ShellExecute(0, 'open', 'cmd.exe', PChar('/K "' + Command + '"'), nil, SW_SHOWNORMAL);

end;

特に、最後の ShellExecute 関数で、Command 部分をダブルクォートで囲む処理を忘れると・・・

My PC 環境では、上のようなエラーが発生します。


原因がわかってしまえば( なぁーんだ )みたいな問題ですが、(私は)なかなか原因がわからなくて、解決までにちょっと時間を要しました。Command 部分をダブルクォートで囲むのを忘れてもコンパイルは通るので、ここはコーディング上の要注意部分です。

また、実行パスに全角文字が含まれている場合でも動作することを確認しましたが、より安定した動作を実現するためには CreateProcess を使って直接実行した方が良いはずです。なので、本番の処理では CreateProcess を使う方法をとることにします(加えて、FFmpeg の処理の進捗状況をプログレスバーに表示する処理も実装しなければいけません)。

CreateProcess を使った場合の、単なる動作確認用コードは、次の通りです。

procedure TForm1.ButtonXXClick(Sender: TObject);
var
  FFmpegPath, CmdLine, InputFile, OutputFile: string;
  AudioBitrate, VideoCRF: Integer;
  strPreset: string;
  StartInfo: TStartupInfo;
  ProcInfo: TProcessInformation;
begin
  //明示的にエスケープ('ffmpeg\bin\ffmpeg.exe' の中の \b が「バックスペース」として扱われる危険を排除)
  FFmpegPath := IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName)) + 'ffmpeg\\bin\\ffmpeg.exe';

  //入力・出力ファイル
  InputFile := ExtractShortPathName(Edit1.Text);
  OutputFile := Label1.Caption;

  if (InputFile = '') or (OutputFile = '') then
  begin
    ShowMessage('入力または出力ファイルのパスが無効です');
    Edit1.SetFocus;
    Exit;
  end;

  //エンコード設定
  VideoCRF := 23;
  strPreset := 'slow';
  AudioBitrate := 192;

  //コマンドライン
  CmdLine := Format(
    '"%s" -i "%s" -map 0:v -map 0:a -vcodec libx264 -acodec aac -ac 2 -b:a %dk -crf %d -preset %s -y "%s"',
    [FFmpegPath, InputFile, AudioBitrate, VideoCRF, strPreset, OutputFile]
  );

  ZeroMemory(@StartInfo, SizeOf(StartInfo));
  StartInfo.cb := SizeOf(StartInfo);
  StartInfo.dwFlags := STARTF_USESHOWWINDOW;
  StartInfo.wShowWindow := SW_SHOW;  // 非表示にするなら SW_HIDE

  ZeroMemory(@ProcInfo, SizeOf(ProcInfo));

  if not CreateProcess(
    nil,               //アプリケーション名(CmdLine 内に含まれるので nil)
    PChar(CmdLine),    //コマンドライン(実行ファイルと引数を含む)
    nil, nil,          //セキュリティ属性
    False,             //ハンドル継承
    CREATE_NEW_CONSOLE,  //新しいコンソールで実行
    nil,               //環境変数
    nil,               //カレントディレクトリ
    StartInfo,         //スタートアップ情報
    ProcInfo           //プロセス情報(プロセスIDなど)
  ) then
  begin
    ShowMessage('CreateProcess に失敗しました: ' + SysErrorMessage(GetLastError));
    Exit;
  end;

  //処理の終了まで待ってから後始末&その他の処理を実行する場合は有効化する
  //ただし、有効化すると、タイトルバーに「応答なし」と表示されるなど動作が重くなる気が。
  //FFmpegに処理を渡すだけなら待機不要とした方が軽快動作?
  //WaitForSingleObject(ProcInfo.hProcess, INFINITE);

  //後始末
  CloseHandle(ProcInfo.hProcess);
  CloseHandle(ProcInfo.hThread);
end;

【ご注意願います】

もくじの「5.文字列の一部を省略(…)して表示」の処理を実行(設定)した場合は、Edit1.Text や Label1.Caption の値を参照せず、グローバル変数に保存した省略のない Path 文字列を参照するようにコードを修正する必要があります(参考コードは後述)。どうか、ご注意ください。

4.プログレスバーも表示

FFmpegは実行中に、標準出力(stdout)や標準エラー(stderr)にログを出力するので、このログを利用して処理の進捗状況(フレーム数、時間、速度など)等を取得することが可能です。

なので、Delphi で CreateProcess を利用して FFmpeg を起動する際に、標準出力・標準エラーをパイプで受け取るように設定すれば、ログをリアルタイムで取得でき、これに基づいてプログレスバーで処理の進捗状況を表示することができます。

PC に詳しい方なら次のような画面が表示され、より詳細な変換処理の進捗状況が見えた方が安心かもしれませんが、この背景が真っ黒な画面にあまり馴染みのない方にとっては、この画面よりもプログレスバーに進捗状況が表示されるという、より単純な GUI による表示の方が安心できるのではないでしょうか?(私は、本質的に難しいことが苦手なので、そのように感じてしまいます)

CreateProcess でファイルコンテナの変換を実行中
(StartInfo.wShowWindow := SW_SHOW;)


なので、動作確認後は StartupInfo.wShowWindow := SW_HIDE を指定し、コマンドプロンプト画面は非表示に設定、その代わりにプログレスバーを表示して、変換処理の進捗状況を表示します。

(変換処理の進捗状況を表示する方法は後述)

StatusBar に ProgressBar を埋め込む方法もありますが・・・
それはスペース的に余裕のない場合のお話。


今回の場合、「終了」ボタンと「変換実行」ボタンの間が空いていますので、ProgressBar はここに設置することにします。

さて、問題は進捗状況を表示する機能の実装です。

調べて見ると、FFmpeg は進行状況(Duration: …, time=…など)を 標準エラー(stderr)に出力する仕様のようでした。この進行状況の出力先が標準エラー(stderr)となっている理由は、 FFmpeg は「標準出力(stdout)」を、エンコード結果(映像などのバイナリ)をパイプ出力する用途にも使うため、ここにログを混ぜると混乱が生じる恐れがあり、ログ類は意図的にすべて stderr に分離して出力する仕様となっているとのことでした。

また、デフォルト設定のままログを出力すると多くの情報が入り混じって流れてくるので、経過時間等の取得したい情報が探しにくくなってしまいます。

そこで、出力されるログを行単位で処理し、進捗状況を表示するためのキーワードを正確に検出できるようにしました。

具体的には、FFmpeg に渡すコマンドラインの中で -progress pipe:1 を指定して意図的にログ出力が標準出力( stdout )へ為されるようにして、ここに key=value 形式で送られてくるログ出力中の「out_time=」という文字列を探して経過時間の情報を得ています。

上記内容を実装する具体的手順です。

まず、パラメータ設定を含めて FFmpeg に渡すコマンドラインを作成する部分です。

  FFmpegPath:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName)) + 'ffmpeg\\bin\\ffmpeg.exe';

  //CRF(0~51)
  //VideoCRF:= 23;
  VideoCRF:=StrToInt(ComboBox1.Text);

  //プリセット
  //strPreset:= 'slow';
  strPreset:= ComboBox2.Text;

  //音声の処理
  //AudioBitrate:= 192;
  AudioBitrate:= StrToInt(ComboBox3.Text);

  //-ac 2 を追加して、5.1ch → 2ch ステレオ に変換して標準出力(stdout)に出力
  //InputFile, OutputFile はこの手続きを呼び出す際に指定
  CmdLine:= Format(
    '"%s" -i "%s" -map 0:v -map 0:a -vcodec libx264 -acodec aac -ac 2 -b:a %dk -crf %d -preset %s -y -progress pipe:1 "%s"',
    [FFmpegPath, InputFile, AudioBitrate, VideoCRF, strPreset, OutputFile]
  );


動作状況を確実に確認するため、Form に TMemo を1つ追加して、この TMemo にログ出力内容を表示してみます。次は、そのテストを行った際の画像です。

実際のプログラムでは TMemo への出力は行いませんが・・・。


ここで記録されたログの最初の方に MTS ファイルの再生(録画)時間が出力されています。実際に取得したログを下に示します。Duration 部分が再生(録画)時間です。

Input #0, mpegts, from 'C:\Users\XXX\Win32\Release\Src\SampleDoga.mts':  Duration: 00:18:58.21, start: 2165.015522, bitrate: 15843 kb/s

この再生(録画)時間の出力と out_time の値を利用して、プログレスバーに進捗状況を表示します。以下、プログレスバーに進捗状況を表示する部分のコードです。

  LogBuffer := '';
  DurationInSec := 0;

  repeat
    FillChar(Buffer, SizeOf(Buffer), 0);
    if ReadFile(StdOutRead, Buffer, SizeOf(Buffer) - 1, BytesRead, nil) and (BytesRead > 0) then
    begin
      LogBuffer := LogBuffer + string(Copy(Buffer, 0, BytesRead));

      //改行で分割して処理
      Lines := LogBuffer.Split([#10, #13], TStringSplitOptions.ExcludeEmpty);
      if Length(Lines) > 0 then
      begin
        for i := 0 to High(Lines) - 1 do
        begin
          Line := Trim(Lines[i]);

          //ログ出力内容を確認
          Memo1.Lines.Add(Line);

          if (DurationInSec = 0) and (Pos('Duration:', Line) > 0) then
          begin
            TotalDurationStr := Copy(Line, Pos('Duration:', Line) + 9, 12);
            DurationInSec := TimeStringToSeconds(Trim(TotalDurationStr));
          end;

          if Pos('out_time=', Line) > 0 then
          begin
            TimeStr := Copy(Line, Pos('out_time=', Line) + 9, 11);
            CurrentTimeInSec := TimeStringToSeconds(Trim(TimeStr));

            if DurationInSec > 0 then
            begin
              ProgressBar.Position := Min(100, Round((CurrentTimeInSec / DurationInSec) * 100));
              Application.ProcessMessages;
            end;
          end;
        end;
        LogBuffer := Lines[High(Lines)];
      end;
    end;
  until WaitForSingleObject(ProcessInfo.hProcess, 10) = WAIT_OBJECT_0;


上記コード内で、「時刻文字列を秒数 に変換」する TimeStringToSeconds 関数を呼び出していますが、この関数は以下のように、別に準備しておきます。

  function TimeStringToSeconds(const TimeStr: string): Double;
  var
    h, m, s: Integer;
    secFrac: Double;
    Parts: TArray<string>;
  begin
    Result := 0;
    Parts := TimeStr.Split([':']);
    if Length(Parts) < 3 then Exit;

    h := StrToIntDef(Parts[0], 0);
    m := StrToIntDef(Parts[1], 0);
    s := Trunc(StrToFloatDef(Parts[2], 0));
    secFrac := Frac(StrToFloatDef(Parts[2], 0));

    Result := h * 3600 + m * 60 + s + secFrac;
  end;


「変換実行」ボタンをクリックした際の手続き全体のコードです。

procedure TForm1.ButtonXClick(Sender: TObject);

  function TimeStringToSeconds(const TimeStr: string): Double;
  var
    h, m, s: Integer;
    secFrac: Double;
    Parts: TArray<string>;
  begin
    Result := 0;
    Parts := TimeStr.Split([':']);
    if Length(Parts) < 3 then Exit;

    h := StrToIntDef(Parts[0], 0);
    m := StrToIntDef(Parts[1], 0);
    s := Trunc(StrToFloatDef(Parts[2], 0));
    secFrac := Frac(StrToFloatDef(Parts[2], 0));

    Result := h * 3600 + m * 60 + s + secFrac;
  end;

  procedure RunFFmpegWithProgressBar(const InputFile, OutputFile: string; ProgressBar: TProgressBar);
  var
    SecurityAttr: TSecurityAttributes;
    StdOutRead, StdOutWrite: THandle;
    StartupInfo: TStartupInfo;
    ProcessInfo: TProcessInformation;
    Buffer: array[0..1023] of AnsiChar;
    LogBuffer: string;
    Lines: TArray<string>;
    Line: string;
    BytesRead: DWORD;
    DurationInSec, CurrentTimeInSec: Double;
    CmdLine: string;
    TotalDurationStr, TimeStr: string;
    FFmpegPath: string;
    AudioBitrate, VideoCRF: Integer;
    strPreset: string;
    i: Integer;
  begin

    //初期化
    ProgressBar.Min := 0;
    ProgressBar.Max := 100;
    ProgressBar.Position := 0;

    //パイプの準備
    SecurityAttr.nLength := SizeOf(SecurityAttr);
    SecurityAttr.bInheritHandle := True;
    SecurityAttr.lpSecurityDescriptor := nil;

    if not CreatePipe(StdOutRead, StdOutWrite, @SecurityAttr, 0) then
      RaiseLastOSError;

    ZeroMemory(@StartupInfo, SizeOf(StartupInfo));
    StartupInfo.cb := SizeOf(StartupInfo);
    StartupInfo.hStdError := StdOutWrite;
    StartupInfo.hStdOutput := StdOutWrite;
    StartupInfo.dwFlags := STARTF_USESTDHANDLES or STARTF_USESHOWWINDOW;
    StartupInfo.wShowWindow := SW_HIDE;

    //Pathを設定
    FFmpegPath:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName)) + 'ffmpeg\\bin\\ffmpeg.exe';

    //各パラメータの設定(値は参考)

    //VideoCRF:= 23;
    VideoCRF:=StrToInt(ComboBox1.Text);

    //プリセット(例: ultrafast, superfast, medium, slow, veryslow など)
    //strPreset:= 'slow';
    strPreset:= ComboBox2.Text;

    //AudioBitrate:= 192;
    AudioBitrate:= StrToInt(ComboBox3.Text);

    //-ac 2 を追加して、5.1ch → 2ch ステレオ に変換して出力(My環境ではこうしないと無音になる!)
    CmdLine:= Format(
      '"%s" -i "%s" -map 0:v -map 0:a -vcodec libx264 -acodec aac -ac 2 -b:a %dk -crf %d -preset %s -y -progress pipe:1 "%s"',
      [FFmpegPath, InputFile, AudioBitrate, VideoCRF, strPreset, OutputFile]
    );

    if not CreateProcess(nil, PChar(CmdLine), nil, nil, True,
      CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo) then
    begin
      CloseHandle(StdOutRead);
      CloseHandle(StdOutWrite);
      RaiseLastOSError;
    end;

    CloseHandle(StdOutWrite);

    LogBuffer := '';
    DurationInSec := 0;

    repeat
      FillChar(Buffer, SizeOf(Buffer), 0);
      if ReadFile(StdOutRead, Buffer, SizeOf(Buffer) - 1, BytesRead, nil) and (BytesRead > 0) then
      begin
        LogBuffer := LogBuffer + string(Copy(Buffer, 0, BytesRead));

        //改行で分割して処理
        Lines := LogBuffer.Split([#10, #13], TStringSplitOptions.ExcludeEmpty);
        if Length(Lines) > 0 then
        begin
          for i := 0 to High(Lines) - 1 do
          begin
            Line := Trim(Lines[i]);

            //ログ出力内容を確認
            //Memo1.Lines.Add(Line);

            if (DurationInSec = 0) and (Pos('Duration:', Line) > 0) then
            begin
              TotalDurationStr := Copy(Line, Pos('Duration:', Line) + 9, 12);
              DurationInSec := TimeStringToSeconds(Trim(TotalDurationStr));
            end;

            if Pos('out_time=', Line) > 0 then
            begin
              TimeStr := Copy(Line, Pos('out_time=', Line) + 9, 11);
              CurrentTimeInSec := TimeStringToSeconds(Trim(TimeStr));

              if DurationInSec > 0 then
              begin
                ProgressBar.Position := Min(100, Round((CurrentTimeInSec / DurationInSec) * 100));
                Application.ProcessMessages;
              end;
            end;
          end;
          LogBuffer := Lines[High(Lines)];
        end;
      end;
    until WaitForSingleObject(ProcessInfo.hProcess, 10) = WAIT_OBJECT_0;

    CloseHandle(StdOutRead);
    CloseHandle(ProcessInfo.hProcess);
    CloseHandle(ProcessInfo.hThread);

  end;

begin

  //変換元ファイルの指定がない場合は、処理しない
  if Edit1.Text='' then
  begin
    Edit1.SetFocus;
    Exit;
  end;

  //プログレスバーを表示
  ProgressBar1.Visible:=True;
  try
    //MTS -> MP4変換
    RunFFmpegWithProgressBar(Edit1.Text, Label1.Caption, ProgressBar1);
  finally
    //非表示にする
    ProgressBar1.Visible:=False;
  end;

end;

私の手持ち機材で録画した MTS ファイルを MP4 ファイルへ変換する作業は、・・・ 安定動作するまでに様々な紆余曲折はありましたが、最終的に上のコードで問題なく動作するようになりました。・・・が、(日々進化する)使用機材とPC環境により、録画&録音の環境は、その利用者により当然異なると思います。

例えば、私の環境では、音声は 5.1ch → 2ch として「品質を低下」させないと生成された MP4 には音声が入らないというトラブル(?)がありました。

//-ac 2 を追加して、5.1ch → 2ch ステレオ に変換して出力
CmdLine:= Format(
      '"%s" -i "%s" -map 0:v -map 0:a -vcodec libx264 -acodec aac -ac 2 ・・・

ですので、上記コードがあらゆる録画&録音設定に対応できるものでは『ない』ことに十分ご留意いただけますよう、心からお願い申し上げます。万一、上記コードを流用される場合、環境によっては、様々な不具合が生じることが予想されます。その場合は、利用者各自の責任でコードに適切な修正または改良を加えていただけますよう、お願い申し上げます。

5.文字列の一部を省略(…)して表示

【追記_20250815】

この記事で紹介している「文字列の一部を省略(…)して表示」するコードの Path 版には、さらに改良したコードがあります。

Delphiでは(他の言語についてはさらに知りませんが)、TEditやTLabelに長い文字列を表示した時、コントロールの幅より文字列の長さが長いと文字列の後半が切れて表示されてしまいます。そうならないように自動的に長い文字列の中央よりの一部を … のように省略して表示する機能はデフォルトの状態では準備されていないようです(間違っていたらすみません)。

この機能を実装してみました。設定可能なコントロールは TEdit と TLabel です。コンポーネント化する方法もあるかと思いますが、より簡単に、関数として実装しました。

最初に、非 Path 文字列用の場合です。

文字列の中央部分を省略して表示します。


次に、フォルダ名部分はなるべく残す Path 文字列専用バージョンです。

Path 文字列の先頭の方を省略して表示します。


TEdit のText や TLabel の Caption を参照したい場合に備えて、省略していない Path 文字列をグローバル変数に保存しておきます。必要な場合は Edit1.Text や Label1.Caption ではなく、グローバル変数から Path 文字列を取得して利用します。コードは次の通りです。

  private
    { Private 宣言 }
    //省略していない Path 文字列をグローバル変数に保存
    SrcFileName, DstFileName:string;

implementation

uses
  Winapi.ShellAPI,
  System.Math;

{$R *.dfm}

procedure TForm1.Button1Click(Sender: TObject);
var
  SelectedFile: string;
  strMsg: string;

  //表示する文字列の長さの自動調整
  //非Path用
  function FitTextWithMiddleEllipsis(TargetControl: TControl; const Text: string): string;
  var
    Bitmap: TBitmap;
    Canvas: TCanvas;
    MaxWidth: Integer;
    LeftPart, RightPart: string;
    Ellipsis: string;
    i, j: Integer;
    CharWidth: Double;
    InitKeep: Integer;
  begin
    Bitmap := TBitmap.Create;
    try
      Canvas := Bitmap.Canvas;

      if TargetControl is TLabel then
        Canvas.Font := TLabel(TargetControl).Font
      else if TargetControl is TEdit then
        Canvas.Font := TEdit(TargetControl).Font
      else
        raise Exception.Create('Font にアクセスできないコントロールです。');

      MaxWidth := TargetControl.Width;
      Ellipsis := '...';

      //全部入るならそのまま返す
      if Canvas.TextWidth(Text) <= MaxWidth then
        Exit(Text);

      //1文字あたりの平均幅を計算
      if Length(Text) > 0 then
        CharWidth := Canvas.TextWidth(Text) / Length(Text)
      else
        CharWidth := Canvas.TextWidth('W');

      // 残せる文字数を幅から概算(両端合計)
      InitKeep := Trunc((MaxWidth - Canvas.TextWidth(Ellipsis)) / CharWidth);

      // 左右で半分ずつ残す
      if InitKeep < 2 then InitKeep := 2; //最低1文字ずつ残すため
      i := InitKeep div 2;
      j := Length(Text) - (InitKeep - i) + 1;

      //徐々に調整して収まる長さを探す
      while (i >= 1) and (j <= Length(Text)) do
      begin
        LeftPart := Copy(Text, 1, i);
        RightPart := Copy(Text, j, Length(Text) - j + 1);
        Result := LeftPart + Ellipsis + RightPart;

        if Canvas.TextWidth(Result) <= MaxWidth then
          Exit(Result);

        Dec(i);
        Inc(j);
      end;

      //最後の手段:1文字ずつ残す
      if Length(Text) >= 2 then
        Result := Copy(Text, 1, 1) + Ellipsis + Copy(Text, Length(Text), 1)
      else
        Result := Ellipsis;

    finally
      Bitmap.Free;
    end;
  end;

  //Path用
  function FitPathWithMiddleEllipsis(TargetControl: TControl; const FilePath: string): string;
  var
    Bitmap: TBitmap;
    Canvas: TCanvas;
    MaxWidth: Integer;
    Ellipsis: string;
    DirPart, FilePart, DrivePart: string;
    Parts: TArray<string>;
    i, LeftCount, RightCount: Integer;
    TestPath: string;

    function MeasureTextWidth(const S: string): Integer;
    begin
      Result := Canvas.TextWidth(S);
    end;

  begin
    Bitmap := TBitmap.Create;
    try
      Canvas := Bitmap.Canvas;

      if TargetControl is TLabel then
        Canvas.Font := TLabel(TargetControl).Font
      else if TargetControl is TEdit then
        Canvas.Font := TEdit(TargetControl).Font
      else
        raise Exception.Create('Font にアクセスできないコントロールです。');

      MaxWidth := TargetControl.Width;
      Ellipsis := '...\';

      //全部入る場合
      if MeasureTextWidth(FilePath) <= MaxWidth then
        Exit(FilePath);

      //ファイル部分とディレクトリ部分を分離
      FilePart := ExtractFileName(FilePath);
      DirPart  := ExtractFilePath(FilePath);
      DrivePart := ExtractFileDrive(FilePath);

      //パスのディレクトリ部分を分解(ドライブ部分は除外)
      Parts := DirPart.Substring(Length(DrivePart) + 1).Split([PathDelim], TStringSplitOptions.ExcludeEmpty);

      //初期は全部表示してみる
      TestPath := IncludeTrailingPathDelimiter(DirPart) + FilePart;

      //左右を削っていくアプローチ
      LeftCount := 0; // 先頭から残すディレクトリ数
      RightCount := Length(Parts); // 後ろから残すディレクトリ数

      while (LeftCount < Length(Parts)) and (MeasureTextWidth(TestPath) > MaxWidth) do
      begin
        //最初の方のディレクトリを省略(中央に Ellipsis)
        Inc(LeftCount);
        TestPath := DrivePart + PathDelim;

        if LeftCount > 0 then
          TestPath := TestPath + Parts[0] + PathDelim;

        if LeftCount < Length(Parts) then
          TestPath := TestPath + Ellipsis;

        if RightCount > 0 then
        begin
          for i := Length(Parts) - RightCount to High(Parts) do
            if i >= 0 then
              TestPath := TestPath + Parts[i] + PathDelim;
        end;

        TestPath := TestPath + FilePart;
        Dec(RightCount);
        if RightCount < 0 then RightCount := 0;
      end;

      //収まる長さで返す
      Result := TestPath;

      //それでも収まらなければ中央省略だけで返す
      if MeasureTextWidth(Result) > MaxWidth then
      begin
        Result := Copy(FilePath, 1, 1) + '...' + Copy(FilePath, Length(FilePath), 1);
      end;

    finally
      Bitmap.Free;
    end;
  end;

begin
  OpenDialog1.Filter := 'MTS ファイル (*.MTS)|*.MTS|すべてのファイル (*.*)|*.*';
  OpenDialog1.Title := 'MTS 形式の動画ファイルを選択してください';
  //ofFileMustExist:ファイルが存在していなければ選択できない
  //ofHideReadOnly:読み取り専用チェックボックスを非表示にする
  OpenDialog1.Options := [ofFileMustExist, ofHideReadOnly];
  OpenDialog1.InitialDir:=ExtractFilePath(Application.ExeName)+'Src';

  if OpenDialog1.Execute then
  begin
    //ShowMessage('選ばれたファイルは: ' + OpenDialog1.FileName);
    //拡張子をチェック
    SelectedFile := OpenDialog1.FileName;
    //拡張子を小文字で取得して比較
    if not SameText(ExtractFileExt(SelectedFile), '.mts') then
    begin
      strMsg:='選択されたファイルは .MTS ファイルではありません。処理を中止します。';
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
      Edit1.Text:='';
      Button1.SetFocus;
      Exit; // 以降の処理をキャンセル
    end;

    //変数内には正しい文字列が保存されている
    SrcFileName:=OpenDialog1.FileName;
    DstFileName:=ExtractFilePath(Application.ExeName)+'Dst\'
      +ChangeFileExt(ExtractFileName(OpenDialog1.FileName), '')+'.mp4';

    //短く表示_非Path用
    //Edit1.Text:= FitTextWithMiddleEllipsis(Edit1, SrcFileName);
    //Label1.Caption:= FitTextWithMiddleEllipsis(Label1, DstFileName);

    //短く表示_Path表示用に特化
    Edit1.Text:= FitPathWithMiddleEllipsis(Edit1, SrcFileName);
    Label1.Caption:= FitPathWithMiddleEllipsis(Label1, DstFileName);

  end;
end;

6.プログラムのダウンロード

FFmpeg 関連のファイルの除いたプログラム一式を以下からダウンロードできます。なお、ダウンロードとご使用にあたっては、免責事項及び使用条件への同意が必要です。免責事項及び使用条件の詳細は付属の License.txt をご覧ください。

また、動作には FFmpeg が必要です。

FFmpeg のダウンロードサイト : https://ffmpeg.org/download.html

ダウンロードするファイルは、次のいずれかを推奨します。ご自身の環境で展開しやすい方を選択してください。

・ffmpeg-release-essentials.7z
・ffmpeg-release-essentials.zip

MTStoMP4.zip を展開(解凍)した後、以下のようなフォルダ・ファイル構成となるようにダウンロードした FFmpeg.exe を配置してください。

MTStoMP4\
 ├ Dst
 ├ FFmpeg\bin\ffmpeg.exe
 ├ Src
 ├ License.txt
 └ MTStoMP4.exe

MP4 形式に変換する MTS 形式の動画ファイルは必ず Src フォルダ内に準備してください。

なお、プログラムの初回起動時には、Windows Defender SmartScreen による警告画面が表示されます。この警告画面に関する詳細は、当 Blog の次の過去記事をご参照ください。

7.まとめ

このプログラムは変換元の MTS ファイルを選択後、オプションを指定して「変換実行」ボタンをクリックすることで動作します。複数の MTS ファイルを同時に指定して、MP4 変換することはできません。

このプログラムを用いて大きさ 2.09 GB(=2135.36 MB)の MTS 形式の動画ファイルを MP4 形式に変換してみました。なお、各パラメータですが、CRF 値は「23」、Preset は「Medium」、AudioBitRate は「128」を指定しました。生成された MP4 形式の動画の大きさは 287 MB でしたので、削減量は 1848.36MB 、削減率は 約86.6% になります。My NotePC ( Panasonic CF-QV )で生成された MP4 ファイルを視聴しましたが、自分個人の感想として、気になるレベルでの画質や音質の劣化はないように思えました(私の矯正視力は両眼とも 1.5、人間ドックでの聴力検査結果は正常範囲です)。

8.お願いとお断り

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

また、FFmpeg.exe 他、FFmpeg 関連のファイルを同梱した状態での MTStoMP4.exe の再配布を禁じます。MTStoMP4.exe を再配布される場合は、FFmpeg関連のファイルはすべて削除し、MTStoMP4.zip に添付した License.txt を必ず添付してください。

マウスだけで操作可能な画面の拡大表示ツール “KindLens” のご紹介

「見る力を、やさしく支える」KindLens — ドラッグとクリックだけで、視界のバリアを取り払います。

【もくじ】

1.機能のご紹介
2.ダウンロード
3.お願いとお断り

矢印型 Form をこの位置に置いてクリックすると・・・
予め設定した範囲を、指定した倍率で、図のように枠のない別窓に拡大表示します。
別窓を指定時間後に自動で閉じる設定も可能です。
(デフォルト設定では、幅640×高さ320ピクセル、倍率2倍で、2.5秒間表示後、自動で閉じます)

追記(20250715)

初期バージョンにあった不具合を解消しました。主な改善点は以下の通りです。

(1)矢印型 Form の画面上の位置に応じて、矢印の向きとキャプチャ範囲を自動設定します。
(2)矢印形状(方向)のリアルタイム描画で、より直感的なキャプチャ範囲設定を可能としました。
(3)キャプチャ画面を指定時間後に自動で閉じる機能を追加しました(0.5秒刻みで設定可)

  指定時間経過後に拡大表示画面が自動で閉じる機能はデフォルト ON になっています。

また、上記改善を行った後、マルチモニター環境で行ったテストにおいて、プログラムの設計時、設定 Form の Scaled プロパティの確認を怠り、これを「 True のまま」としたため(なぜ、そうなっているのか、わかりませんが、 Delphi では Form の Scaled プロパティはデフォルト True なのです)、設定 Form に配置した VCL コントロールの配置がモニタの解像度によっては乱れてしまうことを確認し、一旦公開を中止して当該箇所の不具合を修正し、再度公開しました。

プログラムにはバージョン番号の表記等は一切ありませんので、矢印型 Form を右クリックすると表示されるサブメニューから「設定」をクリックして選択し、表示される設定画面が正常でない場合は、当記事のダウンロードリンクより、最新版の KindLens.exe をダウンロードしていただけますよう、伏してお願い申し上げます。

今後も、修正・改善箇所があればこの記事で報告し、修正・改善したプログラムをダウンロードリンク先にアップロードいたします。

追記(20250716)

拡大表示画面の表示位置の微調整を行いました。また、矢印型 Form の初期表示色を「白」、サイズを設計時の50%としました。

1.機能のご紹介

このプログラムは、重い障害のある方が右手でトラックボールマウスを操作して、動画・静止画を問わず、画面上に表示されている細かな文字や図を拡大表示して読めるように Delphi の力を借りて開発したものです。利用規約及び使用条件に同意していただければ、どなたでも無料でお使いいただけます。

PCの画面を拡大表示できるツールは Windows の拡大鏡をはじめとしてさまざまなものがありますが、各種設定変更の必要性がなく、単一の実行形式ファイルのダブルクリックで起動し、マウス操作(ドラッグ&ドロップと左ボタンクリック)のみで画面の拡大表示を実現できる無料ツールはおそらくないのではないかと思います。

使い方は・・・

【初期バージョン】※ 現在、ダウンロードできません。

(1)矢印型の Form を拡大表示したい領域の右下へドラッグして移動します。
(2)ドロップした矢印型 Form 上をクリック(マウスの左ボタンを押し下げ)します。
(3)ドロップした位置の左上方向の画面が、拡大表示されます。

【改良バージョン】

矢印型のFormを拡大表示したい領域へドラッグすると、その位置に応じて矢印の形状(指し示す方向)が変化しますので、拡大表示したい領域(の右上・左上・左下・右下位置)へドロップすると、自動的にキャプチャされた範囲が、予め設定した倍率・大きさで拡大表示されます。

拡大したい領域の右上・左上・左下・右下のどこにドロップしたらよいのかは、矢印の形状から直感的に判断できるように改善しました。

拡大倍率は、デフォルト設定2倍です。表示窓の大きさは、デフォルト設定で幅640ピクセル、高さ320ピクセルです。これらの設定は任意の値への変更と、変更した状態の保存が可能です(ただし、簡単にデフォルト設定に戻す機能は用意してありません — 少々乱暴ですが、exe と同じ場所にある exe と同じ名前の拡張子が ini のイニシャライズファイルを削除すれば、デフォルト設定に戻ります)。なお、イニシャライズファイルは、削除しても「設定」画面の保存ボタンをクリックすれば、exe と同じ場所に再生成されます。

言葉で表現しても動作の様子がイメージしにくいと思いますので、実際の実行例をご覧ください。

拡大表示したい範囲の右下へ矢印型Formをドラッグして移動します。


矢印型 Form 上(内部)をクリック(マウスの左ボタン押し下げ)します。すると、次のように矢印方向左上の画面が拡大表示されます。※ 改良版では、矢印型 Form のドロップと同時に拡大表示されます。

拡大表示された別窓(窓枠はありません)をクリックすれば表示は消えます。
改良版では、指定時間経過後に自動的に消去する設定も可能です。
(ESCキー押し下げでも拡大表示は消えます)


ESCキー押し下げによる拡大表示の消去機能は、当初、計画したプログラムの仕様にはありませんでしたが、このプログラムをお使いになる方の左側に、介助される方がいらっしゃるような場合には役立つことがあるかもしれないと考え、実装しました。

また、矢印型の Form は、常に最前面に表示されますので、動画等を全画面表示している場合でも問題なく動作します。拡大表示は矢印型の Form 上をクリックすることで実行されますので、動画アプリの操作と干渉することはありません(動画を流したまま、その一部の拡大表示が「静止画」として可能です)。

私のPC環境では、TEAMSで配信した動画や、YouTube の動画は静止画として拡大表示できましたが、PC環境や通信方法によっては動画を静止画として取得できない場合があるかもしれません。また、このプログラムは Microsoft 社の Windows11 で開発し、同 OS 上で動作確認を行っています。他社製 OS 上での動作は未確認ですので、間接的な方法やエミュレーション技術を利用されて本プログラムを Windows 以外の OS 上で実行される場合は、プログラムそのものが動作しない可能性があることに十分ご注意ください。

【プログラムの開発環境(ご参考まで)】

・デバイスの仕様

 デバイス名	XXX
 プロセッサ	11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz (3.00 GHz)
 実装 RAM	32.0 GB (31.7 GB 使用可能)
 デバイス ID	
 プロダクト ID	
 システムの種類	64 ビット オペレーティング システム、x64 ベース プロセッサ
 ペンとタッチ	10 タッチ ポイントでのペンとタッチのサポート

・Windowsの仕様

 エディション	Windows 11 Pro
 バージョン	24H2
 インストール日	‎2024/‎10/‎05
 OS ビルド	26100.4652
 エクスペリエンス	Windows 機能エクスペリエンス パック 1000.26100.128.0

・開発環境

 Embarcadero® Delphi 12.3 (バージョン 29.0.55362.2017)
 Professional with Mobile

当初は、拡大対象が動画であった場合は、拡大表示の映像も動画そのものをリアルタイムで拡大して表示する実装でプログラミングしていたのです・・・が、よくよく考えますと、このプログラムは、その前提として、定点に固定されたビデオカメラで写した映像を視聴する場合を想定しており、特に、「ビデオカメラ自体による画面のズームや向きの変更がない」状態で配信された映像中の文字や図表等を確認したい場合のヘルパーとして役立つ(使える)ように開発しましたので、「読めること」を何よりも優先し、動画も静止画として拡大表示する実装に途中から設計方針を変更しました。

また、矢印型 Form の大きさはデフォルト設定よりさらに大きくすることもできます。ただし、大きくした場合は、矢印内でのクリック位置により、意図した表示範囲と拡大画像の範囲が若干ずれることがあります。

【設定の変更方法】

設定を変更するには、矢印型の Form 上を右クリック(マウスの右ボタン押し下げ)して、表示されるサブメニューから「設定」を選んでクリックします。

「閉じる」をクリックするとプログラムは終了します。


設定画面が次のように表示されます。矢印のサイズは設計時の設定を100(%)としてあります。その他の項目は直感的に意味を御理解いただけると思います。

実行時のイメージをキャプチャしました。


矢印型 Form の色と大きさを変更してみました。

色を水色に、サイズは40%に縮小してみました。


矢印の色に「白」を設定した場合は、矢印の輪郭を黒で描画して白背景の画面でも矢印 Form の位置がわかるように工夫してあります。ただし、白以外の淡色を指定した場合は、このような黒い輪郭の描画は行われません。くれぐれもご注意ください

色を「白」に設定した場合、矢印の輪郭が黒い線で描画されます。


もし、矢印 Form の色を白以外のごく薄い淡色に設定して、矢印型 Form の表示位置がわからなくなった場合は、タスクバーに表示されている KindLens のアイコンを右クリックして表示されるメニューから「ウィンドウを閉じる」を選択(クリック:マウスの左ボタン押し下げ)する方法で、プログラムを終了することができます。

設定状態を保存していない場合は、次回起動時には矢印型 Form の色は以前の状態に戻ると思いますが、ごく薄い淡色の設定状態を保存した場合は、KindLens.exe と同じ場所にある KindLens.ini を削除してから KindLens.exe を起動してください。矢印型 Form は初期設定の赤い状態で表示されます。その後、必要に応じて各種設定を変更してください。設定変更後、「保存」ボタンをクリックすれば、拡張子が ini のイニシャライズファイルが再作成され、新しい設定がこのファイルに保存されます。

タスクバーのアイコンを右クリックした状態です。


上の図で、本来「 KindLens 」と表記されるはずの部分が「 Project1 」と表示されているのは、開発環境である Delphi の最初の保存時のプロジェクトファイルの名称が「 Project1 」であったためかと思われます。

開発が軌道に乗った後、プロジェクトファイルの名称を「 KindLens.dproj 」に変更したのですが、この部分の表記は変更されませんでした。これは Delphi の仕様かと思われます。

【設定の保存】

設定値の変更をデフォルト状態として保存します。


設定画面左下の「保存」ボタンをクリックすれば、イニシャライズファイルに設定内容が記録され、次回起動時は保存された設定内容に従って起動します。

「保存」ボタンをクリックすると、KindLens.exe と同じ場所に KindLens.ini が自動的に作成されます。この拡張子が ini のファイルは設定が記録されているファイルなので、誤って消去しないよう十分にご注意ください(ini ファイルを消去した場合、プログラムはデフォルト設定で起動します)。

また、キャプチャする幅と高さは任意の値を直接指定するか、ComboBox の選択肢から選べます。

//キャプチャ画面の幅と高さの実装コード
cmbCW.Items.AddStrings(['240', '320', '400', '480', '560', '640', '720', '800']);
cmbCH.Items.AddStrings(['240', '320', '400', '480', '560', '640', '720', '800']);

上のコードからわかる通り、選択肢から選べる値は、80の倍数としてあります(80の倍数とした理由は特にありません。480とか、640という数字に対して、僕がうまく言葉に出来ない懐かしさを感じることが、その最大の理由であるように感じます)。

この KindLens と題したプログラムには、自分で考えた最低限の「あったらいいな!」と思う機能はすべて搭載しましたが、今後、実際に使用して判明した問題点等があれば速やかに改善したいと思います。その際は、この blog にバージョンアップ版を掲載いたします。

KindLens に関する操作の説明は以上です。たいへん申し訳ありませんが、取扱説明書やヘルプファイルの準備はございません。仕様・操作方法の説明につきまして、その必要がありましたら、この記事をご参照いただけますよう、お願いできましたら(また、ご案内等いただけましたら)幸いです。

2.ダウンロード

KindLens は、次のリンクからダウンロード可能です。ただし、ご利用に当たっては、利用規約及び使用条件への同意が必要です。

ダウンロード後、ダウンロードした KindLens.zip を右クリックし、表示されるサブメニューから「すべて展開」を選んでクリックしてください。次の画面が表示されます。

展開する場所を指定される場合は「参照」をクリックして、任意のフォルダを指定します。
zip ファイルと同じ場所に展開する場合は、そのまま「展開」をクリックしてください。


無事、ファイルが展開(いまだにこの表現に慣れません。どうしても解凍と言いたくなります)されると、次の3つのファイルがエクスプローラーに表示されます。

Windowsの設定によっては、exe 等の拡張子は表示されません。
(License.txt の内容は必ずご確認ください)


ルーペの中にハートの描かれたアイコンが KindLens の実行形式ファイル( exe )です。

このファイルをダブルクリックしてプログラムを起動してください。
(いきなりダブルクリックせず、このアイコンを右クリックすると表示されるサブメニューから「プロパティ」を選んで、Windows Defender SmartScreen による警告画面の表示を回避することもできます:後述)

【初回起動時に表示される警告について】

プログラムの起動に成功すると、初期状態では赤い矢印型 Form がお使いの PC の画面中央に表示されますが、ダウンロード&展開直後の最初の実行(プログラム起動)時には Windows の保護機能が働いて、次に示す Windows Defender SmartScreen による警告画面が表示されます。

「詳細情報」をクリックします。


次の画面が表示されます。

「実行」をクリックしてください。


これは KindLens.exe が悪意のあるプログラムであるために表示される警告ではなく、Windows に搭載されたセキュリティ機能に「未知の発行元や信頼性の低いファイルに対して警告を出す仕組みがある」ために表示されるものです。

ダウンロードされたファイルに「 Zone.Identifier 」という「ゾーン識別子」が付加されていると、Windows はこの識別子を見て「インターネット経由で取得されたファイル」と判断し、SmartScreen が警告を表示します。

【Windows Defender SmartScreen による警告画面を回避する方法】

ダウンロード&展開直後の最初の実行(プログラム起動)時には Windows の保護機能が働いて、上記 Windows Defender SmartScreen による警告画面が表示されますが、これを回避する方法もあります。以下、その手順です。

(1)KindLens.exe をいきなりダブルクリックせず、KindLens.exe のアイコンを右クリックすると表示されるサブメニューから「プロパティ」を選んでクリックしてください。

(2)次の画面が表示されますので、「全般」タブのいちばん下にある「セキュリティ:」部分を図に示した順にクリックしてください。

この作業を行うと、Zone.Identifier が削除され、警告は表示されなくなります。


上記作業を行うと、KindLens.exe に付加されていた「 Zone.Identifier 」という代替データストリーム(ADS)が削除され、警告の表示が出なくなります。「 Zone.Identifier 」はファイルの「本体(メインストリーム)」とは別の Windows の NTFS ファイルシステムの隠れた領域に付加されるメタ情報で、隠しストリームです。

上記のどちらかの方法で Windows の警告が表示される仕組みを解除してください。ADS の削除に成功すれば、次回の実行時から警告は表示されなくなります。

3.お願いとお断り

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

手書き答案の「デジタル採点補助プログラム」のつもりで作った僕のAC_Reader に自動採点機能みたいなモノを搭載しました!

今回ご紹介するプログラムで、自動採点できるかもしれない(?)手書き答案の解答は・・・

カタカナ「ア・イ・ウ・エ・オ」のいずれか1文字、それから
数字の「1・2・3・4・5」のいずれか1つ、そして
記号の「〇 ・ × 」のどちらかです。

この・・・ わずか 12 個の、文字・数字・記号に限定したお話ですが、僕が行ったテストでは各種パラメータの微調整を行うことなく、デフォルト設定のまま、テストデータ(少ないですが)をほぼ正しく推論できました。※ 制作の最終段階での検証結果です。

「自己責任・サポート無し」という条件付きですが、もし、よかったら、お試しください。

どなたにもお待ちいただいておりませんが、2年ぶりにバージョンアップした解答欄リーダーです。

【もくじ】

0.注意事項
1.論より証拠
2.自動採点機能の使い方
3.推論用画像データの確認
4.プログラムのダウンロード
5.お願いとお断り

【注意事項】

初回の自動採点実行時にPCがフリーズしたような状態になることがあります(正しく動作している状態であっても、Python Engine の初期化には数秒を要します)。特に、ダウンロードした Zip ファイルを展開(解凍)した直後の初めての実行時や、インターネット接続が切れた状態で使用した場合、この初期化作業にかなりの時間を要する場合があることを実際に確認しました(常に、この現象が起きるわけではありません)。この現象発生時に、内部的に呼び出して実行している組み込み Python 環境はエラーメッセージを出しません。つまり、プログラムは単に Python Engine の初期化完了を待つ「待機状態」であることは明らかなのです・・・ が、「プログラムで使用しているどのライブラリがこの待機状態を作り出しているのか」という、はっきりした原因の特定まで現在至っておりません。

この現象は、自動採点実行時、最初の1回に限って発生します。2回目以降は、採点終了まで滞りなく(素人が作ったプログラムなので実行速度は遅いですが)動作すると思います。

お試しいただける方には、たいへん申し訳ありませんが、そのような現象が発生することがあることをご理解いただいた上で、ご試用していただけますよう、伏してお願い申し上げます。

また、このプログラムの動作には「Microsoft Visual C ++ ランタイムライブラリ」のインストールが必要です。お使いのPCに「Microsoft Visual C ++ ランタイムライブラリ」が入っていない場合は、下記 Web サイトから「VisualCppRedist_AIO_x86_x64.exe」をダウンロードし、ダウンロードしたプログラムを管理者権限で実行し、動作に必要なライブラリをPCにインストールしてください。なお、インストール時には Windows のユーザーアカウント制御(UAC) が起動し、管理者用のID とパスワードの入力を求められます。インストールでは、exe の名称からわかるように 32 ビット版と 64 ビット版それぞれの VC++ランタイムライブラリがお使いの PC にセットアップされます。なお、インストール後は(僕のPC環境では)再起動なしで、そのまますぐに AC_Reader.exe を実行できました。

「VisualCppRedist_AIO_x86_x64.exe」の入手先:

https://www.majorgeeks.com/files/details/visual_c_redistributable_runtimes_aio_repack.html

2025年6月11日現在、バージョンは「0.91.0」でした。上記 Web サイトの Download (64-Bit EXE) というリンクをクリックすればインストールプログラムをダウンロードできます。

1.論より証拠

自動採点実行時の画面のハードコピーを以下に示します。なお、テスト用データの手書き「文字・数字・記号」は、すべて「お手本」を参照しながら、僕自身が「お手本」を真似て書いたものです。

まず、最初にカタカナの「アイウエオ」5文字の推論結果です。

正解ラベル:「ア」の場合です。(全体を表示するため、解答欄画像は縮小表示しています)

サンプル画像は、画面の表示倍率を81%に設定しているため、細部が霞んでいます。


正解ラベル:「イ」の場合です。


正解ラベル:「ウ」の場合です。


正解ラベル:「エ」の場合です。


No,1とNo,12の画像に縦方向の直線状の汚れがありますが、推論用画像作成の前段階の処理でその除去に成功しています(これを除去しておかないと、例えばNo,12の画像の推論用データは空白の画像ではなく縦線「|」が入った画像になり、学習モデルは間違いなくこれを「1」と推論してしまうはずです)。

解答欄の切り出し直後の画像では、No,1とNo,12の画像の左側に薄い灰色の直線状の汚れがあります。


推論用データ(文字の輪郭を検出して縦横28ピクセルの画像として解答欄の画像から切り出す)を作成する前段階で、これらの汚れを除去する処理を入れています。

No,1とNo,12の画像にあった汚れはキレイに消えています。


この「文字を消さずに汚れのみ除去する」処理はけっこう苦労しました。が、なんとか工夫を重ねて実現できました。「エ」の構成部品である「|」を消さずに、左側の汚れの「|」のみ除去するのは大変でしたが、線状の汚れと判断する基準にその高さ(長さ)を採用して、それが画像の高さとほぼ等しい場合は汚れと見なすことで、この問題はクリアできました。

以下、そのスクリプトです(ご参考まで)。

# 画像内の灰色の直線状汚れを除去

import cv2
import numpy as np
import os
from glob import glob

folder = r".\GrayLine"
image_extensions = ["*.png", "*.jpg", "*.jpeg"]
image_paths = []
for ext in image_extensions:
    image_paths.extend(glob(os.path.join(folder, ext)))

tolerance = 20  # 画像の高さとの誤差許容範囲(ピクセル単位)

for image_path in image_paths:
    image = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR)
    if image is None:
        continue
    height, width = image.shape[:2]
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray, threshold1=20, threshold2=80, apertureSize=3)
    lines = cv2.HoughLinesP(edges, rho=1, theta=np.pi / 180, threshold=50, minLineLength=50, maxLineGap=5)

    if lines is not None:
        for line in lines:
            x1, y1, x2, y2 = line[0]
            angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
            line_length = np.hypot(x2 - x1, y2 - y1)

            # 垂直線かつ画像の高さとほぼ同じ長さのみ除去
            if (abs(angle - 90) < 1 or abs(angle + 90) < 1) and abs(line_length - height) < tolerance:
                cv2.rectangle(image, (x1-5, 0), (x2+5, height), (255, 255, 255), 2)
                cv2.rectangle(image, (x1-4, min(y1, y2)-5), (x2+4, max(y1, y2)+5), (255, 255, 255), -1)

    cv2.imencode(".png", image)[1].tofile(image_path)

正解ラベル:「オ」の場合です。


カタカナ「アイウエオ」の5文字は間違えずに推論できました。イイ感じです。
次は数字の「12345」。何となくイケそうな気がしてきました☆

正解ラベル:「1」の場合です。


あ・れ・?

なんで「2」に〇が・・・

夢なら覚めてくれ・・・ T_T

一瞬。そう思いましたが・・・

大丈夫。転ぶのには慣れています。これまでだって さんざん・・・、

ここまで来て、あきらめるなんて、そっちの方が無理です。

急いで推論用の画像を確認。

何の問題もなく、解答欄画像からの切り抜きに成功している・・・


・・・ ということ は、学習データに問題があった ってコトか?

左へ 微妙に傾いているように見えます・・・

よくよく考えてみると、このような左に傾いた「2」は、利き腕が右の場合、なんとなく書きにくいような気もします。このことから、つまり、推論をミスした原因は、学習用データとして用意した画像の中に、左に傾いた「2」が少なかったため(?)ではないかと思えてきました。

見たところ、この「2」の画像には極端なシミも汚れもなく、色の濃さも十分、形状もちょっと縦に伸びてるかなって感じもしますが、まぁ、これは一般的にどう見ても「2」です。輪郭検出にも間違いなく成功して期待通りに切り出せている以上、やはり推論ミスの原因は「その傾きにある」としか思えません。

そこで・・・ ナニをしたかというと、

微妙に傾きの異なる画像を50枚作成


取りあえず、1~20°の範囲で、0.5°ずつ傾きに変化をつけ、推論をミスした「2」を左に回転させた画像を上のように50枚用意(処理する際に名称は関係ないので、ファイル名に一貫性はありません)して、さらに「2」の学習データは全体で約7000枚あるので、その1割にあたる700枚を抜き出し、ランダムに5°、10°、15°、20°のいずれかの角度で左に回転させ、先に用意した50枚と合わせて水増し学習データを合計750枚作りました。

「水増し」なんて言うと(文脈にもよりますが)どちらかと言えばネガティブな意味を含むことが多く、なんだか、とてもずる賢い・よからぬことをしているように感じますが、機械学習で使われる「水増し」という言葉は、 データ拡張(Data Augmentation) という概念を表すもので、基本的に悪い意味はないようです。むしろ、このテクニックは、モデルの汎化性能を向上させ、過学習(Overfitting)を防ぐために重要な技術とされているようです。十分な学習データがない場合に、画像の回転・拡大・ぼかし・ノイズ追加などを行うことで、実質的にデータ数を増やせますし(=過学習の防止という意味でもこれは有効)、既存の学習用データに回転(やりすぎは禁物!)や、サイズ変更して作成した水増し学習用データを加えて学習モデルを作れば、異なる角度やサイズの文字にも対応できる、より頑健なモデルにすることができます。

※ 過学習(Overfitting):学習用データが少なかったりすると、学習モデルがそのデータに最適化されすぎてしまい、汎化性能が低下してしまうことを言うそうです。つまり、見たことがあるデータしか、推論に成功しなくなる(見たことがないデータに対して非常に弱くなる)わけですね。

こうして作成した水増し学習用データをを元の約7000枚に追加し、画像をランダムに並び替えて、連番の名前を付け直し、約7800枚の「2」の画像データを作り、そのうち1/3のデータは余白「4」、1/3のデータは余白「5」、1/3のデータは余白「6」を設定(余白の取り方を変更してモデルの汎用性を高めるため)して再学習用の縦横28ピクセルの画像データに変換し、1、3、4、5の各学習用データと合わせて、カタカナ「アイウエオ」の学習モデルを再度構築し直しました。

実際は、再度ではなく、再々々々・・・度の「構築し直し」ですが。
夢は、きっと、叶えるために、あります。

基本的な考え方としては(間違っているかもしれませんが)、学習用データの余白分布が4~6ピクセルであれば、モデルはその範囲内の「平均的」な状態、すなわち中央値に近い値(つまり5ピクセル)に合わせた特徴抽出を学習する(=最も代表的な状態に合わせて内部の重みが調整される)と仮定して・・・

(推論用データの余白の設定を中央値にすると正解率が良いように経験的に感じたのです)

この仮定がもし正しければ、推論用の画像データはそのすべてを「検出した輪郭の周囲に余白5を指定して作成」することで、モデルは最も慣れている条件下で推論動作を行える=最も良い正解率を示すはずだと・・・

実は、この輪郭検出(=文字認識)後、その周囲にどの程度の余白を設定するかについて最初は適当に「8」とか指定していたのですが、モデルの汎用性を高めるためには、学習データの余白の設定は一律に同じ設定としない方が良いはずなので、ある時、ふとその1/3に余白「8」、1/3に余白「9」、1/3に余白「10」を設定して学習モデルを作成し、推論の成否を確認していたところ、推論用データの余白を「9」に設定した場合に正解率がよくなるように感じました(正確に統計をとったわけではありません)。それと最終的には、学習用データ・推論用データともに縦横28ピクセルの画像とすることから、中央に配置した文字が実質縦横20ピクセル程度の領域に入る余白「4・5・6」あたりが最も適当であろうと考えたわけです。MNISTの作りを見ても、この考えは正しいように思われました。

もちろん、学習用データの余白を3・4・5として、推論用データの余白を中央値の4とする設定も考えましたが、余白が3ピクセルではさすがに小さすぎるのではないかと思い直し・・・ つまり、ちょっとした輪郭抽出のズレでも、文字がフレームに近づきすぎて、文字の上下左右の位置のバラつきが大きくなり、モデルが位置変動に過敏になる可能性が大きいと考えました。

逆に余白が6ピクセルと大きい方が、余白を3ピクセルとした場合よりも、文字が中央に安定しやすく、多少のズレがあっても特徴が大きく変わらなくなるはずです(機械学習においては、機械が覚え込んだ特徴量に近い特徴量を示す推論対象が正解とされるわけですから、このことは非常に重要です)。解答欄画像から輪郭検出を行って推論用データを作成する際の余白の設定を様々に変えて試行している際に、わずか1ピクセル、余白の設定を変更しただけで、正解になったり、不正解になったりする事実(プログラムのテストを繰り返す中で、この現象に気づいた当初は本当に不思議に感じました)は、まさにこの推測が正しいことの証明ではないかと思われました。

最終的には、すべて縦横28ピクセルの画像データとするわけですから、このあたりの判断がコトの成否を分ける、言わば「運命の分岐点」であったと、今、ここまでの歩みを振り返って思います。

また、この各数字の画像が約7000枚ずつあるというのは、僕の制作環境においては学習モデルを作成可能な制限ギリギリの値であったようで、学習モデル作成にあたってはまずPCそのものを再起動し、他のアプリが一切動作していない(メモリが十分に空いている)状態を作ってから、学習モデルを作成するスクリプトを実行する必要がありました。

ちなみに僕のPC環境(仕様)は、以下の通りです。

【デバイスの仕様】
プロセッサ	11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz   3.00 GHz
実装 RAM	32.0 GB (31.7 GB 使用可能)
システムの種類	64 ビット オペレーティング システム、x64 ベース プロセッサ
ペンとタッチ	10 タッチ ポイントでのペンとタッチのサポート

【Windowsの仕様】
エディション	Windows 11 Pro
バージョン	24H2
インストール日	‎2024/‎10/‎05
OS ビルド	26100.4351
エクスペリエンス	Windows 機能エクスペリエンス パック 1000.26100.107.0

様々なアプリを使用した後や、Webブラウザを開いたままの状態で学習モデルを作成するスクリプトを実行すると、必ず「メモリが足りません!」というエラーメッセージが表示され、学習モデルの作成に失敗してしまうので、「再起動直後に実行する」という手を思いつく前は、「もはやこれまで」とせっかく作った学習データを減らそうかと思ったりもしました。

誰も教えてくれる人はいませんので、すべてが手探り状態で、後から考えれば実に様々な「それくらい最初から気がつけよ!」みたいな「プロから見れば当たり前のこと」に気づくまでに、試行錯誤を繰り返し、膨大な時間を費やしつつ、一歩一歩前進するしかありません。

昼間は仕事があるし・・・、夜はあたまの回転がトロくなるし・・・、なんや・かんやで、
だいたい日付が変わる頃に目を覚まし、あとは朝が来るまで、ちいさな灯りをともして・・・
僕の人生の中で、いちばん充実した「時」を過ごします・・・

自動採点を、あきらめない以上は・・・ その時々で、僕に出来る最善を尽くすのみ です。

何はともあれ、左に傾いた「2」を新しく学習したモデルが出来ました!

このモデルを用いて「1」の推論に再チャレンジした結果です。


やった! やった!!

なせばなる!!!

もちろん、余白の設定は「5」としてあります。

画像はプロトタイプのものです。

正解ラベル:「2」の場合です。

No,10の画像の「2」が正解となっていることもうれしいことです。
実は、No,11の画像は、検証用にわざと誤りのデータを他の画像から切り貼りして作成したものです。
つまり、正解ラベル「1」のNo,11の「2」と、上のNo,10の「2」は同じデータと思われます。


正解ラベル:「3」の場合です。


正解ラベル:「4」の場合です。


正解ラベル:「5」の場合です。


数字も正しく読めるようになりました☆


次は、記号の「 ○ と × 」です。

正解ラベル:「 ○ 」の場合です。


正解ラベル:「 × 」の場合です。



・・・・・・・


2025 年 6 月 15 日 午前4時
とうとう・・・
夢がかないました!


とても静か・・・

まだみんな
眠っています。

これも夢かもしれません。

夢なら、どうか・・・

覚めないでください。

2.自動採点機能の使い方

ダウンロードした zip ファイルを展開すれば、すぐにお試しいただけるよう、次に紹介する採点サンプルデータを同梱してあります。記事の説明を参照しながら、操作していただけますよう、お願い申し上げます。

この記事の冒頭にも書きましたが、プログラムの動作には「Microsoft Visual C ++ ランタイムライブラリ」のインストールが必要です。お使いのPCに「Microsoft Visual C ++ ランタイムライブラリ」が入っていない場合は、下記 Web サイトから「VisualCppRedist_AIO_x86_x64.exe」をダウンロードし、ダウンロードしたプログラムを管理者権限で実行し、動作に必要なライブラリをPCにインストールしてください。なお、インストール時には Windows のユーザーアカウント制御(UAC) が起動し、管理者用のID とパスワードの入力を求められます。インストールでは、exe の名称からわかるように 32 ビット版と 64 ビット版それぞれの VC++ランタイムライブラリがお使いの PC にセットアップされます。なお、インストール後は(僕のPC環境では)再起動なしで、そのまますぐに AC_Reader.exe を実行できました。

「VisualCppRedist_AIO_x86_x64.exe」の入手先:

https://www.majorgeeks.com/files/details/visual_c_redistributable_runtimes_aio_repack.html

2025年6月11日現在、バージョンは「0.91.0」でした。上記 Web サイトの Download (64-Bit EXE) というリンクをクリックすればインストールプログラムをダウンロードできます。

【採点の準備】

AC_Reader.exe をダブルクリックしてプログラムを起動したら、「採点作業」ボタンをクリックします。ここで「Windows によって PC が保護されました」と書かれた青い画面が表示された場合は、当 blog の過去記事に対応方法の詳細な説明を載せてありますので、そちらをご参照ください。

この青い画面( Windows Defender SmartScreen )に関するより詳細な説明は、次の過去記事にも掲載しています。もし、よろしければ合わせてご参照ください。

「採点作業」ボタンをクリックすると、次のメッセージが表示されます。


「はい」をクリックすると、既存の採点設定を選択できるようになります。


採点設定ファイルを選択するには、ComboBox の右側の ∨ マークをクリックします。すると候補の選択肢として採点サンプルファイルが1つだけ表示されますので、これをクリックして選びます。


案内メッセージが表示されます。


フォルダ選択」用のダイアログが表示されますので、解答用紙画像の入っている「フォルダを選択」してから OK をクリックしてください。

【重要】 選択するのは「フォルダ」であって、「ファイル」ではありません!


案内メッセージが表示されます。よく読んで OK をクリックしてください。

【採点設定ファイルとフォルダの関係】

最初に選んだ「採点設定ファイル」は、試験で使用した解答用紙の解答欄の座標他が登録されています。ですので、同じ解答用紙を使用して行った試験であれば、すべて同一の採点設定ファイルで採点作業を行うことができます。

通常、テストは「クラス単位」で実施されますが、採点設定ファイルはどのクラスに対しても共通で利用しますので、クラス名を入れない名称を付けて保存(例:R7_考査①_数学Ⅰ)するよう、ユーザーの皆さまにはご案内しています。

解答用紙の画像は、通常であれば「クラス名を付けたフォルダ(例:R7_考査①_数学Ⅰ_1A)」に保存するのが一般的であると思います。

ですので、このプログラムの実際の運用に当たっては、「採点設定ファイルにはクラス名を入れず、解答用紙の画像を保存するフォルダにはクラス名を含めた名前を付けてください。」とユーザーの皆さまへご案内しております。

【採点方法】

自動採点は、次の GUI で行います(僕は「フローティングパネル」と呼んでいます)。いろいろ考えてデザインしましたが、使い勝手がよくないと感じられる方もいらっしゃるかもしれません。そうだったら、ほんとに、ごめんなさい。

上部のタイトルバーに相当する部分を左クリックして、
そのまま(左ボタンを押したまま)ドラッグすると、
フローティングパネルを任意の位置へ移動できます。
(閉じるボタンは無効化してあります)


まず、現在、採点しようとしている設問への配点を設定します。


以下、手動採点時の採点方法の説明です。

手動採点時には、このまま、配点設定欄の下にある入力ボタンをクリックすると、配点設定欄が「0」であれば、現在表示されているすべての解答欄に不正解の「×」が、配点設定欄が「1以上」であれば、現在表示されているすべての解答欄に正解の「○」が(設定によっては配点の数字も)自動で入力されます。

これは、つまり、手動採点時には、初めに解答欄全体の出来栄えを見て、全体的によく出来ているような場合は一括して正解とし、不正解の解答欄だけを手動で採点、逆に全体的に出来がよくない場合には、一括して不正解とし、正解の解答欄だけを手動で採点した方が、効率よく採点できると考えて、このような仕様としました。

もちろん、自動採点時には、この入力ボタンをクリックする必要はありません。

また、配点を設定する ComboBox の右隣りの CheckBox「□する」にチェックを入れると、手動採点時に入力ボタンをクリックして、一括採点操作が行われる前に確認メッセージが表示されるようになります(誤入力を防ぎたいという、ユーザーからの要望で追加した機能です)。

【ここから自動採点の実行方法の説明です】

配点を入力後、自動採点を行う場合は、「□自動採点」にチェックを入れます。
次に、その下にある ComboBox からその設問の正解を選び、実行ボタンをクリックします。

正解として指定できるのは・・・

・カタカナの「ア・イ・ウ・エ・オ」のいずれか1文字、
・記号の「○・×」のどちらか1つ、
・数字の「1・2・3・4・5」のいずれか1つです。

これ以外のカタカナ(例えば「カ」)、記号(例えば「△」)、数字(例えば「6以上の数字」)は指定できません(決まりとして指定できないだけで、正解ラベルとしてComboBoxの入力欄に入力することはできます・・・が、正しく採点することは絶対に不可能です)。ただ、数字のゼロは、たぶん記号の「○」で代用が可能かと思われます・・・ ので、数字については、もしかしたら「0・1・2・3・4・5」の6種類が採点可能かも?しれません(試していませんが)。

また、正解ラベルに指定する文字・記号・数字は、直接入力せず、ComboBox の選択肢から選択してください。記号の「○:まる」に誤って漢数字の「ゼロ:〇」を指定しないようご注意願います。漢数字のゼロは「まる」の変換でも IME の変換候補の選択肢に表示されるので十分注意してください。

「チェック」→「正解ラベル選択」→「実行」です。


採点が完了すると、解答欄の画像の左上に、採点記号(自動採点を利用した場合は、○ or × のいずれか)と、先に設定した配点が赤く表示されます(表示位置は任意の位置に変更できます。変更方法は上で紹介しました当 blog の過去記事をご参照ください)。

自動採点実行直後の状態

お願い
ここで、全ての解答欄について、機械の採点結果を目視で必ず確認してください。

※ このプログラムは、添付した学習モデルの性能が及ぶ範囲で「正解・不正解」のいずれかを判定する自動採点を実行しますが、自動採点結果について、それが常に 100 %「正しい」ことを保証するものではありません。自動採点を行った結果につきましては、必ず、ご自身の責任で、直接、目視によって、その成否をご確認いただけますよう、お願い申し上げます。この使用条件に完全に同意し、かつ確実に目視による確認作業を実行していただける方のみ、このプログラムをお使いいただけますことを申し添えます。このプログラムに搭載した手動及び自動の採点機能を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

【修正が必要な場合】

もし、修正が必要な場合は、修正対象の解答欄の画像をまずクリックします。

・正解に修正する場合は、配点に相当する数字キー(その設問の配点が「2」なら「2」のキー)を押下げします。

・不正解に修正する場合は、「B」キーを押下げします。ちなみに「B」は「 ×:Batsu 」の頭文字で、右手でマウス・左手で手動採点する際に「B」キーは押しやすい位置にあり、また、機能を覚えやすいんじゃないかと考え、「B」を不正解の入力キーとしました。

【採点結果の保存方法】

採点結果を保存(=書込み)しないと、次の解答欄を表示することはできません。実行の左隣にある「書込」ボタンをクリックしてください。採点結果が保存されます。

3.推論用画像データの確認

プログラム設計時の動作検証用に作成した機能ですが、解答用紙画像から切り出した解答欄画像と、その解答欄画像から切り出した推論用画像データの状態を確認することが出来ます。

【解答欄画像の確認方法】

まず、次のように、正解ラベルが「空欄」の状態で確認作業を実行した場合、解答用紙画像から切り出した解答欄画像を確認することが出来ます。

正解ラベルは「空欄」のままにしておきます。


正解ラベルが「空欄」のままであることを確認した後、「設定」→「推論用画像を確認する」の順にクリックしてください(元々、開発時に推論用画像を確認するために設けた機能なので、ボタンの名称が「解答欄・・・」ではありません)。

画像はプロトタイプのものです。


【重要】 設定画面表示中は、Form の「閉じる」ボタンは無効化されます。

解答欄画像が表示されます。


解答用紙から切り出した解答欄画像のクリーニングは、採点作業補助用の GUI (フローティングパネル)の CheckBox 「□自動採点する」をチェックして、さらに正解ラベルが空欄ではない状態で、実行ボタンをクリックすると行われる(ように設定してある)ので、クリーニング前の状態を確認したい場合は、自動採点を実行する前の段階、すなわち、「◀」もしくは「▶」ボタンをクリックした直後の、まだ「□自動採点する」をチェックせず、正解ラベルも指定していない状態で、「設定」ボタンをクリックして、「推論用画像を確認する」をクリックすれば(クリーニング前の解答欄画像を)表示できます。

リリース版では、上のプロトタイプの状態にさらに画像のクリーニング機能を追加、パラメータが増えたため、ボタンのキャプションは単に「推論用画像」としています。

採点エンジンは2系統あります。
Version1 を選択した場合は、各パラメータを調整できます。
(デフォルト設定は、パラメータを調整済みの Version2 としてあります)

プログラムは、「実行」ボタンをクリックすると、まず、解答用紙から切り出した解答欄画像のクリーニングを行って、それから自動採点を行います。初回のみならず、2度目、3度目の見直し採点時であっても、プログラムは「修正等を一切加えていない無加工の解答用紙画像」から解答欄を切り抜いて解答欄画像として表示しているので、汚れのある解答欄が毎回表示されます。クリーニングが行われるのは、実行ボタンをクリックした後であることにご留意ください。

【点状汚れの除去の例】

画像の左下隅に点状の汚れがあります。


クリーニング後の画像は・・・

よーく見ると、微かにうすいシミが残っていますが・・・まぁ、消えたと言えるんじゃないかと。
(ここは後日、さらに改良してより白くなるように修正しました)

【線状汚れの除去の例】

クリーニング前の画像の例(1番目と12番目の画像左端に線状の汚れがあります)


クリーニング後の画像は・・・

線状の汚れは消えました!

【推論用画像の確認方法】

自動採点を実行すれば、推論用に解答欄画像から切り出した、縦横28ピクセルの推論用画像を確認できます。自動採点時、実際に機械が見ているのは、この推論用画像になります。

正解ラベルが指定されている場合は、推論用画像を確認できます。


上の図のような状態で、「実行」ボタンをクリックした後で、「設定」→「推論用画像」の順にクリックします。

推論用画像が表示されます。


縦横28ピクセルの、この小さな画像を思った通りに切り出せるようになるまで、いったいどれくらいの試行錯誤を繰り返したか、今はもうそのすべてを思い出せませんが、自分の中に「あきらめる」という選択肢だけはなかったように思います。

これまでの経験から、ただひとつだけ言えることは、機械学習の成否はこの機械が見る(機械に見せる)画像にあるということです。

学習用データとまったく同じ手法で作成した推論用画像を自分では「ブレない画像」と呼んでいますが、画像中の汚れ・シミ等も含めて輪郭検出した部分の面積を計算し、その大きな部分を組み合わせた範囲を文字として切り抜き、中心位置を計算し、最適な余白を設け、汚れ・シミを除去し、白い部分はより白く、逆に薄い灰色は黒く(濃く)する等、文字の特徴量抽出を阻害する要素をできるだけ取り除いた、わずか縦横28ピクセルの、このちいさな文字。その「作り方」として、僕のとった方法が正解であったかどうかの答えを AC_Reader が出してくれると信じています。

もりろん、手書き文字にひとつとして同じ文字はありませんから、そのような意味で「正しいア」は存在しません。ただ、これまでの経緯から、特徴量抽出で機械が学んだ「ア」こそ、もしかしたら「正しいア」に最も近い「ア」なのではないかと思うようになりました。

ひとことで言えば、「正解がないのに、正解を探す旅」それが今、僕が思う機械学習のイメージです。

4.プログラムのダウンロード

自動採点機能みたいなモノを搭載した手書き答案の採点補助プログラム AC_Reader.exe 及びその関連プログラム一式を同梱した AC_Reader_v3.0_AutoGrading.zip は、次のリンクからダウンロードできます。なお、ダウンロードとご使用にあたっては、免責事項及び使用条件への同意が必要です。免責事項及び使用条件の詳細は付属の Readme.txt をご覧ください。

5.お願いとお断り

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

追記_返却用答案の印刷方法について

元々、この AC_Reader には簡易的な合計点の計算と返却用答案の印刷機能があったのですが、高等学校現場における観点別評価の導入に伴い、返却用答案の印刷プログラムは、マークシートリーダーと共用の別プログラム(ReportCard_2024.exe)としました。

AC_Reader.exe から ReportCard_2024.exe を呼び出して実行できます。ReportCard_2024.exe の操作方法は、当ブログの過去記事をご参照ください。


こちらの過去記事にも ReportCard_2024.exe の操作方法の解説があります。上の記事と合わせてご参照ください。

デジタル採点 手書き フリー で検索したら、その後

前回の記事を書いてから、scikit-learn を使った機械学習による手書きカタカナ文字「ア・イ・ウ・エ・オ」及び記号「○・×」の認識用学習モデル作成について、さらに勉強しました☆

今回は、その記録と、今後の抱負です。

【もくじ】

1.さらに勉強した理由
2.HOGを知る
3.気分は「写経」
4.今後の抱負
5.まとめ
6.お願いとお断り

1.さらに勉強した理由

なぜ、さらに勉強したかというと、前回の記事では、画像のピクセル値をそのまま利用する Flattening という特徴量抽出の手法を用いて学習モデルを作成したのですが、前回の記事にある通り、既知の(=学習に利用した)カタカナ文字については、アイウエオ各文字ともに 98 %正しく判定できたという好結果に力を得て、Delphi で GUI を作成した手書き答案の採点補助プログラムから、Python の文字認識スクリプトを実行できるよう、新しくプログラムを書いて実験してみた結果、期待に反して1回も見たことのない新規の文字については、正しく判定できないことがありました。特に「オ」は全滅・・・

以下、かるーくやってみた実験の結果です。

多少の傾きはOK?

「ア」はふたつとも読めた・・・

文字の記入位置は影響なし?

記入位置の探索も、上手く行えてるようです・・・

なぜ、読めない? この「ウ」は読んで欲しかった・・・

ふたつめの「ウ」の方が、典型的な「ウ」により近い? 気がするけど・・・

「エ」は判定しやすい?

「エ」は得意なのかな・・・

この「オ」が見分けられないとは・・・ T_T


この「オ」の認識結果を見て、正直、これはダメだと思いました。また、失敗です。T_T

ちなみに「○・×」は・・・

なにか書いてあれば・・・「○」だと思ってる・・・
(空欄を識別しているのは、うれしい限りですが)


「○ or ×」認識テストの結果は、「オ」の場合よりさらにダメです。まぁ上の「オ」の場合の「ア」についても確信を持って見分けて「×」を付けているのか、どうか、この結果を見てだいぶ怪しくなってきました。(果たして、あの「オ」や「ア」をどう読んだのか・・・、それを確認する気力も失せました・・・)

さらに、お見せしたくないのが、「×」が正解ラベルの場合です。

もうダメです。T_T

THE END.
その想いで胸がいっぱいに!

実装が超シンプルで、かつ高速で軽量、文字画像のピクセル値(28×28)をそのまま利用する Flattening という手法では、これが限界なのでしょうか?

学習用データをさらに増やせば、もっと良い結果が得られるのではないか・・・ とも考えましたが、手元にその学習用データがありません。新規に学習用データを集めるには莫大な手間と時間が必要です。

ただ・・・失敗の中でも唯一救いに感じたのは、2年前の文字認識チャレンジでどうしてもクリア出来なかった解答欄中の文字が書かれている位置を正しく認識することに成功し、意図した通りに文字画像を取得出来ていることです。

No,1の「イ」は解答欄の左側に記入されていますが・・・
No,1の「イ」も正しく切り出せています


プログラムはその記入位置を正確に見つけ出し、28×28の矩形画像への切り出しに成功しています。

実は、この Blog の過去の記事で「失敗の記録」として掲載した手書き文字認識チャレンジの試行錯誤の記事を書いた当時、文字の認識に失敗した最大の原因は「正しく文字を切り出せなかった」ことにありました。今回、テストしたのは、たった3枚の画像ですが、いずれも問題なく文字が記入されている位置をプログラムは特定し、その正確な切り出しに成功しています。

切り出した画像の縦横比が、元の画像と変化していることに、画像を見て気づきました!
ここは出来れば改善したいところです。


2年前の僕の技術では、例えば「ア」について、文字を構成する線がすべて繋がっている場合は「ア」という文字1文字だと正しく認識できても、「つ」部分と「ノ」部分が離れている場合は、「ア」ではなく「つ」と「ノ」に分解して認識してしまうミスをどうしても防げなかったのです。今回のチャレンジでは、この問題を無事クリアできました。

2年前の僕の技術では、3つめの「ア」は「つ」と「ノ」になってしまいましたが・・・
今回のプログラムは、ちゃんと「ア」として切り出しています。
ただ、やはり縦横比が・・・気になりますので、ここは何とかします!


さらに、解答欄から切り出した文字の位置が切り出し画像の中央にあることも、長い間ずっと・・・ この胸に思い描いた夢の通りです。

文字の縦横比は変わっていますが、文字位置の特定には何の問題もなく、成功しています☆
さらに、解答欄左にある(5)のような解答欄の番号を無視することにも成功しています☆☆
2年前にどうしてもクリア出来なかった複数の問題を、今回はすべてクリア出来ました☆☆☆

総合的な意味では今回も失敗でしたが、自分にとって、前回、クリア出来なかった幾つもの問題を解決できたことは、本当に大きな前進でした。だから、総合的には失敗でも、☆5つが完全な成功だとしたら、自分的には ☆☆☆ です。

また、今回、Flattening による学習モデル作成方法を学ぶことで、Python に 32 ビット環境の scikit-learn ライブラリを導入する手法を完全に理解できました。機械学習そのものが現在 64 ビット環境へ移行しつつある中で、32 ビット環境の最後の輝きを、今、僕は目の当たりにしている・・・ そんな気がしてなりませんでした。

2.HOGを知る

Flattening の欠点に気づいたのは、Python 環境で作成した学習モデルを Delphi の Object Pascal から操作できるようにプログラミングを終えた段階(上の画像は、その段階での試行の様子)だったので、・・・結果的に Delphi 側の最も重要なプログラムを最初から組み直すことにはなりましたが・・・ ここで僕は、エッジや輪郭の方向に強く、ノイズの影響も受けにくい HOG(Histogram of Oriented Gradients)という特徴量を抽出する手法があることを知ります。HOG を勉強してみたところ、こちらの手法の方が画像のピクセル値のそのまま利用する Flattening より、文字の識別精度が高いのではないかと思えてきました。

そこで HOG を用いて文字の特徴量を抽出して学習モデルを作成するスクリプトを書きました。最初に、ごく基本的なコードを書き、そこに必要な様々な処理を追加して行く方法で一歩一歩確実に進んだ結果、文字の認識能力が Flattening 特徴量抽出手法を使ったそれよりは高いのではないか?と、確かに思える学習モデルを作成することができました。HOG 特徴量抽出手法を使った学習モデルは、Delphi に組み込む前に、Python スクリプトを使って行った試行で、上の「オ」を2つともサラっと認識してくれたのです!

試行の様子がこちらです。

解答用紙から切り出した解答欄の矩形画像


さらに解答欄の中の文字部分を探索して、切り抜いて・・・

解答欄から切り出した28×28ピクセルの矩形画像
(新しいプログラムではファイル名のIndexは1始まりにしました)


Delphi に埋め込む前に、Python 用のスクリプトで読んでみます・・・

やった! ちゃんと読めた!! 「オ」だけじゃなく「ア」も正しく読めています!!!


以下、HOG特徴量抽出手法を適用した学習モデル作成に必要な、学習用の文字データを作成するために使用したスクリプトです(使用を推奨するものではありません。あくまでもご参考まで)。

このスクリプトは、輪郭検出と文字切り出し、周囲パディングを均一化して、文字を画像の中心に配置、GaussianBlurによるノイズ除去、傾き補正、28×28ピクセルに正規化して保存・・・と言った機能を備えています。万一、コピペして試される場合は PATH をご自身の環境に合わせて変更してください。

import cv2
import numpy as np
import os
from glob import glob
import re

# UTF-8 パス対応の画像読み込み
def imread_utf8(path):
    stream = np.fromfile(path, dtype=np.uint8)
    return cv2.imdecode(stream, cv2.IMREAD_COLOR)

# 傾き補正(修正: warpAffine に補間法と白背景を明示)
def deskew(img):
    m = cv2.moments(img)
    if abs(m['mu02']) < 1e-2:
        return img.copy()
    skew = m['mu11'] / m['mu02']
    M = np.float32([[1, skew, -0.5 * 28 * skew], [0, 1, 0]])
    return cv2.warpAffine(img, M, (28, 28), flags=cv2.INTER_NEAREST | cv2.WARP_INVERSE_MAP, borderValue=255)

# ファイル名から数値を抽出(img12.png → 12)
def extract_number(path):
    filename = os.path.basename(path)
    match = re.search(r'img(\d+)', filename)
    return int(match.group(1)) if match else float("inf")

# 入力・出力フォルダ(パスに全角文字が含まれていてもOK)
input_folder = r"C:\Python39-32\Images_tegaki\aiueo\ア"
output_folder = os.path.join(input_folder, "Trimed")
os.makedirs(output_folder, exist_ok=True)

# 対象画像拡張子
image_extensions = ['*.jpg', '*.jpeg', '*.png']
image_files = []
for ext in image_extensions:
    image_files.extend(glob(os.path.join(input_folder, ext)))

# 並べ替え(img番号順)
image_files.sort(key=extract_number)

index = 1
for image_path in image_files:
    image = imread_utf8(image_path)
    if image is None:
        print(f"読み込めない画像: {image_path}")
        continue

    h, w = image.shape[:2]
    gray_for_line = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray_for_line, 50, 150, apertureSize=3)

    raw_lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=100,
                                minLineLength=min(w, h) // 3, maxLineGap=10)
    filtered_lines = []
    if raw_lines is not None:
        for line in raw_lines:
            x1, y1, x2, y2 = line[0]
            angle = abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)
            length = np.hypot(x2 - x1, y2 - y1)
            if (angle < 10 or angle > 170) and length < w // 2:
                continue
            filtered_lines.append([[x1, y1, x2, y2]])

    if filtered_lines:
        for line in filtered_lines:
            x1, y1, x2, y2 = line[0]
            if abs(x2 - x1) < 10 or abs(y2 - y1) < 10:
                cv2.line(image, (x1, y1), (x2, y2), (255, 255, 255), thickness=3)

    if w > h:
        offset = w // 4
        cropped = image[:, offset:w - offset]
    else:
        offset = h // 4
        cropped = image[offset:h - offset, :]

    gray = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY)
    _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)

    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 10))
    dilated = cv2.dilate(thresh, kernel, iterations=1)
    contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    if contours:
        all_points = np.vstack(contours)
        x, y, w_box, h_box = cv2.boundingRect(all_points)
        padding = 20  # この値は、切り抜き画像を確認しつつ、適宜調整してください。
        if w > h:
            x += offset
        else:
            y += offset

        x1 = max(0, x - padding)
        y1 = max(0, y - padding)
        x2 = min(w, x + w_box + padding)
        y2 = min(h, y + h_box + padding)

        trimmed = image[y1:y2, x1:x2]
        trimmed_gray = cv2.cvtColor(trimmed, cv2.COLOR_BGR2GRAY)
        trimmed_blur = cv2.GaussianBlur(trimmed_gray, (3, 3), 0)

        h_trim, w_trim = trimmed_blur.shape[:2]
        scale = 20.0 / max(h_trim, w_trim)
        new_w = int(w_trim * scale)
        new_h = int(h_trim * scale)
        # resized = cv2.resize(trimmed_blur, (new_w, new_h), interpolation=cv2.INTER_AREA)
        resized = cv2.resize(trimmed_blur, (new_w, new_h), interpolation=cv2.INTER_NEAREST)

        canvas = np.full((28, 28), 255, dtype=np.uint8)
        x_offset = (28 - new_w) // 2
        y_offset = (28 - new_h) // 2
        canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized        

        deskewed = deskew(canvas)

        # モーメントで中心を合わせる(修正: warpAffine に補間法と白背景を明示)
        M = cv2.moments(deskewed)
        if M['m00'] != 0:
            cx = int(M['m10'] / M['m00'])
            cy = int(M['m01'] / M['m00'])
            shift_x = 14 - cx
            shift_y = 14 - cy
            trans_mat = np.float32([[1, 0, shift_x], [0, 1, shift_y]])
            deskewed = cv2.warpAffine(deskewed, trans_mat, (28, 28), flags=cv2.INTER_NEAREST, borderValue=255)

        canvas = deskewed
    else:
        print(f"文字が検出されませんでした: {os.path.basename(image_path)}")
        canvas = np.full((28, 28), 255, dtype=np.uint8)

    # 保存(全角パスにも対応)
    save_path = os.path.join(output_folder, f"{index:04d}.png")
    is_success, encoded_img = cv2.imencode('.png', canvas)
    if is_success:
        encoded_img.tofile(save_path)
        print(f"{save_path} を保存しました。")
    else:
        print(f"{save_path} の保存に失敗しました。")

    index += 1

print("すべての画像の処理が完了しました。")


上のスクリプトで 28×28 ピクセルに整形して保存した大量の学習用データ画像を、次のスクリプトで処理して学習モデルを生成します。こちらについても、万一、コピペして試される場合は PATH をご自身の環境に合わせて変更してください(こちらも使用を推奨するものではありません。あくまでもご参考まで)。

import cv2
import numpy as np
from sklearn import svm
from sklearn.model_selection import train_test_split
import os
import joblib  # モデルの保存と読み込みに使用
from skimage.feature import hog
from sklearn.svm import SVC

# カタカナのクラス
CATEGORIES = ["ア", "イ", "ウ", "エ", "オ"]

# Pathの中の日本語に対応
def imread(filename, flags=cv2.IMREAD_GRAYSCALE, dtype=np.uint8):
    try:
        n = np.fromfile(filename, dtype)
        img = cv2.imdecode(n, flags)
        return img
    except Exception as e:
        print(e)
        return None

# HOG特徴量を抽出する関数
def extract_hog_features(img):
    # 画像はすでに28x28の想定
    features = hog(img,
                   orientations=9,
                   pixels_per_cell=(4, 4),
                   cells_per_block=(2, 2),
                   block_norm='L2-Hys')
    return features

# データセットの準備(28x28 の手書きカタカナ画像)
def load_images_from_folder(folder, categories):
    images = []
    labels = []
    for label, category in enumerate(categories):
        path = os.path.join(folder, category)
        print(f"Processing category: {category}, Path: {path}")

        if not os.path.exists(path):
            print(f"Warning: Path does not exist: {path}")
            continue

        for file in os.listdir(path):
            if file.lower().endswith(('.png', '.jpg', '.jpeg')):
                file_path = os.path.join(path, file)
                try:
                    img = imread(file_path)
                    if img is not None:
                        img = cv2.resize(img, (28, 28))
                        hog_features = extract_hog_features(img)
                        images.append(hog_features)
                        labels.append(label)
                    else:
                        print(f"Failed to load image: {file_path}")
                except Exception as e:
                    print(f"Error loading {file_path}: {e}")
            else:
                print(f"Skipping non-image file: {file}")
    print(f"Loaded {len(images)} images")
    return np.array(images), np.array(labels)

# データ読み込み
X, y = load_images_from_folder(r"C:\Python39-32\Images_tegaki\aiueo\Trimed", CATEGORIES)

if len(X) == 0:
    raise ValueError("No images loaded. Please check the image files and paths.")

# 学習とテストの分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# SVM モデルの作成と学習
model = svm.SVC(kernel='linear')
model.fit(X_train, y_train)

# モデルを保存する
joblib.dump(model, 'katakana_hog_svm_model.pkl')
print("Model saved as 'katakana_hog_svm_model.pkl'")

# 予測用前処理(HOG版)
def preprocess_image(image_path):
    img = imread(image_path)
    h, w = img.shape

    size = max(h, w)
    square_img = np.full((size, size), 255, dtype=np.uint8)
    x_offset = (size - w) // 2
    y_offset = (size - h) // 2
    square_img[y_offset:y_offset + h, x_offset:x_offset + w] = img

    img_resized = cv2.resize(square_img, (28, 28))
    hog_features = extract_hog_features(img_resized)
    return hog_features

def predict_character(image_path):
    img = preprocess_image(image_path)
    model = joblib.load('katakana_hog_svm_model.pkl')
    label = model.predict([img])[0]
    return CATEGORIES[label]

# テスト画像の認識(テスト用の画像は実行中のスクリプトと同じフォルダに用意・保存する)
for image_path in [
    "katakana_sample_a.jpg",
    "katakana_sample_i.jpg",
    "katakana_sample_u.jpg",
    "katakana_sample_e.jpg",
    "katakana_sample_o.jpg"
]:
    result = predict_character(image_path)
    print(f"{os.path.basename(image_path)} の認識結果: {result}")

テストに使用した画像は、次の通りです。文字の太さはテスト用に変化のあるものを選びました。

katakana_sample_a.jpg
katakana_sample_i.jpg
katakana_sample_u.jpg
katakana_sample_e.jpg
katakana_sample_o.jpg

上記、学習モデルを作成するスクリプトの実行結果です。

幸先よし。満足できる結果を得ることができました!

3.気分は「写経」

次は、完成した学習モデルをDelphiから使えるようにすれば OK なのですが、この作業は毎回「写経」を行っているような気持ちを感じる作業です。・・・と、言う僕自身、写経の経験は皆無ですが・・・ この業界で一般的に使用される「写経」的意味合いと、ここでのそれは異なり、感覚的にはむしろ「修行」に近いものです。

次のコードを見ていただければ、なぜ「修行」なのか、ご理解いただけると思います。

procedure TFormCollaboration.btnAutoClick(Sender: TObject);
var
  strScrList:TStringList;
  strAnsList:TStringList;
  j:integer;
  intCols:integer;
  results: TArray<string>;
  s: string;
begin
  // ・・・ 略 ・・・
  try

    //Scriptを入れるStringList
    strScrList:=TStringList.Create;

    //手書き文字の認識結果
    strAnsList:=TStringList.Create;

    try

      strScrList.Add('import cv2');
      strScrList.Add('import numpy as np');
      strScrList.Add('import os');
      strScrList.Add('from glob import glob');
      strScrList.Add('import re');
      strScrList.Add('from skimage.feature import hog');
      strScrList.Add('import joblib');

      //カタカナラベル
      if (cmbAL.Text = 'ア') or (cmbAL.Text = 'イ') or (cmbAL.Text = 'ウ') or (cmbAL.Text = 'エ') or (cmbAL.Text = 'オ') then
      begin
        strScrList.Add('CATEGORIES = ["ア", "イ", "ウ", "エ", "オ"]');
      end;

      //○×ラベル
      if (cmbAL.Text = '○') or (cmbAL.Text = '×') then
      begin
        strScrList.Add('CATEGORIES = ["○", "×"]');
      end;

      //HOG特徴量抽出
      strScrList.Add('def extract_hog_features(img):');
      strScrList.Add('    features = hog(img, orientations=9, pixels_per_cell=(4, 4), cells_per_block=(2, 2), block_norm="L2-Hys")');
      strScrList.Add('    return features');

      //UTF-8 パス対応の画像読み込み
      strScrList.Add('def imread_utf8(path):');
      strScrList.Add('    stream = np.fromfile(path, dtype=np.uint8)');
      strScrList.Add('    return cv2.imdecode(stream, cv2.IMREAD_COLOR)');

      //傾き補正
      strScrList.Add('def deskew(img):');
      strScrList.Add('    m = cv2.moments(img)');
      strScrList.Add('    if abs(m["mu02"]) < 1e-2:');
      strScrList.Add('        return img.copy()');
      strScrList.Add('    skew = m["mu11"] / m["mu02"]');
      strScrList.Add('    M = np.float32([[1, skew, -0.5 * 28 * skew], [0, 1, 0]])');
      strScrList.Add('    return cv2.warpAffine(img, M, (28, 28), flags=cv2.WARP_INVERSE_MAP, borderValue=255)');

      //ファイル名から数値を抽出(crop_Img12.png → 12)
      strScrList.Add('def extract_number(path):');
      strScrList.Add('    filename = os.path.basename(path)');
      strScrList.Add('    match = re.search(r"crop_Img(\d+)", filename)');
      strScrList.Add('    return int(match.group(1)) if match else float("inf")');

      //文字認識処理
      strScrList.Add('def predict_character(img, model):');
      strScrList.Add('    hog_features = extract_hog_features(img)');
      strScrList.Add('    label = model.predict([hog_features])[0]');
      strScrList.Add('    return CATEGORIES[label]');

      //モデル読み込み
      //カタカナラベル
      if (cmbAL.Text = 'ア') or (cmbAL.Text = 'イ') or (cmbAL.Text = 'ウ') or (cmbAL.Text = 'エ') or (cmbAL.Text = 'オ') then
      begin
        strScrList.Add('model_path = r".\Python39-32\katakana_hog_svm_model.pkl"');
      end;

      //○×ラベル
      if (cmbAL.Text = '○') or (cmbAL.Text = '×') then
      begin
        strScrList.Add('model_path = r".\Python39-32\mb_hog_svm_model.pkl"');
      end;

      strScrList.Add('if not os.path.exists(model_path):');
      strScrList.Add('    raise FileNotFoundError(f"モデルファイルが見つかりません: {model_path}")');
      strScrList.Add('model = joblib.load(model_path)');

      //入力・出力フォルダ
      //strScrList.Add('base_path = r".\imgAuto\src"');
      strScrList.Add('input_folder = r".\imgAuto\src"');
      //strScrList.Add('folder_path = os.path.join(base_path, CORRECT_LABEL)');
      strScrList.Add('output_folder = os.path.join(input_folder, "'+ cmbAL.Text +'")');
      strScrList.Add('os.makedirs(output_folder, exist_ok=True)');

      //対象画像を取得
      strScrList.Add('image_extensions = ["*.jpg", "*.jpeg", "*.png"]');
      strScrList.Add('image_files = []');
      strScrList.Add('for ext in image_extensions:');
      strScrList.Add('    image_files.extend(glob(os.path.join(input_folder, ext)))');
      strScrList.Add('image_files.sort(key=extract_number)');

      strScrList.Add('results = []');

      strScrList.Add('index = 1');
      strScrList.Add('for image_path in image_files:');
      strScrList.Add('    image = imread_utf8(image_path)');
      strScrList.Add('    if image is None:');
      strScrList.Add('        print(f"読み込めない画像: {image_path}")');
      strScrList.Add('        continue');

      strScrList.Add('    h, w = image.shape[:2]');
      strScrList.Add('    gray_for_line = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)');
      strScrList.Add('    edges = cv2.Canny(gray_for_line, 50, 150, apertureSize=3)');

      strScrList.Add('    raw_lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=100, minLineLength=min(w, h) // 3, maxLineGap=10)');
      strScrList.Add('    filtered_lines = []');
      strScrList.Add('    if raw_lines is not None:');
      strScrList.Add('        for line in raw_lines:');
      strScrList.Add('            x1, y1, x2, y2 = line[0]');
      strScrList.Add('            angle = abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)');
      strScrList.Add('            length = np.hypot(x2 - x1, y2 - y1)');
      strScrList.Add('            if (angle < 10 or angle > 170) and length < w // 2:');
      strScrList.Add('                continue');
      strScrList.Add('            filtered_lines.append([[x1, y1, x2, y2]])');

      strScrList.Add('    if filtered_lines:');
      strScrList.Add('        for line in filtered_lines:');
      strScrList.Add('            x1, y1, x2, y2 = line[0]');
      strScrList.Add('            if abs(x2 - x1) < 10 or abs(y2 - y1) < 10:');
      strScrList.Add('                cv2.line(image, (x1, y1), (x2, y2), (255, 255, 255), thickness=3)');

      strScrList.Add('    if w > h:');
      strScrList.Add('        offset = w // 4');
      strScrList.Add('        cropped = image[:, offset:w - offset]');
      strScrList.Add('    else:');
      strScrList.Add('        offset = h // 4');
      strScrList.Add('        cropped = image[offset:h - offset, :]');

      strScrList.Add('    gray = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY)');
      strScrList.Add('    _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)');

      strScrList.Add('    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 10))');
      strScrList.Add('    dilated = cv2.dilate(thresh, kernel, iterations=1)');
      strScrList.Add('    contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)');

      strScrList.Add('    if contours:');
      strScrList.Add('        all_points = np.vstack(contours)');
      strScrList.Add('        x, y, w_box, h_box = cv2.boundingRect(all_points)');

      //strScrList.Add('        padding = 20');
      strScrList.Add('        padding = 5');
      strScrList.Add('        if w > h:');
      strScrList.Add('            x += offset');
      strScrList.Add('        else:');
      strScrList.Add('            y += offset');

      strScrList.Add('        x1 = max(0, x - padding)');
      strScrList.Add('        y1 = max(0, y - padding)');
      strScrList.Add('        x2 = min(w, x + w_box + padding)');
      strScrList.Add('        y2 = min(h, y + h_box + padding)');

      strScrList.Add('        trimmed = image[y1:y2, x1:x2]');
      strScrList.Add('        trimmed_gray = cv2.cvtColor(trimmed, cv2.COLOR_BGR2GRAY)');
      strScrList.Add('        trimmed_blur = cv2.GaussianBlur(trimmed_gray, (3, 3), 0)');

      strScrList.Add('        h_trim, w_trim = trimmed_blur.shape[:2]');
      strScrList.Add('        scale = 20.0 / max(h_trim, w_trim)');
      strScrList.Add('        new_w = int(w_trim * scale)');
      strScrList.Add('        new_h = int(h_trim * scale)');
      strScrList.Add('        resized = cv2.resize(trimmed_blur, (new_w, new_h), interpolation=cv2.INTER_AREA)');

      strScrList.Add('        canvas = np.full((28, 28), 255, dtype=np.uint8)');
      strScrList.Add('        x_offset = (28 - new_w) // 2');
      strScrList.Add('        y_offset = (28 - new_h) // 2');
      strScrList.Add('        canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized');

      strScrList.Add('        deskewed = deskew(canvas)');

      strScrList.Add('        M = cv2.moments(deskewed)');
      strScrList.Add('        if M["m00"] != 0:');
      strScrList.Add('            cx = int(M["m10"] / M["m00"])');
      strScrList.Add('            cy = int(M["m01"] / M["m00"])');
      strScrList.Add('            shift_x = 14 - cx');
      strScrList.Add('            shift_y = 14 - cy');
      strScrList.Add('            trans_mat = np.float32([[1, 0, shift_x], [0, 1, shift_y]])');
      strScrList.Add('            deskewed = cv2.warpAffine(deskewed, trans_mat, (28, 28), borderValue=255)');

      strScrList.Add('        canvas = deskewed');
      strScrList.Add('        predicted_char = predict_character(canvas, model)');
      strScrList.Add('        results.append(str(predicted_char))');
      strScrList.Add('    else:');
      strScrList.Add('        results.append("")');
      strScrList.Add('        canvas = np.full((28, 28), 255, dtype=np.uint8)');

      strScrList.Add('    save_path = os.path.join(output_folder, f"{index:04d}.png")');
      strScrList.Add('    is_success, encoded_img = cv2.imencode(".png", canvas)');
      strScrList.Add('    if is_success:');
      strScrList.Add('        encoded_img.tofile(save_path)');
      strScrList.Add('    index += 1');

      strScrList.Add('var1.Value = ";".join(results)');

      try
        PythonEngine1.ExecStrings(strScrList);
      except
        on E: Exception do
        begin
          ShowMessage('Pythonスクリプトの実行中にエラーが発生しました: ' + E.Message);
          Exit;
        end;
      end;

      strAnsList.Clear;

      if Assigned(PythonDelphiVar1) then
      begin
        s := PythonDelphiVar1.ValueAsString;
        if s <> '' then
        begin
          results := SplitString(s, ';');
          for s in results do
            strAnsList.Add(s);
        end else begin
          ShowMessage('sは空欄!');
        end;
      end else begin
        ShowMessage('PythonDelphiVar1 が未定義です');
      end;

      if Assigned(PythonDelphiVar1) then
      begin
        for j := 0 to strAnsList.Count - 1 do
        begin
          if cmbAL.Text = strAnsList[j] then
            StringGrid1.Cells[intCols,j+1] := cmbRendo.Text
          else
            StringGrid1.Cells[intCols,j+1] := '0';
        end;
      end else begin
        ShowMessage('PythonDelphiVar1 が未定義です');
        Exit;
      end;

    finally
      //StringListの解放
      strScrList.Free;
      strAnsList.Free;
    end;

    // ・・・ 略 ・・・
end;

エンエンと続く strScrList.Add( ) そう! ここに Python のスクリプトの1行1行をコピペして行くのです。20 行目くらいから、だんだん、まぶたが重くなり・・・、50 行目まで到達する頃には、意識が朦朧としてきて・・・、残り数行という段階で、ほぼ涅槃の境地に・・・

「涅槃」とは、「一切の煩悩から解脱した、不生不滅の高い境地」であり、「煩悩の火が消え、人間が持っている本能から解放され、心の安らぎを得た状態のこと」をいうのだそうです。

・・・

失礼しました。間違えました。僕のは単に眠くなり、もう何も考えられない状態になっただけです。

何はともあれ、いずれにしてもそのいちばん心が「無」になった状態で、最大の難関が待ち受けています。それは何かというと、Python 側から Delphi 側への判定結果の受け渡しの手続きの記述です。

元々の Python 側でのスクリプトは・・・

        predicted_char = predict_character(canvas, model)
        print(f"{os.path.basename(image_path)} → 認識結果: {predicted_char}")
    else:
        print(f"{os.path.basename(image_path)} → 文字が検出されませんでした。")

ここを、次のように書き換えます。※ results リストは予め空になるよう初期化しておきます。

      strScrList.Add('        predicted_char = predict_character(canvas, model)');
      strScrList.Add('        results.append(str(predicted_char))');
      strScrList.Add('    else:');
      strScrList.Add('        results.append("")');

最後に Delphi 側へ、プレゼント☆

      strScrList.Add('var1.Value = ";".join(results)');

で、Delphi 側では、results に保存されている認識結果を StringList で受け取って、StringGrid に得点を表示します。

      strAnsList.Clear;

      if Assigned(PythonDelphiVar1) then
      begin
        s := PythonDelphiVar1.ValueAsString;
        if s <> '' then
        begin
          results := SplitString(s, ';');
          for s in results do
            strAnsList.Add(s);
        end else begin
          ShowMessage('sは空欄!');
        end;
      end else begin
        ShowMessage('PythonDelphiVar1 が未定義です');
      end;

      if Assigned(PythonDelphiVar1) then
      begin
        for j := 0 to strAnsList.Count - 1 do
        begin
          if cmbAL.Text = strAnsList[j] then
            StringGrid1.Cells[intCols,j+1] := cmbRendo.Text  //得点を指定
          else
            StringGrid1.Cells[intCols,j+1] := '0';
        end;
      end else begin
        ShowMessage('PythonDelphiVar1 が未定義です');
        Exit;
      end;

もちろん、PATH も、exe のある階層が起点となるように修正して・・・

      //入力・出力フォルダ (cmbAL = ComboBox Answer Label)
      strScrList.Add('input_folder = r".\imgAuto\src"');
      strScrList.Add('output_folder = os.path.join(input_folder, "'+ cmbAL.Text +'")');
      strScrList.Add('os.makedirs(output_folder, exist_ok=True)');

こうして、なんとか、エラーを出さずに、プログラムが「動く」状態にまで仕上げました。

追記_20250421

ふと思ったのですが、Form に非表示の TMemo を1つおいて、そこに Python のスクリプトをコピペして、必要な部分のみ上記のように変更すれば、

もっとラクできたかな・・・

みたいな気が。

でも、「修行」には「修行」で、また、

別の意味と価値がある

ような・・・ 気も。

ただ、このプログラムの・・・ 究極の目的は、採点者が単に「ラクする」ためだけの・・・ 採点環境を実現することにある・・・ という事実。

いや、それは「ヒトと機械との美しき協働」の穿った見方。

こんな相反する「矛盾」を、感じるのは作者である僕だけ?

まぁ、全部をまとめて言えば・・・

人生は必ず ± 0になる
ということでしょうか?

なお、Python4Delphi の設定と使い方の詳細は、次の過去記事をご参照ください。

早速、冒頭に紹介したのと同じデータを読んで、動作確認。

Delphiへのスクリプト移植前に試行していたので、
あまりドキドキせずに「自動」ボタンをクリックすることができました!

ボタンの Caption は「自動」より、「実行」の方がよかったかな・・・?

記入位置も、多少の傾きも、問題なくクリアできました。


気になっていた切り出し画像の縦横比も・・・

縦横比が変化しないようにスクリプトを修正できました!

前回は、正しく読めなかった「ウ」も、この通り読めています。

やった! やった!!

長かった・・・ けれど、ここまで来ることができました☆
あきらめなくて、よかった・・・

「エ」も余裕?でクリア

イイ感じというか、エエ感じというか・・・

そして、Flattening 特徴量抽出で作成した学習モデルでは読めなかった「オ」・・・ ですが、

やったー!!!

HOG特徴量抽出で作成した学習モデルは、しっかり読んでくれました!

もちろん、「○・×」判定も・・・ 余裕でOK!
(何が余裕なのかは、僕自身、わかってないですが)

最初に正解ラベル「○」の場合、

自分的には、HOGで作った学習モデルへの「信頼感」みたいなモノが生まれてきました☆


次に、正解ラベル「×」の場合、

100% 正解しました!


これなら販売できそうです。
まぁ買ってくれる人は、
いないと思いますが・・・ *(^_^)*♪

4.今後の抱負

テストとは、とても言えないような、ほんとうに取り急ぎの採点試行結果ですので、これだけを持って公開してOK!とは、とても思えません。実際の採点現場で性能を確認できたら、自作のデジタル採点ソフト AC_Reader のバージョンアップ版として、この blog の未来記事で公開したいと思います。

5.まとめ

手書き文字認識に scikit-learn を使って成功するためには・・・

(1)特徴量抽出前の学習データ作成を丁寧に行い、機械学習しやすい環境を整える。
(2)学習データが同じである場合、Flattening より HOG 特徴量抽出の方が良い結果を出せた。
(3)誤りがあれば必ず修正し、成功するまで、絶対にあきらめないこと。

6.お願いとお断り

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

観点別評価と評定の整合性をチェックする

高等学校における現行の教育課程では、3つの観点それぞれについて、A・B・Cで評価し、トリプルA(AAA)ならば評定は「5」というような成績評価を行っています。ほとんどの現場では、表計算ソフトを使って観点別評価を点数化し、その合計に応じて評定を自動的に計算する仕組みを導入しているのではないか? と思いますが、そうだとしても成績の最終的なチェックは絶対に必要。

そこで、観点別評価と評定を入力したファイル( Excel Book の拡張子が xls, xlsx, xlsm いずれかのファイル)を任意のフォルダに入れ(もちろん、複数個入っていてもよい)、ここで紹介する「観点別評価と評定の整合性をチェックするプログラム」を起動、フォルダを選択するだけで、データのセル番地など、一切指定しなくても各々のファイルに入力された観点別評価と評定の整合性を全自動でチェック(整合性に問題がある場合、オプションで指定すれば観点別評価に基づいて評定を自動修正)してくれるプログラムを書いてみました。

チェック完了時、問題がなかった場合に表示される画面


実際に使ってもらい、「これはイイ!」と評価していただけましたので、ここでフリーソフトとして公開します。「 Excel Book に入力された観点別評価と評定の整合性をチェックするよい方法はないか?」と、悩んでいらっしゃる方にお使いいただけたら、何よりの幸いです。気がついた不具合はすべて解消してありますが、未発見のバグがまだどこかにあるかもしれません。このプログラムはあくまでも「素人」が、「趣味」で書いたものであり、思い込みや勘違いによる誤りを内包している可能性があります。大変、申し訳ないのですが、どうか、そこだけはご了承ください。

【もくじ】

1.観点別評価から評定への変換基準
2.ワークシートへのデータ入力方法
3.プログラムの使い方とダウンロード方法
4.まとめ
5.お願いとお断り

1.観点別評価から評定への変換基準

観点別評価から評定への変換基準は、次の通りです。

Aは6点、Bは4点、Cは1点に変換、その合計値が18ならば評定5、そうでない場合はその合計値が13点以上ならば評定4、そうでない場合はその合計値が9点以上ならば評定3、そうでない場合はその合計値が6点以上ならば評定2、そうでない場合は(合計値が3点ならば)評定1とする変換基準に基づいて、このプログラムは動作します。

観点別評価から評定を計算するのではなく、あくまでも、既存の成績データの整合性をチェックするプログラムであることに、どうか、ご留意ください。

【観点別評価と評定】
AAA ・・・ 5
ABA, BAA, AAB ・・・ 4
AAC, ACA, CAA ・・・ 4
ABB, BAB, BBA ・・・ 4
ABC, ACB, BAC, BCA, CAB, CBA ・・・ 3
BBB ・・・ 3
BBC, BCB, CBB ・・・ 3
ACC, CAC, CCA ・・・ 2
BCC, CBC, CCB ・・・ 2
CCC ・・・ 1

2.ワークシートへのデータ入力方法

次の2つのパターンに対応。

StringGridを2つ並べて作成したUI


「まとめて入力」を選択した場合は、観点別評価がまとめて一つのセルに入力されているファイルをチェックし、「分けて入力」を選択した場合は、観点別評価がそれぞれ独立したセルに入力されているファイルをチェックします。

いずれの場合も観点別評価が文字列または文字データとして入力されたセルの「真」に右隣りのセルに「評定」の数値データが入力されていることが、プログラムが正常に動作するための必須条件。

プログラム完成後にセルに埋め込んだ計算式が表示する値であっても、上の動作条件を満たす形でデータが並んでいれば、プログラムは正しく動作することを一応確認しました、が・・・

評定を計算式で表示しているのであれば、こんなチェック・プログラムはいらないか、と・・・。

【重要な注意】

このプログラムは、任意の行のセルに入力された、文字列(または文字)の観点別評価と「真」に隣り合う列に、数値で入力されている評定がある箇所を見つけ、その整合性をチェックするものとして開発。

プログラムが正しく動作する入力例:

評価は文字列か文字、評定は数値(いずれも計算式が表示する値ではないという前提)

次の場合は動作しません!
ワークシートのセルに設定された計算式がある場合は、それを破壊します。

プログラムが正しく動作しない(どころかデータの破壊が生じる)入力例:

列が非表示に設定されている


上の例のように、観点別評価と評定の入力セルの間に「非表示に設定された列」があり、その非表示に設定された列に観点別評価のA・B・Cを数値に変換する式が組まれているような場合、非表示の列があるため、見た目には観点別評価と評定が隣り合うセルにあるように見えても、プログラムは期待通りに動作しません。このようなファイルを自動修正機能を使用してチェックした場合、非表示の列内のセルに設定された計算式は確実に破壊され、失われます。くれぐれもご注意ください。

前述の通り、このように式で結果を表示している場合は、チェックする必要性などない気が・・・しますが、どうしてもチェックしたい場合は、ファイルのバックアップを取り、ワークシート全体を値複写で上書きしてから、不要な列を削除すれば、チェック可能に。

プログラムは、観点別評価が入力されているセルを自動的に探し、その「真」に右隣りに存在するセルに入力されている数値が期待されたものであるか・どうかをチェックするだけで、この並びに従わないその他のセルに入力されたデータ・計算式はすべて無視して動作します。

問題は、(私の)想定外の(プログラムが)無視できない「何か」に引っかかってしまった際の挙動ですが、重要な部分は try 文を使用して、何かあればエラーメッセージを表示するようにプログラミングしてあります。なので、プログラムがフリーズするようなことは、起きないはずです。

また、観点別評価が入力されていると判定されたセルの「真」に右隣りのセルに、(評定の入力がない)空白セルがあった場合は、エラーメッセージを表示します。動作確認作業を進める中で、そのことの必要性に気づき、プログラムに必要な修正を加えました。もちろん、空白セルの自動修正も可能です。ただし、観点別評価が不足している(3観点分がそろっていない)場合には、プログラムはそのようなセルをチェック対象としません。もちろん、エラーメッセージも表示されません。使用にあたっては、この点にも十分ご注意ください。

3.プログラムの使い方とダウンロード方法

チェックしたいファイルを任意のフォルダに保存します。

拡張子は3種類に対応


上のように、チェックしたいファイルを保存したフォルダ内にその他のフォルダやファイルがあっても問題なく動作します。

チェック・プログラムのアイコンをダブルクリックしてプログラムを起動します。


最初に、チェックしたいファイルの拡張子を選択します。

xls, xlsx, xlsm 3種類のファイルに対応


次に、観点別評価の入力形式を選択します。

観点別評価の入力方法の選択肢は、直感的に選べるよう工夫したつもり・・・


次に、調査対象のファイルを入れたフォルダが exe と同じ場所にあれば「 EXE 位置」、そうでない場所にある場合は「指定なし」をクリックしてください。


次に、調査対象とするワークシートの番号を指定します。


Excelのワークシートコレクションのインデックス番号は「0」始まりではなく、「1」始まりであることに注意してください。1枚目のワークシートとは、次の図の「 Sheet1 」を意味します。このプログラムではワークシートの名称ではなく、その位置でチェック対象のシートを決めています。ですので、ワークシートの名称は問いません。


より詳細な案内表示や、評定の自動修正機能を使用したい場合は、チェックボックスにチェックを入れてください。デフォルトで「案内」は True、「自動修正」は False に設定してあります。


上で述べたように、「自動修正」は最悪の場合、ワークシートのセルに設定された計算式を破壊する可能性があるので、取扱いには十分注意する必要があります。いろいろ考えた末、やはりここはパスワードを入力しないと自動修正機能が有効にならないようにするのが万一の事故を防止するには最善と判断しました。なので、「自動修正」にチェックするとパスワード入力を求める自家製 InputQuery が表示されます。


次のパスワードを入力して、OKをクリックしてください。
最後に半角の「 ! 」がついています。コピペする際、お忘れにならないよう、ご注意願います。

Evaluate-Fix2025!

正しいパスワードが入力されていれば、次のメッセージが表示されるので、

ものすごく、読みにくいとは思いますが・・・

よくよくよくよくよく
お読みいただき、
ご理解・ご了承いただけた場合のみ

「はい」をクリックしてください。万一にも、不安を感じた場合は「いいえ」を選んだ方が賢明です。

【参考】

ちなみに、「自動修正」にチェックが入っていなければ、ファイルは読み取り専用で開くようにして、不測の事故を防止しています。

  if cbAutoWrite.Checked then
  begin
    //ファイルを書込み可能な状態で開く
    Workbook := ExcelApp.Workbooks.Open(ListBox1.Items[i], EmptyParam, False, EmptyParam,
    EmptyParam, EmptyParam, EmptyParam, EmptyParam, EmptyParam, EmptyParam, EmptyParam, 
    EmptyParam, EmptyParam, False);
  end else begin
    //ファイルを読み取り専用で開くことで、編集のためにロックされることを防ぐことができる
    Workbook := ExcelApp.Workbooks.Open(ListBox1.Items[i], ReadOnly := True);
  end;

「はい」をクリックすると、案内のメッセージが表示されます。


準備が全て整ったら、「チェック開始」ボタンをクリックしてください。最初にチェックするファイルを保存したフォルダをクリックして選択し、OKをクリックします。チェックは自動的に始まります。

成績ファイルを保存したフォルダを選択


チェックが完了すると、次のメッセージが表示されます。それまでお待ちください。実測したわけではありませんがチェックするデータがワークシート1枚あたり1000セットあると、PCの性能にもよりますが 30 秒以上かかると思います。


なお、チェック中は、プログラム画面の下方にプログレスバーが表示され、緑の帯が作業の進行状況を示してくれます。

実際に、ある現場で使用しているプロが作成した業務用ファイルに対して実行してみた例。
1学年7クラス規模の場合、約400行 × 約80列程度のチェック範囲となっている。


チェックの結果、問題がなかった場合は「評価と評定の整合性に問題はありませんでした。」と表示されてチェック終了です。


整合性に問題があるデータを見つけた際は、その行・列位置を次のように表示します。

RはRow(行)、CはCol(列)を意味します。
(本番で、こんなに間違いがあることは、まず『ない』と思いますが・・・)


最も上の例で言えば「セル R2C3 」は、第2行目の第3列のセルのデータに問題があることを意味し、プログラムは、その問題の内容を右の( )内に表示します。この場合は、「6」というあり得ない評定値が入力されていたことが問題の原因であることを示しています。

「自動修正」を有効化してチェックした場合は、次のように修正後の評定も表示されます。

自動修正を有効化した場合は、保存する時間も必要なので動作速度が若干低下します。


これで、成績の付け間違いは完全に撲滅できると思ったのですが(確かに実際に撲滅できましたが)、現場でこのプログラムが発見したデータの誤りの中には、「評定が正しく、観点別評価の方が間違い」だった例がありました。ですので、整合性の問題を発見した際には、誤りが「観点別評価」にあるのか、それとも「評定」にあるのかを個別にチェックする必要があるようです。

なお、設定は ini ファイルに保存し、次回起動時に復元することができます。作者が勝手に設定した値になりますが、諸設定を初期化することもできます。

初期化を実行するには、「ロックの解除」が必要

【プログラムのダウンロード】

ダウンロード要件に同意していただける場合のみ、ダウンロードできます。

ダウンロード後、zip ファイルを展開していただき、ABC_Cheker.exe をダブルクリックしてプログラムを起動してください。同梱の TestData フォルダ内にテスト用データを入力済みのファイルがありますので、このファイルを利用してプログラムの動作をご確認ください。

なお、プログラムの初回起動時には、Windowsのセキュリティ機能であるSmartScreenにより「WindowsによってPCが保護されました」というメッセージが表示されると思います。

初回実行時に表示される警告画面


悪意のあるプログラムではありませんので、「詳細情報」をクリックすると表示される次の画面で「実行」を選択(クリック)し、プログラムを起動してください。2回目の実行からは、この警告画面は表示されなくなるはずです。

「実行」をクリックしてプログラムを起動します。


お手数をお掛けして申し訳ありませんが、信頼できる発行元になるために必要なデジタル署名を取得する費用等を考えますと、個人レベルで、その申請手続きを行うことは私の場合、無理と言わざるを得ません。開発に使用している IDE ( Delphi 12.3 )のサブスクリプション費用の支払いだけは Object Pascal の発展を願う1ユーザーとしての気持ちからずっと続けていますが・・・。

なお、最初にアップロードした実行形式ファイルで「自動修正」を有効にした状態で設定を保存すると、次回起動時に Form が表示される前に自動修正を有効化する処理が行われてしまい、「無効/非表示ウィンドウにはフォーカスを設定できません。」というエラーメッセージが表示されてしまうバグがあることに気づき、「自動修正」を有効にした状態で設定を保存しても、次回起動時に Form の表示が完全に行われてから、自動修正を有効化する処理が実行されるように、プログラムを修正しました。

ただ、「自動修正」を常に有効化した状態で起動すると、毎回パスワードを入力する InputQuery が表示されることになってしまいます。そういう「仕様」ですので、これは仕方がありませんが、起動と同時にパスワードの入力を求められますので、ちょっとびっくりします。ですので、危険を防止する意味からも「自動修正」機能を常に有効化しておく設定での運用は避けた方がよろしいかと思います。

4.まとめ

・新教育課程の観点別評価と評定の整合性をチェックするプログラムができました。
・高等学校用です。
・無料でお使いいたけますが、サポート等は一切ありません。
・ヘルプファイルもありません(ここでの説明がすべてです)。
・作者が未発見の(大いなる)不具合がある可能性があります。

5.お願いとお断り

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

ファイル名が連番であることを確認したい!

自作のマークシートリーダーでは、Windows 用の OpenCV に加え、Python 用の OpenCV も利用して、マークの読み取りを高速化している。

この Python 用の OpenCV を動かすには Python4Delphi(P4D) が必要だ。P4D 使用時はプログラムの仕様として、読み取り対象のマークシート画像ファイル名の末尾は「数値化したら連番として読み取れる半角3桁の数字」でなければならない(例:X_001.jpgなど、MS_Reader.exe Version 1.1.5 から)。

そのことを、つい忘れて実行すると・・・

【コンパイル済みの exe を実行した場合】

最初に表示されるエラーメッセージ


さらに、

2つめのエラーメッセージ


OKをクリックすると、もう一度、

3つめのエラーメッセージ


んで・・・

4つめのエラーメッセージ


泣きたい気持ち T_T で OK をクリックすると・・・

メモリーリークまで発生・・・

うわーん T_T

【実行(F9)では?】

エラーメッセージの形式こそ、違え・・・

泣きたい気持ちは、同じではありませんか。みなさん・・・


ファイル名が「プログラムの仕様と異なっている」ために起きるエラーであるという、言わば「確実に発生を予見できるエラー」なのに、

どうして今まで、
何とかしようと思わなかったのか?

以前から、なんとなく、気づいてはいたけれど・・・

オレはもしかして、
自分で思ってる以上に
バカ
なんじゃないか?

あらためて、そう思ったのであります。みなさん・・・

そこで、この 犯罪に近い プログラムの挙動をなんとかするべく、ようやくと言いますか、今更ではありますが立ち上がり・・・ 悪戦苦闘すること幾年月(実際、半日くらいです)。なので、今回は、このふと思い立ったちいさな夢を実現するまでの お読みいただく価値などまったくない 苦闘の成果の記録です。

【もくじ】

1.そして、悲劇は繰り返される
2.連鎖の終止符は?
3.まとめ
4.お願いとお断り

1.そして、悲劇は繰り返される

人間は、いろいろなことを忘れる生き物です。

むかーし、サーフィンに夢中だった頃、台風の海で大波と一緒に落ちてきたサーフボードが脳天を直撃。溺れて、死ななかったのはよかったけれど、とにかく砂浜までなんとか生還後、確かに見覚えのある風景を感じはするし、自分の名前も、家の住所も思い出せるのに、「僕のおうちまでの帰り道がどうしても思い出せません!」みたいな・・・。うぎゃー

( この道、見覚えだけはあるんだけどなー。はたして、おうちは右だっけ? 左だっけ? )

( 家の玄関の風景も覚えてるんだけどなー。そこへの行き方がまったくわかりましぇん T_T )

あの時はやばかった・・・ まぁ、あの時ほど、困るわけではないが、それでも半年に2回くらい、My マークシートリーダーを使っていて、ファイル名の命名規則をド忘れし、今回、冒頭で紹介したエラーメッセージをくり返し登場させてしまう・・・。

その都度、あわてふためき、もう二度とするまいと固く心に誓い、反省し、失敗の原因の記録まで書き、クラウドにはそのバックアップまでとり、それでも、七転び八起きではなく、七転八倒を身上とするかのごとき私は、果てしない後悔の輪廻、そう苦しみと迷いの連鎖の中で、なお、その悲劇を執拗なまでに繰り返してきたのであった。

そもそも、X_01A.jpg、X_01B.jpg みたいな、連番と紛らわしいファイル名を付けるプログラムを作ったのも、 なので、やはり、この負の連鎖は、自分自身に問題の深すぎる根っこが・・・

ぞーぉさん
ぞーぉさん
おーなかがデカいのね・・・

なんかちがう、みたいな・・・

ファイル名が連番でなければ読めないマークシートリーダーであるとわかっているのに、しかも、作ったのが他ならぬ自分自身であるにも関わらず、なぜか、「 X-01A.jpg, X-01B.jpg, x-02A.jpg, X-02B.jpg ・・・」のような、準連番的な?名前の付いたファイルだと、つい安心して、P4D モードで(しつこいようですが、作者である私自身が) マークの読み取りを実行してしまう・・・ T_T

その場合、プログラムの仕様だから当然のごとく、読み取りエラーが発生し・・・

このエラー、なに?

・・・みたいな・・・、決まって毎回、「驚きと焦り」の方が先走って脳内を占拠、「エラーの真の原因=ファイル名が連番でないこと」に、作者である自分自身がなかなか気づかない・・・

だから、バカだと、さっき

さすがに最近はそんなことはないが、以前はコレでさんざん悩んだこともあったのです・・・みなさん。

その My マークシートリーダーで、数学の解答用紙を読み取り、別プログラムで処理(受験者に返却する答案や資料を作成)する方向で、現在、既存のプログラムを改良しているのですが・・・

とある休日の朝、シャワーを浴びながら、なぜか、ふと

(そうだ。この際、アレも何とかしておこう)

と、ようやく思い立ったのです。みなさん。

アレとは、もちろん、P4D 使用時に「ファイル名が連番でないとエラーになること」であります。みなさん。

エラーになって(なぜか?毎回のようにその真の原因を忘れ)あわてふためく前に、予め、読み取り指定フォルダ内の拡張子を小文字に変換すると「jpg」or 「jpeg」になるファイルだけ抽出して、そのファイル名の末尾3桁の半角数字が完全に連番であるか・どうかを調べ、もし、問題がある場合はユーザーに通知して、エラーを未然に防止する、そんなプログラムは・・・ ぎゃはは、Delphi さえあれば、わーらっちゃうくらいカンタンに・・・

すぐ出来る・・・ )

そう軽く考えて、朝から始めた「ファイル名が完全に連番であることを確認する関数」作りに、なんと半日以上、費やしてしまったのであります。みなさん。

たぁーくさんサンプルがあると思ってあちこち調べてみたが(私が調べた範囲では)、Web上にその方法を解説している資料も、サンプル・プログラムも、ついに見つけることができなかったのであります。みなさん。

( もしかして・・・ そんな関数作りは「カンタンすぎる」から、サンプルがないのかなー? )

・・・などと思いつつ、でも、実際にそれを書くとなると、誰も話題にしてないって・・・ なんで? いや、それにしちゃ、なんだかんだ、結構・・・ それなりに難しいぞ、と半日ほど、あーでもない・こーでもないをくり返して・・・ なんとか、自分の環境では、期待通りに動作するものが書けたので、もしかしたら、将来、同じことを実現したくて悩んでおられる方の参考になるかも?しれないと思い、ここに書いておくことにしたわけであります。みなさん。

まず、どなたの役にも立たないカモ・・・ですが。とりあえず、核心部分は、次の通り。

implementation

uses
  //  (略)
  System.RegularExpressions,
  Generics.Collections;

  //System.RegularExpressionsはP4D使用時にファイル名が連番であるかどうかを確認するために追加
  //Generics.Collectionsは上と同じ目的でTListを使うために追加

上記ライブラリを2つ、uses しておいて、プログラム全体で使いまわすわけではないので、Formのメンバーにせず、マークシート画像ファイルを読みだす手続き内から呼び出して使う形で次の関数を記述。

procedure TFormMSReader.ProcDataRead(Sender: TObject);
var
  //  (略)
  strMsg:string;
  Ext1, Ext2: string;
  Extension:string;

  //jpg とjpeg が同一フォルダ内に混在していないことを確認する_20250302追加
  function HasMixedExtensions(const FolderPath: string): Boolean;
  var
    SearchRec: TSearchRec;
    JPGFound, JPEGFound: Boolean;
  begin
    JPGFound := False;
    JPEGFound := False;

    if FindFirst(FolderPath + '\*.jpg', faAnyFile, SearchRec) = 0 then
    begin
      JPGFound := True;
      FindClose(SearchRec);
    end;

    if FindFirst(FolderPath + '\*.jpeg', faAnyFile, SearchRec) = 0 then
    begin
      JPEGFound := True;
      FindClose(SearchRec);
    end;

    Result := JPGFound and JPEGFound;
  end;

  //ファイル名が連番であるかどうか、確認
  function IsSequentialFileNames(const DirPath: String;
    var Extension1, Extension2: String): Boolean;
  var
    FileList: TStringList;
    FileNumbers: TList<Integer>;
    i, j, numStart: Integer;
    tempFileName, fileName, fileNum: string;
  begin

    //Falseで初期化
    Result := False;

    //指定されたディレクトリ内から、指定された拡張子のファイル名を抽出する
    FileList := TStringList.Create;
    FileNumbers := TList<Integer>.Create;

    try

      for j := 0 to 1 do
      begin

        //小文字に変換して拡張子を指定
        case j of
          0:Extension:= LowerCase(Extension1);
          1:Extension:= LowerCase(Extension2);
        end;

        for tempFileName in TDirectory.GetFiles(DirPath, '*' + Extension) do
        begin
          // ファイル名からパスと拡張子を除去
          fileName := TPath.GetFileNameWithoutExtension(tempFileName);
          //数値部分を抽出
          numStart := TRegEx.Match(fileName, '\d+$').Index;
          if numStart <= 0 then
            Exit; // 数値部分がない場合はFalseを返す
          fileNum := Copy(fileName, numStart, Length(fileName) - numStart + 1);
          if TryStrToInt(fileNum, i) then
            FileNumbers.Add(i);
        end;

        //数値部分があるファイルのみ抽出し、比較する
        if FileNumbers.Count > 0 then
        begin
          FileNumbers.Sort;
          for i := 1 to FileNumbers.Count - 1 do
          begin
            if FileNumbers[i] <> FileNumbers[i - 1] + 1 then
              Exit; //連番でない場合はFalseを返す
          end;
          Result := True; //連番である場合はTrueを返す
        end;

      end;

    finally
      FileList.Free;
      FileNumbers.Free;
    end;

  end;

begin

  //文字列型変数 Path に画像ファイルを入れたフォルダへのパスを指定する

  //jpg とjpeg が同一フォルダ内に混在していないことを確認する_20250302追加
  if HasMixedExtensions(Path) then
  begin
    strMsg:='jpg とjpeg の2種類の拡張子が混在しています。'+#13#10+
      '拡張子はjpg か jpeg のどちらかに統一してください。'+#13#10+
      '処理を中止します。';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
    Exit;
  end else begin
    //確認用
    //strMsg:='拡張子の混在はありません!';
    //Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
  end;

  //画像ファイルを読み込む処理でファイル名が連番であるかどうか、確認する
  try
    Ext1:='jpg';
    Ext2:='jpeg';
    if IsSequentialFileNames(Path, Ext1, Ext2) then
    begin
      //確認用
      //strMsg:='ファイル番号は連番です!';
      //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      //Blog用に実験
      //raise Exception.Create('T_T');
    end else begin
      strMsg:='ファイル番号が連番ではありません!';
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
      Exit;
    end;
  except
    on E: Exception do
    begin
      strMsg:='大変です。本物のエラーが発生しました: ' + E.Message;
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
    end;
  end;

end;

なんで、こんなイイことに今まで気づかなかったのか???

だから、バカだと、さっき

*(^_^)*♪

2.連鎖の終止符は?

任意のフォルダに連番でないファイル名を付けたマークシート画像を入れてテスト。

五十音的には「連番」と言えるのだろうか?


MS_Reader.exe を起動して、プログラムが期待通りに動作するか、確認。

読み込む画像が入ったフォルダとして、上の「連番じゃない画像フォルダ」を指定し、画像ファイルを読み込もうとすると・・・

やった! やった!!


MS_Reader.exe が、この世に誕生して5年(くらいかな?)。
ようやく、悲しみの連鎖に終止符が打たれたのであります。みなさん。

あとは、正真正銘のエラーが発生しないことを祈るのみであります。みなさん。

こっちのエラーは、マジでやばい >_<

これだけは見たくないのであります。
みなさん。

でも、よく考えたら(考えなくても)
エラーの連鎖を断ち切るためのメッセージが、

エラーメッセージだった

・・・ということは、

連鎖が断ち切れてるどころか、
これは、むしろ、立派な連鎖ではないでしょうか。みなさん。

私は、
ここに、運命を感じたのであります。
みなさん。

僕のじんせいはー *(^_^)*♪

3.まとめ

一部、変数の宣言が足りないカモですが、フォルダを開く処理まで入れた一連のプログラムコードは、次の通りです。

procedure TFormMSReader.ProcDataRead(Sender: TObject);
const
  //ディレクトリ(フォルダ)の存在を確認 -> なければ作成する
  DataPath='ProcData';
var
  iStartFolder: string;
  iDirectories: TArray<string>;
  Path: string;
  SearchPattern: string;
  Option: TSearchOption;
  FileNames:TStringDynArray;
  FileName:string;
  strFN, strCheckFolder:string;
  strMsg:string;
  Ext1, Ext2: string;
  Extension:string;

  //jpg とjpeg が同一フォルダ内に混在していないことを確認する_20250302追加
  function HasMixedExtensions(const FolderPath: string): Boolean;
  var
    SearchRec: TSearchRec;
    JPGFound, JPEGFound: Boolean;
  begin
    JPGFound := False;
    JPEGFound := False;

    if FindFirst(FolderPath + '\*.jpg', faAnyFile, SearchRec) = 0 then
    begin
      JPGFound := True;
      FindClose(SearchRec);
    end;

    if FindFirst(FolderPath + '\*.jpeg', faAnyFile, SearchRec) = 0 then
    begin
      JPEGFound := True;
      FindClose(SearchRec);
    end;

    Result := JPGFound and JPEGFound;
  end;

  //ファイル名が連番であるかどうか、確認
  function IsSequentialFileNames(const DirPath: String;
    var Extension1, Extension2: String): Boolean;
  var
    FileList: TStringList;
    FileNumbers: TList<Integer>;
    i, j, numStart: Integer;
    tempFileName, fileName, fileNum: string;
  begin

    //Falseで初期化
    Result := False;

    //指定されたディレクトリ内から、指定された拡張子のファイル名を抽出する
    FileList := TStringList.Create;
    FileNumbers := TList<Integer>.Create;

    try

      for j := 0 to 1 do
      begin

        //小文字に変換して拡張子を指定
        case j of
          0:Extension:= LowerCase(Extension1);
          1:Extension:= LowerCase(Extension2);
        end;

        for tempFileName in TDirectory.GetFiles(DirPath, '*' + Extension) do
        begin
          // ファイル名からパスと拡張子を除去
          fileName := TPath.GetFileNameWithoutExtension(tempFileName);

          //数値部分を抽出
          numStart := TRegEx.Match(fileName, '\d+$').Index;
          if numStart <= 0 then
            Exit; // 数値部分がない場合はFalseを返す

          fileNum := Copy(fileName, numStart, Length(fileName) - numStart + 1);
          if TryStrToInt(fileNum, i) then
            FileNumbers.Add(i);

        end;

        //数値部分があるファイルのみ抽出し、比較する
        if FileNumbers.Count > 0 then
        begin
          FileNumbers.Sort;
          for i := 1 to FileNumbers.Count - 1 do
          begin
            if FileNumbers[i] <> FileNumbers[i - 1] + 1 then
              Exit; //連番でない場合はFalseを返す
          end;
          Result := True; //連番である場合はTrueを返す
        end;
      end;
    finally
      FileList.Free;
      FileNumbers.Free;
    end;
  end;

begin

  try

    //読み込むファイルの存在するフォルダを選択

    //Win10のフォルダ選択Dialogを使用する
    iStartFolder := ExpandFileName('.\ProcData');
    if SelectDirectory(iStartFolder, iDirectories,
      [sdHidePinnedPlaces, sdNoDereferenceLinks, sdForceShowHidden,
      sdAllowMultiselect], 'フォルダを選択してください', 'Folder', 'Ok') then
    begin

      //カーソルを待機状態に設定
      Screen.Cursor := crHourGlass;

      //読み込むデータのあるフォルダへのPathを取得
      Path:=iDirectories[0];

      //jpg とjpeg が同一フォルダ内に混在していないことを確認する_20250302追加
      if HasMixedExtensions(Path) then
      begin
        strMsg:='jpg とjpeg の2種類の拡張子が混在しています。'+#13#10+
          '拡張子はjpg か jpeg のどちらかに統一してください。'+#13#10+
          '処理を中止します。';
        Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
        Exit;
      end else begin
        //確認用
        //strMsg:='拡張子の混在はありません!';
        //Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
      end;

      //ファイル名が連番であるかどうか、確認
      try
        Ext1:='jpg';
        Ext2:='jpeg';
        if IsSequentialFileNames(Path, Ext1, Ext2) then
        begin
          //確認用
          //strMsg:='ファイル番号は連番です!';
          //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
          //Blog用に実験
          //raise Exception.Create('T_T');
        end else begin
          strMsg:='ファイル番号が連番ではありません!';
          Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
          Exit;
        end;
      except
        on E: Exception do
        begin
          strMsg:='大変です。本物のエラーが発生しました: ' + E.Message;
          Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
        end;
      end;

      // (省略)

    end;
  finally
    Screen.Cursor := crDefault;
  end;

end;

4.お願いとお断り

今回掲載したプログラムは、拡張子が jpg と jpeg の画像が同一フォルダ内に混在していないことを正常動作の前提にしています。この点には十分、ご注意・ご留意いただけますよう、お願い申し上げます。

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

Python4Delphiが突然、実行できなくなった!

これまで自作のデジタル採点プログラムを使った数学のテストの採点処理は、マークシートの読み取り結果を、表計算ソフトのワークシートに出力する方法で、その最終的な処理を行ってきた。

2024年の年末から、2025年の年始にかけての休暇を利用し、これまで書いてきたデジタル採点プログラムの機能を拡張して、表計算ソフトを使わなくても大問6個までの数学のテストであれば、観点別評価にも対応した合計点の計算や、返却用答案の印刷、得点の平均点・最高点・最低点などの情報を含んだ成績一覧表の自動作成と印刷がひとつのプログラムから実行できるように改良。休暇中にある程度のところまで完成させることができた。

だが、休暇が終わると様々な仕事が次から次に舞い込んで、「あともう少しで完成」・・・というところで作業は完全にストップ。そのまま、ほぼ一月半、デジタル採点関連のプログラム作りは休止状態に。

途中、もちろん休日は何日もあったが、スキーに行ったり、徒歩で神社仏閣を巡る旅(行程24km)に参加したり、冬の山に登ったり、いろいろ楽しく遊んでしまって。

2025年2月22日(土)からの3連休で残りの作業を行って、ずっと気になっていたプログラムの改良を完成させるべく、21日(金)の朝、一月半ぶりにデジタル採点のプロジェクトに触ってみたら、あろうことか、実行(F9)すると「 Python4Delphi 関連のファイルが見つからない」エラーが発生。

これまで、思い出せないくらい何度も繰り返してきた、まったく思いもしなかったところでいきなり転ぶ「いつものパターン」に・・・またハマってしまった・・・内心、そんな気がしてならなかったが、今回も何とか自力で解決。もしかしたら、この記事が同じ悲劇に見舞われた方の目にとまることがあるかもしれないと思い、万一にでもお力添えできれば・・・と。これは、そのトラブルの解決方法のメモです。

【もくじ】

1.プロジェクトが実行(F9)できなくなった!
2.Definition.Inc ファイルも見つからない!
3.GetItパッケージマネージャの内容がヘン!
4.まとめ
5.お願いとお断り

1.プロジェクトが実行(F9)できなくなった!

テストの受験者に採点結果を通知する個票を作成するプログラムのプロジェクトファイルを開き、実行(F9)すると、「 Python4Delphi 関連の非ビジュアルコンポーネントが見つからない」エラーが発生。

(1ヶ月前までは普通に動いていたのに・・・。なんで?)

明日からの3連休で、このプログラムを完成させようと思って作業の準備を始めた矢先、まったく予期しないエラーの発生に、ほんとに心が折れそうになる・・・。

試しにマークシートリーダーや手書き答案の採点プログラムの方も確認してみると、実行(F9)できない状況はほぼ同じ。たとえば、マークシートリーダーのプログラムだとコードの先頭でエラーになり、具体的には・・・

unit UnitXXX;

{$WARN UNIT_PLATFORM OFF}
{$WARN SYMBOL_PLATFORM OFF}
{$WARN SYMBOL_DEPRECATED OFF}

{$I Definition.Inc}  // <- ここでエラーになる。

interface

「[dcc32 致命的エラー] UnitXXX.pas(7): F1026 ファイル ‘Definition.Inc’ が見つかりません。」という、今までさんざん実行(F9)して来て、1回も見たことのないメッセージが表示される。

だいたい、この {$I Definition.Inc} の1行が「なぜ、ここにあるのか」すら、思い出せない。(コレ、いつ・なんのために、誰が書いた?)みたいな疑問が浮かび・・・。でも、自分以外の誰かがこれを書くことはあり得ないので、書いたのは間違いなく自分なのだが、いつ・なんのために、書いたのか、それがどうしても思い出せない。

こういう場合に備えて、当たり前のように思うことでも、なるべくコメントとしてソース内に残し、コードを読み直す必要が生じた時に、行っている処理の内容を確実に思い出せるようにプログラムを書いてきたつもりなのだが、頼りとするはずの・・・そのコメントが見当たらない。

( なんでスルーしちゃったのかなぁ )

仕方がないから、Definition.Inc ファイルを検索してみる。が、少なくともプロジェクトファイルを入れたフォルダ内にはない。バックアップの方も検索してみたが、やはりそちらにもない。しばし考えた後、もしかしたら、Delphiと僕のこれまでのすべてを記録してある Tips ファイルの中に Definition.Inc なる文字列があるかと思い、早速、検索してみると、Python4Delphi の library demodll 関連の資料の中にそれを発見。

(やっぱり Python4Delphi 関連のファイルだったんだ・・・)

つまり、今までは、どこかに「Definition.Inc ファイルがあった」から、このエラーは発生しなかった。でも、今は、どこにも「それがない」から(多分)このエラーは起きている。

(なぜ、無くなったんだ?)

とりあえず、Cドライブ全体をくまなく探してみることにした。

2.Definition.Inc ファイルも見つからない!

Windows キーを押しながら、R キーを押して「ファイル名を指定して実行」の入力画面を出し、そこに「cmd」と入力して Enter キーを押し下げ、コマンドプロンプトを表示。次のコマンドを入力する。

dir C:\ /s /b | findstr /i "Definition.Inc"

こうすると、エクスプローラーの検索よりも速く、正確に検索対象ファイルの有無を知ることができるらしい。

もっとも実行する前から結果はわかっていたが。

( やっぱり、ない! )

3.GetItパッケージマネージャの内容がヘン!

Python4Delphi の非ビジュアルコンポーネントは Form の上に見えているが、それらの実際の動作に必要な「ヘッダファイルやライブラリ関連の情報が失われた」ためにエラーが起きているのではないか・・・と。ようやくここで、朧気ながらエラーの原因らしきものが見えてきた。

試しに GetItパッケージマネージャを開いて Python4Delphi のインストール状況を確認してみる。すると、なんと Python4Delphi が「未インストール」状態になっていた!

実際の画面がこちら。

間違いなくインストールしてあったはずなのに・・・


ちなみにインストールされている状態であれば、このように表示される。

これが正しくインストールされた状態


ここまで来れば、出来のよろしくない僕の頭でも十分、状況が理解できた。2025年1月上旬までは、確かに、PCのどこかに存在していたはずの「Definition.Inc」が、いや、それだけでなく Python4Delphi 関連の設定情報のすべてが「ごっそり削除」されるような事件が「つい最近起きた」に違いないと・・・。

僕はすぐに思い出した。10日ほど前のことだったか・・・。確実にオフに設定しておいたはずの OneDrive のデスクトップとの同期がいつの間にか ON に変更されていることに気づき、同期の設定を手動で OFF にして、デスクトップの表示をローカルPCのそれに修正した「あの時」事件は起きたに違いない。

いつ OneDrive のデスクトップとの同期が ON に変更されたのか、それはわからないが、例えば 24H2 へのアップデート時等にそのような形への設定変更が自動的に(と言うか、勝手に)行われ、連動してPython4Delphi 関連の PATH 等の設定情報も OneDrive 側に自動的に修正された(?)

そのことに気づかないまま、僕は PC を使い続け、同期が ON になっていることに気づいた時点で、同期の設定を手動で OFF にした。そこから見えてくることは(これはあくまでも推測だけど)・・・

・24H2 へのアップデート時に、Python4Delphi の一部の設定ファイルやライブラリが OneDrive フォルダ側に移動した?

・Delphi の GetIt パッケージマネージャに記録されている、インストール済みのパッケージのパス情報もOneDrive 側を参照するように自動的に調整された?( or 最初から OneDrive 側だった?)

・OneDrive との同期を手動で OFF に変更したため、Python4Delphi のファイルが移動・消失したのと同じ状態になり、これを Delphi の GetIt パッケージマネージャは「未インストール」と判断した?

とにかく、Python4Delphi が「未インストール」状態になっているのは事実。ならば、再インストールするしかない!

もう迷うことはない。(これで直る)その確信を持ってインストールボタンをクリックする。もちろん、インストールは何の問題もなく順調に終了。

先ほどと同様にコマンドプロンプトを表示し、「Definition.Inc」を検索すると・・・

( あった!)

Definition.Inc をちゃんとCドライブ内に発見!


ローカルの Documents フォルダの中に、今、それがあるということは・・・。

( そうか! いつの間にか OneDrive の Documents を参照する設定になっていたんだ。)

( だから同期を OFF にして、Documents フォルダの内容を削除した際に・・・ PATH も消え・・・ )

エラーの真の原因を理解☆


何でこんなことになったのか? 自問自答して得た結論は・・・

それが、もしかして Windows11 の仕様???

24H2 にアップデートした際に、Documents フォルダやデスクトップへの PATH を確認すべきだった。

いや、後悔しても始まらない。
ここは前向きに、「今後、OS をアップデートした際は、PATH の設定を必ず確認する」ことにしよう!

新しい自分との約束が出来た☆


で、最終確認。

実行(F9)するとエラーが発生したプロジェクトファイルを次から次へ開き、今度は問題なく実行(F9)出来ることを確かめる。

すべて何事もなかったかのように実行(F9)できた!!

なおったー☆

4.まとめ

・OS をアップデートしたら Documents フォルダやデスクトップへの PATH を必ず確認する。

5.お願いとお断り

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

PDFファイルから任意のページを抽出してマージする

ある朝、職場の同僚から「様式(ページ構成)が同じPDFファイルが大量にあるんだが、2ページ目以降は不要なので、1ページ目だけを抽出して、1つのPDFファイルに結合・印刷できるようにしてもらえないか?」との依頼を受けた。

急な依頼だったので、とりあえず任意のフォルダに保存されているPDFファイルの1ページ目だけを抽出するバッチファイルを作って依頼者に渡し、なんとかその場を凌いだが・・・。

以前から Delphi でPDFファイルを操作する方法に関心があり、PDFを画像化するプログラムなどを書いてみたことがあったが、指定ページを抽出する方法や複数のPDFファイルを結合して1つにまとめる方法はわからないままだった。いい機会なのでちゃんと勉強してみることにした。これはその備忘録。

【もくじ】

1.使用するツール
2.指定ページを抽出
3.PDFファイルを結合
4.進捗状況も表示
5.まとめ
  エラー対策1・2を追記(20250211)
  プログラムコード
  PDFtkのインストールの有無を確認する方法を追記(20250218)
6.お願いとお断り

1.使用するツール

PDFファイルの抽出や結合を実行するために使用したのは「PDFtk Server」というコマンドラインから実行するツール。

PDF Labs
https://www.pdflabs.com/tools/pdftk-server/?form=MG0AV3

リンク先ページの中ほどに「Microsoft Windows」というタイトルがあり、「Click to download the PDFtk Server installer for Windows 10 and 11:」という説明の下に「Windows Download」があるので、これをクリックして「pdftk_server-2.02-win-setup.exe」(2025年2月9日現在)をダウンロードしてインストールしておく。

【インストール後、PATHの登録を必ず確認してください】

インストールしたら、システム環境変数のPATHに「pdftk.exe」までのパスが正しく登録されていることを必ず確認する。

「pdftk.exe」までのパスが正しく登録されていることを必ず確認してください。
(図は PDFtk の設定を変更せずにインストールした場合の設定です)


【重要な注意】

インストールした「pdftk.exe」までの PATH をシステム環境変数の PATH に登録せず、「プログラム内で文字列として指定」した場合、ここで紹介するプログラムコードは 動作しません!

2.指定ページを抽出

まず、GUIを作成。


exe のあるフォルダ内に src と dst という名称のフォルダも用意する。


指定ページを抽出する方法は、次の通り。

  private
    procedure ExtractPDFs(const InputDir, OutputDir: string; PageNum: Integer);

implementation

uses
  Winapi.ShellAPI, System.IOUtils;

{$R *.dfm}

{ TForm1 }

procedure TForm1.Button1Click(Sender: TObject);
var
  InputDir, OutputDir: string;
  strMsg: string;
begin
  if ComboBox1.Text = '' then
  begin
    strMsg := '抽出するページを指定してください';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONSTOP);
    ComboBox1.SetFocus;
    Exit;
  end;

  InputDir := ExtractFilePath(Application.ExeName) + 'src\';
  OutputDir := ExtractFilePath(Application.ExeName) + 'dst\';

  //出力フォルダが存在しない場合は作成
  if not DirectoryExists(OutputDir) then
  begin
    ForceDirectories(OutputDir);
  end;

  ExtractPDFs(InputDir, OutputDir, StrToInt(ComboBox1.Text));
end;

procedure TForm1.ExtractPDFs(const InputDir, OutputDir: string;
  PageNum: Integer);
var
  SearchRec: TSearchRec;
  TempPDFs: TStringList;
  Command, TempPDF, ExtractedPDF, LogFile: string;
  strMsg: string;
  PDFtkPath: string;

  //コマンド実行関数(プロセス完了待ち)
  function ExecuteCommand(const Command: string): Boolean;
  var
    StartupInfo: TStartupInfo;
    ProcessInfo: TProcessInformation;
    //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
    CmdLine: array[0..MAX_PATH] of Char;
  begin
    FillChar(StartupInfo, SizeOf(TStartupInfo), 0);
    StartupInfo.cb := SizeOf(TStartupInfo);
    StartupInfo.dwFlags := STARTF_USESHOWWINDOW;
    StartupInfo.wShowWindow := SW_HIDE;

    //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
    StrPCopy(CmdLine, Command);
    Result := CreateProcess(nil, CmdLine, nil, nil, False, CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo);
    if Result then
    begin
      WaitForSingleObject(ProcessInfo.hProcess, INFINITE);
      CloseHandle(ProcessInfo.hProcess);
      CloseHandle(ProcessInfo.hThread);
    end;
  end;

begin

  //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
  //PDFtkPath := '"C:\Program Files (x86)\PDFtk Server\bin\pdftk.exe"';

  //pdftk.exe の PATH は、システム環境変数の PATH で設定する
  PDFtkPath := 'pdftk';

  //エラーがあった場合はLogファイルにエラー内容を出力する
  LogFile := IncludeTrailingPathDelimiter(OutputDir) + 'log.txt';

  TempPDFs := TStringList.Create;
  try
    //指定フォルダ内のすべての PDF を検索
    if FindFirst(IncludeTrailingPathDelimiter(InputDir) + '*.pdf', faAnyFile, SearchRec) = 0 then
    begin
      try
        repeat
          TempPDF := IncludeTrailingPathDelimiter(OutputDir) + 'temp_' +
            IntToStr(TempPDFs.Count) + '.pdf';

          //PDFtkをシステム環境変数のPathに正しく指定してある場合
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
            [PDFtkPath, IncludeTrailingPathDelimiter(InputDir) +
            SearchRec.Name, PageNum, TempPDF, LogFile]);

          //pdftk を実行して指定ページを抽出
          if ExecuteCommand(Command) then
          begin
            TempPDFs.Add(TempPDF);
          end;

        until FindNext(SearchRec) <> 0;
      finally
        FindClose(SearchRec);
      end;
    end;

    if TempPDFs.Count > 0 then
    begin
      ExtractedPDF := IncludeTrailingPathDelimiter(OutputDir) + 'filelist.txt';
      TempPDFs.SaveToFile(ExtractedPDF); // ファイルリストを保存
    end;

    //Information
    strMsg := '続けて結合も実行しますか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      Button2Click(Button1);
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='抽出ページをマージする場合は結合ボタンをクリックしてください';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;

  finally
    TempPDFs.Free;
  end;
end;

3.PDFファイルを結合

ページを抽出後、そのまま結合させることも当然考えたが、処理の確実性を最優先して、別々の手続きに分けて記述することにした。コードは次の通り。

  private
    procedure ExtractPDFs(const InputDir, OutputDir: string; PageNum: Integer);
    procedure MergePDFs;

implementation

uses
  Winapi.ShellAPI,
  System.IOUtils;

procedure TForm1.Button2Click(Sender: TObject);
var
  strMsg: string;
begin
  try
    MergePDFs;
    strMsg:='PDFの結合が完了しました!';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
  except
    on E: Exception do
    begin
      strMsg:='エラー: ' + E.Message;
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
    end;
  end;
end;

procedure TForm1.MergePDFs;
var
  //ShellExecuteを使用
  //InputDir, OutputFile, Command: string;

  //CreateProcessを使用
  InputDir, OutputFile, Command, CmdLine: string;
  StartupInfo: TStartupInfo;
  ProcessInfo: TProcessInformation;
  strMsg: string;
begin

  //ShellExecuteを使用
  {
  InputDir := ExtractFilePath(Application.ExeName)+'dst\';
  OutputFile := InputDir + 'MergedOutput.pdf';
  //既存のファイルがあれば削除する
  if FileExists(OutputFile) then
  begin
    //削除
    DeleteFile(OutputFile);
  end;
  //pdftkコマンドの構築(すべてのPDFを結合)
  Command := Format('cmd /c pdftk "%s*.pdf" cat output "%s"', [InputDir, OutputFile]);
  //ShellExecuteでpdftkを実行
  ShellExecute(0, 'open', 'cmd.exe', PChar(Command), nil, SW_HIDE);
  }

  //CreateProcessを使用
  InputDir := IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName)) + 'dst\';
  OutputFile := InputDir + 'MergedOutput.pdf';

  //既存のファイルがあれば削除する
  if FileExists(OutputFile) then
  begin
    //削除
    DeleteFile(OutputFile);
  end;

  //pdftkコマンドの構築(すべてのPDFを結合)
  Command := Format('pdftk "%s" cat output "%s"', [InputDir + '*.pdf', OutputFile]);

  //コマンドラインを `cmd.exe /c` でラップ
  CmdLine := Format('cmd.exe /c %s', [Command]);

  // `CreateProcess` の設定
  FillChar(StartupInfo, SizeOf(TStartupInfo), 0);
  StartupInfo.cb := SizeOf(TStartupInfo);
  StartupInfo.dwFlags := STARTF_USESHOWWINDOW;
  StartupInfo.wShowWindow := SW_HIDE;

  if CreateProcess(nil, PChar(CmdLine), nil, nil, False, CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo) then
  begin
    //プロセスが完了するのを待つ
    WaitForSingleObject(ProcessInfo.hProcess, INFINITE);
    //ハンドルを閉じる
    CloseHandle(ProcessInfo.hProcess);
    CloseHandle(ProcessInfo.hThread);
  end else begin
    strMsg:='PDFの結合に失敗しました。pdftkが正しくインストールされているか確認してください。';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
  end;

end;

4.進捗状況も表示

進捗状況も表示できるようにした。プログラムコードは「5.まとめ」の最後に掲載。
(Formに StatusBar と ProgressBar を1つずつ追加)

【実行時の画面】

ProgressBar は、StatusBar に埋め込んで表示する。

5.まとめ

テスト用にファイル名が半角数字「001~100」の100個のPDFファイルを作成して実行。半角数字のファイル名であれば、エラーなく実行できることを確認。
ただし、My環境では、ファイル名に「全角・半角・英数字・記号」が混在しているとエラーになりました。このエラーの発生原因の詳細が判明しましたら、後日追記します。

追記(20250211)

上記エラーの発生原因について調査した結果、PDFtk に渡す PATH に「半角スペース」が混じっているとエラーが発生することが判明。そこで、エラーの発生を防止するため、次の対策1・2を行った。

【対策1】

PDFtk に渡す PATH の文字列をダブルクオートで囲んでから渡すように修正。

  TempPDFs := TStringList.Create;
  try
    //指定フォルダ内のすべての PDF を検索
    if FindFirst(IncludeTrailingPathDelimiter(InputDir) + '*.pdf', faAnyFile, SearchRec) = 0 then
    begin
      try
        repeat
          TempPDF := IncludeTrailingPathDelimiter(OutputDir) + 'temp_' +
            IntToStr(TempPDFs.Count) + '.pdf';

          //PDFtkをシステム環境変数のPathに正しく指定してある場合
          //PDFファイル名に半角スペースが含まれていると
          //多数のファイルを処理する場合、確実にエラーが発生する
          {
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
            [PDFtkPath, IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name,
            PageNum, TempPDF, LogFile]);
          }

          //PDFtkに渡すPATHをダブルクオートで囲んで渡すように修正
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
          [PDFtkPath, '"'+IncludeTrailingPathDelimiter(InputDir)+SearchRec.Name+'"',
          PageNum, TempPDF, LogFile]);

          //pdftk を実行して指定ページを抽出
          if ExecuteCommand(Command) then
          begin
            TempPDFs.Add(TempPDF);
            //省略
          end;

【対策2】

対策1を行った後もエラーが発生。PDFtk に渡す PATH をダブルクオートで囲んでもエラーの発生を防止することはできないようだ。そこで、「長いファイルパスや特殊文字を含むパスを 短縮形式(8.3形式) に変換することで問題を回避できるのでは・・・?」と考え、PATH を短縮形式(8.3形式) に変換してから PDFtk に渡すように修正。

procedure TForm1.ExtractPDFs(const InputDir, OutputDir: string;
  PageNum: Integer);
var
  SearchRec: TSearchRec;
  //略

  //指定フォルダ内にあるPDFファイルの数を取得
  function GetPDFFileCount(const FolderPath: string): Integer;
  var
    Files: TArray<string>;
  begin
    //略
  end;

  function GetShortPath(const LongPath: string): string;
  var
    ShortPath: array[0..MAX_PATH] of Char;
  begin
    if GetShortPathNameW(PChar(LongPath), ShortPath, MAX_PATH) > 0 then
      Result := ShortPath
    else
      Result := LongPath; // 失敗時はそのまま
  end;

  //8.3 名(短縮名)が使えるかどうか確認(C:\Program Files でチェック)
  function Is8dot3NameAvailable(const Path: string): Boolean;
  var
    ShortPath: array[0..MAX_PATH] of Char;
  begin
    FillChar(ShortPath, SizeOf(ShortPath), 0);
    if GetShortPathNameW(PChar(Path), ShortPath, MAX_PATH) > 0 then
      Result := StrComp(ShortPath, PChar(Path)) <> 0  // 短縮名が取得できたか
    else
      Result := False;
  end;

  //Cドライブの 8.3 名を有効に設定
  procedure Enable8dot3Name(DriveLetter: Char);
  var
    Command: string;
  begin
    //fsutil コマンドで 8.3 名を有効化
    Command := Format('fsutil 8dot3name set %s: 0', [DriveLetter]);
    if ShellExecute(0, 'runas', 'cmd.exe', PChar('/c ' + Command), nil, SW_HIDE) <= 32 then
    begin
      strMsg:='8.3 名の有効化に失敗しました。管理者権限で実行してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end else begin
      strMsg:=Format('%s: ドライブの 8.3 名を有効にしました。', [DriveLetter]);
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

  //システム全体の 8.3 名を有効化
  procedure Enable8dot3NameForAllDrives;
  var
    Command: string;
  begin
    //fsutil コマンドでシステム全体の 8.3 名を有効化
    Command := 'fsutil behavior set disable8dot3 0';
    if ShellExecute(0, 'runas', 'cmd.exe', PChar('/c ' + Command), nil, SW_HIDE) <= 32 then
    begin
      strMsg:='8.3 名の有効化に失敗しました。管理者権限で実行してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end else begin
      strMsg:='すべてのドライブで 8.3 名を有効にしました。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

begin

  //8.3 名(短縮名)が使えるかどうか確認(C:\Program Files でチェック)
  if Is8dot3NameAvailable('C:\Program Files') then
  begin
    if CheckBox1.Checked then
    begin
      strMsg:='8.3 名は有効です';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end else begin
    strMsg:='Windowsでは、長いファイルパスや特殊文字を含むパスを 短縮形式(8.3形式) に変換することで問題を回避できます。'+
      '現在、8.3 名(短縮名)は無効です。有効化しますか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      strMsg:='システム全体で有効化しますか?'+#13#10+#13#10+
        '「いいえ」を選択した場合、Cドライブのみ有効化されます。';
      if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
      begin
        Enable8dot3NameForAllDrives;
        //[はい]が選ばれた時
        strMsg:='8.3 名(短縮名)をシステム全体で有効化しました!';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end else begin
        //[いいえ]が選ばれた時
        Enable8dot3Name('C');
        strMsg:='Cドライブで、8.3 名(短縮名)を有効化しました!';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end;
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='長いファイルパスや特殊文字を含むパスは使用できません。'+
        '注意してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

  //略

  TempPDFs := TStringList.Create;
  try
    //指定フォルダ内のすべての PDF を検索
    if FindFirst(IncludeTrailingPathDelimiter(InputDir) + '*.pdf', faAnyFile, SearchRec) = 0 then
    begin
      try
        repeat
          TempPDF := IncludeTrailingPathDelimiter(OutputDir) + 'temp_' +
            IntToStr(TempPDFs.Count) + '.pdf';

          //PDFtkをシステム環境変数のPathに正しく指定してある場合
          //PDFファイル名に半角スペースが含まれていると
          //多数のファイルを処理する場合、確実にエラーが発生する
          {
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
            [PDFtkPath, IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name,
            PageNum, TempPDF, LogFile]);
          }

          //PDFtkに渡すPATHをダブルクオートで囲んで渡すように修正
          //さらに短縮形式(8.3形式) に変換して渡すように修正
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
          [PDFtkPath, '"'+GetShortPath(IncludeTrailingPathDelimiter(InputDir)+SearchRec.Name)+'"',
          PageNum, TempPDF, LogFile]);

          //pdftk を実行して指定ページを抽出
          if ExecuteCommand(Command) then
          begin
            TempPDFs.Add(TempPDF);
            //省略
          end;

ただし、GetShortPathNameW は、ローカルファイルシステムの NTFS/FAT32 に保存されているファイルの短縮名を取得する API であり、UNC パスのような ネットワーク共有上のファイルには対応していない。そこで exe がローカルな環境で実行されていない場合は、Form の表示終了時にユーザーに警告してプログラムを終了するように修正。

Winapi.Shlwapi を uses することで、他の手続きで使用していた StrToInt 関数でエラーが発生。こちらはSystem.SysUtils.StrToInt のように参照先を明示してエラーを回避。

  private
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;

implementation

uses
  Winapi.ShellAPI,
  System.IOUtils,
  Winapi.Shlwapi;

  //Shlwapiはexeの起動PATHの確認に使用
  //ShlwapiにもStrToInt関数があるので StrToInt関数は
  //System.SysUtils.StrToInt のように明示的に使用する

procedure TForm1.CMShowingChanged(var Msg: TMessage);
var
  strMsg:string;

  function IsUNCPath(const Path: string): Boolean;
  begin
    Result := PathIsUNC(PChar(Path));
  end;

  procedure CheckExePath;
  var
    ExePath: string;
  begin
    ExePath := ExtractFilePath(Application.ExeName);
    if IsUNCPath(ExePath) then
    begin
      strMsg:='EXE はネットワーク上の UNC パスで実行されています!'+#13#10+
        'プログラムが安定動作しない可能性があります。'+#13#10+
        'ローカル環境で実行してください。'+#13#10+
        '安全のため、プログラムを終了します。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      Close;
    end;
  end;
begin
  inherited;
  if Visible then
  begin
    Update;
    //実行PATHをチェック
    CheckExePath;
  end;
end;


GUI も修正。


上記対策を行った結果、(My環境では)半角スペースを含む PATH を PDFtk に渡してもエラーが発生することなく、すべてのファイルから指定ページを抽出・結合することができることを確認。

【プログラムコード】

作成の経過が後から見てわかるよう、古いコードをコメント化して残してあるなど、あちこちに冗長な部分があります。あくまでも参考まで。

unit Unit1;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics,
  Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, Vcl.ComCtrls;

type
  TForm1 = class(TForm)
    Button1: TButton;
    Button2: TButton;
    GroupBox1: TGroupBox;
    Label_01: TLabel;
    Label_02: TLabel;
    Label_04: TLabel;
    ComboBox1: TComboBox;
    Label_03: TLabel;
    Button3: TButton;
    StatusBar1: TStatusBar;
    ProgressBar1: TProgressBar;
    CheckBox1: TCheckBox;
    procedure Button1Click(Sender: TObject);
    procedure Button2Click(Sender: TObject);
    procedure Button3Click(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure FormShow(Sender: TObject);
  private
    procedure ExtractPDFs(const InputDir, OutputDir: string; PageNum: Integer);
    procedure MergePDFs;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;
  public
  end;

var
  Form1: TForm1;

implementation

uses
  Winapi.ShellAPI,
  System.IOUtils,
  Winapi.Shlwapi;

  //Shlwapiはexeの起動PATHの確認に使用
  //ShlwapiにもStrToInt関数があるので StrToInt関数は
  //System.SysUtils.StrToInt のように明示的に使用する

{$R *.dfm}

{ TForm1 }

procedure TForm1.Button1Click(Sender: TObject);
var
  InputDir, OutputDir: string;
  strMsg: string;
begin
  if ComboBox1.Text = '' then
  begin
    strMsg := '抽出するページを指定してください';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONSTOP);
    ComboBox1.SetFocus;
    Exit;
  end;

  InputDir := ExtractFilePath(Application.ExeName) + 'src\';
  OutputDir := ExtractFilePath(Application.ExeName) + 'dst\';

  //出力フォルダが存在しない場合は作成
  if not DirectoryExists(OutputDir) then
  begin
    ForceDirectories(OutputDir);
  end;

  ExtractPDFs(InputDir, OutputDir, System.SysUtils.StrToInt(ComboBox1.Text));

end;

procedure TForm1.Button2Click(Sender: TObject);
var
  strMsg: string;
begin
  try
    MergePDFs;
    strMsg:='PDFの結合が完了しました!';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
  except
    on E: Exception do
    begin
      strMsg:='エラー: ' + E.Message;
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
    end;
  end;
end;

procedure TForm1.Button3Click(Sender: TObject);
begin
  Close;
end;

procedure TForm1.CMShowingChanged(var Msg: TMessage);
var
  strMsg:string;

  function IsUNCPath(const Path: string): Boolean;
  begin
    Result := PathIsUNC(PChar(Path));
  end;

  procedure CheckExePath;
  var
    ExePath: string;
  begin
    ExePath := ExtractFilePath(Application.ExeName);
    if IsUNCPath(ExePath) then
    begin
      strMsg:='EXE はネットワーク上の UNC パスで実行されています!'+#13#10+
        'プログラムが安定動作しない可能性があります。'+#13#10+
        'ローカル環境で実行してください。'+#13#10+
        '安全のため、プログラムを終了します。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      Close;
    end else begin
      //何もしない
      //strMsg:='EXE はローカルディスク上で実行されています。';
      //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //ここにやりたいことを書いていく
    //実行PATHをチェック
    CheckExePath;
  end;
end;

procedure TForm1.ExtractPDFs(const InputDir, OutputDir: string;
  PageNum: Integer);
var
  SearchRec: TSearchRec;
  TempPDFs: TStringList;
  Command, TempPDF, ExtractedPDF, LogFile: string;
  strMsg: string;
  PDFtkPath: string;
  intNum, PDFCount: Integer;

  //指定フォルダ内にあるPDFファイルの数を取得
  function GetPDFFileCount(const FolderPath: string): Integer;
  var
    Files: TArray<string>;
  begin
    //Result := 0;
    if not DirectoryExists(FolderPath) then
      raise Exception.CreateFmt('Directory %s does not exist.', [FolderPath]);

    Files := TDirectory.GetFiles(FolderPath, '*.pdf', TSearchOption.soTopDirectoryOnly);
    Result := Length(Files);
  end;

  // コマンド実行関数(プロセス完了待ち)
  function ExecuteCommand(const Command: string): Boolean;
  var
    StartupInfo: TStartupInfo;
    ProcessInfo: TProcessInformation;
    //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
    CmdLine: array[0..MAX_PATH] of Char;
  begin
    FillChar(StartupInfo, SizeOf(TStartupInfo), 0);
    StartupInfo.cb := SizeOf(TStartupInfo);
    StartupInfo.dwFlags := STARTF_USESHOWWINDOW;
    StartupInfo.wShowWindow := SW_HIDE;

    //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
    StrPCopy(CmdLine, Command);
    Result := CreateProcess(nil, CmdLine, nil, nil, False, CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo);
    if Result then
    begin
      WaitForSingleObject(ProcessInfo.hProcess, INFINITE);
      CloseHandle(ProcessInfo.hProcess);
      CloseHandle(ProcessInfo.hThread);
    end;
  end;

  function GetShortPath(const LongPath: string): string;
  var
    ShortPath: array[0..MAX_PATH] of Char;
  begin
    //if GetShortPathName(PChar(LongPath), ShortPath, MAX_PATH) > 0 then
    if GetShortPathNameW(PChar(LongPath), ShortPath, MAX_PATH) > 0 then
      Result := ShortPath
    else
      Result := LongPath; // 失敗時はそのまま
  end;

  //8.3 名(短縮名)が使えるかどうか確認(C:\Program Files でチェック)
  function Is8dot3NameAvailable(const Path: string): Boolean;
  var
    ShortPath: array[0..MAX_PATH] of Char;
  begin
    FillChar(ShortPath, SizeOf(ShortPath), 0);
    //if GetShortPathName(PChar(Path), ShortPath, MAX_PATH) > 0 then
    if GetShortPathNameW(PChar(Path), ShortPath, MAX_PATH) > 0 then
      Result := StrComp(ShortPath, PChar(Path)) <> 0  // 短縮名が取得できたか
    else
      Result := False;
  end;

  //Cドライブの 8.3 名を有効に設定
  procedure Enable8dot3Name(DriveLetter: Char);
  var
    Command: string;
  begin
    //fsutil コマンドで 8.3 名を有効化
    Command := Format('fsutil 8dot3name set %s: 0', [DriveLetter]);
    if ShellExecute(0, 'runas', 'cmd.exe', PChar('/c ' + Command), nil, SW_HIDE) <= 32 then
    begin
      strMsg:='8.3 名の有効化に失敗しました。管理者権限で実行してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end else begin
      strMsg:=Format('%s: ドライブの 8.3 名を有効にしました。', [DriveLetter]);
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

  //システム全体の 8.3 名を有効化
  procedure Enable8dot3NameForAllDrives;
  var
    Command: string;
  begin
    //fsutil コマンドでシステム全体の 8.3 名を有効化
    Command := 'fsutil behavior set disable8dot3 0';
    if ShellExecute(0, 'runas', 'cmd.exe', PChar('/c ' + Command), nil, SW_HIDE) <= 32 then
    begin
      strMsg:='8.3 名の有効化に失敗しました。管理者権限で実行してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end else begin
      strMsg:='すべてのドライブで 8.3 名を有効にしました。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

begin

  //8.3 名(短縮名)が使えるかどうか確認(C:\Program Files でチェック)
  if Is8dot3NameAvailable('C:\Program Files') then
  begin
    if CheckBox1.Checked then
    begin
      strMsg:='8.3 名は有効です';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end else begin
    strMsg:='Windowsでは、長いファイルパスや特殊文字を含むパスを 短縮形式(8.3形式) に変換することで問題を回避できます。'+
      '現在、8.3 名(短縮名)は無効です。有効化しますか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      strMsg:='システム全体で有効化しますか?'+#13#10+#13#10+
        '「いいえ」を選択した場合、Cドライブのみ有効化されます。';
      if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
      begin
        Enable8dot3NameForAllDrives;
        //[はい]が選ばれた時
        strMsg:='8.3 名(短縮名)をシステム全体で有効化しました!';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end else begin
        //[いいえ]が選ばれた時
        Enable8dot3Name('C');
        strMsg:='Cドライブで、8.3 名(短縮名)を有効化しました!';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end;
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='長いファイルパスや特殊文字を含むパスは使用できません。'+
        '注意してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

  //ProgressBar
  ProgressBar1.Visible:=True;
  ProgressBar1.Min:=0;                    //最小値
  ProgressBar1.Position:=0;               //現在の値
  ProgressBar1.Step:=1;                   //増分値

  //カウンタ変数の初期化
  intNum:=0;

  //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
  //PDFtkPath := '"C:\Program Files (x86)\PDFtk Server\bin\pdftk.exe"';

  //pdftk.exe の PATH は、システム環境変数の PATH で設定する
  PDFtkPath := 'pdftk';

  //エラーがあった場合はLogファイルにエラー内容を出力する
  LogFile := IncludeTrailingPathDelimiter(OutputDir) + 'log.txt';

  //指定フォルダ内にあるPDFファイルの数を取得
  PDFCount := GetPDFFileCount(InputDir);

  //進捗状況の表示
  StatusBar1.SimpleText:='進捗状況:';
  ProgressBar1.Visible:=True;
  ProgressBar1.Max:=PDFCount;   //最大値

  TempPDFs := TStringList.Create;
  try
    //指定フォルダ内のすべての PDF を検索
    if FindFirst(IncludeTrailingPathDelimiter(InputDir) + '*.pdf', faAnyFile, SearchRec) = 0 then
    begin
      try
        repeat
          TempPDF := IncludeTrailingPathDelimiter(OutputDir) + 'temp_' +
            IntToStr(TempPDFs.Count) + '.pdf';

          //PDFtkをシステム環境変数のPathに正しく指定してある場合
          //PDFファイル名に半角スペースが含まれていると
          //多数のファイルを処理する場合、確実にエラーが発生する
          {
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
            [PDFtkPath, IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name,
            PageNum, TempPDF, LogFile]);
          }

          //PDFtkに渡すPATHをダブルクオートで囲んで渡すように修正
          //さらに短縮形式(8.3形式) に変換して渡すように修正
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
          [PDFtkPath, '"' + GetShortPath(IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name) + '"',
          PageNum, TempPDF, LogFile]);

          //ShowMessage('"' + IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name + '"');

          //pdftk を実行して指定ページを抽出
          if ExecuteCommand(Command) then
          begin
            TempPDFs.Add(TempPDF);

            //ProgressBar
            intNum:=intNum+1;  // <- 記述を忘れないこと!
            //値を増やす時
            If ProgressBar1.Position < ProgressBar1.Max Then
            begin
              //目的の値より一つ大きくしてから、目的の値にする
              ProgressBar1.Position:=intNum+1;
              ProgressBar1.Position:=intNum;
            end else begin
              //最大値にする時
              //最大値を1つ増やしてから、元に戻す
              ProgressBar1.Max:=PDFCount+1;
              ProgressBar1.Position:=intNum+1;
              ProgressBar1.Max:=PDFCount;
              ProgressBar1.Position:=intNum;
            end;
            //処理の表示を止めないおまじない
            Application.ProcessMessages;

          end;

        until FindNext(SearchRec) <> 0;
      finally
        FindClose(SearchRec);
      end;
    end;

    //初期化
    ProgressBar1.Position:=0;

    if TempPDFs.Count > 0 then
    begin
      ExtractedPDF := IncludeTrailingPathDelimiter(OutputDir) + 'filelist.txt';
      TempPDFs.SaveToFile(ExtractedPDF); // ファイルリストを保存
    end;

    // Information_YesNo
    strMsg := '続けて結合も実行しますか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      Button2Click(Button1);
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='抽出ページをマージする場合は結合ボタンをクリックしてください';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;

  finally
    TempPDFs.Free;
    //進捗状況の表示
    StatusBar1.SimpleText:='';
    ProgressBar1.Visible:=False;
  end;
end;

procedure TForm1.FormCreate(Sender: TObject);
var
  i, w:integer;
begin
  //StatusBarの設定
  StatusBar1.SimplePanel:=True;
  //プログレスバーの初期化
  with ProgressBar1 do begin
    Parent  :=StatusBar1;
    Top     :=2;  //表示位置の調整
    w:= StatusBar1.Canvas.TextWidth('進捗状況:');
    Left    :=w;
    //Left    :=100;  //表示位置の調整
    Height  :=StatusBar1.Height-2;
    Width := StatusBar1.Width-20;
    Visible :=False;
  end;

  //抽出するページの選択肢を作成
  for i := 1 to 999 do
  begin
    ComboBox1.Items.Add(IntToStr(i));
  end;
end;

procedure TForm1.FormShow(Sender: TObject);
begin
  //Formを画面の中央に表示
  Left:=(Screen.Width-Width) div 2;
  Top:=(Screen.Height-Height) div 2;
end;

procedure TForm1.MergePDFs;
var
  //ShellExecuteを使用
  //InputDir, OutputFile, Command: string;

  //CreateProcessを使用
  InputDir, OutputFile, Command, CmdLine: string;
  StartupInfo: TStartupInfo;
  ProcessInfo: TProcessInformation;
  strMsg: string;
begin

  //ShellExecuteを使用
  {
  InputDir := ExtractFilePath(Application.ExeName)+'dst\';
  OutputFile := InputDir + 'MergedOutput.pdf';
  //既存のファイルがあれば削除する
  if FileExists(OutputFile) then
  begin
    //削除
    DeleteFile(OutputFile);
  end;
  //pdftkコマンドの構築(すべてのPDFを結合)
  Command := Format('cmd /c pdftk "%s*.pdf" cat output "%s"', [InputDir, OutputFile]);
  //ShellExecuteでpdftkを実行
  ShellExecute(0, 'open', 'cmd.exe', PChar(Command), nil, SW_HIDE);
  }

  //CreateProcessを使用
  InputDir := IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName)) + 'dst\';
  OutputFile := InputDir + 'MergedOutput.pdf';

  //既存のファイルがあれば削除する
  if FileExists(OutputFile) then
  begin
    //削除
    DeleteFile(OutputFile);
  end;

  //pdftkコマンドの構築(すべてのPDFを結合)
  Command := Format('pdftk "%s" cat output "%s"', [InputDir + '*.pdf', OutputFile]);

  //コマンドラインを `cmd.exe /c` でラップ
  CmdLine := Format('cmd.exe /c %s', [Command]);

  // `CreateProcess` の設定
  FillChar(StartupInfo, SizeOf(TStartupInfo), 0);
  StartupInfo.cb := SizeOf(TStartupInfo);
  StartupInfo.dwFlags := STARTF_USESHOWWINDOW;
  StartupInfo.wShowWindow := SW_HIDE;

  if CreateProcess(nil, PChar(CmdLine), nil, nil, False, CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo) then
  begin
    //プロセスが完了するのを待つ
    WaitForSingleObject(ProcessInfo.hProcess, INFINITE);
    //ハンドルを閉じる
    CloseHandle(ProcessInfo.hProcess);
    CloseHandle(ProcessInfo.hThread);
  end else begin
    strMsg:='PDFの結合に失敗しました。pdftkが正しくインストールされているか確認してください。';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
  end;

end;

end.

追記(20250218)

システム環境変数に PDFtk への PATH が正しく設定されているか、どうかを調べる他に、もう一つ、PDFtk がインストールされているか、どうかを確認する方法も調べてみた。

コマンド・プロンプトを起動して、下記のように「pdftk –version」と入力し、Enter キーを押し下げると、PDFtkがインストールされていれば、次のように応答が返る。


このことを確認しておいて、プログラムを書き、実行するとエラーが発生。当初、なぜエラーになる(IsPDFtkInstalled 関数が False を返す)のか、わからなかったが、出力を確認したところ、ようやく原因が判明。出力は次の通り。

出力が「文字化け」している・・・


シェルの出力を UTF-8 として処理するよう、プログラムを修正。

AStream := TStringStream.Create('', TEncoding.UTF8);

で、ここに出力して・・・

AStream.WriteBuffer(ABuffer, ARead);

さらに StringList に入れて「小文字」にして、出力結果に ‘pdftk’ の文字列が含まれているか、どうかを確認。

AOutput := TStringList.Create;
AOutput.Text := AStream.DataString;

(略)

if Pos('pdftk', LowerCase(AOutput.Text)) > 0 then
begin
  Result := True;
end;

期待通りに動作することを、メッセージを表示して確認(確認後、このメッセージ表示部分はコメント化し、実際に実行する際はインストールされていない場合のみ、メッセージを表示する仕様とした)。

全体のコードは、次の通り。

  private
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;

procedure TForm1.CMShowingChanged(var Msg: TMessage);
var
  strMsg:string;

  //PDFtkのインストールの有無を確認
  function IsPDFtkInstalled: Boolean;
  var
    AStartupInfo: TStartupInfo;
    AProcessInfo: TProcessInformation;
    ASecurityAttributes: TSecurityAttributes;
    ABuffer: array[0..1023] of Byte;
    ARead: Cardinal;
    AStdOutPipeRead, AStdOutPipeWrite: THandle;
    ACommand: String;
    AOutput: TStringList;
    AStream: TStringStream;
  begin
    Result := False;
    AOutput := TStringList.Create;
    AStream := TStringStream.Create('', TEncoding.UTF8);
    try
      FillChar(ASecurityAttributes, SizeOf(ASecurityAttributes), 0);
      ASecurityAttributes.nLength := SizeOf(ASecurityAttributes);
      ASecurityAttributes.bInheritHandle := True;

      CreatePipe(AStdOutPipeRead, AStdOutPipeWrite, @ASecurityAttributes, 0);
      try
        FillChar(AStartupInfo, SizeOf(AStartupInfo), 0);
        AStartupInfo.cb := SizeOf(AStartupInfo);
        AStartupInfo.hStdOutput := AStdOutPipeWrite;
        AStartupInfo.hStdError := AStdOutPipeWrite;
        AStartupInfo.dwFlags := STARTF_USESTDHANDLES or STARTF_USESHOWWINDOW;
        AStartupInfo.wShowWindow := SW_HIDE;

        ACommand := 'pdftk --version';
        if CreateProcess(nil, PChar('cmd.exe /C ' + ACommand), nil, nil, True, CREATE_NO_WINDOW, nil, nil, AStartupInfo, AProcessInfo) then
        try
          CloseHandle(AStdOutPipeWrite);
          while ReadFile(AStdOutPipeRead, ABuffer, SizeOf(ABuffer), ARead, nil) do
          begin
            if ARead = 0 then Break;
            AStream.WriteBuffer(ABuffer, ARead);
          end;
          AOutput.Text := AStream.DataString;
          WaitForSingleObject(AProcessInfo.hProcess, INFINITE);
        finally
          CloseHandle(AProcessInfo.hProcess);
          CloseHandle(AProcessInfo.hThread);
        end;
      finally
        CloseHandle(AStdOutPipeRead);
      end;

      if AOutput.Count > 0 then
      begin
        //確認用
        //strMsg:='PDFtk output: ' + AOutput.Text;
        //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
        if Pos('pdftk', LowerCase(AOutput.Text)) > 0 then
        begin
          Result := True;
        end;
      end else begin
        //No output from PDFtk command.
        strMsg:='PDFtk からの出力がありません。';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end;
    finally
      AOutput.Free;
      AStream.Free;
    end;
  end;

begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //PDFtkのインストールの有無を確認
    try
      if IsPDFtkInstalled then
      begin
        //確認用
        //strMsg:='PDFtk はインストールされています。';
        //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end else begin
        strMsg:='PDFtk はインストールされていません。'+#13#10+#13#10+
          'https://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/'+#13#10+
          '上記Webサイトからダウンロード&インストールしてください。'+#13#10+#13#10+
          'プログラムを終了します。';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
        Close;
      end;
    except
      on E: Exception do begin
        strMsg:='エラー: ' + E.Message;
        Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
      end;
    end;
  end;

end;


【注意のお願い】

追記(20250218)の「PDFtk のインストールを確認するプログラムコード」は、上記の「全体のプログラムコード」には含まれておりません。ご注意願います。

6.お願いとお断り

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

StringGridの自動入力・セルの色分け

組み合わせ採点を行うプログラムを書いた際、StringGridの列に連番を自動入力したり、セルの値が同じ範囲を自動的に色分け(背景色を変更)するプログラムを書いた。これは、その備忘録。

※ Grid の列への連番自動入力他、前回の記事と重複する部分があります。ご容赦ください。

【もくじ】

1.StringGridの基本設定(VCL)
2.列に連番を自動入力
3.連番であるかチェック
4.セルの値が同じ範囲を自動判別して背景色を変更
5.同じ値のセル範囲を自動取得してフラグ化
6.お願いとお断り

1.StringGridの基本設定(VCL)

Form に StringGrid をひとつだけ用意して、次のコードを準備する。


コードは、次の通り。

unit Unit1;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, 
  System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, 
  Vcl.Grids, Vcl.StdCtrls;

type
  TForm1 = class(TForm)
    StringGrid1: TStringGrid;
    CheckBox1: TCheckBox;
    procedure FormCreate(Sender: TObject);
    procedure StringGrid1SelectCell(Sender: TObject; ACol, ARow: LongInt;
      var CanSelect: Boolean);
    procedure StringGrid1DrawCell(Sender: TObject; ACol, ARow: LongInt;
      Rect: TRect; State: TGridDrawState);
    procedure StringGrid1KeyPress(Sender: TObject; var Key: Char);
    procedure StringGrid1SetEditText(Sender: TObject; ACol, ARow: LongInt;
      const Value: string);
  private
    { Private 宣言 }
    //StringGridの列数を設定 -> FormCreate時に設定する
    StrGrid1ColCount: Integer;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;

  public
    { Public 宣言 }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

procedure TForm1.CMShowingChanged(var Msg: TMessage);
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //セットフォーカス
    StringGrid1.Col:=1;
    StringGrid1.Row:=1;
    StringGrid1.SetFocus;
    //セルの編集を開始(ユーザーのクリックを待つ場合はコメント化する)
    StringGrid1.Options := StringGrid1.Options + [goEditing];
    //カーソルが見えるようにする
    StringGrid1.EditorMode:=True;
  end;
end;

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

  //列数
  StrGrid1ColCount:=3;
  StringGrid1.ColCount:=StrGrid1ColCount;

  //FixedCols & FixedRows(固定列と固定行)を設定
  StringGrid1.FixedCols:=1;
  StringGrid1.FixedRows:=1;

  StringGrid1.Rows[0].CommaText:='番号,連番,TF';

  //FixedRows(固定行)に値をセット
  for i:= 1 to StringGrid1.RowCount do
  begin
    StringGrid1.Rows[i].Append(IntToStr(i));
  end;

end;

procedure TForm1.StringGrid1DrawCell(Sender: TObject; ACol, ARow: LongInt;
  Rect: TRect; State: TGridDrawState);
begin
  if StringGrid1.Cells[ACol,ARow]<>'' then
  begin
    //背景色を白に設定
    StringGrid1.Canvas.Brush.Color:=clWhite;
    //セルを塗りつぶす
    StringGrid1.Canvas.FillRect(Rect);
    //テキストを表示(中央寄せ)
    DrawText(StringGrid1.Canvas.Handle,
      PChar(StringGrid1.Cells[ACol,ARow]),
      //[+1]は数値描画位置の調整のため
      Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
      DT_CENTER or DT_VCENTER or DT_SINGLELINE);
  end;
end;

procedure TForm1.StringGrid1KeyPress(Sender: TObject; var Key: Char);
begin
  //[Enter]キーでコントロールを移動
  if Ord(Key)=VK_RETURN then
  begin
    if ActiveControl is TStringGrid then
    begin
      if TStringGrid(ActiveControl).EditorMode then
      begin
        //VK_TABではカーソルがレコードの項目を右へ移動。
        //ActiveControl.Perform(WM_KEYDOWN,VK_TAB,0);
        //VK_DOWNにすると同じ項目の次のレコードへ移動。
        ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
        Key:=#0;
      end;
    end else begin
      SelectNext(ActiveControl,True,True);
      Key:=#0;
    end;
  end;
end;

列の編集の可否を制御したい場合は、以下のコードで実現可能。

procedure TForm1.StringGrid1SelectCell(Sender: TObject; ACol, ARow: LongInt;
  var CanSelect: Boolean);
begin
  //列の編集の可否
  if (ACol=StrGrid1ColCount-1) then
  begin
    //セルの編集は不可
    TStringGrid(Sender).Options:=TStringGrid(Sender).Options-[goEditing];
  end else begin
    //セルは編集可能
    TStringGrid(Sender).Options:=TStringGrid(Sender).Options+[goEditing];
  end;
end;

2.列に連番を自動入力

「常に自動入力する」設定だと、同じ値の連続入力を許可して、それを何かのフラグ(例えば組み合わせ採点の組み合わせ設問設定フラグ)として利用するような場合、後で入力値の修正が必要になったとき大変なことになるので、より実用的にするなら CheckBox などを用意して、「チェックあり」の場合のみ動作するように設定する等の工夫が必須(だと思う)。

次は、チェックボックスのチェックの有無で動作をON・OFFする場合の例。

Form に CheckBox を1つ追加


コードは、次の通り。

procedure TForm1.StringGrid1SetEditText(Sender: TObject; ACol, ARow: LongInt;
  const Value: string);
var
  NewValue: Integer;

  procedure UpdateColumnData(StartRow, NewValue: Integer);
  var
  i: Integer;
  begin
    for i := StartRow + 1 to StringGrid1.RowCount - 1 do
      StringGrid1.Cells[StrGrid1ColCount-2, i] := IntToStr(NewValue + 1);
  end;

begin
  //チェックボックスがチェックされていたら
  if CheckBox1.Checked then
  begin
    //行を自動入力
    if ACol = StrGrid1ColCount-2 then
    begin
      if TryStrToInt(Value, NewValue) then
      begin
        UpdateColumnData(ARow, NewValue);
      end;
    end;
  end;
end;


チェックボックスにチェックした際、Grid コントロールにセットフォーカスさせたければ、次のコードも追加する。

procedure TForm1.CheckBox1Click(Sender: TObject);
begin
  if CheckBox1.Checked then
  begin
    //セットフォーカス
    StringGrid1.Col:=1;
    StringGrid1.Row:=1;
    StringGrid1.SetFocus;
    //セルの編集を開始(ユーザーのクリックを待つ場合はコメント化する)
    StringGrid1.Options := StringGrid1.Options + [goEditing];
    //カーソルが見えるようにする
    StringGrid1.EditorMode:=True;
  end;
end;

実行(F9)時の動作は、次の通り(Enter キーを数回、押し下げ後の状態)。


CheckBox にチェックを入れて、1行1列目のセルをクリックしてEnterキーを押し下げる度にフォーカスが下へ移動し、連番が自動入力される。

同じ番号を入力したい場合は、手動で入力してEnterキーを押し下げ。
※ 入力値を組み合わせ採点を実行するフラグとして利用したかったため、このような仕様とした。

この例では、5行目の「5」は自動入力されるので、
6、7行目の「5」を手入力する。

3.連番であるかチェック

同じ値の繰り返しを許可した上で、入力された値が連番になっているかをチェックする。
FormにButtonを1つ追加して、ボタンをクリックした際にチェックを実行。

Form に Button を1つ追加。


コードは次の通り。

procedure TForm1.Button1Click(Sender: TObject);
var
  ColumnValues: TStringList;
  i: Integer;
  ErrorRows: TStringList;
  ErrorMessage: string;

  function IsSequential(Column: TStrings; out ErrorRows: TStringList): Boolean;
  var
    k, CurrentValue, ExpectedValue: Integer;
  begin
    Result := True; //初期状態で連番と仮定
    ErrorRows.Clear;

    if Column.Count = 0 then
      Exit; //空の場合は連番とみなす

    CurrentValue := StrToInt(Column[0]);
    for k := 1 to Column.Count - 1 do
    begin
      //現在の値が同じであれば次の行へ
      if StrToInt(Column[k]) = CurrentValue then
      begin
        Continue;
      end else begin
        //現在の値が変わった場合、期待される次の値は1増加
        ExpectedValue := CurrentValue + 1;
        //期待される次の値と一致しなければ連番ではない(同じ値のくり返しは許可する)
        //if StrToInt(Column[k]) <> ExpectedValue then
        if (StrToInt(Column[k]) = CurrentValue) or
          (StrToInt(Column[k]) <> ExpectedValue) then
        begin
          Result := False;
          //エラーの行番号を追加(1から始まるインデックスのため +1)
          ErrorRows.Add(IntToStr(k + 1));
          Exit;
        end else begin
          CurrentValue := ExpectedValue;
        end;
      end;
    end;
  end;

begin
  //連番になっていることを確認
  ColumnValues := TStringList.Create;
  ErrorRows := TStringList.Create;
  try
    //StringGridの第1列(インデックス0)を取得
    for i := 1 to StringGrid1.RowCount - 1 do
    begin
      ColumnValues.Add(StringGrid1.Cells[1, i]);
    end;
    if IsSequential(ColumnValues, ErrorRows) then
    begin
      ShowMessage('連番です');  //確認用
    end else begin
      //連番でない行がある場合のメッセージ
      ErrorMessage := ErrorRows.CommaText + ' 行目が連番ではありません!';
      Application.MessageBox(PChar(ErrorMessage), PChar('エラー'), MB_ICONSTOP);
      StringGrid1.Col:=1;
      StringGrid1.Row:=StrToInt(ErrorRows.CommaText);
      StringGrid1.SetFocus;
      Exit;
    end;
  finally
    ColumnValues.Free;
    ErrorRows.Free;
  end;
end;

実行(F9)して、動作テスト。

10行目にわざと連番ではない値を入力して動作テスト


同じ値の繰り返しは許可するようにコーディングしたので、次のような場合は連番と判断する。

        //期待される次の値と一致しなければ連番ではない(同じ値のくり返しは許可する)
        //if StrToInt(Column[k]) <> ExpectedValue then
        if (StrToInt(Column[k]) = CurrentValue) or
          (StrToInt(Column[k]) <> ExpectedValue) then
        begin

4.セルの値が同じ範囲を自動判別して背景色を変更

業務用のプログラムでは、上の図のように同じ値が繰り返し入力されているセルがたやすく見分けられるように工夫した方が好ましいと考え、セルの値が同じ範囲を自動判別して背景色を変更するコードを追加する。

まず、uses に System.Generics.Collections を動的配列要素のSortのために追加。

implementation

uses
  System.Generics.Collections;

{$R *.dfm}


次に、Gridコントロールの OnDrawCell 手続きに以下のコードを記述。

procedure TForm1.StringGrid1DrawCell(Sender: TObject; ACol, ARow: LongInt;
  Rect: TRect; State: TGridDrawState);
var
  Value: String;
  CellColor: TColor;
  ColorMap: TDictionary<String, TColor>;

  function GetLightColor(BaseColor: TColor): TColor;
  var
    R, G, B: Byte;
    pct: Double;  //パーセントを指定する変数
  begin
    // RGB値を取得
    R := GetRValue(ColorToRGB(BaseColor));
    G := GetGValue(ColorToRGB(BaseColor));
    B := GetBValue(ColorToRGB(BaseColor));

    //薄い色に調整(50%白に近づける場合)
    //R := (R + 255) div 2;
    //G := (G + 255) div 2;
    //B := (B + 255) div 2;

    //80%白に近づける場合
    //元のRGB値を20%だけ残し、残りの80%を白(255, 255, 255)に近づける
    //R := Round(R * 0.2 + 255 * 0.8);
    //G := Round(G * 0.2 + 255 * 0.8);
    //B := Round(B * 0.2 + 255 * 0.8);

    //薄い色に調整
    pct:=StrToFloat('0.' + ComboBox1.Text);
    R := Round(R * (1-pct) + 255 * pct);
    G := Round(G * (1-pct) + 255 * pct);
    B := Round(B * (1-pct) + 255 * pct);

    Result := RGB(R, G, B);
  end;

  procedure AssignColorsToValues(ACol: Integer);
  var
    i: Integer;
    Value: String;
    BaseColors: TArray<TColor>;  //動的配列として宣言(解放はDelphiにまかせる)
    ColorIndex: Integer;
  begin
    ColorMap.Clear;
    ColorIndex := 0;

    BaseColors:=[clRed, clGreen, clBlue, clYellow, clAqua, clFuchsia];

    for i := 1 to StringGrid1.RowCount - 1 do
    begin
      Value := StringGrid1.Cells[ACol, i];
      if not ColorMap.ContainsKey(Value) then
      begin
        //色を薄く調整したものを登録
        ColorMap.Add(Value, GetLightColor(BaseColors[ColorIndex mod Length(BaseColors)]));
        Inc(ColorIndex);
      end;
    end;
  end;

begin

  //前掲のコードは、Gridの初期化も兼ねる
  if StringGrid1.Cells[ACol,ARow]<>'' then
  begin
    //背景色を白に設定
    StringGrid1.Canvas.Brush.Color:=clWhite;
    //セルを塗りつぶす
    StringGrid1.Canvas.FillRect(Rect);
    //テキストを表示(中央寄せ)
    DrawText(StringGrid1.Canvas.Handle,
      PChar(StringGrid1.Cells[ACol,ARow]),
      //[+1]は数値描画位置の調整のため
      Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
      DT_CENTER or DT_VCENTER or DT_SINGLELINE);
  end;

  if ARow = 0 then Exit; //ヘッダー行はスキップ

  ColorMap := TDictionary<String, TColor>.Create;

  //色分け対象列を指定
  AssignColorsToValues(1); //ColorMapをCreateしてから呼び出すこと!

  try
    if ACol = 1 then //対象列をチェック
    begin
      Value := StringGrid1.Cells[ACol, ARow];
      if ColorMap.TryGetValue(Value, CellColor) then
      begin
        StringGrid1.Canvas.Brush.Color := CellColor;
        StringGrid1.Canvas.FillRect(Rect);
        //テキストを表示(中央寄せ)_[+1]は数値描画位置の調整のため
        DrawText(StringGrid1.Canvas.Handle,
          PChar(StringGrid1.Cells[ACol,ARow]),
          Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
          DT_CENTER or DT_VCENTER or DT_SINGLELINE);
      end;
    end else begin
      StringGrid1.Canvas.FillRect(Rect);
      //テキストを表示(中央寄せ)_[+1]は数値描画位置の調整のため
      DrawText(StringGrid1.Canvas.Handle,
        PChar(StringGrid1.Cells[ACol,ARow]),
        Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
        DT_CENTER or DT_VCENTER or DT_SINGLELINE);
    end;
  finally
    ColorMap.Free;
  end;

end;


さらに、FormCreate 手続きで ComboBox の選択肢の準備と初期化を行うように設定。

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

  //前掲の通りなので略

  //色の濃さを調節
  for i := 1 to 99 do
  begin
    ComboBox1.Items.Add(IntToStr(i));
  end;

  //初期値を設定
  ComboBox1.Text:='50';

end;


連番で実行(F9)した場合、


同じ値を適当に入力してみた場合、

5.同じ値のセル範囲を自動取得してフラグ化

1.基本設定の最後で「列の編集の可否を制御したい場合は、以下のコードで実現可能」としたのは、実はTFフィールドをフラグとして利用したかったため。

具体的に何がしたかったかと言うと、TF列の任意のセルをクリックしたとき、その左の連番列の同じ値が入力されているセルを自動判別して、TF列の同じセル範囲にクリックで「1」を、スペース押し下げで「0」を自動(切り替え)入力するトグル的操作の実現。

実用上の目的は、連番列で同じ番号が入力されている(=同じ背景色)セルを処理上はセットにして扱うが、TF列に設定されている値が「1」であるセルと、「0」であるセルとで行う処理の内容を分けたいというもの。

つまり、連番列で同じ番号が入力されているセルは「組み合わせ」て採点し、さらにTF列の値が「1」であれば「順不同」で採点を行いたい場合のフラグとして利用できるようにしたかった。

そのための布石として、TF列の自由な編集を不可に設定。

procedure TForm1.StringGrid1SelectCell(Sender: TObject; ACol, ARow: LongInt;
var CanSelect: Boolean);
begin
//列の編集の可否
if (ACol=StrGrid1ColCount-1) then
begin
//セルの編集は不可
TStringGrid(Sender).Options:=TStringGrid(Sender).Options-[goEditing];
end else begin
//セルは編集可能
TStringGrid(Sender).Options:=TStringGrid(Sender).Options+[goEditing];
end;
end;

共通利用する手続きとして、次の手続きを追加(Shift+Ctrl+C で TForm1 のメンバとして作成)。

  private
    { Private 宣言 }

    //状態の切り替え
    procedure ToggleSGCell(ACol, ARow: Integer);
    procedure UpdateColumnData(Value: Integer; IsChecked: Boolean);


ToggleSGCell 手続きのコードは、次の通り。

procedure TForm1.ToggleSGCell(ACol, ARow: Integer);
begin
  //現在の値を切り替え
  if StringGrid1.Cells[ACol, ARow] = '1' then
    StringGrid1.Cells[ACol, ARow] := '0'
  else
    StringGrid1.Cells[ACol, ARow] := '1';

  //再描画をトリガ(即座に変更を表示)
  StringGrid1.Invalidate;
end;


UpdateColumnData のコードは、次の通り。

procedure TForm1.UpdateColumnData(Value: Integer; IsChecked: Boolean);
var
  i: Integer;
  NewValue: string;
begin

  if IsChecked then
  begin
    NewValue := '1';
  end else begin
    NewValue := '0';
  end;

  for i := 1 to StringGrid1.RowCount - 1 do
  begin
    if StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, i]) = Value then
    begin
      StringGrid1.Cells[StrGrid1ColCount-1, i] := NewValue;
    end;
  end;

  //再描画をトリガ(即座に変更を表示)
  StringGrid1.Invalidate;
end;

プログラムの仕様として、TF列の任意のセルをクリックしたら、連番列の値を調査して同じ値が連続して入力されているセル全てに「1」を入力したいので、OnMouseDown 手続きに次のコードを記述。

procedure TForm1.StringGrid1MouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Col, Row: Integer;
begin
  //マウスクリックでセルの0と1を切り替え
  StringGrid1.MouseToCell(X, Y, Col, Row);
  if (Col = StrGrid1ColCount-1) and (Row > 0) then
    ToggleSGCell(Col, Row);
end;

で、OnMouseUp イベントで連番列の値を判定。同じ値の入力されているセル範囲を取得して、TF列の同じ行に「1」を自動入力する。

procedure TForm1.StringGrid1MouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  ACol, ARow: Integer;
begin
  //マウスでクリックして、指を離したときのイベント
  StringGrid1.MouseToCell(X, Y, ACol, ARow);

  //if (ACol = StrGrid1ColCount-1) and (ARow >= 0) then
  //0行目(FixedRow)では動作しないように設定
  if (ACol = StrGrid1ColCount-1) and (ARow > 0) then
    //UpdateColumnData(ARow);
    //引数にはCMS設定値が入る
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, ARow]), True);
end;

TF列の任意のセルをクリックして、スペースキー押し下げで入力値を「0」に切り替える。

procedure TForm1.StringGrid1KeyDown(Sender: TObject; var Key: Word;
  Shift: TShiftState);
begin
  //スペースキーで0と1を切り替え
  if (StringGrid1.Col = StrGrid1ColCount-1) and (StringGrid1.Row > 0) and (Key = VK_SPACE) then
  begin
    ToggleSGCell(StringGrid1.Col, StringGrid1.Row);
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, StringGrid1.Row]), False);
    Key := 0;
  end;
end;

TF列をゼロで初期化するため、FormCreate 手続きの既存のコードに次のコードを追加。

  //FixedRows(固定行)に値をセット
  for i:= 1 to StringGrid1.RowCount do
  begin
    StringGrid1.Rows[i].Append(IntToStr(i));
    //TF列をゼロで初期化
    StringGrid1.Cells[2,i] := '0';
  end;


実行(F9)の動作は、次の通り。

TF列の任意のセル(5行目)をクリックした場合。


同じセルをクリックして選択後、スペースキー押し下げでゼロに切り替え。

6.お願いとお断り

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

組み合わせ採点を実現したい!

2024年11月27日(水)、ある高名な化学者の講演を聴いた。「研究を続けてきた中で、最も困難であったことは何か?」という問いに対し、彼は「実験の99%が失敗であったことだ。」と即答。

その言葉を反芻するうちに、表計算ソフトを使わなければ自分には実現不可能と信じ、
チャレンジする前からあきらめていた「組み合わせ採点」のことを思い出した。

「方向性さえ間違えなければ、失敗の山を築こうとも、いつか必ず成功する。大切なのは、その成功の瞬間を見逃さないことだ。」

僕は、化学者の言葉を、心から信じようと、思った。

表計算ソフトに頼らない「組み合わせ採点」。
Object Pascal だけで書く「組み合わせ採点」。
もしかしたら、僕にも書けるかもしれない・・・と、自分史上、初めて、本気で、そう思えた。

【もくじ】

1.情報処理手順
2.実装
(1)Gridコントロール
(2)組み合わせ採点
(3)順不同採点
3.お知らせ
4.お願いとお断り

1.情報処理手順

まず、最初に「組み合わせ採点」なるものの定義。

例えば、選択肢数が1設問につき8個あるマークシートを考える。そのとき、次のように

    設問1 設問2 設問3
マーク  1   2   3
正 解  1   2   3

設問1~3のマークと正解が完全に一致した場合に「正解」とする採点方法だ。

また、可能であれば、「組み合わせ & 順不同採点」も実現したい。それはつまり、

    設問1 設問2 設問3
マーク  1   2   3
マーク  1   3   2
マーク  2   1   3
マーク  2   3   1
マーク  3   1   2
マーク  3   2   1

このすべてが正解という採点方法、すなわち、解答の順番は不問にして、とにかく設問1~3の解答として1・2・3のいずれかがマークされていればよいというもの(実際の試験では、これまでは「正しいものを昇順に3つ選べ」というような問題文にしたり、正しい語句等を3つ組み合わせた解答の選択肢を用意する必要があったが、これが単に「正しいものを3つ選べ」という表現でよくなる)。

また、組み合わせ採点が設定可能な設問は、必ず連続で並んでいるものとする。
つまり、次のような設定は最初から考えない(設定不可)。

    設問1 設問2 設問3 設問4 設問5
マーク  2       3       4
正 解  2       3       4

「組み合わせ採点」を英語では、次のように表現するようだ。

Combination Matching System -> 組み合わせの「一致性」に基づく評価。
Combination Marking System -> 採点(marking)を強調。教育や試験で使える表現。
Composite Marking System -> 要素を統合してスコアを出す評価システム。

いずれも頭文字を組み合わせると CMS になる。
自分的には、マークシートの採点だから Combination Marking System かな?

それから「順不同」を英語で言うと、No Particular Order だから、こちらは略して NPO だ。

これから書くプログラムでは、この略称でそれぞれの採点方法を表現することにする。
(・・・と勝手に決める)

はたしてどうやったら組み合わせ採点のアルゴリズムを一般化できるか、考える。マークシートリーダーのプログラムを書いたときにも、ちらっと組み合わせ採点のことは脳裏をかすめたが、すぐに表計算ソフトを使ってなんとかすればいいやって・・・。

あのときは表計算ソフトのセルを Delphi で操作するプログラムを書いて、それで誤魔化してしまったんだ。表計算ソフトのファイルにADOで接続して、セルを結合させ、プログラムで作成した式を書き込んで、組み合わせ採点を行った。だから、ワークシートを改変されると、もう、それだけで動作しなかった。

純粋に Delphi だけで、組み合わせ採点を実現するのは、少なくても自分には無理だ・・・と、あのころの僕は、信じて疑わなかったから。

それなのに、なぜ、今は「それが出来る」と考えて、その実現に向かって歩こうとしているのか。

僕は以前より、よくなれたんだろうか・・・

それは おそらく 僕が決めることでは、ないだろう。

自作のプログラムの採点設定画面を見つめて、まず思ったことは、例えば設問1~3を組み合わせ採点するとしたら配点は、3つある配点入力セルの「いずれか1つ」に入力し、残りのセルにはゼロを入れてこれを採点結果印刷行などのフラグとして使う案(下図参照)。

自作の採点結果通知個票作成プログラムの画面

組み合わせ採点・順不同採点は出来ませんが、1問1答形式であれば使用できる(?)マークシートリーダーと手書き答案の採点プログラム、及び採点結果を受験者に通知する個票を作成するプログラムをセットにした zip ファイルを次のリンク先で無料で公開しています。


つまり、配点が「ゼロでない」場合のみ、採点結果通知個票に正解なら○(マル)、そうでなければ×(バツ)を印刷すればいい。

ここで気がついたのだけれど、組み合わせて採点して正解にする以上、観点別評価の区分はどうしても同じにする必要があるということ。これを設問毎に別々に設定可能とすると相当やっかいなことになりそうだ。

約束ごとをさらに1つ増やそう。
組み合わせ採点を設定した設問の観点別評価は観点1か、2のいずれかに統一する。

で、この他に、どの設問を組み合わせ採点とするのか、やはり明示的に示せた方がよい。グリッドコントロールの列を増やし、組み合わせ採点を行う設問には同じ番号を入力してもらうのはどうか?

そうすれば組み合わせ採点箇所は一目瞭然だ。・・・てか、組み合わせ採点をする箇所は何設問分あろうと採点箇所1個としてとらえ、組み合わせ採点をしない箇所も含めて、連番・昇順の通し番号を割り当て、プログラム実行時にその数だけ動的に配列を生成して、そこにマークされた選択肢の番号や正解の選択肢の番号をまとめて入れて・・・

「マーク配列」と「正解配列」を比較して、完全に一致したときのみ正解にすれば・・・

組み合わせ採点を実現できそうだ。

さらに、順不同採点を実行する場合は、例えば、それを実行しないフラグをゼロ、実行するフラグを1として、組合せ採点番号と一緒にこちらも明示的に設定してもらう。

実行時に、組み合わせ採点が設定されていて、かつ順不同採点の実行フラグが1なら、その組合せ採点番号のマーク配列と正解配列の要素をそれぞれ昇順ソート(もちろん、降順でもかまわないが)して比較・・・完全一致した場合だけ正解とすれば・・・

順不同採点も同時に実現できそうだ。

そう思って作成したのが、こちらのグリッドコントロール。

CMSフィールドが組み合わせ採点の番号、NPOフィールドが順不同採点の有無。


初見時、わけわかんない・・・かも。
自分自身、そう思ったが、今の自分にはこれ以上のアルゴリズムは考えられない。マニュアルを読まなくても直感的に使えるプログラムが最もよいプログラムだと信じているが、ここだけはマニュアルを読んでクリアしてもらうしかなさそうだ。

このプログラムを使ってくださる方が、この世にいたとして・・・の話だが。

NPOフィールドにはチェックボックスを埋め込むことも考えた、いや、埋め込んでみたのだが、イマイチその挙動が気に入らない。これはどうしても必要となったら再考することにして、今は組み合わせ採点の実現を最優先することにする。

アルゴリズムは出来た。
さぁ 実装だ。

2.実装

追記_20250105
実装のプログラムコードは、次の記事に略した部分のない詳細があります。

(1)Gridコントロール

最初はGridコントロールの CMS フィールドへの入力から。

ここは、どう考えても自動入力にすべきだろう・・・。設計上、絶対に連番になっていないといけないし、100設問あるような場合、すべてを手入力するのはどう考えても時間の無駄だ。そう思って書いたのが次のコード。

  private
    { Private 宣言 }
    //StringGridの列数を設定 -> FormCreate時に設定する
    StrGrid1ColCount: Integer;
procedure TForm1.UpdateColumnData(Value: Integer; IsChecked: Boolean);
var
  i: Integer;
  NewValue: string;
begin

  if IsChecked then
  begin
    NewValue := '1';
  end else begin
    NewValue := '0';
  end;

  for i := 1 to StringGrid1.RowCount - 1 do
  begin
    if StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, i]) = Value then
    begin
      StringGrid1.Cells[StrGrid1ColCount-1, i] := NewValue;
    end;
  end;
end;

procedure TForm1.StringGrid1MouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  ACol, ARow: Integer;
begin
  //マウスでクリックして、指を離したときのイベント
  StringGrid1.MouseToCell(X, Y, ACol, ARow);
  if (ACol = StrGrid1ColCount-1) and (ARow >= 0) then
    //引数にはCMS設定値が入る
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, ARow]), True);
end;


実行時の動作は、次の通り。
CMS フィールドの1行目のセルをクリックして選択し、Enter キーを押し下げして選択セルを下に移動させると連番が自動的に入力される。

Enter キー押し下げでCMS列のすべての行が自動入力される。


組み合わせ採点を設定したいセルのみ、手動入力する。例えば設問番号2~4を組み合わせ採点したい場合は、2行目は自動入力で2が入るので、3行目・4行目に手動入力で半角数字の 2 を入力する。

組み合わせ採点したいセルには同じ値を入力する。


使ってみて気づいたのだが、この入力方法には問題があって、微調整が効かない!
途中で設定の誤りに気がついて、訂正しようとすると、訂正箇所以下すべての設定が失われてしまう・・・

2行目を選択してEnter キーを押し下げで、すべての設定が消える!


これは、さすがにマズい。部分修正しても、既存の組み合わせ採点設定が消えないようにする必要がある。どうするか? しばし考えて CheckBox と Button を1つずつ追加。

CheckBox のキャプションには「Auto」、Buttonのキャプションには「HELP」を設定。


CMS フィールドの自動入力は、Auto にチェックが入っているときのみ動作するよう設定を変更。これで既存の設定が一瞬にして消える悲劇は防げる? もちろん、デフォルトはFalse!

で、HELP ボタンをクリックしたら、CMS・NPO 各フィールドの意味と設定方法を表示。

説明は、必要最小限にしたつもり・・・だが。


次は、NPO フィールドへの入力。

いちばん、かんたんな方法は何か? いろいろ考えた末、説明されなければ絶対わからないが、説明さえきちんと読んでもらえれば、多分、便利に使える方法を採用。

それはクリックされた NPO フィールドのセル位置に応じて、組み合わせ採点の範囲を自動的に取得し、クリックされたセルとその上下の( CMS フィールドに同じ組み合わせ採点番号が設定されている)セルすべてに 1 (順不同採点ありのフラグとして利用)を自動入力するというもの。

NPO フィールドの任意のセルをクリックすると、
組み合わせ採点設定されている範囲のセルすべてに1を自動入力。


コードは次の通り。

private
  procedure UpdateColumnData(Value: Integer; IsChecked: Boolean);

procedure TForm1.UpdateColumnData(Value: Integer; IsChecked: Boolean);
var
  i: Integer;
  NewValue: string;
begin
  if IsChecked then
  begin
    NewValue := '1';
  end else begin
    NewValue := '0';
  end;
  for i := 1 to StringGrid1.RowCount - 1 do
  begin
    if StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, i]) = Value then
    begin
      StringGrid1.Cells[StrGrid1ColCount-1, i] := NewValue;
    end;
  end;
  //再描画をトリガ(即座に変更を表示)
  StringGrid1.Invalidate;
end;

procedure TForm1.StringGrid1MouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  ACol, ARow: Integer;
begin
  //マウスでクリックして、指を離したとき実行
  StringGrid1.MouseToCell(X, Y, ACol, ARow);
  //0行目(FixedRow)では動作しないように設定
  if (ACol = StrGrid1ColCount-1) and (ARow > 0) then
    //UpdateColumnData(ARow);
    //引数にはCMS設定値が入る
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, ARow]), True);
end;

解除は、解除したい組み合わせ採点範囲の任意のセル1つをクリック(選択)して、スペースキー押し下げ。これでクリックされたセルとその上下の( CMS フィールドに同じ組み合わせ採点番号が設定されている)セルすべてに 0(順不同採点なしのフラグとして利用)を自動入力。

NPO フィールドの任意のセルをクリックして選択し、
スペースキーを押し下げで、順不同採点設定を解除。


コードは、次の通り。

private
  procedure ToggleSGCell(ACol, ARow: Integer);

procedure TForm1.ToggleSGCell(ACol, ARow: Integer);
begin
  //現在の値をトグル
  if StringGrid1.Cells[ACol, ARow] = '1' then
    StringGrid1.Cells[ACol, ARow] := '0'
  else
    StringGrid1.Cells[ACol, ARow] := '1';

  //再描画をトリガ
  StringGrid1.Invalidate;
end;

procedure TForm1.StringGrid1KeyDown(Sender: TObject; var Key: Word;
  Shift: TShiftState);
begin
  //スペースキーでチェックボックスをトグル
  if (StringGrid1.Col = StrGrid1ColCount-1) and (StringGrid1.Row > 0) and (Key = VK_SPACE) then
  begin
    ToggleSGCell(StringGrid1.Col, StringGrid1.Row);
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, StringGrid1.Row]), False);
    Key := 0;
  end;
end;

procedure TForm1.StringGrid1MouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Col, Row: Integer;
begin
  //マウスクリックでGridのセルをトグル
  StringGrid1.MouseToCell(X, Y, Col, Row);
  if (Col = StrGrid1ColCount-1) and (Row > 0) then
    ToggleSGCell(Col, Row);
end;

これでフラグの準備が出来た。次は「組み合わせ採点」そのものの実装だ。

(2)組み合わせ採点

自作の採点結果通知個票作成プログラムでは、マークシートリーダーで読み取った解答用紙のマークの選択肢番号を記録した CSV ファイルを読み込み、その内容をGrid コントロールに表示している。

採点結果通知個票作成プログラム側で作成した、上記の正解データや観点別評価の種類、組み合わせ採点の有無、順不同採点の設定は、また別の CSV ファイルに保存している。

組み合わせ採点を行うには、その2つの CSV ファイルからデータを読み込み、組み合わせ採点設定に応じて、マークの状態と正解及び採点結果(True / False)を動的配列に格納する必要がある。なので、まず、それを準備する。

type
  //動的配列の宣言(配列要素の並べ替え他)
  TString2DArray = array of array of string;
  TString1DArray = array of string;
  TString2DBoolArray = array of array of Boolean;

procedure TForm1.TM(Sender: TObject);
var
  intQ: Integer  //設問数
  intCMS: Integer;  //組み合わせ採点数
  pArr: array of Integer;  //配点を入れる動的配列
  cArr: array of Integer;  //正解を入れる動的配列
  kArr: array of Integer;  //観点別評価の区分を入れる動的配列
  c4_Arr: array of Integer;  //CMS設定番号を入れる動的配列
  c5_Arr: array of Integer;  //NPO設定番号を入れる動的配列
  mArr: array of array of Integer;  //マークを入れる2次元の動的配列
  sArr: array of array of Boolean;  //採点結果を入れる2次元の動的配列
  cms_mArr: TString2DArray;  //マークの組み合わせを入れる2次元の動的配列
  cms_cArr: TString1DArray;  //正解の組み合わせを入れる1次元の動的配列
  cms_sArr: TString2DBoolArray;  //採点結果をTrue or Falseで保存
  cms_jArr: array of Boolean;  //順不同採点の実施の有無をTrue or Falseで保存

プログラムコードは、

//注意:コードは一部の抜粋(重要な部分のみ)であり、これだけでは動作しません。
//一部の変数は、説明用の文字列で代替しています。
var
  //マークを取得_20250228訂正
  function GenerateDynamicArray: TArray<string>;
  var
    i,j: UInt64;  #jを追加
    CurrentValue, NextValue: string;
    ResultArray: TArray<string>;
    TempStr: string;
  begin

    TempStr := '';
    j:=0;  #初期化
    for i := 1 to StringGrid1.RowCount - 2 do
    begin
      CurrentValue := StringGrid1.Cells[4, i];
      NextValue := StringGrid1.Cells[4, i + 1];

      if CurrentValue = NextValue then
      begin
        TempStr := TempStr + IntToStr(mArr[i-1,'答案画像の番号']);
      end else begin
        TempStr := TempStr + IntToStr(mArr[i-1,'答案画像の番号']);
        ResultArray := ResultArray + [TempStr];
        TempStr := '';
      end;
      j:=i;  #値を取得
    end;

    //最後の要素を追加_20250228訂正
    //TempStr := TempStr + StringGrid1.Cells[0, StringGrid1.RowCount - 1];
    TempStr := TempStr + IntToStr(mArr[j, '答案画像の番号']);
    ResultArray := ResultArray + [TempStr];

    Result := ResultArray;
  end;

  //正解を取得
  function GenerateDynamicArray2: TArray<string>;
  var
    i: UInt64;
    CurrentValue, NextValue: string;
    ResultArray: TArray<string>;
    TempStr: string;
  begin

    TempStr := '';
    for i := 1 to StringGrid1.RowCount - 2 do
    begin
      CurrentValue := StringGrid1.Cells[4, i];
      NextValue := StringGrid1.Cells[4, i + 1];

      if CurrentValue = NextValue then
      begin
        //正解を取得
        TempStr := TempStr + StringGrid1.Cells[1, i];
      end else begin
        //正解を取得
        TempStr := TempStr + StringGrid1.Cells[1, i];
        ResultArray := ResultArray + [TempStr];
        TempStr := '';
      end;
    end;

    //最後の要素を追加_20250228訂正
    //TempStr := TempStr + StringGrid1.Cells[0, StringGrid1.RowCount - 1];
    TempStr := TempStr + StringGrid1.Cells[1, StringGrid1.RowCount - 1];
    ResultArray := ResultArray + [TempStr];

    Result := ResultArray;
  end;

  //配列要素の並べ替え
  procedure SortStringWithZeroPriority(var Str: string);
  var
    CharArray: array of Char;
    i, j: Integer;
    Temp: Char;
  begin
    // 文字列を文字配列に変換
    SetLength(CharArray, Length(Str));
    for i := 1 to Length(Str) do
      CharArray[i - 1] := Str[i];

    // 昇順にソート (バブルソートを使用)
    for i := Low(CharArray) to High(CharArray) - 1 do
      for j := i + 1 to High(CharArray) do
      begin
        if (CharArray[j] = '0') or (CharArray[i] > CharArray[j]) then
        begin
          Temp := CharArray[i];
          CharArray[i] := CharArray[j];
          CharArray[j] := Temp;
        end;
      end;

    // ソートされた文字配列を元の文字列に戻す
    Str := '';
    for i := Low(CharArray) to High(CharArray) do
      Str := Str + CharArray[i];
  end;

begin

  //設問数を取得
  intQ:=StringGrid1.RowCount-1;

  //組み合わせ採点数を取得する -> 組み合わせ採点数は、最終行の値
  intCMS:=StrToInt(StringGrid1.Cells[4,intQ]);

  //動的配列を生成
  SetLength(cArr, intQ);  //正解(Correct answer)
  SetLength(pArr, intQ);  //配点(Point allocation)
  SetLength(kArr, intQ);  //観点別評価の区分
  SetLength(c4_Arr, intQ);  //組み合わせ採点の区分
  SetLength(c5_Arr, intQ);  //順不同採点の区分

  //正解・配点・観点別評価の区分を配列に取得
  for i := 1 to intQ do
  begin
    if StringGrid1.Cells[2,i]<>'' then
    begin
      cArr[i-1]:=StrToInt(StringGrid1.Cells[1,i]);
      pArr[i-1]:=StrToInt(StringGrid1.Cells[2,i]);
      kArr[i-1]:=StrToInt(StringGrid1.Cells[3,i]);
      c4_Arr[i-1]:=StrToInt(StringGrid1.Cells[4,i]);
      c5_Arr[i-1]:=StrToInt(StringGrid1.Cells[5,i]);
    end else begin
      pArr[i-1]:=0;
    end;
  end;

  //1問1答の通常採点用の配列を準備
  SetLength(mArr, intQ, ListBox1.Items.Count);  //マーク読み取り結果
  SetLength(sArr, intQ, ListBox1.Items.Count);  //採点結果

  //組み合わせ採点用の配列を準備
  SetLength(cms_mArr, intCMS, ListBox1.Items.Count);  //マーク読み取り結果の組み合わせ
  SetLength(cms_cArr, intCMS);  //正解読み取り結果の組み合わせ
  SetLength(cms_sArr, intCMS, ListBox1.Items.Count);  //組み合わせの採点結果
  SetLength(cms_jArr, intCMS);  //順不同採点実施の有無

  //まず全てのデータを取得する
  //マークを配列に取得・採点結果の初期化(False)
  for i := 1 to ListBox1.Items.Count do  //答案枚数分Loopする
  begin
    for j := 1 to intQ do  //設問数分Loopする
    begin
      if strGrid.Cells[j,i]<>'' then
      begin
        //空欄(999)も、ダブルマーク(99)もそのまま取得する
        mArr[j-1][i-1]:=StrToInt(strGrid.Cells[j,i]);
        //デフォルトFalseで初期化
        sArr[j-1][i-1]:=False;
      end else begin
        mArr[j-1][i-1]:=999;  //Gridが空欄であればマークは空欄として扱う
        sArr[j-1][i-1]:=False;
      end;
    end;
  end;

  //組み合わせ採点用の動的配列にデータをセットする
  for i := 1 to ListBox1.Items.Count do  //答案枚数分Loopする
  begin

    //マークを配列に取得・採点結果の初期化(False)
    DynamicArray := GenerateDynamicArray;
    for j := 0 to intCMS-1 do
    begin
      if strGrid.Cells[j,i]<>'' then
      begin
        cms_mArr[j][i-1]:=DynamicArray[j];
      end else begin
        mArr[j-1][i-1]:=999;  //Gridが空欄であればマークは空欄として扱う
        sArr[j-1][i-1]:=False;
      end;
    end;

    //正解を配列に取得・採点結果の初期化(False)
    DynamicArray := GenerateDynamicArray2;
    for j := 0 to intCMS-1 do
    begin
      if strGrid.Cells[j,i]<>'' then
      begin
        cms_cArr[j]:=DynamicArray[j];
      end else begin
        mArr[j-1][i-1]:=999;  //Gridが空欄であればマークは空欄として扱う
        sArr[j-1][i-1]:=False;
      end;
    end;
  end;

  //答案枚数分Loop
  for i := 1 to ListBox1.Items.Count do
  begin
    //組み合わせ採点数分Loop
    for j := 0 to intCMS-1 do
    begin      
      //もし、マークが正解と等しかったら
      if cms_mArr[j][i-1]=cms_cArr[j] then
      begin
        cms_sArr[j][i-1]:=True;
      end else begin
        cms_sArr[j][i-1]:=False;      
      end;
    end;
  end;

実行(F9)結果は・・・

全問正解で処理した場合
全問不正解で処理した場合
(採点記号・観点別評価の区分に加えて、正解の選択肢を赤字で表示することも可能)


期待した通りに動作しているようだ。

うれしい・・・ことに間違いはないのだが、感極まるような喜びはない。正直なところ、あまりにも簡単に( 絶対! 出来ない )と思い込んでいたことができちゃったので( そんなもんか・・・ )みたいな。

(3)順不同採点

次は、順不同採点だ。アルゴリズムは出来ている。上で作成済みの「マークされた選択肢の番号を入れた動的配列の要素」と、「正解の選択肢の番号を入れた動的配列の要素」をそれぞれ昇順(別に降順でも構わないが)に並び替え、比較して一致した場合を正解として処理すればよい。

var
  CurrentCMSValue: UInt64;

  //配列要素の並べ替え
  procedure SortStringWithZeroPriority(var Str: string);
  var
    CharArray: array of Char;
    i, j: Integer;
    Temp: Char;
  begin
    // 文字列を文字配列に変換
    SetLength(CharArray, Length(Str));
    for i := 1 to Length(Str) do
      CharArray[i - 1] := Str[i];

    // 昇順にソート (バブルソート)
    for i := Low(CharArray) to High(CharArray) - 1 do
      for j := i + 1 to High(CharArray) do
      begin
        if (CharArray[j] = '0') or (CharArray[i] > CharArray[j]) then
        begin
          Temp := CharArray[i];
          CharArray[i] := CharArray[j];
          CharArray[j] := Temp;
        end;
      end;

    //ソートされた文字配列を元の文字列に戻す
    Str := '';
    for i := Low(CharArray) to High(CharArray) do
      Str := Str + CharArray[i];
  end;

begin
  //組み合わせ採点用の動的配列にデータをセットする
  for i := 1 to ListBox1.Items.Count do  //答案枚数分Loopする
  begin
    ・・・
  end;

  //順不同採点のフラグを設定
  for i := 1 to StringGrid1.RowCount-1 do
  begin
    if StringGrid1.Cells[2, i] <> '0' then
    begin
      CurrentCMSValue := StrToInt(StringGrid1.Cells[4, i]);
      case StrToInt(StringGrid1.Cells[5, i]) of
        0:begin
          cms_jArr[CurrentCMSValue-1]:= False;
        end;
        1:begin
          cms_jArr[CurrentCMSValue-1]:= True;
        end;
      end;
    end;
  end;

  //答案枚数分Loop
  for i := 1 to ListBox1.Items.Count do
  begin
    //組み合わせ採点数分Loop
    for j := 0 to intCMS-1 do
    begin

      //順不同採点を実施する場合の処理
      if cms_jArr[j] then
      begin
        //マーク並べ替え
        SortStringWithZeroPriority(cms_mArr[j][i-1]);
        //正解並べ替え
        SortStringWithZeroPriority(cms_cArr[j]);        
      end;

      //もし、マークが正解と等しかったら
      if cms_mArr[j][i-1]=cms_cArr[j] then
      begin
        //採点結果をTrue
        cms_sArr[j][i-1]:=True;
      end else begin
        cms_sArr[j][i-1]:=False;
      end;

    end;
  end;

end;

実行(F9)時の画面は、次の通り。まず、順不同採点を行わない場合、

組み合わせ採点が有効で、順不同採点は無効として採点。
マークは「1・2・3」なので不正解になる。


順不同採点を行う場合、

組み合わせ採点・順不同採点ともに有効として採点。
マークが「1・2・3」でも正解になる。

3.お知らせ

今回紹介した組み合わせ採点機能を組み込んだ採点結果通知個票作成用のプログラムは、実際の試験で必要十分な動作検証を行い、後日、「ReportCard_2025.exe」として公開する予定です。

4.お願いとお断り

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

Checked プロパティのみ設定したい!

CheckBox がクリックされたら「メッセージを表示」して、ユーザーに「はい」・「いいえ」のいずれかを選択してもらう。

「はい」が選択された場合はプログラム自体を再起動。で、再起動後の FormCreate 時に当該 CheckBox の Checked プロパティをクリックされた(変更された)状態に設定。ただし、その際、メッセージは表示しない。

もし、「いいえ」が選択された場合は、CheckBox の Checked プロパティはチェック前の状態を維持、つまり、クリックを無効化する。もちろん、ここでもメッセージは出さずに、Checked プロパティのみ修正したい。

この動作を実現したくて、半日、ハマった。

【もくじ】

1.用意した手続きと関数
2.実行結果
3.お願いとお断り

1.用意した手続きと関数

なんとか、実現。完成したコードは以下の通り。

  private
    { Private 宣言 }

    //チェックボックスの状態をロード中に OnClick イベントがトリガーされるのを防止する
    IsLoading: Boolean;

    procedure SaveCheckCMS_State(CheckBox: TCheckBox);  //Checked プロパティを保存
    procedure LoadCheckCMS_State(CheckBox: TCheckBox);  //Checked プロパティを読込
    procedure ClearRestartFlag;  //再起動フラグをクリア
    function IsRestarting: Boolean;  
    procedure RestartApplication;


グローバル変数を一つ、手続きと関数を上のように準備。それから ini ファイルを使うので、System.IniFiles を uses に追加。

implementation

uses
  System.IniFiles;

Shift+Ctrl+C でそれぞれの手続きや関数を次のように作成。

まず、SaveCheckCMS_State 手続き。CheckCMS が CheckBox の名前。Checked プロパティの状態を保存する。ちなみに CMS は、組み合わせ採点(Combined Scoring Method)の略。

procedure TForm1.SaveCheckCMS_State(CheckBox: TCheckBox);
var
  IniFile: TIniFile;
begin
  IniFile := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    IniFile.WriteBool('セクション', '組み合わせ採点', CheckCMS.Checked);
    IniFile.WriteBool('セクション', 'IsRestarting', True); //再起動フラグを設定
  finally
    IniFile.Free;
  end;
end;

次は LoadCheckCMS_State 手続き(保存した Checked プロパティの状態を読み込む)。

procedure TForm1.LoadCheckCMS_State(CheckBox: TCheckBox);
var
  IniFile: TIniFile;
begin
  IniFile := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    IsLoading := True; // イベントを無効にするためのフラグを設定
    CheckCMS.Checked := IniFile.ReadBool('セクション', '組み合わせ採点', False);
  finally
    IsLoading := False; // フラグをリセット
    IniFile.Free;
  end;
end;

次は ClearRestartFlag 手続き( Checked プロパティの保存時に True に設定した再起動を知るフラグをクリアする)。

procedure TForm1.ClearRestartFlag;
var
  IniFile: TIniFile;
begin
  IniFile := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    IniFile.WriteBool('セクション', 'IsRestarting', False);  //再起動フラグをクリア
  finally
    IniFile.Free;
  end;
end;

次は IsRestarting 関数( FormCreate 時に呼び出し)。

function TForm1.IsRestarting: Boolean;
var
  IniFile: TIniFile;
begin
  IniFile := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    Result := IniFile.ReadBool('セクション', 'IsRestarting', False);
  finally
    IniFile.Free;
  end;
end;

次は RestartApplication 手続き。これを呼び出すことでプログラム自体を再起動する。

procedure TForm1.RestartApplication;
var
  FileName: string;
  StartupInfo: TStartupInfo;
  ProcessInfo: TProcessInformation;
begin

  FileName := ParamStr(0);
  ZeroMemory(@StartupInfo, SizeOf(StartupInfo));
  StartupInfo.cb := SizeOf(StartupInfo);
  ZeroMemory(@ProcessInfo, SizeOf(ProcessInfo));

  if CreateProcess(PChar(FileName), nil, nil, nil, False, 0, nil, nil, StartupInfo, ProcessInfo) then
  begin
    CloseHandle(ProcessInfo.hProcess);
    CloseHandle(ProcessInfo.hThread);
  end;

  Application.Terminate;

end;

以上のように手続き・関数を準備して、FormCreate 時の設定。

procedure TForm1.FormCreate(Sender: TObject);
begin

  //チェックボックスの状態をロード中に OnClick イベントがトリガーされるのを防止する
  IsLoading:=False;

  LoadCheckCMS_State(CheckCMS);  //Checked プロパティを復元
  if IsRestarting then
    ClearRestartFlag;  //再起動フラグをクリア

end;

最後に、いちばん肝心な CheckCMSClick 手続き。実際は、ここからすべてが始まる。

procedure TForm1.CheckCMSClick(Sender: TObject);
var
  strMsg: string;
begin
  //再起動状態でなければ実行
  if not IsLoading then
  begin
    SaveCheckCMS_State(CheckCMS);  //Checked プロパティを保存

    //最初はコレでいいかと思ったんだけれど・・・あまりにも乱暴な気が。
    //strMsg:='設定はプログラムの再起動後に有効になります。'+#13#10+
    //  'OKで再起動します。';
    //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    //RestartApplication;

    //操作の取り消しができるように修正
    strMsg:='設定はプログラムの再起動後に有効になります。'+#13#10+
      '再起動してよろしいですか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      RestartApplication;
    end else begin
      //[いいえ]が選ばれた時
      //メッセージを表示せず、チェックボックスの状態のみ変更
      if CheckCMS.Checked then
      begin
        CheckCMS.OnClick := nil;  //OnClickイベントを一時的に無効にする
        CheckCMS.Checked := False;
        CheckCMS.OnClick := CheckCMSClick;  //OnClickイベントを再度設定
      end else begin
        CheckCMS.OnClick := nil;  //OnClickイベントを一時的に無効にする
        CheckCMS.Checked := True;
        CheckCMS.OnClick := CheckCMSClick;  //OnClickイベントを再度設定
      end;
    end;

  end;
end;

2.実行結果

(1)プログラムを起動。フォームが表示される。

練習用なので、CheckBox をひとつだけ用意。
CheckBox の Checked プロパティはデフォルトでは False に設定している。


(2)CheckBoxをクリックすると、メッセージが表示されるので、「はい」をクリックする。


(3)自分自身を再起動。CheckBox の Checked プロパティは終了時の True 状態で起動するが、上記のメッセージは表示されない。 これが実現したかったことのひとつめ。

Checked プロパティは True でも、メッセージは表示されない。


(4)再度、CheckBox をクリック。Checked プロパティは False に変わり、CheckBox のチェックは外れた状態でメッセージが表示される。今度は「いいえ」をクリック。

今度は「いいえ」をクリックする。


(5)「いいえ」を選択したから再起動はしない。「再起動しない」から CheckBox の Checked プロパティは元の True であった状態を維持(= False から True へ修正)するが、メッセージは表示されない。これが実現したかったことのふたつめ。

「いいえ」が選択された場合は、CheckBox の Checked プロパティはチェック前の状態を維持。
(直前のクリックを無効化)

3.お願いとお断り

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

DelphiのSmart CodeInsightを使ってみました!

Delphi 12.2で利用できるようになったSmart CodeInsightが使えるようになるまでの設定方法です。
ローカルで LLM を実行できる Ollama の環境構築から(見様見真似で)やってみました。

【もくじ】

1.はじめに
2.Ollama のインストール
3.LLM モデルのダウンロード
(1)Gemma 2
(2)Llama-3-ELYZA-JP-8B
4.動作環境の設定
(1)Gemma 2
(2)Llama-3-ELYZA-JP-8B
5.DelphiのIDEのスマート支援機能を設定
6.DelphiのIDEのスマート支援機能を使う
7.まとめ
8.お願いとお断り

1.はじめに

ちなみに LLM は(今回、初めて知った!のですが)、自然言語処理( Natural Language Processing :NLP )のタスクに使用される大規模言語モデル( Large Language Model )の略で、膨大な量のテキストデータを使って訓練された人工知能のモデルを意味するそうです。

※ 自然言語処理のタスク:「コンピュータがヒトの言語を理解し、生成し、処理する上での特定の課題や目的」のこと。すなわち、文章の生成、分類、翻訳、応答、人名・地名・組織名等の特定の名称認識( Named Entity Recognition:NER )、音声認識、要約など、実に様々な「タスク」があるようです。

調べてみると実にたくさんの LLM があり、果たしてどのモデルを選べばよいのか(例えば、日本語が得意で、プログラミングに適したモデルはどれなのか?)がわからず、当初、たいへん困りましたが、いくつかの Web サイトの情報を参考に、ここでは「 Gemma 2 」と「 Llama-3-ELYZA-JP-8B 」をダウンロードして使ってみました。

コマンドを叩くだけで簡単にインストールできたのは「 Gemma 2 」、自分で Modelfile を作る必要があり、設定に少し勉強が必要だったのが「 Llama-3-ELYZA-JP-8B 」です。私のノート PC 環境(もちろん、GPU などという結構なモノは、ハナからついておりません!)で、「実用になるか・どうかは別」にして、単に「応答の速さだけを見た」場合、後者の方が体感的には「圧倒的に速かった」です。

【My デバイスの仕様】
プロセッサ 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz 3.00 GHz
実装 RAM 32.0 GB (31.7 GB 使用可能)
システムの種類 64 ビット オペレーティング システム、x64 ベース プロセッサ

2.Ollama のインストール

Delphi 12.2 Athens をインストールして、その新機能に関する記事を読んでいたら、「Smart CodeInsight: コーディングにAIのパワーを活用」という見出しがあり、そのリンク先の記事で、オンラインソリューションや、オフラインソリューションを使用して AI LLM をコーディングに活用できることを知りました。これが今回の事始めです。ドキドキ。

あっ! ちなみに Ollama は「オラマ」と読むようです。ほんとか・どうか、わかりませんが・・・

https://blogs.embarcadero.com/ja/announcing-the-availability-of-rad-studio-12-2-athens-ja/#Smart_CodeInsight_%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0%E3%81%ABAI%E3%81%AE%E3%83%91%E3%83%AF%E3%83%BC%E3%82%92%E6%B4%BB%E7%94%A8

Windows11 の 23H2 で Copilot に触れてから、生成AIにコーディングを助けてもらうことが断然多くなりました。もし、DelphiのIDE上で生成AIが利用できたら、どれほど快適にプログラムが書けることか、想像するだけでワクワクします。これまでGoogle先生に質問をくり返しながら、四苦八苦していたことが、まるで夢のよう・・・。

そう思いつつ、別の記事を参照して、追加の情報もGet。

https://blogs.embarcadero.com/ja/using-ai-llms-in-the-rad-studio-ide-with-smart-codeinsight-ja/

上の記事によれば、オンラインソリューションは基本的に全て有料とのこと。で、唯一、オフラインソリューションの Ollama だけが無料で使えるソリューションでした。

※ ソリューション:「問題解決方法、手段、対応策」

上記リンク先の記事からの引用です。

「アカウントと支払いに関して唯一の例外はOllamaで、Ollama はローカル (または任意のサーバー) にインストールでき、サービス料金を支払うことなくオフラインで使用できます。」

RAD Studio IDEで「AI LLM」と「Smart CodeInsight」を使用する より引用

・・・ということなので、迷うことなく Ollama に決めました。で、Ollama は、MacOS 版、Linux 版、Windows 版があるとのことで、Windows のユーザーである私はもちろん Windows 版をチョイス。

https://ollama.com/download

上記リンク先から( Windows 環境であれば Windows 版の ) Ollama をダウンロードしました。

特に意識しなくても、自動でWindows版が選択されていた気が・・・しますが、OS が Windows11 であればダウンロードフォルダに OllamaSetup.exe(version 0.3.14:2024/10/29 現在)が保存(663MB)されます。これをダブルクリックしてインストール。

インストール自体は、ただ待つだけ。何の問題もなく終了。

(設定で、何か変わったところはあるのかなー?)

・・・と思って、いちおう確認すると、環境変数のPathの最後に

C:\Users\ユーザー名\AppData\Local\Programs\Ollama

が追加されてました。変更は、はたして、これだけか?

ここでの表記は「Ollama」
「ollama」じゃなくて、「Ollama」が正しい表記なのだろうか?


とりあえず、コマンドプロンプトを起動し、次のコマンドを叩きます。

ollama -v

【実行結果】


大丈夫。ダウンロード & インストールは無事完了!

3.LLM モデルのダウンロード

Ollama のインストールが終了したら、Ollama を使って実行する LLM のモデルを入手しなければなりません。「 LLM 日本語 無料 おすすめ」等のキーワードで検索してみると、実にたくさんの LLM モデルがあることに気付きます。

( 百花繚乱・・・)

LLM は、まさに現在進行形で進化中、きっとお互いにしのぎを削っているような状況なのでしょう。

(1)Gemma 2

いくつかの Web サイトを参照して、まず「Gemma(ジェマ)」という LLM を試してみようかと思いました。正直、専門的なことは「チンプンカンプン」で「まったくわからない」私ですが、様々なサイトで「高性能」と評価されていたこと、そして何より、インストールがとても簡単そうだったのがいちばんの理由です。

スタートボタンの隣にある「検索」欄に「cmd」と入力してコマンドプロンプトを起動、んで、次のコマンドを叩くだけで Gemma2 モデルがダウンロードされて、ただちに起動しました。カンタン!

これ作ったひと、すごい! これ作ったひとみたいに、なりたいなー☆

ollama run gemma2
C:\Users\ユーザー名>ollama run gemma2
pulling manifest
pulling ff1d1fc78170... 100% ▕██████████████|略|██████████████▏ 5.4 GB
pulling 109037bec39c... 100% ▕██████████████|略|██████████████▏  136 B
pulling 097a36493f71... 100% ▕██████████████|略|██████████████▏ 8.4 KB
pulling 2490e7468436... 100% ▕██████████████|略|██████████████▏   65 B
pulling 10aa81da732e... 100% ▕██████████████|略|██████████████▏  487 B
verifying sha256 digest
writing manifest
success
>>> Send a message (/? for help)

いくつか質問してみましたが、英語での質問には英語で、日本語での質問には日本語で答えてくれました。さらにいちばん気になる Object Pascal に関する質問にも、よさげな返事を返してくれました。

(2)Llama-3-ELYZA-JP-8B

もうひとつ気になった LLM が「 Llama-3-ELYZA-JP-8B 」です。なぜ、気になったかというと・・・

・「日本語に特化している」という情報が得られたこと。
・「小さい、軽量、ローカルでの実行に適している」という記述が多数のサイトで見られたこと。
・「Code Llama」を含む「Llama3(ラマ3)」がベースになっていること。

これ以外にもたくさんの情報がありましたが、自分的にはこの3つだけでもう十分に魅力的だと感じました。特に、最後の「Code Llama」というプログラム作成を支援してくれる機能が含まれているという解説は「 Delphi を愛して十数年・・・これを試さずには死ねない!」と思うほど、強烈な輝きを放っていました。びゃかー!ってカンジです。あぁ目が眩む。

さて、肝心のダウンロードですが、次のサイトの情報に従って行いました。

「よーしパパ、Ollama で Llama-3-ELYZA-JP-8B 動かしちゃうぞー」

https://qiita.com/s3kzk/items/3cebb8d306fb46cabe9f

上記サイトの記事の作成者様に心から感謝申し上げます。

まず、上記サイトの案内にある通り、「 Hugging Face 」から、「Llama-3-ELYZA-JP-8B-q4_k_m.gguf」をダウンロードしました。

他にも、よくわからないファイルがたくさんあったけど、とにかく「Llama-3-ELYZA-JP-8B-q4_k_m.gguf」だけををダウンロード!

ファイルの大きさは 4.58 GBほどありました。

4.動作環境の設定

(1)Gemma 2

設定は必要ありませんでした。上の記事に書いた通り、コマンドプロンプトを起動して、

ollama run gemma2

これだけで Gemma2 が起動、使用できました。

(2)Llama-3-ELYZA-JP-8B

こちらのモデルは、Ollama で標準サポートされているモデルではない(2024年10月現在)ため、使用するには少し事前準備が必要でした。以下、見様見真似で行った準備作業の手順とその内容です。

・Modelfile という「拡張子のない」ファイルをメモ帳などのテキストエディタで作成する。
・ダウンロードした Llama-3-ELYZA-JP-8B-q4_k_m.gguf と Modelfile を任意の場所にコピーする。
・コマンドを実行して Modelfile から Ollama 用のモデルを作成する。

最初に行ったのは、Modelfile の作成です。Llama-3-ELYZA-JP-8B はその名前を見れば、Llama3 モデルの発展型(そう呼んでいいのかな?)であることが明らかですから、モデルの作成に使用するフォーマットは、Llama3 モデルのそれと同じになるように設定する必要があるとのこと。なので Modelfile はモデルのフォーマット方法を書いたものなんだと理解しました。

※ フォーマット:「モデルがどのようにデータを扱い、出力を生成するか」について、その形式や構造を決めたもので、モデルが入力を理解できるように、また、効率的にデータを処理できるようにするためのルールや方法が定義されている。

【作成した Modelfile 】

作成にあたって、複数のWebサイトにあっ たModelfile の例を調べましたが、Webサイトによって、下の例の最後の行:PARAMETER stop “<|reserved_special_token” 部分が「ない」などの違いがありました。ちなみにこれは、特定のトークン(単語、フレーズ、または文字)に到達した際に出力を停止するようにモデルに指示、つまり特定の条件で出力を停止させることで、予期せぬ長い出力を防ぐために設定するパラメータのようです。

FROM ./Llama-3-ELYZA-JP-8B-q4_k_m.gguf
TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>

{{ .Response }}<|eot_id|>"""
PARAMETER stop "<|start_header_id|>"
PARAMETER stop "<|end_header_id|>"
PARAMETER stop "<|eot_id|>"
PARAMETER stop "<|reserved_special_token"

次に作成した Modelfile と、ダウンロードした Llama-3-ELYZA-JP-8B-q4_k_m.gguf の保存先ですが、どこにしまったらいいのか、さっぱりわかりません。いろいろ調べてみると、この2つのファイルの保存場所は「任意のフォルダ」としているサイトが数多く見受けられました。これより、この2つのファイルは同じフォルダ内に置けば、それでいいのではないかと思えてきました。

そこで「任意のフォルダ」に保存することに決めたのですが、その場所が次の大きな問題です。出来れば、あとから思い出せるように、「どこに保存したか?」絶対忘れない場所がいいです。

で、思いついたのが Ollama のインストール先フォルダのルートに置けばいいのではないか? ということです。調べて見ると Ollama のインストール先は次の場所でした。

C:\Users\ユーザー名\.ollama

さらに、「モデルがどこに作成されるのか」を調べたら、次の場所に作成されるようでした。

C:\Users\ユーザー名\.ollama\models

以上のことから、作成した Modelfile と、ダウンロードした Llama-3-ELYZA-JP-8B-q4_k_m.gguf の2つのファイルを Ollama のインストール先フォルダのルートに保存し、このフォルダを作業ディレクトリにしてモデル作成のコマンドを叩けば上手く行く(= Llama-3-ELYZA-JP-8B のモデルの作成に成功する)のではないかと考え、次のように操作してみました。

まず、作成した Modelfile と、ダウンロードした Llama-3-ELYZA-JP-8B-q4_k_m.gguf の2つのファイルを C:\Users\ユーザー名\.ollama フォルダにコピペする。

「Modelfile」に拡張子はない!


次に、コマンドプロンプトを起動して、モデル作成のコマンドを実行して、Modelfile から Ollama のモデルを作成します。モデル作成のコマンドは、次の通りです。

ollama create elyza:jp8b -f Modelfile

この時、コマンドプロンプト起動直後の状態のまま、カレントディレクトリを .ollama に変更せずにモデル作成のコマンドを実行したらエラーが発生してしまいました。

C:\Users\ユーザー名>ollama create elyza:jp8b -f Modelfile

Error: open C:\Users\ユーザー名\Modelfile: The system cannot find the file specified.

( Modelfile が見えません・・・ あっ☆)

そこで次のようにしてカレントディレクトリを .ollama に変更。

C:\Users\ユーザー名>cd .ollama

もう一度、上記のモデル作成のコマンドを実行。

C:\Users\ユーザー名\.ollama>ollama create elyza:jp8b -f Modelfile
transferring model data 100%
using existing layer sha256:91553c45080b11d95be21bb67961c9a5d2ed7556275423efaaad6df54ba9beae
creating new layer sha256:8ab4849b038cf0abc5b1c9b8ee1443dca6b93a045c2272180d985126eb40bf6f
creating new layer sha256:c0aac7c7f00d8a81a8ef397cd78664957fbe0e09f87b08bc7afa8d627a8da87f
creating new layer sha256:bc526ae2132e2fc5e7ab4eef535720ce895c7a47429782231a33f62b0fa4401f
writing manifest
success

おしまいに success と表示されたので、上手くいったようです。
モデルを実行するには、次のコマンドを入力すればよいとのこと。

ollama run elyza:jp8b

やってみました!

C:\Users\ユーザー名\.ollama>ollama run elyza:jp8b
>>> Send a message (/? for help)

Send a message とある部分に、次のように入力してみました。

>>> DelphiでTLabelのAlignmentにtaCenterを指定しても実行すると設定が反映されないことがあります。どうしたらLabelのキャプションを中央揃えにして表示できますか?

入力後、Enterキーを叩くと・・・

TLabelのAlignmentにtaCenterを指定した際、設定が反映されない場合があります。この問題は、LabelのParentComponentにContainerControlを使用することで解決します。

以下の手順で解決します:

1. LabelをContainerControl (例えばTPanelやTFrame) のChildにします。
2. ContainerControlのAlignmentプロパティにtaCenterを指定します。

上記の方法で、Labelのキャプションが中央揃えになります。

AI の回答の内容の良否は検証の要有りですが、それより、何より・・・

動きましたー☆

5.DelphiのIDEのスマート支援機能を設定

これがいちばんやりたかったことです。

Delphiを起動して、「ツール」→「オプション」の順にクリックします。


オプションのダイアログ(ユーザーに情報を提供したり、ユーザーから入力を受け取るための小さなウィンドウ。ダイアログボックスともいう)が表示されたら、左ペインの「IDE」の中にある「スマート支援機能」をクリックします。

IDEは、前回、開いた場所を記憶しているようです。
必要であれば、「IDE」→「スマート支援機能」の順にクリックしてください。


次のように設定します。


(1)スマート支援機能の下にある「 Enable AI Engine 」のチェックをONにします。
(2)「エディタのデフォルト AI 」のComboBox は選択肢から Ollama を選択します。
(3)「チャットウインドウのデフォルト AI 」も選択肢から Ollama を選択します。

私の環境では、CheckBox のキャプションは「 Enable AI Engine 」でしたが、
Web上で見たダイアログではそれが「AIエンジンの有効化」となっていました。
なぜなんでしょう?


続けてプラグインの部分も次のように設定します。


(1)最初に「 Ollama 」タブをクリックして選択します。
(2)有効にチェックします。
(3)BaseURL に「 http://localhost:11434 」と入力します。
(4)LLM のモデルが Llama-3-ELYZA-JP-8B 場合、モデルには「 elyza:jp8b 」と入力してください。

モデルの設定部分については、何と設定すればいいのか? 当初わからなくて困ったのですが、コマンドプロンプトで Llama-3-ELYZA-JP-8B を実行する時に入力するコマンドが

C:\Users\ユーザー名\.ollama>ollama run elyza:jp8b

であることから、モデルの設定は「 elyza:jp8b 」に違いない!と考え、実際その設定で動作しましたので、多分、これで OK なのではないかと・・・思われます。

入力が終わったら「保存」ボタンをクリックして、オプション設定のダイアログを閉じてください。

6.DelphiのIDEのスマート支援機能を使う

さっそく使ってみます。既存のプロジェクトファイルを開くか、新規にアプリケーションを作成して、コードエディタが表示された状態にします。

編集画面の余白部分を右クリックすると、次の図のようにポップアップメニューが表示されるので、そのいちばん上にある「 Smart CodeInsight 」をポイント(or クリック)します。すると、さらにサブメニューが表示されます。

全部! 試してみたい機能ばかり☆


AIチャットをクリックしてみました☆

Ollama>に質問を入力すればよさそうです!


質問してみました!


少し、待ち時間がありましたが、待ちくたびれるほどではありません。ちゃんと計ったわけではありませんが 30 秒くらいかなー? AI の返事が表示されました。

表示されたコードは Object Pascal ではありませんでした!


質問に「 Object Pascal で書いて!」という内容を追加して再チャレンジ。

すごーい! すごーい!!


ふと思い立って・・・ DelphiのIDEのコードエディタに「VB.NET」のコードを貼り付けて、

ムチャしてます・・・


コード全体を選択して、コードの変換を試してみます。

「Delphi に変換」をクリック


かなり待ち時間がありましたが・・・

結果が表示されるまで、上のような画面になります。


3分くらいかな? ちょっと長かったけど、無事、変換できました!

すごーい! すごーい!!


もし、わからないコードがあった時は・・・

わからないコードを選択して、「コードの説明」をクリック


すると・・・

すごーい! すごーい!!


感動!

7.まとめ

(1)Ollama をインストールすれば、ローカルな環境でも LLM を利用した生成 AI を使用できる。
(2)LLM は Ollama で標準サポートされているものなら、run するだけで使える。
(3)Ollama で標準サポートされていない LLM でも gguf ファイルから create して使用できる。
(4)Delphi のIDEのスマート支援機能で Ollama を指定すれば、無料でローカル LLM を使える。
(5)GPU がないPCでも(待ち時間はあるが)スマート支援機能は使えそうな気がする。

8.お願いとお断り

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

Excelのプロセスを終了させる

プログラムがフリーズするなどして、実行中の Excel のプロセスが残ってしまうことがある。また、そうでなくても、例えば Delphi の Try ~Finally 文で、確実に Excel のオブジェクトを解放したつもりであるにもかかわらず、プログラムで読み書きした特定の Excel のファイルが「編集のため、ロックされています」と表示され、「読み取り専用」でしか開けなくなり、Ctrl+Alt+Del でタスクマネージャーを起動して確認すると、場合によっては5つも6つもExcelのプロセスが実行中であったり、する。

そこで Delphi で書いた Excel のファイルを操作するアプリケーションを終了する際に、実行中のプロセスが残らないようにする方法を考えてみた。

【もくじ】

1.確認メッセージを表示して終了させる
2.確認メッセージを表示せずに起動中の全てのプロセスを終了させる
3.お願いとお断り

1.確認メッセージを表示して終了させる

最初に書いてみたのがコレ!
Excel のプロセスが実行中であれば(残っていれば)、確認メッセージを表示して、プロセスを強制的に終了させる。ただし、このコードで終了できるプロセスは1つのみ。

procedure TForm1.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
var
  strMsg: string;

  //Excelのプロセスが実行中であるか、どうかを調査する関数
  function IsExcelRunning: Boolean;
  var
    Snapshot: THandle;
    ProcessEntry: TProcessEntry32;
  begin
    Result := False;
    Snapshot := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if Snapshot = INVALID_HANDLE_VALUE then Exit;

    ProcessEntry.dwSize := SizeOf(TProcessEntry32);
    if Process32First(Snapshot, ProcessEntry) then
    begin
      repeat
        if SameText(ProcessEntry.szExeFile, 'EXCEL.EXE') then
        begin
          Result := True;
          Break;
        end;
      until not Process32Next(Snapshot, ProcessEntry);
    end;
    CloseHandle(Snapshot);
  end;

  //プロセスのリストを取得し、特定のプロセスを終了する関数
  function TerminateExcelProcesses: Boolean;
  var
    Snapshot: THandle;
    ProcessEntry: TProcessEntry32;
    ProcessHandle: THandle;
  begin
    Result := False;
    Snapshot := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if Snapshot = INVALID_HANDLE_VALUE then Exit;

    ProcessEntry.dwSize := SizeOf(TProcessEntry32);
    if Process32First(Snapshot, ProcessEntry) then
    begin
      repeat
        if SameText(ProcessEntry.szExeFile, 'EXCEL.EXE') then
        begin
          ProcessHandle := OpenProcess(PROCESS_TERMINATE, False, ProcessEntry.th32ProcessID);
          if ProcessHandle <> 0 then
          begin
            if TerminateProcess(ProcessHandle, 0) then
            begin
              Result := True;
            end;
            CloseHandle(ProcessHandle);
          end;
        end;
      until not Process32Next(Snapshot, ProcessEntry);
    end;
    CloseHandle(Snapshot);
  end;

begin
  if IsExcelRunning then
  begin
    //Excelのプロセスを終了させる
    strMsg:='Excelのプロセスが実行中です。'+#13#10+#13#10+
      '終了してもよろしいですか?';
    if Application.MessageBox(PChar(strMsg), PChar('警告'), MB_YESNO or MB_ICONWARNING) = mrYes then
    begin
      //[はい]が選ばれた時
      if TerminateExcelProcesses then
      begin
        strMsg:='Excelプロセスを終了しました。';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end else begin
        strMsg:='実行中のExcelプロセスは見つかりませんでした。';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end;
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='Ctrl+Alt+Delキーを同時に押してタスクマネージャーを起動し、実行中の'+
      'Excelのプロセスを必ず終了してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end else begin
    strMsg:='Excelは実行されていません。';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
  end;

end;

ただ、コレだと、もし、Excelのプロセスが実行中であった場合、アプリケーションの終了時に突然表示されるメッセージに、ユーザーが驚き、「はい」・「いいえ」のどちらを選べばいいのか、操作上の混乱が生じる可能性があるように思えてきた・・・。

それに、複数の Excel のプロセスが実行中であった場合、この方法では1つしか、終了できない。

そこで、ユーザーには何も知らせずに、もし実行中の Excel のプロセスがあれば、バックグラウンドですべてのプロセスを終了させるようにプログラムを修正。それが次の「確認メッセージを表示せずに起動中の全てのプロセスを終了させる」例。

2.確認メッセージを表示せずに起動中の全てのプロセスを終了させる

実行中の全ての Excel のプロセスを強制的に終了させる。ユーザーに対する確認メッセージは表示しない。

procedure TForm1.FormCloseQuery(Sender: TObject; var CanClose: Boolean);

  //Excelのプロセスが実行中であるか、どうかを調査する関数
  function IsExcelRunning: Boolean;
  var
    Snapshot: THandle;
    ProcessEntry: TProcessEntry32;
  begin
    Result := False;
    Snapshot := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if Snapshot = INVALID_HANDLE_VALUE then Exit;

    ProcessEntry.dwSize := SizeOf(TProcessEntry32);
    if Process32First(Snapshot, ProcessEntry) then
    begin
      repeat
        if SameText(ProcessEntry.szExeFile, 'EXCEL.EXE') then
        begin
          Result := True;
          Break;
        end;
      until not Process32Next(Snapshot, ProcessEntry);
    end;
    CloseHandle(Snapshot);
  end;

  //プロセスのリストを取得し、特定のプロセスを終了する関数
  function TerminateExcelProcesses: Boolean;
  var
    Snapshot: THandle;
    ProcessEntry: TProcessEntry32;
    ProcessHandle: THandle;
  begin
    Result := False;
    Snapshot := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if Snapshot = INVALID_HANDLE_VALUE then Exit;

    ProcessEntry.dwSize := SizeOf(TProcessEntry32);
    if Process32First(Snapshot, ProcessEntry) then
    begin
      repeat
        if SameText(ProcessEntry.szExeFile, 'EXCEL.EXE') then
        begin
          ProcessHandle := OpenProcess(PROCESS_TERMINATE, False, ProcessEntry.th32ProcessID);
          if ProcessHandle <> 0 then
          begin
            if TerminateProcess(ProcessHandle, 0) then
            begin
              Result := True;
            end;
            CloseHandle(ProcessHandle);
          end;
        end;
      until not Process32Next(Snapshot, ProcessEntry);
    end;
    CloseHandle(Snapshot);
  end;

begin

  //Excelのプロセスが実行中である限りLoopさせ、完全にExcelのプロセスを終了させる。
  While IsExcelRunning do
  begin
    TerminateExcelProcesses;
    Application.ProcessMessages;
  end;

end;

これが、いちばんスマートかな?

3.お願いとお断り

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

デジタル採点 All in One !

これまでに書いてきたデジタル採点プログラムをひとつにまとめました!

手書き答案採点・マークシートリーダー・採点結果通知&成績一覧表の作成プログラム


「AC_Reader」は、手書き答案のデジタル採点に、「MS_Reader」は、マークシート形式の試験のデジタル採点に、それぞれ使用します。

「ReportCard_2024」は、「AC_Reader.exe」及び「MS_Reader.exe」と連携して動作するプログラムで、受験者へのデジタル採点の採点結果を通知する個票及び採点者用の成績一覧表を作成することが出来ます。

「デジタル採点 All in One! 」では、3通りのデジタル採点の実行と、受験者に採点結果を通知する個票及び成績一覧表の作成が可能です。その概要は次の通りです。

1.マークシートの読み取りとデジタル採点
2.手書き答案のデジタル採点(縦書き・横書き、両方の答案に対応)
3.マークシートと手書きを併用した試験のデジタル採点

ただ、プログラミングには素人である筆者が作成したプログラムですので、使いにくいのはもちろんのこと、未発見の不具合もまだきっとあると思います・・・が、掲載したプログラムはすべて「実際に採点の現場で使用」し、動作確認を行ったもので、その際に発見できた不具合はすべて修正してあります。したがって、筆者の想定する範囲内での運用であれば、確実に動作するはずですが、ご使用に際しては事前に必要十分な試行・動作確認を行っていただけますよう、心からお願い申し上げます。

掲載したプログラムは、何の保証もサポートもありませんが、すべて無料でお使いいただけます。ただし、ご使用に際しては、完全に自己責任での運用をお願いいたします。ここに記載した内容及びダウンロードしたプログラムを利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

プログラムは今後も改良し続け、掲載したプログラムは随時改良版に更新する予定です。
見た目も、内容も不出来なプログラムですが、万一にでも、使ってくださった方の採点業務のご負担の軽減に貢献できましたなら、私にとって、それは何よりの喜びです。

プログラムのダウンロード(ZIPファイル)

プログラムのダウンロード後、任意の場所に展開してください。

【心からのお詫び】

2025年3月29日に「観点別評価と評定の整合性をチェックする」という記事を公開した際、誤ってこちらのダウンロードリンク先のファイルを削除し、そこに「観点別評価と評定の整合性をチェックするプログラム:ABC_Checker.exe」を置いてしまいました。そのため、2025年3月29日午前5時すぎから、2025年4月1日午後6時頃まで「DigitalSaiten_All_in_One.zip」がダウンロードできない状況でありましたこと、心よりお詫び申し上げます。ほんとうに、こころから、ごめんなさいです。

【もくじの前書き】

今回の記事では、採点プログラムそれぞれについて、ダウンロードしていただいたZIPファイルを展開すればすぐに試せる簡単な試用方法をご紹介しています。

実際の試験においては、スキャナーを使用してマークシートや手書き答案の画像化処理を行ったり、専用プログラムを使用して、マークシートや手書き答案の解答欄の座標を取得する等、採点の事前準備作業が必要です。

ダウンロード後展開していただいたZIPファイル内のファイルやフォルダの構成及び以下の説明の内容は、それらの必要な事前準備作業を終えた段階以降の『実際の採点作業部分のみ』を手軽にお試しいただけるように作成してあります。それぞれ、説明の通りに操作していただけたら幸いです。

操作に際し、予期しないエラーが出た場合の対処方法や、実際の試験の採点に必要な事前準備作業の詳細は、以下の説明の中でご紹介する「過去記事のご案内」リンク先の各採点プログラムの取扱い説明記事をご参照ください

【もくじ】

1.マークシートの読み取り
2.手書き答案のデジタル採点
3.マークシートと手書きを併用した試験のデジタル採点
4.採点結果通知の作成
5.お願いとお断り

1.マークシートの読み取り

プログラムアイコン


【スクリーンショット】

マーク読み取り実行直後の画面


マークシートは、市販のものでなく、再生コピー用紙にインクジェットプリンタで印刷したものを使用します。輪転機で印刷するとマークが濃く印刷されてしまい、「複数マークあり」の誤判定が出やすくなります。ですので、マークシートの印刷には、インクジェットプリンタを使用してください。

シートの左上には特徴点(例:■■■)が必要です。プログラムはマーク読み取り時に、まずシート内の特徴点を探し、そこからの距離情報をもとに一つ一つのマークを切り出して塗りつぶし面積を計算、マークの有無を判定しています。

マークシートの画像は、複合機等のスキャナーで200dpiの解像度でスキャンして作成してください。解像度を大きくしても読み取り処理に必要な時間が大幅に増加するだけでメリットは何一つありません。

デジタル採点の現場で実際に使用した様々な形式のマークシートを添付しましたので、こちらを印刷してお試しください。オリジナルマークシートの作成方法は、添付したPDFファイル「01_マークシートリーダーご利用の手引き」の「7 マークシートの作り方」をご参照ください。

【添付したマークシート】

・1列25行×4列(100設問まで対応)、選択肢は1始まりで8選択肢(A4横R25C04S08)
・1列25行×3列(75設問まで対応)、選択肢は1始まりで10選択肢(A4横R25C03S10)
・1列25行×2列(50設問まで対応)、1始まりで8選択肢。右余白は手書きの解答欄に使用。
・1列25行×3列(大問3個に対応)、16選択肢の数学用(2枚1セットで大問6個に対応)
・1列25行×3列(75設問まで対応)、選択肢は0始まりで16選択肢の教科「情報」用
・1列25行×4列(100設問まで対応)、大語群(選択肢番号は0~99まで使用可能)マーク試験用


マークシートのサンプル①

一般的な塗りつぶす形式のマークシートです。実際の試験の現場で過去5年以上使用しています。読み取り精度が問題になったことは一度もありません。

塗りつぶすマークシート(Wordで作成)


マークシートのサンプル②

線でマークすれば、大語群を使用する試験で解答に要する時間を大幅に短縮できます。ちなみに、芯の太さ0.9mm、硬さ・濃さ2Bのシャープペンシルを使用してマークし、読み取りテストを行ったところ、読み取りパラメータの設定はデフォルト値のまま、すべてのマークを正しく読むことができました。

例:線で「35」をマーク(Excelで作成)


MS_Reader.exe の詳しい使い方は、当Blogの過去記事をご参照ください。
(プログラムを動かすために必要な諸設定についての情報も記載しています)

【過去記事のご案内】

重要 数学採点用途で使用される場合は、当Blogの過去記事「マークシートリーダーを数学用に設定」にあります使用方法を必ずご確認ください。


今回掲載したプログラムには、すぐにお試しいただけますよう、マークシート情報設定済みのサンプルを添付してあります。ファイルのダウンロード及び展開に時間がかかるデメリットはありますが、マーク読み取りを圧倒的に高速化するPython4Delphi(=P4D環境)も今回は、ダウンロードサイズと展開時間を顧みず、敢えて同梱しました。ですので、ここでご紹介する筆者作のマークシートリーダーは自動的にPython環境を使用する高速読み取りモードで起動します。

以下、Zipファイルのダウンロード後、ファイルを任意のフォルダに展開した後の、筆者作マークシートリーダーの試用方法です。

(1)MS_Reader を起動

MS_Reader.exe をダブルクリックして、MS_Reader を起動します。MSはもちろんマークシートの略ですが、筆者のイニシャルが M.S なので、それにもかけてあります。

ここで発生すると思われる不具合とエラーの解決方法は、当Blogの次の過去記事をご参照ください。


(2)マークシートの情報を記録したテンプレートを選択

画面左上のメニューの「2 テンプレート」をクリックすると表示されるサブメニューの「テンプレートの選択」をクリックします。


(3)リストボックスに表示された候補から「N_R25C04S08」をクリックして選択し、「決定」ボタンをクリックします。


ちなみに、テンプレート名の N は、解像度200dpiでスキャンした際の画像ファイルの大きさをノーマルと考えて画像サイズから自動で付けています。

その後ろのR、C、D、Sはそれぞれ次のような意味です。

RはRow、すなわち「行」です。R25なら1列あたり25行のマークシートを意味します。
CはCol、すなわち「列」です。C04なら4列で構成されたマークシートを意味します。
DはDouble、複数マーク可能なマークシートを意味(19選択肢のシートのみ設定可能)。
SはSingle & Select、複数マーク不可で、S08なら選択肢の数は8個のシートを意味します。


(4)表示されるメッセージを読んで、「はい」をクリックします。


(5)「Sample_Data_01_一般用」フォルダをクリックして選択し、「OK」をクリックします。

選択するのは「フォルダ」で、「ファイル」ではありません!


(6)正しくプログラムが動作していれば、画面は次のようになります。

バックグラウンドで動作するPython環境のOpenCVが特徴点画像(■■■)を探し出し、赤枠の矩形でそれを囲んで表示します。同時に、Delphi側のプログラムでテンプレートに記録したマークシートの座標情報を読み込み、最も左側の列の第1行目の選択肢欄を赤枠で囲んで表示します。これでマークシートの読み取り準備が出来ました!


(7)操作方法を案内するバルーンが表示されますので、その先にある「読む」ボタンをクリックしてください。プログラムがマークシートのマークを読み取り、結果をグリッドコントロール上に表示します。


筆者のPCでは、Python4Delphi(P4D)を利用した状態で、1枚100設問(800マーク)×3枚で合計2400マークを986ミリ秒で読み取り、結果を表示しました。1マーク2.43ミリ秒、1枚329ミリ秒で読み取っていますので、この形式(25行×4列・8選択肢)のマークシートを使用した場合、筆者の環境では平均的な1クラス分(40名)を約13.2秒で読むものと推測できますが、使用するPCの性能によりこの値は変化します。


(8)「Check!」ボタンをクリックして、読み取り結果をヒトの目でチェックします。次の例のように、読み取り結果の確認(修正)が必要と思われる箇所で赤枠を表示してチェックプログラムは一時停止します。

【空欄(マークなし)と判定した場合】

「空欄(マークなし)」と判定した場合、グリッドコントロール上には「999」と表示されます。
なお、「白紙(全マークが空欄)」のマークシートは読み飛ばす設定が可能です。


【複数マークありと判定した場合】

「複数マークあり」と判定した場合、グリッドコントロール上には「99」と表示されます。
マークの状況を確認し、必要な場合は読み取り結果を直接入力して修正します。


読み取り結果の確認・修正後、再度「Check!」ボタンをクリックすると、一時停止が解除され、チェックが続行されます。次のメッセージが表示されたら、読み取り結果のチェックは終了です。


(9)読み取り結果の書き出しを実行

マークの読み取り結果はCSV形式でファイルに出力できます。表計算ソフトを利用して読み取り結果を処理する場合はもちろんですが、筆者が作成した「ReportCard_2024.exe」を用いて、採点結果を受験者に通知する個票を作成する場合は、必ずここで読み取り結果をCSVファイルに書き出す作業を行ってください。

読み取り結果をCSVファイルに出力


同梱の「ReportCard_2024.exe」を用いて、採点結果通知を作成できます。こちらのプログラムの使用方法は後述します。


「ReportCard_2024.exe」を用いた採点結果通知の作成例です。

設問ごとに採点結果と観点別評価の区分及び正解マークを表示できます。
また、任意の位置に得点を表示できます。


(10)その他の機能

MS_Reader には、マーク読み取りに加え、読み取り結果の音声読み上げ機能や、チェックのスキップ機能など、筆者が必要と考えた機能を搭載してあります。使い方の詳細は、当Blogの過去記事(上のリンク先)にありますので、必要に応じてご参照ください。

2.手書き答案のデジタル採点

プログラムアイコン


【スクリーンショット】

横書き答案の採点実行時の画面
(添付した答案枚数3枚の採点サンプルを使用)


採点する答案は、横書き・縦書きを問いません。どちらの形式の答案でも採点可能です。また、複合機のスキャナーで読み取り可能な大きさであれば、解答用紙のサイズも問いません(ただし、採点する答案すべてのサイズと解答欄の形式は同じである必要があります)。前述のマークシートでは、解答用紙の左上に特徴点(■■■)が必要でしたが、こちらの手書き答案の採点補助プログラムでは必要ありません。

答案画像は複合機のスキャナー等を用いて、解像度200dpiでスキャンしたカラー画像を使用してください。解答欄矩形は、採点準備作業時に、別に作成した矩形検出プログラムを用いて解答用紙画像より検出・座標データを取得してiniファイルに保存します。手書き答案の採点プログラムは、この座標データをもとに解答欄矩形を答案画像から切り出して、切り出した解答欄を画面に並べて表示します。

解答用紙の解答欄を作成する際は、矩形検出されたくない部分を点線で作成していただく必要がありますが、これさえ守っていただければ、かなりスムーズに解答欄矩形の検出作業(座標データ化)が行えると思います。実際に採点に入るまでに必要な採点準備作業の詳細は、当Blogの過去記事をご参照ください。

解答欄の作成例:矩形検出されたくない部分の罫線は点線を使用します。
これにより設問番号を含んだ解答欄矩形の切り出しや、
字数を指定しての解答欄作成が可能になります。


【過去記事のご案内】

【機能追加版のご案内】

ここで紹介している手書き答案のデジタル採点補助プログラム AC_Reader に自動採点機能みたいなモノを搭載しました。


今回掲載したプログラムには、すぐにお試しいただけますよう、採点準備作業を行ってあるサンプルを添付してあります。以下、手書き答案採点補助プログラムの試用方法です。

(1)AC_Readerを起動

AC_Reader.exe をダブルクリックしてプログラムを起動します。AC は Answer Column(解答欄)の略です。プログラム起動時(初回)に次のメッセージが表示されます。

PCのボリューム設定値が0より大きい場合に表示されるメッセージです。

消音して作業できます。
(ボリューム設定値が0の場合は、表示されません)


採点作業内容の確認メッセージです。

必要に応じて「はい」・「いいえ」のいずれかをクリックします。


「いいえ」をクリックすると、次のメッセージが表示されますが、これは表計算ソフトを使用して採点結果通知を作成していた頃の名残りで、表計算ソフトを使用せずに採点結果通知の作成ができるようになった現在はどちらから採点を始めていただいても問題は生じません。


試用される場合、次のメッセージには「いいえ」を選択(クリック)してください。

添付した採点試行用のサンプルは「横書き」です。


初回起動時には複数のメッセージが表示されますが、次回起動時からこれを表示しない設定にすることができます。お好きな方のボタンをクリックしてください。


あらゆるケースを想定した場合、このようなメッセージも必要と判断しました・・・。
(確か、採点結果通知の個票作成プログラムで、合計点を計算するコードを書いていた際に「何か」問題が起きて、このメッセージを表示することにしたような記憶があります)


お断りしたように不出来なプログラムですので、こちらの注意も必ずお守りください。


同じく、こちらの注意も必ずお守りください。


採点方法のご案内です。複数のユーザーより、「前回の採点から2~3か月も経過すると忘れてしまう!」との指摘がありましたので起動時に採点方法を案内するメッセージを表示するようにしました。


このメッセージは、プログラムの画面右下にある「入力方法のご案内」ボタンをクリックすれば、いつでも表示することができます。


(2)既存の採点設定を選択

試用に際しては、筆者が設定・保存した採点設定をお使いください。
画面右上にある「採点作業」ボタンをクリックします。


次のメッセージが表示されますので、「はい」をクリックしてください。


採点設定ファイルの選択を促す案内バルーンが表示されます。
ComboBox右端の∨をクリックしてください。


表示された選択肢の「テスト採点.ini」をクリックして選択します。


(3)続けて採点したいクラス/講座の答案画像が保存されているフォルダを選択します。


上のメッセージの「OK」をクリックすると、フォルダの選択ダイアログが表示されます。

「Sample_Data_04_Markと横手書併用」フォルダをクリックして、「OK」をクリック


(4)採点を実行

最初にフローティング状態のパネルを適切な位置へ移動します。

フローティングパネルのタイトルバーをクリックして任意の位置へD&Dします。


点数を一括入力する場合は、「入力と確認」のComboBoxから入力したい値を選択して「入力」ボタンをクリックします。選択した値がすべての解答欄に設定されますが、入力値が「0」であれば×、そうでない場合は○と得点が表示されます。


個々の採点は、採点したい解答欄の中央付近をクリックして、採点方法の案内にあった方法で採点します。


(5)採点結果の保存

採点結果を答案画像に書き込むには、フローティングパネルの「書込」ボタンをクリックします。

重要 作業の状態は「書込」ボタンをクリックしたところまでが保存されます。「書込」ボタンをクリック後はいつでも終了できます。

重要 採点は何度でもやり直すことができます。


何設問目まで採点したかについては、答案画像を表示して確認できます。

「返却用答案を表示」にチェックを入れると、現在採点している答案画像が表示されます。


◀ボタンや▶ボタンをクリックして表示する答案を変更することができます。


(6)返却用答案及び成績一覧表の作成

手書き答案の採点プログラムには、単独で受験者に返却する答案の印刷や教科担任用の成績一覧表を作成する機能があったのですが、今回、新しく採点結果通知作成プログラムを作成しましたので、独自に返却用答案を作成する機能はCut(正確には非表示に)してあります。

今回、新しく作成した採点結果を通知する個票及び教科・科目担任用に成績一覧表を作成するプログラム「ReportCard_2024」は、「返却用答案&成績一覧作成」をクリックすると起動できます。


「ReportCard_2024」の使い方は、この後の説明をお読みください。

3.マークシートと手書きを併用した試験のデジタル採点

次のような解答用紙を用いて、マークシートと手書きを併用した試験を実施・採点することも可能です。


採点は、マークシート部分のマークの読み取りはMS_Readerで、手書き解答欄の採点はAC_Readerでそれぞれ行ってください。

MS_Readerを用いてマークの読み取りを行った後はCSVファイルに読み取り結果を出力、AC_Readerを用いて手書き答案の採点を行った後は採点結果を「書込み」ボタンをクリックして自動保存(保存先ファイル等を指定していただく必要はありません)していただければ、採点結果通知の作成準備も内部的に完了します。

どちらの採点を先に行うかについて、その作業順は問いませんが、マークシートの採点→手書き答案の採点という流れの方がプログラムが表示するメッセージの内容に矛盾を感じることなく作業できると思います。

採点終了後、次にご案内する採点結果通知を作成するプログラムで、それぞれの試験の合計得点を計算します。

4.採点結果通知の作成

プログラムアイコン

【スクリーンショット①】

受験者への採点結果通知の作成例


【スクリーンショット②】

採点者用の成績一覧表の作成例
氏名データは架空のものです。また、得点データが2件しかないのは添付した試用サンプルを用いて作成したためです。

ReportCard_2024の使い方

(1)起動

ReportCard_2024.exe をダブルクリックしてプログラムを起動します。PCのボリューム設定値が0でない場合は、Beep音を消音するかどうかを確認するメッセージが表示されます。


(2)採点作業を選択します。

ここでは併用タイプを選択しました。


(3)採点対象の答案画像を保存したフォルダを選択します。

「開く」ボタンをクリックします。


採点対象の答案画像を保存したフォルダを選択します。


(4)採点設定がある場合

既存の採点設定がGridコントロールに表示され、採点できる状態になります。

「実行」ボタンをクリックして採点を行ってください。


マークシートの採点の場合、形式の確認メッセージが表示されます。


マークシートの採点の場合、使用したテンプレートを指定します。


採点結果は次のように表示されます。

【マークシート部分①】

デフォルト設定では、左から「採点記号・配点・観点別評価の区分」がそれぞれ表示されます。


【マークシート部分②】

空欄もしくは誤りがある設問には正解の選択肢が数字で示されます(デフォルト設定を利用した場合)。


【手書き答案部分】

観点別評価の区分を表示することはできませんので、受験者に口頭で区分を説明する必要があります。

得点は下の例のように表示されます。得点の表示位置は任意の位置を選択・設定を保存できます。

手書き答案部分には「採点記号と得点」が表示されます。
(観点別評価の区分を表示することはできません)


(5)採点設定がない場合

次のメッセージが表示されます。内容をよく読んで「OK」をクリックしてください。


設問数を入力し、「入力完了」をチェックしてください。


操作方法を案内するメッセージが表示されます。


入力をクリックします。


デフォルトの配点を設定します。入力は半角数字で整数を入力し、「OK」をクリックしてください。


マークシート用の採点設定には「正解の選択肢の番号」・「配点」・「観点別評価の区分」をそれぞれ入力してください。また、手書き答案用の採点設定には「配点」・「観点別評価の区分」を入力してください。


採点設定を入力後、「保存」ボタンをクリックして、設定を保存してください。


採点設定の保存が完了すると、採点の「実行」ボタンがクリックできる状態になります。


(6)採点結果通知個票の印刷

「印刷」ボタンをクリックしてください。


出力先プリンタ・用紙・印刷の向きを指定して「OK」をクリックしてください。
元々の答案のサイズがA3やB4であっても、用紙サイズでA4を指定すれば縮小印刷されます。


答案すべてを印刷するか、個別に印刷するか、いずれかを指定してください。


「いいえ」(個別印刷を選択)を選んだ場合は、印刷したい答案の番号を指定してください。


印刷例です。


(7)成績一覧表の作成と印刷

成績一覧表の作成の「講座名票」ボタンをクリックします。


受験者の氏名等のデータを保存したCSVファイルを選択します。

予めsNameフォルダ内にクラス・講座の氏名データを所定の様式で作成・保存してください。


【参考:氏名データの様式】

クラス・出席番号は「半角」で入力、氏名・よみがな・性別は「全角」でそれぞれ入力し、CSV形式でsNameフォルダ内に保存してください。

フィールド名は入れないでください。
(添付したデータは架空のものです)


平均点を正しく計算するため、試験を欠席した受験者を計算対象から除く処理を行います。
「欠席者をチェック」のCheckBoxをチェックしてください。


得点の合計が「0」の受験者について、平均点の計算処理の対象とするか・しないかを指定します。「はい」をクリックした場合は、成績は「空欄」扱いとなり、平均点の計算対象からは除かれます。
「いいえ」をクリックした場合は、その受験者の得点合計は0点であったものとして平均点を計算します。


印刷プレビューを表示します。「PreView」ボタンをクリックしてください。


添付したファイルのデータはすべて架空のものです。

添付したサンプルデータが3件しなないため、このような表示となっています。


「印刷」ボタンをクリックして印刷します。「CSV出力」ボタンをクリックすれば、CSVファイルに出力することも可能です。表計算ソフトを利用した追加の処理にお役立てください。


CSVファイルのファイル名は自動的に設定されます。また、CSVファイルは処理対象の答案画像があるフォルダ内に出力されます。

「保存」ボタンをクリックしてください。


保存処理が完了すると、次のメッセージが表示されます。


「はい」をクリックした場合、エクスプローラーが起動し、保存先フォルダを開きます。
「いいえ」をクリックした場合は、CSVファイルの保存先を示すメッセージが表示されます。

「はい」をクリックした場合、エクスプローラーが起動し、保存先フォルダを開きます。

5.お願いとお断り

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

追記(20250702)

100選択肢用のマークシートを使って試験を行ったユーザーの方から、『マークの塗りつぶし面積が大きいと誤判定が出やすい』とのご指摘をいただきました。確認すると、受験者は「濃く・丁寧に」マークを塗りつぶしたことがマークシートから伝わってくるのですが、100選択肢用のA4横サイズのマークシートは・・・やはり、と言うか、どうしてもマークそのものが小さく、受験者によっては「きちんとマークすること=大きくマークすること」と、感じて(?)、選択肢番号の外枠の楕円「0」部分を上下左右に大きくはみ出して隣のマークの領域範囲まで塗りつぶしてしまい、結果的に、その受験者のマークシートは複数マーク判定だらけになることがわかりました。

対策として、『選択肢番号の外枠の楕円「0」部分をはみ出さないようにマークする』よう注意を徹底することをお願いしたのですが、それだけでは根本的な解決とならないように感じ、マークとマークの間隔が狭いためにこの問題が起きていることは明白ですから、1設問について100選択肢に対応を維持しつつ、1列25行×4列で100設問まで対応という現在のマークシート構成を見直し(マーク間の幅を広げるため列数を減らし)、1列33行×3列で99設問まで対応可能というマークシートを作成しました。また、50分という試験時間を考えると80設問あれば十分というご意見も頂戴しましたので、1列30行×3列で90設問まで対応可能なマークシートや、1列25行×3列で75設問まで対応可能なマークシートを作成し、これらのマークシートを1つの Excel Book にまとめました。以下のリンクからダウンロードできます。

[dcc32 致命的エラー] F2039 ファイル ‘.\Win32\Release\project1.exe’ を作成できません


Delphiで「実行(F9)」すると、時々、コレが・・・

コンパイル結果の表示

さらに・・・

メッセージにも(泣)


原因は人それぞれなのかもしれませんが、私は以下の方法でこれを解決できました!

【これまでの解決方法】

既存の「xxx.exe」を手動で削除して、再度「実行(F9)」。
今までは、ずっとこの方法で対応。

【新しい解決方法】

「プロジェクト」ツールウィンドウの xxx.exe を右クリックして、


表示されるメニューの「クリーンアップ」をクリック。


「クリーンアップ」というタイトルの画面が表示され・・・


「成功」!


これで「実行(F9)」で、再び exe が生成されるようになりました。

【まとめ】

「実行(F9)」時に exe を作成できない場合は、プロジェクトツールウインドウの exe を右クリックして「クリーンアップ」を実行する。

【お願いとお断り】

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

StringGridのデータを印刷プレビューして印刷

Delphiで、どうしても書きたいプログラムがあった。
そのために絶対に越えなければならないハードルが、データの印刷プレビューと印刷だ。

正直、これは、ごく簡単に思えてならないことなんだけれど・・・
今まで何度もチャレンジして、そのたびに失敗してきた。。。

今回、ようやく、自分自身、納得の行くものが書けた。
これはその備忘録。

【もくじ】

1.印刷したいデータを準備
2.罫線も印刷する
3.CSVファイルをStringGridに表示する
4.印刷のコード
5.rect:TRectとしてはいけません!
6.印刷プレビューのコード
7.まとめ
8.お願いとお断り

1.印刷したいデータを準備

表計算ソフトを使って、印刷したいデータを作成し、CSV形式で保存する。
例えば、こんな感じ。

データはすべて架空のもの


書きたい印刷プログラムは、A4版・縦の用紙に収まる範囲の列数で、行数は1ページに最高50行を予定。ただし、データとして50行なので、フィールド名を入れれば1ページあたり51行となる。

CSVファイルの先頭には「フィールド名も保存」する。ただし、フィールド名があるのはファイルの先頭のみ。50行ごとに入れたりはしない。

自分の場合、印刷データが50行を超えることは、まず「ない」・・・のだが、冒頭で述べた「どうしても書きたいプログラム」の使用予定者の中にはそうでない方もいる。

なので、書きたいプログラムでは、データ数(=行数)がどんなに増えても、各ページの先頭行にはフィールド名を入れる仕様とする。これは譲れない自分との約束。

2.罫線も印刷する

これも、どうしても越えたいハードルのひとつ。フォントの大きさに関係なく、1行毎に罫線(=下線)を印刷する。もちろん、「罫線無し」の印刷も可能とするが、「罫線有り」の場合は1ページについて必ず51行分の罫線が引かれるのではなく、印刷する行数に合わせて罫線(=下線)を引くようにしたい。

今まで、これがどうしても「できなかった」。

だから、この壁は必ず乗り越える。これも譲れない自分との約束。


追記(20240901)

フォントの大きさは9ポイントに固定しました。

3.CSVファイルをStringGridに表示する

Delphiを起動。次の構造ペインに示すような形でVCLコントロールをForm上に配置。

最低限必要なVCLコントロール
AlignやVisibleなど、各プロパティは必要に応じて設定


Formは常に最大化して表示されるように設定。FormCreate手続きに次のコードを記述。

procedure TForm1.FormCreate(Sender: TObject);
begin
  //Formを最大化して表示(幅も最大化される)
  Form1.WindowState:=wsMaximized;
end;

この場合は、Form1が親だからこれでOKだが、子の場合は注意が必要。

どこに記述するか?
・自分自身が親Formの場合:FormCreateでOK!
・自分自身が子Formの場合:FormShowに書くこと(FormCreateに書くと一般保護違反のエラーが発生する)
・自分自身が子Formの場合にFormのWindowStateプロパティで直接指定しておいたらMy環境では何の問題もなく動作した。

また、Form1のScaledプロパティをFalseに設定することも忘れない。

これはどんなプログラムでも必ず最初に設定する


これをTrue にすると OS の DPI (ユーザが指定した DPI)によってフォームサイズやコントロールサイズが勝手に変更されてしまう。デフォルトでTrueなので注意が必要。Formを作成したら毎回忘れずに設定する。

で、exeがあるフォルダ以下の構成は次の通り。この階層構造をもとにしてPathを設定する。

ProcはProceed(処理済み)の略(のつもり)


StringGridに読み込むCSVファイルの置き場所は、「\sNameフォルダ」とする。

sNameフォルダ内にCSVファイルを用意する


これを読み込んでStringGridに表示する。OpenDialogをFormに追加する。

Form上に追加


読み込むCSVファイルのデータは次の通り。
(フィールド名が「ない」場合や、データに通し番号がなく、行番号を表示したい場合にも対応できるコードを含めて記述した。必要ない部分はコメントアウトしている)

今回使用するデータは「フィールド名・通し番号あり」


次のコードを記述。

procedure TForm1.Button1Click(Sender: TObject);
var
  //CSVファイルの読み込み
  CSVFileName: string;
  CsvFile:TextFile;
  CsvRowStr: string;
  i: Integer;
  strMsg: string;
  //列幅の調整
  iCOL: Integer;
  MaxColWidth: Integer;
  iROW: Integer;
  TmpColWidth: Integer;
begin

  //表示設定
  StringGrid1.Visible:=False;

  //列数
  StringGrid1.ColCount:=7;

  //OpenDialogのプロパティはExecuteする前に設定しておくこと
  With OpenDialog1 do begin
    //表示するファイルの種類をcsvに設定
    Filter:='CSVファイル(*.csv)|*.csv';
    //データの読込先フォルダを指定
    InitialDir:=ExtractFilePath(Application.ExeName)+'sName';
  end;

  //ダイアログ呼び出し
  if OpenDialog1.Execute then
  begin
    CsvFileName:=OpenDialog1.FileName;
    AssignFile(CsvFile, CsvFileName);
    Reset(CsvFile);
  end else begin
    strMsg:='ユーザーによる処理のキャンセル';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    Exit;
  end;

  //フィールド名が必要なCSVファイルなら記述する
  //StringGrid1.Rows[0].CommaText:=
  //  '通し番号,氏名,よみがな,年齢,生年月日,性別,血液型';
  //Fixed Colが1列あって、そこに行番号を設定する場合
  //  ',通し番号,氏名,よみがな,年齢,生年月日,性別,血液型';

  //読込み開始行を指定(FixedRowがある場合 -> ない場合は[0]にする)
  i:=0;
  try
    while not EOF(CsvFile) do
    begin
      //CSVファイルを1行読み込み、その1行分を文字列として代入する。
      Readln(CsvFile, CsvRowStr);
      //グリッドの行数が読み込み行数より少なければ、グリッドの行数を追加する。
      if StringGrid1.RowCount <= i then StringGrid1.RowCount := i + 1;
      //グリッドの指定行目に読み込み行を代入
      //[0]列はFixedCol-> 行番号を設定したい場合
      //StringGrid1.Rows[i].CommaText:=IntToStr(i)+','+CsvRowStr;
      StringGrid1.Rows[i].CommaText:=CsvRowStr;
      i := i + 1;
    end;
  finally
    //行番号を設定した場合
    //StringGrid1.Cells[0,0]:='行番号';
    CloseFile(CsvFile);
  end;

  //列幅の自動調整
  for iCOL := 0 to StringGrid1.ColCount-1 do
  begin
    MaxColWidth := 0;
    for iROW := 0 to StringGrid1.RowCount-1 do
    begin
      TmpColWidth := Canvas.TextWidth(StringGrid1.Cells[iCOL,iROW]) + 10;
      if MaxColWidth < TmpColWidth then
        MaxColWidth := TmpColWidth;
    end;
    StringGrid1.ColWidths[iCOL] := MaxColWidth;
  end;

  //表示設定
  StringGrid1.Visible:=True;

end;

実行結果は次の通り。

文字コードはANSI(CP932、Shift_JIS が拡張されたもの?)


以下、データを読み込む上での注意のあれこれ。

まず、CSVファイルの文字コードがUTF-8だと・・・

たいへんなコトに・・・


また、氏名やよみがなの「姓と名の間にあるスペースが全角でなく、半角」だと・・・

このコードでは、半角スペースが区切り文字として認識されてしまう!


CSVファイルのデータ形式には、文字コードも含めて十分、注意する必要がある。

4.印刷のコード

正直に言うと、今回のチャレンジでは「印刷プレビュー」のプログラムの方を先に書いた。
自分自身の感覚に自信などあるわけないが、通常(?)の感覚からすれば「印刷プレビュー」⇨「印刷」という流れが自然であるような気がして、そうなったのだ。

そこで問題になったのがデータ数が多く、印刷(出力)が「複数ページ」となる場合、プレビューの2枚目、3枚目をどう表示するか? という部分。

先に述べた通り、2枚目以降の先頭行にも「フィールド名を表示」するという自分との約束もあったし・・・。この「ページ毎、先頭行にはフィールド名を表示する」処理の方法をいろいろ考え、試してみたが、どうにも上手く行かない。

これだ! と思える処理手順が思いつかないまま、1ページ目だけの表示であれば問題なくできるプログラムを作成。とりあえず、印刷プレビューは1ページ目だけ表示することで妥協して、(仮)印刷プレビュープログラムとしておき、複数ページの印刷に対応した印刷プログラムが完成したら、もう一度、夢見た通りの印刷プレビューとなるよう、ここに戻ってくることにする。

そうして様々な問題をひとつひとつ自分なりに丁寧にクリアして最終的に書き上げたのが、下に掲載した「印刷」のプログラムコード(データ全体の行数や列数は限定せず、汎用的に使える=再利用できるコードを目指したつもりだが、どうだろうか?)。

FormにPrinterSetupDialog、その他のVCLコントロールを追加。

PrinterSetupDialogの方が用紙サイズと印刷方向を選ぶには便利!


if PrinterSetupDialog1.Execute then ~で、呼び出したDialogの画面。

今回の用途なら、出力先のプリンタ・用紙・向きの指定が出来ればOK!
追加したVCLコントロールとそれらに設定した名称
設定した値と状態

Button2は「印刷プレビュー」機能を、Button3は「印刷」機能を、それぞれ割り当てる予定。
なので、Button3をダブルクリックして手続きを作成し、「印刷」プログラムのコードを入力。

implementation

uses
  System.Math,
  Vcl.Printers;
procedure TForm1.Button3Click(Sender: TObject);
var
  //用紙サイズ、縦置き・横置きの設定を知る(Charだと推奨されない警告が表示される->Stringに変更)
  //Device, Driver, Port: array[0..255] of Char;
  Device, Driver, Port: string;
  DeviceMode: THandle;
  DevMode: PDeviceMode;

  //StringGrid->CSVファイル名とそこまでのPathを入れる
  csvFN:string;

  StringList: TStringList;
  i, j, k, MaxWidth: Integer;
  Fields: TStringList;
  FieldWidths: array of Integer;
  ColMargin: Integer;
  MarginX, MarginY: Integer;
  intLoop: Integer;
  FontHeight: Integer;
  eNum: Integer;
  iPlus: Integer;
  myFieldElement: string;
  LowNum: Integer;
  HighNum: Integer;
  MyRect:TRect;
  //平均値・最高値・最低値 -> 汎用性を考えExtended ではなく、Double とした
  DSum: Double;
  DAvg: Double;
  MinValue, MaxValue: Double;
  intDenomin: Double;

  //StringGrid -> CSV File
  procedure SaveStringGridToCSV(StringGrid: TStringGrid; const FileName: string);
  var
    CSVFile: TextFile;
    Row, Col: Integer;
    Line: string;
  begin
    AssignFile(CSVFile, FileName);
    Rewrite(CSVFile);
    try
      for Row := 0 to StringGrid.RowCount - 1 do
      begin
        Line := '';
        for Col := 0 to StringGrid.ColCount - 1 do
        begin
          Line := Line + StringGrid.Cells[Col, Row];
          if Col < StringGrid.ColCount - 1 then
            Line := Line + ',';
        end;
        WriteLn(CSVFile, Line);
      end;
    finally
      CloseFile(CSVFile);
    end;
  end;

  // ビットマップ用印刷ルーチン
  procedure StretchDrawBitmap(Canvas:TCanvas;  // 描画先キャンバス
                              r : TRect;       // 描画先範囲
                              Bitmap:TBitmap); // ビットマップ
  const
    InfoSize = SizeOf(TBitmapInfoHeader) + 4 * 256;
  var
    OldMode   : integer;      // StretchModeの保存用
    pInfo     : PBitmapInfo;  // DIBヘッダ+カラーテーブルへのポインタ

    InfoData  : array[0..InfoSize-1] of Byte; // DIBヘッダ+カラーテーブル
    Image     : array of Byte;// DIBのピクセルデータ
    DC        : HDC;          // GetDIBits 用 Device Context
    OldPal    : HPALETTE;     // パレット保存用
  begin
    pInfo :=@InfoData;

    // 24 Bit DIB の領域を確保
    SetLength(Image, ((Bitmap.Width * 24 + 31) div 32) * 4 * Bitmap.Height);

    // DIB のBitmapInfoHeader を初期化
    with pInfo^.bmiHeader do begin
      biSize := SizeOf(TBitmapInfoHeader);
      biWidth := Bitmap.Width;     biHeight := Bitmap.Height;
      biPlanes := 1;               biBitCount := 24;
      biCompression := BI_RGB;
    end;

    // 24bpp DIB イメージを取得
    DC := GetDC(0);
    try
      OldPal := 0;
      if Bitmap.Palette <> 0 then
        OldPal := SelectPalette(DC, Bitmap.Palette, True);

      GetDIBits(DC, Bitmap.Handle, 0, Bitmap.Height,
                Image, pInfo^, DIB_RGB_COLORS);
      if OldPal <> 0 then SelectPalette(DC, OldPal, True);
    finally
      ReleaseDC(0, DC);
    end;

    // 拡大モードを カラー用に変更
    OldMode:=SetStretchBltMode(Canvas.Handle,COLORONCOLOR);

    // 描画!!
    StretchDIBits(Canvas.Handle,
                  r.Left,r.Top,r.Right-r.Left,r.Bottom-r.Top,
                  0,0,pInfo^.bmiHeader.biWidth,pInfo^.bmiHeader.biHeight,
                  Image,pInfo^,DIB_RGB_COLORS,SRCCOPY);
    // 拡大モードを元に戻す
    SetStretchBltMode(Canvas.Handle,OldMode);
  end;

  procedure GetMinMaxValues(StringGrid: TStringGrid; ColIndex: Integer; out MinValue, MaxValue: Double);
  var
    Row: Integer;
    Value: Double;
  begin
    if StringGrid.RowCount = 0 then
      raise Exception.Create('StringGridにデータがありません。');

    MinValue := MaxDouble;
    MaxValue := -MaxDouble;

    for Row := 1 to StringGrid.RowCount - 1 do
    begin
      if TryStrToFloat(StringGrid.Cells[ColIndex, Row], Value) then
      begin
        if Value < MinValue then
          MinValue := Value;
        if Value > MaxValue then
          MaxValue := Value;
      end;
    end;

    if MinValue = MaxDouble then
      raise Exception.Create('指定された列に数値データがありません。');
  end;

begin

  //複数回クリックを防止する
  Button3.Enabled:=False;

  //初期化
  Image1.Picture:=nil;
  Image2.Picture:=nil;
  Image1.Visible:=False;
  Image2.Visible:=False;

  //印刷設定(用紙・向き)後に印刷
  if PrinterSetupDialog1.Execute then
  begin

    //プリンタの設定を取得
    Printer.GetPrinter(Device, Driver, Port, DeviceMode);
    DevMode := GlobalLock(DeviceMode);
    try
      //用紙サイズをA4に設定
      DevMode^.dmPaperSize := DMPAPER_A4;
      //用紙方向を縦に設定
      DevMode^.dmOrientation := DMORIENT_PORTRAIT;
      //設定をプリンタに反映
      Printer.SetPrinter(Device, Driver, Port, DeviceMode);
    finally
      GlobalUnlock(DeviceMode);
    end;

    //プリンタの解像度を取得
    //DPI := GetDeviceCaps(Printer.Handle, LOGPIXELSX);
    //家庭用のEPSONのプリンタは360DPI
    //業務用のEPSONの複合機は、600DPI
    //FinePrintは、600DPI

    //A4サイズの用紙の寸法は210mm x 297mm。インチに換算:約8.27インチ x 11.69インチ
    //プリンタの解像度(DPI: Dots Per Inch)

    //100DPIの場合
    //幅: 8.27インチ × 100 DPI = 827ピクセル
    //高さ: 11.69インチ × 100 DPI = 1169ピクセル

    //200DPIの場合
    //幅: 8.27インチ × 200 DPI = 1654ピクセル
    //高さ: 11.69インチ × 200 DPI = 2338ピクセル

    //100DPIとして描画したものをStretchDrawする

    //TImageの初期設定
    Image1.Width := 827;
    Image1.Height := 1169;
    Image1.Picture.Bitmap.Width := 827;
    Image1.Picture.Bitmap.Height := 1169;

    //背景を塗りつぶす
    Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    MyRect:=Rect(0, 0, 827, 1169);
    Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

    //使用するフォント(必ず等幅フォントを指定する)
    //数値の右揃え用に追加(20240820)
    Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

    //フォントサイズ -> 実際にはComboBoxで指定・選択できるようにする
    Image1.Picture.Bitmap.Canvas.Font.Size:=11;

    //平均値を計算 -> 実際のプログラムではこのような計算も行っている
    {
    DSum:=0;
    for i := 1 to StringGrid1.RowCount do
    begin
      if StringGrid1.Cells[5,i] <> '' then
      begin
        DSum:= DSum + StrToInt(StringGrid1.Cells[5,i]);
      end;
    end;
    DAvg:= SimpleRoundTo(DSum / intDenomin, -2);

    //最高値及び最低値を計算
    GetMinMaxValues(StringGrid1, 5, MinValue, MaxValue);
    }

    //StringGrid -> CSV
    //実際のプログラムでは、sNameフォルダ内のCSVファイルを読み込み、
    //さらに幾つかフィールドを追加して新しいデータを追加している。
    //追加したデータを含めて印刷する仕様

    //実際のプログラムでは、LabelSaveFolderName.Captionは別手続きで取得・表示済み
    LabelSaveFolderName.Caption:='SampleData';

    //保存するフォルダへのPath
    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\';

    //フォルダの存在を確認、なければ作成
    if not System.SysUtils.DirectoryExists(ExtractFileDir(csvFN)) then
    begin
      //フォルダ階層を作成
      System.SysUtils.ForceDirectories(ExtractFileDir(csvFN));
    end;

    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\'+LabelSaveFolderName.Caption+'.csv';
    SaveStringGridToCSV(StringGrid1, csvFN);

    StringList:=TStringList.Create;
    Fields:=TStringList.Create;

    try

      //Create
      StringList.LoadFromFile(csvFN);
      //Create
      SetLength(FieldWidths, 0);

      //各フィールドの最大幅を計算
      for i := 0 to StringList.Count - 1 do
      begin
        Fields.CommaText := StringList[i];
        if Length(FieldWidths) < Fields.Count then
          SetLength(FieldWidths, Fields.Count);

        for j := 0 to Fields.Count - 1 do
        begin
          //MaxWidth := Printer.Canvas.TextWidth(Fields[j]);
          MaxWidth := Image1.Picture.Bitmap.Canvas.TextWidth(Fields[j]);
          if FieldWidths[j] < MaxWidth then
            FieldWidths[j] := MaxWidth;
        end;
      end;

      eNum:=StringList.Count div 50;

      //51,101,151,201,251,301,・・・,XX1番目にフィールド名を挿入しておく

      //0番目の要素をコピー
      myFieldElement:=StringList[0];
      //要素を挿入(追加)
      if eNum<>0 then
      begin
        for i := 1 to eNum do
        begin
          StringList.Insert((50*i)+1, myFieldElement);
        end;
      end;

      //ここから印刷Loop
      try

        for intLoop := 0 to eNum do
        begin

          //初期化(白紙にする)
          Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
          MyRect:=Rect(0, 0, 827, 1169);
          Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

          if intLoop=0 then
          begin
            Printer.BeginDoc;
          end else begin
            Printer.NewPage;
          end;

          //タイトルを描画
          Image1.Picture.Bitmap.Canvas.Font.Color:=clBlue;
          Image1.Picture.Bitmap.Canvas.TextOut(
            StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
            LabelSaveFolderName.Caption);

          //タイトルを描画
          {
          Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
          Image1.Picture.Bitmap.Canvas.TextOut(
            StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
            LabelSaveFolderName.Caption + ' 【平均値:'+FloatToStr(DAvg)+
            '、最高値:'+ FloatToStr(MaxValue)+
            '、最低値:'+ FloatToStr(MinValue)+'】');
          }

          Image1.Picture.Bitmap.Canvas.Font.Color:=clBlack;

          k:=0;
          MarginX:=StrToInt(EditMarginX.Text);
          MarginY:=StrToInt(EditMarginY.Text);
          ColMargin:=StrToInt(EditColMargin.Text);

          iPlus:=0;
          //次のcase文でelseを使って何らかの値が必ず代入されるようにしたので不要
          //LowNum:=0;
          //HighNum:=0;

          case intLoop of
            0:begin
              LowNum:=0;
              if StringList.Count > 50 then
              begin
                HighNum:=50;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            {
            1:begin
              LowNum:=51;
              if StringList.Count > 100 then
              begin
                HighNum:=100;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            2:begin
              LowNum:=101;
              if StringList.Count > 150 then
              begin
                HighNum:=150;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            }
          else
            //一般化
            LowNum:=(intLoop*50)+1;
            if StringList.Count > (intLoop*50)+50 then
            begin
              HighNum:=(intLoop*50)+50;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;

          for i := LowNum to HighNum do
          begin
            Fields.CommaText := StringList[i];
            for j := 0 to Fields.Count - 1 do
            begin
              //処理できる列数を無制限にする
              case j of
                0:k:=0;
              else
                k:=k+FieldWidths[j-1]+ColMargin;
              end;
              //フィールド名に「備考」を追加する
              if i=0 then
              begin
                if j=Fields.Count-1 then
                begin
                  Fields[j]:=Fields[j]+' 備考';
                end;
              end;

              //データを出力(数値の右揃え:なし)
              //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

              //データを出力(数値の右揃え:あり))
              //数値の右揃え用に追加(20240820
              if TryStrToInt(Fields[j], intValue) then
              begin
                //数値である -> 右揃えで出力する
                Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
                  Format('%3d', [strToInt(Fields[j])]));
              end else begin
                //数値でない -> 左揃えで出力する
                Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
              end;

              //罫線を描画
              if cbLine.Checked then
              begin
                Image1.Picture.Bitmap.Canvas.Pen.Color:= clBlack;
                FontHeight:= -1 * Image1.Picture.Bitmap.Canvas.Font.Height;
                Image1.Picture.Bitmap.Canvas.MoveTo(MarginX+k, MarginY+(iPlus*20)+FontHeight+4);
                Image1.Picture.Bitmap.Canvas.LineTo(Image1.Picture.Bitmap.Width-50, MarginY+(iPlus*20)+FontHeight+4);
              end;
            end;
            inc(iPlus);
          end;

          //大きさを指定
          MyRect.Top:=0;
          MyRect.Left:=0;
          MyRect.Bottom:= Trunc((Printer.PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
          MyRect.Right:= Printer.PageWidth;
          //ファイルを描画
          StretchDrawBitmap(Printer.Canvas, MyRect, Image1.Picture.Bitmap);
          Application.ProcessMessages;

        end;  //intLoop

      finally
        Printer.EndDoc;
      end;

    finally
      StringList.Free;
      Fields.Free;
      //複数回クリックを防止する
      Button3.Enabled:=True;
    end;

    //ファイルの完全削除
    DeleteFile(PChar(csvFN));

    //TImageの表示位置を指定
    ScrollBox1.VertScrollBar.Position:=0;
    ScrollBox1.HorzScrollBar.Position:=0;
    Image1.Top:=ScrollBox1.VertScrollBar.Position+14;
    Image1.Left:=ScrollBox1.HorzScrollBar.Position+14;

    //TImageの表示
    Image1.Visible:=True;  //印刷プレビューを実行しなければ不要

    Button2Click(Sender);  //印刷プレビューを表示する

  end else begin
    //キャンセルに対応
    Button2Click(Sender);  //印刷プレビューを表示する
  end;

end;

上のコードには、このサンプルでの処理には不要な部分や、(コードを一般化する前に場合分けして処理手順を考えた)冗長な部分も含まれている。自分のバカさを全世界にPRするようなものだが、計算処理を追加したり、コードを一般化する際の考え方の参考となるよう、敢えてそのまま残した。

そもそも、この記事を書こうと思ったきっかけは、DelphiのStringGridの内容を定型用紙に印刷するサンプルコードが(Web上に)あまりにも少ない気がしたこと。ただ、この例では、いったんCSVファイルにして保存したり、PrinterのCanvasではなくTImageのCanvasに描画したり、普通とは言い難い方法を行って印刷している気がするので、普通(?)の印刷方法を学びたい方にはまるで参考にならないかもですが、万一にでも、どなたかのお役に立てれば何よりの幸いです。

【実行結果】

まず、PrinterSetupDialogが表示されるので、A4・縦を選択する。

プリンター名に「FinePrint」とあるのはお気に入りのプリントユーティリティ
プレビュー的に印刷内容を確認したり、まとめ印刷を行ったり、縮小・両面設定で印刷枚数を減らしたり、とにかく使えるユーティリティ
FinePrintへ出力
FinePrintへの出力の印刷プレビュー部分を拡大


これで数値データを右寄せ表示できれば、大満足なんだけど・・・ 。

追記(20240819)

Format関数を使えば、数値データの右揃えが簡単に実現できることを忘れてた!

(最近は「データを保存する」プログラムばかり書いていて、「データを印刷する」プログラムはほとんど書いたことがないことにあらためて気づいた。

 例1:csv形式で保存 -> 表計算ソフトで読み込んで活用。
 例2:表計算ソフトのファイルにADO接続して、直接書き込み。
 例3:データベースにADO接続して、データを保存、必要な部分をクエリで抽出。みたいな・・・

遠い昔、VBでデータを縦・横罫線付きの一覧表形式で印刷するプログラムをさんざん書いていたのが夢のよう・・・。

てか、今回も最終的に印刷しているのは、プリンターのCanvasに描画した「絵」なんですが。)

「印刷プレビュー」及び「印刷」の手続きを次のように追加・修正。

1.手続きの冒頭で、「等幅フォントを忘れずに指定」する(追加)。

  //使用するフォント(必ず等幅フォントを指定する)
  Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

  //フォントサイズ -> 実際にはComboBoxで指定・選択できるようにする
  Image1.Picture.Bitmap.Canvas.Font.Size:=11;

2.データ出力部分を次のように修正する。

  //データを出力
  //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

  //数値データは右揃えで出力する
  if TryStrToInt(Fields[j], intValue) then
  begin
    //数値である -> 右揃えで出力する
    Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
      Format('%3d', [strToInt(Fields[j])]));
  end else begin
    //数値でない -> 左揃えで出力する
    Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
  end;

【実行結果】

通し番号(と年齢)が右揃えになった!

追記(20240819)ここまで

2ページ目以降も先頭行にフィールド名を表示

今までの自分には罫線付きでこのように表示することが、どうしてもできなかった・・・


先頭行にフィールド名を表示する部分は、いちばん悩んだところ。
最終的に変数eNum(LoopのEndNumber)から印刷に必要なページ数を取得し、StringListに格納した印刷データの0番目の要素をコピーして、これをStringListの51、101、151のように、eNumの現在の値( i * 50)+1番目に挿入して行く方法が計算的にも、処理的にも、いちばんラクなのではないか?・・・と考え、このアルゴリズムでプログラムを作成。

こうすれば1ページ目には要素0から50、2ページ目には要素51から100、3ページ目には101から150・・・のように印刷データの割り当てが決まり、プログラムも心もすっきり。0番目の要素(フィールド名)のコピーと所定の位置への挿入さえ行ってしまえば、あとは単純にLoopを廻すだけだ。

      eNum:=StringList.Count div 50;

      //51,101,151,201,251,301・・・番目にフィールド名を挿入

      //0番目の要素をコピー
      myFieldElement:=StringList[0];
      //要素を追加
      if eNum<>0 then
      begin
        for i := 1 to eNum do
        begin
          StringList.Insert((50*i)+1, myFieldElement);
        end;
      end;


最も重要なデータ出力部分は、生成AIに教えてもらった!
次のデータを出力するコードの(iPlus * 20)の部分は、自分では絶対に書けなかったと思う・・・。
ついに、と言うか、とうとう、わからない部分は生成AIに聞きながらプログラムが書ける、夢のような時代がやってきた!!

でも、頼りすぎは禁物。実際、今回もかなり痛い目にあった・・・。
その内容は、後述。

//データを出力
Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

印刷枚数が1ページだけなら、変数はLoop変数の i がそのまま使えるのだが、複数枚印刷を実行する必要があるので、Loop用の変数 i とは別に iPlus という名前の変数(特に意味はない)を用意し、ページが切り替わる毎にゼロで初期化するように生成AIが教えてくれたプログラムを改良。

それから、これはあった方が親切かな? と考え、先頭行のフィールド名の最後に「備考」も追加。

Loopの様子をわかりやすくしたのが次のコード。
ページ内のデータ印刷作業で、i , j , k を使ってしまったので、いちばん大きな(外側の)ページを切り替えるLoopの変数名をどうするかで悩み l(エルの小文字)はちょっと・・・って感じがしたので、最終的に変数名はintLoopとした。

最終的に、TImageのBitmapに出力したものをPrinterのCanvasにコピーして印刷している。

for intLoop := 0 to eNum do
begin
  k:=0;
  iPlus:=0;
  for i := LowNum to HighNum do
  begin
    for j := 0 to Fields.Count - 1 do
    begin    
      //フィールド名に「備考」を追加する
      if i=0 then
      begin
        if j=Fields.Count-1 then
        begin
          Fields[j]:=Fields[j]+' 備考';
        end;
      end;
      //データを出力
      Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
    end;
    inc(iPlus);
  end; 

  //大きさを指定
  MyRect.Top:=0;
  MyRect.Left:=0;
  MyRect.Bottom:= Trunc((Printer.PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
  MyRect.Right:= Printer.PageWidth;
  //ファイルを描画
  StretchDrawBitmap(Printer.Canvas, MyRect, Image1.Picture.Bitmap);
  Application.ProcessMessages;

end;  //intLoopの終わり


実は、ここでひと悶着あって・・・

5.rect:TRectとしてはいけません!

実際には、最初に1ページ目だけを表示できる「印刷プレビュー」のプログラムを書き、それを元にして「印刷」プログラムを書いたのだが、いつもの通り、というか、お決まりの「解決策がまったくわからずにトホーに暮れる」・・・ 後から考えれば(理由がわかってみれば)実に「なぁーんだ。そんなコトか」みたいな、でも、それがわかるまでは七転八倒の苦しみとなるイベントに今回も遭遇。

毎回、これが楽しみでプログラムを書いている、そんな気がしないでもないが。

今回のそれは・・・ナニかというと、

「印刷プレビュー」の手続き内では「何の問題もなかった」次のコードだが、

procedure TForm1.Button2Click(Sender: TObject);
begin

  ・・・じんせい、イロイロ・・・

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
  Image1.Picture.Bitmap.Canvas.FillRect(rect(0, 0, 827, 1169)); 

  ・・・タコは、イボイボ・・・

end;

これを「印刷」手続き内に複写して、「印刷プレビュー」手続きにはないPrinterのCanvasへの描画コードを追加するなど、あちこちいじっていたら、いつの間にか・・・

エラーの!マークが付いてる(問題が起きた状況を再現)


構造ペインには・・・


『はぁ?』

だって行末にちゃんとセミコロンあるし・・・みたいな感じ。
・・・てか、なんで、こっちの手続きだけ、エラーになるの???

この時点で、早朝2時頃から連続15時間くらいPCと向かい合っていたため、精神的にはもうフラフラの状態。もちろん、エラーの原因は、まったくわからない。

まったく同じプログラムコードが、あっちの手続きではOK! こっちの手続きではダメな理由は、いったい何なんだろう???

この後も、しばらく、がんばって考えたんだけど、原因はさっぱりわからず、


Delphiを再起動したら直るかなー?

もちろん、直るわけもなく・・・。


もしかして、PCを再起動したら直るかなー??

もちろん、再起動してもエラーは消えない。


万策尽きた感じで、とりあえず、いけない水に手を伸ばし・・・ 心は折れたまま、遥かなる夢の国へ。

翌朝、ってか、午前0時前に目覚めたから、日付はまだ今日だけど・・・
とりあえず、すっきりした頭で問題に再挑戦。ようやくエラーの原因が判明。

ほんとに偶然発見したのだけれど、エラーにならない「印刷プレビュー」の手続きでは・・・

このRectはSystem.TypesのRect関数・・・


これに対し、エラーになる「印刷」の手続きでは・・・

このrectはvar宣言したTRect型の変数・・・

あー!!
わかったー☆☆☆ みたいな

1ページ目だけ表示可能な最初に書いた「印刷プレビュー」の手続きをそっくり「印刷」手続きに複写して、「印刷プレビュー」の手続きには「存在しなかった」プリンタのCanvasへの描画コードを追加したのだが・・・ その時、var宣言部でTRect型の変数rectを宣言していたのだ。

これが追加したPrinterのCanvasへ描画するプログラムの主要部分。

procedure TForm1.btnPrintASheetClick(Sender: TObject);
var
  i, j: Integer;
  strMsg: string;
  PrintALL: Boolean;
  intLoopNum: Integer;
  rect:TRect;
  StrCaption:String;
  StrPrompt:String;
  StrValue1, StrValue2:String;
  Chr : array [0..255]  of  char;

  // ビットマップ用印刷ルーチン
  procedure StretchDrawBitmap(Canvas:TCanvas;  // 描画先キャンバス
                              r : TRect;       // 描画先範囲
                              Bitmap:TBitmap); // ビットマップ
  ・・・省略・・・

begin
  if PrinterSetupDialog1.Execute then
  begin

    //背景を塗りつぶす
    Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    Image1.Picture.Bitmap.Canvas.FillRect(rect(0, 0, 827, 1169));  //エラーになる部分

    //Info
    strMsg:='全員分印刷しますか?'+#13#10+'(個別印刷は「いいえ」)';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      PrintALL:=True;
    end else begin
      PrintALL:=False;
    end;

    //全部印刷
    if PrintAll then
    begin
      //先頭のデータを表示
      btnFirstClick(Sender);
      for i := 1 to ListBox1.Items.Count do
      begin
        //まず現在のImageを印刷
        with Printer do
        begin
          if i=1 then
          begin
            BeginDoc;
          end else begin
            NewPage;
          end;
          //大きさを指定
          rect.Top:=0;
          rect.Left:= 0;
          rect.Bottom:= Trunc(( PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
          rect.Right:= PageWidth;
          //TImageのBitmapをPrinterのCanvasに描画
          StretchDrawBitmap(Printer.Canvas, rect, Image1.Picture.Bitmap);
          
          if i=ListBox1.Items.Count then
          begin
            EndDoc;
          end;
        end;

        //次を表示
        btnNextClick(Sender);
      end;

      ・・・

だから、結果的に(当然だが)「印刷」手続き内ではコードで意図したSystem.TypesのRect関数は呼ばれずに、「rect」と記述するとそれはvar宣言したrect変数の方を意味(参照)することになって・・・

これはエラーになって当然。Delphiさん、あなたはやっぱり正しかった。

ちなみに、Delphiは大文字・小文字を区別しないから、R でも r でも問題は起きない。問題の根源であるvar宣言部のrect変数の名前を変え、次のようにコードを書き直せば・・・

☆エラーは消えました☆

System.TypesのRect関数と名前が衝突しないように、変数名をMyRectに変える
もしくはSystem.Types.pasのRect関数を明示的に指定する


(試してみたい方は、次のコードをコピペしてください。)

procedure TForm1.Button4Click(Sender: TObject);
var
  MyRect:TRect;
begin

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;

  //解決方法その1
  MyRect:=rect(0, 0, 827, 1169);
  Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

  //解決方法その2
  Image1.Picture.Bitmap.Canvas.FillRect(System.Types.Rect(0, 0, 827, 1169));

end;

【まとめ】

関数名として使われているような語句をそのまんま変数名として利用するのはNo Good! であります。

6.印刷プレビューのコード

そういう訳で、「印刷」手続きのコードが完成し、問題も解決したので、この完成したコードを「印刷プレビュー」に流用することにした。

基本的に、「印刷」手続きのコードからBeginDocとNewPage、それからEndDocを消し、PrinterのCanvasに描画してる部分をコメントアウトして、1ページ目以降を表示する方法を追加すればOKのはずだ・・・。そう考えて書いたのが次のコード。

procedure TForm1.Button2Click(Sender: TObject);
var
  //用紙サイズ、縦置き・横置きの設定を知る(Charだと推奨されない警告が表示される->Stringに変更)
  //Device, Driver, Port: array[0..255] of Char;
  Device, Driver, Port: string;
  DeviceMode: THandle;
  DevMode: PDeviceMode;

  //StringGrid->CSVファイル名とそこまでのPathを入れる
  csvFN:string;

  StringList: TStringList;
  i, j, k, MaxWidth: Integer;
  Fields: TStringList;
  FieldWidths: array of Integer;
  ColMargin: Integer;
  MarginX, MarginY: Integer;
  intLoop: Integer;
  FontHeight: Integer;
  eNum: Integer;
  iPlus: Integer;
  myFieldElement: string;
  LowNum: Integer;
  HighNum: Integer;
  MyRect:TRect;
  //平均値・最高値・最低値
  //DSum: Double;
  //DAvg: Double;
  //MinValue, MaxValue: Double;

  //StringGrid -> CSV File
  procedure SaveStringGridToCSV(StringGrid: TStringGrid; const FileName: string);
  var
    CSVFile: TextFile;
    Row, Col: Integer;
    Line: string;
  begin
    AssignFile(CSVFile, FileName);
    Rewrite(CSVFile);
    try
      for Row := 0 to StringGrid.RowCount - 1 do
      begin
        Line := '';
        for Col := 0 to StringGrid.ColCount - 1 do
        begin
          Line := Line + StringGrid.Cells[Col, Row];
          if Col < StringGrid.ColCount - 1 then
            Line := Line + ',';
        end;
        WriteLn(CSVFile, Line);
      end;
    finally
      CloseFile(CSVFile);
    end;
  end;

  procedure GetMinMaxValues(StringGrid: TStringGrid; ColIndex: Integer; out MinValue, MaxValue: Double);
  var
    Row: Integer;
    Value: Double;
  begin
    if StringGrid.RowCount = 0 then
      raise Exception.Create('StringGridにデータがありません。');

    MinValue := MaxDouble;
    MaxValue := -MaxDouble;

    for Row := 1 to StringGrid.RowCount - 1 do
    begin
      if TryStrToFloat(StringGrid.Cells[ColIndex, Row], Value) then
      begin
        if Value < MinValue then
          MinValue := Value;
        if Value > MaxValue then
          MaxValue := Value;
      end;
    end;

    if MinValue = MaxDouble then
      raise Exception.Create('指定された列に数値データがありません。');
  end;

  //Image1のBitmapをImage2の指定位置へ複写する
  procedure CopyBitmapToImage(Image1, Image2: TImage; DestX, DestY: Integer);
  var
    SrcRect, DestRect: TRect;
  begin
    // ソースの矩形を設定
    SrcRect := Rect(0, 0, Image1.Picture.Bitmap.Width, Image1.Picture.Bitmap.Height);
    // 目的地の矩形を設定
    DestRect := Rect(DestX, DestY, 
      DestX + Image1.Picture.Bitmap.Width, DestY + Image1.Picture.Bitmap.Height);
    // Image2のCanvasにImage1のBitmapを複写
    Image2.Picture.Bitmap.Canvas.CopyRect(DestRect, Image1.Picture.Bitmap.Canvas, SrcRect);

    //追加(20240820)
    //ページ区切り線を表示するコードを追加
    //ペンの色を青に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clGray;
    //ページ区切り線の太さ
    Image2.Picture.Bitmap.Canvas.Pen.Width:=3;
    //ペンのスタイルを点線に設定
    //Image1.Canvas.Pen.Style := psDot;
    Image1.Canvas.Pen.Style := psSolid;
    //線を引く
    Image2.Canvas.MoveTo(0, DestY + Image1.Picture.Bitmap.Height); // 線の開始位置
    Image2.Canvas.LineTo(Image2.Picture.Bitmap.Width, 
      DestY + Image1.Picture.Bitmap.Height); // 線の終了位置
    //ページ区切り線の太さを元に戻す
    Image2.Picture.Bitmap.Canvas.Pen.Width:=1;
    //ペンの色を黒に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clBlack;
    //ペンのスタイルを直線に戻す
    //Image1.Canvas.Pen.Style := psSolid;
  end;

begin

  //複数回クリックを防止する
  Button2.Enabled:=False;

  //初期化
  Image1.Picture:=nil;

  //印刷設定(用紙・向き)後に印刷

  //プリンタの設定を取得
  Printer.GetPrinter(Device, Driver, Port, DeviceMode);
  DevMode := GlobalLock(DeviceMode);
  try
    //用紙サイズをA4に設定
    DevMode^.dmPaperSize := DMPAPER_A4;
    //用紙方向を縦に設定
    DevMode^.dmOrientation := DMORIENT_PORTRAIT;
    //設定をプリンタに反映
    Printer.SetPrinter(Device, Driver, Port, DeviceMode);
  finally
    GlobalUnlock(DeviceMode);
  end;

  //TImageの初期設定
  Image1.Width := 827;
  Image1.Height := 1169;
  Image1.Picture.Bitmap.Width := 827;
  Image1.Picture.Bitmap.Height := 1169;

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
  MyRect:=Rect(0, 0, 827, 1169);
  Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

  //使用するフォント(必ず等幅フォントを指定する)
  //数値の右揃え用に追加(20240820)
  Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

  //フォントサイズ
  Image1.Picture.Bitmap.Canvas.Font.Size:=11;
  //フォントサイズ <- 要らなかった!
  //Image2.Picture.Bitmap.Canvas.Font.Size:=11;

  //平均値を計算  intDenominはグローバル変数として宣言
  {
  DSum:=0;
  for i := 1 to StringGrid1.RowCount do
  begin
    if StringGrid1.Cells[5,i] <> '' then
    begin
      DSum:= DSum + StrToInt(StringGrid1.Cells[5,i]);
    end;
  end;
  DAvg:= SimpleRoundTo(DSum / intDenomin, -2);

  //最高値及び最低値を計算
  GetMinMaxValues(StringGrid1, 5, MinValue, MaxValue);

  //タイトルを描画
  Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
  Image1.Picture.Bitmap.Canvas.TextOut(
    StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30, LabelKoza.Caption);

  //フォント色を変更
  Image1.Picture.Bitmap.Canvas.Font.Color := clBlack;
  }

  //Grid -> CSV
    //実際のプログラムでは、sNameフォルダ内のCSVファイルを読み込み、
    //さらに幾つかフィールドを追加して新しいデータを入力している。
    //印刷では、この新しく入力されたデータを含めて印刷している

    //実際のプログラムでは、LabelSaveFolderName.Captionは別手続きで取得・表示済み
    LabelSaveFolderName.Caption:='SampleData';

    //保存するフォルダへのPath
    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\';

    //フォルダの存在を確認、なければ作成
    if not System.SysUtils.DirectoryExists(ExtractFileDir(csvFN)) then
    begin
      //フォルダ階層を作成
      System.SysUtils.ForceDirectories(ExtractFileDir(csvFN));
    end;

    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\'+LabelSaveFolderName.Caption+'.csv';
    SaveStringGridToCSV(StringGrid1, csvFN);

  StringList:=TStringList.Create;
  Fields:=TStringList.Create;

  try

    //Create
    StringList.LoadFromFile(csvFN);
    //Create
    SetLength(FieldWidths, 0);

    //各フィールドの最大幅を計算
    for i := 0 to StringList.Count - 1 do
    begin
      Fields.CommaText := StringList[i];
      if Length(FieldWidths) < Fields.Count then
        SetLength(FieldWidths, Fields.Count);

      for j := 0 to Fields.Count - 1 do
      begin
        //MaxWidth := Printer.Canvas.TextWidth(Fields[j]);
        MaxWidth := Image1.Picture.Bitmap.Canvas.TextWidth(Fields[j]);
        if FieldWidths[j] < MaxWidth then
          FieldWidths[j] := MaxWidth;
      end;
    end;

    eNum:=StringList.Count div 50;

    //PreView用TImageの初期設定
    Image2.Width := 827;
    case eNum of
      0:Image2.Height := 1169;
    else
      Image2.Height := 1169 * (eNum + 1);
    end;
    Image2.Picture.Bitmap.Width := 827;
    case eNum of
      0:Image2.Picture.Bitmap.Height := 1169;
    else
      Image2.Picture.Bitmap.Height := 1169 * (eNum + 1);
    end;    

    //背景を塗りつぶす
    Image2.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    case eNum of
      0:MyRect:=Rect(0, 0, 827, 1169);
    else
      MyRect:=Rect(0, 0, 827, 1169 * (eNum + 1));
    end;
    Image2.Picture.Bitmap.Canvas.FillRect(MyRect);

    //51,101,151,201,251,301,・・・,XX1番目にフィールド名を挿入しておく

    //0番目の要素をコピー
    myFieldElement:=StringList[0];
    //要素を挿入(追加)
    if eNum<>0 then
    begin
      for i := 1 to eNum do
      begin
        StringList.Insert((50*i)+1, myFieldElement);
      end;
    end;

    //ここから印刷Loop
    try

      for intLoop := 0 to eNum do
      begin

        //初期化(白紙にする)
        Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
        MyRect:=Rect(0, 0, 827, 1169);
        Image1.Picture.Bitmap.Canvas.FillRect(MyRect);
        {
        //印刷プレビューだから不要
        if intLoop=0 then
        begin
          Printer.BeginDoc;
        end else begin
          Printer.NewPage;
        end;
        }

        //タイトルを描画
        Image1.Picture.Bitmap.Canvas.Font.Color:=clBlue;
        Image1.Picture.Bitmap.Canvas.TextOut(
          StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
          LabelSaveFolderName.Caption);

        //タイトルを描画(計算が必要な場合の例)
        {
        Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
        Image1.Picture.Bitmap.Canvas.TextOut(
          StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
          LabelKoza.Caption + ' 【平均値:'+FloatToStr(DAvg)+
          '、最高値:'+ FloatToStr(MaxValue)+
          '、最低値:'+ FloatToStr(MinValue)+'】');
        }

        Image1.Picture.Bitmap.Canvas.Font.Color:=clBlack;

        //水平方向の各フィールドの印字開始位置決定用変数を初期化
        k:=0;

        //水平方向の印字開始位置
        MarginX:=StrToInt(EditMarginX.Text);
        //垂直方向の印字開始位置
        MarginY:=StrToInt(EditMarginY.Text);
        //列(フィールド)と列の(余白的な)間隔
        ColMargin:=StrToInt(EditColMargin.Text);

        //ページが変わったら初期化する
        iPlus:=0;

        case intLoop of
          0:begin
            LowNum:=0;
            if StringList.Count > 50 then
            begin
              HighNum:=50;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          {
          1:begin
            LowNum:=51;
            if StringList.Count > 100 then
            begin
              HighNum:=100;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          2:begin
            LowNum:=101;
            if StringList.Count > 150 then
            begin
              HighNum:=150;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          }
        else
          //一般化
          LowNum:=(intLoop*50)+1;
          if StringList.Count > (intLoop*50)+50 then
          begin
            HighNum:=(intLoop*50)+50;
          end else begin
            HighNum:=StringList.Count-1;
          end;
        end;

        for i := LowNum to HighNum do
        begin
          Fields.CommaText := StringList[i];
          for j := 0 to Fields.Count - 1 do
          begin
            //処理できる列数を無制限にする
            case j of
              0:k:=0;
            else
              k:=k+FieldWidths[j-1]+ColMargin;
            end;
            //フィールド名に「備考」を追加する
            if i=0 then
            begin
              if j=Fields.Count-1 then
              begin
                Fields[j]:=Fields[j]+' 備考';
              end;
            end;

            //データを出力(数値の右揃え:なし)
            //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

            //データを出力(数値の右揃え:あり)
            //数値の右揃え用に追加(20240820)
            if TryStrToInt(Fields[j], intValue) then
            begin
              //数値である -> 右揃えで出力する
              Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
                Format('%3d', [strToInt(Fields[j])]));
            end else begin
              //数値でない -> 左揃えで出力する
              Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
            end;

            //罫線を描画
            if cbLine.Checked then
            begin
              Image1.Picture.Bitmap.Canvas.Pen.Color:= clBlack;
              FontHeight:= -1 * Image1.Picture.Bitmap.Canvas.Font.Height;
              Image1.Picture.Bitmap.Canvas.MoveTo(MarginX+k, MarginY+(iPlus*20)+FontHeight+4);
              Image1.Picture.Bitmap.Canvas.LineTo(Image1.Picture.Bitmap.Width-50, MarginY+(iPlus*20)+FontHeight+4);
            end;
          end;

          inc(iPlus);

        end;

        //Image1のBitmapをImage2の(XX, YY)の位置に複写
        case intLoop of
          0:CopyBitmapToImage(Image1, Image2, 0, 0);
        else
          CopyBitmapToImage(Image1, Image2, 0, 1169 * intLoop);
        end;

      end;

    finally
      //Printer.EndDoc;
    end;

  finally
    StringList.Free;
    Fields.Free;
    //複数回クリックを防止する
    Button2.Enabled:=True;
  end;

  //ファイルの完全削除
  DeleteFile(PChar(csvFN));

  //Imageの高さをScrollBoxのスクロール範囲に反映
  ScrollBox1.VertScrollBar.Range := Image2.Picture.Bitmap.Height;

  //TImageの表示位置を指定
  ScrollBox1.VertScrollBar.Position:=0;
  ScrollBox1.HorzScrollBar.Position:=0;
  Image1.Top:=ScrollBox1.VertScrollBar.Position+14;
  Image1.Left:=ScrollBox1.HorzScrollBar.Position+14;
  Image2.Top:=ScrollBox1.VertScrollBar.Position+14;
  Image2.Left:=ScrollBox1.HorzScrollBar.Position+14;

  //TImageの表示
  Image1.Visible:=False;
  Image2.Visible:=True;

end;


【実行結果】

実行結果は、次の通り。用意したデータ件数は320件。

1ページ目


スクロールして下へ。表示されているのは、最終ページ。
※ マウスのホイールを廻してスクロールさせるには別途コードの記述が必要(後述)。

最終ページ


マウスのホイールを廻して、TImageをスクロールさせるには、FormのOnMouseWheelイベントの手続きを次のように作成する。

procedure TForm1.FormMouseWheel(Sender: TObject; Shift: TShiftState;
  WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
var
  LDelta:Integer;
  //追加
  LWinCtrl:TWinControl;
  LCurPos:TPoint;
begin

  {
  //TScrollBox のマウスホイールによるスクロール
  //マウスがTScrollBoxの外にあってもスクロールする・・・ならこちら☆
  LDelta:=WheelDelta div 5;
  if ssCtrl in Shift then
  begin
    ScrollBox1.HorzScrollBar.Position:=ScrollBox1.HorzScrollBar.Position-LDelta;
  end else begin
    ScrollBox1.VertScrollBar.Position:=ScrollBox1.VertScrollBar.Position-LDelta;
  end;
  Handled:=True;
  }

  //マウスカーソルが TScrollBox の領域内にある時だけスクロールを可能にする
  LCurPos := ScrollBox1.Parent.ScreenToClient(MousePos);
  if PtInRect(ScrollBox1.BoundsRect, LCurPos) then
  begin
    LDelta := WheelDelta div 3;
    if ssCtrl in Shift then
    begin
      ScrollBox1.HorzScrollBar.Position := ScrollBox1.HorzScrollBar.Position - LDelta;
    end else begin
      ScrollBox1.VertScrollBar.Position := ScrollBox1.VertScrollBar.Position - LDelta;
      //Memoも連動してスクロールさせる
      {
      if LDelta > 0 then
      begin
        Memo2.Perform(WM_VSCROLL, SB_LINEUP, 0);
      end else begin
        Memo2.Perform(WM_VSCROLL, SB_LINEDOWN, 0);
      end;
      }
    end;
  end else begin
    //マウス直下のコントロールを取得
    LWinCtrl := FindVCLWindow(MousePos);
    //TStringGridの場合
    if LWinCtrl is TStringGrid then
    begin
      if WheelDelta > 0 then
      begin
        LWinCtrl.Perform(WM_VSCROLL, SB_LINEUP, 0);
      end else begin
        LWinCtrl.Perform(WM_VSCROLL, SB_LINEDOWN, 0);
      end;
    end;
  end;

  //この1行を忘れないこと!
  Handled:=True;

end;


【印刷プレビューコードの工夫】

1.ページ毎に作成されるImage1のBitmapを、Image2のCanvasの指定位置に複写する。
2.最初にページ数を調べ、Image2の高さをページ数にあわせて高くしておく。
3.1の複写手続きの最後に、ページ区切り線を描画するコードを追加(20240820)。

1.に関して、Image1のBitmapをImage2のCanvasに複写する手続き

  //Image1のBitmapをImage2の指定位置へ複写する
  procedure CopyBitmapToImage(Image1, Image2: TImage; DestX, DestY: Integer);
  var
    SrcRect, DestRect: TRect;
  begin
    // ソースの矩形を設定
    SrcRect := Rect(0, 0, Image1.Picture.Bitmap.Width, Image1.Picture.Bitmap.Height);

    // 目的地の矩形を設定
    DestRect := Rect(DestX, DestY, DestX + Image1.Picture.Bitmap.Width, DestY + Image1.Picture.Bitmap.Height);

    // Image2のCanvasにImage1のBitmapを複写
    Image2.Picture.Bitmap.Canvas.CopyRect(DestRect, Image1.Picture.Bitmap.Canvas, SrcRect);
  end;

    //追加(20240820)
    //ページ区切り線を表示するコードを追加
    //ペンの色を青に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clGray;
    //ページ区切り線の太さ
    Image2.Picture.Bitmap.Canvas.Pen.Width:=3;
    //ペンのスタイルを点線に設定
    //Image1.Canvas.Pen.Style := psDot;
    //ペンのスタイルを直線に設定
    Image1.Canvas.Pen.Style := psSolid;
    //線を引く
    Image2.Canvas.MoveTo(0, DestY + Image1.Picture.Bitmap.Height); // 線の開始位置
    Image2.Canvas.LineTo(Image2.Picture.Bitmap.Width, 
      DestY + Image1.Picture.Bitmap.Height); // 線の終了位置
    //ページ区切り線の太さを元に戻す
    Image2.Picture.Bitmap.Canvas.Pen.Width:=1;
    //ペンの色を黒に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clBlack;
    //ペンのスタイルを直線に戻す
    //Image1.Canvas.Pen.Style := psSolid;
  end;

ページ区切り線については、いろいろ試行した結果、やや太い灰色の直線が最も適している(ほどよく自己主張するが、データほどではない)と感じたので、そのように設定。上のコードにはその痕跡を残している。


1.に関して、複写する手続きを呼び出すコード。

        //Image1のBitmapをImage2の(XX, YY)の位置に複写
        case intLoop of
          0:CopyBitmapToImage(Image1, Image2, 0, 0);
        else
          CopyBitmapToImage(Image1, Image2, 0, 1169 * intLoop);
        end;


2.に関して、ページ数に応じてImage2の高さを高くするコード。

    eNum:=StringList.Count div 50;

    //PreView用TImageの初期設定
    Image2.Width := 827;
    case eNum of
      0:Image2.Height := 1169;
    else
      Image2.Height := 1169 * (eNum + 1);
    end;
    Image2.Picture.Bitmap.Width := 827;
    case eNum of
      0:Image2.Picture.Bitmap.Height := 1169;
    else
      Image2.Picture.Bitmap.Height := 1169 * (eNum + 1);
    end;

7.まとめ

(1)StringGridのデータを用紙と向きを指定して罫線付きで印刷するコードを掲載
(2)(1)のコードを流用して、印刷プレビューを表示するコードを掲載
(3)変数名を付ける時は既存の関数名等との衝突に十分に注意する。

8.お願いとお断り

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

100選択肢対応マークシートを使用した試験の実施方法

自作マークシートリーダーのプログラムを書き替え、大語群(使用できる選択肢の番号は 0~99)の使用に対応したマークシートを Excel で作成した。

今回は、このマークシートを用いた試験を実施する方法です。

【もくじ】

1.事前の準備と受験者への注意事項
2.スキャナーでマークシートをスキャンしてJpeg画像に変換
3.指定のフォルダ内にフォルダを作成してスキャンした画像をコピー
4.採点専用画像に変換
5.テンプレートとして登録
6.マーク読み取りを実行
7.マーク読み取り結果のチェック
8.CSV形式で出力
9.採点結果通知シートの作成方法
10.お願いとお断り

追記(20240929)

当Blogで紹介してきた自作のデジタル採点プログラムを一つにまとめました。次のリンク先にその紹介とダウンロードリンクがあります。マークシートも、ここに紹介した形式の他、様々なタイプのものを同梱しています。

追記(20250702)

100選択肢用のマークシートを使って試験を行ったユーザーの方から、『マークの塗りつぶし面積が大きいと誤判定が出やすい』とのご指摘をいただきました。確認すると、受験者は「濃く・丁寧に」マークを塗りつぶしたことがマークシートから伝わってくるのですが、100選択肢用のA4横サイズのマークシートは・・・やはり、と言うか、どうしてもマークそのものが小さく、受験者によっては「きちんとマークすること=大きくマークすること」と、感じて(?)、選択肢番号の外枠の楕円「0」部分を上下左右に大きくはみ出して隣のマークの領域範囲まで塗りつぶしてしまい、結果的に、その受験者のマークシートは複数マーク判定だらけになることがわかりました。

対策として、『選択肢番号の外枠の楕円「0」部分をはみ出さないようにマークする』よう注意を徹底することをお願いしたのですが、それだけでは根本的な解決とならないように感じ、マークとマークの間隔が狭いためにこの問題が起きていることは明白ですから、1設問について100選択肢に対応を維持しつつ、1列25行×4列で100設問まで対応という現在のマークシート構成を見直し(マーク間の幅を広げるため列数を減らし)、1列33行×3列で99設問まで対応可能というマークシートを作成しました。また、50分という試験時間を考えると80設問あれば十分というご意見も頂戴しましたので、1列30行×3列で90設問まで対応可能なマークシートや、1列25行×3列で75設問まで対応可能なマークシートを作成し、これらのマークシートを1つの Excel Book にまとめました。以下のリンクからダウンロードできます。

1.事前の準備と受験者への注意事項

(1)マークシートの印刷

プログラムに添付した R25C04D19.xlsx をダブルクリックして開き、必要な枚数をインクジェットプリンタで印刷します。印刷設定はA4・横に設定済みです。用紙はホームセンター等で購入できるコピー用紙を使用してください(読み取りパラメータの設定とマークの読み取りテストは再生コピー用紙を使用して行っています)。マークや罫線枠が設定よりも濃く印刷される輪転機での印刷はお止めください。

100選択肢(0~99)に対応した、複数選択可能なマークシート 「R25C04D19.xlsx」


(2)受験者への注意事項

試験を実施する前にHB以上の濃さの鉛筆とプラスチック消しゴムを用意するよう、受験者全員に連絡してください。可能であれば、シャープペンシルの使用は禁止した方がよいと思います。

※追記(20240924)
どうしてもシャープペンシルを使用したい場合は、硬さ・濃さは「2B」で、芯の太さは「0.9mm」以上のものであれば使用可とするような「条件付き許可」とした方がよろしいかと思います。

問題冊子の表紙には、次の注意事項を印刷してください。


上記注意事項のサンプルをダウンロードできます。

2.スキャナーでマークシートをスキャンしてJpeg画像に変換

(1)スキャンの準備

解答用紙(マークシート)をスキャナーでスキャンする前に、解答用紙が裏返しだったり、逆さまになっていないかどうか等の確認に加え、次のことを必ず実行してください。

・解答用紙に付着している消しゴムの屑をしっかり落とす。
・受験番号(出席番号)が昇順になるよう、解答用紙の並び順を2回以上確認する。
・欠席者がいる場合は、未使用の解答用紙を該当箇所に挿入する。

(2)スキャナーの設定

解答用紙(マークシート)をスキャンする際の読み取り解像度は 200dpi で十分です(解像度を大きく設定しても、MS_Reader.exe での利用に関する限り、メリットは何一つありません)。また、画質は「カラー」を指定し、出力先はPDFファイルではなく、Jpeg画像を指定してください。

3.指定のフォルダ内にフォルダを作成してスキャンした画像をコピー

スキャンしたJpeg画像は、MS_Reader.exeと同じ階層にあるScanDataフォルダ内に適切な名称のフォルダを作成し、そこに保存してください。

【利用できるフォルダ構造】

〇:ScanData¥1年A組

【利用できないフォルダ構造】

×:ScanData¥1年¥A組

ScanData フォルダ内に新規にフォルダを作成し、さらにそのフォルダ内に新規にフォルダを作成して、そこにスキャンした Jpeg 画像を保存するような使い方は出来ません。ご注意ください。

4.採点専用画像に変換

ScanData フォルダ内に新規に作成したフォルダにスキャンしたJpeg画像が用意できたら、次にこの画像をマークシートリーダーで読むための専用画像に変換します。その理由はいくつかありますが、主なものは次の三つです。

(1)読み取り原本は、オリジナル状態のまま残しておく。
(2)マーク読み取り後に人の眼でチェックする際、作業しやすい大きさに整える。
(3)Loop 処理するため、画像の名称を統一し、1から始まる連番の番号を付ける。

具体的な方法は次の通りです。

(1)ダウンロードした Zip ファイルを展開したフォルダ内にある MS_Reader を起動します。

上のアイコンをダブルクリックして起動します。


(2)画面左上のメニューの「1 画像変換」をクリックします。


(3)表示されたサブメニューの「専用画像を作成」をクリックします。


(4)次のフォームが表示されます。画面右上の「選択」ボタンをクリックします。


(5)ScanDataフォルダの内容が表示されます。マークの読み取り処理を行いたいJpeg画像を保存したフォルダをクリックして選択します。選択したフォルダ名が下の「Folder」部分に表示されていることを確認して「OK」をクリックしてください。

【重要】選択するのは「フォルダ」で、「ファイル」ではありません!

Folder部分に、選択したフォルダ名が表示されます。


(6)画面は次のようになります。画面の中央左に表示されたサムネイル画像を参考にして、画像の回転の有無及び回転方向を選択し、続けて、リサイズの有無と縮小率を指定します。読み取り解像度が200 dpi ・A4横形式で、マークシートの列が4列ある場合は、「80%に縮小」してください。


(7)変換処理を行った画像データの出力先(書き出し先)フォルダを指定します。「参照」ボタンをクリックしてください。再びフォルダの選択ダイアログが表示されます。


(8)プログラムは、(5)で指定されたScanDataフォルダ内のフォルダと同じ名前のフォルダをProcDataフォルダ内に自動的に作成します。フォルダをクリックして選択し(下のFolder部分に選択したフォルダ名が表示されたことを確認)、OKをクリックしてください。

【参考】ProcData は、Processed(加工済み)の意味です。短くしすぎカモですが・・・


(9)ProcDataに続くPathを確認し、「変換実行」ボタンをクリックします。

〇:ProcData¥(自動的に作成されたスキャンした画像を保存したフォルダと同名のフォルダ)

×:ProcData¥AAA¥BBB¥CCC


(10)次のメッセージが表示されます。MS_Readerは、筆者が別に作成した手書き答案の採点プログラムと併用して使用することもできますが、今回の処理ではそのような形では運用しませんので「いいえ」ボタンをクリックしてください。


(11)専用画像の作成が完了すると、次のメッセージが表示されます。OKをクリックしてください。


(12)「終了」ボタンをクリックして、変換作業を終了します(画面下・中央の「画面の初期化」ボタンをクリックすれば、続けて他のクラス/講座の画像を同様に処理することも可能・・・なようにプログラミングしたのですが、「画面の初期化」ボタンのクリックでは「初期化されない何か」が残ってしまうバグが発現することがあるようです。意図した通りに変換されない状況を1度、経験しました)。なので、他のクラス/講座の画像を処理する際は、一旦終了してから再度このプロセスを呼び出していただいた方がよろしいかと思います。

ド素人が書いたプログラムであります。内在する不具合につきまして、もし、それが発現するようなことがありましたならば、ただ、ただ、伏してお詫び申し上げます。不具合がありましたら、一旦「終了」をクリックして、再度、画像変換処理を呼び出していただけますよう、お願い申し上げます。

変換後のファイル名はProcDataフォルダ内に作成した変換先フォルダの名称+01から始まる連番となります。

5.テンプレートとして登録

筆者の作成したマークシートリーダーでは、マークの読み取りに際し、まず特徴点(マークシート画像内のトリプルドット:■■■ )をコンピュータの眼である OpenCV を用いて探し出し、三つの ■ のうち最も左の ■ の左上隅を座標原点(0,0)として、ここからの距離情報を利用してマークシートの第1列を画像として切り出し、二値化・色の反転処理を行って、さらにそこから1行ずつ「行の画像」を切り出し、この「行の画像」を選択肢数個に切り分けて白面積を計算、それが大きい場合に「マークあり」と判定しています。

このため最初にマークシートの特徴点の位置と各列の左上隅及び右下隅の位置を座標として登録し、これをそのマークシート形式の定型フォーマット(=テンプレート)として利用できるように名前を付けて登録する処理を行います。

テンプレートの名前の意味は、次の通りです。

例:R25C04D19

1列あたりの行数・全列数・選択肢の形式と選択肢数を「行・列・選択肢」順に並べています。
R は Row (=行)、すなわち1列 25 行より成ること、
C は Column (=列)、すなわち4列あること、
D は Double 型、すなわち複数マーク対応で、1行あたりの選択肢数は 19 個。
(ここが S の場合は Single 型、複数マーク不可)

Word や Excel で作成したマークシートを、同じインクジェットプリンタで印刷して使用しているので、試験を実施する度にテンプレートを登録する必要はないはずなのですが、筆者はなんとなく不安で、毎回新しくテンプレートを登録し直して作業しています・・・

テンプレートの登録方法は、次の通りです。

(1)「2 テンプレート」をクリックして表示されるサブメニューから「テンプレートの新規登録」をクリックして選択します。


(2)別のWindowが開き、次の画面が表示されます。画面右上の「取得」ボタンをクリックします。


(3)ファイル選択ダイアログが表示されます。任意のマークシート画像を1枚選んでクリックして選択してください。下のファイル名欄にクリックした画像の名称が表示されていることを確認して、「開く」ボタンをクリックします。


(4)選択したマークシートが表示されます。画面右上の「マーカー」オプションボタンをクリックしてください。


(5)画像左上の特徴点部分が3倍の大きさで拡大表示されます。特徴点画像の左上位置をポイント(マウスのカーソル:+の中心を合わせる)してクリックしたらそのまま指を離さずに特徴点画像の右下へドラッグしてください。ドラッグ中は黒い太い枠線(=ラバーバンド)が表示されます。

赤枠の中、ラバーバンドの様子がよくわかるように、特徴点画像より少し大きめにドラッグしています。


実際は、次の図のように、ラバーバンドが特徴点画像の外側をぴったり包む(両者の幅と高さが同じになる)ようにドラッグします。


(6)ドラッグ終了時に矩形選択された部分が特徴点画像としてコピーされ、下の図のように表示されます。照合手法は自動的に設定されますので、変更しないでください。続けてコンピュータの眼である OpenCV がマークシート画像内の特徴点を見つけることができるか、どうかのテストを実行します。

「マーカー画像の読み取りテスト」ボタンをクリックしてください。


(7)OpenCVが発見した特徴点画像の位置が赤い矩形で表示されます。特徴点画像を完全に一致していることを確認し、表示されるメッセージを読んでOKをクリックしてください。


(8)選択対象グループの「解答欄」オプションボタンをクリックすると、案内バルーンが表示されます。マークシートの1列あたりの行数と、マークシート全体の列数、1行あたりの選択肢数をコンボボックスの選択肢から選んで順に設定します。


(9)次の図は、複数選択可能なマークシートの設定例で、行数・列数・選択肢数をそれぞれ入力した直後の状態です。複数マークを許可するか、どうかの設定を行います。

【重要】
複数マークを許可する場合は「複数マークによる採点を実施」チェックボックスをクリックして、チェックが入った状態 にしてください!!

GUIのデザインが悪いためか、作った本人でも! この設定の操作を時々忘れます。複数選択可能なマークシートのテンプレート設定を行う場合は、くれぐれも注意してください。


(10)マークシートの1列目から順に、その座標を取得します。1列ラベルの左のオプションボタンをクリックしてください。マウスカーソルの形状が+になります。

次の図に示したように、まずマークシートのマーク欄枠の左上をクリックし、そのまま指を離さずに、第1列めの右下隅へ向かってドラッグしてください。なお、ドラッグ中は、細い点線のラバーバンド矩形が表示されます。

設問番号欄を含めないようにご注意願います。
必要な座標は、マークシート欄の座標です!


列の右下隅までドラッグした状態を示します。


ドラッグを終了する(マウスの左ボタンから指を離す)と、選択範囲が赤の矩形で囲まれます。取得できた第1列目の座標が画面右のラベルに表示されます。

第1列めの範囲を指定し、座標を取得したところ

【重要】
この作業にマーク読み取りの成否がかかっています!
くれぐれも慎重に、正確に、作業してください。

うまく列を選択できなかった場合は、「再範囲選択」ボタンをクリックして作業をやり直すことができます。


(11)以降、4列目まで同じように作業します。4列目の座標を取得できたら、「保存」ボタンをクリックして取得した座標を ini ファイルに保存します。

保存処理が完了するとメッセージが表示されます


(12)最後に「終了」ボタンをクリックして、テンプレート作成の画面を閉じます。

ボタンは画面右下隅にあります。


これでマークシートを読む準備ができました!

6.マーク読み取りを実行

(1)最初に使用するテンプレートを選択します。

「2 テンプレート」をクリックするとサブメニューが表示されます。
「テンプレートの選択」を選んでください。


(2)テンプレートの選択画面が開きます。使用したいテンプレート名をクリックして反転表示させ、「決定」ボタンをクリックしてください。


【参考】テンプレートの削除方法
必要のなくなったテンプレート名をクリックして選択、「テンプレートの削除」チェックボックスをチェック、「実行」ボタンをクリックすれば不要なテンプレートを削除できます。削除したテンプレートを元に戻すことはできません。テンプレートを削除する際は、その要不要に十分ご注意ください。

(3)次のメッセージが表示されます。これはメニューの「3 作業フォルダ」をクリックすると表示されるサブメニューの「作業フォルダの選択」をクリックした際に表示されるメッセージと同じものです。「はい」ボタンをクリックしてください。


(4)フォルダの選択ダイアログが表示されます。ProcData フォルダ内の読み取りたいマークシート画像のあるフォルダをクリックして選択してください。下のFoleder部分に選択したフォルダ名が表示されたことを確認して、「OK」ボタンをクリックしてください。

選択するのは「ファイル」ではなく、「フォルダ」です。


(5)テンプレート名と関連付けて保存されている特徴点画像が見つかった場合は、それを赤の矩形で囲んで表示します。また、特徴点画像からの距離座標を用いてマークシート欄第1列の1行目がどこにあるのかを計算し、その位置をこちらも赤い矩形で囲って表示します。

【参考】PCによっては、ここで Python Engine の初期化に時間がかかることがあります!

次の図のように表示されれば、マークの読み取り準備は完了です。

諸設定が意図した通りに反映され、OpenCVが正しく動作していることを確認したら、
「OK」ボタンをクリックしてください。


(6)マークの読み取りを実行します。案内バルーンが表示されますので、その下にある「読む」ボタンをクリックしてください。


Python4Delphi が使用できる環境(組み込みPython環境を入れた Python39-32 フォルダが MS_Reader.exe と同じフォルダにある場合)ならば、P4D チェックボックスに自動的にチェックが入り、Python 用のOpenCVを用いてプログラムは動作します。


Python4Delphi が利用できない場合、プログラムはDelphi用のOpenCVを利用して動作します。

画面下に表示されている Grid コントロールにすべてのマークシート画像の読み取り結果が表示されたら、マークの読み取りは完了です。通常の動作モードでは、マークの読み取り完了を知らせるメッセージは表示されません。

複数選択可能なマークシートの場合、空欄や3個以上マークされている等、
読み取り結果に何らかの問題がある場合は「999」と表示されます。

【重要】二値化閾値の修正方法(20240707追加)

マークが「うすい」場合、これを正しく読み取ることができず、「空欄」と判定して「999」と表示される場合があります。同じ理由で、複数マークされた解答欄の「1の位」が読めなかった場合も、読み取り判定は「マークの状態に問題あり」となり、「999」と表示されます。

これらの場合は、この後、実行する「読み取り結果のチェック」時に、該当箇所の解答欄が赤枠で囲まれて表示されますので、マークの状態をヒトの眼で確認し、読み取り結果を修正できます。

最も困るのが、複数マークされた解答欄の「10の位」のマークは薄くて読めなかったが、「1の位」のマークの読み取りには成功している場合です。この場合は正しく読み取れた「1の位」のマークが読み取り結果として表示されてしまいます。大変申し訳ないのですが、MS_Reader.exe のチェックプログラムは、この誤読を見つけることができません!!(これは原理的な問題なので、チェックする方法がありません)

この事故を防ぐには、事前に読み取りテストを十分に行って、読み取りパラメータを調整するしか方法がありません。具体的には、最もマークの濃度が薄い受験者のマークシートが正しく読めるようになるまで、閾値の値を1ずつ大きくして読み取りテストを実行します。筆者の行ったテストでは、デフォルト設定の閾値(180)では正しく読めなかったマークも、閾値を大きくすれば読めるようになりました。

「塗りつぶす」方式でなく、より簡易な「線を引く」方式でマークするシートを用いて行った読み取りテストの例

「64」と読むべき箇所の「10の位」を「空欄」と判定、「1の位」は正しく読めた場合、プログラムは読み取り結果を「4」と表示してしまう。


閾値を「1」大きくすると・・・

正しく読めるようになりました。


ごく薄いマークも正しく読めるようになるまで、この操作をくり返します。筆者の行ったテストでは、閾値を1ずつ大きくすることで、指示通り普通の濃さ(マーク箇所の数字が読めなくなる濃さ)でマークされたシートであれば、最終的にすべてのマークを正しく読めるようになりました(上記のマークシートを使用して行った実験では、最終的にすべてのマークを正しく読めた段階で、二値化の閾値の値は「184」でした。なお、このマークシートは後日公開する予定です)。

ただ、あまりにもマークの濃さが薄い場合は「原理的に読めません」ので、「対応不可」としてヒトの眼で読むしかないと思います・・・。

そのような事態にならないよう、予め受験者に対し「薄いマークは読み取れない」旨の注意を徹底しておく必要があります。マークシートを用いた試験では、これが最も重要なことかもしれません。


【参考】Grid コントロールの高さを変更する方法

Grid コントロールの高さは自由に変更できます。マークシート画像と Grid コントロールの境界部分にマウスカーソルを持って行く(ポイントする)と、=の上と下に上下向きの矢印のついたポインタ形状に変化する場所があります。この部分を上下にドラッグすることで、Grid コントロールの高さを変更することができます。

Grid コントロールの高さを1行分にしたところ

7.マーク読み取り結果のチェック方法

(1)マークの読み取りが完了したら、結果をチェックします。設問数が100である場合はそのまま「Check!」ボタンをクリックしてください。

Check! ボタンをクリックして、読み取り結果を確認します。


設問数が100 未満の場合、空欄その他の判定フラグである「999」が多数入力されていますので、チェック時にそれらのチェックを省略する設定を行います。

例えば、設問数が「80」である場合、4列目の設問「81」以降の「999」はチェックの必要性がありませんから、Skip チェックボックスにチェックして、4列目のコンボボックスに選択肢から「81」を選択します。「覚」ボタンをクリックすると、この設定を記憶します(他のクラス/講座のマークシートを読み取る場合は記憶させてください)。この設定を行ってから、Check! ボタンをクリックして読み取り結果のチェックを実行してください。


(2)読み取り結果に問題があると判定された場合は、次のように「問題あり」と判定された箇所が赤い矩形で囲まれて表示されます。(この場合は、マークが横に長すぎて隣のマークの領域に入ってしまっているのだと思います)

実際の画面では、14 設問目の「999」は青く反転表示されています。
(画面をキャプチャーした際、青の反転表示が消えてしまいました)


確認を行った結果、マークが「15」であり、正解と見なせる場合は Grid コントロールの「999」を消して「15」と入力し、読み取り結果を修正することができます。

読み取り結果を「15」に変更したところ


ちなみにこのような場合、「P4Dを使用」のチェックを外し、判定領域をマーク画像の中心付近のみに設定して読み直せば正しく読めるようになります。(ただし、動作速度は遅くなります)


読み取りパラメータを上のように変更して再度マーク読み取りを実行した場合、

「999」表示は消え、さっきは読めなかったマークを正しく読むことができました。


(3)再度「Check!」ボタンをクリックして、チェックを続行します。

10の位に二つマークがある場合等、不正解と見なせる場合は、「999」を修正せず、チェックを続行します。
続けて「Check!」をクリックしてください。


次のメッセージが表示されたらチェックは完了です。

8.CSV形式で出力

マークの読み取り結果の確認作業が完了したら、Grid コントロールに表示されているデータをCSV形式でファイルに出力できます。このCSVファイルは筆者作の採点結果通知作成プログラムから読み込んで採点結果通知票の作成に利用できます。Excel Book にもこのデータを書き出すことができますが、複数マーク対応の採点結果通知を作成できる Excel のワークシートは作成しておりませんので、ここではその処理方法の詳細は説明しません。

CSV形式で出力する方法は、次の通りです。

画面右下の「ファイルへ出力」グループの CSV オプションボタンをクリックして選択し、「書き出し」ボタンをクリックします。


書込みが完了すると、次のメッセージが表示されます。


表計算ソフトその他を用い、ご自身で採点結果を処理される場合は、出力先をメモしてください。

出力されたCSVの内容は、次の通りです。

1行目は「設問番号」、A列が「マークシート番号(=出席番号)」です。

9.採点結果通知シートの作成方法

採点結果通知の作成方法は、当Blogの過去記事をご参照下さい。

10.お願いとお断り

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

100選択肢に対応したマークシートリーダー

追記(20240929)

当Blogで紹介してきた自作のデジタル採点プログラムを一つにまとめました。次のリンク先にその紹介とダウンロードリンクがあります。マークシートも、ここに紹介した形式の他、様々なタイプのものを同梱しています。

当Blogで紹介したデジタル採点プログラムのすべてをまとめました!

追記(20250702)

100選択肢用のマークシートを使って試験を行ったユーザーの方から、『マークの塗りつぶし面積が大きいと誤判定が出やすい』とのご指摘をいただきました。確認すると、受験者は「濃く・丁寧に」マークを塗りつぶしたことがマークシートから伝わってくるのですが、100選択肢用のA4横サイズのマークシートは・・・やはり、と言うか、どうしてもマークそのものが小さく、受験者によっては「きちんとマークすること=大きくマークすること」と、感じて(?)、選択肢番号の外枠の楕円「0」部分を上下左右に大きくはみ出して隣のマークの領域範囲まで塗りつぶしてしまい、結果的に、その受験者のマークシートは複数マーク判定だらけになることがわかりました。

対策として、『選択肢番号の外枠の楕円「0」部分をはみ出さないようにマークする』よう注意を徹底することをお願いしたのですが、それだけでは根本的な解決とならないように感じ、マークとマークの間隔が狭いためにこの問題が起きていることは明白ですから、1設問について100選択肢に対応を維持しつつ、1列25行×4列で100設問まで対応という現在のマークシート構成を見直し(マーク間の幅を広げるため列数を減らし)、1列33行×3列で99設問まで対応可能というマークシートを作成しました。また、50分という試験時間を考えると80設問あれば十分というご意見も頂戴しましたので、1列30行×3列で90設問まで対応可能なマークシートや、1列25行×3列で75設問まで対応可能なマークシートを作成し、これらのマークシートを1つの Excel Book にまとめました。以下のリンクからダウンロードできます。

【追記追加前の記事の本文はここからです!】

先日、電車にゆられていたら先輩が。

「選択肢がたくさんあると、マークシート使うの、難しいかなー?」って。

なんかおもしろいこと、ないかなー☆って、毎日、ひまなんだもん。

すぐに出来そうな気がしたので、さっそく大語群に対応したマークシートリーダー作成にチャレンジ。

今までのは数学や教科「情報」の試験用に作成した16選択肢が最大だったが。

数学の試験用に作成したマークシート(マーク部分は-記号)
マークの色が濃いのは、開発初期のマークシートの画像であるため。
教科「情報」の試験用に作成したマークシート(選択肢の番号はゼロ始まり)
経験を積む中で、誤判定を防止するため、マークの色はどんどん薄くなった。

今回、作成したマークシート(最終的なかたち・Excel で作成)。

大語群に対応したマークシート(選択肢の番号は0ー99、合計100)、遂に完成!

正直、思ったほど、かんたんではありませんでした!!

【もくじ】

1.最初に作ったのはB4縦型のマークシート
2.次に作ったのはA4横型のマークシート
3.Excel でマークシート作成に挑戦
4.マークシートを最適化
5.読み取りプログラムも修正
6.発見した問題点と解決策
7.まとめにならないまとめ
8.プログラムのダウンロード
9.お願いとお断り

1.最初に作ったのはB4縦型のマークシート

選択肢の数が多いことを、ここでは『大語群』と呼ぶことにする。この大語群に対応したマークシートを作るにあたり、最初に決めておくべきことはもちろん選択肢の最大数。30個もあれば十分な気もしたが、「大きいことはいいことだ!」とも言うし、どうせ作るなら100個まで対応できるようにしようと決心。

数年前にマークシートリーダーを作ったとき、選択肢数50個に対応した複数マーク読み取り可能なプログラムを書いた記憶があり、10の位と1の位を分けてマークする次のような形式のマークシートがすぐに思い浮かぶ。1行あたり、2個までのマークを読み取れるようにコードを修正すれば、このマークシートで選択肢の番号を0-99として、計100個の大語群を使った試験にも対応できるはずだ。

10の位のマーク欄が空欄なら、プログラムは1の位のマークのみを読むよう設定


このイメージを実現するにあたり、差し当たって問題になるのはマークシート用紙の基本サイズ。さすがにA3サイズの用紙は、マークシートとして使うには巨大すぎる気がする・・・が、1行あたり10の位のマークに①~⑨で9個、1の位のマークに⓪~⑨で10個、合計19個のマークを用意して、得点設定は1設問1点で合計100点とするためには、当然100設問分の行を用意しなければならない。

1列50行で2列作成するとなると・・・、やっぱり、B4版で、縦置きか?

これまでのマークシートは Word で作成していたので、今回も Word を利用。・・・と言うか、本当は印刷設定の自由度が大きい Excel を使いたいのだが、Excel で縦楕円の丸囲み数字を上手に作成する方法がわからない。そこで縦楕円の丸囲み数字が簡単に作成できる Word を利用した・・・というのが正直なところ。

ちなみに Word で縦楕円の丸囲み数字(=「囲い文字」というらしい)を作成する方法は・・・

Word なら、Font は「メイリオ」を選択(フォントサイズを大きくしない場合)、丸囲みしたい数字を半角で入力、入力した数字をマウスでドラッグして選択してから、フォントリボンの「囲い文字」アイコンをクリックすると・・・

赤い枠で囲んだのが「囲い文字」を作成するアイコン。
数字を入力して、ドラッグして選択したのち、これをクリック。


ダイアログが表示されるので、スタイルを設定して・・・

スタイルは「文字のサイズを合わせる」を選択。


さらに数字の選択状態は解除しないまま、段落リボンの「拡張書式」をクリックして、表示されるサブメニューのいちばん下にある「文字の拡大/縮小」をクリックして、さらに表示されるサブメニューの「66%」をクリックすれば・・・

マークシートのマーク領域(縦楕円の囲い文字)を作成


思った通りの囲い文字が完成!


あとはマークシート用途に利用できるよう、色の設定を薄めに変更する等して、必要な選択肢の数だけこれを作成すればいいんだけど・・・

これを Excel で実現する方法がわからない・・・


そのような理由から、とりあえず Word で作成してみたB4版・縦置き型のマークシート。
思ったより巨大で、マークするのがたいへんな気が。

マークするだけで疲れた・・・


読み取り実験用に設問番号1から順に、読み取りデータが設問番号と同じになるようマークしてみる。
100個目は1の位の「0:ゼロ」をマーク。これで1から99と0(ゼロ)で、合計100の選択肢が使える大語群対応型マークシートが完成・・・したと思ったんだけど。

複数マークの読み取りを可能にするため、リーダーのプログラムを少し変更。

Delphiを起動して、マークシートリーダーのプロジェクトファイル一式をコピーして、新たな複数マークの読み取りに対応したプロジェクトを作成。

マーク読み取り手続き部分のコードを次のように変更。最初に手直ししたのは、P4Dを使ったスクリプト部分。

      //複数マークの読み取り方法
      if (Copy(strMS_Type,10,2)='19') and (chk_MultipleMarks.Checked) then
      begin
        //選択肢数が19で、複数マーク許可であった場合
        StrList.Add('                var1.Value = str(res)');
      end else begin
        //複数マークは不許可であった場合
        StrList.Add('                var1.Value = "99"');
      end;

Python側で読み取った値をDelphi側で処理する部分も変更(一部を抜粋)。

//選択肢の始まりは「ゼロ」
  if (Copy(strMS_Type,10,2)='19') and (chk_MultipleMarks.Checked) then
  begin
    //複数マークに対応
    //strAnsList[intSG_k]の文字数を調査
    strCount:=ElementToCharLen(strAnsList[intSG_k],Length(strAnsList[intSG_k]));

    //チェック内容は、以下の通り
    {
    文字数が2文字の場合、末尾の1文字を取得する
    10 -> 0
    11 -> 1
    19 -> 9
    末尾1文字がマークした選択肢の番号になる

    文字数が5文字の場合、
     1 10 -> 2文字目が1、末尾2文字が10 -> 10
     2 11 -> 2文字目が2、末尾2文字が11 -> 21
     3 12 -> 2文字目が3、末尾2文字が12 -> 32
    (2文字目×10)+(末尾2文字 - 10)がマークした選択肢の番号になる
    }

    case strCount of
      2:begin
        //2文字の場合は、末尾1文字が選択した選択肢の番号
        StringGrid1.Cells[intSG_Col,intSG_Row]:=RightStr(strAnsList[intSG_k],1);
      end;
      3:begin
        //空欄と判定された場合
        if strAnsList[intSG_k]='999' then
        begin
          StringGrid1.Cells[intSG_Col,intSG_Row]:=strAnsList[intSG_k];
        end;
      end;
      5:begin
        //(2文字目×10)+(末尾2文字 - 10)がマークした選択肢の番号
        StringGrid1.Cells[intSG_Col,intSG_Row]:=IntToStr(
          (StrToInt(Copy(strAnsList[intSG_k],2,1)) * 10) +
          (StrToInt(RightStr(strAnsList[intSG_k],2))) - 10);
      end;
    end;
  end else begin

次に、P4Dを使用しないDelphi用のOpenCVを利用したマーク読み取り部分のコードも変更(一部を抜粋)。

  //1行につき選択肢数分Loopする_複数選択肢に対応(New)_20240614
  if (Copy(strMS_Type,10,2)='19') and (chk_MultipleMarks.Checked) then
  begin
    //複数選択可能な場合_選択肢の数だけLoopする
    for p := 0 to intCol-1 do
    begin
      //対象値pが平均値の3倍より大きいか、どうかでマークありと判定
      if AryVal[p]>dblAvg * intKeisu then
      begin
        //マークありとした判定の数を記録
        q:=q+1;
        //マークした番号(記号)を記録
        //intMark:=p+1;
        //10の位(0-8)
        case p of
          0:strMark_A:='1';
          1:strMark_A:='2';
          2:strMark_A:='3';
          3:strMark_A:='4';
          4:strMark_A:='5';
          5:strMark_A:='6';
          6:strMark_A:='7';
          7:strMark_A:='8';
          8:strMark_A:='9';
        end;
        //1の位
        case p of
           9:strMark_B:='0';
          10:strMark_B:='1';
          11:strMark_B:='2';
          12:strMark_B:='3';
          13:strMark_B:='4';
          14:strMark_B:='5';
          15:strMark_B:='6';
          16:strMark_B:='7';
          17:strMark_B:='8';
          18:strMark_B:='9';
        end;
      end;
    end;
    //Loop終了時にマーク数を判定
    if q=0 then
    begin
      //マークした番号がない場合
      iArr[i,Rep]:=999;
    end else begin
      //マークした番号があり、それが一の位である場合
      if (q=1) and (strMark_A='') then
      begin
        //マーク数が1、かつ十の位が空欄であったら
        iArr[i,Rep]:=StrToInt(strMark_B);
      end else begin
        //マーク数は1だが、それが十の位であったら
        iArr[i,Rep]:=100;
      end;
      if (q=2) and (strMark_A<>'') and (strMark_B<>'') then
      begin
        //マーク数が2、かつ十の位と一の位がともに空欄でなかったら
        strMark:=strMark_A+strMark_B;
        iArr[i,Rep]:=StrToInt(strMark);
      end;
      if q>2 then
      begin
        //トリプル以上のマーク数を見分けるフラグは100
        iArr[i,Rep]:=100;
      end;
    end;
  end else begin


Delphiでマークシートリーダーを作成する方法の基本は過去記事をご参照ください。

マークの読み取りそのものは「絶対成功する」自信があった(?)ので、複数マークの読み取り処理を既存のプログラムに追加すれば、速度的なことも含めて楽勝でプログラムは完成するはず・・・だったんだけれど。

実際に上のB4版・縦置き型マークシートをスキャンして読み取りテストを行ってみると・・・

P4D利用時の読み取り結果は期待した通り、100 %正確にマークの読み取りに成功するが、P4Dを利用しない場合に不具合が発生。50設問目は正しくは「2」と読み取らなければならないはずなのに、読み取り結果の表示には、なぜかトリプルマークの判定結果である「100」が表示されている。

※ この時点では、必要数以上にマークがあった場合の表示フラグとして「100」を使用していた。
※ 最終的に、読み取れない解答欄は全て空欄の表示フラグ「999」で示すようプログラムを修正した。

「100」は3つ以上のマークがあった場合に表示されるはずなんだけど?


さらに、よく見てみると 100 設問目もヘン。91、92、・・・、97、98、99 と順調に読み取って、最後は「 0:ゼロ」とくるはず!なのに、読み取り結果はトリプルマーク以上の判定結果である「100」がここにも登場。ヒトならともかく、機械が勢い余るはずもなく、誤認識の原因はまったくもって不明。

どうみてもマークしたのは「0:ゼロ」なんだけど。


今までさんざんテストして、読み取りパラメータ設定も変更の必要が「ない」ところまで煮詰めたと思っていたのに、この結果には唖然とするしかなく、悪夢を見ているのではないかと思ったが。

現実は現実。

変更したコードを見直してみるが、おかしなところは見当たらない(ように思う)。
実際、大多数のマークは「ちゃんと読み取ってる」し・・・

なんで、部分的に読めない箇所があるのか???

読めないなら読めないで、「全部」間違うのが機械だと思うんだけど。

仕方がないから、パラメータ設定をいじってみるが・・・

これがデフォルトのパラメータ設定。
(自分的には、変更の必要がないと思えるところまで、さんざん修正を繰り返して決めた値)


パラメータをどう設定しても、一部のデータを誤って読んでしまう・・・。

例えば、閾値を「200」、判定領域を「20」に変更した場合、

47、48、49ときて、次は2のはずなのになぜか「42」
機械のクセに、勢い余ってるとしか思えない・・・。

こんなプログラムは使えない!!

2.次に作ったのはA4横型のマークシート

誰も助けてくれる人なんていないから、問題は自分で解決するしかない。まぁ、問題そのものを自分で作り出しているとも言えるわけで、自業自得と言えばそれまでのこと。自分以外の誰も困ってないし、それを幸いにあきらめてしまうのがいちばん簡単なことだが、それは最終手段。

原因はわからないが、今まで起きたことのないことが起きている、つまり、今までと違うことをしてるから、そこに問題の発生する原因そのものがあるはずだ。何が違うのか、そこを考えてみる。

今までと違うのは、まず、マークシートのサイズそのもの。B4版なんて使ったことがない。もしかしてそれが原因か? 判定プログラムでは行を図として切り出して、さらにマーク1つずつに分解し、二値化して「白」面積が大きいものを「マークあり」と判定しているから、二値化の閾値の設定にもよるが面積的な部分にも誤認識の原因があるような気もしてきた。そうでなくても、実際に使ってみて、やはりB4サイズは「マークシートとして大きすぎる」気がしたのは、ほんとう・・・。

もし、マークの読み取りプログラムそのものに誤りがあるなら、全てのマークを正しく読めないはずだが、ほとんど正しく読めているから、読み取りプログラムそのものに致命的な問題はない・・・はず。

そこで、これまでに正しく読み取れたものとサイズ的に同じ「A4版・横置き型」のマークシートを作成して実験してみることにする。

そう思って作成したのがこちらのマークシート。縦置きにしなかったのは、「今までと同じにする」という部分にあくまでもこだわった結果。

かなり無理して「詰め込んだ」感、満載。


さすがにA4版・横置き型で1列50行のマークシートは(自分の技術では)Word で作成できず、作成にあたっては(念願の?)Excel を使用。Excel で作成した際の画面はこんな感じ。

縦に長い楕円の囲い文字の作り方がわからず、仕方がないから丸囲みの番号でマークを作成
どこか、なにかが「チープな感じ」で、出来栄えもいまいち。


これで実験すると・・・、P4Dを使った場合からして

まったく読めてない!


高速読み取り処理が可能なP4D環境で正しく動作しなかった時点で(これはダメだ)と思ったが、とりあえず非P4Dモードで動かしてみると・・・

B4版使用時より、さらに悪い結果に。


B4版使用時は「唖然」とする思いであったが、今度は「暗澹たる」思いが。

この結果を目の当たりにしたときは、驚愕のあまり、言葉を失い、ついでにやる気もほぼ全部失い、PCの蓋を閉じて(この表現でいいのか?)、火酒を求めてバイクで現実から逃走・・・

あの土曜日の夜は、まじで、つらかった。

ひー(こころの声)

今、冷静になって考えると、P4Dモードでほとんど読み取れてないのはおそらくマークシートの罫線に問題があり(太すぎ)、これが複数マークの判定につながったのではないかと思えてならないが、最初からマークシートの作り自体が気に入らなかったこともあり、自分史的には・・・この実験自体を「なかった」ことにして、心のバランスを保つことに決定。

ただ、まだ「あきらめる」という気持ちには到底なれないので、このピンチをまたとないチャンスと前向きに捉え、Excel で縦長楕円の丸囲み文字を作成するところからチャレンジを再開。

あきらめられない以上、自分も、プログラムも良くなるしか「ない」。
それが嫌なら、はじめからこんなこと、しないほうがイイ。

このチャレンジは、僕にある唯一の「自由」なんだ。
暗澹たる思いなんかで、終わりにはしたくない。

3.Excel でマークシート作成に挑戦

Excel を起動し、何も入力されていない白い画面をじっと見つめて考える。

(縦長の楕円で、囲い文字をつくるには・・・)

経験から唯一思いつく方法は、図形(楕円)をセル内に収まるように挿入して右クリック、テキストの編集を選択して、中に数字を入力する方法だ。楕円の挿入方法は次の通り。

挿入タブをクリック ⇨ 図のリボンにある図形から楕円を挿入


まずは、準備作業。

(楕円を挿入するより先に)あとあと作業しやすいよう、画面右下の「ズーム」で画面表示を拡大(238%くらいにした)して、全セルを選択(下図を参照)。で、列幅を28ピクセル、行の高さを32ピクセルくらいに設定。

全セルを選択し、列幅と行の高さを変更する


B2のセルに収まるよう、楕円を挿入(ズーム300%)。

楕円を挿入


挿入した楕円を右クリックして、表示されたサブメニューから「テキストの編集」を選択(左クリック)。

図形の中にテキストを挿入


半角で1と入れてみた。

数字は入ったが、位置がよくない・・・


ホームタブをクリックして、配置リボンにある「上下中央揃え」と、その下の「中央揃え」をクリック。

数字の位置はいい感じになった。


あとはマークシートのマークらしくするため、楕円をクリックして選択すると表示される図形の書式タブをクリックし、図形のスタイルリボンのコマンドを使い、楕円の中を白くして、囲いを灰色に設定。

マークシートのマークらしくする


フォントの色も灰色に変更。

数字の色も灰色にする


で、セル内での図形オブジェクトの位置を微調整。

セル内で中央に揃うよう、楕円を選択して左右の矢印キーで位置を微調整


できた!

スキャナーで実際にスキャンしてみた結果から言うと、
実際に使用するマークシートではもう1段階濃い灰色を選んだほうがよさそう


あとは、コレを等間隔で上下の位置もそろえて並べれば・・・いい・・・んだが、その方法がわからない。

藁にも縋る思いで、Google先生に訊ねると・・・

単に「楕円を挿入したセルをクリックして選択し、オートフィルの機能を使って右方向へコピー」するだけ! だよって。

半信半疑で、やってみた。

楕円ではなく、セルを選択


表示された緑の枠の右下隅にあるハンドルをクリックして選択して、そのまま右へドラッグ。
すると・・・

祈るような気持ちとは、まさにこのことか・・・


マウスのボタンを離すと、表示されたのは・・・

思った通りにコピーできた!


これだ。これ!
これを待っていたんだ。

Google先生、ありがとう!

あとは数字を変更すれば・・・

マークシートのマークができた!


こうして得た知識をベースに、これまでの経験を加えて Excel で作成したマークシートがこちら(枠の線の設定状態がよくわかるように、枠線の表示はONに設定した状態)

マークを塗りつぶす際に、上下左右のマークに影響が及ばないだけの間隔を確保


ページレイアウトタブの配置リボンの「配置」をクリックして、「枠線の表示」をオフに設定。

「枠線の表示」をクリックする度に、表示のONとOFFが切り替わる


先の実験では、罫線の太さで痛い思いをした(?)ので、再びその轍を踏まないよう、罫線はいちばん細いものを選び、色もオレンジに設定。こうしておけば二値化する際に、罫線は完全に消えるはずだ。

注意:この時点では、そう、考えて罫線の色を設定しましたが、以下で述べる通り、いちばん細い罫線に対する色の設定は、印刷時に無効になり、印刷色は必ず黒になります!

確か、いちばん細い罫線はこれだったはず・・・


で、罫線の色も文字の色(=マークの色)と同じ薄い灰色に設定して試しに印刷してみると、なにか違和感を感じる印刷物がプリンターから排出された。よく見ると罫線の色が濃い! 指定した灰色でなく、普通の黒のような気がしてならない。他の灰色を選んでも、印刷すると罫線の色は「まったく変わっていない」ように見える。もしかして、色の濃さの設定が反映されていない?

(オレンジ色にしてみるか?)

早速、設定 ⇨ 印刷を実行して確認 ⇨ 結果は「黒いまま!」

再び、Google先生にお伺いをたてて知った驚愕の事実。

「Excel はその仕様のため、いちばん細い罫線は印刷時に必ず黒で印刷されてしまう」とのこと。

まじですか? まったく知りませんでした!!

あわてて2番目に細い罫線に変更。色はオレンジ色を指定(この色がなぜか、すーぱー気に入った)。

そうこうして、ようやく、これなら大丈夫と思えるマークシートが完成。

上記の方法で枠線の表示はOFFに設定した状態の画面。
自分的には、満足できる出来栄え。

4.マークシートを最適化

完成したマークシートの全体のイメージはこんな感じ。

1列あたり25行×4列、1行について19選択肢(10の位:1-9、1の位:0-9)、A4版・横置き、
最大100設問に対応


ちょっとマークが小さいような気もしたが、数学・情報用に作成した1行あたり16選択肢のマークシート同等に、隣り合うマークどうしの間隔もあけることが出来た気がする。これが近すぎると乱暴にマークされた場合、「複数マークあり」と判定してしまう危険性が高まってしまう。

また、1列あたり25行の設定としたことで、上下のマークの間隔も十分広くなった。B4版・縦置きの1列50行やA4版・横置きの1列50行よりも遥かに圧迫感は減少している気がする。

でも、試しに100設問分ぬってみたら、やっぱり、疲れた・・・。100設問分マークするってことは、その約2倍マークしなければならないから、疲れて当然と言えば、当然。

これをスキャンして、最終動作確認。


上のマークシートを、実際にスキャナーでスキャンしてJpeg画像に変換し、マークの読み取り処理を行ってみた。結果はPython環境を使っても、使わなくても、読み取り成功率は100%、ようやく期待通りに動くようになってきた。でも、途中、マークシート作成作業での失敗がなければ、Excel を使って(縦長楕円の囲い文字で)マークシートを作る技術は習得できなかった。「失敗は成功のもと」というけれど、今回あらためて諺の重みを実感。

次はマークシートの印刷の濃度の調整(最終仕上げ)。・・・と言うのも、実はマークの読み取り結果をExcel で処理して採点結果の通知シートを作ってる時は全く問題にならなかったことが、読み取ったマークシート画像に直接 〇 や × 、個々の設問の得点や配点、合計点等を入力して返却するように処理系全体を改良したら、これまで思っても見なかったことが重大な問題となってきたのだ。

次の画像を見れば、それは一目瞭然。

マークシートがほとんど見えない!


マークシートの印刷濃度を薄くしすぎると、スキャナーで読み取る際に枠やマークが本当に薄くなってほとんど見えなくなってしまうのだ。単にマークの有無を読み取るだけなら、■■■ からの距離でマークを切り出して二値化し、白面積を計算しているだけだからマーク以外の画像は真っ白でも何の問題もない(むしろ、それくらいの画像の方がより確実にマークを読み取れる)のだが、スキャンした画像そのものを採点結果通知に再利用するとなると、枠やマークがある程度は「見える・読める」ようにスキャンしなければならない。

マークシートのマークや罫線枠の灰色の濃度を少し濃く(下図を参照)して画像をスキャン、どの程度見えるようになったか、確認してみる。

1段階濃い灰色を指定(実際には Ctrl + A でオブジェクトをすべて選択してから設定)


スキャンして画像を表示してみると・・・

マークは読めるようになったが、罫線枠はまだ見えにくい。


罫線の色をオレンジ色に設定したら、それがとても気に入ってしまったのだが、残念ながらスキャンすると罫線枠はほとんど消えて見えなくなってしまう(マークの有無のみを正確に読み取るという意味では、それは実に理想的なのだが)。ただ、マークシート情報の取得プログラムでは、マークひとつひとつを切り出すために罫線の枠の座標を利用しているから、罫線枠の左上隅と右下隅は座標を取得する場面では確実に見えるようにしておきたい。そこで、罫線枠の左上隅と右下隅だけは線の色を灰色にすることにした。(実は、上の画像はそれがほどこしてある画像)

まず、左上隅を設定。

罫線枠の左上隅の「 部分のみ灰色に変更


同様に、右下隅も設定。

罫線枠の右下隅の 」部分のみ灰色に変更


マークシートの情報を取得する際に、罫線枠が十分よく見えることを確認。

すみっこはよく見える!

5.読み取りプログラムも修正

最終的に実用上問題のないプログラムにするため、思いつく様々なパターンで(誤りを含む)マークを作成し、これをプログラムがどのように判定するか、テストしてみた。

テスト用に、次のマークシートを作成。

動作検証用に作成したマークシート
(スキャンしたら、画像中央やや上に横線が入っていた。原因は不明。)


マークシートのスキャンに使っているスキャナーでスキャンすると、時々、黒い線の入ったJpeg画像が生成される。しかも、この黒い線はマークの読み取り判定になぜか?影響を与えない。

上半分だけ塗りつぶしたマークでも、正しく「4」と読んでいる。
複数マークありと判定されないのはなぜ?


この不思議な現象の原因はまったくわからないが、判定に影響を与えないから、これまでは(まぁいいか)としてきたが・・・。

いずれにしろ、このマークシートを使って動作検証を行った結果、先に記した判定プログラムでは対応できない問題が複数あることが判明。検証をくり返し実行して、一つ一つの問題に対応。最終的に完成したのが次のコード。

  //選択肢の始まりは「ゼロ」(1の位を基準)
  if (Copy(strMS_Type,10,2)='19') and (chk_MultipleMarks.Checked) then
  begin
    //strAnsList[intSG_k]の文字数を調査
    strCount:=ElementToCharLen(strAnsList[intSG_k],Length(strAnsList[intSG_k]));

    //チェック内容は、以下の通り
    {
    文字数が2文字の場合、末尾の1文字を取得する
    10 -> 0
    11 -> 1
    19 -> 9
    末尾1文字がマークした選択肢の番号になる

    文字数が5文字の場合、
     1 10 -> 2文字目が1、末尾2文字が10 -> 10
     2 11 -> 2文字目が2、末尾2文字が11 -> 21
     3 12 -> 2文字目が3、末尾2文字が12 -> 32
    (2文字目×10)+(末尾2文字 - 10)がマークした選択肢の番号になる
    }

    case strCount of
      1:begin
        if StrToInt(strAnsList[intSG_k])<10 then
        begin
          StringGrid1.Cells[intSG_Col,intSG_Row]:='100';
        end;
      end;
      2:begin
        //2文字の場合は、末尾1文字が選択した選択肢の番号
        StringGrid1.Cells[intSG_Col,intSG_Row]:=RightStr(strAnsList[intSG_k],1);
      end;
      3:begin
        //空欄と判定された場合
        if strAnsList[intSG_k]='999' then
        begin
          StringGrid1.Cells[intSG_Col,intSG_Row]:=strAnsList[intSG_k];
        end;
        //3文字と判定された場合、十の位の1~9のダブルマークの場合、
        //2文字目は必ず半角の空欄になる
        if Copy(strAnsList[intSG_k],2,1)=' ' then
        begin
          StringGrid1.Cells[intSG_Col,intSG_Row]:='999';
        end;
      end;
      5:begin
        //文字列の置き換え(先頭2文字を抽出&半角スペースを削除する)
        strData:=StringReplace(Copy(strAnsList[intSG_k],1,2),
          ' ', '', [rfReplaceAll, rfIgnoreCase]);
        //Case 5で先頭2文字が10である場合はダブル以上のマークあり
        if StrToInt(strData) > 9 then
        begin
          StringGrid1.Cells[intSG_Col,intSG_Row]:='999';
        end else begin
          //2文字目が半角スペースでなければ処理可能
          if Copy(strAnsList[intSG_k],2,1)=' ' then
          begin
            StringGrid1.Cells[intSG_Col,intSG_Row]:='999';
          end else begin
            //(2文字目×10)+(末尾2文字 - 10)がマークした選択肢の番号
            StringGrid1.Cells[intSG_Col,intSG_Row]:=IntToStr(
              (StrToInt(Copy(strAnsList[intSG_k],2,1)) * 10) +
              (StrToInt(RightStr(strAnsList[intSG_k],2))) - 10);
          end;
        end;
      end;
      6..99:begin
        StringGrid1.Cells[intSG_Col,intSG_Row]:='999';
      end;
    end;
  end else begin

    //複数選択を許可しないマークシートの処理

  end;

end;


ここでいちばん困ったのは、必要以上にマークされていた場合の処理。

複数マークを容認しないプログラムなら、1行について2個以上マークされていた場合は「複数マークあり」を意味するフラグとして「99」、マークなしの場合(=空欄)は「999」というフラグを用意して対応したが、今回のように複数マークを許可し、読み取り結果を 0 – 99 の100分類で表示する場合、空欄すなわち「マークなし」を「999」と表示するのは同じでよいとしても、十の位や一の位のマーク欄それぞれに2つ、ないし、3つ以上マークされていた場合のフラグをどうしたらいいのか? 最適と思われる答えが見つからずにかなり悩んだ。

出来れば、既存かつ(プログラムによっては)数年をかけて動作検証済みの、読み取り結果のチェックプログラムや、採点結果通知のプログラムを修正せずに、それらをこの複数マーク対応採点システムにもそのまま適用できるように、処理の流れを作りたい。

必要数以上のマークがあった場合、当初、選択肢としては決して使うことのない「000」、「100」、「XXX」等をフラグとして利用することも、かなり真剣に考えたが、これらのフラグを新規に採用した場合、これまでに書いてきたマークシートの読み取り結果を記録したCSVファイルを利用して動作するプログラムをことごとく修正しなければならない。そして、それは新しいバグを生むことに、間違いなく直結する。それだけは、どうしても避けたい。

この際、読み取りエラーをすべて「999」で処理すれば、これまでの経験から、読み取り結果のチェックプログラムは確実に「空欄」=「999」位置を教えてくれるし、もし、それが本当に「空欄」である場合は、人が見ればそれは一目瞭然、もし、それが空欄でない場合は、それを見た「人」に、マークの有無 or 空欄 or その他複数マークの判断を委ねればいい。そしてもし、「人」が見て、マークが正しければプログラムの判定結果を正しく修正、そうでなく、マークが「空欄でない」・「必要数以上にマークされていた」場合は、そのまま「空欄として処理(999)」してもらえば、採点結果には一切影響を与えないはずだ。

そう考えて、「トリプル以上のマークあり」をユーザーに伝えるフラグは用意せず、10の位に1つ、1の位に1つ以外のマークがあった場合はすべて同一に「空欄」フラグの「999」で処理することにした。

6.発見した問題点と解決策

上記動作検証用に作成したマークシートで、実際に動作確認を行った結果、ひとつだけ気になった点があった。それは、マークから横にはみ出て(横に広く)マークされると「トリプル以上のマークあり」という判定が出やすいこと。

「12」を読み取れていない。
おそらく1の位の「2」の横棒マークが「1」の領域に侵入している?


マークとマークの間隔を、これ以上広くするのはさすがに困難。1行19選択肢+行番号というマークシートの形式そのものに起因する問題だから、これは試験の問題用紙の表紙に図付きで「横棒型の塗りつぶし禁止」&「なるべく横に広がらないようマークする」注意を載せて、読み取り不能の「999」判定がなるべく出ないようにすることくらいしか、対策を思いつかなかった。

(縦に長い四角形、もしくは縦型の [ ] で数字を囲うことも考えたが、実際には試していない)

Python環境を利用しない場合は、判定領域をマークの中心付近のみに狭めることで読み取り精度を上げることができる。ただし、読み取り速度はかなり遅くなってしまう。

Python環境を使わず、判定領域を70→50に狭くして、実行すれば上の読み取りエラーは解消できる。


マークシートリーダー作成の初期、まだPython環境を利用できなかった頃、少しでも読み取り速度を早くできないかといろいろ考え、二値化後の白面積の計算領域をマークの中心付近のみとすれば、読み取り速度を向上させることができるのではないかと思って作った機能が思わぬところで役に立った!

以上が、解決策とは言えない対策と、読み取り速度を気にしなければ使える解決策。

7.まとめにならないまとめ

(1)実用的ではないかもしれないが、大語群(100選択肢)に対応したマークシートリーダー完成
(2)Excel を使って1行19選択肢で25行4列、A4横置きのマークシートを作成して試験を実施
(3)採点結果通知は付属の ReportCard.exe で作成

ReportCard.exe の使い方は、下のリンク先をご参照ください。

上のリンク先から、デジタル採点プログラム一式をダウンロードできます。
同梱した採点結果通知作成用のプログラム


ただし、選択可能な採点オプションの指定は「採点のみ」or「採点と配点」のみとなります。観点別評価の区分を含めて表示するオプションを選択しても、プログラムは複数マークに対応していないバージョンの流用なので、正解マークの位置を正しく表示できません。少なくても観点別評価の区分は表示できるよう、今後プログラムを改良する予定です。

追記 改良したプログラムに更新しました(20240625)

複数選択可能なマークシートについても、観点別評価の区分や正解マークの位置を表示できるよう、プログラムを更新しました。

採点及び観点別評価の区分と、不正解の設問について正解マークの位置を表示
得点は返却用シートの右下に表示

この大語群専用マークシートを使用した試験の実施方法と、試験後の処理方法について次回の記事で詳細を説明する予定です。よろしければ、そちらの記事もご参照ください。

この記事で作成方法を説明した大語群専用マークシートを利用した試験の実施方法です。


マークを塗りつぶす時間を少しでも短縮できないかと考え、作成したマークシートもあります。

「線を引く」方式でマークする、複数マーク対応型のシートをダウンロードできます。

8.プログラムのダウンロード

大語群に対応したマークシートリーダーは、下のリンク先からダウンロードできます。
なお、バックグラウンドでPython環境を利用し、より高速にマークの読み取り処理を実行するには、別途Python環境の組み込みが必要です。動作に必要なライブラリをインストール済みのPython環境は、当Blogの過去記事へのリンクからダウンロードできます。

Python環境の組み込みはカンタンです。ダウンロードしたZipファイルをダウンロードした後、任意のフォルダに展開、生成されたPython39-32フォルダを、そのまま MS_Reader.exe があるフォルダにコピーするだけです。

こちらの大語群に対応したマークシートリーダーは、当ブログの過去記事に掲載した複数選択不可のマークシートリーダーを、複数選択を前提として設計された専用マークシートのマーク読み取りに特化させたバージョン、いわば派生版です。複数選択不可のマークシートの読み取りにも使用できると思いますがテストは行っておりませんので、ダウンロードしたZipファイルを展開して出来る MS_Reader.exe は複数選択可能なマークシートの読み取り処理専用にお使いください。

この記事で紹介した通り、様々な要因から、マークシートによっては(基本設計が同じであっても)マークを正しく読み取れないことがあります。
また、添付した動作検証済みのマークシートをご利用いただいた場合でも、その印刷方法(輪転機使用等でマークが濃く印刷されていた場合)によっては、マークを正しく読み取れないことがあります。印刷用紙は再生コピー用紙で十分ですが、印刷には 必ずインクジェットプリンタを使用 し、スキャンしたJpeg画像において、マークや罫線枠がうっすらと判別できる程度の濃さで印刷していただく必要があります。

以下、読み取りテスト実行時の環境です。

・A4用紙は、(白くない)再生コピー用紙
・スキャナーは有名メーカー製複合機のスキャナー(カラー/読み取り解像度200 dpi)
・PCはPanasonic製Let’s Note CF-QV
・スキャンした画像をこのプログラム用に変換する際の倍率は80%を指定

「1 画像変換」をクリックすると表示されるサブメニューの「専用画像を作成」をクリック
倍率は80%を指定


上記の環境で、筆者がテストした結果を記事としてここに掲載しました。発見した不具合も正直に書きましたが、筆者が発見していない不具合が他にまだあるかもしれません。ですので、ダウンロードしたマークシートリーダーのご使用はあくまでも自己責任でお願いします。

また、派生版であるため、プログラムには Excel Book に読み取り結果を出力する機能がありますが、大語群に対応した採点結果通知作成用の Excel ファイルは、Zipファイルを展開後、 eFile フォルダ内にあるテンプレートから生成できる Excel ファイルをマクロ有効な Excel Book として保存し、これを元にご自身で作成していただく必要があります。※ Zip ファイルに添付した Excel Book は、大語群マークシートに対応しておりません。

採点結果通知が必要な場合、Zipファイルに同梱した ReportCard.exe をお試しください。こちらは「まとめ」で紹介した通り、動作検証済みです。同梱の ReportCard.exe は選択肢が「ゼロ」から始まる教科「情報」用のマークシートにも対応したものです。

※ お使いのPC環境により、Python Engine の初期化に異様に時間がかかったり、おまけの機能である成績一覧表作成時に、罫線位置が誤って描画される不具合があります。罫線の描画に問題がある場合は、罫線機能をOFFにしてデータのみを出力してください。こちらの成績一覧表はメモ程度にお使いください。

なお、大語群対応のマークシートリーダーでは、マークの読み取り結果を最後に一括して表示する速度優先モード及び読み取り結果の音声読み上げ機能は使用できません。

本記事で紹介したマークシートを同梱しました。Zipファイル展開後に作成されるSample_MarkSheet フォルダ内にある R25C04D19.xlsx をご参照ください。

塗りつぶし形式のマークシートは、解答に時間がかかります!
「塗りつぶさないマークシート」の改良版が上記リンク先からダウンロードできます。こちらもあわせてお試しください。

次のリンク先からマークの高速読み取りを可能にする Python 環境を含めたデジタル採点プログラム一式をダウンロードできます。マークシートも、ここに紹介した形式の他、様々なタイプのものを同梱しています。



この記事で紹介した100選択肢対応マークシートリーダーの使い方は、次の記事をご参照ください。

9.お願いとお断り

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

PowerShellに文字列を送信

職場の同僚に頼まれて、任意に選択したフォルダへのアクセスを禁止したり、それを解除したりするスクリプトを書いた。PowerShellの使い方を勉強すれば、フォルダの属性の変更は容易に実行できるから、セキュリティ対策として不完全なことは百も承知。

ダブルクリックでは開けないフォルダを作っただけなんだけれど、起動したPowerShellのWindowを閉じる部分でちょっとハマったので、備忘録として、ここにメモ。

フォルダへのアクセスを禁止したり、それを解除したりするスクリプトは、使い方を誤るとたいへんなコトになる(と思う)ので、ここには書かない。

【もくじ】

1.PowerShellを起動してスクリプトを実行
2.PowerShellのWindowを閉じる
3.PowerShellのWindowを表示しない方法もある
4.まとめ
5.お願いとお断り

1.PowerShellを起動してスクリプトを実行

PowerShellで実行したいスクリプトを用意する。ここではそれを「SetDeny.ps1」とした。Formに用意したEditコントロールのキャプションを次のように指定する。

コントロールの名前は、EditDeny


スクリプトを実行するButtonを1つ用意。名前をButtonDeny、Captionを「変更」として、次の手続きを作成。

procedure TForm1.ButtonDenyClick(Sender: TObject);
var
  strExeFile: string;
  strParams: string;
begin
  strExeFile:= 'PowerShell.exe';
  strParams:= '-noExit  -ExecutionPolicy RemoteSigned '+EditDeny.Text;
  ShellExecute(0, '', PChar(strExeFile), PChar(strParams), nil, SW_SHOWNORMAL);
end;

実行(F9)すると・・・

何も表示されないが、Editコントロールに指定したスクリプトが実行される。


スクリプトを実行したら、PowerShellのWindowも閉じたい。

2.PowerShellのWindowを閉じる

そこで上の手続きを次のように変更。

procedure TForm1.ButtonDenyClick(Sender: TObject);
var
  strExeFile: string;
  strParams: string;
  hWndInstance: HWND;
begin
  strExeFile:= 'PowerShell.exe';
  strParams:= '-noExit  -ExecutionPolicy RemoteSigned '+EditDeny.Text;
  //ShellExecute(0, '', PChar(strExeFile), PChar(strParams), nil, SW_SHOWNORMAL);
  hWndInstance:= ShellExecute(0, '', PChar(strExeFile), PChar(strParams), nil, SW_SHOWNORMAL);
  //Windowの存在を確認
  if hWndInstance > 32 then
  begin
    //タイムラグを設定
    //Sleep(100);  //短すぎる
    Sleep(1000);  //My環境では500でも動作した
    //ExitとEnterキーの入力を送信
    ButtonExitClick(Sender);
  end;
end;

PowerShellのWindowを閉じる部分は、別の手続きにしないと期待した通りに動作しなかった・・・。
なんでかな?

「閉じる」ボタンの名前は、ButtonExit に設定。
procedure TForm1.ButtonExitClick(Sender: TObject);
var
  hWndPSWindow: HWND;
begin
  //PowerShellを閉じる
  hWndPSWindow:=FindWindow(nil, PChar('Windows PowerShell'));
  if hWndPSWindow <> 0 then
  begin
    SetForegroundWindow(hWndPSWindow);
    //文字列の送信
    SendKeys('Exit');
    //Enterキーの送信
    SendKeys(#13#10);
  end else begin
    ShowMessage('PowerShellのウィンドウが見つかりません!');
  end;
end;

あと、最初は、PowerShellのWindowを探す部分を次のように書いていたんだけれど、これではWindowが見つからなくて・・・

hWndPSWindow:=FindWindow(nil, PChar('PowerShell'));
メッセージのキャプションのCfaは実行ファイルの名前。
名前は「Change Folder Attributes」から作成。


よく見たらPowerShellのタイトルは Windows PowerShell だった・・・

hWndPSWindow:=FindWindow(nil, PChar('Windows PowerShell'));

3.PowerShellのWindowを表示しない方法もある

自分的には「画面に何か動きがあった」方が安心できる気がしたので、PowerShellを表示する方法を選んだが、もちろん表示しない方法もある。いつもお世話になるMr.XRAYさんのWebページに詳しい解説がある。

Delphi で Windows PowerShell を使用する

http://mrxray.on.coocan.jp/Delphi/Others/PowerShellDelphi.htm#09

4.まとめ

(1)PowerShellに文字列を送信するには、FindWindow関数を使う。
(2)手続き的には、SetForegroundWindow関数とSendKeysを組み合わせて実行。
(3)PowerShellを閉じるタイミングはSleep関数で調整。

5.お願いとお断り

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