. 대소문자 변환

당근은 텍스트 편집기고 지금까지 기본적인 편집기능들을 만들어 왔다. 문자열을 삽입하거나 삭제하고 클립보드를 통해 선택영역을 복사하거나 붙여넣는 등의 기능은 텍스트 편집기라면 당연히 갖추어야 할 필수 기능들이다. 이 장에서 만들 편집기능은 기본 기능이라기 보다는 다소 고급 기능에 속한다. 잘 쓰지도 않으며 필수적인 기능도 아니지만 있으면 언젠가는 도움이 되는 그런 기능들이다. 다른 편집기들도 이 장에서 작성할 고급 편집기능을 다 제공하고 있기 때문에 이제는 더 이상 고급 기능 축에 속한다고 보기 어려워졌고 오히려 없으면 섭섭한 경우가 많다.

그래서 당근도 시대의 흐름에 부응하기 위해, 솔직히 말하자면 다른 편집기에게 밀리지 않기 위해 이런 고급 편집기능을 작성해보기로 했다. 고급 편집기능이라고 해서 불가능한 것을 가능하게 하는 마술 같은 것은 아니며 사용자들이 여러 번 작업해야 할 동작을 한 번에 자동으로 하는 편의 기능 정도로 생각하면 된다. 선택한 범위의 텍스트를 미리 정해진 작업 규칙대로 조작한 후 대체시켜 주는 식으로 기능을 구현하는데 난이도는 중급 정도 되며 텍스트 조작을 위해 포인터를 많이 사용한다. 이 장의 실습을 통해 포인터에 대해 많은 것을 경험해 볼 수 있을 것이다.

항상 그래왔듯이 새로운 기능을 작성하기 전에 이전 프로젝트를 복사하여 Dangeun9 프로젝트를 만들도록 하자. 텍스트 포맷은 단순한 문자열의 나열이다. 하지만 단순한 문자들 중에도 좀 특별하게 취급되는 부류가 두 가지 있는데 바로 공백과 줄 끝 코드이다. 공백으로 취급되는 문자에는 스페이스와 탭이 있으며 단어 이동이나 단어 선택 등의 기준으로 사용된다. 줄 끝 코드는 물리적인 줄의 끝을 나타내는데 대표적으로 엔터코드가 있고 문서의 끝 표식인 널 종료문자(0)도 줄 끝 코드이다. 널 종료문자는 문서 끝을 나타냄과 동시에 마지막 줄의 끝을 나타낸다.

편집 코드들은 문자열을 다룰 때 공백과 줄 끝 코드에 대해서는 항상 특별하게 취급해야 하며 코드의 곳곳에서 공백과 줄 끝 코드 검사를 한다. 그래서 이것들을 검출해내는 간단한 매크로를 정의함으로써 고급 편집기능 실습을 시작하도록 하자. 공백과 줄 끝 코드에 두 개씩의 코드가 있기 때문에 항상 논리합으로 두 조건을 연결해야 하는데 이 간단한 조건을 매크로로 정의해두면 코드가 간단해지는 효과가 있다. 앞으로 이 매크로를 자주 사용하게 될 것이므로 ApiEdit.h헤더 파일에 다음 두 매크로를 정의한다.

 

#define AeIsWhiteSpace(c) ((c)==‘ ‘ || (c) == ‘\t’)

#define AeIsLineEnd(c) ((c)==‘\r’ || (c) == 0)

 

매크로 내용은 너무 쉬워서 읽어만 보면 이해가 될 정도다. 매크로 함수를 작성할 때는 인수를 괄호로 반드시 묶어 연산 순위에 영향을 받지 않도록 해야 함을 유의하자. 이 두 함수는 이 장 전반에 걸쳐 종종 사용될 것이다. 이 짧은 매크로 덕분에 코드가 얼마나 읽기 쉬워지는지 코드를 만들면서 직접 확인해보게 될 것이다.

고급 편집기능의 첫 번째 실습은 대소문자를 변환하는 것이다. 한글에는 대소문자가 없지만 영문에는 같은 문자에 대해 대문자와 소문자가 각각 하나씩 있기 때문에 두 문자 사이를 자동으로 변환하는 기능이 필요하다. 편집 메뉴에 대소문자를 변환하는 명령 다섯 가지가 이미 만들어져 있다.

이런 간단한 기능을 다섯 개의 명령으로 나눌 수 있다는 것 자체가 참 놀라운 일이다. 이 명령들은 모두 비슷비슷하게 처리되므로 한 함수로 처리하도록 하자. CApiEdit에 다음 멤버함수를 추가한다.

 

void CApiEdit::ChangeCase(int action)

{

     int SelFirst, SelSecond;

     int len;

     TCHAR *tbuf;

     TCHAR *p;

     int toff;

 

     if (SelStart == SelEnd) {

          switch (action) {

          case 0:

          case 1:

          case 2:

              if (buf[off]==0) {

                   return;

              }

              SelFirst=SelSecond=off;

               if (IsDBCS(off)) {

                   len=2;

              } else {

                   len=1;

              }

              break;

          case 3:

              GetNowWord(off,SelFirst,SelSecond);

              len=SelSecond-SelFirst;

              break;

          case 4:

              return;

          }

     } else {

          SelFirst=min(SelStart,SelEnd);

          SelSecond=max(SelStart,SelEnd);

          len=SelSecond-SelFirst;

     }

 

     tbuf=(TCHAR *)malloc(len+1);

     lstrcpyn(tbuf,buf+SelFirst,len+1);

 

     switch (action) {

     case 0:

          CharUpper(tbuf);

          break;

     case 1:

          CharLower(tbuf);

          break;

     case 2:

          p=tbuf;

          while (*p) {

              if (IsCharUpper(*p)) {

                   *p+=0x20;

              } else if (IsCharLower(*p)) {

                   *p-=0x20;

              }

              p++;

          }

          break;

     case 3:

          p=tbuf;

          if (IsCharLower(*p)) {

              *p-=0x20;

          }

          p++;

          while (*p) {

              if (IsDelimiter(SelFirst+(p-tbuf)-1)) {

                   if (IsCharLower(*p)) {

                        *p-=0x20;

                   }

              } else {

                   if (IsCharUpper(*p)) {

                        *p+=0x20;

                   }

              }

              p++;

          }

          break;

     case 4:

          p=tbuf;

 

          toff=SelFirst;

          while (AeIsWhiteSpace(buf[toff]) && toff > 0)

              toff--;

          if (toff==0 || buf[toff]==‘\n’ || buf[toff]==‘.’) {

              if (IsCharLower(*p)) {

                   *p-=0x20;

              }

          } else {

              if (IsCharUpper(*p)) {

                   *p+=0x20;

              }

          }

          p++;

 

          while (*p) {

              if (*p==‘.’ || *p==‘\n’) {

                   p++;

                   while (AeIsWhiteSpace(*p) && *p)

                        p++;

                   if (*p==0)

                        break;

                   if (IsCharLower(*p)) {

                        *p-=0x20;

                   }

              } else {

                   if (IsCharUpper(*p)) {

                        *p+=0x20;

                   }

              }

              p++;

          }

          break;

     }

 

     StartUndoGroup();

     Delete(SelFirst,len);

     Insert(SelFirst,tbuf);

     EndUndoGroup();

 

     if (SelStart==SelEnd && action < 3) {

          off=GetNextOff(off);

     }

     free(tbuf);

     Invalidate(SelFirst);

     SetCaret();

}

 

action 인수에 따라 변환 방법이 달라지는데 위쪽 메뉴항목부터 차례대로 액션 0~5까지의 값을 가진다. 대소문자를 변환하는 과정은 코끼리를 냉장고에 넣는 방법과 유사하다. 변환 대상을 고른다. 변환한다. 결과를 써 넣는다. 대소문자 변환뿐만 아니라 이어지는 모든 변환 작업의 큰 틀은 이 세 단계를 크게 벗어나지 않는다. action에 따라 달라지는 것은 번 과정뿐이며 , 번은 action과 상관없이 동일하다.

그래서 다섯 개의 변환 명령을 한 함수가 처리할 수 있는 것이다. ChangeCase 함수는 변환 대상을 먼저 선택하고 action에 따라 선택된 문자열을 변환하며 그 결과를 다시 원래 위치에 써 넣는 식으로 구성되어 있다. 각 단계가 어떻게 처리되는지 보자.

변환 대상 고르기

선택영역이 있으면 사용자가 변환 대상을 명시적으로 지정한 것이므로 더 고를 것도 없이 주어진 선택영역을 취하면 된다. 선택영역이 없을 경우는 디폴트 대상을 취하되 액션에 따라 디폴트가 달라진다. 0,1,2 action은 한 문자를 대상으로 하므로 현재 캐럿 위치의 글자 하나를 취한다. action 3은 단어를 대상으로 하므로 GetNowWord 함수를 호출하여 현재 캐럿 위치의 단어를 취한다. 4 action은 문장을 대상으로 하므로 선택영역이 없으면 사용할 수 없으며 그냥 리턴해 버린다.

대상이 선정되면 그 길이만큼 tbuf에 메모리를 할당하고 사본을 만든다. 이때 변환 대상 문자열의 길이에 널 종료문자의 길이를 더한 만큼을 할당해야 한다. 이렇게 할당된 tbuf가 바로 변환 대상이다. 대소문자 변환의 경우 변환 후의 길이가 바뀌지 않기 때문에 buf를 직접 변경하는 것도 가능하다. 하지만 굳이 사본을 만드는 이유는 변환 전의 문자열과 변환 후의 문자열에 대해 편집기록을 남겨야만 실행취소가 가능해지기 때문이다.

ApiEdit는 어떤 동작이든지 문서를 변경하는 작업은 반드시 Insert Delete를 통하도록 되어 있다. 그래서 buf의 대상을 먼저 Delete한 후 tbuf를 그 위치에 다시 Insert함으로써 변환을 완료할 수 있다. 설사 이런 구조가 좀 비효율적이고 억지스럽더라도 그렇게 했을 때의 이점에 대해서는 앞에서 많이 경험해 본 바가 있으므로 이제 이런 정책이 아주 자연스럽게 느껴질 것이다. 이런 기능은 애초에 설계할 때부터 Insert, Delete 길목을 피해갈 생각을 하지 말아야 한다.

tbuf에 변환할 대상의 사본이 준비되면 변환한다. 다행히 대소문자 변환은 변환 전과 후의 길이가 동일하므로 선택영역이나 오프셋의 변화 따위는 신경쓰지 않아도 된다. switch문으로 action별로 분기하여 tbuf의 문자열을 조작한다.

대소문자 변환-액션 0, 1

이건 완전히 앉아서 떡 먹기다. 문자열을 변환하는 두 개의 API 함수가 제공되므로 불러 주기만 하면 된다. 대문자로 바꿀 때는 CharUpper 함수를 호출하고 소문자로 바꿀 때는 CharLower 함수를 호출한다. 이 두 함수는 영문자가 아닌 문자는 그대로 유지하는 특성이 있으므로 한글이나 숫자, 기호 등의 문자는 변환되지 않으며 원래값을 유지한다.

대소문자 반대로-액션 2

이것도 생각보다 쉬워서 서서 떡 먹기 정도는 된다. p tbuf의 시작 번지를 대입하고 버퍼의 끝까지 돌면서 대문자는 소문자로, 소문자는 대문자로 바꿔주면 된다. 임의의 문자가 대문자인지, 소문자인지는 IsCharUpper, IsCharLower 함수로 조사할 수 있다. 대문자와 소문자의 코드값 차이는 0x20(32)이므로 이만큼 더하거나 빼주면 대소문자를 반대로 만들 수 있다. CharUpper, CharLower API 함수는 널 종료문자열을 인수로 요구하므로 한 문자를 변환할 때는 쓸 수 없다.

첫 글자만 대문자로-액션 3

이 명령의 정확한 의미는 각 단어의 시작 문자를 대문자로 바꾸는 것이다. 예를 들어 made in korea에 대해 이 명령을 내리면 Made In Korea로 바꿔주면 된다. 버퍼의 처음부터 끝까지 루프를 돌면서 구분자 다음의 문자를 대문자로 바꾸고 나머지 문자는 소문자로 바꿔주면 된다. 구분자는 현재 선택된 문법 분석기에 따라 달라지므로 어떤 문자가 단어의 선두가 될 것인가는 선택된 분석기에 따라 달라진다.

C 언어 분석기는 괄호나 세미콜론을 분석기로 인식하므로 for(idx=0;idx<10;idx++)를 변환하면 For(Idx=0;Idx<10;Idx++) 이렇게 변환될 것이다. 예외적으로 첫 번째 글자는 단어의 선두가 아니더라도 무조건 대문자로 바꾼다. 선택영역이 있는 상태에서 이 명령을 내렸다는 것은 선택영역의 첫 문자를 대문자로 바꾸라는 뜻으로 해석하는 것이다.

문장 처음만 대문자로-액션 4

이 명령은 문장을 대상으로 하므로 약간 어려운 점이 있다. 문장의 첫 문자란 마침표 다음의 문자 또는 개행코드 다음의 문자를 의미하되 단, 공백은 무시하고 계산해야 한다. 예를 들어 Hey. Miyoung M은 마침표 다음에 나왔으므로 문장의 첫 문자로 인식되는데 마침표와 M사이에 있는 공백은 마땅히 무시해야 한다. Hey.Miyoung이나 Hey.   Miyoung이 모두 똑같이 분석되어야 한다. 공백을 무시하지 않으면 마침표 바로 다음에 있는 문자만 문장의 처음으로 인식될 것이다.

여기서 공백이라 함은 스페이스만을 말하는 것이 아니라 탭도 포함된다. 그래서 공백을 검사하는 조건문에 AeIsWhiteSpace 매크로 함수를 사용했다. 여기까지만 코드로 구현하자면 그리 어렵지 않을 것이다. tbuf의 처음부터 끝까지 루프를 돌면서 마침표나 개행코드를 만나면 공백을 건너 뛴 후 대문자로 바꾸고 그 외의 경우는 모두 소문자로 바꿔주면 된다.

이 기능이 골치 아픈 이유는 선택영역의 첫 번째 문자를 처리하기가 어렵다는 점이다. 예를 들어 hey go. i go라는 문장을 선택한 상태일 때 첫 번째 h문자가 문장의 첫 문자인지 아닌지를 알려면 이 문자 앞에 어떤 문자가 있는지 조사해 봐야 한다. 물론 앞에 있는 공백은 무시해야 한다. 만약 앞 문자가 마침표나 개행코드라면 h는 대문자가 되어야 하고 다른 문자가 앞에 있다면 문장의 처음이 아님을 알 수 있다. 하지만 tbuf는 선택영역에 대한 사본이기 때문에 그 앞문자에 대한 정보를 전혀 가지고 있지 않으며 따라서 tbuf만으로는 첫 문자에 대한 처리를 결정할 수 없다.

그래서 같은 위치의 원본인 buf를 읽어서 공백을 제외한 바로 이전 문자를 찾고 이 문자의 값으로부터 첫 문자의 처리 방식을 결정한다. 이 문자가 개행코드나 마침표이거나 또는 문서의 처음에 도달하면 선택영역의 첫 문자가 문장의 처음이고 그 외의 경우는 문장의 중간쯤이라고 판단하게 된다.

변환 결과 써 넣기

tbuf에 있는 문자열을 변환 완료 했으면 이 문자열을 원래 문자열과 대체시킨다. 원래의 변환 대상을 Delete로 삭제하고 Insert tbuf를 다시 삽입하면 변환 결과가 문서에 기록된다. 삭제, 삽입 위치는 모두 블록의 시작위치인 SelFirst인데 변환 대상을 선정할 때 SelFirst가 대상의 선두를 가리키도록 해놓았다. 두 동작은 편집 그룹으로 묶어야 한 번에 취소할 수 있으므로 Delete, Insert 호출문은 StartUndoGroup, EndUndoGroup으로 둘러 싸야 한다.

뒷정리

대소문자 변환을 한 글자에 대해서만 한 경우 다음 글자로 자동 이동한다. 이렇게 하면 단축키를 계속 눌러서 연속적인 문자열을 원하는 길이만큼 변환할 수 있다. 모든 변환 작업이 완료되면 변환 대상을 위한 임시버퍼 tbuf를 해제하고 변환 결과가 보이도록 화면을 무효화한다. 이때 무효화 시작 시점은 변환 대상의 선두인 SelFirst 이후이며 그 앞은 변할 리가 없으므로 다시 그리지 않아도 된다. 자동 이동 기능과 문자 변환에 의해 캐럿의 위치가 변경될 수 있으므로 SetCaret 함수를 호출하였다.

CApiEdit가 대소문자 변환 기능을 제공하므로 호스트는 메뉴가 선택될 때 적당한 인수와 함께 이 함수를 불러주면 된다.

 

void OnCommand(HWND hWnd,WPARAM wParam,LPARAM lParam)

{

     ....

     case IDM_EDIT_UPPER:

          pSi->Ae.ChangeCase(0);

          break;

     case IDM_EDIT_LOWER:

          pSi->Ae.ChangeCase(1);

          break;

     case IDM_EDIT_REVERSE:

          pSi->Ae.ChangeCase(2);

          break;

     case IDM_EDIT_UPPERFIRST:

          pSi->Ae.ChangeCase(3);

          break;

     case IDM_EDIT_UPPERSENT:

          pSi->Ae.ChangeCase(4);

          break;

 

호출하는 함수는 모두 ChangeCase이되 action인수만 다르다. 영문 문서를 열어 놓고 테스트해보면 잘 동작할 것이다. 대문자로, 소문자로 명령에 대해서는 각각 <Ctrl+U>, <Ctrl+L> 단축키가 할당되어 있으므로 원하는 위치에서 또는 원하는 만큼 선택한 상태에서 키만 눌러도 즉시 변환된다.