|
| 1 | +// Reject oversized images BEFORE anything rasterizes them. |
| 2 | +// |
| 3 | +// `load-image-theme` converts non-JPEG sources with `sips -Z`, which must |
| 4 | +// fully decode the source first — a near-flat 30000×30000 PNG under the 50 MB |
| 5 | +// byte cap would still balloon to gigabytes of pixels. This preflight reads the |
| 6 | +// container header only (PNG/JPEG/WebP) and falls back to `sips -g` metadata for |
| 7 | +// formats the header parser does not recognize (HEIC/TIFF); it never decodes. |
| 8 | +// |
| 9 | +// Exit 0 = dimensions are known and within caps, |
| 10 | +// 1 = over caps, 2 = usage / unreadable or undetermined dimensions. |
| 11 | + |
| 12 | +import fs from "node:fs/promises"; |
| 13 | +import path from "node:path"; |
| 14 | +import { execFileSync } from "node:child_process"; |
| 15 | +import { |
| 16 | + MAX_IMAGE_DIMENSION, |
| 17 | + MAX_IMAGE_PIXELS, |
| 18 | + readRawDimensions, |
| 19 | +} from "./image-metadata.mjs"; |
| 20 | + |
| 21 | +const file = process.argv[2]; |
| 22 | +if (!file) { |
| 23 | + console.error("usage: check-image-dimensions.mjs <image>"); |
| 24 | + process.exit(2); |
| 25 | +} |
| 26 | + |
| 27 | +function overCaps(width, height) { |
| 28 | + return !Number.isSafeInteger(width) || !Number.isSafeInteger(height) |
| 29 | + || width < 1 || height < 1 |
| 30 | + || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION |
| 31 | + || width * height > MAX_IMAGE_PIXELS; |
| 32 | +} |
| 33 | + |
| 34 | +let dimensions = null; |
| 35 | +try { |
| 36 | + const bytes = new Uint8Array(await fs.readFile(file)); |
| 37 | + dimensions = readRawDimensions(bytes, path.extname(file)); |
| 38 | +} catch (error) { |
| 39 | + console.error(`Could not read image: ${error.message}`); |
| 40 | + process.exit(2); |
| 41 | +} |
| 42 | + |
| 43 | +// HEIC/TIFF and anything the header parser does not recognize: ask sips for |
| 44 | +// image properties only. Reading properties does not rasterize the file. |
| 45 | +if (!dimensions) { |
| 46 | + try { |
| 47 | + const out = execFileSync( |
| 48 | + "/usr/bin/sips", |
| 49 | + ["-g", "pixelWidth", "-g", "pixelHeight", file], |
| 50 | + { encoding: "utf8", timeout: 10000 }, |
| 51 | + ); |
| 52 | + const width = Number(/pixelWidth:\s*(\d+)/.exec(out)?.[1]); |
| 53 | + const height = Number(/pixelHeight:\s*(\d+)/.exec(out)?.[1]); |
| 54 | + if (Number.isFinite(width) && Number.isFinite(height)) { |
| 55 | + dimensions = { width, height }; |
| 56 | + } |
| 57 | + } catch { |
| 58 | + // sips unavailable or refused the file: fall through. The 50 MB byte cap and |
| 59 | + // the inject-time dimension check remain as backstops. |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +if (!dimensions) { |
| 64 | + console.error("Could not determine image dimensions without rasterizing the source."); |
| 65 | + process.exit(2); |
| 66 | +} |
| 67 | + |
| 68 | +if (overCaps(dimensions.width, dimensions.height)) { |
| 69 | + console.error( |
| 70 | + `Image is ${dimensions.width}×${dimensions.height}px, over the ` |
| 71 | + + `${MAX_IMAGE_DIMENSION}px-per-side / ${MAX_IMAGE_PIXELS / 1_000_000}-megapixel safety limit.`, |
| 72 | + ); |
| 73 | + process.exit(1); |
| 74 | +} |
| 75 | +process.exit(0); |
0 commit comments