PowerCMS X ブログ
2026-02-25
2月3日(火曜日)の夕方に公開した「やさしい選挙」というウェブサイトについて、今回はCMS実装面に焦点を当てて紹介してみたいと思います。
「やさしい選挙」は、第51回衆議院議員選挙の選挙公報を集約したウェブサイトです。小選挙区の全候補者の選挙公報を音声で聞けるようになっているのが最大の特徴です。今回は公示からわずか1週間という短期間で827名分もの選挙公報を音声データ化し、817件分を掲載しました(一部の県でサイト公開後に音声ファイルが公開されたため、公開後に差し替えました)。
7つの都道府県のウェブサイトには、選挙公報を音声化したオーディオファイルが掲載されていて、読み上げができるようになっていますので、その7都道府県分については、そのオーディオファイルにリンクを張り、残りの40都道府県の候補者分の音声を作成しました。
プラグインの作成には、PluginStarterを利用しました。CMSカスタマイズにはこのプラグインが欠かせません。
PowerCMS Xの最大の特徴でもあるモデルですが、今回は3つのモデルを新規に作成しました。
政党、選挙区に情報を登録し、候補者モデルのオブジェクトとリレーションさせます。
| 入力項目 | 内容 |
|---|---|
| 名前 | 姓名(半角スペース開け) |
| ふりがな | ふりがな(半角スペース開け) |
| 本文 | 最終的な読み上げるテキスト(リッチテキスト) |
| URL | 候補者ウェブサイトのURL |
| 選挙公報URL | 東京と千葉のみ個別候補者のPDFのURL、その他は個別PDFがないため空欄 |
| 選挙公報PDF | スクリーンキャプチャから生成したPDFを登録するバイナリカラム |
| OCRテキスト | AIが抽出したテキストが入る |
| 読み上げ対応URL | 音声対応PDFがある場合に登録 |
| 読み上げ対応PDF | 保存用のバイナリカラム |
| 音声ファイルURL | 音声ファイルのある7都道府県はオーディオファイルのURL、その他は空欄 |
| 音声ファイル | 最終的に作成した音声ファイルを登録するバイナリカラム |
| 読み上げ音声 | 候補者の性別に応じて男女を指定 |
| 音声作成者 | 合成音声か、都道府県提供の二択 |
| 政党 | 政党モデルとのリレーション(選択) |
| 選挙区 | 選挙区モデルとのリレーション(階層型、選択) |
上記の他に、不明点などを一時的に保存する「メモ」項目や、PowerCMS Xの標準の「ステータス」、ワークフロー用の「ユーザー」リビジョンなどのカラムが存在します。
PDFや音声ファイルのURLを文字列で指定するカラムと、実ファイルのデータを格納するバイナリ型のカラムがあります。選挙公報URLと、選挙公報PDFなど、3×2=6つのカラムです。このカラム名のつけかたを間違えました。
バイナリ型のカラムのファイルのURL取得するタグ名は「モデル名+カラム名+URL」となります。「pdf_outlined_url(文字列)」「pdf_outlined(バイナリ)」のURLを取得するタグ名がいずれも「<mt:candidatepdfoutlinedurl />」となって、被ってしまいました。これはサポートでもいくつか事例があるのですが、テンプレート・タグで意図した値が得られないトラブルにつながります。今回はプラグインで2つファンクションタグ「<mt:candidatebinaryurl name="pdf_outlined" />」「<mt:candidatecolumnvalue name="pdf_outlined_url"/>」を作成することで回避しました。設計時に注意する必要があります。
人の手による読み上げ順は基本的に以下のルールに則って行いました。
都道府県によって候補者情報の掲載方法は様々なため、都道府県ごとにスクレイピングするコードを書いて対応しましたが、音声読み上げ対応PDFの掲載が遅かったり、候補者情報がHTMLで掲載されている都道府県がほとんどなかったため、作業の後半は、各選挙区の候補者PDFから AI(ChatGPT)にCSV化してもらい、音声読み上げ対応PDFなどの情報があるものはCSVにマージしてもらい、PowerCMS Xにインポートする方法をとりました。
(私)「候補者名,ふりがな,政党名,候補者ウェブサイトのアドレス,選挙区名」をCSVにしてほしい。
名前とふりがなは姓と名を半角スペース区切りにしてください。
選挙区名はすべて「岩手1区」です。
PDFの候補者名は意図的にひらがなにしているものもあるので、PDFに合わせてほしい。
値のないものは空欄で。このPDFから。
https://www.pref.iwate.jp/_res/projects/default_project/_page_/001/093/939/iwate01_shimei.pdf
(ChatGPT)以下、PDF(岩手県第1区)から読み取れる「候補者名・ふりがな・政党名・候補者ウェブサイト等のアドレス」
をCSVにしました。
候補者名,ふりがな,政党名,候補者ウェブサイト,選挙区名,岩手1区
しな たけし,しな たけし,中道改革連合,https://shina.jp/a/,岩手1区
米内 ひろまさ,よない ひろまさ,自由民主党,https://yonai.info/,岩手1区
吉田 恭子,よしだ きょうこ,日本共産党,https://jcp-iwate.jp,岩手1区
佐々木 大成,ささき たいせい,参政党,https://sasaki-taisei.com,岩手1区
小笠原 勇治,おがさわら ゆうじ,,,岩手1区
CSVのデータを PowerCMS Xの候補者モデルのCSVに整形して(ヘッダセルをあわせて)、インポートします。音声読み上げ対応版CSVや音声ファイルが揃っている選挙区の場合は、掲載ページのURLと当該部分のHTMLを貼り付けてCSVにマージしてもらいます(URLだけだと間違うことが多く、HTMLの当該部分のソースを貼り付けました)。
(私)CSVに音声読み上げ対応版PDFと音声ファイルのURLをマージしてほしい。
音声読み上げ対応PDF は、提出のない候補者もいる可能性があります。
https://www.pref.iwate.jp/iinkai/senkyo/1093939/1094025/1094028.html
以下、当該のHTMLです。
<h2>小選挙区選出議員選挙</h2>
<h3>岩手県第1区</h3><p>しな たけし</p>
<p>中道改革連合(ちゅうどうかいかくれんごう)</p><ul class="objectlink"><li class="pdf">
<a href="../../../../_res/projects/default_project/_page_/001/094/028/01_01_shina_yomiage.pdf" class="ga-tmp" data-type="ga-tmp">音声読み上げ対応データ(候補者提出) (PDF 198.4KB)</a>
</li>
<li class="mp3">
<a href="../../../../_res/projects/default_project/_page_/001/094/028/1-sina.mp3" class="ga-tmp" data-type="ga-tmp">選挙公報(全文音声版) (mp3 1.3MB)</a>
</li></ul>
...
見込み違いが2つありました。ひとつめは、音声読み上げ対応PDFではない、いわゆる「普通」の選挙公報は、個別の候補者ごとに公開されない点です(東京都と千葉県以外)。二つ目は、「音声読み上げ対応PDF」は通常版の選挙公報より遅れて掲載される都道府県があることです。たとえば埼玉県では、やさしい選挙の公開日となった2月3日にはじめて「音声読み上げ対応PDF」が掲載されました。
「音声読み上げ対応PDF」は、全員分が掲載されるとは限りません。任意提出のためです。ところが、テキスト抽出には、このファイルと通常の選挙公報をAIでテキスト化したもののうち、どちらか良いものを利用としていた目論見がくずれました。掲載時期を待っていると、サイトの公開に間に合わないからです(選挙が終わってから公開になるのは避けたい)。
そこで、選挙区ごとの候補者が一まとまりになっているPDFについてはスクリーンキャプチャをとって、PDF化してから一つ一つ登録するようにしました。スクリーンキャプチャの登録は残念ながら自動化できませんでした。PNGをPDFに変換するのは、sipsコマンド(macOS)が便利でした。
for f in *.png; do
sips -s format pdf "$f" --out "${f%.png}.pdf"
done
通常の選挙公報PDFとスクリーンキャプチャで作成したPDFからのテキスト抽出は、Azure Document Intelligenceを利用しました。昨年時点でPDFの解析やアクセシビリティ向上の試行錯誤の段階で、試していた準備が役に立ちました。
Azure Document Intelligenceでは、PDFのデータを背景にして、各々のテキストの上に抽出された「透明な」テキストを置くことでPDFの読み上げを実現することができますが、今回は変換後のPDFは不要なので、JSON形式で返却される「analyzeResult」の「content」フィールドを抽出してから、調整をしました。ボタンをクリックすると「OCRテキスト」欄に調整済みのテキストが入ります。
調整は2点、正規化、マルチバイトテキストの間の半角スペースを詰めることです。Document Intelligenceでは「透明な」テキストをPDFに置きます。位置調整のために半角スペースを使うので、マルチバイトテキストの間の半角スペースを詰めます(PHP)。
function adjustJapaneseText (string $text): string {
$map = [
// 中点系
'·' => '・',
'・' => '・',
'·' => '・',
'∙' => '・',
'•' => '・',
// 長音・ハイフン系
'-' => '-',
'‐' => '-',
'-' => '-',
'‒' => '-',
'–' => '-',
'—' => '-',
// 波ダッシュ
'〜' => '~',
'∼' => '~',
// 引用符
'“' => '"',
'”' => '"',
'‘' => "'",
'’' => "'",
// スラッシュ
'/' => '/',
'∕' => '/',
'⁄' => '/',
];
$text = strtr( $text, $map );
return preg_replace(
'/(?<=[\p{Han}\p{Hiragana}\p{Katakana}])\h+(?=[\p{Han}\p{Hiragana}\p{Katakana}])/u',
'',
$text
);
}
一方、音声読み上げ対応PDFについては、pdftotextを使います(暗号化されていて抽出できないものがあります)。
pdftotext -raw input.pdf output.txt
-rawオプションを指定することで、ストリーム順でテキスト抽出ができます。音声読み上げ順の指定がされているもの(ほんのわずかですが)であれば、指定順でテキストがとれます。こちらの場合も調整は2点。縦書き用の記号を横書き用に置き換えること、縦書きテキストには1文字ごとに改行が入るので、これを繋げることです(PHP)。
function normalizeVerticalForms(string $text): string {
$map = [
// 句読点
'︒' => '。',
'︑' => '、',
// 丸括弧・波括弧
'︵' => '(',
'︶' => ')',
'︷' => '{',
'︸' => '}',
'︹' => '〔',
'︺' => '〕',
'︻' => '【',
'︼' => '】',
// 引用符・括弧
'︽' => '〈',
'︾' => '〉',
'︿' => '《',
'﹀' => '》',
'﹁' => '「',
'﹂' => '」',
'﹃' => '『',
'﹄' => '』',
// 線・区切り
'︱' => '|',
'︲' => '―',
'︳' => '|',
'︴' => '…',
// その他
'︐' => ',',
'︔' => ';',
'︕' => '!',
'︖' => '?',
];
return strtr($text, $map);
}
function joinVerticalText(array $lines): array {
// 縦書きを丸める
$out = [];
$buffer = '';
foreach ($lines as $line) {
$line = trim($line);
// 1文字だけの行(マルチバイト対応)
if (mb_strlen($line, 'UTF-8') === 1) {
$buffer .= $line;
continue;
}
// ここに来た=縦書きブロックが終わった
if ($buffer !== '') {
$out[] = $buffer;
$buffer = '';
}
$out[] = $line;
}
// 末尾が縦書きだった場合
if ($buffer !== '') {
$out[] = $buffer;
}
return $out;
}
通常の選挙公報PDF、音声読み上げ対応PDF、オーディオファイルは、ダウンロードしてから登録する手間を省くため、保存時にバイナリカラムに登録するような仕組みを構築しました。また、候補者名とふりがなから、ユーザー辞書(ふりがなや合成音声の読み上げ方を指定するモデル)への登録を同時に行うようにしました。
post_saveコールバックを使います(PHP)。
function post_save_candidate ( &$cb, $app, &$obj, $original, $clone_org = null ) {
$update = false;
$ctx = $app->ctx;
// 通常の選挙公報
if ( $obj->pdf_outlined_url && !$obj->pdf_ontlined ) {
$url = $obj->pdf_outlined_url;
$basename = basename( $url );
$out = $app->upload_dir() . DS . $basename;
$args = ['url' => $url, 'to_encoding' => ''];
// function_fetchでは、User-Agentなどがセットされます。
$data = $ctx->function_fetch( $args, $ctx );
if ( $data ) {
$app->fmgr->put( $out, $data );
$app->file_attach_to_obj( $obj, 'pdf_ontlined', $out, $basename );
$update = true;
}
}
// 音声読み上げ対応の選挙公報(中略)
// オーディオファイル(中略)
// ユーザー辞書の登録(登録がなければ)
$name = $obj->name;
$phonetic = $obj->phonetic;
if ( $phonetic && $phonetic != $name ) {
$phonetic = str_replace( ' ', '', $phonetic );
$user_dic = $app->db->model( 'user_dic' )->get_by_key( ['word' => $name, 'workspace_id' => $obj->workspace_id] );
$update_dict = false;
if (! $user_dic->id ) {
$user_dic->phonetic( $phonetic );
$user_dic->exception( 0 );
$user_dic->proper_noun( 1 );
$user_dic->status( 2 );
$app->set_default( $user_dic );
$update_dict = $user_dic->save();
}
$name = str_replace( ' ', '', $name );
$user_dic = $app->db->model( 'user_dic' )->get_by_key( ['word' => $name, 'workspace_id' => $obj->workspace_id] );
if (! $user_dic->id ) {
$user_dic->phonetic( $phonetic );
$user_dic->exception( 0 );
$user_dic->proper_noun( 1 );
$user_dic->status( 2 );
$app->set_default( $user_dic );
$update_dict = $user_dic->save();
}
if ( $update_dict ) {
$this->app = $app;
$this->user_dic = $user_dic; // __destruct時に辞書をアップデートするため
}
}
if ( $update ) {
$app->publish_obj( $obj );
}
return true;
}
作業分担は基本的に都道府県で割り振るため(多いところでは選挙区ごと)、フィルタリングができると便利です。
以下のファイルを用意します(パスは PowerCMS Xクラウドの場合)。
/data/sites/01/user-customize-files/plugins/ElectionBulletin/alt-tmpl/include/list/candidate/list_header.tmpl
以下のような一覧を作成します。2、3行作成してあとは AIに作ってもらいました。
<li><a <mt:if name="request.pref" like="北海道"> class="list-active" </mt:if> href="<mt:var name="script_uri">?__mode=view&_type=list&_model=candidate&sort=electoral_district_id&direction=asc&workspace_id=<mt:var name="workspace_id">&limit=200&pref=%E5%8C%97%E6%B5%B7%E9%81%93">北海道</a></li>
<li><a <mt:if name="request.pref" like="青森"> class="list-active" </mt:if> href="<mt:var name="script_uri">?__mode=view&_type=list&_model=candidate&sort=electoral_district_id&direction=asc&workspace_id=<mt:var name="workspace_id">&limit=200&pref=%E9%9D%92%E6%A3%AE%E7%9C%8C">青森県</a></li>
<li><a <mt:if name="request.pref" like="岩手"> class="list-active" </mt:if> href="<mt:var name="script_uri">?__mode=view&_type=list&_model=candidate&sort=electoral_district_id&direction=asc&workspace_id=<mt:var name="workspace_id">&limit=200&pref=%E5%B2%A9%E6%89%8B%E7%9C%8C">岩手県</a></li>
<li><a <mt:if name="request.pref" like="宮城"> class="list-active" </mt:if> href="<mt:var name="script_uri">?__mode=view&_type=list&_model=candidate&sort=electoral_district_id&direction=asc&workspace_id=<mt:var name="workspace_id">&limit=200&pref=%E5%AE%AE%E5%9F%8E%E7%9C%8C">宮城県</a></li>
また、「大阪1区」などを指定するために、以下。
<span class="mb-2" style="float:right"><button id="area-filter" class="btn btn-sm btn-secondary" type="button" id="area-filter-text">選挙区名</button></span>
<script>
$('#area-filter').click(function(){
const areaName = prompt("選挙区名を入力してください:", " <mt:if name="request.pref"><mt:var name="request.pref" escape><mt:else>東京1区</mt:if>");
if (areaName !== null && areaName.trim() !== "") {
const filterURL = '<mt:var name="script_uri">?__mode=view&_type=list&_model=candidate&sort=electoral_district_id&direction=asc&workspace_id=<mt:var name="workspace_id">&limit=200&pref=' + areaName;
window.location.replace( filterURL );
}
});
</script>
絞り込みの方は、プラグインで pre_listingコールバックを利用します(PHP)。
function pre_listing_candidate ( &$cb, $app, &$terms, &$args, &$extra, &$placeholders ) {
$pref = $app->param( 'pref' );
if ( $pref ) {
$electoral_district = $app->db->model( 'electoral_district' )->get_by_key( ['name' => $pref ] );
if ( $electoral_district->id ) {
if (! $electoral_district->parent_id ) {
// 都道府県名であれば、その配下の 1区、2区、3区 ... に属する候補者
$electoral_districts = $app->db->model( 'electoral_district' )->load( ['parent_id' => $electoral_district->id ], null, 'id' );
$ids = [];
foreach ( $electoral_districts as $district ) {
$ids[] = $district->id;
}
if (!empty( $ids ) ) {
$terms['electoral_district_id'] = ['IN' => $ids ];
}
} else {
$terms['electoral_district_id'] = $electoral_district->id;
}
}
}
return true;
}
最終的に読み上げ順などを音声ファイルにします。Amazon(AWS)のPollyを使っています。
ニューラル音声を指定することで自然な読み上げを実現しています。![]()
音声化は AWS SDK for PHP を使うことで簡単に実現できますが、APIに渡す前にHTMLを SSMLに変換する処理と、テキストの上限があるため、文章を分割して音声化し、FFmpegで結合しています。
<ruby>高市早苗<rt>たかいちさなえ</rt></ruby>
↓
<phoneme type="ruby" ph="たかいちさなえ">高市早苗</phoneme>
その他、ブロック要素の閉じタグや鉤括弧などの記号を「<break />」タグに置換することで、一拍空けるなどの調整を行うようにしています。
公開側のウェブサイトのアクセシビリティでもさまざまな工夫をしました。音声を掲載するので、視覚障害者の利用を想定したこともあります。
公開直後に、SVGで作成した地図が特定のスクリーンリーダーでうまく使えないという指摘をいただきました。最終的には解決したのですが、リスト形式の方が読み上げでは理解しやすいという指摘もあり、地図とは別にテキスト(リスト)形式でのリンクを追加し、地図の直前に地図をスキップするスキップリンク(ページ内リンク)を追加しました。
地図については、SVG Map of Japanを利用させていただきました。
SVGには都道府県のラベルがなかったので、SVGのDOMに追加しています。また、PowerCMS XのふりがなAPIと連携して、都道府県名のふりがなON / OFFが動作するようにしています。
スマートフォンなどの幅の狭い画面では、肝心の候補者ごとの音声を掲載したテーブルが画面に収まりません。スクロールに対応させるだけではなく、キーボードでも操作可能にしました(tableを囲うdivにtabindex="1"を指定して、キー操作に対応したJavaScriptでスクロールします)。気づかない可能性もあるので、スクロールが必要になる幅の時だけ「表の右側が隠れている場合は、スワイプ(スマートフォン)またはスクロールバーで操作してください。」の文字列を表示するようにしました。
音声は、複数の音声が同時に再生されるのを避けるため、別の音声が再生されたら他の音声は再生を止めるようにしています。
尚、この部分ですが、公開前のデータ確認の際には音声の再生速度を2倍速になるようにして、URLマップのファイル出力を「ダイナミック」にして確認しました。即時反映され、読み上げの確認も効率化されます。公開時には再生速度をノーマルに戻して「静的」にして再構築しました。
<div class="table-responsive" tabindex="0" role="region" aria-label="フォーカスを当てると矢印キーでスクロールできます。">
ここにtableによる表
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// 音声を同時に再生しない
const audios = document.querySelectorAll('.candidateTable audio');
audios.forEach((audio) => {
audio.addEventListener('play', () => {
audios.forEach((otherAudio) => {
if (otherAudio !== audio) {
otherAudio.pause();
}
});
});
});
const checkTableScroll = () => {
const scrollMessages = document.querySelectorAll('.scroll-view-only');
scrollMessages.forEach((message) => {
const tableResponsive = message.nextElementSibling;
if (tableResponsive && tableResponsive.classList.contains('table-responsive')) {
if (tableResponsive.scrollWidth > tableResponsive.clientWidth) {
message.style.display = 'block';
// ホバー時にスクロールバーを表示(毎回実行)
if (!tableResponsive.dataset.scrollInitialized) {
tableResponsive.addEventListener('mouseenter', () => {
const currentScroll = tableResponsive.scrollLeft;
tableResponsive.scrollLeft = currentScroll + 1;
tableResponsive.scrollLeft = currentScroll;
}); // once: true を削除
tableResponsive.dataset.scrollInitialized = 'true';
}
} else {
message.style.display = 'none';
}
}
});
};
checkTableScroll();
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(checkTableScroll, 100);
});
const responsiveTables = document.querySelectorAll('.table-responsive[tabindex]');
responsiveTables.forEach((table) => {
table.addEventListener('keydown', (e) => {
const scrollAmount = 40;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
table.scrollLeft -= scrollAmount;
break;
case 'ArrowRight':
e.preventDefault();
table.scrollLeft += scrollAmount;
break;
case 'ArrowUp':
e.preventDefault();
table.scrollTop -= scrollAmount;
break;
case 'ArrowDown':
e.preventDefault();
table.scrollTop += scrollAmount;
break;
}
});
});
});
document.addEventListener('DOMContentLoaded', () => {
const audios = document.querySelectorAll('.candidateTable audio');
// audio要素 → fired状態 を持つ
const firedMap = new WeakMap();
audios.forEach((audio) => {
const candidateName = audio.dataset.candidateName;
// audioごとの初期状態
firedMap.set(audio, { 25: false, 50: false, 75: false, 98: false });
audio.addEventListener('play', () => {
gtag('event', 'audio_play', {
audio_title: candidateName,
});
});
audio.addEventListener('timeupdate', () => {
if (!audio.duration) return;
const percent = Math.floor((audio.currentTime / audio.duration) * 100);
const fired = firedMap.get(audio);
[25, 50, 75, 98].forEach(p => {
if (percent >= p && !fired[p]) {
fired[p] = true;
gtag('event', 'audio_progress', {
audio_title: candidateName,
progress: p
});
}
});
});
});
});
</script>
選挙公報や候補者名簿の名前ですが、意図的にひらがなで登録している候補者が多くいます。投票用紙に書くときに難しい漢字を避けて書きやすいようにしたいという意図だと思われますが、知っている候補者の名前で検索したのにヒットしない、ということがあり得ます。
この検索は「SearchEstraier」プラグインを利用していますが、漢字、ひらがな、スペースあり、スペースなしを属性にセットしておいて、検索時にひらがなに変えて検索するようにしています。
pre_search_estraier(キーワードをカスタマイズする)、search_condition(属性検索条件をカスタマイズする)コールバックを使います。
function pre_search_estraier ( &$cb, $app, &$phrase, &$args ) {
if ( $app->ctx->stash( 'workspace_id' ) != 2 ) {
return true;
}
$plugin = $app->component( 'SimplifiedJapanese' );
$phrase = $plugin->filter_hiragana( $phrase, 1, $app->ctx );
return true;
}
function search_condition ( &$cb, $app, &$phrase, &$condition ) {
if ( $app->ctx->stash( 'workspace_id' ) != 2 ) {
return true;
}
$plugin = $app->component( 'SimplifiedJapanese' );
$cond = " -attr '@yasashii-name STRINC ";
$q = preg_quote( $cond, '/' );
$name = preg_replace( "/^{$q}/", '', $condition );
$name = rtrim( $name, "'" );
$name = $plugin->filter_hiragana( $name, 1, $app->ctx );
$cond = escapeshellarg( "@yasashii-name STRINC {$name}" );
$condition = " -attr {$cond}";
return true;
}
入力された検索文字列を内部的にひらがなに変換して検索することで、ひらがな、漢字、漢字ひらがなの混在でも候補者にヒットするようにしています。
今回、衆議院が解散になり、公示から投票までの日数が非常に短い選挙でした。公示日(1月27日)の前の週末、1月24日、25日の実質土日でCMSのカスタマイズを行いました。自画自賛になってしまう点も多々ありますが、基本的にたったの2日でCMS実装を実現できた理由としては、以下が挙げられます。
最後に、急な対応になったにも関わらず協力してくれたアルファサードの新規開発チームのメンバーをはじめとするスタッフと、テキスト・音声化をサポートしてくださった、一般社団法人やさしい日本語普及連絡会の吉開 章さんと、『入門・やさしい日本語』認定講師の皆さんに御礼を申し上げます。