import React, { useState, useEffect, useRef } from 'react';
// lucide-reactのインポートエラーを回避するため、インラインSVGコンポーネントに置き換えます
const UploadCloud = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M12 12v9"/><path d="m16 16-4-4-4 4"/></svg>;
const FileImage = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><circle cx="10" cy="13" r="2"/><path d="m20 17-1.09-1.09a2 2 0 0 0-2.82 0L10 22"/></svg>;
const FileDown = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M12 18v-6"/><path d="m9 15 3 3 3-3"/></svg>;
const AlertCircle = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>;
const CheckCircle2 = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>;
const Info = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="12" y2="16"/><line x1="12" x2="12.01" y1="8" y2="8"/></svg>;
const Loader2 = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>;
const Trash2 = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>;
const Settings = ({ className, size = 24 }) => <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>;
export default function App() {
const [files, setFiles] = useState([]);
const [isPdfReady, setIsPdfReady] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [logs, setLogs] = useState([]);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
// jsPDFライブラリの動的ロード
useEffect(() => {
if (window.jspdf) {
setIsPdfReady(true);
return;
}
const script = document.createElement('script');
script.src = "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js";
script.async = true;
script.onload = () => setIsPdfReady(true);
document.body.appendChild(script);
return () => {
if (document.body.contains(script)) {
document.body.removeChild(script);
}
};
}, []);
const addLog = (msg) => {
setLogs(prev => [...prev, msg]);
};
// ファイルが選択・ドロップされた時の処理(インベントリ作成と自然順ソート)
const handleFiles = (newFiles) => {
const imageFiles = Array.from(newFiles).filter(file =>
file.type.match('image/jpeg') ||
file.type.match('image/png') ||
file.type.match('image/webp')
);
if (imageFiles.length === 0) {
setError("有効な画像ファイル(JPEG, PNG, WEBP)が見つかりませんでした。");
return;
}
setError(null);
// 既存のファイルと結合
const allFiles = [...files, ...imageFiles];
// 重複を排除(ファイル名とサイズで判定)
const uniqueFiles = Array.from(new Map(allFiles.map(f => [`${f.name}-${f.size}`, f])).values());
// ★最優先ルール: 自然順ソート (Natural Sort) の適用
uniqueFiles.sort((a, b) => {
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
});
setFiles(uniqueFiles);
};
const onDragOver = (e) => {
e.preventDefault();
};
const onDrop = (e) => {
e.preventDefault();
if (!isProcessing) {
handleFiles(e.dataTransfer.files);
}
};
const clearFiles = () => {
setFiles([]);
setLogs([]);
setProgress(0);
};
// 画像を非同期で読み込むPromiseベースの関数
const loadImage = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("画像フォーマットが不正か破損しています"));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error("ファイルの読み込み権限がないか、アクセスできません"));
reader.readAsDataURL(file);
});
};
// PDF統合処理(チャンク処理・例外ハンドリング)
const generatePDF = async () => {
if (!isPdfReady) {
setError("PDFライブラリをロード中です。しばらくお待ちください。");
return;
}
if (files.length === 0) return;
setIsProcessing(true);
setProgress(0);
setError(null);
setLogs([]);
addLog(`PDF変換処理を開始します (対象: ${files.length} ファイル)`);
try {
const { jsPDF } = window.jspdf;
// A4サイズ (210mm x 297mm) をデフォルトとする
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
let successCount = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
// UIのフリーズを防ぐためのチャンクディレイ
await new Promise(r => setTimeout(r, 20));
try {
addLog(`[${i + 1}/${files.length}] ${file.name} を読み込み中...`);
const img = await loadImage(file);
// アスペクト比を保持してA4にフィットさせる計算
const imgRatio = img.width / img.height;
const pdfRatio = pdfWidth / pdfHeight;
let renderWidth, renderHeight;
if (imgRatio > pdfRatio) {
renderWidth = pdfWidth;
renderHeight = pdfWidth / imgRatio;
} else {
renderHeight = pdfHeight;
renderWidth = pdfHeight * imgRatio;
}
// 中央に配置
const x = (pdfWidth - renderWidth) / 2;
const y = (pdfHeight - renderHeight) / 2;
// 2ページ目以降はページを追加
if (successCount > 0) {
pdf.addPage();
}
// 画像形式をJPEGに標準化して描画 (品質0.95)
pdf.addImage(img, 'JPEG', x, y, renderWidth, renderHeight, undefined, 'FAST');
successCount++;
} catch (err) {
// ★例外処理の定義: エラーファイルはスキップして継続
addLog(`⚠️ エラー: ${file.name} をスキップしました (${err.message})`);
console.error(`Error processing ${file.name}:`, err);
}
setProgress(Math.round(((i + 1) / files.length) * 100));
}
if (successCount > 0) {
addLog("PDFファイルをコンパイルしています...");
// UI更新のための微小待機
await new Promise(r => setTimeout(r, 50));
pdf.save("compiled_book.pdf");
addLog(`✅ 完了しました! ${successCount} ページを統合しました。`);
} else {
throw new Error("有効な画像が1つも処理されませんでした。");
}
} catch (err) {
setError(`処理中に致命的なエラーが発生しました: ${err.message}`);
addLog(`❌ 中断: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
return (
<div className="min-h-screen bg-gray-50 text-gray-800 font-sans p-6">
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<header className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-3 bg-blue-100 text-blue-600 rounded-lg">
<FileDown size={28} />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Secure Scan to PDF Converter</h1>
<p className="text-sm text-gray-500 mt-1">
画像ファイルを自然順にソートし、ローカル環境でPDFに統合します。データがサーバーに送信されることはありません。
</p>
</div>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column: UI & Actions */}
<div className="space-y-6">
{/* Dropzone */}
<div
onDragOver={onDragOver}
onDrop={onDrop}
className={`bg-white border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
isProcessing ? 'border-gray-300 opacity-50 cursor-not-allowed' : 'border-blue-300 hover:border-blue-500 hover:bg-blue-50 cursor-pointer'
}`}
onClick={() => !isProcessing && fileInputRef.current?.click()}
>
<input
type="file"
multiple
accept="image/jpeg, image/png, image/webp"
className="hidden"
ref={fileInputRef}
onChange={(e) => handleFiles(e.target.files)}
disabled={isProcessing}
/>
<UploadCloud className="mx-auto h-12 w-12 text-blue-400 mb-3" />
<p className="text-gray-700 font-medium">画像ファイルをドラッグ&ドロップ</p>
<p className="text-sm text-gray-500 mt-1">または、クリックしてファイルを選択 (JPEG, PNG, WEBP)</p>
</div>
{/* Error Message */}
{error && (
<div className="p-4 bg-red-50 text-red-700 rounded-lg flex items-start gap-3 border border-red-200">
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
<p className="text-sm">{error}</p>
</div>
)}
{/* File List */}
{files.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col h-80">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="font-semibold text-gray-700 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
インベントリ ({files.length} ファイル)
</h3>
<button
onClick={clearFiles}
disabled={isProcessing}
className="text-sm text-red-500 hover:text-red-700 disabled:opacity-50 flex items-center gap-1"
>
<Trash2 className="w-4 h-4" /> クリア
</button>
</div>
<div className="overflow-y-auto p-2 flex-1">
<ul className="space-y-1">
{files.map((file, index) => (
<li key={index} className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-md text-sm border-b border-gray-100 last:border-0">
<span className="text-gray-400 font-mono w-6 text-right shrink-0">{index + 1}.</span>
<FileImage className="w-4 h-4 text-blue-400 shrink-0" />
<span className="truncate flex-1 text-gray-700">{file.name}</span>
<span className="text-xs text-gray-400 shrink-0">{(file.size / 1024 / 1024).toFixed(2)} MB</span>
</li>
))}
</ul>
</div>
</div>
)}
{/* Actions & Progress */}
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<button
onClick={generatePDF}
disabled={files.length === 0 || isProcessing || !isPdfReady}
className={`w-full py-3 px-4 rounded-lg font-bold text-white flex justify-center items-center gap-2 transition-all ${
files.length === 0 || isProcessing || !isPdfReady
? 'bg-gray-300 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 shadow-md hover:shadow-lg'
}`}
>
{isProcessing ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
PDFを生成中... {progress}%
</>
) : (
<>
<FileDown className="w-5 h-5" />
ページ順を保持してPDFを作成
</>
)}
</button>
{/* Progress Bar */}
{isProcessing && (
<div className="mt-4 w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
></div>
</div>
)}
</div>
</div>
{/* Right Column: Documentation & Logs */}
<div className="space-y-6">
{/* Documentation (Prompt Response) */}
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<h2 className="text-lg font-bold text-gray-900 border-b border-gray-200 pb-3 mb-4 flex items-center gap-2">
<Info className="w-5 h-5 text-blue-500" />
実装アルゴリズムと設計仕様
</h2>
<div className="prose prose-sm text-gray-600 space-y-4">
<p>プロンプトの要件に基づき、以下のプロセスを実装しています。</p>
<div>
<h3 className="font-semibold text-gray-800">1. ファイル・インベントリの作成</h3>
<p>HTML5 <code>FileReader API</code> と <code>DataTransfer</code> を用いて、ブラウザのメモリ領域へ直接読み込みます。<code>MIME Type</code>検査により、許可された拡張子のみをフィルタリングします。</p>
</div>
<div>
<h3 className="font-semibold text-gray-800">2. ページ順の厳密なソート</h3>
<p>JavaScriptの <code>String.prototype.localeCompare</code> を <code>numeric: true</code> オプションで実行しています。これにより、「page1.jpg, page2.jpg, page10.jpg」という書籍本来の「自然順ソート」が保証されます。</p>
</div>
<div>
<h3 className="font-semibold text-gray-800">3. 画像形式の検証と変換</h3>
<p>ブラウザのネイティブな <code>Image</code> オブジェクトを使用して画像をデコードします。この手法により、解像度やカラーモードの違いがCanvas描画に適した標準形式に自動補正されます。</p>
</div>
<div>
<h3 className="font-semibold text-gray-800">4. PDF統合処理(チャンク設計)</h3>
<p>クライアントサイドライブラリ <code>jsPDF</code> を採用。全画像を一度に処理するとブラウザがメモリクラッシュを起こすため、<code>async/await</code> を用いた非同期ループで1ページごとに「読み込み→描画→メモリ解放」を行うストリームライクなチャンク処理を実装しています。</p>
</div>
<div>
<h3 className="font-semibold text-gray-800">5. 例外処理とセキュア設計</h3>
<p>各画像の処理を <code>try...catch</code> ブロックで囲み、破損したファイル(権限不足やフォーマット異常)はログを出力してスキップします。また、サーバー通信を一切行わない完全なローカル処理アーキテクチャにより、機密データの流出を防ぎます(有料APIや外部ライブラリの通信もありません)。</p>
</div>
</div>
</div>
{/* Execution Logs */}
<div className="bg-slate-900 rounded-xl shadow-sm border border-slate-800 overflow-hidden flex flex-col h-64">
<div className="bg-slate-800 px-4 py-2 border-b border-slate-700 flex items-center gap-2">
<Settings className="w-4 h-4 text-slate-400" />
<h3 className="font-mono text-sm font-semibold text-slate-300">Execution Logs</h3>
</div>
<div className="p-4 overflow-y-auto font-mono text-xs text-slate-300 flex-1 space-y-1">
{logs.length === 0 ? (
<p className="text-slate-500 italic">システムの待機中... ファイルを選択して処理を開始してください。</p>
) : (
logs.map((log, index) => (
<div key={index} className={`${log.includes('エラー') || log.includes('❌') ? 'text-red-400' : log.includes('✅') ? 'text-green-400' : ''}`}>
<span className="text-slate-500 mr-2">[{new Date().toLocaleTimeString()}]</span>
{log}
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
Total Installments (before discounts)