PoopMan333 commited on
Commit
85b977a
·
verified ·
1 Parent(s): 3d6bb5b

Upload Pocket_Image_Converter_v1_6.html

Browse files
Files changed (1) hide show
  1. Pocket_Image_Converter_v1_6.html +1293 -0
Pocket_Image_Converter_v1_6.html ADDED
@@ -0,0 +1,1293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
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;
10
+ --txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6;
11
+ --ok:#3fb950; --warn:#d29922; --danger:#f85149; --red:#7f1018;
12
+ --r:10px;
13
+ }
14
+ *{box-sizing:border-box}
15
+ html,body{margin:0;min-height:100%}
16
+ body{
17
+ background:var(--bg); color:var(--txt);
18
+ font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
19
+ -webkit-font-smoothing:antialiased;
20
+ }
21
+ .wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}
22
+ header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
23
+ header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
24
+ header .sub{color:var(--dim);font-size:12.5px}
25
+ header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px}
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);
32
+ padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s;
33
+ }
34
+ button:hover:not(:disabled){background:#242d39;border-color:#3a4553}
35
+ button:disabled{opacity:.4;cursor:not-allowed}
36
+ button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
37
+ button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
38
+ button.primary:hover:not(:disabled){background:#6bcfff}
39
+ button.ghost{background:transparent}
40
+ button.danger{color:#ffb4ae;border-color:#5a2b2b}
41
+ button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a}
42
+ .small{padding:5px 10px;font-size:12.5px}
43
+ /* ---------- drop zone ---------- */
44
+ #drop{
45
+ border:2px dashed var(--line);border-radius:14px;padding:40px 24px;text-align:center;background:var(--panel);
46
+ cursor:pointer;transition:.15s;margin-bottom:16px;
47
+ }
48
+ #drop:hover,#drop.over{border-color:var(--accent);background:#12212b}
49
+ #drop .big{font-size:38px;line-height:1;margin-bottom:12px;opacity:.65}
50
+ #drop h2{margin:0 0 6px;font-size:17px;font-weight:600}
51
+ #drop p{margin:0;color:var(--dim);font-size:12.5px}
52
+ #drop .row2{display:flex;gap:8px;justify-content:center;margin-top:14px;flex-wrap:wrap}
53
+ #drop{position:relative}
54
+ #dropToggle{position:absolute;top:8px;right:8px;padding:3px 10px;font-size:12px;border-radius:7px;z-index:2}
55
+ #dropToggle .chev{display:inline-block;transition:transform .15s;font-size:10px}
56
+ /* collapsed state */
57
+ #drop.mini{padding:10px 14px;text-align:left;border-style:solid;display:flex;align-items:center;gap:10px}
58
+ #drop.mini .big,#drop.mini h2,#drop.mini p{display:none}
59
+ #drop.mini .row2{margin:0;justify-content:flex-start}
60
+ #drop.mini #dropToggle{position:static;margin-left:auto}
61
+ #drop .miniLabel{display:none;font-size:12.5px;color:var(--dim)}
62
+ #drop.mini .miniLabel{display:block}
63
+ #drop.mini .miniLabel b{color:var(--txt);font-weight:600}
64
+ /* ---------- page-wide drop overlay ---------- */
65
+ #pageDrop{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;
66
+ background:rgba(8,14,20,.82);backdrop-filter:blur(3px);pointer-events:none}
67
+ #pageDrop.show{display:flex}
68
+ #pageDrop .box{border:3px dashed var(--accent);border-radius:20px;padding:44px 60px;text-align:center;
69
+ background:rgba(18,33,43,.92);box-shadow:0 20px 60px rgba(0,0,0,.5)}
70
+ #pageDrop .box .big{font-size:46px;opacity:.8;margin-bottom:10px}
71
+ #pageDrop .box h2{margin:0 0 6px;font-size:20px}
72
+ #pageDrop .box p{margin:0;color:var(--dim);font-size:13px}
73
+ /* ---------- layout ---------- */
74
+ .grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start}
75
+ @media(max-width:940px){.grid{grid-template-columns:1fr}}
76
+ .card{background:#11161d;border:1px solid var(--line);border-radius:var(--r);padding:14px}
77
+ .card h3,.panelbox h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
78
+ .panelbox{background:#0d1219;border:1px solid var(--line);border-radius:10px;padding:10px 12px 12px;margin-bottom:14px}
79
+ .panelbox:last-child{margin-bottom:0}
80
+ h3.collap{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;
81
+ margin:-10px -12px 10px;padding:9px 12px;border-radius:9px 9px 0 0;background:#171d26;
82
+ border-bottom:1px solid var(--line);color:var(--txt)}
83
+ h3.collap:hover{background:#1c232d}
84
+ h3.collap.shut{margin-bottom:-12px;border-bottom:none;border-radius:9px}
85
+ h3.collap .chev{font-size:11px;color:var(--accent);transition:transform .15s}
86
+ h3.collap.shut .chev{transform:rotate(-90deg)}
87
+ .collapbody.shut{display:none}
88
+ /* ---------- view tabs ---------- */
89
+ .viewtabs{display:flex;gap:5px;margin-bottom:12px}
90
+ .viewtabs button{padding:6px 14px;font-size:12.5px;border-radius:8px}
91
+ .viewtabs button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
92
+ /* ---------- queue ---------- */
93
+ .q-head{display:flex;align-items:center;gap:8px;margin:0 0 8px;flex-wrap:wrap}
94
+ .q-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
95
+ .qlist{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);max-height:420px;overflow-y:auto}
96
+ .qrow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px}
97
+ .qrow:first-child{border-top:none}
98
+ .qrow:hover{background:var(--panel2)}
99
+ .qrow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)}
100
+ .qrow input[type=checkbox]{width:16px;height:16px;flex:none;accent-color:var(--accent);cursor:pointer}
101
+ .qthumb{width:52px;height:40px;border-radius:4px;border:1px solid var(--line);background:#07090c;flex:none;object-fit:cover;display:block}
102
+ .qthumb.ph{display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:16px}
103
+ .qmeta{flex:1 1 90px;min-width:60px;overflow:hidden}
104
+ .qname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
105
+ .qsub{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;margin-top:1px}
106
+ .qstate{font-size:11px;font-variant-numeric:tabular-nums;flex:none;width:74px;text-align:right}
107
+ .qstate.done{color:var(--ok)} .qstate.err{color:#ffb4ae} .qstate.work{color:var(--accent)}
108
+ .qx{padding:2px 8px;font-size:13px;border-radius:6px;flex:none}
109
+ .empty{color:var(--dim);font-size:12.5px;text-align:center;padding:24px 10px}
110
+ /* ---------- grid view ---------- */
111
+ .imgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(122px,1fr));gap:10px}
112
+ .gcard{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);cursor:pointer;transition:.12s}
113
+ .gcard:hover{border-color:#3a4553}
114
+ .gcard.on{box-shadow:0 0 0 2px var(--accent);border-color:var(--accent)}
115
+ .gcard img{width:100%;height:104px;object-fit:cover;display:block;background:#07090c}
116
+ .gcard .gph{width:100%;height:104px;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:20px;background:#07090c}
117
+ .gcard .gname{font-size:11px;padding:5px 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--txt)}
118
+ .gcard .gchk{position:absolute;top:6px;left:6px;width:18px;height:18px;accent-color:var(--accent);cursor:pointer;z-index:2}
119
+ .gcard .gx{position:absolute;top:4px;right:4px;padding:1px 7px;font-size:12px;line-height:1.5;border-radius:6px;
120
+ background:rgba(0,0,0,.55);color:var(--dim);z-index:2}
121
+ .gcard .gx:hover{color:#ffb4ae;border-color:#5a2b2b;background:#341c1c}
122
+ .gcard .gstate{position:absolute;bottom:24px;right:5px;font-size:10px;padding:1px 6px;border-radius:5px;background:rgba(0,0,0,.6);font-variant-numeric:tabular-nums}
123
+ .gcard .gstate.done{color:#9ae6a8} .gcard .gstate.err{color:#ffb4ae} .gcard .gstate.work{color:var(--accent)}
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}
131
+ /* ---------- settings ---------- */
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}
145
+ .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
146
+ .hint{color:var(--dim);font-size:11.5px;margin:-2px 0 10px;line-height:1.45}
147
+ .chk{display:flex;align-items:center;gap:7px;color:var(--txt);font-size:12.5px;margin-bottom:10px;cursor:pointer}
148
+ .chk input{width:auto;flex:none;accent-color:var(--accent)}
149
+ .chk .sub{color:var(--dim);font-size:11px}
150
+ .est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px}
151
+ .est div{display:flex;justify-content:space-between;margin:2px 0;gap:10px}
152
+ .est span{color:var(--dim)}
153
+ .est b{font-variant-numeric:tabular-nums;font-weight:600;text-align:right}
154
+ .expBtn{width:100%;padding:11px}
155
+ .stopBtn{width:100%;padding:9px;margin-top:8px;display:none}
156
+ .bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none}
157
+ .bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
158
+ .status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px;line-height:1.4}
159
+ .status.err{color:#ffb4ae}
160
+ .status.ok{color:#9ae6a8}
161
+ .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
162
+ .toolbtns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
163
+ .toolbtns button{flex:1 1 auto;padding:6px 8px;font-size:12.5px}
164
+ .keys{color:var(--dim);font-size:11.5px;margin-top:12px;line-height:1.7}
165
+ .kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
166
+ border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace}
167
+ code{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 4px;font-size:11.5px}
168
+ @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
169
+ </style>
170
+ </head>
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>
183
+ <h2>Drop images here to convert</h2>
184
+ <p>HEIC / HEIF &middot; JPEG / JFIF &middot; PNG &middot; WebP &middot; AVIF &middot; GIF &middot; BMP &middot; TIFF* &middot; ICO &nbsp;&middot;&nbsp; add as many as you like &nbsp;&middot;&nbsp; nothing is uploaded, it all stays on this computer</p>
185
+ <div class="row2">
186
+ <button id="pickFiles" class="small">Choose files</button>
187
+ <button id="pickFolder" class="small ghost">Choose a folder</button>
188
+ </div>
189
+ <input type="file" id="fileInput" accept="image/*,.heic,.heif,.heics,.jfif,.pjpeg,.pjp,.avif,.tif,.tiff,.bmp,.ico" multiple hidden>
190
+ <input type="file" id="folderInput" webkitdirectory directory multiple hidden>
191
+ </div>
192
+ <div class="grid">
193
+ <!-- LEFT: preview + queue -->
194
+ <div>
195
+ <div class="viewtabs" id="viewTabs">
196
+ <button data-v="preview" class="on">Preview</button>
197
+ <button data-v="grid">Grid</button>
198
+ </div>
199
+ <div id="previewView">
200
+ <div class="stage">
201
+ <canvas id="preview"></canvas>
202
+ <div class="badge" id="srcBadge">No image selected</div>
203
+ <div class="badge ba-right" id="outBadge"></div>
204
+ </div>
205
+ </div>
206
+ <div id="gridView" style="display:none">
207
+ <div class="imgrid" id="imgrid"></div>
208
+ </div>
209
+ <div class="q-head">
210
+ <h3>Queue</h3>
211
+ <span class="hint" id="qCount" style="margin:0">0 images</span>
212
+ <span class="spacer"></span>
213
+ <button class="small ghost" id="selAll">Select all</button>
214
+ <button class="small ghost" id="selNone">None</button>
215
+ <button class="small ghost" id="clearDone">Clear finished</button>
216
+ <button class="small ghost" id="clearAll">Remove all</button>
217
+ </div>
218
+ <div id="listView">
219
+ <div class="qlist" id="qList"><div class="empty">No images yet. Drop files above and they will appear here in order.</div></div>
220
+ </div>
221
+ <div class="keys">
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>
228
+ <!-- RIGHT: settings -->
229
+ <div class="card">
230
+ <!-- Output format -->
231
+ <div class="panelbox">
232
+ <h3>Output format</h3>
233
+ <div class="seg" id="fmtSeg">
234
+ <button data-fmt="image/png" class="on">PNG</button>
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">
241
+ <label>Quality</label>
242
+ <input type="range" id="quality" min="1" max="100" value="82">
243
+ <b class="hint" id="qualityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">82</b>
244
+ </div>
245
+ <label class="chk" id="targetChk"><input type="checkbox" id="targetOn"> Target file size <span class="sub">(auto-tunes quality)</span></label>
246
+ <div class="row" id="targetRow" style="display:none">
247
+ <label>Max size</label>
248
+ <input type="number" id="targetSize" min="0.05" step="0.1" value="2" style="flex:1">
249
+ <select id="targetUnit" style="flex:0 0 78px"><option value="1048576" selected>MB</option><option value="1024">KB</option></select>
250
+ </div>
251
+ <!-- PNG mode -->
252
+ <div id="pngOpts" style="display:none">
253
+ <div class="row">
254
+ <label>PNG mode</label>
255
+ <select id="pngColors">
256
+ <option value="0" selected>Lossless &middot; best quality (24-bit)</option>
257
+ <option value="256">Indexed &middot; 256 colours</option>
258
+ <option value="128">Indexed &middot; 128 colours</option>
259
+ <option value="64">Indexed &middot; 64 colours</option>
260
+ <option value="32">Indexed &middot; 32 colours</option>
261
+ </select>
262
+ </div>
263
+ <label class="chk" id="ditherChk" style="display:none"><input type="checkbox" id="pngDither" checked> Dither <span class="sub">(smoother gradients)</span></label>
264
+ </div>
265
+ <div class="hint" id="fmtHint">PNG is lossless — great for screenshots and graphics. The quality slider only affects JPEG, WebP and AVIF.</div>
266
+ </div>
267
+ <!-- Resize -->
268
+ <div class="panelbox">
269
+ <h3 class="collap shut" data-target="resizeBody">Resize <span class="chev">&#9660;</span></h3>
270
+ <div class="collapbody shut" id="resizeBody">
271
+ <div class="seg" id="resizeMode">
272
+ <button data-m="none" class="on">Original</button>
273
+ <button data-m="pct">Percent</button>
274
+ <button data-m="px">Pixels</button>
275
+ <button data-m="max">Max side</button>
276
+ </div>
277
+ <div class="row" id="pctRow" style="display:none"><label>Scale</label><input type="number" id="pct" min="1" max="400" step="1" value="100"><span class="hint" style="margin:0">%</span></div>
278
+ <div id="pxRows" style="display:none">
279
+ <div class="row"><label>Width</label><input type="number" id="pxW" min="1" step="1"></div>
280
+ <div class="row"><label>Height</label><input type="number" id="pxH" min="1" step="1"></div>
281
+ <label class="chk"><input type="checkbox" id="lockAr" checked> Lock aspect ratio</label>
282
+ </div>
283
+ <div class="row" id="maxRow" style="display:none">
284
+ <label>Longest side</label>
285
+ <select id="maxPreset">
286
+ <option value="640">640 px</option>
287
+ <option value="1024">1024 px</option>
288
+ <option value="1600" selected>1600 px</option>
289
+ <option value="1920">1920 px</option>
290
+ <option value="2560">2560 px</option>
291
+ <option value="4096">4096 px</option>
292
+ </select>
293
+ </div>
294
+ <div class="hint">Images are only ever scaled down to fit — never blown up past their real pixels unless you use Percent above 100.</div>
295
+ </div>
296
+ </div>
297
+ <!-- Adjust -->
298
+ <div class="panelbox">
299
+ <h3 class="collap shut" data-target="adjBody">Rotate &amp; flip <span class="chev">&#9660;</span></h3>
300
+ <div class="collapbody shut" id="adjBody">
301
+ <div class="toolbtns">
302
+ <button id="rotL" title="Rotate left">&#8630; Left</button>
303
+ <button id="rotR" title="Rotate right (R)">Right &#8631;</button>
304
+ </div>
305
+ <div class="toolbtns">
306
+ <button id="flipH">&#8646; Flip H</button>
307
+ <button id="flipV">&#8645; Flip V</button>
308
+ <button id="resetAdj" class="ghost">Reset</button>
309
+ </div>
310
+ <label class="chk"><input type="checkbox" id="autoExif" checked> Auto-rotate from EXIF <span class="sub">(fixes sideways iPhone photos)</span></label>
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 -->
338
+ <div class="panelbox">
339
+ <h3 class="collap shut" data-target="nameBody">File names <span class="chev">&#9660;</span></h3>
340
+ <div class="collapbody shut" id="nameBody">
341
+ <div class="seg" id="nameMode">
342
+ <button data-m="keep" class="on">Keep original</button>
343
+ <button data-m="tpl">Template</button>
344
+ </div>
345
+ <div id="tplRows" style="display:none">
346
+ <div class="row"><label>Prefix</label><input type="text" id="prefix" placeholder="e.g. web_"></div>
347
+ <div class="row"><label>Base</label><input type="text" id="tpl" value="IMG_{n}"></div>
348
+ <div class="row"><label>Suffix</label><input type="text" id="suffix" placeholder="e.g. _v2"></div>
349
+ <div class="hint">Tokens: <code>{n}</code> number &middot; <code>{name}</code> original name &middot; <code>{date}</code> today &middot; <code>{w}</code>/<code>{h}</code> output size.</div>
350
+ </div>
351
+ <div class="row"><label>Start #</label><input type="number" id="startN" min="0" step="1" value="1"></div>
352
+ </div>
353
+ </div>
354
+ <!-- Export -->
355
+ <div class="panelbox">
356
+ <h3>Export</h3>
357
+ <div class="est">
358
+ <div><span>Selected</span><b id="eCount">&mdash;</b></div>
359
+ <div><span>Output format</span><b id="eFmt">PNG</b></div>
360
+ <div><span>Selected output</span><b id="eDims">&mdash;</b></div>
361
+ <div><span>Est. size (selected)</span><b id="eBytes">&mdash;</b></div>
362
+ </div>
363
+ <button id="exportBtn" class="primary expBtn">Convert &amp; save</button>
364
+ <button id="stopBtn" class="danger stopBtn">Stop</button>
365
+ <div class="bar" id="bar"><i id="barI"></i></div>
366
+ <div class="status" id="status"></div>
367
+ <div class="note" id="note">Batches of 2+ save into a folder you pick (Chrome/Edge), otherwise they download one by one.</div>
368
+ </div>
369
+ </div>
370
+ </div>
371
+ <div class="panelbox" id="offlineNote" style="margin-top:14px;background:#11161d">
372
+ <h3 class="collap shut" data-target="offlineBody">Offline &amp; HEIC support <span class="chev">&#9660;</span></h3>
373
+ <div class="collapbody shut" id="offlineBody" style="color:var(--dim);font-size:12px;line-height:1.6">
374
+ <p style="margin:0 0 8px">This file is <b style="color:var(--txt)">fully self-contained</b> — no internet needed, nothing is uploaded. All the common formats (<b style="color:var(--txt)">JPEG / JFIF, PNG, WebP, AVIF, GIF, BMP</b>) are decoded and converted entirely by your browser.</p>
375
+ <p style="margin:0 0 8px"><b style="color:var(--txt)">HEIC</b> is the one format browsers can't decode on their own. This tool tries, in order &amp; all offline: your browser's native decoder &rarr; the OS codec via WebCodecs &rarr; a local <code>heic2any.min.js</code>. Only if all three fail <i>and</i> you happen to be online does it try a CDN as a last resort — your images still never leave the machine either way. It works with no setup on:</p>
376
+ <ul style="margin:0 0 8px;padding-left:18px">
377
+ <li><b style="color:var(--txt)">Safari</b> (Mac/iPhone/iPad) — decodes HEIC natively, works instantly.</li>
378
+ <li><b style="color:var(--txt)">Chrome / Edge on Windows</b> — install the free <i>HEVC Video Extensions</i> from the Microsoft Store once, then HEIC decodes offline via the OS.</li>
379
+ <li><b style="color:var(--txt)">Chrome / Edge on macOS</b> — uses the system codec, usually works out of the box.</li>
380
+ </ul>
381
+ <p style="margin:0"><b style="color:var(--txt)">Guaranteed-offline HEIC on any browser:</b> download <code>heic2any.min.js</code> once (from <code>github.com/alexcorvi/heic2any</code>, the <code>/dist</code> folder) and save it <b style="color:var(--txt)">in the same folder as this HTML file</b>. The app auto-detects it and then decodes HEIC locally forever, with zero network.</p>
382
+ </div>
383
+ </div>
384
+ <div class="keys" style="margin-top:8px">
385
+ AVIF <i>output</i> depends on your browser (Chrome/Edge support it; Safari/Firefox may not). A few spec items (original file dates, Live Photo extraction, subfolder mirroring) need a desktop app.
386
+ </div>
387
+ </div>
388
+ <div id="pageDrop" aria-hidden="true">
389
+ <div class="box">
390
+ <div class="big">&#8681;</div>
391
+ <h2>Drop to add images</h2>
392
+ <p>Anywhere on the page &middot; folders work too &middot; nothing leaves this computer</p>
393
+ </div>
394
+ </div>
395
+ <canvas id="work" style="display:none"></canvas>
396
+ <script>
397
+ "use strict";
398
+ (function(){
399
+ /* ======================= helpers ======================= */
400
+ const $ = id => document.getElementById(id);
401
+ const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
402
+ function human(bytes){
403
+ if(!isFinite(bytes) || bytes <= 0) return "\u2014";
404
+ if(bytes < 1024) return bytes + " B";
405
+ if(bytes < 1024*1024) return Math.round(bytes/1024) + " KB";
406
+ if(bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(bytes < 10*1024*1024 ? 2 : 1) + " MB";
407
+ return (bytes/1024/1024/1024).toFixed(2) + " GB";
408
+ }
409
+ function baseName(n){ return String(n||"image").replace(/\.[^.]+$/, "") || "image"; }
410
+ 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 : ""); }
418
+ function todayStr(){ const d = new Date(); const p = n => String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate()); }
419
+ function wait(ms){ return new Promise(r => setTimeout(r, ms)); }
420
+ /* format support probe */
421
+ async function supportsMime(mime){
422
+ try{
423
+ const c = document.createElement("canvas"); c.width = c.height = 4;
424
+ const blob = await new Promise(r => c.toBlob(r, mime, 0.8));
425
+ return !!(blob && blob.type === 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 ======================= */
442
+ 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
+
453
+ /* ---- collapse / expand the drop zone ---- */
454
+ let dropUserOpen = true; // user's explicit preference; auto-collapse only happens once
455
+ let dropAutoDone = false;
456
+ function setDropMini(mini){
457
+ drop.classList.toggle("mini", !!mini);
458
+ const t = $("dropToggle");
459
+ t.setAttribute("aria-expanded", String(!mini));
460
+ t.innerHTML = mini ? 'Add <span class="chev">&#9660;</span>' : 'Hide <span class="chev">&#9650;</span>';
461
+ }
462
+ $("dropToggle").addEventListener("click", e => {
463
+ e.stopPropagation();
464
+ dropUserOpen = drop.classList.contains("mini");
465
+ setDropMini(!dropUserOpen);
466
+ });
467
+ function autoCollapseDrop(){
468
+ if(dropAutoDone || !items.length) return;
469
+ dropAutoDone = true; dropUserOpen = false;
470
+ setDropMini(true);
471
+ }
472
+
473
+ /* ---- page-wide drag & drop ---- */
474
+ const pageDrop = $("pageDrop");
475
+ let dragDepth = 0;
476
+ const hasFiles = e => {
477
+ const t = e.dataTransfer && e.dataTransfer.types;
478
+ return !!t && (Array.prototype.indexOf.call(t, "Files") !== -1);
479
+ };
480
+ function showOverlay(on){
481
+ pageDrop.classList.toggle("show", !!on);
482
+ drop.classList.toggle("over", !!on);
483
+ if(!on) dragDepth = 0;
484
+ }
485
+ window.addEventListener("dragenter", e => {
486
+ if(!hasFiles(e)) return;
487
+ e.preventDefault(); dragDepth++; showOverlay(true);
488
+ });
489
+ window.addEventListener("dragover", e => {
490
+ if(!hasFiles(e)) return;
491
+ e.preventDefault();
492
+ try{ e.dataTransfer.dropEffect = "copy"; }catch(_){}
493
+ });
494
+ window.addEventListener("dragleave", e => {
495
+ if(!hasFiles(e)) return;
496
+ dragDepth = Math.max(0, dragDepth - 1);
497
+ if(dragDepth === 0) showOverlay(false);
498
+ });
499
+ window.addEventListener("drop", async e => {
500
+ if(!hasFiles(e)) return;
501
+ e.preventDefault(); showOverlay(false);
502
+ const dt = e.dataTransfer;
503
+ let files = null;
504
+ try{ files = await filesFromDataTransfer(dt); }catch(_){}
505
+ if(!files || !files.length) files = dt.files;
506
+ if(files && files.length) addFiles(files);
507
+ });
508
+ /* walk dropped folders (Chrome/Edge/Safari/Firefox all support webkitGetAsEntry) */
509
+ async function filesFromDataTransfer(dt){
510
+ const its = dt.items;
511
+ if(!its || !its.length || typeof DataTransferItem === "undefined" ||
512
+ !DataTransferItem.prototype.webkitGetAsEntry) return null;
513
+ const entries = [];
514
+ for(const it of its){ if(it.kind === "file"){ const en = it.webkitGetAsEntry && it.webkitGetAsEntry(); if(en) entries.push(en); } }
515
+ if(!entries.length) return null;
516
+ if(!entries.some(en => en.isDirectory)) return null; // plain files: use dt.files, it's faster
517
+ const out = [];
518
+ const readDir = dr => new Promise(res => {
519
+ const acc = [], rd = dr.createReader();
520
+ const step = () => rd.readEntries(batch => {
521
+ if(!batch.length) return res(acc);
522
+ acc.push(...batch); step();
523
+ }, () => res(acc));
524
+ step();
525
+ });
526
+ const walk = async en => {
527
+ if(en.isFile){ await new Promise(r => en.file(f => { out.push(f); r(); }, r)); }
528
+ else if(en.isDirectory){ for(const c of await readDir(en)) await walk(c); }
529
+ };
530
+ for(const en of entries) await walk(en);
531
+ return out;
532
+ }
533
+ 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";
557
+ /* ---- HEIC engine (optional, offline-first) ---- */
558
+ let heicPromise = null, heicSource = "";
559
+ function loadScript(src){
560
+ return new Promise((res, rej) => {
561
+ const s = document.createElement("script");
562
+ s.src = src; s.async = true;
563
+ s.onload = () => res(true);
564
+ s.onerror = () => rej(new Error("failed " + src));
565
+ document.head.appendChild(s);
566
+ });
567
+ }
568
+ function ensureHeic(){
569
+ if(typeof heic2any !== "undefined") return Promise.resolve(true);
570
+ if(heicPromise) return heicPromise;
571
+ const sources = [
572
+ { url: "heic2any.min.js", local: true },
573
+ { url: "libheif/heic2any.min.js", local: true },
574
+ { url: "https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js", local: false },
575
+ { url: "https://unpkg.com/heic2any@0.0.4/dist/heic2any.min.js", local: false }
576
+ ];
577
+ heicPromise = (async () => {
578
+ for(const s of sources){
579
+ try{ await loadScript(s.url); if(typeof heic2any !== "undefined"){ heicSource = s.local ? "local file" : "CDN"; return true; } }
580
+ catch(e){ /* try next */ }
581
+ }
582
+ heicPromise = null;
583
+ throw new Error("no HEIC engine available offline");
584
+ })();
585
+ return heicPromise;
586
+ }
587
+ async function decodeViaWebCodecs(blob){
588
+ if(typeof ImageDecoder === "undefined") return null;
589
+ try{
590
+ const type = blob.type && blob.type !== "" ? blob.type : "image/heic";
591
+ if(ImageDecoder.isTypeSupported){
592
+ const ok = await ImageDecoder.isTypeSupported(type).catch(() => false);
593
+ if(!ok) return null;
594
+ }
595
+ const buf = await blob.arrayBuffer();
596
+ const dec = new ImageDecoder({ data: buf, type });
597
+ const { image } = await dec.decode();
598
+ const bmp = await createImageBitmap(image);
599
+ try{ image.close && image.close(); }catch(_){}
600
+ return bmp;
601
+ }catch(e){ return null; }
602
+ }
603
+ async function decodeNative(blob){
604
+ const useExif = $("autoExif").checked;
605
+ try{
606
+ return await createImageBitmap(blob, useExif ? { imageOrientation: "from-image" } : {});
607
+ }catch(e){
608
+ try{ return await loadViaImg(blob); }
609
+ catch(e2){ return null; }
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);
617
+ if(!bmp && isHeic(it.file)){
618
+ try{
619
+ await ensureHeic();
620
+ let out;
621
+ try{ out = await heic2any({ blob: it.file, toType: "image/jpeg", quality: 0.92 }); }
622
+ catch(e){
623
+ if(/already browser readable/i.test(e && e.message || "")){ bmp = await decodeNative(it.file); }
624
+ else { out = await heic2any({ blob: it.file, toType: "image/png" }); }
625
+ }
626
+ if(!bmp && out){ bmp = await decodeNative(Array.isArray(out) ? out[0] : out); }
627
+ }catch(engineErr){}
628
+ }
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();
641
+ img.onload = () => { res(img); setTimeout(()=>URL.revokeObjectURL(url), 5000); };
642
+ img.onerror = () => { URL.revokeObjectURL(url); rej(new Error("decode failed")); };
643
+ img.src = url;
644
+ });
645
+ }
646
+ function makeThumb(bmp){
647
+ const c = document.createElement("canvas");
648
+ const w = 160, h = 120;
649
+ c.width = w; c.height = h;
650
+ const x = c.getContext("2d");
651
+ x.fillStyle = "#07090c"; x.fillRect(0,0,w,h);
652
+ const sc = Math.max(w/bmp.width, h/bmp.height);
653
+ const dw = bmp.width*sc, dh = bmp.height*sc;
654
+ x.drawImage(bmp, (w-dw)/2, (h-dh)/2, dw, dh);
655
+ return c.toDataURL("image/jpeg", 0.72);
656
+ }
657
+ /* ======================= transforms ======================= */
658
+ let rot = 0, flipH = false, flipV = false;
659
+ $("rotL").addEventListener("click", () => { rot = (rot + 270) % 360; drawPreview(); updateEstimate(); });
660
+ $("rotR").addEventListener("click", () => { rot = (rot + 90) % 360; drawPreview(); updateEstimate(); });
661
+ $("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){
671
+ const mode = document.querySelector("#resizeMode button.on").dataset.m;
672
+ let w = sw, h = sh;
673
+ if(mode === "pct"){ const p = (+$("pct").value||100)/100; w = sw*p; h = sh*p; }
674
+ else if(mode === "px"){
675
+ const pw = +$("pxW").value||0, ph = +$("pxH").value||0;
676
+ if(pw && ph){ w = pw; h = ph; }
677
+ else if(pw){ w = pw; h = sh*pw/sw; }
678
+ else if(ph){ h = ph; w = sw*ph/sh; }
679
+ }
680
+ else if(mode === "max"){
681
+ const m = +$("maxPreset").value||1600;
682
+ const s = Math.min(1, m/Math.max(sw, sh));
683
+ w = sw*s; h = sh*s;
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);
738
+ $("srcBadge").textContent = it ? (it.status === "error" ? "Could not decode this file" : "Decoding\u2026") : "No image selected";
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;
752
+ view = b.dataset.v;
753
+ document.querySelectorAll("#viewTabs button").forEach(x => x.classList.toggle("on", x === b));
754
+ $("previewView").style.display = view === "preview" ? "block" : "none";
755
+ $("gridView").style.display = view === "grid" ? "block" : "none";
756
+ $("listView").style.display = view === "grid" ? "none" : "block";
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 ======================= */
765
+ function render(){
766
+ $("qCount").textContent = items.length + (items.length===1?" image":" images") +
767
+ (items.length ? " \u00b7 " + selectedReady().length + " ticked" : "");
768
+ renderList();
769
+ if(view === "grid") renderGrid();
770
+ updateEstimate();
771
+ }
772
+ function renderList(){
773
+ const list = $("qList");
774
+ if(!items.length){ list.innerHTML = '<div class="empty">No images yet. Drop files above and they will appear here in order.</div>'; drawPreview(); return; }
775
+ list.textContent = "";
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";
783
+ chk.addEventListener("click", ev => { ev.stopPropagation(); it.selected = chk.checked; render(); });
784
+ row.appendChild(chk);
785
+ if(it.thumbUrl){ const img = document.createElement("img"); img.className = "qthumb"; img.src = it.thumbUrl; row.appendChild(img); }
786
+ else { const ph = document.createElement("div"); ph.className = "qthumb ph"; ph.textContent = it.status === "error" ? "!" : "\u2026"; row.appendChild(ph); }
787
+ const meta = document.createElement("div"); meta.className = "qmeta";
788
+ const nm = document.createElement("div"); nm.className = "qname"; nm.textContent = it.name; nm.title = it.name;
789
+ const sub = document.createElement("div"); sub.className = "qsub";
790
+ sub.textContent = it.status === "error" ? ("error: " + it.err)
791
+ : it.w ? (it.w + "\u00d7" + it.h + " \u00b7 " + human(it.file.size)) : "\u2026";
792
+ meta.appendChild(nm); meta.appendChild(sub);
793
+ row.appendChild(meta);
794
+ const st = document.createElement("div");
795
+ st.className = "qstate " + (it.status === "done" ? "done" : it.status === "error" ? "err" : (it.status==="decoding"||it.status==="working") ? "work" : "");
796
+ st.textContent = it.status === "done" ? human(it.outSize)
797
+ : it.status === "error" ? "failed"
798
+ : it.status === "working" ? "\u2026"
799
+ : it.status === "decoding" ? "reading" : "ready";
800
+ row.appendChild(st);
801
+ const x = document.createElement("button");
802
+ x.className = "qx danger ghost"; x.innerHTML = "&times;"; x.title = "Remove";
803
+ x.addEventListener("click", ev => { ev.stopPropagation(); removeAt(i); });
804
+ row.appendChild(x);
805
+ list.appendChild(row);
806
+ });
807
+ }
808
+ function renderGrid(){
809
+ const g = $("imgrid");
810
+ if(!items.length){ g.innerHTML = '<div class="empty">No images yet.</div>'; return; }
811
+ g.textContent = "";
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";
819
+ chk.addEventListener("click", ev => { ev.stopPropagation(); it.selected = chk.checked; render(); });
820
+ card.appendChild(chk);
821
+ const x = document.createElement("button");
822
+ x.className = "gx"; x.innerHTML = "&times;"; x.title = "Remove";
823
+ x.addEventListener("click", ev => { ev.stopPropagation(); removeAt(i); });
824
+ card.appendChild(x);
825
+ if(it.thumbUrl){ const img = document.createElement("img"); img.src = it.thumbUrl; card.appendChild(img); }
826
+ else { const ph = document.createElement("div"); ph.className = "gph"; ph.textContent = it.status === "error" ? "!" : "\u2026"; card.appendChild(ph); }
827
+ if(it.status !== "queued" && it.status !== "ready"){
828
+ const st = document.createElement("div");
829
+ st.className = "gstate " + (it.status === "done" ? "done" : it.status === "error" ? "err" : "work");
830
+ st.textContent = it.status === "done" ? human(it.outSize) : it.status === "error" ? "failed" : it.status === "decoding" ? "reading" : "\u2026";
831
+ card.appendChild(st);
832
+ }
833
+ const nm = document.createElement("div"); nm.className = "gname"; nm.textContent = it.name; nm.title = it.name;
834
+ card.appendChild(nm);
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);
850
+ });
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";
858
+ $("ditherChk").style.display = (outFmt === "image/png" && (+$("pngColors").value||0) > 0) ? "flex" : "none";
859
+ let hint = "";
860
+ if(outFmt === "image/png"){
861
+ hint = (+$("pngColors").value||0) > 0
862
+ ? "Indexed PNG (like Photoshop PNG-8) — much smaller, but limited to the chosen palette; photos may posterise. Keeps sharp graphics crisp."
863
+ : "Lossless 24-bit PNG (like Photoshop PNG-24) — full quality, no colour loss. Uses adaptive filtering so files are far smaller than a plain PNG. Transparency is preserved.";
864
+ }
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;
875
+ syncFmtUI();
876
+ drawPreview(); updateEstimate();
877
+ });
878
+ $("quality").addEventListener("input", () => { $("qualityL").textContent = $("quality").value; updateEstimate(); });
879
+ $("targetOn").addEventListener("change", () => { $("targetRow").style.display = ($("targetOn").checked && isLossy(outFmt)) ? "flex" : "none"; updateEstimate(); });
880
+ ["targetSize","targetUnit"].forEach(id => $(id).addEventListener("input", updateEstimate));
881
+ $("pngColors").addEventListener("change", () => { syncFmtUI(); drawPreview(); updateEstimate(); });
882
+ $("pngDither").addEventListener("change", updateEstimate);
883
+ $("resizeMode").addEventListener("click", e => {
884
+ const b = e.target.closest("button"); if(!b) return;
885
+ document.querySelectorAll("#resizeMode button").forEach(x => x.classList.toggle("on", x === b));
886
+ const m = b.dataset.m;
887
+ $("pctRow").style.display = m === "pct" ? "flex" : "none";
888
+ $("pxRows").style.display = m === "px" ? "block" : "none";
889
+ $("maxRow").style.display = m === "max" ? "flex" : "none";
890
+ if(m === "px" && items[sel]){ const it = items[sel]; $("pxW").value = it.w; $("pxH").value = it.h; }
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(){
906
+ const selN = selectedReady().length;
907
+ $("eCount").textContent = items.length ? (selN + " of " + items.length) : "\u2014";
908
+ const pc = +$("pngColors").value||0;
909
+ $("eFmt").textContent = fmtLabel(outFmt) + (outFmt==="image/png" ? (pc>0 ? " \u00b7 " + pc + "c" : " \u00b7 24-bit") : "");
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);
927
+ }
928
+ /* ======================= encoding ======================= */
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);
935
+ return encodeLosslessPNG(cv); // adaptive-filtered 24/32-bit (Photoshop PNG-24 style)
936
+ }
937
+ const q = (+$("quality").value||82)/100;
938
+ if(doTarget && $("targetOn").checked){
939
+ const unit = +$("targetUnit").value, limit = (+$("targetSize").value||2) * unit;
940
+ return encodeToTarget(cv, limit);
941
+ }
942
+ return canvasToBlob(cv, outFmt, q);
943
+ }
944
+ async function encodeToTarget(cv, limit){
945
+ let lo = 0.05, hi = 0.98, best = null;
946
+ let blob = await canvasToBlob(cv, outFmt, hi);
947
+ if(blob && blob.size <= limit) return blob;
948
+ for(let i = 0; i < 8; i++){
949
+ const mid = (lo + hi) / 2;
950
+ blob = await canvasToBlob(cv, outFmt, mid);
951
+ if(!blob){ break; }
952
+ if(blob.size <= limit){ best = blob; lo = mid; } else { hi = mid; }
953
+ }
954
+ return best || await canvasToBlob(cv, outFmt, lo);
955
+ }
956
+ /* ---------- PNG plumbing (CRC, chunks, zlib, adaptive filtering) ---------- */
957
+ async function deflateZlib(u8){
958
+ if(typeof CompressionStream === "undefined") return null;
959
+ const cs = new CompressionStream("deflate"); // zlib wrapper (RFC1950) — what PNG needs
960
+ const w = cs.writable.getWriter(); w.write(u8); w.close();
961
+ const buf = await new Response(cs.readable).arrayBuffer();
962
+ return new Uint8Array(buf);
963
+ }
964
+ const _crcTable = (() => { const t = new Uint32Array(256);
965
+ for(let n=0;n<256;n++){ let c=n; for(let k=0;k<8;k++) c = c&1 ? 0xEDB88320^(c>>>1) : c>>>1; t[n]=c>>>0; } return t; })();
966
+ function crc32(buf){ let c=0xFFFFFFFF; for(let i=0;i<buf.length;i++) c=_crcTable[(c^buf[i])&255]^(c>>>8); return (c^0xFFFFFFFF)>>>0; }
967
+ function u32be(n){ return new Uint8Array([(n>>>24)&255,(n>>>16)&255,(n>>>8)&255,n&255]); }
968
+ function pngChunk(type, data){
969
+ const t = new Uint8Array(4); for(let i=0;i<4;i++) t[i]=type.charCodeAt(i);
970
+ const body = new Uint8Array(t.length + data.length); body.set(t,0); body.set(data,t.length);
971
+ const out = new Uint8Array(4 + body.length + 4);
972
+ out.set(u32be(data.length),0); out.set(body,4); out.set(u32be(crc32(body)), 4+body.length);
973
+ return out;
974
+ }
975
+ function paeth(a,b,c){ const p=a+b-c, pa=Math.abs(p-a), pb=Math.abs(p-b), pc=Math.abs(p-c);
976
+ return pa<=pb && pa<=pc ? a : pb<=pc ? b : c; }
977
+ /* adaptive per-scanline filtering (None/Sub/Up/Average/Paeth), min-sum-of-abs heuristic.
978
+ Works for any bytes-per-pixel: 1 (indexed), 3 (RGB), 4 (RGBA). This is the core of
979
+ Photoshop-grade lossless PNG compression — no colour is changed, files just get much smaller. */
980
+ function filterScanlines(data, w, h, bpp){
981
+ const stride = w*bpp;
982
+ const out = new Uint8Array(h*(stride+1));
983
+ const c0=new Uint8Array(stride), c1=new Uint8Array(stride), c2=new Uint8Array(stride), c3=new Uint8Array(stride), c4=new Uint8Array(stride);
984
+ let prev = new Uint8Array(stride);
985
+ for(let y=0;y<h;y++){
986
+ const row = data.subarray(y*stride, y*stride+stride);
987
+ c0.set(row);
988
+ for(let i=0;i<stride;i++){
989
+ const a = i>=bpp ? row[i-bpp] : 0, b = prev[i], c = i>=bpp ? prev[i-bpp] : 0;
990
+ c1[i] = (row[i]-a) & 255;
991
+ c2[i] = (row[i]-b) & 255;
992
+ c3[i] = (row[i]-((a+b)>>1)) & 255;
993
+ c4[i] = (row[i]-paeth(a,b,c)) & 255;
994
+ }
995
+ let bestF=0, bestScore=Infinity;
996
+ const cands=[c0,c1,c2,c3,c4];
997
+ for(let f=0;f<5;f++){ let s=0; const cc=cands[f]; for(let i=0;i<stride;i++){ const v=cc[i]; s += v<128 ? v : 256-v; } if(s<bestScore){ bestScore=s; bestF=f; } }
998
+ out[y*(stride+1)] = bestF;
999
+ out.set(cands[bestF], y*(stride+1)+1);
1000
+ prev = row.slice ? row.slice() : new Uint8Array(row); // keep a stable copy for the next row
1001
+ }
1002
+ return out;
1003
+ }
1004
+ function assemblePNG(w, h, colorType, chunksBetween, idat){
1005
+ const sig = new Uint8Array([137,80,78,71,13,10,26,10]);
1006
+ const ihdr = new Uint8Array(13);
1007
+ ihdr.set(u32be(w),0); ihdr.set(u32be(h),4); ihdr[8]=8; ihdr[9]=colorType; ihdr[10]=0; ihdr[11]=0; ihdr[12]=0;
1008
+ const chunks = [ sig, pngChunk("IHDR",ihdr) ];
1009
+ for(const c of (chunksBetween||[])) chunks.push(c);
1010
+ chunks.push(pngChunk("IDAT", idat));
1011
+ chunks.push(pngChunk("IEND", new Uint8Array(0)));
1012
+ let total=0; chunks.forEach(c=>total+=c.length);
1013
+ const out=new Uint8Array(total); let off=0; chunks.forEach(c=>{ out.set(c,off); off+=c.length; });
1014
+ return new Blob([out], { type:"image/png" });
1015
+ }
1016
+ /* Lossless 24/32-bit PNG. Detects alpha; RGB (type 2) if fully opaque, else RGBA (type 6). */
1017
+ async function encodeLosslessPNG(cv){
1018
+ const ctx = cv.getContext("2d");
1019
+ const { data, width:w, height:h } = ctx.getImageData(0,0,cv.width,cv.height);
1020
+ let hasAlpha = false;
1021
+ for(let i=3;i<data.length;i+=4){ if(data[i] !== 255){ hasAlpha = true; break; } }
1022
+ let pixels, bpp, colorType;
1023
+ if(hasAlpha){ pixels = new Uint8Array(data.buffer.slice(0)); bpp = 4; colorType = 6; }
1024
+ else {
1025
+ bpp = 3; colorType = 2;
1026
+ pixels = new Uint8Array(w*h*3);
1027
+ for(let p=0,q=0;q<w*h;q++,p+=4){ pixels[q*3]=data[p]; pixels[q*3+1]=data[p+1]; pixels[q*3+2]=data[p+2]; }
1028
+ }
1029
+ const filtered = filterScanlines(pixels, w, h, bpp);
1030
+ const idat = await deflateZlib(filtered);
1031
+ if(!idat) return canvasToBlob(cv, "image/png"); // no CompressionStream → browser PNG
1032
+ return assemblePNG(w, h, colorType, null, idat);
1033
+ }
1034
+ /* ---------- indexed PNG (median cut + adaptive filtering) ---------- */
1035
+ const _hist = new Int32Array(32768), _rs = new Int32Array(32768), _gs = new Int32Array(32768), _bs = new Int32Array(32768);
1036
+ const _binArr = new Int32Array(32768);
1037
+ function buildPalette(data, maxColors){
1038
+ _hist.fill(0); _rs.fill(0); _gs.fill(0); _bs.fill(0);
1039
+ for(let i=0;i<data.length;i+=4){
1040
+ const r=data[i],g=data[i+1],b=data[i+2];
1041
+ const k=((r>>3)<<10)|((g>>3)<<5)|(b>>3);
1042
+ _hist[k]++; _rs[k]+=r; _gs[k]+=g; _bs[k]+=b;
1043
+ }
1044
+ let m=0; for(let k=0;k<32768;k++) if(_hist[k]) _binArr[m++]=k;
1045
+ const stat=(s,e)=>{
1046
+ let rmin=31,rmax=0,gmin=31,gmax=0,bmin=31,bmax=0;
1047
+ for(let i=s;i<e;i++){ const k=_binArr[i], r=(k>>10)&31,g=(k>>5)&31,b=k&31;
1048
+ if(r<rmin)rmin=r; if(r>rmax)rmax=r; if(g<gmin)gmin=g; if(g>gmax)gmax=g; if(b<bmin)bmin=b; if(b>bmax)bmax=b; }
1049
+ const dr=rmax-rmin,dg=gmax-gmin,db=bmax-bmin; let chan=0,ext=dr;
1050
+ if(dg>ext){chan=1;ext=dg;} if(db>ext){chan=2;ext=db;}
1051
+ return {s,e,chan,ext};
1052
+ };
1053
+ let boxes=[stat(0,m)];
1054
+ while(boxes.length<maxColors){
1055
+ let bi=-1,best=0;
1056
+ for(let i=0;i<boxes.length;i++){ const B=boxes[i]; if(B.e-B.s<2) continue; if(B.ext>best){best=B.ext;bi=i;} }
1057
+ if(bi<0) break;
1058
+ const B=boxes[bi], shift=B.chan===0?10:B.chan===1?5:0;
1059
+ const sub=Array.from(_binArr.subarray(B.s,B.e));
1060
+ sub.sort((x,y)=>(((x>>shift)&31)-((y>>shift)&31)));
1061
+ _binArr.set(sub,B.s);
1062
+ let total=0; for(let i=B.s;i<B.e;i++) total+=_hist[_binArr[i]];
1063
+ let acc=0,mid=B.s+1;
1064
+ for(let i=B.s;i<B.e;i++){ acc+=_hist[_binArr[i]]; if(acc*2>=total){ mid=i+1; break; } }
1065
+ if(mid<=B.s) mid=B.s+1; if(mid>=B.e) mid=B.e-1;
1066
+ boxes.splice(bi,1,stat(B.s,mid),stat(mid,B.e));
1067
+ }
1068
+ const pal=new Uint8Array(768); let count=0;
1069
+ for(let i=0;i<boxes.length;i++){
1070
+ const B=boxes[i]; let c=0,R=0,G=0,Bl=0;
1071
+ for(let x=B.s;x<B.e;x++){ const k=_binArr[x]; c+=_hist[k]; R+=_rs[k]; G+=_gs[k]; Bl+=_bs[k]; }
1072
+ if(c){ pal[i*3]=Math.round(R/c); pal[i*3+1]=Math.round(G/c); pal[i*3+2]=Math.round(Bl/c); count++; }
1073
+ }
1074
+ return { pal, count: Math.max(1,count) };
1075
+ }
1076
+ const _near = new Int16Array(32768);
1077
+ function mapToPalette(data, w, h, pal, palCount, dither){
1078
+ const idx=new Uint8Array(w*h); _near.fill(-1);
1079
+ const nearest=(r,g,b)=>{
1080
+ const key=((r>>3)<<10)|((g>>3)<<5)|(b>>3); const hit=_near[key]; if(hit>=0) return hit;
1081
+ let bd=Infinity,bi=0;
1082
+ for(let i=0;i<palCount;i++){ const dr=r-pal[i*3],dg=g-pal[i*3+1],db=b-pal[i*3+2]; const d=dr*dr*3+dg*dg*6+db*db; if(d<bd){bd=d;bi=i;} }
1083
+ _near[key]=bi; return bi;
1084
+ };
1085
+ if(!dither){ for(let p=0,q=0;q<idx.length;q++,p+=4) idx[q]=nearest(data[p],data[p+1],data[p+2]); return idx; }
1086
+ const buf=new Float32Array(w*h*3);
1087
+ for(let p=0,q=0;q<w*h;q++,p+=4){ buf[q*3]=data[p]; buf[q*3+1]=data[p+1]; buf[q*3+2]=data[p+2]; }
1088
+ const cl=v=>v<0?0:(v>255?255:v);
1089
+ for(let y=0;y<h;y++) for(let x=0;x<w;x++){
1090
+ const q=y*w+x,o=q*3; const r=cl(buf[o]),g=cl(buf[o+1]),b=cl(buf[o+2]);
1091
+ const i=nearest(r|0,g|0,b|0); idx[q]=i;
1092
+ const er=(r-pal[i*3])*0.75,eg=(g-pal[i*3+1])*0.75,eb=(b-pal[i*3+2])*0.75;
1093
+ if(x+1<w){ const oo=(q+1)*3; buf[oo]+=er*0.4375; buf[oo+1]+=eg*0.4375; buf[oo+2]+=eb*0.4375; }
1094
+ if(y+1<h){
1095
+ if(x>0){ const oo=(q+w-1)*3; buf[oo]+=er*0.1875; buf[oo+1]+=eg*0.1875; buf[oo+2]+=eb*0.1875; }
1096
+ { const oo=(q+w)*3; buf[oo]+=er*0.3125; buf[oo+1]+=eg*0.3125; buf[oo+2]+=eb*0.3125; }
1097
+ if(x+1<w){ const oo=(q+w+1)*3; buf[oo]+=er*0.0625; buf[oo+1]+=eg*0.0625; buf[oo+2]+=eb*0.0625; }
1098
+ }
1099
+ }
1100
+ return idx;
1101
+ }
1102
+ async function encodeIndexedPNG(cv, colors, dither){
1103
+ const ctx = cv.getContext("2d");
1104
+ const { data, width:w, height:h } = ctx.getImageData(0,0,cv.width,cv.height);
1105
+ const { pal, count } = buildPalette(data, colors);
1106
+ const idx = mapToPalette(data, w, h, pal, count, dither);
1107
+ const filtered = filterScanlines(idx, w, h, 1); // adaptive filtering on indices too
1108
+ const idat = await deflateZlib(filtered);
1109
+ if(!idat) return canvasToBlob(cv, "image/png");
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);
1116
+ const bytes = [];
1117
+ const push = s => { for(let i=0;i<s.length;i++) bytes.push(s.charCodeAt(i) & 255); };
1118
+ const pushBytes = arr => { for(let i=0;i<arr.length;i++) bytes.push(arr[i]); };
1119
+ const imgDatas = [];
1120
+ for(const cv of canvases){ const b = await canvasToBlob(cv, "image/jpeg", q); imgDatas.push(new Uint8Array(await b.arrayBuffer())); }
1121
+ const N = canvases.length;
1122
+ const offsets = [];
1123
+ const start = () => offsets.push(bytes.length);
1124
+ push("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
1125
+ start(); push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1126
+ const kids = []; for(let i=0;i<N;i++) kids.push((3 + i*3) + " 0 R");
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];
1138
+ start();
1139
+ push(imgObj + " 0 obj\n<< /Type /XObject /Subtype /Image /Width " + w + " /Height " + h +
1140
+ " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + data.length + " >>\nstream\n");
1141
+ pushBytes(data);
1142
+ push("\nendstream\nendobj\n");
1143
+ }
1144
+ const xrefPos = bytes.length, total = 2 + N*3;
1145
+ push("xref\n0 " + (total+1) + "\n");
1146
+ push("0000000000 65535 f \n");
1147
+ for(const off of offsets){ push(String(off).padStart(10,"0") + " 00000 n \n"); }
1148
+ push("trailer\n<< /Size " + (total+1) + " /Root 1 0 R >>\nstartxref\n" + xrefPos + "\n%%EOF");
1149
+ return new Blob([new Uint8Array(bytes)], { type: "application/pdf" });
1150
+ }
1151
+ /* ======================= naming ======================= */
1152
+ function outNameFor(it, i, w, h){
1153
+ const mode = document.querySelector("#nameMode button.on").dataset.m;
1154
+ const ext = extFor(outFmt);
1155
+ const num = (+$("startN").value||1) + i;
1156
+ if(mode === "keep") return safeName(baseName(it.name), "image") + "." + ext;
1157
+ const tpl = ($("tpl").value || "IMG_{n}")
1158
+ .replace(/\{n\}/g, String(num).padStart(3,"0"))
1159
+ .replace(/\{name\}/g, baseName(it.name))
1160
+ .replace(/\{date\}/g, todayStr())
1161
+ .replace(/\{w\}/g, w).replace(/\{h\}/g, h);
1162
+ return safeName(($("prefix").value||"") + tpl + ($("suffix").value||""), "image") + "." + ext;
1163
+ }
1164
+ /* Two source files can easily produce the same output name (e.g. "Keep original"
1165
+ with IMG_001.heic from two different folders). Without this, the second silently
1166
+ overwrites the first when saving to a picked folder. */
1167
+ function uniqueName(name, used){
1168
+ if(!used.has(name)){ used.add(name); return name; }
1169
+ const dot = name.lastIndexOf(".");
1170
+ const stem = dot > 0 ? name.slice(0, dot) : name;
1171
+ const ext = dot > 0 ? name.slice(dot) : "";
1172
+ let k = 2, next;
1173
+ do { next = stem + "-" + k + ext; k++; } while(used.has(next));
1174
+ used.add(next); return next;
1175
+ }
1176
+ /* ======================= save helpers ======================= */
1177
+ function download(blob, name){
1178
+ const a = document.createElement("a");
1179
+ a.href = URL.createObjectURL(blob); a.download = name; a.style.display = "none";
1180
+ document.body.appendChild(a); a.click();
1181
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 12000);
1182
+ }
1183
+ /* ======================= export ======================= */
1184
+ $("stopBtn").addEventListener("click", () => { cancel = true; say($("status"), "Stopping\u2026"); });
1185
+ $("exportBtn").addEventListener("click", async () => {
1186
+ if(exporting) return;
1187
+ const ready = selectedReady();
1188
+ if(!ready.length){ say($("status"), "Tick at least one readable image first.", "err"); return; }
1189
+ if(outFmt === "image/avif" && support["image/avif"] === false){
1190
+ say($("status"), "This browser can\u2019t encode AVIF. Pick WebP or JPEG, or use Chrome/Edge.", "err"); return;
1191
+ }
1192
+ if(outFmt === "application/pdf") return exportPdf(ready);
1193
+ exporting = true; cancel = false; lock(true);
1194
+ $("bar").style.display = "block"; $("barI").style.width = "0%";
1195
+ say($("status"), "Converting\u2026");
1196
+ let dirHandle = null;
1197
+ if(ready.length > 1 && window.showDirectoryPicker){
1198
+ try{ dirHandle = await window.showDirectoryPicker({ id:"pocket-img", mode:"readwrite", startIn:"pictures" }); }
1199
+ catch(e){ if(e && e.name === "AbortError"){ exporting=false; lock(false); $("bar").style.display="none"; say($("status"),"Export cancelled \u2014 no folder chosen."); return; } dirHandle = null; }
1200
+ }
1201
+ let done = 0, failed = 0, i = 0;
1202
+ const usedNames = new Set();
1203
+ for(const it of ready){
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;
1212
+ if(dirHandle){
1213
+ const fh = await dirHandle.getFileHandle(name, { create: true });
1214
+ const ws = await fh.createWritable(); await ws.write(blob); await ws.close();
1215
+ }else{
1216
+ download(blob, name);
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();
1224
+ await wait(0);
1225
+ }
1226
+ exporting = false; lock(false); render();
1227
+ $("stopBtn").style.display = "none"; $("bar").style.display = "none";
1228
+ const where = dirHandle ? " to \u201c" + dirHandle.name + "\u201d" : (ready.length>1 ? " to your Downloads folder" : "");
1229
+ say($("status"), cancel ? ("Stopped \u2014 " + done + " saved.") :
1230
+ ("Done \u2014 " + done + " saved" + where + (failed ? " \u00b7 " + failed + " failed" : "") + "."), failed ? "err" : "ok");
1231
+ });
1232
+ async function exportPdf(ready){
1233
+ exporting = true; cancel = false; lock(true);
1234
+ $("bar").style.display = "block"; $("barI").style.width = "0%";
1235
+ say($("status"), "Building PDF\u2026");
1236
+ try{
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);
1244
+ $("barI").style.width = ((k+1)/ready.length*80).toFixed(1) + "%";
1245
+ say($("status"), "Rendering page " + (k+1) + " of " + ready.length + "\u2026");
1246
+ await wait(0);
1247
+ }
1248
+ if(cancel){ say($("status"), "Stopped."); }
1249
+ else{
1250
+ $("barI").style.width = "92%";
1251
+ const blob = await makePdf(canvases);
1252
+ const name = safeName(($("prefix").value||"") + "images_" + todayStr(), "images") + ".pdf";
1253
+ download(blob, name);
1254
+ ready.forEach(it => { it.status = "done"; it.outSize = Math.round(blob.size/ready.length); });
1255
+ say($("status"), "Saved " + name + " (" + human(blob.size) + ", " + ready.length + " page" + (ready.length===1?"":"s") + ").", "ok");
1256
+ }
1257
+ }catch(e){ say($("status"), e.message || "PDF failed.", "err"); }
1258
+ finally{ exporting = false; lock(false); $("stopBtn").style.display = "none"; $("bar").style.display = "none"; render(); }
1259
+ }
1260
+ function lock(on){
1261
+ $("exportBtn").disabled = on;
1262
+ $("stopBtn").style.display = on ? "block" : "none";
1263
+ ["clearAll","clearDone","pickFiles","pickFolder","selAll","selNone"].forEach(id => $(id).disabled = on);
1264
+ }
1265
+ /* don't lose a batch to an accidental tab close */
1266
+ window.addEventListener("beforeunload", e => { if(exporting){ e.preventDefault(); e.returnValue = ""; } });
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); } }
1278
+ });
1279
+ /* ======================= startup ======================= */
1280
+ (async function init(){
1281
+ support["image/webp"] = await supportsMime("image/webp");
1282
+ support["image/avif"] = await supportsMime("image/avif");
1283
+ if(!support["image/avif"]){
1284
+ const b = document.querySelector('#fmtSeg button[data-fmt="image/avif"]');
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>