지원한 회사의 과제로 아스키 아트를 PNG로 만드는 문제가 나왔다.
예전에 컴퓨터비전 수업을 들을 때 openCV로 사진을 만드는 과제를 해본적 있었기에 막연히 비슷하겠지 했는데, 제약조건이 "다른 어떠한 라이브러리를 사용하지 않고 구현" 하기였다.
이때부터 살짝 멘붕이 왔다. png 구성을 대략 찾아보니까, 내 힘만으로는 정말 만들기 힘들었다. 게다가 난 타입스크립트를 제대로 써본지가 오래되기도 했고...
일단 PNG의 구성요소를 확인해보자. 이 블로그에서 큰 도움을 받았다.
가장 먼저, PNG는 8바이트의 시그니처를 갖는다.
89 50 4E 47 0D 0A 1A 0A
이 시그니처를 보고 이 파일이 PNG 파일임을 알 수 있다.
이 16진수들을 10진수로 만들어서 버퍼로 만들어주자
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
이후, PNG 파일은 여러개의 청크로 이루어져 있다. 그 중 필수적인 3개의 청크(IHDR, IDAT, IEND) 가 있다.
그리고 각 청크는 청크의 길이 (length), 타입 (이 청크가 IHDR인지? IDAT인지?), 데이터, 그리고 이 청크의 무결성을 검증해줄 CRC라는 체크섬이 있어야한다. 데이터 이외에는 항상 4byte 길이여야 한다.
// PNG 청크(Chunk) 생성기
// PNG 청크는 length, type, data, crc가 순서대로 합쳐진 구조여야 함
function createChunk(type: string, data: Buffer): Buffer {
// length 만들기
const lengthBuffer = Buffer.alloc(4); // 4바이트짜리 버퍼
lengthBuffer.writeUInt32BE(data.length, 0); // PNG 표준 방식인 빅 엔디안으로 data의 길이 저장
// type 만들기
const typeBuffer = Buffer.from(type, "ascii"); // type을 4바이트 아스키 버퍼로 변환
// crc 만들기
const crcInput = Buffer.concat([typeBuffer, data]); // type와 data 버퍼를 합치기
const crc = calculateCrc32(crcInput); // CRC 값 계산 -> 이 값이 온전한지 확인하는 체크섬을 만들기 위해
const crcBuffer = Buffer.alloc(4); // 4바이트 버퍼
crcBuffer.writeUInt32BE(crc, 0); // 빅 엔디안 방식으로 write
return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]); // 하나의 버퍼로 합쳐서 반환
}
요런식으로 만들어주면 되는데, CRC 체크섬 값은 또 계산식이 따로 있다. 계산식은 여기에 적혀있는데, 이것을 그대로 타입스크립트로 옮겨놨다.
// 체크섬 계산을 위한 CRC-32 조회 테이블
const crcTable = new Uint32Array(256).map((_, i) => {
// 0부터 255까지 미리 만들어놓기
let c = i;
for (let k = 0; k < 8; k++) {
// 8비트
// 1칸 오른쪽으로 밀면서 검사 -> 가장 오른쪽 비트(c & 1)만 검사하기 위해
// 가장 오른쪽 비트가 1이라면 표준 다항식 값과 XOR 연산하기
// 표준 다항식 값은 ISO/IEC 3309에 정의
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
return c;
});
// 체크섬 계산
// 각 청크 끝에 붙는 4바이트짜리 무결성 검사 값을 만들 때 사용함
function calculateCrc32(buffer: Buffer): number {
let crc = -1; // CRC32 계산 시작 표준 초기값, 0xFFFFFFFF
for (let i = 0; i < buffer.length; i++) {
let index = (crc ^ buffer[i]) & 0xff; // 현재 CRC값과 buffre[i]를 xor 연산 후 마지막 1바이트만 골라낸다 -> crc 테이블의 인덱스로 사용
crc = (crc >>> 8) ^ crcTable[index]; // crc 테이블에서 미리 계산된 값을 가져온 후, crc 값을 8비트 시프트한 값이랑 XOR 연산
}
return (crc ^ -1) >>> 0; // CRC-32 표준 규격 마무리 작업, 음수가 되더라도 >>> 0으로 양수로 반환
}
그러면 이제 청크를 하나씩 만들어주자
IHDR
이미지의 메타데이터가 담겨있는 청크이다. 이 청크는 총 13바이트로 이루어져 있는데, 다음과 같은 데이터를 담아두어야 한다.
| Byte | data |
| 4 | 이미지의 가로 픽셀 수 |
| 4 | 이미지의 세로 픽셀 수 |
| 1 | Bit depth |
| 1 | Color type |
| 1 | Compression method |
| 1 | Filter method |
| 1 | Interlace method |
가로 픽셀 수와 세로 픽셀 수는 말 그대로이므로 넘기고, 나머지 정보에 대해 알아보자
- Bit depth : "색상 채널 하나당 몇 비트를 사용할 것인지"를 정한다. 허용되는 값은 {1, 2, 4, 8, 16} 이다.
1비트로는 2가지 값을 나타내니까 흑백 단 두개만을 나타낼 수 있고, 8비트로는 256단계, (0~255)까지의 음영을 나타낼 수 있다. 16비트는 2^16의 단계로 그라데이션을 나타낼 수 있다. - Color type : 어떤 색상 채널을 사용할지를 결정한다. 허용되는 값은 {0, 2, 3, 4, 6}이다.
0은 그레이스케일, 2는 RGB, 3은 인덱스 컬러, 4는 알파 채널 그레이스케일, 6은 RGBA이다. - Compression method : 압축 방식을 정의하는 바이트, 표준 정의 방식으로 0을 준다.
- Filter method : 필터링 방식을 지정한다. 표준 정의 방식으로 0을 준다.
- Interlace method : 웹에서 이미지 로딩이 완료되기 전 해상도가 낮은 이미지를 보여줄 때 사용되는 방식을 지정한다. {0, 1}을 사용 가능하며, 0은 No interlace, 1은 Adam7 interlace이다.
// IHDR 청크
// 이미지의 메타데이터가 담김
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(width, 0); // 이미지의 가로 픽셀 수
ihdrData.writeUInt32BE(height, 4); // 이미지의 세로 픽셀 수
ihdrData.writeUInt8(8, 8); // 픽셀 당 비트
ihdrData.writeUInt8(0, 9); // Color type (0 = 그레이스케일)
ihdrData.writeUInt8(0, 10); // 압축 방식
ihdrData.writeUInt8(0, 11); // 필터 방식
ihdrData.writeUInt8(0, 12); // 인터레이스 (표준값)
const ihdrChunk = createChunk("IHDR", ihdrData); // 조립하기
13바이트짜리 버퍼를 미리 만들어두고, 위 표를 보고 그대로 지정한다.
PNG 파일 명세는 모든 4바이트 숫자를 반드시 Big endian으로 저장하도록 강제하기 때문에, 4바이트를 저장해야하는 가로, 세로 픽셀 수는 writeUInt32BE 를 사용하도록 한다.
과제 요구사항에서는 그레이스케일, 0~255의 밝기를 사용하도록 요구하고 있기 때문에 bit depth를 8, color type을 0, 압축 방식과 필터 방식, 인터레이스는 모두 표준값 0으로 맞춰준다.
이후에 이 데이터를 가지고 createChunk를 통해 청크를 만들어주자.
IDAT
IDAT 청크는 실제로 이미지 데이터가 들어가는 청크이다. 원본이 필터링과 압축 이후에 저장된다.
그 전에, PNG는 데이터를 읽을 때, 가로 한줄씩 데이터를 읽는데 이를 스캔라인이라고 한다.
그리고 각 스캔라인마다 맨 앞에 1바이트짜리 필터 타입 바이트를 추가해주어야 한다.
우리는 아까 필터 타입을 0으로 설정하였으므로 0을 추가해주면 된다.
// PNG는 이미지를 가로 한줄씩 (스캔라인) 처리
// PNG는 각 스캔라인마다 1바이트짜리 필터 타입 바이트를 추가해야 함
function createScanlineBuffer(
pixels: number[],
width: number,
height: number
): Buffer {
const buffer = Buffer.alloc((width + 1) * height); // 필터 타입 바이트가 필요하므로 width + 1
let pixelIndex = 0;
for (let y = 0; y < height; y++) {
const lineStartIndex = y * (width + 1); // 새로운 스캔라인
buffer.writeUInt8(0, lineStartIndex); // Filter Type 0 추가
for (let x = 0; x < width; x++) {
buffer.writeUInt8(pixels[pixelIndex], lineStartIndex + 1 + x); // 필터 타입 파이트 + 1 부터 원본 스캔라인 픽셀 데이터 복사
pixelIndex++;
}
}
return buffer;
}
먼저 각 row마다 바이트 하나씩 추가해야하니까, (width + 1) * height 만큼의 버퍼를 할당한 다음, 각 row마다 맨 앞에 0을 추가해주고 원본 데이터를 순회하며 복사해준다.
const scanlineData = createScanlineBuffer(pixels, width, height); // 필터 타입을 추가한 스캔라인 데이터
const compressedData = zlib.deflateSync(scanlineData); // 픽셀 데이터 압축
const idatChunk = createChunk("IDAT", compressedData); // 조립하기
그러면 이제 위에서 본 createScanlineBuffer를 이용해 스캔라인을 만들고, 이를 기본 제공되는 zlib를 사용해 압축하고, 이를 IDAT 청크로 만들어준다.
필터링이 어떻게 되는지 계속 찾아보는데... 아 정말 어렵다. 뭔소리인지 영 이해가 안된다. 자세히 알고 싶다면 이 블로그를 참고하자.
IEND
PNG 파일의 끝을 알려주는 청크이다. 데이터를 담지 않으므로 length는 항상 0이다.
// IEND 청크
// 파일의 끝을 알려주는 청크
const iendChunk = createChunk("IEND", Buffer.alloc(0)); // 0바이트짜리 빈 버퍼로 사용
요고 하나면 끝!!
여기까지 만들었다면 시그니처, IHDR, IDAT, IEND 청크를 하나로 모아 파일로 출력하기만 하면 끝!!
// 모든 버퍼 조립
const finalPngBuffer = Buffer.concat([
pngSignature,
ihdrChunk,
idatChunk,
iendChunk,
]);
// 파일 쓰기
const outputFilePath = path.join(baseDir, "output", "image.png");
await fs.writeFile(outputFilePath, finalPngBuffer);
과제에는 이것 외에도 여러가지 요구사항이 있었다.
파일을 읽어서 밝기로 변환하거나 하는 로직은 따로 작성하지는 않겠다.
여러가지로 머리아픈 과제였다.
'코딩' 카테고리의 다른 글
| Redis 자료구조와 사용방법 (0) | 2025.11.19 |
|---|---|
| 객체지향은 신이고 테스트는 무적이다 (2) | 2025.11.17 |
| 의존성 역전으로 테스트 편하게 만들기 !! (0) | 2025.11.08 |
| PG 결제 (0) | 2025.10.21 |
| 이분 탐색 (0) | 2025.10.10 |