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
|
| 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
|
| 175 |
-
<span class="sub">
|
| 176 |
<span class="spacer"></span>
|
| 177 |
-
<
|
| 178 |
</header>
|
| 179 |
-
<div id="drop"
|
| 180 |
<button id="dropToggle" class="ghost" type="button" title="Collapse / expand" aria-expanded="true">Hide <span class="chev">▲</span></button>
|
| 181 |
<span class="miniLabel"><b>Add more images</b> · or drop them anywhere on the page</span>
|
| 182 |
<div class="big">▢</div>
|
|
@@ -222,6 +227,7 @@
|
|
| 222 |
<span class="kbd">←</span> <span class="kbd">→</span> move between images ·
|
| 223 |
<span class="kbd">Space</span> toggle selected ·
|
| 224 |
<span class="kbd">R</span> rotate · <span class="kbd">Del</span> remove ·
|
|
|
|
| 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
<div class="collapbody shut" id="wmBody">
|
| 318 |
-
<div class="row"><label>Text</label><
|
| 319 |
-
<div class="row">
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
</div>
|
| 329 |
-
<
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = []; //
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
}
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
| 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";
|
| 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;
|
|
|
|
|
|
|
| 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){
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
render();
|
| 667 |
-
for(const it of items) if(it.status !== "error") { await decode(it);
|
|
|
|
| 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 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
const
|
| 692 |
-
const
|
| 693 |
-
|
| 694 |
-
const
|
| 695 |
-
|
| 696 |
-
const
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
const
|
| 704 |
-
const
|
| 705 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
g.restore();
|
| 707 |
-
|
|
|
|
| 708 |
return cv;
|
| 709 |
}
|
| 710 |
-
function
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
const
|
| 718 |
-
const
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 743 |
cv.width = out.width; cv.height = out.height;
|
| 744 |
g.drawImage(out, 0, 0);
|
| 745 |
-
|
| 746 |
-
$("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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(
|
| 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 |
-
|
|
|
|
| 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 === "
|
|
|
|
| 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 |
-
|
| 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
|
| 914 |
-
$("eDims").textContent =
|
| 915 |
clearTimeout(estTimer);
|
| 916 |
const token = ++estToken;
|
| 917 |
estTimer = setTimeout(async () => {
|
| 918 |
try{
|
| 919 |
const cur = items[sel];
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
|
|
|
| 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 " +
|
| 1133 |
"/Resources << /XObject << /Im0 " + imgObj + " 0 R >> >> /Contents " + contentObj + " 0 R >>\nendobj\n");
|
| 1134 |
-
const content = "q\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
|
| 1208 |
-
const
|
|
|
|
| 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 = "
|
| 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
|
|
|
|
|
|
|
| 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 · resize · GIF timing · captions & 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">▲</span></button>
|
| 186 |
<span class="miniLabel"><b>Add more images</b> · or drop them anywhere on the page</span>
|
| 187 |
<div class="big">▢</div>
|
|
|
|
| 227 |
<span class="kbd">←</span> <span class="kbd">→</span> move between images ·
|
| 228 |
<span class="kbd">Space</span> toggle selected ·
|
| 229 |
<span class="kbd">R</span> rotate · <span class="kbd">Del</span> remove ·
|
| 230 |
+
<span class="kbd">Ctrl</span>+<span class="kbd">V</span> paste an image ·
|
| 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 & straighten <span class="chev">▼</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 & meme <span class="chev">▼</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">▼</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>
|