2026-10-06 15:58:02 +02:00
|
|
|
// @ts-check
|
|
|
|
|
/**
|
|
|
|
|
* Reads the resolution an image file records about itself, which gives the
|
|
|
|
|
* image its natural size on the page. Scanners write it; most other
|
|
|
|
|
* programs do not.
|
|
|
|
|
*
|
|
|
|
|
* Understood: the PNG `pHYs` chunk, the JPEG JFIF header, and the JPEG EXIF
|
|
|
|
|
* resolution tags. Anything else gives null.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/** Resolutions outside this range are mistakes, not information. */
|
|
|
|
|
const LEAST_DPI = 30;
|
|
|
|
|
const MOST_DPI = 2400;
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* The nominal screen resolutions that cameras, phones and screenshots
|
|
|
|
|
* write without meaning them; an image so marked arrives at the default
|
|
|
|
|
* size instead (Andy, 2026-10-08, with every image now placed at its
|
|
|
|
|
* natural size: at 72 dpi a phone's photo would be over a metre wide).
|
|
|
|
|
*/
|
|
|
|
|
const NOMINAL_DPI = [72, 96];
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {number | null} dpi
|
|
|
|
|
* @returns {number | null}
|
|
|
|
|
*/
|
|
|
|
|
function plausible(dpi) {
|
2026-10-10 19:01:20 +02:00
|
|
|
if (dpi === null || dpi < LEAST_DPI || dpi > MOST_DPI) return null;
|
|
|
|
|
return NOMINAL_DPI.some((nominal) => Math.abs(dpi - nominal) < 0.5) ? null : dpi;
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {DataView} view
|
|
|
|
|
* @returns {number | null} Dots per inch from the pHYs chunk, if any.
|
|
|
|
|
*/
|
|
|
|
|
function pngResolution(view) {
|
|
|
|
|
let at = 8; // After the signature.
|
|
|
|
|
while (at + 12 <= view.byteLength) {
|
|
|
|
|
const length = view.getUint32(at);
|
|
|
|
|
const type = String.fromCharCode(
|
|
|
|
|
view.getUint8(at + 4), view.getUint8(at + 5), view.getUint8(at + 6), view.getUint8(at + 7));
|
|
|
|
|
if (type === 'pHYs' && length === 9 && at + 8 + 9 <= view.byteLength) {
|
|
|
|
|
const perMetre = view.getUint32(at + 8);
|
|
|
|
|
const unitIsMetre = view.getUint8(at + 16) === 1;
|
|
|
|
|
return unitIsMetre ? perMetre * 0.0254 : null;
|
|
|
|
|
}
|
|
|
|
|
if (type === 'IDAT' || type === 'IEND') return null;
|
|
|
|
|
at += 12 + length;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {DataView} view
|
|
|
|
|
* @param {number} start Where the TIFF header of the EXIF data begins.
|
|
|
|
|
* @returns {number | null} Dots per inch from XResolution and ResolutionUnit.
|
|
|
|
|
*/
|
|
|
|
|
function exifResolution(view, start) {
|
|
|
|
|
const little = view.getUint16(start) === 0x4949;
|
|
|
|
|
/** @param {number} at */
|
|
|
|
|
const u16 = (at) => view.getUint16(at, little);
|
|
|
|
|
/** @param {number} at */
|
|
|
|
|
const u32 = (at) => view.getUint32(at, little);
|
|
|
|
|
if (u16(start + 2) !== 42) return null;
|
|
|
|
|
const ifd = start + u32(start + 4);
|
|
|
|
|
if (ifd + 2 > view.byteLength) return null;
|
|
|
|
|
const count = u16(ifd);
|
|
|
|
|
let resolution = null;
|
|
|
|
|
let unit = 2; // Inches unless said otherwise.
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
const entry = ifd + 2 + i * 12;
|
|
|
|
|
if (entry + 12 > view.byteLength) return null;
|
|
|
|
|
const tag = u16(entry);
|
|
|
|
|
if (tag === 0x011a) { // XResolution, a rational at the given offset.
|
|
|
|
|
const at = start + u32(entry + 8);
|
|
|
|
|
if (at + 8 > view.byteLength) return null;
|
|
|
|
|
const denominator = u32(at + 4);
|
|
|
|
|
resolution = denominator ? u32(at) / denominator : null;
|
|
|
|
|
} else if (tag === 0x0128) { // ResolutionUnit: 2 inches, 3 centimetres.
|
|
|
|
|
unit = u16(entry + 8);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (resolution === null) return null;
|
|
|
|
|
return unit === 3 ? resolution * 2.54 : resolution;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {DataView} view
|
|
|
|
|
* @returns {number | null} Dots per inch from the JFIF or EXIF header, if any.
|
|
|
|
|
*/
|
|
|
|
|
function jpegResolution(view) {
|
|
|
|
|
let at = 2; // After the SOI marker.
|
|
|
|
|
let fromJfif = null;
|
|
|
|
|
while (at + 4 <= view.byteLength && view.getUint8(at) === 0xff) {
|
|
|
|
|
const marker = view.getUint8(at + 1);
|
|
|
|
|
if (marker === 0xda) break; // Start of scan: no more headers.
|
|
|
|
|
const length = view.getUint16(at + 2);
|
|
|
|
|
const body = at + 4;
|
|
|
|
|
if (marker === 0xe0 && length >= 16 && body + 12 <= view.byteLength &&
|
|
|
|
|
view.getUint32(body) === 0x4a464946) { // "JFIF"
|
|
|
|
|
const units = view.getUint8(body + 7);
|
|
|
|
|
const density = view.getUint16(body + 8);
|
|
|
|
|
if (units === 1) fromJfif = density;
|
|
|
|
|
else if (units === 2) fromJfif = density * 2.54;
|
|
|
|
|
}
|
|
|
|
|
if (marker === 0xe1 && body + 6 <= view.byteLength &&
|
|
|
|
|
view.getUint32(body) === 0x45786966) { // "Exif"
|
|
|
|
|
const fromExif = plausible(exifResolution(view, body + 6));
|
|
|
|
|
if (fromExif !== null) return fromExif;
|
|
|
|
|
}
|
|
|
|
|
at = body + length - 2;
|
|
|
|
|
}
|
|
|
|
|
return fromJfif;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {Uint8Array<ArrayBuffer>} bytes An image file.
|
|
|
|
|
* @returns {number | null} The resolution the file records, in dots per
|
|
|
|
|
* inch, or null if it records none that can be believed.
|
|
|
|
|
*/
|
|
|
|
|
export function imageResolution(bytes) {
|
|
|
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
|
|
|
try {
|
|
|
|
|
if (bytes.length > 8 && view.getUint32(0) === 0x89504e47) return plausible(pngResolution(view));
|
|
|
|
|
if (bytes.length > 4 && view.getUint16(0) === 0xffd8) return plausible(jpegResolution(view));
|
|
|
|
|
} catch {
|
|
|
|
|
// A truncated or malformed header: no resolution.
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|