0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

kintone+html2canvas+jsPDFでPDF出力時にレイアウトが崩れる(内容によって高さが変動)問題について

Posted at

質問本文
kintoneのレコード詳細画面に「PDF出力」ボタンを追加し、
html2canvas+jsPDFを使ってレコード内容をPDF化するカスタマイズを作っています。

■実現したいこと
A4縦サイズのPDFに、受注連絡票のような表を出力したい

上左右に10mm、下に0mmの余白で整ったPDFを出したい

内容の長さに関係なく、レイアウトが毎回同じになるようにしたい

■現在の状況
表はHTMLテンプレートで生成し、html2canvas() → jsPDF().addImage()でPDF化

表の一部(たとえば工事概要や備考など)がレコードによって内容量が違う

その結果、HTML全体の高さが可変となり、PDF内でのレイアウトがずれてしまう(場合によっては1ページに収まらなかったり、文字が潰れる)

■試したこと
html2canvasのscale指定で高解像度化 → 効果は限定的

スタイルでmin-heightやoverflow: hiddenなど指定 → テンプレートの高さは一定化できず

jsPDFのサイズ指定や倍率調整 → 内容によってPDFの縮尺が毎回変動してしまう

■聞きたいこと
html2canvas+jsPDFで、内容量によらず 常に 同じレイアウト・同じ倍率・同じ見た目のPDF を出力するにはどうすればよいか?

html2canvasでHTMLの高さを強制的に固定する(たとえばA4 297mmに相当するピクセル数に)ことは可能か?

または、他にこの目的に適した方法・ライブラリがあるか?

■補足
使用環境:kintoneカスタマイズ(JavaScript)

外部ライブラリ:html2canvas 1.4.1、jsPDF 2.5.1

表示用HTMLのテンプレートと出力コードは既にできています(希望があれば一部コードも掲載します)

■望むゴール
どんなデータでも、整ったA4 PDFが一発で出力されること(=見た目が安定すること)

■現在のコード

(function () {
  'use strict';

  // レコード詳細画面が表示されたときに実行
  kintone.events.on('app.record.detail.show', function (event) {
    console.log('レコード詳細画面表示イベント発火', event);

    // レコードデータを取得
    const record = event.record;

    // 右上レコードメニューを取得
    const recordMenu = document.querySelector('.gaia-argoui-app-toolbar-menu') ||
                       document.querySelector('.gaia-argoui-app-record-menu') ||
                       document.querySelector('.record-gaia .gaia-argoui-toolbar-menu');
    const header = kintone.app.record.getHeaderMenuSpaceElement();

    // ボタンの共通スタイル
    const buttonStyle = {
      backgroundColor: '#4CAF50',
      color: 'white',
      padding: '8px 16px',
      border: 'none',
      borderRadius: '4px',
      cursor: 'pointer',
      fontSize: '14px',
      fontWeight: '500',
      display: 'inline-flex',
      alignItems: 'center',
      gap: '6px',
      boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
      transition: 'background-color 0.2s, transform 0.1s',
      marginRight: '8px',
      marginTop: '4px'
    };

    // ホバー時のスタイル関数
    const applyHoverStyles = (button) => {
      button.onmouseover = () => {
        button.style.backgroundColor = '#45a049';
        button.style.transform = 'scale(1.05)';
      };
      button.onmouseout = () => {
        button.style.backgroundColor = '#4CAF50';
        button.style.transform = 'scale(1)';
      };
    };

    // HTMLテンプレート生成関数
    const generateHtmlContent = () => {
      const customerName = record['tokuisakirup']?.value || record['tokuisakinew']?.value || '━━━━━━━━━━━━━━━━';
      const siteName = record['genbarup']?.value || record['genbanew']?.value || '━━━━━━━━━━━━━━━━';
      const siteAddress = record['jyusyorup']?.value || record['jyusyonew']?.value || '━━━━━━━━━━━━━━━━';
      const contractDate = record['keiyakubi']?.value || '━━━━━━━━';
      const constructionStart = record['kouki1']?.value || '━━━━━━━━';
      const constructionEnd = record['kouki2']?.value || '━━━━━━━━';
      const amountExcludingTax = record['zeinuki']?.value || '━━━━━━━━';
      const amountIncludingTax = record['zeikomi']?.value || '━━━━━━━━';
      const orderType = record['jyuri']?.value || '━━━━━━━━━━━━━━━━';
      const paymentTerms = record['jyouken']?.value || '━━━━━━━━━━━━━━━━';
      const constructionOverview = record['koujigaiyou']?.value || '━━━━━━━━━━━━━━━━';
      const remarks = record['bikou']?.value || '━━━━━━━━━━━━━━━━';
      const siteAgent = record['dairinin']?.value || '━━━━━━━━';
      const chiefEngineer = record['syunin']?.value || '━━━━━━━━';
      const supervisingEngineer = record['kanri']?.value || '━━━━━━━━';
      const salesRepresentative = record['eigyotanto']?.value || '━━━━━━━━';

      // デバッグ用ログ
      console.log('取得した値:', {
        customerName,
        siteName,
        siteAddress,
        contractDate,
        constructionStart,
        constructionEnd,
        amountExcludingTax,
        amountIncludingTax,
        orderType,
        paymentTerms,
        constructionOverview,
        remarks,
        siteAgent,
        chiefEngineer,
        supervisingEngineer,
        salesRepresentative
      });

      return `
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>受注連絡票</title>
  <style>
    body {
      font-family: 'Arial', sans-serif;
      padding: 15mm 15mm 0 15mm; /* 上左右10mm、下0mm */
      line-height: 1.6;
      box-sizing: border-box;
      width: 190mm; /* A4幅(210mm) - 左右余白(20mm) */
      margin: 0 auto;
    }
    h1 {
      text-align: center;
      font-size: 24px;
      margin-bottom: 20px;
      border-bottom: 2px solid #000;
      padding-bottom: 10px;
    }
    .date {
      text-align: right;
      margin-bottom: 10px;
    }
    table {
      width: 100%;
      border-collapse: collapse;
      margin-bottom: 15px;
    }
    td, th {
      border: 1px solid #000;
      padding: 8px;
      font-size: 14px;
    }
    .section-title {
      background-color: #f0f0f0;
      font-weight: bold;
      text-align: center;
    }
    .no-border {
      border: none;
    }
    .approval-box {
      display: flex;
      justify-content: space-between;
      margin-top: 30px;
    }
    .approval-box div {
      width: 24%;
      text-align: center;
      border: 1px solid #000;
      padding: 20px;
    }
  </style>
</head>
<body>
  <div class="date">日付:${new Date().toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })}</div>
  <h1>受 注 連 絡 票</h1>
  <table>
    <tr>
      <th>顧 客 名</th>
      <td colspan="3">${customerName}</td>
    </tr>
    <tr>
      <th>現 場 名</th>
      <td colspan="3">${siteName}</td>
    </tr>
    <tr>
      <th>現場住所</th>
      <td colspan="3">${siteAddress}</td>
    </tr>
    <tr>
      <th>契 約 日</th>
      <td>${contractDate}</td>
      <th>工 期</th>
      <td>${constructionStart} ~ ${constructionEnd}</td>
    </tr>
    <tr>
      <th>請負金額</th>
      <td colspan="3">¥${amountExcludingTax} (税抜) ¥${amountIncludingTax} (税込)</td>
    </tr>
    <tr>
      <th>受注形態</th>
      <td colspan="3">${orderType}</td>
    </tr>
    <tr>
      <th>支払条件</th>
      <td colspan="3">${paymentTerms}</td>
    </tr>
    <tr>
      <th>工事概要</th>
      <td colspan="3" style="height: 100px;">${constructionOverview}</td>
    </tr>
    <tr>
      <th>備考</th>
      <td colspan="3" style="height: 100px;">${remarks}</td>
    </tr>
    <tr>
      <th>現場代理人</th>
      <td>${siteAgent}</td>
      <th>主任技術者</th>
      <td>${chiefEngineer}</td>
    </tr>
    <tr>
      <th>監理技術者</th>
      <td>${supervisingEngineer}</td>
      <th>営業担当者</th>
      <td>${salesRepresentative}</td>
    </tr>
  </table>
</body>
</html>
      `;
    };

    // レイアウト確認ボタンを作成
    const previewButton = document.createElement('button');
    previewButton.id = 'previewButton';
    previewButton.textContent = 'レイアウト確認';
    Object.assign(previewButton.style, buttonStyle);
    applyHoverStyles(previewButton);

    // レイアウト確認ボタンのクリック処理
    previewButton.onclick = function () {
      console.log('レイアウト確認ボタンがクリックされました', record);
      const htmlContent = generateHtmlContent();
      const popup = window.open('', '受注連絡票プレビュー', 'width=800,height=600,scrollbars=yes,resizable=yes');
      if (!popup) {
        alert('ポップアップがブロックされました。ブラウザの設定でポップアップを許可してください。');
        console.error('ポップアップウィンドウが開けませんでした');
        return;
      }
      popup.document.write(htmlContent);
      popup.document.close();
      console.log('ポップアップウィンドウにHTMLを表示しました');
    };

    // PDF出力ボタンを作成
    const pdfButton = document.createElement('button');
    pdfButton.id = 'pdfButton';
    pdfButton.textContent = 'PDF出力';
    Object.assign(pdfButton.style, buttonStyle);
    applyHoverStyles(pdfButton);

    // PDF出力ボタンのクリック処理
    pdfButton.onclick = async function () {
      console.log('PDF出力ボタンがクリックされました', record);
      try {
        // HTMLを一時的な要素にレンダリング
        const htmlContent = generateHtmlContent();
        const tempDiv = document.createElement('div');
        tempDiv.style.position = 'absolute';
        tempDiv.style.left = '-9999px';
        tempDiv.innerHTML = htmlContent;
        document.body.appendChild(tempDiv);

        // html2canvasでキャンバスに変換
        const canvas = await html2canvas(tempDiv, {
          scale: 2, // 高解像度でレンダリング
          useCORS: true,
          logging: true
        });

        // デバッグ用にキャンバスサイズをログ
        console.log('キャンバスサイズ:', { width: canvas.width, height: canvas.height });

        // jsPDFでPDF生成
        const { jsPDF } = window.jspdf;
        const pdf = new jsPDF({
          orientation: 'portrait',
          unit: 'mm',
          format: 'a4'
        });

        const imgData = canvas.toDataURL('image/png');
        const imgProps = pdf.getImageProperties(imgData);
        const pdfWidth = pdf.internal.pageSize.getWidth();
        const pdfHeight = pdf.internal.pageSize.getHeight();
        const marginTop = 15; // 上10mm
        const marginBottom = 0; // 下0mm
        const marginHorizontal = 15; // 左右10mm
        const contentWidth = pdfWidth - 2 * marginHorizontal;
        const contentHeight = (imgProps.height * contentWidth) / imgProps.width;

        // デバッグ用に余白とコンテンツサイズをログ
        console.log('PDF設定:', {
          pdfWidth,
          pdfHeight,
          marginTop,
          marginBottom,
          marginHorizontal,
          contentWidth,
          contentHeight,
          totalHeight: contentHeight + marginTop + marginBottom
        });

        // コンテンツがページ高さを超える場合、縮小して1ページに収める
        if (contentHeight + marginTop + marginBottom > pdfHeight) {
          const scaleFactor = (pdfHeight - marginTop - marginBottom) / contentHeight;
          console.log('スケール適用:', { scaleFactor });
          pdf.addImage(imgData, 'PNG', marginHorizontal, marginTop, contentWidth * scaleFactor, contentHeight * scaleFactor);
        } else {
          pdf.addImage(imgData, 'PNG', marginHorizontal, marginTop, contentWidth, contentHeight);
        }

        // PDFをダウンロード
        pdf.save('受注連絡票.pdf');

        // 一時要素を削除
        document.body.removeChild(tempDiv);
        console.log('PDF生成およびダウンロードが完了しました');
      } catch (error) {
        console.error('PDF生成エラー:', error);
        alert('PDF生成中にエラーが発生しました: ' + error.message);
      }
    };

    // ボタンを右上レコードメニューに追加
    try {
      if (recordMenu) {
        console.log('右上メニューが見つかりました:', recordMenu);
        // 既存のボタンを削除
        const existingPdfButton = document.getElementById('pdfButton');
        const existingPreviewButton = document.getElementById('previewButton');
        if (existingPdfButton) existingPdfButton.remove();
        if (existingPreviewButton) existingPreviewButton.remove();
        // PDF出力ボタンを先に追加(レイアウト確認の左に)
        recordMenu.insertBefore(pdfButton, recordMenu.firstChild);
        // レイアウト確認ボタンを追加
        recordMenu.insertBefore(previewButton, recordMenu.firstChild);
      } else {
        console.warn('右上メニューが見つかりませんでした。ヘッダーにフォールバックします。');
        if (header) {
          const existingPdfButton = document.getElementById('pdfButton');
          const existingPreviewButton = document.getElementById('previewButton');
          if (existingPdfButton) existingPdfButton.remove();
          if (existingPreviewButton) existingPreviewButton.remove();
          header.appendChild(pdfButton);
          header.appendChild(previewButton);
        } else {
          console.error('ヘッダーメニュースペースも見つかりませんでした');
          alert('エラー: ボタンを配置できません。管理者に連絡してください。');
        }
      }
    } catch (error) {
      console.error('ボタン配置エラー:', error);
      if (header) {
        const existingPdfButton = document.getElementById('pdfButton');
        const existingPreviewButton = document.getElementById('previewButton');
        if (existingPdfButton) existingPdfButton.remove();
        if (existingPreviewButton) existingPreviewButton.remove();
        header.appendChild(pdfButton);
        header.appendChild(previewButton);
      } else {
        console.error('フォールバック失敗:', error);
        alert('エラー: ボタンを配置できません。管理者に連絡してください。');
      }
    }

    return event;
  });
})();
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?