<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SuperBook PDF Converter</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #f5f5f5;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
}
h1 {
color: #333;
font-size: 2rem;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 0.95rem;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 24px;
margin-bottom: 20px;
}
.upload-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover,
.upload-area.dragover {
border-color: #4a90d9;
background: #f0f7ff;
}
.upload-area.has-file {
border-color: #4caf50;
background: #f0fff0;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 12px;
}
.upload-text {
color: #666;
}
.upload-text strong {
color: #4a90d9;
}
.selected-file {
margin-top: 12px;
padding: 8px 16px;
background: #e8f5e9;
border-radius: 4px;
display: inline-block;
}
#file-input {
display: none;
}
.options-title {
font-size: 1.1rem;
margin-bottom: 16px;
color: #333;
}
.options-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
}
.option-item input[type="checkbox"] {
width: 18px;
height: 18px;
}
.option-item label {
color: #555;
cursor: pointer;
}
.option-item input[type="number"] {
width: 80px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.slider-container {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.slider-container input[type="range"] {
flex: 1;
height: 6px;
-webkit-appearance: none;
background: #e0e0e0;
border-radius: 3px;
outline: none;
}
.slider-container input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #4a90d9;
border-radius: 50%;
cursor: pointer;
}
.slider-value {
min-width: 60px;
text-align: right;
font-weight: 500;
color: #4a90d9;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0;
border-top: 1px solid #eee;
margin-top: 16px;
cursor: pointer;
user-select: none;
}
.advanced-toggle:hover {
color: #4a90d9;
}
.advanced-toggle .arrow {
transition: transform 0.3s;
}
.advanced-toggle.open .arrow {
transform: rotate(90deg);
}
.advanced-options {
display: none;
padding-top: 16px;
}
.advanced-options.open {
display: block;
}
.preview-section {
display: none;
margin-top: 20px;
}
.preview-section.active {
display: block;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-top: 12px;
}
.preview-card {
background: #f9f9f9;
border-radius: 8px;
padding: 12px;
text-align: center;
}
.preview-card img {
max-width: 100%;
height: auto;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.preview-card .stage {
margin-top: 8px;
font-size: 0.85rem;
color: #666;
}
.preview-card .page-num {
font-weight: 500;
color: #333;
}
.convert-btn {
display: block;
width: 100%;
padding: 16px;
margin-top: 20px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
font-size: 1.1rem;
cursor: pointer;
transition: background 0.3s;
}
.convert-btn:hover:not(:disabled) {
background: #3a7bc8;
}
.convert-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.progress-section {
display: none;
}
.progress-section.active {
display: block;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
color: #555;
}
.progress-bar {
height: 24px;
background: #e0e0e0;
border-radius: 12px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4a90d9, #6ab7ff);
transition: width 0.3s;
}
.progress-step {
text-align: center;
margin-top: 8px;
color: #666;
}
.result-section {
display: none;
text-align: center;
}
.result-section.active {
display: block;
}
.result-success {
color: #4caf50;
}
.result-error {
color: #f44336;
}
.result-icon {
font-size: 3rem;
margin-bottom: 12px;
}
.download-btn {
display: inline-block;
padding: 12px 32px;
margin-top: 16px;
background: #4caf50;
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 1rem;
transition: background 0.3s;
}
.download-btn:hover {
background: #43a047;
}
.retry-btn {
display: inline-block;
padding: 12px 32px;
margin-top: 16px;
background: #f44336;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
footer {
text-align: center;
margin-top: 40px;
color: #999;
font-size: 0.85rem;
}
footer a {
color: #4a90d9;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>SuperBook PDF Converter</h1>
<p class="subtitle">AI画像鮮明化 | 傾き補正 | マージン最適化</p>
</header>
<div class="card">
<div class="upload-area" id="upload-area">
<div class="upload-icon">📄</div>
<p class="upload-text">
<strong>クリック</strong>または<strong>ドラッグ&ドロップ</strong>でPDFを選択
</p>
<div class="selected-file" id="selected-file" style="display: none;"></div>
</div>
<input type="file" id="file-input" accept=".pdf">
</div>
<div class="card">
<h2 class="options-title">変換オプション</h2>
<div class="option-item" style="margin-bottom: 16px;">
<label for="dpi-slider">出力DPI:</label>
<div class="slider-container">
<input type="range" id="dpi-slider" min="150" max="600" value="300" step="50">
<span class="slider-value" id="dpi-value">300 DPI</span>
</div>
<input type="hidden" id="dpi" value="300">
</div>
<div class="options-grid">
<div class="option-item">
<input type="checkbox" id="deskew" checked>
<label for="deskew">傾き補正</label>
</div>
<div class="option-item">
<input type="checkbox" id="upscale" checked>
<label for="upscale">AI超解像</label>
</div>
<div class="option-item">
<input type="checkbox" id="ocr">
<label for="ocr">OCR (日本語)</label>
</div>
</div>
<div class="advanced-toggle" id="advanced-toggle">
<span class="arrow">▶</span>
<span>高度なオプション</span>
</div>
<div class="advanced-options" id="advanced-options">
<div class="options-grid">
<div class="option-item" style="grid-column: 1 / -1;">
<label for="margin-slider">マージントリム:</label>
<div class="slider-container">
<input type="range" id="margin-slider" min="0" max="5" value="0.5" step="0.5">
<span class="slider-value" id="margin-value">0.5%</span>
</div>
</div>
<div class="option-item">
<input type="checkbox" id="color-correction">
<label for="color-correction">色補正</label>
</div>
<div class="option-item">
<input type="checkbox" id="offset-alignment">
<label for="offset-alignment">オフセット整列</label>
</div>
<div class="option-item">
<input type="checkbox" id="internal-resolution">
<label for="internal-resolution">内部解像度正規化</label>
</div>
</div>
</div>
<input type="hidden" id="advanced" value="false">
<button class="convert-btn" id="convert-btn" disabled>変換開始</button>
</div>
<div class="card preview-section" id="preview-section">
<h2 class="options-title">プレビュー</h2>
<div class="preview-grid" id="preview-grid"></div>
</div>
<div class="card progress-section" id="progress-section">
<div class="progress-info">
<span id="progress-percent">0%</span>
<span id="progress-step">準備中...</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
</div>
</div>
<div class="card result-section" id="result-section">
<div id="result-content">
<div id="result-success" class="result-success" style="display: none;">
<div class="result-icon">✓</div>
<p>変換完了!</p>
<a id="download-link" class="download-btn" href="#">ダウンロード</a>
</div>
<div id="result-error" class="result-error" style="display: none;">
<div class="result-icon">✕</div>
<p>エラー: <span id="error-message"></span></p>
<button class="retry-btn" id="retry-btn">やり直す</button>
</div>
</div>
</div>
<footer>
<p>SuperBook PDF Converter v<span id="version">-</span></p>
<p><a href="https://github.com/clearclown/DN_SuperBook_PDF_Converter_Linux" target="_blank">GitHub</a></p>
</footer>
</div>
<script>
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('file-input');
const selectedFile = document.getElementById('selected-file');
const convertBtn = document.getElementById('convert-btn');
const progressSection = document.getElementById('progress-section');
const progressFill = document.getElementById('progress-fill');
const progressPercent = document.getElementById('progress-percent');
const progressStep = document.getElementById('progress-step');
const resultSection = document.getElementById('result-section');
const resultSuccess = document.getElementById('result-success');
const resultError = document.getElementById('result-error');
const downloadLink = document.getElementById('download-link');
const errorMessage = document.getElementById('error-message');
const retryBtn = document.getElementById('retry-btn');
let currentFile = null;
let currentJobId = null;
let ws = null;
const dpiSlider = document.getElementById('dpi-slider');
const dpiValue = document.getElementById('dpi-value');
const dpiInput = document.getElementById('dpi');
dpiSlider.addEventListener('input', () => {
const val = dpiSlider.value;
dpiValue.textContent = val + ' DPI';
dpiInput.value = val;
});
const marginSlider = document.getElementById('margin-slider');
const marginValue = document.getElementById('margin-value');
marginSlider.addEventListener('input', () => {
marginValue.textContent = marginSlider.value + '%';
});
const advancedToggle = document.getElementById('advanced-toggle');
const advancedOptions = document.getElementById('advanced-options');
const advancedInput = document.getElementById('advanced');
advancedToggle.addEventListener('click', () => {
advancedToggle.classList.toggle('open');
advancedOptions.classList.toggle('open');
updateAdvancedState();
});
function updateAdvancedState() {
const colorCorrection = document.getElementById('color-correction').checked;
const offsetAlignment = document.getElementById('offset-alignment').checked;
const internalRes = document.getElementById('internal-resolution').checked;
advancedInput.value = colorCorrection || offsetAlignment || internalRes;
}
document.querySelectorAll('#advanced-options input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', updateAdvancedState);
});
const previewSection = document.getElementById('preview-section');
const previewGrid = document.getElementById('preview-grid');
function addPreview(pageNum, base64Data, stage) {
previewSection.classList.add('active');
let card = document.getElementById('preview-' + pageNum + '-' + stage);
if (!card) {
card = document.createElement('div');
card.className = 'preview-card';
card.id = 'preview-' + pageNum + '-' + stage;
const img = document.createElement('img');
img.alt = 'Page ' + pageNum;
card.appendChild(img);
const pageDiv = document.createElement('div');
pageDiv.className = 'page-num';
card.appendChild(pageDiv);
const stageDiv = document.createElement('div');
stageDiv.className = 'stage';
card.appendChild(stageDiv);
previewGrid.appendChild(card);
}
card.querySelector('img').src = 'data:image/jpeg;base64,' + base64Data;
card.querySelector('.page-num').textContent = 'ページ ' + pageNum;
card.querySelector('.stage').textContent = getStageLabel(stage);
}
function getStageLabel(stage) {
const labels = {
'original': '元画像',
'deskewed': '傾き補正後',
'trimmed': 'トリミング後',
'upscaled': '超解像後',
'normalized': '正規化後',
'color_corrected': '色補正後',
'final': '最終出力'
};
return labels[stage] || stage;
}
function clearPreviews() {
while (previewGrid.firstChild) {
previewGrid.removeChild(previewGrid.firstChild);
}
previewSection.classList.remove('active');
}
fetch('/api/health')
.then(r => r.json())
.then(data => {
document.getElementById('version').textContent = data.version;
});
retryBtn.addEventListener('click', resetForm);
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 = e.dataTransfer.files;
if (files.length > 0 && files[0].type === 'application/pdf') {
handleFileSelect(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelect(e.target.files[0]);
}
});
function handleFileSelect(file) {
currentFile = file;
selectedFile.textContent = file.name;
selectedFile.style.display = 'inline-block';
uploadArea.classList.add('has-file');
convertBtn.disabled = false;
}
convertBtn.addEventListener('click', async () => {
if (!currentFile) return;
convertBtn.disabled = true;
progressSection.classList.add('active');
resultSection.classList.remove('active');
resultSuccess.style.display = 'none';
resultError.style.display = 'none';
const options = {
dpi: parseInt(document.getElementById('dpi').value),
deskew: document.getElementById('deskew').checked,
upscale: document.getElementById('upscale').checked,
ocr: document.getElementById('ocr').checked,
advanced: document.getElementById('advanced').checked
};
const formData = new FormData();
formData.append('file', currentFile);
formData.append('options', JSON.stringify(options));
try {
const response = await fetch('/api/convert', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
currentJobId = data.job_id;
connectWebSocket(currentJobId);
} catch (error) {
showError(error.message);
}
});
function connectWebSocket(jobId) {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + location.host + '/ws/jobs/' + jobId;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected for job:', jobId);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleWebSocketMessage(msg);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onclose = () => {
console.log('WebSocket closed');
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
startPollingFallback();
};
}
function handleWebSocketMessage(msg) {
switch (msg.type) {
case 'progress':
const data = msg.data;
progressFill.style.width = data.percent + '%';
progressPercent.textContent = data.percent + '%';
progressStep.textContent = data.step_name;
break;
case 'status_change':
console.log('Status changed:', msg.data.old_status, '->', msg.data.new_status);
break;
case 'completed':
closeWebSocket();
showSuccess(msg.data.download_url);
break;
case 'error':
closeWebSocket();
showError(msg.data.message);
break;
case 'page_preview':
const preview = msg.data;
addPreview(preview.page_number, preview.preview_base64, preview.stage);
break;
}
}
function closeWebSocket() {
if (ws) {
ws.close();
ws = null;
}
}
function startPollingFallback() {
const pollInterval = setInterval(async () => {
try {
const response = await fetch('/api/jobs/' + currentJobId);
const job = await response.json();
if (job.progress) {
const percent = Math.round((job.progress.current_step / job.progress.total_steps) * 100);
progressFill.style.width = percent + '%';
progressPercent.textContent = percent + '%';
progressStep.textContent = job.progress.step_name;
}
if (job.status === 'completed') {
clearInterval(pollInterval);
showSuccess('/api/jobs/' + currentJobId + '/download');
} else if (job.status === 'failed') {
clearInterval(pollInterval);
showError(job.error || 'Unknown error');
} else if (job.status === 'cancelled') {
clearInterval(pollInterval);
showError('Job was cancelled');
}
} catch (error) {
console.error('Polling error:', error);
}
}, 1000);
}
function showSuccess(downloadUrl) {
progressSection.classList.remove('active');
resultSection.classList.add('active');
resultSuccess.style.display = 'block';
resultError.style.display = 'none';
downloadLink.href = downloadUrl || '/api/jobs/' + currentJobId + '/download';
resetFormState();
}
function showError(message) {
progressSection.classList.remove('active');
resultSection.classList.add('active');
resultSuccess.style.display = 'none';
resultError.style.display = 'block';
errorMessage.textContent = message;
}
function resetFormState() {
currentFile = null;
currentJobId = null;
fileInput.value = '';
selectedFile.style.display = 'none';
uploadArea.classList.remove('has-file');
convertBtn.disabled = true;
progressFill.style.width = '0%';
progressPercent.textContent = '0%';
progressStep.textContent = '準備中...';
}
function resetForm() {
closeWebSocket();
resetFormState();
clearPreviews();
resultSection.classList.remove('active');
resultSuccess.style.display = 'none';
resultError.style.display = 'none';
}
</script>
</body>
</html>