refactor(png): share IHDR interpretation

This commit is contained in:
morluto committed 2026-10-26 16:04:33 +08:00
1 parent 92bdb4443f
commit 729684af9a
3 files changed
+108 -26

No files matched your search

+43
View File
@@ -2,6 +2,7 @@ import { crc32, deflateSync } from "node:zlib";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { readPngHeaderDimensions } from "../domain/pngHeader.js";
import { comparePngScreenshots } from "./PngVisualDiff.js"; import { comparePngScreenshots } from "./PngVisualDiff.js";
import { import {
AnalysisResourceConstraintError, AnalysisResourceConstraintError,
@@ -13,6 +14,48 @@ import {
webScreenshotDiffSchema, webScreenshotDiffSchema,
} from "../domain/webScreenshot.js"; } from "../domain/webScreenshot.js";
describe("PNG header admission", () => {
it.each([16, 20])(
"shares dimension admission with header sniffing at %s",
(offset) => {
for (const dimension of [0, 0x8000_0000]) {
const bytes = png(1, 1, [0, 0, 0, 255]);
bytes.writeUInt32BE(dimension, offset);
bytes.writeUInt32BE(crc32(bytes.subarray(12, 29)), 29);
expect(readPngHeaderDimensions(bytes)).toBeUndefined();
const image = createWebScreenshotArtifact(bytes);
expect(() =>
comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({
before: image,
after: image,
}),
),
).toThrow("Invalid PNG header");
}
},
);
it.each([24, 25, 26, 27, 28])(
"preserves header dimensions for unknown encoding field at %s",
(offset) => {
const bytes = png(1, 1, [0, 0, 0, 255]);
bytes[offset] = 255;
bytes.writeUInt32BE(crc32(bytes.subarray(12, 29)), 29);
expect(readPngHeaderDimensions(bytes)).toEqual({ width: 1, height: 1 });
const image = createWebScreenshotArtifact(bytes);
expect(() =>
comparePngScreenshots(
compareWebScreenshotsInputSchema.parse({
before: image,
after: image,
}),
),
).toThrow("Invalid PNG header");
},
);
});
describe("PNG visual diff", () => { describe("PNG visual diff", () => {
it("reports exact changed-pixel and channel metrics", () => { it("reports exact changed-pixel and channel metrics", () => {
const before = artifact(1, 1, [0, 0, 0, 255]); const before = artifact(1, 1, [0, 0, 0, 255]);
+20 -17
View File
@@ -10,6 +10,7 @@ import {
AnalysisUnsupportedTargetError, AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js"; } from "../domain/analysisErrorCore.js";
import { decodeCanonicalBase64 } from "../domain/webScreenshot.js"; import { decodeCanonicalBase64 } from "../domain/webScreenshot.js";
import { hasPngSignature, readPngIhdrFacts } from "../domain/pngHeader.js";
interface DecodedPng { interface DecodedPng {
readonly width: number; readonly width: number;
@@ -102,7 +103,7 @@ const parsePng = (
field: "before" | "after", field: "before" | "after",
): ParsedPng => { ): ParsedPng => {
const bytes = decodeCanonicalBase64(artifact.data_base64); const bytes = decodeCanonicalBase64(artifact.data_base64);
if (bytes === undefined || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) if (bytes === undefined || !hasPngSignature(bytes))
throw new TypeError("Invalid PNG signature"); throw new TypeError("Invalid PNG signature");
let offset = 8; let offset = 8;
let header: ReturnType<typeof parseHeader> | undefined; let header: ReturnType<typeof parseHeader> | undefined;
@@ -347,11 +348,17 @@ const inflatePng = (compressed: Buffer, maxOutputLength: number): Buffer => {
}; };
const parseHeader = (data: Buffer) => { const parseHeader = (data: Buffer) => {
if (data.byteLength !== 13) throw new TypeError("Invalid PNG header"); const parsed = readPngIhdrFacts(data);
const width = data.readUInt32BE(0); if (!parsed.ok) throw new TypeError(parsed.error);
const height = data.readUInt32BE(4); const {
const bitDepth = data[8] ?? 0; width,
const colorType = data[9] ?? -1; height,
bitDepth,
colorType,
compressionMethod,
filterMethod,
interlaceMethod,
} = parsed.value;
const validDepth = const validDepth =
(colorType === 0 && [1, 2, 4, 8, 16].includes(bitDepth)) || (colorType === 0 && [1, 2, 4, 8, 16].includes(bitDepth)) ||
(colorType === 2 && [8, 16].includes(bitDepth)) || (colorType === 2 && [8, 16].includes(bitDepth)) ||
@@ -359,14 +366,10 @@ const parseHeader = (data: Buffer) => {
(colorType === 4 && [8, 16].includes(bitDepth)) || (colorType === 4 && [8, 16].includes(bitDepth)) ||
(colorType === 6 && [8, 16].includes(bitDepth)); (colorType === 6 && [8, 16].includes(bitDepth));
if ( if (
width === 0 ||
height === 0 ||
width > 0x7fffffff ||
height > 0x7fffffff ||
!validDepth || !validDepth ||
data[10] !== 0 || compressionMethod !== 0 ||
data[11] !== 0 || filterMethod !== 0 ||
(data[12] !== 0 && data[12] !== 1) (interlaceMethod !== 0 && interlaceMethod !== 1)
) )
throw new TypeError("Invalid PNG header"); throw new TypeError("Invalid PNG header");
return { return {
@@ -374,10 +377,12 @@ const parseHeader = (data: Buffer) => {
height, height,
colorType, colorType,
bitDepth, bitDepth,
interlace: data[12], interlace: interlaceMethod,
channels: colorType === 6 ? 4 : 3, channels: colorType === 6 ? 4 : 3,
supported: supported:
bitDepth === 8 && (colorType === 2 || colorType === 6) && data[12] === 0, bitDepth === 8 &&
(colorType === 2 || colorType === 6) &&
interlaceMethod === 0,
}; };
}; };
@@ -491,5 +496,3 @@ const limitations = (): string[] => [
"Only non-interlaced 8-bit RGB and RGBA PNG screenshots are accepted.", "Only non-interlaced 8-bit RGB and RGBA PNG screenshots are accepted.",
"PNG tRNS transparency is accepted only as a six-byte RGB color key; tRNS on RGBA or with another length is rejected.", "PNG tRNS transparency is accepted only as a six-byte RGB color key; tRNS on RGBA or with another length is rejected.",
]; ];
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+45 -9
View File
@@ -1,26 +1,62 @@
import { crc32 } from "node:zlib"; import { crc32 } from "node:zlib";
import { err, ok, type Result } from "./result.js";
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const IHDR_TYPE = Buffer.from("IHDR"); const IHDR_TYPE = Buffer.from("IHDR");
/** PNG signature recognition does not establish image validity. */
export const hasPngSignature = (bytes: Buffer): boolean =>
bytes.subarray(0, 8).equals(PNG_SIGNATURE);
export interface PngIhdrFacts {
readonly width: number;
readonly height: number;
readonly bitDepth: number;
readonly colorType: number;
readonly compressionMethod: number;
readonly filterMethod: number;
readonly interlaceMethod: number;
}
/** Decode IHDR declarations and admit dimensions; encoding support belongs to consumers. */
export const readPngIhdrFacts = (
data: Buffer,
): Result<PngIhdrFacts, string> => {
if (data.length !== 13) return err("Invalid PNG header");
const width = data.readUInt32BE(0);
const height = data.readUInt32BE(4);
if (
width === 0 ||
height === 0 ||
width > 0x7fff_ffff ||
height > 0x7fff_ffff
)
return err("Invalid PNG header");
return ok({
width,
height,
bitDepth: data[8]!,
colorType: data[9]!,
compressionMethod: data[10]!,
filterMethod: data[11]!,
interlaceMethod: data[12]!,
});
};
/** Read screenshot dimensions from a byte-faithful PNG header, not a full image decode. */ /** Read screenshot dimensions from a byte-faithful PNG header, not a full image decode. */
export const readPngHeaderDimensions = ( export const readPngHeaderDimensions = (
bytes: Buffer, bytes: Buffer,
): { readonly width: number; readonly height: number } | undefined => { ): { readonly width: number; readonly height: number } | undefined => {
if ( if (
bytes.byteLength < 33 || bytes.byteLength < 33 ||
!bytes.subarray(0, 8).equals(PNG_SIGNATURE) || !hasPngSignature(bytes) ||
bytes.readUInt32BE(8) !== 13 || bytes.readUInt32BE(8) !== 13 ||
!bytes.subarray(12, 16).equals(IHDR_TYPE) || !bytes.subarray(12, 16).equals(IHDR_TYPE) ||
crc32(bytes.subarray(12, 29)) !== bytes.readUInt32BE(29) crc32(bytes.subarray(12, 29)) !== bytes.readUInt32BE(29)
) )
return undefined; return undefined;
const width = bytes.readUInt32BE(16); const facts = readPngIhdrFacts(bytes.subarray(16, 29));
const height = bytes.readUInt32BE(20); return facts.ok
return width === 0 || ? { width: facts.value.width, height: facts.value.height }
height === 0 || : undefined;
width > 0x7fff_ffff ||
height > 0x7fff_ffff
? undefined
: { width, height };
}; };