PageSourceSearch

https://toolbench.live/assets/tools-runtime.js

js toolbench.live collected 2026-10-02 01:59:48 UTC 55,139 bytes, 1,144 lines download raw bytes

1/* Toolbench — per-tool runtime. Each function gets the tool host element.
2   Registered under window.ToolbenchTools[<runtime name>]. */
3(function () {
4  "use strict";
5  var T = (window.ToolbenchTools = window.ToolbenchTools || {});
6  var $ = function (id) { return document.getElementById(id); };
7  var fmt = function (n, dp) { return window.Toolbench.fmt(n, dp); };
8  var copy = function (t, l) { window.Toolbench.copy(t, l); };
9  function on(el, ev, fn) { if (el) el.addEventListener(ev, fn); }
10  function activeTab(panel) {
11    var t = panel.querySelector(".tp-tab.is-active");
12    return t ? t.getAttribute("data-tab") : null;
13  }
14  function num(id) { var v = parseFloat($(id) && $(id).value); return isFinite(v) ? v : 0; }
15
16  /* ════════ CALCULATORS ════════ */
17  T.percentageCalculator = function (panel) {
18    var out = $("pctResult");
19    function calc() {
20      var tab = activeTab(panel), r;
21      if (tab === "of") { r = (num("pofA") / 100) * num("pofB"); out.textContent = fmt(r, 2); }
22      else if (tab === "is") {
23        var b = num("pisB"); r = b ? (num("pisA") / b) * 100 : 0;
24        out.textContent = fmt(r, 2) + " %";
25      } else {
26        var a = num("pcA"), c = num("pcB"); r = a ? ((c - a) / a) * 100 : 0;
27        out.innerHTML = fmt(Math.abs(r), 2) + " %<small>" + (r >= 0 ? "increase" : "decrease") + "</small>";
28      }
29    }
30    panel.addEventListener("input", calc);
31    panel.addEventListener("tabchange", calc);
32    calc();
33  };
34
35  T.bmiCalculator = function (panel) {
36    var out = $("bmiResult");
37    function band(b) {
38      if (b < 18.5) return "Underweight";
39      if (b < 25) return "Healthy weight";
40      if (b < 30) return "Overweight";
41      return "Obese";
42    }
43    function calc() {
44      var tab = activeTab(panel), bmi;
45      if (tab === "metric") {
46        var h = num("bmiHcm") / 100, w = num("bmiWkg");
47        bmi = h > 0 ? w / (h * h) : 0;
48      } else {
49        var inches = num("bmiHft") * 12 + num("bmiHin"), lb = num("bmiWlb");
50        bmi = inches > 0 ? (703 * lb) / (inches * inches) : 0;
51      }
52      if (!bmi) { out.textContent = "—"; return; }
53      out.innerHTML = fmt(bmi, 1) + "<small>" + band(bmi) + "</small>";
54    }
55    panel.addEventListener("input", calc);
56    panel.addEventListener("tabchange", calc);
57    calc();
58  };
59
60  T.loanEmiCalculator = function () {
61    var out = $("emiResult"), bd = $("emiBreakdown");
62    function calc() {
63      var P = num("emiP"), rate = num("emiR"), yrs = num("emiN");
64      var n = yrs * 12, r = rate / 12 / 100, emi;
65      if (r === 0) emi = n ? P / n : 0;
66      else emi = (P * r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
67      var total = emi * n, interest = total - P;
68      if (!isFinite(emi) || emi <= 0) { out.textContent = "—"; bd.innerHTML = ""; return; }
69      out.innerHTML = fmt(emi, 2) + "<small>per month</small>";
70      bd.innerHTML =
71        item("Total interest", fmt(interest, 2)) +
72        item("Total repayment", fmt(total, 2)) +
73        item("Payments", fmt(n, 0));
74    }
75    on($("emiP"), "input", calc); on($("emiR"), "input", calc); on($("emiN"), "input", calc);
76    calc();
77  };
78
79  T.ageCalculator = function () {
80    var out = $("ageResult"), bd = $("ageBreakdown"), on2 = $("ageOn");
81    on2.value = new Date().toISOString().slice(0, 10);
82    function calc() {
83      var dob = $("ageDob").value, end = on2.value;
84      if (!dob || !end) { out.textContent = "—"; bd.innerHTML = ""; return; }
85      var d1 = new Date(dob), d2 = new Date(end);
86      if (d2 < d1) { out.textContent = "Second date is before the birth date"; bd.innerHTML = ""; return; }
87      var y = d2.getFullYear() - d1.getFullYear();
88      var m = d2.getMonth() - d1.getMonth();
89      var d = d2.getDate() - d1.getDate();
90      if (d < 0) { m--; d += new Date(d2.getFullYear(), d2.getMonth(), 0).getDate(); }
91      if (m < 0) { y--; m += 12; }
92      var totalDays = Math.floor((d2 - d1) / 86400000);
93      out.innerHTML = y + " yr " + m + " mo " + d + " d";
94      var nb = new Date(d2.getFullYear(), d1.getMonth(), d1.getDate());
95      if (nb < d2) nb.setFullYear(nb.getFullYear() + 1);
96      var untilNb = Math.ceil((nb - d2) / 86400000);
97      bd.innerHTML =
98        item("Total days", fmt(totalDays, 0)) +
99        item("Total weeks", fmt(Math.floor(totalDays / 7), 0)) +
100        item("Next birthday in", untilNb + " days");
101    }
102    on($("ageDob"), "input", calc); on(on2, "input", calc);
103    calc();
104  };
105
106  T.discountCalculator = function () {
107    var out = $("discResult");
108    function calc() {
109      var price = num("discPrice"), pct = num("discPct");
110      var save = price * (pct / 100), final = price - save;
111      out.innerHTML = fmt(final, 2) + "<small>you save " + fmt(save, 2) + "</small>";
112    }
113    on($("discPrice"), "input", calc); on($("discPct"), "input", calc);
114    calc();
115  };
116
117  /* ════════ DEVELOPER ════════ */
118  T.jsonFormatter = function (panel) {
119    var inp = $("jsonInput"), out = $("jsonOutput"), st = $("jsonStatus");
120    function indent() { var v = $("jsonIndent").value; return v === "tab" ? "\t" : parseInt(v, 10); }
121    function run(min) {
122      st.textContent = ""; st.className = "tp-status";
123      if (!inp.value.trim()) { out.value = ""; return; }
124      try {
125        var obj = JSON.parse(inp.value);
126        out.value = min ? JSON.stringify(obj) : JSON.stringify(obj, null, indent());
127        st.textContent = "\u2713 Valid JSON"; st.className = "tp-status ok";
128      } catch (e) {
129        out.value = ""; st.textContent = "\u2717 " + e.message; st.className = "tp-status err";
130      }
131    }
132    panel.querySelectorAll("[data-act]").forEach(function (b) {
133      on(b, "click", function () {
134        var a = b.getAttribute("data-act");
135        if (a === "format") run(false);
136        else if (a === "minify") run(true);
137        else if (a === "copy") copy(out.value, "Copied output");
138        else if (a === "clear") { inp.value = ""; out.value = ""; st.textContent = ""; }
139      });
140    });
141    on(inp, "input", function () { run(false); });
142  };
143
144  T.base64Tool = function (panel) {
145    var inp = $("b64Input"), out = $("b64Output");
146    function enc(s) { return btoa(unescape(encodeURIComponent(s))); }
147    function dec(s) { return decodeURIComponent(escape(atob(s.replace(/\s/g, "")))); }
148    function run() {
149      var tab = activeTab(panel);
150      if (!inp.value) { out.value = ""; return; }
151      try { out.value = tab === "encode" ? enc(inp.value) : dec(inp.value); }
152      catch (e) { out.value = "\u26A0 Invalid Base64 input"; }
153    }
154    panel.addEventListener("input", run);
155    panel.addEventListener("tabchange", run);
156    panel.querySelectorAll("[data-act]").forEach(function (b) {
157      on(b, "click", function () {
158        var a = b.getAttribute("data-act");
159        if (a === "copy") copy(out.value, "Copied result");
160        else if (a === "clear") { inp.value = ""; out.value = ""; }
161      });
162    });
163  };
164
165  T.urlTool = function (panel) {
166    var inp = $("urlInput"), out = $("urlOutput"), comp = $("urlComponent");
167    function run() {
168      var tab = activeTab(panel);
169      if (!inp.value) { out.value = ""; return; }
170      try {
171        if (tab === "encode") out.value = comp.checked ? encodeURIComponent(inp.value) : encodeURI(inp.value);
172        else out.value = comp.checked ? decodeURIComponent(inp.value) : decodeURI(inp.value);
173      } catch (e) { out.value = "\u26A0 Could not decode input"; }
174    }
175    panel.addEventListener("input", run);
176    panel.addEventListener("tabchange", run);
177    panel.querySelectorAll("[data-act]").forEach(function (b) {
178      on(b, "click", function () {
179        var a = b.getAttribute("data-act");
180        if (a === "copy") copy(out.value, "Copied result");
181        else if (a === "clear") { inp.value = ""; out.value = ""; }
182      });
183    });
184  };
185
186  T.uuidGenerator = function (panel) {
187    var out = $("uuidOutput");
188    function uuid() {
189      if (crypto.randomUUID) return crypto.randomUUID();
190      var b = crypto.getRandomValues(new Uint8Array(16));
191      b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
192      var h = [].map.call(b, function (x) { return ("0" + x.toString(16)).slice(-2); });
193      return h.slice(0, 4).join("") + "-" + h.slice(4, 6).join("") + "-" + h.slice(6, 8).join("") + "-" + h.slice(8, 10).join("") + "-" + h.slice(10).join("");
194    }
195    function gen() {
196      var count = Math.max(1, Math.min(1000, parseInt($("uuidCount").value, 10) || 1));
197      var list = [];
198      for (var i = 0; i < count; i++) {
199        var u = uuid();
200        if ($("uuidNoDash").checked) u = u.replace(/-/g, "");
201        if ($("uuidUpper").checked) u = u.toUpperCase();
202        list.push(u);
203      }
204      out.value = list.join("\n");
205    }
206    panel.querySelectorAll("[data-act]").forEach(function (b) {
207      on(b, "click", function () {
208        var a = b.getAttribute("data-act");
209        if (a === "generate") gen();
210        else if (a === "copy") copy(out.value, "Copied UUIDs");
211      });
212    });
213    gen();
214  };
215
216  T.hashGenerator = function (panel) {
217    var inp = $("hashInput"), box = $("hashOutputs"), last256 = "";
218    var algos = [["SHA-256", "SHA-256"], ["SHA-1", "SHA-1"], ["SHA-512", "SHA-512"]];
219    function hex(buf) {
220      return [].map.call(new Uint8Array(buf), function (x) { return ("0" + x.toString(16)).slice(-2); }).join("");
221    }
222    async function run() {
223      if (!inp.value) { box.innerHTML = ""; last256 = ""; return; }
224      var data = new TextEncoder().encode(inp.value);
225      var rows = "";
226      for (var i = 0; i < algos.length; i++) {
227        try {
228          var d = await crypto.subtle.digest(algos[i][1], data);
229          var h = hex(d);
230          if (algos[i][0] === "SHA-256") last256 = h;
231          rows += '<div class="hash-row"><span class="hr-label">' + algos[i][0] + '</span><span class="hr-val">' + h + "</span></div>";
232        } catch (e) {}
233      }
234      box.innerHTML = rows;
235    }
236    on(inp, "input", run);
237    panel.querySelectorAll("[data-act]").forEach(function (b) {
238      on(b, "click", function () { if (last256) copy(last256, "Copied SHA-256"); });
239    });
240  };
241
242  /* ════════ TEXT ════════ */
243  T.wordCounter = function () {
244    var inp = $("wcInput"), grid = $("wcStats");
245    function run() {
246      var t = inp.value;
247      var words = (t.trim().match(/\S+/g) || []).length;
248      var chars = t.length;
249      var noSpace = t.replace(/\s/g, "").length;
250      var sentences = (t.match(/[.!?]+(\s|$)/g) || []).length;
251      var paras = (t.trim() ? t.trim().split(/\n\s*\n/).length : 0);
252      var mins = Math.max(1, Math.round(words / 200));
253      grid.innerHTML =
254        stat(fmt(words, 0), "Words") + stat(fmt(chars, 0), "Characters") +
255        stat(fmt(noSpace, 0), "No spaces") + stat(fmt(sentences, 0), "Sentences") +
256        stat(fmt(paras, 0), "Paragraphs") + stat("~" + mins, "Min read");
257    }
258    on(inp, "input", run); run();
259  };
260
261  T.caseConverter = function (panel) {
262    var inp = $("caseInput"), out = $("caseOutput");
263    function words(s) { return s.replace(/[_-]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").trim().split(/\s+/); }
264    var conv = {
265      upper: function (s) { return s.toUpperCase(); },
266      lower: function (s) { return s.toLowerCase(); },
267      title: function (s) { return s.replace(/\w\S*/g, function (w) { return w[0].toUpperCase() + w.slice(1).toLowerCase(); }); },
268      sentence: function (s) { return s.toLowerCase().replace(/(^\s*\w|[.!?]\s+\w)/g, function (c) { return c.toUpperCase(); }); },
269      camel: function (s) { return words(s).map(function (w, i) { w = w.toLowerCase(); return i ? w[0].toUpperCase() + w.slice(1) : w; }).join(""); },
270      snake: function (s) { return words(s).map(function (w) { return w.toLowerCase(); }).join("_"); },
271      kebab: function (s) { return words(s).map(function (w) { return w.toLowerCase(); }).join("-"); },
272    };
273    panel.querySelectorAll("[data-case]").forEach(function (b) {
274      on(b, "click", function () { out.value = conv[b.getAttribute("data-case")](inp.value); });
275    });
276    panel.querySelectorAll("[data-act]").forEach(function (b) {
277      on(b, "click", function () {
278        var a = b.getAttribute("data-act");
279        if (a === "copy") copy(out.value, "Copied");
280        else if (a === "clear") { inp.value = ""; out.value = ""; }
281      });
282    });
283  };
284
285  T.loremGenerator = function (panel) {
286    var out = $("loremOutput");
287    var W = ("lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit esse cillum eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum").split(" ");
288    function rand(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; }
289    function word() { return W[rand(0, W.length - 1)]; }
290    function sentence() {
291      var n = rand(8, 16), s = [];
292      for (var i = 0; i < n; i++) s.push(word());
293      s[0] = s[0][0].toUpperCase() + s[0].slice(1);
294      return s.join(" ") + ".";
295    }
296    function para() { var n = rand(3, 6), p = []; for (var i = 0; i < n; i++) p.push(sentence()); return p.join(" "); }
297    function gen() {
298      var count = Math.max(1, Math.min(100, parseInt($("loremCount").value, 10) || 1));
299      var unit = $("loremUnit").value, parts = [];
300      if (unit === "paragraphs") { for (var i = 0; i < count; i++) parts.push(para()); }
301      else if (unit === "sentences") { var ss = []; for (var j = 0; j < count; j++) ss.push(sentence()); parts = [ss.join(" ")]; }
302      else { var w = []; for (var k = 0; k < count;
302 k++) w.push(word()); w[0] = w[0][0].toUpperCase() + w[0].slice(1); parts = [w.join(" ") + "."]; }
303      if ($("loremStart").checked && parts.length) parts[0] = "Lorem ipsum dolor sit amet, " + parts[0][0].toLowerCase() + parts[0].slice(1);
304      out.innerHTML = parts.map(function (p) { return "<p>" + p + "</p>"; }).join("");
305    }
306    panel.querySelectorAll("[data-act]").forEach(function (b) {
307      on(b, "click", function () {
308        var a = b.getAttribute("data-act");
309        if (a === "generate") gen();
310        else if (a === "copy") copy(out.innerText, "Copied text");
311      });
312    });
313    gen();
314  };
315
316  T.dedupeLines = function (panel) {
317    var inp = $("dedupInput"), out = $("dedupOutput"), st = $("dedupStatus");
318    function run() {
319      var lines = inp.value.split("\n");
320      var trim = $("dedupTrim").checked, ic = $("dedupCase").checked, blank = $("dedupBlank").checked;
321      var seen = {}, res = [];
322      lines.forEach(function (l) {
323        var v = trim ? l.trim() : l;
324        if (blank && v === "") return;
325        var key = ic ? v.toLowerCase() : v;
326        if (!seen[key]) { seen[key] = 1; res.push(v); }
327      });
328      if ($("dedupSort").checked) res.sort(function (a, b) { return a.localeCompare(b); });
329      out.value = res.join("\n");
330      var removed = lines.length - res.length;
331      st.textContent = removed > 0 ? "Removed " + removed + " duplicate/blank line" + (removed === 1 ? "" : "s") + " \u00B7 " + res.length + " kept" : res.length + " unique lines";
332      st.className = "tp-status ok";
333    }
334    panel.querySelectorAll("[data-act]").forEach(function (b) {
335      on(b, "click", function () {
336        var a = b.getAttribute("data-act");
337        if (a === "dedupe") run();
338        else if (a === "copy") copy(out.value, "Copied");
339      });
340    });
341  };
342
343  T.slugGenerator = function (panel) {
344    var inp = $("slugInput"), out = $("slugResult");
345    var STOP = "a an and the or but of in on at to for with is are".split(" ");
346    function run() {
347      var sep = $("slugSep").value;
348      var s = inp.value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
349      s = s.replace(/[^a-z0-9\s-]/g, "").trim();
350      var w = s.split(/[\s-]+/).filter(Boolean);
351      if ($("slugStop").checked) w = w.filter(function (x) { return STOP.indexOf(x) === -1; });
352      out.textContent = w.join(sep) || "\u2014";
353    }
354    panel.addEventListener("input", run);
355    panel.querySelectorAll("[data-act]").forEach(function (b) {
356      on(b, "click", function () { copy(out.textContent, "Copied slug"); });
357    });
358    run();
359  };
360
361  /* ════════ IMAGE ════════ */
362  function wireDrop(dropId, fileId, onFiles) {
363    var dz = $(dropId), fi = $(fileId);
364    on(dz, "click", function () { fi.click(); });
365    on(fi, "change", function () { if (fi.files.length) onFiles(fi.files); });
366    ["dragover", "dragenter"].forEach(function (ev) {
367      on(dz, ev, function (e) { e.preventDefault(); dz.classList.add("drag"); });
368    });
369    ["dragleave", "drop"].forEach(function (ev) {
370      on(dz, ev, function (e) { e.preventDefault(); dz.classList.remove("drag"); });
371    });
372    on(dz, "drop", function (e) { if (e.dataTransfer.files.length) onFiles(e.dataTransfer.files); });
373  }
374  function humanSize(b) {
375    if (b < 1024) return b + " B";
376    if (b < 1048576) return (b / 1024).toFixed(1) + " KB";
377    return (b / 1048576).toFixed(2) + " MB";
378  }
379  function loadImage(file) {
380    return new Promise(function (res, rej) {
381      var img = new Image();
382      img.onload = function () { res(img); };
383      img.onerror = rej;
384      img.src = URL.createObjectURL(file);
385    });
386  }
387
388  T.imageCompressor = function () {
389    var origImg = null, origSize = 0;
390    wireDrop("imgcDrop", "imgcFile", function (files) {
391      origSize = files[0].size;
392      loadImage(files[0]).then(function (img) { origImg = img; render(); });
393      $("imgcControls").hidden = false; $("imgcActions").hidden = false; $("imgcPreview").hidden = false;
394    });
395    function render() {
396      if (!origImg) return;
397      var q = parseFloat($("imgcQuality").value), fmtType = $("imgcFormat").value;
398      var c = document.createElement("canvas");
399      c.width = origImg.naturalWidth; c.height = origImg.naturalHeight;
400      c.getContext("2d").drawImage(origImg, 0, 0);
401      c.toBlob(function (blob) {
402        if (!blob) return;
403        var url = URL.createObjectURL(blob);
404        $("imgcPreview").innerHTML =
405          '<figure><img src="' + origImg.src + '"><figcaption>Original \u00B7 ' + humanSize(origSize) + "</figcaption></figure>" +
406          '<figure><img src="' + url + '"><figcaption>Compressed \u00B7 ' + humanSize(blob.size) +
407          " (" + Math.round((1 - blob.size / origSize) * 100) + "% smaller)</figcaption></figure>";
408        var dl = $("imgcDownload"); dl.href = url;
409        dl.download = "compressed." + (fmtType.split("/")[1] === "jpeg" ? "jpg" : fmtType.split("/")[1]);
410      }, fmtType, q);
411    }
412    on($("imgcQuality"), "input", render); on($("imgcFormat"), "change", render);
413  };
414
415  T.imageResizer = function () {
416    var img = null, ratio = 1;
417    function lock() { return $("imgrLock").checked; }
418    wireDrop("imgrDrop", "imgrFile", function (files) {
419      loadImage(files[0]).then(function (i) {
420        img = i; ratio = i.naturalWidth / i.naturalHeight;
421        $("imgrW").value = i.naturalWidth; $("imgrH").value = i.naturalHeight;
422        $("imgrControls").hidden = false; $("imgrActions").hidden = false; $("imgrPreview").hidden = false;
423        render();
424      });
425    });
426    on($("imgrW"), "input", function () { if (lock() && img) $("imgrH").value = Math.round(num("imgrW") / ratio); render(); });
427    on($("imgrH"), "input", function () { if (lock() && img) $("imgrW").value = Math.round(num("imgrH") * ratio); render(); });
428    function render() {
429      if (!img) return;
430      var w = Math.max(1, num("imgrW")), h = Math.max(1, num("imgrH"));
431      var c = document.createElement("canvas"); c.width = w; c.height = h;
432      c.getContext("2d").drawImage(img, 0, 0, w, h);
433      c.toBlob(function (blob) {
434        if (!blob) return;
435        var url = URL.createObjectURL(blob);
436        $("imgrPreview").innerHTML = '<figure><img src="' + url + '"><figcaption>' + w + " \u00D7 " + h + " px \u00B7 " + humanSize(blob.size) + "</figcaption></figure>";
437        $("imgrDownload").href = url; $("imgrDownload").download = "resized.png";
438      }, "image/png");
439    }
440  };
441
442  T.imageToBase64 = function (panel) {
443    var dataUri = "";
444    wireDrop("i2bDrop", "i2bFile", function (files) {
445      var fr = new FileReader();
446      fr.onload = function () {
447        dataUri = fr.result;
448        $("i2bOutput").value = dataUri;
449        $("i2bPreview").hidden = false; $("i2bActions").hidden = false;
450        $("i2bPreview").innerHTML = '<figure><img src="' + dataUri + '"><figcaption>' + humanSize(files[0].size) + " \u2192 " + humanSize(dataUri.length) + " as text</figcaption></figure>";
451      };
452      fr.readAsDataURL(files[0]);
453    });
454    panel.querySelectorAll("[data-act]").forEach(function (b) {
455      on(b, "click", function () {
456        var a = b.getAttribute("data-act");
457        if (a === "copyData") copy(dataUri, "Copied data URI");
458        else if (a === "copyImg") copy('<img src="' + dataUri + '" alt="">', "Copied img tag");
459        else if (a === "copyCss") copy("background-image: url(" + dataUri + ");", "Copied CSS");
460      });
461    });
462  };
463
464  /* ════════ FINANCE ════════ */
465  T.compoundInterest = function () {
466    var out = $("ciResult"), bd = $("ciBreakdown");
467    function calc() {
468      var P = num("ciP"), pmt = num("ciPmt"), r = num("ciR") / 100, yrs = num("ciN"), n = parseInt($("ciFreq").value, 10);
469      var i = r / n, periods = n * yrs;
470      var fvP = P * Math.pow(1 + i, periods);
471      var monthlyPeriods = 12 * yrs, mi = r / 12;
472      var fvC = mi === 0 ? pmt * monthlyPeriods : pmt * ((Math.pow(1 + mi, monthlyPeriods) - 1) / mi);
473      var total = fvP + fvC;
474      var contributed = P + pmt * monthlyPeriods;
475      var interest = total - contributed;
476      if (!isFinite(total)) { out.textContent = "\u2014"; bd.innerHTML = ""; return; }
477      out.innerHTML = fmt(total, 2) + "<small>final balance after " + yrs + " years</small>";
478      bd.innerHTML =
479        item("You contribute", fmt(contributed, 2)) +
480        item("Interest earned", fmt(interest, 2)) +
481        item("Growth multiple", (contributed ? total / contributed : 0).toFixed(2) + "\u00D7");
482    }
483    ["ciP", "ciPmt", "ciR", "ciN"].forEach(function (id) { on($(id), "input", calc); });
484    on($("ciFreq"), "change", calc);
485    calc();
486  };
487
488  T.simpleInterest = function () {
489    var out = $("siResult");
490    function calc() {
491      var P = num("siP"), r = num("siR") / 100, t = num("siT");
492      var I = P * r * t;
493      out.innerHTML = fmt(P + I, 2) + "<small>interest " + fmt(I, 2) + " over " + t + " years</small>";
494    }
495    ["siP", "siR", "siT"].forEach(function (id) { on($(id), "input", calc); });
496    calc();
497  };
498
499  T.savingsGoal = function () {
500    var out = $("sgResult");
501    function calc() {
502      var goal = num("sgGoal"), start = num("sgStart"), months = num("sgMonths"), r = num("sgRate") / 100 / 12;
503      var fvStart = start * Math.pow(1 + r, months);
504      var need = goal - fvStart;
505      if (need <= 0) { out.innerHTML = "Goal already reached<small>your current savings will grow past the target</small>"; return; }
506      var pmt = r === 0 ? need / months : need * r / (Math.pow(1 + r, months) - 1);
507      out.innerHTML = fmt(pmt, 2) + "<small>per month for " + months + " months</small>";
508    }
509    ["sgGoal", "sgStart", "sgMonths", "sgRate"].forEach(function (id) { on($(id), "input", calc); });
510    calc();
511  };
512
513  T.roiCalculator = function () {
514    var out = $("roiResult"), bd = $("roiBreakdown");
515    function calc() {
516      var cost = num("roiCost"), final = num("roiFinal"), yrs = num("roiYears");
517      if (!cost) { out.textContent = "\u2014"; bd.innerHTML = ""; return; }
518      var gain = final - cost, roi = (gain / cost) * 100;
519      out.innerHTML = fmt(roi, 2) + " %<small>total return on investment</small>";
520      var ann = yrs > 0 ? (Math.pow(final / cost, 1 / yrs) - 1) * 100 : null;
521      bd.innerHTML =
522        item("Net profit", fmt(gain, 2)) +
523        (ann != null && isFinite(ann) ? item("Annualized ROI", fmt(ann, 2) + " %") : "") +
524        item("Final / cost", (final / cost).toFixed(2) + "\u00D7");
525    }
526    ["roiCost", "roiFinal", "roiYears"].forEach(function (id) { on($(id), "input", calc); });
527    calc();
528  };
529
530  /* ════════ PDF (pdf-lib) ════════ */
531  function fileList(listEl, files, onChange) {
532    listEl.innerHTML = "";
533    files.forEach(function (f, idx) {
534      var li = document.createElement("li");
535      li.draggable = true; li.dataset.idx = idx;
536      li.innerHTML = '<span class="fl-drag">\u22EE\u22EE</span><span class="fl-name">' + f.name +
537        '</span><span class="fl-size">' + humanSize(f.size) + '</span><button class="fl-x" aria-label="Remove">\u00D7</button>';
538      li.querySelector(".fl-x").addEventListener("click", function () { files.splice(idx, 1); onChange(); });
539      li.addEventListener("dragstart", function () { li.classList.add("dragging"); listEl._drag = idx; });
540      li.addEventListener("dragend", function () { li.classList.remove("dragging"); });
541      li.addEventListener("dragover", function (e) { e.preventDefault(); });
542      li.addEventListener("drop", function (e) {
543        e.preventDefault();
544        var from = listEl._drag, to = idx;
545        if (from === to) return;
546        var moved = files.splice(from, 1)[0];
547        files.splice(to, 0, moved); onChange();
548      });
549      listEl.appendChild(li);
550    });
551  }
552
553  T.mergePdf = function (panel) {
554    var files = [], list = $("mpList"), st = $("mpStatus");
555    function refresh() { fileList(list, files, refresh); $("mpActions").hidden = files.length === 0; }
556    wireDrop("mpDrop", "mpFile", function (fl) {
557      [].forEach.call(fl, function (f) { if (f.type === "application/pdf") files.push(f); });
558      refresh();
559    });
560    panel.querySelectorAll("[data-act]").forEach(function (b) {
561      on(b, "click", function () {
562        var a = b.getAttribute("data-act");
563        if (a === "clear") { files.length = 0; refresh(); st.textContent = ""; }
564        else if (a === "merge") merge();
565      });
566    });
567    async function merge() {
568      if (!window.PDFLib) { st.textContent = "PDF library still loading, try again in a moment."; st.className = "tp-status err"; return; }
569      if (!files.length) return;
570      st.textContent = "Merging\u2026"; st.className = "tp-status";
571      try {
572        var outDoc = await PDFLib.PDFDocument.create();
573        for (var i = 0; i < files.length; i++) {
574          var bytes = await files[i].arrayBuffer();
575          var src = await PDFLib.PDFDocument.load(bytes, { ignoreEncryption: true });
576          var pages = await outDoc.copyPages(src, src.getPageIndices());
577          pages.forEach(function (p) { outDoc.addPage(p); });
578        }
579        downloadBlob(new Blob([await outDoc.save()], { type: "application/pdf" }), "merged.pdf");
580        st.textContent = "\u2713 Merged " + files.length + " files"; st.className = "tp-status ok";
581      } catch (e) { st.textContent = "\u2717 " + e.message; st.className = "tp-status err"; }
582    }
583  };
584
585  T.imagesToPdf = function (panel) {
586    var files = [], list = $("i2pList"), st = $("i2pStatus");
587    function refresh() {
588      fileList(list, files, refresh);
589      var has = files.length > 0;
590      $("i2pActions").hidden = !has; $("i2pControls").hidden = !has;
591    }
592    wireDrop("i2pDrop", "i2pFile", function (fl) {
593      [].forEach.call(fl, function (f) { if (/image\/(jpeg|png)/.test(f.type)) files.push(f); });
594      refresh();
595    });
596    panel.querySelectorAll("[data-act]").forEach(function (b) {
597      on(b, "click", function () {
598        var a = b.getAttribute("data-act");
599        if (a === "clear") { files.length = 0; refresh(); st.textContent = ""; }
600        else if (a === "convert") convert();
601      });
602    });
603    async function convert() {
604      if (!window.PDFLib) { st.textContent = "PDF library still loading, try again."; st.className = "tp-status err"; return; }
605      if (!files.length) return;
606      st.textContent = "Building PDF\u2026"; st.className = "tp-status";
607      var sizes = { a4: [595.28, 841.89], letter: [612, 792] };
608      var mode = $("i2pSize").value;
609      try {
610        var doc = await PDFLib.PDFDocument.create();
611        for (var i = 0; i < files.length; i++) {
612          var bytes = await files[i].arrayBuffer();
613          var img = files[i].type === "image/png" ? await doc.embedPng(bytes) : await doc.embedJpg(bytes);
614          if (mode === "fit") {
615            var p1 = doc.addPage([img.width, img.height]);
616            p1.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
617          } else {
618            var s = sizes[mode], p2 = doc.addPage(s);
619            var scale = Math.min(s[0] / img.width, s[1] / img.height) * 0.92;
620            var w = img.width * scale, h = img.height * scale;
621            p2.drawImage(img, { x: (s[0] - w) / 2, y: (s[1] - h) / 2, width: w, height: h });
622          }
623        }
624        downloadBlob(new Blob([await doc.save()], { type: "application/pdf" }), "images.pdf");
625        st.textContent = "\u2713 Created PDF with " + files.length + " pages"; st.className = "tp-status ok";
626      } catch (e) { st.textContent = "\u2717 " + e.message; st.className = "tp-status err"; }
627    }
628  };
629
630  /* ════════ AI PRODUCTIVITY ════════ */
631  T.tokenEstimator = function () {
632    var inp = $("tokInput"), grid = $("tokStats"), ctx = $("tokContext");
633    function run() {
634      var t = inp.value;
635      var chars = t.length;
636      var words = (t.trim().match(/\S+/g) || []).length;
637      var byChar = chars / 4, byWord = words / 0.75;
638      var tokens = Math.round((byChar + byWord) / 2);
639      grid.innerHTML = stat(fmt(tokens, 0), "Tokens \u2248") + stat(fmt(words, 0), "Words") + stat(fmt(chars, 0), "Characters");
640      if (!tokens) { ctx.textContent = ""; return; }
641      var windows = [["8K", 8192], ["128K", 128000], ["200K", 200000]];
642      ctx.textContent = "Approx. context usage: " + windows.map(function (w) { return Math.round((tokens / w[1]) * 100) + "% of " + w[0]; }).join(" \u00B7 ");
643    }
644    on(inp, "input", run); run();
645  };
646
647  T.readabilityChecker = function () {
648    var inp = $("readInput"), out = $("readResult"), grid = $("readStats");
649    function syllables(word) {
650      word = word.toLowerCase().replace(/[^a-z]/g, "");
651      if (word.length <= 3) return word ? 1 : 0;
652      word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "").replace(/^y/, "");
653      var m = word.match(/[aeiouy]{1,2}/g);
654      return m ? m.length : 1;
655    }
656    function run() {
657      var t = inp.value.trim();
658      if (!t) { out.textContent = "\u2014"; grid.innerHTML = ""; return; }
659      var sentences = (t.match(/[.!?]+(\s|$)/g) || []).length || 1;
660      var words = t.match(/\S+/g) || [];
661      var wc = words.length || 1;
662      var syl = words.reduce(function (a, w) { return a + syllables(w); }, 0);
663      var wps = wc / sentences, spw = syl / wc;
664      var flesch = 206.835 - 1.015 * wps - 84.6 * spw;
665      var grade = 0.39 * wps + 11.8 * spw - 15.59;
666      flesch = Math.max(0, Math.min(100, flesch));
667      var label = flesch >= 70 ? "Easy" : flesch >= 60 ? "Standard" : flesch >= 30 ? "Fairly hard" : "Very hard";
668      out.innerHTML = fmt(flesch, 1) + "<small>Flesch Reading Ease \u00B7 " + label + "</small>";
669      grid.innerHTML =
670        stat("~" + Math.max(1, Math.round(grade)), "Grade level") +
671        stat(fmt(wps, 1), "Words / sentence") +
672        stat(fmt(spw, 2), "Syllables / word") +
673        stat(fmt(wc, 0), "Words");
674    }
675    on(inp, "input", run); run();
676  };
677
678  /* ── small builders ── */
679  function item(label, value) {
680    return '<div class="bd-item"><div class="bd-label">' + label + '</div><div class="bd-value">' + value + "</div></div>";
681  }
682  function stat(val, lab) {
683    return '<div class="stat"><div class="s-val">' + val + '</div><div class="s-lab">' + lab + "</div></div>";
684  }
685  function downloadBlob(blob, name) {
686    var url = URL.createObjectURL(blob);
687    var a = document.createElement("a");
688    a.href = url;
688 a.download = name; document.body.appendChild(a); a.click();
689    setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(url); }, 1000);
690  }
691
692  /* ═══════════ PHASE 2 — NEW TOOLS ═══════════ */
693
694  /* Tip calculator */
695  T.tipCalculator = function (panel) {
696    var stats = $("tipStats");
697    function calc() {
698      var bill = num("tipBill"), pct = num("tipPct"), split = Math.max(1, Math.floor(num("tipSplit")));
699      var tip = bill * pct / 100, total = bill + tip;
700      stats.innerHTML =
701        stat(fmt(tip, 2), "Tip amount") +
702        stat(fmt(total, 2), "Total bill") +
703        stat(fmt(total / split, 2), split > 1 ? "Each of " + split : "Per person");
704    }
705    panel.querySelectorAll("[data-tip]").forEach(function (b) {
706      on(b, "click", function () {
707        $("tipPct").value = b.getAttribute("data-tip");
708        panel.querySelectorAll("[data-tip]").forEach(function (x) { x.classList.remove("btn-primary"); });
709        b.classList.add("btn-primary");
710        calc();
711      });
712    });
713    panel.addEventListener("input", calc);
714    calc();
715  };
716
717  /* Unit converter */
718  var UNITS = {
719    length: [["m", "Meters", 1], ["km", "Kilometers", 1000], ["cm", "Centimeters", 0.01], ["mm", "Millimeters", 0.001], ["mi", "Miles", 1609.344], ["yd", "Yards", 0.9144], ["ft", "Feet", 0.3048], ["in", "Inches", 0.0254]],
720    mass: [["kg", "Kilograms", 1000], ["g", "Grams", 1], ["mg", "Milligrams", 0.001], ["t", "Metric tons", 1e6], ["lb", "Pounds", 453.59237], ["oz", "Ounces", 28.349523125], ["st", "Stones", 6350.29318]],
721    temperature: [["c", "Celsius", 0], ["f", "Fahrenheit", 0], ["k", "Kelvin", 0]],
722    volume: [["l", "Liters", 1], ["ml", "Milliliters", 0.001], ["gal", "Gallons (US)", 3.785411784], ["qt", "Quarts (US)", 0.946352946], ["pt", "Pints (US)", 0.473176473], ["cup", "Cups (US)", 0.2365882365], ["floz", "Fluid oz (US)", 0.0295735295625]],
723    area: [["m2", "Sq meters", 1], ["km2", "Sq kilometers", 1e6], ["ft2", "Sq feet", 0.09290304], ["yd2", "Sq yards", 0.83612736], ["ha", "Hectares", 1e4], ["ac", "Acres", 4046.8564224]],
724    speed: [["ms", "m/s", 1], ["kmh", "km/h", 0.277777778], ["mph", "mph", 0.44704], ["kn", "Knots", 0.514444444], ["fts", "ft/s", 0.3048]],
725    data: [["b", "Bytes", 1], ["kb", "Kilobytes", 1024], ["mb", "Megabytes", 1048576], ["gb", "Gigabytes", 1073741824], ["tb", "Terabytes", 1099511627776]],
726  };
727  T.unitConverter = function (panel) {
728    var cat = $("unitCat"), from = $("unitFrom"), to = $("unitTo"), out = $("unitResult");
729    function opts(sel, list, pick) {
730      sel.innerHTML = list.map(function (u, i) {
731        return '<option value="' + u[0] + '"' + (i === pick ? " selected" : "") + ">" + u[1] + "</option>";
732      }).join("");
733    }
734    function fill() {
735      var list = UNITS[cat.value];
736      opts(from, list, 0);
737      opts(to, list, Math.min(1, list.length - 1));
738    }
739    function tempTo(v, u) { return u === "f" ? (v - 32) * 5 / 9 : u === "k" ? v - 273.15 : v; }
740    function tempFrom(c, u) { return u === "f" ? c * 9 / 5 + 32 : u === "k" ? c + 273.15 : c; }
741    function label(list, key) { for (var i = 0; i < list.length; i++) if (list[i][0] === key) return list[i][1]; return key; }
742    function calc() {
743      var list = UNITS[cat.value], v = num("unitVal"), res;
744      if (cat.value === "temperature") res = tempFrom(tempTo(v, from.value), to.value);
745      else {
746        var f = 1, t = 1;
747        list.forEach(function (u) { if (u[0] === from.value) f = u[2]; if (u[0] === to.value) t = u[2]; });
748        res = v * f / t;
749      }
750      var dp = Math.abs(res) >= 1000 || res === 0 ? 2 : 6;
751      out.innerHTML = fmt(v, 2) + " " + label(list, from.value) + " = <strong>" + fmt(res, dp) + "</strong> " + label(list, to.value);
752    }
753    on(cat, "change", function () { fill(); calc(); });
754    panel.addEventListener("input", calc);
755    panel.querySelectorAll("[data-act=swap]").forEach(function (b) {
756      on(b, "click", function () { var tmp = from.value; from.value = to.value; to.value = tmp; calc(); });
757    });
758    fill(); calc();
759  };
760
761  /* GPA calculator */
762  var GRADES = [["A", 4.0], ["A-", 3.7], ["B+", 3.3], ["B", 3.0], ["B-", 2.7], ["C+", 2.3], ["C", 2.0], ["C-", 1.7], ["D+", 1.3], ["D", 1.0], ["F", 0.0]];
763  T.gpaCalculator = function (panel) {
764    var rows = $("gpaRows"), out = $("gpaResult"), bd = $("gpaBreakdown");
765    function addRow(name, gi, cr) {
766      var div = document.createElement("div");
767      div.className = "gpa-row";
768      div.innerHTML =
769        '<input type="text" class="gpa-name" placeholder="Course" value="' + (name || "") + '">' +
770        '<select class="select gpa-grade">' + GRADES.map(function (g, i) {
771          return '<option value="' + g[1] + '"' + (i === (gi == null ? 0 : gi) ? " selected" : "") + ">" + g[0] + "</option>";
772        }).join("") + "</select>" +
773        '<input type="number" class="gpa-cr" min="0" step="0.5" value="' + (cr == null ? 3 : cr) + '">' +
774        '<button class="gpa-x" aria-label="Remove">×</button>';
775      div.querySelector(".gpa-x").addEventListener("click", function () { div.remove();
775 calc(); });
776      rows.appendChild(div);
777    }
778    function calc() {
779      var qp = 0, cr = 0;
780      rows.querySelectorAll(".gpa-row").forEach(function (r) {
781        var pts = parseFloat(r.querySelector(".gpa-grade").value);
782        var c = parseFloat(r.querySelector(".gpa-cr").value) || 0;
783        qp += pts * c; cr += c;
784      });
785      if (!cr) { out.textContent = "—"; bd.innerHTML = ""; return; }
786      out.innerHTML = fmt(qp / cr, 2) + "<small>weighted GPA · 4.0 scale</small>";
787      bd.innerHTML = item("Total credits", fmt(cr, 1)) + item("Quality points", fmt(qp, 1)) + item("Courses", rows.querySelectorAll(".gpa-row").length);
788    }
789    panel.querySelectorAll("[data-act=add]").forEach(function (b) { on(b, "click", function () { addRow(); calc(); }); });
790    panel.addEventListener("input", calc);
791    panel.addEventListener("change", calc);
792    addRow("", 0, 3); addRow("", 2, 4); addRow("", 1, 3);
793    calc();
794  };
795
796  /* Sales tax calculator */
797  T.salesTaxCalculator = function (panel) {
798    var out = $("stResult"), bd = $("stBreakdown");
799    function calc() {
800      var tab = activeTab(panel);
801      if (tab === "add") {
802        var net = num("stAmount"), rate = num("stRate"), tax = net * rate / 100;
803        out.innerHTML = fmt(net + tax, 2) + "<small>total including " + fmt(rate, 2) + "% tax</small>";
804        bd.innerHTML = item("Net amount", fmt(net, 2)) + item("Tax", fmt(tax, 2)) + item("Total", fmt(net + tax, 2));
805      } else {
806        var gross = num("stGross"), r = num("stRate2"), netv = gross / (1 + r / 100), taxv = gross - netv;
807        out.innerHTML = fmt(netv, 2) + "<small>net, before " + fmt(r, 2) + "% tax</small>";
808        bd.innerHTML = item("Gross (incl. tax)", fmt(gross, 2)) + item("Tax portion", fmt(taxv, 2)) + item("Net amount", fmt(netv, 2));
809      }
810    }
811    panel.addEventListener("input", calc);
812    panel.addEventListener("tabchange", calc);
813    calc();
814  };
815
816  /* Mortgage calculator */
817  T.mortgageCalculator = function (panel) {
818    var out = $("mResult"), bd = $("mBreakdown");
819    function calc() {
820      var home = num("mHome"), down = num("mDown"), rate = num("mRate"), yrs = num("mTerm");
821      var loan = Math.max(0, home - down), n = yrs * 12, r = rate / 100 / 12, pi;
822      if (r === 0) pi = n ? loan / n : 0;
823      else pi = loan * r * Math.pow(1 + r, n) / (Math.pow(1 + r, n) - 1);
824      var taxM = num("mTax") / 12, insM = num("mIns") / 12;
825      var monthly = pi + taxM + insM;
826      if (!isFinite(monthly) || monthly <= 0) { out.textContent = "—"; bd.innerHTML = ""; return; }
827      var totalInterest = pi * n - loan;
828      out.innerHTML = fmt(monthly, 2) + "<small>estimated monthly payment</small>";
829      bd.innerHTML =
830        item("Loan amount", fmt(loan, 2)) +
831        item("Principal & interest", fmt(pi, 2)) +
832        (taxM ? item("Property tax /mo", fmt(taxM, 2)) : "") +
833        (insM ? item("Insurance /mo", fmt(insM, 2)) : "") +
834        item("Total interest", fmt(totalInterest, 2));
835    }
836    panel.addEventListener("input", calc);
837    calc();
838  };
839
840  /* Investment calculator */
841  T.investmentCalculator = function (panel) {
842    var out = $("invResult"), bd = $("invBreakdown");
843    function calc() {
844      var P = num("invInit"), pmt = num("invMon"), r = num("invRate") / 100, yrs = num("invYears");
845      var months = yrs * 12, mi = r / 12;
846      var fvP = P * Math.pow(1 + mi, months);
847      var fvC = mi === 0 ? pmt * months : pmt * ((Math.pow(1 + mi, months) - 1) / mi);
848      var total = fvP + fvC, contributed = P + pmt * months, growth = total - contributed;
849      if (!isFinite(total)) { out.textContent = "—"; bd.innerHTML = ""; return; }
850      out.innerHTML = fmt(total, 2) + "<small>projected value after " + yrs + " years</small>";
851      bd.innerHTML =
852        item("Total contributed", fmt(contributed, 2)) +
853        item("Growth", fmt(growth, 2)) +
854        item("Growth share", (total ? Math.round(growth / total * 100) : 0) + "%");
855    }
856    panel.addEventListener("input", calc);
857    calc();
858  };
859
860  /* Salary converter */
861  T.salaryCalculator = function (panel) {
862    var stats = $("salStats");
863    function calc() {
864      var amt = num("salAmount"), period = $("salPeriod").value, hpw = num("salHours") || 40, wpy = num("salWeeks") || 52;
865      var hoursPerYear = hpw * wpy;
866      var annual;
867      if (period === "hour") annual = amt * hoursPerYear;
868      else if (period === "day") annual = amt * (hpw / 5) * wpy; // assume 5-day week
869      else if (period === "week") annual = amt * wpy;
870      else if (period === "month") annual = amt * 12;
871      else annual = amt;
872      var hourly = hoursPerYear ? annual / hoursPerYear : 0;
873      stats.innerHTML =
874        stat(fmt(hourly, 2), "Hourly") +
875        stat(fmt(annual / wpy, 2), "Weekly") +
876        stat(fmt(annual / 12, 2), "Monthly") +
877        stat(fmt(annual, 2), "Annual");
878    }
879    panel.addEventListener("input", calc);
880    panel.addEventListener("change", calc);
881    calc();
882  };
883
884  /* Password generator */
885  T.passwordGenerator = function (panel) {
886    var out = $("pgOut"), bar = $("pgStrength"), lenVal = $("pgLenVal");
887    var SETS = {
888      pgUpper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
889      pgLower: "abcdefghijklmnopqrstuvwxyz",
890      pgNum: "0123456789",
891      pgSym: "!@#$%^&*()-_=+[]{};:,.<>?",
892    };
893    var AMBIG = /[l1IO0]/g;
894    function gen() {
895      var pool = "";
896      ["pgUpper", "pgLower", "pgNum", "pgSym"].forEach(function (id) { if ($(id).checked) pool += SETS[id]; });
897      if ($("pgAmbig").checked) pool = pool.replace(AMBIG, "");
898      if (!pool) { out.value = ""; strength(0, 0); return; }
899      var len = parseInt($("pgLen").value, 10);
900      var bytes = crypto.getRandomValues(new Uint32Array(len));
901      var pw = "";
902      for (var i = 0; i < len; i++) pw += pool[bytes[i] % pool.length];
903      out.value = pw;
904      strength(len, pool.length);
905    }
906    function strength(len, poolSize) {
907      var bits = len && poolSize ? len * Math.log2(poolSize) : 0;
908      var pct = Math.max(0, Math.min(100, Math.round(bits / 128 * 100)));
909      var color = bits < 40 ? "var(--err)" : bits < 70 ? "var(--warn)" : "var(--ok)";
910      bar.style.width = pct + "%";
911      bar.style.background = color;
912    }
913    on($("pgLen"), "input", function () { lenVal.textContent = $("pgLen").value; gen(); });
914    panel.querySelectorAll("input[type=checkbox]").forEach(function (c) { on(c, "change", gen); });
915    panel.querySelectorAll("[data-act]").forEach(function (b) {
916      on(b, "click", function () {
917        var a = b.getAttribute("data-act");
918        if (a === "generate") gen();
919        else if (a === "copy") { if (out.value) copy(out.value, "Password copied"); }
920      });
921    });
922    gen();
923  };
924
925  /* QR code generator */
926  T.qrCodeGenerator = function (panel) {
927    var stage = $("qrStage");
928    function gen() {
929      if (!window.QRCode) { stage.innerHTML = '<p class="tp-status err">QR library still loading — try again.</p>'; return; }
930      var text = $("qrText").value.trim();
931      if (!text) { stage.innerHTML = '<p class="tp-note">Enter something to encode.</p>'; return; }
932      stage.innerHTML = "";
933      var size = parseInt($("qrSize").value, 10);
934      var lvl = { L: 1, M: 0, Q: 3, H: 2 }[$("qrEc").value]; // QRCode.CorrectLevel: M=0,L=1,H=2,Q=3
935      new window.QRCode(stage, { text: text, width: size, height: size, correctLevel: lvl });
936    }
937    function download() {
938      var c = stage.querySelector("canvas"), url;
939      if (c) url = c.toDataURL("image/png");
940      else { var im = stage.querySelector("img"); if (!im) return; url = im.src; }
941      var a = document.createElement("a"); a.href = url; a.download = "qr-code.png";
942      document.body.appendChild(a); a.click(); document.body.removeChild(a);
943    }
944    panel.querySelectorAll("[data-act]").forEach(function (b) {
945      on(b, "click", function () { b.getAttribute("data-act") === "download" ? download() : gen(); });
946    });
947    on($("qrText"), "input", gen);
948    on($("qrSize"), "change", gen);
949    on($("qrEc"), "change", gen);
950    gen();
951  };
952
953  /* Color converter */
954  T.colorConverter = function (panel) {
955    var picker = $("ccPicker"), hex = $("ccHex"), swatch = $("ccSwatch"), out = $("ccOut");
956    function norm(h) {
957      h = h.replace(/^#/, "").trim();
958      if (/^[0-9a-f]{3}$/i.test(h)) h = h.split("").map(function (c) { return c + c; }).join("");
959      return /^[0-9a-f]{6}$/i.test(h) ? "#" + h.toLowerCase() : null;
960    }
961    function toRgb(h) { return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; }
962    function toHsl(r, g, b) {
963      r /= 255; g /= 255; b /= 255;
964      var mx = Math.max(r, g, b), mn = Math.min(r, g, b), h, s, l = (mx + mn) / 2;
965      if (mx === mn) { h = s = 0; }
966      else {
967        var d = mx - mn;
968        s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
969        if (mx === r) h = (g - b) / d + (g < b ? 6 : 0);
970        else if (mx === g) h = (b - r) / d + 2;
971        else h = (r - g) / d + 4;
972        h /= 6;
973      }
974      return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
975    }
976    function row(label, val) {
977      return '<div class="cc-line"><span class="cc-label">' + label + '</span><code class="cc-val">' + val +
978        '</code><button class="btn cc-copy" data-copy="' + val.replace(/"/g, "&quot;") + '">Copy</button></div>';
979    }
980    function render(h) {
981      var rgb = toRgb(h), hsl = toHsl(rgb[0], rgb[1], rgb[2]);
982      swatch.style.background = h;
983      var rgbStr = "rgb(" + rgb.join(", ") + ")", hslStr = "hsl(" + hsl[0] + ", " + hsl[1] + "%, " + hsl[2] + "%)";
984      out.innerHTML = row("HEX", h) + row("RGB", rgbStr) + row("HSL", hslStr);
985      out.querySelectorAll(".cc-copy").forEach(function (b) {
986        on(b, "click", function () { copy(b.getAttribute("data-copy"), "Copied " + b.getAttribute("data-copy")); });
987      });
988    }
989    on(picker, "input", function () { hex.value = picker.value; render(picker.value); });
990    on(hex, "input", function () { var n = norm(hex.value); if (n) { picker.value = n; render(n); } });
991    render(picker.value);
992  };
993
994  /* Text diff (line-based LCS) */
995  T.textDiff = function (panel) {
996    var out = $("diffOut");
997    function diff(a, b) {
998      var m = a.length, n = b.length, dp = [];
999      for (var i = 0; i <= m; i++) { dp[i] = []; for (var j = 0; j <= n; j++) dp[i][j] = 0; }
1000      for (i = m - 1; i >= 0; i--) for (j = n - 1; j >= 0; j--)
1001        dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
1002      var res = []; i = 0; j = 0;
1003      while (i < m && j < n) {
1004        if (a[i] === b[j]) { res.push(["=", a[i]]); i++; j++; }
1005        else if (dp[i + 1][j] >= dp[i][j + 1]) { res.push(["-", a[i]]); i++; }
1006        else { res.push(["+", b[j]]); j++; }
1007      }
1008      while (i < m) { res.push(["-", a[i]]); i++; }
1009      while (j < n) { res.push(["+", b[j]]); j++; }
1010      return res;
1011    }
1012    function run() {
1013      var a = $("diffA").value.split("\n"), b = $("diffB").value.split("\n");
1014      var rows = diff(a, b), adds = 0, dels = 0;
1015      var html = rows.map(function (r) {
1016        var cls = r[0] === "+" ? "d-add" : r[0] === "-" ? "d-del" : "d-eq";
1017        if (r[0] === "+") adds++; if (r[0] === "-") dels++;
1018        var sign = r[0] === "=" ? " " : r[0];
1019        return '<div class="' + cls + '"><span class="d-sign">' + sign + "</span>" + (esc(r[1]) || "&nbsp;") + "</div>";
1020      }).join("");
1021      out.innerHTML = '<div class="d-summary"><span class="d-add-c">+' + adds + " added</span><span class=\"d-del-c\">-" + dels + " removed</span></div>" + html;
1022    }
1023    function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]; }); }
1024    panel.querySelectorAll("[data-act=compare]").forEach(function (b) { on(b, "click", run); });
1025    run();
1026  };
1027
1028  /* Character counter */
1029  T.characterCounter = function () {
1030    var inp = $("chInput"), grid = $("chStats"), bars = $("chLimits");
1031    var LIMITS = [["X / Twitter post", 280], ["SMS message", 160], ["Meta description", 160], ["Title tag", 60]];
1032    function run() {
1033      var t = inp.value, chars = t.length, noSpace = t.replace(/\s/g, "").length;
1034      var words = (t.trim().match(/\S+/g) || []).length, lines = t ? t.split("\n").length : 0;
1035      grid.innerHTML = stat(fmt(chars, 0), "Characters") + stat(fmt(noSpace, 0), "No spaces") + stat(fmt(words, 0), "Words") + stat(fmt(lines, 0
1035), "Lines");
1036      bars.innerHTML = LIMITS.map(function (l) {
1037        var pct = Math.min(100, chars / l[1] * 100), over = chars > l[1];
1038        return '<div class="limit-bar' + (over ? " over" : "") + '"><div class="lb-head"><span>' + l[0] +
1039          '</span><span>' + chars + " / " + l[1] + "</span></div><div class=\"lb-track\"><div class=\"lb-fill\" style=\"width:" + pct + "%\"></div></div></div>";
1040      }).join("");
1041    }
1042    on(inp, "input", run); run();
1043  };
1044
1045  /* Image cropper */
1046  T.imageCropper = function (panel) {
1047    var img = $("cropImg"), stage = $("cropStage"), box = $("cropBox"), ratio = null, natW = 0, natH = 0;
1048    wireDrop("cropDrop", "cropFile", function (files) {
1049      loadImage(files[0]).then(function (im) {
1050        natW = im.naturalWidth; natH = im.naturalHeight;
1051        img.src = im.src;
1052        $("cropControls").hidden = false;
1053        setTimeout(resetBox, 30);
1054      });
1055    });
1056    function stageRect() { return img.getBoundingClientRect(); }
1057    function resetBox() {
1058      var r = stageRect(), w = r.width * 0.6, h = ratio ? w / ratio : r.height * 0.6;
1059      if (h > r.height) { h = r.height * 0.9; w = ratio ? h * ratio : w; }
1060      place((r.width - w) / 2, (r.height - h) / 2, w, h);
1061    }
1062    function place(x, y, w, h) {
1063      var r = stageRect();
1064      w = Math.max(24, Math.min(w, r.width)); h = Math.max(24, Math.min(h, r.height));
1065      x = Math.max(0, Math.min(x, r.width - w)); y = Math.max(0, Math.min(y, r.height - h));
1066      box.style.left = x + "px"; box.style.top = y + "px"; box.style.width = w + "px"; box.style.height = h + "px";
1067    }
1068    function getBox() { return { x: parseFloat(box.style.left), y: parseFloat(box.style.top), w: parseFloat(box.style.width), h: parseFloat(box.style.height) }; }
1069    // drag to move
1070    var drag = null;
1071    box.addEventListener("pointerdown", function (e) {
1072      if (e.target.classList.contains("crop-handle")) return;
1073      var b = getBox(); drag = { sx: e.clientX, sy: e.clientY, bx: b.x, by: b.y }; box.setPointerCapture(e.pointerId);
1074    });
1075    box.addEventListener("pointermove", function (e) {
1076      if (!drag) return;
1077      place(drag.bx + (e.clientX - drag.sx), drag.by + (e.clientY - drag.sy), getBox().w, getBox().h);
1078    });
1079    box.addEventListener("pointerup", function () { drag = null; });
1080    // resize via br handle
1081    var rz = null, handle = box.querySelector(".crop-handle");
1082    handle.addEventListener("pointerdown", function (e) {
1083      e.stopPropagation(); var b = getBox(); rz = { sx: e.clientX, sy: e.clientY, w: b.w, h: b.h }; handle.setPointerCapture(e.pointerId);
1084    });
1085    handle.addEventListener("pointermove", function (e) {
1086      if (!rz) return; var b = getBox();
1087      var w = rz.w + (e.clientX - rz.sx), h = ratio ? w / ratio : rz.h + (e.clientY - rz.sy);
1088      place(b.x, b.y, w, h);
1089    });
1090    handle.addEventListener("pointerup", function () { rz = null; });
1091    panel.querySelectorAll("[data-ratio]").forEach(function (b) {
1092      on(b, "click", function () {
1093        panel.querySelectorAll("[data-ratio]").forEach(function (x) { x.classList.remove("btn-primary"); });
1094        b.classList.add("btn-primary");
1095        var v = b.getAttribute("data-ratio");
1096        ratio = v === "free" ? null : parseFloat(v);
1097        resetBox();
1098      });
1099    });
1100    panel.querySelectorAll("[data-act=crop]").forEach(function (b) {
1101      on(b, "click", function () {
1102        var r = stageRect(), b2 = getBox(), scale = natW / r.width;
1103        var sx = b2.x * scale, sy = b2.y * scale, sw = b2.w * scale, sh = b2.h * scale;
1104        var c = document.createElement("canvas"); c.width = Math.round(sw); c.height = Math.round(sh);
1105        var im = new Image();
1106        im.onload = function () {
1107          c.getContext("2d").drawImage(im, sx, sy, sw, sh, 0, 0, sw, sh);
1108          c.toBlob(function (blob) { if (blob) downloadBlob(blob, "cropped.png"); }, "image/png");
1109        };
1110        im.src = img.src;
1111      });
1112    });
1113    window.addEventListener("resize", function () { if (!$("cropControls").hidden) resetBox(); });
1114  };
1115
1116  /* Image format converter */
1117  T.imageFormatConverter = function (panel) {
1118    var loaded = null;
1119    wireDrop("fmtDrop", "fmtFile", function (files) {
1120      loadImage(files[0]).then(function (im) { loaded = im; $("fmtControls").hidden = false; render(); });
1121    });
1122    function render() {
1123      if (!loaded) return;
1124      var type = $("fmtFormat").value, q = parseFloat($("fmtQuality").value);
1125      var c = document.createElement("canvas"); c.width = loaded.naturalWidth; c.height = loaded.naturalHeight;
1126      var ctx = c.getContext("2d");
1127      if (type === "image/jpeg") { ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, c.width, c.height); }
1128      ctx.drawImage(loaded, 0, 0);
1129      c.toBlob(function (blob) {
1130        if (!blob) return;
1131        var url = URL.createObjectURL(blob), ext = type.split("/")[1] === "jpeg" ? "jpg" : type.split("/")[1];
1132        $("fmtPreview").innerHTML = '<figure><img src="' + url + '"><figcaption>' + type.split("/")[1].toUpperCase() + " · " + humanSize(blob.size) + "</figcaption></figure>";
1133        $("fmtPreview")._blob = blob; $("fmtPreview")._ext = ext;
1134      }, type, q);
1135    }
1136    on($("fmtFormat"), "change", render);
1137    on($("fmtQuality"), "input", render);
1138    panel.querySelectorAll("[data-act=download]").forEach(function (b) {
1139      on(b, "click", function () {
1140        var p = $("fmtPreview"); if (p._blob) downloadBlob(p._blob, "converted." + p._ext);
1141      });
1142    });
1143  };
1144})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.