// @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; /** * @param {number | null} dpi * @returns {number | null} */ function plausible(dpi) { return dpi !== null && dpi >= LEAST_DPI && dpi <= MOST_DPI ? dpi : null; } /** * @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} 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; }