Type anything in the box below — or keep the default — and watch LZW process it character by character. The table shows every dictionary entry the algorithm adds, the code it emits for each match, and the running compression ratio.
<div class="controls">
<label for="inputText">Input text:</label>
<textarea id="inputText" rows="2" spellcheck="false">ABABABABAB</textarea>
<button id="runBtn" type="button">Compress</button>
<button id="clearBtn" type="button" class="ghost">Reset</button>
</div>
<div id="stats" class="stats hidden"></div>
<div class="table-wrap">
<table id="dictTable">
<thead><tr><th>Step</th><th>Matched phrase</th><th>Code emitted</th><th>New entry</th><th>Ratio so far</th></tr></thead>
<tbody id="dictBody"></tbody>
</table>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.controls { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem; margin-bottom: .6rem; }
label { font-weight: 600; font-size: .85rem; margin-bottom: .15rem; display: block; }
textarea { flex: 1 1 180px; min-width: 120px; padding: .4rem .55rem; border: 1px solid #bcc4cc;
border-radius: 7px; font: 14px ui-monospace, monospace; resize: none; line-height: 1.35; }
button { font: 600 13px system-ui, sans-serif; padding: .42rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; white-space: nowrap; }
button.ghost { background: #fff; color: #1d3557; }
.stats { background: #e9f0f7; border-radius: 7px; padding: .45rem .75rem; margin-bottom: .55rem;
font-size: .88rem; display: flex; flex-wrap: wrap; gap: .4rem 1.2rem; }
.stats span { color: #1d3557; font-weight: 600; }
.hidden { display: none; }
.table-wrap { overflow-x: auto; max-height: 260px; overflow-y: auto; border: 1px solid #d6dde4; border-radius: 8px; }
table { border-collapse: collapse; width: 100%; font-size: .82rem; }
thead th { position: sticky; top: 0; background: #1d3557; color: #fff; padding: .4rem .65rem;
text-align: left; font-weight: 600; white-space: nowrap; }
tbody tr:nth-child(even) { background: #f4f7fa; }
tbody tr:hover { background: #dde8f2; }
td { padding: .32rem .65rem; border-bottom: 1px solid #e5eaef; font-family: ui-monospace, monospace; }
td.ratio { color: #0a7d33; font-weight: 600; }
td.ratio.worse { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// LZW encoder: standard algorithm (Welch 1984).
// Initial dictionary = all single chars in the input alphabet (codes 0..k-1).
// On each step: find the longest match W in the dictionary, emit code(W),
// add W + next_char as a new entry, advance past W.
function lzwEncode(input) {
if (!input.length) return { steps: [], dictSize: 0, outputBits: 0, inputBits: 0 };
// Build initial dictionary from unique chars in input
const chars = [...new Set(input.split(''))].sort();
const dict = {};
chars.forEach((c, i) => { dict[c] = i; });
let nextCode = chars.length;
const steps = [];
let i = 0;
let bitsNeeded = (n) => Math.max(1, Math.ceil(Math.log2(n + 1)));
while (i < input.length) {
let W = input[i];
// extend W as far as possible while W is in the dictionary
while (i + W.length < input.length && dict[W + input[i + W.length]] !== undefined) {
W = W + input[i + W.length];
}
const code = dict[W];
const nextChar = input[i + W.length] || null;
const newEntry = nextChar !== null ? W + nextChar : null;
const dictSizeNow = nextCode;
// bits: ceil(log2(dictSize)) per code
const bits = bitsNeeded(dictSizeNow);
const ratio = ((i + W.length) / (steps.reduce((a, s) => a + s.bits, 0) + bits) * (bits / 8)).toFixed(3);
steps.push({ step: steps.length + 1, phrase: W, code, newEntry, dictSizeNow, bits });
if (newEntry !== null) {
dict[newEntry] = nextCode++;
}
i += W.length;
}
// Compute overall ratio: input chars (8 bits each) vs output (variable bits per code)
const inputBits = input.length * 8;
let bitPos = 0;
steps.forEach(s => { bitPos += s.bits; });
return { steps, dictSize: nextCode, outputBits: bitPos, inputBits };
}
function run() {
const input = document.getElementById('inputText').value;
if (!input.trim()) return;
const result = lzwEncode(input);
const tbody = document.getElementById('dictBody');
tbody.innerHTML = '';
let cumBits = 0;
result.steps.forEach(s => {
cumBits += s.bits;
const cumBytes = cumBits / 8;
const origBytes = /* chars processed so far */ (function() {
let pos = 0;
for (let j = 0; j < s.step; j++) pos += result.steps[j - 1] ? result.steps[j-1].phrase.length : 0;
pos += s.phrase.length;
return pos;
})();
const ratio = (origBytes / cumBytes).toFixed(2);
const tr = document.createElement('tr');
const ratioCell = parseFloat(ratio) >= 1 ? 'ratio' : 'ratio worse';
tr.innerHTML = `<td>${s.step}</td><td>${esc(s.phrase)}</td><td>${s.code}</td><td>${s.newEntry ? esc(s.newEntry) : '—'}</td><td class="${ratioCell}">${ratio}×</td>`;
tbody.appendChild(tr);
});
const overallRatio = result.inputBits > 0
? (result.inputBits / result.outputBits).toFixed(2)
: '—';
const statsEl = document.getElementById('stats');
statsEl.classList.remove('hidden');
statsEl.innerHTML =
fmt('Input: <span>%length% chars / %inputBits% bits</span> Output: <span>%outputBits% bits (%v% bytes)</span> Dictionary entries: <span>%dictSize%</span> Overall ratio: <span>%overallRatio%×</span>', { length: input.length, inputBits: result.inputBits, outputBits: result.outputBits, v: (result.outputBits/8).toFixed(1), dictSize: result.dictSize, overallRatio: overallRatio });
console.log('LZW result:', { inputLen: input.length, dictSize: result.dictSize, outputBits: result.outputBits, ratio: overallRatio });
}
function esc(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
document.getElementById('runBtn').addEventListener('click', run);
document.getElementById('clearBtn').addEventListener('click', () => {
document.getElementById('inputText').value = 'ABABABABAB';
document.getElementById('dictBody').innerHTML = '';
document.getElementById('stats').classList.add('hidden');
});
// Run on load
run();
<div class="controls">
<label for="inputText">Input text:</label>
<textarea id="inputText" rows="2" spellcheck="false">ABABABABAB</textarea>
<button id="runBtn" type="button">Compress</button>
<button id="clearBtn" type="button" class="ghost">Reset</button>
</div>
<div id="stats" class="stats hidden"></div>
<div class="table-wrap">
<table id="dictTable">
<thead><tr><th>Step</th><th>Matched phrase</th><th>Code emitted</th><th>New entry</th><th>Ratio so far</th></tr></thead>
<tbody id="dictBody"></tbody>
</table>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.controls { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem; margin-bottom: .6rem; }
label { font-weight: 600; font-size: .85rem; margin-bottom: .15rem; display: block; }
textarea { flex: 1 1 180px; min-width: 120px; padding: .4rem .55rem; border: 1px solid #bcc4cc;
border-radius: 7px; font: 14px ui-monospace, monospace; resize: none; line-height: 1.35; }
button { font: 600 13px system-ui, sans-serif; padding: .42rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; white-space: nowrap; }
button.ghost { background: #fff; color: #1d3557; }
.stats { background: #e9f0f7; border-radius: 7px; padding: .45rem .75rem; margin-bottom: .55rem;
font-size: .88rem; display: flex; flex-wrap: wrap; gap: .4rem 1.2rem; }
.stats span { color: #1d3557; font-weight: 600; }
.hidden { display: none; }
.table-wrap { overflow-x: auto; max-height: 260px; overflow-y: auto; border: 1px solid #d6dde4; border-radius: 8px; }
table { border-collapse: collapse; width: 100%; font-size: .82rem; }
thead th { position: sticky; top: 0; background: #1d3557; color: #fff; padding: .4rem .65rem;
text-align: left; font-weight: 600; white-space: nowrap; }
tbody tr:nth-child(even) { background: #f4f7fa; }
tbody tr:hover { background: #dde8f2; }
td { padding: .32rem .65rem; border-bottom: 1px solid #e5eaef; font-family: ui-monospace, monospace; }
td.ratio { color: #0a7d33; font-weight: 600; }
td.ratio.worse { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// LZW encoder: standard algorithm (Welch 1984).
// Initial dictionary = all single chars in the input alphabet (codes 0..k-1).
// On each step: find the longest match W in the dictionary, emit code(W),
// add W + next_char as a new entry, advance past W.
function lzwEncode(input) {
if (!input.length) return { steps: [], dictSize: 0, outputBits: 0, inputBits: 0 };
// Build initial dictionary from unique chars in input
const chars = [...new Set(input.split(''))].sort();
const dict = {};
chars.forEach((c, i) => { dict[c] = i; });
let nextCode = chars.length;
const steps = [];
let i = 0;
let bitsNeeded = (n) => Math.max(1, Math.ceil(Math.log2(n + 1)));
while (i < input.length) {
let W = input[i];
// extend W as far as possible while W is in the dictionary
while (i + W.length < input.length && dict[W + input[i + W.length]] !== undefined) {
W = W + input[i + W.length];
}
const code = dict[W];
const nextChar = input[i + W.length] || null;
const newEntry = nextChar !== null ? W + nextChar : null;
const dictSizeNow = nextCode;
// bits: ceil(log2(dictSize)) per code
const bits = bitsNeeded(dictSizeNow);
const ratio = ((i + W.length) / (steps.reduce((a, s) => a + s.bits, 0) + bits) * (bits / 8)).toFixed(3);
steps.push({ step: steps.length + 1, phrase: W, code, newEntry, dictSizeNow, bits });
if (newEntry !== null) {
dict[newEntry] = nextCode++;
}
i += W.length;
}
// Compute overall ratio: input chars (8 bits each) vs output (variable bits per code)
const inputBits = input.length * 8;
let bitPos = 0;
steps.forEach(s => { bitPos += s.bits; });
return { steps, dictSize: nextCode, outputBits: bitPos, inputBits };
}
function run() {
const input = document.getElementById('inputText').value;
if (!input.trim()) return;
const result = lzwEncode(input);
const tbody = document.getElementById('dictBody');
tbody.innerHTML = '';
let cumBits = 0;
result.steps.forEach(s => {
cumBits += s.bits;
const cumBytes = cumBits / 8;
const origBytes = /* chars processed so far */ (function() {
let pos = 0;
for (let j = 0; j < s.step; j++) pos += result.steps[j - 1] ? result.steps[j-1].phrase.length : 0;
pos += s.phrase.length;
return pos;
})();
const ratio = (origBytes / cumBytes).toFixed(2);
const tr = document.createElement('tr');
const ratioCell = parseFloat(ratio) >= 1 ? 'ratio' : 'ratio worse';
tr.innerHTML = `<td>${s.step}</td><td>${esc(s.phrase)}</td><td>${s.code}</td><td>${s.newEntry ? esc(s.newEntry) : '—'}</td><td class="${ratioCell}">${ratio}×</td>`;
tbody.appendChild(tr);
});
const overallRatio = result.inputBits > 0
? (result.inputBits / result.outputBits).toFixed(2)
: '—';
const statsEl = document.getElementById('stats');
statsEl.classList.remove('hidden');
statsEl.innerHTML =
fmt('Input: <span>%length% chars / %inputBits% bits</span> Output: <span>%outputBits% bits (%v% bytes)</span> Dictionary entries: <span>%dictSize%</span> Overall ratio: <span>%overallRatio%×</span>', { length: input.length, inputBits: result.inputBits, outputBits: result.outputBits, v: (result.outputBits/8).toFixed(1), dictSize: result.dictSize, overallRatio: overallRatio });
console.log('LZW result:', { inputLen: input.length, dictSize: result.dictSize, outputBits: result.outputBits, ratio: overallRatio });
}
function esc(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
document.getElementById('runBtn').addEventListener('click', run);
document.getElementById('clearBtn').addEventListener('click', () => {
document.getElementById('inputText').value = 'ABABABABAB';
document.getElementById('dictBody').innerHTML = '';
document.getElementById('stats').classList.add('hidden');
});
// Run on load
run();
Notice how the first pass through unique characters adds many entries quickly, but once the algorithm has seen a phrase before it starts emitting single codes for multi-character strings. Heavy repetition — like "ABABAB…" — produces dramatic compression. Random text with few repeated patterns barely shrinks at all.
Comments
Loading comments...