
有料ソフト不要。PDFツール6つを1つにまとめたら、爆速で作業が終わるようになった

こんにちは、ゆうき建築AI活用です。
一級建築士・一級建築施工管理技士として、設計から施工管理まで幅広く実務をこなしています。現在は地場の工務店で木造を中心に老人ホームやマンションの設計を担当し、ゼネコンで鉄骨造の現場代理人経験も複数あり。建築×AI活用を実践しながら、日々の業務効率化に取り組んでいます。
結論:入り口を1つにしたら、誰でも使えるツールになった
PDF作業でこんなことありませんか?
容量が重すぎてメール添付できない
スキャンした書類の向きがバラバラ
図面の変更箇所を見比べたい
構造図と意匠図を重ねて確認したい
こういった作業、実は専用ソフトがなくても無料でできます。
今回、自分用に作っていた6つのPDF処理ツールを、Gensparkで1つのWebアプリにまとめて公開しました。
👉 PDF ツールボックス

ブラウザだけで完結。インストール不要。建築図面はもちろん、営業資料・契約書・報告書など、あらゆる業務で使えます。
しかも、オフラインツールなのでセキュリティも安心。
アップロードしたファイルは外部サーバーに送信されず、すべてブラウザ内で処理されます。
なぜ作ったのか
もともと自分用に、6つの機能を持つツールをHTML形式で作っていました。
でも問題がありました。
機能ごとにファイルが分かれている
どれを開けばいいか迷う
他人に共有しづらい
そこで、入り口を1つにまとめることにしました。
結果、誰でも直感的に使えるツールになったんです。
Gensparkでウェブ公開することで、URLひとつで誰でもアクセスできるようになりました。
6つの機能と使いどころ
① PDF容量削減
編集後のPDFや、画像が重いPDFを圧縮します。
仕組みは「PDFをPDF印刷する」だけ。シンプルですが効果抜群。
使う場面:
プレゼン資料を作成後、容量が数十MBになってしまった時
メール添付の容量制限に引っかかった時
② PDF自動回転
縦A4・横A4が混ざってスキャンされたPDFを、自動で向きを調整してくれます。
使う場面:複合機でまとめてスキャンしたら、向きがバラバラだった時
手作業で1ページずつ回転するのが面倒な時
③ PDF変換
PDFをjpg/pngに変換、逆も可能です。
使う場面:図面をSNSやプレゼンで画像として使いたい時
逆に、複数の画像をまとめてPDF化したい時
④ PDF比較
2つのPDFを並べて、変更箇所を確認できます。
使う場面:図面の変更前・変更後を見比べたい時
契約書の修正箇所をチェックしたい時
⑤ PDF重ね図(オーバーレイ)
複数のPDFを重ね合わせて表示します。
使う場面:構造図を意匠図に重ねて整合確認したい時
基礎伏図と土台伏図を重ねてチェックしたい時
営業資料で複数データを重ねて比較したい時
⑥ PDF計測
図面の一部寸法記載が無くても、簡易的に計測できたり、CADデータが貰えなくても面積を測定したりできます。
※CADではないので誤差があります。
使う場面:寸法の記載がない図面で、おおよその長さを知りたい時。PDF上で面積測定したい時。
セキュリティ面も安心:すべてブラウザ内で処理
このツールの大きな特徴は、完全にオフラインで動作することです。
アップロードしたファイルは外部サーバーに送信されません
すべての処理がブラウザ内で完結します
機密情報を含む図面や契約書も、安心して使えます
オンラインで公開していますが、実際にはオフラインでも稼働するツールです。
6つのツールのコードを記事末尾に掲載しているので、ローカル環境で試してもらえれば分かるはずです。
4つのフリーソフトと組み合わせれば最強
今回公開したツールに加えて、以下の4つのフリーソフトを組み合わせれば、有料ソフトは不要です。
オブジェクトの一括移動以外は、大体できます。
一括移動はPythonで作りましたので、またどこかで
PDF-XChange Viewer(編集)
文字や図形の書き込み、写真の貼り付けができます。
無料なのに高機能。AdobeやEditorと違って、写真貼り付けも無料で可能。
👉
CUBE PDF(仮想プリンタ)
「印刷」からPDFを作成できる仮想プリンタ。
ExcelやWordなど、あらゆるファイルをPDF化できます。
👉
CUBE PDF Utility(並べ替え)
PDFのページ順を入れ替えたり、回転したりできます。
👉
CUBE PDF Page(結合・分割)
複数のPDFを1つに結合したり、逆に分割したりできます。
👉
これらを使い分けることで、PDF作業のほぼすべてを無料で完結できます。
まとめ
今回公開した「PDFツールボックス」は、自分の実務で使っていたものを、誰でも使いやすい形に整えたものです。
バラバラだったツールの入り口を1つにまとめたことで、迷わず使えるようになりました。
ブラウザだけで完結
インストール不要
完全無料
オフライン動作でセキュリティも安心
建築図面だけでなく、営業資料・契約書・報告書など、あらゆる業務で使えます。
ぜひ一度試してみてください。
👉 PDF ツールボックス
もし役に立ったら、スキボタンを押していただけると嬉しいです。
スレッズとXもフォローしていただけると励みになります!
付録:6つのツールのコード
オフラインで稼働することを試してもらえれば分かるはず。
オンライン公開していますが、実際にはオフラインでも動作するツール群です。
統合と公開のためにオンライン公開しましたが、本質はローカルで完結するツールです。
以下、各ツールのコードを掲載します。
① PDF容量削減
<!DOCTYPE html>
<html lang="ja">
<head>
<style>
.preloader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
z-index: 9999;
animation: fadeOut 0.1s 0.3s forwards;
}
.glasses {
display: flex;
align-items: center;
animation: swing 0.3s ease-in-out infinite alternate;
transform-origin: top center;
}
.lens {
width: 40px;
height: 20px;
border: 3px solid #888;
border-radius: 4px;
background: rgba(200,200,200,0.2);
}
.bridge {
width: 12px;
height: 3px;
background: #888;
}
.temple {
width: 20px;
height: 3px;
background: #888;
}
.pencil {
font-size: 48px;
animation: swing 0.3s ease-in-out infinite alternate;
animation-delay: 0.15s;
transform-origin: top center;
display: inline-block;
}
@keyframes swing {
0% { transform: rotate(-15deg); }
100% { transform: rotate(15deg); }
}
@keyframes fadeOut {
to { opacity: 0; visibility: hidden; }
}
</style>
<div class="preloader">
<div class="glasses">
<div class="temple"></div>
<div class="lens"></div>
<div class="bridge"></div>
<div class="lens"></div>
<div class="temple"></div>
</div>
<span class="pencil">✏️</span>
</div>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF容量最適化ツール(一括処理)</title>
<script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background: white;
border-radius: 15px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 { color: #333; text-align: center; margin-bottom: 10px; }
.subtitle { text-align: center; color: #666; margin-bottom: 30px; font-size: 14px; }
.upload-area {
border: 3px dashed #667eea;
border-radius: 10px;
padding: 50px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #f8f9fa;
margin-bottom: 20px;
}
.upload-area:hover { background: #e9ecef; border-color: #764ba2; }
.upload-area.dragover { background: #e3f2fd; border-color: #2196f3; }
.upload-area.has-file { background: #e8f5e9; border-color: #4caf50; }
input[type="file"] { display: none; }
.file-list {
margin: 20px 0;
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 8px;
display: none;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.file-item:last-child { border-bottom: none; }
.file-item .file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-item .file-size { color: #666; margin-left: 10px; }
.file-item .file-status { margin-left: 10px; font-weight: bold; }
.file-item .status-waiting { color: #999; }
.file-item .status-processing { color: #2196f3; }
.file-item .status-done { color: #4caf50; }
.file-item .status-error { color: #dc3545; }
.file-item .remove-btn {
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
margin-left: 10px;
font-size: 12px;
}
.file-item .remove-btn:hover { background: #c82333; }
.settings {
margin: 30px 0;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
}
.settings-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.setting-group { margin-bottom: 25px; }
label { display: block; margin-bottom: 8px; color: #555; font-weight: bold; }
select {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
background: white;
}
select:focus { outline: none; border-color: #667eea; }
.page-size-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 10px; }
.size-btn {
padding: 15px 10px;
border: 2px solid #ddd;
background: white;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: all 0.3s;
}
.size-btn.active {
border-color: #667eea;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
transform: scale(1.05);
}
.size-btn:hover:not(.active) { background: #f8f9fa; border-color: #999; }
.size-name { font-weight: bold; color: #333; margin-bottom: 5px; }
.size-dims { font-size: 11px; color: #666; }
.button-group { display: flex; gap: 15px; margin-bottom: 20px; }
.button-group button { flex: 1; }
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 30px;
border-radius: 25px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); }
button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.reset-btn {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
box-shadow: 0 4px 15px rgba(220, 53, 69, 0.4);
}
.reset-btn:hover:not(:disabled) { box-shadow: 0 6px 20px rgba(220, 53, 69, 0.5); }
.progress { display: none; margin: 20px 0; }
.progress-bar {
width: 100%;
height: 35px;
background: #e9ecef;
border-radius: 20px;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
width: 0;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
}
.result-info {
display: none;
margin-top: 20px;
padding: 20px;
background: #d4edda;
border: 2px solid #28a745;
border-radius: 10px;
text-align: center;
}
.result-info .result-title { font-size: 20px; font-weight: bold; color: #155724; margin-bottom: 10px; }
.result-stats { display: flex; justify-content: space-around; margin-top: 15px; }
.result-stat { text-align: center; }
.result-label { font-size: 12px; color: #155724; opacity: 0.8; }
.result-value { font-size: 28px; font-weight: bold; color: #155724; }
.warning {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 15px;
margin-top: 20px;
font-size: 13px;
color: #856404;
}
.warning-title { font-weight: bold; margin-bottom: 5px; display: flex; align-items: center; gap: 5px; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.fade-in { animation: fadeIn 0.3s ease-in-out; }
</style>
</head>
<body>
<div class="container">
<h1>🖨️ PDF容量最適化ツール</h1>
<p class="subtitle">複数PDFファイルを一括で圧縮します(順番に処理)</p>
<div class="upload-area" id="uploadArea">
<p style="margin: 0; font-size: 18px;">
📁 PDFファイルをドラッグ&ドロップ<br>
<span style="font-size: 14px; color: #666;">または</span><br>
<strong>クリックして選択(複数可)</strong>
</p>
<input type="file" id="fileInput" accept=".pdf" multiple>
</div>
<div class="file-list" id="fileList"></div>
<div class="button-group">
<button id="convertBtn" disabled>🖨️ 一括最適化を実行</button>
<button id="resetBtn" class="reset-btn" onclick="resetAll()">🔄 リセット</button>
</div>
<div class="settings">
<div class="settings-title">⚙️ 設定</div>
<div class="setting-group">
<label>📐 用紙サイズ</label>
<div class="page-size-grid">
<div class="size-btn active" data-size="A4" onclick="selectPageSize('A4')">
<div class="size-name">A4</div>
<div class="size-dims">210×297mm</div>
</div>
<div class="size-btn" data-size="A3" onclick="selectPageSize('A3')">
<div class="size-name">A3</div>
<div class="size-dims">297×420mm</div>
</div>
<div class="size-btn" data-size="B4" onclick="selectPageSize('B4')">
<div class="size-name">B4</div>
<div class="size-dims">257×364mm</div>
</div>
<div class="size-btn" data-size="B5" onclick="selectPageSize('B5')">
<div class="size-name">B5</div>
<div class="size-dims">182×257mm</div>
</div>
<div class="size-btn" data-size="Letter" onclick="selectPageSize('Letter')">
<div class="size-name">Letter</div>
<div class="size-dims">216×279mm</div>
</div>
<div class="size-btn" data-size="Original" onclick="selectPageSize('Original')">
<div class="size-name">元のサイズ</div>
<div class="size-dims">変更なし</div>
</div>
</div>
</div>
<div class="setting-group">
<label for="quality">🎨 出力品質</label>
<select id="quality">
<option value="lowest">最低品質(最小サイズ優先)</option>
<option value="low">低品質(小サイズ)</option>
<option value="low-medium">やや低品質(バランス型)</option>
<option value="medium" selected>標準品質(推奨)</option>
<option value="high">高品質(大サイズ)</option>
<option value="best">最高品質(印刷用)</option>
</select>
</div>
<div class="setting-group">
<label for="orientation">📄 用紙の向き</label>
<select id="orientation">
<option value="auto" selected>自動(コンテンツに合わせる)</option>
<option value="portrait">縦向き</option>
<option value="landscape">横向き</option>
</select>
</div>
<div class="setting-group">
<label for="margins">📏 余白</label>
<select id="margins">
<option value="none" selected>なし(フチなし)</option>
<option value="narrow">狭い(5mm)</option>
<option value="normal">標準(10mm)</option>
<option value="wide">広い(20mm)</option>
</select>
</div>
</div>
<div class="progress" id="progress">
<div class="progress-bar">
<div class="progress-fill" id="progressFill">処理中...</div>
</div>
</div>
<div class="result-info" id="resultInfo">
<div class="result-title">✅ 一括最適化完了!</div>
<div class="result-stats">
<div class="result-stat">
<div class="result-label">処理ファイル数</div>
<div class="result-value" id="processedCount">-</div>
</div>
<div class="result-stat">
<div class="result-label">合計削減サイズ</div>
<div class="result-value" id="totalReduction">-</div>
</div>
</div>
</div>
<div class="warning">
<div class="warning-title">💡 品質設定の目安</div>
• <strong>最低品質</strong>: ファイルサイズ最優先(画質50%、解像度1.5倍)<br>
• <strong>低品質</strong>: 小さいサイズ(画質60%、解像度2.0倍)<br>
• <strong>やや低品質</strong>: バランス重視(画質68%、解像度2.5倍)<br>
• <strong>標準品質</strong>: 推奨設定(画質75%、解像度3.0倍)<br>
• <strong>高品質</strong>: 品質重視(画質85%、解像度4.0倍)<br>
• <strong>最高品質</strong>: 印刷用(画質95%、解像度5.0倍)
</div>
</div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const CMAP_URL = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/cmaps/';
const CMAP_PACKED = true;
const STANDARD_FONT_DATA_URL = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/standard_fonts/';
let pdfFiles = [];
let selectedPageSize = 'A4';
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const convertBtn = document.getElementById('convertBtn');
const resetBtn = document.getElementById('resetBtn');
const progress = document.getElementById('progress');
const progressFill = document.getElementById('progressFill');
const resultInfo = document.getElementById('resultInfo');
const PAGE_SIZES = {
A4: { width: 595, height: 842 },
A3: { width: 842, height: 1191 },
B4: { width: 729, height: 1032 },
B5: { width: 516, height: 729 },
Letter: { width: 612, height: 792 },
Original: null
};
function selectPageSize(size) {
selectedPageSize = size;
document.querySelectorAll('.size-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.size === size);
});
}
function resetAll() {
pdfFiles = [];
fileInput.value = '';
uploadArea.classList.remove('has-file');
fileList.style.display = 'none';
fileList.innerHTML = '';
resultInfo.style.display = 'none';
progress.style.display = 'none';
convertBtn.disabled = true;
selectPageSize('A4');
document.getElementById('quality').value = 'medium';
document.getElementById('orientation').value = 'auto';
document.getElementById('margins').value = 'none';
}
function renderFileList() {
fileList.innerHTML = '';
if (pdfFiles.length === 0) {
fileList.style.display = 'none';
uploadArea.classList.remove('has-file');
convertBtn.disabled = true;
return;
}
fileList.style.display = 'block';
uploadArea.classList.add('has-file');
convertBtn.disabled = false;
pdfFiles.forEach((f, idx) => {
const item = document.createElement('div');
item.className = 'file-item';
item.id = `file-item-${idx}`;
item.innerHTML = `
<span class="file-name">${f.file.name}</span>
<span class="file-size">${formatFileSize(f.file.size)}</span>
<span class="file-status status-waiting" id="status-${idx}">待機中</span>
<button class="remove-btn" onclick="removeFile(${idx})">✕</button>
`;
fileList.appendChild(item);
});
}
function removeFile(idx) {
pdfFiles.splice(idx, 1);
renderFileList();
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function getQualitySettings(quality) {
switch(quality) {
case 'lowest': return { scale: 1.5, imageQuality: 0.5, compression: true };
case 'low': return { scale: 2.0, imageQuality: 0.6, compression: true };
case 'low-medium': return { scale: 2.5, imageQuality: 0.68, compression: true };
case 'medium': return { scale: 3.0, imageQuality: 0.75, compression: true };
case 'high': return { scale: 4.0, imageQuality: 0.85, compression: true };
case 'best': return { scale: 5.0, imageQuality: 0.95, compression: false };
default: return { scale: 3.0, imageQuality: 0.75, compression: true };
}
}
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files).filter(f => f.type === 'application/pdf');
addFiles(files);
});
fileInput.addEventListener('change', (e) => {
addFiles(Array.from(e.target.files));
fileInput.value = '';
});
function addFiles(files) {
files.forEach(file => {
pdfFiles.push({ file, status: 'waiting' });
});
renderFileList();
}
convertBtn.addEventListener('click', async () => {
if (pdfFiles.length === 0) return;
convertBtn.disabled = true;
resetBtn.disabled = true;
progress.style.display = 'block';
resultInfo.style.display = 'none';
document.querySelectorAll('.remove-btn').forEach(btn => btn.disabled = true);
const quality = document.getElementById('quality').value;
const orientation = document.getElementById('orientation').value;
const margins = document.getElementById('margins').value;
const qualitySettings = getQualitySettings(quality);
let margin = 0;
switch(margins) {
case 'narrow': margin = 14; break;
case 'normal': margin = 28; break;
case 'wide': margin = 57; break;
}
let totalBefore = 0, totalAfter = 0, processedCount = 0;
for (let fileIdx = 0; fileIdx < pdfFiles.length; fileIdx++) {
const statusEl = document.getElementById(`status-${fileIdx}`);
statusEl.textContent = '処理中...';
statusEl.className = 'file-status status-processing';
progressFill.style.width = ((fileIdx) / pdfFiles.length * 100) + '%';
progressFill.textContent = `ファイル ${fileIdx + 1}/${pdfFiles.length} 処理中...`;
try {
const pdfFile = pdfFiles[fileIdx].file;
const arrayBuffer = await pdfFile.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({
data: arrayBuffer,
cMapUrl: CMAP_URL,
cMapPacked: CMAP_PACKED,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
disableFontFace: false,
useSystemFonts: true,
verbosity: 0
});
const srcPdf = await loadingTask.promise;
const numPages = srcPdf.numPages;
const pdfDoc = await PDFLib.PDFDocument.create();
for (let i = 1; i <= numPages; i++) {
const page = await srcPdf.getPage(i);
const viewport = page.getViewport({ scale: qualitySettings.scale });
const offscreenCanvas = new OffscreenCanvas(viewport.width, viewport.height);
const context = offscreenCanvas.getContext('2d', { alpha: false, desynchronized: true });
context.fillStyle = '#ffffff';
context.fillRect(0, 0, viewport.width, viewport.height);
const renderContext = {
canvasContext: context,
viewport: viewport,
intent: 'print',
annotationMode: 2,
renderTextLayer: false,
renderInteractiveForms: true
};
await page.render(renderContext).promise;
const blob = await offscreenCanvas.convertToBlob({
type: 'image/jpeg',
quality: qualitySettings.imageQuality
});
let pageWidth, pageHeight;
if (selectedPageSize === 'Original') {
const originalViewport = page.getViewport({ scale: 1 });
pageWidth = originalViewport.width;
pageHeight = originalViewport.height;
} else {
const size = PAGE_SIZES[selectedPageSize];
if (orientation === 'auto') {
const isContentLandscape = viewport.width > viewport.height;
if (isContentLandscape) {
pageWidth = Math.max(size.width, size.height);
pageHeight = Math.min(size.width, size.height);
} else {
pageWidth = Math.min(size.width, size.height);
pageHeight = Math.max(size.width, size.height);
}
} else if (orientation === 'landscape') {
pageWidth = Math.max(size.width, size.height);
pageHeight = Math.min(size.width, size.height);
} else {
pageWidth = Math.min(size.width, size.height);
pageHeight = Math.max(size.width, size.height);
}
}
const newPage = pdfDoc.addPage([pageWidth, pageHeight]);
const imageBytes = new Uint8Array(await blob.arrayBuffer());
const image = await pdfDoc.embedJpg(imageBytes);
const drawWidth = pageWidth - (margin * 2);
const drawHeight = pageHeight - (margin * 2);
const imageAspect = viewport.width / viewport.height;
const pageAspect = drawWidth / drawHeight;
let finalWidth, finalHeight, x, y;
if (imageAspect > pageAspect) {
finalWidth = drawWidth;
finalHeight = drawWidth / imageAspect;
x = margin;
y = margin + (drawHeight - finalHeight) / 2;
} else {
finalHeight = drawHeight;
finalWidth = drawHeight * imageAspect;
x = margin + (drawWidth - finalWidth) / 2;
y = margin;
}
newPage.drawImage(image, { x, y, width: finalWidth, height: finalHeight });
}
const pdfBytes = await pdfDoc.save({
useObjectStreams: qualitySettings.compression,
addDefaultPage: false,
objectsPerTick: 200,
});
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '').replace('T', '_');
a.download = pdfFile.name.replace('.pdf', `_optimized_${timestamp}.pdf`);
a.click();
URL.revokeObjectURL(url);
totalBefore += pdfFile.size;
totalAfter += pdfBytes.length;
processedCount++;
statusEl.textContent = '完了';
statusEl.className = 'file-status status-done';
} catch (error) {
console.error('Error:', error);
statusEl.textContent = 'エラー';
statusEl.className = 'file-status status-error';
}
}
progressFill.style.width = '100%';
progressFill.textContent = '全て完了!';
document.getElementById('processedCount').textContent = `${processedCount}件`;
const reduction = totalBefore - totalAfter;
document.getElementById('totalReduction').textContent = reduction > 0 ? formatFileSize(reduction) : '0 B';
resultInfo.style.display = 'block';
resultInfo.classList.add('fade-in');
setTimeout(() => {
progress.style.display = 'none';
convertBtn.disabled = false;
resetBtn.disabled = false;
}, 2000);
});
</script>
</body>
</html>
② PDF自動回転
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF 自動回転ツール</title>
<style>
.preloader {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: #1a1a2e; display: flex; justify-content: center; align-items: center; gap: 20px;
z-index: 9999; animation: fadeOut 0.5s 1.5s forwards;
}
.glasses {
display: flex; align-items: center;
animation: swing 0.3s ease-in-out infinite alternate; transform-origin: top center;
}
.lens { width: 40px; height: 20px; border: 3px solid #00d2ff; border-radius: 4px; background: rgba(0,210,255,0.1); }
.bridge { width: 12px; height: 3px; background: #00d2ff; }
.temple { width: 20px; height: 3px; background: #00d2ff; }
.pencil {
font-size: 48px; animation: swing 0.3s ease-in-out infinite alternate;
animation-delay: 0.15s; transform-origin: top center; display: inline-block;
}
@keyframes swing { 0% { transform: rotate(-15deg); } 100% { transform: rotate(15deg); } }
@keyframes fadeOut { to { opacity: 0; visibility: hidden; pointer-events: none; } }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', 'Meiryo', sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
h1 { text-align: center; margin: 20px 0; font-size: 1.5em; color: #00d2ff; }
.drop-zone {
border: 3px dashed #00d2ff; border-radius: 16px; padding: 50px 20px;
text-align: center; cursor: pointer; transition: all 0.3s;
background: rgba(0,210,255,0.05); margin-bottom: 20px; position: relative;
}
.drop-zone.dragover { background: rgba(0,210,255,0.18); border-color: #00ff88; }
.drop-zone:hover { background: rgba(0,210,255,0.10); }
.drop-zone p { font-size: 1.1em; color: #aaa; pointer-events: none; }
.drop-zone .file-name { font-size: 0.95em; color: #00ff88; margin-top: 12px; min-height: 1.2em; pointer-events: none; }
.mode-select { display: none; text-align: center; margin-bottom: 20px; }
.mode-select .mode-btn {
padding: 16px 40px; border: 2px solid #00d2ff; border-radius: 12px; cursor: pointer;
font-size: 1.1em; font-weight: bold; background: #16213e; color: #00d2ff;
margin: 0 10px; transition: all 0.2s;
}
.mode-select .mode-btn:hover { background: #00d2ff; color: #1a1a2e; }
.controls { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
.btn {
padding: 10px 24px; border: none; border-radius: 8px; cursor: pointer;
font-size: 0.95em; font-weight: bold; transition: all 0.2s;
}
.btn-primary { background: #00d2ff; color: #1a1a2e; }
.btn-primary:hover { background: #00ff88; }
.btn-primary:disabled { background: #555; color: #888; cursor: not-allowed; }
.btn-download { background: #00ff88; color: #1a1a2e; }
.btn-download:hover { background: #00d2ff; }
.btn-reset { background: #ff4466; color: #fff; }
.btn-reset:hover { background: #ff6688; }
.settings { display: none; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; }
.settings label { font-size: 0.85em; color: #aaa; }
.settings select { background: #16213e; color: #eee; border: 1px solid #444; padding: 6px 10px; border-radius: 6px; }
.settings input[type=checkbox] { margin-right: 4px; }
.progress-area { margin-bottom: 20px; display: none; }
.progress-bar-bg { background: #333; border-radius: 8px; height: 30px; overflow: hidden; position: relative; }
.progress-bar-fill { background: linear-gradient(90deg, #00d2ff, #00ff88); height: 100%; width: 0%; transition: width 0.3s; }
.progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-weight: bold; font-size: 0.85em; color: #fff; text-shadow: 0 0 4px rgba(0,0,0,0.8); }
#statusText { margin-top: 8px; font-size: 0.9em; color: #aaa; }
.log-area {
background: #0d0d1a; border: 1px solid #333; border-radius: 8px;
padding: 12px; max-height: 200px; overflow-y: auto; font-family: monospace;
font-size: 0.8em; margin-bottom: 20px; display: none;
}
.log-area .log-ok { color: #00ff88; }
.log-area .log-rotate { color: #ffaa00; }
.log-area .log-err { color: #ff4444; }
/* === レビューエリア === */
.review-area { display: none; margin-top: 20px; }
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 10px; }
.review-header h2 { color: #00d2ff; font-size: 1.2em; }
.review-main { display: flex; gap: 20px; align-items: flex-start; flex-wrap: wrap; }
.preview-box {
flex: 1; min-width: 300px; background: #0d0d1a; border-radius: 12px; padding: 16px;
display: flex; flex-direction: column; align-items: center;
}
.preview-box canvas { max-width: 100%; max-height: 70vh; border-radius: 4px; background: #fff; }
.preview-label { margin-top: 8px; font-size: 0.85em; color: #aaa; }
/* ナビ+手動回転: プレビュー下 */
.below-preview {
display: flex; flex-direction: column; align-items: center; gap: 12px; margin-top: 16px; width: 100%;
}
.review-nav { display: flex; align-items: center; gap: 10px; }
.review-nav .btn-nav {
padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer;
font-size: 1em; font-weight: bold; background: #16213e; color: #00d2ff; border: 1px solid #00d2ff;
}
.review-nav .btn-nav:hover { background: #00d2ff; color: #1a1a2e; }
.review-nav .btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
.review-nav .page-indicator { font-size: 1em; color: #aaa; min-width: 100px; text-align: center; }
.manual-controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: center; }
.manual-controls .btn-rotate {
padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer;
font-size: 0.9em; font-weight: bold; transition: all 0.2s;
}
.btn-rot-cw { background: #ffaa00; color: #1a1a2e; }
.btn-rot-ccw { background: #ff8800; color: #1a1a2e; }
.btn-rot-180 { background: #ff6644; color: #fff; }
.btn-rot-reset { background: #555; color: #eee; }
.rotation-info {
background: #16213e; border-radius: 8px; padding: 8px 14px; text-align: center;
font-size: 0.85em; color: #aaa;
}
.rotation-info .rot-value { font-size: 1.2em; color: #00ff88; font-weight: bold; }
/* サムネイル */
.thumb-strip {
display: flex; gap: 8px; overflow-x: auto; padding: 10px 0; margin-top: 16px;
}
.thumb-strip .ts-item {
flex-shrink: 0; width: 80px; cursor: pointer; border-radius: 6px;
border: 2px solid transparent; padding: 4px; background: #16213e; text-align: center;
transition: border-color 0.2s;
}
.thumb-strip .ts-item:hover { border-color: #00d2ff; }
.thumb-strip .ts-item.active { border-color: #00ff88; }
.thumb-strip .ts-item.modified { border-color: #ffaa00; }
.thumb-strip .ts-item.active.modified { border-color: #00ff88; box-shadow: 0 0 0 2px #ffaa00; }
.thumb-strip .ts-item img { width: 100%; border-radius: 3px; background: #fff; }
.thumb-strip .ts-item .ts-label { font-size: 0.65em; color: #aaa; margin-top: 2px; }
.thumb-strip .ts-item .ts-badge {
font-size: 0.6em; font-weight: bold; display: inline-block;
padding: 1px 4px; border-radius: 3px; margin-top: 2px;
}
/* 手動モードグリッド */
.manual-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 20px;
}
.mg-card {
background: #16213e; border-radius: 10px; padding: 10px; text-align: center;
display: flex; flex-direction: column; align-items: center;
height: 380px;
}
.mg-card img { max-width: 100%; flex: 1; object-fit: contain; border-radius: 4px; background: #fff; min-height: 0; }
.mg-card .mg-label { margin-top: 6px; flex-shrink: 0; }
.mg-card .mg-info { flex-shrink: 0; }
.mg-card button { flex-shrink: 0; }
.mg-card img { max-width: 100%; border-radius: 4px; background: #fff; }
.mg-card .mg-label { font-size: 0.8em; color: #00d2ff; margin: 6px 0 4px; font-weight: bold; }
.mg-card .mg-btns { display: flex; gap: 4px; justify-content: center; flex-wrap: wrap; }
.mg-card .mg-btns button {
padding: 5px 10px; border: none; border-radius: 5px; cursor: pointer;
font-size: 0.75em; font-weight: bold; background: #2a2a4a; color: #eee;
}
.mg-card .mg-btns button:hover { background: #00d2ff; color: #1a1a2e; }
.mg-card .mg-info { font-size: 0.7em; color: #aaa; margin-top: 4px; }
</style>
</head>
<body>
<div class="preloader">
<div class="glasses">
<div class="temple"></div><div class="lens"></div><div class="bridge"></div><div class="lens"></div><div class="temple"></div>
</div>
<span class="pencil">✏️</span>
</div>
<div class="container">
<h1>PDF 自動回転ツール(OCR文字方向検出)</h1>
<div class="drop-zone" id="dropZone">
<p>PDFをドラッグ&ドロップ or クリックして選択</p>
<div class="file-name" id="fileName"></div>
<input type="file" id="fileInput" accept=".pdf" style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:0;cursor:pointer;">
</div>
<!-- モード選択 -->
<div class="mode-select" id="modeSelect">
<button class="mode-btn" id="btnModeAuto">自動回転(OCR)</button>
<button class="mode-btn" id="btnModeManual">手動回転</button>
</div>
<!-- 自動モード設定 -->
<div class="settings" id="autoSettings">
<label>DPI: <select id="dpiSelect">
<option value="150">150(高速)</option>
<option value="200" selected>200(推奨)</option>
<option value="300">300(高精度)</option>
</select></label>
<label>並列Worker数: <select id="workerCount">
<option value="2">2</option>
<option value="4" selected>4</option>
<option value="6">6</option>
</select></label>
<label><input type="checkbox" id="chkAutoDL" checked> 自動ダウンロード</label>
</div>
<div class="controls" id="autoControls" style="display:none;">
<button class="btn btn-primary" id="btnProcess">回転処理を開始</button>
<button class="btn btn-download" id="btnDownload" style="display:none">回転済みPDFをダウンロード</button>
<button class="btn btn-reset" id="btnReset" style="display:none">リセット</button>
<span id="timeInfo" style="font-size:0.85em; color:#aaa;"></span>
</div>
<div class="progress-area" id="progressArea">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressFill"></div>
<div class="progress-text" id="progressText">0%</div>
</div>
<div id="statusText"></div>
</div>
<div class="log-area" id="logArea"></div>
<!-- 自動後レビュー -->
<div class="review-area" id="reviewArea">
<div class="review-header">
<h2>ページ確認・手動微調整</h2>
</div>
<div class="review-main">
<div class="preview-box">
<canvas id="previewCanvas"></canvas>
<div class="preview-label" id="previewLabel"></div>
<div class="below-preview">
<div class="manual-controls">
<div class="rotation-info">
<span class="rot-value" id="rotValue">0°</span>
<div id="rotSource" style="font-size:0.7em;margin-top:2px;"></div>
</div>
<button class="btn-rotate btn-rot-ccw" id="btnRotCCW">← 90°</button>
<button class="btn-rotate btn-rot-cw" id="btnRotCW">90° →</button>
<button class="btn-rotate btn-rot-180" id="btnRot180">180°</button>
<button class="btn-rotate btn-rot-reset" id="btnRotReset">0°</button>
</div>
<div class="review-nav">
<button class="btn-nav" id="btnPrev" disabled>◀ 前</button>
<span class="page-indicator" id="pageIndicator">1 / 1</span>
<button class="btn-nav" id="btnNext">次 ▶</button>
</div>
</div>
</div>
</div>
<div class="thumb-strip" id="thumbStrip"></div>
</div>
<!-- 手動モード -->
<div id="manualArea" style="display:none;">
<div class="controls">
<button class="btn btn-download" id="btnManualDL">回転済みPDFをダウンロード</button>
<button class="btn btn-reset" id="btnManualReset">リセット</button>
</div>
<div id="manualStatus" style="font-size:0.9em;color:#aaa;margin-bottom:10px;"></div>
<div class="manual-grid" id="manualGrid"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
var dropZone = document.getElementById('dropZone');
var fileInput = document.getElementById('fileInput');
var fileNameDiv = document.getElementById('fileName');
var modeSelect = document.getElementById('modeSelect');
var autoSettings = document.getElementById('autoSettings');
var autoControls = document.getElementById('autoControls');
var btnProcess = document.getElementById('btnProcess');
var btnDownload = document.getElementById('btnDownload');
var btnReset = document.getElementById('btnReset');
var progressArea = document.getElementById('progressArea');
var progressFill = document.getElementById('progressFill');
var progressText = document.getElementById('progressText');
var statusText = document.getElementById('statusText');
var logArea = document.getElementById('logArea');
var timeInfo = document.getElementById('timeInfo');
var reviewArea = document.getElementById('reviewArea');
var thumbStrip = document.getElementById('thumbStrip');
var previewCanvas = document.getElementById('previewCanvas');
var previewLabel = document.getElementById('previewLabel');
var pageIndicator = document.getElementById('pageIndicator');
var rotValue = document.getElementById('rotValue');
var rotSource = document.getElementById('rotSource');
var manualArea = document.getElementById('manualArea');
var manualGrid = document.getElementById('manualGrid');
var manualStatus = document.getElementById('manualStatus');
var pdfArrayBuffer = null;
var originalFileName = '';
var pageData = [];
var currentPage = 0;
var currentMode = ''; // 'auto' or 'manual'
/* === D&D === */
dropZone.addEventListener('dragover', function(e) { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', function(e) { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('dragover'); });
dropZone.addEventListener('drop', function(e) {
e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('dragover');
var files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length > 0) { fileInput.value = ''; handleFile(files[0]); }
});
fileInput.addEventListener('change', function(e) {
if (e.target.files && e.target.files[0]) handleFile(e.target.files[0]);
});
function handleFile(file) {
if (!file.name.toLowerCase().endsWith('.pdf')) { alert('PDFファイルを選択してください'); return; }
originalFileName = file.name.replace(/\.pdf$/i, '');
var reader = new FileReader();
reader.onload = function(e) {
pdfArrayBuffer = e.target.result;
fileNameDiv.textContent = file.name + '(' + (file.size / 1024 / 1024).toFixed(1) + 'MB)';
resetUI();
modeSelect.style.display = 'block';
};
reader.readAsArrayBuffer(file);
}
function resetUI() {
autoSettings.style.display = 'none';
autoControls.style.display = 'none';
btnDownload.style.display = 'none';
btnReset.style.display = 'none';
progressArea.style.display = 'none';
progressFill.style.width = '0%';
progressText.textContent = '0%';
statusText.textContent = '';
logArea.style.display = 'none';
logArea.innerHTML = '';
timeInfo.textContent = '';
reviewArea.style.display = 'none';
thumbStrip.innerHTML = '';
manualArea.style.display = 'none';
manualGrid.innerHTML = '';
pageData = [];
currentPage = 0;
currentMode = '';
}
function fullReset() {
pdfArrayBuffer = null; originalFileName = '';
fileInput.value = ''; fileNameDiv.textContent = '';
modeSelect.style.display = 'none';
resetUI();
}
/* === モード選択 === */
document.getElementById('btnModeAuto').addEventListener('click', function() {
currentMode = 'auto';
modeSelect.style.display = 'none';
autoSettings.style.display = 'flex';
autoControls.style.display = 'flex';
btnProcess.disabled = false;
});
document.getElementById('btnModeManual').addEventListener('click', function() {
currentMode = 'manual';
modeSelect.style.display = 'none';
manualArea.style.display = 'block';
manualStatus.textContent = 'サムネイル読み込み中...';
loadManualGrid();
});
/* === ユーティリティ === */
function addLog(msg, cls) {
logArea.style.display = 'block';
var div = document.createElement('div');
if (cls) div.className = cls;
div.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
logArea.appendChild(div);
logArea.scrollTop = logArea.scrollHeight;
}
function setProgress(pct, msg) {
progressFill.style.width = pct + '%';
progressText.textContent = Math.round(pct) + '%';
if (msg) statusText.textContent = msg;
}
function pageToImage(pdfDoc, pageNum, dpi) {
return pdfDoc.getPage(pageNum).then(function(page) {
var vp = page.getViewport({ scale: dpi / 72 });
var canvas = document.createElement('canvas');
canvas.width = vp.width; canvas.height = vp.height;
return page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise.then(function() { return canvas; });
});
}
function rotateCanvas(canvas, degrees) {
var d = ((degrees % 360) + 360) % 360;
if (d === 0) return canvas;
var c2 = document.createElement('canvas');
var ctx = c2.getContext('2d');
if (d === 90 || d === 270) { c2.width = canvas.height; c2.height = canvas.width; }
else { c2.width = canvas.width; c2.height = canvas.height; }
ctx.translate(c2.width / 2, c2.height / 2);
ctx.rotate(d * Math.PI / 180);
ctx.drawImage(canvas, -canvas.width / 2, -canvas.height / 2);
return c2;
}
function canvasToThumbSrc(canvas, w) {
var s = w / canvas.width;
var tc = document.createElement('canvas');
tc.width = w; tc.height = canvas.height * s;
tc.getContext('2d').drawImage(canvas, 0, 0, tc.width, tc.height);
return tc.toDataURL('image/jpeg', 0.6);
}
function detectOrientation(canvas, worker) {
var angles = [0, 90, 180, 270];
var bestAngle = 0; var bestConf = -1; var idx = 0;
function next() {
if (idx >= angles.length) return Promise.resolve({ angle: bestAngle, confidence: bestConf });
var angle = angles[idx];
var rotated = rotateCanvas(canvas, angle);
var dataUrl = rotated.toDataURL('image/png');
return worker.recognize(dataUrl).then(function(ret) {
var conf = ret.data.confidence;
if (conf > bestConf) { bestConf = conf; bestAngle = angle; }
if (angle === 0 && conf >= 85) return { angle: 0, confidence: conf };
idx++; return next();
}).catch(function() { idx++; return next(); });
}
return next();
}
/* === 手動モード === */
async function loadManualGrid() {
manualGrid.innerHTML = '';
var pdfDoc = await pdfjsLib.getDocument({ data: pdfArrayBuffer.slice(0) }).promise;
var total = pdfDoc.numPages;
pageData = [];
for (var p = 1; p <= total; p++) {
var cv = await pageToImage(pdfDoc, p, 100);
pageData.push({ canvas: cv, autoAngle: 0, manualAngle: 0,
totalAngle: function() { return ((this.autoAngle + this.manualAngle) % 360 + 360) % 360; }
});
addMgCard(p - 1, cv);
manualStatus.textContent = '読み込み中: ' + p + '/' + total;
}
manualStatus.textContent = total + 'ページ読み込み完了 — サムネイルをクリックして回転';
}
function addMgCard(idx, canvas) {
var card = document.createElement('div');
card.className = 'mg-card'; card.id = 'mgcard-' + idx;
var img = document.createElement('img');
img.src = canvasToThumbSrc(canvas, 300);
img.id = 'mgimg-' + idx;
card.appendChild(img);
var label = document.createElement('div');
label.className = 'mg-label'; label.textContent = 'P.' + (idx + 1);
card.appendChild(label);
var info = document.createElement('div');
info.className = 'mg-info'; info.id = 'mginfo-' + idx; info.textContent = '0°';
card.appendChild(info);
var btn = document.createElement('button');
btn.textContent = '90° 回転';
btn.style.cssText = 'padding:6px 16px;border:none;border-radius:6px;cursor:pointer;font-weight:bold;background:#ffaa00;color:#1a1a2e;margin-top:6px;';
btn.addEventListener('click', function() {
pageData[idx].manualAngle = (pageData[idx].manualAngle + 90) % 360;
refreshMgCard(idx);
});
card.appendChild(btn);
manualGrid.appendChild(card);
}
function refreshMgCard(idx) {
var pd = pageData[idx];
var total = pd.totalAngle();
var rotated = rotateCanvas(pd.canvas, total);
document.getElementById('mgimg-' + idx).src = canvasToThumbSrc(rotated, 300);
document.getElementById('mginfo-' + idx).textContent = total === 0 ? '0°' : total + '°';
}
/* 手動DL */
document.getElementById('btnManualDL').addEventListener('click', function() { generateAndDownload(); });
document.getElementById('btnManualReset').addEventListener('click', function() { fullReset(); });
/* === 自動モード === */
btnProcess.addEventListener('click', async function() {
if (!pdfArrayBuffer) return;
btnProcess.disabled = true;
btnDownload.style.display = 'none'; btnReset.style.display = 'none';
reviewArea.style.display = 'none'; thumbStrip.innerHTML = '';
logArea.innerHTML = ''; progressArea.style.display = 'block';
setProgress(0, '初期化中...'); pageData = []; currentPage = 0;
var startTime = Date.now();
var dpi = parseInt(document.getElementById('dpiSelect').value);
var numWorkers = parseInt(document.getElementById('workerCount').value);
addLog('DPI: ' + dpi + ' / Worker数: ' + numWorkers);
try {
var pdfDoc = await pdfjsLib.getDocument({ data: pdfArrayBuffer.slice(0) }).promise;
var totalPages = pdfDoc.numPages;
addLog('総ページ数: ' + totalPages);
setProgress(5, 'OCRエンジン初期化中(初回は学習データDLで時間かかります)...');
var workers = [];
for (var i = 0; i < numWorkers; i++) {
var w = await Tesseract.createWorker('jpn+eng', 1, { logger: function() {} });
workers.push(w);
}
addLog('OCRエンジン ' + numWorkers + '基 準備完了');
setProgress(10, 'ページ画像化中...');
for (var p = 1; p <= totalPages; p++) {
var cv = await pageToImage(pdfDoc, p, dpi);
pageData.push({ canvas: cv, autoAngle: 0, manualAngle: 0, confidence: 0,
totalAngle: function() { return ((this.autoAngle + this.manualAngle) % 360 + 360) % 360; }
});
setProgress(10 + (20 * p / totalPages), '画像化中: ' + p + '/' + totalPages);
}
addLog('全ページ画像化完了');
setProgress(30, '文字方向検出中...');
var processed = 0; var nextPageIdx = 0;
async function processPage(pageIdx, worker) {
var result = await detectOrientation(pageData[pageIdx].canvas, worker);
pageData[pageIdx].autoAngle = result.angle;
pageData[pageIdx].confidence = result.confidence;
processed++;
setProgress(30 + (60 * processed / totalPages), '向き検出中: ' + processed + '/' + totalPages);
var pn = pageIdx + 1;
if (result.angle === 0) {
addLog('P.' + pn + ': 正立(信頼度 ' + Math.round(result.confidence) + '%)', 'log-ok');
} else {
addLog('P.' + pn + ': ' + result.angle + '° 回転(信頼度 ' + Math.round(result.confidence) + '%)', 'log-rotate');
}
}
async function workerLoop(worker) {
while (nextPageIdx < totalPages) { var idx = nextPageIdx++; await processPage(idx, worker); }
}
await Promise.all(workers.map(function(w) { return workerLoop(w); }));
for (var wi = 0; wi < workers.length; wi++) await workers[wi].terminate();
addLog('OCRエンジン終了');
setProgress(100, '完了');
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
var rotatedCount = pageData.filter(function(d) { return d.autoAngle !== 0; }).length;
timeInfo.textContent = '処理時間: ' + elapsed + '秒 / ' + totalPages + 'ページ / ' + rotatedCount + 'ページ自動回転';
addLog('処理完了: ' + elapsed + '秒', 'log-ok');
/* 自動DL */
if (document.getElementById('chkAutoDL').checked) {
await generateAndDownload();
addLog('自動ダウンロード完了', 'log-ok');
}
/* レビュー表示 */
reviewArea.style.display = 'block';
buildThumbStrip();
renderPreview(0);
btnDownload.style.display = 'inline-block';
btnReset.style.display = 'inline-block';
btnProcess.disabled = false;
} catch (err) {
addLog('エラー: ' + err.message, 'log-err');
setProgress(0, 'エラーが発生しました');
btnProcess.disabled = false; btnReset.style.display = 'inline-block';
}
});
/* === レビュー === */
function renderPreview(idx) {
if (idx < 0 || idx >= pageData.length) return;
currentPage = idx;
var pd = pageData[idx];
var total = pd.totalAngle();
var rotated = rotateCanvas(pd.canvas, total);
previewCanvas.width = rotated.width; previewCanvas.height = rotated.height;
previewCanvas.getContext('2d').drawImage(rotated, 0, 0);
var autoLabel = pd.autoAngle === 0 ? '自動: 正立' : '自動: ' + pd.autoAngle + '°';
var manualLabel = pd.manualAngle === 0 ? '' : ' / 手動: ' + pd.manualAngle + '°';
previewLabel.textContent = 'P.' + (idx + 1) + ' — ' + autoLabel + manualLabel;
rotValue.textContent = total + '°';
rotSource.textContent = pd.manualAngle !== 0 ? '(手動調整あり)' : pd.autoAngle !== 0 ? '(自動検出)' : '';
pageIndicator.textContent = (idx + 1) + ' / ' + pageData.length;
document.getElementById('btnPrev').disabled = (idx === 0);
document.getElementById('btnNext').disabled = (idx === pageData.length - 1);
var items = thumbStrip.querySelectorAll('.ts-item');
for (var i = 0; i < items.length; i++) items[i].classList.toggle('active', i === idx);
if (items[idx]) items[idx].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
function buildThumbStrip() {
thumbStrip.innerHTML = '';
for (var i = 0; i < pageData.length; i++) {
(function(idx) {
var pd = pageData[idx];
var total = pd.totalAngle();
var rotated = rotateCanvas(pd.canvas, total);
var item = document.createElement('div');
item.className = 'ts-item' + (idx === currentPage ? ' active' : '') + (total !== 0 ? ' modified' : '');
var img = document.createElement('img');
img.src = canvasToThumbSrc(rotated, 70);
item.appendChild(img);
var label = document.createElement('div');
label.className = 'ts-label'; label.textContent = 'P.' + (idx + 1);
item.appendChild(label);
var badge = document.createElement('span');
badge.className = 'ts-badge';
if (total === 0) { badge.textContent = '正立'; badge.style.background = '#00ff88'; badge.style.color = '#1a1a2e'; }
else { badge.textContent = total + '°'; badge.style.background = '#ffaa00'; badge.style.color = '#1a1a2e'; }
item.appendChild(badge);
item.addEventListener('click', function() { renderPreview(idx); });
thumbStrip.appendChild(item);
})(i);
}
}
function updateThumbBadge(idx) {
var pd = pageData[idx];
var total = pd.totalAngle();
var item = thumbStrip.children[idx]; if (!item) return;
item.classList.toggle('modified', total !== 0);
var badge = item.querySelector('.ts-badge');
if (total === 0) { badge.textContent = '正立'; badge.style.background = '#00ff88'; badge.style.color = '#1a1a2e'; }
else { badge.textContent = total + '°'; badge.style.background = '#ffaa00'; badge.style.color = '#1a1a2e'; }
var rotated = rotateCanvas(pd.canvas, total);
item.querySelector('img').src = canvasToThumbSrc(rotated, 70);
}
/* 手動回転(レビュー内) */
function applyManualRotation(delta) {
var pd = pageData[currentPage];
if (delta === null) { pd.manualAngle = -pd.autoAngle; }
else { pd.manualAngle = ((pd.manualAngle + delta) % 360 + 360) % 360; }
updateThumbBadge(currentPage);
renderPreview(currentPage);
}
document.getElementById('btnRotCW').addEventListener('click', function() { applyManualRotation(90); });
document.getElementById('btnRotCCW').addEventListener('click', function() { applyManualRotation(-90); });
document.getElementById('btnRot180').addEventListener('click', function() { applyManualRotation(180); });
document.getElementById('btnRotReset').addEventListener('click', function() {
pageData[currentPage].manualAngle = 0; pageData[currentPage].autoAngle = 0;
updateThumbBadge(currentPage); renderPreview(currentPage);
});
document.getElementById('btnPrev').addEventListener('click', function() { if (currentPage > 0) renderPreview(currentPage - 1); });
document.getElementById('btnNext').addEventListener('click', function() { if (currentPage < pageData.length - 1) renderPreview(currentPage + 1); });
document.addEventListener('keydown', function(e) {
if (reviewArea.style.display === 'none') return;
if (e.key === 'ArrowLeft') { if (currentPage > 0) renderPreview(currentPage - 1); e.preventDefault(); }
if (e.key === 'ArrowRight') { if (currentPage < pageData.length - 1) renderPreview(currentPage + 1); e.preventDefault(); }
if (e.key === 'r' || e.key === 'R') { applyManualRotation(90); e.preventDefault(); }
if (e.key === 'l' || e.key === 'L') { applyManualRotation(-90); e.preventDefault(); }
});
/* リセット */
btnReset.addEventListener('click', function() { fullReset(); });
/* === PDF生成&ダウンロード === */
async function generateAndDownload() {
var pdfLibDoc = await PDFLib.PDFDocument.load(pdfArrayBuffer);
var pages = pdfLibDoc.getPages();
var rotatedCount = 0;
for (var i = 0; i < pageData.length; i++) {
var total = pageData[i].totalAngle();
if (total !== 0) {
var cur = pages[i].getRotation().angle;
pages[i].setRotation(PDFLib.degrees(cur + total));
rotatedCount++;
}
}
var bytes = await pdfLibDoc.save();
var blob = new Blob([bytes], { type: 'application/pdf' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = originalFileName + '_rotated.pdf';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(function() { URL.revokeObjectURL(url); }, 5000);
addLog('PDF保存: ' + rotatedCount + 'ページ回転 → ' + originalFileName + '_rotated.pdf', 'log-ok');
}
</script>
</body>
</html>
③ PDF変換
<!DOCTYPE html>
<html lang="ja">
<head>
<style>
.preloader {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: #fff;
display: flex; justify-content: center; align-items: center; gap: 20px;
z-index: 9999;
animation: fadeOut 0.1s 0.3s forwards;
}
.glasses {
display: flex; align-items: center;
animation: swing 0.3s ease-in-out infinite alternate;
transform-origin: top center;
}
.lens { width: 40px; height: 20px; border: 3px solid #888; border-radius: 4px; background: rgba(200,200,200,0.2); }
.bridge { width: 12px; height: 3px; background: #888; }
.temple { width: 20px; height: 3px; background: #888; }
.pencil {
font-size: 48px;
animation: swing 0.3s ease-in-out infinite alternate;
animation-delay: 0.15s;
transform-origin: top center;
display: inline-block;
}
@keyframes swing { 0% { transform: rotate(-15deg); } 100% { transform: rotate(15deg); } }
@keyframes fadeOut { to { opacity: 0; visibility: hidden; } }
</style>
<div class="preloader">
<div class="glasses">
<div class="temple"></div><div class="lens"></div><div class="bridge"></div><div class="lens"></div><div class="temple"></div>
</div>
<span class="pencil">✏️</span>
</div>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>画像形式変換ツール(PDF対応)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex; justify-content: center; align-items: center; padding: 20px;
}
.container {
background: white; border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px; max-width: 700px; width: 100%;
}
h1 { text-align: center; color: #333; margin-bottom: 30px; font-size: 28px; }
.upload-area {
border: 3px dashed #667eea; border-radius: 15px; padding: 40px;
text-align: center; transition: all 0.3s ease; cursor: pointer; background: #f8f9fa;
}
.upload-area:hover { border-color: #764ba2; background: #f0f0f0; }
.upload-area.dragover { background: #e8e8ff; border-color: #764ba2; }
.upload-icon { font-size: 48px; color: #667eea; margin-bottom: 20px; }
.upload-text { color: #666; font-size: 16px; margin-bottom: 10px; }
.upload-hint { color: #999; font-size: 14px; }
.supported-formats { margin-top: 10px; padding: 10px; background: #f0f8ff; border-radius: 8px; }
.supported-formats-title { color: #667eea; font-weight: bold; margin-bottom: 5px; }
.format-list { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.format-tag { background: #667eea; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
input[type="file"] { display: none; }
.preview-section { margin-top: 30px; display: none; }
.preview-image {
max-width: 100%; max-height: 300px; border-radius: 10px;
box-shadow: 0 5px 15px rgba(0,0,0,0.2); margin: 20px auto; display: block;
}
.file-info { background: #f8f9fa; padding: 15px; border-radius: 10px; margin-bottom: 20px; }
.file-info p { color: #666; margin: 5px 0; font-size: 14px; }
.format-selection { margin: 20px 0; }
.format-selection label { display: block; color: #333; margin-bottom: 10px; font-weight: 500; }
.format-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
.format-btn {
flex: 1; padding: 10px 20px; border: 2px solid #667eea; background: white;
color: #667eea; border-radius: 8px; cursor: pointer; transition: all 0.3s ease;
font-size: 14px; font-weight: 500; min-width: 80px;
}
.format-btn:hover { background: #667eea; color: white; }
.format-btn.active { background: #667eea; color: white; }
.quality-slider, .pdf-options {
margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 10px;
}
.quality-slider label, .pdf-options label {
display: block; color: #333; margin-bottom: 10px; font-weight: 500;
}
.slider-container { display: flex; align-items: center; gap: 15px; }
input[type="range"] {
flex: 1; -webkit-appearance: none; appearance: none;
height: 6px; background: #ddd; border-radius: 3px; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 20px; height: 20px; background: #667eea; border-radius: 50%; cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 20px; height: 20px; background: #667eea; border-radius: 50%; cursor: pointer; border: none;
}
.quality-value {
background: #667eea; color: white; padding: 5px 10px;
border-radius: 5px; min-width: 45px; text-align: center; font-weight: 500;
}
.pdf-size-select {
width: 100%; padding: 8px 12px; border: 2px solid #ddd; border-radius: 8px;
font-size: 14px; margin-top: 10px; background: white; cursor: pointer; transition: border-color 0.3s;
}
.pdf-size-select:focus { outline: none; border-color: #667eea; }
.pdf-orientation { display: flex; gap: 15px; margin-top: 10px; }
.orientation-option { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.orientation-option input[type="radio"] { cursor: pointer; }
.action-buttons { display: flex; gap: 10px; margin-top: 20px; }
.btn {
flex: 1; padding: 12px 24px; border: none; border-radius: 8px;
font-size: 16px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;
}
.btn-convert { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
.btn-convert:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(102,126,234,0.4); }
.btn-convert:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-reset { background: #e0e0e0; color: #666; }
.btn-reset:hover { background: #d0d0d0; }
.download-link {
display: inline-block; margin-top: 20px; padding: 12px 24px;
background: #28a745; color: white; text-decoration: none; border-radius: 8px;
font-weight: 500; transition: all 0.3s ease;
}
.download-link:hover { background: #218838; transform: translateY(-2px); box-shadow: 0 5px 15px rgba(40,167,69,0.4); }
.bulk-download-btn {
display: inline-block; margin-bottom: 15px; padding: 14px 28px;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white; border: none; border-radius: 8px; cursor: pointer;
font-size: 16px; font-weight: 600; transition: all 0.3s ease; width: 100%; text-align: center;
}
.bulk-download-btn:hover { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(238,90,36,0.4); }
.status-message { padding: 10px 15px; border-radius: 8px; margin-top: 15px; text-align: center; font-size: 14px; }
.status-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.status-warning { background: #fff3cd; color: #856404; border: 1px solid #ffeaa7; }
.status-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
.multiple-files-area {
margin-top: 15px; padding: 15px; background: #f0f0ff; border-radius: 10px; display: none;
}
.file-list { margin-top: 10px; }
.file-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px; background: white; border-radius: 5px; margin-bottom: 5px;
}
.file-item button {
padding: 4px 8px; background: #dc3545; color: white;
border: none; border-radius: 4px; cursor: pointer; font-size: 12px;
}
.file-item button:hover { background: #c82333; }
.loading-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.7); display: none;
justify-content: center; align-items: center; z-index: 9999;
}
.loading-content { background: white; padding: 30px; border-radius: 10px; text-align: center; }
.loading-spinner {
border: 4px solid #f3f3f3; border-top: 4px solid #667eea;
border-radius: 50%; width: 50px; height: 50px;
animation: spin 1s linear infinite; margin: 0 auto 20px;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-text { color: #333; font-size: 16px; }
</style>
</head>
<body>
<div class="container">
<h1>🖼️ 画像形式変換ツール</h1>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📁</div>
<p class="upload-text">画像をドラッグ&ドロップ</p>
<p class="upload-hint">または クリックしてファイルを選択</p>
<div class="supported-formats">
<div class="supported-formats-title">対応形式:</div>
<div class="format-list">
<span class="format-tag">JPG</span>
<span class="format-tag">PNG</span>
<span class="format-tag">BMP</span>
<span class="format-tag">WEBP</span>
<span class="format-tag">ICO</span>
<span class="format-tag">HEIC</span>
<span class="format-tag">RAW</span>
<span class="format-tag">PDF</span>
</div>
</div>
<input type="file" id="fileInput" accept=".jpg,.jpeg,.png,.bmp,.webp,.ico,.heic,.heif,.raw,.cr2,.nef,.arw,.dng,.orf,.rw2,.pdf,image/*,application/pdf" multiple>
</div>
<div class="multiple-files-area" id="multipleFilesArea">
<p><strong>複数の画像を選択しました(PDF作成用)</strong></p>
<div class="file-list" id="fileList"></div>
</div>
<div class="preview-section" id="previewSection">
<div class="file-info" id="fileInfo"></div>
<img class="preview-image" id="previewImage" alt="プレビュー">
<div class="format-selection">
<label>変換後の形式を選択:</label>
<div class="format-buttons">
<button class="format-btn" data-format="jpeg">JPEG</button>
<button class="format-btn" data-format="png">PNG</button>
<button class="format-btn" data-format="bmp">BMP</button>
<button class="format-btn" data-format="webp">WEBP</button>
<button class="format-btn" data-format="ico">ICO</button>
<button class="format-btn" data-format="pdf">PDF</button>
</div>
</div>
<div class="quality-slider" id="qualitySlider">
<label>画質 (JPEGのみ):</label>
<div class="slider-container">
<input type="range" id="quality" min="10" max="100" value="90">
<span class="quality-value" id="qualityValue">90%</span>
</div>
</div>
<div class="pdf-options" id="pdfOptions" style="display: none;">
<label>PDFページサイズ:</label>
<select class="pdf-size-select" id="pdfPageSize">
<option value="a4">A4 (210 × 297 mm)</option>
<option value="a3">A3 (297 × 420 mm)</option>
<option value="letter">Letter (216 × 279 mm)</option>
<option value="legal">Legal (216 × 356 mm)</option>
<option value="fit">画像に合わせる</option>
</select>
<label style="margin-top: 15px;">用紙の向き:</label>
<div class="pdf-orientation">
<label class="orientation-option">
<input type="radio" name="orientation" value="portrait" checked><span>縦向き</span>
</label>
<label class="orientation-option">
<input type="radio" name="orientation" value="landscape"><span>横向き</span>
</label>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-convert" id="convertBtn">変換する</button>
<button class="btn btn-reset" id="resetBtn">リセット</button>
</div>
<div id="downloadSection"></div>
<div id="statusMessage"></div>
</div>
</div>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-content">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">変換処理中...</div>
</div>
</div>
<!-- ライブラリ -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js"></script>
<script>
let selectedFiles = [];
let selectedFormat = 'jpeg';
let originalImages = [];
let isPdfSource = false;
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const previewSection = document.getElementById('previewSection');
const previewImage = document.getElementById('previewImage');
const fileInfo = document.getElementById('fileInfo');
const formatButtons = document.querySelectorAll('.format-btn');
const qualitySlider = document.getElementById('qualitySlider');
const pdfOptions = document.getElementById('pdfOptions');
const quality = document.getElementById('quality');
const qualityValue = document.getElementById('qualityValue');
const convertBtn = document.getElementById('convertBtn');
const resetBtn = document.getElementById('resetBtn');
const downloadSection = document.getElementById('downloadSection');
const statusMessage = document.getElementById('statusMessage');
const multipleFilesArea = document.getElementById('multipleFilesArea');
const fileList = document.getElementById('fileList');
const loadingOverlay = document.getElementById('loadingOverlay');
const loadingText = document.getElementById('loadingText');
const rawExtensions = ['cr2','nef','arw','dng','orf','rw2','raw','raf','sr2','pef'];
uploadArea.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => handleFileSelect(Array.from(e.target.files)));
uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
uploadArea.addEventListener('drop', (e) => {
e.preventDefault(); uploadArea.classList.remove('dragover');
handleFileSelect(Array.from(e.dataTransfer.files));
});
function showLoading(show = true, text = '変換処理中...') {
loadingText.textContent = text;
loadingOverlay.style.display = show ? 'flex' : 'none';
}
function getFileFormat(file) {
const ext = file.name.split('.').pop().toLowerCase();
if (ext === 'ico') return 'ico';
if (['heic','heif'].includes(ext)) return 'heic';
if (rawExtensions.includes(ext)) return 'raw';
if (ext === 'pdf' || file.type === 'application/pdf') return 'pdf';
if (file.type.startsWith('image/')) return 'image';
return 'unknown';
}
async function handleFileSelect(files) {
if (!files || files.length === 0) return;
selectedFiles = []; originalImages = []; isPdfSource = false;
const validFiles = files.filter(f => getFileFormat(f) !== 'unknown');
if (validFiles.length === 0) { showStatus('対応している画像ファイルを選択してください','error'); return; }
selectedFiles = validFiles;
const hasSpecial = validFiles.some(f => { const fmt = getFileFormat(f); return fmt === 'heic' || fmt === 'raw'; });
if (hasSpecial) showStatus('HEIC/RAWファイルの処理には時間がかかる場合があります','info');
if (validFiles.length > 1) {
showMultipleFiles(validFiles);
await loadImagesForPDF(validFiles);
} else {
multipleFilesArea.style.display = 'none';
await loadSingleImage(validFiles[0]);
}
previewSection.style.display = 'block';
uploadArea.style.display = 'none';
}
async function convertHEIC(file) {
try {
showLoading(true, 'HEIC変換中...');
const blob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.9 });
return blob;
} catch (error) {
console.error('HEIC変換エラー:', error);
showStatus('HEICファイルの変換に失敗しました','error');
return null;
} finally { showLoading(false); }
}
async function processRAW(file) {
showStatus('RAWファイルは限定的なサポートです。正確な変換には専用ソフトをご利用ください','warning');
return file;
}
function showMultipleFiles(files) {
multipleFilesArea.style.display = 'block';
fileList.innerHTML = '';
files.forEach((file, index) => {
const format = getFileFormat(file);
const label = format === 'heic' ? ' (HEIC)' : format === 'raw' ? ' (RAW)' : format === 'ico' ? ' (ICO)' : format === 'pdf' ? ' (PDF)' : '';
const item = document.createElement('div');
item.className = 'file-item';
item.innerHTML = `<span>${index+1}. ${file.name}${label}</span><button onclick="removeFile(${index})">削除</button>`;
fileList.appendChild(item);
});
}
window.removeFile = function(index) {
selectedFiles.splice(index, 1);
originalImages.splice(index, 1);
if (selectedFiles.length === 0) { resetAll(); }
else if (selectedFiles.length === 1) { multipleFilesArea.style.display = 'none'; loadSingleImage(selectedFiles[0]); }
else { showMultipleFiles(selectedFiles); if (originalImages.length > 0) previewImage.src = originalImages[0].src; }
}
async function loadSingleImage(file) {
try {
showLoading(true, '読み込み中...');
const format = getFileFormat(file);
if (format === 'pdf') {
isPdfSource = true;
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const totalPages = pdfDoc.numPages;
originalImages = []; selectedFiles = [file];
for (let i = 1; i <= totalPages; i++) {
loadingText.textContent = `PDFページ読み込み中... ${i}/${totalPages}`;
const page = await pdfDoc.getPage(i);
const scale = 2;
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width; canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport }).promise;
const img = new Image();
await new Promise((resolve) => { img.onload = resolve; img.src = canvas.toDataURL('image/png'); });
originalImages.push(img);
}
previewImage.src = originalImages[0].src;
const fileSize = (file.size / 1024).toFixed(2);
fileInfo.innerHTML = `
<p><strong>ファイル名:</strong> ${file.name}</p>
<p><strong>形式:</strong> PDF</p>
<p><strong>サイズ:</strong> ${fileSize} KB</p>
<p><strong>ページ数:</strong> ${totalPages} ページ</p>
<p><strong>解像度(1P):</strong> ${originalImages[0].width} × ${originalImages[0].height} px</p>`;
if (totalPages > 1) showStatus(`PDF ${totalPages}ページを読み込みました。各ページが個別ファイルになります`,'info');
showLoading(false);
return;
}
isPdfSource = false;
let processedFile = file;
if (format === 'heic') {
const convertedBlob = await convertHEIC(file);
if (!convertedBlob) { showLoading(false); return; }
processedFile = new File([convertedBlob], file.name.replace(/\.heic$/i, '.jpg'), { type: 'image/jpeg' });
}
if (format === 'raw') processedFile = await processRAW(file);
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => { originalImages = [img]; previewImage.src = e.target.result; showFileInfo(file, img, format); showLoading(false); };
img.onerror = () => { showStatus('画像の読み込みに失敗しました','error'); showLoading(false); };
img.src = e.target.result;
};
reader.readAsDataURL(processedFile);
} catch (error) {
console.error('読み込みエラー:', error);
showStatus('ファイルの読み込みに失敗しました','error');
showLoading(false);
}
}
async function loadImagesForPDF(files) {
let loadedCount = 0;
originalImages = new Array(files.length);
isPdfSource = false;
showLoading(true, '複数ファイル読み込み中...');
for (let index = 0; index < files.length; index++) {
const file = files[index];
let processedFile = file;
const format = getFileFormat(file);
try {
if (format === 'heic') {
const convertedBlob = await convertHEIC(file);
if (!convertedBlob) { loadedCount++; continue; }
processedFile = new File([convertedBlob], file.name.replace(/\.heic$/i, '.jpg'), { type: 'image/jpeg' });
}
if (format === 'raw') processedFile = await processRAW(file);
if (format === 'pdf') {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const page = await pdfDoc.getPage(1);
const scale = 2;
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width; canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport }).promise;
const img = new Image();
await new Promise((resolve) => { img.onload = resolve; img.src = canvas.toDataURL('image/png'); });
originalImages[index] = img;
loadedCount++;
if (index === 0) { previewImage.src = img.src; showFileInfo(file, img, format); }
if (loadedCount === files.length) fileInfo.innerHTML += `<p><strong>総ファイル数:</strong> ${files.length} 枚</p>`;
continue;
}
await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
originalImages[index] = img; loadedCount++;
if (index === 0) { previewImage.src = e.target.result; showFileInfo(file, img, format); }
if (loadedCount === files.length) fileInfo.innerHTML += `<p><strong>総ファイル数:</strong> ${files.length} 枚</p>`;
resolve();
};
img.src = e.target.result;
};
reader.readAsDataURL(processedFile);
});
} catch (error) {
console.error(`ファイル ${file.name} の処理エラー:`, error);
loadedCount++;
}
}
showLoading(false);
}
function showFileInfo(file, img, format) {
const fileSize = (file.size / 1024).toFixed(2);
let fileType = file.type.split('/')[1]?.toUpperCase() || 'Unknown';
if (format === 'heic') fileType = 'HEIC';
if (format === 'raw') fileType = 'RAW (' + file.name.split('.').pop().toUpperCase() + ')';
if (format === 'ico') fileType = 'ICO';
if (format === 'pdf') fileType = 'PDF';
fileInfo.innerHTML = `
<p><strong>ファイル名:</strong> ${file.name}</p>
<p><strong>形式:</strong> ${fileType}</p>
<p><strong>サイズ:</strong> ${fileSize} KB</p>
${img ? `<p><strong>解像度:</strong> ${img.width} × ${img.height} px</p>` : ''}`;
}
formatButtons.forEach(btn => {
btn.addEventListener('click', () => {
formatButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedFormat = btn.dataset.format;
qualitySlider.style.display = selectedFormat === 'jpeg' ? 'block' : 'none';
pdfOptions.style.display = selectedFormat === 'pdf' ? 'block' : 'none';
});
});
formatButtons[0].classList.add('active');
quality.addEventListener('input', () => { qualityValue.textContent = quality.value + '%'; });
convertBtn.addEventListener('click', () => {
if (originalImages.length === 0) return;
if (selectedFormat === 'pdf') convertToPDF();
else if (selectedFormat === 'ico') convertToICO();
else convertToImage();
});
function convertToICO() {
showLoading(true);
if (originalImages.length > 1) showStatus('ICO変換は1ページ目のみ対象です','warning');
setTimeout(() => {
const img = originalImages[0];
const sizes = [16,32,48,64,128,256];
let targetSize = 32;
for (let size of sizes) { if (img.width <= size && img.height <= size) { targetSize = size; break; } targetSize = size; }
const canvas = document.createElement('canvas');
canvas.width = targetSize; canvas.height = targetSize;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, targetSize, targetSize);
const scale = Math.min(targetSize / img.width, targetSize / img.height);
const sw = img.width * scale; const sh = img.height * scale;
const x = (targetSize - sw) / 2; const y = (targetSize - sh) / 2;
ctx.drawImage(img, x, y, sw, sh);
canvas.toBlob((blob) => {
if (!blob) { showStatus('ICO変換に失敗しました','error'); showLoading(false); return; }
const url = URL.createObjectURL(blob);
const originalName = selectedFiles[0].name.split('.')[0];
downloadSection.innerHTML = `
<a href="${url}" download="${originalName}_converted.ico" class="download-link">📥 ICOファイルをダウンロード</a>
<p style="margin-top:10px;color:#666;font-size:12px;">※ ICOサイズ: ${targetSize}×${targetSize}px</p>`;
showStatus('ICO変換が完了しました!','success');
showLoading(false);
}, 'image/png');
}, 100);
}
function convertToImage() {
showLoading(true);
setTimeout(() => {
let mimeType, extension;
switch (selectedFormat) {
case 'jpeg': mimeType = 'image/jpeg'; extension = 'jpg'; break;
case 'png': mimeType = 'image/png'; extension = 'png'; break;
case 'bmp': mimeType = 'image/bmp'; extension = 'bmp'; break;
case 'webp': mimeType = 'image/webp'; extension = 'webp'; break;
}
const q = selectedFormat === 'jpeg' ? quality.value / 100 : 1;
const originalName = selectedFiles[0].name.split('.')[0];
// 単一画像
if (originalImages.length === 1) {
const canvas = document.createElement('canvas');
const img = originalImages[0];
canvas.width = img.width; canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (selectedFormat === 'jpeg' || selectedFormat === 'bmp') { ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); }
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (!blob) { showStatus('変換に失敗しました','error'); showLoading(false); return; }
const url = URL.createObjectURL(blob);
downloadSection.innerHTML = `<a href="${url}" download="${originalName}_converted.${extension}" class="download-link">📥 変換した画像をダウンロード</a>`;
showStatus('変換が完了しました!','success');
showLoading(false);
}, mimeType, q);
return;
}
// 複数ページ
const total = originalImages.length;
let blobs = new Array(total);
let processed = 0;
originalImages.forEach((img, index) => {
const canvas = document.createElement('canvas');
canvas.width = img.width; canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (selectedFormat === 'jpeg' || selectedFormat === 'bmp') { ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); }
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
blobs[index] = blob;
processed++;
if (processed === total) {
let linksHTML = '';
// 一括ダウンロードボタン(先頭)
if (total > 1) {
linksHTML += `<button class="bulk-download-btn" id="bulkDownloadBtn">📦 全${total}ファイルを一括ダウンロード</button><hr style="margin:15px 0;">`;
}
linksHTML += '<p style="margin-bottom:10px;font-weight:bold;">個別ダウンロード:</p>';
const fileData = [];
blobs.forEach((b, i) => {
if (b) {
const url = URL.createObjectURL(b);
const fileName = `${originalName}_page${i + 1}.${extension}`;
fileData.push({ url, fileName });
linksHTML += `<a href="${url}" download="${fileName}" class="download-link" style="margin:5px;font-size:14px;">📥 ${fileName}</a><br>`;
}
});
downloadSection.innerHTML = linksHTML;
const bulkBtn = document.getElementById('bulkDownloadBtn');
if (bulkBtn) {
bulkBtn.addEventListener('click', () => {
fileData.forEach((fd, i) => {
setTimeout(() => {
const a = document.createElement('a');
a.href = fd.url; a.download = fd.fileName;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}, i * 300);
});
});
}
showStatus(`${total}ページの変換が完了しました!`,'success');
showLoading(false);
}
}, mimeType, q);
});
}, 100);
}
function convertToPDF() {
showLoading(true);
setTimeout(() => {
const { jsPDF } = window.jspdf;
const pageSize = document.getElementById('pdfPageSize').value;
const orientation = document.querySelector('input[name="orientation"]:checked').value;
let pdf;
if (pageSize === 'fit') {
const img = originalImages[0];
const wMM = img.width * 0.264583; const hMM = img.height * 0.264583;
pdf = new jsPDF({ orientation: wMM > hMM ? 'landscape' : 'portrait', unit: 'mm', format: [wMM, hMM] });
} else {
pdf = new jsPDF({ orientation, unit: 'mm', format: pageSize });
}
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
originalImages.forEach((img, index) => {
if (index > 0) pdf.addPage();
const canvas = document.createElement('canvas');
canvas.width = img.width; canvas.height = img.height;
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
const imgData = canvas.toDataURL('image/jpeg', 0.95);
const imgRatio = img.width / img.height;
const pageRatio = pageWidth / pageHeight;
let fw, fh, x, y;
if (imgRatio > pageRatio) { fw = pageWidth; fh = pageWidth / imgRatio; x = 0; y = (pageHeight - fh) / 2; }
else { fh = pageHeight; fw = pageHeight * imgRatio; x = (pageWidth - fw) / 2; y = 0; }
pdf.addImage(imgData, 'JPEG', x, y, fw, fh);
});
const pdfBlob = pdf.output('blob');
const url = URL.createObjectURL(pdfBlob);
const originalName = selectedFiles[0].name.split('.')[0];
downloadSection.innerHTML = `<a href="${url}" download="${originalName}_converted.pdf" class="download-link">📄 PDFをダウンロード</a>`;
showStatus('PDF変換が完了しました!','success');
showLoading(false);
}, 100);
}
function resetAll() {
selectedFiles = []; selectedFormat = 'jpeg'; originalImages = []; isPdfSource = false;
fileInput.value = '';
previewSection.style.display = 'none'; uploadArea.style.display = 'block';
multipleFilesArea.style.display = 'none';
downloadSection.innerHTML = ''; statusMessage.innerHTML = '';
formatButtons.forEach(b => b.classList.remove('active'));
formatButtons[0].classList.add('active');
quality.value = 90; qualityValue.textContent = '90%';
qualitySlider.style.display = 'block'; pdfOptions.style.display = 'none';
}
resetBtn.addEventListener('click', resetAll);
function showStatus(message, type) {
statusMessage.innerHTML = `<div class="status-message status-${type}">${message}</div>`;
setTimeout(() => { statusMessage.innerHTML = ''; }, 5000);
}
</script>
</body>
</html>
④ PDF比較
<!DOCTYPE html>
<html lang="ja">
<head>
<style>
.preloader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
z-index: 9999;
animation: fadeOut 0.1s 0.3s forwards;
}
.glasses {
display: flex;
align-items: center;
animation: swing 0.3s ease-in-out infinite alternate;
transform-origin: top center;
}
.lens {
width: 40px;
height: 20px;
border: 3px solid #888;
border-radius: 4px;
background: rgba(200,200,200,0.2);
}
.bridge {
width: 12px;
height: 3px;
background: #888;
}
.temple {
width: 20px;
height: 3px;
background: #888;
}
.pencil {
font-size: 48px;
animation: swing 0.3s ease-in-out infinite alternate;
animation-delay: 0.15s;
transform-origin: top center;
display: inline-block;
}
@keyframes swing {
0% { transform: rotate(-15deg); }
100% { transform: rotate(15deg); }
}
@keyframes fadeOut {
to { opacity: 0; visibility: hidden; }
}
</style>
<div class="preloader">
<div class="glasses">
<div class="temple"></div>
<div class="lens"></div>
<div class="bridge"></div>
<div class="lens"></div>
<div class="temple"></div>
</div>
<span class="pencil">✏️</span>
</div>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF図面比較ツール</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 30px;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 15px;
}
h1 { color: #333; font-size: 1.8em; margin: 0; white-space: nowrap; }
.slot-wrapper {
display: flex;
align-items: center;
gap: 10px;
background: #f0f0f0;
padding: 8px 12px;
border-radius: 6px;
}
.slot-label { font-size: 11px; color: #666; font-weight: bold; white-space: nowrap; }
.slot-container { display: flex; flex-wrap: wrap; gap: 2px; }
.slot-group { display: flex; gap: 1px; align-items: center; margin-right: 3px; }
.slot-btn {
padding: 5px 8px;
font-size: 10px;
border: none;
border-radius: 3px;
cursor: pointer;
color: #fff;
transition: all 0.2s;
}
.slot-btn.save { background: #4CAF50; }
.slot-btn.save:hover { background: #45a049; }
.slot-btn.load { background: #2196F3; }
.slot-btn.load:hover { background: #1976D2; }
.slot-btn.load:disabled { background: #ccc; cursor: not-allowed; }
.slot-btn.delete { background: #f44336; padding: 5px 6px; }
.slot-btn.delete:hover { background: #d32f2f; }
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 12px 24px;
border-radius: 6px;
z-index: 9999;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.toast.show { opacity: 1; }
.drop-zones { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px; }
.drop-zone {
border: 3px dashed #ccc;
border-radius: 10px;
padding: 40px 20px;
text-align: center;
background: #f9f9f9;
transition: all 0.3s ease;
cursor: pointer;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.drop-zone:hover { border-color: #667eea; background: #f0f4ff; }
.drop-zone.dragover { border-color: #764ba2; background: #ffe4f3; transform: scale(1.02); }
.drop-zone.loaded { border-color: #4caf50; background: #e8f5e9; }
.drop-zone h3 { color: #666; margin-bottom: 10px; }
.drop-zone p { color: #999; font-size: 0.9em; }
.file-info { margin-top: 10px; padding: 10px; background: white; border-radius: 5px; font-size: 0.9em; color: #333; }
.page-info { margin-top: 5px; color: #667eea; font-weight: bold; }
.controls { background: #f5f5f5; padding: 20px; border-radius: 10px; margin-bottom: 20px; }
.control-group { margin-bottom: 15px; }
.control-group label { display: block; margin-bottom: 5px; color: #555; font-weight: 500; }
.page-selector {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
padding: 15px;
background: white;
border-radius: 8px;
margin-bottom: 15px;
}
.page-input-group { display: flex; align-items: center; gap: 10px; }
.page-input-group label { margin: 0; min-width: 80px; }
.page-input { flex: 1; padding: 8px; border: 2px solid #ddd; border-radius: 5px; font-size: 1em; }
.page-input:focus { border-color: #667eea; outline: none; }
.page-range { font-size: 0.9em; color: #666; margin-left: 10px; }
.batch-mode {
display: flex;
align-items: center;
gap: 15px;
padding: 10px;
background: #fff3cd;
border-radius: 5px;
margin-bottom: 15px;
}
.batch-mode input[type="checkbox"] { width: 20px; height: 20px; cursor: pointer; }
.radio-group { display: flex; gap: 20px; flex-wrap: wrap; }
.radio-option {
display: flex;
align-items: center;
padding: 8px 12px;
background: white;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
}
.radio-option:hover { background: #e3f2fd; }
.radio-option input[type="radio"] { margin-right: 8px; }
.slider-container { display: flex; align-items: center; gap: 15px; }
input[type="range"] { flex: 1; height: 6px; background: #ddd; border-radius: 3px; outline: none; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
background: #667eea;
border-radius: 50%;
cursor: pointer;
}
.slider-value { min-width: 50px; text-align: center; font-weight: bold; color: #667eea; }
.compare-button {
display: block;
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1.1em;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.compare-button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(0,0,0,0.2); }
.compare-button:disabled { background: #ccc; cursor: not-allowed; }
.results-tabs { display: none; margin-bottom: 20px; }
.tabs-header { display: flex; gap: 5px; border-bottom: 2px solid #ddd; overflow-x: auto; padding-bottom: 0; }
.tab-button {
padding: 10px 20px;
background: #f5f5f5;
border: none;
border-radius: 10px 10px 0 0;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
font-weight: 500;
}
.tab-button:hover { background: #e0e0e0; }
.tab-button.active {
background: white;
border: 2px solid #ddd;
border-bottom: 2px solid white;
margin-bottom: -2px;
color: #667eea;
}
.zoom-controls {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 10px;
background: white;
padding: 5px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 100;
}
.zoom-button {
width: 35px;
height: 35px;
border: none;
background: #667eea;
color: white;
border-radius: 5px;
cursor: pointer;
font-size: 18px;
font-weight: bold;
transition: all 0.3s ease;
}
.zoom-button:hover { background: #764ba2; }
.zoom-label {
display: flex;
align-items: center;
padding: 0 10px;
min-width: 60px;
justify-content: center;
font-weight: bold;
color: #667eea;
}
.result-container {
position: relative;
background: #f9f9f9;
border-radius: 10px;
padding: 20px;
min-height: 600px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.canvas-wrapper {
position: relative;
width: 100%;
height: 600px;
overflow: hidden;
border: 1px solid #ddd;
border-radius: 5px;
background: white;
}
.canvas-container {
display: none;
position: absolute;
cursor: grab;
transform-origin: center center;
}
.canvas-container.active { display: block; }
.canvas-container.dragging { cursor: grabbing; }
.result-canvas { display: block; image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; }
.loading { display: none; text-align: center; color: #666; }
.loading.active { display: block; }
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin-bottom: 20px;
padding: 15px;
background: #f5f5f5;
border-radius: 5px;
}
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-color { width: 20px; height: 20px; border-radius: 3px; border: 1px solid #ddd; }
.action-buttons { display: none; justify-content: center; gap: 15px; margin-bottom: 20px; }
.action-buttons.show { display: flex; }
.action-button {
padding: 10px 20px;
background: #4caf50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: bold;
transition: all 0.3s ease;
}
.action-button:hover { background: #45a049; }
.action-button.secondary { background: #2196F3; }
.action-button.secondary:hover { background: #1976D2; }
.action-button.tertiary { background: #ff9800; }
.action-button.tertiary:hover { background: #f57c00; }
.progress-bar {
display: none;
width: 100%;
height: 20px;
background: #f0f0f0;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.progress-bar.active { display: block; }
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.9em;
}
input[type="file"] { display: none; }
.adjust-panel {
display: none;
justify-content: center;
align-items: center;
gap: 20px;
padding: 15px;
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
border-radius: 10px;
margin-top: 20px;
border: 2px solid #4caf50;
}
.adjust-panel.show { display: flex; flex-wrap: wrap; }
.adjust-panel h4 {
color: #2e7d32;
font-size: 14px;
margin-right: 10px;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 40px);
grid-template-rows: repeat(3, 40px);
gap: 3px;
}
.dpad-btn {
width: 40px;
height: 40px;
border: none;
background: #4caf50;
color: white;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
font-weight: bold;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.dpad-btn:hover { background: #45a049; transform: scale(1.05); }
.dpad-btn:active { transform: scale(0.95); background: #388e3c; }
.dpad-btn.center { background: #ff9800; font-size: 11px; }
.dpad-btn.center:hover { background: #f57c00; }
.dpad-btn.empty { visibility: hidden; }
.adjust-info {
display: flex;
gap: 15px;
font-size: 14px;
color: #333;
background: white;
padding: 10px 15px;
border-radius: 6px;
}
.adjust-info div {
font-family: monospace;
font-weight: bold;
}
.step-control {
display: flex;
align-items: center;
gap: 8px;
}
.step-control label { font-size: 13px; color: #555; }
.step-control select {
padding: 8px 12px;
border: 2px solid #4caf50;
border-radius: 6px;
font-size: 14px;
background: white;
cursor: pointer;
}
@media (max-width: 768px) {
.header-row { flex-direction: column; align-items: flex-start; }
.drop-zones { grid-template-columns: 1fr; }
.page-selector { grid-template-columns: 1fr; }
.radio-group { flex-direction: column; }
}
</style>
</head>
<body>
<div class="container">
<div class="header-row">
<h1>📐 PDF図面比較ツール</h1>
<div class="slot-wrapper">
<span class="slot-label">保存スロット</span>
<div class="slot-container" id="slotContainer"></div>
</div>
</div>
<div class="drop-zones">
<div class="drop-zone" id="dropZone1">
<h3>元図面 (PDF)</h3>
<p>ファイルをドラッグ&ドロップ<br>またはクリックして選択</p>
<div class="file-info" id="fileInfo1" style="display:none;"></div>
<input type="file" id="fileInput1" accept=".pdf">
</div>
<div class="drop-zone" id="dropZone2">
<h3>新図面 (PDF)</h3>
<p>ファイルをドラッグ&ドロップ<br>またはクリックして選択</p>
<div class="file-info" id="fileInfo2" style="display:none;"></div>
<input type="file" id="fileInput2" accept=".pdf">
</div>
</div>
<div class="controls">
<div class="page-selector" id="pageSelector" style="display:none;">
<div class="page-input-group">
<label>元図面ページ:</label>
<input type="text" id="pages1" class="page-input" placeholder="例: 1,3,5-8">
<span class="page-range" id="pageRange1"></span>
</div>
<div class="page-input-group">
<label>新図面ページ:</label>
<input type="text" id="pages2" class="page-input" placeholder="例: 1,3,5-8">
<span class="page-range" id="pageRange2"></span>
</div>
</div>
<div class="batch-mode">
<input type="checkbox" id="compareAllPages">
<label for="compareAllPages">
<strong>全ページを自動比較</strong> (同じページ番号同士を比較)
</label>
</div>
<div class="control-group">
<label>表示モード:</label>
<div class="radio-group">
<label class="radio-option">
<input type="radio" name="colorMode" value="diff" checked>
<span>差分表示 (緑:削除 / 赤:追加 / 黒:変更なし)</span>
</label>
<label class="radio-option">
<input type="radio" name="colorMode" value="blue_red">
<span>青(元) / 赤(新)</span>
</label>
<label class="radio-option">
<input type="radio" name="colorMode" value="gray_red">
<span>グレー(元) / 赤(新)</span>
</label>
<label class="radio-option">
<input type="radio" name="colorMode" value="overlay">
<span>半透明オーバーレイ</span>
</label>
</div>
</div>
<div class="control-group">
<label>透明度:</label>
<div class="slider-container">
<input type="range" id="opacitySlider" min="0" max="100" value="50">
<span class="slider-value" id="opacityValue">50%</span>
</div>
</div>
<div class="control-group">
<label>線の検出感度:</label>
<div class="slider-container">
<input type="range" id="thresholdSlider" min="100" max="250" value="180">
<span class="slider-value" id="thresholdValue">180</span>
</div>
</div>
</div>
<button class="compare-button" id="compareButton" disabled>比較を実行</button>
<div class="progress-bar" id="progressBar">
<div class="progress-fill" id="progressFill" style="width: 0%">0%</div>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>処理中...</p>
</div>
<div class="action-buttons" id="actionButtons">
<button class="action-button" id="downloadCurrentButton">現在のページをPDFダウンロード</button>
<button class="action-button secondary" id="downloadAllButton">全ページを1つのPDFでダウンロード</button>
<button class="action-button tertiary" id="downloadAllSeparateButton">全ページを個別PDFでダウンロード</button>
</div>
<div class="legend" id="legend" style="display:none;">
<div class="legend-item" id="legendDiff1">
<div class="legend-color" style="background:#00C800;"></div>
<span>削除された線</span>
</div>
<div class="legend-item" id="legendDiff2">
<div class="legend-color" style="background:#FF0000;"></div>
<span>追加された線</span>
</div>
<div class="legend-item" id="legendDiff3">
<div class="legend-color" style="background:#000000;"></div>
<span>変更なし</span>
</div>
<div class="legend-item" id="legendBlue" style="display:none;">
<div class="legend-color" style="background:#0000FF;"></div>
<span>元図面</span>
</div>
<div class="legend-item" id="legendRed" style="display:none;">
<div class="legend-color" style="background:#FF0000;"></div>
<span>新図面</span>
</div>
</div>
<div class="results-tabs" id="resultsTabs">
<div class="tabs-header" id="tabsHeader"></div>
</div>
<div class="result-container">
<div class="zoom-controls" id="zoomControls" style="display:none;">
<button class="zoom-button" onclick="zoomIn()">+</button>
<span class="zoom-label" id="zoomLabel">100%</span>
<button class="zoom-button" onclick="zoomOut()">-</button>
<button class="zoom-button" onclick="resetZoom()">⟲</button>
</div>
<div class="canvas-wrapper" id="canvasWrapper">
<div id="canvasContainers"></div>
</div>
<p id="placeholder" style="color:#999; position:absolute;">比較結果がここに表示されます</p>
</div>
<div class="adjust-panel" id="adjustPanel">
<h4>🎯 新図面の位置調整</h4>
<div class="dpad">
<button class="dpad-btn empty"></button>
<button class="dpad-btn" onclick="adjustPosition(0, -1)">▲</button>
<button class="dpad-btn empty"></button>
<button class="dpad-btn" onclick="adjustPosition(-1, 0)">◀</button>
<button class="dpad-btn center" onclick="resetAdjustment()">RST</button>
<button class="dpad-btn" onclick="adjustPosition(1, 0)">▶</button>
<button class="dpad-btn empty"></button>
<button class="dpad-btn" onclick="adjustPosition(0, 1)">▼</button>
<button class="dpad-btn empty"></button>
</div>
<div class="adjust-info">
<div>X: <span id="offsetX">0</span>px</div>
<div>Y: <span id="offsetY">0</span>px</div>
</div>
<div class="step-control">
<label>移動量:</label>
<select id="adjustStep">
<option value="1">1px</option>
<option value="5" selected>5px</option>
<option value="10">10px</option>
<option value="20">20px</option>
<option value="50">50px</option>
</select>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const { jsPDF } = window.jspdf;
const DB_NAME = 'pdfCompareToolDB_v4';
const DB_VERSION = 1;
const STORE_NAME = 'compareSlots';
const SLOT_COUNT = 5;
let db = null;
let file1 = null;
let file2 = null;
let pdf1 = null;
let pdf2 = null;
let pdf1Bytes = null;
let pdf2Bytes = null;
let totalPages1 = 0;
let totalPages2 = 0;
let comparisonResults = [];
let currentTab = 0;
let offsetX = 0;
let offsetY = 0;
let currentZoom = 1;
let minZoom = 0.1;
let maxZoom = 5;
let zoomStep = 0.2;
let isDragging = false;
let startX = 0;
let startY = 0;
let translateX = 0;
let translateY = 0;
/* ============================================================
* 凡例をキャンバス上に描画する関数
* ============================================================ */
function drawLegendOnCanvas(canvas, colorMode) {
const ctx = canvas.getContext('2d');
let items = [];
if (colorMode === 'diff') {
items = [
{ color: '#00C800', label: '削除された線' },
{ color: '#FF0000', label: '追加された線' },
{ color: '#000000', label: '変更なし' }
];
} else if (colorMode === 'blue_red') {
items = [
{ color: '#0000FF', label: '元図面' },
{ color: '#FF0000', label: '新図面' },
{ color: '#800080', label: '共通' }
];
} else if (colorMode === 'gray_red') {
items = [
{ color: '#969696', label: '元図面' },
{ color: '#FF0000', label: '新図面' }
];
} else if (colorMode === 'overlay') {
items = [
{ color: '#888888', label: '元図面(半透明)' },
{ color: '#444444', label: '新図面(半透明)' }
];
}
if (items.length === 0) return;
// 凡例のサイズ計算
const scale = Math.max(1, canvas.width / 1200); // キャンバスサイズに応じてスケール
const fontSize = Math.round(14 * scale);
const boxSize = Math.round(16 * scale);
const padding = Math.round(12 * scale);
const itemGap = Math.round(24 * scale);
const textGap = Math.round(6 * scale);
const cornerRadius = Math.round(6 * scale);
const borderWidth = Math.round(1.5 * scale);
ctx.font = `${fontSize}px "Segoe UI", "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif`;
// 全体幅を計算
let totalWidth = padding * 2;
items.forEach((item, idx) => {
totalWidth += boxSize + textGap + ctx.measureText(item.label).width;
if (idx < items.length - 1) totalWidth += itemGap;
});
const legendHeight = boxSize + padding * 2;
// 右下に配置
const margin = Math.round(10 * scale);
const legendX = canvas.width - totalWidth - margin;
const legendY = canvas.height - legendHeight - margin;
// 背景(角丸四角)
ctx.save();
ctx.globalAlpha = 0.92;
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.moveTo(legendX + cornerRadius, legendY);
ctx.lineTo(legendX + totalWidth - cornerRadius, legendY);
ctx.arcTo(legendX + totalWidth, legendY, legendX + totalWidth, legendY + cornerRadius, cornerRadius);
ctx.lineTo(legendX + totalWidth, legendY + legendHeight - cornerRadius);
ctx.arcTo(legendX + totalWidth, legendY + legendHeight, legendX + totalWidth - cornerRadius, legendY + legendHeight, cornerRadius);
ctx.lineTo(legendX + cornerRadius, legendY + legendHeight);
ctx.arcTo(legendX, legendY + legendHeight, legendX, legendY + legendHeight - cornerRadius, cornerRadius);
ctx.lineTo(legendX, legendY + cornerRadius);
ctx.arcTo(legendX, legendY, legendX + cornerRadius, legendY, cornerRadius);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1.0;
// 枠線
ctx.strokeStyle = '#CCCCCC';
ctx.lineWidth = borderWidth;
ctx.stroke();
ctx.restore();
// 各アイテム描画
let curX = legendX + padding;
const curY = legendY + padding;
items.forEach((item, idx) => {
// 色ボックス
ctx.fillStyle = item.color;
ctx.fillRect(curX, curY, boxSize, boxSize);
ctx.strokeStyle = '#AAAAAA';
ctx.lineWidth = Math.max(1, borderWidth * 0.5);
ctx.strokeRect(curX, curY, boxSize, boxSize);
curX += boxSize + textGap;
// テキスト
ctx.fillStyle = '#333333';
ctx.font = `${fontSize}px "Segoe UI", "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillText(item.label, curX, curY + boxSize / 2);
curX += ctx.measureText(item.label).width;
if (idx < items.length - 1) curX += itemGap;
});
}
/* ============================================================
* キャンバスをPDFに変換するユーティリティ
* ============================================================ */
function canvasToPdfBlob(canvasList) {
// canvasList: [{canvas, page1, page2}, ...]
let pdfDoc = null;
canvasList.forEach((item, idx) => {
const canvas = item.canvas;
const imgData = canvas.toDataURL('image/jpeg', 0.92);
// キャンバスのアスペクト比からPDFページサイズを決定
const cWidth = canvas.width;
const cHeight = canvas.height;
const orientation = cWidth >= cHeight ? 'landscape' : 'portrait';
if (idx === 0) {
pdfDoc = new jsPDF({
orientation: orientation,
unit: 'pt',
format: [cWidth * 0.75, cHeight * 0.75] // 96dpi → 72dpi 変換
});
} else {
pdfDoc.addPage([cWidth * 0.75, cHeight * 0.75], orientation);
}
const pageW = pdfDoc.internal.pageSize.getWidth();
const pageH = pdfDoc.internal.pageSize.getHeight();
pdfDoc.addImage(imgData, 'JPEG', 0, 0, pageW, pageH);
});
return pdfDoc;
}
function adjustPosition(dx, dy) {
const step = parseInt(document.getElementById('adjustStep').value);
offsetX += dx * step;
offsetY += dy * step;
updateOffsetDisplay();
if (comparisonResults.length > 0) {
comparePDFs();
}
}
function resetAdjustment() {
offsetX = 0;
offsetY = 0;
updateOffsetDisplay();
if (comparisonResults.length > 0) {
comparePDFs();
}
}
function updateOffsetDisplay() {
document.getElementById('offsetX').textContent = offsetX;
document.getElementById('offsetY').textContent = offsetY;
}
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => { db = request.result; resolve(db); };
request.onupgradeneeded = (e) => {
const database = e.target.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
});
}
function getSlots() {
return new Promise((resolve, reject) => {
if (!db) { resolve({}); return; }
try {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const slots = {};
request.result.forEach(item => { slots[item.id] = item; });
resolve(slots);
};
request.onerror = () => reject(request.error);
} catch (e) { resolve({}); }
});
}
function saveSlotToDB(slotNum, data) {
return new Promise((resolve, reject) => {
if (!db) { reject(new Error('DB not initialized')); return; }
try {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
data.id = slotNum;
const request = store.put(data);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
} catch (e) { reject(e); }
});
}
function deleteSlotFromDB(slotNum) {
return new Promise((resolve, reject) => {
if (!db) { reject(new Error('DB not initialized')); return; }
try {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.delete(slotNum);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
} catch (e) { reject(e); }
});
}
async function saveToSlot(slotNum) {
if (!pdf1Bytes || !pdf2Bytes) {
alert('両方のPDFを読み込んでください');
return;
}
try {
const data = {
pdf1Data: Array.from(pdf1Bytes),
pdf2Data: Array.from(pdf2Bytes),
file1Name: file1 ? file1.name : 'PDF1',
file2Name: file2 ? file2.name : 'PDF2',
pages1: document.getElementById('pages1').value,
pages2: document.getElementById('pages2').value,
compareAllPages: document.getElementById('compareAllPages').checked,
colorMode: document.querySelector('input[name="colorMode"]:checked').value,
opacity: document.getElementById('opacitySlider').value,
threshold: document.getElementById('thresholdSlider').value,
offsetX: offsetX,
offsetY: offsetY,
adjustStep: document.getElementById('adjustStep').value,
savedAt: new Date().toLocaleString('ja-JP')
};
await saveSlotToDB(slotNum, data);
showToast(`スロット${slotNum}に保存しました`);
await renderSlotUI();
} catch (e) {
console.error('saveToSlot error:', e);
alert('保存に失敗しました: ' + e.message);
}
}
async function loadFromSlot(slotNum) {
try {
const slots = await getSlots();
const slot = slots[slotNum];
if (!slot) {
alert('データがありません');
return;
}
pdf1Bytes = new Uint8Array(slot.pdf1Data);
pdf2Bytes = new Uint8Array(slot.pdf2Data);
pdf1 = await pdfjsLib.getDocument({ data: pdf1Bytes.slice() }).promise;
totalPages1 = pdf1.numPages;
file1 = { name: slot.file1Name, size: pdf1Bytes.length };
document.getElementById('fileInfo1').innerHTML = `
📄 ${slot.file1Name} (${(pdf1Bytes.length / 1024).toFixed(1)} KB)
<div class="page-info">ページ数: ${totalPages1}</div>
`;
document.getElementById('fileInfo1').style.display = 'block';
document.getElementById('dropZone1').classList.add('loaded');
document.getElementById('pageRange1').textContent = `(1-${totalPages1})`;
pdf2 = await pdfjsLib.getDocument({ data: pdf2Bytes.slice() }).promise;
totalPages2 = pdf2.numPages;
file2 = { name: slot.file2Name, size: pdf2Bytes.length };
document.getElementById('fileInfo2').innerHTML = `
📄 ${slot.file2Name} (${(pdf2Bytes.length / 1024).toFixed(1)} KB)
<div class="page-info">ページ数: ${totalPages2}</div>
`;
document.getElementById('fileInfo2').style.display = 'block';
document.getElementById('dropZone2').classList.add('loaded');
document.getElementById('pageRange2').textContent = `(1-${totalPages2})`;
document.getElementById('pages1').value = slot.pages1 || '1';
document.getElementById('pages2').value = slot.pages2 || '1';
document.getElementById('compareAllPages').checked = slot.compareAllPages || false;
const colorRadio = document.querySelector(`input[name="colorMode"][value="${slot.colorMode}"]`);
if (colorRadio) colorRadio.checked = true;
document.getElementById('opacitySlider').value = slot.opacity || 50;
document.getElementById('opacityValue').textContent = (slot.opacity || 50) + '%';
document.getElementById('thresholdSlider').value = slot.threshold || 180;
document.getElementById('thresholdValue').textContent = slot.threshold || 180;
offsetX = slot.offsetX || 0;
offsetY = slot.offsetY || 0;
updateOffsetDisplay();
if (slot.adjustStep) {
document.getElementById('adjustStep').value = slot.adjustStep;
}
const pageInputs = document.querySelectorAll('.page-input');
pageInputs.forEach(input => {
input.disabled = slot.compareAllPages;
input.style.opacity = slot.compareAllPages ? '0.5' : '1';
});
document.getElementById('compareButton').disabled = false;
document.getElementById('pageSelector').style.display = 'grid';
document.getElementById('adjustPanel').classList.add('show');
showToast(`スロット${slotNum}を読み込みました`);
} catch (e) {
console.error('loadFromSlot error:', e);
alert('読み込みに失敗しました: ' + e.message);
}
}
async function deleteSlot(slotNum, e) {
e.stopPropagation();
if (!confirm(`スロット${slotNum}を削除しますか?`)) return;
try {
await deleteSlotFromDB(slotNum);
await renderSlotUI();
showToast(`スロット${slotNum}を削除しました`);
} catch (e) {
console.error('deleteSlot error:', e);
}
}
async function renderSlotUI() {
const container = document.getElementById('slotContainer');
try {
const slots = await getSlots();
let html = '';
for (let i = 1; i <= SLOT_COUNT; i++) {
const slot = slots[i];
const hasData = !!slot;
html += `<div class="slot-group">
<button class="slot-btn save" onclick="saveToSlot(${i})" title="${hasData ? '上書き保存\n' + slot.savedAt : '新規保存'}">S${i}</button>
<button class="slot-btn load" onclick="loadFromSlot(${i})" ${hasData ? '' : 'disabled'} title="${hasData ? '読込: ' + slot.savedAt : '空'}">L${i}</button>
${hasData ? `<button class="slot-btn delete" onclick="deleteSlot(${i}, event)" title="削除">×</button>` : ''}
</div>`;
}
container.innerHTML = html;
} catch (e) {
console.error('renderSlotUI error:', e);
}
}
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}
function zoomIn() {
if (currentZoom < maxZoom) {
currentZoom = Math.min(currentZoom + zoomStep, maxZoom);
applyZoom();
}
}
function zoomOut() {
if (currentZoom > minZoom) {
currentZoom = Math.max(currentZoom - zoomStep, minZoom);
applyZoom();
}
}
function resetZoom() {
currentZoom = 1;
translateX = 0;
translateY = 0;
applyZoom();
}
function applyZoom() {
const activeContainer = document.querySelector('.canvas-container.active');
if (activeContainer) {
activeContainer.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentZoom})`;
document.getElementById('zoomLabel').textContent = Math.round(currentZoom * 100) + '%';
}
}
function setupZoomControls() {
const canvasWrapper = document.getElementById('canvasWrapper');
canvasWrapper.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newZoom = Math.max(minZoom, Math.min(maxZoom, currentZoom + delta));
if (newZoom !== currentZoom) {
const rect = canvasWrapper.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const scale = newZoom / currentZoom;
translateX = x - scale * (x - translateX);
translateY = y - scale * (y - translateY);
currentZoom = newZoom;
applyZoom();
}
});
let activeContainer = null;
canvasWrapper.addEventListener('mousedown', (e) => {
activeContainer = document.querySelector('.canvas-container.active');
if (activeContainer && e.button === 0) {
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
activeContainer.classList.add('dragging');
}
});
document.addEventListener('mousemove', (e) => {
if (isDragging && activeContainer) {
translateX = e.clientX - startX;
translateY = e.clientY - startY;
applyZoom();
}
});
document.addEventListener('mouseup', () => {
if (activeContainer) activeContainer.classList.remove('dragging');
isDragging = false;
activeContainer = null;
});
}
function setupDropZone(dropZone, fileInput, fileInfoDiv, fileNumber) {
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
handleFile(e.target.files[0], fileNumber, fileInfoDiv, dropZone);
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].type === 'application/pdf') {
handleFile(files[0], fileNumber, fileInfoDiv, dropZone);
}
});
}
async function handleFile(file, fileNumber, fileInfoDiv, dropZone) {
if (!file || file.type !== 'application/pdf') {
alert('PDFファイルを選択してください');
return;
}
const arrayBuffer = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer).slice();
const pdf = await pdfjsLib.getDocument({ data: bytes.slice() }).promise;
if (fileNumber === 1) {
file1 = file;
pdf1 = pdf;
pdf1Bytes = bytes;
totalPages1 = pdf.numPages;
fileInfoDiv.innerHTML = `
📄 ${file.name} (${(file.size / 1024).toFixed(1)} KB)
<div class="page-info">ページ数: ${totalPages1}</div>
`;
document.getElementById('pageRange1').textContent = `(1-${totalPages1})`;
} else {
file2 = file;
pdf2 = pdf;
pdf2Bytes = bytes;
totalPages2 = pdf.numPages;
fileInfoDiv.innerHTML = `
📄 ${file.name} (${(file.size / 1024).toFixed(1)} KB)
<div class="page-info">ページ数: ${totalPages2}</div>
`;
document.getElementById('pageRange2').textContent = `(1-${totalPages2})`;
}
fileInfoDiv.style.display = 'block';
dropZone.classList.add('loaded');
checkReady();
}
function checkReady() {
const compareButton = document.getElementById('compareButton');
const pageSelector = document.getElementById('pageSelector');
const adjustPanel = document.getElementById('adjustPanel');
if (pdf1 && pdf2) {
compareButton.disabled = false;
pageSelector.style.display = 'grid';
adjustPanel.classList.add('show');
document.getElementById('pages1').value = '1';
document.getElementById('pages2').value = '1';
}
}
function parsePageNumbers(input, maxPage) {
const pages = [];
const parts = input.split(',');
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.includes('-')) {
const [start, end] = trimmed.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= Math.min(end, maxPage); i++) {
if (i >= 1 && i <= maxPage && !pages.includes(i)) pages.push(i);
}
} else {
const page = parseInt(trimmed);
if (page >= 1 && page <= maxPage && !pages.includes(page)) pages.push(page);
}
}
return pages.sort((a, b) => a - b);
}
async function pdfPageToCanvas(pdf, pageNum, scale = 3.0) {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d', { alpha: false, willReadFrequently: true });
canvas.width = viewport.width;
canvas.height = viewport.height;
context.imageSmoothingEnabled = false;
await page.render({ canvasContext: context, viewport: viewport, intent: 'display' }).promise;
return canvas;
}
function updateProgress(current, total) {
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const percent = Math.round((current / total) * 100);
progressBar.classList.add('active');
progressFill.style.width = `${percent}%`;
progressFill.textContent = `${percent}%`;
}
async function comparePDFs() {
const loading = document.getElementById('loading');
const placeholder = document.getElementById('placeholder');
const legend = document.getElementById('legend');
const actionButtons = document.getElementById('actionButtons');
const resultsTabs = document.getElementById('resultsTabs');
const progressBar = document.getElementById('progressBar');
const zoomControls = document.getElementById('zoomControls');
loading.classList.add('active');
placeholder.style.display = 'none';
comparisonResults = [];
const colorMode = document.querySelector('input[name="colorMode"]:checked').value;
try {
let pagePairs = [];
if (document.getElementById('compareAllPages').checked) {
const maxPages = Math.min(totalPages1, totalPages2);
for (let i = 1; i <= maxPages; i++) {
pagePairs.push({ page1: i, page2: i });
}
} else {
const pages1Input = document.getElementById('pages1').value || '1';
const pages2Input = document.getElementById('pages2').value || '1';
const pages1 = parsePageNumbers(pages1Input, totalPages1);
const pages2 = parsePageNumbers(pages2Input, totalPages2);
if (pages1.length === 0 || pages2.length === 0) {
alert('有効なページ番号を入力してください');
loading.classList.remove('active');
return;
}
const maxLength = Math.max(pages1.length, pages2.length);
for (let i = 0; i < maxLength; i++) {
const page1 = pages1[i % pages1.length];
const page2 = pages2[i % pages2.length];
pagePairs.push({ page1, page2 });
}
}
const totalPairs = pagePairs.length;
for (let i = 0; i < totalPairs; i++) {
updateProgress(i + 1, totalPairs);
const { page1, page2 } = pagePairs[i];
const canvas1 = await pdfPageToCanvas(pdf1, page1);
const canvas2 = await pdfPageToCanvas(pdf2, page2);
const resultCanvas = compareCanvases(canvas1, canvas2);
// 凡例をキャンバスに描き込む
drawLegendOnCanvas(resultCanvas, colorMode);
comparisonResults.push({ canvas: resultCanvas, page1, page2 });
}
displayResults();
legend.style.display = 'flex';
updateLegend();
actionButtons.classList.add('show');
resultsTabs.style.display = 'block';
progressBar.classList.remove('active');
zoomControls.style.display = 'flex';
resetZoom();
} catch (error) {
console.error('エラー:', error);
alert('処理中にエラーが発生しました: ' + error.message);
} finally {
loading.classList.remove('active');
}
}
function updateLegend() {
const colorMode = document.querySelector('input[name="colorMode"]:checked').value;
document.getElementById('legendDiff1').style.display = 'none';
document.getElementById('legendDiff2').style.display = 'none';
document.getElementById('legendDiff3').style.display = 'none';
document.getElementById('legendBlue').style.display = 'none';
document.getElementById('legendRed').style.display = 'none';
if (colorMode === 'diff') {
document.getElementById('legendDiff1').style.display = 'flex';
document.getElementById('legendDiff2').style.display = 'flex';
document.getElementById('legendDiff3').style.display = 'flex';
} else {
document.getElementById('legendBlue').style.display = 'flex';
document.getElementById('legendRed').style.display = 'flex';
}
}
function compareCanvases(canvas1, canvas2) {
const width = Math.max(canvas1.width, canvas2.width + Math.abs(offsetX));
const height = Math.max(canvas1.height, canvas2.height + Math.abs(offsetY));
const resultCanvas = document.createElement('canvas');
resultCanvas.width = width;
resultCanvas.height = height;
resultCanvas.className = 'result-canvas';
const ctx = resultCanvas.getContext('2d', { alpha: false, willReadFrequently: true });
ctx.imageSmoothingEnabled = false;
const tempCanvas2 = document.createElement('canvas');
tempCanvas2.width = width;
tempCanvas2.height = height;
const tempCtx2 = tempCanvas2.getContext('2d', { willReadFrequently: true });
tempCtx2.fillStyle = 'white';
tempCtx2.fillRect(0, 0, width, height);
tempCtx2.drawImage(canvas2, offsetX, offsetY);
const tempCanvas1 = document.createElement('canvas');
tempCanvas1.width = width;
tempCanvas1.height = height;
const tempCtx1 = tempCanvas1.getContext('2d', { willReadFrequently: true });
tempCtx1.fillStyle = 'white';
tempCtx1.fillRect(0, 0, width, height);
tempCtx1.drawImage(canvas1, 0, 0);
const imageData1 = tempCtx1.getImageData(0, 0, width, height);
const imageData2 = tempCtx2.getImageData(0, 0, width, height);
const colorMode = document.querySelector('input[name="colorMode"]:checked').value;
const opacity = document.getElementById('opacitySlider').value / 100;
const threshold = parseInt(document.getElementById('thresholdSlider').value);
const resultImageData = ctx.createImageData(width, height);
for (let i = 0; i < resultImageData.data.length; i += 4) {
resultImageData.data[i] = 255;
resultImageData.data[i + 1] = 255;
resultImageData.data[i + 2] = 255;
resultImageData.data[i + 3] = 255;
}
if (colorMode === 'diff') {
processDiff(imageData1, imageData2, resultImageData, threshold);
} else if (colorMode === 'blue_red') {
processBlueRed(imageData1, imageData2, resultImageData, threshold, opacity);
} else if (colorMode === 'gray_red') {
processGrayRed(imageData1, imageData2, resultImageData, threshold, opacity);
} else if (colorMode === 'overlay') {
processOverlay(tempCanvas1, tempCanvas2, ctx, opacity);
return resultCanvas;
}
ctx.putImageData(resultImageData, 0, 0);
return resultCanvas;
}
function processDiff(data1, data2, result, threshold) {
for (let i = 0; i < result.data.length; i += 4) {
const gray1 = (data1.data[i] + data1.data[i + 1] + data1.data[i + 2]) / 3;
const gray2 = (data2.data[i] + data2.data[i + 1] + data2.data[i + 2]) / 3;
const isLine1 = gray1 < threshold;
const isLine2 = gray2 < threshold;
if (isLine1 && !isLine2) {
result.data[i] = 0; result.data[i + 1] = 200; result.data[i + 2] = 0;
} else if (!isLine1 && isLine2) {
result.data[i] = 255; result.data[i + 1] = 0; result.data[i + 2] = 0;
} else if (isLine1 && isLine2) {
result.data[i] = 0; result.data[i + 1] = 0; result.data[i + 2] = 0;
}
}
}
function processBlueRed(data1, data2, result, threshold, opacity) {
for (let i = 0; i < result.data.length; i += 4) {
const gray1 = (data1.data[i] + data1.data[i + 1] + data1.data[i + 2]) / 3;
const gray2 = (data2.data[i] + data2.data[i + 1] + data2.data[i + 2]) / 3;
const isLine1 = gray1 < threshold;
const isLine2 = gray2 < threshold;
if (isLine1 && isLine2) {
result.data[i] = 128; result.data[i + 1] = 0; result.data[i + 2] = 128;
} else if (isLine1) {
result.data[i] = 0; result.data[i + 1] = 0; result.data[i + 2] = 255;
} else if (isLine2) {
result.data[i] = 255; result.data[i + 1] = 0; result.data[i + 2] = 0;
}
}
}
function processGrayRed(data1, data2, result, threshold, opacity) {
for (let i = 0; i < result.data.length; i += 4) {
const gray1 = (data1.data[i] + data1.data[i + 1] + data1.data[i + 2]) / 3;
const gray2 = (data2.data[i] + data2.data[i + 1] + data2.data[i + 2]) / 3;
const isLine1 = gray1 < threshold;
const isLine2 = gray2 < threshold;
if (isLine2) {
result.data[i] = 255; result.data[i + 1] = 0; result.data[i + 2] = 0;
} else if (isLine1) {
result.data[i] = 150; result.data[i + 1] = 150; result.data[i + 2] = 150;
}
}
}
function processOverlay(canvas1, canvas2, ctx, opacity) {
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.globalAlpha = 1 - opacity;
ctx.drawImage(canvas1, 0, 0);
ctx.globalAlpha = opacity;
ctx.globalCompositeOperation = 'multiply';
ctx.drawImage(canvas2, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function displayResults() {
const tabsHeader = document.getElementById('tabsHeader');
const canvasContainers = document.getElementById('canvasContainers');
tabsHeader.innerHTML = '';
canvasContainers.innerHTML = '';
comparisonResults.forEach((result, index) => {
const tabButton = document.createElement('button');
tabButton.className = 'tab-button';
tabButton.textContent = `P${result.page1} vs P${result.page2}`;
tabButton.onclick = () => switchTab(index);
if (index === 0) tabButton.classList.add('active');
tabsHeader.appendChild(tabButton);
const container = document.createElement('div');
container.className = 'canvas-container';
container.id = `canvas-container-${index}`;
if (index === 0) container.classList.add('active');
const canvas = result.canvas;
const maxWidth = window.innerWidth * 0.8;
const maxHeight = 600;
let displayWidth = canvas.width;
let displayHeight = canvas.height;
if (displayWidth > maxWidth) {
const ratio = maxWidth / displayWidth;
displayWidth = maxWidth;
displayHeight = displayHeight * ratio;
}
if (displayHeight > maxHeight) {
const ratio = maxHeight / displayHeight;
displayHeight = maxHeight;
displayWidth = displayWidth * ratio;
}
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
container.appendChild(canvas);
container.style.width = displayWidth + 'px';
container.style.height = displayHeight + 'px';
container.style.left = '50%';
container.style.top = '50%';
container.style.marginLeft = -displayWidth/2 + 'px';
container.style.marginTop = -displayHeight/2 + 'px';
canvasContainers.appendChild(container);
});
currentTab = 0;
}
function switchTab(index) {
const tabButtons = document.querySelectorAll('.tab-button');
const canvasContainers = document.querySelectorAll('.canvas-container');
tabButtons.forEach((btn, i) => btn.classList.toggle('active', i === index));
canvasContainers.forEach((container, i) => container.classList.toggle('active', i === index));
currentTab = index;
resetZoom();
}
/* ============================================================
* ダウンロード関数 — すべてPDF出力
* ============================================================ */
function downloadCurrent() {
if (comparisonResults.length === 0) return;
const result = comparisonResults[currentTab];
const pdfDoc = canvasToPdfBlob([result]);
pdfDoc.save(`comparison_P${result.page1}_vs_P${result.page2}.pdf`);
}
function downloadAll() {
if (comparisonResults.length === 0) return;
const pdfDoc = canvasToPdfBlob(comparisonResults);
pdfDoc.save('comparison_all_pages.pdf');
}
async function downloadAllSeparate() {
if (comparisonResults.length === 0) return;
for (let i = 0; i < comparisonResults.length; i++) {
const result = comparisonResults[i];
const pdfDoc = canvasToPdfBlob([result]);
await new Promise(resolve => setTimeout(resolve, 300));
pdfDoc.save(`comparison_P${result.page1}_vs_P${result.page2}.pdf`);
}
}
document.addEventListener('keydown', (e) => {
if (!pdf1 || !pdf2) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const step = parseInt(document.getElementById('adjustStep').value);
let moved = false;
switch(e.key) {
case 'ArrowUp':
offsetY -= step;
moved = true;
break;
case 'ArrowDown':
offsetY += step;
moved = true;
break;
case 'ArrowLeft':
offsetX -= step;
moved = true;
break;
case 'ArrowRight':
offsetX += step;
moved = true;
break;
}
if (moved) {
e.preventDefault();
updateOffsetDisplay();
if (comparisonResults.length > 0) {
comparePDFs();
}
}
});
setupDropZone(
document.getElementById('dropZone1'),
document.getElementById('fileInput1'),
document.getElementById('fileInfo1'),
1
);
setupDropZone(
document.getElementById('dropZone2'),
document.getElementById('fileInput2'),
document.getElementById('fileInfo2'),
2
);
document.getElementById('compareButton').addEventListener('click', comparePDFs);
document.getElementById('downloadCurrentButton').addEventListener('click', downloadCurrent);
document.getElementById('downloadAllButton').addEventListener('click', downloadAll);
document.getElementById('downloadAllSeparateButton').addEventListener('click', downloadAllSeparate);
setupZoomControls();
document.getElementById('compareAllPages').addEventListener('change', (e) => {
const pageInputs = document.querySelectorAll('.page-input');
pageInputs.forEach(input => {
input.disabled = e.target.checked;
input.style.opacity = e.target.checked ? '0.5' : '1';
});
});
document.querySelectorAll('input[name="colorMode"]').forEach(radio => {
radio.addEventListener('change', () => {
if (comparisonResults.length > 0) updateLegend();
});
});
document.getElementById('opacitySlider').addEventListener('input', (e) => {
document.getElementById('opacityValue').textContent = e.target.value + '%';
});
document.getElementById('thresholdSlider').addEventListener('input', (e) => {
document.getElementById('thresholdValue').textContent = e.target.value;
});
initDB().then(() => renderSlotUI()).catch(e => console.error('DB init failed:', e));
</script>
</body>
</html>
⑤ PDF重ね図(オーバーレイ)
<!DOCTYPE html>
<html lang="ja">
<head>
<style>
.preloader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
z-index: 9999;
animation: fadeOut 0.1s 0.3s forwards;
}
.glasses {
display: flex;
align-items: center;
animation: swing 0.3s ease-in-out infinite alternate;
transform-origin: top center;
}
.lens {
width: 40px;
height: 20px;
border: 3px solid #888;
border-radius: 4px;
background: rgba(200,200,200,0.2);
}
.bridge {
width: 12px;
height: 3px;
background: #888;
}
.temple {
width: 20px;
height: 3px;
background: #888;
}
.pencil {
font-size: 48px;
animation: swing 0.3s ease-in-out infinite alternate;
animation-delay: 0.15s;
transform-origin: top center;
display: inline-block;
}
@keyframes swing {
0% { transform: rotate(-15deg); }
100% { transform: rotate(15deg); }
}
@keyframes fadeOut {
to { opacity: 0; visibility: hidden; }
}
</style>
<div class="preloader">
<div class="glasses">
<div class="temple"></div>
<div class="lens"></div>
<div class="bridge"></div>
<div class="lens"></div>
<div class="temple"></div>
</div>
<span class="pencil">✏️</span>
</div>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF重ね図</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Arial', sans-serif; background: #f0f0f0; }
.container { display: flex; height: 100vh; }
.sidebar { width: 300px; background: #2c3e50; color: white; padding: 20px; overflow-y: auto; }
.title-row { display: flex; align-items: flex-end; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.title-row h2 { margin: 0; }
.slot-wrapper { display: flex; flex-direction: column; align-items: flex-start; }
.slot-label { font-size: 9px; color: #95a5a6; margin-bottom: 2px; }
.slot-container { display: flex; gap: 2px; align-items: center; }
.slot-group { position: relative; display: flex; gap: 1px; }
.slot-btn { padding: 4px 6px; font-size: 10px; border-radius: 3px; width: auto; margin: 0; }
.slot-btn.save { background: #27ae60; }
.slot-btn.save:hover { background: #229954; }
.slot-btn.load { background: #3498db; }
.slot-btn.load:hover { background: #2980b9; }
.slot-btn.load:disabled { background: #555; cursor: not-allowed; }
.slot-btn.delete { position: absolute; top: -5px; right: -5px; background: #c0392b; padding: 1px 4px; font-size: 8px; border-radius: 50%; min-width: auto; line-height: 1; }
.slot-btn.delete:hover { background: #a93226; }
.control-group { margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #34495e; }
.control-group h3 { margin-bottom: 10px; font-size: 14px; }
.file-input { width: 100%; padding: 10px; margin-bottom: 10px; background: #34495e; border: 1px solid #4a5f7a; color: white; cursor: pointer; }
.slider-container { display: flex; align-items: center; margin: 10px 0; }
.slider { flex: 1; margin: 0 10px; }
.scale-input { width: 60px; padding: 5px; background: #34495e; border: 1px solid #4a5f7a; color: white; text-align: center; margin-left: 5px; }
.scale-input:focus { outline: none; border-color: #3498db; }
.scale-unit { margin-left: 5px; color: #95a5a6; }
button { width: 100%; padding: 10px; margin: 5px 0; background: #3498db; color: white; border: none; cursor: pointer; transition: background 0.3s; }
button:hover { background: #2980b9; }
button.active { background: #e74c3c; }
.export-btn { background: #27ae60; }
.export-btn:hover { background: #229954; }
.main-area { flex: 1; position: relative; overflow: auto; background: #ecf0f1; }
#canvas-container { position: relative; margin: 20px; display: inline-block; background: white; background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px; }
.pdf-canvas { position: absolute; top: 0; left: 0; border: 1px solid #bdc3c7; }
#base-canvas { z-index: 1; position: relative; }
#overlay-wrapper { position: absolute; z-index: 2; }
#overlay-canvas { cursor: move; display: block; image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; }
.resize-handle { position: absolute; width: 10px; height: 10px; background: #3498db; border: 2px solid white; display: none; z-index: 10; }
#overlay-wrapper:hover .resize-handle { display: block; }
.resize-handle:hover { background: #e74c3c; }
.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
#crop-overlay { z-index: 3; pointer-events: none; position: absolute; top: 0; left: 0; }
.crop-active #overlay-canvas { cursor: crosshair !important; }
.scale-match-active #base-canvas { cursor: crosshair !important; }
.scale-match-active #overlay-canvas { cursor: crosshair !important; }
.scale-match-info { padding: 10px; background: #34495e; border-radius: 5px; margin-top: 10px; font-size: 12px; display: none; }
.scale-match-info.active { display: block; }
.scale-match-status { padding: 5px; margin-top: 5px; background: #2c3e50; border-radius: 3px; font-size: 11px; }
.point-marker { position: absolute; width: 10px; height: 10px; border-radius: 50%; z-index: 10; pointer-events: none; }
.point-marker.base-1 { background: #3498db; border: 2px solid white; }
.point-marker.base-2 { background: #3498db; border: 2px solid white; }
.point-marker.overlay-1 { background: #e74c3c; border: 2px solid white; }
.point-marker.overlay-2 { background: #e74c3c; border: 2px solid white; }
.color-controls { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
.color-input { width: 50px; height: 30px; border: none; cursor: pointer; }
.transparent-btn { padding: 5px 10px; margin-left: 5px; background: #7f8c8d; font-size: 12px; width: auto; }
.transparent-btn.active { background: #e74c3c; }
.page-controls { display: flex; gap: 10px; align-items: center; justify-content: center; }
.page-info { padding: 0 10px; }
.preset-colors { display: flex; gap: 5px; margin-top: 10px; }
.preset-color { width: 30px; height: 30px; border: 2px solid #34495e; cursor: pointer; }
.preset-color:hover { border-color: white; }
.mono-options { margin-top: 10px; padding: 10px; background: #34495e; border-radius: 5px; }
.threshold-container { margin: 10px 0; }
.color-row { display: flex; align-items: center; margin-bottom: 10px; }
.color-label { width: 40px; }
.transparent-indicator { width: 50px; height: 30px; border: 1px solid #fff; background-image: linear-gradient(45deg, #666 25%, transparent 25%), linear-gradient(-45deg, #666 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #666 75%), linear-gradient(-45deg, transparent 75%, #666 75%); background-size: 10px 10px; background-position: 0 0, 0 5px, 5px -5px, -5px 0px; display: none; margin-left: 10px; }
.crop-info { padding: 10px; background: #34495e; border-radius: 5px; margin-top: 10px; font-size: 12px; }
.quality-info { padding: 10px; background: #34495e; border-radius: 5px; margin-top: 10px; font-size: 11px; color: #95a5a6; }
.pdf-size-select { width: 100%; padding: 5px; margin: 10px 0; background: #34495e; border: 1px solid #4a5f7a; color: white; }
.loading-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: flex; justify-content: center; align-items: center; z-index: 9999; color: white; font-size: 18px; }
.loading-overlay.hidden { display: none; }
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: #27ae60; color: white; padding: 12px 24px; border-radius: 6px; font-size: 14px; z-index: 10000; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.toast.show { opacity: 1; }
</style>
</head>
<body>
<div class="loading-overlay hidden" id="loading-overlay"><div>読み込み中...</div></div>
<div class="toast" id="toast"></div>
<div class="container">
<div class="sidebar">
<div class="title-row">
<h2>PDF重ね図</h2>
<div class="slot-wrapper">
<span class="slot-label">保存スロット</span>
<div class="slot-container" id="slotContainer"></div>
</div>
</div>
<div class="control-group">
<h3>ベースPDF</h3>
<input type="file" id="base-file" class="file-input" accept=".pdf">
<div class="page-controls">
<button id="base-prev">◀</button>
<span class="page-info" id="base-page">1/1</span>
<button id="base-next">▶</button>
</div>
</div>
<div class="control-group">
<h3>重ねるPDF</h3>
<input type="file" id="overlay-file" class="file-input" accept=".pdf">
<div class="page-controls">
<button id="overlay-prev">◀</button>
<span class="page-info" id="overlay-page">1/1</span>
<button id="overlay-next">▶</button>
</div>
</div>
<div class="control-group">
<h3>縮尺合わせ</h3>
<button id="scale-match-mode">対角2点で縮尺合わせ</button>
<button id="scale-match-reset">ポイントリセット</button>
<div class="scale-match-info" id="scale-match-info">
<div class="scale-match-status" id="scale-match-status">ベース図面の1点目を選択してください</div>
</div>
</div>
<div class="control-group">
<h3>トリミング</h3>
<button id="crop-mode">トリミングモード</button>
<div class="crop-info" id="crop-info" style="display: none;"><div>範囲選択して適用を押してください</div></div>
<button id="apply-crop">トリミング適用</button>
<button id="reset-crop">トリミングリセット</button>
</div>
<div class="control-group">
<h3>透過度</h3>
<div class="slider-container">
<input type="range" id="opacity" class="slider" min="0" max="100" value="50">
<input type="number" id="opacity-input" class="scale-input" min="0" max="100" value="50">
<span class="scale-unit">%</span>
</div>
</div>
<div class="control-group">
<h3>色変更</h3>
<label style="display: block; margin-bottom: 10px;">
<input type="checkbox" id="color-mode"> 単色モード
</label>
<div id="normal-color-controls">
<div class="color-controls">
<label>色調整:</label>
<input type="color" id="color-filter" class="color-input" value="#ff0000">
</div>
</div>
<div id="mono-color-controls" class="mono-options" style="display: none;">
<div class="color-row">
<span class="color-label">黒→</span>
<input type="color" id="black-replace" class="color-input" value="#000000">
<button id="black-transparent" class="transparent-btn">透明</button>
<div id="black-transparent-indicator" class="transparent-indicator"></div>
</div>
<div class="color-row">
<span class="color-label">白→</span>
<input type="color" id="white-replace" class="color-input" value="#ffffff">
<button id="white-transparent" class="transparent-btn">透明</button>
<div id="white-transparent-indicator" class="transparent-indicator"></div>
</div>
<div class="threshold-container">
<label>閾値:</label>
<div class="slider-container">
<input type="range" id="threshold" class="slider" min="0" max="255" value="128">
<input type="number" id="threshold-input" class="scale-input" min="0" max="255" value="128">
</div>
</div>
<label style="display: block; margin-top: 10px;">
<input type="checkbox" id="invert-mono"> 白黒反転
</label>
</div>
<div class="preset-colors">
<div class="preset-color" style="background: #ff0000" data-color="#ff0000"></div>
<div class="preset-color" style="background: #00ff00" data-color="#00ff00"></div>
<div class="preset-color" style="background: #0000ff" data-color="#0000ff"></div>
<div class="preset-color" style="background: #ffff00" data-color="#ffff00"></div>
<div class="preset-color" style="background: #ff00ff" data-color="#ff00ff"></div>
<div class="preset-color" style="background: #00ffff" data-color="#00ffff"></div>
</div>
<button id="apply-color">色を適用</button>
<button id="reset-color">色をリセット</button>
</div>
<div class="control-group">
<h3>拡大縮小</h3>
<div class="slider-container">
<input type="range" id="scale" class="slider" min="50" max="500" value="100">
<input type="number" id="scale-input" class="scale-input" min="50" max="500" value="100">
<span class="scale-unit">%</span>
</div>
<div class="slider-container">
<label style="width: 80px;">重ねる側:</label>
<input type="range" id="overlay-scale" class="slider" min="10" max="500" value="100">
<input type="number" id="overlay-scale-input" class="scale-input" min="10" max="500" value="100">
<span class="scale-unit">%</span>
</div>
</div>
<div class="control-group">
<h3>位置リセット</h3>
<button id="reset-position">中央に配置</button>
<button id="fit-to-base">ベースに合わせる</button>
</div>
<div class="control-group">
<h3>ブレンドモード</h3>
<select id="blend-mode" style="width: 100%; padding: 5px;">
<option value="normal">通常</option>
<option value="multiply">乗算</option>
<option value="screen">スクリーン</option>
<option value="overlay">オーバーレイ</option>
<option value="difference">差分</option>
<option value="exclusion">除外</option>
<option value="darken">暗く</option>
<option value="lighten">明るく</option>
</select>
</div>
<div class="control-group">
<h3>エクスポート</h3>
<select id="pdf-size" class="pdf-size-select">
<option value="a3" selected>A3サイズ</option>
<option value="a4">A4サイズ</option>
<option value="a2">A2サイズ</option>
<option value="a1">A1サイズ</option>
</select>
<button id="export-jpg" class="export-btn">JPGで保存</button>
<button id="export-pdf" class="export-btn">PDFで保存</button>
<button id="export-png" class="export-btn">PNGで保存</button>
<div class="quality-info">※最高画質出力(A3・300dpi相当)</div>
</div>
</div>
<div class="main-area">
<div id="canvas-container">
<canvas id="base-canvas" class="pdf-canvas"></canvas>
<div id="overlay-wrapper">
<canvas id="overlay-canvas"></canvas>
<div class="resize-handle nw"></div>
<div class="resize-handle ne"></div>
<div class="resize-handle sw"></div>
<div class="resize-handle se"></div>
</div>
<canvas id="crop-overlay" class="pdf-canvas"></canvas>
</div>
</div>
</div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
function showToast(message, duration = 2000) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), duration);
}
const DB_NAME = 'pdfOverlayDB';
const DB_VERSION = 1;
const STORE_NAME = 'slots';
let db = null;
async function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => { db = request.result; resolve(db); };
request.onupgradeneeded = (e) => {
const database = e.target.result;
if (!database.objectStoreNames.contains(STORE_NAME)) database.createObjectStore(STORE_NAME, { keyPath: 'id' });
};
});
}
async function saveSlotToDB(slotNum, data) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
data.id = slotNum;
store.put(data);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getSlotFromDB(slotNum) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(slotNum);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async function deleteSlotFromDB(slotNum) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.delete(slotNum);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getAllSlots() {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const result = {};
request.result.forEach(slot => { result[slot.id] = slot; });
resolve(result);
};
request.onerror = () => reject(request.error);
});
}
const SLOT_COUNT = 5;
let basePdfArrayBuffer = null;
let overlayPdfArrayBuffer = null;
function showLoading() { document.getElementById('loading-overlay').classList.remove('hidden'); }
function hideLoading() { document.getElementById('loading-overlay').classList.add('hidden'); }
async function saveToSlot(slotNum) {
showLoading();
try {
const data = {
id: slotNum,
basePdf: basePdfArrayBuffer,
overlayPdf: overlayPdfArrayBuffer,
baseCurrentPage: baseCurrentPage,
overlayCurrentPage: overlayCurrentPage,
currentScale: currentScale,
overlayScale: overlayScale,
overlayX: overlayX,
overlayY: overlayY,
relativePosition: relativePosition,
isPositionSet: isPositionSet,
opacity: document.getElementById('opacity').value,
blendMode: document.getElementById('blend-mode').value,
colorMode: document.getElementById('color-mode').checked,
colorFilter: document.getElementById('color-filter').value,
blackReplace: document.getElementById('black-replace').value,
whiteReplace: document.getElementById('white-replace').value,
threshold: document.getElementById('threshold').value,
invertMono: document.getElementById('invert-mono').checked,
blackTransparent: blackTransparent,
whiteTransparent: whiteTransparent,
pdfSize: document.getElementById('pdf-size').value,
// 色適用状態も保存
isColorApplied: currentState.isColorApplied,
savedAt: new Date().toLocaleString('ja-JP')
};
await saveSlotToDB(slotNum, data);
await renderSlotUI();
showToast(`スロット${slotNum}に保存しました`);
} finally {
hideLoading();
}
}
async function loadFromSlot(slotNum) {
showLoading();
try {
const slot = await getSlotFromDB(slotNum);
if (!slot) return;
if (slot.basePdf) {
basePdfArrayBuffer = slot.basePdf;
basePdf = await pdfjsLib.getDocument(slot.basePdf.slice(0)).promise;
baseCurrentPage = slot.baseCurrentPage || 1;
document.getElementById('base-page').textContent = `${baseCurrentPage}/${basePdf.numPages}`;
}
if (slot.overlayPdf) {
overlayPdfArrayBuffer = slot.overlayPdf;
overlayPdf = await pdfjsLib.getDocument(slot.overlayPdf.slice(0)).promise;
overlayCurrentPage = slot.overlayCurrentPage || 1;
document.getElementById('overlay-page').textContent = `${overlayCurrentPage}/${overlayPdf.numPages}`;
}
currentScale = slot.currentScale;
overlayScale = slot.overlayScale;
overlayX = slot.overlayX;
overlayY = slot.overlayY;
relativePosition = slot.relativePosition;
isPositionSet = slot.isPositionSet;
blackTransparent = slot.blackTransparent;
whiteTransparent = slot.whiteTransparent;
// UI更新
document.getElementById('scale').value = currentScale * 100;
document.getElementById('scale-input').value = currentScale * 100;
document.getElementById('overlay-scale').value = overlayScale * 100;
document.getElementById('overlay-scale-input').value = overlayScale * 100;
document.getElementById('opacity').value = slot.opacity;
document.getElementById('opacity-input').value = slot.opacity;
document.getElementById('blend-mode').value = slot.blendMode;
document.getElementById('color-mode').checked = slot.colorMode;
document.getElementById('color-filter').value = slot.colorFilter;
document.getElementById('black-replace').value = slot.blackReplace;
document.getElementById('white-replace').value = slot.whiteReplace;
document.getElementById('threshold').value = slot.threshold;
document.getElementById('threshold-input').value = slot.threshold;
document.getElementById('invert-mono').checked = slot.invertMono;
document.getElementById('pdf-size').value = slot.pdfSize;
overlayCanvas.style.opacity = slot.opacity / 100;
overlayCanvas.style.mixBlendMode = slot.blendMode;
// 単色モード表示切替
document.getElementById('normal-color-controls').style.display = slot.colorMode ? 'none' : 'block';
document.getElementById('mono-color-controls').style.display = slot.colorMode ? 'block' : 'none';
// 透明ボタン状態
document.getElementById('black-transparent').classList.toggle('active', blackTransparent);
document.getElementById('white-transparent').classList.toggle('active', whiteTransparent);
document.getElementById('black-replace').style.display = blackTransparent ? 'none' : 'inline-block';
document.getElementById('white-replace').style.display = whiteTransparent ? 'none' : 'inline-block';
document.getElementById('black-transparent-indicator').style.display = blackTransparent ? 'inline-block' : 'none';
document.getElementById('white-transparent-indicator').style.display = whiteTransparent ? 'inline-block' : 'none';
// レンダリング
if (basePdf) await renderBasePDF();
if (overlayPdf) {
await renderOverlayPDF(false);
overlayWrapper.style.left = overlayX + 'px';
overlayWrapper.style.top = overlayY + 'px';
// 色適用状態を復元
if (slot.isColorApplied) {
currentState.isColorApplied = true;
applyColorToCanvas(false);
}
}
} finally {
hideLoading();
}
}
async function deleteSlot(slotNum, e) {
e.stopPropagation();
if (!confirm(`スロット${slotNum}を削除?`)) return;
await deleteSlotFromDB(slotNum);
await renderSlotUI();
}
async function renderSlotUI() {
const container = document.getElementById('slotContainer');
const slots = await getAllSlots();
container.innerHTML = '';
for (let i = 1; i <= SLOT_COUNT; i++) {
const slot = slots[i];
const group = document.createElement('div');
group.className = 'slot-group';
const saveBtn = document.createElement('button');
saveBtn.className = 'slot-btn save';
saveBtn.textContent = `S${i}`;
saveBtn.title = slot ? `上書き保存\n${slot.savedAt}` : `スロット${i}に保存`;
saveBtn.onclick = () => saveToSlot(i);
const loadBtn = document.createElement('button');
loadBtn.className = 'slot-btn load';
loadBtn.textContent = `L${i}`;
loadBtn.title = slot ? `読込\n${slot.savedAt}` : '(空)';
loadBtn.onclick = () => loadFromSlot(i);
loadBtn.disabled = !slot;
group.appendChild(saveBtn);
group.appendChild(loadBtn);
if (slot) {
const delBtn = document.createElement('button');
delBtn.className = 'slot-btn delete';
delBtn.textContent = '×';
delBtn.title = '削除';
delBtn.onclick = (e) => deleteSlot(i, e);
group.appendChild(delBtn);
}
container.appendChild(group);
}
}
let basePdf = null, overlayPdf = null;
let baseCurrentPage = 1, overlayCurrentPage = 1;
let currentScale = 1, overlayScale = 1;
let overlayX = 0, overlayY = 0;
let relativePosition = { x: 0.5, y: 0.5 };
let isPositionSet = false;
let scaleMatchMode = false;
let scaleMatchPoints = { base1: null, base2: null, overlay1: null, overlay2: null };
let currentScaleMatchStep = 0;
let currentState = { cropData: null, colorSettings: null, isCropped: false, isColorApplied: false };
let blackTransparent = false, whiteTransparent = false;
let cropMode = false;
let cropStartX = 0, cropStartY = 0, cropEndX = 0, cropEndY = 0;
let cropRect = null, isDrawingCrop = false;
const baseCanvas = document.getElementById('base-canvas');
const overlayCanvas = document.getElementById('overlay-canvas');
const overlayWrapper = document.getElementById('overlay-wrapper');
const cropOverlay = document.getElementById('crop-overlay');
const baseCtx = baseCanvas.getContext('2d');
const overlayCtx = overlayCanvas.getContext('2d', { alpha: true });
const cropCtx = cropOverlay.getContext('2d');
overlayCtx.imageSmoothingEnabled = true;
overlayCtx.imageSmoothingQuality = 'high';
baseCtx.imageSmoothingEnabled = true;
baseCtx.imageSmoothingQuality = 'high';
let overlayImageData = null, originalOverlayImageData = null, fullOriginalImageData = null;
let isResizing = false, resizeHandle = null;
let originalWidth = 0, originalHeight = 0, resizeStartX = 0, resizeStartY = 0;
function saveRelativePosition() {
if (baseCanvas.width > 0 && baseCanvas.height > 0) {
const centerX = overlayX + (overlayCanvas.width / 2);
const centerY = overlayY + (overlayCanvas.height / 2);
relativePosition.x = centerX / baseCanvas.width;
relativePosition.y = centerY / baseCanvas.height;
isPositionSet = true;
}
}
function restorePositionFromRelative() {
if (!isPositionSet) { centerOverlay(); return; }
const centerX = relativePosition.x * baseCanvas.width;
const centerY = relativePosition.y * baseCanvas.height;
overlayX = centerX - (overlayCanvas.width / 2);
overlayY = centerY - (overlayCanvas.height / 2);
overlayWrapper.style.left = overlayX + 'px';
overlayWrapper.style.top = overlayY + 'px';
}
document.getElementById('scale-match-mode').addEventListener('click', function() {
scaleMatchMode = !scaleMatchMode;
cropMode = false;
document.getElementById('crop-mode').classList.remove('active');
this.classList.toggle('active');
document.getElementById('canvas-container').classList.toggle('scale-match-active');
document.getElementById('scale-match-info').classList.toggle('active');
if (scaleMatchMode) { currentScaleMatchStep = 0; updateScaleMatchStatus(); }
else { clearScaleMatchPoints(); }
});
document.getElementById('scale-match-reset').addEventListener('click', () => {
clearScaleMatchPoints();
currentScaleMatchStep = 0;
if (scaleMatchMode) updateScaleMatchStatus();
});
function clearScaleMatchPoints() {
scaleMatchPoints = { base1: null, base2: null, overlay1: null, overlay2: null };
document.querySelectorAll('.point-marker').forEach(m => m.remove());
}
function updateScaleMatchStatus() {
const statusEl = document.getElementById('scale-match-status');
const messages = ['ベース図面の1点目を選択','ベース図面の2点目を選択','重ねる図面の1点目を選択','重ねる図面の2点目を選択'];
if (currentScaleMatchStep < messages.length) statusEl.textContent = messages[currentScaleMatchStep];
else { statusEl.textContent = '縮尺を計算中...'; calculateAndApplyScale(); }
}
function addPointMarker(x, y, className) {
const marker = document.createElement('div');
marker.className = `point-marker ${className}`;
marker.style.left = (x - 5) + 'px';
marker.style.top = (y - 5) + 'px';
document.getElementById('canvas-container').appendChild(marker);
}
baseCanvas.addEventListener('click', (e) => {
if (!scaleMatchMode || currentScaleMatchStep >= 2) return;
const rect = baseCanvas.getBoundingClientRect();
const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
const x = e.clientX - containerRect.left, y = e.clientY - containerRect.top;
if (currentScaleMatchStep === 0) { scaleMatchPoints.base1 = { x: e.clientX - rect.left, y: e.clientY - rect.top }; addPointMarker(x, y, 'base-1'); }
else if (currentScaleMatchStep === 1) { scaleMatchPoints.base2 = { x: e.clientX - rect.left, y: e.clientY - rect.top }; addPointMarker(x, y, 'base-2'); }
currentScaleMatchStep++;
updateScaleMatchStatus();
});
overlayCanvas.addEventListener('click', (e) => {
if (!scaleMatchMode || currentScaleMatchStep < 2 || currentScaleMatchStep >= 4) return;
const rect = overlayCanvas.getBoundingClientRect();
const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
const x = e.clientX - containerRect.left, y = e.clientY - containerRect.top;
if (currentScaleMatchStep === 2) { scaleMatchPoints.overlay1 = { x: e.clientX - rect.left, y: e.clientY - rect.top }; addPointMarker(x, y, 'overlay-1'); }
else if (currentScaleMatchStep === 3) { scaleMatchPoints.overlay2 = { x: e.clientX - rect.left, y: e.clientY - rect.top }; addPointMarker(x, y, 'overlay-2'); }
currentScaleMatchStep++;
updateScaleMatchStatus();
});
async function calculateAndApplyScale() {
const baseDistance = Math.sqrt(Math.pow(scaleMatchPoints.base2.x - scaleMatchPoints.base1.x, 2) + Math.pow(scaleMatchPoints.base2.y - scaleMatchPoints.base1.y, 2));
const overlayDistance = Math.sqrt(Math.pow(scaleMatchPoints.overlay2.x - scaleMatchPoints.overlay1.x, 2) + Math.pow(scaleMatchPoints.overlay2.y - scaleMatchPoints.overlay1.y, 2));
const newScale = (baseDistance / overlayDistance) * overlayScale;
const scalePercent = Math.round(newScale * 100);
document.getElementById('overlay-scale').value = Math.min(500, Math.max(10, scalePercent));
document.getElementById('overlay-scale-input').value = scalePercent;
overlayScale = newScale;
await renderOverlayPDF(true);
const scaledOverlayX = scaleMatchPoints.overlay1.x * (newScale / overlayScale);
const scaledOverlayY = scaleMatchPoints.overlay1.y * (newScale / overlayScale);
overlayX = scaleMatchPoints.base1.x - scaledOverlayX;
overlayY = scaleMatchPoints.base1.y - scaledOverlayY;
overlayWrapper.style.left = overlayX + 'px';
overlayWrapper.style.top = overlayY + 'px';
saveRelativePosition();
scaleMatchMode = false;
document.getElementById('scale-match-mode').classList.remove('active');
document.getElementById('canvas-container').classList.remove('scale-match-active');
document.getElementById('scale-match-info').classList.remove('active');
clearScaleMatchPoints();
document.getElementById('scale-match-status').textContent = '縮尺合わせ完了';
}
function setupSliderInputSync(sliderId, inputId, callback) {
const slider = document.getElementById(sliderId);
const input = document.getElementById(inputId);
slider.addEventListener('input', (e) => { input.value = e.target.value; callback(e.target.value); });
input.addEventListener('input', (e) => {
let value = parseInt(e.target.value);
if (isNaN(value)) return;
value = Math.max(parseInt(input.min), Math.min(parseInt(input.max), value));
e.target.value = value; slider.value = value; callback(value);
});
input.addEventListener('blur', (e) => { if (e.target.value === '') e.target.value = slider.value; });
}
setupSliderInputSync('opacity', 'opacity-input', (value) => { overlayCanvas.style.opacity = value / 100; });
setupSliderInputSync('threshold', 'threshold-input', () => {});
setupSliderInputSync('scale', 'scale-input', async (value) => { saveRelativePosition(); currentScale = value / 100; await renderBasePDF(); await renderOverlayPDF(true); restorePositionFromRelative(); });
setupSliderInputSync('overlay-scale', 'overlay-scale-input', async (value) => { saveRelativePosition(); overlayScale = value / 100; await renderOverlayPDF(true); restorePositionFromRelative(); });
document.querySelectorAll('.resize-handle').forEach(handle => { handle.addEventListener('mousedown', startResize); });
function startResize(e) {
isResizing = true; resizeHandle = e.target.classList[1];
resizeStartX = e.clientX; resizeStartY = e.clientY;
originalWidth = overlayCanvas.width; originalHeight = overlayCanvas.height;
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize);
e.preventDefault(); e.stopPropagation();
}
async function doResize(e) {
if (!isResizing) return;
const deltaX = e.clientX - resizeStartX;
let newWidth = originalWidth;
const aspectRatio = originalWidth / originalHeight;
const centerX = overlayX + originalWidth / 2, centerY = overlayY + originalHeight / 2;
switch(resizeHandle) {
case 'se': newWidth = originalWidth + deltaX; break;
case 'sw': newWidth = originalWidth - deltaX; overlayX = centerX - newWidth / 2; break;
case 'ne': newWidth = originalWidth + deltaX; overlayY = centerY - (newWidth / aspectRatio) / 2; break;
case 'nw': newWidth = originalWidth - deltaX; overlayX = centerX - newWidth / 2; overlayY = centerY - (newWidth / aspectRatio) / 2; break;
}
if (newWidth < 50) return;
const baseWidth = currentState.isCropped ? currentState.cropData.width : (fullOriginalImageData ? fullOriginalImageData.width / (currentScale * 2) : overlayCanvas.width);
const newScale = (newWidth / (baseWidth * currentScale)) * 100;
document.getElementById('overlay-scale').value = Math.min(500, Math.max(10, newScale));
document.getElementById('overlay-scale-input').value = Math.round(newScale);
saveRelativePosition(); overlayScale = newScale / 100; await renderOverlayPDF(true);
overlayWrapper.style.left = overlayX + 'px'; overlayWrapper.style.top = overlayY + 'px';
}
async function stopResize() { isResizing = false; resizeHandle = null; saveRelativePosition(); document.removeEventListener('mousemove', doResize); document.removeEventListener('mouseup', stopResize); }
async function renderBasePDF() {
if (!basePdf) return;
const page = await basePdf.getPage(baseCurrentPage);
const viewport = page.getViewport({ scale: currentScale });
baseCanvas.width = viewport.width; baseCanvas.height = viewport.height;
await page.render({ canvasContext: baseCtx, viewport: viewport, intent: 'display' }).promise;
updateContainerSize();
}
async function renderOverlayPDF(preserveState = false) {
if (!overlayPdf) return;
const page = await overlayPdf.getPage(overlayCurrentPage);
const renderScale = 2;
const viewport = page.getViewport({ scale: currentScale * overlayScale * renderScale });
const tempCanvas = document.createElement('canvas');
tempCanvas.width = viewport.width; tempCanvas.height = viewport.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.imageSmoothingEnabled = true; tempCtx.imageSmoothingQuality = 'high';
await page.render({ canvasContext: tempCtx, viewport: viewport, intent: 'display' }).promise;
const actualWidth = viewport.width / renderScale, actualHeight = viewport.height / renderScale;
fullOriginalImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
if (preserveState && currentState.isCropped && currentState.cropData) {
const scale = currentScale * overlayScale * renderScale;
const cropX = currentState.cropData.x * scale, cropY = currentState.cropData.y * scale;
const cropWidth = currentState.cropData.width * scale, cropHeight = currentState.cropData.height * scale;
overlayCanvas.width = cropWidth / renderScale; overlayCanvas.height = cropHeight / renderScale;
overlayWrapper.style.width = (cropWidth / renderScale) + 'px'; overlayWrapper.style.height = (cropHeight / renderScale) + 'px';
overlayCtx.drawImage(tempCanvas, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth / renderScale, cropHeight / renderScale);
} else {
overlayCanvas.width = actualWidth; overlayCanvas.height = actualHeight;
overlayWrapper.style.width = actualWidth + 'px'; overlayWrapper.style.height = actualHeight + 'px';
overlayCtx.drawImage(tempCanvas, 0, 0, actualWidth, actualHeight);
}
overlayImageData = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
originalOverlayImageData = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
if (preserveState && currentState.isColorApplied) applyColorToCanvas(false);
}
function updateContainerSize() {
const container = document.getElementById('canvas-container');
container.style.width = baseCanvas.width + 'px'; container.style.height = baseCanvas.height + 'px';
cropOverlay.width = baseCanvas.width; cropOverlay.height = baseCanvas.height;
}
function centerOverlay() {
overlayX = (baseCanvas.width - overlayCanvas.width) / 2;
overlayY = (baseCanvas.height - overlayCanvas.height) / 2;
overlayWrapper.style.left = overlayX + 'px'; overlayWrapper.style.top = overlayY + 'px';
saveRelativePosition();
}
document.getElementById('crop-mode').addEventListener('click', function() {
cropMode = !cropMode; scaleMatchMode = false;
document.getElementById('scale-match-mode').classList.remove('active');
this.classList.toggle('active');
document.getElementById('canvas-container').classList.toggle('crop-active');
document.getElementById('canvas-container').classList.remove('scale-match-active');
document.getElementById('crop-info').style.display = cropMode ? 'block' : 'none';
document.getElementById('scale-match-info').classList.remove('active');
if (!cropMode) clearCropOverlay();
});
overlayCanvas.addEventListener('mousedown', (e) => {
if (scaleMatchMode) return;
if (!cropMode) { startDrag(e); return; }
const rect = overlayCanvas.getBoundingClientRect();
cropStartX = e.clientX - rect.left; cropStartY = e.clientY - rect.top;
isDrawingCrop = true;
document.addEventListener('mousemove', drawCropSelection);
document.addEventListener('mouseup', endCropSelection);
});
function drawCropSelection(e) {
if (!isDrawingCrop) return;
const rect = overlayCanvas.getBoundingClientRect();
cropEndX = e.clientX - rect.left; cropEndY = e.clientY - rect.top;
cropCtx.clearRect(0, 0, cropOverlay.width, cropOverlay.height);
const x = Math.min(cropStartX, cropEndX) + overlayX, y = Math.min(cropStartY, cropEndY) + overlayY;
const width = Math.abs(cropEndX - cropStartX), height = Math.abs(cropEndY - cropStartY);
cropCtx.strokeStyle = '#ff0000'; cropCtx.lineWidth = 2; cropCtx.setLineDash([5, 5]);
cropCtx.strokeRect(x, y, width, height);
cropRect = { x: Math.min(cropStartX, cropEndX), y: Math.min(cropStartY, cropEndY), width, height };
}
function endCropSelection() { if (!isDrawingCrop) return; document.removeEventListener('mousemove', drawCropSelection); document.removeEventListener('mouseup', endCropSelection); isDrawingCrop = false; }
document.getElementById('apply-crop').addEventListener('click', () => {
if (!cropRect || !overlayImageData) return;
saveRelativePosition();
currentState.cropData = { x: cropRect.x / (currentScale * overlayScale), y: cropRect.y / (currentScale * overlayScale), width: cropRect.width / (currentScale * overlayScale), height: cropRect.height / (currentScale * overlayScale) };
currentState.isCropped = true;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = cropRect.width; tempCanvas.height = cropRect.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(overlayCanvas, cropRect.x, cropRect.y, cropRect.width, cropRect.height, 0, 0, cropRect.width, cropRect.height);
overlayCanvas.width = cropRect.width; overlayCanvas.height = cropRect.height;
overlayWrapper.style.width = cropRect.width + 'px'; overlayWrapper.style.height = cropRect.height + 'px';
overlayCtx.drawImage(tempCanvas, 0, 0);
overlayImageData = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
originalOverlayImageData = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
if (currentState.isColorApplied) applyColorToCanvas(false);
restorePositionFromRelative();
clearCropOverlay(); cropMode = false; cropRect = null;
document.getElementById('crop-mode').classList.remove('active');
document.getElementById('canvas-container').classList.remove('crop-active');
document.getElementById('crop-info').style.display = 'none';
});
document.getElementById('reset-crop').addEventListener('click', async () => {
cropRect = null; currentState.cropData = null; currentState.isCropped = false;
clearCropOverlay(); saveRelativePosition();
if (overlayPdf) { await renderOverlayPDF(false); if (currentState.isColorApplied) applyColorToCanvas(false); restorePositionFromRelative(); }
});
function clearCropOverlay() { cropCtx.clearRect(0, 0, cropOverlay.width, cropOverlay.height); }
document.getElementById('base-file').addEventListener('change', async (e) => {
const file = e.target.files[0]; if (!file) return;
basePdfArrayBuffer = await file.arrayBuffer();
basePdf = await pdfjsLib.getDocument(basePdfArrayBuffer.slice(0)).promise;
baseCurrentPage = 1;
document.getElementById('base-page').textContent = `${baseCurrentPage}/${basePdf.numPages}`;
await renderBasePDF();
if (overlayPdf) restorePositionFromRelative();
});
document.getElementById('overlay-file').addEventListener('change', async (e) => {
const file = e.target.files[0]; if (!file) return;
overlayPdfArrayBuffer = await file.arrayBuffer();
overlayPdf = await pdfjsLib.getDocument(overlayPdfArrayBuffer.slice(0)).promise;
overlayCurrentPage = 1;
currentState = { cropData: null, colorSettings: null, isCropped: false, isColorApplied: false };
cropRect = null; isPositionSet = false;
document.getElementById('overlay-page').textContent = `${overlayCurrentPage}/${overlayPdf.numPages}`;
await renderOverlayPDF(false); centerOverlay();
});
document.getElementById('base-prev').addEventListener('click', async () => { if (!basePdf || baseCurrentPage <= 1) return; baseCurrentPage--; document.getElementById('base-page').textContent = `${baseCurrentPage}/${basePdf.numPages}`; await renderBasePDF(); if (overlayPdf) restorePositionFromRelative(); });
document.getElementById('base-next').addEventListener('click', async () => { if (!basePdf || baseCurrentPage >= basePdf.numPages) return; baseCurrentPage++; document.getElementById('base-page').textContent = `${baseCurrentPage}/${basePdf.numPages}`; await renderBasePDF(); if (overlayPdf) restorePositionFromRelative(); });
document.getElementById('overlay-prev').addEventListener('click', async () => { if (!overlayPdf || overlayCurrentPage <= 1) return; overlayCurrentPage--; currentState = { cropData: null, colorSettings: null, isCropped: false, isColorApplied: false }; cropRect = null; document.getElementById('overlay-page').textContent = `${overlayCurrentPage}/${overlayPdf.numPages}`; await renderOverlayPDF(false); restorePositionFromRelative(); });
document.getElementById('overlay-next').addEventListener('click', async () => { if (!overlayPdf || overlayCurrentPage >= overlayPdf.numPages) return; overlayCurrentPage++; currentState = { cropData: null, colorSettings: null, isCropped: false, isColorApplied: false }; cropRect = null; document.getElementById('overlay-page').textContent = `${overlayCurrentPage}/${overlayPdf.numPages}`; await renderOverlayPDF(false); restorePositionFromRelative(); });
document.getElementById('color-mode').addEventListener('change', (e) => {
const isMonoMode = e.target.checked;
document.getElementById('normal-color-controls').style.display = isMonoMode ? 'none' : 'block';
document.getElementById('mono-color-controls').style.display = isMonoMode ? 'block' : 'none';
});
document.getElementById('black-transparent').addEventListener('click', function() {
blackTransparent = !blackTransparent; this.classList.toggle('active');
document.getElementById('black-replace').style.display = blackTransparent ? 'none' : 'inline-block';
document.getElementById('black-transparent-indicator').style.display = blackTransparent ? 'inline-block' : 'none';
if (document.getElementById('color-mode').checked) applyColorToCanvas(true);
});
document.getElementById('white-transparent').addEventListener('click', function() {
whiteTransparent = !whiteTransparent; this.classList.toggle('active');
document.getElementById('white-replace').style.display = whiteTransparent ? 'none' : 'inline-block';
document.getElementById('white-transparent-indicator').style.display = whiteTransparent ? 'inline-block' : 'none';
if (document.getElementById('color-mode').checked) applyColorToCanvas(true);
});
function applyColorToCanvas(saveState = true) {
if (!overlayImageData) return;
const colorMode = document.getElementById('color-mode').checked;
if (saveState) {
currentState.colorSettings = { colorMode, normalColor: document.getElementById('color-filter').value, blackColor: document.getElementById('black-replace').value, whiteColor: document.getElementById('white-replace').value, threshold: document.getElementById('threshold').value, invert: document.getElementById('invert-mono').checked, blackTransparent, whiteTransparent };
currentState.isColorApplied = true;
}
const newImageData = new ImageData(new Uint8ClampedArray(originalOverlayImageData.data), originalOverlayImageData.width, originalOverlayImageData.height);
if (colorMode) {
const blackColor = document.getElementById('black-replace').value, whiteColor = document.getElementById('white-replace').value;
const threshold = parseInt(document.getElementById('threshold').value), invert = document.getElementById('invert-mono').checked;
const blackR = parseInt(blackColor.substr(1, 2), 16), blackG = parseInt(blackColor.substr(3, 2), 16), blackB = parseInt(blackColor.substr(5, 2), 16);
const whiteR = parseInt(whiteColor.substr(1, 2), 16), whiteG = parseInt(whiteColor.substr(3, 2), 16), whiteB = parseInt(whiteColor.substr(5, 2), 16);
for (let i = 0; i < newImageData.data.length; i += 4) {
if (newImageData.data[i + 3] > 0) {
const gray = newImageData.data[i] * 0.299 + newImageData.data[i + 1] * 0.587 + newImageData.data[i + 2] * 0.114;
let isBlack = gray < threshold; if (invert) isBlack = !isBlack;
if (isBlack) { if (blackTransparent) newImageData.data[i + 3] = 0; else { newImageData.data[i] = blackR; newImageData.data[i + 1] = blackG; newImageData.data[i + 2] = blackB; } }
else { if (whiteTransparent) newImageData.data[i + 3] = 0; else { newImageData.data[i] = whiteR; newImageData.data[i + 1] = whiteG; newImageData.data[i + 2] = whiteB; } }
}
}
} else {
const color = document.getElementById('color-filter').value;
const r = parseInt(color.substr(1, 2), 16), g = parseInt(color.substr(3, 2), 16), b = parseInt(color.substr(5, 2), 16);
for (let i = 0; i < newImageData.data.length; i += 4) {
if (newImageData.data[i + 3] > 0) {
newImageData.data[i] = Math.min(255, newImageData.data[i] * (r / 128));
newImageData.data[i + 1] = Math.min(255, newImageData.data[i + 1] * (g / 128));
newImageData.data[i + 2] = Math.min(255, newImageData.data[i + 2] * (b / 128));
}
}
}
overlayCtx.putImageData(newImageData, 0, 0); overlayImageData = newImageData;
}
document.getElementById('apply-color').addEventListener('click', () => applyColorToCanvas(true));
document.getElementById('reset-color').addEventListener('click', () => {
if (originalOverlayImageData) { overlayCtx.putImageData(originalOverlayImageData, 0, 0); overlayImageData = originalOverlayImageData; }
currentState.colorSettings = null; currentState.isColorApplied = false;
blackTransparent = false; whiteTransparent = false;
document.getElementById('black-transparent').classList.remove('active');
document.getElementById('white-transparent').classList.remove('active');
document.getElementById('black-replace').style.display = 'inline-block';
document.getElementById('white-replace').style.display = 'inline-block';
document.getElementById('black-transparent-indicator').style.display = 'none';
document.getElementById('white-transparent-indicator').style.display = 'none';
});
document.querySelectorAll('.preset-color').forEach(preset => {
preset.addEventListener('click', () => {
const color = preset.dataset.color;
if (document.getElementById('color-mode').checked) { if (!blackTransparent) document.getElementById('black-replace').value = color; }
else { document.getElementById('color-filter').value = color; }
applyColorToCanvas(true);
});
});
['black-replace', 'white-replace', 'invert-mono'].forEach(id => { document.getElementById(id).addEventListener('change', () => { if (document.getElementById('color-mode').checked) applyColorToCanvas(true); }); });
document.getElementById('threshold').addEventListener('input', () => { if (document.getElementById('color-mode').checked) applyColorToCanvas(true); });
let isDragging = false, dragStartX, dragStartY, initialLeft, initialTop;
function startDrag(e) {
if (cropMode || isResizing || scaleMatchMode) return;
isDragging = true; dragStartX = e.clientX; dragStartY = e.clientY;
const rect = overlayWrapper.getBoundingClientRect();
const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
initialLeft = rect.left - containerRect.left; initialTop = rect.top - containerRect.top;
document.addEventListener('mousemove', drag); document.addEventListener('mouseup', stopDrag);
e.preventDefault();
}
function drag(e) { if (!isDragging) return; overlayX = initialLeft + (e.clientX - dragStartX); overlayY = initialTop + (e.clientY - dragStartY); overlayWrapper.style.left = overlayX + 'px'; overlayWrapper.style.top = overlayY + 'px'; }
function stopDrag() { isDragging = false; saveRelativePosition(); document.removeEventListener('mousemove', drag); document.removeEventListener('mouseup', stopDrag); }
document.getElementById('reset-position').addEventListener('click', () => centerOverlay());
document.getElementById('fit-to-base').addEventListener('click', () => { overlayX = 0; overlayY = 0; overlayWrapper.style.left = '0px'; overlayWrapper.style.top = '0px'; saveRelativePosition(); });
document.getElementById('blend-mode').addEventListener('change', (e) => { overlayCanvas.style.mixBlendMode = e.target.value; });
function exportAsImage(format) {
const exportScale = 5;
const exportCanvas = document.createElement('canvas');
exportCanvas.width = baseCanvas.width * exportScale; exportCanvas.height = baseCanvas.height * exportScale;
const exportCtx = exportCanvas.getContext('2d');
exportCtx.imageSmoothingEnabled = true; exportCtx.imageSmoothingQuality = 'high';
if (format === 'jpeg') { exportCtx.fillStyle = 'white'; exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); }
exportCtx.scale(exportScale, exportScale);
exportCtx.drawImage(baseCanvas, 0, 0, baseCanvas.width, baseCanvas.height);
exportCtx.globalAlpha = parseFloat(overlayCanvas.style.opacity || 1);
exportCtx.globalCompositeOperation = document.getElementById('blend-mode').value;
exportCtx.drawImage(overlayCanvas, overlayX, overlayY, overlayCanvas.width, overlayCanvas.height);
exportCanvas.toBlob((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `pdf-kasane-zu_${Date.now()}.${format === 'jpeg' ? 'jpg' : format}`; a.click(); URL.revokeObjectURL(url); }, `image/${format}`, 1.0);
}
document.getElementById('export-jpg').addEventListener('click', () => exportAsImage('jpeg'));
document.getElementById('export-png').addEventListener('click', () => exportAsImage('png'));
document.getElementById('export-pdf').addEventListener('click', () => {
if (!window.jspdf) { alert('PDF出力ライブラリの読み込みに失敗しました。'); return; }
const { jsPDF } = window.jspdf;
const pdfSize = document.getElementById('pdf-size').value;
const exportScale = 4;
const exportCanvas = document.createElement('canvas');
exportCanvas.width = baseCanvas.width * exportScale; exportCanvas.height = baseCanvas.height * exportScale;
const exportCtx = exportCanvas.getContext('2d');
exportCtx.imageSmoothingEnabled = true; exportCtx.imageSmoothingQuality = 'high';
exportCtx.scale(exportScale, exportScale);
exportCtx.fillStyle = 'white'; exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
exportCtx.drawImage(baseCanvas, 0, 0, baseCanvas.width, baseCanvas.height);
exportCtx.globalAlpha = parseFloat(overlayCanvas.style.opacity || 1);
exportCtx.globalCompositeOperation = document.getElementById('blend-mode').value;
exportCtx.drawImage(overlayCanvas, overlayX, overlayY, overlayCanvas.width, overlayCanvas.height);
const orientation = exportCanvas.width > exportCanvas.height ? 'l' : 'p';
const pdf = new jsPDF({ orientation, unit: 'mm', format: pdfSize, compress: false });
const pageWidth = pdf.internal.pageSize.getWidth(), pageHeight = pdf.internal.pageSize.getHeight();
const imgAspect = exportCanvas.width / exportCanvas.height, pageAspect = pageWidth / pageHeight;
let imgWidth, imgHeight;
if (imgAspect > pageAspect) { imgWidth = pageWidth; imgHeight = pageWidth / imgAspect; }
else { imgHeight = pageHeight; imgWidth = pageHeight * imgAspect; }
const x = (pageWidth - imgWidth) / 2, y = (pageHeight - imgHeight) / 2;
pdf.addImage(exportCanvas.toDataURL('image/png', 1.0), 'PNG', x, y, imgWidth, imgHeight, undefined, 'NONE');
pdf.save(`pdf-kasane-zu_${pdfSize.toUpperCase()}_${Date.now()}.pdf`);
});
document.getElementById('canvas-container').addEventListener('wheel', async (e) => {
if (e.ctrlKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -5 : 5;
let newValue = Math.max(50, Math.min(500, parseInt(document.getElementById('scale').value) + delta));
document.getElementById('scale').value = newValue; document.getElementById('scale-input').value = newValue;
saveRelativePosition(); currentScale = newValue / 100;
await renderBasePDF(); await renderOverlayPDF(true); restorePositionFromRelative();
}
});
async function init() { await initDB(); await renderSlotUI(); overlayCanvas.style.opacity = 0.5; }
init();
</script>
</body>
</html>
⑥ PDF計測
<!DOCTYPE html>
<html lang="ja">
<head>
<style>
.preloader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
z-index: 9999;
animation: fadeOut 0.1s 0.3s forwards;
}
.glasses {
display: flex;
align-items: center;
animation: swing 0.3s ease-in-out infinite alternate;
transform-origin: top center;
}
.lens {
width: 40px;
height: 20px;
border: 3px solid #888;
border-radius: 4px;
background: rgba(200,200,200,0.2);
}
.bridge {
width: 12px;
height: 3px;
background: #888;
}
.temple {
width: 20px;
height: 3px;
background: #888;
}
.pencil {
font-size: 48px;
animation: swing 0.3s ease-in-out infinite alternate;
animation-delay: 0.15s;
transform-origin: top center;
display: inline-block;
}
@keyframes swing {
0% { transform: rotate(-15deg); }
100% { transform: rotate(15deg); }
}
@keyframes fadeOut {
to { opacity: 0; visibility: hidden; }
}
</style>
<div class="preloader">
<div class="glasses">
<div class="temple"></div>
<div class="lens"></div>
<div class="bridge"></div>
<div class="lens"></div>
<div class="temple"></div>
</div>
<span class="pencil">✏️</span>
</div>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF重ね図</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Arial', sans-serif; background: #f0f0f0; }
.container { display: flex; height: 100vh; }
.sidebar { width: 300px; background: #2c3e50; color: white; padding: 20px; overflow-y: auto; }
.title-row { display: flex; align-items: flex-end; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.title-row h2 { margin: 0; }
.slot-wrapper { display: flex; flex-direction: column; align-items: flex-start; }
.slot-label { font-size: 9px; color: #95a5a6; margin-bottom: 2px; }
.slot-container { display: flex; gap: 2px; align-items: center; }
.slot-group { position: relative; display: flex; gap: 1px; }
.slot-btn { padding: 4px 6px; font-size: 10px; border-radius: 3px; width: auto; margin: 0; }
.slot-btn.save { background: #27ae60; }
.slot-btn.save:hover { background: #229954; }
.slot-btn.load { background: #3498db; }
.slot-btn.load:hover { background: #2980b9; }
.slot-btn.load:disabled { background: #555; cursor: not-allowed; }
.slot-btn.delete { position: absolute; top: -5px; right: -5px; background: #c0392b; padding: 1px 4px; font-size: 8px; border-radius: 50%; min-width: auto; line-height: 1; }
.slot-btn.delete:hover { background: #a93226; }
.control-group { margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #34495e; }
.control-group h3 { margin-bottom: 10px; font-size: 14px; }
.file-input { width: 100%; padding: 10px; margin-bottom: 10px; background: #34495e; border: 1px solid #4a5f7a; color: white; cursor: pointer; }
.slider-container { display: flex; align-items: center; margin: 10px 0; }
.slider { flex: 1; margin: 0 10px; }
.scale-input { width: 60px; padding: 5px; background: #34495e; border: 1px solid #4a5f7a; color: white; text-align: center; margin-left: 5px; }
.scale-input:focus { outline: none; border-color: #3498db; }
.scale-unit { margin-left: 5px; color: #95a5a6; }
button { width: 100%; padding: 10px; margin: 5px 0; background: #3498db; color: white; border: none; cursor: pointer; transition: background 0.3s; }
button:hover { background: #2980b9; }
button.active { background: #e74c3c; }
.export-btn { background: #27ae60; }
.export-btn:hover { background: #229954; }
.main-area { flex: 1; position: relative; overflow: auto; background: #ecf0f1; }
#canvas-container { position: relative; margin: 20px; display: inline-block; background: white; background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px; }
.pdf-canvas { position: absolute; top: 0; left: 0; border: 1px solid #bdc3c7; }
#base-canvas { z-index: 1; position: relative; }
#overlay-wrapper { position: absolute; z-index: 2; }
#overlay-canvas { cursor: move; display: block; image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; }
.resize-handle { position: absolute; width: 10px; height: 10px; background: #3498db; border: 2px solid white; display: none; z-index: 10; }
#overlay-wrapper:hover .resize-handle { display: block; }
.resize-handle:hover { background: #e74c3c; }
.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
#crop-overlay { z-index: 3; pointer-events: none; position: absolute; top: 0; left: 0; }
.crop-active #overlay-canvas { cursor: crosshair !important; }
.scale-match-active #base-canvas { cursor: crosshair !important; }
.scale-match-active #overlay-canvas { cursor: crosshair !important; }
.scale-match-info { padding: 10px; background: #34495e; border-radius: 5px; margin-top: 10px; font-size: 12px; display: none; }
.scale-match-info.active { display: block; }
.scale-match-status { padding: 5px; margin-top: 5px; background: #2c3e50; border-radius: 3px; font-size: 11px; }
.point-marker { position: absolute; width: 10px; height: 10px; border-radius: 50%; z-index: 10; pointer-events: none; }
.point-marker.base-1 { background: #3498db; border: 2px solid white; }
.point-marker.base-2 { background: #3498db; border: 2px solid white; }
.point-marker.overlay-1 { background: #e74c3c; border: 2px solid white; }
.point-marker.overlay-2 { background: #e74c3c; border: 2px solid white; }
.color-controls { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
.color-input { width: 50px; height: 30px; border: none; cursor: pointer; }
.transparent-btn { padding: 5px 10px; margin-left: 5px; background: #7f8c8d; font-size: 12px; width: auto; }
.transparent-btn.active { background: #e74c3c; }
.page-controls { display: flex; gap: 10px; align-items: center; justify-content: center; }
.page-info { padding: 0 10px; }
.preset-colors { display: flex; gap: 5px; margin-top: 10px; }
.preset-color { width: 30px; height: 30px; border: 2px solid #34495e; cursor: pointer; }
.preset-color:hover { border-color: white; }
.mono-options { margin-top: 10px; padding: 10px; background: #34495e; border-radius: 5px; }
.threshold-container { margin: 10px 0; }
.color-row { display: flex; align-items: center; margin-bottom: 10px; }
.color-label { width: 40px; }
.transparent-indicator { width: 50px; height: 30px; border: 1px solid #fff; background-image: linear-gradient(45deg, #666 25%, transparent 25%), linear-gradient(-45deg, #666 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #666 75%), linear-gradient(-45deg, transparent 75%, #666 75%); background-size: 10px 10px; background-position: 0 0, 0 5px, 5px -5px, -5px 0px; display: none; margin-left: 10px; }
.crop-info { padding: 10px; background: #34495e; border-radius: 5px; margin-top: 10px; font-size: 12px; }
.quality-info { padding: 10px; background: #34495e; border-radius: 5px; margin-top: 10px; font-size: 11px; color: #95a5a6; }
.pdf-size-select { width: 100%; padding: 5px; margin: 10px 0; background: #34495e; border: 1px solid #4a5f7a; color: white; }
.loading-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: flex; justify-content: center; align-items: center; z-index: 9999; color: white; font-size: 18px; }
.loading-overlay.hidden { display: none; }
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: #27ae60; color: white; padding: 12px 24px; border-radius: 6px; font-size: 14px; z-index: 10000; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.toast.show { opacity: 1; }
</style>
</head>
<body>
<div class="loading-overlay hidden" id="loading-overlay"><div>読み込み中...</div></div>
<div class="toast" id="toast"></div>
<div class="container">
<div class="sidebar">
<div class="title-row">
<h2>PDF重ね図</h2>
<div class="slot-wrapper">
<span class="slot-label">保存スロット</span>
<div class="slot-container" id="slotContainer"></div>
</div>
</div>
<div class="control-group">
<h3>ベースPDF</h3>
<input type="file" id="base-file" class="file-input" accept=".pdf">
<div class="page-controls">
<button id="base-prev">◀</button>
<span class="page-info" id="base-page">1/1</span>
<button id="base-next">▶</button>
</div>
</div>
<div class="control-group">
<h3>重ねるPDF</h3>
<input type="file" id="overlay-file" class="file-input" accept=".pdf">
<div class="page-controls">
<button id="overlay-prev">◀</button>
<span class="page-info" id="overlay-page">1/1</span>
<button id="overlay-next">▶</button>
</div>
</div>
<div class="control-group">
<h3>縮尺合わせ</h3>
<button id="scale-match-mode">対角2点で縮尺合わせ</button>
<button id="scale-match-reset">ポイントリセット</button>
<div class="scale-match-info" id="scale-match-info">
<div class="scale-match-status" id="scale-match-status">ベース図面の1点目を選択してください</div>
</div>
</div>
<div class="control-group">
<h3>トリミング</h3>
<button id="crop-mode">トリミングモード</button>
<div class="crop-info" id="crop-info" style="display: none;"><div>範囲選択して適用を押してください</div></div>
<button id="apply-crop">トリミング適用</button>
<button id="reset-crop">トリミングリセット</button>
</div>
<div class="control-group">
<h3>透過度</h3>
<div class="slider-container">
<input type="range" id="opacity" class="slider" min="0" max="100" value="50">
<input type="number" id="opacity-input" class="scale-input" min="0" max="100" value="50">
<span class="scale-unit">%</span>
</div>
</div>
<div class="control-group">
<h3>色変更</h3>
<label style="display: block; margin-bottom: 10px;">
<input type="checkbox" id="color-mode"> 単色モード
</label>
<div id="normal-color-controls">
<div class="color-controls">
<label>色調整:</label>
<input type="color" id="color-filter" class="color-input" value="#ff0000">
</div>
</div>
<div id="mono-color-controls" class="mono-options" style="display: none;">
<div class="color-row">
<span class="color-label">黒→</span>
<input type="color" id="black-replace" class="color-input" value="#000000">
<button id="black-transparent" class="transparent-btn">透明</button>
<div id="black-transparent-indicator" class="transparent-indicator"></div>
</div>
<div class="color-row">
<span class="color-label">白→</span>
<input type="color" id="white-replace" class="color-input" value="#ffffff">
<button id="white-transparent" class="transparent-btn">透明</button>
<div id="white-transparent-indicator" class="transparent-indicator"></div>
</div>
<div class="threshold-container">
<label>閾値:</label>
<div class="slider-container">
<input type="range" id="threshold" class="slider" min="0" max="255" value="128">
<input type="number" id="threshold-input" class="scale-input" min="0" max="255" value="128">
</div>
</div>
<label style="display: block; margin-top: 10px;">
<input type="checkbox" id="invert-mono"> 白黒反転
</label>
</div>
<div class="preset-colors">
<div class="preset-color" style="background: #ff0000" data-color="#ff0000"></div>
<div class="preset-color" style="background: #00ff00" data-color="#00ff00"></div>
<div class="preset-color" style="background: #0000ff" data-color="#0000ff"></div>
<div class="preset-color" style="background: #ffff00" data-color="#ffff00"></div>
<div class="preset-color" style="background: #ff00ff" data-color="#ff00ff"></div>
<div class="preset-color" style="background: #00ffff" data-color="#00ffff"></div>
</div>
<button id="apply-color">色を適用</button>
<button id="reset-color">色をリセット</button>
</div>
<div class="control-group">
<h3>拡大縮小</h3>
<div class="slider-container">
<input type="range" id="scale" class="slider" min="50" max="500" value="100">
<input type="number" id="scale-input" class="scale-input" min="50" max="500" value="100">
<span class="scale-unit">%</span>
</div>
<div class="slider-container">
<label style="width: 80px;">重ねる側:</label>
<input type="range" id="overlay-scale" class="slider" min="10" max="500" value="100">
<input type="number" id="overlay-scale-input" class="scale-input" min="10" max="500" value="100">
<span class="scale-unit">%</span>
</div>
</div>
<div class="control-group">
<h3>位置リセット</h3>
<button id="reset-position">中央に配置</button>
<button id="fit-to-base">ベースに合わせる</button>
</div>
<div class="control-group">
<h3>ブレンドモード</h3>
<select id="blend-mode" style="width: 100%; padding: 5px;">
<option value="normal">通常</option>
<option value="multiply">乗算</option>
<option value="screen">スクリーン</option>
<option value="overlay">オーバーレイ</option>
<option value="difference">差分</option>
<option value="exclusion">除外</option>
<option value="darken">暗く</option>
<option value="lighten">明るく</option>
</select>
</div>
<div class="control-group">
<h3>エクスポート</h3>
<select id="pdf-size" class="pdf-size-select">
<option value="a3" selected>A3サイズ</option>
<option value="a4">A4サイズ</option>
<option value="a2">A2サイズ</option>
<option value="a1">A1サイズ</option>
</select>
<button id="export-jpg" class="export-btn">JPGで保存</button>
<button id="export-pdf" class="export-btn">PDFで保存</button>
<button id="export-png" class="export-btn">PNGで保存</button>
<div class="quality-info">※最高画質出力(A3・300dpi相当)</div>
</div>
</div>
<div class="main-area">
<div id="canvas-container">
<canvas id="base-canvas" class="pdf-canvas"></canvas>
<div id="overlay-wrapper">
<canvas id="overlay-canvas"></canvas>
<div class="resize-handle nw"></div>
<div class="resize-handle ne"></div>
<div class="resize-handle sw"></div>
<div class="resize-handle se"></div>
</div>
<canvas id="crop-overlay" class="pdf-canvas"></canvas>
</div>
</div>
</div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
function showToast(message, duration = 2000) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), duration);
}
const DB_NAME = 'pdfOverlayDB';
const DB_VERSION = 1;
const STORE_NAME = 'slots';
let db = null;
async function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => { db = request.result; resolve(db); };
request.onupgradeneeded = (e) => {
const database = e.target.result;
if (!database.objectStoreNames.contains(STORE_NAME)) database.createObjectStore(STORE_NAME, { keyPath: 'id' });
};
});
}
async function saveSlotToDB(slotNum, data) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
data.id = slotNum;
store.put(data);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getSlotFromDB(slotNum) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(slotNum);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async function deleteSlotFromDB(slotNum) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.delete(slotNum);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getAllSlots() {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const result = {};
request.result.forEach(slot => { result[slot.id] = slot; });
resolve(result);
};
request.onerror = () => reject(request.error);
});
}
const SLOT_COUNT = 5;
let basePdfArrayBuffer = null;
let overlayPdfArrayBuffer = null;
function showLoading() { document.getElementById('loading-overlay').classList.remove('hidden'); }
function hideLoading() { document.getElementById('loading-overlay').classList.add('hidden'); }
async function saveToSlot(slotNum) {
showLoading();
try {
const data = {
id: slotNum,
basePdf: basePdfArrayBuffer,
overlayPdf: overlayPdfArrayBuffer,
baseCurrentPage: baseCurrentPage,
overlayCurrentPage: overlayCurrentPage,
currentScale: currentScale,
overlayScale: overlayScale,
overlayX: overlayX,
overlayY: overlayY,
relativePosition: relativePosition,
isPositionSet: isPositionSet,
opacity: document.getElementById('opacity').value,
blendMode: document.getElementById('blend-mode').value,
colorMode: document.getElementById('color-mode').checked,
colorFilter: document.getElementById('color-filter').value,
blackReplace: document.getElementById('black-replace').value,
whiteReplace: document.getElementById('white-replace').value,
threshold: document.getElementById('threshold').value,
invertMono: document.getElementById('invert-mono').checked,
blackTransparent: blackTransparent,
whiteTransparent: whiteTransparent,
pdfSize: document.getElementById('pdf-size').value,
// 色適用状態も保存
isColorApplied: currentState.isColorApplied,
savedAt: new Date().toLocaleString('ja-JP')
};
await saveSlotToDB(slotNum, data);
await renderSlotUI();
showToast(`スロット${slotNum}に保存しました`);
} finally {
hideLoading();
}
}
async function loadFromSlot(slotNum) {
showLoading();
try {
const slot = await getSlotFromDB(slotNum);
if (!slot) return;
if (slot.basePdf) {
basePdfArrayBuffer = slot.basePdf;
basePdf = await pdfjsLib.getDocument(slot.basePdf.slice(0)).promise;
baseCurrentPage = slot.baseCurrentPage || 1;
document.getElementById('base-page').textContent = `${baseCurrentPage}/${basePdf.numPages}`;
}
if (slot.overlayPdf) {
overlayPdfArrayBuffer = slot.overlayPdf;
overlayPdf = await pdfjsLib.getDocument(slot.overlayPdf.slice(0)).promise;
overlayCurrentPage = slot.overlayCurrentPage || 1;
document.getElementById('overlay-page').textContent = `${overlayCurrentPage}/${overlayPdf.numPages}`;
}
currentScale = slot.currentScale;
overlayScale = slot.overlayScale;
overlayX = slot.overlayX;
overlayY = slot.overlayY;
relativePosition = slot.relativePosition;
isPositionSet = slot.isPositionSet;
blackTransparent = slot.blackTransparent;
whiteTransparent = slot.whiteTransparent;
// UI更新
document.getElementById('scale').value = currentScale * 100;
document.getElementById('scale-input').value = currentScale * 100;
document.getElementById('overlay-scale').value = overlayScale * 100;
document.getElementById('overlay-scale-input').value = overlayScale * 100;
document.getElementById('opacity').value = slot.opacity;
document.getElementById('opacity-input').value = slot.opacity;
document.getElementById('blend-mode').value = slot.blendMode;
document.getElementById('color-mode').checked = slot.colorMode;
document.getElementById('color-filter').value = slot.colorFilter;
document.getElementById('black-replace').value = slot.blackReplace;
document.getElementById('white-replace').value = slot.whiteReplace;
document.getElementById('threshold').value = slot.threshold;
document.getElementById('threshold-input').value = slot.threshold;
document.getElementById('invert-mono').checked = slot.invertMono;
document.getElementById('pdf-size').value = slot.pdfSize;
overlayCanvas.style.opacity = slot.opacity / 100;
overlayCanvas.style.mixBlendMode = slot.blendMode;
// 単色モード表示切替
document.getElementById('normal-color-controls').style.display = slot.colorMode ? 'none' : 'block';
document.getElementById('mono-color-controls').style.display = slot.colorMode ? 'block' : 'none';
// 透明ボタン状態
document.getElementById('black-transparent').classList.toggle('active', blackTransparent);
document.getElementById('white-transparent').classList.toggle('active', whiteTransparent);
document.getElementById('black-replace').style.display = blackTransparent ? 'none' : 'inline-block';
document.getElementById('white-replace').style.display = whiteTransparent ? 'none' : 'inline-block';
document.getElementById('black-transparent-indicator').style.display = blackTransparent ? 'inline-block' : 'none';
document.getElementById('white-transparent-indicator').style.display = whiteTransparent ? 'inline-block' : 'none';
// レンダリング
if (basePdf) await renderBasePDF();
if (overlayPdf) {
await renderOverlayPDF(false);
overlayWrapper.style.left = overlayX + 'px';
overlayWrapper.style.top = overlayY + 'px';
// 色適用状態を復元
if (slot.isColorApplied) {
currentState.isColorApplied = true;
applyColorToCanvas(false);
}
}
} finally {
hideLoading();
}
}
async function deleteSlot(slotNum, e) {
e.stopPropagation();
if (!confirm(`スロット${slotNum}を削除?`)) return;
await deleteSlotFromDB(slotNum);
await renderSlotUI();
}
async function renderSlotUI() {
const container = document.getElementById('slotContainer');
const slots = await getAllSlots();
container.innerHTML = '';
for (let i = 1; i <= SLOT_COUNT; i++) {
const slot = slots[i];
const group = document.createElement('div');
group.className = 'slot-group';
const saveBtn = document.createElement('button');
saveBtn.className = 'slot-btn save';
saveBtn.textContent = `S${i}`;
saveBtn.title = slot ? `上書き保存\n${slot.savedAt}` : `スロット${i}に保存`;
saveBtn.onclick = () => saveToSlot(i);
const loadBtn = document.createElement('button');
loadBtn.className = 'slot-btn load';
loadBtn.textContent = `L${i}`;
loadBtn.title = slot ? `読込\n${slot.savedAt}` : '(空)';
loadBtn.onclick = () => loadFromSlot(i);
loadBtn.disabled = !slot;
group.appendChild(saveBtn);
group.appendChild(loadBtn);
if (slot) {
const delBtn = document.createElement('button');
delBtn.className = 'slot-btn delete';
delBtn.textContent = '×';
delBtn.title = '削除';
delBtn.onclick = (e) => deleteSlot(i, e);
group.appendChild(delBtn);
}
container.appendChild(group);
}
}
let basePdf = null, overlayPdf = null;
let baseCurrentPage = 1, overlayCurrentPage = 1;
let currentScale = 1, overlayScale = 1;
let overlayX = 0, overlayY = 0;
let relativePosition = { x: 0.5, y: 0.5 };
let isPositionSet = false;
let scaleMatchMode = false;
let scaleMatchPoints = { base1: null, base2: null, overlay1: null, overlay2: null };
let currentScaleMatchStep = 0;
let currentState = { cropData: null, colorSettings: null, isCropped: false, isColorApplied: false };
let blackTransparent = false, whiteTransparent = false;
let cropMode = false;
let cropStartX = 0, cropStartY = 0, cropEndX = 0, cropEndY = 0;
let cropRect = null, isDrawingCrop = false;
const baseCanvas = document.getElementById('base-canvas');
const overlayCanvas = document.getElementById('overlay-canvas');
const overlayWrapper = document.getElementById('overlay-wrapper');
const cropOverlay = document.getElementById('crop-overlay');
const baseCtx = baseCanvas.getContext('2d');
const overlayCtx = overlayCanvas.getContext('2d', { alpha: true });
const cropCtx = cropOverlay.getContext('2d');
overlayCtx.imageSmoothingEnabled = true;
overlayCtx.imageSmoothingQuality = 'high';
baseCtx.imageSmoothingEnabled = true;
baseCtx.imageSmoothingQuality = 'high';
let overlayImageData = null, originalOverlayImageData = null, fullOriginalImageData = null;
let isResizing = false, resizeHandle = null;
let originalWidth = 0, originalHeight = 0, resizeStartX = 0, resizeStartY = 0;
function saveRelativePosition() {
if (baseCanvas.width > 0 && baseCanvas.height > 0) {
const centerX = overlayX + (overlayCanvas.width / 2);
const centerY = overlayY + (overlayCanvas.height / 2);
relativePosition.x = centerX / baseCanvas.width;
relativePosition.y = centerY / baseCanvas.height;
isPositionSet = true;
}
}
function restorePositionFromRelative() {
if (!isPositionSet) { centerOverlay(); return; }
const centerX = relativePosition.x * baseCanvas.width;
const centerY = relativePosition.y * baseCanvas.height;
overlayX = centerX - (overlayCanvas.width / 2);
overlayY = centerY - (overlayCanvas.height / 2);
overlayWrapper.style.left = overlayX + 'px';
overlayWrapper.style.top = overlayY + 'px';
}
document.getElementById('scale-match-mode').addEventListener('click', function() {
scaleMatchMode = !scaleMatchMode;
cropMode = false;
document.getElementById('crop-mode').classList.remove('active');
this.classList.toggle('active');
document.getElementById('canvas-container').classList.toggle('scale-match-active');
document.getElementById('scale-match-info').classList.toggle('active');
if (scaleMatchMode) { currentScaleMatchStep = 0; updateScaleMatchStatus(); }
else { clearScaleMatchPoints(); }
});
document.getElementById('scale-match-reset').addEventListener('click', () => {
clearScaleMatchPoints();
currentScaleMatchStep = 0;
if (scaleMatchMode) updateScaleMatchStatus();
});
function clearScaleMatchPoints() {
scaleMatchPoints = { base1: null, base2: null, overlay1: null, overlay2: null };
document.querySelectorAll('.point-marker').forEach(m => m.remove());
}
function updateScaleMatchStatus() {
const statusEl = document.getElementById('scale-match-status');
const messages = ['ベース図面の1点目を選択','ベース図面の2点目を選択','重ねる図面の1点目を選択','重ねる図面の2点目を選択'];
if (currentScaleMatchStep < messages.length) statusEl.textContent = messages[currentScaleMatchStep];
else { statusEl.textContent = '縮尺を計算中...'; calculateAndApplyScale(); }
}
function addPointMarker(x, y, className) {
const marker = document.createElement('div');
marker.className = `point-marker ${className}`;
marker.style.left = (x - 5) + 'px';
marker.style.top = (y - 5) + 'px';
document.getElementById('canvas-container').appendChild(marker);
}
baseCanvas.addEventListener('click', (e) => {
if (!scaleMatchMode || currentScaleMatchStep >= 2) return;
const rect = baseCanvas.getBoundingClientRect();
const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
const x = e.clientX - containerRect.left, y = e.clientY - containerRect.top;
if (currentScaleMatchStep === 0) { scaleMatchPoints.base1 = { x: e.clientX - rect.left, y: e.clientY - rect.top }; addPointMarker(x, y, 'base-1'); }
else if (currentScaleMatchStep === 1) { scaleMatchPoints.base2 = { x: e.clientX - rect.left, y: e.clientY - rect.top }; addPointMarker(x, y, 'base-2'); }
currentScaleMatchStep++;
updateScaleMatchStatus();
});
overlayCanvas.addEventListener('click', (e) => {
if (!scaleMatchMode || currentScaleMatchStep < 2 || currentScaleMatchStep >= 4) return;
const rect = overlayCanvas.getBoundingClientRect();
const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
const x = e.clientX - containerRect.left, y = e.clientY - containerRect.top;
if (currentScaleMatchStep === 2) { scaleMatchPoints.overlay1 = { x: e.clientX - rect.left, y: e.clientY - rect.top }; addPointMarker(x, y, 'overlay-1'); }
else if (currentScaleMatchStep === 3) { scaleMatchPoints.overlay2 = { x: e.clientX - rect.left, y: e.clientY - rect.top }; addPointMarker(x, y, 'overlay-2'); }
currentScaleMatchStep++;
updateScaleMatchStatus();
});
async function calculateAndApplyScale() {
const baseDistance = Math.sqrt(Math.pow(scaleMatchPoints.base2.x - scaleMatchPoints.base1.x, 2) + Math.pow(scaleMatchPoints.base2.y - scaleMatchPoints.base1.y, 2));
const overlayDistance = Math.sqrt(Math.pow(scaleMatchPoints.overlay2.x - scaleMatchPoints.overlay1.x, 2) + Math.pow(scaleMatchPoints.overlay2.y - scaleMatchPoints.overlay1.y, 2));
const newScale = (baseDistance / overlayDistance) * overlayScale;
const scalePercent = Math.round(newScale * 100);
document.getElementById('overlay-scale').value = Math.min(500, Math.max(10, scalePercent));
document.getElementById('overlay-scale-input').value = scalePercent;
overlayScale = newScale;
await renderOverlayPDF(true);
const scaledOverlayX = scaleMatchPoints.overlay1.x * (newScale / overlayScale);
const scaledOverlayY = scaleMatchPoints.overlay1.y * (newScale / overlayScale);
overlayX = scaleMatchPoints.base1.x - scaledOverlayX;
overlayY = scaleMatchPoints.base1.y - scaledOverlayY;
overlayWrapper.style.left = overlayX + 'px';
overlayWrapper.style.top = overlayY + 'px';
saveRelativePosition();
scaleMatchMode = false;
document.getElementById('scale-match-mode').classList.remove('active');
document.getElementById('canvas-container').classList.remove('scale-match-active');
document.getElementById('scale-match-info').classList.remove('active');
clearScaleMatchPoints();
document.getElementById('scale-match-status').textContent = '縮尺合わせ完了';
}
function setupSliderInputSync(sliderId, inputId, callback) {
const slider = document.getElementById(sliderId);
const input = document.getElementById(inputId);
slider.addEventListener('input', (e) => { input.value = e.target.value; callback(e.target.value); });
input.addEventListener('input', (e) => {
let value = parseInt(e.target.value);
if (isNaN(value)) return;
value = Math.max(parseInt(input.min), Math.min(parseInt(input.max), value));
e.target.value = value; slider.value = value; callback(value);
});
input.addEventListener('blur', (e) => { if (e.target.value === '') e.target.value = slider.value; });
}
setupSliderInputSync('opacity', 'opacity-input', (value) => { overlayCanvas.style.opacity = value / 100; });
setupSliderInputSync('threshold', 'threshold-input', () => {});
setupSliderInputSync('scale', 'scale-input', async (value) => { saveRelativePosition(); currentScale = value / 100; await renderBasePDF(); await renderOverlayPDF(true); restorePositionFromRelative(); });
setupSliderInputSync('overlay-scale', 'overlay-scale-input', async (value) => { saveRelativePosition(); overlayScale = value / 100; await renderOverlayPDF(true); restorePositionFromRelative(); });
document.querySelectorAll('.resize-handle').forEach(handle => { handle.addEventListener('mousedown', startResize); });
function startResize(e) {
isResizing = true; resizeHandle = e.target.classList[1];
resizeStartX = e.clientX; resizeStartY = e.clientY;
originalWidth = overlayCanvas.width; originalHeight = overlayCanvas.height;
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize);
e.preventDefault(); e.stopPropagation();
}
async function doResize(e) {
if (!isResizing) return;
const deltaX = e.clientX - resizeStartX;
let newWidth = originalWidth;
const aspectRatio = originalWidth / originalHeight;
const centerX = overlayX + originalWidth / 2, centerY = overlayY + originalHeight / 2;
switch(resizeHandle) {
case 'se': newWidth = originalWidth + deltaX; break;
case 'sw': newWidth = originalWidth - deltaX; overlayX = centerX - newWidth / 2; break;
case 'ne': newWidth = originalWidth + deltaX; overlayY = centerY - (newWidth / aspectRatio) / 2; break;
case 'nw': newWidth = originalWidth - deltaX; overlayX = centerX - newWidth / 2; overlayY = centerY - (newWidth / aspectRatio) / 2; break;
}
if (newWidth < 50) return;
const baseWidth = currentState.isCropped ? currentState.cropData.width : (fullOriginalImageData ? fullOriginalImageData.width / (currentScale * 2) : overlayCanvas.width);
const newScale = (newWidth / (baseWidth * currentScale)) * 100;
document.getElementById('overlay-scale').value = Math.min(500, Math.max(10, newScale));
document.getElementById('overlay-scale-input').value = Math.round(newScale);
saveRelativePosition(); overlayScale = newScale / 100; await renderOverlayPDF(true);
overlayWrapper.style.left = overlayX + 'px'; overlayWrapper.style.top = overlayY + 'px';
}
async function stopResize() { isResizing = false; resizeHandle = null; saveRelativePosition(); document.removeEventListener('mousemove', doResize); document.removeEventListener('mouseup', stopResize); }
async function renderBasePDF() {
if (!basePdf) return;
const page = await basePdf.getPage(baseCurrentPage);
const viewport = page.getViewport({ scale: currentScale });
baseCanvas.width = viewport.width; baseCanvas.height = viewport.height;
await page.render({ canvasContext: baseCtx, viewport: viewport, intent: 'display' }).promise;
updateContainerSize();
}
async function renderOverlayPDF(preserveState = false) {
if (!overlayPdf) return;
const page = await overlayPdf.getPage(overlayCurrentPage);
const renderScale = 2;
const viewport = page.getViewport({ scale: currentScale * overlayScale * renderScale });
const tempCanvas = document.createElement('canvas');
tempCanvas.width = viewport.width; tempCanvas.height = viewport.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.imageSmoothingEnabled = true; tempCtx.imageSmoothingQuality = 'high';
await page.render({ canvasContext: tempCtx, viewport: viewport, intent: 'display' }).promise;
const actualWidth = viewport.width / renderScale, actualHeight = viewport.height / renderScale;
fullOriginalImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
if (preserveState && currentState.isCropped && currentState.cropData) {
const scale = currentScale * overlayScale * renderScale;
const cropX = currentState.cropData.x * scale, cropY = currentState.cropData.y * scale;
const cropWidth = currentState.cropData.width * scale, cropHeight = currentState.cropData.height * scale;
overlayCanvas.width = cropWidth / renderScale; overlayCanvas.height = cropHeight / renderScale;
overlayWrapper.style.width = (cropWidth / renderScale) + 'px'; overlayWrapper.style.height = (cropHeight / renderScale) + 'px';
overlayCtx.drawImage(tempCanvas, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth / renderScale, cropHeight / renderScale);
} else {
overlayCanvas.width = actualWidth; overlayCanvas.height = actualHeight;
overlayWrapper.style.width = actualWidth + 'px'; overlayWrapper.style.height = actualHeight + 'px';
overlayCtx.drawImage(tempCanvas, 0, 0, actualWidth, actualHeight);
}
overlayImageData = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
originalOverlayImageData = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
if (preserveState && currentState.isColorApplied) applyColorToCanvas(false);
}
function updateContainerSize() {
const container = document.getElementById('canvas-container');
container.style.width = baseCanvas.width + 'px'; container.style.height = baseCanvas.height + 'px';
cropOverlay.width = baseCanvas.width; cropOverlay.height = baseCanvas.height;
}
function centerOverlay() {
overlayX = (baseCanvas.width - overlayCanvas.width) / 2;
overlayY = (baseCanvas.height - overlayCanvas.height) / 2;
overlayWrapper.style.left = overlayX + 'px'; overlayWrapper.style.top = overlayY + 'px';
saveRelativePosition();
}
document.getElementById('crop-mode').addEventListener('click', function() {
cropMode = !cropMode; scaleMatchMode = false;
document.getElementById('scale-match-mode').classList.remove('active');
this.classList.toggle('active');
document.getElementById('canvas-container').classList.toggle('crop-active');
document.getElementById('canvas-container').classList.remove('scale-match-active');
document.getElementById('crop-info').style.display = cropMode ? 'block' : 'none';
document.getElementById('scale-match-info').classList.remove('active');
if (!cropMode) clearCropOverlay();
});
overlayCanvas.addEventListener('mousedown', (e) => {
if (scaleMatchMode) return;
if (!cropMode) { startDrag(e); return; }
const rect = overlayCanvas.getBoundingClientRect();
cropStartX = e.clientX - rect.left; cropStartY = e.clientY - rect.top;
isDrawingCrop = true;
document.addEventListener('mousemove', drawCropSelection);
document.addEventListener('mouseup', endCropSelection);
});
function drawCropSelection(e) {
if (!isDrawingCrop) return;
const rect = overlayCanvas.getBoundingClientRect();
cropEndX = e.clientX - rect.left; cropEndY = e.clientY - rect.top;
cropCtx.clearRect(0, 0, cropOverlay.width, cropOverlay.height);
const x = Math.min(cropStartX, cropEndX) + overlayX, y = Math.min(cropStartY, cropEndY) + overlayY;
const width = Math.abs(cropEndX - cropStartX), height = Math.abs(cropEndY - cropStartY);
cropCtx.strokeStyle = '#ff0000'; cropCtx.lineWidth = 2; cropCtx.setLineDash([5, 5]);
cropCtx.strokeRect(x, y, width, height);
cropRect = { x: Math.min(cropStartX, cropEndX), y: Math.min(cropStartY, cropEndY), width, height };
}
function endCropSelection() { if (!isDrawingCrop) return; document.removeEventListener('mousemove', drawCropSelection); document.removeEventListener('mouseup', endCropSelection); isDrawingCrop = false; }
document.getElementById('apply-crop').addEventListener('click', () => {
if (!cropRect || !overlayImageData) return;
saveRelativePosition();
currentState.cropData = { x: cropRect.x / (currentScale * overlayScale), y: cropRect.y / (currentScale * overlayScale), width: cropRect.width / (currentScale * overlayScale), height: cropRect.height / (currentScale * overlayScale) };
currentState.isCropped = true;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = cropRect.width; tempCanvas.height = cropRect.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(overlayCanvas, cropRect.x, cropRect.y, cropRect.width, cropRect.height, 0, 0, cropRect.width, cropRect.height);
overlayCanvas.width = cropRect.width; overlayCanvas.height = cropRect.height;
overlayWrapper.style.width = cropRect.width + 'px'; overlayWrapper.style.height = cropRect.height + 'px';
overlayCtx.drawImage(tempCanvas, 0, 0);
overlayImageData = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
originalOverlayImageData = overlayCtx.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
if (currentState.isColorApplied) applyColorToCanvas(false);
restorePositionFromRelative();
clearCropOverlay(); cropMode = false; cropRect = null;
document.getElementById('crop-mode').classList.remove('active');
document.getElementById('canvas-container').classList.remove('crop-active');
document.getElementById('crop-info').style.display = 'none';
});
document.getElementById('reset-crop').addEventListener('click', async () => {
cropRect = null; currentState.cropData = null; currentState.isCropped = false;
clearCropOverlay(); saveRelativePosition();
if (overlayPdf) { await renderOverlayPDF(false); if (currentState.isColorApplied) applyColorToCanvas(false); restorePositionFromRelative(); }
});
function clearCropOverlay() { cropCtx.clearRect(0, 0, cropOverlay.width, cropOverlay.height); }
document.getElementById('base-file').addEventListener('change', async (e) => {
const file = e.target.files[0]; if (!file) return;
basePdfArrayBuffer = await file.arrayBuffer();
basePdf = await pdfjsLib.getDocument(basePdfArrayBuffer.slice(0)).promise;
baseCurrentPage = 1;
document.getElementById('base-page').textContent = `${baseCurrentPage}/${basePdf.numPages}`;
await renderBasePDF();
if (overlayPdf) restorePositionFromRelative();
});
document.getElementById('overlay-file').addEventListener('change', async (e) => {
const file = e.target.files[0]; if (!file) return;
overlayPdfArrayBuffer = await file.arrayBuffer();
overlayPdf = await pdfjsLib.getDocument(overlayPdfArrayBuffer.slice(0)).promise;
overlayCurrentPage = 1;
currentState = { cropData: null, colorSettings: null, isCropped: false, isColorApplied: false };
cropRect = null; isPositionSet = false;
document.getElementById('overlay-page').textContent = `${overlayCurrentPage}/${overlayPdf.numPages}`;
await renderOverlayPDF(false); centerOverlay();
});
document.getElementById('base-prev').addEventListener('click', async () => { if (!basePdf || baseCurrentPage <= 1) return; baseCurrentPage--; document.getElementById('base-page').textContent = `${baseCurrentPage}/${basePdf.numPages}`; await renderBasePDF(); if (overlayPdf) restorePositionFromRelative(); });
document.getElementById('base-next').addEventListener('click', async () => { if (!basePdf || baseCurrentPage >= basePdf.numPages) return; baseCurrentPage++; document.getElementById('base-page').textContent = `${baseCurrentPage}/${basePdf.numPages}`; await renderBasePDF(); if (overlayPdf) restorePositionFromRelative(); });
document.getElementById('overlay-prev').addEventListener('click', async () => { if (!overlayPdf || overlayCurrentPage <= 1) return; overlayCurrentPage--; currentState = { cropData: null, colorSettings: null, isCropped: false, isColorApplied: false }; cropRect = null; document.getElementById('overlay-page').textContent = `${overlayCurrentPage}/${overlayPdf.numPages}`; await renderOverlayPDF(false); restorePositionFromRelative(); });
document.getElementById('overlay-next').addEventListener('click', async () => { if (!overlayPdf || overlayCurrentPage >= overlayPdf.numPages) return; overlayCurrentPage++; currentState = { cropData: null, colorSettings: null, isCropped: false, isColorApplied: false }; cropRect = null; document.getElementById('overlay-page').textContent = `${overlayCurrentPage}/${overlayPdf.numPages}`; await renderOverlayPDF(false); restorePositionFromRelative(); });
document.getElementById('color-mode').addEventListener('change', (e) => {
const isMonoMode = e.target.checked;
document.getElementById('normal-color-controls').style.display = isMonoMode ? 'none' : 'block';
document.getElementById('mono-color-controls').style.display = isMonoMode ? 'block' : 'none';
});
document.getElementById('black-transparent').addEventListener('click', function() {
blackTransparent = !blackTransparent; this.classList.toggle('active');
document.getElementById('black-replace').style.display = blackTransparent ? 'none' : 'inline-block';
document.getElementById('black-transparent-indicator').style.display = blackTransparent ? 'inline-block' : 'none';
if (document.getElementById('color-mode').checked) applyColorToCanvas(true);
});
document.getElementById('white-transparent').addEventListener('click', function() {
whiteTransparent = !whiteTransparent; this.classList.toggle('active');
document.getElementById('white-replace').style.display = whiteTransparent ? 'none' : 'inline-block';
document.getElementById('white-transparent-indicator').style.display = whiteTransparent ? 'inline-block' : 'none';
if (document.getElementById('color-mode').checked) applyColorToCanvas(true);
});
function applyColorToCanvas(saveState = true) {
if (!overlayImageData) return;
const colorMode = document.getElementById('color-mode').checked;
if (saveState) {
currentState.colorSettings = { colorMode, normalColor: document.getElementById('color-filter').value, blackColor: document.getElementById('black-replace').value, whiteColor: document.getElementById('white-replace').value, threshold: document.getElementById('threshold').value, invert: document.getElementById('invert-mono').checked, blackTransparent, whiteTransparent };
currentState.isColorApplied = true;
}
const newImageData = new ImageData(new Uint8ClampedArray(originalOverlayImageData.data), originalOverlayImageData.width, originalOverlayImageData.height);
if (colorMode) {
const blackColor = document.getElementById('black-replace').value, whiteColor = document.getElementById('white-replace').value;
const threshold = parseInt(document.getElementById('threshold').value), invert = document.getElementById('invert-mono').checked;
const blackR = parseInt(blackColor.substr(1, 2), 16), blackG = parseInt(blackColor.substr(3, 2), 16), blackB = parseInt(blackColor.substr(5, 2), 16);
const whiteR = parseInt(whiteColor.substr(1, 2), 16), whiteG = parseInt(whiteColor.substr(3, 2), 16), whiteB = parseInt(whiteColor.substr(5, 2), 16);
for (let i = 0; i < newImageData.data.length; i += 4) {
if (newImageData.data[i + 3] > 0) {
const gray = newImageData.data[i] * 0.299 + newImageData.data[i + 1] * 0.587 + newImageData.data[i + 2] * 0.114;
let isBlack = gray < threshold; if (invert) isBlack = !isBlack;
if (isBlack) { if (blackTransparent) newImageData.data[i + 3] = 0; else { newImageData.data[i] = blackR; newImageData.data[i + 1] = blackG; newImageData.data[i + 2] = blackB; } }
else { if (whiteTransparent) newImageData.data[i + 3] = 0; else { newImageData.data[i] = whiteR; newImageData.data[i + 1] = whiteG; newImageData.data[i + 2] = whiteB; } }
}
}
} else {
const color = document.getElementById('color-filter').value;
const r = parseInt(color.substr(1, 2), 16), g = parseInt(color.substr(3, 2), 16), b = parseInt(color.substr(5, 2), 16);
for (let i = 0; i < newImageData.data.length; i += 4) {
if (newImageData.data[i + 3] > 0) {
newImageData.data[i] = Math.min(255, newImageData.data[i] * (r / 128));
newImageData.data[i + 1] = Math.min(255, newImageData.data[i + 1] * (g / 128));
newImageData.data[i + 2] = Math.min(255, newImageData.data[i + 2] * (b / 128));
}
}
}
overlayCtx.putImageData(newImageData, 0, 0); overlayImageData = newImageData;
}
document.getElementById('apply-color').addEventListener('click', () => applyColorToCanvas(true));
document.getElementById('reset-color').addEventListener('click', () => {
if (originalOverlayImageData) { overlayCtx.putImageData(originalOverlayImageData, 0, 0); overlayImageData = originalOverlayImageData; }
currentState.colorSettings = null; currentState.isColorApplied = false;
blackTransparent = false; whiteTransparent = false;
document.getElementById('black-transparent').classList.remove('active');
document.getElementById('white-transparent').classList.remove('active');
document.getElementById('black-replace').style.display = 'inline-block';
document.getElementById('white-replace').style.display = 'inline-block';
document.getElementById('black-transparent-indicator').style.display = 'none';
document.getElementById('white-transparent-indicator').style.display = 'none';
});
document.querySelectorAll('.preset-color').forEach(preset => {
preset.addEventListener('click', () => {
const color = preset.dataset.color;
if (document.getElementById('color-mode').checked) { if (!blackTransparent) document.getElementById('black-replace').value = color; }
else { document.getElementById('color-filter').value = color; }
applyColorToCanvas(true);
});
});
['black-replace', 'white-replace', 'invert-mono'].forEach(id => { document.getElementById(id).addEventListener('change', () => { if (document.getElementById('color-mode').checked) applyColorToCanvas(true); }); });
document.getElementById('threshold').addEventListener('input', () => { if (document.getElementById('color-mode').checked) applyColorToCanvas(true); });
let isDragging = false, dragStartX, dragStartY, initialLeft, initialTop;
function startDrag(e) {
if (cropMode || isResizing || scaleMatchMode) return;
isDragging = true; dragStartX = e.clientX; dragStartY = e.clientY;
const rect = overlayWrapper.getBoundingClientRect();
const containerRect = document.getElementById('canvas-container').getBoundingClientRect();
initialLeft = rect.left - containerRect.left; initialTop = rect.top - containerRect.top;
document.addEventListener('mousemove', drag); document.addEventListener('mouseup', stopDrag);
e.preventDefault();
}
function drag(e) { if (!isDragging) return; overlayX = initialLeft + (e.clientX - dragStartX); overlayY = initialTop + (e.clientY - dragStartY); overlayWrapper.style.left = overlayX + 'px'; overlayWrapper.style.top = overlayY + 'px'; }
function stopDrag() { isDragging = false; saveRelativePosition(); document.removeEventListener('mousemove', drag); document.removeEventListener('mouseup', stopDrag); }
document.getElementById('reset-position').addEventListener('click', () => centerOverlay());
document.getElementById('fit-to-base').addEventListener('click', () => { overlayX = 0; overlayY = 0; overlayWrapper.style.left = '0px'; overlayWrapper.style.top = '0px'; saveRelativePosition(); });
document.getElementById('blend-mode').addEventListener('change', (e) => { overlayCanvas.style.mixBlendMode = e.target.value; });
function exportAsImage(format) {
const exportScale = 5;
const exportCanvas = document.createElement('canvas');
exportCanvas.width = baseCanvas.width * exportScale; exportCanvas.height = baseCanvas.height * exportScale;
const exportCtx = exportCanvas.getContext('2d');
exportCtx.imageSmoothingEnabled = true; exportCtx.imageSmoothingQuality = 'high';
if (format === 'jpeg') { exportCtx.fillStyle = 'white'; exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); }
exportCtx.scale(exportScale, exportScale);
exportCtx.drawImage(baseCanvas, 0, 0, baseCanvas.width, baseCanvas.height);
exportCtx.globalAlpha = parseFloat(overlayCanvas.style.opacity || 1);
exportCtx.globalCompositeOperation = document.getElementById('blend-mode').value;
exportCtx.drawImage(overlayCanvas, overlayX, overlayY, overlayCanvas.width, overlayCanvas.height);
exportCanvas.toBlob((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `pdf-kasane-zu_${Date.now()}.${format === 'jpeg' ? 'jpg' : format}`; a.click(); URL.revokeObjectURL(url); }, `image/${format}`, 1.0);
}
document.getElementById('export-jpg').addEventListener('click', () => exportAsImage('jpeg'));
document.getElementById('export-png').addEventListener('click', () => exportAsImage('png'));
document.getElementById('export-pdf').addEventListener('click', () => {
if (!window.jspdf) { alert('PDF出力ライブラリの読み込みに失敗しました。'); return; }
const { jsPDF } = window.jspdf;
const pdfSize = document.getElementById('pdf-size').value;
const exportScale = 4;
const exportCanvas = document.createElement('canvas');
exportCanvas.width = baseCanvas.width * exportScale; exportCanvas.height = baseCanvas.height * exportScale;
const exportCtx = exportCanvas.getContext('2d');
exportCtx.imageSmoothingEnabled = true; exportCtx.imageSmoothingQuality = 'high';
exportCtx.scale(exportScale, exportScale);
exportCtx.fillStyle = 'white'; exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
exportCtx.drawImage(baseCanvas, 0, 0, baseCanvas.width, baseCanvas.height);
exportCtx.globalAlpha = parseFloat(overlayCanvas.style.opacity || 1);
exportCtx.globalCompositeOperation = document.getElementById('blend-mode').value;
exportCtx.drawImage(overlayCanvas, overlayX, overlayY, overlayCanvas.width, overlayCanvas.height);
const orientation = exportCanvas.width > exportCanvas.height ? 'l' : 'p';
const pdf = new jsPDF({ orientation, unit: 'mm', format: pdfSize, compress: false });
const pageWidth = pdf.internal.pageSize.getWidth(), pageHeight = pdf.internal.pageSize.getHeight();
const imgAspect = exportCanvas.width / exportCanvas.height, pageAspect = pageWidth / pageHeight;
let imgWidth, imgHeight;
if (imgAspect > pageAspect) { imgWidth = pageWidth; imgHeight = pageWidth / imgAspect; }
else { imgHeight = pageHeight; imgWidth = pageHeight * imgAspect; }
const x = (pageWidth - imgWidth) / 2, y = (pageHeight - imgHeight) / 2;
pdf.addImage(exportCanvas.toDataURL('image/png', 1.0), 'PNG', x, y, imgWidth, imgHeight, undefined, 'NONE');
pdf.save(`pdf-kasane-zu_${pdfSize.toUpperCase()}_${Date.now()}.pdf`);
});
document.getElementById('canvas-container').addEventListener('wheel', async (e) => {
if (e.ctrlKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -5 : 5;
let newValue = Math.max(50, Math.min(500, parseInt(document.getElementById('scale').value) + delta));
document.getElementById('scale').value = newValue; document.getElementById('scale-input').value = newValue;
saveRelativePosition(); currentScale = newValue / 100;
await renderBasePDF(); await renderOverlayPDF(true); restorePositionFromRelative();
}
});
async function init() { await initDB(); await renderSlotUI(); overlayCanvas.style.opacity = 0.5; }
init();
</script>
</body>
</html>