電子化賞状作成スクリプト

JARLでは2025年のALL JAコンテストから主催国内コンテストの賞状を電子化しました。入賞者はPDFファイルの賞状をダウンロードできます。紙に印刷した賞状が欲しい方には別途オプションで紙賞状をお送りします。
ここに電子化賞状発行スクリプトを公開します。地方本部や支部、クラブなどで賞状を電子化されたい方はご自由にお使いください。
なお、当ページおよび本スクリプトはJARL、JARLコンテスト委員会とは関係なくJR1LQK土屋が個人として発表しています。JARL、JARLコンテスト委員会へお問い合わせすることはお控えください。質問は土屋本人 masaruアットマークerde.co.jpへ。

必要なもの

電子化賞状は発行リストをlualatexという組版言語で処理し、PDFファイルを出力しています。

lualatexはLaTeX(らてふ)というオープンソース組版言語の一種で、Lua言語という簡易プログラム言語を含んでおり、レイアウト処理はLua言語で行っています。

lualatexを含むLaTeX処理系はCTAN The Comprehensive TeX Archive Networkからtexliveというインストールパッケージをダウンロードし、インストールしてください。
フォントは日本語は原ノ味明朝、アルファベットはInterBold、Libre、Palatinoを使っています。いずれも一般書籍にも使われるクォリティを持ちながら自由にダウンロードして使うことのできるオープンソースフォントです。
インストール方法とかLaTeXの使い方については私の書いた『LaTeXはじめの一歩 Windows 11/10対応』(土屋勝著・カットシステム発行)を読んでいただくか、TeX Wikiを参照してください。
LaTeXは.texという拡張子を持ったテキストファイルに必要な文書やタグを埋め込み、LaTeX処理系でコンパイルします。lualatexはPDFファイルを直接出力することができます。
電子化賞状発行スクリプト(TeXソースコード)は次の通りです。
\documentclass[a4paper]{jlreq}
\usepackage{graphicx}
\usepackage{tikz}
\usepackage{luacode}
\usepackage{fontspec}
\usetikzlibrary{positioning}

\setmainfont{HaranoAjiMincho-Regular}
\newfontfamily\InterBold{Inter-Bold}
\newfontfamily\Libre{LibreBaskerville-Regular}
\newfontfamily\Palatino{Palatino Linotype}

\newcommand{\Contest}{2025}
\newcommand{\Round}{第67回ALL JAコンテストにおいて}
\newcommand{\Winning}{優秀なる成績を収められたのでこれを賞します}
\newcommand{\Date}{2025年8月23日}
\newcommand{\nowrap}[1]{\mbox{#1}}

% フォントサイズ調整(例)
\makeatletter
\renewcommand\Huge{\@setfontsize\Huge{32pt}{38.4pt}}
\newcommand\XLARGE{\@setfontsize\Huge{22pt}{38.4pt}}
\makeatother

\pagestyle{empty}
\begin{document}
\begin{luacode*}
-- === 安全で堅牢な CSV->TikZ 出力ルーチン(UTF-8 前提) ===

-- 3桁区切りフォーマット
local function format_number(n)
  if not n then return "" end
  local s = tostring(n)
  local t = {}
  local len = #s
  local cnt = 0
  for i = len, 1, -1 do
    cnt = cnt + 1
    table.insert(t, 1, s:sub(i,i))
    if cnt % 3 == 0 and i > 1 then table.insert(t, 1, ",") end
  end
  return table.concat(t)
end

-- 不正UTF-8シーケンスを削る(バイト列ベースのサニタイズ)
local function sanitize_invalid_utf8(s)
  if not s then return "" end
  local res = {}
  local i = 1
  local n = #s
  while i <= n do
    local b = s:byte(i)
    if b < 128 then
      table.insert(res, s:sub(i,i)); i = i + 1
    elseif b >= 194 and b <= 223 then
      if i+1 <= n and s:byte(i+1) >= 128 and s:byte(i+1) <= 191 then
        table.insert(res, s:sub(i,i+1)); i = i + 2
      else i = i + 1 end
    elseif b >= 224 and b <= 239 then
      if i+2 <= n and s:byte(i+1) >= 128 and s:byte(i+1) <= 191 and s:byte(i+2) >= 128 and s:byte(i+2) <= 191 then
        table.insert(res, s:sub(i,i+2)); i = i + 3
      else i = i + 1 end
    elseif b >= 240 and b <= 244 then
      if i+3 <= n and s:byte(i+1) >= 128 and s:byte(i+1) <= 191 and s:byte(i+2) >= 128 and s:byte(i+2) <= 191 and s:byte(i+3) >= 128 and s:byte(i+3) <= 191 then
        table.insert(res, s:sub(i,i+3)); i = i + 4
      else i = i + 1 end
    else
      i = i + 1
    end
  end
  return table.concat(res)
end

-- TeX 専用エスケープ(最低限の ASCII 特殊文字のみ)
local function escape_tex(str)
  if not str then return "" end
  local s = tostring(str)
  s = s:gsub("\\", "\\textbackslash{}")
  s = s:gsub("%%", "\\%%")
  s = s:gsub("%$", "\\%$")
  s = s:gsub("#", "\\#")
  s = s:gsub("&", "\\&")
  s = s:gsub("_", "\\_")
  s = s:gsub("{", "\\{")
  s = s:gsub("}", "\\}")
  s = s:gsub("%^", "\\^{}")
  s = s:gsub("~", "\\textasciitilde{}")
  return s
end

-- CSV 行分割("で囲まれたフィールドに対応)
local function split_csv_line(line)
  local fields = {}
  local i = 1
  local len = #line
  while i <= len do
    if line:sub(i,i) == '"' then
      local j = i + 1
      local buf = {}
      while j <= len do
        local ch = line:sub(j,j)
        if ch == '"' then
          if line:sub(j+1,j+1) == '"' then
            table.insert(buf, '"'); j = j + 2
          else j = j + 1; break end
        else
          table.insert(buf, ch); j = j + 1
        end
      end
      table.insert(fields, table.concat(buf))
      if line:sub(j,j) == ',' then j = j + 1 end
      i = j
    else
      local nc = line:find(',', i, true)
      if nc then
        table.insert(fields, line:sub(i, nc-1))
        i = nc + 1
      else
        table.insert(fields, line:sub(i))
        break
      end
    end
  end
  return fields
end

-- 数値に安全に変換する(全角数字などを半角化)
local function to_number_safely(s)
  if not s then return nil end
  local t = tostring(s)
  t = t:gsub("%s+", "")
  t = t
    :gsub("0","0"):gsub("1","1"):gsub("2","2"):gsub("3","3")
    :gsub("4","4"):gsub("5","5"):gsub("6","6"):gsub("7","7")
    :gsub("8","8"):gsub("9","9")
  t = t:gsub(",", ",")
  t = t:gsub("[^%d%.-]", "")
  if t == "" then return nil end
  local ok, num = pcall(tonumber, t)
  if ok and num then return num end
  return nil
end

-- メイン関数:CSV 読み込み -> tikz 出力
function read_csv(filename)
  -- バイナリで読み込み
  local fh, ferr = io.open(filename, "rb")
  if not fh then
    tex.print("\\textbf{CSVファイルが開けません}")
    texio.write_nl("log", "[read_csv] open error: " .. tostring(ferr))
    return
  end
  local raw = fh:read("*a")
  fh:close()

  -- サニタイズ(不正UTF-8を削る)して確認用に出力
  local clean = sanitize_invalid_utf8(raw)
  do
    local outname = "2025ALLJA-sanitized.csv"
    local outfh = io.open(outname, "wb")
    if outfh then
      outfh:write(clean); outfh:close()
      texio.write_nl("log", "[read_csv] wrote sanitized copy to " .. outname)
    end
  end

  -- 行ごとに分割(改行で)
  local lines = {}
  for l in clean:gmatch("([^\n]*)\n") do table.insert(lines, l) end
  local trail = clean:match("([^\n]+)$")
  if trail and (#lines == 0 or trail ~= lines[#lines]) then table.insert(lines, trail) end

  -- ヘッダ行を読み飛ばす(存在する前提)
  local start_i = 1
  if #lines >= 1 then start_i = 2 end

  for idx = start_i, #lines do
    local line = lines[idx]
    if not line or line:match("^%s*$") then goto continue end

    -- --- CSV を分割してから「形を揃える」---
    local fields = split_csv_line(line)

    -- fields が多すぎる場合(余分なカンマなど)11列目以降を OPS に結合して 11,12 を整える
    if #fields > 12 then
      local ops_concat = table.concat(fields, ",", 11)
      local newf = {}
      for i = 1, 10 do newf[i] = fields[i] or "" end
      newf[11] = ops_concat
      newf[12] = ""
      fields = newf
    end

    -- 12列まで空で埋める
    for i = #fields + 1, 12 do fields[i] = "" end

    -- --- 各フィールドをサニタイズ(BOM/置換文字/方向制御等・改行→空白・トリム) ---
    for i = 1, 12 do
      if not fields[i] then fields[i] = "" end
      fields[i] = fields[i]
        :gsub("\239\187\191", "") -- EF BB BF (BOM)
        :gsub("\239\191\189", "") -- EF BF BD (replacement)
        :gsub("\226\128\142", "") -- U+200E
        :gsub("\226\128\143", "") -- U+200F
        :gsub("\226\128\170", "") -- U+202A
        :gsub("\226\128\171", "") -- U+202B
        :gsub("\226\128\172", "") -- U+202C
        :gsub("\226\128\173", "") -- U+202D
        :gsub("\226\128\174", "") -- U+202E
        :gsub("\r",""):gsub("\n"," ")
        :gsub("^%s+",""):gsub("%s+$","")
    end

    -- デバッグ: fields の状況をログへ出力(必要に応じてコメントアウト)
    local dbg = {}
    for i = 1, 12 do dbg[i] = fields[i] or "" end
    texio.write_nl("log", string.format("[FIELDS] idx=%d count=%d fields=%s", idx, #fields, table.concat(dbg, "|")))

    -- --- フィールド取り出し(ここでは 1..12 が対応) ---
    local call = fields[1] or ""
    local cat  = fields[2] or ""
    local divs = {}
    for i = 3, 8 do if fields[i] and fields[i] ~= "" then table.insert(divs, fields[i]) end end

    local raw_score = fields[9] or ""
    local name      = fields[10] or ""
    local ops       = fields[11] or ""
    local ops2      = fields[12] or ""

    -- スコアの整形
    local formatted_score = ""
    local num_score = to_number_safely(raw_score)
    if num_score then formatted_score = format_number(num_score) end

    -- OPS の前処理(改行→空白、先頭ラベル除去、トリム)
    local ops_flat = tostring(ops):gsub("\r",""):gsub("\n"," ")
    ops_flat = ops_flat:gsub("^%s*[Oo][Pp][Ss][::]%s*", "")
    ops_flat = ops_flat:gsub("^%s+",""):gsub("%s+$","")

    -- OPS の行数をざっくり推定(表示幅 0.8\textwidth を想定)
    local n_chars = #ops_flat
    local chars_per_line = 50
    local est_lines = 1
    if n_chars > 0 then est_lines = math.ceil(n_chars / chars_per_line) end

    -- TeX に渡すための最終エスケープ(表示用)
    local esc_call  = escape_tex(call)
    local esc_cat   = escape_tex(cat)
    local esc_name  = escape_tex(name)
    -- NOTE: esc_ops は string.format に渡すと % が問題になるため、detokenize 出力で扱います
    local esc_score = escape_tex(formatted_score)

    -- ==== ここが変更点:divs を 2 列で描画するための行数を使う ====
    local rows = 0
    if #divs > 0 then rows = math.ceil(#divs / 2) else rows = 0 end

    -- レイアウト計算(CALL/NAME の基準は縦行数 rows に依存)
    local base_call_y = 1.5 - (rows + 1) * 0.8
    local base_call_offset = 0.8
    local per_line_raise = 0.35
    local call_offset = base_call_offset + math.max(0, est_lines - 1) * per_line_raise
    local call_y = base_call_y + call_offset

    -- DIV の最上部 y(干渉を避ける)
    local top_div_y = nil
    if rows > 0 then top_div_y = 2.5 - 1 * 0.7 end
    local gap_with_div = 0.9
    if top_div_y and call_y > (top_div_y - gap_with_div) then call_y = top_div_y - gap_with_div end

    local name_y = call_y - 1.2
    local first_gap = 1.2
    local line_height_cm = 0.5
    local ops_yshift = name_y - first_gap

    -- 出力
    tex.print("\\newpage")
    tex.print("\\begin{tikzpicture}[remember picture, overlay]")
							             -- 背景画像
    tex.print("\\node[inner sep=0pt] at (current page.center) {\\includegraphics[width=\\paperwidth,height=\\paperheight,keepaspectratio]{ALLJA_back.pdf}};")

    -- 年度・部門
    tex.print(string.format("\\node[align=center,font=\\Huge\\InterBold,yshift=3.8cm] at (current page.center) {\\Contest};"))
    tex.print(string.format("\\node[align=left,font=\\LARGE,anchor=west,xshift=4cm,yshift=2.5cm] at (current page.west) {%s};", esc_cat))

    -- 種目(左側:**2列表示**)
-- 種目(左側:2列表示、**行優先(DIV1 DIV2 / DIV3 DIV4 ...)**)
if #divs > 0 then
local center_x = 8.0  -- ページ左端からの基準位置(cm)
local col_gap = 6
local left_x  = center_x - col_gap/2
local right_x = center_x + col_gap/2

--  local left_x  = 4.0   -- cm (左列の xshift、必要なら調整)
--  local right_x = 8.0   -- cm (右列の xshift、必要なら調整)
local row_gap = 0.7   -- cm (行間)
  local rows = math.ceil(#divs / 2)
  for r = 1, rows do
    local yshift = 2.5 - r * row_gap
    local left_idx  = (r - 1) * 2 + 1
    local right_idx = left_idx + 1
    if divs[left_idx] then
      tex.print(string.format("\\node[align=left,font=\\LARGE,anchor=west,xshift=%.1fcm,yshift=%.1fcm] at (current page.west) {%s};",
        left_x, yshift, escape_tex(divs[left_idx])))
    end
    if divs[right_idx] then
      tex.print(string.format("\\node[align=left,font=\\LARGE,anchor=west,xshift=%.1fcm,yshift=%.1fcm] at (current page.west) {%s};",
        right_x, yshift, escape_tex(divs[right_idx])))
    end
  end
end

    -- CALL / NAME
    tex.print(string.format("\\node[align=center,font=\\Huge\\InterBold,yshift=%.2fcm] at (current page.center) {%s};", call_y, esc_call))
    tex.print(string.format("\\node[align=center,font=\\XLARGE,yshift=%.2fcm] at (current page.center) {%s};", name_y, escape_tex(name)))

    -- OPS / SCORE
    if ops_flat == "" or ops_flat:match("^%s*$") then
      -- OPS 空 -> NAME 直下に SCORE を表示(存在すれば)
      if esc_score ~= "" and esc_score ~= "0" then
        tex.print(string.format("\\node[align=center,font=\\Palatino\\LARGE,yshift=%.2fcm] at (current page.center) {Total Score %s};", name_y - 1.2, esc_score))
      end
    else
      -- OPS がある場合は detokenize で安全に渡す(自動改行、行間を詰める)
      local node_head = string.format([[
\node[align=center,text width=0.8\textwidth,font=\Large,anchor=center,yshift=%.2fcm] at (current page.center) {\begingroup\setlength{\baselineskip}{0.6\baselineskip}
]], ops_yshift)
      tex.print(node_head .. "\\detokenize{" .. ops_flat .. "}" .. "\\endgroup};")
      -- OPS の下に SCORE
      if esc_score ~= "" and esc_score ~= "0" then
        local score_y = ops_yshift - est_lines * line_height_cm - 0.3
        tex.print(string.format("\\node[align=center,font=\\Palatino\\LARGE,yshift=%.2fcm] at (current page.center) {Total Score %s};", score_y, esc_score))
      end
    end

     -- 下部固定文
    tex.print("\\node[align=left,anchor=west,xshift=6cm,yshift=-7\\baselineskip] at (current page.west) {\\fontsize{16pt}{20pt}\\selectfont\\Libre{\\Round}};")
    tex.print("\\node[align=left,anchor=west,xshift=6cm,yshift=-8.2\\baselineskip] at (current page.west) {\\fontsize{16pt}{16pt}\\selectfont\\Libre{\\Winning}};")
    tex.print("\\node[align=right,anchor=east,xshift=-5cm,yshift=-10\\baselineskip] at (current page.east) {\\fontsize{16pt}{16pt}\\selectfont\\Libre{\\Date}};")
      -- 署名画像(日付の下、紙面下部) - 位置変更なし
      tex.print("\\node[inner sep=0pt,anchor=south,yshift=-15.5cm] at (current page.south) {\\includegraphics[width=0.9\\paperwidth,keepaspectratio]{ALLJA_morita.png}};")

    tex.print("\\end{tikzpicture}")

    ::continue::
  end
end
\end{luacode*}

% CSV を読み込んで出力(ファイル名は適宜)
\directlua{read_csv("2025ALLJA_paper.csv")}

\end{document}
ZIPファイルをダウンロード→certificate.zip

この中で

\newcommand{\Contest}{2025}
\newcommand{\Round}{第67回ALL JAコンテストにおいて}
\newcommand{\Winning}{優秀なる成績を収められたのでこれを賞します}
\newcommand{\Date}{2025年8月23日}
はコンテストごとに異なる文字列なので、お使いになるコンテストに応じて変更してください。また
-- 背景画像
      tex.print("\\node[inner sep=0pt] at (current page.center) {\\includegraphics[width=\\paperwidth,height=\\paperheight,keepaspectratio]{ALLJA_back.pdf}};")
は背景に敷く飾り画像、
-- 署名画像(日付の下、紙面下部) - 位置変更なし
      tex.print("\\node[inner sep=0pt,anchor=south,yshift=-15.5cm] at (current page.south) {\\includegraphics[width=0.9\\paperwidth,keepaspectratio]{ALLJA_morita.png}};")
はJARL会長の署名・押印の画像です。
こちらも自組織の署名などに置き換えてください。背景画像と署名画像はcertificate.texと同じディレクトリに置きます。電子化賞状ではPDFファイルに背景画像と署名画像を置いて出力します。紙賞状を発行する場合は背景と署名を印刷した専用用紙を使うのでコメントアウトしておきます。
tex.print(string.format("\\node[align=center,font=\\XLARGE,yshift=%.2fcm] at (current page.center) {%s};", name_y, escape_tex(name)))
は入賞者名です。紙に印刷する場合はこのままにしておきますが、PDFでWebからダウンロードする場合は個人情報保護の観点から名前は入れない方がいいでしょう。名前を消すにはこの行の先頭に「--」と書くとコメントアウトになります。

入賞局データを用意する

もう一つ用意しないといけないのが発行する入賞局データです。
CSVファイルで、以下のフォーマットになっています。

CALL,CAT,DIV1,DIV2,DIV3,DIV4,DIV5,DIV6,SCORE,NAME,OPS,OPS2
JA1RL,電信電話部門 シングルオペ 50MHzバンド,H 全国第3位,H 関東 第1位,M 全国第2位,M 関東 第1位,P 全国第1位,P 関東 第1位,599599,ハム太郎 殿,,
8J1HAM,電話部門 シングルオペオールバンド,M 全国第2位,M 関東 第1位,,,,,4949,ハム花子 殿,, 
JARLコンテストの場合、H/M/Pの出力区分ごと、エリアごとの順位を出すのでこのような順番になります。地方コンテストで出力区分やエリア順位が必要ないのであれば、「,,」と空欄にしておいてください。カンマを省略してしまうと項目がずれるのでtexファイルの修正が必要になります。名前の次の空欄はマルチオペの場合のオペレーターリストです。オペレータ名を半角カンマで区切ると別フィールドとみなされますので全角カンマやスペースなどで区切ってください。

lualatexで処理する

修正したlatexファイル、ここではcertificate.texというファイル名にします。同じフォルダに入賞局リスト、たとえば2025ALLJA_paper.csv、必要に応じて背景画像・署名画像のファイルを置いてください。
コマンドプロンプトで
lualatex certificate.tex
と入力し、Enterキーを押します。
何も問題がなければcertificate.pdfが出力されます。最初は2回コンパイルしないと真っ白な画像になってしまうかもしれません。

エラーが出てしまった場合はエラー表示をよく見て対応してください。
多いのは修正時のタイプミス、入賞局リストの名前間違い、項目間違い、必要な背景画像、署名画像のファイル名間違いなどです。
分からない時は生成AIにtexファイル全文と入賞局リスト、エラーメッセージを送ってどこが間違っているか聞いてください。修正すべき箇所を教えてくれるはずです。
無事に賞状PDFファイルが生成されたなら、プリンターで印刷するなり、Webからダウンロードできるようにするなりしてください。Webからコールサインを指定してその局の賞状だけを発行する仕組みについてはいずれ公開します。