TheTechHacker PDF Converter — Free Online PDF Tools That Work Offline in Your Browser

Every PDF job, done right in your browser.

Merge, split, compress, redact, impose booklets and convert between PDF, Word, Excel, Markdown, images, HTML and plain text. Nothing is uploaded to a server, so private documents stay private.

Files never leave your device 32 tools No file size or count limit No sign-up, no watermark

Organise pages

Change what is in the file and what order it comes in

Convert to PDF

Photos, scans, Word documents and text become one PDF

Convert from PDF

Pull pages, text or tables into a format you can edit

Edit a PDF

Stamp, number and label pages without a design app

Advanced tools

Print shop and archive jobs most free PDF sites do not offer

Drop files here or click to choose from your device

    Ready to download

    Frequently asked questions

    How TheTechHacker PDF Converter works, what it can do, and where its limits are.

    Is TheTechHacker PDF Converter really free, and do I need an account?

    Yes, every one of the 32 tools is free and there is no account, no email capture and no trial period. There are no watermarks stamped on output files, no daily conversion cap and no premium tier holding back features. The entire application is a single HTML page, so you can save it to your computer and keep using it after you close the tab.

    Are my PDF files uploaded to a server?

    No. Unlike most online PDF converters, which upload your document, process it on their infrastructure and delete it some hours later, TheTechHacker PDF Converter runs entirely inside your browser using JavaScript. Your file is read from disk into memory on your own machine, processed there, and handed straight back to you as a download. Nothing is transmitted, which makes it safe for contracts, medical records, financial statements and anything else covered by confidentiality rules.

    Does it work offline?

    Almost. The page loads three small libraries from a public CDN when it first opens, so the initial load needs a connection. Once the page is open you can disconnect completely and every tool keeps working. If you want a fully offline copy, download the libraries and change the four script tags to point at your local files.

    Is there a file size limit?

    There is no limit imposed by the tool itself. The practical ceiling is your device's available memory, since the whole document is held in RAM while it is processed. On a typical laptop, documents of a few hundred megabytes are fine. Very large scanned files processed at 300 dpi are the most memory-hungry operation, so drop the resolution if a big job stalls.

    Can it open password-protected PDFs?

    Files with an owner password that only restricts printing or copying will usually open. Files encrypted with a user password, where the password is required to view the document at all, cannot be opened, and removing a password is not something a browser can do. Unlock the file in a desktop PDF reader first, then bring it here.

    How good is the PDF to Word conversion?

    It extracts the text, detects headings by font size and produces a document that Word, Pages and Google Docs open and edit normally. It does not attempt to rebuild the original page layout, columns, tables or images, because that needs a full layout engine. Think of it as recovering the words so you can reuse them, not as producing a pixel-identical copy.

    Why does a converted file come back empty?

    The PDF is almost certainly a scan, meaning the pages are photographs of text rather than real text. No text-based tool can read those without optical character recognition, which this tool does not include. Run the file through OCR software first, or use PDF to images if you only need the pictures.

    What does the redact tool actually remove?

    It genuinely removes the words rather than hiding them. Each page containing a match is covered with a black box and then flattened to an image, so the underlying text layer is destroyed and cannot be recovered by copying, searching or opening the file in an editor. This is the correct way to redact, unlike drawing a rectangle over text in a PDF editor.

    Which browsers are supported?

    Any current version of Chrome, Edge, Firefox, Safari, Brave or Opera on Windows, macOS, Linux, Android or iOS. It works on phones, although large files are limited by mobile memory. No extension or plugin is needed.

    Will non-English text work?

    Reading and converting works for any language the PDF contains. Tools that draw new text onto a page, namely watermarks and page numbers, use the built-in PDF fonts, which cover English and Western European characters only. Text in Telugu, Hindi, Arabic, Chinese or Japanese is stripped from watermarks rather than rendered incorrectly.

    "; res.push({ name: stem(files[f].name)+".doc", blob:new Blob(["\ufeff"+html],{type:"application/msword"}) }); } return res; } }, pdf2excel:{ group:"from", icon:"sheet", name:"PDF to Excel", blurb:"Tabular pages become a CSV that opens straight in Excel.", lede:"Columns are detected from the gaps between words, which works well on real tables and poorly on prose.", accept:".pdf", multiple:true, opts:[{k:"gap", label:"Column gap sensitivity", type:"select", options:[["8","Tight — more columns"],["14","Balanced"],["24","Loose — fewer columns"]], value:"14"}], run: async (opts, tick) => { const res = [], gap = +opts.gap; for(let f=0;f gap){ cells.push(cur.trim()); cur = ""; } else if(prev && (it.x - (prev.x + prev.w)) > prev.h*0.22) cur += " "; cur += it.str; prev = it; } if(cur.trim()) cells.push(cur.trim()); if(cells.length) lines.push(cells.map(c=>'"'+c.replace(/"/g,'""')+'"').join(",")); } tick((f + n/pdf.numPages)/files.length); } if(!lines.length) throw new Error("No text found in “"+files[f].name+"”. Scanned pages need OCR before a table can be read."); res.push({ name: stem(files[f].name)+".csv", blob:new Blob(["\ufeff"+lines.join("\n")],{type:"text/csv;charset=utf-8"}) }); } return res; } }, pdf2text:{ group:"from", icon:"text", name:"PDF to text", blurb:"Plain .txt with the page breaks kept.", lede:"The quickest way to get the words out for search, notes or another tool.", accept:".pdf", multiple:true, run: async (opts, tick) => { const res = []; for(let f=0;f { const res = []; for(let f=0;f'+esc(it.str)+""; } pages += '
    '+spans+"
    \n"; tick((f + n/pdf.numPages)/files.length); } const doc = ''+esc(stem(files[f].name))+""+pages+""; res.push({ name: stem(files[f].name)+".html", blob:new Blob([doc],{type:"text/html;charset=utf-8"}) }); } return res; } }, extractimg:{ group:"from", icon:"camera", name:"Extract images", blurb:"Pull out photos embedded in a PDF, not screenshots of pages.", lede:"Finds the original bitmaps the document was built from. A PDF made purely of text returns nothing.", accept:".pdf", multiple:true, run: async (opts, tick) => { const res = []; for(let f=0;f{ try{ page.objs.get(name, r); }catch(e){ r(null); } }), new Promise(r=>setTimeout(()=>r(null), 4000)) ]); } if(!img || !img.width || !img.height) continue; if(img.width < 16 || img.height < 16) continue; const c = document.createElement("canvas"); c.width = img.width; c.height = img.height; const ctx = c.getContext("2d"); if(img.bitmap) ctx.drawImage(img.bitmap, 0, 0); else if(img.data){ const id = ctx.createImageData(img.width, img.height); const src = img.data, dst = id.data; if(src.length === img.width*img.height*3){ for(let p=0,q=0;p= dst.length){ dst.set(src.subarray(0, dst.length)); } else continue; ctx.putImageData(id, 0, 0); } else continue; count++; res.push({ name: base+" image "+String(count).padStart(2,"0")+".png", blob: await canvasBlob(c,"image/png") }); } tick((f + n/pdf.numPages)/files.length); } } if(!res.length) throw new Error("No embedded images found. For pictures of the pages themselves, use PDF to images instead."); return res; } }, watermark:{ group:"edit", icon:"stamp", name:"Add a watermark", blurb:"Stamp text diagonally across every page.", lede:"Draft marks, confidentiality notices, client names. English and Western European characters only.", accept:".pdf", multiple:true, opts:[ {k:"text", label:"Watermark text", type:"text", value:"DRAFT"}, {k:"size", label:"Size (points)", type:"number", value:64}, {k:"opacity", label:"Opacity", type:"select", options:[["0.08","Very faint"],["0.15","Faint"],["0.3","Medium"],["0.5","Strong"]], value:"0.15"}, {k:"colour", label:"Colour", type:"select", options:[["grey","Grey"],["red","Red"],["violet","Violet"]], value:"grey"}, {k:"tile", label:"Repeat across the page", type:"check", value:false} ], run: async (opts, tick) => { const text = latin(opts.text).trim(); if(!text) throw new Error("Enter the text you want stamped on the pages."); const colour = { grey:rgb(.42,.45,.55), red:rgb(.85,.19,.32), violet:rgb(.36,.24,.96) }[opts.colour]; const res = []; for(let f=0;fpage.drawText(text,{ x, y, size:s, font, color:colour, opacity:+opts.opacity, rotate:degrees(38) }); if(opts.tile){ const small = size*0.55; const sw = Math.max(30, font.widthOfTextAtSize(text, small)); for(let y=-height*0.2; y { const res = []; for(let f=0;f{ if(opts.skip && i === 0) return; const num = start + i - (opts.skip ? 1 : 0); const label = { n:String(num), nt:num+" / "+total, pn:"Page "+num, pnt:"Page "+num+" of "+total }[opts.style]; const { width, height } = page.getSize(); const w = font.widthOfTextAtSize(label, size), m = 28; const x = opts.pos.endsWith("c") ? (width-w)/2 : opts.pos.endsWith("r") ? width-w-m : m; const y = opts.pos.startsWith("t") ? height-m-size*0.4 : m; page.drawText(label,{ x, y, size, font, color:rgb(.12,.14,.22) }); }); res.push({ name: stem(files[f].name)+" (numbered).pdf", blob:new Blob([await doc.save()],{type:"application/pdf"}) }); tick((f+1)/files.length); } return res; } }, metadata:{ group:"edit", icon:"tag", name:"Edit document details", blurb:"Change the title, author and keywords stored in the file.", lede:"Load a PDF and its current details appear below, ready to change.", accept:".pdf", multiple:false, readMeta:true, opts:[ {k:"title", label:"Title", type:"text", value:""}, {k:"author", label:"Author", type:"text", value:""}, {k:"subject", label:"Subject", type:"text", value:""}, {k:"keywords", label:"Keywords", type:"text", value:"", hint:"Separate with commas"} ], run: async (opts) => { const doc = await loadPdfLib(files[0]); doc.setTitle(opts.title || ""); doc.setAuthor(opts.author || ""); doc.setSubject(opts.subject || ""); doc.setKeywords((opts.keywords||"").split(",").map(s=>s.trim()).filter(Boolean)); doc.setProducer("TheTechHacker PDF Converter"); doc.setModificationDate(new Date()); return [{ name: stem(files[0].name)+" (updated).pdf", blob:new Blob([await doc.save()],{type:"application/pdf"}) }]; } } }; /* ---------- shared helpers for the advanced tools ---------- */ const A4 = { w:595.28, h:841.89 }; function inkRatio(canvas, threshold){ const ctx = canvas.getContext("2d"); const d = ctx.getImageData(0,0,canvas.width,canvas.height).data; let ink = 0; for(let i=0;i r) r = x; if(y < t) t = y; if(y > b) b = y; } } return r < 0 ? null : { l, t, r, b }; } async function pageToJpeg(doc, canvas, quality){ const blob = await canvasBlob(canvas, "image/jpeg", quality); return doc.embedJpg(await blob.arrayBuffer()); } function lineDiff(a, b){ const n = a.length, m = b.length; if(n*m > 4000000) return null; // too big for the LCS table const dp = new Uint32Array((n+1)*(m+1)); for(let i=n-1;i>=0;i--) for(let j=m-1;j>=0;j--) dp[i*(m+1)+j] = a[i] === b[j] ? dp[(i+1)*(m+1)+j+1]+1 : Math.max(dp[(i+1)*(m+1)+j], dp[i*(m+1)+j+1]); const out = []; let i = 0, j = 0; while(i < n && j < m){ if(a[i] === b[j]){ out.push(["same", a[i]]); i++; j++; } else if(dp[(i+1)*(m+1)+j] >= dp[i*(m+1)+j+1]){ out.push(["del", a[i++]]); } else out.push(["add", b[j++]]); } while(i < n) out.push(["del", a[i++]]); while(j < m) out.push(["add", b[j++]]); return out; } async function allLines(file){ const pdf = await loadPdfJs(file); const lines = []; for(let n=1;n<=pdf.numPages;n++){ const { rows } = await textRows(await pdf.getPage(n)); rows.map(rowText).filter(Boolean).forEach(t=>lines.push(t)); } return lines; } /* ---------- organise ---------- */ TOOLS.reverse = { group:"organise", icon:"rotate", name:"Reverse page order", blurb:"Flip the document back to front.", lede:"Handy when a scanner fed the stack the wrong way round.", accept:".pdf", multiple:true, run: async (opts, tick) => { const res = []; for(let f=0;fdoc.addPage(p)); res.push({ name: stem(files[f].name)+" (reversed).pdf", blob:new Blob([await doc.save()],{type:"application/pdf"}) }); tick((f+1)/files.length); } return res; } }; TOOLS.chunks = { group:"organise", icon:"split", name:"Split into chunks", blurb:"Break a long file into parts of a fixed page count.", lede:"Turns a 300 page report into a set of 20 page pieces, delivered as a zip.", accept:".pdf", multiple:false, opts:[{k:"size", label:"Pages per file", type:"number", value:10}], run: async (opts, tick) => { const per = Math.max(1, Math.floor(+opts.size || 10)); const src = await loadPdfLib(files[0]); const total = src.getPageCount(), base = stem(files[0].name), res = []; if(per >= total) throw new Error("That file only has "+total+" pages, so there is nothing to split."); for(let start=0; startdoc.addPage(p)); res.push({ name: base+" part "+String(res.length+1).padStart(2,"0")+".pdf", blob:new Blob([await doc.save()],{type:"application/pdf"}) }); tick((start+per)/total); } return res; } }; TOOLS.interleave = { group:"organise", icon:"layers", name:"Alternate & merge", blurb:"Weave two files together, one page from each in turn.", lede:"For a duplex scanner that produced one file of fronts and another of backs. Add the fronts first, then the backs.", accept:".pdf", multiple:true, min:2, opts:[{k:"rev", label:"Second file is in reverse order", type:"check", value:false}], run: async (opts, tick) => { if(files.length !== 2) throw new Error("This tool takes exactly two files — fronts first, then backs."); const a = await loadPdfLib(files[0]), b = await loadPdfLib(files[1]); const out = await PDFDocument.create(); const ia = a.getPageIndices(); const ib = opts.rev ? b.getPageIndices().reverse() : b.getPageIndices(); const pa = await out.copyPages(a, ia), pb = await out.copyPages(b, ib); const max = Math.max(pa.length, pb.length); for(let i=0;i { const res = []; for(let f=0;fr.h).sort((a,b)=>a-b); const median = sizes[Math.floor(sizes.length/2)] || 10; let para = ""; const flush = ()=>{ if(para){ md += para.trim()+"\n\n"; para = ""; } }; for(const row of rows){ const t = rowText(row); if(!t) continue; if(row.h > median*1.6){ flush(); md += "## "+t+"\n\n"; continue; } if(row.h > median*1.25){ flush(); md += "### "+t+"\n\n"; continue; } if(/^[\u2022\u2023\u25cf\u25aa*\-\u2013]\s+/.test(t)){ flush(); md += "- "+t.replace(/^\S+\s+/,"")+"\n"; continue; } para = para ? para+" "+t : t; if(/[.!?:;]["')\]]?$/.test(t)) flush(); } flush(); if(n < pdf.numPages) md += "---\n\n"; tick((f + n/pdf.numPages)/files.length); } if(md.trim().split("\n").length < 3) throw new Error("No text found in that PDF — it is probably a scan and needs OCR first."); res.push({ name: stem(files[f].name)+".md", blob:new Blob([md],{type:"text/markdown;charset=utf-8"}) }); } return res; } }; /* ---------- advanced ---------- */ TOOLS.nup = { group:"advanced", icon:"columns", name:"N-up (pages per sheet)", blurb:"Print 2, 4, 6 or 9 pages on every sheet of paper.", lede:"Shrinks a long document down for handouts and saves paper. The original pages are placed on a grid, reading left to right.", accept:".pdf", multiple:false, opts:[ {k:"per", label:"Pages per sheet", type:"select", options:[["2","2 up"],["4","4 up"],["6","6 up"],["9","9 up"]], value:"2"}, {k:"gap", label:"Gap between pages (points)", type:"number", value:10} ], run: async (opts, tick) => { const layout = { "2":[2,1], "4":[2,2], "6":[3,2], "9":[3,3] }[opts.per]; const [cols, rows] = layout; const per = cols*rows, gap = Math.max(0, +opts.gap || 0); const src = await loadPdfLib(files[0]); const pages = src.getPages(); const out = await PDFDocument.create(); const emb = await out.embedPages(pages); const first = pages[0].getSize(); const landscape = cols > rows; const sheetW = landscape ? Math.max(first.width, first.height) : Math.min(first.width, first.height); const sheetH = landscape ? Math.min(first.width, first.height) : Math.max(first.width, first.height); for(let i=0;i { const gap = Math.max(0, +opts.gap || 0); const src = await loadPdfLib(files[0]); const pages = src.getPages(); const out = await PDFDocument.create(); const emb = await out.embedPages(pages); const n = emb.length; const padded = Math.ceil(n/4)*4; const first = pages[0].getSize(); const sheetW = Math.max(first.width, first.height); const sheetH = Math.min(first.width, first.height); const order = []; for(let i=0;i{ const sheet = out.addPage([sheetW, sheetH]); [[left,0],[right,1]].forEach(([num,slot])=>{ const e = emb[num-1]; if(!e) return; // padding page, left blank const cw = sheetW/2 - gap; const scale = Math.min(cw/e.width, sheetH/e.height); const w = e.width*scale, h = e.height*scale; const x = slot*(sheetW/2) + gap/2 + (cw-w)/2; sheet.drawPage(e, { x, y:(sheetH-h)/2, xScale:scale, yScale:scale }); }); tick((si+1)/order.length); }); return [{ name: stem(files[0].name)+" (booklet).pdf", blob:new Blob([await out.save()],{type:"application/pdf"}) }]; } }; TOOLS.margins = { group:"advanced", icon:"ruler", name:"Resize & add margins", blurb:"Fit pages to A4 or Letter, or add white space for notes.", lede:"Rescales every page onto a consistent sheet size. Useful for mixed-size scans, and for adding a binding margin before printing.", accept:".pdf", multiple:true, opts:[ {k:"size", label:"Target sheet", type:"select", options:[["keep","Keep current size"],["a4","A4"],["letter","US Letter"]], value:"a4"}, {k:"margin", label:"Margin (points)", type:"number", value:36, hint:"72 points = 1 inch"}, {k:"side", label:"Extra margin on", type:"select", options:[["none","No extra"],["left","Left edge (binding)"],["top","Top edge"]], value:"none"}, {k:"extra", label:"Extra amount (points)", type:"number", value:36} ], run: async (opts, tick) => { const m = Math.max(0, +opts.margin || 0), extra = Math.max(0, +opts.extra || 0); const res = []; for(let f=0;f{ let pw, ph; if(opts.size === "a4"){ pw = A4.w; ph = A4.h; } else if(opts.size === "letter"){ pw = 612; ph = 792; } else { pw = e.width; ph = e.height; } if(opts.size !== "keep" && e.width > e.height){ const t = pw; pw = ph; ph = t; } const left = m + (opts.side === "left" ? extra : 0); const top = m + (opts.side === "top" ? extra : 0); const boxW = pw - left - m, boxH = ph - top - m; const scale = Math.min(boxW/e.width, boxH/e.height); const w = e.width*scale, h = e.height*scale; out.addPage([pw, ph]).drawPage(e, { x: left + (boxW-w)/2, y: m + (boxH-h)/2, xScale:scale, yScale:scale }); }); res.push({ name: stem(files[f].name)+" (resized).pdf", blob:new Blob([await out.save()],{type:"application/pdf"}) }); tick((f+1)/files.length); } return res; } }; TOOLS.autocrop = { group:"advanced", icon:"crop", name:"Auto-crop white margins", blurb:"Trim the empty border so text fills more of the screen.", lede:"Detects where the ink actually starts on each page and sets the crop box just outside it. Makes academic papers far more readable on a tablet or e-reader.", accept:".pdf", multiple:true, opts:[ {k:"pad", label:"Padding to keep (points)", type:"number", value:8}, {k:"uniform", label:"Use one crop for the whole document", type:"check", value:true} ], run: async (opts, tick) => { const pad = Math.max(0, +opts.pad || 0); const res = []; for(let f=0;f{ shared = shared ? { l:Math.min(shared.l,b.l), t:Math.min(shared.t,b.t), r:Math.max(shared.r,b.r), b:Math.max(shared.b,b.b) } : { ...b }; }); } libPages.forEach((page,i)=>{ const box = opts.uniform ? shared : boxes[i]; if(!box){ skipped++; return; } if(page.getRotation().angle % 180 !== 0){ skipped++; return; } const mb = page.getMediaBox(); const l = Math.max(0, box.l - pad), t = Math.max(0, box.t - pad); const r = Math.min(mb.width, box.r + pad), b = Math.min(mb.height, box.b + pad); if(r - l < 20 || b - t < 20){ skipped++; return; } page.setCropBox(mb.x + l, mb.y + (mb.height - b), r - l, b - t); cropped++; }); if(!cropped) throw new Error("Nothing could be cropped — the pages are either blank or rotated."); res.push({ name: stem(files[f].name)+" (cropped).pdf", blob:new Blob([await doc.save()],{type:"application/pdf"}), note: cropped+" page"+(cropped>1?"s":"")+" cropped"+(skipped?", "+skipped+" left alone":"") }); } return res; } }; TOOLS.removeblank = { group:"advanced", icon:"eraser", name:"Remove blank pages", blurb:"Drop the empty sheets a scanner produced.", lede:"Measures how much ink is on each page and removes the ones below the threshold. Raise the sensitivity if pages with faint specks are being kept.", accept:".pdf", multiple:true, opts:[{k:"level", label:"Sensitivity", type:"select", options:[["0.0005","Strict — only perfectly blank"],["0.002","Balanced"],["0.008","Loose — drops near-empty pages"]], value:"0.002"}], run: async (opts, tick) => { const limit = +opts.level, res = []; for(let f=0;f limit) keep.push(n-1); tick((f + n/pdf.numPages)/files.length); } const removed = pdf.numPages - keep.length; if(!keep.length) throw new Error("Every page in that file looks blank — lower the sensitivity."); if(!removed) throw new Error("No blank pages were found in "+files[f].name+"."); const doc = await PDFDocument.create(); (await doc.copyPages(src, keep)).forEach(p=>doc.addPage(p)); res.push({ name: stem(files[f].name)+" (cleaned).pdf", blob:new Blob([await doc.save()],{type:"application/pdf"}), note: removed+" blank page"+(removed>1?"s":"")+" removed" }); } return res; } }; TOOLS.redact = { group:"advanced", icon:"marker", name:"Redact words", blurb:"Black out terms and destroy the text underneath.", lede:"Finds every occurrence of the words you list, covers them, then flattens the page to an image so the text cannot be recovered by copying or searching. Real redaction, not a black rectangle over live text.", accept:".pdf", multiple:true, opts:[ {k:"terms", label:"Words or phrases to remove", type:"text", value:"", hint:"Separate with commas, for example: John Smith, salary, 555-0134"}, {k:"dpi", label:"Output resolution", type:"select", options:[["120","120 dpi — smaller file"],["150","150 dpi"],["200","200 dpi — sharper"]], value:"150"}, {k:"only", label:"Keep only the pages that had a match", type:"check", value:false} ], run: async (opts, tick) => { const terms = (opts.terms||"").split(",").map(s=>s.trim().toLowerCase()).filter(Boolean); if(!terms.length) throw new Error("List at least one word or phrase to redact."); const scale = (+opts.dpi)/72, res = []; for(let f=0;f{ const x = b.x*scale - 2, w = b.w*scale + 4; const h = b.h*scale*1.3; const y = vpH - (b.y*scale) - h*0.78; ctx.fillRect(x, y, w, h); }); const base = page.getViewport({ scale:1 }); const img = await pageToJpeg(out, canvas, 0.85); out.addPage([base.width, base.height]).drawImage(img,{ x:0, y:0, width:base.width, height:base.height }); kept++; tick((f + n/pdf.numPages)/files.length); } if(!kept) throw new Error("None of those terms appear in "+files[f].name+"."); res.push({ name: stem(files[f].name)+" (redacted).pdf", blob:new Blob([await out.save()],{type:"application/pdf"}), note: hits+" match"+(hits===1?"":"es")+" removed" }); } return res; } }; TOOLS.grayscale = { group:"advanced", icon:"contrast", name:"Convert to grayscale", blurb:"Strip the colour before sending a file to print.", lede:"Stops a colour printer billing you colour rates for a document with one blue logo on it. Pages are flattened to grey images, so text is no longer selectable.", accept:".pdf", multiple:true, opts:[{k:"dpi", label:"Resolution", type:"select", options:[["110","110 dpi — smaller"],["150","150 dpi"],["200","200 dpi — sharper"]], value:"150"}], run: async (opts, tick) => { const scale = (+opts.dpi)/72, res = []; for(let f=0;f { const cols = +opts.cols, res = []; for(let f=0;f { const res = []; for(let f=0;f { if(files.length !== 2) throw new Error("This tool takes exactly two files — the old version first, then the new one."); const a = await allLines(files[0]); tick(0.35); const b = await allLines(files[1]); tick(0.7); if(!a.length && !b.length) throw new Error("Neither file contains readable text — scans need OCR first."); const diff = lineDiff(a, b); if(!diff) throw new Error("Those documents are too long to compare line by line. Split them into smaller parts first."); let adds = 0, dels = 0, html = ""; diff.forEach(([kind,text])=>{ if(kind === "add") adds++; if(kind === "del") dels++; if(opts.only && kind === "same") return; const cls = kind === "add" ? "a" : kind === "del" ? "d" : "s"; const sign = kind === "add" ? "+" : kind === "del" ? "-" : " "; html += '
    '+sign+""+esc(text)+"
    "; }); tick(0.95); const doc = "Comparison report

    Comparison report

    "+ esc(files[0].name)+" → "+esc(files[1].name)+" · "+adds+" lines added, "+dels+" removed

    "+ (html || "

    The two documents are identical.

    ")+""; return [{ name:"comparison report.html", blob:new Blob([doc],{type:"text/html;charset=utf-8"}), note: adds+" added, "+dels+" removed" }]; } }; TOOLS.stats = { group:"advanced", icon:"chart", name:"Document statistics", blurb:"Word count, reading time and the most frequent terms.", lede:"Produces a small report with counts, averages and a keyword frequency table. Useful for editors, students working to a word limit, and anyone auditing content.", accept:".pdf", multiple:true, run: async (opts, tick) => { const stop = new Set(("the of and to a in is it for on that with as this by are be or an at from was were which has have had not but they their you your we our can will may all any more other than then them these those such into its if no so do does done been each also had her his she him he i us who whom what when where how"). split(" ")); const res = []; for(let f=0;f{ const k = w.toLowerCase(); if(k.length < 3 || stop.has(k) || /^\d+$/.test(k)) return; freq.set(k, (freq.get(k)||0)+1); }); const top = [...freq.entries()].sort((x,y)=>y[1]-x[1]).slice(0,20); const mins = Math.max(1, Math.round(words.length/220)); const row = (a,b)=>""+a+""+b+""; const doc = "Statistics

    "+esc(stem(files[f].name))+"

    Document statistics

    "+ "

    Totals

    "+ row("Pages", pdf.numPages)+row("Words", words.length.toLocaleString())+ row("Characters (no spaces)", chars.toLocaleString())+row("Sentences", sentences.toLocaleString())+ row("Average words per page", Math.round(words.length/pdf.numPages))+ row("Average words per sentence", Math.round(words.length/sentences))+ row("Estimated reading time", mins+" min")+ "

    Most frequent terms

    "+ top.map(([w,c])=>row(esc(w), c)).join("")+"
    "+ "

    Words per page

    "+ perPage.map((c,i)=>row("Page "+(i+1), c)).join("")+"
    "; res.push({ name: stem(files[f].name)+" statistics.html", blob:new Blob([doc],{type:"text/html;charset=utf-8"}), note: words.length.toLocaleString()+" words, "+mins+" min read" }); } return res; } }; /* ============================================================ UI ============================================================ */ for(const [key,t] of Object.entries(TOOLS)){ const host = $("g-"+t.group); if(!host) continue; const b = document.createElement("button"); b.className = "tool"; b.type = "button"; b.innerHTML = icon(t.icon)+""+t.name+""+t.blurb+""; b.addEventListener("click", ()=>openTool(key)); host.appendChild(b); } $("s-count").textContent = Object.keys(TOOLS).length; function openTool(key){ current = TOOLS[key]; files = []; outputs = []; organiserState = null; els.picker.style.display = "none"; els.bench.classList.add("on"); els.title.textContent = current.name; els.lede.textContent = current.lede; els.input.accept = current.accept || ""; els.input.multiple = !!current.multiple; els.dropSub.textContent = current.multiple ? "or click to choose files — you can add more later" : "or click to choose a file"; els.organiser.innerHTML = ""; els.err.classList.remove("on"); els.out.classList.remove("on"); els.bar.classList.remove("on"); els.results.innerHTML = ""; els.zipall.style.display = "none"; say(""); buildOptions(); renderFiles(); window.scrollTo(0,0); } $("back").addEventListener("click", ()=>{ els.bench.classList.remove("on"); els.picker.style.display = ""; current = null; files = []; organiserState = null; window.scrollTo(0,0); }); function buildOptions(){ els.opts.innerHTML = ""; (current.opts||[]).forEach(o=>{ const d = document.createElement("div"); d.className = "field"; d.dataset.k = o.k; if(o.type === "check"){ d.innerHTML = '"; } else if(o.type === "select"){ d.innerHTML = '"; } else if(o.type === "textarea"){ d.style.gridColumn = "1 / -1"; d.innerHTML = ''+ (o.hint ? '
    '+o.hint+"
    " : ""); } else { d.innerHTML = ''+ (o.hint ? '
    '+o.hint+"
    " : ""); } els.opts.appendChild(d); }); (current.opts||[]).forEach(o=>{ const el = $("o-"+o.k); if(!el) return; el.addEventListener(o.type === "textarea" || o.type === "text" ? "input" : "change", ()=>{ applyConditions(); checkReady(); }); }); applyConditions(); } function applyConditions(){ (current.opts||[]).forEach(o=>{ if(!o.showIf) return; const src = $("o-"+o.showIf.k); const field = els.opts.querySelector('[data-k="'+o.k+'"]'); if(src && field) field.style.display = (src.value === o.showIf.eq) ? "" : "none"; }); } function readOptions(){ const o = {}; (current.opts||[]).forEach(s=>{ const el = $("o-"+s.k); if(el) o[s.k] = s.type === "check" ? el.checked : el.value; }); return o; } /* file intake */ els.drop.addEventListener("click", ()=>els.input.click()); els.drop.addEventListener("dragover", e=>{ e.preventDefault(); els.drop.classList.add("hot"); }); els.drop.addEventListener("dragleave", ()=>els.drop.classList.remove("hot")); els.drop.addEventListener("drop", e=>{ e.preventDefault(); els.drop.classList.remove("hot"); addFiles([...e.dataTransfer.files]); }); els.input.addEventListener("change", e=>{ addFiles([...e.target.files]); e.target.value = ""; }); function addFiles(list){ if(!current || !list.length) return; const a = current.accept || ""; const ok = list.filter(f=>{ if(!a) return true; if(a === "image/*") return /^image\//.test(f.type) || /\.(jpe?g|png|gif|webp|bmp|avif)$/i.test(f.name); return a.split(",").some(x=>f.name.toLowerCase().endsWith(x.trim())); }); if(!ok.length){ fail("Those files are not the right type. This tool accepts "+(a === "image/*" ? "images" : a)+"."); return; } els.err.classList.remove("on"); files = current.multiple ? files.concat(ok) : [ok[0]]; renderFiles(); if(current.organiser) loadOrganiser(); if(current.readMeta) loadMeta(); } function renderFiles(){ els.files.innerHTML = ""; files.forEach((f,i)=>{ const li = document.createElement("li"); li.innerHTML = ''+esc(ext(f.name))+''+ ''+esc(f.name)+''+fmtSize(f.size)+""+ (current.multiple ? '' : "")+ ''; els.files.appendChild(li); }); els.files.querySelectorAll("button").forEach(b=>{ b.addEventListener("click", ()=>{ const key = b.dataset.rm !== undefined ? "rm" : b.dataset.up !== undefined ? "up" : "down"; const i = +b.dataset[key]; if(key === "rm"){ files.splice(i,1); if(current.organiser){ organiserState = null; els.organiser.innerHTML = ""; } } else if(key === "up" && i > 0) [files[i-1],files[i]] = [files[i],files[i-1]]; else if(key === "down" && i < files.length-1) [files[i+1],files[i]] = [files[i],files[i+1]]; renderFiles(); }); }); checkReady(); } function checkReady(){ const min = current.min || 1; const ta = current.optionalFile ? $("o-text") : null; const textOk = !!(ta && ta.value.trim()); els.go.disabled = !(files.length >= min || textOk); els.go.textContent = current.organiser ? "Save changes" : "Run"; } async function loadMeta(){ try{ const doc = await loadPdfLib(files[0]); $("o-title").value = doc.getTitle() || ""; $("o-author").value = doc.getAuthor() || ""; $("o-subject").value = doc.getSubject() || ""; const kw = doc.getKeywords(); $("o-keywords").value = Array.isArray(kw) ? kw.join(", ") : (kw || ""); say("Loaded the current details."); }catch(e){ fail("That PDF could not be read: "+e.message); } } /* page organiser */ async function loadOrganiser(){ els.organiser.innerHTML = '

    Drawing the pages…

    '; try{ const pdf = await loadPdfJs(files[0]); const libDoc = await loadPdfLib(files[0]); const rots = libDoc.getPages().map(p=>p.getRotation().angle); organiserState = { pages:[] }; const holder = document.createElement("div"); holder.className = "pages"; els.organiser.innerHTML = ""; els.organiser.appendChild(holder); for(let n=1;n<=pdf.numPages;n++){ const { canvas } = await renderPage(pdf, n, 0.34); organiserState.pages.push({ index:n-1, rotation:0, deleted:false, baseRotation:rots[n-1]||0, canvas }); progress(n/pdf.numPages); } els.bar.classList.remove("on"); drawOrganiser(holder); say(pdf.numPages+" page"+(pdf.numPages>1?"s":"")+" loaded."); }catch(e){ fail("That PDF could not be opened: "+e.message); els.organiser.innerHTML = ""; } } function drawOrganiser(holder){ holder.innerHTML = ""; organiserState.pages.forEach((p,i)=>{ const d = document.createElement("div"); d.className = "pg"+(p.deleted ? " del" : ""); const shot = document.createElement("div"); shot.className = "shot"; p.canvas.style.transform = "rotate("+p.rotation+"deg)"; shot.appendChild(p.canvas); d.appendChild(shot); const tag = document.createElement("span"); tag.className = "num"; tag.textContent = i+1; d.appendChild(tag); const acts = document.createElement("div"); acts.className = "acts"; acts.innerHTML = ''+ ''+ '"+ ''; acts.querySelectorAll("button").forEach(b=>{ b.addEventListener("click", ()=>{ const a = b.dataset.a, arr = organiserState.pages; if(a === "rot") p.rotation = (p.rotation + 90) % 360; if(a === "del") p.deleted = !p.deleted; if(a === "left" && i > 0) [arr[i-1],arr[i]] = [arr[i],arr[i-1]]; if(a === "right" && i < arr.length-1) [arr[i+1],arr[i]] = [arr[i],arr[i+1]]; drawOrganiser(holder); }); }); d.appendChild(acts); holder.appendChild(d); }); } /* run */ function fail(msg){ els.err.textContent = msg; els.err.classList.add("on"); els.bar.classList.remove("on"); } els.go.addEventListener("click", async ()=>{ els.err.classList.remove("on"); els.out.classList.remove("on"); els.results.innerHTML = ""; els.zipall.style.display = "none"; els.go.disabled = true; const label = els.go.textContent; els.go.textContent = "Working…"; say(""); progress(0.03); const t0 = performance.now(); try{ outputs = await current.run(readOptions(), p=>progress(Math.max(0.03, Math.min(0.99, p)))); progress(1); showResults(); say(outputs.length+" file"+(outputs.length>1?"s":"")+" ready in "+((performance.now()-t0)/1000).toFixed(1)+"s"); }catch(e){ console.error(e); fail(e && e.message ? e.message : "Something went wrong reading that file. If it is password protected, unlock it in a PDF reader first."); say(""); } els.go.textContent = label; checkReady(); }); function showResults(){ els.out.classList.add("on"); els.results.innerHTML = ""; outputs.forEach(o=>{ const url = URL.createObjectURL(o.blob); const row = document.createElement("div"); row.className = "result"; row.innerHTML = ''+esc(ext(o.name))+''+esc(o.name)+""+ ''+esc(o.note || fmtSize(o.blob.size))+""+ 'Download'; els.results.appendChild(row); }); if(outputs.length > 1){ els.zipall.style.display = ""; els.zipall.onclick = async ()=>{ const original = els.zipall.textContent; els.zipall.textContent = "Zipping…"; try{ const zip = new JSZip(); outputs.forEach(o=>zip.file(o.name, o.blob)); const blob = await zip.generateAsync({ type:"blob" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = current.name.toLowerCase().replace(/[^a-z0-9]+/g,"-")+".zip"; document.body.appendChild(a); a.click(); a.remove(); }catch(e){ fail("The zip could not be built: "+e.message); } els.zipall.textContent = original; }; } }