기술 문서

Delphi에서 CF_HTML 클립보드 형식 구현하기

Delphi 그리드에서 범위를 복사해 Word에 붙여넣으면 서식은 보통 사라진다: 평범한 텍스트만 남고, 굵은 헤더도, 테두리도, 채우기도 없다. HotXLS는 TXLSRange.CopyToClipboard로 이 공백을 메운다. 이 메서드는 CF_HTML 클립보드 페이로드 — 바이트 단위로 정확한 프래그먼트 마커를 가진, 서식 있는 HTML을 위한 Windows 형식 — 를 일반 유니코드 텍스트 옆에 클립보드에 올려놓는다

CF_HTML 페이로드가 실제로 무엇을 요구하는지 살펴보기 전까지는 이것이 단순해 보인다. 이 형식은 프래그먼트가 더 큰 클립보드 버퍼 안 정확히 어디서 시작하고 끝나는지 알려주는 짧은 텍스트 헤더를 필요로 하며, 그 위치는 HTML이 최종적으로 인코딩되는 멀티바이트 인코딩을 기준으로 센 바이트 오프셋이다. 이 계산을 단 1바이트라도 틀리면 대상 애플리케이션은 잘못된 마크업 조각을 붙잡거나 아예 포기하고 평범한 텍스트로 되돌아가는데, 어느 실패든 여러분 코드의 버그처럼 보이지 않는다 — 그냥 Word가 Word답게 구는 것처럼 보일 뿐이다

Delphi 그리드에서 복사-붙여넣기는 왜 보통 서식을 잃는가

대부분의 Delphi 코드가 사용하는 기본 Windows 클립보드 호출, 즉 CF_TEXTCF_UNICODETEXT를 쓴 SetClipboardData는 순수한 문자만 담을 수 있으므로, 원본 그리드에 적용된 어떤 스타일링도 갈 곳이 없다. Word, Outlook, 그리고 Chromium 기반 브라우저는 붙여넣을 때 더 풍부한 형식을 찾는다: 인라인 스타일, 테이블 구조, 링크를 완비한 선택 영역의 HTML 표현이다. Excel 자체가 정확히 이 트릭에 의존한다 — Excel에서 범위를 복사하면 클립보드는 조용히 여러 형식을 한꺼번에 받는데 그중에 HTML도 있어서, 붙여넣는 애플리케이션이 무엇이든 자신이 이해하는 가장 풍부한 형식을 고른다. CF_UNICODETEXT만 쓰는 컴포넌트는 그런 더 풍부한 소비자들에게 아무것도 줄 것이 없고, 사용자가 방금 복사한 시각적 풍부함은 붙여넣을 곳에 존재하지 않게 된다

CF_HTML 클립보드 형식이란 정확히 무엇인가?

CF_HTML은 CF_TEXT 같은 고정 시스템 클립보드 형식이 아니다; 이는 RegisterClipboardFormat('HTML Format')으로 이름을 통해 요청되는 동적으로 등록된 형식이며, 그 페이로드는 짧은 ASCII 헤더 뒤에 HTML 문서나 프래그먼트가 이어지는 형태다. 헤더는 다섯 개 필드 — Version, StartHTML, EndHTML, StartFragment, EndFragment — 를 담으며, Version은 항상 0.9이고 나머지 넷은 ASCII 숫자로 작성된 10진수다. StartHTMLEndHTML은 받는 애플리케이션이 폰트와 스타일을 포함한 문맥을 파악하기 위해 파싱해야 할 전체 문서의 경계이고, StartFragmentEndFragment는 실제로 커서 위치에 떨어지는 더 좁은 조각의 경계다. 이는 관례적으로 마크업 자체 안에 <!--StartFragment--><!--EndFragment--> 주석으로 표시되어, 경계가 순진한 재직렬화를 거쳐도 살아남는다

바이트 오프셋이지 문자 수가 아니다: 고전적인 CF_HTML 함정

CF_HTML의 네 숫자 헤더 필드는 클립보드에 놓인 정확한 바이트 시퀀스 안의 바이트 오프셋으로, 헤더 자체의 첫 문자부터 세어진다 — 문자 수도, 유니코드 코드 포인트도, 프래그먼트나 <body> 태그를 기준으로 한 오프셋도 아니다. 이 구분이야말로 손으로 만든 CF_HTML 구현이 조용히 잘못되는 지점이다: Delphi UnicodeStringLength는 UTF-16 코드 유닛을 보고하는데, 순수 ASCII 텍스트에서는 이것이 우연히 바이트 수와 같아서, 영어 샘플 데이터로 작성된 어떤 테스트도 이 버그를 깔끔하게 통과시키고, 복사된 셀에 엠 대시나 통화 기호나 악센트 붙은 문자가 들어 있을 때에만 드러난다 — 유로 기호는 UTF-16 코드 유닛 하나지만 UTF-8에서는 3바이트이며, 그 지점 이후에 계산된 모든 오프셋은 인코딩이 추가한 바이트 수만큼 어긋난다. 그 뒤에 이어지는 실패는 크래시가 아니다; 받는 애플리케이션이 헤더가 가리킨 정확한 바이트 범위를 그대로 붙잡아, 태그 중간에서 시작하거나 끝나는 마크업 조각을 발견하고는 깨진 결과를 렌더링하거나 아예 포기하고 클립보드에 옆에 있던 평범한 텍스트로 조용히 되돌아가는데, 여러분 코드 어디에도 그 이유를 설명해 줄 것이 없다 — 정확히 그 실패를 만들어내는 코드의 모습은 이렇다:

// Fragile: Length() on a UnicodeString counts UTF-16 code units, not bytes
var
  Header: string;
  Fragment: string;
  StartFragmentOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 + 'StartHTML:0000000000'#13#10 + '...';
  StartFragmentOfs := Length(Header) + Pos('<!--StartFragment-->', Fragment);
  // A currency symbol, an em dash, or any accented character placed
  // before this point costs one character here but two or three bytes
  // once the document is UTF-8 encoded, so StartFragmentOfs now points
  // short of where the fragment actually begins on the real clipboard
end;

HotXLS는 헤더를 어떻게 바이트 단위로 정확하게 유지하는가

HotXLS는 이 부류의 버그를 구조적으로 피한다: TXLSRange.CopyToClipboard와 그 아래의 lxClipboard 유닛은 CF_HTML 문서와 그 헤더를 전부 Delphi의 바이트 문자열 타입인 AnsiString으로 만들므로, 계산 전체에 걸쳐 LengthPos는 이미 어디서든 바이트 위치를 반환한다 — 유니코드 문자 수를 헤더에 넣기 전에 바이트 수로 변환해야 하는 별도 단계가 없으므로, 잊어버릴 단계 자체가 없다

CF_HTML 헤더를 직접 손으로 만들어 본다면 알아둘 가치가 있는 더 작은 트릭이 하나 더 있다. 헤더는 두 번 작성된다: 한 번은 네 오프셋 각각을 10개의 0 자리로 대신해 그 자체의 바이트 길이를 측정할 수 있게 하고, 또 한 번은 실제 오프셋을 채워 넣는다. 모든 실제 오프셋이 같은 고정된 10자리 폭으로 포맷되기 때문에, 두 번째 헤더는 자리표시자 버전과 바이트 단위로 정확히 같은 길이가 나오며, 이것이 바로 이전 측정값이 재작성 후에도 여전히 유효한 이유다. 고정 폭을 생략하고 그냥 IntToStr로 숫자를 포맷하면 헤더는 두 패스 사이에서 자릿수 하나만큼 줄어들거나 늘어날 수 있고, 그 이후의 모든 오프셋을 조용히 무효화한다:

const
  Placeholder = '0000000000';   // 10 ASCII digits: fixed width in, fixed width out
var
  Header: AnsiString;           // AnsiString.Length is a byte count, not a char count
  StartHtmlOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 +
    'StartHTML:' + Placeholder + #13#10 +
    'EndHTML:' + Placeholder + #13#10 +
    'StartFragment:' + Placeholder + #13#10 +
    'EndFragment:' + Placeholder + #13#10;
  StartHtmlOfs := Length(Header);   // safe to measure once, up front
  // ...compute the real offsets against the AnsiString document...
  // then rebuild Header with the real numbers formatted to the same
  // 10-digit width, so its byte length -- and therefore StartHtmlOfs --
  // never moves between the placeholder pass and the final one
end;

평범한 텍스트 페이로드가 여전히 함께 실려가야 하는 이유

TXLSRange.CopyToClipboard는 CF_HTML을 절대 클립보드에 단독으로 올려놓지 않는다. 항상 같은 호출 안에서 CF_UNICODETEXT도 함께 쓰는데, CF_HTML은 모든 Windows 애플리케이션이 이미 확인할 줄 아는 고정 CF_* 상수 중 하나가 아니라 등록된 형식이기 때문이다 — 평범한 텍스트 편집기, 레거시 그리드, 혹은 'HTML Format'을 확인해 본 적 없는 무엇이든 이를 전혀 보지 못하며, 복사한 범위는 탭으로 구분된 텍스트로 도착하거나 아예 도착하지 않는다. 그 탭 구분 텍스트도 대충 만든 근사치가 아니다: 수식 셀은 저장된 텍스트가 앞의 =를 빠뜨렸다면 그것을 복원한 수식 문자열로 복사되어 Excel 자체의 클립보드 텍스트 동작과 일치하고, 일반 셀은 표시된 그대로의 문자열인 FormattedText로 복사되므로 통화 셀은 밑에 깔린 1234.56이 아니라 $1,234.56으로 복사되며, 탭이나 따옴표나 줄바꿈을 포함하는 어떤 필드든 CSV가 쓰는 것과 같은 관례로 내부 따옴표를 두 배로 만들어 따옴표로 감싼다

SaveAsHTML은 클립보드 경우만을 위해 따로 덧붙인 별도의 렌더링 경로가 아니다. CopyToClipboardHotXLS의 CSV, TSV, HTML 내보내기에서 설명한 바로 그 HTML 라이터를 호출한 뒤, 그것이 만들어낸 결과를 독립 파일로 저장하는 대신 CF_HTML 봉투로 감싼다. 그래서 그 HTML에 대해 참인 것은 무엇이든 클립보드에 놓이는 결과로 곧바로 이어진다. 워크시트 범위를 한 번의 호출로 두 형식 모두로 모으는 것은 이렇게 생겼다:

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-report.xlsx');
    // Classic TXLSWorkbook ranges expose the identical method as
    // Workbook.Sheets[1].Range['A1', 'F40'].CopyToClipboard
    if Book.Sheets[1].Range['A1:F40'].CopyToClipboard then
      ShowMessage('Range copied - press Ctrl+V in Word or a browser')
    else
      ShowMessage('Clipboard was busy; see the retry pattern below');
  finally
    Book.Free;
  end;
end;

붙여넣은 범위는 폰트, 색상, 병합된 셀을 유지하는가?

그렇다, 페이로드의 HTML 절반이 단순한 데이터 덤프가 아니라 범위의 완전한 렌더링이기 때문이다: 폰트, 채우기 색, 테두리, 숫자 서식, 병합된 셀 모두 인라인 스타일과 테이블 구조로 그대로 전달되는데, 이는 HotXLS의 조건부 서식과 서식 있는 텍스트 가이드에서 다루는 것과 동일한 스타일링 메커니즘이다. 셀의 서식 있는 텍스트 런과 조건부 서식 결과 모두 CopyToClipboard가 읽어들이는 것과 같은 렌더링을 공급하기 때문이다. 그 여정에서 살아남지 못하는 것은 살아있는 수식 동작이다: 수식 셀의 평범한 텍스트 형태는 수식 문자열을 담으므로 스프레드시트를 인식하는 붙여넣기 대상은 원칙적으로 이를 다시 계산할 수 있지만, HTML 형태는 마지막으로 계산된 결과만 담는다. HTML에는 브라우저나 워드 프로세서가 평가할 수식이라는 개념 자체가 없기 때문이다

붙여넣기 검증하기, 그리고 사용 중인 클립보드 다루기

고객이 발견하기 전에 대부분의 클립보드 문제를 잡아내는 두 가지 습관이 있다. 먼저 메모장에 붙여넣어 CF_UNICODETEXT 폴백이 온전한 탭 구분 텍스트인지 확인한 다음, 같은 복사본을 Word나 브라우저에 붙여넣어 서식 있는 버전이 나타나는지 확인하라 — 한쪽에서는 맞아 보이고 다른 쪽에서는 틀려 보이는 페이로드는 보통 프래그먼트 마커가 잘못된 위치에 놓였다는 뜻이다. 그다음 CopyToClipboard가 반환하는 불리언 결과를 장식이 아니라 의미 있는 것으로 취급하라: OpenClipboard는 다른 프로세스가 클립보드를 열어 붙잡고 있을 때 실패할 수 있는데, 바쁜 데스크톱에서는 흔한 일이라 확인되지 않은 호출 하나가 결국 아무것도 붙여넣지 못하면서도 그 이유를 설명할 오류는 전혀 없게 된다. 아래의 재시도가 막으려는 것이 바로 이것이다:

function TryCopyRangeToClipboard(Workbook: TXLSXWorkbook): Boolean;
var
  Attempt: Integer;
begin
  Result := False;
  for Attempt := 1 to 5 do
  begin
    Result := Workbook.Sheets[1].Range['A1:F40'].CopyToClipboard;
    if Result then
      Break;
    Sleep(50);   // give whichever app is holding the clipboard a moment
  end;
  if not Result then
    raise Exception.Create('Could not take ownership of the clipboard');
end;

이 형식 자체는 헤더가 바이트 단위로 정확하고 평범한 텍스트 폴백이 자신이 담은 내용에 정직하기만 하다면 특별히 이국적이지 않다 — Internet Explorer가 처음 정의한 이래로 크게 바뀌지 않은 채 존재해 왔고, 모든 주요 Windows 애플리케이션은 여전히 같은 방식으로 이를 읽는다. CopyToClipboard는 같은 교환의 읽기 쪽인 PasteFromClipboard와 나란히, HotXLS 컴포넌트 제품 페이지에 문서화된 더 넓은 클립보드 및 내보내기 표면에 자리하고 있다