본문 바로가기

PNG 변환기 만들기

@정소민fan2025. 10. 21. 19:49

지원한 회사의 과제로 아스키 아트를 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
정소민fan
@정소민fan :: 코딩은 관성이야

코딩은 관성적으로 해야합니다 즐거운 코딩 되세요

목차