. 줄 끝 위치 찾기

그럼 이제 새로 만든 ApiEdit2프로젝트에 정렬 기능을 실제로 구현해보도록 하자. 먼저 다음 전역변수를 추가한다.

 

int nWrap;

 

이 변수는 어떤 정렬방식을 사용할 것인가를 지정하는데 보다시피 BOOL형이 아니라 int형이다. , 여러 가지 방식의 정렬을 지원한다는 뜻이다. 이 변수값과 정렬방식은 다음과 같이 정의하였다.

 

nWrap

한글

영문

0(없음)

정렬 안함

정렬 안함

1(글자)

글자

글자

2(혼합)

글자

단어

3(단어)

단어

단어

 

nWrap 0이면 정렬을 하지 않는다는 뜻이고 0이 아니면 어떤 방식으로든 정렬을 한다는 뜻이다. 이 중 보편적으로 많이 사용하는 정렬방식은 2번 혼합 정렬이며 메모장이 바로 이 방식으로 정렬을 수행한다. 그래서 OnCreate에서 이 변수를 2로 초기화하였으며 이 변수를 바꾸면 정렬방식을 변경할 수 있다.

 

BOOL OnCreate(HWND hWnd, LPCREATESTRUCT lpCreateStruct)

{

     ...

    nWrap=2;

     return TRUE;

}

 

정렬이란 작업영역의 폭에 맞게 줄의 시작과 끝을 결정하는 일이므로 정렬의 주체는 GetLine 함수이다. 줄 끝을 제대로 찾아 내는 것이 정렬의 주된 작업이며 결국 GetLineSub 함수가 정렬의 핵심 함수가 된다. 자동개행을 하지 않을 때 이 함수는 \r이나 \0일 때 즉, 정말로 줄의 물리적인 끝일 때 리턴하도록 되어 있었다. 그러나 이제는 작업영역의 오른쪽 끝에 닿은 경우도 줄의 끝으로 판단해야 한다. 전체 코드를 보고 분석을 해보도록 하자.

 

int GetLineSub(TCHAR *&p)

{

     HDC hdc;

     int len, acwidth;

     RECT crt;

     int ret;

     TCHAR *EndPos=NULL;

     BOOL IsPrevDBCS=FALSE;

 

     hdc=GetDC(hWndMain);

     GetClientRect(hWndMain,&crt);

 

     if (nWrap==0) {

          for (;;p++) {

               if (*p == ‘\r’) {

                   ret=1;

                   break;

               }

 

               if (*p == 0) {

                   ret=0;

                   break;

               }

          }

     } else {

          for (acwidth=0;;) {

               if (IsDBCS(p-buf)) {

                   len=2;

 

                   if (nWrap==2 || (nWrap==3 && IsPrevDBCS==FALSE)) {

                        EndPos=p;

                   }

                   IsPrevDBCS=TRUE;

               } else {

                   len=1;

 

                   if (IsPrevDBCS==TRUE) {

                        EndPos=p;

                   }

                   IsPrevDBCS=FALSE;

               }

               acwidth+=GetCharWidth(hdc,p,len);

 

               if (*p == ‘\r’) {

                   EndPos=p;

                   ret=1;

                   break;

               }

 

               if (*p == 0) {

                   EndPos=p;

                   ret=0;

                   break;

               }

 

               if (acwidth > crt.right-2) {

                   ret=1;

                   break;

               }

 

               if (*p == ‘ ‘ || *p==‘\t’) {

                   EndPos=p+1;

               }

 

               p+=len;

          }

     }

 

     ReleaseDC(hWndMain,hdc);

     if (nWrap == 1 || EndPos == NULL) {

          p=p;

     } else {

          p=EndPos;

     }

     return ret;

}

 

코드의 길이만 봐도 정렬이 과히 간단한 기능이 아님을 알 수 있다. 함수의 첫 부분에서 먼저 DC의 핸들을 구하고 작업영역을 crt에 조사한다. 작업영역 폭에 맞게 정렬을 하려면 글자들의 폭을 구해야 하므로 DC가 필요하다. 글자의 폭이란 DC에 선택되어 있는 글꼴의 폭이기 때문이다. 작업영역의 폭도 미리 조사해놓는 것이 좋다.

우선 nWrap 0일 때, 즉 정렬을 하지 않을 때는 기존 코드와 완전히 동일하다. 다만 함수를 종료하기 전에 폭 계산을 위해 얻어 놓은 DC를 해제해야 하므로 곧바로 리턴하지 못하고 ret변수에 리턴값을 먼저 대입한 후 루프를 빠져나가야 한다는 점이 다를 뿐이다.

nWrap 0이 아닐 경우는 정렬을 하는데 문장의 처음부터 한 문자씩 읽어 문자들의 폭을 합산하면서 작업영역의 폭을 넘어가는지 항상 감시해야 한다. else 안의 for문에서 줄의 처음부터 문자를 검사하여 acwidth 변수에 문자들의 폭을 누적시키고 있다. 이 루프를 탈출하는 조건은 세 가지가 있다.

첫 번째는 \r을 만났을 때인데 이 코드는 진짜 개행하라는 명령이므로 더 볼 것도 없이 당장 루프를 탈출해야 한다. 개행코드가 있는 자리는 자동개행 기능의 여부에 상관없이 무조건 줄의 끝이다. 두 번째는 \0를 만났을 때인데 문서의 끝이므로 마찬가지로 루프를 탈출해야 한다. 문서의 끝을 만났으니 더 보고 싶어도 뒤쪽에 볼 문자가 없다.

세 번째 루프 탈출 조건은 acwidth > crt.right-2일 때 즉, 문자폭의 누적값이 작업영역의 오른쪽 끝보다 더 커졌을 때이다. 예를 들어 작업영역의 폭은 200픽셀인데 지금까지의 문자폭 총합이 210이라면 자동개행해야 할 때이므로 루프를 탈출한다. 이 문자 바로 앞문자가 줄의 마지막 문자가 되며 이 위치에서 잘라야 한다. 앞 절에서 설명했다시피 GetLineSub 함수는 줄의 끝 문자를 리턴하는 것이 아니라 줄 끝 다음 문자를 리턴하도록 되어 있으므로 이 위치에서 루프를 빠져 나가면 된다. 이 조건문은 작업영역폭을 넘은 최초의 문자를 만났을 때 이 위치를 줄의 끝으로 본다는 뜻이다.

작업영역의 폭은 crt.right-2로 표현되는데 여기서 -2를 한 의미는 작업영역 오른쪽에 2픽셀만큼 여유를 준다는 뜻이다. 이 여유분이 왜 필요한가 하면 제일 오른쪽에 문자가 밀착해 있고 캐럿이 이 문자 뒤에 있을 때 작업영역 바깥으로 캐럿이 밀려 안보이기 때문이다. 그래서 어떤 경우라도 캐럿이 보이도록 하기 위해 캐럿의 폭만큼인 2픽셀의 여유를 주는 것이다. 캐럿이 숨어서 안 보이는 상황을 만들어서는 곤란하다.

nWrap 1일 때를 보자. 한글이나 영문이나 글자 단위로 잘라야 하므로 루프를 빠져나온 직후의 위치를 리턴하면 된다. for 루프 다음에 nWrap 1이면 p=p를 대입하도록 하여 루프에서 계산한 p를 그대로 다시 넘겨 준다. 글자 단위 정렬은 폭을 넘었을 때 루프를 탈출하고 그 위치를 그대로 리턴하므로 별로 어렵지 않다.

이번에는 nWrap 3인 경우, 한글과 영문을 모두 단어로 정렬할 때를 보자. 단어로 정렬할 때는 루프 탈출시의 위치와 실제 잘라야 할 위치가 다르기 때문에 골치가 아프다.

자에서 폭을 넘었지만 실제로 잘라야 할 위치는 자가 포함된 단어인 강산의 바로 앞 위치가 된다. 단어 정렬을 하기 위해서는 앞쪽으로 이동하면서 공백을 찾아야 하는데 MBCS에서 앞으로의 이동은 느리고 번거롭다. 매번 GetPrevOff 함수를 불러야 하는데 이러면 얼마나 느려지겠는가? 그래서 이미 읽은 문자를 거꾸로 다시 읽는 방법은 곤란하고 좀 더 효율적인 다른 방법을 찾아야 한다.

여기서 사용하는 방법은 애초부터 한문자씩 폭을 합산하는 과정에서 잘라야 할 후보 위치를 기억해놓고 오른쪽 끝에 닿으면 미리 기억해놓은 후보 위치에서 자르는 방법이다. 이 후보 위치를 기억하는 변수가 바로 EndPos이다. 단어 정렬을 할 때 후보 위치를 선정하는 시점은 세 가지가 있다. 첫 번째는 공백과 탭을 만날 때이다. 공백과 탭은 단어를 구분하는 구분자이므로 이 문자 다음 위치가 후보가 된다. 공백이나 탭(합쳐서 그냥 공백이라고 하자) 위치가 아닌 바로 다음 위치를 선정하는 이유는 공백까지 현재 줄에 포함시키기 위해서이다. 공백은 가급적이면 줄 끝에 오는 것이 좋으며 줄 처음에 공백이 있는 것은 보기에 좋지 않다. 다음 코드가 공백에서 후보 위치를 선정한다.

 

               if (*p == ‘ ‘ || *p==‘\t’) {

                   EndPos=p+1;

               }

 

공백 다음을 후보로 만들기 위해 EndPos p+1을 대입하고 있다. 참고로 이 예제는 공백과 탭만으로 단어를 구분하는데 정렬 정책에 따라서는 콤마나 마침표 기타 구두점 등도 단어 구분자로 지정할 수 있다.

후보 위치가 되는 두 번째 경우는 한글 다음에 곧바로 영문이 올 때이다. 이 경우 비록 공백은 없지만 한글과 영문을 한 단어로 볼 수 없다. 예를 들어 한국YMCA컴퓨터(Computer) 같은 문장은 공백없이 붙어 있지만 한국YMCA는 분명히 다른 단어로 취급되어야 한다. 마찬가지로 FIFA월드컵처럼 영문이 오다가 이어서 한글이 오는 경우도 공백으로 분리되어 있지는 않지만 각각 다른 단어로 봐야 한다.

이 두 조건을 판단하기 위해서는 이전 문자가 한글이었는지 영문이었는지를 알아야 하는데 이런 목적으로 IsPrevDBCS변수가 사용된다. 이 변수가 TRUE이면 이전에 읽은 문자가 한글이었고 FALSE이면 영문이었다. 글자 한자를 읽을 때마다 이 변수에 한글이었는지 영문이었는지를 기억해놓으며 조건을 판단할 때도 이 변수값을 참조한다. 다음 코드는 영문자를 만났을 때의 처리이다.

 

                   if (IsPrevDBCS==TRUE) {

                        EndPos=p;

                   }

                   IsPrevDBCS=FALSE;

 

앞 글자가 한글이었다면 이 위치가 후보가 되므로 EndPos에 현재 위치를 기억해놓는다. 그리고 IsPrevDBCS FALSE로 바꾸어 지금 읽은 문자가 영문자임을 기록해놓는데 이 변수는 또 다음 번 루프를 돌 때 참조될 것이다. IsPrevDBCS는 글자의 바이트 수를 한 박자 늦게 가지고 있는 변수이며 만약 이 변수를 사용하지 않는다면 앞쪽으로 이동해 봐야 하는 번거로움이 있을 것이다. 다음 문장에서 후보 위치로 선정되는 곳을 보자.

처음부터 죽 글자를 읽어가면서 후보가 될만한 위치를 EndPos에 일일이 기록해놓는다. 그러다가 어떤 조건에 의해 루프를 탈출하면 마지막 찾아 놓은 EndPos 위치가 바로 마지막 단어의 시작위치이며 여기서 줄을 자르면 된다. \r, \0에 의해 루프를 탈출할 때는 정렬방식과는 상관없이 바로 그 자리에서 잘라야 하므로 탈출하기 전에 EndPos p를 대입해야 한다.

단어 정렬은 사실 굉장히 복잡한 부분인데 이 예제는 EndPos IsPrevDBCS 두 변수의 도움을 받아 앞쪽으로 이동하지 않고도 잘라야 할 단어의 위치를 훌륭하게 검사해낸다.

마지막으로 nWrap 2인 경우 즉, 한글은 글자로 정렬하고 영문은 단어로 정렬하는 혼합 정렬방식을 구현해보자. 글자, 단어로 정렬하는 기능을 이미 구현해놓았으므로 혼합 정렬은 이제 아주 쉽다. 한글인 경우 이전 문자가 영문자이거나 한글이었거나 상관없이 무조건 후보 위치로 선정해놓으면 된다. 다음 코드가 이 일을 한다.

 

                   if (nWrap==2 || (nWrap==3 && IsPrevDBCS==FALSE)) {

                        EndPos=p;

                   }

 

nWrap 2인 상태에서 한글이 발견되는 족족 EndPos를 후보 위치로 선정함으로써 루프 탈출시 이 위치에서 줄을 자르도록 한다. 자연히 한글은 글자 단위로 정렬된다. nWrap 3인 경우는 앞 글자가 영문자였을 때만 이 위치가 후보 위치가 되므로 이때는 단어 정렬된다. 여기까지의 코드를 보면 GetLineSub가 정렬방식에 따라 줄의 끝을 잘 찾아 내도록 되어 있다. 하지만 이는 어디까지나 특이하지 않은 문장에 대해서만 잘 동작하며 정렬 하기 힘든 조건에 대해서는 예외 처리가 필요하다.

GetLineSub 함수의 끝부분에 있는 조건문 if (nWrap==1 || EndPos == NULL) 중에 EndPos NULL인 경우는 어떤 경우인지 생각해보자. 이 조건문은 글자 정렬이거나 EndPos가 없을 때를 의미하며 이 경우 앞쪽 단어 시작위치를 찾지 않고 루프 탈출시의 위치인 p를 취하게 된다. EndPos NULL이라는 조건은 선정된 후보 위치가 없다는 뜻이며 즉, 단어 정렬 상태인데 줄 내에는 자를만한 단어가 없다는 뜻이다.

이때는 단어 정렬을 하지 못하고 어쩔 수 없이 글자 단위로 정렬을 해야 한다. 앞에서 메모장의 예를 보면서 이런 경우를 설명했었는데 바로 이 코드가 그 일을 담당하고 있다. 만약 이 조건문이 없다면 영문자를 공백없이 계속 쓴 줄에서는 줄의 끝을 EndPos의 초기값인 NULL이라고 조사하게 될 것이며 그 이후에 프로그램이 어떻게 돌아갈지는 가히 짐작이 간다.

이 처리 외에도 윈도우 폭이 지나치게 좁은 경우도 처리해야 하나 이 예제의 메인 윈도우는 최소한의 폭을 확보하고 있으므로 아직 그럴 필요가 없다. 다음에 컨트롤로 만들 때 최소 정렬폭을 처리하게 될 것이다.