Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Local PDF Extractor</title> | |
| <script> | |
| /* Set the theme before first paint (no flash), then wire the header toggle. | |
| Self-contained so app.js is untouched. */ | |
| (function () { | |
| var root = document.documentElement; | |
| try { | |
| var t = localStorage.getItem("theme"); | |
| if (t !== "light" && t !== "dark") t = "light"; | |
| root.setAttribute("data-theme", t); | |
| } catch (e) { | |
| root.setAttribute("data-theme", "light"); | |
| } | |
| document.addEventListener("DOMContentLoaded", function () { | |
| var btn = document.getElementById("themeToggle"); | |
| if (btn) { | |
| btn.addEventListener("click", function () { | |
| var next = root.getAttribute("data-theme") === "light" ? "dark" : "light"; | |
| root.setAttribute("data-theme", next); | |
| try { localStorage.setItem("theme", next); } catch (e) {} | |
| }); | |
| } | |
| /* Disable local-handwriting gracefully where its packages aren't | |
| installed (e.g. the lean hosted build) and point users to Gemini, | |
| instead of letting a page fail mid-extraction. */ | |
| fetch("/api/capabilities") | |
| .then(function (r) { return r.json(); }) | |
| .then(function (caps) { | |
| if (!caps) return; | |
| if (caps.handwriting === false) { | |
| var hw = document.getElementById("handwritingToggle"); | |
| if (hw) { hw.checked = false; hw.disabled = true; } | |
| var row = document.getElementById("handwritingRow"); | |
| if (row) row.classList.add("is-unavailable"); | |
| var note = document.getElementById("hwUnavailable"); | |
| if (note) note.hidden = false; | |
| } | |
| if (caps.online_demo === true) { | |
| var on = document.getElementById("onlineDemoNote"); | |
| if (on) on.hidden = false; | |
| } | |
| }) | |
| .catch(function () {}); | |
| }); | |
| })(); | |
| </script> | |
| <link rel="stylesheet" href="/static/styles.css" /> | |
| </head> | |
| <body> | |
| <header class="app-header"> | |
| <div class="brand"> | |
| <div class="brand-mark" aria-hidden="true"> | |
| <svg viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="logo"> | |
| <rect x="5" y="3" width="18" height="26" rx="3" fill="currentColor" opacity="0.15"></rect> | |
| <rect x="5" y="3" width="18" height="26" rx="3" fill="none" stroke="currentColor" stroke-width="2"></rect> | |
| <line x1="9" y1="11" x2="19" y2="11" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line> | |
| <line x1="9" y1="16" x2="19" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line> | |
| <line x1="9" y1="21" x2="15" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line> | |
| </svg> | |
| </div> | |
| <div class="brand-text"> | |
| <h1>Local PDF Extractor</h1> | |
| <p class="tagline">Extract text from typed, scanned & handwritten PDFs</p> | |
| </div> | |
| </div> | |
| <div class="header-actions"> | |
| <button type="button" class="theme-toggle" id="themeToggle" aria-label="Switch light / dark theme" title="Switch light / dark theme"> | |
| <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| <circle cx="12" cy="12" r="4"></circle> | |
| <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path> | |
| </svg> | |
| <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> | |
| </svg> | |
| </button> | |
| </div> | |
| </header> | |
| <main class="layout"> | |
| <!-- Sidebar: settings only --> | |
| <aside class="sidebar"> | |
| <section class="panel"> | |
| <h2 class="panel-title">Settings</h2> | |
| <div class="setting"> | |
| <label class="setting-label">Accuracy</label> | |
| <div class="segmented" id="modeToggle" role="radiogroup" aria-label="Accuracy"> | |
| <button type="button" class="seg active" data-mode="max" role="radio" aria-checked="true">Max accuracy</button> | |
| <button type="button" class="seg" data-mode="fast" role="radio" aria-checked="false">Faster</button> | |
| </div> | |
| <p class="hint" id="modeHint">Max accuracy is slower but renders at higher DPI and uses angle correction.</p> | |
| </div> | |
| <div class="setting"> | |
| <label class="setting-label" for="langSelect">Language</label> | |
| <select id="langSelect" class="select"> | |
| <option value="en" selected>English</option> | |
| <option value="hi">Hindi (Devanagari)</option> | |
| </select> | |
| </div> | |
| <details class="accordion"> | |
| <summary class="accordion-summary"> | |
| <span>Advanced preprocessing</span> | |
| <svg class="accordion-chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg> | |
| </summary> | |
| <div class="accordion-body"> | |
| <div class="setting setting-row"> | |
| <div> | |
| <label class="setting-label" for="preprocessToggle">Preprocessing</label> | |
| <p class="hint">Deskew, contrast-boost & denoise before OCR. Best left on.</p> | |
| </div> | |
| <label class="switch"> | |
| <input type="checkbox" id="preprocessToggle" checked /> | |
| <span class="slider" aria-hidden="true"></span> | |
| </label> | |
| </div> | |
| <div class="setting setting-row"> | |
| <div> | |
| <label class="setting-label" for="binarizeToggle">Binarize</label> | |
| <p class="hint">Force black-and-white. Helps some clean/high-contrast scans; usually leave off (grayscale is more accurate on most documents).</p> | |
| </div> | |
| <label class="switch"> | |
| <input type="checkbox" id="binarizeToggle" /> | |
| <span class="slider" aria-hidden="true"></span> | |
| </label> | |
| </div> | |
| <div class="setting setting-row"> | |
| <div> | |
| <label class="setting-label" for="forceOcrToggle">Force OCR</label> | |
| <p class="hint">Re-read every page as an image, even pages that already have selectable text. Use only when the embedded text is wrong/garbled, or text is trapped inside images. Slower; off by default.</p> | |
| </div> | |
| <label class="switch"> | |
| <input type="checkbox" id="forceOcrToggle" /> | |
| <span class="slider" aria-hidden="true"></span> | |
| </label> | |
| </div> | |
| <div class="setting setting-row"> | |
| <div> | |
| <label class="setting-label" for="removeHeadersToggle">Remove headers & footers</label> | |
| <p class="hint">Strip repeating page headers, footers & page numbers from multi-page documents (3+ pages). On by default; turn off to keep every line exactly as on the page.</p> | |
| </div> | |
| <label class="switch"> | |
| <input type="checkbox" id="removeHeadersToggle" checked /> | |
| <span class="slider" aria-hidden="true"></span> | |
| </label> | |
| </div> | |
| </div> | |
| </details> | |
| <details class="accordion"> | |
| <summary class="accordion-summary"> | |
| <span>Handwriting & online OCR</span> | |
| <svg class="accordion-chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg> | |
| </summary> | |
| <div class="accordion-body"> | |
| <div class="setting setting-row" id="handwritingRow"> | |
| <div> | |
| <label class="setting-label" for="handwritingToggle">Handwriting (local) <span class="beta-tag">beta</span></label> | |
| <p class="hint">Read handwritten pages with a local model (English, ~73% on real cursive). Much slower; first use downloads a model once. For far better handwriting, use Online Gemini below instead — you don't need both.</p> | |
| </div> | |
| <label class="switch"> | |
| <input type="checkbox" id="handwritingToggle" /> | |
| <span class="slider" aria-hidden="true"></span> | |
| </label> | |
| </div> | |
| <p class="hint hw-note" id="hwUnavailable" hidden>Local handwriting isn't installed in this build — use <strong>Online vision OCR (Gemini)</strong> below for handwriting.</p> | |
| <div class="setting setting-row"> | |
| <div> | |
| <label class="setting-label" for="onlineToggle">Online vision OCR (Gemini)</label> | |
| <p class="hint">Far higher accuracy on handwriting & hard scans, via Google Gemini. | |
| <strong>Sends your page images to Google</strong> — off by default. Needs a free API key.</p> | |
| </div> | |
| <label class="switch"> | |
| <input type="checkbox" id="onlineToggle" /> | |
| <span class="slider" aria-hidden="true"></span> | |
| </label> | |
| </div> | |
| <p class="hint hw-note" id="onlineDemoNote" hidden>This demo includes a Gemini key — just turn this on and leave the key field blank. Or paste your own key to use that instead.</p> | |
| <div class="setting setting-sub" id="onlineRow" hidden> | |
| <label class="setting-label" for="onlineKey">Gemini API key</label> | |
| <input type="password" id="onlineKey" class="select online-input" placeholder="paste key from aistudio.google.com" autocomplete="off" spellcheck="false" /> | |
| <div class="online-key-actions"> | |
| <button type="button" class="btn btn-ghost btn-sm" id="onlineCheck">Check key</button> | |
| <span class="online-status" id="onlineStatus" data-role="onlineStatus"></span> | |
| </div> | |
| <label class="setting-label" for="onlineModel" style="margin-top:8px;">Model</label> | |
| <select id="onlineModel" class="select"></select> | |
| <p class="hint">Your key is stored only in this browser and sent with each upload — never saved on the server. | |
| Pages with a real text layer are still read locally (not sent).</p> | |
| </div> | |
| </div> | |
| </details> | |
| </section> | |
| </aside> | |
| <!-- Main work area: dropzone becomes the results pane after upload --> | |
| <section class="work"> | |
| <div class="toolbar" id="toolbar" hidden> | |
| <div class="search-wrap"> | |
| <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" class="search-icon"> | |
| <circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"></circle> | |
| <line x1="16.5" y1="16.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line> | |
| </svg> | |
| <input type="search" id="searchInput" class="search-input" placeholder="Search across all extracted text…" autocomplete="off" /> | |
| <span class="search-count" id="searchCount" hidden></span> | |
| </div> | |
| <div class="toolbar-actions"> | |
| <label for="fileInput" class="btn btn-ghost add-more" title="Add more PDFs">+ Add PDF</label> | |
| <button type="button" class="btn btn-ghost" id="copyAllBtn">Copy all</button> | |
| <div class="export-group"> | |
| <button type="button" class="btn btn-ghost" data-export="txt">.txt</button> | |
| <button type="button" class="btn btn-ghost" data-export="md">.md</button> | |
| <button type="button" class="btn btn-ghost" data-export="docx">.docx</button> | |
| <button type="button" class="btn btn-ghost" data-export="json">.json</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Empty state == the single central dropzone + auto-detect --> | |
| <div class="empty-state" id="emptyState"> | |
| <section | |
| class="dropzone" | |
| id="dropzone" | |
| tabindex="0" | |
| role="button" | |
| aria-label="Drop PDF files here or click to browse"> | |
| <div class="dz-icon-wrap"> | |
| <svg viewBox="0 0 24 24" width="34" height="34" aria-hidden="true" class="dz-icon"> | |
| <path d="M12 16V4m0 0l-4 4m4-4l4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> | |
| <path d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> | |
| </svg> | |
| </div> | |
| <p class="dz-primary">Drop your PDF here</p> | |
| <p class="dz-secondary">or <span class="dz-link">browse files</span> · multiple files supported</p> | |
| </section> | |
| <div class="dz-extra"> | |
| <button type="button" class="btn btn-ghost" id="suggestBtn">Auto-detect best settings</button> | |
| <p class="hint dz-extra-hint">Samples 1–2 pages of a file and recommends settings · can take up to a minute on the CPU · nothing is applied until you click Apply.</p> | |
| <div class="setting" id="suggestResult" hidden> | |
| <div class="suggest-card"> | |
| <p class="suggest-rationale" data-role="suggestRationale"></p> | |
| <div class="suggest-evidence" data-role="suggestEvidence"></div> | |
| <button type="button" class="btn btn-primary btn-sm" id="suggestApply">Apply recommended</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- File pickers live here (always present, hidden) so the dropzone and the | |
| toolbar "Add PDF" label can both trigger them regardless of view state. --> | |
| <input type="file" id="fileInput" accept="application/pdf,.pdf" multiple hidden /> | |
| <input type="file" id="suggestInput" accept="application/pdf,.pdf" hidden /> | |
| <div id="filesContainer" class="files-container"></div> | |
| </section> | |
| </main> | |
| <footer class="app-footer"> | |
| <details class="footer-tips"> | |
| <summary> | |
| <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4M12 8h.01"></path></svg> | |
| Good to know | |
| </summary> | |
| <ul class="footer-tips-list"> | |
| <li>Typed / born-digital pages are extracted instantly and exactly — no OCR.</li> | |
| <li>OCR accuracy depends on scan quality; handwriting is the hardest.</li> | |
| <li>For the best handwriting, use Online Gemini; everything else runs on your machine.</li> | |
| <li>The first local-OCR run downloads model weights once, then caches them.</li> | |
| </ul> | |
| </details> | |
| </footer> | |
| <!-- Templates --> | |
| <template id="fileCardTemplate"> | |
| <article class="file-card"> | |
| <div class="file-head"> | |
| <div class="file-meta"> | |
| <span class="status-dot" data-role="statusDot"></span> | |
| <h3 class="file-name" data-role="fileName"></h3> | |
| </div> | |
| <div class="file-head-right"> | |
| <span class="status-text" data-role="statusText">Pending</span> | |
| <button type="button" class="btn btn-danger btn-sm" data-role="cancelBtn">Cancel</button> | |
| <button type="button" class="btn btn-ghost btn-sm btn-remove" data-role="removeBtn" title="Remove this file" aria-label="Remove this file">×</button> | |
| </div> | |
| </div> | |
| <div class="progress-row"> | |
| <div class="progress-track"><div class="progress-fill" data-role="progressFill"></div></div> | |
| <span class="progress-label" data-role="progressLabel">0 / 0</span> | |
| </div> | |
| <div class="file-error" data-role="fileError" hidden></div> | |
| <details class="kg-panel" data-role="kgPanel" hidden> | |
| <summary class="kg-summary"> | |
| <svg class="kg-summary-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | |
| <circle cx="5" cy="6" r="2.4"></circle><circle cx="19" cy="6" r="2.4"></circle><circle cx="12" cy="18" r="2.4"></circle> | |
| <line x1="6.9" y1="7.4" x2="10.4" y2="16.2"></line><line x1="17.1" y1="7.4" x2="13.6" y2="16.2"></line><line x1="7" y1="6" x2="17" y2="6"></line> | |
| </svg> | |
| <span>Knowledge graph & search</span> | |
| <span class="kg-summary-status" data-role="kgStatus"></span> | |
| <svg class="accordion-chevron kg-chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg> | |
| </summary> | |
| <div class="kg-body"> | |
| <p class="hint kg-intro">Turn this document into a searchable graph of its entities & facts, then ask questions and get answers traced back to the exact page. Uses <strong>Google Gemini</strong> (online — same key as Online OCR).</p> | |
| <div class="kg-build-row"> | |
| <button type="button" class="btn btn-primary btn-sm" data-role="kgBuildBtn">Build knowledge graph</button> | |
| <span class="kg-build-note hint" data-role="kgBuildNote"></span> | |
| </div> | |
| <div class="kg-query-wrap" data-role="kgQueryWrap" hidden> | |
| <div class="kg-ask"> | |
| <input type="search" class="kg-query-input" data-role="kgQueryInput" placeholder="Ask about this document…" autocomplete="off" /> | |
| <button type="button" class="btn btn-primary btn-sm" data-role="kgAskBtn">Ask</button> | |
| </div> | |
| <div class="kg-results" data-role="kgResults"></div> | |
| <div class="kg-tools"> | |
| <button type="button" class="btn btn-ghost btn-sm" data-role="kgVizToggle">Show graph view</button> | |
| <button type="button" class="btn btn-ghost btn-sm" data-role="kgExportBtn">Export graph (.json)</button> | |
| <button type="button" class="btn btn-ghost btn-sm" data-role="kgRebuildBtn">Rebuild</button> | |
| </div> | |
| <div class="kg-viz-wrap" data-role="kgVizWrap" hidden> | |
| <canvas class="kg-canvas" data-role="kgCanvas" width="640" height="400"></canvas> | |
| <p class="hint kg-viz-note" data-role="kgVizNote"></p> | |
| </div> | |
| </div> | |
| </div> | |
| </details> | |
| <div class="pages" data-role="pages"></div> | |
| </article> | |
| </template> | |
| <template id="pageCardTemplate"> | |
| <div class="page-card" data-role="pageCard"> | |
| <div class="page-head"> | |
| <span class="page-num" data-role="pageNum"></span> | |
| <span class="badge" data-role="badge"></span> | |
| <span class="to-check" data-role="toCheck" hidden></span> | |
| <span class="page-spacer"></span> | |
| <button type="button" class="btn btn-ghost btn-sm" data-role="toggleImg">Show original page</button> | |
| <button type="button" class="btn btn-ghost btn-sm" data-role="editBtn">Edit</button> | |
| <button type="button" class="btn btn-ghost btn-sm" data-role="copyPage">Copy</button> | |
| </div> | |
| <pre class="page-text" data-role="pageText"></pre> | |
| <textarea class="page-edit" data-role="pageEdit" spellcheck="false" hidden></textarea> | |
| <div class="page-image-wrap" data-role="imageWrap" hidden></div> | |
| </div> | |
| </template> | |
| <div class="toast" id="toast" role="status" aria-live="polite" hidden></div> | |
| <script src="/static/app.js"></script> | |
| </body> | |
| </html> | |