質問本文
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;
});
})();