Check if file is valid (A)PNG image

(c) Copyright, all rights reserved

by PhilQ

HTML

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/all.min.css">
<label for="file">
	<input type="file" id="file" class="visually-hidden" accept="image/*" />
	<p class="pick">
		<i class="fa-solid fa-folder"></i>
		<i class="fa-regular fa-folder-open"></i>
		<span>Pick an image</span>
	</p>
	<p class="is-png">
		<i class="fa-solid fa-file-image"></i>
		<!-- <i class="fa-solid fa-image"></i> -->
		<span>filename.png</span>
	</p>
	<p class="is-apng">
		<i class="fa-solid fa-file-image"></i>
		<!-- <i class="fa-solid fa-film"></i> -->
		<span>filename.apng</span>
	</p>
	<p class="not-png">
		<i class="fa-solid fa-file"></i>
		<span>filename.ext</span>
	</p>
</label>

<div class="wrapper">
	<div class="col-left">
		<ul id="info">
			<!--
			<li><i class="fa-solid fa-arrows-left-right" title="Width"></i>- px</li>
			<li><i class="fa-solid fa-arrows-up-down" title="Height"></i>- px</li>
			-->
			<li><i class="fa-solid fa-ruler-combined" title="Dimensions - Width x Height"></i> </li>
			<li><i class="fa-solid fa-palette" title="Color type"></i>-</li>
			<li><i class="fa-solid fa-droplet" title="Bit depth"></i>- bit</li>
			<li><i class="fa-solid fa-film" title="Frames"></i>- frames</li>
			<li><i class="fa-solid fa-file-zipper" title="Compression method"></i>Deflate</li>
			<li><i class="fa-solid fa-filter" title="Filter method"></i> </li>
			<li><i class="fa-solid fa-shuffle" title="Interlace method"></i> </li>
		</ul>
		<ul id="refs">
			<li><a target="_blank" rel="noreferrer noopener" href="https://www.w3.org/TR/png/"><i class="fa-solid fa-file-lines"></i>(A)PNG specification</a> <sup>latest draft</sup></li>
			<li><a target="_blank" rel="noreferrer noopener" href="http://www.libpng.org/pub/png/spec/iso/index-object.html"><i class="fa-solid fa-file-circle-check"></i>PNG specification</a> <sup>2nd edition</sup></li>
			<li><a target="_blank" rel="noreferrer noopener" href="https://wiki.mozilla.org/APNG_Specification"><i...

SCSS

*, ::before, ::after {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
}

@import url('https://fonts.cdnfonts.com/css/cascadia-code');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,600;1,600&family=Source+Sans+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');

html {
	width: 100%;
	height: 100%;
}

body {
	display: flex;
	flex-direction: column;
	gap: 4em;
	width: 100%;
	min-height: 100%;
	font-family: 'Source Code Sans', 'Segoe UI', Tahoma, Geneva, Verdana,
    sans-serif;
	background: #f1f1f1;
	padding: 4em;
}

code {
	font-family: 'Cascadia Code', sans-serif;
	// font-family: 'JetBrains Mono', monospace;
}

/* See: https://www.a11yproject.com/posts/how-to-hide-content/ */
.visually-hidden {
	/*
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  width: 1px;
  height: 1px;
  overflow: hidden;
	*/
  position: absolute;
	width: 300%;
	height: 300%;
	opacity: 1;
	top: -200%;
	left: -200%;
	cursor: pointer;
	z-index: 1;
	outline: none;
}

label {
	position: relative;
	display: block;
	width: 100%;
	max-width: 450px;
	margin: 0 auto;
	background: #fff;
	border-radius: 0.5em;
	cursor: pointer;
	height: 6rem;
	font-size: 1.75em;
	outline: none;
	-webkit-user-select: none; /* Safari */
  -ms-user-select: none; /* IE 10 and IE 11 */
  user-select: none; /* Standard syntax */
	overflow: hidden;

	p {
		display: none;
		position: relative;
		width: 100%;
		height: 100%;
		align-items: center;
	}
	
	i {
		padding-left: 0.7em;
		font-size: 2.5rem;
		color: #ccc;
		flex-basis: 6rem;
		flex-shrink: 0;
	}
	
	span {
		font-weight: bold;
		color: hsl(216, 10, 40);
		overflow: hidden;
		white-space: nowrap;
		text-overflow: ellipsis;
		padding-right: 1em;
		padding-bottom: 0.15em;
	}
	
	&:focus-within, &:hover {
		i {
			color: hsl(216, 80, 75);
		}
		
		span {
			color: hsl(216, 80, 40);
		}
	}

	&:not(.is-png):not(.is-apng):not(.not-png) {
		display: flex;

		.pick {
			display: flex;
		}
		
		.pick .fa-folder-open...

TypeScript

// Decimal <-> Hex
const d2h = (d: number, l = 2): string => d.toString(16).padStart(l, '0')
const h2d = (h: string): number => Number.parseInt('0x' + h)

// Decimal <-> Binary
const d2b = (d: number, l = 2): string => d.toString(2).padStart(l, '0')
const b2d = (b: string): number => Number.parseInt('0b' + b)

// Decimal <-> ASCII
const d2a = (d: number): string => String.fromCharCode(d)
// const a2d = (a: string): number => a.charCodeAt(0)

// Hex <-> Binary
// const h2b = (h: string, l: number = 0): string => d2b(h2d(h), l || h.length);
// const b2h = (b: string, l: number = 0): string => d2h(b2d(b), l || b.length);

/*
  PNG specs
	see: http://www.libpng.org/pub/png/spec/iso/
	see: http://www.libpng.org/pub/png/spec/1.2/

	APNG specs
	see: https://wiki.mozilla.org/APNG_Specification
	see: https://en.wikipedia.org/wiki/APNG
*/

const PNG_SIGNATURE_LENGTH = 8
const PNG_CHUNK_OVERHEAD_LENGTH = 4 + 4 + 4 // length, type and crc
const APNG_FRAME_CHUNK_TYPES = ['fcTL', 'IDAT', 'fdAT'] as const

type ReadPNGOptions = {
  onRead?: (state: {
    chunk: ChunkInfo
    data: Uint8Array
    offsetAfter: number
    png: PNGInfo
  }) => void
  forceWithData?: boolean
  forceWithCRC?: boolean
}

type PNGInfo = {
  width: number
  height: number
  color_type: number
  bit_depth: number
  animated: boolean
  frames: number
  thumbnail: boolean
  count: {
    [chunkType: string]: number
  }
  valid: {
    [checkNameOrChunkType: string]: boolean | undefined
    dimensions?: boolean
    criticalChunks?: boolean
    frameCount?: boolean
  }
  loops?: number
  isValid: boolean
}

type ChunkInfo = {
  length: number
  type: {
    h: string
    b: string
    a: string
  }
  data: Uint8Array | null
  crc: number
  content?: {
    [key: string]: string | boolean | number
  }
}

// type ChunkInfoWithData = ChunkInfo & {
//   data: Uint8Array
// }

type ChunkInterpreter = {
  callback: (chunk: ChunkInfo, png: PNGInfo) => void
  needsData: boolean
  needsCRC: boolean
}
// | {
//    ...