fix(documents): read a JPEG's size past fill bytes and refuse an unreadable one

The thumbnail's header walk took the byte after any 0xFF as a marker. A
single 0xFF fill byte after the start-of-image marker, which the JPEG
standard allows and every decoder skips, threw it off; it read no size, and
an unread size let the image through to a decode that allocates the full
bitmap. A small JPEG declaring 20000 x 20000 pixels allocated 1.6 GB in the
web process before the post-decode check could see it.

The avatar upload already had a walker that skips fill bytes. Both now use
one reader (lib/image-dimensions) that follows the marker rules: fill bytes,
the length-less markers, and no size when a scan or the end of the image
comes before a frame header. The thumbnail treats an unreadable size as a
refusal (size-unreadable) for every format it decodes, and the backfill
counts that refusal as final.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:52:04 +01:00
1 parent 518ce2fd37
commit ba22932101
6 files changed
+333 -230

No files matched your search

@@ -0,0 +1,78 @@
import { Buffer } from "node:buffer";
import { createCanvas } from "@napi-rs/canvas";
import { describe, expect, it } from "vitest";
import { readImageDimensions } from "../image-dimensions";
/**
* The header walk both the avatar upload and the document thumbnail trust to
* keep an oversize image away from a decoder. `null` is a refusal at both
* call sites, so the walk must find the size wherever a decoder would.
*/
function encoded(mime: "image/jpeg" | "image/png" | "image/webp"): Buffer {
const canvas = createCanvas(37, 23);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#808080";
ctx.fillRect(0, 0, 37, 23);
return mime === "image/jpeg"
? canvas.toBuffer(mime, 80)
: canvas.toBuffer(mime as "image/png");
}
const SIZE = { width: 37, height: 23 };
describe("readImageDimensions", () => {
it("reads PNG, JPEG and WebP as the encoder wrote them", () => {
expect(readImageDimensions(encoded("image/png"), "image/png")).toEqual(
SIZE,
);
expect(readImageDimensions(encoded("image/jpeg"), "image/jpeg")).toEqual(
SIZE,
);
expect(readImageDimensions(encoded("image/webp"), "image/webp")).toEqual(
SIZE,
);
});
it("reads a GIF's logical screen", () => {
const gif = Buffer.from("GIF89a\x25\x00\x17\x00\x00\x00\x00", "latin1");
expect(readImageDimensions(gif, "image/gif")).toEqual(SIZE);
});
it("skips fill bytes, RSTn and TEM before the frame header", () => {
const jpeg = encoded("image/jpeg");
const withFill = Buffer.concat([
jpeg.subarray(0, 2),
Buffer.from([0xff, 0xff, 0xff, 0x01, 0xff, 0xd3]),
jpeg.subarray(2),
]);
expect(readImageDimensions(withFill, "image/jpeg")).toEqual(SIZE);
});
it("finds no size in a scan or an end of image before any frame header", () => {
expect(
readImageDimensions(
Buffer.from([0xff, 0xd8, 0xff, 0xda, 0, 8, 1, 2, 3, 4, 5, 6]),
"image/jpeg",
),
).toBeNull();
expect(
readImageDimensions(Buffer.from([0xff, 0xd8, 0xff, 0xd9]), "image/jpeg"),
).toBeNull();
});
it("answers null for truncated and zero-sized headers, never throwing", () => {
const jpeg = encoded("image/jpeg");
const sof = jpeg.indexOf(Buffer.from([0xff, 0xc0]));
expect(readImageDimensions(jpeg.subarray(0, sof + 4), "image/jpeg")).toBe(
null,
);
const zero = Buffer.from(encoded("image/png"));
zero.writeUInt32BE(0, 16);
expect(readImageDimensions(zero, "image/png")).toBeNull();
expect(readImageDimensions(Buffer.alloc(3), "image/webp")).toBeNull();
expect(readImageDimensions(Buffer.alloc(0), "image/gif")).toBeNull();
});
});
+8 -130
View File
@@ -8,8 +8,9 @@
* the format's header bytes by hand. Adding a heavyweight image
* library (sharp / jimp) would pull a native dependency into the
* Alpine standalone image just to read width + height — the hand-
* rolled parser is < 100 LOC and covers JPEG / PNG / WebP.
* rolled parser lives in `./image-dimensions` and covers JPEG / PNG / WebP.
*/
import { readImageDimensions, type ImageDimensions } from "./image-dimensions";
/** Hard upload cap. 2 MiB is comfortable for a 512×512 JPEG. */
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
@@ -64,142 +65,19 @@ export function detectAvatarMimeType(buffer: Buffer): AvatarMime | null {
return null;
}
export interface AvatarDimensions {
width: number;
height: number;
}
export type AvatarDimensions = ImageDimensions;
/**
* Read the native image dimensions from the header bytes. Each
* format encodes width + height at a fixed offset in the leading
* chunks, so a full decode is unnecessary:
*
* - PNG: IHDR chunk after the 8-byte signature; width at offset
* 16, height at offset 20, both big-endian 32-bit unsigned.
* - JPEG: walk the marker segments until the first SOFn (Start
* Of Frame) marker, then read the two big-endian 16-bit values
* at marker offset + 5 (height) and + 7 (width).
* - WebP: three sub-formats (VP8 / VP8L / VP8X); each pins
* width-1 and height-1 at known offsets in the leading chunk.
*
* Returns `null` when the buffer is truncated or the format header
* is malformed; the caller maps that to a 422.
* Read the native image dimensions from the header bytes, without a
* decode (`readImageDimensions`, shared with the document thumbnail).
* Returns `null` when the buffer is truncated or the format header is
* malformed; the caller maps that to a 422.
*/
export function readAvatarDimensions(
buffer: Buffer,
mime: AvatarMime,
): AvatarDimensions | null {
switch (mime) {
case "image/png":
return readPngDimensions(buffer);
case "image/jpeg":
return readJpegDimensions(buffer);
case "image/webp":
return readWebpDimensions(buffer);
}
}
function readPngDimensions(buffer: Buffer): AvatarDimensions | null {
// 8-byte signature + 4-byte chunk length + "IHDR" + 4-byte width
// + 4-byte height = first valid read at offset 16.
if (buffer.length < 24) return null;
if (buffer.toString("ascii", 12, 16) !== "IHDR") return null;
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function readJpegDimensions(buffer: Buffer): AvatarDimensions | null {
// SOI marker (FF D8) confirmed by `detectAvatarMimeType`. Walk
// the marker segments until the first SOFn (Start Of Frame)
// marker (FF C0 .. FF C3, FF C5 .. FF C7, FF C9 .. FF CB, FF CD ..
// FF CF). Every other marker carries a 16-bit length we can skip.
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) return null;
// Skip any fill bytes (FF FF ...)
while (offset < buffer.length && buffer[offset] === 0xff) {
offset += 1;
}
if (offset >= buffer.length) return null;
const marker = buffer[offset];
offset += 1;
// Standalone markers without a payload.
if (marker === 0xd8 || marker === 0xd9) continue;
if (marker >= 0xd0 && marker <= 0xd7) continue;
if (offset + 7 >= buffer.length) return null;
const segmentLength = buffer.readUInt16BE(offset);
if (segmentLength < 2) return null;
const isSof =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);
if (isSof) {
// The SOF payload layout is:
// length (2) | precision (1) | height (2) | width (2) | ...
const height = buffer.readUInt16BE(offset + 3);
const width = buffer.readUInt16BE(offset + 5);
if (width <= 0 || height <= 0) return null;
return { width, height };
}
offset += segmentLength;
}
return null;
}
function readWebpDimensions(buffer: Buffer): AvatarDimensions | null {
// RIFF / WEBP confirmed by `detectAvatarMimeType`. The chunk at
// offset 12 is one of:
// "VP8 " — lossy
// "VP8L" — lossless
// "VP8X" — extended
if (buffer.length < 30) return null;
const fourCc = buffer.toString("ascii", 12, 16);
if (fourCc === "VP8 ") {
// VP8 frame header. Width/height live at chunk-payload offset
// 6 / 8 as 14-bit values (bottom 14 bits of a 16-bit field).
if (buffer.length < 30) return null;
const width = buffer.readUInt16LE(26) & 0x3fff;
const height = buffer.readUInt16LE(28) & 0x3fff;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
if (fourCc === "VP8L") {
// VP8L header: 1-byte signature (0x2f) + 4-byte packed dims.
// width-1 = 14 low bits of the 32-bit little-endian word;
// height-1 = the next 14 bits.
if (buffer.length < 25) return null;
if (buffer[20] !== 0x2f) return null;
const packed = buffer.readUInt32LE(21);
const width = (packed & 0x3fff) + 1;
const height = ((packed >> 14) & 0x3fff) + 1;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
if (fourCc === "VP8X") {
// VP8X chunk: 1-byte flags + 3-byte reserved + 3-byte canvas
// width-1 + 3-byte canvas height-1 (all little-endian, padded
// to 24 bits).
if (buffer.length < 30) return null;
const widthMinusOne = buffer[24] | (buffer[25] << 8) | (buffer[26] << 16);
const heightMinusOne = buffer[27] | (buffer[28] << 8) | (buffer[29] << 16);
const width = widthMinusOne + 1;
const height = heightMinusOne + 1;
if (width <= 0 || height <= 0) return null;
return { width, height };
}
return null;
return readImageDimensions(buffer, mime);
}
/**
+66 -6
View File
@@ -132,7 +132,15 @@ describe("generateThumbnail", () => {
it("returns { ok: false } and never throws on malformed image bytes", async () => {
await expect(
generateThumbnail(Buffer.from("not a real image"), "image/png"),
).resolves.toEqual({ ok: false, reason: "error" });
).resolves.toEqual({ ok: false, reason: "size-unreadable" });
});
it("returns { ok: false } when a readable header fronts bytes that do not decode", async () => {
const header = sourceImage(8, 8, "image/png").subarray(0, 40);
await expect(generateThumbnail(header, "image/png")).resolves.toEqual({
ok: false,
reason: "error",
});
});
it("returns { ok: false } on a malformed PDF", async () => {
@@ -158,13 +166,65 @@ describe("generateThumbnail", () => {
});
it("refuses an oversize image the header sniff missed (post-decode cap)", async () => {
// Bytes the JPEG sniffer cannot read (no SOI) → the pre-decode guard is
// skipped and the code falls through to `loadImage`. The decoder is forced
// to return 30000×30000 = 900 MP, so only the post-decode backstop can
// catch it. Without that backstop this would OOM the serial worker.
// A readable header declaring a small image whose decoder is forced to
// return 30000×30000 = 900 MP: only the post-decode backstop can catch
// it.
loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 });
await expect(
generateThumbnail(Buffer.from("desynced jpeg bytes"), "image/jpeg"),
generateThumbnail(sourceImage(16, 16, "image/jpeg"), "image/jpeg"),
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
});
/**
* A JPEG whose frame header declares `width` × `height`, with `fill` 0xFF
* fill bytes after the start-of-image marker. The standard allows fill
* bytes before any marker and every decoder skips them.
*/
function jpegDeclaring(width: number, height: number, fill: number): Buffer {
const base = sourceImage(16, 16, "image/jpeg");
const sof = base.indexOf(Buffer.from([0xff, 0xc0]));
const patched = Buffer.from(base);
patched.writeUInt16BE(height, sof + 5);
patched.writeUInt16BE(width, sof + 7);
return Buffer.concat([
patched.subarray(0, 2),
Buffer.alloc(fill, 0xff),
patched.subarray(2),
]);
}
it("reads the size of a JPEG with fill bytes before a marker, and refuses a bomb before decoding", async () => {
loadImageMock.mockClear();
// 20000×20000 behind one fill byte: the old walker lost its place, read
// no size and let the decoder allocate 1.6 GB.
await expect(
generateThumbnail(jpegDeclaring(20_000, 20_000, 1), "image/jpeg"),
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
await expect(
generateThumbnail(jpegDeclaring(20_000, 20_000, 3), "image/jpeg"),
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
expect(loadImageMock).not.toHaveBeenCalled();
});
it("still previews an ordinary JPEG that carries fill bytes", async () => {
const result = await generateThumbnail(
jpegDeclaring(16, 16, 2),
"image/jpeg",
);
expect(result.ok).toBe(true);
});
it("refuses an image whose size cannot be read instead of decoding it", async () => {
loadImageMock.mockClear();
// Start of image, then straight into a scan: no frame header to read.
const noFrame = Buffer.from([0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 1, 2, 3]);
await expect(generateThumbnail(noFrame, "image/jpeg")).resolves.toEqual({
ok: false,
reason: "size-unreadable",
});
await expect(
generateThumbnail(Buffer.from("RIFF0000WEBPVP8Z"), "image/webp"),
).resolves.toEqual({ ok: false, reason: "size-unreadable" });
expect(loadImageMock).not.toHaveBeenCalled();
});
});
+27 -92
View File
@@ -31,6 +31,10 @@ import { Buffer } from "node:buffer";
import { nativeCanvasSupported } from "./native-canvas-support";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import {
readImageDimensions,
type DimensionedImageMime,
} from "@/lib/image-dimensions";
import { annotate } from "@/lib/logging/context";
/** Longest-edge pixel cap for the preview — a legible card tile at ~10-25 KB. */
@@ -60,94 +64,16 @@ const THUMBNAILABLE_IMAGE_MIMES = new Set([
]);
/**
* Read a source image's pixel count (width × height) from its container header
* WITHOUT decoding the bitmap — the guard that keeps a decompression bomb out of
* `loadImage`. Covers the four thumbnailable formats. Returns null when the
* header can't be read (a malformed file `loadImage` will itself reject, or a
* format we don't pre-screen); a bomb necessarily carries a parseable header
* declaring huge dimensions, so a null here is never the bomb case.
* A source image's pixel count (width × height) from its container header,
* WITHOUT decoding the bitmap — the guard that keeps a decompression bomb out
* of `loadImage`. `null` when the header cannot be read, which the caller
* treats as a refusal: the decoder reads headers more leniently than any
* hand-written walker, so an unreadable size is no evidence of a small one.
*/
function sniffSourcePixels(bytes: Buffer, mimeType: string): number | null {
try {
switch (mimeType) {
case "image/png": {
// 8-byte signature + 4-byte length + "IHDR" → width@16, height@20 (BE).
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452)
return null;
return bytes.readUInt32BE(16) * bytes.readUInt32BE(20);
}
case "image/gif": {
// "GIF8" + logical-screen descriptor: width@6, height@8 (uint16 LE).
if (bytes.length < 10) return null;
return bytes.readUInt16LE(6) * bytes.readUInt16LE(8);
}
case "image/jpeg":
return sniffJpegPixels(bytes);
case "image/webp":
return sniffWebpPixels(bytes);
default:
return null;
}
} catch {
return null;
}
}
/** Walk JPEG marker segments to the first Start-Of-Frame and read its W×H. */
function sniffJpegPixels(bytes: Buffer): number | null {
if (bytes.length < 4 || bytes.readUInt16BE(0) !== 0xffd8) return null; // SOI
let offset = 2;
while (offset + 9 < bytes.length) {
if (bytes[offset] !== 0xff) return null;
const marker = bytes[offset + 1]!;
const segLen = bytes.readUInt16BE(offset + 2);
if (segLen < 2) return null;
// SOF0-15 carry dimensions, EXCEPT DHT (C4), JPG (C8), DAC (CC).
const isSof =
marker >= 0xc0 &&
marker <= 0xcf &&
marker !== 0xc4 &&
marker !== 0xc8 &&
marker !== 0xcc;
if (isSof) {
const height = bytes.readUInt16BE(offset + 5);
const width = bytes.readUInt16BE(offset + 7);
return width * height;
}
offset += 2 + segLen;
}
return null;
}
/** Read W×H from a WebP RIFF header (VP8 lossy / VP8L lossless / VP8X ext). */
function sniffWebpPixels(bytes: Buffer): number | null {
if (bytes.length < 30) return null;
if (bytes.readUInt32BE(0) !== 0x52494646) return null; // "RIFF"
if (bytes.readUInt32BE(8) !== 0x57454250) return null; // "WEBP"
const fourcc = bytes.toString("ascii", 12, 16);
if (fourcc === "VP8 ") {
// Lossy: 14-bit width/height (LE) at 26/28.
const w = bytes.readUInt16LE(26) & 0x3fff;
const h = bytes.readUInt16LE(28) & 0x3fff;
return w * h;
}
if (fourcc === "VP8L") {
// Lossless: 14-bit each, bit-packed after the 1-byte signature at 21.
const b0 = bytes[21]!;
const b1 = bytes[22]!;
const b2 = bytes[23]!;
const b3 = bytes[24]!;
const w = 1 + (((b1 & 0x3f) << 8) | b0);
const h = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6));
return w * h;
}
if (fourcc === "VP8X") {
// Extended: 24-bit LE (stored value + 1) width@24, height@27.
const w = 1 + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16));
const h = 1 + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16));
return w * h;
}
return null;
if (!THUMBNAILABLE_IMAGE_MIMES.has(mimeType)) return null;
const size = readImageDimensions(bytes, mimeType as DimensionedImageMime);
return size ? size.width * size.height : null;
}
/** The generated preview plus its decoded dimensions (for `Content-Length`). */
@@ -166,6 +92,7 @@ export type ThumbnailSkipReason =
| "unsupported-type"
| "native-canvas-unsupported"
| "pixel-cap"
| "size-unreadable"
| "raster-failed"
| "resource-limit"
| "empty-render"
@@ -252,9 +179,18 @@ export async function generateThumbnail(
(await import("@napi-rs/canvas")) as unknown as NapiCanvasModule;
if (THUMBNAILABLE_IMAGE_MIMES.has(mimeType)) {
// Refuse a decompression bomb BEFORE `loadImage` allocates the bitmap.
// Refuse a decompression bomb BEFORE `loadImage` allocates the bitmap,
// and refuse an image whose size cannot be read: that one would reach
// the decoder with no ceiling at all.
const pixels = sniffSourcePixels(bytes, mimeType);
if (pixels !== null && pixels > MAX_SOURCE_PIXELS) {
if (pixels === null) {
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: { source: "image", reason: "size_unreadable" },
});
return { ok: false, reason: "size-unreadable" };
}
if (pixels > MAX_SOURCE_PIXELS) {
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: { source: "image", reason: "pixel_cap", pixels },
@@ -262,10 +198,9 @@ export async function generateThumbnail(
return { ok: false, reason: "pixel-cap" };
}
const image = await napi.loadImage(bytes);
// Post-decode backstop: the header sniff returns null on any marker
// desync (a malformed-but-decodable JPEG), falling through to here with
// NO ceiling. Re-check the DECODED dimensions so such a source can't
// bypass the cap and OOM the serial worker.
// Post-decode backstop: should a decoder ever read a larger frame than
// the header walk did, the decoded size is checked again before the
// bitmap is drawn.
if (image.width * image.height > MAX_SOURCE_PIXELS) {
annotate({
action: { name: "documents.thumbnail.rejected" },
+151
View File
@@ -0,0 +1,151 @@
/**
* An image's declared width and height, read from its container header
* without decoding it.
*
* Two places depend on this. The avatar upload refuses anything larger than
* it serves, and the document thumbnail refuses to decode a source past its
* pixel cap, because the decoder allocates the full bitmap before anything
* can look at it. Both treat `null` as a refusal: a decoder reads headers
* more leniently than a hand-written walker, so "could not read the size" is
* no evidence the size is small.
*
* There used to be two walkers. The thumbnail's took the byte after any 0xFF
* as a marker, so a single fill byte after the start-of-image marker, which
* the JPEG standard allows and every decoder skips, threw it off; it answered
* `null`, and `null` let the image through to a decode of 20000 x 20000
* pixels. This is the one walker now.
*/
export interface ImageDimensions {
width: number;
height: number;
}
export type DimensionedImageMime =
"image/jpeg" | "image/png" | "image/webp" | "image/gif";
/**
* The declared size, or `null` when the header is truncated, malformed, or
* declares a zero side. Never throws.
*/
export function readImageDimensions(
bytes: Buffer,
mime: DimensionedImageMime,
): ImageDimensions | null {
try {
const size = readBySniffedType(bytes, mime);
if (!size || size.width <= 0 || size.height <= 0) return null;
return size;
} catch {
return null;
}
}
function readBySniffedType(
bytes: Buffer,
mime: DimensionedImageMime,
): ImageDimensions | null {
switch (mime) {
case "image/png":
return readPng(bytes);
case "image/jpeg":
return readJpeg(bytes);
case "image/webp":
return readWebp(bytes);
case "image/gif":
return readGif(bytes);
}
}
function readPng(bytes: Buffer): ImageDimensions | null {
// 8-byte signature + 4-byte chunk length + "IHDR" + width + height.
if (bytes.length < 24) return null;
if (bytes.toString("ascii", 12, 16) !== "IHDR") return null;
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
function readGif(bytes: Buffer): ImageDimensions | null {
// "GIF87a" / "GIF89a", then the logical screen: width, height (LE).
if (bytes.length < 10 || bytes.toString("ascii", 0, 4) !== "GIF8") {
return null;
}
return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
}
/**
* Walk the marker segments to the first Start Of Frame (ITU-T T.81, B.1.1).
* A marker is 0xFF followed by a byte that is neither 0x00 nor 0xFF; any
* number of 0xFF fill bytes may come before it. SOI, EOI, RST0-7 and TEM
* carry no length. A scan or the end of the image before any frame header
* means there is no size to read.
*/
function readJpeg(bytes: Buffer): ImageDimensions | null {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
let offset = 2;
while (offset < bytes.length) {
if (bytes[offset] !== 0xff) return null;
while (offset < bytes.length && bytes[offset] === 0xff) offset++;
if (offset >= bytes.length) return null;
const marker = bytes[offset]!;
offset++;
if (marker === 0x00) return null; // a stuffed byte outside entropy data
if (marker === 0xd8 || marker === 0x01) continue; // SOI, TEM
if (marker >= 0xd0 && marker <= 0xd7) continue; // RST0-7
if (marker === 0xd9 || marker === 0xda) return null; // EOI, SOS
if (offset + 2 > bytes.length) return null;
const length = bytes.readUInt16BE(offset);
if (length < 2) return null;
// SOF0-15 carry the frame size, except DHT (C4), JPG (C8) and DAC (CC).
const isSof =
marker >= 0xc0 &&
marker <= 0xcf &&
marker !== 0xc4 &&
marker !== 0xc8 &&
marker !== 0xcc;
if (isSof) {
// length (2) | precision (1) | height (2) | width (2)
if (offset + 7 > bytes.length) return null;
return {
height: bytes.readUInt16BE(offset + 3),
width: bytes.readUInt16BE(offset + 5),
};
}
offset += length;
}
return null;
}
function readWebp(bytes: Buffer): ImageDimensions | null {
if (bytes.length < 30) return null;
if (bytes.toString("ascii", 0, 4) !== "RIFF") return null;
if (bytes.toString("ascii", 8, 12) !== "WEBP") return null;
const fourCc = bytes.toString("ascii", 12, 16);
if (fourCc === "VP8 ") {
// Lossy: 14-bit width and height (LE) in the frame header.
return {
width: bytes.readUInt16LE(26) & 0x3fff,
height: bytes.readUInt16LE(28) & 0x3fff,
};
}
if (fourCc === "VP8L") {
// Lossless: signature 0x2f, then width-1 and height-1 in 14 bits each.
if (bytes[20] !== 0x2f) return null;
const packed = bytes.readUInt32LE(21);
return {
width: (packed & 0x3fff) + 1,
height: ((packed >>> 14) & 0x3fff) + 1,
};
}
if (fourCc === "VP8X") {
// Extended: the canvas, width-1 and height-1 in 24 bits each (LE).
return {
width: 1 + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)),
height: 1 + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)),
};
}
return null;
}
+3 -2
View File
@@ -49,8 +49,8 @@ const THUMBNAILABLE_MIMES = [
/**
* Render refusals that the same bytes will always get again: the first page
* renders blank, the render comes out empty, the image is past the pixel cap,
* the PDF crossed the renderer's memory or time bound. One attempt settles
* renders blank, the render comes out empty, the image is past the pixel cap
* or its size cannot be read, the PDF crossed the renderer's memory or time bound. One attempt settles
* them; queueing a document that exhausts the renderer again at every boot
* would only repeat the exhaustion.
*/
@@ -58,6 +58,7 @@ const FINAL_THUMBNAIL_FAILURES = [
"raster-failed",
"empty-render",
"pixel-cap",
"size-unreadable",
"resource-limit",
] as const;