NaN/NaN/NaN

HTMLで美しく設計したテーブルを PDF に変換したとき、ヘッダーが消えて 2ページ目の行が宙に浮いていたり、1行だけが切れてページをまたいでいたり――そんな経験はありませんか?

テーブルは PDF 変換で最もトラブルが多い要素のひとつです。この記事では、FUNBREW PDF API や Chromium ベースのエンジンを使う際に役立つ、テーブルの改ページ制御テクニックを網羅的に解説します。

なぜ PDF でテーブルが切れるのか

ブラウザはページの概念がなくコンテンツをスクロールで表示しますが、PDF は固定高さの「ページ」に分割します。PDF エンジンはコンテンツを上から順に配置し、ページ末尾に到達したら次ページへ移動します。

このとき、テーブルの行が「ちょうどページの境界線にかかる」場合に問題が起きます。

よくある症状:

症状 原因
ページをまたいだ行が途中で切れる break-inside 未設定
2ページ目以降にヘッダー行がない thead の繰り返し設定が不十分
colspan/rowspan 行で崩れる 複雑なセル結合と改ページの干渉
合計行が孤立してページ末尾に残る フッター行の分割防止未設定

CSSによる改ページ制御の基本

break-inside: avoid でテーブルを分割させない

もっともシンプルな解決策は、テーブル全体を1ページ内に収めることです。

/* テーブル全体が1ページに収まるなら、分割させない */
table {
  break-inside: avoid;
  page-break-inside: avoid; /* 古いエンジン向けフォールバック */
}

ただし、行数が多いテーブルでは1ページに収まらないため、この設定だけでは不十分です。

行単位で分割を防ぐ

テーブルが複数ページにわたる場合、行(<tr>)を途中で切らない設定が基本です。

/* 行が改ページで分割されるのを防ぐ */
tr {
  break-inside: avoid;
  page-break-inside: avoid;
}

/* Chromiumベースのエンジン(FUNBREW PDFのqualityエンジン)での確実な設定 */
tr {
  break-inside: avoid;
  page-break-inside: avoid;
  -webkit-column-break-inside: avoid;
}

セル内テキストの折り返し制御

セル内のテキストが長すぎると、行の高さが増してページ境界にかかりやすくなります。

td, th {
  /* 長い単語を折り返す */
  overflow-wrap: break-word;
  word-break: break-word;

  /* URLやコードが横にはみ出さないようにする */
  max-width: 200px;
  white-space: normal;
}

thead 繰り返し表示のテクニック

複数ページにわたるテーブルで、2ページ目以降にもヘッダー行を表示するには display: table-header-group を使います。

/* ページをまたぐとき、theadを各ページの先頭に繰り返す */
thead {
  display: table-header-group;
}

/* tfoot(合計行など)を最終ページの末尾に固定 */
tfoot {
  display: table-footer-group;
}

HTMLマークアップの確認

thead/tfoot の繰り返しが機能するには、HTML の構造が正しい必要があります。

<table>
  <!-- theadを必ず明示的に指定 -->
  <thead>
    <tr>
      <th>品目</th>
      <th>数量</th>
      <th>単価</th>
      <th>小計</th>
    </tr>
  </thead>

  <!-- データ行はtbodyに入れる -->
  <tbody>
    <tr>
      <td>Webサイト制作</td>
      <td>1</td>
      <td>¥300,000</td>
      <td>¥300,000</td>
    </tr>
    <!-- ... -->
  </tbody>

  <!-- 合計行はtfootに入れると最終ページ末尾に固定 -->
  <tfoot>
    <tr>
      <td colspan="3">合計</td>
      <td>¥330,000</td>
    </tr>
  </tfoot>
</table>

注意: tfoot を使うと合計行が常に最終ページの末尾に表示されます。合計行をデータ行の直後に置きたい場合は、tbody 内の最終行として実装し、break-inside: avoid でグループ化する方法(後述)を使ってください。

theadのスタイリング

ページをまたいで繰り返されるヘッダーは、視認性を高めるスタイリングが重要です。

thead tr {
  background-color: #1e3a5f;
  color: #ffffff;

  /* PDFで背景色を出力するために必須 */
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

thead th {
  padding: 10px 14px;
  font-weight: 700;
  font-size: 10pt;
  text-align: left;
  border-bottom: 2px solid #0f2443;
}

/* 繰り返し表示されるheadの上部に区切り線を追加 */
@media print {
  thead {
    border-top: 1px solid #e2e8f0;
  }
}

長いテーブルの分割戦略

複数の tbody でグループ管理

行数が多い場合、<tbody> を複数に分割して各グループの分割を防ぐのが実用的なパターンです。請求書の月別小計や、カテゴリ別集計に有効です。

<table>
  <thead>
    <tr>
      <th>月</th>
      <th>項目</th>
      <th>金額</th>
    </tr>
  </thead>

  <!-- 1月分グループ(まとめて次ページに移動させる) -->
  <tbody style="break-inside: avoid; page-break-inside: avoid;">
    <tr>
      <td rowspan="3">1月</td>
      <td>開発費</td>
      <td>¥500,000</td>
    </tr>
    <tr>
      <td>サーバー費</td>
      <td>¥50,000</td>
    </tr>
    <tr class="subtotal">
      <td>1月小計</td>
      <td>¥550,000</td>
    </tr>
  </tbody>

  <!-- 2月分グループ -->
  <tbody style="break-inside: avoid; page-break-inside: avoid;">
    <tr>
      <td rowspan="2">2月</td>
      <td>開発費</td>
      <td>¥480,000</td>
    </tr>
    <tr>
      <td>サーバー費</td>
      <td>¥50,000</td>
    </tr>
  </tbody>
</table>

CSSでまとめて指定する場合:

/* 全tbodyのbreak-insideをまとめて設定 */
tbody {
  break-inside: avoid;
  page-break-inside: avoid;
}

/* 小計行のスタイル */
tr.subtotal td {
  font-weight: 700;
  background-color: #f0f4ff;
  border-top: 2px solid #c7d7f0;
  border-bottom: 2px solid #c7d7f0;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

ページ境界での強制分割

逆に、特定の位置で強制的に改ページしたい場合は、ダミー行を使う方法があります。

<!-- 特定のtbodyの前で改ページ -->
<tbody>
  <tr class="page-break-row">
    <td colspan="4" style="
      break-before: page;
      page-break-before: always;
      padding: 0;
      height: 0;
      border: none;
    "></td>
  </tr>
  <tr>
    <td>新しいページのデータ</td>
    <!-- ... -->
  </tr>
</tbody>

または、CSS クラスで管理:

/* テーブル内の改ページポイント */
.table-page-break {
  break-before: page;
  page-break-before: always;
}

/* ダミー行は視覚的に消す */
.table-page-break td {
  padding: 0;
  height: 0;
  border: none;
  font-size: 0;
}

複雑なテーブル(colspan / rowspan)の対応

colspanrowspan を使ったテーブルは、改ページとの組み合わせで特に問題が起きやすいです。

rowspan のある行の分割問題

rowspan="3" の行がページ境界にかかると、結合セルが分断されて表示が崩れます。

問題のあるパターン:

<!-- rowspan行が改ページで切れる例 -->
<tr>
  <td rowspan="4">カテゴリA</td>  <!-- ←この行がページ末尾に来ると崩れる -->
  <td>項目1</td>
  <td>¥10,000</td>
</tr>
<tr>
  <td>項目2</td>
  <td>¥20,000</td>
</tr>

解決策: rowspanグループをtbodyで囲む

<table>
  <thead>
    <tr>
      <th>カテゴリ</th>
      <th>項目</th>
      <th>金額</th>
    </tr>
  </thead>

  <!-- rowspanグループをtbodyで分割防止 -->
  <tbody style="break-inside: avoid;">
    <tr>
      <td rowspan="4">カテゴリA</td>
      <td>項目1</td>
      <td>¥10,000</td>
    </tr>
    <tr><td>項目2</td><td>¥20,000</td></tr>
    <tr><td>項目3</td><td>¥15,000</td></tr>
    <tr class="subtotal">
      <td>小計</td><td>¥45,000</td>
    </tr>
  </tbody>

  <tbody style="break-inside: avoid;">
    <tr>
      <td rowspan="3">カテゴリB</td>
      <td>項目A</td>
      <td>¥30,000</td>
    </tr>
    <tr><td>項目B</td><td>¥25,000</td></tr>
    <tr class="subtotal">
      <td>小計</td><td>¥55,000</td>
    </tr>
  </tbody>
</table>

colspanを含むヘッダーの繰り返し

複雑なマルチレベルヘッダーを繰り返す場合:

<table>
  <thead>
    <!-- 2段のヘッダー行もtheadに含めれば繰り返される -->
    <tr>
      <th rowspan="2">品目</th>
      <th colspan="2">第1四半期</th>
      <th colspan="2">第2四半期</th>
    </tr>
    <tr>
      <th>予算</th>
      <th>実績</th>
      <th>予算</th>
      <th>実績</th>
    </tr>
  </thead>
  <tbody>
    <!-- ... -->
  </tbody>
</table>
/* 複数行theadのスタイル */
thead tr:first-child th {
  background-color: #1e3a5f;
  color: #ffffff;
  text-align: center;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

thead tr:last-child th {
  background-color: #2d5585;
  color: #ffffff;
  font-size: 9pt;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

レスポンシブテーブルの PDF 対応

レスポンシブ設計のテーブルは、画面では横スクロールやカード型表示に切り替わりますが、PDF ではそのまま崩れることがあります。

横スクロールテーブルをPDFで縮小表示

/* 画面では横スクロール */
.table-wrapper {
  overflow-x: auto;
}

/* PDF生成時は縮小して1ページ幅に収める */
@media print {
  .table-wrapper {
    overflow: visible;
  }

  /* テーブルをページ幅に合わせて縮小 */
  .table-responsive {
    width: 100%;
    font-size: 8pt; /* フォントを小さくして収める */
    table-layout: fixed;
  }

  .table-responsive td,
  .table-responsive th {
    padding: 5px 6px; /* パディングも縮小 */
    word-break: break-word;
  }
}

カード型レスポンシブをPDF向けにテーブル表示に戻す

画面ではカード型(display: block)表示のテーブルを、PDF では通常のテーブル表示に戻す方法:

/* 画面ではカード型 */
@media screen and (max-width: 768px) {
  table, thead, tbody, th, td, tr {
    display: block;
  }
  thead tr {
    display: none; /* 画面ではヘッダー行を隠す */
  }
  td::before {
    content: attr(data-label);
    font-weight: 700;
    display: inline-block;
    width: 40%;
  }
}

/* PDFでは通常のテーブル表示に戻す */
@media print {
  table { display: table; }
  thead { display: table-header-group; }
  tbody { display: table-row-group; }
  tr { display: table-row; }
  th, td { display: table-cell; }
  thead tr { display: table-row; } /* ヘッダーを表示 */
  td::before { display: none; } /* ラベルプレフィックスを非表示 */
}

列幅の固定とPDF最適化

ブラウザと PDF エンジンでは列幅の計算が異なる場合があります。table-layout: fixed で固定幅を指定すると安定します。

table {
  table-layout: fixed;
  width: 100%;
}

/* 列幅を明示的に指定 */
.col-item   { width: 40%; }
.col-qty    { width: 10%; }
.col-price  { width: 20%; }
.col-total  { width: 20%; }
.col-action { width: 10%; }
<table>
  <colgroup>
    <col class="col-item">
    <col class="col-qty">
    <col class="col-price">
    <col class="col-total">
  </colgroup>
  <thead>
    <tr>
      <th>品目</th>
      <th>数量</th>
      <th>単価</th>
      <th>小計</th>
    </tr>
  </thead>
  <!-- ... -->
</table>

テーブルの完全なスタイリング例

PDF出力で安定して動作するテーブルのCSSをまとめます。

/* === テーブルベーススタイル === */
table {
  width: 100%;
  border-collapse: collapse;
  font-size: 10pt;
  line-height: 1.5;
  table-layout: fixed;

  /* テーブル全体の改ページ制御 */
  page-break-inside: auto;
}

/* === ヘッダー === */
thead {
  display: table-header-group; /* ページをまたいで繰り返す */
}

thead tr {
  background-color: #1e40af;
  color: #ffffff;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

thead th {
  padding: 10px 12px;
  font-weight: 700;
  text-align: left;
  border: 1px solid #1e3a8a;
  font-size: 9.5pt;
}

/* === フッター === */
tfoot {
  display: table-footer-group; /* 最終ページ末尾に固定 */
}

tfoot tr {
  background-color: #f0f4ff;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

tfoot td {
  padding: 10px 12px;
  font-weight: 700;
  border: 1px solid #c7d2fe;
  border-top: 2px solid #4f46e5;
}

/* === データ行 === */
tbody {
  /* tbodyを単位とした改ページ防止(グループ化戦略で使用) */
}

tr {
  break-inside: avoid;
  page-break-inside: avoid;
}

td {
  padding: 8px 12px;
  border: 1px solid #e2e8f0;
  vertical-align: top;
  overflow-wrap: break-word;
  word-break: break-word;
}

/* 偶数行のストライプ */
tbody tr:nth-child(even) td {
  background-color: #f8fafc;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

/* 小計・合計行 */
tr.subtotal td {
  font-weight: 700;
  background-color: #eff6ff;
  border-top: 2px solid #bfdbfe;
  border-bottom: 2px solid #bfdbfe;
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

/* 金額列は右寄せ */
td.amount, th.amount {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* 数値の桁揃え */
td.number {
  text-align: right;
  font-family: 'Noto Sans JP', monospace;
  font-variant-numeric: tabular-nums;
}

実践例:明細付き請求書テーブル

実際の請求書テーブルの完全な実装例です。改ページ対応・thead繰り返し・合計行の固定をすべて含みます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    @page {
      size: A4;
      margin: 20mm 15mm;
    }

    body {
      font-family: 'Noto Sans JP', 'Hiragino Sans', sans-serif;
      font-size: 10pt;
      color: #1f2937;
    }

    /* ===== 請求書ヘッダー ===== */
    .invoice-header {
      display: flex;
      justify-content: space-between;
      margin-bottom: 24pt;
      break-inside: avoid;
    }

    .invoice-title {
      font-size: 24pt;
      font-weight: 700;
      color: #1e40af;
    }

    /* ===== 明細テーブル ===== */
    .line-items-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 9.5pt;
      table-layout: fixed;
      margin-bottom: 0;
    }

    /* 列幅定義 */
    .col-no     { width: 6%; }
    .col-desc   { width: 40%; }
    .col-qty    { width: 10%; }
    .col-unit   { width: 18%; }
    .col-amount { width: 18%; }
    .col-note   { width: 8%; }

    /* ヘッダー(ページをまたいで繰り返し表示) */
    .line-items-table thead {
      display: table-header-group;
    }

    .line-items-table thead tr {
      background-color: #1e3a5f;
      color: #ffffff;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }

    .line-items-table thead th {
      padding: 9px 10px;
      font-weight: 700;
      border: 1px solid #0f2443;
      text-align: left;
    }

    .line-items-table thead th.align-right {
      text-align: right;
    }

    /* データ行(行単位で分割防止) */
    .line-items-table tbody tr {
      break-inside: avoid;
      page-break-inside: avoid;
    }

    .line-items-table tbody td {
      padding: 7px 10px;
      border: 1px solid #d1d5db;
      vertical-align: top;
      overflow-wrap: break-word;
    }

    .line-items-table tbody tr:nth-child(even) td {
      background-color: #f9fafb;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }

    /* 金額列 */
    .align-right { text-align: right; }
    .font-mono {
      font-variant-numeric: tabular-nums;
    }

    /* カテゴリ区切り行(tbodyで分割防止) */
    .line-items-table tbody.category-group {
      break-inside: avoid;
    }

    /* カテゴリヘッダー行 */
    .line-items-table tr.category-header td {
      background-color: #dbeafe;
      font-weight: 700;
      font-size: 9pt;
      color: #1e3a8a;
      padding: 5px 10px;
      border-top: 2px solid #93c5fd;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }

    /* 小計行 */
    .line-items-table tr.subtotal td {
      font-weight: 700;
      background-color: #eff6ff;
      border-top: 2px solid #bfdbfe;
      border-bottom: 2px solid #bfdbfe;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }

    /* ===== 合計セクション(改ページ分割防止) ===== */
    .total-section {
      break-inside: avoid;
      page-break-inside: avoid;
      margin-top: 0;
      border: 1px solid #d1d5db;
      border-top: none;
    }

    .total-section table {
      width: 100%;
      border-collapse: collapse;
    }

    .total-section td {
      padding: 7px 10px;
      border-bottom: 1px solid #e5e7eb;
    }

    .total-section .total-row {
      background-color: #1e3a5f;
      color: #ffffff;
      font-size: 12pt;
      font-weight: 700;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }

    .total-section .total-row td {
      border-bottom: none;
    }
  </style>
</head>
<body>

  <div class="invoice-header">
    <div>
      <div class="invoice-title">御請求書</div>
      <p>請求番号: INV-2026-0042</p>
      <p>発行日: 2026年4月5日</p>
      <p>支払期限: 2026年4月30日</p>
    </div>
    <div style="text-align: right;">
      <p style="font-weight: 700; font-size: 12pt;">株式会社サンプル</p>
      <p>東京都渋谷区○○1-2-3</p>
    </div>
  </div>

  <!-- 明細テーブル(theadが自動繰り返し) -->
  <table class="line-items-table">
    <colgroup>
      <col class="col-no">
      <col class="col-desc">
      <col class="col-qty">
      <col class="col-unit">
      <col class="col-amount">
      <col class="col-note">
    </colgroup>

    <thead>
      <tr>
        <th class="align-right">No.</th>
        <th>品目・摘要</th>
        <th class="align-right">数量</th>
        <th class="align-right">単価</th>
        <th class="align-right">金額</th>
        <th>税区分</th>
      </tr>
    </thead>

    <!-- カテゴリ1: 開発費(グループ単位で分割防止) -->
    <tbody class="category-group">
      <tr class="category-header">
        <td colspan="6">■ 開発費</td>
      </tr>
      <tr>
        <td class="align-right">1</td>
        <td>Webアプリケーション開発<br><span style="font-size:8.5pt; color:#6b7280;">フロントエンド実装(React)</span></td>
        <td class="align-right font-mono">1式</td>
        <td class="align-right font-mono">¥800,000</td>
        <td class="align-right font-mono">¥800,000</td>
        <td class="align-right">課税</td>
      </tr>
      <tr>
        <td class="align-right">2</td>
        <td>バックエンドAPI開発<br><span style="font-size:8.5pt; color:#6b7280;">REST API設計・実装(Node.js)</span></td>
        <td class="align-right font-mono">1式</td>
        <td class="align-right font-mono">¥600,000</td>
        <td class="align-right font-mono">¥600,000</td>
        <td class="align-right">課税</td>
      </tr>
      <tr>
        <td class="align-right">3</td>
        <td>データベース設計・構築</td>
        <td class="align-right font-mono">1式</td>
        <td class="align-right font-mono">¥200,000</td>
        <td class="align-right font-mono">¥200,000</td>
        <td class="align-right">課税</td>
      </tr>
      <tr class="subtotal">
        <td colspan="4" class="align-right">開発費 小計</td>
        <td class="align-right font-mono">¥1,600,000</td>
        <td></td>
      </tr>
    </tbody>

    <!-- カテゴリ2: インフラ費(グループ単位で分割防止) -->
    <tbody class="category-group">
      <tr class="category-header">
        <td colspan="6">■ インフラ・その他費用</td>
      </tr>
      <tr>
        <td class="align-right">4</td>
        <td>クラウドサーバー初期設定<br><span style="font-size:8.5pt; color:#6b7280;">AWS EC2/RDS構築、VPC設定</span></td>
        <td class="align-right font-mono">1式</td>
        <td class="align-right font-mono">¥150,000</td>
        <td class="align-right font-mono">¥150,000</td>
        <td class="align-right">課税</td>
      </tr>
      <tr>
        <td class="align-right">5</td>
        <td>ドメイン・SSL証明書取得</td>
        <td class="align-right font-mono">1式</td>
        <td class="align-right font-mono">¥30,000</td>
        <td class="align-right font-mono">¥30,000</td>
        <td class="align-right">課税</td>
      </tr>
      <tr class="subtotal">
        <td colspan="4" class="align-right">インフラ費 小計</td>
        <td class="align-right font-mono">¥180,000</td>
        <td></td>
      </tr>
    </tbody>
  </table>

  <!-- 合計セクション(テーブルと連続させて改ページ防止) -->
  <div class="total-section">
    <table>
      <tr>
        <td style="width:70%; text-align:right; color:#6b7280;">小計(税抜)</td>
        <td style="width:20%; text-align:right;" class="font-mono">¥1,780,000</td>
        <td style="width:10%;"></td>
      </tr>
      <tr>
        <td style="text-align:right; color:#6b7280;">消費税(10%)</td>
        <td style="text-align:right;" class="font-mono">¥178,000</td>
        <td></td>
      </tr>
      <tr class="total-row">
        <td style="text-align:right;">合計金額(税込)</td>
        <td style="text-align:right;" class="font-mono">¥1,958,000</td>
        <td></td>
      </tr>
    </table>
  </div>

</body>
</html>

FUNBREW PDF APIでの実装例

上記のHTMLテンプレートを FUNBREW PDF API で実際に PDF 化する方法です。

JavaScript (Node.js)

const fs = require('fs');

// HTMLテンプレートを読み込む
const html = fs.readFileSync('./invoice-template.html', 'utf8');

// 動的データを埋め込む
const invoiceHtml = html
  .replace('{{invoice_number}}', 'INV-2026-0042')
  .replace('{{issue_date}}', '2026年4月5日')
  .replace('{{due_date}}', '2026年4月30日')
  .replace('{{customer_name}}', '株式会社テスト')
  .replace('{{line_items}}', buildLineItemsHtml(items))
  .replace('{{subtotal}}', '1,780,000')
  .replace('{{tax}}', '178,000')
  .replace('{{total}}', '1,958,000');

// PDF生成API呼び出し
const response = await fetch('https://pdf.funbrew.cloud/api/pdf/generate', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk-your-api-key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    html: invoiceHtml,
    options: {
      engine: 'quality',       // Chromiumエンジン(CSS完全対応)
      format: 'A4',
      printBackground: true,   // 背景色を出力
      margin: {
        top: '20mm',
        bottom: '20mm',
        left: '15mm',
        right: '15mm',
      },
    },
  }),
});

const result = await response.json();
console.log('PDF生成完了:', result.data.download_url);

Python

import requests

with open('./invoice-template.html', 'r', encoding='utf-8') as f:
    html = f.read()

# 動的データを埋め込む
invoice_html = (html
    .replace('{{invoice_number}}', 'INV-2026-0042')
    .replace('{{issue_date}}', '2026年4月5日')
    .replace('{{customer_name}}', '株式会社テスト')
    .replace('{{subtotal}}', '1,780,000')
    .replace('{{tax}}', '178,000')
    .replace('{{total}}', '1,958,000')
)

response = requests.post(
    'https://pdf.funbrew.cloud/api/pdf/generate',
    headers={'Authorization': 'Bearer sk-your-api-key'},
    json={
        'html': invoice_html,
        'options': {
            'engine': 'quality',
            'format': 'A4',
            'printBackground': True,
            'margin': {
                'top': '20mm',
                'bottom': '20mm',
                'left': '15mm',
                'right': '15mm',
            },
        },
    },
)

print('PDF生成完了:', response.json()['data']['download_url'])

PHP (Laravel)

$html = file_get_contents(resource_path('views/pdf/invoice.html'));

$invoiceHtml = str_replace(
    ['{{invoice_number}}', '{{customer_name}}', '{{total}}'],
    ['INV-2026-0042', '株式会社テスト', '1,958,000'],
    $html
);

$response = Http::withToken('sk-your-api-key')
    ->post('https://pdf.funbrew.cloud/api/pdf/generate', [
        'html' => $invoiceHtml,
        'options' => [
            'engine' => 'quality',
            'format' => 'A4',
            'printBackground' => true,
            'margin' => [
                'top' => '20mm',
                'bottom' => '20mm',
                'left' => '15mm',
                'right' => '15mm',
            ],
        ],
    ]);

$downloadUrl = $response->json('data.download_url');

テンプレートエンジンを使った実装

変数置換が多い場合は、FUNBREW PDFのテンプレートエンジンを使うと {{変数名}} の置換を API が自動で行ってくれます。

// テンプレートエンジンAPIを使う場合
const response = await fetch('https://pdf.funbrew.cloud/api/pdf/generate-from-template', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk-your-api-key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    template: 'invoice-with-table',  // ダッシュボードで登録したテンプレート名
    variables: {
      invoice_number: 'INV-2026-0042',
      issue_date: '2026年4月5日',
      customer_name: '株式会社テスト',
      items: [
        { no: 1, desc: 'Webアプリ開発', qty: '1式', unit: '800,000', amount: '800,000', tax_type: '課税' },
        { no: 2, desc: 'API開発', qty: '1式', unit: '600,000', amount: '600,000', tax_type: '課税' },
      ],
      subtotal: '1,780,000',
      tax: '178,000',
      total: '1,958,000',
    },
    options: { engine: 'quality', format: 'A4' },
  }),
});

よくあるトラブルと解決策

症状 原因 解決策
ページをまたいだ行が途中で切れる break-inside 未設定 tr { break-inside: avoid; } を追加
2ページ目以降にヘッダーがない thead の display 設定 thead { display: table-header-group; }
合計行がデータ行と離れた位置に出る tfoot の挙動 tfoot を使わず tbody 末尾の行を break-inside: avoid のグループに含める
rowspan行でレイアウトが崩れる セル結合と改ページの干渉 rowspanグループを tbody で囲み break-inside: avoid
背景色が PDF に出ない エンジンの既定設定 print-color-adjust: exactprintBackground: true を両方設定
テーブルが用紙幅からはみ出す 固定幅の列 table-layout: fixedoverflow-wrap: break-word
列幅がブラウザと PDF でずれる エンジンの列幅計算の差異 <colgroup> で明示的に列幅を指定
数字の桁が揃わない プロポーショナルフォント font-variant-numeric: tabular-nums を適用

デバッグ方法

  1. Chrome の印刷プレビューで確認: Ctrl+Shift+P → "Emulate CSS media type" → "print" で @media print スタイルを確認
  2. Playground でリアルタイムテスト: HTMLを貼り付けて即 PDF 出力を確認
  3. quality エンジンを使う: Chromium ベースの quality エンジンは CSS の改ページプロパティを最も正確に実装しています
  4. 最小再現例から始める: 問題の行だけを含む小さなテーブルで動作確認してから複雑なテーブルに適用

まとめ

PDF テーブルの改ページ問題を解決するための要点:

  • 行の分割防止: tr { break-inside: avoid; } が基本。まずここから始める
  • thead の繰り返し: thead { display: table-header-group; } で 2 ページ目以降も自動表示
  • グループ管理: 複数 tbody + break-inside: avoid でカテゴリ・rowspan グループを一体管理
  • colspan/rowspan: セル結合グループを tbody で囲んで分割防止
  • レスポンシブ対応: @media print でテーブル表示に戻し、table-layout: fixed で列幅を安定させる
  • 背景色: print-color-adjust: exactprintBackground: true を必ずセットで設定
  • デバッグ: Chrome 印刷プレビューと Playground で確認

Playground で実際に HTML を試しながら、最適なテーブルレイアウトを見つけてください。テンプレートエンジンと組み合わせた請求書自動化については請求書PDFをAPIで自動生成する方法も参照してください。

関連リンク

Powered by FUNBREW PDF