기술 문서

losLab PDF Library로 델파이에서 RTF를 PDF로 변환

RTF는 아무도 계획하지 않은 자리에 나타날 만큼 오래 버텨 왔습니다. 예전 보고서 생성기, 메일 병합 파이프라인, 요즘 워드 프로세서보다 앞선 법률 문서 보관소 같은 곳입니다. 그것을 즉석에서 PDF로 바꾸는 일은 되풀이되는 요구 사항이고, Windows에서 실제로 통하는 접근법은 전용 RTF 파서가 아니라 Windows 자신이 TRichEdit와 EM_FORMATRANGE로 이미 제공하는 렌더링 경로입니다. losLab PDF Library는 그 파이프라인에 곧장 끼워지는 가상 장치 컨텍스트를 드러냅니다

작동 원리: 가상 DC와 EM_FORMATRANGE

Rich Edit 컨트롤은 물리적 프린터뿐 아니라 어떤 장치 컨텍스트에 대해서도 내용을 쪽으로 나눌 수 있습니다. EM_FORMATRANGE 메시지는 컨트롤에게 주어진 DC에 문자 범위를 배치하라고 알리고, 담아 낼 수 있었던 마지막 문자의 위치를 돌려줍니다. 호출을 되풀이하며 cpMin을 매번 앞으로 밀면 쪽 단위 출력이 나옵니다. losLab PDF Library의 GetCanvasDC는 여러분이 지정한 쪽 치수에 맞춘 메모리 속 DC를 제공하고, 한 쪽을 거기에 렌더링한 뒤 LoadFromCanvasDC가 그 결과를 새로운 한 쪽짜리 PDF 문서로 담아 냅니다. 그것이 파이프라인 전부입니다

미리 제대로 해 둘 것이 하나 있습니다. TRichEdit 컨트롤은 대상 쪽에 맞게 크기를 잡아야 합니다. 컨트롤이 DC 치수보다 작거나 크면 쪽 나눔이 PDF에 최종 담기는 것과 맞아떨어지지 않습니다. A4 출력의 표준적인 접근은 RTF 파일을 적재하기 전에, DC 크기를 잡을 때 쓰는 것과 같은 밀리미터-픽셀 변환으로, 나중에 LoadFromCanvasDC에 넘길 것과 같은 DPI에서 컨트롤의 픽셀 치수를 210 x 297 mm에 맞추는 것입니다

PDF: RTF에서 PDF로 가는 파이프라인. 크기를 잡은 Rich Edit 컨트롤이 EM_FORMATRANGE로 자기 텍스트를 가상 캔버스 DC에 쪽으로 나누어 넣고, 그 통과마다 PDF 한 쪽으로 담긴다
EM_FORMATRANGE가 RTF 텍스트의 한 범위를 가상 캔버스 DC에 놓고 LoadFromCanvasDC가 그 결과를 담아 내며, 마지막 문자에 이를 때까지 되풀이합니다

델파이 구현

다음은 라이브러리 ActiveX 에디션의 타입 라이브러리 임포트인 PDFlibAX_TLB 임포트 유닛을 씁니다. 폼은 TRichEdit 하나와 버튼 하나를 담고, 폼의 OnCreate 처리기가 컨트롤 크기를 잡고 RTF를 적재하며, 버튼 클릭이 변환 루프를 몰아 갑니다

unit MainUnit;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ComCtrls, PDFlibAX_TLB, ActiveX;

type
  TForm1 = class(TForm)
    RichEdit1: TRichEdit;
    Button1: TButton;
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
  private
    function PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
      FirstChar: Integer): Integer;
  end;

var
  Form1: TForm1;
  PdfDoc: IPDFlibrary;

implementation

{$R *.dfm}

procedure TForm1.FormCreate(Sender: TObject);
begin
  PdfDoc := CoPDFlibrary.Create;
  // 쪽 나눔이 DC와 맞도록 컨트롤을 화면 DPI 기준 A4 크기로 잡습니다
  RichEdit1.Width  := MulDiv(210, Screen.PixelsPerInch * 10, 254);
  RichEdit1.Height := MulDiv(297, Screen.PixelsPerInch * 10, 254);
  RichEdit1.Lines.LoadFromFile(
    ExtractFilePath(Application.ExeName) + 'document.rtf');
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Dc: HDC;
  PageNumber, LastChar, PdfDocId: Integer;
begin
  PageNumber := 1;
  LastChar   := 0;
  repeat
    // A4 크기로 잡힌 가상 DC를 얻습니다
    Dc := PdfDoc.GetCanvasDC(
      MulDiv(210, Screen.PixelsPerInch * 10, 254),
      MulDiv(297, Screen.PixelsPerInch * 10, 254));
    // RTF 내용의 다음 쪽을 DC에 렌더링합니다
    LastChar := PrintRtfBox(Dc, RichEdit1, LastChar);
    // DC 내용을 PDF 문서로 담아냅니다
    PdfDoc.LoadFromCanvasDC(Screen.PixelsPerInch, 0);
    PdfDocId := PdfDoc.SelectedDocument;
    PdfDoc.SaveToFile(
      ExtractFilePath(Application.ExeName)
      + 'Output' + IntToStr(PageNumber) + '.pdf');
    PdfDoc.RemoveDocument(PdfDocId);
    Inc(PageNumber);
  until LastChar = 0;
  // 컨트롤이 DC를 위해 캐시한 포맷 데이터를 내려놓게 한다
  SendMessage(RichEdit1.Handle, EM_FORMATRANGE, 0, 0);
end;

function TForm1.PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
  FirstChar: Integer): Integer;
var
  Fr: TFormatRange;
  Dpi, PageWidthTw, PageHeightTw, NextCharPosition: Integer;
begin
  // rc와 rcPage는 픽셀이 아니라 트윕(1/1440인치)이다. 쪽 크기는
  // DC와 컨트롤을 잡을 때 쓴 같은 DPI로 변환한다
  Dpi          := Screen.PixelsPerInch;
  PageWidthTw  := MulDiv(rtfBox.Width,  1440, Dpi);
  PageHeightTw := MulDiv(rtfBox.Height, 1440, Dpi);

  Fr.hdc         := hDc;
  Fr.hdcTarget   := hDc;
  Fr.rcPage      := Rect(0, 0, PageWidthTw, PageHeightTw);
  Fr.rc          := Fr.rcPage;  // 페이지 전체에 그린다. 여백이 필요하면 줄인다
  Fr.chrg.cpMin  := FirstChar;
  Fr.chrg.cpMax  := -1;

  NextCharPosition :=
    SendMessage(rtfBox.Handle, EM_FORMATRANGE, 1, LPARAM(@Fr));
  if NextCharPosition < Length(rtfBox.Text) then
    Result := NextCharPosition
  else
    Result := 0;  // 마지막 쪽임을 알립니다
end;

end.

루프가 하는 일

PrintRtfBox는 TFormatRange 구조체를 채워 SendMessage로 Rich Edit 컨트롤에 넘깁니다. 컨트롤은 cpMin에서 시작해 문자를 렌더링하다가 DC가 차면 멈추고, 들어가지 못한 첫 문자의 위치를 돌려줍니다. 반환 값이 전체 텍스트 길이와 같거나 그것을 넘으면 모든 문자가 렌더링된 것이고, 함수는 0을 돌려주어 repeat...until 루프를 끝냅니다

반복마다 Output1.pdf, Output2.pdf 하는 식으로 이름 붙은 PDF 파일 하나가 나옵니다. 대신 여러 쪽짜리 문서 하나를 원한다면 AddToFileList에 MergeFileList를 더해 나중에 쪽별 파일을 조립하거나, 루프를 재구성해 새 쪽 문서를 MergeDocument로 첫 문서에 접을 수 있습니다. 위의 반복마다 SaveToFile 뒤 RemoveDocument 패턴은 최대 메모리를 한 쪽 분량의 내용으로 한정해 주며, 아주 긴 RTF 파일에서 중요합니다

사람들이 걸려 넘어지는 크기 잡기 세부

LoadFromCanvasDC의 DPI 인수는 여러분의 픽셀 몇 개가 1인치인지를 라이브러리에 알려 줍니다. 쪽은 캔버스 너비를 DPI로 나눈 뒤 72를 곱한 포인트 폭으로 나오고, 높이도 마찬가지입니다. 이것을 틀리면 화면에서는 이미지가 올바로 보여도 출력에서 텍스트 크기가 틀리게 나옵니다

rc와 rcPage의 단위는 이 기법의 대부분의 Delphi 샘플이 틀리는 세부입니다. EM_FORMATRANGE는 그림을 그리는 DC가 픽셀로 동작하더라도 두 사각형을 트윕, 즉 1/1440인치 단위로 읽습니다. 픽셀 값을 채워 넣으면 컨트롤은 96 DPI에서 15배나 작은 쪽에 텍스트를 배치합니다. 통과마다 몇 단어씩, 그리고 10페이지 문서를 수백 개의 거의 빈 PDF로 바꾸는 루프가 되죠. DC를 잡은 DPI로 MulDiv(Pixels, 1440, Dpi) 변환을 하면 쪽 나눔이 캔버스가 실제 끝나는 곳에서 일어납니다. rcPage는 페이지 전체이고 rc는 텍스트가 차지할 수 있는 영역이라, 여백은 그 안에서 rc를 줄여 만들며 이것도 트윕입니다. 루프가 끝나면 nil lParam으로 EM_FORMATRANGE를 한 번 더 불러 컨트롤이 DC를 위해 캐시한 포맷 데이터를 해제하게 합니다

PDF: EM_FORMATRANGE의 rcPage와 rc 중첩 사각형. 둘 다 가상 DC를 잡은 DPI로 캔버스 픽셀에서 변환한 트윕으로 주어진다
rcPage는 페이지 전체이고 rc는 텍스트가 쓸 수 있는 영역입니다. 둘 다 EM_FORMATRANGE에는 트윕으로 들어가며, 그 밑의 DC는 픽셀로 머뭅니다

끝으로, FormCreate가 돌 때 컨트롤이 이미 보이는 폼 창에 붙어 있어야 첫 SendMessage 호출 전에 그 창 핸들이 유효합니다. 실행 중에 동적으로 만든 TRichEdit는 폼이 아직 표시되지 않았다면 렌더링 루프가 시작되기 전에 HandleNeeded를 명시적으로 불러 주어야 합니다

글꼴과 RTF 기능 다루기

렌더링을 Windows Rich Edit 엔진이 하므로, 글꼴 대체는 그것이 표시와 인쇄에 쓰는 것과 같은 규칙을 따릅니다. RTF 파일이 참조하는 글꼴 중 컴퓨터에 설치된 것은 충실하게 렌더링되고, 없는 것은 말없이 대체되어 줄 길이와 쪽 나눔이 어긋날 수 있습니다. 생산 환경의 배치 변환에서는 이것을 명시적으로 시험해 볼 값어치가 있습니다. RTF 원본이 쓰는 서체마다 문서를 하나씩 적재해, 출력 쪽 수가 수동 인쇄 미리 보기에서 기대한 것과 맞는지 확인하십시오

표, 임베드된 이미지, 대부분의 서식 있는 텍스트 기능은 Rich Edit가 네이티브로 렌더링하므로 별도 처리 없이 잘 됩니다. 의외일 수 있는 한 영역은 트윕으로 표현된 사용자 지정 문단 간격이나 첫 줄 들여쓰기를 쓰는 텍스트입니다. Rich Edit는 TFormatRange의 사각형과 같은 단위인 트윕(1/1440인치)으로 재는 반면, 그 밑의 DC는 현재 DPI의 픽셀로 동작합니다. 컨트롤이 그리는 동안 한쪽을 다른 쪽으로 매핑하지만, RTF를 프로그램으로 짓고 있다면 여백 값도 트윕인지 확인해야 합니다

DPI 인식과 고 DPI 디스플레이

150% 배율(144 DPI)로 돌아가는 디스플레이에서는 밀리미터-픽셀 변환이 100% 디스플레이보다 큰 픽셀 수를 돌려줍니다. PDF Library는 GetCanvasDC에 넘긴 픽셀 치수를 그대로 기록하고, LoadFromCanvasDC의 DPI 인수로 PDF 안의 물리적 쪽 크기를 역산합니다. 밀리미터를 픽셀로 바꿀 때 쓴 그 DPI 값을 그대로 넘기기만 하면, 디스플레이 배율과 무관하게 출력 쪽 크기는 올바릅니다

애플리케이션이 DPI 비인식(옛 기본값)이라면 Windows가 화면 DC를 배율 조정하므로 고 DPI 머신에서 픽셀 계산이 틀립니다. 가장 간단한 해결은 애플리케이션 매니페스트에 DPI 인식을 선언하는 것입니다. 그러면 애플리케이션이 진짜 장치 픽셀을 받고, 샘플이 LoadFromCanvasDC에 넘기는 Screen.PixelsPerInch 값이 100% 배율을 가정하는 대신 실제 디스플레이 DPI를 보고합니다

출력 구조: 쪽마다 파일 하나 대 합쳐진 문서 하나

위 루프는 각 쪽을 별개의 PDF 파일에 기록합니다. 그것이 원하는 것인지는 하류 용도에 달려 있습니다. 보고서 생성 시스템은 최종 문서를 나중에 쪽 병합이나 재정렬로 조립하므로 종종 개별 쪽이 필요합니다. 처음부터 PDF 하나를 원한다면 라이브러리가 한 세션에서 만들게 해 줍니다. 첫 LoadFromCanvasDC 호출이 만든 문서의 ID를 붙잡아 두고, 이후 통과마다 SelectDocument로 그것을 선택한 뒤 SaveToFile 대신 새 쪽의 문서 ID로 MergeDocument를 부르고, 루프가 끝난 뒤 한 번만 저장하세요. 중간 파일이 없어지고 대부분의 단일 문서 변환 시나리오에 맞는 구조입니다

PDF: 나중에 조립하려고 통과마다 PDF 파일을 하나씩 쓰는 방식과, 루프 안에서 쪽을 덧붙여 여러 쪽짜리 PDF 하나를 짓는 방식 사이의 선택
같은 렌더링 루프가 따로 떨어진 Output 파일들이든 자라나는 문서 하나든 먹여 주며, 하류의 유연성과 중간 파일을 맞바꿉니다

큰 RTF 파일에서는 루프에 진행 상황 표시를 얼마쯤 더할 값어치가 있습니다. 변환 속도가 쪽 수에 대략 비례하고 200쪽 문서는 몇 초가 걸릴 수 있기 때문입니다. repeat...until 구조는 늘리기 쉽습니다. 반복마다 LastChar를 RichEdit1.GetTextLen에서 얻은 전체 문자 수로 나누어 진행 막대를 갱신하며 문자 오프셋을 좇으십시오

여기 보인 GetCanvasDC와 LoadFromCanvasDC 메서드는 Delphi와 C++Builder용 losLab PDF Library의 일부입니다