投稿者「サイト管理者」のアーカイブ

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

高等学校における現行の教育課程では、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.お願いとお断り

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

TSL205の修理

スノーシューの先端の、登山靴の爪先を固定する部品が、前回の山行で壊れてしまった。

雪の上に落ちた欠片を拾ってきた。
僕に修理できるとは思えなかったけれど、壊れた欠片を山のゴミにするわけにはいかない・・・


写真では3つに分断されたように見えるが、実は右側の部品のベルトを通す部分も破損していて、実際は4つに分断される形で壊れている。

この部品がなくても、靴の爪先を固定するベルトはまったく問題なく機能するので、ベルトと靴の摩擦で靴が早く傷むかな?・・・という心配以外には、何の問題もないような気もしたが。

Web 上に、このことに関して何か情報はないか・・・と検索してみると、これと同じ「部品が破損して、要修理状態ですが・・・」という但し書き付きで販売されている中古の TSL205 を複数発見。

プラスチック樹脂の経年劣化で、遅かれ、早かれ、この問題は必ず生じるのだろうな・・・ と、ひとり納得する。

「最新型に買い替える」という手も、もちろん「あり」だが、新品の価格は2万数千円。毎日使用するならともかく、年に数回使うか、どうか、という「遊び道具(ただし、命にかかわる)」に、今、それだけのお金を支払う気にはなれない。

壊れたのは右足側だけで、左足側は現在のところ、何の問題もないし・・・

それに、いろんな最新型を見てみると、登山靴の固定方法がより一層便利な方向へ、一見「進化」しているように見えるのだけれど、( もし、山で、壊れたら・・・ 現場でリペアできる? )みたいな視点で考えると、ちょっと怖くなるような商品が大多数・・・。

山の先輩から教わった、いちばん、信頼できる靴の固定方法は「ベルトで締め付ける」タイプ。
単純で、簡単で、万一、不具合が生じても、現場で修理できるカタチがベスト。

このいちばん単純な固定方法であっても、氷点下の環境で、締め付けベルトが凍結&結氷し、手指もかじかんで、自由な運動がままならない状況では、脱着にとてつもなく苦労したりするのだ。

これは実際に、僕が雪山で経験して得た教訓。だから、いい加減な妥協は、絶対にできない。
何かを結ぶ・固定するには「紐・ベルトがいちばん良い」という先輩の言葉を、僕は心から信じる。

修理と言うか、とりあえず、(上の写真の通り)壊れた部品だけ外して、登山靴の爪先を固定するベルトはまったく問題なく使用できるから、そのまま使おうか・・・と思っていたら・・・

スノーシューが壊れた山行を共にした、僕の大好きな先輩が・・・ 後日、やってきて・・・

これは、壊れていない方(左足用)のスノーシュー


手持ちの細引きと100均で購入してきたベルトで、壊れた僕のスノーシューを修理してくださった・・・。

「あのスノーシュー、ある?」って、先輩が言うから、「はい、あります。」って返事して、先輩に壊れたスノーシューを手渡したら・・・、その10分後。

先輩は、細引きとベルトでプラスチック樹脂の代替部品を作成


まるで、これがオリジナル状態かと思うほどの出来栄え・・・


実際に登山靴を装着してみたところ・・・

純正より、先輩が修理した右足側の方が・・・ 精悍に感じる!


先輩曰く。

「ベルトと紐が、いちばん確実なんだ。」

「擦り切れたら、予備と交換するだけで、直る。」

「だから・・・ これは、もう片方が壊れた時の分な・・・」

そう言って、予備の細引きとベルトを、僕に渡してくださった・・・。

さらに・・・

「 電車にも乗るし、ザックにそのまま付けて歩くわけにもいかんだろう 」と・・・

先輩は、スノーシューを入れるバッグとザックに付けるためのベルトも用意してくださっていた・・・


ただ、ただ、先輩に感謝。

うれしくて、雪山へは、この状態のまま持って行った!


先輩から借りたトレッキングポール2本を左手に束ねて持ち、右手にスノーシューを入れたバッグをぶら下げて、ザックを背負い、嬉々として僕は先輩と雪山へ・・・。

その日、山はフカフカの新雪に覆われていた。
はるかに北アルプスを望む

思えば・・・

もう、何十年も登っていなかった山に、「 一緒に登ろう! 」って誘ってくれたのも、この人だった。

「お近づきのしるしに・・・」って、先輩がプレゼントしてくれたメスティンで、先輩からもらったパエリアのもとを入れ、ドキドキしながら庭で炊飯して食べたごはんは、涙がこぼれるほど美味しかった。

「時間を見て、引き返せ」ってアドバイスをもらった山行では、日本でいちばん高い山を間近に見て大興奮。あまりのうれしさに時の経過を忘れ、無理な登山を強行。登頂は果たしたものの、下山途中でグリコーゲンが尽き、たどり着いたテント場では疲労困憊のため、空腹であるにも関わらず、食事すら摂れない状態に・・・。そのことを帰りの電車から先輩に報告したメールは、僕の山行の復活の証だ。

いつも、こんな僕のことを、先輩は気にかけてくれて・・・

ネットで「 これは、きみのアイゼン 」って勝手に決めて、勝手に購入して、本当は高価な良い品を格安で譲ってくれたり・・・

僕がルートを間違えた時も、いち早く、その誤りに気づき、谷底に降りてはいけない理由や、その怖さを実地に諭し・・・、雪に覆われた川の渡り方を、僕に教え・・・「こっちだ。」って、先輩の言う通りの方向へ進んで正しいルートに戻れたことも・・・。

また、ある山行では、悪化する天候を予測。登頂を断念して引き返す「勇気」の大切さを、教わり・・・

山での食事の際は、いつも食後の紅茶やコーヒーを皆に。

そして、ここ、いちばんのシーンで、ザイルを肩に断崖に立つ、その姿は・・・ 数万の敵を睥睨して一歩も引かない、古代ローマの戦士のようにも、見えた・・・。

スノーシューの修理後、テストを兼ねて先輩と登った山のひとコマ


修理したスノーシューは、実際に、20~30cmほどの新雪に覆われたこの雪山で丸一日使用。修理してくれた先輩と一緒に、標高差約1000mを登って降りた。行動中に、締め付けベルトはもちろん凍結し(左右とも)、山頂で昼食を作る際の脱着にはそれなりに苦労したが、行動そのものには「何の問題もなし」。

登山前日に降ったばかりのフカフカの新雪で、スノーシューを履いていても一歩踏み出すごとに足が数十センチは雪に潜り、スノーシュー無しで一緒に登ったアイゼン組のメンバーからは「二度と行きたくない山ナンバーワン(もちろん冗談。それくらいキツかったということ?)」との感想も出た中で、僕は筋肉痛すら出ず(先輩に勧められて食べたサラダチキンの効用も多分にあり?)。もちろん、先輩の修理により、見事復活した My スノーシューは、終日、外れる気配すらなし。

左右のトレッキングポールの刺さり方から、フカフカの新雪であることが伝わるでしょうか?
先輩が貸してくれたトレッキングポールのバスケットは、もちろん雪山用の大きいタイプ。


もちろん、締め付けベルトは、カチン・コチンに凍った・・・ が、1日で登って降りるというハードな山行をスノーシューはしっかりサポート。

今や、積雪期の山行に、なくてはならないアイテムとなった My Snowshoe.

ただ、ひとつだけ、妙に気になったことがあって・・・。

僕らのクライミング・リーダーである先輩は、なんと・・・

スノーシューを持ってない!

僕にスノーシューの購入を勧め、破損した際には、こんなにも素晴らしい修理を施してくれた先輩は、なぜ、スノーシューを履かないのか?

先輩曰く。

オレ、一度も履いたことない。

そこだけは、謎。

謎だが、そこがまた、先輩の不思議な魅力であることに、間違いはなく。

破損した TSL205 の修理で困っていらっしゃる方に、この記事が少しでも参考になれば、それは何よりの喜びです。*(^_^)*♪

【お願いとお断り】

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。特に、登山用品は自らの命を預けるものです。そのメンテナンスについては、自己の責任のもと、常に万全を期す必要があります。今回、記載した記事は、自己流の修理を奨励・推奨するものでは決してありません。

【追記】

登攀する際は、TSL205 の後部のロック(留め金)を解除して、太い針金状の部品(ヒールリフターと言うらしい)を前に倒し、かかとの位置を上げることで、足の負担がかなり軽減される。

ヒールリフターを使用しない状態
ヒールリフターを使用する状態


いちばん最初にヒールリフターを試した際は、ストッパーでしっかり固定された可動部のあまりの固さに、この太い針金状の金属部品を無理して持ち上げるとスノーシューが壊れるのではないか? と、かなり心配したが、慎重にゆっくり持ち上げれば大丈夫のようだ。

実際、登りでヒールリフターを使用してみたが、使用していない状態よりも、使用した方がはるかにラクに登れた。ご参考まで。

雪と、空と、僕と・・・

心に決めたタイムリミットまで、あと 20 分。
胸に思い描いた、ピークが ・・・ ついに、見えた。

画面中央やや右に見える丘が目標のピーク。

その右上の白点は「月」


そうだ・・・。あの頂きに立ちたくて、僕はここへ来た。
高度にして・・・あと 100 m ・・・ と、ほんの少し・・・ の ・・・ はずだ。

高度計の示度は、2300 m。


補正していないから、正確な値ではないが、真の高度に近い値であることは間違いない。

天候は 快晴。
風力 0。

リミットと決めた時まで・・・残り 20 分。

残り 20 分の距離にしては、少しだけ、遠すぎる・・・気がする・・・
せめて・・・、あと、30分。 あれば・・・

ただ、幸いなことに足元の雪は・・・ 固く、しまっている。
スノーシューの力を借りれば、潜るようなことはない。

スノーシューは、TSL 205。 僕の誕生日と同じナンバー。


おそらく、この辺りが森林限界だ。
もしかしたら、視界が広がったことで、そう感じるのかも・・・ しれないが。

今は、とにかく、前へ、前へ、進む。
トレッキングポールの有難みを痛いほど、感じる。
普段、平地を歩くときは、その必要性など感じないが、斜面を登攀する際は、いつの間にか、なくてはならないアイテムになったトレッキングポール。

今日使っているのは、先輩に借りた雪面用のバスケットの大きいタイプだ。
だから体重をかけても、ポールが雪面に潜るようなことはない。

腕の力を使い、言わば 4WD 的に運動できることで、足の負担は相当に軽くなる。

ザック サク・・・

ザック サク・・・

ザック サク・・・

雪を踏みしめる音と、トレッキングポールが雪面を刺す音が交互に響く。

登りが少し急になり、呼吸も次第に荒くなる。
歩幅を小さくして、低い階段を登るように細目にステップを切って進む。
汗が目に沁みて痛い。

( この斜面を越えたら、ザックからタオルを出そう )

そう思いながら登ること、しばし。
ようやく、急な斜面を超える。

ザックを雪面に降ろし、上部のポケットからタオルを取り出して、汗を拭く。
背に心地よい冷気を感じるが、それはザックを降ろした今、この瞬間だけだろう・・・。

気温はわからないが、ザックのハーネスに付けたペットボトルの水の冷たさがそれを教えてくれる。
滴り落ちる汗に反して、それは凍り付くように冷たい。

荒くなった呼吸を、少しでも落ちつけたくて、
乾きを感じないままに、ひとくち、ふたくち、みくち、
ペットボトルを傾ける。

時計を見る。
心に決めたリミットまで、まだ数分ある。

自らに、問う。
行くか、戻るか ・・・を。

今、ここにいるのは僕だけだ。

歩みを止めた瞬間に、感じた ・・・ 恐ろしいくらいの「静寂」
雪が、空が、すべての音を吸い込んで ・・・ 聴覚から得られるものが・・・ 何もない。

遥かに、北アルプスが見える・・・
初めて、白馬岳の山頂に立った時、僕はまだ・・・16歳だった・・・


ほんとうに、なにも・・・ なんにも、聴こえない・・・。
風がないと、山はこんなにも・・・ 恐ろしいくらいに、静か・・・ なのか。

振り返り、もう一度、目指すピークを見る。
雪と、空と、僕と・・・

ピーク。

そうだ。胸に、思い描いた、約束の場所。

( ・・・ )

周囲の木立には、吹雪の爪痕が残されている。
昨夜の風の形、そのままに。

大丈夫。
この先、数時間、天候の急変は まず ない。

ならば・・・

( 行くぞ! )

決心した 僕は・・・
ピークへの新しい一歩を、踏み出した。


時計が、心に決めたリミットを告げている・・・
でも、ピークは、もう、すぐそこ だ。

( 登ったら、日没までに、高低差約 1000m を駆け降りる力だけ残っていれば、大丈夫。)

そう思いつつ、足元を見ると・・・
右足に履いたスノーシューの、靴のつま先を覆うプラスチック部品が壊れている。
だがスノーシューの結束バンドは、靴の爪先をしっかり捉えている。

( 大丈夫。外れはしない。 )

そう自分に言い聞かせ、雪を踏みしめる。

あぁ・・・ 遠く、月だけが、僕を見てる・・・
あと、もう少し・・・だ。

ここまで登っても、月まではまだ 38 万kmもある。しかし、頂きまでは・・・ あと10mだ。

約束の場所からは、美しすぎる風景が、見えた・・・

「あれが白馬だ」
そう、思うと、もう言葉が・・・何も、出てこない。


見える全てを、胸に刻む。

ただ・・・

登ったら、降りなきゃいけない。
それが、山との約束だ。

登ったら・・・ その想いを噛みしめている時間は、いつも、余りにも、短い。

陽は傾き、すでに時は心に決めたリミットを過ぎている。
これから日没までに 1000m 以上、降りねばならない。

( もう少しだけ、ここにいたい・・・ )

それが、ほんとうの気持ちだが・・・。
でも、もう時間がない。

もし、日が暮れたら、ライトはあっても、樹々に結ばれたルートを示すピンクのリボンを見つけるのは至難の業だ。どんなことがあっても、自己責任で日没までに人の住む世界へ降りなければならない。

ピークを示す山頂標識の上に誰かが置いたスノーマンに無言で別れを告げ、
僕は下りの一歩を踏み出した。

遥かなる麓へ・・・。

刻々と迫る日没。麓を見れば、山陰の雪は、白銀から、うす青く、その暗さを増しつつある。
気持ちは駆けているのだが、斜面を転がるように下降する僕は、実際はどう見えただろう・・・?

正直、下りが苦手だ。
これまでの山行で、そのことを嫌と言うほど思い知らされるシーンが何度もあった。

バレーボールなど、足の屈伸を繰り返すスポーツを過去に経験した人は、鍛えに鍛えたその足のバネを生かして、ほんとうに軽やかに、まるで舞うように山を駆け降りて行く。

( 無理だ。追いつけない。待ってくれないか・・・ )

そう感じたことは、1度や2度ではない。実際、下山のタイムリミットが決まっている、つまり、帰りの電車やバスの発車時刻が「絶対に遅れてはならないリミット」として下山予定時刻に組み込まれている場合など、心底、泣きたい気持ちを味わった山行もあった。

ただ、この差は普通のトレーニングでは、そう簡単には埋まらない。
なので僕は、グループで登山した際の下山では、いちばん遅い人の後ろを定位置に決めて、グループの最後尾を歩き、自分だけが遅れて全体の行動に迷惑をかけることがないようにしてきた。

でも、今日の山行は違う。
日没を別にすれば、下山予定時刻のリミットもない。

このように、気持ちがラクだったせいなのかもしれないが・・・。
今日の僕は、降りに、降りた。

日没が近くなり、気温がさらに下がったためか、木陰に入ると染み入るような寒さを感じる。
若干、風も出てきたようだ。正面から吹いてくるから・・・ 風向きは、西風か・・・

雪面はさらに固く締まり、スノーシューから登りの時とは明らかに異なる感覚が伝わってくる。それは「ザクッ」ではなく、「バリッ」・・・と、その表面を割るような感覚だ。

一方、日当たりのよい場所は雪がクサっていて、スノーシューが大きく滑り、何度か転んで雪まみれになる・・・ が、しかし、樹林帯の中などでは雪は程よく締まっていて、そのような心配はなく、いつもなら躊躇するような急斜面も、そのすべてをジャンプを繰り返すようにして通過する。

それは、正直、降りたって言うより、
落ちたって感覚だった・・・。

そして、麓に日没が訪れた頃・・・

空はまだ明るいが、地上には夕闇が迫る


僕は、目標地点まで、無事、下山することができた。

「人は、なぜ、山に登るのか・・・?」

ふと、そう思い、答えを探せなくて、苦笑する。

答えは・・・、人、それぞれでいい。

僕に限って言えば、答えはなくても いい。


振り返れば、空に、その白さを増した月が見えた。

月が微笑んでくれた、気がした・・・

38 万km 彼方で。

僕は、いつまでも、きっと・・・ この景色を忘れないだろう・・・。

もしか、したら・・・

それが、僕が山に登る「理由」かな?

僕に限って言えば・・・

うん・・・。

答えなど、なくても いい のだ けれど・・・

僕を包む、すべてに・・・

そう・・・。

答えなど・・・

何一つ、なくても いい のだ けれど・・・。

10より大きいマークを使うマークシートの作り方

以前、文書作成ソフト( Word )や表計算ソフト( Excel )を使用してオリジナルのマークシートを作成する我流も我流、はたしてこんなんでいいのか? まぁ、実際に使えるから、いいか・・・みたいな記事をいくつか書いた。

書いた本人が言うのだから間違いないであろう、過去のいい加減な記事の数々・・・


で、今回はナニをしたかと言うと、数学用マークシート処理プログラムの改良版を作成するにあたり、マークシートそのものも改良(と本人は思っている)し、プログラムもあらかたできた(と本人は思っている)ので、「実際の試験でテストしたいなー」と思ったわけですが・・・

「実際の試験でテストする」って言い方もヘンですが。

「実際のテストで試験する」って言っても、やっぱりヘンですが。

・逆もまた真なり? どっちもヘン

 まぁ、なんでもイイです。

いきなり数学の先生に「試しに使ってみてください」というお願いをするのもナンだし・・・

万一どころか、使って初めて気づく
バグ満載のプログラムであることは「間違いない」自信だけはあり・・・

( なら 自分で、こっそり )

プログラムのテストを決行することに決めました。


決めたのはいいんですが、使用するマークシートが問題で、数学用途のシートは個人的な問題から使用できないため、マークが「 -(マイナス記号)から始まり、dで終わる」数学用のシートではなく、それと見た目が同じ(大問番号や設問記号及び枠の大きさが同じ)で、ただマークのみ「1から始まり16で終わる」カタチに変更したマークシートを作成し、これで新しい採点処理プログラムをテストしようと思ったワケです。

しかーし、ここで大問題が二つ発生!

大問題その1:
・私の技量では表計算ソフトで、10以上のマークが作成できない!

大問題その2:
・文書作成ソフトで、マークシートを修正する方法を全部忘れた!!

その1は純粋に技術的な問題で、「今後の学び & 創意工夫」により改善が見込めるからまだイイとしても、あろうことか、その2は青天の霹靂・悲惨の極み・驚天動地・寝耳に水・予期せぬ不意打ち などなど、日本語ではいろいろな表現が可能だが、まぁ最も適切なのは「痛恨の一事」か・・・

なんで全部忘れるの オレ?

ってか、修正方法をもともと知りませんでした☆ ぎゃはは

・・・というわけで、たとえこのように七転八倒と運命づけられた人生であっても、まだあきらめる気がしない(ここにメモしておけば、また忘れても必ず思い出せる & 万一にも同じ志を抱く、どなた様かのお役に立てれば・・・それこそ幸い的な思いもあり)、今回のテーマは「10より大きいマークを使うマークシートの作り方」です。

【もくじ】

1.(私には)表計算ソフトで10以上のマークが作れない!
2.マークの修正方法を全部忘れてることに気づく
3.イチから出直します
4.まとめ
5.お願いとお断り

1.(私には)表計算ソフトで10以上のマークが作れない!

自身が最も多用するのは、1ブロックが 25 行で、1設問あたり8選択肢、合計4ブロックの全 100 問対応の A4 横置き型マークシート。(My 用途では、実はコレでほんとに十分なのですが・・・)

25行、8選択肢、4ブロック、100問対応のマークシート


あれもしたい、これもしたい、みたいな、欲に目が眩んで、というか、思いつくままにマーク読み取りプログラムの機能を拡張したくなり、このシートを元にして作成した発展形の一つである数学用は、1ブロックが 25 行で、1設問あたり 16 選択肢、合計3ブロックの全 75 問対応の A4 横置き型マークシート。1枚で大問3個しか設定できないので、2枚を組み合わせて採点することで大問6個に対応。

選択肢は、-・±・0~9・a・b・c・d の16個(文書作成ソフトで作成)。
実は、マークとマークの間隔が狭いところ等を直したいって、ずっと思っていた。


現在、この数学用マークシートを改良して、B4 縦置きの用紙にB5横置きを縦に2枚並べて印刷し、半分に折りたたんだ状態で試験を実施、シート回収後、マークの読み取りと採点処理を実行できるプログラムを書いている。

この新しいプログラムをテストするにあたり、いろいろ直したかったところが満載だった数学用マークシートそのものも改良したくなり、反省点を元に作成したのがコレ(図は設計時の画面)。

表計算ソフトで作成。選択肢の数は16個で旧版と同じ。

反省点とは何かというと、

(1)マーク読み取り範囲の設定方法がわかりにくかった(と思う)ので、まず、これを改良。

旧版では、マーク読み取り範囲の設定時、利用可能な枠線がなかった!

旧版では、左上の「|」マークを目印に読み取り枠を設定した。

赤枠で囲んだ範囲がマークの読み取り範囲


新版は、枠線があるので、読み取り範囲の設定が少しはラクになった?
同時に、マークの間隔もより広めに設定し、受験者が多少大きめに塗りつぶしても誤判定が出にくく改良(したつもり・・・テストしていないので、現時点では効果のほどは?)。

きちんとした枠線を設け、マークの間隔を広くした!


なので、読み取り範囲の設定は、枠線を利用して実行できるようになった。


(2)1ブロックあたりの行数を 25 → 30 行に増やした。 これで大問1個について、30 設問の設定が可能になった。

ア・カ・サ・タ・ナ・ハ行で1ブロック30行
つまり、大問1個について、30設問を設定可能とした。


(3)旧版の A3 縦( A4 横置き×2)ではなく、B4 縦( B5 横置き×2)へ用紙サイズを変更した。

B4縦にB5のマークシート2枚を配置

A3 サイズのシートも作成してみたのだが、A3 サイズだとインクジェット複合機を利用して印刷(輪転機での印刷はマークの濃度が濃くなり、誤判定が出やすくなることから非推奨・・・というか、ユーザーには禁止と案内している)する時間が B4 サイズのそれより明らかに遅くなる、スキャナーでの読み取り処理にも時間がかかる等、いろいろ問題があり、少々マークの文字は小さくなるが A 版に比べて何かとメリットが多い B 版の用紙を使うことに決定。

もちろん、国際的にはやはり A 版だと思うが、欧米文化圏で My マークシートリーダーが使われるシーンはさすがに想像できない。できないが、今年、いちばんの夢は英語バージョンを作成することだ。これは新年早々に思いつき、数学用シートの処理プログラムが完成したら、今年の次のチャレンジ・イベントはそれだと思っている。

で、話を本題へ。

この表計算ソフトで作成した数学用マークシートのマークを「1」から「16」に変更しようとしたのだが、どうがんばってもそれが出来ない!

実際のシーンを再現。

表計算ソフトを起動して、全行・全列のセルの高さと大きさを適当なサイズに設定し、挿入 ⇨ 図形から楕円を1つ、セル内ちょうどおさまるように描画、このオブジェクトを右クリックして表示されるサブメニューから、「テキストの編集」を選択(クリック)して半角数字で「1」を入力。オブジェクトの色は灰色に設定する。


次にマークのオブジェクトが入っているセルを選択し、オートフィルの機能を使って右へドラッグしてコピーする。

とりあえず16個、コピーした。

ここまでは、実にイイ感じ♪

左から2つめのマークの数字部分をクリックして編集状態にし、半角数字の「2」を入力。


これを3、4、5、・・・、9まで繰り返して、10を作成すると・・・

「9」まではイイ感じだが☆ 10で問題が発生。

おい、ちょっと待て・・・

「0」は「1」の下じゃなくて、「1」の横に表示して欲しいんだけど・・・


しかも、フォーカスを外すと・・・

ヘイ バカターレ!
8、9、1じゃないよー!!

楕円のオブジェクトの幅を変えるわけにはいかないから、フォントサイズを小さくして修正。

ハイ
不採用決定。(T_T)

このまま、あきらめるのはどーしてもイヤだったので、ジタバタしてみることにする。
どーせ、他にすることないし。実はあったかもだけど、したくないし・・・

しばし、沈思黙考

(-_-)zzZ

寝るなー!!

オブジェクトの中に数字を描画するのがイケナイのかと思ひ・・・、楕円オブジェクトは「塗りつぶしなし」に設定して、テキストはセルに直接入力してみる。

半角数字をセルに入力


ちょっと、微妙に違和感がないこともないが、なんとか使えるかな・・・という程度にはなったか?
2桁数字の方が、なんとなく、下がって見える・・・ 色も濃い?(同じ灰色でも面積の関係か?)


試しに、印刷プレビューしてみると・・・

2桁数字のインパクトが強すぎ!!

(塗りつぶし面積も、実用的にはもっと狭い方が好ましい)


こんなマークシートでは、存在感の薄い「1」~「9」にマークするには、余程の勇気が必要です!

ハイ
不採用決定。(T_T)

上の例なんてまだ良いほうで、実際には、もっとイロイロやってみたが、使えないマークシートをひたすら量産する結果に。(元々ない)知恵の限りを尽くしても、状況は改善する兆しすらなく・・・

少なくても現在の私の技能では、表計算ソフトを用いて「実際に使いたいと感じるレベルの品質」を維持した「10以上の数値を表示するマークを作成することは不可能」と悟ったのであった。

2.マークの修正方法を全部忘れてることに気づく

まだ、すべてが終わったわけではない。そうだ。文書作成ソフトを使って再チャレンジする方法が残されている。以前、教科「情報」用のゼロ始まりのマークシートを作ったじゃないか。あの時は特に問題なく、0、1、2、・・・、14、15まで計16個の丸囲み数字を作成できたはずだ。

そう思い、保存してあった教科「情報」用のファイルを開き、それを改良しようとしたのだが・・・

手も、足も、出ないとはこのことか・・・

ヤバイ!

いじれない!!

修正方法、全部、忘れた!!!

・・・ってか、よく考えたら、もともと知らない。
コレ、作り直した方が早くね??? みたいな・・・

3.イチから出直します

既存のファイルはいじれそうにない。・・・となれば、残された道はただひとつ。

白紙状態から全部書く!
それしかない!!

あの日、近所の国道を爆走していた緑色の大型トラックの運転席の後ろに力いっぱい掲げられた看板にも、「イチから出直します!」って、確かに書いてあった。・・・あの時、感動で魂が震えたな・・・

実際、ナニがあったのか、わかりませんが・・・

My ふぇーばりっと Car の運転席から思わず叫んでました☆

運転手さん、がんばって!!

・・・ということで 走召 有名な!あの文書作成ソフトを起動し、新規作成で用紙を「 B5 横置き」に設定。余白は最小値(My環境では 0.3 mm)にする(行数・列数共に詰め込みたくて、この設定にしています。実際のシーンではもう少し余裕マージンを取り、あまり攻めすぎない方が良いと思います)。

「レイアウト」タブをクリックして、「ページ設定」リボンの中の「段組み」アイコンをクリックし、表示されるサブメニューから「3段」を選択する。

とりあえず段組は3段を指定


これだけだと何も表示がなく、段組みの状況がわかりにくいので、再度同じ操作を行い、今度はいちばん下の「段組みの詳細設定」をクリック。

「境界線を引く」をチェックしてOK


画面に境界線が描かれる(最終的に消しますが・・・)。


「タイトル・大問番号・OpenCV用のマーカー画像」を1~3行目に入力。

■■■ はマークシートのマーク位置を決定する指標として利用する


4行目にカーソルを置き、「挿入」タブをクリックして「表」リボンの「表」をクリックして表示される「表の挿入」の枠をドラッグして1行×7列の表を挿入する。

画面はこんな感じ


画面右下の「ズーム」のスライダーを右へドラッグし、画面の拡大率を大きくして・・・


表内の任意の場所をクリックすると表示される「表の移動ハンドル」をクリックすると、表全体が選択されるので、「テーブルレイアウト」タブをクリックして表示される「配置」リボンの「中央揃え」をクリックする。これで表への入力値はすべて中央揃えで表示される。


表の例えば一番右のセルを右クリックして、表示されるサブメニューから「挿入」をクリック、さらに表示されるサブメニューの「右に列を挿入」をクリック。表の列が1つ増えるので、Ctrl+Y を繰り返し実行して表の列数を 17 列にする。

上の操作を1回行ったら、Ctrl+Y で直前の操作を繰り返し実行できる


画面はこんな感じになる。


いちばん左のセルに半角カタカナの「ア」を入力し、左から2番目のセルに丸囲みの1(= ① )を入力する。以降、セルを右へ移動しながら順次丸囲みの数字を 16 まで入力する。

My 環境では、みんな右へ寄った形で表示される・・・


【注意:解答欄の番号・記号について】

「ア」としたのは、自作のマークシートリーダーで使用している数学用シートの流用型として使用するため。数学用とマークシートでは、大問1の ア 、イ 、ウ 、・・・、大問2の ア 、イ 、ウ 、・・・、大問3の ア 、イ 、ウ 、・・・、これで解答用紙 A 面(第1面:1枚目)が終了、続けて B 面(第2面:2枚目)へ移動し、大問4の ア 、イ 、ウ 、・・・、大問5の ア 、イ 、ウ 、・・・、大問6の ア 、イ 、ウ 、・・・ のように設問を設定している。

もちろん、ここは「1」から連番で作成しても構わないのだが、自分的には「2枚1セットで使用する予定の数学用マークシートの採点処理を行う新しいプログラムが、実際の採点現場で正しく動くことを確認する」ために今回は行動しているので、採点プログラムのデータ入力欄との整合性等も考えると、シートの変更点は解答欄のマークのみに留めたかったので、この仕様とした。

ちなみに動作テストを予定している新しい採点処理プログラムの採点データ等の入力画面は、こんな感じ。数学用途の採点の場合、設問の欄は数値の連番ではなく、解答用紙に合わせてカタカナ表記としている。ここが数値の連番だと、正解他のデータが入力しづらい。CMS は「組み合わせ採点」、NPO は「順不同採点」の実施の有無を見分けるフラグ(ここが1ならば順不同採点「有」)として利用する。特に「組み合わせ採点」は、数学用途では必須の機能なので、ここを念入りにテストしたいと考えた。

組み合わせ採点を実施(=CMS列の番号が同じ行)する場合、
配点は組み合わせ採点を実施する範囲内の任意の1行に入力し、他は0を入力。
かつ、組み合わせ採点を実施する範囲の観点別評価の種類は必ず一致させる。

解答を要しない(=使用しない)解答欄を見分けるフラグは「-1」としている。


表の任意のセルを再びクリックし、表の左上に表示される「表の移動ハンドル」をクリックして、表全体を選択。「テーブルレイアウト」タブをクリックして、「配置」リボンの「セルの配置」をクリック、表示される「表のオプション」ダイアログの「既定のセルの余白」の左と右の値を0(ゼロ)に設定して OK 。

この設定方法は、これまで知らなかった!

あれこれ、設定を弄り倒す中で、先日、偶然発見 *(^_^)*♪


表はこうなる。

イイ感じ


次に表の ① ~ ⑯ セルをドラッグして選択し、


「ホーム」タブをクリックし、「段落」リボンの「拡張書式」をクリック、表示されるサブメニューの「文字の拡大/縮小」をクリック、さらに表示されるサブメニューの「66%」をクリックする。


表はこうなる。

さらにイイ感じ

気分は Good! Goooder!! Goooodest!!!

あとは不要な罫線を消し、罫線とフォントの色をごく薄い灰色に設定するのみ。

ここは可能な限り薄い灰色に設定したい


罫線の色を変更するには、表全体を選択して、表中で右クリック。表示されるサブメニューから「表のプロパティ」を選択(クリック)。


表のプロパティが表示されたら、「罫線と網かけ」をクリック。


「色」と「線の太さ」を変更して、「プレビュー」の必要箇所をクリックしてOK。


表はこうなる。


今度は、もう一度表を全選択し、選択範囲内で右クリックして表のプロパティをもう一度表示し、「罫線と網かけ」をクリックして、線の色を「白」に設定、表内の縦罫線を表示しない設定にする。

最後に「ア」のセルのみ選択して、上と同様の操作を実行し、「ア」の右に灰色・ 0.25 ポイントの太さで縦罫線を引く。最終的な画面はこうなる。

コレを作りたかった☆

あとは、この1行を全選択し、選択範囲内で右クリック、表示されたサブメニューの「挿入」をクリックして、さらに表示されるサブメニューの「下に行を挿入」を選択(クリック)。


結果は、こうなる。


追加した行に1行目の内容をコピーしてもよいし、Ctrl+Y で直前の操作を繰り返して必要な行数分、行を作成してもよい。とにかく、行を増やして、そこに1行目のマークを貼り付けて行く。罫線は消えたら消えたで最後にまとめて設定すればよい。

もし、行数が足りない場合は・・・

「ノ」が欲しい とか「ハ行」も欲しい場合がある


Ctrl+A でオブジェクトを全て選択して、選択範囲内で右クリックし、下の図の赤い枠で囲んだ部分のチェックをすべて外して OK をクリックすると、行の高さが小さくなる(はず)。

赤枠内のチェックを全て外す。


次に表のみ、上から下までドラッグ等して選択し、選択範囲内で右クリックして表のプロパティを表示して、「行」タブをクリック。高さを「固定値」として、最適な数値を入力してOKをクリックして行の高さを修正する。

【注意】

理由は定かでないが、この方法で行の高さを「修正できる」場合と、「出来ない」場合があった。


他にも、表のみ全選択するところは同じだが、「テーブルレイアウト」タブの「セルのサイズ」リボンの「高さ」でも同じことができる(こともある?)。


【注意】

理由は定かでないが、やはり、この方法で行の高さを「修正できる」場合と、「出来ない」場合があった。出来ない場合は、Ctrl+Z(元に戻す)で、修正できる場合の直後のところまで戻して実行すると変更が適用された。原因は私にはわからない。

最終的に、1設問あたりの選択肢数は 16 個、1ブロック 30 行、全3ブロックの B5 横置きのマークシートが完成。

このブログ用に作成した参考作品
(実用化するには ■■■ の位置調整が必要)


上の図は、「レイアウト」タブをクリックして、「ページ設定」リボンの「段組み」をクリックして表示されるサブメニューから「段組みの詳細設定」をクリックしてダイアログを表示し、「境界線を引く」のチェックを OFF にした状態の印刷プレビュー。

冷静になって考えると、ヒトはわずかながらでも、進歩し続ける生き物らしい。
以前、出来なかったことが、今は、できるようになった。

きっと、「イチから出直します」トラックの運転手さんのお陰です。

ほんとに、こころから、ありがとう!!

ここには掲載できないけれど、あの日撮った、爆走トラックの写真。

生涯、宝物にします!

4.まとめ

(1)表計算ソフトでは、10 以上の数値を表示するマークの制作は(私には)難しい。
(2)文書作成ソフトなら、比較的簡単に10 以上の数値を表示するマークが(私にも)作成可能。
(3)文書作成ソフトの行の高さの修正は、出来る場合と出来ない場合があった。理由は不明。
(4)イチから出直すことも、より良い人生を歩むためには必要になることがあるカモです☆
(5)大型トラックの看板からは深い学びを得ることがあります。

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.お願いとお断り

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

それは冷蔵庫のドアの警報音じゃなくて・・・

このところ、料理にハマっている・・・。自分の人生で、お湯を沸かす以外の目的でキッチンに立つことは絶対にないだろうと、これまでずっと思っていたし、実際、何十年もその通りだったのだ、けれど。

ふとしたことから、マニュアル通りにやれば、自分にも美味しい料理が作れることに気が付いてしまったのだ・・・。

それは、それですごく良かったことなんだけれど、今日、起きたトラブルは・・・

1.冷蔵庫のドアの汚れに気付く

そんなこんなで休日の昼食は、このところ、自分で作ることが多くなった。そのために調味料もそろえたし、調理用具も買った。まさか、こんな日がくるなんて思いもしなかったのだけれど、もうかれこれ3ヶ月くらい、休日は自分で作る昼食を続けている。

今日、いつもと違ったのは、冷蔵庫のドアが、いつの間にか、結構汚れていることに気が付いてしまったことだ。もちろん、気が付いた以上、掃除をしないわけにはいかない!

冷蔵庫のドアには、閉め忘れを防止するためのセンサーが付いているから、ドアを開けたまま掃除していると、当然、警報音が鳴る。

ピピッ ピピッ

もちろん、警報音は無視。エタノールを含んだウェットティッシュで、ひたすら汚れを落とす。

ピピッ ピピッ

( わかってるよー )

ピピッ ピピッ

やっと、きれいになった!
ドアを閉めて・・・ひと安心、これで、やっと昼食が作れる。

2.昼食を作り、食べて、片付けてたら・・・

水から、調味料まで、すべてレシピ通りに計量し、調理時間も最近は一家に1台はあるんじゃないか?みたいな、いわゆる「スマートスピーカー」とか「スマートアシスタント」と呼ばれる アレ! に正しく計測してもらう。計測中の自分自身の動きも毎回同じなので、ほとんど調理ロボットだ。

料理は何の問題もなく完成。一緒に暮らしている人にもおすそ分け。この上ないくらい、しあわせな休日のお昼ごはんTimeを過ごす。レシピを作り、公開してくださった方に、心から感謝!

食べたら今度は後片付けだ。自分で言うのもナンだけど、洗い物も別に苦にならない。調理器具がキレイになると、すごくうれしいし、乾燥させた後は一緒に暮らしている人が片付けてくれるので、とにかくキレイに洗えば、それでOK!

以前は食洗器を使っていたのだが、なんか調子が悪くなって、いつの間にか、使うのをやめてしまった・・・。あの頃は、食洗器に洗い物を並べるのが「食べる」以外の部分で、自分と料理との唯一の接点だったのだけれど。

(変われば、変わるもんだよなー)

そんなことを思いながら、お皿を洗っていると・・・

ピピッ ピピッ ピピッ ピピッ ピピッ・・・

間違いなく、警報音が聞こえる。背後にある冷蔵庫をじっと見つめる。見た目、ドアはきちんと全部しまっている。いちおう、全てのドアを手で押して確認。動くドアは「ない」。つまり、間違いなく、すべて完全に閉まっている。なのに、しばらくすると・・・

ピピッ ピピッ ピピッ ピピッ ピピッ・・・

冷蔵庫のドアについている操作パネルを適当にいじって見る。警告の表示は見当たらない。なのに、また、しばらくすると・・・

ピピッ ピピッ ピピッ ピピッ ピピッ・・・

どうやら、1分間隔で警報音が鳴るようだ。しかし、その原因がわからない・・・

もしかして、冷蔵庫のセンサーが壊れた?

そう思いたくなったのが、正直で、本当の気持ち。

(気持ちは本当ですが、メーカーさんの名誉のために一言。冷蔵庫に問題はありませんでした)

3.いっしょに暮している人に相談する

一緒に暮らしている人は、ある理由から一般ピープルよりも家電に強い(と思う)。だから、とりあえず言って見た。

「冷蔵庫のドア閉め忘れの警報音が鳴りやまないんだけど・・・」

たたたた(駆け出す音)、一緒に暮らしている人は、行動もとても素早い。自分自身は、その後ろ姿を見送ったあと、しばし、炭酸水で乾いた喉を潤す。んで、しばらくしてからキッチンへ降りてみると、どこにしまってあったのか、冷蔵庫の操作マニュアルが伝家の宝刀のように取り出され、広げられていて・・・

一緒に暮らしている人が、まるで大発見でもしたかのように断言。

「警報音が鳴るときは、こんな表示が出ると書いてあるけれど、表示が一切ない!」

冷蔵庫のディスプレイを見れば、それは明らか。
なんの表示もない。それなのに・・・

ピピッピピッピピッピピッピピッ・・・

1分経過するごとに、規則正しく、警報音は鳴り響く。

「原因不明。がんばったけれど、もぉ無理。」

「こうなったら、お客様相談室に電話しよう!」

さすが同居人。あきらめも潔い。言うが早いか、家電を持って、ピッポッパ。

4.原因判明

「少々、お尋ねしたいのですが・・・」

「はい、警報音が消えなくて・・・」

「冷蔵庫には、操作マニュアルにあるような表示が出なくて・・・」

「でも、警報音が・・・」

お客様相談センターの係員の方から、ここで何か、アドバイスがあったらしく・・・

「あっ、はい。ガスコンロですか? はい、あります。冷蔵庫の前です。」

「へっ」・「あっ!」

なんと警報音を出していたのは、ガスコンロの鍋無し検知機能だったのです!

調理の最終段階で、鍋の保温のため、ガスコンロの火力を最小にして、そのまま鍋を外して火を消し忘れ、その「ガスコンロを切らない」ままの状態で、そのこと自体を忘れて放置・・・

規定の15分が経過。コンロは安全装置が働いて、自動消火。ただ、着火レバーはONのままなので、冷蔵庫ではなく、冷蔵庫のそれに非常によく似た、ガスコンロの警報音が、鳴り続けることに。

でも、自分は、最初に聞こえたドア解放の警報音から、音源は冷蔵庫だと思い込んで。
それを伝え聞いた、一緒に暮らしている人も、音源は冷蔵庫だと思い込んで、いるから・・・

お互い、まさか、それがガスコンロの警報音だなんて、夢にも思わず・・・

冷蔵庫の、ひたすら平らなドアは、真向かいにあるガスコンロの警報音をよく反射する・・・

そう、まるで自分がそれを発しているかのように。

5.M社のお客様相談センターのスタッフの皆様へ、心から感謝申し上げます。

それにしても、凄いのは、一緒に暮らしている人の言葉を丁寧に聞き取って下さり、問題の真の原因にいち早く気が付いて教えてくださったM社のお客様相談センターのスタッフの皆様のご対応です。

ご対応から、拝察すると、おそらくは同じような事例が、過去に多々・・・

優しく、丁寧に、ご案内してくださっただけでなく、最後に「ご迷惑をお掛けして」とお詫びの言葉まで頂戴し。

M社の冷蔵庫は最高です!
購入してはや8年、ただの1度も不具合なく、日々、勤めを忠実に果たし、
期待通りの冷たさで、ありとあらゆる食材を冷やし続けてくれています。

壊れたなんて、疑って、ほんとうにごめんなさい。

6.まとめ

(1)ガスコンロの消し忘れ警報音は、冷蔵庫のドア閉め忘れの警報音によく似ている。
(2)キッチンで警報音が鳴り響いたら、冷蔵庫だけを疑ってはいけない。
(3)ガスコンロを使用したら、最後にレバーのOFFを必ず確認する。

プログラミングも、調理も、安全確認という最も重要な部分は、まったく同じでありました。そのことに思いを深くした、誠に貴重な経験でありました・・・が、もしも、もしも可能なら☆

「これは、冷蔵庫のドア閉め忘れの警報音です」とか、「これは、ガスコンロの鍋無し検知機能の警報音です」って、個別に案内してもらえると、まったく混乱せずに済んだので、最高に良かったかもですが。

そんな、わがままは、置いといて・・・

ガスコンロのレバーをOFFにし忘れても、自動的に消化するという、画期的な安全装置の開発者の方々にも、心から、心から、感謝です!

火事にならなくて、よかったー!!

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の過去記事をご参照ください。

解答欄の作成例:矩形検出されたくない部分の罫線は点線を使用します。
これにより設問番号を含んだ解答欄矩形の切り出しや、
字数を指定しての解答欄作成が可能になります。


【過去記事のご案内】


今回掲載したプログラムには、すぐにお試しいただけますよう、採点準備作業を行ってあるサンプルを添付してあります。以下、手書き答案採点補助プログラムの試用方法です。

(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.お願いとお断り

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

[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 m。

そこに行くだけの力は、まだ残っていた。


しかし・・・

標高に比例して強まる風。

確実に悪化する天候。

残り100mの高度を往復するのに必要な時間と体力。

予報では、明日は未明から雨・・・

予定を変更して、今日中に下山すれば、最も危険な鎖場を天候が持つうちに通過できる・・・

ただし、予定2日分の行程を今日の日没までに歩かねばならない。


行くか、戻るか。

この状況から判断すれば、選択すべき答えは明らかだ。

いつかまた、ここへ 来ればいい。

登頂を断念し、僕たちは引き返すことを選んだ。


標高が下がると、先ほどまでの強風は徐々に弱まり、

冷気を孕んで吹く風は「天然のクーラー」のようで 心地よかった。

左足親指の巻き爪にできた、小さな肉芽腫の痛みだけが、自分の中の不安要素だが。

でも、まだ、足を引きずらずに歩くことは出来る・・・

パーティの仲間たちを心配させるわけにはいかない。

ゼリー飲料

途中の小屋前の広場で大休止。昼食を摂るために湯を沸かす。

バーナーの力強い音を聞きながら、ふと隣を見ると何もせずに手元を見つめているきみがいた。

そうか・・・。この先には、往路、登攀に苦しんだ連続する鎖場がある・・・

「食べたくないのかい?」

そう問うときみは涙色の目をして、無言で頷いた。

今日の行程は長い。グリコーゲンが尽きたら歩けなくなる。

何としても日没までにベースキャンプまで降りなければならない。

「ゼリーなら食べれる?」

頷くきみに、僕はザックから行動食のゼリー飲料を取り出した。

アタック時のエネルギー補給用に、重いのを我慢して持ってきたものだ。

ここまで運んできたことを思うと少しのためらいはあったが、今はそれより大切なことがある。

ゼリー飲料を受け取ったきみがそのキャップを廻すのを見て、一安心。

「大丈夫。登れたんだ。降りれないわけがない。」

きみは涙目のまま、少し笑って、頷いた。

クライミング・リーダー

「 ザイル出せ 」

登攀リーダーは力強くそう言うと、受け取ったザイルを左肩に掛け、切り立った崖に立った。

その姿は、古代ローマの戦士のように見えた。

体力に優れたサブリーダーがザックのピストン輸送を登攀リーダーに申し出た。

ロッククライミングの経験が豊富な登攀リーダーがメンバーと自らをザイルで結ぶ。

短い言葉で、的確にステップの切り方を指示。

最後に僕が降りて、最大の難所である連続した鎖場をメンバー全員が無事通過した。


よかった。でも、まだここは雲の上だ。

僕たちは、あの雲の下へ行かねばならない。

水場

標高が下がるにつれて気温と湿度が高くなり、発汗が激しくなった。

山頂近くで吹いていた冷たい強風が嘘のようだ。

休憩時だけでなく、身体が欲する度、ひと口、ふた口と、少しずつ水を飲む。

ゴクゴク、音を立てて飲みたくなるが、必死で我慢する。

朝、1.5L あったはずの水。出発時にはその重さから必要十分な量といつも思えてならないが・・・

ザックのハーネスに付けたペットボトル内の水の残量は、今、残り1/3もない。

登りで水を補給した、岩清水の湧く水場が、もう近いはずだ・・・。


水は、ザックの重さを左右する・・・。

水を入れると極端に重みを増すザック。

今回の山行では、真水2.0Lに加え、ペットボトル飲料500mLを3本用意した。

これだけで3.5kg・・・。山行初日、荷の重量は20kgを超過した。

ザックの比較的高い位置に水をパッキングしたため、岩場の登攀時に身体が振られて困ったが。

ヒトは水無しではいられない。


ようやく水場へ到着。岩の割れ目から清らかな冷水がこんこんと湧き出している。

この山系に降った雨が、地下に染み渡り、岩盤で濾過され、冷水となって・・・

ここで湧き出すまでに、いったいどれくらいの歳月を必要とするのだろう・・・


先に到着したメンバーが、歓声をあげて水をペットボトルに詰めている。

水を汲む前に、ペットボトルに残ったぬるい水を一気に全部飲む。

ここまで運んできた水だ。捨てる気になど、到底、なれない。

ザックを開けて空の水筒も出し、ペットボトルと合わせて冷水1.5Lを補給する。

ここでは我慢する必要はない。

古第三紀の花崗岩で濾過された透明で美しい水をペットボトル1本分、そのまま飲む。

ゴクゴク・・・ のどが鳴る。

( なんて うまい 水 なんだろー! )

再び、補給してペットボトルを冷水で満たす。

持っている水筒とペットボトルをフル活用すれば3.5L補給することも可能だが1.5Lに留める。

山行3日目で軽くなったと言っても、まだザックの重さは間違いなく15kg以上ある・・・

これを16kgにするか、18kgにするか、この状況で、どちらか一つを選ぶなら・・・

少しでも軽く・・・ 少しでも軽く・・・ そう気持ちが傾くのは当然だ。

とにかく、この水がなくなる前に、ベースキャンプまで降りなければならない。

ようやく太陽は大きく傾きはじめ、日没が少しずつ近づいてくる。

あとは時間との競争だ。

僕の体力は持つだろうか・・・

肉芽腫

悪いことに、左足親指の爪の左側にできた肉芽腫が次第に強く痛むようになってきた。

時折り、大電流が流れて、強く痺れるような、激しい痛みが僕を襲う。

そうなると、とても歩くどころではない。

両ひざに手をついて、じっと痛みがおさまるのを待つ。

傍から見れば、バテて休憩しているようにしか見えないだろう・・・。


記憶にある往路は、木の根が縦横に入り組んだ、傾斜のきつい、一歩の高低差の大きい登り道。

所々に風化した花崗岩の露頭・・・そう、真砂の上を登る箇所もあった・・・

登り始めから森林限界を超えるまで、その風景の繰り返しだった。

今は、登りが下りに変わっただけで、ゴールまで記憶にあるこの風景が続くのだ。

泣いても、わめいても、誰も僕を救援してはくれない。

自分の命は、自分で運ぶしか ない。

山と交わした その約束は 絶対 だ。


実は、山行前から左足に小さな違和感があった。

山行の一週間ほど前に足の指の爪を切った際、左足親指の爪を少し深く切りすぎたのだ。

( しまった! )と思った時は、後の祭り。


爪と指の細胞のつながりの部分を、僕は切ってしまっていた・・・。


山行2日目に一人用テントの中で、就寝前に靴下を脱ぐと、そこには小さな肉芽腫が出来ていて・・・

流れ出る血と膿を見た瞬間、たまらなく不安になったけれど、

これ以上酷くならないことを祈りながら、絆創膏をそっと患部に貼るくらいしか・・・

僕にできることはなかった・・・。


山行3日目の今日は、朝から状態を1度も確認していないのだけれど、

痛みから想像して、肉芽腫が成長していることだけは間違いない。

今、ここで出来る「治療」など、あるわけがない。

痛みは、生きている証拠。

そう考えて痛みに耐え、一歩一歩前へ進む。

きみと小休止

気がつくと、僕の前を行く きみが 少しずつ遅れ始めた・・・。

決して初心者ではない、きみだけれど、

危険を避けるために必要な予定プラスアルファの行動は、

おそらく、きみの体力とギリギリいっぱい。

そのちいさな肩に、今日のザックは少し重かったに違いない。

僕の左足の痛みは増す一方だけれど、まだ、きみと同じ速さで歩くことは出来る。

僕は、そっと、きみの後ろへついた。


「少し、休んでも いいですか?」

僕の本当を知らない きみがかけてくれた声は、僕の心の声だった。

最後の小休止

みんなが小休止する小広場に、ふたりは少しだけ遅れて到着。

ザックを降ろす間もなく、そのまま地面に崩れ落ち、僕はタオルで汗を拭う。

( もう少しで、ゴールだ・・・ )

ただ、それだけを思う。

息が荒いわけではない。熱中症の心配も、まずない。

呼吸は正常。意識も明瞭。まだ、水もある。

だけど・・・ もう、足が動かない・・・。


ザックのウエストベルトのポケットからブドウ糖のタブレットを取り出す。

錠剤を2つ、掌に落として口へ運び、そのまま、ガリガリと噛み砕く。

ここまで幾度となく、この行動を繰り返してきたけれど、感覚として「何か」が違う。

もしかして、グリコーゲンが尽きた? 多分、そうだ。

( もうすぐ・・・ 動けなくなる )

日没まで、残された時間はあとわずか・・・

もし、引き返していなかったら・・・

登攀リーダーの決断の本当を、体験として理解できた瞬間だった。


経験豊かな登攀リーダーは、山行全体を見ていた。

単に「頂上直下が強風」だから「下山する」のではなく、

そこから山頂を往復するまでに消耗する体力と時間、

さらに、今後悪化する天候を見越して、

今日中にベースキャンプまで降りるのに必要な体力と時間。

メンバー全員のそれを、すべて計算した上での判断だった。


隣ではサブリーダーが、地面に座り込んだきみに声をかけていた。

「あたまを前に・・・」

水場で汲んできた冷たい水をかけてもらって、

きみは少しだけ、自分を取り戻した。

登攀リーダーは、予め、このシーンがあることを予測していたに違いない。


「ここから先は、これまでより高低差のない道がゴールまで続きます・・・」

地図を確認したメンバーが、みんなに説明する声が聞こえる。

最後の出発の合図だ。


地面に降ろしたザックと向かい合う。

左右のショルダーハーネスを掴んで、両手でザックを持ち上げ、両ひざの上に乗せる。

荷の重さを実感する瞬間だ。

そのまま、向かって右のショルダーハーネスに左腕を通す。

身体を右に回転させ、両ひざを伸ばし、その反動を利用して、ザックを背にする。

ウエストベルトを締めて、ショルダーハーネスのスタビライザーを引く。

そこにどんな物理的理由があるのか、僕にはわからないが、ザックは急に軽くなる。

( 唯、歩めば 至る アイン ツバァイ ドライ・・・)

遠い日の記憶を胸に、パーティの最後尾を僕は行く。

きみが左右に揺れながら、僕の前を歩いている・・・。

きみと歩く

行動を開始したのは、午前5時8分だった。

もう、それから12時間以上が経過している・・・。

木々の合間に見える谷の風景。まだ、その底は見えない。

谷底には、ベースキャンプがあるはずだ。

ベースキャンプさえ、見えたなら・・・


きみと僕の歩みは、もどかしいまでに遅くなる。

気持ちはどんなに歩きたくても、もう足が上がらない・・・ 足が前に出ない。

そうだ・・・ 前にも、同じシーンがあった・・・。


確かに・・・ 今と、同じシーンがあった・・・

あの時も・・・

きみは、歩みを止めなかった。


きみの歩みに導かれて

僕は、歩き通すことができた。


今も、きみが前にいる。

きみが、ゆっくりと、歩いている。

それが、今、僕にある 確かなことの すべてだ。

ベースキャンプ

木々の合間に、緑色のテントが見えたような気がした。

よく見るとそれはしかし、日差しを浴びた谷の反対側の森だった。

谷底を流れる川の音はずっと聞こえているが、大きくも小さくもならない。

所々に「倒木に注意」と書かれた看板があり、チェーンソーで切断された丸太が転がっている。

確か、登りはじめに、見た風景だ。


あと何メートルだろう?

あとどれだけ歩けば、いいのだろう?

山行の終わりは、いつも自分の限界との戦いだ・・・。


何か、谷ではないものが、見えた気がした。

木立の隙間に見えるものは、間違いなく平らな地面だ。

とうとう、帰ってきた・・・。

同時に、視野の左に動きを感じる・・・。


人が登ってくる・・・

ザックを置いたサブリーダーだ。

僕たちを心配して、迎えに来てくれたんだ。


合流したサブリーダーがきみの状態を確かめている。

「 ゴールまでがんばろうか 」

体力に優れたサブリーダーは、しかし、きみのザックを背負おうとはしなかった・・・。

きみの気持ちへの最大限の配慮が、その言葉から感じられた。


三人で歩く道は、いつしか広くなり、自動車も入れる林道となった。

その林道の向こうから、みんなが手を振ってやってくる。

先にゴールしたみんながザックを置いて、最後尾の僕たちを迎えに、

いま、降りたばかりの道をのぼってくる。


目指したピークには、誰も立てなかった。

でも、メンバーの誰一人、後悔なんかしていない。

それは全員の表情を見て明らか。みんな笑顔だ。

ピークに立つ、それ以上に大切なことを

僕たちは、この山行で学んだから。


それは ・・・ 「 引き返す勇気 」

言葉にすれば、わずか6文字だけど・・・。


でも、僕は信じたい。

「 引き返す勇気 」が・・・ 導いてくれた明日を。


あの雲と、風の、彼方に・・・

僕たちの頂きがあった。


僕たちは、いつか、そこへ行く。

必ず、行く。


それで、いいじゃないか。


それが、僕たちの勇気との、約束だから・・・。


うん。

大切な約束だから・・・。

塗りつぶさないマークシート

「塗りつぶす」方式でなく、より簡易な「線を引く」方式でマークするシートの例。

複数マーク可能なマークシート(選択肢は0~99 に対応)を作成したら、マークする時間そのものを短縮する必要性を痛感。今回は「よりはやく」・「よりカンタン」にマーク可能なシート作りに挑戦。

追記(20240929)

当Blogで紹介してきた自作のデジタル採点プログラムを一つにまとめました。次のリンク先にその紹介とダウンロードリンクがあります。マークシートも、ここに紹介した形式の他、様々なタイプのものを同梱しています。

上のリンク先で、ここで紹介したマークシートを含む、デジタル採点プログラム一式をダウンロードできます。

【もくじ】

1.もっとはやくマークできないか?
2.凡例として線でマークして違和感のないカタチは?
3.「線」マークの読み取りテスト
4.まとめ
5.お願いとお断り

1.もっとはやくマークできないか?

設問数が100ある試験で、複数マークを可とした場合、受験者は制限時間内に最大で200近いマークを塗りつぶさなくてはならない。制限時間が50分の試験で、190個マークするとして、1個2秒でマークした場合、マークするのに必要な時間は380秒、すなわち6分20秒となる。1個3秒でマークした場合の必要時間は、なんと9分30秒。制限時間の1/5がマークするためだけに使われてしまう。

採点者がラクをするのに、無理やり付き合わされる受験者の不満が爆発するのが見えるような気がして、小心者の筆者は「マークするために必要な時間を短縮する」にはどうしたらいいか、必死で考えた。

これまでに使用してきたマークシートのマークは、すべて選択肢の番号を縦長だ円で囲んだもの。

これまでに使用してきたマークシート


実際には、テストしてみると、次のようにマークしても読み取りパラメータの設定次第で十分読み取り可能なのだが・・・

こんなマークでも、実際には読み取りが可能。


しかし、受験者の心理として、選択肢が縦長だ円で囲まれていれば、だ円内を塗りつぶしたくなるもの。塗りつぶさず、「線」でマークしてもらうには、「線」でマークしたくなる形状にマークシートを改良しなければならない。

最初に考えたのは、「選択肢の番号を縦長四角形で囲む」という方法。

たとえば、こんな感じ


ただ、これだと、凡例で次のようにマーク方法を示してあっても・・・

どちらかと言えば、これはむしろ「悪いマーク」の例


真面目な受験者であれば、あるほど・・・

枠内を塗りつぶさなくてはいけないと考えるのが自然。


かといって、次のようにすると、デザインとして美しくない気がするし、四角形の幅が狭まったたけで「塗りつぶし」たくなる気持ちは同じ。

塗りつぶし面積が減少しただけ?


そんな、こんな理由から縦長四角形で選択肢の番号を囲むというアイデアは見送ることに決定。

2.凡例として線でマークして違和感のないカタチは?

では、凡例として線でマークしてあっても受験者に違和感を与えないマークとは、どんなマークか?

思いついたのは「囲みのない」マーク。これなら・・・

凡例として「線」が使えるし、塗りつぶしたくても塗りつぶす枠がない!


ただ、これだとマークシートっぽくない気が・・・。

マークシートらしくするために一工夫した結果・・・

[ ] 記号で数字の上下を囲んでみた


これならマークの凡例として、「線」が使えるのではあるまいか?


で、出来上がったのが、このマークシート。

複数マーク可能な「線」でマークするシート(設問数は100設問まで)


このマークシートは、次のリンク先からダウンロードすることができます。

3.「線」マークの読み取りテスト

例え、「線」であっても、これまでの経験からまず間違いなくマークの読み取りには成功すると思ったが、「やっぱりできませんでした!」では使ってくださる方に申し訳がたたないので、念のためテストを実行。

読み取り結果を確認しやすいように、設問番号にマークした次のようなマークシートを作成。やっぱり「線」で引く方が、「塗りつぶし」より遥かにラクであることを実感。これなら1設問に対し2か所マークしても「塗りつぶす」より、全然はやい!!

筆記用具は、シャープペンシルを使用。
芯は、HBの0.5mm。
用紙はホームセンターなどで普通に販売されている白色度の高いコピー用紙を使用。
(普段マークシートの印刷に使用している再生紙より、白色度が高いもの)


MS_Reader.exe を起動し、パラメータ設定はデフォルトのまま、読み取りテストを実行。

スキャナーの解像度は200dpiに設定。
カラー画像としてスキャン。
テスト1回目で、全てのマークの読み取りに成功!


間違いなく読めると思ってはいたけれど、ちょっと(あれっ?)って思ったのは、デフォルトパラメータの設定のまま、テスト第1回目で、全てのマークを正しく読み取ることに成功したこと。

実は、今回、実験で使用した用紙とは異なる、白色度が70%程度の再生コピー用紙を用いて予備的に実験した際は、閾値のパラメータをデフォルト設定より1~3程度大きく設定しないと読み取れないマークがあったのだ。今回、実験したものと「線」そのものの濃さや太さが厳密には異なるから正確なことは言えないが、白色度が高い用紙の方が二値化の際に有利なのだろうか?

・・・ということで、今回の実験では何も問題が起きなかったので、その前に行った白色度が70%程度の再生コピー用紙を使って行った予備実験で発見できた問題と対応方法を紹介。

(これは当Blogの過去記事「100選択肢対応マークシートを使用した試験の実施方法」に書いたものの再録です)

【重要】二値化閾値の修正方法(20240707追加)

マークが「うすい」場合、これを正しく読み取ることができず、「空欄」と判定して「999」と表示される場合があります。同じ理由で、複数マークされた解答欄の「1の位」が読めなかった場合も、読み取り判定は「マークの状態に問題あり」となり、「999」と表示されます。

これらの場合は、この後、実行する「読み取り結果のチェック」時に、該当箇所の解答欄が赤枠で囲まれて表示されますので、マークの状態をヒトの眼で確認し、読み取り結果を修正できます。

最も困るのが、複数マークされた解答欄の「10の位」のマークは薄くて読めなかったが、「1の位」のマークの読み取りには成功している場合です。この場合は正しく読み取れた「1の位」のマークが読み取り結果として表示されてしまいます。大変申し訳ないのですが、MS_Reader.exe のチェックプログラムは、この誤読を見つけることができません!!(これは原理的な問題なので、チェックする方法がありません)

この事故を防ぐには、事前に読み取りテストを十分に行って、読み取りパラメータを調整するしか方法がありません。具体的には、最もマークの濃度が薄い受験者のマークシートが正しく読めるようになるまで、閾値の値を1ずつ大きくして読み取りテストを実行します。筆者の行ったテストでは、デフォルト設定の閾値(180)では正しく読めなかったマークも、閾値を大きくすれば読めるようになりました。

「塗りつぶす」方式でなく、より簡易な「線を引く」方式でマークするシートを用いて行った読み取りテストの例

「64」と読むべき箇所の「10の位」を「空欄」と判定、「1の位」は正しく読めた場合、プログラムは読み取り結果を「4」と表示してしまう。


閾値を「1」大きくすると・・・

正しく読めるようになりました。


ごく薄いマークも正しく読めるようになるまで、この操作をくり返します。筆者の行ったテストでは、閾値を1ずつ大きくすることで、指示通り普通の濃さ(マーク箇所の数字が読めなくなる濃さ)でマークされたシートであれば、最終的にすべてのマークを正しく読めるようになりました(上記のマークシートを使用して行った実験では、最終的にすべてのマークを正しく読めた段階で、二値化の閾値の値は「184」でした。なお、このマークシートは後日公開する予定です)。

ただ、あまりにもマークの濃さが薄い場合は「原理的に読めません」ので、「対応不可」としてヒトの眼で読むしかないと思います・・・。

そのような事態にならないよう、予め受験者に対し「薄いマークは読み取れない」旨の注意を徹底しておく必要があります。マークシートを用いた試験では、これが最も重要なことかもしれません。

4.まとめ

(1)複数マーク可能なマークシートを使う場合は、「線」でマークできるようにする。
(2)マークシートを印刷する用紙は、白色度の高いものがよい?カモ
(3)マークシートは「濃く」マークするよう、予め注意を徹底する。
(4)筆記用具は、硬さと濃さが2B、太さ0.9mmのシャープペンシルがよいと思われます。

追記_20240709

【注意】複数マーク可能なマークシートでは、音声読み上げ機能は正しく動作しません!

5.お願いとお断り

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

100選択肢対応マークシートを使用した試験の実施方法

自作マークシートリーダーのプログラムを書き替え、大語群(使用できる選択肢の番号は 0~99)の使用に対応したマークシートを Excel で作成した。

今回は、このマークシートを用いた試験を実施する方法です。

【もくじ】

1.事前の準備と受験者への注意事項
2.スキャナーでマークシートをスキャンしてJpeg画像に変換
3.指定のフォルダ内にフォルダを作成してスキャンした画像をコピー
4.採点専用画像に変換
5.テンプレートとして登録
6.マーク読み取りを実行
7.マーク読み取り結果のチェック
8.CSV形式で出力
9.採点結果通知シートの作成方法
10.お願いとお断り

追記(20240929)

当Blogで紹介してきた自作のデジタル採点プログラムを一つにまとめました。次のリンク先にその紹介とダウンロードリンクがあります。マークシートも、ここに紹介した形式の他、様々なタイプのものを同梱しています。

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で紹介したデジタル採点プログラムのすべてをまとめました!

先日、電車にゆられていたら先輩が。

「選択肢がたくさんあると、マークシート使うの、難しいかなー?」って。

なんかおもしろいこと、ないかなー☆って、毎日、ひまなんだもん。

すぐに出来そうな気がしたので、さっそく大語群に対応したマークシートリーダー作成にチャレンジ。

今までのは数学や教科「情報」の試験用に作成した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.お願いとお断り

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