Your browser can do it.

Every other file tool makes you upload your files to a server first. Your browser doesn't need to — it's more than capable of doing it all itself. PDF, image, audio, video, text, code, handled right here in your tab. Your files never leave your device.

100% free · no signup · no uploads · no tracking

Tools chain together: watermark a PDF, then protect it, then compress it, without ever downloading in between.

The case for local

Every other tool uploads your files.

Smallpdf, iLovePDF, PDF24, CloudConvert. They all process your files on their servers. We do it in this browser tab.

i.

Files stay on your device.

The engine runs in your browser: pdf-lib, ffmpeg.wasm, Web Crypto, Canvas. Open DevTools and watch the network tab stay quiet.

ii.

Free, no upsell, no signup.

No paywalls, no “upgrade to Pro for larger files,” no mandatory account before downloading. Just the tools.

iii.

Open and verifiable.

The code that handles your file is right here in this page. Read it, audit it, or self-host the whole thing.

The honest trade

How we convert PDF↔Word/Excel/PowerPoint without uploading your file.

Most PDF tools that promise “PDF → Word” run LibreOffice on a server and need your file uploaded. We run the same LibreOffice. We just put it inside your browser tab.

There is no free lunch: the LibreOffice engine is roughly 300 MB. We lazy-load it on first use of any Office conversion tool, then cache it forever. From the second conversion onward, it’s instant.

Smallpdf, iLovePDF, PDF24, etc. Zero download. Every file you convert gets uploaded to their server. Every time.
yourbrowsercandoit ~300 MB download once. After that, zero uploads, forever. Conversion stays in your tab.

For everything else (merge, split, sign, compress, OCR, watermark, page numbers, redact, crop, organize, convert, trim audio or video, hash, encrypt), there is no bundle to download and your file never leaves this tab.

Saved
`; downloadBlob(new Blob([html], { type: 'text/html' }), 'document.html'); }); update(); return w; } /* ── Text: word counter ─────────────────────────── */ function uiWordCounter() { const w = el(); w.innerHTML = `
`; function update() { const t = $('#in', w).value; const chars = t.length; const charsNoSpace = t.replace(/\s/g, '').length; const words = t.trim() ? t.trim().split(/\s+/).length : 0; const lines = t.split('\n').length; const sentences = t.trim() ? (t.match(/[.!?]+/g) || []).length : 0; const paragraphs = t.trim() ? t.split(/\n\s*\n/).filter(p => p.trim()).length : 0; const readingMin = Math.max(1, Math.round(words / 200)); $('#stats', w).innerHTML = `
Words${words.toLocaleString()}
Characters${chars.toLocaleString()}
No spaces${charsNoSpace.toLocaleString()}
Lines${lines.toLocaleString()}
Sentences${sentences.toLocaleString()}
Paragraphs${paragraphs.toLocaleString()}
Reading${readingMin} min
Speaking${Math.max(1, Math.round(words / 130))} min
`; } $('#in', w).addEventListener('input', update); update(); return w; } /* ── Text: case converter ───────────────────────── */ function uiCaseConverter() { const w = el(); const CASES = [ { id: 'upper', label: 'UPPER CASE', sample: 'HELLO WORLD', fn: s => s.toUpperCase() }, { id: 'lower', label: 'lower case', sample: 'hello world', fn: s => s.toLowerCase() }, { id: 'title', label: 'Title Case', sample: 'Hello World', fn: s => s.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase()) }, { id: 'sentence', label: 'Sentence case', sample: 'Hello world', fn: s => s.toLowerCase().replace(/(^|\. )(.)/g, (_, p, c) => p + c.toUpperCase()) }, { id: 'camel', label: 'camelCase', sample: 'helloWorld', fn: s => s.toLowerCase().replace(/[^a-z0-9]+(.)/g, (_, c) => c.toUpperCase()) }, { id: 'pascal', label: 'PascalCase', sample: 'HelloWorld', fn: s => { const c = s.toLowerCase().replace(/[^a-z0-9]+(.)/g, (_, c) => c.toUpperCase()); return c.charAt(0).toUpperCase() + c.slice(1); } }, { id: 'snake', label: 'snake_case', sample: 'hello_world', fn: s => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') }, { id: 'kebab', label: 'kebab-case', sample: 'hello-world', fn: s => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') }, { id: 'constant', label: 'CONSTANT_CASE', sample: 'HELLO_WORLD', fn: s => s.toUpperCase().trim().replace(/[^A-Z0-9]+/g, '_').replace(/^_|_$/g, '') }, ]; w.innerHTML = `
${CASES.map(c => ` `).join('')}
`; function refreshPreviews() { const input = $('#in', w).value; if (!input) return; CASES.forEach(c => { const out = c.fn(input); const p = $('#prev-' + c.id, w); if (p) p.textContent = out.length > 50 ? out.slice(0, 50) + '…' : out; }); } $('#in', w).addEventListener('input', refreshPreviews); $$('.case-btn', w).forEach(btn => { btn.addEventListener('click', () => { const c = CASES.find(x => x.id === btn.dataset.id); const out = c.fn($('#in', w).value); renderTextOutput(w, out, { label: c.label }); }); }); refreshPreviews(); return w; } /* ── Dev: password generator ────────────────────── */ function uiPasswordGenerator() { const w = el(); w.innerHTML = `
20
`; $('#len', w).addEventListener('input', e => { $('#lenv', w).textContent = e.target.value; }); $('#run', w).addEventListener('click', () => { const len = +$('#len', w).value; const qty = Math.max(1, Math.min(100, +$('#qty', w).value || 1)); let charset = ''; if ($('#up', w).checked) charset += 'ABCDEFGHJKLMNPQRSTUVWXYZ'; if ($('#low', w).checked) charset += 'abcdefghjkmnpqrstuvwxyz'; if ($('#num', w).checked) charset += '23456789'; if ($('#sym', w).checked) charset += '!@#$%^&*-_=+?'; if (!charset) { toast('Pick at least one character class.'); return; } // Rejection sampling — a plain modulo skews toward the start of the charset. const limit = Math.floor(0x100000000 / charset.length) * charset.length; function randomChar() { const buf = new Uint32Array(1); do { crypto.getRandomValues(buf); } while (buf[0] >= limit); return charset[buf[0] % charset.length]; } const passwords = []; for (let i = 0; i < qty; i++) { let pw = ''; for (let j = 0; j < len; j++) pw += randomChar(); passwords.push(pw); } renderTextOutput(w, passwords.join('\n'), { label: `${qty} password${qty > 1 ? 's' : ''}, ${len} chars` }); }); return w; } /* ── Dev: password checker ──────────────────────── */ function uiPasswordChecker() { const w = el(); w.innerHTML = `
`; function check() { const p = $('#pw', w).value; if (!p) { $('#bar', w).style.width = '0%'; $('#verdict', w).textContent = ''; $('#stats', w).innerHTML = ''; return; } const len = p.length; const hasLow = /[a-z]/.test(p); const hasUp = /[A-Z]/.test(p); const hasNum = /\d/.test(p); const hasSym = /[^a-zA-Z0-9]/.test(p); const classes = [hasLow, hasUp, hasNum, hasSym].filter(Boolean).length; const poolSize = (hasLow ? 26 : 0) + (hasUp ? 26 : 0) + (hasNum ? 10 : 0) + (hasSym ? 32 : 0); const entropy = poolSize ? Math.round(len * Math.log2(poolSize)) : 0; let score = 0; if (len >= 8) score++; if (len >= 12) score++; if (len >= 16) score++; if (classes >= 3) score++; if (classes === 4) score++; if (entropy >= 60) score++; if (entropy >= 100) score++; const labels = ['Very weak', 'Weak', 'Weak', 'Fair', 'Good', 'Strong', 'Very strong', 'Excellent']; const colors = ['#dc2626', '#dc2626', '#ea580c', '#ea580c', '#ca8a04', '#65a30d', '#16a34a', '#16a34a']; const verdict = labels[Math.min(score, labels.length - 1)]; const color = colors[Math.min(score, colors.length - 1)]; const pct = Math.min(100, (score / 7) * 100); $('#bar', w).style.width = pct + '%'; $('#bar', w).style.background = color; $('#verdict', w).textContent = verdict; $('#verdict', w).style.color = color; // crack-time estimate (very rough) const guessesPerSec = 1e10; const guesses = Math.pow(poolSize || 1, len); const secs = guesses / guessesPerSec; const crackTime = secs < 60 ? '< 1 min' : secs < 3600 ? Math.round(secs / 60) + ' min' : secs < 86400 ? Math.round(secs / 3600) + ' hours' : secs < 86400 * 365 ? Math.round(secs / 86400) + ' days' : secs < 86400 * 365 * 1000 ? Math.round(secs / 86400 / 365) + ' years' : 'centuries'; $('#stats', w).innerHTML = `
Length${len}
Character classes${classes} / 4
Entropy${entropy} bits
Crack time (offline)${crackTime}
`; } $('#pw', w).addEventListener('input', check); return w; } /* ── Dev: JSON formatter ────────────────────────── */ function uiJsonFormatter() { const w = el(); w.innerHTML = `
`; function process() { const input = $('#in', w).value; const status = $('#status', w); if (!input.trim()) { $('#out', w).classList.add('hidden'); status.textContent = ''; return; } try { const parsed = JSON.parse(input); const indentChoice = $('#indent', w).value; const indent = indentChoice === 'tab' ? '\t' : indentChoice === '0' ? 0 : +indentChoice; const formatted = JSON.stringify(parsed, null, indent); status.innerHTML = `✓ Valid JSON · ${input.length.toLocaleString()} chars in, ${formatted.length.toLocaleString()} chars out`; renderTextOutput(w, formatted, { filename: 'formatted.json', mime: 'application/json' }); } catch (e) { $('#out', w).classList.add('hidden'); status.innerHTML = `✗ ${escapeHtml(e.message)}`; } } $('#in', w).addEventListener('input', process); $('#indent', w).addEventListener('change', process); return w; } /* ── Dev: regex tester ──────────────────────────── */ function uiRegexTester() { const w = el(); w.innerHTML = `
/ /
`; function process() { const pattern = $('#pattern', w).value; const flags = $('#flags', w).value; const text = $('#text', w).value; const status = $('#status', w); const out = $('#result-out', w); if (!pattern || !text) { status.textContent = ''; out.classList.add('hidden'); return; } let re; try { re = new RegExp(pattern, flags.replace(/[^gimsuy]/g, '')); } catch (e) { status.innerHTML = `✗ ${escapeHtml(e.message)}`; out.classList.add('hidden'); return; } const matches = []; let highlighted = ''; let lastIdx = 0; if (flags.includes('g')) { let m; while ((m = re.exec(text)) !== null) { matches.push({ match: m[0], index: m.index, groups: m.slice(1) }); highlighted += escapeHtml(text.slice(lastIdx, m.index)) + '' + escapeHtml(m[0]) + ''; lastIdx = m.index + m[0].length; if (!m[0]) re.lastIndex++; } highlighted += escapeHtml(text.slice(lastIdx)); } else { const m = re.exec(text); if (m) { matches.push({ match: m[0], index: m.index, groups: m.slice(1) }); highlighted = escapeHtml(text.slice(0, m.index)) + '' + escapeHtml(m[0]) + '' + escapeHtml(text.slice(m.index + m[0].length)); } else { highlighted = escapeHtml(text); } } status.innerHTML = `${matches.length} match${matches.length === 1 ? '' : 'es'}`; out.classList.remove('hidden'); out.innerHTML = `
Matches in text
${highlighted || '(no text)'}
${matches.length ? `
Match list
${matches.map((m, i) => `${i + 1}. ${escapeHtml(m.match)} at ${m.index}${m.groups.length ? ' · groups: ' + m.groups.map(g => g == null ? '(none)' : escapeHtml(g)).join(', ') : ''}`).join('\n')}
` : ''} `; } $('#pattern', w).addEventListener('input', process); $('#flags', w).addEventListener('input', process); $('#text', w).addEventListener('input', process); return w; } /* ── Dev: URL encoder ───────────────────────────── */ function uiUrlEncoder() { const w = el(); w.innerHTML = `
`; function process() { const input = $('#in', w).value; if (!input) { $('#out', w).classList.add('hidden'); return; } try { const mode = $('#mode', w).value; const scope = $('#scope', w).value; const fn = mode === 'encode' ? (scope === 'uri' ? encodeURI : encodeURIComponent) : (scope === 'uri' ? decodeURI : decodeURIComponent); renderTextOutput(w, fn(input)); } catch (e) { renderTextOutput(w, '(invalid input: ' + e.message + ')'); } } $('#in', w).addEventListener('input', process); $('#mode', w).addEventListener('change', process); $('#scope', w).addEventListener('change', process); return w; } /* ── Dev: UUID generator ────────────────────────── */ function uiUuidGenerator() { const w = el(); w.innerHTML = `
`; $('#run', w).addEventListener('click', () => { const qty = Math.max(1, Math.min(1000, +$('#qty', w).value || 1)); const fmt = $('#fmt', w).value; const ids = []; for (let i = 0; i < qty; i++) { let id = crypto.randomUUID(); if (fmt === 'upper') id = id.toUpperCase(); else if (fmt === 'nohyphens') id = id.replace(/-/g, ''); else if (fmt === 'braces') id = '{' + id + '}'; ids.push(id); } renderTextOutput(w, ids.join('\n'), { label: `${qty} UUID${qty > 1 ? 's' : ''}` }); setProcessing($('#run', w), false, 'Generate again'); }); return w; } /* ── Dev: JWT decoder ───────────────────────────── */ function uiJwtDecoder() { const w = el(); w.innerHTML = `

Decodes locally. Note: this does not verify the signature; never trust a JWT without verifying.

`; function b64UrlDecode(s) { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return decodeURIComponent(escape(atob(s))); } function process() { const jwt = $('#jwt', w).value.trim(); if (!jwt) { $('#out', w).classList.add('hidden'); return; } const parts = jwt.split('.'); if (parts.length !== 3) { $('#out', w).classList.remove('hidden'); $('#out', w).innerHTML = `
!

Not a JWT

Expected three dot-separated segments.

`; return; } try { const header = JSON.parse(b64UrlDecode(parts[0])); const payload = JSON.parse(b64UrlDecode(parts[1])); $('#out', w).classList.remove('hidden'); $('#out', w).innerHTML = `
Header
Payload
Signature (Base64URL, opaque)
${payload.exp ? `

Expires: ${new Date(payload.exp * 1000).toISOString()} ${payload.exp * 1000 < Date.now() ? '(expired)' : '(valid window)'}

` : ''} `; } catch (e) { $('#out', w).classList.remove('hidden'); $('#out', w).innerHTML = `
!

Decode failed

${escapeHtml(e.message)}

`; } } $('#jwt', w).addEventListener('input', process); return w; } /* ── Dev: HTML entity encoder ───────────────────── */ function uiHtmlEntity() { const NAMED = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', '©': '©', '®': '®', '™': '™', '€': '€', '£': '£', '¥': '¥', '§': '§', '¶': '¶', '°': '°', '±': '±', '×': '×', '÷': '÷', '←': '←', '→': '→', '↑': '↑', '↓': '↓', '–': '–', '…': '…', ' ': ' ' }; const w = el(); w.innerHTML = `
`; function encode(s) { return s.split('').map(c => NAMED[c] || (c.charCodeAt(0) > 127 ? '&#' + c.charCodeAt(0) + ';' : c)).join(''); } function decode(s) { const tmp = document.createElement('textarea'); tmp.innerHTML = s; return tmp.value; } function process() { const input = $('#in', w).value; if (!input) { $('#out', w).classList.add('hidden'); return; } const mode = $('#mode', w).value; renderTextOutput(w, mode === 'encode' ? encode(input) : decode(input)); } $('#in', w).addEventListener('input', process); $('#mode', w).addEventListener('change', process); return w; } /* ── Dev: SVG optimizer ─────────────────────────── */ function uiSvgOptimizer() { if (!currentFile) return msgEl('Drop an SVG to optimize.'); const w = el(); w.innerHTML = `

Basic browser-side optimization: removes comments, metadata, editor-specific attrs, and collapses whitespace.

`; $('#run', w).addEventListener('click', async () => { const btn = $('#run', w); setProcessing(btn, true); try { let svg = await currentFile.text(); const original = svg.length; svg = svg.replace(//g, ''); svg = svg.replace(/<\?xml[^?]*\?>/g, ''); svg = svg.replace(/]*>/g, ''); svg = svg.replace(/\s+xmlns:(inkscape|sodipodi|rdf|cc|dc)="[^"]*"/g, ''); svg = svg.replace(/\s+(inkscape|sodipodi):[a-z-]+="[^"]*"/g, ''); svg = svg.replace(/<(metadata|rdf:RDF|sodipodi:namedview)\b[\s\S]*?<\/\1>/g, ''); svg = svg.replace(//g, ''); svg = svg.replace(/\s{2,}/g, ' '); svg = svg.replace(/>\s+<'); svg = svg.trim(); const pct = ((1 - svg.length / original) * 100); const blob = new Blob([svg], { type: 'image/svg+xml' }); showResult(w, { summary: `${fmtBytes(original)}${fmtBytes(blob.size)} · ${pct > 0 ? pct.toFixed(0) + '% smaller' : 'unchanged'}`, blob, filename: stripExt(currentFile.name) + '-optimized.svg', }); setProcessing(btn, false, 'Optimize again'); } catch (err) { showError(w, err.message); setProcessing(btn, false, 'Optimize'); } }); return w; } /* ── Dev: text hash ─────────────────────────────── */ function uiHashText() { const w = el(); w.innerHTML = `
`; let pending = null; async function hash() { const input = $('#in', w).value; const algo = $('#algo', w).value; if (!input) { $('#out', w).classList.add('hidden'); return; } const buf = new TextEncoder().encode(input); const digest = await crypto.subtle.digest(algo, buf); const hex = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join(''); renderTextOutput(w, hex, { label: algo }); } function schedule() { if (pending) clearTimeout(pending); pending = setTimeout(hash, 80); } $('#in', w).addEventListener('input', schedule); $('#algo', w).addEventListener('change', hash); return w; } /* ════════════════════════════════════════════════════════ BATCH 6: Office conversions (LibreOffice WASM, lazy-loaded) ═══════════════════════════════════════════════════════ */ /* placeholder URL — to be populated when the LO WASM build is hosted with proper COOP/COEP headers for SharedArrayBuffer support. */ const LIBRE_OFFICE_BUNDLE_URL = null; // e.g. 'https://lo.yourbrowsercandoit.com/v1/libreoffice.js' let libreOfficeReady = false; async function loadLibreOffice(onProgress) { if (libreOfficeReady && window.LibreOffice) return window.LibreOffice; if (!LIBRE_OFFICE_BUNDLE_URL) { throw new Error('LibreOffice engine isn’t hosted yet. We’re shipping it as a lazy-loaded WASM bundle once the hosting (with COOP/COEP headers for SharedArrayBuffer) is in place. Until then, this tool can’t run locally.'); } if (onProgress) onProgress(0, 'Connecting…'); await loadLib(LIBRE_OFFICE_BUNDLE_URL); if (onProgress) onProgress(1, 'Ready'); libreOfficeReady = true; return window.LibreOffice; } function uiOfficeConvert() { // Be honest up front: without a hosted LibreOffice bundle the engine cannot run. // Show the coming-soon state before asking anyone to pick a file. if (!LIBRE_OFFICE_BUNDLE_URL) { const w = el(); const alts = currentTool.slug.startsWith('pdf-to-') ? `In the meantime: OCR PDF extracts the text, and PDF to image converts the pages.` : `In the meantime, most editors (Word, Pages, Google Docs) can export to PDF directly via File → Save as PDF.`; w.innerHTML = `

Not available yet

${currentTool.name} needs the LibreOffice WASM engine, which we haven’t shipped hosting for yet. Like every tool here it will run 100% in your browser once it lands — no uploads. ${alts}

`; return w; } if (!currentFile) { return msgEl(`Drop a ${currentTool.accept.replace(/[,*\/]/g, ' ').replace(/\./g, '').trim()} file to convert.`); } const w = el(); const isFromPdf = currentTool.slug.startsWith('pdf-to-'); const direction = currentTool.name; // e.g. "PDF to Word" w.innerHTML = `

Ready to convert ${escapeHtml(currentFile.name)} using LibreOffice. Engine downloads on first click (~300 MB), then cached.

`; $('#run', w).addEventListener('click', async () => { const btn = $('#run', w); setProcessing(btn, true); const progress = $('#progress', w); const bar = progress.querySelector('.bar'); const label = $('#progress-label', w); progress.classList.remove('hidden'); label.classList.remove('hidden'); try { const LO = await loadLibreOffice((ratio, msg) => { bar.style.width = (ratio * 100) + '%'; label.textContent = msg || `Downloading engine · ${Math.round(ratio * 100)}%`; }); label.textContent = 'Converting…'; // When LO is hosted, the per-tool conversion lands here. Each slug maps to // an LO command line invocation (--convert-to docx, --convert-to pdf, etc.). const outFormat = isFromPdf ? currentTool.slug.replace('pdf-to-', '') : 'pdf'; const blob = await LO.convert(currentFile, { to: outFormat }); const ext = outFormat === 'word' ? 'docx' : outFormat === 'excel' ? 'xlsx' : outFormat === 'powerpoint' ? 'pptx' : outFormat; showResult(w, { summary: `Converted to ${ext.toUpperCase()}`, blob, filename: stripExt(currentFile.name) + '.' + ext, }); progress.classList.add('hidden'); label.classList.add('hidden'); setProcessing(btn, false, 'Convert again'); } catch (err) { progress.classList.add('hidden'); label.classList.add('hidden'); showError(w, err.message); setProcessing(btn, false, 'Load engine and convert'); } }); return w; } /* ──────────── encoding + download ──────────── */ function encodeImage(img, mime, quality, w, h) { return new Promise(resolve => { const cw = w || img.naturalWidth, ch = h || img.naturalHeight; const c = document.createElement('canvas'); c.width = cw; c.height = ch; const ctx = c.getContext('2d'); // JPEG/BMP/ICO don't support transparency; fill white first to flatten alpha. if (mime === 'image/jpeg' || mime === 'image/bmp' || mime === 'image/x-icon') { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, cw, ch); } ctx.drawImage(img, 0, 0, cw, ch); if (mime === 'image/bmp') return resolve(canvasToBmp(c)); if (mime === 'image/x-icon') return resolve(canvasToIco(c)); c.toBlob(b => resolve(b), mime, quality); }); } // Returns true if the browser can encode the given mime via canvas.toBlob. async function canEncode(mime) { if (mime === 'image/bmp' || mime === 'image/x-icon') return true; // hand-rolled below const c = document.createElement('canvas'); c.width = 1; c.height = 1; return await new Promise(res => c.toBlob(b => res(!!b && b.type === mime), mime)); } // BMP encoder (24-bit BGR, bottom-up rows, 4-byte padded). function canvasToBmp(canvas) { const w = canvas.width, h = canvas.height; const data = canvas.getContext('2d').getImageData(0, 0, w, h).data; const rowSize = (w * 3 + 3) & ~3; const pixSize = rowSize * h; const fileSize = 54 + pixSize; const buf = new ArrayBuffer(fileSize); const v = new DataView(buf); v.setUint16(0, 0x4D42, true); // 'BM' v.setUint32(2, fileSize, true); v.setUint32(10, 54, true); // pixel offset v.setUint32(14, 40, true); // DIB header size v.setInt32(18, w, true); v.setInt32(22, h, true); // positive → bottom-up v.setUint16(26, 1, true); // planes v.setUint16(28, 24, true); // bpp v.setUint32(34, pixSize, true); const px = new Uint8Array(buf, 54); for (let y = 0; y < h; y++) { const srcRow = (h - 1 - y) * w * 4; const dstRow = y * rowSize; for (let x = 0; x < w; x++) { const s = srcRow + x * 4, d = dstRow + x * 3; px[d] = data[s + 2]; // B px[d + 1] = data[s + 1]; // G px[d + 2] = data[s]; // R } } return new Blob([buf], { type: 'image/bmp' }); } // ICO encoder — wraps a single PNG entry. Browsers and Windows both accept this. async function canvasToIco(canvas) { const max = Math.max(canvas.width, canvas.height); if (max > 256) throw new Error('ICO requires width and height ≤ 256. Resize the image first.'); const pngBlob = await new Promise(res => canvas.toBlob(res, 'image/png')); const png = new Uint8Array(await pngBlob.arrayBuffer()); const header = new ArrayBuffer(22); const v = new DataView(header); v.setUint16(0, 0, true); // reserved v.setUint16(2, 1, true); // type = icon v.setUint16(4, 1, true); // count v.setUint8(6, canvas.width === 256 ? 0 : canvas.width); v.setUint8(7, canvas.height === 256 ? 0 : canvas.height); v.setUint8(8, 0); // palette v.setUint8(9, 0); // reserved v.setUint16(10, 1, true); // planes v.setUint16(12, 32, true); // bpp v.setUint32(14, png.length, true); v.setUint32(18, 22, true); // offset to PNG data return new Blob([header, png], { type: 'image/x-icon' }); } function downloadBlob(blob, name) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 400); // One-time nudge: the first download on a page that offers chain tiles points // at them once, then never again (localStorage flag). let hinted = false; try { hinted = localStorage.getItem('chain-hinted') === '1'; } catch {} if (!hinted && document.querySelector('.chain-tool')) { toast('Saved · ' + name + ' — tip: you can send the result straight to another tool below'); try { localStorage.setItem('chain-hinted', '1'); } catch {} } else { toast('Saved · ' + name); } trackEvent('tool-download', { tool: currentTool?.slug || 'unknown', filename: name, size: blob?.size || 0, }); } /* ──────────── footer links — every tool reachable from every page for SEO ──────────── */ (function () { const fill = (id, cat) => { const ul = $(id); if (!ul) return; ul.innerHTML = TOOLS.filter(t => t.cat === cat).map(t => `
  • ${t.name}
  • `).join(''); }; fill('#foot-pdf', 'pdf'); fill('#foot-image', 'image'); fill('#foot-av', 'av'); fill('#foot-text', 'text'); fill('#foot-dev', 'dev'); fill('#foot-data', 'data'); })(); /* ──────────── init ──────────── */ renderGrid(); render();