ClientInfoOCR / index.html
Binayak Panigrahi
Add application file
969a8a9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Card OCR Tester</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--ink: #0f0e0d;
--paper: #f5f0e8;
--cream: #ede7d9;
--accent: #d4400a;
--accent2: #1a6b4a;
--muted: #8a8070;
--border: #c8bfae;
--card-bg: #faf7f2;
--success: #1a6b4a;
--error: #c0392b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--paper);
color: var(--ink);
font-family: 'DM Mono', monospace;
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: repeating-linear-gradient(
transparent, transparent 31px,
var(--border) 31px, var(--border) 32px
);
opacity: 0.35;
pointer-events: none;
z-index: 0;
}
.page {
position: relative;
z-index: 1;
max-width: 860px;
margin: 0 auto;
padding: 48px 24px 80px;
}
header {
border-left: 5px solid var(--accent);
padding-left: 20px;
margin-bottom: 48px;
animation: slideIn 0.5s ease both;
}
header .eyebrow {
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 6px;
}
header h1 {
font-family: 'Syne', sans-serif;
font-size: clamp(28px, 5vw, 44px);
font-weight: 800;
line-height: 1.1;
color: var(--ink);
}
header h1 span { color: var(--accent); }
header p {
margin-top: 10px;
font-size: 13px;
color: var(--muted);
max-width: 480px;
line-height: 1.7;
}
/* ── API URL bar ── */
.api-bar {
display: flex;
align-items: center;
gap: 10px;
background: var(--cream);
border: 1.5px solid var(--border);
border-radius: 4px;
padding: 10px 14px;
margin-bottom: 32px;
animation: slideIn 0.5s 0.1s ease both;
}
.api-bar label {
font-size: 10px;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
}
.api-bar input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-family: 'DM Mono', monospace;
font-size: 13px;
color: var(--ink);
}
.api-bar .reset-btn {
font-size: 10px;
padding: 3px 9px;
border: 1px solid var(--border);
border-radius: 3px;
background: transparent;
cursor: pointer;
color: var(--muted);
white-space: nowrap;
transition: all 0.15s;
}
.api-bar .reset-btn:hover { border-color: var(--ink); color: var(--ink); }
/* ── Mode tabs ── */
.tabs {
display: flex;
margin-bottom: 28px;
border: 1.5px solid var(--border);
border-radius: 4px;
overflow: hidden;
width: fit-content;
animation: slideIn 0.5s 0.15s ease both;
}
.tab-btn {
padding: 9px 22px;
font-family: 'Syne', sans-serif;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--cream);
border: none;
cursor: pointer;
color: var(--muted);
transition: background 0.2s, color 0.2s;
}
.tab-btn + .tab-btn { border-left: 1.5px solid var(--border); }
.tab-btn.active { background: var(--ink); color: var(--paper); }
/* ── Upload zone ── */
.upload-section {
animation: slideIn 0.5s 0.2s ease both;
margin-bottom: 28px;
}
.drop-zone {
border: 2px dashed var(--border);
border-radius: 6px;
background: var(--card-bg);
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
position: relative;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--accent);
background: #fff9f5;
}
.drop-zone input[type=file] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.drop-icon { font-size: 36px; margin-bottom: 12px; display: block; }
.drop-zone h3 {
font-family: 'Syne', sans-serif;
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
}
.drop-zone p { font-size: 11px; color: var(--muted); letter-spacing: 0.05em; }
kbd {
display: inline-block;
font-family: 'DM Mono', monospace;
font-size: 10px;
background: var(--cream);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
color: var(--muted);
}
/* preview strip */
#preview-strip { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
.preview-thumb {
position: relative;
width: 90px; height: 68px;
border-radius: 4px;
overflow: hidden;
border: 1.5px solid var(--border);
animation: popIn 0.25s ease both;
}
.preview-thumb img { width: 100%; height: 100%; object-fit: cover; }
.preview-thumb .rm {
position: absolute;
top: 2px; right: 2px;
width: 18px; height: 18px;
background: rgba(0,0,0,0.65);
color: #fff;
border: none;
border-radius: 50%;
font-size: 10px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.preview-thumb .fname {
position: absolute;
bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,0.5);
color: #fff;
font-size: 8px;
padding: 2px 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Submit button ── */
.submit-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 32px;
background: var(--ink);
color: var(--paper);
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
animation: slideIn 0.5s 0.25s ease both;
}
.submit-btn:hover { background: var(--accent); }
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { background: var(--muted); cursor: not-allowed; }
.submit-btn .spinner {
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: none;
}
.submit-btn.loading .spinner { display: block; }
.submit-btn.loading .btn-text { opacity: 0.7; }
/* ── Status bar ── */
#status {
margin-top: 16px;
font-size: 12px;
padding: 10px 14px;
border-radius: 4px;
display: none;
}
#status.info { background: #e8f4ff; color: #1a4a7a; border-left: 3px solid #1a4a7a; }
#status.ok { background: #e6f5ee; color: var(--success); border-left: 3px solid var(--success); }
#status.err { background: #fdecea; color: var(--error); border-left: 3px solid var(--error); }
/* ── Results ── */
#results-section { margin-top: 48px; }
.results-header {
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1.5px solid var(--border);
padding-bottom: 8px;
margin-bottom: 24px;
}
.result-card {
background: var(--card-bg);
border: 1.5px solid var(--border);
border-radius: 6px;
padding: 24px;
margin-bottom: 20px;
position: relative;
animation: popIn 0.3s ease both;
}
.result-card .card-badge {
position: absolute;
top: -1px; right: 16px;
background: var(--accent);
color: #fff;
font-size: 10px;
font-family: 'Syne', sans-serif;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 10px;
border-radius: 0 0 4px 4px;
}
.result-card .company-name {
font-family: 'Syne', sans-serif;
font-size: 20px;
font-weight: 800;
margin-bottom: 4px;
}
.result-card .contact-person {
font-size: 13px;
color: var(--accent2);
font-weight: 500;
margin-bottom: 20px;
}
.result-card .contact-person span { color: var(--muted); font-weight: 400; }
.fields-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px 24px;
}
.field { display: flex; flex-direction: column; gap: 3px; }
.field .lbl { font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--muted); }
.field .val { font-size: 13px; color: var(--ink); word-break: break-word; line-height: 1.5; }
.field .val.empty { color: var(--border); font-style: italic; font-size: 11px; }
.field .val.hi { color: var(--accent); font-weight: 500; }
.copy-row {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.copy-btn {
font-family: 'DM Mono', monospace;
font-size: 10px;
padding: 5px 12px;
border: 1.5px solid var(--border);
border-radius: 3px;
background: transparent;
cursor: pointer;
color: var(--muted);
transition: all 0.15s;
}
.copy-btn:hover { border-color: var(--ink); color: var(--ink); }
.copy-btn.copied { border-color: var(--success); color: var(--success); }
@keyframes slideIn {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes popIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pasteFlash {
0% { border-color: var(--accent2); background: #edfaf4; }
100% { border-color: var(--border); background: var(--card-bg); }
}
.drop-zone.paste-flash { animation: pasteFlash 0.6s ease both; }
</style>
</head>
<body>
<div class="page">
<header>
<div class="eyebrow">NVIDIA OCR Β· NeMo Retriever + Nemotron</div>
<h1>Card &amp; Letterhead<br><span>Extractor</span></h1>
<p>Upload or paste a visiting card / letterhead image to extract company name, contacts, address, PIN &amp; GST in one click.</p>
</header>
<!-- API URL -->
<div class="api-bar">
<label>API</label>
<input type="text" id="api-url" value="/extract-card" spellcheck="false">
<button class="reset-btn" onclick="resetUrl()">Reset</button>
</div>
<!-- Mode tabs -->
<div class="tabs">
<button class="tab-btn active" onclick="setMode('single',this)">Single Card</button>
<button class="tab-btn" onclick="setMode('batch',this)">Batch (up to 10)</button>
</div>
<!-- Upload zone -->
<div class="upload-section">
<div class="drop-zone" id="drop-zone">
<input type="file" id="file-input" accept="image/jpeg,image/png,image/webp">
<span class="drop-icon">πŸͺͺ</span>
<h3>Drop, paste, or click to browse</h3>
<p>JPG · PNG · WEBP &nbsp;|&nbsp; Max ~130 KB per image &nbsp;|&nbsp; <kbd>Ctrl+V</kbd> / <kbd>⌘V</kbd> to paste</p>
</div>
<div id="preview-strip"></div>
</div>
<button class="submit-btn" id="submit-btn" onclick="doSubmit()">
<div class="spinner"></div>
<span class="btn-text">Extract Data</span>
</button>
<div id="status"></div>
<div id="results-section" style="display:none">
<div class="results-header">Extracted Results</div>
<div id="results-container"></div>
</div>
</div>
<script>
/* ── State ── */
let mode = 'single';
let files = [];
/* ── Relative base URL (works on HF Spaces and localhost alike) ── */
// Default is relative "/extract-card" so it always points to the same origin.
// User can override in the API bar for cross-origin setups.
const DEFAULT_SINGLE = '/extract-card';
const DEFAULT_BATCH = '/extract-card/batch';
function resetUrl() {
document.getElementById('api-url').value = mode === 'single' ? DEFAULT_SINGLE : DEFAULT_BATCH;
}
/* ── Mode ── */
function setMode(m, el) {
mode = m;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
document.getElementById('file-input').multiple = (m === 'batch');
document.getElementById('api-url').value = m === 'single' ? DEFAULT_SINGLE : DEFAULT_BATCH;
clearFiles();
}
/* ── Drag & drop ── */
const dz = document.getElementById('drop-zone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
dz.addEventListener('drop', e => {
e.preventDefault();
dz.classList.remove('dragover');
addFiles([...e.dataTransfer.files]);
});
document.getElementById('file-input').addEventListener('change', e => {
addFiles([...e.target.files]);
e.target.value = '';
});
/* ── Clipboard paste (Ctrl+V / ⌘V) ── */
document.addEventListener('paste', e => {
const items = [...(e.clipboardData?.items || [])];
const imgs = items.filter(i => i.type.startsWith('image/'));
if (!imgs.length) return;
e.preventDefault();
const pasted = imgs.map(i => i.getAsFile()).filter(Boolean);
addFiles(pasted);
dz.classList.remove('paste-flash');
void dz.offsetWidth;
dz.classList.add('paste-flash');
dz.addEventListener('animationend', () => dz.classList.remove('paste-flash'), { once: true });
setStatus(`βœ“ ${pasted.length} image${pasted.length > 1 ? 's' : ''} pasted from clipboard.`, 'ok');
});
/* ── File management ── */
function addFiles(newFiles) {
const imageFiles = newFiles.filter(f => f.type.startsWith('image/'));
if (!imageFiles.length) { setStatus('No image found in clipboard / selection.', 'err'); return; }
if (mode === 'single') files = imageFiles.slice(0, 1);
else files = [...files, ...imageFiles].slice(0, 10);
renderPreviews();
}
function clearFiles() { files = []; renderPreviews(); }
function removeFile(i) { files.splice(i, 1); renderPreviews(); }
function renderPreviews() {
const strip = document.getElementById('preview-strip');
strip.innerHTML = '';
files.forEach((f, i) => {
const url = URL.createObjectURL(f);
const div = document.createElement('div');
div.className = 'preview-thumb';
const name = f.name || `image-${i+1}`;
div.innerHTML = `<img src="${url}" alt="preview">
<button class="rm" onclick="removeFile(${i})" title="Remove">βœ•</button>
<div class="fname">${esc(name)}</div>`;
strip.appendChild(div);
});
}
/* ── Status ── */
function setStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = type;
el.style.display = msg ? 'block' : 'none';
}
/* ── Submit ── */
async function doSubmit() {
if (!files.length) { setStatus('Please select or paste at least one image.', 'err'); return; }
const url = document.getElementById('api-url').value.trim();
const btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.classList.add('loading');
btn.querySelector('.btn-text').textContent = 'Extracting…';
setStatus('Sending to OCR pipeline…', 'info');
document.getElementById('results-section').style.display = 'none';
document.getElementById('results-container').innerHTML = '';
try {
const fd = new FormData();
if (mode === 'single') {
fd.append('file', files[0], files[0].name || 'image.png');
} else {
files.forEach((f, i) => fd.append('files', f, f.name || `image-${i+1}.png`));
}
const res = await fetch(url, { method: 'POST', body: fd });
let data;
try { data = await res.json(); } catch { throw new Error('Server returned a non-JSON response.'); }
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
setStatus(`βœ“ Extraction complete${mode === 'batch' ? ` Β· ${files.length} card(s) processed` : ''}.`, 'ok');
renderResults(Array.isArray(data) ? data : [data]);
} catch(err) {
setStatus('Error: ' + err.message, 'err');
} finally {
btn.disabled = false;
btn.classList.remove('loading');
btn.querySelector('.btn-text').textContent = 'Extract Data';
}
}
/* ── Render results ── */
function renderResults(items) {
const container = document.getElementById('results-container');
container.innerHTML = '';
items.forEach((d, idx) => {
const card = document.createElement('div');
card.className = 'result-card';
card.innerHTML = `
<div class="card-badge">${items.length > 1 ? `Card ${idx+1}` : 'Result'}</div>
<div class="company-name">${esc(d.company_name) || '<span style="color:var(--muted);font-size:14px">Company name not found</span>'}</div>
<div class="contact-person">
${d.contact_person ? esc(d.contact_person) : '<span style="color:var(--border)">β€”</span>'}
${d.designation ? `<span> Β· ${esc(d.designation)}</span>` : ''}
</div>
<div class="fields-grid">
${fld('Mobile', d.mobile, true)}
${fld('Phone / Landline', d.phone)}
${fld('Email', d.email, true)}
${fld('Website', d.website)}
${fldFull('Address', d.address)}
${fld('PIN Code', d.pin, true)}
${fld('City', d.city)}
${fld('State', d.state)}
${fld('Country', d.country)}
${fld('GST Number', d.gst_number, true)}
${fld('Fax', d.fax)}
</div>
<div class="copy-row">
<button class="copy-btn" onclick="copyJSON(this,${idx})">Copy JSON</button>
<button class="copy-btn" onclick="copyCSV(this,${idx})">Copy CSV row</button>
</div>`;
container.appendChild(card);
});
window._results = items;
document.getElementById('results-section').style.display = 'block';
}
function fld(label, val, hi=false) {
const empty = !val || !val.trim();
return `<div class="field">
<span class="lbl">${label}</span>
<span class="val ${empty?'empty':hi?'hi':''}">${empty?'not found':esc(val)}</span>
</div>`;
}
function fldFull(label, val) {
const empty = !val || !val.trim();
return `<div class="field" style="grid-column:1/-1">
<span class="lbl">${label}</span>
<span class="val ${empty?'empty':''}">${empty?'not found':esc(val).replace(/\|/g,'<br>')}</span>
</div>`;
}
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* ── Copy helpers ── */
async function safeCopy(btn, text, label) {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for browsers that block clipboard API in non-secure contexts
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
btn.textContent = 'βœ“ Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = label; btn.classList.remove('copied'); }, 1800);
}
function copyJSON(btn, idx) {
safeCopy(btn, JSON.stringify(window._results[idx], null, 2), 'Copy JSON');
}
function copyCSV(btn, idx) {
const d = window._results[idx];
const keys = ['company_name','contact_person','designation','mobile','phone','email','address','pin','city','state','country','gst_number','website','fax'];
const row = keys.map(k => `"${String(d[k]||'').replace(/"/g,'""')}"`).join(',');
safeCopy(btn, keys.join(',') + '\n' + row, 'Copy CSV row');
}
</script>
</body>
</html>