///|
/// Normalize PNG's IDAT stream to stored deflate blocks on Node.js. The image
/// decoder remains MoonBit; this avoids deep recursion in its flate dependency.
/// Reject corrupt chunks and bound decompression to 128 MiB.
extern "js" fn png_stored_deflate(data : Bytes) -> Bytes =
#| (input) => {
#| try {
#| const zlib = require('node:zlib');
#| const data = Buffer.from(input);
#| const signature = Buffer.from('89504e470d0a1a0a', 'hex');
#| if (data.length < 8 || !data.subarray(0, 8).equals(signature)) return new Uint8Array(0);
#| const chunks = [];
#| const idats = [];
#| let position = 8, firstIdat = -1, afterIdat = false, ended = false;
#| while (position + 12 <= data.length) {
#| const size = data.readUInt32BE(position);
#| const end = position + 12 + size;
#| if (end > data.length) return new Uint8Array(0);
#| const type = data.toString('ascii', position + 4, position + 8);
#| const body = data.subarray(position + 4, position + 8 + size);
#| if (zlib.crc32(body) !== data.readUInt32BE(position + 8 + size)) return new Uint8Array(0);
#| if (type === 'IDAT') {
#| if (afterIdat) return new Uint8Array(0);
#| if (firstIdat < 0) { firstIdat = chunks.length; chunks.push(null); }
#| idats.push(data.subarray(position + 8, position + 8 + size));
#| } else {
#| if (firstIdat >= 0) afterIdat = true;
#| chunks.push(data.subarray(position, end));
#| }
#| position = end;
#| if (type === 'IEND') { ended = true; break; }
#| }
#| if (!ended || position !== data.length || firstIdat < 0) return new Uint8Array(0);
#| const raw = zlib.inflateSync(Buffer.concat(idats), {maxOutputLength: 128 * 1024 * 1024});
#| const packed = zlib.deflateSync(raw, {level: 0});
#| const size = Buffer.alloc(4), checksum = Buffer.alloc(4), tag = Buffer.from('IDAT');
#| size.writeUInt32BE(packed.length);
#| checksum.writeUInt32BE(zlib.crc32(Buffer.concat([tag, packed])));
#| chunks[firstIdat] = Buffer.concat([size, tag, packed, checksum]);
#| return new Uint8Array(Buffer.concat([signature, ...chunks]));
#| } catch (_) { return new Uint8Array(0); }
#| }
///|
/// Difference hash, average luminance and coarse mean colors.
/// A close hash is only a review hint and never authorizes deletion.
pub fn analyze_image(data : Bytes, extension : String) -> (String, ImageInfo)? {
let bytes = if extension.to_lower() == ".png" {
png_stored_deflate(data)
} else {
data
}
let reader = @io.Buffer::from_bytes(bytes)
let (image, error) = if extension.to_lower() == ".png" {
@png.decode(reader)
} else if extension.to_lower() == ".jpg" || extension.to_lower() == ".jpeg" {
@jpeg.decode(reader)
} else {
return None
}
if error is Some(_) {
return None
}
let bounds = image.bounds()
let width = bounds.dx()
let height = bounds.dy()
if width < 1 || height < 1 {
return None
}
let bits = StringBuilder()
let samples : Array[UInt32] = []
let mut gray_sum = 0U
let mut red_sum = 0U
let mut green_sum = 0U
let mut blue_sum = 0U
for row = 0; row < 8; row = row + 1 {
let y = bounds.min.y + (row * height / 8).min(height - 1)
let mut previous = 0U
for col = 0; col < 9; col = col + 1 {
let x = bounds.min.x + (col * width / 9).min(width - 1)
let (red, green, blue, _) = image.at(x, y).rgba()
let gray = (red * 299U + green * 587U + blue * 114U) / 257000U
if col < 8 {
samples.push(gray)
gray_sum = gray_sum + gray
red_sum = red_sum + red / 257U
green_sum = green_sum + green / 257U
blue_sum = blue_sum + blue / 257U
}
if col > 0 {
bits.write_char(if previous > gray { '1' } else { '0' })
}
previous = gray
}
}
let average = gray_sum / 64U
for sample in samples {
bits.write_char(if sample >= average { '1' } else { '0' })
}
for color in [red_sum / 1024U, green_sum / 1024U, blue_sum / 1024U] {
for level = 0; level < 16; level = level + 1 {
bits.write_char(
if level < color.reinterpret_as_int() {
'1'
} else {
'0'
},
)
}
}
Some(
(
bits.to_string(),
{
width,
height,
luminance: (gray_sum / 64U).reinterpret_as_int(),
red: (red_sum / 64U).reinterpret_as_int(),
green: (green_sum / 64U).reinterpret_as_int(),
blue: (blue_sum / 64U).reinterpret_as_int(),
},
),
)
}
///|
/// Build the 176-bit image signature when the input is a supported decodable image.
pub fn fingerprint(data : Bytes, extension : String) -> String? {
match analyze_image(data, extension) {
Some((signature, _)) => Some(signature)
None => None
}
}