Last active
April 3, 2026 22:50
-
-
Save jonathanhudak/0f47b24c4d5e0f8ba5c44ebf2a6a6c40 to your computer and use it in GitHub Desktop.
PDF → Reader: Val Town HTTP val. Upload PDF, get beautiful readable HTML. Server-side extraction via pdfjs-dist.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // PDF → Reader — Val Town HTTP Val | |
| // GET / → upload UI, POST /convert → PDF extraction → HTML output | |
| // Uses pdfjs-dist with CDN-hosted CMap files to handle custom font encodings | |
| import * as pdfjsLib from "npm:pdfjs-dist@4.9.124/legacy/build/pdf.mjs"; | |
| // Point to CDN-hosted CMap files — this is the key fix for garbled/box text | |
| // These files decode custom font glyph indices back to Unicode | |
| const CMAP_URL = "https://unpkg.com/pdfjs-dist@4.9.124/cmaps/"; | |
| export default async function (req: Request): Promise<Response> { | |
| const url = new URL(req.url); | |
| if (req.method === "POST" && url.pathname === "/convert") { | |
| try { | |
| const form = await req.formData(); | |
| const file = form.get("pdf") as File; | |
| if (!file || !file.name.toLowerCase().endsWith(".pdf")) { | |
| return Response.json({ error: "No PDF uploaded" }, { status: 400 }); | |
| } | |
| const buf = new Uint8Array(await file.arrayBuffer()); | |
| const pdf = await pdfjsLib.getDocument({ | |
| data: buf, | |
| cMapUrl: CMAP_URL, | |
| cMapPacked: true, | |
| useSystemFonts: true, | |
| }).promise; | |
| const pages: { num: number; text: string }[] = []; | |
| for (let i = 1; i <= pdf.numPages; i++) { | |
| const page = await pdf.getPage(i); | |
| const content = await page.getTextContent(); | |
| let text = ""; | |
| let lastY: number | null = null; | |
| for (const item of content.items as any[]) { | |
| if (!item.str) continue; | |
| const y = Math.round(item.transform[5]); | |
| if (lastY !== null && Math.abs(y - lastY) > 3) { | |
| text += "\n"; | |
| } | |
| text += item.str; | |
| lastY = y; | |
| } | |
| pages.push({ num: i, text }); | |
| } | |
| console.log("pages:", pages.length); | |
| console.log("page 1 sample:", JSON.stringify(pages[0]?.text.slice(0, 300))); | |
| const title = file.name.replace(/\.pdf$/i, "").replace(/[-_]+/g, " "); | |
| const html = buildOutputHtml(title, pages); | |
| return new Response(html, { | |
| headers: { | |
| "Content-Type": "text/html; charset=utf-8", | |
| "Content-Disposition": `attachment; filename="${encodeURIComponent(title)}.html"`, | |
| }, | |
| }); | |
| } catch (e: any) { | |
| console.error("convert error:", e.message, e.stack); | |
| return Response.json({ error: e.message, stack: e.stack }, { status: 500 }); | |
| } | |
| } | |
| return new Response(UPLOAD_HTML, { | |
| headers: { "Content-Type": "text/html; charset=utf-8" }, | |
| }); | |
| } | |
| function esc(s: string): string { | |
| return s | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """); | |
| } | |
| function buildOutputHtml(title: string, pages: { num: number; text: string }[]): string { | |
| let body = ""; | |
| for (let p = 0; p < pages.length; p++) { | |
| const lines = pages[p].text.split("\n"); | |
| for (const line of lines) { | |
| const t = line.trim(); | |
| if (!t) continue; | |
| const short = t.length < 80; | |
| const allCaps = t === t.toUpperCase() && /[A-Z]/.test(t) && short && t.length > 3; | |
| const heading = short && !t.endsWith(".") && !t.endsWith(",") && | |
| t.length > 2 && t.length < 60 && /^[A-Z]/.test(t); | |
| if (allCaps) body += `<h2>${esc(t)}</h2>\n`; | |
| else if (heading && lines.length > 3) body += `<h3>${esc(t)}</h3>\n`; | |
| else body += `<p>${esc(t)}</p>\n`; | |
| } | |
| if (p < pages.length - 1) body += "<hr>\n"; | |
| } | |
| return `<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>${esc(title)}</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400;1,700&family=Inter:wght@400;600&display=swap'); | |
| :root{--bg:#f5f0e8;--fg:#1a1a1a;--dim:#666;--accent:#ff3333;--link:#0055aa;--border:#ccc;--surface:#ede8e0} | |
| [data-theme="dark"]{--bg:#0a0a0a;--fg:#d4d4d4;--dim:#888;--accent:#ff3333;--link:#6cb4ee;--border:#333;--surface:#141414} | |
| @media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#0a0a0a;--fg:#d4d4d4;--dim:#888;--accent:#ff3333;--link:#6cb4ee;--border:#333;--surface:#141414}} | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| html{font-size:18px;scroll-behavior:smooth} | |
| body{font-family:Charter,'Iowan Old Style','Palatino Linotype',serif;background:var(--bg);color:var(--fg);line-height:1.7;max-width:68ch;margin:0 auto;padding:2rem 1.5rem 4rem} | |
| h1,h2,h3,h4,h5,h6{font-family:Inter,system-ui,sans-serif;font-weight:600;margin:2em 0 .5em;line-height:1.3} | |
| h1{font-size:2rem;border-bottom:3px dashed var(--accent);padding-bottom:.3em} | |
| h2{font-size:1.5rem}h3{font-size:1.25rem} | |
| p{margin:.8em 0}a{color:var(--link)} | |
| hr{border:none;border-top:2px dashed var(--border);margin:2em 0} | |
| .theme-toggle{position:sticky;top:0;z-index:100;text-align:right;padding:.5rem 0;background:var(--bg)} | |
| .theme-toggle button{background:var(--surface);color:var(--fg);border:1px dashed var(--border);padding:.3em .7em;cursor:pointer;font-family:Inter,system-ui,sans-serif;font-size:.75rem;font-weight:600} | |
| .theme-toggle button:hover,.theme-toggle button.active{border-color:var(--accent);color:var(--accent)} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="theme-toggle"> | |
| <button onclick="setTheme('light')" id="tl">☀ Light</button> | |
| <button onclick="setTheme('auto')" id="ta">◐ Auto</button> | |
| <button onclick="setTheme('dark')" id="td">☾ Dark</button> | |
| </div> | |
| <h1>${esc(title)}</h1> | |
| ${body} | |
| <script> | |
| function setTheme(t){if(t==='auto')document.documentElement.removeAttribute('data-theme');else document.documentElement.setAttribute('data-theme',t);localStorage.setItem('theme',t);document.querySelectorAll('.theme-toggle button').forEach(function(b){b.classList.remove('active')});document.getElementById({light:'tl',auto:'ta',dark:'td'}[t]).classList.add('active')} | |
| (function(){var t=localStorage.getItem('theme')||'auto';setTheme(t)})(); | |
| </script> | |
| </body> | |
| </html>`; | |
| } | |
| const UPLOAD_HTML = `<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>PDF → Reader</title> | |
| <style> | |
| :root{--bg:#0a0a0a;--fg:#e0e0e0;--accent:#ff3333;--accent2:#00ff88;--border:#444;--surface:#141414;--dim:#888} | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:'SF Mono','Fira Code',ui-monospace,monospace;background:var(--bg);color:var(--fg);min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1rem} | |
| h1{font-size:2rem;font-weight:900;letter-spacing:-.03em;border-bottom:3px dashed var(--accent);padding-bottom:.3em;margin-bottom:1.5rem} | |
| h1 span{color:var(--accent2)} | |
| .drop-zone{width:min(90vw,500px);height:280px;border:3px dashed var(--border);display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;transition:border-color .2s;background:var(--surface);position:relative} | |
| .drop-zone:hover,.drop-zone.drag-over{border-color:var(--accent2)} | |
| .drop-zone p{font-size:1.1rem;color:var(--dim);text-align:center;padding:0 1rem} | |
| .drop-zone .icon{font-size:3rem;margin-bottom:.5rem} | |
| .drop-zone input{position:absolute;inset:0;opacity:0;cursor:pointer} | |
| .status{margin-top:1rem;font-size:.9rem;color:var(--accent2);min-height:1.5em;text-align:center} | |
| .status.error{color:var(--accent)} | |
| .result{margin-top:1rem;display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center} | |
| .btn{color:var(--accent2);text-decoration:none;border:1px dashed var(--accent2);font-weight:700;padding:.5em 1em;display:inline-block;background:transparent;font-family:inherit;font-size:.9rem;cursor:pointer} | |
| .btn:hover{color:var(--accent);border-color:var(--accent)} | |
| footer{position:fixed;bottom:1rem;color:var(--dim);font-size:.75rem} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>PDF <span>→</span> Reader</h1> | |
| <div class="drop-zone" id="dropZone"> | |
| <div class="icon">📄</div> | |
| <p>Drop a PDF here or click to upload</p> | |
| <input type="file" accept=".pdf,application/pdf" id="fileInput"> | |
| </div> | |
| <div class="status" id="status"></div> | |
| <div class="result" id="result"></div> | |
| <footer>drag → extract → read</footer> | |
| <script> | |
| var dz=document.getElementById('dropZone'),fi=document.getElementById('fileInput'),st=document.getElementById('status'),re=document.getElementById('result'); | |
| ['dragenter','dragover'].forEach(function(e){dz.addEventListener(e,function(ev){ev.preventDefault();dz.classList.add('drag-over')})}); | |
| ['dragleave','drop'].forEach(function(e){dz.addEventListener(e,function(ev){ev.preventDefault();dz.classList.remove('drag-over')})}); | |
| dz.addEventListener('drop',function(ev){if(ev.dataTransfer.files.length)upload(ev.dataTransfer.files[0])}); | |
| fi.addEventListener('change',function(){if(fi.files.length)upload(fi.files[0])}); | |
| function upload(file){ | |
| if(!file.name.toLowerCase().endsWith('.pdf')){st.textContent='Not a PDF.';st.className='status error';return} | |
| st.textContent='Uploading & converting…';st.className='status';re.innerHTML=''; | |
| var fd=new FormData();fd.append('pdf',file); | |
| fetch('/convert',{method:'POST',body:fd}).then(function(r){ | |
| if(!r.ok)return r.json().then(function(e){throw new Error(e.error||'Failed')}); | |
| return r.blob(); | |
| }).then(function(blob){ | |
| var url=URL.createObjectURL(blob); | |
| var name=file.name.replace(/\.pdf$/i,'.html'); | |
| st.textContent='Done!'; | |
| re.innerHTML='<a class="btn" href="'+url+'" download="'+name+'">⬇ Download</a> <a class="btn" href="'+url+'" target="_blank">↗ Preview</a>'; | |
| }).catch(function(e){st.textContent=e.message;st.className='status error'}); | |
| } | |
| </script> | |
| </body> | |
| </html>`; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment