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というインストールパッケージをダウンロードし、インストールしてください。\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}
この中で
\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会長の署名・押印の画像です。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 certificate.texと入力し、Enterキーを押します。