PoopMan333 commited on
Commit
3f92098
·
verified ·
1 Parent(s): 57449b0

Rename Pocket_Image_Converter_v1_6.html to Pocket_Image_Converter_v3_0.html

Browse files
Pocket_Image_Converter_v1_6.html → Pocket_Image_Converter_v3_0.html RENAMED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Pocket Image Converter</title>
7
  <style>
8
  :root{
9
  --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
@@ -26,6 +26,11 @@
26
  header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;
27
  border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)}
28
  header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8}
 
 
 
 
 
29
  .spacer{flex:1}
30
  button{
31
  font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);
@@ -124,7 +129,7 @@
124
  /* ---------- preview stage ---------- */
125
  .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;
126
  position:relative;border:1px solid var(--line);min-height:240px;margin-bottom:14px}
127
- .stage canvas{max-width:100%;max-height:52vh;width:auto;height:auto;display:block;background:#000}
128
  .badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);
129
  padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px}
130
  .ba-right{left:auto;right:10px;color:#bfe9ff}
@@ -132,13 +137,13 @@
132
  .row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
133
  .row label{flex:0 0 92px;color:var(--dim);font-size:12.5px}
134
  .row:last-child{margin-bottom:0}
135
- input[type=text],input[type=number],select{
136
  font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);
137
  border-radius:7px;padding:6px 8px;width:100%;min-width:0;
138
  }
139
  input[type=color]{padding:2px;height:31px;width:44px;flex:none;background:var(--panel2);
140
  border:1px solid var(--line);border-radius:7px;cursor:pointer}
141
- input:focus,select:focus{outline:none;border-color:var(--accent)}
142
  input[type=range]{width:100%;accent-color:var(--accent)}
143
  .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
144
  .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px;flex:1 1 auto}
@@ -171,12 +176,12 @@
171
  <body>
172
  <div class="wrap">
173
  <header>
174
- <h1>Pocket Image Converter</h1>
175
- <span class="sub">HEIC &amp; more &middot; convert &middot; resize &middot; batch</span>
176
  <span class="spacer"></span>
177
- <span class="verwrap"><span class="ver">v1.6</span><span class="by">by C_Nugget</span></span>
178
  </header>
179
- <div id="drop" role="button" tabindex="0" aria-label="Add images">
180
  <button id="dropToggle" class="ghost" type="button" title="Collapse / expand" aria-expanded="true">Hide <span class="chev">&#9650;</span></button>
181
  <span class="miniLabel"><b>Add more images</b> &middot; or drop them anywhere on the page</span>
182
  <div class="big">&#9634;</div>
@@ -222,6 +227,7 @@
222
  <span class="kbd">&larr;</span> <span class="kbd">&rarr;</span> move between images &nbsp;&middot;&nbsp;
223
  <span class="kbd">Space</span> toggle selected &nbsp;&middot;&nbsp;
224
  <span class="kbd">R</span> rotate &nbsp;&middot;&nbsp; <span class="kbd">Del</span> remove &nbsp;&middot;&nbsp;
 
225
  only <b style="color:var(--txt)">ticked</b> images are converted; edits apply to every image.
226
  </div>
227
  </div>
@@ -235,6 +241,7 @@
235
  <button data-fmt="image/jpeg">JPEG</button>
236
  <button data-fmt="image/webp">WebP</button>
237
  <button data-fmt="image/avif">AVIF</button>
 
238
  <button data-fmt="application/pdf">PDF</button>
239
  </div>
240
  <div class="row" id="qualityRow">
@@ -311,27 +318,45 @@
311
  <div class="hint">Rotate / flip applies to every image in the queue.</div>
312
  </div>
313
  </div>
314
- <!-- Watermark -->
315
  <div class="panelbox">
316
- <h3 class="collap shut" data-target="wmBody">Watermark <span class="chev">&#9660;</span></h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  <div class="collapbody shut" id="wmBody">
318
- <div class="row"><label>Text</label><input type="text" id="wmText" placeholder="Leave blank for none"></div>
319
- <div class="row">
320
- <label>Position</label>
321
- <select id="wmPos">
322
- <option value="br" selected>Bottom right</option>
323
- <option value="bl">Bottom left</option>
324
- <option value="tr">Top right</option>
325
- <option value="tl">Top left</option>
326
- <option value="c">Centre</option>
327
- </select>
328
- </div>
329
- <div class="row">
330
- <label>Size</label>
331
- <select id="wmSize"><option value="0.03">Small</option><option value="0.05" selected>Medium</option><option value="0.08">Large</option></select>
332
- <input type="color" id="wmColor" value="#ffffff" title="Text colour">
333
- </div>
334
- <div class="row"><label>Opacity</label><input type="range" id="wmOpacity" min="10" max="100" value="70"><b class="hint" id="wmOpacityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">70%</b></div>
 
 
 
 
 
335
  </div>
336
  </div>
337
  <!-- Naming -->
@@ -393,6 +418,7 @@
393
  </div>
394
  </div>
395
  <canvas id="work" style="display:none"></canvas>
 
396
  <script>
397
  "use strict";
398
  (function(){
@@ -411,7 +437,7 @@ function safeName(s, fallback){
411
  return String(s || fallback).replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,90) || fallback;
412
  }
413
  function extFor(mime){
414
- return ({"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/avif":"avif","application/pdf":"pdf"})[mime] || "img";
415
  }
416
  function isLossy(mime){ return mime==="image/jpeg" || mime==="image/webp" || mime==="image/avif"; }
417
  function say(el, msg, kind){ el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); }
@@ -426,16 +452,29 @@ async function supportsMime(mime){
426
  }catch(e){ return false; }
427
  }
428
  /* ======================= state ======================= */
429
- const items = []; // {id,file,name,selected,bitmap,w,h,status,outName,outSize,thumbUrl,err}
 
 
 
 
 
430
  let sel = -1, uid = 0, exporting = false, cancel = false, view = "preview";
431
  let outFmt = "image/png";
432
  const support = {};
433
  /* ======================= collapsibles ======================= */
434
  document.querySelectorAll("h3.collap").forEach(h => {
435
- h.addEventListener("click", () => {
 
 
 
436
  h.classList.toggle("shut");
437
  const b = $(h.dataset.target);
438
  if(b) b.classList.toggle("shut");
 
 
 
 
 
439
  });
440
  });
441
  /* ======================= loading files ======================= */
@@ -443,10 +482,7 @@ const drop = $("drop");
443
  $("pickFiles").addEventListener("click", e => { e.stopPropagation(); $("fileInput").click(); });
444
  $("pickFolder").addEventListener("click", e => { e.stopPropagation(); $("folderInput").click(); });
445
  drop.addEventListener("click", e => { if(e.target.closest("button")) return; $("fileInput").click(); });
446
- drop.addEventListener("keydown", e => {
447
- if(e.target !== drop) return;
448
- if(e.key === "Enter" || e.key === " "){ e.preventDefault(); $("fileInput").click(); }
449
- });
450
  $("fileInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; });
451
  $("folderInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; });
452
 
@@ -534,23 +570,46 @@ function looksImage(f){
534
  return (f.type && f.type.startsWith("image/")) ||
535
  /\.(heic|heif|heics|jpe?g|jfif|pjpeg|pjp|png|webp|gif|bmp|tiff?|avif|ico)$/i.test(f.name);
536
  }
 
 
 
 
 
 
 
537
  async function addFiles(fileList){
 
538
  const files = Array.from(fileList).filter(looksImage);
539
  if(!files.length){ say($("status"), "Those files are not images. Add HEIC, JPEG, PNG, WebP, GIF or BMP.", "err"); return; }
540
  say($("status"), "Reading " + files.length + (files.length === 1 ? " image\u2026" : " images\u2026"));
541
  const firstNew = items.length;
542
  for(const f of files){
543
- items.push({ id: ++uid, file: f, name: f.name, selected: true, status: "queued",
544
- bitmap: null, w: 0, h: 0, outName: "", outSize: 0, thumbUrl: "", err: "" });
545
  }
546
  render();
547
  autoCollapseDrop();
548
- for(let i = firstNew; i < items.length; i++){
549
- if(!items[i].bitmap && items[i].status !== "error") await decode(items[i]);
550
- render();
 
 
 
 
 
 
 
 
 
 
 
 
551
  }
552
- if(sel < 0 && items.length){ sel = 0; drawPreview(); }
553
- say($("status"), items.length + " image" + (items.length===1?"":"s") + " ready.", "ok");
 
 
 
554
  updateEstimate();
555
  }
556
  const isHeic = f => /\.(heic|heif)$/i.test(f.name) || f.type === "image/heic" || f.type === "image/heif" || f.type === "image/heic-sequence";
@@ -610,7 +669,7 @@ async function decodeNative(blob){
610
  }
611
  }
612
  async function decode(it){
613
- it.status = "decoding"; render();
614
  try{
615
  let bmp = await decodeNative(it.file);
616
  if(!bmp && isHeic(it.file)) bmp = await decodeViaWebCodecs(it.file);
@@ -629,12 +688,26 @@ async function decode(it){
629
  if(!bmp) throw new Error(isHeic(it.file)
630
  ? "HEIC needs a codec this browser lacks — see the offline HEIC note, or open in Safari"
631
  : "this browser can't read that format");
632
- it.bitmap = bmp; it.w = bmp.width; it.h = bmp.height; it.status = "ready";
 
 
633
  it.thumbUrl = makeThumb(bmp);
634
  }catch(e){
635
  it.status = "error"; it.err = e.message || "could not decode";
636
  }
637
  }
 
 
 
 
 
 
 
 
 
 
 
 
638
  function loadViaImg(blob){
639
  return new Promise((res, rej) => {
640
  const url = URL.createObjectURL(blob), img = new Image();
@@ -662,9 +735,14 @@ $("flipH").addEventListener("click", () => { flipH = !flipH; drawPreview(); });
662
  $("flipV").addEventListener("click", () => { flipV = !flipV; drawPreview(); });
663
  $("resetAdj").addEventListener("click", () => { rot = 0; flipH = flipV = false; drawPreview(); updateEstimate(); });
664
  $("autoExif").addEventListener("change", async () => {
665
- for(const it of items){ if(it.bitmap){ try{ it.bitmap.close && it.bitmap.close(); }catch(_){} it.bitmap = null; it.status = "queued"; } }
 
 
 
 
666
  render();
667
- for(const it of items) if(it.status !== "error") { await decode(it); render(); }
 
668
  drawPreview();
669
  });
670
  function targetSize(sw, sh){
@@ -684,54 +762,88 @@ function targetSize(sw, sh){
684
  }
685
  return { w: Math.max(1, Math.round(w)), h: Math.max(1, Math.round(h)) };
686
  }
687
- function renderToCanvas(bmp){
688
- const swapped = (rot === 90 || rot === 270);
689
- const bw = swapped ? bmp.height : bmp.width;
690
- const bh = swapped ? bmp.width : bmp.height;
691
- const t = targetSize(bw, bh);
692
- const cv = $("work");
693
- cv.width = t.w; cv.height = t.h;
694
- const g = cv.getContext("2d");
695
- g.imageSmoothingEnabled = true; g.imageSmoothingQuality = "high";
696
- const indexedPng = (outFmt === "image/png" && (+$("pngColors").value||0) > 0);
697
- // JPEG/PDF have no alpha; indexed PNG flattens alpha. Lossless PNG keeps alpha.
698
- if(outFmt === "image/jpeg" || outFmt === "application/pdf" || indexedPng){ g.fillStyle = "#fff"; g.fillRect(0,0,t.w,t.h); }
699
- g.save();
700
- g.translate(t.w/2, t.h/2);
701
- g.rotate(rot * Math.PI/180);
702
- g.scale(flipH ? -1 : 1, flipV ? -1 : 1);
703
- const drawW = swapped ? t.h : t.w;
704
- const drawH = swapped ? t.w : t.h;
705
- g.drawImage(bmp, -drawW/2, -drawH/2, drawW, drawH);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  g.restore();
707
- drawWatermark(g, t.w, t.h);
 
708
  return cv;
709
  }
710
- function drawWatermark(g, W, H){
711
- const text = ($("wmText").value || "").trim();
712
- if(!text) return;
713
- const size = Math.max(11, Math.round(Math.min(W,H) * (parseFloat($("wmSize").value)||0.05)));
714
- g.font = "600 " + size + "px -apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif";
715
- g.textBaseline = "alphabetic";
716
- const pad = Math.round(size*0.6);
717
- const w = g.measureText(text).width;
718
- const pos = $("wmPos").value;
719
- let x = W - w - pad, y = H - pad;
720
- if(pos === "bl"){ x = pad; }
721
- else if(pos === "tr"){ y = size + pad; }
722
- else if(pos === "tl"){ x = pad; y = size + pad; }
723
- else if(pos === "c"){ x = (W - w)/2; y = (H + size)/2; }
724
- g.globalAlpha = (+$("wmOpacity").value||70)/100;
725
- g.fillStyle = "rgba(0,0,0,.45)";
726
- g.fillText(text, x+Math.max(1,size*0.04), y+Math.max(1,size*0.04));
727
- g.fillStyle = $("wmColor").value || "#ffffff";
728
- g.fillText(text, x, y);
729
- g.globalAlpha = 1;
 
 
 
 
 
 
 
 
 
 
 
 
730
  }
 
731
  /* ======================= preview ======================= */
732
  function drawPreview(){
733
  const cv = $("preview"), g = cv.getContext("2d");
734
  const it = items[sel];
 
735
  if(!it || !it.bitmap){
736
  cv.width = 640; cv.height = 360;
737
  g.fillStyle = "#000"; g.fillRect(0,0,cv.width,cv.height);
@@ -739,13 +851,24 @@ function drawPreview(){
739
  $("outBadge").textContent = "";
740
  return;
741
  }
742
- const out = renderToCanvas(it.bitmap);
 
 
743
  cv.width = out.width; cv.height = out.height;
744
  g.drawImage(out, 0, 0);
745
- $("srcBadge").textContent = it.name + " \u00b7 " + it.w + "\u00d7" + it.h + (isHeic(it.file) ? " \u00b7 HEIC" : "");
746
- $("outBadge").textContent = out.width + "\u00d7" + out.height + " \u00b7 " + fmtLabel(outFmt);
 
 
 
 
 
 
 
 
 
747
  }
748
- function fmtLabel(m){ return ({"image/png":"PNG","image/jpeg":"JPEG","image/webp":"WebP","image/avif":"AVIF","application/pdf":"PDF"})[m] || m; }
749
  /* ======================= view toggle ======================= */
750
  $("viewTabs").addEventListener("click", e => {
751
  const b = e.target.closest("button"); if(!b) return;
@@ -757,8 +880,7 @@ $("viewTabs").addEventListener("click", e => {
757
  if(view === "grid") renderGrid(); else drawPreview();
758
  });
759
  /* ======================= selection helpers ======================= */
760
- function selectableCount(){ return items.filter(it => it.status !== "error").length; }
761
- function selectedReady(){ return items.filter(it => it.selected && it.status !== "error" && it.bitmap); }
762
  $("selAll").addEventListener("click", () => { items.forEach(it => { if(it.status !== "error") it.selected = true; }); render(); });
763
  $("selNone").addEventListener("click", () => { items.forEach(it => it.selected = false); render(); });
764
  /* ======================= queue rendering ======================= */
@@ -776,7 +898,7 @@ function renderList(){
776
  items.forEach((it, i) => {
777
  const row = document.createElement("div");
778
  row.className = "qrow" + (i === sel ? " on" : "");
779
- row.addEventListener("click", () => { sel = i; drawPreview(); render(); });
780
  const chk = document.createElement("input");
781
  chk.type = "checkbox"; chk.checked = !!it.selected; chk.title = "Include in conversion";
782
  chk.disabled = it.status === "error";
@@ -812,7 +934,7 @@ function renderGrid(){
812
  items.forEach((it, i) => {
813
  const card = document.createElement("div");
814
  card.className = "gcard" + (i === sel ? " on" : "");
815
- card.addEventListener("click", () => { sel = i; if(view==="preview") drawPreview(); render(); });
816
  const chk = document.createElement("input");
817
  chk.type = "checkbox"; chk.className = "gchk"; chk.checked = !!it.selected;
818
  chk.disabled = it.status === "error"; chk.title = "Include in conversion";
@@ -835,15 +957,19 @@ function renderGrid(){
835
  g.appendChild(card);
836
  });
837
  }
 
 
 
 
838
  function removeAt(i){
839
  if(exporting) return;
840
- const it = items[i]; if(it && it.bitmap){ try{ it.bitmap.close && it.bitmap.close(); }catch(_){} }
841
  items.splice(i, 1);
842
  if(sel >= items.length) sel = items.length - 1;
843
  if(!items.length){ dropAutoDone = false; setDropMini(false); }
844
  render(); if(view==="preview") drawPreview();
845
  }
846
- $("clearAll").addEventListener("click", () => { if(exporting) return; items.forEach(it => it.bitmap && it.bitmap.close && it.bitmap.close()); items.length = 0; sel = -1; dropAutoDone = false; setDropMini(false); render(); drawPreview(); say($("status"),""); });
847
  $("clearDone").addEventListener("click", () => {
848
  if(exporting) return;
849
  for(let i = items.length-1; i>=0; i--) if(items[i].status === "done") removeAt(i);
@@ -851,7 +977,8 @@ $("clearDone").addEventListener("click", () => {
851
  /* ======================= settings wiring ======================= */
852
  function syncFmtUI(){
853
  const lossy = isLossy(outFmt);
854
- $("qualityRow").style.display = lossy ? "flex" : "none";
 
855
  $("targetChk").style.display = lossy ? "flex" : "none";
856
  $("targetRow").style.display = (lossy && $("targetOn").checked) ? "flex" : "none";
857
  $("pngOpts").style.display = outFmt === "image/png" ? "block" : "none";
@@ -865,10 +992,12 @@ function syncFmtUI(){
865
  else if(outFmt === "image/jpeg") hint = "JPEG is best for photos. No transparency — transparent areas become white.";
866
  else if(outFmt === "image/webp") hint = "WebP gives smaller files than JPEG at similar quality, with transparency support.";
867
  else if(outFmt === "image/avif") hint = support["image/avif"] === false ? "\u26a0 Your browser can\u2019t encode AVIF — try Chrome or Edge, or pick WebP." : "AVIF is the smallest for photos, but slower to encode. Chrome/Edge only.";
868
- else if(outFmt === "application/pdf") hint = "Each ticked image becomes one PDF page (embedded as JPEG) in a single document.";
 
869
  $("fmtHint").textContent = hint;
870
  }
871
  $("fmtSeg").addEventListener("click", async e => {
 
872
  const b = e.target.closest("button"); if(!b || b.disabled) return;
873
  document.querySelectorAll("#fmtSeg button").forEach(x => x.classList.toggle("on", x === b));
874
  outFmt = b.dataset.fmt;
@@ -891,15 +1020,40 @@ $("resizeMode").addEventListener("click", e => {
891
  drawPreview(); updateEstimate();
892
  });
893
  ["pct","maxPreset"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); }));
894
- $("pxW").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxH").value = Math.round((+$("pxW").value||1) * it.h / it.w); } drawPreview(); updateEstimate(); });
895
- $("pxH").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxW").value = Math.round((+$("pxH").value||1) * it.w / it.h); } drawPreview(); updateEstimate(); });
896
- ["wmText","wmPos","wmSize","wmColor"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); }));
897
- $("wmOpacity").addEventListener("input", () => { $("wmOpacityL").textContent = $("wmOpacity").value + "%"; drawPreview(); });
898
  $("nameMode").addEventListener("click", e => {
899
  const b = e.target.closest("button"); if(!b) return;
900
  document.querySelectorAll("#nameMode button").forEach(x => x.classList.toggle("on", x === b));
901
  $("tplRows").style.display = b.dataset.m === "tpl" ? "block" : "none";
902
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  /* ======================= estimate ======================= */
904
  let estTimer = 0, estToken = 0;
905
  function updateEstimate(){
@@ -910,17 +1064,18 @@ function updateEstimate(){
910
  $("exportBtn").disabled = exporting || selN === 0;
911
  const it = items[sel];
912
  if(!it || !it.bitmap){ $("eDims").textContent = "\u2014"; $("eBytes").textContent = "\u2014"; return; }
913
- const out = renderToCanvas(it.bitmap);
914
- $("eDims").textContent = out.width + "\u00d7" + out.height;
915
  clearTimeout(estTimer);
916
  const token = ++estToken;
917
  estTimer = setTimeout(async () => {
918
  try{
919
  const cur = items[sel];
920
- if(!cur || !cur.bitmap){ $("eBytes").textContent = "\u2014"; return; }
921
- // re-render now: #work is shared, so the canvas captured above may be stale by this point
922
- const blob = await encodeCanvas(renderToCanvas(cur.bitmap), false);
923
- if(token !== estToken) return; // a newer estimate started, drop this one
 
924
  $("eBytes").textContent = blob ? human(blob.size) : "\u2014";
925
  }catch(e){ if(token === estToken) $("eBytes").textContent = "\u2014"; }
926
  }, 300);
@@ -929,6 +1084,7 @@ function updateEstimate(){
929
  function canvasToBlob(cv, mime, q){ return new Promise(res => cv.toBlob(res, mime, q)); }
930
  async function encodeCanvas(cv, doTarget){
931
  if(outFmt === "application/pdf") return makePdf([cv]);
 
932
  if(outFmt === "image/png"){
933
  const colors = +$("pngColors").value || 0;
934
  if(colors > 0) return encodeIndexedPNG(cv, colors, $("pngDither").checked);
@@ -1110,6 +1266,21 @@ async function encodeIndexedPNG(cv, colors, dither){
1110
  const plte = pngChunk("PLTE", pal.subarray(0, count*3));
1111
  return assemblePNG(w, h, 3, [plte], idat);
1112
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1113
  /* ---------- PDF: one page per image, JPEG embedded (DCTDecode) ---------- */
1114
  async function makePdf(canvases){
1115
  const q = clamp((+$("quality").value||82)/100, 0.4, 0.95);
@@ -1127,11 +1298,15 @@ async function makePdf(canvases){
1127
  start(); push("2 0 obj\n<< /Type /Pages /Count " + N + " /Kids [ " + kids.join(" ") + " ] >>\nendobj\n");
1128
  for(let i=0;i<N;i++){
1129
  const cv = canvases[i], w = cv.width, h = cv.height;
 
 
 
 
1130
  const pageObj = 3 + i*3, contentObj = pageObj + 1, imgObj = pageObj + 2;
1131
  start();
1132
- push(pageObj + " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + w + " " + h + "] " +
1133
  "/Resources << /XObject << /Im0 " + imgObj + " 0 R >> >> /Contents " + contentObj + " 0 R >>\nendobj\n");
1134
- const content = "q\n" + w + " 0 0 " + h + " 0 0 cm\n/Im0 Do\nQ\n";
1135
  start();
1136
  push(contentObj + " 0 obj\n<< /Length " + content.length + " >>\nstream\n" + content + "endstream\nendobj\n");
1137
  const data = imgDatas[i];
@@ -1204,8 +1379,9 @@ $("exportBtn").addEventListener("click", async () => {
1204
  if(cancel) break;
1205
  it.status = "working"; render();
1206
  try{
1207
- const cv = renderToCanvas(it.bitmap);
1208
- const blob = await encodeCanvas(cv, true);
 
1209
  if(!blob || !blob.size) throw new Error("empty output");
1210
  const name = uniqueName(outNameFor(it, i, cv.width, cv.height), usedNames);
1211
  it.outName = name; it.outSize = blob.size;
@@ -1217,7 +1393,7 @@ $("exportBtn").addEventListener("click", async () => {
1217
  if(ready.length > 1) await wait(300);
1218
  }
1219
  it.status = "done"; done++;
1220
- }catch(e){ it.status = "error"; it.err = e.message || "convert failed"; failed++; }
1221
  i++;
1222
  $("barI").style.width = ((done+failed)/ready.length*100).toFixed(1) + "%";
1223
  render();
@@ -1237,7 +1413,9 @@ async function exportPdf(ready){
1237
  const canvases = [];
1238
  for(let k=0;k<ready.length;k++){
1239
  if(cancel) break;
1240
- const cv = renderToCanvas(ready[k].bitmap);
 
 
1241
  const c2 = document.createElement("canvas"); c2.width = cv.width; c2.height = cv.height;
1242
  c2.getContext("2d").drawImage(cv, 0, 0);
1243
  canvases.push(c2);
@@ -1267,11 +1445,12 @@ window.addEventListener("beforeunload", e => { if(exporting){ e.preventDefault()
1267
  /* ======================= keyboard ======================= */
1268
  document.addEventListener("keydown", e => {
1269
  const tag = (e.target.tagName||"").toLowerCase();
1270
- if(tag === "input" || tag === "select" || tag === "textarea") return;
 
1271
  if(e.metaKey || e.ctrlKey || e.altKey) return;
1272
  if(exporting) return;
1273
- if(e.key === "ArrowLeft"){ e.preventDefault(); if(sel > 0){ sel--; drawPreview(); render(); } }
1274
- else if(e.key === "ArrowRight"){ e.preventDefault(); if(sel < items.length-1){ sel++; drawPreview(); render(); } }
1275
  else if(e.key === " "){ e.preventDefault(); const it=items[sel]; if(it && it.status!=="error"){ it.selected=!it.selected; render(); } }
1276
  else if(e.key === "r" || e.key === "R"){ e.preventDefault(); rot = (rot+90)%360; drawPreview(); updateEstimate(); }
1277
  else if(e.key === "Delete" || e.key === "Backspace"){ if(sel>=0){ e.preventDefault(); removeAt(sel); } }
@@ -1285,9 +1464,11 @@ document.addEventListener("keydown", e => {
1285
  if(b){ b.disabled = true; b.title = "Not supported by this browser"; b.style.opacity = ".4"; b.style.cursor = "not-allowed"; }
1286
  }
1287
  syncFmtUI();
 
1288
  drawPreview(); render();
1289
  })();
1290
  })();
1291
  </script>
 
1292
  </body>
1293
  </html>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Pocket Image Editor</title>
7
  <style>
8
  :root{
9
  --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
 
26
  header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;
27
  border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)}
28
  header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8}
29
+
30
+ .profileLink{text-decoration:none;color:inherit;border-radius:10px;padding:2px 3px;transition:.12s}
31
+ .profileLink:hover{background:var(--panel2)}
32
+ .profileLink:hover .ver{border-color:var(--accent);color:var(--accent)}
33
+ .profileLink:hover .by{color:var(--txt);opacity:1}
34
  .spacer{flex:1}
35
  button{
36
  font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);
 
129
  /* ---------- preview stage ---------- */
130
  .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;
131
  position:relative;border:1px solid var(--line);min-height:240px;margin-bottom:14px}
132
+ .stage canvas{max-width:100%; touch-action:none; user-select:none; cursor:default;max-height:52vh;width:auto;height:auto;display:block;background:#000}
133
  .badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);
134
  padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px}
135
  .ba-right{left:auto;right:10px;color:#bfe9ff}
 
137
  .row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
138
  .row label{flex:0 0 92px;color:var(--dim);font-size:12.5px}
139
  .row:last-child{margin-bottom:0}
140
+ input[type=text],input[type=number],textarea,select{
141
  font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);
142
  border-radius:7px;padding:6px 8px;width:100%;min-width:0;
143
  }
144
  input[type=color]{padding:2px;height:31px;width:44px;flex:none;background:var(--panel2);
145
  border:1px solid var(--line);border-radius:7px;cursor:pointer}
146
+ input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
147
  input[type=range]{width:100%;accent-color:var(--accent)}
148
  .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
149
  .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px;flex:1 1 auto}
 
176
  <body>
177
  <div class="wrap">
178
  <header>
179
+ <h1>Pocket Image Editor</h1>
180
+ <span class="sub">convert &middot; resize &middot; GIF timing &middot; captions &amp; memes</span>
181
  <span class="spacer"></span>
182
+ <a class="verwrap profileLink" href="https://huggingface.co/PoopMan333" target="_blank" rel="noopener noreferrer" title="Open PoopMan333 on Hugging Face"><span class="ver">v3.0</span><span class="by">by C_Nugget</span></a>
183
  </header>
184
+ <div id="drop" aria-label="Add images">
185
  <button id="dropToggle" class="ghost" type="button" title="Collapse / expand" aria-expanded="true">Hide <span class="chev">&#9650;</span></button>
186
  <span class="miniLabel"><b>Add more images</b> &middot; or drop them anywhere on the page</span>
187
  <div class="big">&#9634;</div>
 
227
  <span class="kbd">&larr;</span> <span class="kbd">&rarr;</span> move between images &nbsp;&middot;&nbsp;
228
  <span class="kbd">Space</span> toggle selected &nbsp;&middot;&nbsp;
229
  <span class="kbd">R</span> rotate &nbsp;&middot;&nbsp; <span class="kbd">Del</span> remove &nbsp;&middot;&nbsp;
230
+ <span class="kbd">Ctrl</span>+<span class="kbd">V</span> paste an image &nbsp;&middot;&nbsp;
231
  only <b style="color:var(--txt)">ticked</b> images are converted; edits apply to every image.
232
  </div>
233
  </div>
 
241
  <button data-fmt="image/jpeg">JPEG</button>
242
  <button data-fmt="image/webp">WebP</button>
243
  <button data-fmt="image/avif">AVIF</button>
244
+ <button data-fmt="image/gif">GIF</button>
245
  <button data-fmt="application/pdf">PDF</button>
246
  </div>
247
  <div class="row" id="qualityRow">
 
318
  <div class="hint">Rotate / flip applies to every image in the queue.</div>
319
  </div>
320
  </div>
 
321
  <div class="panelbox">
322
+ <h3 class="collap shut" data-target="cropBody">Crop &amp; straighten <span class="chev">&#9660;</span></h3>
323
+ <div class="collapbody shut" id="cropBody">
324
+ <div class="row"><label>Preset</label><select id="cropPreset"><option value="free">Free crop</option><option value="1:1">Square 1:1</option><option value="4:3">4:3</option><option value="3:2">3:2</option><option value="16:9">16:9</option><option value="9:16">Story 9:16</option></select></div>
325
+ <div class="row"><label>Left</label><input type="range" id="cropL" min="0" max="45" value="0"><b class="hint" id="cropLL" style="margin:0;flex:0 0 34px">0%</b></div>
326
+ <div class="row"><label>Right</label><input type="range" id="cropR" min="0" max="45" value="0"><b class="hint" id="cropRL" style="margin:0;flex:0 0 34px">0%</b></div>
327
+ <div class="row"><label>Top</label><input type="range" id="cropT" min="0" max="45" value="0"><b class="hint" id="cropTL" style="margin:0;flex:0 0 34px">0%</b></div>
328
+ <div class="row"><label>Bottom</label><input type="range" id="cropB" min="0" max="45" value="0"><b class="hint" id="cropBL" style="margin:0;flex:0 0 34px">0%</b></div>
329
+ <div class="row"><label>Straighten</label><input type="range" id="straighten" min="-15" max="15" step="0.1" value="0"><b class="hint" id="straightenL" style="margin:0;flex:0 0 42px">0°</b></div>
330
+ <button id="resetCrop" type="button" class="ghost" style="width:100%">Reset crop</button>
331
+ <div class="hint">Crop is non-destructive and applies to preview and export. Straightening trims the rotated edges.</div>
332
+ </div>
333
+ </div>
334
+ <!-- Text and meme editor -->
335
+ <div class="panelbox">
336
+ <h3 class="collap shut" data-target="wmBody">Text &amp; meme <span class="chev">&#9660;</span></h3>
337
  <div class="collapbody shut" id="wmBody">
338
+ <div class="row" style="align-items:flex-start"><label>Text layers</label><div style="flex:1"><select id="textLayerList" size="3" style="margin-bottom:6px"></select><div class="toolbtns" style="margin:0"><button id="addTextLayer" type="button">+ Add</button><button id="dupTextLayer" type="button">Duplicate</button><button id="delTextLayer" type="button" class="danger">Delete</button></div></div></div>
339
+ <div class="row"><label>Style</label><select id="textMode"><option value="none">None</option><option value="bubble">WhatsApp-style bubble</option><option value="meme">Meme text</option><option value="topbox">Black box above</option><option value="bottombox">Black box below</option></select></div>
340
+ <div class="row"><label>Text</label><textarea id="wmText" rows="3" placeholder="Type text or emoji 😀"></textarea></div>
341
+ <div class="hint" style="margin:-4px 0 10px 100px">Emoji work in the text box — copy one from anywhere and paste it in. Whether it draws depends on the emoji your operating system has; very new ones may come out as an empty box.</div>
342
+ <div class="row"><label>Position</label><select id="wmPos"><option value="tl">Top left</option><option value="tc">Top centre</option><option value="tr">Top right</option><option value="c">Centre</option><option value="bl">Bottom left</option><option value="bc" selected>Bottom centre</option><option value="br">Bottom right</option></select></div>
343
+ <div class="row"><label>Font</label><select id="wmFont"><option value="Impact,Arial Black,sans-serif">Impact / meme</option><option value="Arial,Segoe UI,sans-serif" selected>Arial</option><option value="Georgia,serif">Georgia</option><option value="Comic Sans MS,cursive">Comic Sans</option><option value="system-ui,sans-serif">System / emoji</option></select></div>
344
+ <div class="row"><label>Size</label><input type="range" id="wmSize" min="2" max="30" step="0.1" value="7"><b class="hint" id="wmSizeL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">7%</b></div>
345
+ <div class="row"><label>Text colour</label><input type="color" id="wmColor" value="#ffffff"><span class="spacer"></span><label style="flex:0 0 auto">Background</label><input type="color" id="wmBg" value="#000000"></div>
346
+ <div class="hint" id="dragHint">For WhatsApp bubbles, drag the text directly on the preview. Drag the round handle to rotate. Drag the square corner to increase or decrease the text and box size together. Editing handles appear only in the preview, never in saved files.</div>
347
+ <div class="row"><label>Padding</label><input type="range" id="wmPad" min="0" max="100" value="45"><b class="hint" id="wmPadL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">45%</b></div>
348
+ <div class="row"><label>Opacity</label><input type="range" id="wmOpacity" min="10" max="100" value="95"><b class="hint" id="wmOpacityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">95%</b></div>
349
+ <label class="chk"><input type="checkbox" id="wmStroke" checked> Outline text <span class="sub">(recommended for memes)</span></label>
350
+ </div>
351
+ </div>
352
+ <div class="panelbox">
353
+ <h3 class="collap shut" data-target="gifBody">Animated GIF <span class="chev">&#9660;</span></h3>
354
+ <div class="collapbody shut" id="gifBody">
355
+ <div class="row"><label>Playback speed</label><input type="range" id="gifSpeed" min="25" max="400" value="100"><b class="hint" id="gifSpeedL" style="margin:0;flex:0 0 46px;text-align:right;color:var(--txt)">100%</b></div>
356
+ <label class="chk"><input type="checkbox" id="gifLoop" checked> Loop animation</label>
357
+ <label class="chk"><input type="checkbox" id="gifTransparent" checked> Preserve transparent pixels</label>
358
+ <div class="row"><label>Alpha cutoff</label><input type="range" id="gifAlpha" min="1" max="254" value="128"><b class="hint" id="gifAlphaL" style="margin:0;flex:0 0 38px">128</b></div>
359
+ <div class="hint">Animated GIFs use all decoded frames when the browser supports ImageDecoder. If it does not, the first frame is still editable.</div>
360
  </div>
361
  </div>
362
  <!-- Naming -->
 
418
  </div>
419
  </div>
420
  <canvas id="work" style="display:none"></canvas>
421
+
422
  <script>
423
  "use strict";
424
  (function(){
 
437
  return String(s || fallback).replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,90) || fallback;
438
  }
439
  function extFor(mime){
440
+ return ({"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/avif":"avif","image/gif":"gif","application/pdf":"pdf"})[mime] || "img";
441
  }
442
  function isLossy(mime){ return mime==="image/jpeg" || mime==="image/webp" || mime==="image/avif"; }
443
  function say(el, msg, kind){ el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); }
 
452
  }catch(e){ return false; }
453
  }
454
  /* ======================= state ======================= */
455
+ const items = []; // images, optionally with animated GIF frames
456
+ const PREVIEW_CAP = 1600; // longest side rendered in the preview; export is always full size
457
+ let previewTimer = 0, previewFrame = 0;
458
+ let bubbleHit=null,dragState=null;
459
+ let crop={l:0,r:0,t:0,b:0,angle:0};
460
+ let textLayers=[{id:1,mode:"bubble",text:"",pos:"bc",font:"Arial,Segoe UI,sans-serif",size:7,color:"#ffffff",bg:"#000000",pad:45,opacity:95,stroke:false,x:.5,y:.82,rot:0}],activeText=0,nextTextId=2;
461
  let sel = -1, uid = 0, exporting = false, cancel = false, view = "preview";
462
  let outFmt = "image/png";
463
  const support = {};
464
  /* ======================= collapsibles ======================= */
465
  document.querySelectorAll("h3.collap").forEach(h => {
466
+ h.setAttribute("role","button");
467
+ h.setAttribute("tabindex","0");
468
+ h.setAttribute("aria-expanded", String(!h.classList.contains("shut")));
469
+ const toggle = () => {
470
  h.classList.toggle("shut");
471
  const b = $(h.dataset.target);
472
  if(b) b.classList.toggle("shut");
473
+ h.setAttribute("aria-expanded", String(!h.classList.contains("shut")));
474
+ };
475
+ h.addEventListener("click", toggle);
476
+ h.addEventListener("keydown", e => {
477
+ if(e.key === "Enter" || e.key === " "){ e.preventDefault(); toggle(); }
478
  });
479
  });
480
  /* ======================= loading files ======================= */
 
482
  $("pickFiles").addEventListener("click", e => { e.stopPropagation(); $("fileInput").click(); });
483
  $("pickFolder").addEventListener("click", e => { e.stopPropagation(); $("folderInput").click(); });
484
  drop.addEventListener("click", e => { if(e.target.closest("button")) return; $("fileInput").click(); });
485
+
 
 
 
486
  $("fileInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; });
487
  $("folderInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; });
488
 
 
570
  return (f.type && f.type.startsWith("image/")) ||
571
  /\.(heic|heif|heics|jpe?g|jfif|pjpeg|pjp|png|webp|gif|bmp|tiff?|avif|ico)$/i.test(f.name);
572
  }
573
+ let _rTimer = 0;
574
+ function renderSoon(){ if(_rTimer) return; _rTimer = setTimeout(() => { _rTimer = 0; render(); }, 120); }
575
+ /* paste an image straight from the clipboard */
576
+ window.addEventListener("paste", e => {
577
+ const f = e.clipboardData && e.clipboardData.files;
578
+ if(f && f.length){ e.preventDefault(); addFiles(f); }
579
+ });
580
  async function addFiles(fileList){
581
+ if(exporting){ say($("status"), "Finish or stop the current export before adding more images.", "err"); return; }
582
  const files = Array.from(fileList).filter(looksImage);
583
  if(!files.length){ say($("status"), "Those files are not images. Add HEIC, JPEG, PNG, WebP, GIF or BMP.", "err"); return; }
584
  say($("status"), "Reading " + files.length + (files.length === 1 ? " image\u2026" : " images\u2026"));
585
  const firstNew = items.length;
586
  for(const f of files){
587
+ items.push({ id: ++uid, file: f, name: f.name, selected: true, status: "queued", gifTotalFrames:0, gifTruncated:false,
588
+ bitmap: null, frames: null, delays: null, loops: 0, w: 0, h: 0, outName: "", outSize: 0, thumbUrl: "", err: "" });
589
  }
590
  render();
591
  autoCollapseDrop();
592
+ const pending = items.slice(firstNew);
593
+ const worker = async () => {
594
+ while(pending.length){
595
+ const it = pending.shift();
596
+ if(!it.bitmap && it.status !== "error") await decode(it);
597
+ renderSoon();
598
+ }
599
+ };
600
+ await Promise.all([worker(), worker(), worker()]);
601
+ clearTimeout(_rTimer); _rTimer = 0; render();
602
+ if(sel < 0 && items.length) sel = 0;
603
+ if(firstNew === 0 && files.length && files.every(f => /\.gif$/i.test(f.name) || f.type === "image/gif")){
604
+ outFmt = "image/gif";
605
+ document.querySelectorAll("#fmtSeg button").forEach(x => x.classList.toggle("on", x.dataset.fmt === outFmt));
606
+ syncFmtUI();
607
  }
608
+ drawPreview();
609
+ const bad = items.filter(it => it.status === "error").length;
610
+ say($("status"),
611
+ items.length + " image" + (items.length===1?"":"s") + " ready" + (bad ? " \u00b7 " + bad + " could not be read" : "") + ".",
612
+ bad ? "err" : "ok");
613
  updateEstimate();
614
  }
615
  const isHeic = f => /\.(heic|heif)$/i.test(f.name) || f.type === "image/heic" || f.type === "image/heif" || f.type === "image/heic-sequence";
 
669
  }
670
  }
671
  async function decode(it){
672
+ it.status = "decoding"; renderSoon();
673
  try{
674
  let bmp = await decodeNative(it.file);
675
  if(!bmp && isHeic(it.file)) bmp = await decodeViaWebCodecs(it.file);
 
688
  if(!bmp) throw new Error(isHeic(it.file)
689
  ? "HEIC needs a codec this browser lacks — see the offline HEIC note, or open in Safari"
690
  : "this browser can't read that format");
691
+ it.bitmap = bmp; it.w = bmp.width; it.h = bmp.height;
692
+ if(/\.gif$/i.test(it.name) || it.file.type === "image/gif") await decodeGifFrames(it);
693
+ it.status = "ready";
694
  it.thumbUrl = makeThumb(bmp);
695
  }catch(e){
696
  it.status = "error"; it.err = e.message || "could not decode";
697
  }
698
  }
699
+ async function decodeGifFrames(it){
700
+ if(typeof ImageDecoder === "undefined") return false;
701
+ let dec=null,frames=[];
702
+ try{
703
+ const ok=!ImageDecoder.isTypeSupported||await ImageDecoder.isTypeSupported("image/gif");if(!ok)return false;
704
+ dec=new ImageDecoder({data:await it.file.arrayBuffer(),type:"image/gif",preferAnimation:true});await dec.tracks.ready;
705
+ const track=dec.tracks.selectedTrack,total=Number(track&&track.frameCount)||1,count=Math.min(total,600);it.gifTotalFrames=total;it.gifTruncated=total>600;if(count<2)return false;
706
+ const delays=[];
707
+ for(let i=0;i<count;i++){const r=await dec.decode({frameIndex:i,completeFramesOnly:true}),vf=r.image;frames.push(await createImageBitmap(vf));delays.push(Math.max(20,Math.round((Number(vf.duration)||100000)/1000)));try{vf.close()}catch(_){}if(i%10===0)await wait(0);}
708
+ const old=it.bitmap;it.frames=frames;it.delays=delays;it.loops=track.repetitionCount;it.bitmap=frames[0];it.w=frames[0].width;it.h=frames[0].height;if(old&&old!==it.bitmap){try{old.close&&old.close()}catch(_){}}return true;
709
+ }catch(e){for(const f of frames){try{f.close&&f.close()}catch(_){}}it.frames=null;it.delays=null;return false;}finally{try{dec&&dec.close()}catch(_){}}
710
+ }
711
  function loadViaImg(blob){
712
  return new Promise((res, rej) => {
713
  const url = URL.createObjectURL(blob), img = new Image();
 
735
  $("flipV").addEventListener("click", () => { flipV = !flipV; drawPreview(); });
736
  $("resetAdj").addEventListener("click", () => { rot = 0; flipH = flipV = false; drawPreview(); updateEstimate(); });
737
  $("autoExif").addEventListener("change", async () => {
738
+ for(const it of items){
739
+ if(!it.bitmap) continue;
740
+ freeItem(it);
741
+ it.bitmap = null; it.frames = null; it.delays = null; it.status = "queued";
742
+ }
743
  render();
744
+ for(const it of items) if(it.status !== "error") { await decode(it); renderSoon(); }
745
+ clearTimeout(_rTimer); _rTimer = 0; render();
746
  drawPreview();
747
  });
748
  function targetSize(sw, sh){
 
762
  }
763
  return { w: Math.max(1, Math.round(w)), h: Math.max(1, Math.round(h)) };
764
  }
765
+ /* Work out the exact output geometry without drawing anything.
766
+ Used for the size badges and the estimate, so we no longer re-render
767
+ the full-resolution image just to learn how big it will be. */
768
+ function outputGeom(bmp){
769
+ const swapped=(rot===90||rot===270);
770
+ const cl=Math.min(.45,crop.l/100),cr=Math.min(.45,crop.r/100),ct=Math.min(.45,crop.t/100),cb=Math.min(.45,crop.b/100);
771
+ const srcW=Math.max(1,bmp.width*(1-cl-cr)),srcH=Math.max(1,bmp.height*(1-ct-cb));
772
+ const baseW=swapped?srcH:srcW,baseH=swapped?srcW:srcH,t=targetSize(baseW,baseH);
773
+ let extraTop=0,extraBottom=0;
774
+ for(const o of textLayers){if(!o.text)continue;if(o.mode==="topbox")extraTop=Math.max(extraTop,captionExtraFor(o,t.w,t.h));if(o.mode==="bottombox")extraBottom=Math.max(extraBottom,captionExtraFor(o,t.w,t.h));}
775
+ return {swapped,cl,ct,srcW,srcH,tw:t.w,th:t.h,extraTop,extraBottom,w:t.w,h:t.h+extraTop+extraBottom};
776
+ }
777
+ /* cap = longest side to render at (preview only). 0 = full resolution. */
778
+ function renderToCanvas(bmp,showControls=false,cap=0){
779
+ const G=outputGeom(bmp);
780
+ const k=(cap&&Math.max(G.w,G.h)>cap)?cap/Math.max(G.w,G.h):1;
781
+ const tw=Math.max(1,Math.round(G.tw*k)),th=Math.max(1,Math.round(G.th*k));
782
+ const extraTop=Math.round(G.extraTop*k),extraBottom=Math.round(G.extraBottom*k);
783
+ const cv=$("work");cv.width=tw;cv.height=th+extraTop+extraBottom;
784
+ const g=cv.getContext("2d");g.imageSmoothingEnabled=true;g.imageSmoothingQuality="high";
785
+ const indexedPng=(outFmt==="image/png"&&(+$("pngColors").value||0)>0);
786
+ if(outFmt==="image/jpeg"||outFmt==="application/pdf"||indexedPng||extraTop||extraBottom){
787
+ g.fillStyle=(extraTop||extraBottom)?"#000":"#fff";g.fillRect(0,0,cv.width,cv.height);
788
+ }
789
+ const sx=bmp.width*G.cl,sy=bmp.height*G.ct;
790
+ g.save();g.beginPath();g.rect(0,extraTop,tw,th);g.clip();
791
+ g.translate(tw/2,extraTop+th/2);
792
+ g.rotate((rot+crop.angle)*Math.PI/180);
793
+ /* straightening rotates inside a fixed frame, which would expose empty
794
+ corners — zoom just enough to keep the frame covered. */
795
+ if(crop.angle){
796
+ const ar=Math.abs(crop.angle)*Math.PI/180,ca=Math.cos(ar),sa=Math.sin(ar);
797
+ const cover=Math.max((tw*ca+th*sa)/tw,(tw*sa+th*ca)/th);
798
+ g.scale(cover,cover);
799
+ }
800
+ g.scale(flipH?-1:1,flipV?-1:1);
801
+ const drawW=G.swapped?th:tw,drawH=G.swapped?tw:th;
802
+ g.drawImage(bmp,sx,sy,G.srcW,G.srcH,-drawW/2,-drawH/2,drawW,drawH);
803
  g.restore();
804
+ if(showControls) bubbleHit=null;
805
+ for(let i=0;i<textLayers.length;i++)drawTextLayer(g,textLayers[i],cv.width,cv.height,extraTop,th,extraTop,extraBottom,showControls&&i===activeText);
806
  return cv;
807
  }
808
+ function captionExtraFor(o,W,H){const size=Math.max(14,Math.round(Math.min(W,H)*(o.size/100))),pad=Math.round(size*(o.pad/100)),tmp=document.createElement("canvas").getContext("2d");tmp.font="700 "+size+"px "+o.font;return wrapLines(tmp,o.text,Math.max(80,W-pad*4)).length*Math.round(size*1.18)+pad*2;}
809
+ function wrapLines(g,text,maxW){
810
+ const out=[]; for(const para of text.split(/\n/)){ const words=para.split(/\s+/); let line="";
811
+ for(const w of words){ const test=line?line+" "+w:w; if(line&&g.measureText(test).width>maxW){out.push(line);line=w;}else line=test; } out.push(line); }
812
+ return out.length?out:[""];
813
+ }
814
+ function drawTextLayer(g,o,W,H,imageY,imageH,extraTop,extraBottom,isActive){
815
+ if(!o||o.mode==="none"||!o.text)return;const mode=o.mode,text=mode==="meme"?o.text.toUpperCase():o.text,size=Math.max(14,Math.round(Math.min(W,imageH)*(o.size/100))),basePad=Math.round(size*(o.pad/100)),pad=basePad;
816
+ g.save();g.globalAlpha=o.opacity/100;const family=mode==="meme"?"Impact,Arial Black,sans-serif":o.font;g.font=(mode==="meme"?"900 ":"700 ")+size+"px "+family;g.textBaseline="alphabetic";g.textAlign="left";const maxW=Math.max(80,W-basePad*4),lines=wrapLines(g,text,maxW),metrics=lines.map(x=>g.measureText(x)),ascent=Math.max(...metrics.map(m=>m.actualBoundingBoxAscent||size*.8)),descent=Math.max(...metrics.map(m=>m.actualBoundingBoxDescent||size*.2)),gap=Math.round(size*.18),lh=ascent+descent+gap,tw=Math.min(maxW,Math.max(...metrics.map(m=>m.width))),th=lines.length*(ascent+descent)+(lines.length-1)*gap;
817
+ if(mode==="topbox"||mode==="bottombox"){const y=mode==="topbox"?0:H-extraBottom;g.fillStyle="#000";g.fillRect(0,y,W,mode==="topbox"?extraTop:extraBottom);g.fillStyle=o.color;g.textAlign="center";lines.forEach((ln,i)=>g.fillText(ln,W/2,y+pad+ascent+i*lh));g.restore();return;}
818
+ let x=o.x*W,y=imageY+o.y*imageH;if(mode!=="bubble"){const pos=o.pos;x=pad+tw/2;y=imageY+pad+th/2;if(pos.endsWith("c"))x=W/2;else if(pos.endsWith("r"))x=W-pad-tw/2;if(pos.startsWith("b"))y=imageY+imageH-pad-th/2;else if(pos==="c"){x=W/2;y=imageY+imageH/2;}}
819
+ g.translate(x,y);g.rotate(o.rot*Math.PI/180);
820
+ const lineWidths=lines.map(ln=>g.measureText(ln).width);
821
+ const bubbleLineH=ascent+descent+pad*2;
822
+ const bubbleStep=ascent+descent+gap;
823
+ const bubbleTotalH=lines.length===1?bubbleLineH:bubbleLineH+(lines.length-1)*bubbleStep;
824
+ if(mode==="bubble"){
825
+ g.fillStyle=o.bg;
826
+ lines.forEach((ln,i)=>{
827
+ const bw=lineWidths[i]+pad*2;
828
+ const by=-bubbleTotalH/2+i*bubbleStep;
829
+ const radius=Math.min(bubbleLineH/2,Math.max(12,bubbleLineH*.34));
830
+ roundRect(g,-bw/2,by,bw,bubbleLineH,radius);g.fill();
831
+ });
832
+ }
833
+ g.fillStyle=o.color;const stroke=o.stroke||mode==="meme";if(stroke){g.strokeStyle="#000";g.lineWidth=Math.max(2,size*.09);g.lineJoin="round";}
834
+ lines.forEach((ln,i)=>{const lx=-lineWidths[i]/2,ly=(mode==="bubble"?-bubbleTotalH/2+pad+ascent+i*bubbleStep:-th/2+ascent+i*lh);if(stroke)g.strokeText(ln,lx,ly);g.fillText(ln,lx,ly);});
835
+ if(isActive&&mode==="bubble"){
836
+ const hitW=Math.max(...lineWidths)+pad*2,hitH=bubbleTotalH;
837
+ bubbleHit={cx:x,cy:y,w:hitW,h:hitH,a:o.rot*Math.PI/180,handleY:-hitH/2-size*.8,resizeX:hitW/2,resizeY:hitH/2};
838
+ g.globalAlpha=1;g.strokeStyle="#4cc2ff";g.lineWidth=Math.max(2,W/400);g.setLineDash([6,4]);g.strokeRect(-hitW/2,-hitH/2,hitW,hitH);g.setLineDash([]);g.beginPath();g.arc(0,bubbleHit.handleY,Math.max(7,size*.13),0,Math.PI*2);g.fillStyle="#4cc2ff";g.fill();const hs=Math.max(10,size*.18);g.fillRect(bubbleHit.resizeX-hs/2,bubbleHit.resizeY-hs/2,hs,hs);
839
+ }g.restore();
840
  }
841
+ function roundRect(g,x,y,w,h,r){r=Math.min(r,w/2,h/2);g.beginPath();g.moveTo(x+r,y);g.arcTo(x+w,y,x+w,y+h,r);g.arcTo(x+w,y+h,x,y+h,r);g.arcTo(x,y+h,x,y,r);g.arcTo(x,y,x+w,y,r);g.closePath();}
842
  /* ======================= preview ======================= */
843
  function drawPreview(){
844
  const cv = $("preview"), g = cv.getContext("2d");
845
  const it = items[sel];
846
+ clearTimeout(previewTimer); previewTimer = 0;
847
  if(!it || !it.bitmap){
848
  cv.width = 640; cv.height = 360;
849
  g.fillStyle = "#000"; g.fillRect(0,0,cv.width,cv.height);
 
851
  $("outBadge").textContent = "";
852
  return;
853
  }
854
+ const anim = it.frames && it.frames.length > 1;
855
+ const frame = anim ? it.frames[previewFrame % it.frames.length] : it.bitmap;
856
+ const out = renderToCanvas(frame, true, PREVIEW_CAP);
857
  cv.width = out.width; cv.height = out.height;
858
  g.drawImage(out, 0, 0);
859
+ const G = outputGeom(frame);
860
+ $("srcBadge").textContent = it.name + " \u00b7 " + it.w + "\u00d7" + it.h +
861
+ (isHeic(it.file) ? " \u00b7 HEIC" : "") + (anim ? " \u00b7 " + it.frames.length + " frames" : "");
862
+ $("outBadge").textContent = G.w + "\u00d7" + G.h + " \u00b7 " + fmtLabel(outFmt) +
863
+ (out.width < G.w ? " \u00b7 preview scaled" : "");
864
+ /* play animated GIFs in the preview instead of freezing on frame 1 */
865
+ if(anim && !dragState && view === "preview"){
866
+ const sp = (+$("gifSpeed").value || 100) / 100;
867
+ const d = Math.max(20, (it.delays && it.delays[previewFrame % it.frames.length] || 100) / sp);
868
+ previewTimer = setTimeout(() => { previewFrame++; drawPreview(); }, d);
869
+ }
870
  }
871
+ function fmtLabel(m){ return ({"image/png":"PNG","image/jpeg":"JPEG","image/webp":"WebP","image/avif":"AVIF","image/gif":"GIF","application/pdf":"PDF"})[m] || m; }
872
  /* ======================= view toggle ======================= */
873
  $("viewTabs").addEventListener("click", e => {
874
  const b = e.target.closest("button"); if(!b) return;
 
880
  if(view === "grid") renderGrid(); else drawPreview();
881
  });
882
  /* ======================= selection helpers ======================= */
883
+ function selectedReady(){ return items.filter(it => it.selected && it.status !== "error" && (it.bitmap || (it.frames && it.frames[0]))); }
 
884
  $("selAll").addEventListener("click", () => { items.forEach(it => { if(it.status !== "error") it.selected = true; }); render(); });
885
  $("selNone").addEventListener("click", () => { items.forEach(it => it.selected = false); render(); });
886
  /* ======================= queue rendering ======================= */
 
898
  items.forEach((it, i) => {
899
  const row = document.createElement("div");
900
  row.className = "qrow" + (i === sel ? " on" : "");
901
+ row.addEventListener("click", () => { sel = i; previewFrame = 0; drawPreview(); render(); });
902
  const chk = document.createElement("input");
903
  chk.type = "checkbox"; chk.checked = !!it.selected; chk.title = "Include in conversion";
904
  chk.disabled = it.status === "error";
 
934
  items.forEach((it, i) => {
935
  const card = document.createElement("div");
936
  card.className = "gcard" + (i === sel ? " on" : "");
937
+ card.addEventListener("click", () => { sel = i; previewFrame = 0; if(view==="preview") drawPreview(); render(); });
938
  const chk = document.createElement("input");
939
  chk.type = "checkbox"; chk.className = "gchk"; chk.checked = !!it.selected;
940
  chk.disabled = it.status === "error"; chk.title = "Include in conversion";
 
957
  g.appendChild(card);
958
  });
959
  }
960
+ function freeItem(it){
961
+ if(!it) return;
962
+ for(const b of new Set([it.bitmap, ...(it.frames || [])])){ try{ b && b.close && b.close(); }catch(_){} }
963
+ }
964
  function removeAt(i){
965
  if(exporting) return;
966
+ freeItem(items[i]);
967
  items.splice(i, 1);
968
  if(sel >= items.length) sel = items.length - 1;
969
  if(!items.length){ dropAutoDone = false; setDropMini(false); }
970
  render(); if(view==="preview") drawPreview();
971
  }
972
+ $("clearAll").addEventListener("click", () => { if(exporting) return; items.forEach(freeItem); items.length = 0; sel = -1; dropAutoDone = false; setDropMini(false); render(); drawPreview(); say($("status"),""); });
973
  $("clearDone").addEventListener("click", () => {
974
  if(exporting) return;
975
  for(let i = items.length-1; i>=0; i--) if(items[i].status === "done") removeAt(i);
 
977
  /* ======================= settings wiring ======================= */
978
  function syncFmtUI(){
979
  const lossy = isLossy(outFmt);
980
+ /* PDF pages are embedded as JPEG, so the quality slider matters there too. */
981
+ $("qualityRow").style.display = (lossy || outFmt === "application/pdf") ? "flex" : "none";
982
  $("targetChk").style.display = lossy ? "flex" : "none";
983
  $("targetRow").style.display = (lossy && $("targetOn").checked) ? "flex" : "none";
984
  $("pngOpts").style.display = outFmt === "image/png" ? "block" : "none";
 
992
  else if(outFmt === "image/jpeg") hint = "JPEG is best for photos. No transparency — transparent areas become white.";
993
  else if(outFmt === "image/webp") hint = "WebP gives smaller files than JPEG at similar quality, with transparency support.";
994
  else if(outFmt === "image/avif") hint = support["image/avif"] === false ? "\u26a0 Your browser can\u2019t encode AVIF — try Chrome or Edge, or pick WebP." : "AVIF is the smallest for photos, but slower to encode. Chrome/Edge only.";
995
+ else if(outFmt === "image/gif") hint = "Animated GIF export keeps animation when its frames were decoded; otherwise it exports one GIF frame. Colours are reduced to a web-safe palette.";
996
+ else if(outFmt === "application/pdf") hint = "Each ticked image becomes one PDF page (embedded as JPEG), scaled to fit A4 in its own orientation. The quality slider controls the page images.";
997
  $("fmtHint").textContent = hint;
998
  }
999
  $("fmtSeg").addEventListener("click", async e => {
1000
+ if(exporting) return;
1001
  const b = e.target.closest("button"); if(!b || b.disabled) return;
1002
  document.querySelectorAll("#fmtSeg button").forEach(x => x.classList.toggle("on", x === b));
1003
  outFmt = b.dataset.fmt;
 
1020
  drawPreview(); updateEstimate();
1021
  });
1022
  ["pct","maxPreset"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); }));
1023
+ /* the naming segment had no handler at all, so "Template" could never be chosen */
 
 
 
1024
  $("nameMode").addEventListener("click", e => {
1025
  const b = e.target.closest("button"); if(!b) return;
1026
  document.querySelectorAll("#nameMode button").forEach(x => x.classList.toggle("on", x === b));
1027
  $("tplRows").style.display = b.dataset.m === "tpl" ? "block" : "none";
1028
  });
1029
+ $("pxW").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxH").value = Math.round((+$("pxW").value||1) * it.h / it.w); } drawPreview(); updateEstimate(); });
1030
+ $("pxH").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxW").value = Math.round((+$("pxH").value||1) * it.w / it.h); } drawPreview(); updateEstimate(); });
1031
+ function activeLayer(){return textLayers[activeText];}
1032
+ function saveLayerFromUI(){const o=activeLayer();if(!o)return;o.mode=$("textMode").value;o.text=$("wmText").value;o.pos=$("wmPos").value;o.font=$("wmFont").value;o.size=+$("wmSize").value;o.color=$("wmColor").value;o.bg=$("wmBg").value;o.pad=+$("wmPad").value;o.opacity=+$("wmOpacity").value;o.stroke=$("wmStroke").checked;}
1033
+ function loadLayerToUI(){const o=activeLayer();if(!o)return;$("textMode").value=o.mode;$("wmText").value=o.text;$("wmPos").value=o.pos;$("wmFont").value=o.font;$("wmSize").value=o.size;$("wmColor").value=o.color;$("wmBg").value=o.bg;$("wmPad").value=o.pad;$("wmOpacity").value=o.opacity;$("wmStroke").checked=o.stroke;$("wmSizeL").textContent=o.size+"%";$("wmPadL").textContent=o.pad+"%";$("wmOpacityL").textContent=o.opacity+"%";renderTextLayerList();drawPreview();updateEstimate();}
1034
+ function renderTextLayerList(){const l=$("textLayerList");l.textContent="";textLayers.forEach((o,i)=>{const op=document.createElement("option");op.value=i;op.textContent=(i+1)+". "+(o.mode==="none"?"None":o.mode)+" · "+(o.text||"empty").slice(0,22);op.selected=i===activeText;l.appendChild(op);});}
1035
+ ["textMode","wmText","wmPos","wmFont","wmSize","wmColor","wmBg","wmPad","wmStroke","wmOpacity"].forEach(id=>$(id).addEventListener("input",()=>{saveLayerFromUI();$("wmSizeL").textContent=$("wmSize").value+"%";$("wmPadL").textContent=$("wmPad").value+"%";$("wmOpacityL").textContent=$("wmOpacity").value+"%";renderTextLayerList();drawPreview();updateEstimate();}));
1036
+ $("textLayerList").onchange=e=>{saveLayerFromUI();activeText=+e.target.value;loadLayerToUI();};
1037
+ $("addTextLayer").onclick=()=>{saveLayerFromUI();textLayers.push({id:nextTextId++,mode:"bubble",text:"",pos:"bc",font:"Arial,Segoe UI,sans-serif",size:7,color:"#ffffff",bg:"#000000",pad:45,opacity:95,stroke:false,x:.5,y:.82,rot:0});activeText=textLayers.length-1;loadLayerToUI();};
1038
+ $("dupTextLayer").onclick=()=>{saveLayerFromUI();const n={...activeLayer(),id:nextTextId++,x:Math.min(.95,activeLayer().x+.04),y:Math.min(.95,activeLayer().y+.04)};textLayers.push(n);activeText=textLayers.length-1;loadLayerToUI();};
1039
+ $("delTextLayer").onclick=()=>{if(textLayers.length===1){textLayers[0]={id:nextTextId++,mode:"bubble",text:"",pos:"bc",font:"Arial,Segoe UI,sans-serif",size:7,color:"#ffffff",bg:"#000000",pad:45,opacity:95,stroke:false,x:.5,y:.82,rot:0};activeText=0;}else{textLayers.splice(activeText,1);activeText=Math.max(0,activeText-1);}loadLayerToUI();};
1040
+ $("textMode").addEventListener("change",()=>{if($("textMode").value==="meme"){$("wmFont").value="Impact,Arial Black,sans-serif";}saveLayerFromUI();drawPreview();});
1041
+ function canvasPoint(e){const c=$("preview"),r=c.getBoundingClientRect();return{x:(e.clientX-r.left)*c.width/r.width,y:(e.clientY-r.top)*c.height/r.height};}
1042
+ function localHit(p,h){const dx=p.x-h.cx,dy=p.y-h.cy,ca=Math.cos(-h.a),sa=Math.sin(-h.a);return{x:dx*ca-dy*sa,y:dx*sa+dy*ca};}
1043
+ $("preview").addEventListener("pointerdown",e=>{const o=activeLayer();if(!o||o.mode!=="bubble"||!bubbleHit)return;const p=canvasPoint(e),l=localHit(p,bubbleHit),hr=Math.max(14,bubbleHit.h*.15);if(Math.hypot(l.x,l.y-bubbleHit.handleY)<hr)dragState={kind:"rotate"};else if(Math.hypot(l.x-bubbleHit.resizeX,l.y-bubbleHit.resizeY)<hr)dragState={kind:"resize",startDist:Math.hypot(l.x,l.y),startSize:o.size};else if(Math.abs(l.x)<=bubbleHit.w/2&&Math.abs(l.y)<=bubbleHit.h/2)dragState={kind:"move",ox:p.x-bubbleHit.cx,oy:p.y-bubbleHit.cy};if(dragState){e.preventDefault();$("preview").setPointerCapture(e.pointerId);}});
1044
+ $("preview").addEventListener("pointermove",e=>{if(!dragState)return;const p=canvasPoint(e),o=activeLayer();if(!o)return;if(dragState.kind==="move"){o.x=Math.max(0,Math.min(1,(p.x-dragState.ox)/$("preview").width));o.y=Math.max(0,Math.min(1,(p.y-dragState.oy)/$("preview").height)); }else if(dragState.kind==="resize"){const l=localHit(p,bubbleHit),ratio=Math.hypot(l.x,l.y)/Math.max(1,dragState.startDist);o.size=Math.max(2,Math.min(30,dragState.startSize*ratio));$("wmSize").value=o.size;$("wmSizeL").textContent=o.size.toFixed(1)+"%";}else{o.rot=Math.round(Math.atan2(p.y-bubbleHit.cy,p.x-bubbleHit.cx)*180/Math.PI+90);}drawPreview();});
1045
+ function endBubbleDrag(){if(!dragState)return;dragState=null;renderTextLayerList();updateEstimate();drawPreview();}
1046
+ $("preview").addEventListener("pointerup",endBubbleDrag);
1047
+ $("preview").addEventListener("pointercancel",endBubbleDrag);
1048
+ $("preview").addEventListener("lostpointercapture",()=>{if(dragState){dragState=null;renderTextLayerList();updateEstimate();drawPreview();}});
1049
+ ["cropL","cropR","cropT","cropB","straighten"].forEach(id=>$(id).addEventListener("input",()=>{crop.l=+$("cropL").value;crop.r=+$("cropR").value;crop.t=+$("cropT").value;crop.b=+$("cropB").value;crop.angle=+$("straighten").value;$("cropLL").textContent=crop.l+"%";$("cropRL").textContent=crop.r+"%";$("cropTL").textContent=crop.t+"%";$("cropBL").textContent=crop.b+"%";$("straightenL").textContent=crop.angle+"°";drawPreview();updateEstimate();}));
1050
+ $("resetCrop").onclick=()=>{crop={l:0,r:0,t:0,b:0,angle:0};$("cropPreset").value="free";["cropL","cropR","cropT","cropB","straighten"].forEach(id=>$(id).value=0);["cropLL","cropRL","cropTL","cropBL"].forEach(id=>$(id).textContent="0%");$("straightenL").textContent="0°";drawPreview();updateEstimate();};
1051
+ $("cropPreset").onchange=()=>{const v=$("cropPreset").value,it=items[sel];
1052
+ if(v==="free"){["cropL","cropR","cropT","cropB"].forEach(id=>$(id).value=0);$("cropL").dispatchEvent(new Event("input"));return;}
1053
+ if(!it)return;const [a,b]=v.split(":").map(Number),target=a/b,cur=it.w/it.h;let lr=0,tb=0;if(cur>target)lr=(1-target/cur)*50;else tb=(1-cur/target)*50;$("cropL").value=$("cropR").value=lr.toFixed(1);$("cropT").value=$("cropB").value=tb.toFixed(1);$("cropL").dispatchEvent(new Event("input"));};
1054
+ $("gifAlpha").oninput=()=>{$("gifAlphaL").textContent=$("gifAlpha").value;updateEstimate();};
1055
+ $("gifSpeed").oninput=()=>{$("gifSpeedL").textContent=$("gifSpeed").value+"%";};
1056
+ $("gifTransparent").onchange=updateEstimate;
1057
  /* ======================= estimate ======================= */
1058
  let estTimer = 0, estToken = 0;
1059
  function updateEstimate(){
 
1064
  $("exportBtn").disabled = exporting || selN === 0;
1065
  const it = items[sel];
1066
  if(!it || !it.bitmap){ $("eDims").textContent = "\u2014"; $("eBytes").textContent = "\u2014"; return; }
1067
+ const G = outputGeom(it.bitmap);
1068
+ $("eDims").textContent = G.w + "\u00d7" + G.h;
1069
  clearTimeout(estTimer);
1070
  const token = ++estToken;
1071
  estTimer = setTimeout(async () => {
1072
  try{
1073
  const cur = items[sel];
1074
+ const source=cur&&(cur.bitmap||(cur.frames&&cur.frames[0]));
1075
+ if(!source){ $("eBytes").textContent = "\u2014"; return; }
1076
+ if(outFmt === "image/gif" && cur.frames && cur.frames.length > 1){if(token === estToken) $("eBytes").textContent = "calculated on export";return;}
1077
+ const blob = await encodeCanvas(renderToCanvas(source), false);
1078
+ if(token !== estToken) return;
1079
  $("eBytes").textContent = blob ? human(blob.size) : "\u2014";
1080
  }catch(e){ if(token === estToken) $("eBytes").textContent = "\u2014"; }
1081
  }, 300);
 
1084
  function canvasToBlob(cv, mime, q){ return new Promise(res => cv.toBlob(res, mime, q)); }
1085
  async function encodeCanvas(cv, doTarget){
1086
  if(outFmt === "application/pdf") return makePdf([cv]);
1087
+ if(outFmt === "image/gif") return encodeStillGif(cv);
1088
  if(outFmt === "image/png"){
1089
  const colors = +$("pngColors").value || 0;
1090
  if(colors > 0) return encodeIndexedPNG(cv, colors, $("pngDither").checked);
 
1266
  const plte = pngChunk("PLTE", pal.subarray(0, count*3));
1267
  return assemblePNG(w, h, 3, [plte], idat);
1268
  }
1269
+ /* ---------- compact animated GIF encoder: fixed 216-colour palette ---------- */
1270
+ function gifBytes(it){const a=[];return{a,u8(v){a.push(v&255)},u16(v){a.push(v&255,(v>>8)&255)},str(t){for(const c of t)a.push(c.charCodeAt(0))},arr(v){for(const x of v)a.push(x)}}}
1271
+ function gifPalette(){const p=[];for(let r=0;r<6;r++)for(let g=0;g<6;g++)for(let b=0;b<6;b++)p.push(r*51,g*51,b*51);while(p.length<768)p.push(0);return p;}
1272
+ function gifIndex(data){const o=new Uint8Array(data.length/4),keep=$("gifTransparent").checked,cut=+$("gifAlpha").value||128;for(let p=0,q=0;q<o.length;q++,p+=4){if(keep&&data[p+3]<cut){o[q]=255;continue;}const r=Math.round(data[p]/51),g=Math.round(data[p+1]/51),b=Math.round(data[p+2]/51);o[q]=r*36+g*6+b;}return o;}
1273
+ function lzwGif(idx,min=8){const clear=1<<min,eoi=clear+1;let size=min+1,next=eoi+1,dict=new Map(),bits=0,nb=0,out=[];const put=c=>{bits|=c<<nb;nb+=size;while(nb>=8){out.push(bits&255);bits>>=8;nb-=8;}};const reset=()=>{dict=new Map();size=min+1;next=eoi+1;};put(clear);let pref=idx[0]||0;for(let i=1;i<idx.length;i++){const k=pref+','+idx[i];if(dict.has(k))pref=dict.get(k);else{put(pref);if(next<4096){dict.set(k,next++);if(next===(1<<size)&&size<12)size++;}else{put(clear);reset();}pref=idx[i];}}put(pref);put(eoi);if(nb)out.push(bits&255);return out;}
1274
+ function addGifFrame(w,indices,delay,W,H){w.u8(0x21);w.u8(0xF9);w.u8(4);w.u8($("gifTransparent").checked?9:4); /* disposal 2 + transparency, or disposal 1 */w.u16(Math.max(2,Math.min(65535,Math.round(delay/10))));w.u8($("gifTransparent").checked?255:0);w.u8(0);w.u8(0x2C);w.u16(0);w.u16(0);w.u16(W);w.u16(H);w.u8(0);w.u8(8);const z=lzwGif(indices);for(let i=0;i<z.length;i+=255){const n=Math.min(255,z.length-i);w.u8(n);w.arr(z.slice(i,i+n));}w.u8(0);}
1275
+ async function encodeGifCanvases(canvases,delays,loop){
1276
+ if(!canvases.length)throw new Error("GIF has no frames");const W=canvases[0].width,H=canvases[0].height;
1277
+ const w=gifBytes();w.str("GIF89a");w.u16(W);w.u16(H);w.u8(0xF7);w.u8(0);w.u8(0);w.arr(gifPalette());
1278
+ if(loop){w.u8(0x21);w.u8(0xFF);w.u8(11);w.str("NETSCAPE2.0");w.u8(3);w.u8(1);w.u16(0);w.u8(0);}
1279
+ for(let i=0;i<canvases.length;i++){const cv=canvases[i];if(cv.width!==W||cv.height!==H)throw new Error("GIF frame size mismatch");const d=cv.getContext("2d",{willReadFrequently:true}).getImageData(0,0,W,H).data;addGifFrame(w,gifIndex(d),delays[i]||100,W,H);if(exporting){$("barI").style.width=Math.min(95,5+(i+1)/canvases.length*88)+"%";say($("status"),"Encoding GIF frame "+(i+1)+" of "+canvases.length+"…");}if(i%2===0)await wait(0);if(cancel)throw new Error("Export stopped");}
1280
+ w.u8(0x3B);const blob=new Blob([new Uint8Array(w.a)],{type:"image/gif"});if(blob.size<20)throw new Error("GIF encoder produced an empty file");return blob;
1281
+ }
1282
+ async function encodeStillGif(cv){return encodeGifCanvases([cv],[100],false);}
1283
+ async function encodeAnimatedItem(it){if(!it.frames||!it.frames.length)throw new Error("No animated GIF frames were decoded");const arr=[];for(let i=0;i<it.frames.length;i++){const src=renderToCanvas(it.frames[i]),c=document.createElement("canvas");c.width=src.width;c.height=src.height;c.getContext("2d").drawImage(src,0,0);arr.push(c);if(i%6===0)await wait(0);}const sp=(+$("gifSpeed").value||100)/100;return encodeGifCanvases(arr,(it.delays||[]).map(d=>d/sp),$("gifLoop").checked);}
1284
  /* ---------- PDF: one page per image, JPEG embedded (DCTDecode) ---------- */
1285
  async function makePdf(canvases){
1286
  const q = clamp((+$("quality").value||82)/100, 0.4, 0.95);
 
1298
  start(); push("2 0 obj\n<< /Type /Pages /Count " + N + " /Kids [ " + kids.join(" ") + " ] >>\nendobj\n");
1299
  for(let i=0;i<N;i++){
1300
  const cv = canvases[i], w = cv.width, h = cv.height;
1301
+ /* A PDF unit is 1/72 inch, so using raw pixels made a 4000px photo into a
1302
+ 55-inch page. Scale each page to fill A4 in its own orientation. */
1303
+ const sc = Math.min(595 / Math.min(w,h), 842 / Math.max(w,h));
1304
+ const pw = (w*sc).toFixed(2), ph = (h*sc).toFixed(2);
1305
  const pageObj = 3 + i*3, contentObj = pageObj + 1, imgObj = pageObj + 2;
1306
  start();
1307
+ push(pageObj + " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + pw + " " + ph + "] " +
1308
  "/Resources << /XObject << /Im0 " + imgObj + " 0 R >> >> /Contents " + contentObj + " 0 R >>\nendobj\n");
1309
+ const content = "q\n" + pw + " 0 0 " + ph + " 0 0 cm\n/Im0 Do\nQ\n";
1310
  start();
1311
  push(contentObj + " 0 obj\n<< /Length " + content.length + " >>\nstream\n" + content + "endstream\nendobj\n");
1312
  const data = imgDatas[i];
 
1379
  if(cancel) break;
1380
  it.status = "working"; render();
1381
  try{
1382
+ const source=it.bitmap||(it.frames&&it.frames[0]);if(!source)throw new Error("No readable image frame");
1383
+ const cv = renderToCanvas(source);
1384
+ const blob = (outFmt==="image/gif" && it.frames && it.frames.length>1) ? await encodeAnimatedItem(it) : await encodeCanvas(cv, true);
1385
  if(!blob || !blob.size) throw new Error("empty output");
1386
  const name = uniqueName(outNameFor(it, i, cv.width, cv.height), usedNames);
1387
  it.outName = name; it.outSize = blob.size;
 
1393
  if(ready.length > 1) await wait(300);
1394
  }
1395
  it.status = "done"; done++;
1396
+ }catch(e){ it.status = "ready"; it.err = e.message || "convert failed"; failed++; say($("status"), "Could not save "+it.name+": "+it.err, "err"); }
1397
  i++;
1398
  $("barI").style.width = ((done+failed)/ready.length*100).toFixed(1) + "%";
1399
  render();
 
1413
  const canvases = [];
1414
  for(let k=0;k<ready.length;k++){
1415
  if(cancel) break;
1416
+ const src = ready[k].bitmap || (ready[k].frames && ready[k].frames[0]);
1417
+ if(!src) continue;
1418
+ const cv = renderToCanvas(src);
1419
  const c2 = document.createElement("canvas"); c2.width = cv.width; c2.height = cv.height;
1420
  c2.getContext("2d").drawImage(cv, 0, 0);
1421
  canvases.push(c2);
 
1445
  /* ======================= keyboard ======================= */
1446
  document.addEventListener("keydown", e => {
1447
  const tag = (e.target.tagName||"").toLowerCase();
1448
+ if(tag === "input" || tag === "select" || tag === "textarea" || tag === "button" || tag === "a") return;
1449
+ if(e.target.isContentEditable) return;
1450
  if(e.metaKey || e.ctrlKey || e.altKey) return;
1451
  if(exporting) return;
1452
+ if(e.key === "ArrowLeft"){ e.preventDefault(); if(sel > 0){ sel--; previewFrame = 0; drawPreview(); render(); } }
1453
+ else if(e.key === "ArrowRight"){ e.preventDefault(); if(sel < items.length-1){ sel++; previewFrame = 0; drawPreview(); render(); } }
1454
  else if(e.key === " "){ e.preventDefault(); const it=items[sel]; if(it && it.status!=="error"){ it.selected=!it.selected; render(); } }
1455
  else if(e.key === "r" || e.key === "R"){ e.preventDefault(); rot = (rot+90)%360; drawPreview(); updateEstimate(); }
1456
  else if(e.key === "Delete" || e.key === "Backspace"){ if(sel>=0){ e.preventDefault(); removeAt(sel); } }
 
1464
  if(b){ b.disabled = true; b.title = "Not supported by this browser"; b.style.opacity = ".4"; b.style.cursor = "not-allowed"; }
1465
  }
1466
  syncFmtUI();
1467
+ renderTextLayerList();loadLayerToUI();
1468
  drawPreview(); render();
1469
  })();
1470
  })();
1471
  </script>
1472
+
1473
  </body>
1474
  </html>