Spaces:
Sleeping
Sleeping
| <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 & Letterhead<br><span>Extractor</span></h1> | |
| <p>Upload or paste a visiting card / letterhead image to extract company name, contacts, address, PIN & 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 | Max ~130 KB per image | <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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| /* ββ 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> | |