feat(base64-decoder): add base64 to file decoder web tool
Single-file browser tool that decodes a base64 string and saves the result back out as a file. No server, no dependencies, nothing uploaded. Input cleanup covers data URIs, URL-safe alphabet, missing padding, line wrapping, percent-encoding and wrapping quotes. Type detection runs magic bytes (~30 signatures), then the data URI's declared MIME, then UTF-8 content sniffing for SVG/HTML/XML/JSON/PEM; unrecognised payloads fall back to .bin with a hex dump. Images, audio, video, PDF and text are previewed inline before download.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# Base64 → File
|
||||
|
||||
A single-file web tool that decodes a base64 string and saves the result back out as a real file. Useful for pulling attachments out of API responses, log dumps, JSON payloads, config blobs and `data:` URIs without hunting for a working online decoder — and without pasting anything sensitive into someone else's website.
|
||||
|
||||
## What it does
|
||||
|
||||
Open `base64-decoder.html` in any browser, paste the base64, and the decoded file appears on the right — type detected, previewed, ready to download.
|
||||
|
||||
Everything runs locally in the page. There is no server, no build step, no dependencies, and nothing is ever uploaded.
|
||||
|
||||
```bash
|
||||
xdg-open base64-decoder/base64-decoder.html
|
||||
```
|
||||
|
||||
## Input handling
|
||||
|
||||
The input is cleaned up before decoding, so most real-world paste jobs work as-is:
|
||||
|
||||
| Input | Handled |
|
||||
|---|---|
|
||||
| `data:image/png;base64,iVBORw0...` | Prefix stripped, MIME type read from the header |
|
||||
| Line-wrapped / indented base64 | All whitespace removed |
|
||||
| URL-safe base64 (`-` and `_`) | Converted to standard `+` and `/` |
|
||||
| Missing `=` padding | Restored |
|
||||
| Percent-encoded (`%2B`) | URL-decoded first |
|
||||
| Wrapping quotes from a JSON value | Trimmed |
|
||||
|
||||
Invalid input gives a specific reason — the offending character, or a truncated length — rather than a generic failure.
|
||||
|
||||
## Type detection
|
||||
|
||||
The file type is worked out in three passes, in order of reliability:
|
||||
|
||||
1. **Magic bytes** — ~30 signatures covering PNG, JPEG, GIF, WEBP, BMP, ICO, PDF, ZIP, GZIP, BZ2, XZ, 7z, RAR, TAR, MP3, MP4, MKV, OGG, FLAC, WAV, AVI, SQLite, TTF/OTF/WOFF/WOFF2, ELF, EXE and Java class files
|
||||
2. **Data URI header** — if the input was a `data:` URI, its declared MIME type supplies the extension
|
||||
3. **Content sniffing** — payloads that decode as valid UTF-8 are checked for SVG, HTML, XML, JSON, PEM and shell scripts
|
||||
|
||||
Anything unrecognised falls back to `application/octet-stream` and a `.bin` extension. The suggested filename is always editable before saving.
|
||||
|
||||
## Preview
|
||||
|
||||
| Decoded type | Preview |
|
||||
|---|---|
|
||||
| Images | Rendered inline |
|
||||
| Audio / video | Playable with controls |
|
||||
| PDF | Embedded viewer |
|
||||
| Text-based | Syntax-free text view, plus a **Copy text** button |
|
||||
| Anything else | Hex dump of the first 192 bytes with an ASCII column |
|
||||
|
||||
## Shortcuts
|
||||
|
||||
- **Drag and drop** a `.txt` / `.b64` file onto the input to load its contents
|
||||
- **Ctrl/Cmd + S** downloads the decoded file
|
||||
- **Enter** in the filename field downloads immediately
|
||||
|
||||
## Requirements
|
||||
|
||||
Any modern browser. Works straight from `file://` — no web server needed.
|
||||
|
||||
The **Paste** button uses the clipboard API, which some browsers restrict on `file://` pages; if it does nothing, use Ctrl+V in the input instead.
|
||||
@@ -0,0 +1,572 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Base64 → File</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
font-family:"Segoe UI",Calibri,Arial,sans-serif;
|
||||
background:#0f1316;color:#c8d8ea;display:flex;flex-direction:column;overflow:hidden
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header{
|
||||
flex-shrink:0;height:54px;display:flex;align-items:center;gap:14px;
|
||||
padding:0 22px;
|
||||
background:linear-gradient(135deg,#14191b 0%,#0e1e28 60%,#0b2020 100%);
|
||||
border-bottom:2px solid transparent;
|
||||
border-image:linear-gradient(to right,#3eafff,#2acc94) 1;
|
||||
}
|
||||
.brand-name{font-size:18px;font-weight:800;letter-spacing:.3px;color:#fff}
|
||||
.brand-name em{
|
||||
font-style:normal;
|
||||
background:linear-gradient(90deg,#3eafff,#2acc94);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text
|
||||
}
|
||||
.brand-pipe{color:#2a3a3a;font-size:13px}
|
||||
.brand-sub{font-size:12px;color:#4a6878}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||
.stat-chip{
|
||||
background:rgba(62,175,255,.08);border:1px solid rgba(62,175,255,.18);
|
||||
border-radius:20px;padding:3px 12px;font-size:11px;color:#5a8898;white-space:nowrap
|
||||
}
|
||||
.stat-chip strong{color:#2acc94}
|
||||
|
||||
/* ── Layout ── */
|
||||
.layout{display:flex;flex:1;min-height:0}
|
||||
.pane{display:flex;flex-direction:column;min-width:0;min-height:0}
|
||||
.pane-in{flex:1 1 50%;border-right:1px solid #1a2428}
|
||||
.pane-out{flex:1 1 50%;background:#0c1014}
|
||||
.pane-head{
|
||||
flex-shrink:0;display:flex;align-items:center;gap:10px;
|
||||
padding:9px 16px;border-bottom:1px solid #1a2428;background:#0c1014
|
||||
}
|
||||
.pane-out .pane-head{background:#0a0e11}
|
||||
.pane-title{
|
||||
font-size:9px;font-weight:700;letter-spacing:2px;
|
||||
text-transform:uppercase;color:#2a4048
|
||||
}
|
||||
.pane-tools{margin-left:auto;display:flex;align-items:center;gap:6px}
|
||||
|
||||
/* ── Input ── */
|
||||
.drop{position:relative;flex:1;display:flex;min-height:0}
|
||||
textarea{
|
||||
flex:1;resize:none;border:0;outline:0;padding:14px 16px;
|
||||
background:#0f1316;color:#a8c4d8;
|
||||
font-family:"Cascadia Mono",Consolas,"Courier New",monospace;
|
||||
font-size:12px;line-height:1.55;word-break:break-all
|
||||
}
|
||||
textarea::placeholder{color:#2f4450}
|
||||
.drop.over::after{
|
||||
content:"Drop file to load its contents";
|
||||
position:absolute;inset:8px;border:2px dashed #3eafff;border-radius:8px;
|
||||
background:rgba(62,175,255,.07);display:flex;align-items:center;justify-content:center;
|
||||
font-size:13px;color:#3eafff;pointer-events:none
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
button{
|
||||
font-family:inherit;font-size:11px;cursor:pointer;
|
||||
background:#141e24;color:#6a8898;border:1px solid #1e2c34;
|
||||
border-radius:5px;padding:5px 12px;transition:background .1s,color .1s,border-color .1s
|
||||
}
|
||||
button:hover:not(:disabled){background:#18242c;color:#a0c0d0;border-color:#2a3c46}
|
||||
button:disabled{opacity:.35;cursor:not-allowed}
|
||||
button.primary{
|
||||
background:linear-gradient(90deg,#3eafff,#2acc94);color:#08191f;
|
||||
border:0;font-weight:700;padding:7px 18px
|
||||
}
|
||||
button.primary:hover:not(:disabled){filter:brightness(1.12)}
|
||||
|
||||
/* ── Output ── */
|
||||
.out-body{flex:1;overflow-y:auto;min-height:0;padding:16px}
|
||||
.empty{
|
||||
height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:8px;color:#2f4450;font-size:13px;text-align:center;padding:20px
|
||||
}
|
||||
.empty .big{font-size:30px;opacity:.5}
|
||||
|
||||
.card{
|
||||
background:#0f151a;border:1px solid #1a2428;border-radius:8px;
|
||||
padding:14px 16px;margin-bottom:14px
|
||||
}
|
||||
.card.err{border-color:#5a2430;background:#1a1013}
|
||||
.card.err .card-label{color:#e06a7a}
|
||||
.card-label{
|
||||
font-size:9px;font-weight:700;letter-spacing:2px;text-transform:uppercase;
|
||||
color:#2a4048;margin-bottom:10px
|
||||
}
|
||||
.err-msg{font-size:13px;color:#e8a0aa;line-height:1.5}
|
||||
.err-hint{font-size:11px;color:#8a5a64;margin-top:8px;line-height:1.5}
|
||||
|
||||
.meta{display:grid;grid-template-columns:auto 1fr;gap:7px 16px;font-size:12px}
|
||||
.meta dt{color:#4a6878;white-space:nowrap}
|
||||
.meta dd{color:#c8d8ea;word-break:break-all}
|
||||
.meta dd code{
|
||||
font-family:"Cascadia Mono",Consolas,monospace;font-size:11px;
|
||||
color:#2acc94;background:#0a1418;border-radius:3px;padding:1px 5px
|
||||
}
|
||||
|
||||
.save-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
input[type=text]{
|
||||
flex:1;min-width:150px;font-family:"Cascadia Mono",Consolas,monospace;font-size:12px;
|
||||
background:#0a0e11;color:#c8d8ea;border:1px solid #1e2c34;border-radius:5px;
|
||||
padding:7px 10px;outline:0
|
||||
}
|
||||
input[type=text]:focus{border-color:#3eafff}
|
||||
|
||||
.preview img,.preview video{
|
||||
max-width:100%;max-height:340px;display:block;margin:0 auto;
|
||||
border-radius:5px;background:#0a0e11
|
||||
}
|
||||
.preview audio{width:100%}
|
||||
.preview iframe{width:100%;height:420px;border:0;border-radius:5px;background:#fff}
|
||||
.preview pre{
|
||||
max-height:340px;overflow:auto;
|
||||
font-family:"Cascadia Mono",Consolas,monospace;font-size:11.5px;line-height:1.55;
|
||||
color:#a8c4d8;white-space:pre-wrap;word-break:break-word
|
||||
}
|
||||
.hex{
|
||||
font-family:"Cascadia Mono",Consolas,monospace;font-size:11.5px;line-height:1.6;
|
||||
color:#5a8898;white-space:pre;overflow-x:auto
|
||||
}
|
||||
.hex b{color:#2acc94;font-weight:400}
|
||||
.hex i{color:#2a4048;font-style:normal}
|
||||
|
||||
@media (max-width:820px){
|
||||
body{overflow:auto}
|
||||
.layout{flex-direction:column}
|
||||
.pane-in{border-right:0;border-bottom:1px solid #1a2428}
|
||||
.pane-in .drop{min-height:200px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<span class="brand-name">Base<em>64</em> → File</span>
|
||||
<span class="brand-pipe">|</span>
|
||||
<span class="brand-sub">decode base64 and save it back out as a file</span>
|
||||
<div class="header-right">
|
||||
<span class="stat-chip">In <strong id="statIn">0</strong></span>
|
||||
<span class="stat-chip">Out <strong id="statOut">0 B</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
<div class="pane pane-in">
|
||||
<div class="pane-head">
|
||||
<span class="pane-title">Base64 input</span>
|
||||
<div class="pane-tools">
|
||||
<button id="btnPaste">Paste</button>
|
||||
<button id="btnLoad">Load file</button>
|
||||
<button id="btnClear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drop" id="drop">
|
||||
<textarea id="input" spellcheck="false" autocomplete="off"
|
||||
placeholder="Paste base64 here. Data URIs (data:image/png;base64,...), URL-safe base64, missing padding and wrapped lines are all handled automatically."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane pane-out">
|
||||
<div class="pane-head">
|
||||
<span class="pane-title">Decoded file</span>
|
||||
<div class="pane-tools">
|
||||
<button id="btnCopy" disabled>Copy text</button>
|
||||
<button class="primary" id="btnSave" disabled>Download</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="out-body" id="out">
|
||||
<div class="empty">
|
||||
<div class="big">⇩</div>
|
||||
<div>Paste or drop some base64 to get started</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="file" id="filePicker" hidden>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
var input = document.getElementById('input');
|
||||
var out = document.getElementById('out');
|
||||
var drop = document.getElementById('drop');
|
||||
var filePicker = document.getElementById('filePicker');
|
||||
var statIn = document.getElementById('statIn');
|
||||
var statOut = document.getElementById('statOut');
|
||||
var btnSave = document.getElementById('btnSave');
|
||||
var btnCopy = document.getElementById('btnCopy');
|
||||
|
||||
var state = { bytes:null, name:'', mime:'', text:null, url:null };
|
||||
|
||||
/* ── Magic-byte signatures ──────────────────────────────────────────
|
||||
Checked in order; first match wins. `at` is the byte offset of the
|
||||
signature, `sig` is a byte array (null = wildcard for that byte). */
|
||||
var SIGS = [
|
||||
{ ext:'png', mime:'image/png', at:0, sig:[0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A] },
|
||||
{ ext:'jpg', mime:'image/jpeg', at:0, sig:[0xFF,0xD8,0xFF] },
|
||||
{ ext:'gif', mime:'image/gif', at:0, sig:[0x47,0x49,0x46,0x38] },
|
||||
{ ext:'webp', mime:'image/webp', at:8, sig:[0x57,0x45,0x42,0x50] },
|
||||
{ ext:'bmp', mime:'image/bmp', at:0, sig:[0x42,0x4D] },
|
||||
{ ext:'ico', mime:'image/x-icon', at:0, sig:[0x00,0x00,0x01,0x00] },
|
||||
{ ext:'pdf', mime:'application/pdf', at:0, sig:[0x25,0x50,0x44,0x46] },
|
||||
{ ext:'wav', mime:'audio/wav', at:8, sig:[0x57,0x41,0x56,0x45] },
|
||||
{ ext:'avi', mime:'video/x-msvideo', at:8, sig:[0x41,0x56,0x49,0x20] },
|
||||
{ ext:'mp4', mime:'video/mp4', at:4, sig:[0x66,0x74,0x79,0x70] },
|
||||
{ ext:'mkv', mime:'video/x-matroska', at:0, sig:[0x1A,0x45,0xDF,0xA3] },
|
||||
{ ext:'ogg', mime:'audio/ogg', at:0, sig:[0x4F,0x67,0x67,0x53] },
|
||||
{ ext:'flac', mime:'audio/flac', at:0, sig:[0x66,0x4C,0x61,0x43] },
|
||||
{ ext:'mp3', mime:'audio/mpeg', at:0, sig:[0x49,0x44,0x33] },
|
||||
{ ext:'mp3', mime:'audio/mpeg', at:0, sig:[0xFF,0xFB] },
|
||||
{ ext:'zip', mime:'application/zip', at:0, sig:[0x50,0x4B,0x03,0x04] },
|
||||
{ ext:'gz', mime:'application/gzip', at:0, sig:[0x1F,0x8B] },
|
||||
{ ext:'bz2', mime:'application/x-bzip2', at:0, sig:[0x42,0x5A,0x68] },
|
||||
{ ext:'xz', mime:'application/x-xz', at:0, sig:[0xFD,0x37,0x7A,0x58,0x5A,0x00] },
|
||||
{ ext:'7z', mime:'application/x-7z-compressed', at:0, sig:[0x37,0x7A,0xBC,0xAF,0x27,0x1C] },
|
||||
{ ext:'rar', mime:'application/vnd.rar', at:0, sig:[0x52,0x61,0x72,0x21,0x1A,0x07] },
|
||||
{ ext:'tar', mime:'application/x-tar', at:257, sig:[0x75,0x73,0x74,0x61,0x72] },
|
||||
{ ext:'sqlite', mime:'application/vnd.sqlite3', at:0,
|
||||
sig:[0x53,0x51,0x4C,0x69,0x74,0x65,0x20,0x66,0x6F,0x72,0x6D,0x61,0x74,0x20,0x33,0x00] },
|
||||
{ ext:'ttf', mime:'font/ttf', at:0, sig:[0x00,0x01,0x00,0x00,0x00] },
|
||||
{ ext:'otf', mime:'font/otf', at:0, sig:[0x4F,0x54,0x54,0x4F] },
|
||||
{ ext:'woff', mime:'font/woff', at:0, sig:[0x77,0x4F,0x46,0x46] },
|
||||
{ ext:'woff2',mime:'font/woff2', at:0, sig:[0x77,0x4F,0x46,0x32] },
|
||||
{ ext:'exe', mime:'application/vnd.microsoft.portable-executable', at:0, sig:[0x4D,0x5A] },
|
||||
{ ext:'elf', mime:'application/x-elf', at:0, sig:[0x7F,0x45,0x4C,0x46] },
|
||||
{ ext:'class',mime:'application/java-vm', at:0, sig:[0xCA,0xFE,0xBA,0xBE] }
|
||||
];
|
||||
|
||||
function matches(bytes, s){
|
||||
if (bytes.length < s.at + s.sig.length) return false;
|
||||
for (var i = 0; i < s.sig.length; i++){
|
||||
if (s.sig[i] !== null && bytes[s.at + i] !== s.sig[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sniff(bytes){
|
||||
for (var i = 0; i < SIGS.length; i++){
|
||||
if (matches(bytes, SIGS[i])) return { ext:SIGS[i].ext, mime:SIGS[i].mime, sure:true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Text-based formats have no magic bytes, so classify by content once
|
||||
we know the payload decodes as valid UTF-8. */
|
||||
function sniffText(txt){
|
||||
var t = txt.replace(/^/, '').trimStart();
|
||||
var head = t.slice(0, 400).toLowerCase();
|
||||
if (/^<svg[\s>]/.test(head) || (head.indexOf('<?xml') === 0 && head.indexOf('<svg') > -1))
|
||||
return { ext:'svg', mime:'image/svg+xml' };
|
||||
if (head.indexOf('<!doctype html') === 0 || head.indexOf('<html') === 0)
|
||||
return { ext:'html', mime:'text/html' };
|
||||
if (head.indexOf('<?xml') === 0)
|
||||
return { ext:'xml', mime:'application/xml' };
|
||||
if (/^[{\[]/.test(t)){
|
||||
try { JSON.parse(t); return { ext:'json', mime:'application/json' }; } catch(e){}
|
||||
}
|
||||
if (head.indexOf('-----begin ') === 0)
|
||||
return { ext:'pem', mime:'application/x-pem-file' };
|
||||
if (head.indexOf('#!') === 0)
|
||||
return { ext:'sh', mime:'text/x-shellscript' };
|
||||
if (/^%pdf/.test(head))
|
||||
return { ext:'pdf', mime:'application/pdf' };
|
||||
return { ext:'txt', mime:'text/plain' };
|
||||
}
|
||||
|
||||
/* Last resort for a data URI whose payload has no magic bytes — the
|
||||
declared MIME type usually names the extension we want. */
|
||||
var MIME_EXT = {
|
||||
'plain':'txt', 'jpeg':'jpg', 'javascript':'js', 'markdown':'md',
|
||||
'octet-stream':'bin', 'msword':'doc', 'mpeg':'mp3', 'quicktime':'mov'
|
||||
};
|
||||
function extFromMime(mime){
|
||||
var sub = (mime.split('/')[1] || '').split(';')[0].split('+')[0].trim().toLowerCase();
|
||||
sub = sub.replace(/^(x-|vnd\.)/, '');
|
||||
if (MIME_EXT[sub]) return MIME_EXT[sub];
|
||||
return /^[a-z0-9]{1,8}$/.test(sub) ? sub : null;
|
||||
}
|
||||
|
||||
function asUtf8(bytes){
|
||||
try { return new TextDecoder('utf-8', {fatal:true}).decode(bytes); }
|
||||
catch(e){ return null; }
|
||||
}
|
||||
|
||||
function humanSize(n){
|
||||
if (n < 1024) return n + ' B';
|
||||
var u = ['KB','MB','GB'], i = -1, v = n;
|
||||
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
|
||||
return v.toFixed(v < 10 ? 1 : 0) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function esc(s){
|
||||
return String(s).replace(/[&<>"]/g, function(c){
|
||||
return {'&':'&','<':'<','>':'>','"':'"'}[c];
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Decode ──────────────────────────────────────────────────────── */
|
||||
|
||||
// Strips a data URI prefix, whitespace and quotes; converts URL-safe
|
||||
// base64 to standard and restores missing '=' padding.
|
||||
function normalise(raw){
|
||||
var mime = '';
|
||||
var s = raw.trim().replace(/^["'`]|["'`]$/g, '');
|
||||
|
||||
var m = /^data:([^;,]*)(;[^,]*)?,/i.exec(s);
|
||||
if (m){
|
||||
if (!/;base64/i.test(m[2] || '')) throw new Error('That data URI is not base64-encoded.');
|
||||
mime = m[1] || '';
|
||||
s = s.slice(m[0].length);
|
||||
}
|
||||
|
||||
s = s.replace(/\s+/g, '');
|
||||
if (/%[0-9a-f]{2}/i.test(s)){
|
||||
try { s = decodeURIComponent(s); } catch(e){}
|
||||
}
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
s = s.replace(/=+$/, '');
|
||||
|
||||
var bad = s.match(/[^A-Za-z0-9+/]/);
|
||||
if (bad) throw new Error('Not valid base64 — unexpected character "' + bad[0] + '".');
|
||||
if (s.length % 4 === 1) throw new Error('Not valid base64 — the input length is truncated.');
|
||||
|
||||
while (s.length % 4) s += '=';
|
||||
return { data:s, mime:mime };
|
||||
}
|
||||
|
||||
function toBytes(b64){
|
||||
var bin = atob(b64);
|
||||
var bytes = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function hexDump(bytes, rows){
|
||||
var lines = [], limit = Math.min(bytes.length, rows * 16);
|
||||
for (var o = 0; o < limit; o += 16){
|
||||
var hex = '', txt = '';
|
||||
for (var i = 0; i < 16; i++){
|
||||
if (o + i < limit){
|
||||
var b = bytes[o + i];
|
||||
hex += (b < 16 ? '0' : '') + b.toString(16) + ' ';
|
||||
txt += (b >= 32 && b < 127) ? esc(String.fromCharCode(b)) : '.';
|
||||
} else {
|
||||
hex += ' '; txt += ' ';
|
||||
}
|
||||
if (i === 7) hex += ' ';
|
||||
}
|
||||
lines.push('<i>' + ('0000000' + o.toString(16)).slice(-8) + '</i> ' + hex + ' <b>' + txt + '</b>');
|
||||
}
|
||||
if (bytes.length > limit) lines.push('<i>' + humanSize(bytes.length - limit) + ' more…</i>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function reset(){
|
||||
if (state.url) URL.revokeObjectURL(state.url);
|
||||
state = { bytes:null, name:'', mime:'', text:null, url:null };
|
||||
btnSave.disabled = true;
|
||||
btnCopy.disabled = true;
|
||||
statOut.textContent = '0 B';
|
||||
}
|
||||
|
||||
function showEmpty(){
|
||||
reset();
|
||||
out.innerHTML = '<div class="empty"><div class="big">⇩</div>' +
|
||||
'<div>Paste or drop some base64 to get started</div></div>';
|
||||
}
|
||||
|
||||
function showError(msg, hint){
|
||||
reset();
|
||||
out.innerHTML = '<div class="card err"><div class="card-label">Could not decode</div>' +
|
||||
'<div class="err-msg">' + esc(msg) + '</div>' +
|
||||
(hint ? '<div class="err-hint">' + esc(hint) + '</div>' : '') + '</div>';
|
||||
}
|
||||
|
||||
function decode(){
|
||||
var raw = input.value;
|
||||
statIn.textContent = raw.replace(/\s+/g, '').length.toLocaleString();
|
||||
|
||||
if (!raw.trim()){ showEmpty(); return; }
|
||||
|
||||
var norm, bytes;
|
||||
try {
|
||||
norm = normalise(raw);
|
||||
bytes = toBytes(norm.data);
|
||||
} catch(e){
|
||||
showError(e.message, 'Check that the whole string was copied and that nothing ' +
|
||||
'was truncated or line-wrapped with stray characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bytes.length){ showError('The input decoded to zero bytes.'); return; }
|
||||
|
||||
var text = asUtf8(bytes);
|
||||
var hit = sniff(bytes);
|
||||
var kind;
|
||||
if (hit) kind = hit;
|
||||
else if (text !== null) kind = sniffText(text);
|
||||
else kind = { ext:'bin', mime:'application/octet-stream' };
|
||||
|
||||
var mime = norm.mime || kind.mime;
|
||||
if (!hit && norm.mime) kind.ext = extFromMime(norm.mime) || kind.ext;
|
||||
|
||||
reset();
|
||||
state.bytes = bytes;
|
||||
state.mime = mime;
|
||||
state.text = (kind.ext === 'txt' || /^text\/|json|xml|svg|javascript|pem|shellscript/.test(kind.mime)) ? text : null;
|
||||
state.name = 'decoded.' + kind.ext;
|
||||
state.url = URL.createObjectURL(new Blob([bytes], {type:mime}));
|
||||
|
||||
statOut.textContent = humanSize(bytes.length);
|
||||
btnSave.disabled = false;
|
||||
btnCopy.disabled = state.text === null;
|
||||
|
||||
render(bytes, mime, kind, hit, norm.mime);
|
||||
}
|
||||
|
||||
function render(bytes, mime, kind, hit, uriMime){
|
||||
var detected = hit ? 'magic bytes'
|
||||
: uriMime ? 'data URI header'
|
||||
: kind.ext === 'bin' ? 'unrecognised — treated as binary'
|
||||
: 'content sniffing';
|
||||
|
||||
var html =
|
||||
'<div class="card"><div class="card-label">Save as</div>' +
|
||||
'<div class="save-row">' +
|
||||
'<input type="text" id="nameField" value="' + esc(state.name) + '" spellcheck="false">' +
|
||||
'<button class="primary" id="btnSave2">Download</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="card"><div class="card-label">File</div>' +
|
||||
'<dl class="meta">' +
|
||||
'<dt>Type</dt><dd><code>' + esc(mime) + '</code></dd>' +
|
||||
'<dt>Size</dt><dd>' + humanSize(bytes.length) +
|
||||
' <span style="color:#4a6878">(' + bytes.length.toLocaleString() + ' bytes)</span></dd>' +
|
||||
'<dt>Detected via</dt><dd style="color:#4a6878">' + detected + '</dd>' +
|
||||
'</dl>' +
|
||||
'</div>';
|
||||
|
||||
var p = '';
|
||||
if (/^image\//.test(mime)){
|
||||
p = '<img src="' + state.url + '" alt="decoded image">';
|
||||
} else if (/^video\//.test(mime)){
|
||||
p = '<video src="' + state.url + '" controls></video>';
|
||||
} else if (/^audio\//.test(mime)){
|
||||
p = '<audio src="' + state.url + '" controls></audio>';
|
||||
} else if (mime === 'application/pdf'){
|
||||
p = '<iframe src="' + state.url + '"></iframe>';
|
||||
} else if (state.text !== null){
|
||||
p = '<pre>' + esc(state.text.slice(0, 20000)) +
|
||||
(state.text.length > 20000 ? '\n…' : '') + '</pre>';
|
||||
} else {
|
||||
p = '<div class="hex">' + hexDump(bytes, 12) + '</div>';
|
||||
}
|
||||
html += '<div class="card preview"><div class="card-label">Preview</div>' + p + '</div>';
|
||||
|
||||
out.innerHTML = html;
|
||||
|
||||
var nameField = document.getElementById('nameField');
|
||||
nameField.addEventListener('input', function(){ state.name = nameField.value; });
|
||||
nameField.addEventListener('keydown', function(e){ if (e.key === 'Enter') save(); });
|
||||
document.getElementById('btnSave2').addEventListener('click', save);
|
||||
}
|
||||
|
||||
function save(){
|
||||
if (!state.bytes) return;
|
||||
var a = document.createElement('a');
|
||||
a.href = state.url;
|
||||
a.download = (state.name || 'decoded.bin').trim() || 'decoded.bin';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
/* ── Events ──────────────────────────────────────────────────────── */
|
||||
|
||||
var timer;
|
||||
input.addEventListener('input', function(){
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(decode, 120);
|
||||
});
|
||||
|
||||
btnSave.addEventListener('click', save);
|
||||
|
||||
btnCopy.addEventListener('click', function(){
|
||||
if (state.text === null) return;
|
||||
navigator.clipboard.writeText(state.text).then(function(){
|
||||
btnCopy.textContent = 'Copied';
|
||||
setTimeout(function(){ btnCopy.textContent = 'Copy text'; }, 1200);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btnClear').addEventListener('click', function(){
|
||||
input.value = '';
|
||||
input.focus();
|
||||
decode();
|
||||
});
|
||||
|
||||
document.getElementById('btnPaste').addEventListener('click', function(){
|
||||
navigator.clipboard.readText().then(function(t){
|
||||
input.value = t;
|
||||
decode();
|
||||
}).catch(function(){
|
||||
input.focus();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btnLoad').addEventListener('click', function(){
|
||||
filePicker.click();
|
||||
});
|
||||
|
||||
filePicker.addEventListener('change', function(){
|
||||
if (filePicker.files[0]) readAsText(filePicker.files[0]);
|
||||
filePicker.value = '';
|
||||
});
|
||||
|
||||
function readAsText(file){
|
||||
var r = new FileReader();
|
||||
r.onload = function(){ input.value = r.result; decode(); };
|
||||
r.readAsText(file);
|
||||
}
|
||||
|
||||
['dragenter','dragover'].forEach(function(ev){
|
||||
drop.addEventListener(ev, function(e){
|
||||
e.preventDefault();
|
||||
drop.classList.add('over');
|
||||
});
|
||||
});
|
||||
['dragleave','drop'].forEach(function(ev){
|
||||
drop.addEventListener(ev, function(e){
|
||||
e.preventDefault();
|
||||
if (ev === 'dragleave' && drop.contains(e.relatedTarget)) return;
|
||||
drop.classList.remove('over');
|
||||
});
|
||||
});
|
||||
drop.addEventListener('drop', function(e){
|
||||
var f = e.dataTransfer.files[0];
|
||||
if (f) readAsText(f);
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', function(e){
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && state.bytes){
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
});
|
||||
|
||||
input.focus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user