Download image-header.js from VladZpp/waste-sorting-classifier: direct link, hf CLI and curl.
- Browser
- Download file 22.5 kB
-
https://huggingface.co/spaces/VladZpp/waste-sorting-classifier/resolve/main/image-header.js
- Command line
-
hf download hf://spaces/VladZpp/waste-sorting-classifier/image-header.js
-
curl -L -o image-header.js https://huggingface.co/spaces/VladZpp/waste-sorting-classifier/resolve/main/image-header.js
22.5 kB
| // Read what is needed before decoding an upload: the real format (from its bytes, not its | |
| // name), the pixel size (to refuse huge or elongated images before decoding), whether the | |
| // file is complete, and the EXIF orientation exactly as Pillow's ImageOps.exif_transpose | |
| // finds it (src/waste_sorting_classifier/preprocess.py): | |
| // - JPEG: every APP1 "Exif" segment, concatenated; XMP from the last XMP segment; | |
| // - PNG: the last eXIf chunk or text chunk keyed "exif", else a "Raw profile type exif" | |
| // text chunk; XMP from iTXt; | |
| // - WebP: the first EXIF and XMP chunks, when the VP8X header (first chunk) announces them; | |
| // - in EXIF, at most two "Exif\0\0" prefixes, then a TIFF directory read like Pillow's | |
| // ImageFileDirectory_v2; XMP's tiff:Orientation only when EXIF has none. | |
| // Every read is bounded by the file and by the block it belongs to: hostile files are | |
| // refused or ignored, never followed outside their data. Valid files match Pillow exactly; | |
| // for files that break their format's rules the page aims to match Pillow too, but only | |
| // guarantees to stay safe (bounded time and memory). | |
| import { inflateInto } from "./inflate.js"; | |
| const u16 = (b, o, le) => (le ? b[o] | (b[o + 1] << 8) : (b[o] << 8) | b[o + 1]); | |
| const u32 = (b, o, le) => | |
| le | |
| ? (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0 | |
| : ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0; | |
| const ascii = (b, o, n) => String.fromCharCode(...b.subarray(o, Math.min(o + n, b.length))); | |
| const latin1 = (b) => new TextDecoder("latin1").decode(b); | |
| export class ImageError extends Error {} | |
| export function sniffFormat(b) { | |
| if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpeg"; | |
| if (b.length > 8 && b[0] === 0x89 && ascii(b, 1, 7) === "PNG\r\n\x1a\n") return "png"; | |
| if (b.length > 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") return "webp"; | |
| return null; | |
| } | |
| // --- EXIF and XMP ---------------------------------------------------------------------- | |
| // Orientation from a TIFF-structured EXIF block, read like Pillow 13's Exif.load and | |
| // ImageFileDirectory_v2.load: | |
| // - the header must be one of Pillow's TIFF prefixes, or Pillow raises (ExifError here); | |
| // - entries of unknown type, or with no data, are skipped; | |
| // - out-of-line data counts towards a total for the whole directory; an entry that takes | |
| // the total past the block's size is skipped, and one whose data runs past the end of the | |
| // block stops the directory, keeping what was read before it; | |
| // - the first stored entry for a tag wins, and later entries for that tag are skipped | |
| // before anything else (they add nothing to the total); numeric types give their first | |
| // value, byte and string types never match an orientation. | |
| // Returns the value (NaN when not numeric) or null when there is none. | |
| export class ExifError extends Error {} | |
| const UNIT = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8, 13: 4, 16: 8 }; | |
| function firstValue(b, at, type, le) { | |
| const dv = new DataView(b.buffer, b.byteOffset + at, UNIT[type]); | |
| switch (type) { | |
| case 3: return dv.getUint16(0, le); | |
| case 4: case 13: return dv.getUint32(0, le); | |
| case 6: return dv.getInt8(0); | |
| case 8: return dv.getInt16(0, le); | |
| case 9: return dv.getInt32(0, le); | |
| case 11: return dv.getFloat32(0, le); | |
| case 12: return dv.getFloat64(0, le); | |
| case 16: return Number(dv.getBigUint64(0, le)); | |
| case 5: case 10: { // rationals: Pillow compares the fraction with the orientation numbers | |
| const n = type === 5 ? dv.getUint32(0, le) : dv.getInt32(0, le); | |
| const d = type === 5 ? dv.getUint32(4, le) : dv.getInt32(4, le); | |
| return d !== 0 ? n / d : NaN; | |
| } | |
| default: return NaN; // BYTE, ASCII, UNDEFINED | |
| } | |
| } | |
| export function exifOrientation(exif) { | |
| let s = 0; // Pillow 13 strips one or two prefixes; a third is not a TIFF header | |
| for (let k = 0; k < 2 && s + 6 <= exif.length && ascii(exif, s, 6) === "Exif\0\0"; k++) s += 6; | |
| const b = exif.subarray(s); | |
| if (b.length === 0) return null; | |
| const prefix = ascii(b, 0, 4); | |
| if (!["MM\0*", "II*\0", "MM*\0", "II\0*", "MM\0+", "II+\0"].includes(prefix)) { | |
| throw new ExifError("not a TIFF header"); | |
| } | |
| if (b[2] === 43 || b.length < 8) throw new ExifError("truncated TIFF header"); // BigTIFF too | |
| const le = prefix.startsWith("II"); | |
| const ifd = u32(b, 4, le); | |
| if (ifd + 2 > b.length) return null; | |
| let found = null; | |
| let outOfLine = 0; | |
| const stored = new Set(); | |
| const count = u16(b, ifd, le); | |
| for (let i = 0; i < count; i++) { | |
| const e = ifd + 2 + i * 12; | |
| if (e + 12 > b.length) break; | |
| const tag = u16(b, e, le), type = u16(b, e + 2, le), n = u32(b, e + 4, le); | |
| if (stored.has(tag)) continue; // the first stored entry wins | |
| if (!UNIT[type]) continue; | |
| const size = UNIT[type] * n; | |
| let at = e + 8; | |
| if (size > 4) { | |
| at = u32(b, e + 8, le); | |
| outOfLine += size; | |
| if (outOfLine > b.length) continue; // larger than the block: skipped | |
| if (at + size > b.length) break; // a truncated read stops the directory | |
| } | |
| if (size === 0) continue; | |
| stored.add(tag); | |
| if (tag === 0x0112) found = firstValue(b, at, type, le); | |
| } | |
| return found; | |
| } | |
| function xmpOrientation(text) { | |
| const match = /tiff:Orientation(="|>)([0-9])/.exec(text); | |
| return match ? Number(match[2]) : null; | |
| } | |
| // What exif_transpose does with the value: 2-8 transform the image, anything else nothing. | |
| const effective = (value) => (Number.isInteger(value) && value >= 2 && value <= 8 ? value : 1); | |
| // onBadExif: "refuse" (what happens in Pillow for PNG, WebP and most JPEGs) or "upright" | |
| // (a JPEG without a JFIF dpi: Pillow reads its EXIF early, swallows the error and then | |
| // ignores XMP too). | |
| function orientationOf(exif, xmp, onBadExif = "refuse") { | |
| let fromExif = null; | |
| try { | |
| fromExif = exif ? exifOrientation(exif) : null; | |
| } catch (err) { | |
| if (!(err instanceof ExifError)) throw err; | |
| if (onBadExif === "upright") return 1; | |
| throw new ImageError("That file's metadata is damaged, so it could not be read."); | |
| } | |
| if (fromExif !== null) return effective(fromExif); | |
| return effective(xmp !== null ? xmpOrientation(xmp) : null); | |
| } | |
| const concat = (parts) => { | |
| const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); | |
| let o = 0; | |
| for (const p of parts) { out.set(p, o); o += p.length; } | |
| return out; | |
| }; | |
| // Pillow's limits on PNG metadata (PngImagePlugin): each compressed chunk may expand to at | |
| // most 1 MiB (MAX_TEXT_CHUNK) and all text together to 64 MiB (MAX_TEXT_MEMORY); beyond | |
| // that the file is refused. Corrupt compressed data is ignored, as Pillow ignores it. | |
| const MAX_TEXT_CHUNK = 1024 * 1024; | |
| const MAX_TEXT_MEMORY = 64 * MAX_TEXT_CHUNK; | |
| const tooMuch = () => new ImageError("That file carries too much metadata to be read safely."); | |
| // -> Uint8Array, or null when the data is corrupt; throws when it expands past the limit. | |
| function inflateLimited(data) { | |
| const out = new Uint8Array(MAX_TEXT_CHUNK); | |
| const { filled, overflow, error } = inflateInto(data, out); | |
| if (overflow) throw tooMuch(); | |
| return error ? null : out.slice(0, filled); | |
| } | |
| // --- Containers ------------------------------------------------------------------------ | |
| // JPEG segments up to the first scan. Fill bytes (0xFF runs) and stray bytes between | |
| // segments are skipped like libjpeg does; standalone markers carry no length. | |
| const unreadable = () => new ImageError("That file could not be read as an image."); | |
| const compareBytes = (x, y) => { | |
| for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i]; | |
| return x.length - y.length; | |
| }; | |
| // Pillow parses Photoshop resource blocks (APP13) and raises on a resource name length | |
| // past the segment's end; a block cut short otherwise just ends the parse. | |
| function checkPhotoshop(s) { | |
| let o = 14; | |
| while (ascii(s, o, 4) === "8BIM") { | |
| o += 4; | |
| if (o + 2 > s.length) return; | |
| const code = u16(s, o, false); | |
| o += 2; | |
| if (o >= s.length) throw unreadable(); | |
| o += 1 + s[o]; | |
| o += o & 1; | |
| if (o + 4 > s.length) return; | |
| const size = u32(s, o, false); | |
| o += 4; | |
| if (code === 0x03ed && Math.min(size, Math.max(0, s.length - o)) < 14) return; // ResolutionInfo | |
| o += size; | |
| o += o & 1; | |
| } | |
| } | |
| function jpegHeader(b) { | |
| let width = 0, height = 0, components = 0, scanAt = -1; | |
| const exif = []; | |
| const icc = []; // ICC_PROFILE segments since the last frame header | |
| let xmp = null, dpi = false; | |
| const junk = []; // stray bytes before markers, which libjpeg skips with a warning | |
| let o = 2; | |
| while (o < b.length) { | |
| if (b[o] !== 0xff) { | |
| junk.push(o++); | |
| continue; | |
| } | |
| while (o < b.length && b[o] === 0xff) o++; | |
| if (o >= b.length) break; | |
| const marker = b[o++]; | |
| if (marker === 0x01) throw unreadable(); // TEM: Pillow refuses | |
| if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd8)) continue; | |
| if (marker === 0xd9 || o + 2 > b.length) break; | |
| const length = u16(b, o, false); | |
| if (length < 2 || o + length > b.length) break; | |
| const data = b.subarray(o + 2, o + length); | |
| if (marker === 0xda) { scanAt = o + length; break; } | |
| // Pillow's APP handler: JFIF (no NUL needed) always has its version read, and gives a | |
| // dpi when the density is all there; Adobe has its version read; ICC segments are | |
| // checked at the next frame header; Photoshop resource blocks are parsed. | |
| if (marker === 0xe0 && ascii(data, 0, 4) === "JFIF") { | |
| if (data.length < 7) throw unreadable(); | |
| if (data.length >= 12 && (data[7] === 1 || data[7] === 2)) dpi = true; | |
| } | |
| if (marker === 0xee && ascii(data, 0, 5) === "Adobe" && data.length < 7) throw unreadable(); | |
| if (marker === 0xe2 && ascii(data, 0, 12) === "ICC_PROFILE\0") icc.push(data); | |
| if (marker === 0xed && ascii(data, 0, 14) === "Photoshop 3.0\0") checkPhotoshop(data); | |
| if (marker === 0xe1 && ascii(data, 0, 6) === "Exif\0\0") exif.push(exif.length ? data.subarray(6) : data); | |
| else if (marker === 0xe1 && ascii(data, 0, 29) === "http://ns.adobe.com/xap/1.0/\0") xmp = latin1(data.subarray(29)); | |
| const sof = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker); | |
| if (sof && icc.length) { | |
| // Pillow sorts them and reads the fragment count of the first. | |
| if (icc.reduce((m, x) => (compareBytes(x, m) < 0 ? x : m)).length < 14) throw unreadable(); | |
| icc.length = 0; | |
| } | |
| if (sof && data.length >= 6 && !width) { | |
| height = u16(data, 1, false); | |
| width = u16(data, 3, false); | |
| components = data[5]; | |
| } | |
| o += length; | |
| } | |
| // Pillow refuses a file that ends before the image does: the end-of-image marker must | |
| // follow the scan (entropy-coded data never contains 0xFF 0xD9; anything after is ignored). | |
| let complete = false; | |
| for (let i = scanAt; scanAt >= 0 && i + 1 < b.length; i++) { | |
| if (b[i] === 0xff && b[i + 1] === 0xd9) { complete = true; break; } | |
| } | |
| const orientation = orientationOf(exif.length ? concat(exif) : null, xmp, dpi ? "refuse" : "upright"); | |
| // The WebAssembly decoder treats libjpeg's warning as an error, so it gets the file | |
| // without the stray bytes, which is what libjpeg (and so Pillow) decodes. | |
| let decodeBytes = null; | |
| if (junk.length) { | |
| decodeBytes = new Uint8Array(b.length - junk.length); | |
| let from = 0, to = 0; | |
| for (const at of junk) { | |
| decodeBytes.set(b.subarray(from, at), to); | |
| to += at - from; | |
| from = at + 1; | |
| } | |
| decodeBytes.set(b.subarray(from), to); | |
| } | |
| return { width, height, components, complete, orientation, decodeBytes }; | |
| } | |
| // PNG chunks as Pillow reads them, up to IEND, each with its phase: 0 before the image | |
| // data, 1 in its first run of IDAT chunks, 2 after it. | |
| // - A chunk whose data is cut off by the end of the file is yielded with the data there | |
| // is, marked `cut`, and ends the walk. A missing checksum alone is not a cut. | |
| // - `named`: whether the type is four word characters; Pillow refuses a file with another | |
| // name before the image data. | |
| // - After the image data the walk stops, as Pillow's load_end does, at an invalid name or | |
| // at fcTL (the next APNG frame). | |
| export function* pngChunks(b) { | |
| let phase = 0; | |
| for (let o = 8; o + 8 <= b.length; ) { | |
| const size = u32(b, o, false), type = ascii(b, o + 4, 4), end = o + 8 + size; | |
| if (phase === 0 && type === "IDAT") phase = 1; | |
| else if (phase === 1 && type !== "IDAT") phase = 2; | |
| const named = /^\w{4}$/.test(type); | |
| if (phase === 2 && (!named || type === "fcTL")) return; | |
| const cut = end > b.length; | |
| yield { type, at: o, data: b.subarray(o + 8, Math.min(end, b.length)), cut, named, phase }; | |
| if (cut || type === "IEND") return; | |
| o = end + 4; | |
| } | |
| } | |
| // Characters of valid UTF-8, counted like Python's len() (code points). | |
| function codePoints(bytes) { | |
| let n = 0; | |
| for (let i = 0; i < bytes.length; i++) if ((bytes[i] & 0xc0) !== 0x80) n++; | |
| return n; | |
| } | |
| // Only these text keys are used; other text only counts towards Pillow's memory limit. | |
| const NEEDED = new Set(["Raw profile type exif", "XML:com.adobe.xmp"]); | |
| const textOf = (key, value) => (NEEDED.has(key) ? latin1(value) : ""); | |
| // A PNG text chunk as Pillow reads it -> { key, text, xmpBytes, length, exif } or null | |
| // (ignored). A chunk keyed "exif" becomes Pillow's EXIF: tEXt as its raw bytes, zTXt and | |
| // iTXt as text (EXIF_TEXT), which Pillow then fails to parse. | |
| const EXIF_TEXT = "text"; | |
| function pngText(chunk) { | |
| const d = chunk.data; | |
| const nul = d.indexOf(0); | |
| if (chunk.type === "tEXt") { | |
| const key = latin1(nul < 0 ? d : d.subarray(0, nul)); | |
| const value = nul < 0 ? new Uint8Array() : d.subarray(nul + 1); | |
| return key ? { key, text: textOf(key, value), length: value.length, exif: value } : null; | |
| } | |
| if (chunk.type === "zTXt") { | |
| const key = latin1(nul < 0 ? d : d.subarray(0, nul)); | |
| const rest = nul < 0 ? new Uint8Array() : d.subarray(nul + 1); | |
| if (rest.length && rest[0] !== 0) throw new ImageError("That file could not be read as an image."); | |
| const value = inflateLimited(rest.subarray(1)) ?? new Uint8Array(); | |
| return key ? { key, text: textOf(key, value), length: value.length, exif: EXIF_TEXT } : null; | |
| } | |
| // iTXt: keyword, compression flag, method, language tag, translated keyword, text. | |
| if (nul < 0 || d.length - nul - 1 < 2) return null; | |
| const flag = d[nul + 1], method = d[nul + 2]; | |
| const lang = d.indexOf(0, nul + 3); | |
| const translated = lang < 0 ? -1 : d.indexOf(0, lang + 1); | |
| if (translated < 0) return null; | |
| let value = d.subarray(translated + 1); | |
| if (flag !== 0) { | |
| if (method !== 0) return null; | |
| value = inflateLimited(value); | |
| if (value === null) return null; | |
| } | |
| const key = latin1(d.subarray(0, nul)); | |
| const xmpBytes = key === "XML:com.adobe.xmp" ? latin1(value) : null; // kept even if not UTF-8 | |
| let text; | |
| try { | |
| new TextDecoder("utf-8", { fatal: true }).decode(d.subarray(lang + 1, translated)); | |
| new TextDecoder("utf-8", { fatal: true }).decode(d.subarray(nul + 3, lang)); | |
| text = new TextDecoder("utf-8", { fatal: true }).decode(value); | |
| } catch { | |
| return { key: null, xmpBytes }; | |
| } | |
| return { key, text: NEEDED.has(key) ? text : "", xmpBytes, length: codePoints(value), exif: EXIF_TEXT }; | |
| } | |
| // An iCCP colour profile is not used, but Pillow refuses one it cannot safely decompress. | |
| function checkIccp(chunk) { | |
| const d = chunk.data; | |
| const nul = d.indexOf(0); | |
| if (nul < 0 || d[nul + 1] !== 0) throw new ImageError("That file could not be read as an image."); | |
| inflateLimited(d.subarray(nul + 2)); | |
| } | |
| // bytes.fromhex: pairs of hex digits, with ASCII whitespace allowed only between pairs. | |
| function fromHex(text) { | |
| const out = new Uint8Array(text.length >> 1); | |
| let n = 0; | |
| for (let i = 0; ; ) { | |
| while (i < text.length && " \t\n\r\v\f".includes(text[i])) i++; | |
| if (i >= text.length) return out.subarray(0, n); | |
| const pair = text.slice(i, i + 2); | |
| if (!/^[0-9a-fA-F]{2}$/.test(pair)) throw unreadable(); | |
| out[n++] = parseInt(pair, 16); | |
| i += 2; | |
| } | |
| } | |
| const EXIF_PREFIX = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0, 0]); // "Exif\0\0" | |
| async function pngHeader(b) { | |
| let exif = null, ihdr = null, seenData = false, memory = 0, xmpRaw = null; | |
| const info = {}; // Pillow's im_info for the keys that matter: the last chunk wins | |
| for (const c of pngChunks(b)) { | |
| if (c.cut || !c.named) break; // Pillow refuses the file (png.js does) | |
| if (c.type === "IDAT") seenData = true; | |
| if (c.type === "IHDR" && !seenData && c.data.length >= 13) ihdr = c.data; // as png.js uses | |
| if (c.type === "eXIf") exif = concat([EXIF_PREFIX, c.data]); // Pillow adds one; the last wins | |
| if (c.type === "iCCP") checkIccp(c); | |
| if (c.type === "tEXt" || c.type === "zTXt" || c.type === "iTXt") { | |
| const t = pngText(c); | |
| if (!t) continue; | |
| if (t.xmpBytes !== null && t.xmpBytes !== undefined) xmpRaw = t.xmpBytes; | |
| if (t.key === null) continue; | |
| if (t.key === "exif") exif = t.exif; | |
| info[t.key] = t.text; | |
| memory += t.length; | |
| if (memory > MAX_TEXT_MEMORY) throw tooMuch(); | |
| } | |
| } | |
| if (exif === EXIF_TEXT) throw new ImageError("That file's metadata is damaged, so it could not be read."); | |
| const rawProfile = info["Raw profile type exif"]; | |
| if (!exif && rawProfile !== undefined) exif = fromHex(rawProfile.split("\n").slice(3).join("")); | |
| // Pillow: XMP stored under its own key (any text chunk) first, then raw iTXt XMP. | |
| const xmp = info["XML:com.adobe.xmp"] || xmpRaw || null; | |
| return { | |
| width: ihdr ? u32(ihdr, 0, false) : 0, | |
| height: ihdr ? u32(ihdr, 4, false) : 0, | |
| complete: true, // whether all pixel data is there is judged by png.js, like Pillow | |
| orientation: orientationOf(exif && exif.length ? exif : null, xmp), | |
| }; | |
| } | |
| // libwebp's WebPDemux acceptance rules (non-incremental), which Pillow's WebP reader uses: | |
| // a file it would reject is refused here too. (Reviewed port of libwebp's demux.c checks.) | |
| function webpDemuxOk(b) { | |
| const riffSize = u32(b, 4, true); | |
| if (riffSize < 8 || riffSize > 0xfffffff6) return false; | |
| const end = Math.min(b.length, riffSize + 8); | |
| if (riffSize + 8 > b.length) return false; // incomplete | |
| let o = 12; | |
| const chunkAt = (p) => (p + 8 <= end ? { id: ascii(b, p, 4), size: u32(b, p + 4, true) } : null); | |
| const first = chunkAt(o); | |
| if (!first) return false; | |
| const fits = (p, size) => size <= 0xfffffff6 && p + 8 + size + (size & 1) <= end; | |
| // StoreFrame: ALPH? then VP8/VP8L; returns { next, ok, image, alphaAfter } | |
| const storeFrame = (p) => { | |
| let alpha = 0, image = 0, alphaAt = -1, imageAt = -1; | |
| for (;;) { | |
| const c = chunkAt(p); | |
| if (!c) return { ok: false }; | |
| if (!fits(p, c.size)) return { ok: false }; | |
| const padded = c.size + (c.size & 1); | |
| if (c.id === "ALPH" && alpha === 0) { alpha++; alphaAt = p; p += 8 + padded; } | |
| else if ((c.id === "VP8 " || c.id === "VP8L") && image === 0) { | |
| if (c.id === "VP8L" && alpha > 0) return { ok: false }; | |
| image++; imageAt = p; p += 8 + padded; | |
| } else break; | |
| if (p === end) break; | |
| if (end - p < 8) return { ok: false }; | |
| } | |
| if (!image) return { ok: false }; | |
| if (alpha && alphaAt > imageAt) return { ok: false }; | |
| return { ok: true, next: p, alphaAt }; | |
| }; | |
| if (first.id === "VP8 " || first.id === "VP8L") return storeFrame(o).ok; | |
| if (first.id !== "VP8X") return false; | |
| if (first.size < 10 || !fits(o, first.size)) return false; | |
| const flags = b[o + 8]; | |
| if (flags & ~0x3e) return false; | |
| if (flags & 0x02) return true; // animations are refused by the page anyway | |
| o += 8 + first.size + (first.size & 1); | |
| if (end - o < 8) return false; | |
| let frames = 0; | |
| while (o < end) { | |
| const c = chunkAt(o); | |
| if (!c || !fits(o, c.size)) return false; | |
| if (c.id === "VP8X" || c.id === "ANMF") return false; | |
| if (c.id === "ALPH" || c.id === "VP8 " || c.id === "VP8L") { | |
| if (frames) return false; | |
| const f = storeFrame(o); | |
| if (!f.ok) return false; | |
| frames++; | |
| o = f.next; | |
| } else { | |
| o += 8 + c.size + (c.size & 1); | |
| } | |
| if (o === end) break; | |
| if (end - o < 8) return false; | |
| } | |
| return frames === 1; | |
| } | |
| function webpHeader(b) { | |
| let width = 0, height = 0, flags = 0, exif = null, xmp = null, alpha = false; | |
| const declared = u32(b, 4, true) + 8; | |
| for (let o = 12; o + 8 <= b.length; ) { | |
| const id = ascii(b, o, 4), size = u32(b, o + 4, true); | |
| if (o + 8 + size > b.length) break; | |
| const data = b.subarray(o + 8, o + 8 + size); | |
| if (id === "VP8X" && size >= 10 && o === 12) { // only as the first chunk, as in libwebp | |
| flags = data[0]; | |
| width = 1 + (data[4] | (data[5] << 8) | (data[6] << 16)); | |
| height = 1 + (data[7] | (data[8] << 8) | (data[9] << 16)); | |
| alpha = Boolean(flags & 0x10); | |
| } else if (id === "VP8 " && !width && size >= 10) { | |
| width = u16(data, 6, true) & 0x3fff; | |
| height = u16(data, 8, true) & 0x3fff; | |
| } else if (id === "VP8L" && !width && size >= 5) { | |
| const bits = u32(data, 1, true); | |
| width = 1 + (bits & 0x3fff); | |
| height = 1 + ((bits >> 14) & 0x3fff); | |
| alpha = Boolean((bits >>> 28) & 1); // the alpha_is_used bit of a simple lossless file | |
| } else if (id === "EXIF" && exif === null) { | |
| exif = data; | |
| } else if (id === "XMP " && xmp === null) { | |
| xmp = latin1(data); | |
| } | |
| o += 8 + size + (size & 1); | |
| } | |
| // libwebp's demuxer only exposes metadata the VP8X header announces. | |
| if (!(flags & 0x08)) exif = null; | |
| if (!(flags & 0x04)) xmp = null; | |
| const animated = Boolean(flags & 0x02); | |
| const complete = declared <= b.length; | |
| if (complete && !animated && !webpDemuxOk(b)) { | |
| throw new ImageError("That file could not be read as an image: it may be damaged."); | |
| } | |
| // alpha: whether Pillow keeps the alpha channel (otherwise the decoded colours are used | |
| // as they are, without flattening). | |
| return { width, height, animated, alpha, complete, orientation: orientationOf(exif, xmp) }; | |
| } | |
| // { format, width, height, orientation, complete, components (JPEG), animated (WebP) } | |
| export async function readHeader(b) { | |
| const format = sniffFormat(b); | |
| if (format === "jpeg") return { format, ...jpegHeader(b) }; | |
| if (format === "png") return { format, ...(await pngHeader(b)) }; | |
| if (format === "webp") return { format, ...webpHeader(b) }; | |
| return { format: null, width: 0, height: 0, orientation: 1, complete: false }; | |
| } | |