1/** 2 * DPI / Print Size Converter (client-side). 3 * JPEG: read/write XResolution/YResolution/ResolutionUnit via piexifjs (binary, no re-encode). 4 * PNG: read/write pHYs chunk (pixels-per-meter). 5 * WebP: show pixels + calculator; DPI tag rewrite not supported (honest). 6 * CDN: piexifjs + JSZip (see dpi-converter.html). 7 */ 8(function () { 9 'use strict'; 10 11 var MAX_FILES = 20; 12 var ACCEPT_EXT = /\.(jpe?g|png|webp)$/i; 13 var ACCEPT_MIME = /^(image\/jpeg|image\/png|image\/webp)$/i; 14 var INCH_PER_METER = 39.37007874015748; 15 16 var files = []; // { id, file, previewUrl, width, height, dpi, dpiSource, status, resultBlob, resultName } 17 var nextId = 1; 18 var activeId = null; 19 20 var uploadArea = document.getElementById('uploadArea'); 21 var fileInput = document.getElementById('fileInput'); 22 var uploadBtn = document.getElementById('uploadBtn'); 23 var fileCount = document.getElementById('fileCount'); 24 var fileList = document.getElementById('fileList'); 25 var settingsPanel = document.getElementById('settingsPanel'); 26 var resultsPanel = document.getElementById('resultsPanel'); 27 var resultsBody = document.getElementById('resultsBody'); 28 var applyBtn = document.getElementById('applyBtn'); 29 var clearBtn = document.getElementById('clearBtn'); 30 var zipBtn = document.getElementById('downloadAllBtn'); 31 var statusMessage = document.getElementById('statusMessage'); 32 var progressWrap = document.getElementById('progressWrap'); 33 var progressBar = document.getElementById('progressBar'); 34 35 var infoPixels = document.getElementById('infoPixels'); 36 var infoDpi = document.getElementById('infoDpi'); 37 var infoPrint = document.getElementById('infoPrint'); 38 var targetDpiInput = document.getElementById('targetDpi'); 39 var presetBtns = document.querySelectorAll('[data-dpi-preset]'); 40 var calcInW = document.getElementById('calcInW'); 41 var calcInH = document.getElementById('calcInH'); 42 var calcCmW = document.getElementById('calcCmW'); 43 var calcCmH = document.getElementById('calcCmH'); 44 var calcPxW = document.getElementById('calcPxW'); 45 var calcPxH = document.getElementById('calcPxH'); 46 var calcDpi = document.getElementById('calcDpi'); 47 var calcMode = document.getElementById('calcMode'); 48 var calcRunBtn = document.getElementById('calcRunBtn'); 49 var calcOut = document.getElementById('calcOut'); 50 51 function setStatus(msg, isError) { 52 statusMessage.textContent = msg || ''; 53 statusMessage.classList.toggle('error', !!isError); 54 } 55 56 function formatBytes(n) { 57 if (n < 1024) return n + ' B'; 58 if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; 59 return (n / (1024 * 1024)).toFixed(2) + ' MB'; 60 } 61 62 function arrayBufferToBinaryString(buffer) { 63 var bytes = new Uint8Array(buffer); 64 var chunk = 0x8000; 65 var parts = []; 66 for (var i = 0; i < bytes.length; i += chunk) { 67 parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))); 68 } 69 return parts.join(''); 70 } 71 72 function binaryStringToUint8Array(bin) { 73 var out = new Uint8Array(bin.length); 74 for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) & 0xff; 75 return out; 76 } 77 78 function isJpeg(file) { 79 var t = (file.type || '').toLowerCase(); 80 if (t === 'image/jpeg') return true; 81 return /\.jpe?g$/i.test(file.name || ''); 82 } 83 84 function isPng(file) { 85 var t = (file.type || '').toLowerCase(); 86 if (t === 'image/png') return true; 87 return /\.png$/i.test(file.name || ''); 88 } 89 90 function isWebp(file) { 91 var t = (file.type || '').toLowerCase(); 92 if (t === 'image/webp') return true; 93 return /\.webp$/i.test(file.name || ''); 94 } 95 96 function rationalToNumber(v) { 97 if (typeof v === 'number') return v; 98 if (Array.isArray(v) && v.length >= 2 && v[1]) return v[0] / v[1]; 99 return null; 100 } 101 102 function readJpegDpi(bin) { 103 if (typeof piexif === 'undefined') { 104 return { dpi: null, source: 'piexifjs failed to load' }; 105 } 106 try { 107 var exifObj = piexif.load(bin); 108 var z = exifObj['0th'] || {}; 109 var xRes = rationalToNumber(z[piexif.ImageIFD.XResolution]); 110 var yRes = rationalToNumber(z[piexif.ImageIFD.YResolution]); 111 var unit = z[piexif.ImageIFD.ResolutionUnit]; 112 var res = xRes || yRes; 113 if (!res || res <= 0) { 114 return { dpi: null, source: 'not set in EXIF' }; 115 } 116 // 2 = inches, 3 = cm 117 if (unit === 3) { 118 return { dpi: res * 2.54, source: 'EXIF (cm â DPI)' }; 119 } 120 if (unit === 2 || unit == null) { 121 return { dpi: res, source: 'EXIF X/YResolution' }; 122 } 123 return { dpi: res, source: 'EXIF (unit ' + unit + ')' }; 124 } catch (e) { 125 return { dpi: null, source: 'no readable JPEG EXIF' }; 126 } 127 } 128 129 function crc32(buf) { 130 var table = crc32._t; 131 if (!table) { 132 table = new Uint32Array(256); 133 for (var n = 0; n < 256; n++) { 134 var c = n; 135 for (var k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); 136 table[n] = c >>> 0; 137 } 138 crc32._t = table; 139 } 140 var crc = 0xffffffff; 141 for (var i = 0; i < buf.length; i++) { 142 crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); 143 } 144 return (crc ^ 0xffffffff) >>> 0; 145 } 146 147 function readPngDpi(bytes) { 148 // PNG signature 8 bytes, then chunks: len(4) type(4) data(len) crc(4)
149 if (bytes.length < 24) return { dpi: null, source: 'invalid PNG' }; 150 var sig = [137, 80, 78, 71, 13, 10, 26, 10]; 151 for (var s = 0; s < 8; s++) { 152 if (bytes[s] !== sig[s]) return { dpi: null, source: 'not a PNG' }; 153 } 154 var i = 8; 155 while (i + 12 <= bytes.length) { 156 var len = (bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]; 157 if (len < 0) break; 158 var type = String.fromCharCode(bytes[i + 4], bytes[i + 5], bytes[i + 6], bytes[i + 7]); 159 var dataStart = i + 8; 160 var dataEnd = dataStart + len; 161 if (dataEnd + 4 > bytes.length) break; 162 if (type === 'pHYs' && len >= 9) { 163 var ppux = ((bytes[dataStart] << 24) | (bytes[dataStart + 1] << 16) | 164 (bytes[dataStart + 2] << 8) | bytes[dataStart + 3]) >>> 0; 165 var unit = bytes[dataStart + 8]; 166 if (unit === 1 && ppux > 0) { 167 return { dpi: ppux / INCH_PER_METER, source: 'PNG pHYs' }; 168 } 169 return { dpi: null, source: 'PNG pHYs unit unknown (not meters)' }; 170 } 171 if (type === 'IEND') break; 172 i = dataEnd + 4; 173 } 174 return { dpi: null, source: 'not set (no pHYs)' }; 175 } 176 177 function writePngDpi(bytes, dpi) { 178 var ppm = Math.round(dpi * INCH_PER_METER); 179 if (ppm < 1) ppm = 1; 180 var physData = new Uint8Array(9); 181 physData[0] = (ppm >>> 24) & 0xff; 182 physData[1] = (ppm >>> 16) & 0xff; 183 physData[2] = (ppm >>> 8) & 0xff;
184 physData[3] = ppm & 0xff; 185 physData[4] = (ppm >>> 24) & 0xff; 186 physData[5] = (ppm >>> 16) & 0xff; 187 physData[6] = (ppm >>> 8) & 0xff; 188 physData[7] = ppm & 0xff; 189 physData[8] = 1; // meter 190 191 var typeBytes = [112, 72, 89, 115]; // pHYs 192 var crcBuf = new Uint8Array(4 + 9); 193 for (var t = 0; t < 4; t++) crcBuf[t] = typeBytes[t]; 194 for (var d = 0; d < 9; d++) crcBuf[4 + d] = physData[d]; 195 var crc = crc32(crcBuf); 196 197 var chunk = new Uint8Array(4 + 4 + 9 + 4); 198 chunk[0] = 0; chunk[1] = 0; chunk[2] = 0; chunk[3] = 9; 199 for (t = 0; t < 4; t++) chunk[4 + t] = typeBytes[t]; 200 for (d = 0; d < 9; d++) chunk[8 + d] = physData[d]; 201 chunk[17] = (crc >>> 24) & 0xff; 202 chunk[18] = (crc >>> 16) & 0xff; 203 chunk[19] = (crc >>> 8) & 0xff; 204 chunk[20] = crc & 0xff; 205 206 // Find IHDR end and whether pHYs exists 207 var i = 8; 208 var ihdrEnd = -1; 209 var physStart = -1; 210 var physEnd = -1; 211 while (i + 12 <= bytes.length) { 212 var len = (bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]; 213 var type = String.fromCharCode(bytes[i + 4], bytes[i + 5], bytes[i + 6], bytes[i + 7]); 214 var dataEnd = i + 8 + len; 215 var next = dataEnd + 4; 216 if (type === 'IHDR') ihdrEnd = next; 217 if (type === 'pHYs') { physStart = i; physEnd = next; } 218 if (type === 'IEND') break; 219 i = next; 220 } 221 if (ihdrEnd < 0) throw new Error('PNG missing IHDR'); 222 223 var out; 224 if (physStart >= 0) { 225 out = new Uint8Array(bytes.length - (physEnd - physStart) + chunk.length); 226 out.set(bytes.subarray(0, physStart), 0); 227 out.set(chunk, physStart); 228 out.set(bytes.subarray(physEnd), physStart + chunk.length); 229 } else { 230 out = new Uint8Array(bytes.length + chunk.length); 231 out.set(bytes.subarray(0, ihdrEnd), 0); 232 out.set(chunk, ihdrEnd); 233 out.set(bytes.subarray(ihdrEnd), ihdrEnd + chunk.length); 234 } 235 return out; 236 } 237 238 function writeJpegDpi(bin, dpi) { 239 if (typeof piexif === 'undefined') throw new Error('piexifjs not loaded'); 240 var dpiInt = Math.round(dpi); 241 if (dpiInt < 1) dpiInt = 1; 242 var exifObj; 243 try { 244 exifObj = piexif.load(bin); 245 } catch (e) { 246 exifObj = { '0th': {}, Exif: {}, GPS: {}, '1st': {}, thumbnail: null }; 247 } 248 if (!exifObj['0th']) exifObj['0th'] = {}; 249 exifObj['0th'][piexif.ImageIFD.XResolution] = [dpiInt, 1]; 250 exifObj['0th'][piexif.ImageIFD.YResolution] = [dpiInt, 1]; 251 exifObj['0th'][piexif.ImageIFD.ResolutionUnit] = 2; // inches 252 var dumped = piexif.dump(exifObj); 253 // remove then insert for a clean path 254 var cleaned = piexif.remove(bin); 255 return piexif.insert(dumped, cleaned); 256 } 257 258 function loadImageDims(file) { 259 return new Promise(function (resolve, reject) { 260 var url = URL.createObjectURL(file); 261 var img = new Image(); 262 img.onload = function () { 263 var w = img.naturalWidth || img.width; 264 var h = img.naturalHeight || img.height; 265 URL.revokeObjectURL(url); 266 resolve({ width: w, height: h, previewUrl: URL.createObjectURL(file) }); 267 }; 268 img.onerror = function () { 269 URL.revokeObjectURL(url); 270 reject(new Error('Could not decode image')); 271 }; 272 img.src = url; 273 }); 274 } 275 276 function getTargetDpi() { 277 var v = parseFloat(targetDpiInput.value); 278 if (!isFinite(v) || v <= 0) return 300; 279 return v; 280 } 281 282 function printSizeText(w, h, dpi) { 283 if (!w || !h || !dpi || dpi <= 0) return 'â'; 284 var inW = w / dpi; 285 var inH = h / dpi; 286 var cmW = inW * 2.54; 287 var cmH = inH * 2.54; 288 return inW.toFixed(2) + ' Ã ' + inH.toFixed(2) + ' in (' + 289 cmW.toFixed(1) + ' Ã ' + cmH.toFixed(1) + ' cm) @ ' + Math.round(dpi) + ' DPI'; 290 } 291 292 function updateActiveInfo() { 293 var item = files.find(function (f) { return f.id === activeId; }) || files[0]; 294 if (!item) { 295 infoPixels.textContent = 'â'; 296 infoDpi.textContent = 'â'; 297 infoPrint.textContent = 'â'; 298 return; 299 } 300 infoPixels.textContent = item.width + ' Ã ' + item.height + ' px'; 301 if (item.dpi != null) { 302 infoDpi.textContent = Math.round(item.dpi * 10) / 10 + ' DPI (' + item.dpiSource + ')'; 303 } else { 304 infoDpi.textContent = 'Not set â calculator assumes 72 DPI unless you choose a target'; 305 } 306 var useDpi = item.dpi != null ? item.dpi : 72; 307 infoPrint.textContent = printSizeText(item.width, item.height, useDpi) + 308 (item.dpi == null ? ' (assumed 72)' : ''); 309 310 if (calcPxW && !calcPxW.value) calcPxW.value = item.width; 311 if (calcPxH && !calcPxH.value) calcPxH.value = item.height;
312 if (calcDpi && !calcDpi.value) calcDpi.value = String(Math.round(item.dpi != null ? item.dpi : getTargetDpi())); 313 } 314 315 function renderFileList() { 316 fileList.innerHTML = ''; 317 files.forEach(function (item) { 318 var row = document.createElement('div'); 319 row.className = 'file-row' + (item.id === activeId ? ' active-file' : ''); 320 row.innerHTML = 321 '<span class="file-name" title="' + item.file.name.replace(/"/g, '"') + '">' + 322 item.file.name.replace(/</g, '<') + '</span>' + 323 '<span class="file-size">' + formatBytes(item.file.size) + '</span>' + 324 '<button type="button" class="file-remove" data-id="' + item.id + '" aria-label="Remove">â</button>'; 325 row.addEventListener('click', function (e) { 326 if (e.target.classList.contains('file-remove')) return; 327 activeId = item.id; 328 renderFileList(); 329 updateActiveInfo(); 330 }); 331 fileList.appendChild(row); 332 }); 333 fileList.querySelectorAll('.file-remove').forEach(function (btn) { 334 btn.addEventListener('click', function (e) { 335 e.stopPropagation(); 336 var id = parseInt(btn.getAttribute('data-id'), 10); 337 var found = files.find(function (f) { return f.id === id; }); 338 if (found && found.previewUrl) URL.revokeObjectURL(found.previewUrl); 339 files = files.filter(function (f) { return f.id !== id; }); 340 if (activeId === id) activeId = files.length ? files[0].id : null; 341 fileCount.textContent = files.length + ' file' + (files.length === 1 ? '' : 's') + ' selected (max ' + MAX_FILES + ')'; 342 settingsPanel.style.display = files.length ? 'block' : 'none'; 343 if (!files.length) resultsPanel.style.display = 'none'; 344 renderFileList(); 345 updateActiveInfo(); 346 renderResults(); 347 }); 348 }); 349 } 350 351 function renderResults() { 352 var any = files.some(function (f) { return f.status === 'done' || f.status === 'error' || f.status === 'ready'; }); 353 if (!any && !files.length) { 354 resultsPanel.style.display = 'none'; 355 return; 356 } 357 resultsPanel.style.display = 'block'; 358 resultsBody.innerHTML = ''; 359 var doneCount = 0; 360 files.forEach(function (item) { 361 var tr = document.createElement('tr'); 362 var dpiLabel = item.dpi != null 363 ? (Math.round(item.dpi * 10) / 10 + ' DPI') 364 : 'not set'; 365 var target = getTargetDpi(); 366 var preview = item.previewUrl 367 ? '<img src="' + item.previewUrl + '" alt="">' 368 : ''; 369 var action = ''; 370 var method = 'â'; 371 if (item.status === 'done' && item.resultBlob) { 372 doneCount += 1; 373 var url = URL.createObjectURL(item.resultBlob); 374 action = '<a class="dl-btn" download="' + item.resultName.replace(/"/g, '') + '" href="' + url + '">Download</a>'; 375 method = item.method || 'DPI tag written'; 376 } else if (item.status === 'error') {
377 action = '<span class="err-text">' + (item.error || 'Failed') + '</span>'; 378 method = 'â'; 379 } else if (item.status === 'unsupported') { 380 action = '<span class="muted">Calculator only</span>'; 381 method = item.method || 'WebP DPI rewrite not supported'; 382 } else { 383 action = '<span class="muted">Ready</span>'; 384 method = isJpeg(item.file) ? 'JPEG EXIF rewrite' : (isPng(item.file) ? 'PNG pHYs rewrite' : 'â'); 385 } 386 tr.innerHTML = 387 '<td class="col-preview">' + preview + '</td>' + 388 '<td><div class="file-name">' + item.file.name.replace(/</g, '<') + '</div>' + 389 '<div class="meta-line">' + item.width + 'Ã' + item.height + ' px · current ' + dpiLabel + 390 (item.dpiSource ? ' (' + item.dpiSource + ')' : '') + '</div>' + 391 '<div class="meta-line">At target ' + Math.round(target) + ' DPI â ' + 392 printSizeText(item.width, item.height, target) + '</div></td>' + 393 '<td>' + formatBytes(item.file.size) + '</td>' + 394 '<td>' + (item.resultBlob ? formatBytes(item.resultBlob.size) : 'â') + '</td>' + 395 '<td class="method-cell">' + method + '</td>' + 396 '<td>' + action + '</td>'; 397 resultsBody.appendChild(tr); 398 }); 399 zipBtn.style.display = doneCount >= 2 ? 'inline-block' : 'none'; 400 clearBtn.style.display = files.length ? 'inline-block' : 'none'; 401 } 402 403 async function addFiles(fileListLike) { 404 var incoming = Array.prototype.slice.call(fileListLike || []); 405 var added = 0; 406 for (var i = 0; i < incoming.length; i++) { 407 if (files.length >= MAX_FILES) { 408 setStatus('Max ' + MAX_FILES + ' files â extra files skipped.', true); 409 break; 410 } 411 var file = incoming[i]; 412 var okMime = ACCEPT_MIME.test(file.type || ''); 413 var okExt = ACCEPT_EXT.test(file.name || ''); 414 if (!okMime && !okExt) continue; 415 try { 416 var dims = await loadImageDims(file); 417 var buf = await file.arrayBuffer(); 418 var bytes = new Uint8Array(buf); 419 var dpiInfo = { dpi: null, source: 'not set' }; 420 if (isJpeg(file)) { 421 dpiInfo = readJpegDpi(arrayBufferToBinaryString(buf)); 422 } else if (isPng(file)) { 423 dpiInfo = readPngDpi(bytes); 424 } else if (isWebp(file)) { 425 dpiInfo = { dpi: null, source: 'WebP â no standard DPI tag' }; 426 } 427 var item = { 428 id: nextId++, 429 file: file, 430 previewUrl: dims.previewUrl, 431 width: dims.width, 432 height: dims.height, 433 dpi: dpiInfo.dpi, 434 dpiSource: dpiInfo.source, 435 status: isWebp(file) ? 'unsupported' : 'ready', 436 method: isWebp(file) ? 'WebP DPI rewrite not supported â use calculator' : null, 437 resultBlob: null, 438 resultName: null, 439 error: null 440 }; 441 files.push(item); 442 if (activeId == null) activeId = item.id; 443 added += 1; 444 } catch (e) { 445 setStatus((file.name || 'File') + ': ' + (e.message || 'failed to read'), true); 446 } 447 } 448 fileCount.textContent = files.length + ' file' + (files.length === 1 ? '' : 's') + ' selected (max ' + MAX_FILES + ')'; 449 settingsPanel.style.display = files.length ? 'block' : 'none'; 450 renderFileList(); 451 updateActiveInfo(); 452 renderResults(); 453 if (added) setStatus('Loaded ' + added + ' image' + (added === 1 ? '' : 's') + '. Set target DPI and apply, or use the calculator.'); 454 } 455 456 async function applyDpi() { 457 var target = getTargetDpi(); 458 var writable = files.filter(function (f) { return isJpeg(f.file) || isPng(f.file); }); 459 if (!writable.length) { 460 setStatus('Add a JPG or PNG to rewrite DPI tags. WebP is calculator-only.', true); 461 return; 462 } 463 applyBtn.disabled = true; 464 progressWrap.style.display = 'block'; 465 progressBar.style.width = '0%'; 466 setStatus('Writing DPI tagsâ¦'); 467 var done = 0; 468 for (var i = 0; i < files.length; i++) { 469 var item = files[i]; 470 if (isWebp(item.file)) { 471 item.status = 'unsupported'; 472 item.method = 'WebP DPI rewrite not supported â use calculator'; 473 continue; 474 }
475 try { 476 var buf = await item.file.arrayBuffer(); 477 if (isJpeg(item.file)) { 478 var bin = arrayBufferToBinaryString(buf); 479 var outBin = writeJpegDpi(bin, target); 480 var outBytes = binaryStringToUint8Array(outBin); 481 item.resultBlob = new Blob([outBytes], { type: 'image/jpeg' }); 482 item.resultName = item.file.name.replace(/\.(jpe?g)$/i, '') + '-' + Math.round(target) + 'dpi.jpg'; 483 item.method = 'JPEG EXIF X/YResolution â ' + Math.round(target) + ' DPI (no pixel re-encode)'; 484 } else if (isPng(item.file)) { 485 var pngOut = writePngDpi(new Uint8Array(buf), target); 486 item.resultBlob = new Blob([pngOut], { type: 'image/png' }); 487 item.resultName = item.file.name.replace(/\.png$/i, '') + '-' + Math.round(target) + 'dpi.png'; 488 item.method = 'PNG pHYs â ' + Math.round(target) + ' DPI (chunk rewrite, pixels unchanged)'; 489 } 490 item.status = 'done'; 491 item.error = null; 492 } catch (e) { 493 item.status = 'error'; 494 item.error = e.message || 'Write failed'; 495 item.resultBlob = null; 496 } 497 done += 1; 498 progressBar.style.width = Math.round((done / files.length) * 100) + '%'; 499 } 500 applyBtn.disabled = false; 501 progressWrap.style.display = 'none'; 502 renderResults(); 503 var ok = files.filter(function (f) { return f.status === 'done'; }).length; 504 setStatus('Done â ' + ok + ' file' + (ok === 1 ? '' : 's') + ' tagged at ' + Math.round(target) + ' DPI. Pixels unchanged.'); 505 } 506 507 function runCalculator() { 508 var mode = calcMode.value; 509 var dpi = parseFloat(calcDpi.value) || getTargetDpi(); 510 if (dpi <= 0) { 511 calcOut.textContent = 'Enter a valid DPI.'; 512 return; 513 } 514 if (mode === 'px-to-print') { 515 var pw = parseFloat(calcPxW.value); 516 var ph = parseFloat(calcPxH.value); 517 if (!pw || !ph) { 518 calcOut.textContent = 'Enter pixel width and height.'; 519 return; 520 } 521 var inW = pw / dpi; 522 var inH = ph / dpi; 523 calcInW.value = inW.toFixed(3); 524 calcInH.value = inH.toFixed(3); 525 calcCmW.value = (inW * 2.54).toFixed(2); 526 calcCmH.value = (inH * 2.54).toFixed(2); 527 calcOut.textContent = 'Print size: ' + inW.toFixed(2) + ' Ã ' + inH.toFixed(2) + 528 ' in (' + (inW * 2.54).toFixed(1) + ' Ã ' + (inH * 2.54).toFixed(1) + ' cm) at ' + 529 Math.round(dpi) + ' DPI. Changing DPI does not add detail â it only changes this print math / metadata.'; 530 } else { 531 var iw = parseFloat(calcInW.value); 532 var ih = parseFloat(calcInH.value); 533 if ((!iw || !ih) && (calcCmW.value || calcCmH.value)) { 534 iw = parseFloat(calcCmW.value) / 2.54; 535 ih = parseFloat(calcCmH.value) / 2.54; 536 } 537 if (!iw || !ih) { 538 calcOut.textContent = 'Enter desired print size in inches (or cm).'; 539 return; 540 } 541 var rpw = Math.round(iw * dpi); 542 var rph = Math.round(ih * dpi); 543 calcPxW.value = String(rpw); 544 calcPxH.value = String(rph); 545 calcCmW.value = (iw * 2.54).toFixed(2); 546 calcCmH.value = (ih * 2.54).toFixed(2); 547 calcOut.textContent = 'You need about ' + rpw + ' Ã ' + rph + 548 ' pixels for ' + iw.toFixed(2) + ' Ã ' + ih.toFixed(2) + ' in at ' + 549 Math.round(dpi) + ' DPI. Upscaling a smaller photo will not create real detail.'; 550 } 551 } 552 553 // Events 554 uploadBtn.addEventListener('click', function (e) { 555 e.preventDefault(); 556 e.stopPropagation(); 557 fileInput.click(); 558 }); 559 uploadArea.addEventListener('click', function (e) { 560 if (e.target.closest('button') || e.target.closest('input') || e.target.closest('.file-row')) return; 561 fileInput.click(); 562 }); 563 fileInput.addEventListener('change', function () { 564 addFiles(fileInput.files); 565 fileInput.value = ''; 566 }); 567 ['dragenter', 'dragover'].forEach(function (ev) { 568 uploadArea.addEventListener(ev, function (e) { 569 e.preventDefault(); 570 uploadArea.classList.add('active'); 571 }); 572 }); 573 ['dragleave', 'drop'].forEach(function (ev) { 574 uploadArea.addEventListener(ev, function (e) { 575 e.preventDefault(); 576 uploadArea.classList.remove('active'); 577 if (ev === 'drop' && e.dataTransfer) addFiles(e.dataTransfer.files); 578 }); 579 }); 580 581 presetBtns.forEach(function (btn) { 582 btn.addEventListener('click', function () { 583 targetDpiInput.value = btn.getAttribute('data-dpi-preset');
584 if (calcDpi) calcDpi.value = targetDpiInput.value; 585 updateActiveInfo(); 586 renderResults(); 587 }); 588 }); 589 targetDpiInput.addEventListener('input', function () { 590 if (calcDpi) calcDpi.value = targetDpiInput.value; 591 updateActiveInfo(); 592 renderResults(); 593 }); 594 595 applyBtn.addEventListener('click', function () { applyDpi(); }); 596 clearBtn.addEventListener('click', function () { 597 files.forEach(function (f) { 598 if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); 599 }); 600 files = []; 601 activeId = null; 602 fileCount.textContent = '0 files selected (max ' + MAX_FILES + ')'; 603 settingsPanel.style.display = 'none'; 604 resultsPanel.style.display = 'none'; 605 zipBtn.style.display = 'none'; 606 renderFileList(); 607 updateActiveInfo(); 608 setStatus(''); 609 }); 610 zipBtn.addEventListener('click', async function () { 611 if (typeof JSZip === 'undefined') { 612 setStatus('JSZip failed to load.', true); 613 return; 614 } 615 var zip = new JSZip(); 616 var n = 0; 617 files.forEach(function (f) { 618 if (f.status === 'done' && f.resultBlob) { 619 zip.file(f.resultName || f.file.name, f.resultBlob); 620 n += 1; 621 } 622 }); 623 if (!n) return; 624 var blob = await zip.generateAsync({ type: 'blob' }); 625 var a = document.createElement('a'); 626 a.href = URL.createObjectURL(blob); 627 a.download = 'dpi-' + Math.round(getTargetDpi()) + '-batch.zip'; 628 a.click(); 629 }); 630 calcRunBtn.addEventListener('click', runCalculator); 631 632 // Calculator usable without files 633 settingsPanel.style.display = 'block'; 634 updateActiveInfo(); 635})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.