電子化賞状発行・ダウンロードシステム

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

どういう仕組みでやっているか

電子化賞状は発行リストをlualatexという組版言語で処理し、全入賞局の賞状をPDFファイルとして出力しています。
これをWebサーバー上のPHPスクリプトを使ってコールサインごとに1枚のPDFに切り出し、ブラウザでプレビューし、ダウンロードできるようにしています。
入賞局データをSQLサーバに保存し、リクエストされるとその場でPDFを作成する方がファイルサイズが小さく、スマートだと思います。実際に参加証はそのようにしています。ただ、賞状は参加証よりも見た目、レイアウトにこだわりたいのでLaTeXで組みました。
LaTeXは書店で売られる書籍のレイアウトにも使われる組版言語です。微積分学の定番書である『解析概論』(高木貞治/岩波書店)も現在出ている第5版はLaTeXで組まれています。つまり、それぐらいきれいな紙面が作れるということ(プロがやれば)。
コンテストの賞状では、1局分のPDFファイルも4~500局が繋がった1個のPDFファイルでもファイルサイズはほぼ同じ1MBです。ということはあらかじめ全局に分割してしまうとファイル容量が数百倍になってしまい、年に6回のコンテストでこんなことをやっているとあっという間にサーバの容量を食いつぶしてしまいます。PDFファイルのサイズは、ほとんどがバックに敷いている飾り画像や会長印の画像です。それに乗っているコールサインや部門・種目・得点はごくわずかなテキストデータなので、1局分も500局分もほぼ同じサイズになるわけです。

電子化賞状発行システムの画面遷移

電子化賞状発行システムはjarl.orgのサーバ上で動いています。OSはLinuxです。
入賞局にはメールでコールサイン付きのURLを送ります。

contest.jarl.org/award/2025/allja/JA1ZGO

このURLをクリックするか、賞状発行ページにアクセスし、コールサイン検索窓に発行したいコールサインを入力し、[送信]ボタンを押します。


賞状のプレビュー画面に遷移します。

ダウンロードボタンを押せば、自分のパソコンにPDFファイルで賞状がダウンロードされます。
該当する局が無いと

Error: Callsign 'JR1LQK' was not found.

のようにエラーが表示されます。
移動局の「/」はLinuxでもWindowsでもディレクトリ(フォルダ)の階層区切りなのでそのままは使えません。アンダースコア「_」に置き換えています。

電子化賞状発行システムのファイル

関係しているファイルは

index.php
extract.php
preview.php
download.php
page.csv
awards.pdf
です。
index.phpはユーザがこのページにアクセスすると最初に表示されるファイルです。
<?php
$cookieParams = [
    'lifetime' => 0,       // セッション・クッキー(ブラウザ終了で消える)
    'path'     => '/',
    'domain'   => '',      // 必要なら '.example.com' 等を指定
    'secure'   => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
    'httponly' => true,
    'samesite' => 'Lax'    // 'Lax' or 'Strict' or 'None'
];
if (session_status() === PHP_SESSION_NONE) {
    if (PHP_VERSION_ID >= 70300) {
        session_set_cookie_params($cookieParams);
    } else {
        session_set_cookie_params(
            $cookieParams['lifetime'],
            $cookieParams['path'],
            $cookieParams['domain'],
            $cookieParams['secure'],
            $cookieParams['httponly']
        );
    }
    session_start();
} else {
    if (PHP_VERSION_ID >= 70300) {
        $current = session_get_cookie_params();
        $opts = [
            'expires'  => ($cookieParams['lifetime'] ? time() + $cookieParams['lifetime'] : 0),
            'path'     => $cookieParams['path'] ?? ($current['path'] ?? '/'),
            'domain'   => $cookieParams['domain'] ?? ($current['domain'] ?? ''),
            'secure'   => $cookieParams['secure'] ?? ($current['secure'] ?? false),
            'httponly' => $cookieParams['httponly'] ?? ($current['httponly'] ?? false),
            'samesite' => $cookieParams['samesite'] ?? ($current['samesite'] ?? '')
        ];
        // ヘッダがまだ送られていなければ再発行(必ず出力前にこのファイルを読み込むこと)
        setcookie(session_name(), session_id(), $opts);
    } else {
        error_log("Notice: session already active; cannot call session_set_cookie_params() here.");
    }
}
if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
    if (isset($_SESSION['csrf_token']) && is_array($_SESSION['csrf_token'])) {
        error_log("WARNING: session csrf_token was array; regenerating token (stack trace omitted).");
    }
    // 安全なランダムトークンを作る(bin2hex(random_bytes(32)) は安全)
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$token = (string) $_SESSION['csrf_token'];
?>
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Award Certificate</title>
</head>
<body>
  <h1>2025 ALL JA CONTEST 賞状</h1>

  <form action="extract.php" method="post" autocomplete="off">
    コールサイン:
    <input type="text" name="callsign" required maxlength="10"
           pattern="^[A-Za-z0-9_-]{3,14}$"
           title="英数字、アンダースコア(_)、ハイフン(-)、3〜14文字で入力してください">
    <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
    <button type="submit">送信</button>
  </form>
</body>
</html>

セキュリティのため入力文字をチェックし、英数字、アンダースコア、ハイフンだけを認めています。あまり長い文字列の入力を許すと何が起こるかわからないので14文字までに限定しています。そんな長いコールサインは記念局でもないだろうし。

実際にコールサインに従ってPDFファイルから切り出しているのはextract.phpです。

<?php
// --- 設定 ------------------------------------------------
$SOURCE_PDF = __DIR__ . '/awards.pdf';      // 抜き出し元(フルパス推奨)
$OUTPUT_DIR  = __DIR__ . '/tmp';            // 一時出力ディレクトリ(可能なら web 直下ではなく外に置く)
$QPDF_PATH   = '/usr/bin/qpdf';             // qpdf 実行ファイルのフルパス(環境に合わせて修正)
$RATE_SECONDS = 3;                          // セッション単位の簡易レート制限(秒)

// --- ヘルパ ----------------------------------------------------------------
function bad($msg = 'Error') {
    error_log('[extract.php] ' . $msg);
    // ユーザには一般的なメッセージだけを返す
    header('HTTP/1.1 400 Bad Request');
    echo 'Error: Unable to process request.';
    exit;
}

// セッション開始(重複開始を避ける)
if (session_status() === PHP_SESSION_NONE) {
    session_start();
}

// --- 入力取得 ----------------------------------------------------------------
// callsign は POST 優先、なければ GET (rewrite ルールなどで渡される場合あり)
$callsign_raw = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['callsign'])) {
    $callsign_raw = (string) $_POST['callsign'];
} elseif (isset($_REQUEST['callsign'])) {
    $callsign_raw = (string) $_REQUEST['callsign'];
} else {
    bad('No callsign');
}

// CSRF チェック(POST の場合は必ずチェック)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (empty($_POST['csrf_token']) || empty($_SESSION['csrf_token'])) {
        bad('Missing CSRF token');
    }
    // 安全比較
    if (!function_exists('hash_equals') || !hash_equals((string)$_SESSION['csrf_token'], (string)$_POST['csrf_token'])) {
        bad('Invalid CSRF token');
    }
}

// 簡易レート制限(セッション単位)
if (!isset($_SESSION['last_extract_ts'])) $_SESSION['last_extract_ts'] = 0;
if (time() - (int)$_SESSION['last_extract_ts'] < $RATE_SECONDS) {
    bad('Too many requests');
}
$_SESSION['last_extract_ts'] = time();

// --- callsign バリデーション -------------------------------------------------
// ここでは callsign に許可する文字を制限する(英数字, slash, underscore, hyphen, 最大長等)
if (!preg_match('/^[A-Za-z0-9\/_\-]{3,14}$/', $callsign_raw)) {
    bad('Invalid callsign format');
}
// 正規化:先頭/末尾の空白削除
$callsign_raw = trim($callsign_raw);

// 既存運用に合わせて「/ を _ に置換してファイル名にする」等の処理
$safe_callsign_basename = preg_replace('/[^A-Za-z0-9_-]/', '_', str_replace('/', '_', $callsign_raw));
if ($safe_callsign_basename === '') bad('Invalid callsign after sanitization');
// 最大長制限(念のため)
$safe_callsign_basename = substr($safe_callsign_basename, 0, 64);

// --- page.csv を安全に読み込む(CSV の 2 列目が callsign, 1 列目が page) ---
$page_map = [];
$csvfile = __DIR__ . '/page.csv';
if (!is_readable($csvfile)) {
    bad('Database not available');
}
if (($fh = fopen($csvfile, 'r')) === false) {
    bad('Failed to open DB');
}
while (($row = fgetcsv($fh)) !== false) {
    // 期待: [page, callsign, ...]
    if (!isset($row[0]) || !isset($row[1])) continue;
    $page_raw = trim($row[0]);
    $cs_raw   = trim($row[1]);

    // page は整数に制限
    if (!preg_match('/^\d+$/', $page_raw)) continue;
    $page_num = intval($page_raw);
    if ($page_num <= 0) continue;

    // normalize key variants so lookup works whether CSV uses "/" or "_" etc.
    $k1 = $cs_raw;
    $k2 = str_replace('/', '_', $cs_raw);
    $k3 = str_replace('_', '/', $cs_raw);

    $page_map[$k1] = $page_num;
    $page_map[$k2] = $page_num;
    $page_map[$k3] = $page_num;
}
fclose($fh);

// --- 対応するページ番号を探す -----------------------------------------------
$lookup_keys = [
    $callsign_raw,
    str_replace('/', '_', $callsign_raw),
    str_replace('_', '/', $callsign_raw),
    $safe_callsign_basename
];

$page_number = null;
foreach ($lookup_keys as $k) {
    if (isset($page_map[$k])) { $page_number = $page_map[$k]; break; }
}
if ($page_number === null) {
    bad('Callsign not found');
}

// 追加安全チェック: page_number はすでに整数化済み
$page_number = intval($page_number);
if ($page_number <= 0) bad('Invalid page number');

// --- 出力ファイルパスの準備 -----------------------------------------------
if (!is_dir($OUTPUT_DIR)) {
    if (!mkdir($OUTPUT_DIR, 0700, true)) {
        bad('Failed to create output dir');
    }
}

// 出力ファイル名・パスを決定(tmp ディレクトリの下に限定)
$output_filename = $safe_callsign_basename . '.pdf';
$output_path = $OUTPUT_DIR . DIRECTORY_SEPARATOR . $output_filename;

// 既に同名ファイルが存在する場合は上書き/再生成する仕様(必要に応じ変更可)
if (file_exists($output_path)) {
    // 安全のため、unlink してから再生成(権限に注意)
    @unlink($output_path);
}

// --- qpdf 呼び出し(安全に) ----------------------------------------------
// qpdf を直接叩く必要があるなら引数は必ず escapeshellarg() で囲む
if (!is_file($SOURCE_PDF) || !is_readable($SOURCE_PDF)) {
    bad('Source PDF missing');
}
if (!is_executable($QPDF_PATH) && !file_exists($QPDF_PATH)) {
    // 環境により /usr/bin/qpdf の場所を調整してください
    error_log("[extract.php] qpdf not found at configured path: " . $QPDF_PATH);
    bad('Server configuration error');
}

$src_esc = escapeshellarg($SOURCE_PDF);
$out_esc = escapeshellarg($output_path);
$page = intval($page_number);

// コマンド組立(--pages のページ指定は整数なので安全に扱える)
$cmd = sprintf('%s %s --pages %s %d -- %s 2>&1', escapeshellarg($QPDF_PATH), $src_esc, $src_esc, $page, $out_esc);

// 実行(タイムアウトの必要があれば proc_open で実装)
exec($cmd, $cmd_out, $cmd_status);

// ログ(デバッグ時のみ; 本番では詳細ログは制限)
if ($cmd_status !== 0) {
    error_log('[extract.php] qpdf failed. cmd=' . $cmd . ' status=' . intval($cmd_status) . ' out=' . implode("\n", $cmd_out));
    bad('PDF extraction failed');
}

// 出力ファイルが作成されているか確認し、安全な場所にあるか realpath でチェック
$real_out = realpath($output_path);
$real_tmp = realpath($OUTPUT_DIR);
if ($real_out === false || $real_tmp === false || strpos($real_out, $real_tmp) !== 0) {
    error_log('[extract.php] output path validation failed: real_out=' . var_export($real_out, true) . ' real_tmp=' . var_export($real_tmp, true));
    bad('Output validation failed');
}
if (!file_exists($real_out) || filesize($real_out) === 0) {
    error_log('[extract.php] output file missing or empty: ' . $real_out);
    bad('Output file not available');
}

// --- 成功: preview へリダイレクト -------------------------------------------
$redirect_file = rawurlencode($output_filename);
// 安全のため絶対パスや外部URLを直接使わない
header('Location: preview.php?file=' . $redirect_file);
exit;

検索窓から入力されたコールサイン、あるいは直接URLに付けて呼ばれたコールサインを受け取り、page.csvファイルでページ番号に変換し、PDFファイルからそのページの画像をテンポラリファイルとして書き出します。
page.csvはコールサインとPDFファイル中のページ番号を紐づけたファイルです。
1,7K1PEO/2
2,7K1PTO/1
3,7K4GUR
4,7K4TKB
5,7L1ETP/1
・・・
のようになっています。PDFファイルを作るときの入賞局リストから作成しました。
パラメータとして与えたコールサインがpage.csvに存在すればそのページ番号でPDFファイルから抽出します。該当局が無いと
Error: Unable to process request.
と表示されます。
抽出されたコールサイン.pdfファイルはtmpフォルダに保存されます。
そしてpreview.phpによってPDFファイルが表示されます。
<?php
// preview.php - wrapper + raw PDF serving
// - If ?mode=raw is present, serve PDF inline (application/pdf) as before (use strict validation).
// - Otherwise, return an HTML page that embeds the PDF and shows a "Download" button.
// Make sure TMP_DIR and validation match extract.php / download.php.

define('TMP_DIR', __DIR__ . '/tmp');
define('MAX_FILENAME_LEN', 128);

function error_exit($msg = 'Unable to serve file', $code = 400) {
    error_log('[preview.php] ' . $msg);
    http_response_code($code);
    echo 'Error: Unable to display file.';
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    error_exit('Invalid method', 405);
}

if (!isset($_GET['file']) || !is_string($_GET['file'])) {
    error_exit('Missing file parameter');
}
$file = $_GET['file'];

if (strlen($file) === 0 || strlen($file) > MAX_FILENAME_LEN) {
    error_exit('Invalid filename length');
}
if (!preg_match('/^[A-Za-z0-9_\-]+\.pdf$/', $file)) {
    error_exit('Invalid filename format');
}

$path = TMP_DIR . DIRECTORY_SEPARATOR . $file;
$real_path = realpath($path);
$real_tmp  = realpath(TMP_DIR);
if ($real_path === false || $real_tmp === false) {
    error_exit('File not found', 404);
}
if (strpos($real_path, $real_tmp) !== 0) {
    error_exit('Access denied', 403);
}
if (!is_file($real_path) || !is_readable($real_path)) {
    error_exit('File missing or unreadable', 404);
}

$mode = (isset($_GET['mode']) ? $_GET['mode'] : 'html');

if ($mode === 'raw') {
    // Serve PDF inline (as earlier). Keep security headers.
    $filesize = filesize($real_path);
    if ($filesize === 0) error_exit('Empty file', 404);

    // MIME check optional (finfo) — or assume PDF if you validated earlier
    header('Content-Type: application/pdf');
    header('Content-Length: ' . $filesize);
    // inline display
    $filename_http = rawurlencode(basename($real_path));
    header("Content-Disposition: inline; filename=\"${filename_http}\"; filename*=UTF-8''${filename_http}");
    header('X-Content-Type-Options: nosniff');
    header('X-Frame-Options: SAMEORIGIN');
    header("Content-Security-Policy: object-src 'none'; frame-ancestors 'self';");
    header('X-Frame-Options: SAMEORIGIN');
    header('Cache-Control: private, max-age=0, must-revalidate');

    $fh = fopen($real_path, 'rb');
    if ($fh === false) error_exit('Cannot open file', 500);
    while (!feof($fh)) {
        echo fread($fh, 8192);
        @ob_flush(); @flush();
    }
    fclose($fh);
    exit;
}

// Otherwise: return an HTML wrapper page with an embedded viewer + download button.
// Use download.php for the download link (safer: it serves as attachment).
$download_url = 'download.php?file=' . rawurlencode(basename($real_path));
$raw_view_url = 'preview.php?file=' . rawurlencode(basename($real_path)) . '&mode=raw';

// Minimal safe HTML output
?><!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Preview: <?php echo htmlspecialchars(basename($real_path), ENT_QUOTES, 'UTF-8'); ?></title>
<style>
  body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; }
  .toolbar { display:flex; gap:0.5rem; align-items:center; padding:0.6rem; background:#f6f6f6; border-bottom:1px solid #ddd; }
  .toolbar .title { flex:1; font-weight:600; }
  .btn { display:inline-block; padding:0.45rem 0.8rem; background:#007bff; color:#fff; text-decoration:none; border-radius:4px; }
  .btn.secondary { background:#6c757d; }
  .viewer { width:100%; height:calc(100vh - 54px); border:0; }
  @media (max-width:600px){ .toolbar { padding:0.4rem } .viewer { height:calc(100vh - 48px); } }
</style>
</head>
<body>
  <div class="toolbar" role="toolbar" aria-label="PDF toolbar">
    <div class="title"><?php echo htmlspecialchars(basename($real_path), ENT_QUOTES, 'UTF-8'); ?></div>
    <a class="btn" href="<?php echo $download_url; ?>" rel="noopener" download>ダウンロード</a>
    <a class="btn secondary" href="<?php echo $raw_view_url; ?>" target="_blank" rel="noopener">別タブで表示</a>
  </div>

  <!-- embed the PDF. using <iframe> keeps UI consistent; <object> or <embed> are alternatives -->
  <iframe class="viewer" src="<?php echo $raw_view_url; ?>" title="PDF Preview"></iframe>

  <noscript>
    <p>ブラウザでPDFが表示できません。<a href="<?php echo $download_url; ?>">こちらをクリックしてダウンロード</a>してください。</p>
  </noscript>
</body>
</html>
<?php
exit;
コールサインがヒットすれば賞状がプレビューされ、[ダウンロード]ボタンをクリックすると自分のパソコンにPDFファイルが保存されます。
ダウンロードはdownload.phpが担当しています。そのコードは
<?php
// download.php - secure PDF download handler
// Place this file alongside preview.php/extract.php. Adjust TMP_DIR to match extract.php output.

declare(strict_types=1);

// --- Config ----------------------------------------------------------
define('TMP_DIR', __DIR__ . '/tmp');   // 出力ディレクトリ(extract.php と同じにする)
define('MAX_FILENAME_LEN', 128);
define('CHUNK_SIZE', 8192);            // 読み出しチャンク
// ---------------------------------------------------------------------

function error_exit(string $logmsg, int $http_code = 400): void {
    // 内部ログは詳細を残すが、ユーザには曖昧なメッセージのみ返す
    error_log('[download.php] ' . $logmsg);
    http_response_code($http_code);
    echo 'Error: Unable to download file.';
    exit;
}

// GET で file= を受け取る仕様
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    error_exit('Invalid request method: ' . $_SERVER['REQUEST_METHOD'], 405);
}

if (!isset($_GET['file']) || !is_string($_GET['file'])) {
    error_exit('Missing file parameter');
}

$filename = $_GET['file'];

// 長さチェック
if ($filename === '' || strlen($filename) > MAX_FILENAME_LEN) {
    error_exit('Invalid filename length');
}

// 厳密なファイル名パターン(英数字、アンダースコア、ハイフン、末尾は .pdf のみ)
// スラッシュ禁止 ->パス横断を防止。realpath 二重チェックも行う。
if (!preg_match('/^[A-Za-z0-9_\-]+\.pdf$/', $filename)) {
    error_exit('Invalid filename format');
}

// 絶対パス候補
$path = TMP_DIR . DIRECTORY_SEPARATOR . $filename;

// realpath で検証(存在し、かつ TMP_DIR 以下であること)
$real_path = realpath($path);
$real_tmp  = realpath(TMP_DIR);
if ($real_path === false || $real_tmp === false) {
    error_exit('File not found', 404);
}
if (strpos($real_path, $real_tmp) !== 0) {
    error_exit('Access denied (path validation)', 403);
}

// 存在/読み込みチェック
if (!is_file($real_path) || !is_readable($real_path)) {
    error_exit('File missing or unreadable', 404);
}

// サイズチェック(空ファイルや非常に大きいファイルのポリシーに応じて調整)
$filesize = filesize($real_path);
if ($filesize === 0) {
    error_exit('Empty file', 404);
}

// MIME タイプ確認(finfo) — PDF であることを確認する
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime  = $finfo->file($real_path) ?: 'application/octet-stream';
// Allow common PDF mime types
$allowed_mimes = ['application/pdf', 'application/x-pdf'];
if (!in_array($mime, $allowed_mimes, true)) {
    // ここはポリシー次第で柔軟にすること(ログは残す)
    error_exit('Invalid file type: ' . $mime, 403);
}

// 安全な出力名(HTTPヘッダに直接未検証文字を入れない)
// header() の値でダブルクオート等を入れるとヘッダインジェクションの危険があるため除去
$basename = basename($real_path); // ここは正規表現で既に安全化済み
$safe_basename = str_replace('"', '', $basename);
$encoded_name = rawurlencode($safe_basename);

// セキュリティヘッダ(OWASP 推奨の堅牢化)
// MIME スニッフィング防止
header('X-Content-Type-Options: nosniff');
// クリックジャッキング防止
header('X-Frame-Options: SAMEORIGIN');
// コンテンツセキュリティポリシー(埋め込み/スクリプト実行の抑止)
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none';");
// キャッシュ制御(ポリシーに応じて変更)
header('Cache-Control: private, max-age=0, must-revalidate');
header('Pragma: public');

// Download 用ヘッダ
header('Content-Description: File Transfer');
// MIME は finfo で確定(上で PDF を確認済み)
header('Content-Type: ' . $mime);
header('Content-Length: ' . $filesize);

// RFC6266 互換でのファイル名指定(ASCII filename と filename* の両方を出す)
header('Content-Disposition: attachment; filename="' . $safe_basename . '"; filename*=UTF-8\'\'' . $encoded_name);

// 出力(バイナリセーフ、チャンクで)
$fh = fopen($real_path, 'rb');
if ($fh === false) {
    error_exit('Cannot open file', 500);
}

// turn off output buffering to stream directly
if (function_exists('apache_setenv')) {
    @apache_setenv('no-gzip', '1');
}
@ini_set('zlib.output_compression', '0');

while (!feof($fh)) {
    $buf = fread($fh, CHUNK_SIZE);
    if ($buf === false) break;
    echo $buf;
    // フラッシュ(ベストエフォート)
    @ob_flush();
    @flush();
}
fclose($fh);
exit;

です。
まとめたZIPファイルはZIPファイルをダウンロード→certificate2.zipです。
tmp/フォルダに生成されたコールサイン.pdfファイルはそのままだと溜まってしまい、サーバのストレージを圧迫するので定期的にチェックし、10分以上前に作成されたファイルは削除します。これにはLinuxのcronを使っています。
#! /bin/bash
find /var/www/html/award/2024/aacw/tmp/ -maxdepth 1 -type f -user apache -mmin +10 -exec rm -f {} \;
find /var/www/html/award/2024/aaph/tmp/ -maxdepth 1 -type f -user apache -mmin +10 -exec rm -f {} \;
find /var/www/html/award/2025/allja/tmp/ -maxdepth 1 -type f -user apache -mmin +10 -exec rm -f {} \;