Skip to content

Instantly share code, notes, and snippets.

@jonathanhudak
Last active April 3, 2026 22:50
Show Gist options
  • Select an option

  • Save jonathanhudak/0f47b24c4d5e0f8ba5c44ebf2a6a6c40 to your computer and use it in GitHub Desktop.

Select an option

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.
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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