Files
local-ocr/templates/index.html
2025-12-28 01:20:37 +08:00

570 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OCR KTP/KK - Pembaca Dokumen Indonesia</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>📄 OCR KTP/KK</h1>
<p class="subtitle">Pembaca Dokumen Indonesia Offline</p>
</header>
<main>
<!-- Upload Section -->
<section class="upload-section">
<div class="doc-type-selector">
<button class="doc-btn active" data-type="ktp">
<span class="icon">🪪</span>
KTP
</button>
<button class="doc-btn" data-type="kk">
<span class="icon">👨‍👩‍👧‍👦</span>
Kartu Keluarga
</button>
</div>
<div class="dropzone" id="dropzone">
<div class="dropzone-content">
<div class="upload-icon">📷</div>
<p>Drag & drop gambar di sini</p>
<p class="hint">atau</p>
<label class="file-btn">
Pilih File
<input type="file" id="fileInput" accept="image/*" hidden>
</label>
<p class="file-types">PNG, JPG, JPEG, BMP, WEBP (max 16MB)</p>
</div>
<img id="preview" class="preview-image" style="display: none;">
</div>
<button id="processBtn" class="process-btn" disabled>
<span class="btn-text">🔍 Proses OCR</span>
<span class="btn-loading" style="display: none;">⏳ Memproses...</span>
</button>
</section>
<!-- Results Section -->
<section class="results-section" id="resultsSection" style="display: none;">
<div class="results-header">
<h2>📋 Hasil Ekstraksi</h2>
<div class="results-actions">
<button class="action-btn" id="copyBtn" title="Copy JSON">📋 Copy</button>
<button class="action-btn" id="exportBtn" title="Export JSON">💾 Export</button>
<button class="action-btn secondary" id="toggleRaw">📝 Raw Text</button>
</div>
</div>
<div class="results-content">
<table class="results-table" id="resultsTable">
<thead>
<tr>
<th>Field</th>
<th>Nilai</th>
</tr>
</thead>
<tbody id="resultsBody">
</tbody>
</table>
<div class="raw-text-section" id="rawTextSection" style="display: none;">
<h3>Raw OCR Text</h3>
<pre id="rawText"></pre>
</div>
</div>
</section>
<!-- Error Section -->
<section class="error-section" id="errorSection" style="display: none;">
<div class="error-content">
<span class="error-icon">⚠️</span>
<p id="errorMessage"></p>
</div>
</section>
</main>
<footer>
<p>OCR menggunakan <a href="https://github.com/PaddlePaddle/PaddleOCR" target="_blank">PaddleOCR</a> • Data
diproses secara lokal</p>
</footer>
</div>
<script>
// State
let selectedFile = null;
let docType = 'ktp';
let extractedData = null;
// Elements
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const processBtn = document.getElementById('processBtn');
const resultsSection = document.getElementById('resultsSection');
const resultsBody = document.getElementById('resultsBody');
const rawText = document.getElementById('rawText');
const rawTextSection = document.getElementById('rawTextSection');
const errorSection = document.getElementById('errorSection');
const errorMessage = document.getElementById('errorMessage');
const docBtns = document.querySelectorAll('.doc-btn');
// Field labels untuk display
const fieldLabels = {
// KTP
'nik': 'NIK',
'nama': 'Nama',
'tempat_lahir': 'Tempat Lahir',
'tanggal_lahir': 'Tanggal Lahir',
'jenis_kelamin': 'Jenis Kelamin',
'gol_darah': 'Gol. Darah',
'alamat': 'Alamat',
'rt_rw': 'RT/RW',
'kel_desa': 'Kel/Desa',
'kecamatan': 'Kecamatan',
'agama': 'Agama',
'status_perkawinan': 'Status Perkawinan',
'pekerjaan': 'Pekerjaan',
'kewarganegaraan': 'Kewarganegaraan',
'berlaku_hingga': 'Berlaku Hingga',
'provinsi': 'Provinsi',
'kabupaten_kota': 'Kabupaten/Kota',
'tanggal_penerbitan': 'Tanggal Penerbitan',
// KK
'no_kk': 'No. KK',
'nama_kepala_keluarga': 'Kepala Keluarga',
'kode_pos': 'Kode Pos',
'anggota_keluarga': 'Jumlah Anggota'
};
// Doc type selection
docBtns.forEach(btn => {
btn.addEventListener('click', () => {
docBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
docType = btn.dataset.type;
});
});
// Drag & drop
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) {
handleFile(files[0]);
}
});
// File input
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
// Click on dropzone
dropzone.addEventListener('click', (e) => {
if (e.target === dropzone || e.target.closest('.dropzone-content')) {
fileInput.click();
}
});
function handleFile(file) {
if (!file.type.startsWith('image/')) {
showError('File harus berupa gambar');
return;
}
if (file.size > 16 * 1024 * 1024) {
showError('Ukuran file maksimal 16MB');
return;
}
selectedFile = file;
// Show preview
const reader = new FileReader();
reader.onload = (e) => {
preview.src = e.target.result;
preview.style.display = 'block';
dropzone.querySelector('.dropzone-content').style.display = 'none';
};
reader.readAsDataURL(file);
processBtn.disabled = false;
hideError();
resultsSection.style.display = 'none';
}
// Process button
processBtn.addEventListener('click', async () => {
if (!selectedFile) return;
const btnText = processBtn.querySelector('.btn-text');
const btnLoading = processBtn.querySelector('.btn-loading');
processBtn.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'inline';
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('doc_type', docType);
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
extractedData = result.data;
displayResults(result);
hideError();
} else {
showError(result.error);
resultsSection.style.display = 'none';
}
} catch (error) {
showError('Terjadi kesalahan: ' + error.message);
} finally {
processBtn.disabled = false;
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
}
});
// Region fields that use dropdowns - in hierarchical order
const regionFields = ['provinsi', 'kabupaten_kota', 'kecamatan', 'kel_desa'];
let regionData = {
provinces: [],
regencies: {},
districts: {},
villages: {}
};
let validationResult = null;
// Define field display order
const fieldOrder = [
// Location hierarchy first
'provinsi', 'kabupaten_kota', 'kecamatan', 'kel_desa',
// Identity
'nik', 'nama', 'tempat_lahir', 'tanggal_lahir', 'jenis_kelamin', 'gol_darah',
// Address
'alamat', 'rt_rw',
// Other info
'agama', 'status_perkawinan', 'pekerjaan', 'kewarganegaraan', 'berlaku_hingga',
// Issue date
'tanggal_penerbitan',
// KK specific
'no_kk', 'nama_kepala_keluarga', 'kode_pos', 'anggota_keluarga'
];
async function displayResults(result) {
resultsBody.innerHTML = '';
const data = result.data;
extractedData = data;
// Validate region data first
await validateRegionData(data);
// Sort keys by fieldOrder
const sortedKeys = Object.keys(data).sort((a, b) => {
const indexA = fieldOrder.indexOf(a);
const indexB = fieldOrder.indexOf(b);
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
for (const key of sortedKeys) {
const value = data[key];
if (key === 'anggota_keluarga') {
const count = Array.isArray(value) ? value.length : 0;
addResultRow('Jumlah Anggota', count + ' orang', null, false);
} else if (regionFields.includes(key)) {
// Region field with dropdown
const label = fieldLabels[key] || key;
await addRegionRow(label, value || '', key);
} else {
const label = fieldLabels[key] || key;
addResultRow(label, value || '', key, true);
}
}
rawText.textContent = result.raw_text;
resultsSection.style.display = 'block';
resultsSection.scrollIntoView({ behavior: 'smooth' });
}
async function validateRegionData(data) {
try {
const response = await fetch('/api/validate-region', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
validationResult = result.validation;
}
} catch (e) {
console.error('Validation error:', e);
}
}
async function addRegionRow(label, value, key) {
const row = document.createElement('tr');
const validation = validationResult?.[key];
const isValid = validation?.valid;
const suggestion = validation?.suggestion;
// Status indicator
const statusIcon = isValid ? '✓' : (value ? '⚠' : '');
const statusClass = isValid ? 'valid-field' : (value ? 'invalid-field' : '');
row.innerHTML = `
<td class="field-label">
${label}
<span class="validation-status ${statusClass}">${statusIcon}</span>
</td>
<td class="field-value">
<div class="region-field-wrapper">
<input type="text" class="editable-field ${statusClass}" data-key="${key}"
value="${suggestion || value || ''}" placeholder="Ketik atau pilih...">
<select class="region-dropdown" data-key="${key}" style="display: none;">
<option value="">-- Pilih --</option>
</select>
<button type="button" class="dropdown-toggle" data-key="${key}" title="Pilih dari daftar">▼</button>
</div>
${suggestion && suggestion !== value ? `<div class="suggestion-text">Saran: ${suggestion}</div>` : ''}
</td>
`;
const input = row.querySelector('input');
const select = row.querySelector('select');
const toggleBtn = row.querySelector('.dropdown-toggle');
// Input change
input.addEventListener('input', (e) => {
if (extractedData) {
extractedData[key] = e.target.value;
}
});
// Toggle dropdown
toggleBtn.addEventListener('click', async () => {
if (select.style.display === 'none') {
await loadDropdownOptions(key, select);
select.style.display = 'block';
input.style.display = 'none';
} else {
select.style.display = 'none';
input.style.display = 'block';
}
});
// Select change
select.addEventListener('change', (e) => {
const selectedOption = e.target.options[e.target.selectedIndex];
const selectedCode = selectedOption.value;
const selectedName = selectedOption.text !== '-- Pilih --' ? selectedOption.text : '';
input.value = selectedName;
if (extractedData) {
extractedData[key] = selectedName;
}
// Update validation result with selected code for cascading
if (!validationResult) validationResult = {};
validationResult[key] = {
valid: !!selectedCode,
code: selectedCode,
suggestion: selectedName
};
select.style.display = 'none';
input.style.display = 'block';
// Change toggle button to checkmark if valid selection
if (selectedCode) {
toggleBtn.textContent = '✓';
toggleBtn.classList.add('confirmed');
input.classList.remove('invalid-field');
input.classList.add('valid-field');
} else {
toggleBtn.textContent = '▼';
toggleBtn.classList.remove('confirmed');
}
// Clear dependent fields and their codes
clearDependentFields(key);
});
resultsBody.appendChild(row);
}
async function loadDropdownOptions(key, select) {
select.innerHTML = '<option value="">Loading...</option>';
try {
let data = [];
if (key === 'provinsi') {
if (!regionData.provinces.length) {
const res = await fetch('/api/provinces');
const json = await res.json();
regionData.provinces = json.data || [];
}
data = regionData.provinces;
} else if (key === 'kabupaten_kota') {
const provCode = validationResult?.provinsi?.code;
if (provCode) {
if (!regionData.regencies[provCode]) {
const res = await fetch(`/api/regencies/${provCode}`);
const json = await res.json();
regionData.regencies[provCode] = json.data || [];
}
data = regionData.regencies[provCode];
}
} else if (key === 'kecamatan') {
const regCode = validationResult?.kabupaten_kota?.code;
if (regCode) {
if (!regionData.districts[regCode]) {
const res = await fetch(`/api/districts/${regCode}`);
const json = await res.json();
regionData.districts[regCode] = json.data || [];
}
data = regionData.districts[regCode];
}
} else if (key === 'kel_desa') {
const distCode = validationResult?.kecamatan?.code;
if (distCode) {
if (!regionData.villages[distCode]) {
const res = await fetch(`/api/villages/${distCode}`);
const json = await res.json();
regionData.villages[distCode] = json.data || [];
}
data = regionData.villages[distCode];
}
}
select.innerHTML = '<option value="">-- Pilih --</option>';
data.forEach(item => {
const option = document.createElement('option');
option.value = item.code;
option.textContent = item.name;
select.appendChild(option);
});
} catch (e) {
select.innerHTML = '<option value="">Error loading data</option>';
}
}
function clearDependentFields(key) {
const dependents = {
'provinsi': ['kabupaten_kota', 'kecamatan', 'kel_desa'],
'kabupaten_kota': ['kecamatan', 'kel_desa'],
'kecamatan': ['kel_desa']
};
(dependents[key] || []).forEach(depKey => {
const input = document.querySelector(`input[data-key="${depKey}"]`);
if (input) input.value = '';
if (extractedData) extractedData[depKey] = '';
// Clear validation code for cascading
if (validationResult && validationResult[depKey]) {
validationResult[depKey] = { valid: false, code: null, suggestion: null };
}
});
}
function addResultRow(label, value, key, editable = true) {
const row = document.createElement('tr');
if (editable && key) {
row.innerHTML = `
<td class="field-label">${label}</td>
<td class="field-value">
<input type="text" class="editable-field" data-key="${key}" value="${value || ''}" placeholder="Klik untuk edit...">
</td>
`;
const input = row.querySelector('input');
input.addEventListener('input', (e) => {
if (extractedData && key) {
extractedData[key] = e.target.value;
}
});
} else {
row.innerHTML = `
<td class="field-label">${label}</td>
<td class="field-value">${value || '-'}</td>
`;
}
resultsBody.appendChild(row);
}
// Toggle raw text
document.getElementById('toggleRaw').addEventListener('click', () => {
const isVisible = rawTextSection.style.display !== 'none';
rawTextSection.style.display = isVisible ? 'none' : 'block';
});
// Copy to clipboard
document.getElementById('copyBtn').addEventListener('click', () => {
if (extractedData) {
navigator.clipboard.writeText(JSON.stringify(extractedData, null, 2))
.then(() => alert('Data berhasil disalin!'));
}
});
// Export JSON
document.getElementById('exportBtn').addEventListener('click', () => {
if (extractedData) {
const blob = new Blob([JSON.stringify(extractedData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${docType}_data.json`;
a.click();
URL.revokeObjectURL(url);
}
});
function showError(message) {
errorMessage.textContent = message;
errorSection.style.display = 'block';
}
function hideError() {
errorSection.style.display = 'none';
}
// Reset on new file selection
preview.addEventListener('click', () => {
preview.style.display = 'none';
dropzone.querySelector('.dropzone-content').style.display = 'flex';
selectedFile = null;
processBtn.disabled = true;
fileInput.value = '';
});
</script>
</body>
</html>