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 { 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]);
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user