유니코드 점자를 이용한 CLI 도트 그래픽 만들기

개요

팀원들과 이야기하다가 누군가가 'CLI용 툴 만들때 아스키아트로 회사 로고라도 나오게 할까 했는데 귀찮아서 관뒀다' 라는 말을 했다. 그걸 듣고 아스키 아트로 회사 로고를 그려보려고 했는데 너무 힘들고 잘 되지도 않아서 괴로워 하다가 '유니코드에 있는 점자를 가지고 그림을 보여주면 되지 않을까?' 로 발상을 바꿨다.

그래서 막상 하다보니 생각보다 재밌는 결과물이 나와서 대충 적어본다.

픽셀 버퍼 만들기

우선 화면에 그려질 그림을 담을 픽셀 버퍼를 만들어야 한다. 간단한 클래스를 만들어보자.

class DotDisplay {
	constructor(width, height) {
		this.width = width;
		this.height = height;
		this.buf = new Array(width * height); // 픽셀 정보들을 담을 배열
	}

	clear() {
		this.buf.fill(0);
	}

	put(x, y, color) { // color: 0 = empty, 1 = dot
		if ( x < 0 || x >= this.width || y < 0 || y >= this.height ) return; // clipping
		x = x >> 0;
		y = y >> 0;
		this.buf[y * this.width + x] = color;
	}
}

보다시피 단순하게 버퍼를 생성하고 put 을 호출해서 특정 위치에 점을 찍는 클래스다.

점만 찍으면 불편하니까 선을 그리거나 박스를 그리는 메소드도 추가해보자.

// DotDisplay 에 추가

// 선긋는 함수
line(x0, y0, x1, y1, color) {
	x0 >>= 0;
	y0 >>= 0;
	x1 >>= 0;
	y1 >>= 0;

	let dx = Math.abs(x1 - x0);
	let dy = Math.abs(y1 - y0);

	let sx = (x0 < x1) ? 1 : -1;
	let sy = (y0 < y1) ? 1 : -1;

	let err = dx - dy;

	const len = (dx * dx) + (dy * dy);

	while(true) {
		const g = (Math.pow(x0 - pt1[0], 2) + Math.pow(y0 - pt1[1], 2));
		this.put(x0, y0, color);
		if ((x0 === x1) && (y0 === y1)) break;
		const e2 = 2 * err;
		if (e2 > -dy) { err -= dy; x0 += sx; }
		if (e2 < dx ) { err += dx; y0 += sy; }
	}
}

// 박스 칠하는 함수
fillRect(x, y, w, h, color) {
	x >>= 0;
	y >>= 0;
	w >>= 0;
	h >>= 0;

	for(let i = 0; i < w; i++) {
		for(let j = 0; j < h; j++) {
			this.put(i + x, j + y, color);
		}
	}
}

아직 화면에 표시하는 부분이 없어서 이게 뭥미? 싶겠지만 우선 여기까지 하고 다음으로 넘어가자능..

점자 코드 분석

유니코드 점자는 기본적으로 6점 점자를 기반으로 해서 8점 점자에도 대응하기 위해 아래에 점 두개를 추가한 형태다. 따라서 글자 하나당 점 8개, 2x4 영역의 픽셀을 표현할 수 있다는 이야기다. 이 점자들의 코드는 아래와 같다.

    0 1 2 3 4 5 6 7 8 9 A B C D E F
0 ~ 15 U+2800
16 ~ 31 U+2810
32 ~ 47 U+2820
48 ~ 63 U+2830
64 ~ 79 U+2840
80 ~ 95 U+2850
96 ~ 111 U+2860
112 ~ 127 U+2870
128 ~ 143 U+2880
144 ~ 159 U+2890
160 ~ 175 U+28A0
176 ~ 191 U+28B0
192 ~ 207 U+28C0
208 ~ 223 U+28D0
224 ~ 239 U+28E0
240 ~ 255 U+28F0

(출처: https://johngrib.github.io/wiki/braille-pattern-chars/ )

점이 8개이므로 쉽게 생각할 수 있듯이 각 점이 한 바이트의 한 비트에 대응된다. 대응관계는 위 출처에 나와있는 것 처럼 아래와 같다고 한다.

1 8
2 16
4 32
64 128

따라서 우리는 필요한 지점에 점을 찍기 위해서 적절한 자리의 비트를 세팅해주기만 하면 되는것! 이걸 이용해서 출력용 함수를 만들어 보자.

우선 위 비트 위치를 미리 정의해두고

const dotPosition = [1, 8, 2, 16, 4, 32, 64, 128];

// DotDisplay 에 추가
render() {
	// 글자당 2*4 픽셀이지만 줄간격이 조금 있으므로 세로는 5 픽셀을 한 글자로 취급하자. 줄 사이에 있는 픽셀은... 아쉽지만 포기하는걸로.
	const xChunks = Math.ceil(this.width / 2);
	const yChunks = Math.ceil(this.height / 5);

	const lines = []; // 만들어진 문자열들을 담을 배열
	let line, ch; 

	for(let y = 0; y < yChunks; y++) {
		line = []; // 우선 한 줄을 초기화하고
		for(let x = 0; x < xChunks; x++) {
			ch = 0; // 한 글자를 초기화

			const sx = x * 2;
			const sy = y * 5;

			const xlimit = Math.min(2, this.width - sx);
			const ylimit = Math.min(4, this.height - sy);

			for(let i = 0; i < xlimit; i++) {
				for (let j = 0; j < ylimit; j++) {
					const idx = (j + sy) * this.width + (i + sx);
					const cl = this.buf[idx];
					if (cl) {
						ch |= dotPosition[i + (j*2)]; // 필요한 위치의 비트를 1로 세팅하고
					}
				}
			}

			line.push(String.fromCharCode(ch + 0x2800)); // 유니코드 점자의 시작 코드를 더해주면 원하는 점자 문자를 얻어올 수 있따!
			ch = 0;
		}
	  lines.push(line.join(''));
	}
	return lines.join('\n');
}

여기까지 작업했으면 기본적인 사용이 가능한 디스플레이가 된다. 대충 아래처럼 이것저것 그리면서 놀아보자.

See the Pen dotDisplay1 by LazyGyu (@lazygyu) on CodePen.

디더링

점자 문자가 on/off 의 흑백이라서 어쩔 수 없지만 아무래도 단색으로만 뭘 그리다 보면 사용하기가 어렵다.

흑백 인쇄 업계나, 옛날에 화면에 사용할 수 있는 색상이 부족하던 시절부터 전해져 내려오는 망점 기법, 디더링을 도입해보자. 점묘화라던지, 어릴때 미술시간에 배운 '병치 혼합'을 떠올리면 된다. 그냥 화면에 점을 듬성듬성 찍어서 농도를 표현하자 뭐 이런 이야기다.

순서 디더링 원리

사실 뭐 원리랄 것도 없지만.. 위키피디아 문서를 보자.

친절하게도 위키피디아 문서에 거의 모든 구현이 다 되어있다!! 인터넷은 정보의 바다라더니 정말이지...호호호

코드

우선 위 위키피디아에 있던 표를 배열로 옮겨준다. 2x2 는 너무 작고 8x8은 너무 커서 귀찮으니까 우리는 4x4 디더링 패턴을 사용해보자. 4x4 패턴을 사용하면 화면에 총 16+1색(?)을 사용할 수 있게 된다.

const ditherMap = [
    [0, 8, 2, 10],
    [12, 4, 14, 6],
    [3, 11, 1, 9],
    [15, 7, 13, 5],
];

좌표에 따라 디더링을 판별해주는 함수도 하나 만들자.

function dithering(x, y, color) {
    return ditherMap[y % 4][x % 4] < color;
}

그리고 그 다음에는 render 메소드를 해당 디더링 패턴에 대응되도록 조금 고쳐주면 된다. 아래의 조건문을

	if(cl) {

아래처럼 고쳐주자

	if(cl && dithering(i + sx, j + sy, cl)) {

그럼 이제 정말 디더링이 되는지 확인해볼깡?

See the Pen dotDisplay2 by LazyGyu (@lazygyu) on CodePen.

CLI 에서 사용하기

위 포스팅에서는 웹페이지에서 결과를 보여주기 위해 pre 엘리먼트에 출력했지만 당연히 렌더링 결과물이 평범한 string 이기 때문에 CLI 에서도 쉽게 사용이 가능하다. CLI 에서 화면에 출력하는건 그냥 console.log를 사용하면 되긴 하지만, 포스팅 맨 위에 있는 것처럼 지속적으로 화면을 갱신하려면 단순히 console.log만으로는 곤란하다. 한 번 출력할 때마다 커서를 다시 위로 옮겨줄 필요가 있다.

커서를 위로 올려주기 위해서는 우선 '몇 줄이나 올려야 하는가' 를 알아야 할 필요가 있다. 우리는 DotDisplay 생성시에 가로 세로 사이즈를 지정해주니까 거기에서 총 라인 수를 계산해두자.

// DotDisplay 생성자 내에 추가
this.lines = Math.ceil(this.height / 5); // 왜 5로 나누는지는 위에 설명해뒀으니 알고 있겠징!
this.columns = Math.ceil(this.width / 2); // 왜 5로 나누는지는 위에 설명해뒀으니 알고 있겠징!

그리고 커서를 올려주는 건 쉽다. nodejsprocess.stdout 이라는 객체로 표준 출력에 대한 인터페이스를 제공하는데, 여기에는 고맙게도 moveCursor(dx, dy) 라는 함수가 제공된다. 따라서 단순히

process.stdout.moveCursor(-dot.columns, -dot.lines);

만 해줘도 출력하는 첫 위치로 되돌아갈 수 있다.

const dot = new DotDisplay(30, 30);
let frame = 0;

function update() {
	setTimeout(update, 1000 / 60);

	dot.clear();
	const color = Math.round(Math.sin(((frame % 33) / 32) * Math.PI)* 16);
	dot.fillRect(0, 0, 30, 30, frame % 17);

	console.log(dot.render());
	process.stdout.moveCursor(-dot.columns, -dot.lines);

	frame++;
}

update();

위와 같은 코드를 짜서 실행하면 아래와 같은 결과를 볼 수 있다!

결론

그래서 이걸로 뭘 하면 좋겠냐고 물어보면 사실 전혀 쓸모가 없다.......... 시간이 남아돌면 이걸로 CLI 용 미니 게임이나.. 다마고치라도 만들어 볼까....