PageSourceSearch

https://quickimgtools.com/dpi-converter.js

js quickimgtools.com collected 2026-10-08 09:23:49 UTC 24,421 bytes, 635 lines download raw bytes

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, '&quot;') + '">' +
322        item.file.name.replace(/</g, '&lt;') + '</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, '&lt;') + '</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.