유니코드 점자를 이용한 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로 나누는지는 위에 설명해뒀으니 알고 있겠징!
그리고 커서를 올려주는 건 쉽다. nodejs
는 process.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 용 미니 게임이나.. 다마고치라도 만들어 볼까....