. 가변폭 폰트 지원

설정 변경 기능이 작성되었으므로 이제 사용자들은 마음에 드는 글꼴과 크기를 선택할 수 있게 되었다. 그런데 이 기능이 들어감으로써 ApiEdit가 글꼴의 폭을 계산하는 방법에 문제가 있다는 것을 알게 되었다. 시스템 폰트만 쓸 때는 별 문제가 없지만 다양한 글꼴의 특성을 모두 만족시키려면 여분의 코드가 더 필요하다.

한글은 정사각형의 문자이기 때문에 모든 문자의 폭이 동일한 고정폭을 가진다. 즉 폭이 좁은 고, , 이 같은 글자와 좀 복잡하게 생긴 황, , , 뽕 이런 글자들의 폭이 같다. 하지만 조사 결과 음절별로 폭이 다르게 작성되어 있는 폰트가 있었다. 한글은 모두 고정폭이라는 고정관념을 가지고 있었는데 그렇지 않다는 것을 개발을 한참 진행하던 중에 알게 되었다. 대부분의 폰트는 고정폭이지만 매직체 같은 주로 장식성이 있는 폰트들이 가변폭으로 작성되어 있다. 이런 폰트는 속도나 효율보다 예쁜 글자 모양을 최우선시하기 때문에 음절별로 가장 예쁜 모양을 낼 수 있는 폭을 가진다.

ApiEdit는 지금까지 한글에 대해서 대표적으로 자의 폭만 조사해놓고 다른 글자도 이 폭과 같은 폭을 가지는 것으로 가정했다. 덕분에 굉장한 속도 향상 효과를 보기는 했지만 가변폭 폰트에서는 출력과 정렬이 정상적으로 이루어지지 않는다. 애초의 가정이 틀렸기 때문이다. 매직체로 글꼴을 변경해보면 어떤 문제가 있는지 금방 눈치챌 수 있다.

문자열이 창의 오른쪽 끝에 정렬되지 않았고 그보다 더 짧게 정렬되었는데 왜냐하면 문자의 폭이 과대 평가되어 있기 때문이다. 실제로는 15글자가 들어갈 수 있는데 12글자만 들어가는 것으로 잘못 계산했다. 과대 평가된 폭으로 정렬을 했지만 TextOut은 문자의 실제폭으로 출력하므로 불일치가 발생한다. 이 상태에서 블록을 선택해보면 선택된 블록이 뒤쪽으로 밀려 표시되는 것을 확인할 수 있는데 선택영역은 조각을 구성하며 앞쪽 문자폭의 합을 구해 이 좌표에 출력되기 때문이다. 캐럿의 위치도 문자의 폭을 합산하여 구하므로 정확하지 않다.

장식체의 특수한 글꼴뿐만 아니라 바탕, 굴림 등의 기본 폰트도 한글과 한자는 고정폭이지만 특수 문자나 외국어는 한글과 폭이 다르다는 것을 알았다. 이런 폰트들도 전각 문자나 그래픽문자는 글자의 폭이 조금 특수하며 더 심각한 문제는 문자가 매핑되어 있지 않은 잘못된 코드의 폭도 가변적이라는 것이다. 한글 윈도우즈에서 일본어나 중국어로 된 문서를 열면 제대로 보이지 않는 것은 당연한 일이겠지만 적어도 정렬과 출력은 제대로 해야 한다.

CD-ROM Text/가변폭.txt 파일은 전각문자, 외국어, 특수 기호 등의 가변폭 문자들로 작성된 문서이다. 이 문서를 열어 보면 폭이 작은 전각 문자로 인해 정렬과 출력 상태가 일치하지 않는 현상을 확인할 수 있다. 블록을 선택해보면 가변폭 폰트처럼 선택영역이 뒤로 밀리며 캐럿의 위치도 맞지 않다.

모든 한글 문자의 폭은 같지만 그래픽문자나 외국어는 한글 문자보다 폭이 좁다는 것을 확인할 수 있다. 홑따옴표나 중앙점 같은 아주 작은 글자와 위첨자, 아래첨자, 분수 같은 기호들의 폭은 한글보다 작다. 위 문서를 열어 놓은 채로 굴림체나 궁서체로 바꾸어 보면 글꼴마다 전각 문자의 폭이 완전히 제 각각이라 일반적인 법칙을 찾을 수가 없었다.

이 모든 문제들은 DBCS 문자의 폭이 가변이기 때문에 생긴 것들이다. 그래서 ApiEdit는 가변폭의 한글 폰트와 특수 문자, 외국어를 지원할 수 있도록 수정되어야 한다. 처음부터 폰트의 이런 특성을 알고 있었다면 설계할 때부터 가변 폰트를 지원할 수 있도록 했겠지만 공부가 부족해서 중간에 뜯어 고쳐야 하는 수고를 하게 되었다. 이런 경우를 보고 머리가 딸리면 손발이 고생한다고 하는데 역시 개발자는 공부를 게을리 해서는 안된다는 것을 느낄 수 있다.

문제의 핵심은 DBCS문자, 2바이트 문자의 폭도 개별적으로 다르기 때문에 자의 폭을 대표로 사용할 수 없다는 것이다. 각 문자에 대해 정확한 폭을 계산하도록 해야 한다. 이 문제를 해결하는 방법에는 2386가지나 있는데 여기서 몇 가지 방법들을 시도해보고 그 중에 가장 효율적인 방법을 선택할 것이다. 직접 실습을 해보면 좋겠지만 다소 귀찮은 면이 있으므로 지면의 코드를 읽어만 보도록 하자.

첫 번째 방법은 DBCS 문자에 대해서는 미리 계산된 폭을 사용하지 않고 문자를 정렬할 때 실제 폭을 계산하는 것이다. GetCharWidth 함수를 다음과 같이 수정해보자.

 

int CApiEdit::GetCharWidth(TCHAR *ch, int len)

{

     SIZE sz;

     HDC hdc;

     HFONT hFont, hOldFont=NULL;

     int code;

 

     if (len==1) {

          return arChWidth[*ch];

     } else {

          hdc=GetDC(hWnd);

          if (logfont.lfHeight != 0) {

              hFont=CreateFontIndirect(&logfont);

              hOldFont=(HFONT)SelectObject(hdc,hFont);

          }

          GetTextExtentPoint32(hdc,ch,2,&sz);

          if (hOldFont) {

              DeleteObject(SelectObject(hdc,hOldFont));

          }

          ReleaseDC(hWnd,hdc);

          return (BYTE)sz.cx;

     }

}

 

영문인 경우는 지금까지 하던 대로 arChWidth 배열에 미리 계산된 폭을 리턴했고 한글인 경우는 GetTextExtentPoint32 함수로 실시간으로 폭을 조사하여 리턴했다. 이렇게 하면 정확하게 정렬되고 정확하고 출력된다. 정확성을 위해 속도를 희생한 역최적화의 예인데 DC에 선택되어 있는 실제 폰트로부터 폭을 구했으니 틀릴 수가 없다.

그러나 보다시피 이 방법은 최적화를 하기 전보다 오히려 더 안 좋아졌으며 엄청나게 느리다. 글자 하나의 폭을 구하기 위해 DC를 얻고 폰트를 만들어 선택한 후 글자 폭을 계산하고 다시 폰트 삭제하고 DC를 해제하는 과정을 거쳐야만 한다. 100KB 크기의 문서를 정렬하자면 이 과정을 5만 번 반복해야 한다는 얘기인데 그러자면 프로그램은 가히 못 봐줄 지경이 되어 버릴 것이다. 폰트의 폭을 어떻게 구하는가에 따라 프로그램의 속도에 너무 많은 차이가 나기 때문에 이 방법은 쓰지 말아야 한다. 게다가 이 함수는 정렬, 출력, 이동 등 코드의 곳곳에서 반복적으로 불러대는 함수가 아닌가? 정확한 것도 좋지만 이 속도차를 도저히 용납할 수가 없다.

두 번째 방법은 가변폭 문자를 강제로 고정폭으로 바꾸어 출력하는 것이다. 문자의 실제폭을 무시하고 프로그램이 편리한대로 문자폭을 지정하는 방식이다. 가변폭일 때 TextOut으로 한꺼번에 출력하지 말고 한 글자씩 출력하되 문자폭만큼 글자를 강제로 띄어 쓰도록 하였다. DrawSegment에서 TextOut으로 문자열을 출력하지 않고 직접 문자폭을 계산하여 출력한다.

 

void CApiEdit::DrawSegment(HDC hdc, int &x, int y, int SegOff, int len, BOOL ignoreX,

     COLORREF fore, COLORREF back)

{

     if (buf[SegOff] == ‘\t’) {

          ....

     } else {

          SetTextColor(hdc,fore);

          SetBkColor(hdc,back);

          int toff;

          toff=SegOff;

          for (;;) {

              if (toff-SegOff==len) {

                   break;

              }

              if (IsDBCS(toff)) {

                   TextOut(hdc,x,y,buf+toff,2);

                   x+=GetCharWidth(buf+toff,2);

                   toff+=2;

              } else {

                   TextOut(hdc,x,y,buf+toff,1);

                   x+=GetCharWidth(buf+toff,1);

                   toff++;

              }

          }

          if (bShowSpace) {

              DisplaySpace(hdc,x,y,buf+SegOff,len);

          }

     }

     ....

 

각 문자를 하나씩 개별적으로 출력하되 모든 문자가 arHanWidth[0]의 폭을 가지는 것으로 계산했다. 자의 폭만큼 강제로 일정폭을 띄워 가면서 문자를 출력하는 것이다. 이렇게 하면 정상적으로 출력되며 캐럿도 제 위치를 찾는다. 적어도 정렬 결과와 출력 결과의 불일치는 사라졌다. 하지만 블록을 선택해보면 선택 블록 중간중간에 틈이 생긴다. 문자폭을 강제로 늘였기 때문에 실제 문자폭과의 차이만큼 틈이 생겨 보기에 과히 좋지 않다.

이 방법은 문제를 해결하기는 했지만 또 다른 문제를 야기했으며 이 부작용을 해결하려면 틈을 메워주는 여분의 코드를 더 작성해야 한다. 문자가 원래폭보다 과장되게 출력되어 있으므로 보기에도 좋지 않다. 이 방법이 굉장히 억지스럽고 이상하다고 생각될지 모르겠지만 상용 편집기 중에 이 방법을 쓰는 것들이 있으며 심지어 워드프로세서도 그런 종류가 있다.

세 번째 방법은 영문자에서와 마찬가지로 모든 DBCS 문자의 폭을 미리 구해놓고 이 폭을 사용하도록 하는 것이다. PrepareCharWidth에서 0x8000크기의 배열을 할당하고 이 배열에 미리 계산된 폭을 구해놓고 GetCharWidth에서는 이 배열에 미리 조사된 값을 리턴한다.

 

void CApiEdit::PrepareCharWidth(HDC hdc)

{

     int i;

     TCHAR Char[3]={0,};

     SIZE sz;

 

     for (i=1;i<128;i++) {

          Char[0]=i;

          GetTextExtentPoint32(hdc, Char, 1, &sz);

          arChWidth[i]=(BYTE)sz.cx;

     }

     arChWidth[0]=arChWidth[32];

 

     if (arHanWidth) {

          free(arHanWidth);

     }

     arHanWidth=(BYTE *)malloc(0x8000);

     for (i=0x8000;i<=0xffff;i++) {

          Char[0]=i >> 8;

          Char[1]=i & 0xff;

          GetTextExtentPoint32(hdc,Char,2,&sz);

          arHanWidth[i-0x8000]=(BYTE)sz.cx;

     }

}

 

int CApiEdit::GetCharWidth(TCHAR *ch, int len)

{

     int code;

 

     if (len==1) {

          return arChWidth[*ch];

     } else {

          code=*(BYTE *)ch << 8 | *((BYTE *)ch+1);

          if (code == 0x0d0a) {

              return 0;

          }

          return arHanWidth[code-0x8000];

     }

}

 

영문자의 폭을 미리 계산해놓는 방법과 완전히 동일하다. 다만 DBCS 문자는 개수가 무려 0x8000개나 되기 때문에 배열의 크기가 크고 그래서 정적배열 대신 동적배열을 사용한다는 정도만 다르다. 개행코드는 DBCS로 취급되지만 실제로는 폭을 가지지 않으므로 0을 리턴하도록 예외 처리했다. 이 방법은 32KB의 메모리를 더 쓰는 대신 미리 구해진 폭을 사용하므로 속도상으로 볼 때 가장 빠른 방법이다. DC를 얻거나 폰트를 생성하는 과정없이 곧바로 배열에서 문자폭을 구하므로 빠를 수밖에 없다. 하지만 메모리를 많이 쓴다는 것 외에도 아주 치명적인 약점이 있는데 문자폭을 구하는데 너무 많은 시간을 소모함으로써 ApiEdit의 초기화가 느려진다.

STQRTQ, ENDQ 매크로로 측정해보면 PrepareCharWidth 함수가 모든 문자의 폭을 구해 배열을 작성하는데 걸리는 시간이 1초 정도 되는데 이렇게 되면 새 편집창을 열거나 문서를 열 때 1초씩이나 기다려야 화면에 나타나게 된다. , 실행속도는 빨라졌지만 대신 반응 속도가 느려졌다. 메모리를 많이 차지하는 것은 큰 문제가 되지 않지만 준비 시간이 너무 오래 걸려서 이 방법도 쓸 수 없다.

네 번째 방법은 세 번째 방법의 변형인데 미리 문자의 폭을 다 구해놓지 말고 실시간으로 구하되 한 번 구한 문자의 폭을 캐시해두는 것이다. 다음과 같이 수정해보자.

 

void CApiEdit::PrepareCharWidth(HDC hdc)

{

     int i;

     TCHAR Char[3]={0,};

     SIZE sz;

 

     for (i=1;i<128;i++) {

          Char[0]=i;

          GetTextExtentPoint32(hdc, Char, 1, &sz);

          arChWidth[i]=(BYTE)sz.cx;

     }

     arChWidth[0]=arChWidth[32];

 

     if (arHanWidth) {

          free(arHanWidth);

     }

     arHanWidth=(BYTE *)malloc(0x8000);

     for (i=0;i<0x8000;i++) {

          arHanWidth[i]=0xff;

     }

}

 

int CApiEdit::GetCharWidth(TCHAR *ch, int len)

{

     SIZE sz;

     HDC hdc;

     HFONT hFont, hOldFont=NULL;

     int code;

 

     if (len==1) {

          return arChWidth[*ch];

     } else {

          code=*(BYTE *)ch << 8 | *((BYTE *)ch+1);

          if (code == 0x0d0a) {

              return 0;

          }

          if (arHanWidth[code-0x8000]==0xff) {

              hdc=GetDC(hWnd);

              if (logfont.lfHeight != 0) {

                   hFont=CreateFontIndirect(&logfont);

                   hOldFont=(HFONT)SelectObject(hdc,hFont);

              }

              GetTextExtentPoint32(hdc,ch,2,&sz);

              if (hOldFont) {

                   DeleteObject(SelectObject(hdc,hOldFont));

              }

              ReleaseDC(hWnd,hdc);

              arHanWidth[code-0x8000]=(BYTE)sz.cx;

          }

          return arHanWidth[code-0x8000];

     }

}

 

PrepareCharWidth에서는 0x8000크기의 배열을 할당하여 모두 0xff로 초기화하고 문자폭을 조사하지는 않았다. 0xff 값은 아직 이 코드의 문자가 조사되지 않았다는 뜻이다. 실제 문자폭은 GetCharWidth에서 조사하되 한 번 조사한 폭은 캐시에 기록해놓음으로써 같은 문자에 대해서는 두 번 폭을 조사하지 않도록 한다. 대응되는 문자폭이 0xff일 때만 직접 조사하고 그렇지 않을 때는 배열에 이미 조사해놓은 폭을 사용한다.

이렇게 하면 편집창을 만들 때 문자폭을 조사하는 것이 아니라 필요할 때마다 폭을 조사하므로 시작 속도가 빨라진다는 이점이 있다. 물론 실행 시간에는 처음 만나는 모든 문자폭을 조사하게 되므로 느려진다. 시작시의 부담이 실행시간으로 옮겨진 셈인데 두 방법의 조합을 취해보자. , 일부 자주 사용되는 문자에 대해서는 미리 폭을 조사해놓고 나머지 문자들은 실행 시간에 조사하도록 내버려 둔다.

그렇다면 어떤 문자들이 자주 사용되는지 조사 대상을 선정해야 하는데 이런 목적으로 문자코드와 문자를 조사해 볼 수 있는 ViewCode라는 예제를 만들었다.

이 예제는 한 번에 256개의 코드에 대해 문자코드와 폭, 높이를 보여주며 스크롤바로 페이지를 스크롤하면서 현재 폰트에 들어있는 모든 문자에 대한 정보를 보여준다. CD-ROM에 소스도 있으므로 관심있는 사람은 분석해보되 여기서는 사용만 하기로 한다. 이 예제로 한글 폰트를 분석해 본 결과는 다음과 같다. 이 표에서 코드는 모두 16진수이다.

 

코드

설명

a1a1~a2ff

그래픽 기호

a3a1~a3ff

전각 숫자 알파벳

a4a1~a4a1

한글 낱글자, 고어

a5a1~a5ff

그리스 문자

a6a1~a9ff

문자, 괄호 문자, 문자, 도량형 문자

aaa1~abff

일본어

aca1~acff

러시아어

b0a1~c8fe

완성형 한글

ca00~

한자

 

이 도표에서 보다시피 자주 사용하는 글자는 0xb0a1~0xc8fe 까지(~)의 완성형 한글뿐이며 그 외의 외국어나 특수 문자는 아주 가끔씩만 사용되므로 미리 조사해놓을 필요가 없다. 만약 일본어나 중국어를 지원하고자 한다면 자주 사용하는 코드가 달라질 것이다. 자주 사용하는 한글 문자에 대해서만 미리 폭을 조사하도록 PrepareCharWidth 함수를 다음과 같이 수정하자.

 

void CApiEdit::PrepareCharWidth(HDC hdc)

{

     int i;

     TCHAR Char[3]={0,};

     SIZE sz;

     int hi,low;

     BOOL Const=FALSE;

 

     for (i=1;i<128;i++) {

          Char[0]=i;

          GetTextExtentPoint32(hdc, Char, 1, &sz);

          arChWidth[i]=(BYTE)sz.cx;

     }

     arChWidth[0]=arChWidth[32];

 

     if (arHanWidth) {

          free(arHanWidth);

     }

     arHanWidth=(BYTE *)malloc(0x8000);

     for (i=0;i<0x8000;i++) {

          arHanWidth[i]=0xff;

     }

 

     for (hi=0xb000;hi<=0xc800;hi+=0x100) {

          for (low=0xa1;low<=0xfe;low++) {

              Char[0]=hi >> 8;

              Char[1]=low;

              if (Const==TRUE) {

                   sz.cx=arHanWidth[0xb0a1-0x8000];

              } else {

                   GetTextExtentPoint32(hdc,Char,2,&sz);

              }

              arHanWidth[hi+low-0x8000]=(BYTE)sz.cx;

              if (hi==0xb000 && low==0xfe) {

                   for (i=0xb0a1,Const=TRUE;i<=0xb0fe;i++) {

                        if (arHanWidth[i-0x8000] != arHanWidth[0xb0a1-0x8000]) {

                            Const=FALSE;

                        }

                   }

              }

          }

     }

}

 

모든 완성형 한글 문자에 대해 폭을 조사하되 시간을 아끼기 위해 첫 페이지만 일단 조사해보고 첫 페이지의 문자들이 모두 폭이 같으면 나머지 문자의 폭도 같은 것으로 가정하였다. 즉 적당히 폭을 구해보다가 계속 같은 폭이 반복되면 뒷부분의 문자도 모두 같겠거니 하고 생각하는 것이다. 이 가정은 완벽하게 안전하지는 않으므로 일종의 꽁수라고 할 수 있다. 하지만 적어도 한글 윈도우즈에서는 별 문제가 없는 것 같다.

이 코드를 STARTQ, ENDQ 매크로로 측정해보면 0.0004~0.007초 정도밖에 걸리지 않는다. 이 함수는 hdc를 인수로 전달받으며 이 인수에 이미 폰트가 선택되어 있기 때문에 조사 속도가 훨씬 더 빠르다. ApiEdit는 최종적으로 이 코드를 채택하였다.

앞에서 가변폭 폰트 문제를 해결하는 방법에는 2386가지 방법이 있다고 했는데 실제로 세어 본 것은 아니지만 그렇다고 농담은 아니다. 가변폭 폰트를 지원하기 위한 방법은 여러 가지가 있고 각 방법의 조합을 취하면 정말로 그 정도의 해결책이 나올 수 있다. 그만큼 이 문제는 프로그램의 상황과 목적에 따라 해결 방법이 다양할 수 있다는 것이다.

여기서 시도해보지는 않겠지만 가변폭 폰트를 지원하는 다른 방법들에 대해서도 생각해보자. ApiEdit는 문자폭 저장을 위해 배열을 사용하되 매번 조사하지 않도록 하기 위해 캐시를 사용하는데 캐시를 작성하는 방법에도 여러 가지가 있다. 캐시 크기의 비대화를 방지하기 위한 여러 가지 지능적인 방법을 도입할 수 있는데 각 문자의 폭을 배열에 작성하지 않고 각 폭의 문자 그룹을 배열에 작성한다거나 한 글자의 폭을 조사할 때 글자가 포함된 페이지단위로 한꺼번에 캐시를 작성할 수도 있다. 한 번 사용된 글자의 주변 글자들이 요청될 확률이 높다는 것에 근거하면 이 방법은 확실히 효과가 있는 방법이다. 매 글자마다 DC할당, 해제를 반복하지 않으므로 그만큼 시간이 절약된다.

현재 구조에서는 ApiEdit 컨트롤 하나가 자신의 문자폭 저장을 위해 32KB의 메모리를 독점적으로 사용하고 있는데 이 메모리를 하나로 합칠 수도 있다. arHanWidth 배열을 정적 멤버로 만들고 모든 ApiEdit객체가 공유하도록 한다면 캐시의 효율도 좋아지고 메모리도 절약할 수 있다. , 당근은 개별 편집창에 대해서 글꼴을 변경하는 것을 허용하기 때문에 Option의 폰트와 다른 글꼴을 가지는 ApiEdit에 대한 예외 처리를 해야 한다.

ApiEdit가 채택한 방법은 분명히 최선의 방법은 아니며 꽁수까지 곁들였다. 아직 찾지는 못했지만 속도와 크기를 동시에 만족시킬 수 있고 출력 모양도 예쁜 다른 방법이 분명히 있을 것이다. 최선책을 찾는 데는 실패했지만 그렇다고 해서 최선책을 찾을 때까지 프로젝트를 진행시키지 않을 수는 없는 노릇이다. 비록 차선책이지만 일단 문제는 해결했으므로 계속 다음 작업을 진행하는 것이 현명한 처사라 생각한다.