mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
refactor(png): share IHDR interpretation
This commit is contained in:
1 parent
92bdb4443f
commit
729684af9a
3 files changed
+108
-26
No files matched your search
@@ -2,6 +2,7 @@ import { crc32, deflateSync } from "node:zlib";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readPngHeaderDimensions } from "../domain/pngHeader.js";
|
||||
import { comparePngScreenshots } from "./PngVisualDiff.js";
|
||||
import {
|
||||
AnalysisResourceConstraintError,
|
||||
@@ -13,6 +14,48 @@ import {
|
||||
webScreenshotDiffSchema,
|
||||
} 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", () => {
|
||||
it("reports exact changed-pixel and channel metrics", () => {
|
||||
const before = artifact(1, 1, [0, 0, 0, 255]);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { decodeCanonicalBase64 } from "../domain/webScreenshot.js";
|
||||
import { hasPngSignature, readPngIhdrFacts } from "../domain/pngHeader.js";
|
||||
|
||||
interface DecodedPng {
|
||||
readonly width: number;
|
||||
@@ -102,7 +103,7 @@ const parsePng = (
|
||||
field: "before" | "after",
|
||||
): ParsedPng => {
|
||||
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");
|
||||
let offset = 8;
|
||||
let header: ReturnType<typeof parseHeader> | undefined;
|
||||
@@ -347,11 +348,17 @@ const inflatePng = (compressed: Buffer, maxOutputLength: number): Buffer => {
|
||||
};
|
||||
|
||||
const parseHeader = (data: Buffer) => {
|
||||
if (data.byteLength !== 13) throw new TypeError("Invalid PNG header");
|
||||
const width = data.readUInt32BE(0);
|
||||
const height = data.readUInt32BE(4);
|
||||
const bitDepth = data[8] ?? 0;
|
||||
const colorType = data[9] ?? -1;
|
||||
const parsed = readPngIhdrFacts(data);
|
||||
if (!parsed.ok) throw new TypeError(parsed.error);
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
bitDepth,
|
||||
colorType,
|
||||
compressionMethod,
|
||||
filterMethod,
|
||||
interlaceMethod,
|
||||
} = parsed.value;
|
||||
const validDepth =
|
||||
(colorType === 0 && [1, 2, 4, 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 === 6 && [8, 16].includes(bitDepth));
|
||||
if (
|
||||
width === 0 ||
|
||||
height === 0 ||
|
||||
width > 0x7fffffff ||
|
||||
height > 0x7fffffff ||
|
||||
!validDepth ||
|
||||
data[10] !== 0 ||
|
||||
data[11] !== 0 ||
|
||||
(data[12] !== 0 && data[12] !== 1)
|
||||
compressionMethod !== 0 ||
|
||||
filterMethod !== 0 ||
|
||||
(interlaceMethod !== 0 && interlaceMethod !== 1)
|
||||
)
|
||||
throw new TypeError("Invalid PNG header");
|
||||
return {
|
||||
@@ -374,10 +377,12 @@ const parseHeader = (data: Buffer) => {
|
||||
height,
|
||||
colorType,
|
||||
bitDepth,
|
||||
interlace: data[12],
|
||||
interlace: interlaceMethod,
|
||||
channels: colorType === 6 ? 4 : 3,
|
||||
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.",
|
||||
"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
@@ -1,26 +1,62 @@
|
||||
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 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. */
|
||||
export const readPngHeaderDimensions = (
|
||||
bytes: Buffer,
|
||||
): { readonly width: number; readonly height: number } | undefined => {
|
||||
if (
|
||||
bytes.byteLength < 33 ||
|
||||
!bytes.subarray(0, 8).equals(PNG_SIGNATURE) ||
|
||||
!hasPngSignature(bytes) ||
|
||||
bytes.readUInt32BE(8) !== 13 ||
|
||||
!bytes.subarray(12, 16).equals(IHDR_TYPE) ||
|
||||
crc32(bytes.subarray(12, 29)) !== bytes.readUInt32BE(29)
|
||||
)
|
||||
return undefined;
|
||||
const width = bytes.readUInt32BE(16);
|
||||
const height = bytes.readUInt32BE(20);
|
||||
return width === 0 ||
|
||||
height === 0 ||
|
||||
width > 0x7fff_ffff ||
|
||||
height > 0x7fff_ffff
|
||||
? undefined
|
||||
: { width, height };
|
||||
const facts = readPngIhdrFacts(bytes.subarray(16, 29));
|
||||
return facts.ok
|
||||
? { width: facts.value.width, height: facts.value.height }
|
||||
: undefined;
|
||||
};
|
||||
Reference in new issue
Block a user