Skip to content

Commit da27026

Browse files
committed
fix: satisfy fallow audit
1 parent 229feba commit da27026

7 files changed

Lines changed: 263 additions & 203 deletions

src/utils/png-codec.ts

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ type PngMetadata = {
3838
transparency?: Buffer;
3939
};
4040

41+
type PngChunk = {
42+
type: string;
43+
data: Buffer;
44+
};
45+
4146
export class PNG {
4247
width: number;
4348
height: number;
@@ -60,14 +65,54 @@ export class PNG {
6065
}
6166

6267
function readPng(buffer: Buffer): PNG {
68+
const { metadata, idatChunks } = collectPngChunks(buffer);
69+
if (!metadata) throw new Error('PNG is missing IHDR');
70+
if (idatChunks.length === 0) throw new Error('PNG is missing IDAT');
71+
const inflated = inflateSync(Buffer.concat(idatChunks));
72+
return new PNG({
73+
width: metadata.width,
74+
height: metadata.height,
75+
data:
76+
metadata.interlace === 1
77+
? decodeInterlacedPixels(inflated, metadata)
78+
: decodePixels(unfilterPng(inflated, metadata), metadata),
79+
});
80+
}
81+
82+
function collectPngChunks(buffer: Buffer): { metadata?: PngMetadata; idatChunks: Buffer[] } {
83+
let metadata: PngMetadata | undefined;
84+
const idatChunks: Buffer[] = [];
85+
86+
for (const chunk of iteratePngChunks(buffer)) {
87+
if (chunk.type === 'IHDR') metadata = parseIhdr(chunk.data);
88+
else if (chunk.type === 'IDAT') idatChunks.push(Buffer.from(chunk.data));
89+
else metadata = applyMetadataChunk(chunk, metadata);
90+
if (chunk.type === 'IEND') break;
91+
}
92+
93+
return { metadata, idatChunks };
94+
}
95+
96+
function applyMetadataChunk(
97+
chunk: PngChunk,
98+
metadata: PngMetadata | undefined,
99+
): PngMetadata | undefined {
100+
if (chunk.type === 'PLTE') {
101+
if (!metadata) throw new Error('PNG PLTE appeared before IHDR');
102+
metadata.palette = Buffer.from(chunk.data);
103+
} else if (chunk.type === 'tRNS') {
104+
if (!metadata) throw new Error('PNG tRNS appeared before IHDR');
105+
metadata.transparency = Buffer.from(chunk.data);
106+
}
107+
return metadata;
108+
}
109+
110+
function* iteratePngChunks(buffer: Buffer): Generator<PngChunk> {
63111
if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
64112
throw new Error('Invalid PNG signature');
65113
}
66114

67-
let metadata: PngMetadata | undefined;
68-
const idatChunks: Buffer[] = [];
69115
let offset = PNG_SIGNATURE.length;
70-
71116
while (offset < buffer.length) {
72117
if (offset + 12 > buffer.length) throw new Error('Truncated PNG chunk');
73118
const length = buffer.readUInt32BE(offset);
@@ -80,29 +125,8 @@ function readPng(buffer: Buffer): PNG {
80125
const actualCrc = crc32(buffer.subarray(offset + 4, dataEnd));
81126
if (actualCrc !== expectedCrc) throw new Error(`Invalid PNG ${type} chunk CRC`);
82127
offset = dataEnd + 4;
83-
84-
if (type === 'IHDR') metadata = parseIhdr(data);
85-
else if (type === 'PLTE') {
86-
if (!metadata) throw new Error('PNG PLTE appeared before IHDR');
87-
metadata.palette = Buffer.from(data);
88-
} else if (type === 'tRNS') {
89-
if (!metadata) throw new Error('PNG tRNS appeared before IHDR');
90-
metadata.transparency = Buffer.from(data);
91-
} else if (type === 'IDAT') idatChunks.push(Buffer.from(data));
92-
else if (type === 'IEND') break;
128+
yield { type, data };
93129
}
94-
95-
if (!metadata) throw new Error('PNG is missing IHDR');
96-
if (idatChunks.length === 0) throw new Error('PNG is missing IDAT');
97-
const inflated = inflateSync(Buffer.concat(idatChunks));
98-
return new PNG({
99-
width: metadata.width,
100-
height: metadata.height,
101-
data:
102-
metadata.interlace === 1
103-
? decodeInterlacedPixels(inflated, metadata)
104-
: decodePixels(unfilterPng(inflated, metadata), metadata),
105-
});
106130
}
107131

108132
function writePng(png: PNG): Buffer {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
type ConnectedMaskComponentHooks<TComponent> = {
2+
create(pixelIndex: number): TComponent;
3+
visit(component: TComponent, pixelIndex: number): void;
4+
};
5+
6+
const NEIGHBOR_OFFSETS = [
7+
{ x: -1, y: -1 },
8+
{ x: 0, y: -1 },
9+
{ x: 1, y: -1 },
10+
{ x: -1, y: 0 },
11+
{ x: 1, y: 0 },
12+
{ x: -1, y: 1 },
13+
{ x: 0, y: 1 },
14+
{ x: 1, y: 1 },
15+
] as const;
16+
17+
export function findConnectedMaskComponents<TComponent>(params: {
18+
mask: Uint8Array;
19+
width: number;
20+
height: number;
21+
hooks: ConnectedMaskComponentHooks<TComponent>;
22+
}): TComponent[] {
23+
const { mask, width, height, hooks } = params;
24+
const visited = new Uint8Array(mask.length);
25+
const queue = new Int32Array(mask.length);
26+
const components: TComponent[] = [];
27+
28+
for (let pixelIndex = 0; pixelIndex < mask.length; pixelIndex += 1) {
29+
if (!isUnvisitedMaskPixel(mask, visited, pixelIndex)) continue;
30+
31+
let queueStart = 0;
32+
let queueEnd = enqueuePixel(queue, visited, 0, pixelIndex);
33+
const component = hooks.create(pixelIndex);
34+
35+
while (queueStart < queueEnd) {
36+
const currentPixelIndex = queue[queueStart]!;
37+
queueStart += 1;
38+
hooks.visit(component, currentPixelIndex);
39+
queueEnd = enqueueMaskNeighbors({
40+
mask,
41+
visited,
42+
queue,
43+
queueEnd,
44+
width,
45+
height,
46+
pixelIndex: currentPixelIndex,
47+
});
48+
}
49+
50+
components.push(component);
51+
}
52+
53+
return components;
54+
}
55+
56+
function enqueueMaskNeighbors(params: {
57+
mask: Uint8Array;
58+
visited: Uint8Array;
59+
queue: Int32Array;
60+
queueEnd: number;
61+
width: number;
62+
height: number;
63+
pixelIndex: number;
64+
}): number {
65+
const { mask, visited, queue, width, height, pixelIndex } = params;
66+
const x = pixelIndex % width;
67+
const y = Math.floor(pixelIndex / width);
68+
let queueEnd = params.queueEnd;
69+
70+
for (const offset of NEIGHBOR_OFFSETS) {
71+
const neighborX = x + offset.x;
72+
const neighborY = y + offset.y;
73+
if (!isInBounds(neighborX, neighborY, width, height)) continue;
74+
const neighborIndex = neighborY * width + neighborX;
75+
if (!isUnvisitedMaskPixel(mask, visited, neighborIndex)) continue;
76+
queueEnd = enqueuePixel(queue, visited, queueEnd, neighborIndex);
77+
}
78+
79+
return queueEnd;
80+
}
81+
82+
function enqueuePixel(
83+
queue: Int32Array,
84+
visited: Uint8Array,
85+
queueEnd: number,
86+
pixelIndex: number,
87+
): number {
88+
visited[pixelIndex] = 1;
89+
queue[queueEnd] = pixelIndex;
90+
return queueEnd + 1;
91+
}
92+
93+
function isUnvisitedMaskPixel(mask: Uint8Array, visited: Uint8Array, pixelIndex: number): boolean {
94+
return mask[pixelIndex] === 1 && visited[pixelIndex] !== 1;
95+
}
96+
97+
function isInBounds(x: number, y: number, width: number, height: number): boolean {
98+
return x >= 0 && x < width && y >= 0 && y < height;
99+
}

src/utils/screenshot-diff-non-text.ts

Lines changed: 38 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import type { Rect } from './snapshot.ts';
2+
import { findConnectedMaskComponents } from './screenshot-diff-components.ts';
23
import type { ScreenshotOcrAnalysis, ScreenshotOcrBlock } from './screenshot-diff-ocr.ts';
34
import type { ScreenshotDiffRegion } from './screenshot-diff-regions.ts';
5+
import {
6+
clamp,
7+
expandRect,
8+
intersectArea,
9+
rectCenter,
10+
squaredDistance,
11+
unionRects,
12+
} from './screenshot-geometry.ts';
413

514
export type ScreenshotNonTextDelta = {
615
index: number;
@@ -118,56 +127,37 @@ function findConnectedComponents(
118127
width: number,
119128
height: number,
120129
): MutableComponent[] {
121-
const visited = new Uint8Array(mask.length);
122-
const queue = new Int32Array(mask.length);
123-
const components: MutableComponent[] = [];
124-
for (let pixelIndex = 0; pixelIndex < mask.length; pixelIndex += 1) {
125-
if (mask[pixelIndex] !== 1 || visited[pixelIndex] === 1) continue;
126-
let queueStart = 0;
127-
let queueEnd = 0;
128-
queue[queueEnd] = pixelIndex;
129-
queueEnd += 1;
130-
visited[pixelIndex] = 1;
131-
132-
const startX = pixelIndex % width;
133-
const startY = Math.floor(pixelIndex / width);
134-
const component: MutableComponent = {
135-
minX: startX,
136-
minY: startY,
137-
maxX: startX,
138-
maxY: startY,
139-
differentPixels: 0,
140-
};
130+
return findConnectedMaskComponents({
131+
mask,
132+
width,
133+
height,
134+
hooks: {
135+
create: (pixelIndex) => createComponent(pixelIndex, width),
136+
visit: (component, pixelIndex) => addPixelToComponent(component, pixelIndex, width),
137+
},
138+
});
139+
}
141140

142-
while (queueStart < queueEnd) {
143-
const currentIndex = queue[queueStart]!;
144-
queueStart += 1;
145-
const x = currentIndex % width;
146-
const y = Math.floor(currentIndex / width);
147-
component.minX = Math.min(component.minX, x);
148-
component.minY = Math.min(component.minY, y);
149-
component.maxX = Math.max(component.maxX, x);
150-
component.maxY = Math.max(component.maxY, y);
151-
component.differentPixels += 1;
141+
function createComponent(pixelIndex: number, width: number): MutableComponent {
142+
const startX = pixelIndex % width;
143+
const startY = Math.floor(pixelIndex / width);
144+
return {
145+
minX: startX,
146+
minY: startY,
147+
maxX: startX,
148+
maxY: startY,
149+
differentPixels: 0,
150+
};
151+
}
152152

153-
for (let yOffset = -1; yOffset <= 1; yOffset += 1) {
154-
const neighborY = y + yOffset;
155-
if (neighborY < 0 || neighborY >= height) continue;
156-
for (let xOffset = -1; xOffset <= 1; xOffset += 1) {
157-
if (xOffset === 0 && yOffset === 0) continue;
158-
const neighborX = x + xOffset;
159-
if (neighborX < 0 || neighborX >= width) continue;
160-
const neighborIndex = neighborY * width + neighborX;
161-
if (mask[neighborIndex] !== 1 || visited[neighborIndex] === 1) continue;
162-
visited[neighborIndex] = 1;
163-
queue[queueEnd] = neighborIndex;
164-
queueEnd += 1;
165-
}
166-
}
167-
}
168-
components.push(component);
169-
}
170-
return components;
153+
function addPixelToComponent(component: MutableComponent, pixelIndex: number, width: number): void {
154+
const x = pixelIndex % width;
155+
const y = Math.floor(pixelIndex / width);
156+
component.minX = Math.min(component.minX, x);
157+
component.minY = Math.min(component.minY, y);
158+
component.maxX = Math.max(component.maxX, x);
159+
component.maxY = Math.max(component.maxY, y);
160+
component.differentPixels += 1;
171161
}
172162

173163
function mergeNearbyComponents(components: MutableComponent[], gapPx: number): MutableComponent[] {
@@ -397,20 +387,6 @@ function findNearestText(
397387
return nearest;
398388
}
399389

400-
function unionRects(rects: Rect[]): Rect {
401-
let minX = Number.POSITIVE_INFINITY;
402-
let minY = Number.POSITIVE_INFINITY;
403-
let maxX = Number.NEGATIVE_INFINITY;
404-
let maxY = Number.NEGATIVE_INFINITY;
405-
for (const rect of rects) {
406-
minX = Math.min(minX, rect.x);
407-
minY = Math.min(minY, rect.y);
408-
maxX = Math.max(maxX, rect.x + rect.width);
409-
maxY = Math.max(maxY, rect.y + rect.height);
410-
}
411-
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
412-
}
413-
414390
function cleanOcrAnchorText(text: string): string {
415391
return text
416392
.trim()
@@ -436,15 +412,6 @@ function componentToRect(component: MutableComponent): Rect {
436412
};
437413
}
438414

439-
function expandRect(rect: Rect, padding: number): Rect {
440-
return {
441-
x: rect.x - padding,
442-
y: rect.y - padding,
443-
width: rect.width + padding * 2,
444-
height: rect.height + padding * 2,
445-
};
446-
}
447-
448415
function clearRect(mask: Uint8Array, width: number, height: number, rect: Rect): void {
449416
const minX = clamp(Math.floor(rect.x), 0, width - 1);
450417
const minY = clamp(Math.floor(rect.y), 0, height - 1);
@@ -470,30 +437,9 @@ function componentsAreNear(
470437
);
471438
}
472439

473-
function intersectArea(left: Rect, right: Rect): number {
474-
const minX = Math.max(left.x, right.x);
475-
const minY = Math.max(left.y, right.y);
476-
const maxX = Math.min(left.x + left.width, right.x + right.width);
477-
const maxY = Math.min(left.y + left.height, right.y + right.height);
478-
if (maxX <= minX || maxY <= minY) return 0;
479-
return (maxX - minX) * (maxY - minY);
480-
}
481-
482440
function verticalOverlap(left: Rect, right: Rect): number {
483441
return Math.max(
484442
0,
485443
Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y),
486444
);
487445
}
488-
489-
function rectCenter(rect: Rect): { x: number; y: number } {
490-
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
491-
}
492-
493-
function squaredDistance(left: { x: number; y: number }, right: { x: number; y: number }): number {
494-
return (left.x - right.x) ** 2 + (left.y - right.y) ** 2;
495-
}
496-
497-
function clamp(value: number, min: number, max: number): number {
498-
return Math.min(Math.max(value, min), max);
499-
}

0 commit comments

Comments
 (0)