기술 문서

PDF Library for Delphi: Delphi에서 DLL, ActiveX, and Dylib integration

여기 PDF 라이브러리가 자신의 모국어를 벗어나는 순간 나타나는 문제가 있습니다. Windows의 C#에서 완벽하게 동작하는 바인딩이 있습니다. macOS의 Python에서도 같은 호출이 필요해서 Windows 선언 파일을 복사하고 바이너리 이름만 바꿔서 실행합니다. 모든 심볼이 잘 해석됩니다. 첫 번째 호출은 쓰레기 값을 반환하고, 두 번째 호출은 액세스 위반으로 크래시하지만, PDF 관련 코드는 한 줄도 바뀌지 않았습니다. 문제는 PDF보다 한 단계 아래에 있습니다. Windows 익스포트는 Stdcall 호출 규약을 사용하는데, macOS dylib는 같은 함수를 밑줄이 앞에 붙은 Cdecl로 익스포트하며, 둘 중 하나라도 잘못 선언된 외부 함수 선언은 문서를 단 하나도 열기 전에 스택을 손상시킵니다

이 모든 종류의 실패는 미리 이해해 둘 가치가 있는 하나의 설계 결정에서 비롯됩니다. losLab이 Delphi와 C++Builder용으로 제공하는 소스 공개 PDF 엔진인 PDF Library for Delphi는 전체 객체 모델을 하나의 평평한 파사드 클래스인 TPDFlib에 감싼 다음, 그 파사드를 세 가지 바이너리 형태로 배포합니다. 약 1,250개의 함수를 익스포트하는 Windows DLL, COM/ActiveX 자동화 객체, macOS dylib입니다. PDF 의미론은 세 형태 모두에서 동일합니다. 문제가 나는 부분은 그 아래의 ABI에 있습니다. 호출 규약, 문자열 인코딩, 핸들 소유권, 그리고 어느 쪽이 어느 버퍼를 해제할 수 있는지가 그것입니다

하나의 파사드, 세 가지 바이너리 형태

TPDFlib의 모든 공개 함수에는 DL과 메서드 이름을 이어 붙인 평평한 대응 함수가 있습니다. LoadFromFileDLLoadFromFile이 되고, EncryptDLEncrypt가 되며, NewSignProcessFromFileDLNewSignProcessFromFile이 됩니다. 거의 모든 익스포트 함수의 첫 번째 매개변수는 DLCreateLibrary가 반환하는 InstanceID로, Delphi 호출자가 대신 들고 있었을 객체 참조를 대체합니다. 이 대응 관계를 초반에 체화해 두어야 합니다. Delphi API 레퍼런스가 곧 다른 모든 언어를 위한 문서 역할도 겸한다는 뜻입니다. 클래스가 할 수 있는 일이라면 DLL도 예측 가능한 이름 아래에서 할 수 있으며, Pascal 메서드 시그니처를 읽으면 Python이나 C#에서 필요한 호출을 알아낼 수 있습니다

Windows 빌드는 PDFlibDLL32.dllPDFlibDLL64.dll을 만듭니다. 호스트 프로세스의 비트수에 맞는 쪽을 선택해야 합니다. 64비트 Java나 .NET 프로세스는 선언이 어떻게 생겼든 32비트 라이브러리를 로드할 수 없기 때문입니다

하나의 TPDFlib 퍼사드가 Stdcall Windows DLL, Safecall ActiveX 자동화 객체, Cdecl macOS dylib으로 노출되는 아키텍처 다이어그램
세 바이너리 모두 하나의 평면한 PDF 퍼사드를 공유하지만, 호출 규약, 문자열 처리, 등록 요구 사항은 서로 다릅니다

Windows: Stdcall 인스턴스와 W/A 함수 쌍

문자열을 받는 익스포트는 각각 두 벌씩 존재합니다. 와이드 버전은 PWideChar(UTF-16이며 .NET, Java, Python의 c_wchar_p에 자연스럽게 들어맞습니다)를 받고, A가 접미사로 붙은 버전은 PAnsiChar를 받습니다. 둘은 동일한 의미를 지니며 인코딩만 다른데, 바로 이 점 때문에 둘을 섞어 쓰면 추적하기가 몹시 고통스럽습니다. 아무것도 예외를 던지지 않고, 아무것도 오류 코드를 반환하지 않으며, 그저 메타데이터에 깨진 문자가 나타나거나 순수 ASCII를 벗어난 문자를 가진 경로에서 엉뚱한 "파일을 찾을 수 없음"이 뜰 뿐입니다. 팀이 이런 식으로 처음 만나는 인코딩 버그는 보통 오후 반나절을 잡아먹습니다. 증상은 데이터를 가리키는데 원인은 선언에 있기 때문입니다

// Windows 바인딩(PDFlibDLL64.dll): Stdcall, 그대로의 익스포트 이름
function DLCreateLibrary: Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLCreateLibrary';
function DLReleaseLibrary(InstanceID: Integer): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLReleaseLibrary';
function DLLoadFromFile(InstanceID: Integer;
  FileName, Password: PWideChar): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLLoadFromFile';

// macOS 바인딩: 같은 함수, Cdecl, 익스포트 이름 앞에 밑줄
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

호스트마다 문자 폭을 하나로 정하고 바인딩 생성기에 그대로 규칙화하십시오. 실용적인 규칙 하나: 호스트 언어가 네이티브 UTF-16 문자열을 가지고 있다면 어디서나 W 버전을 바인딩하고 다시는 A 계열을 건드리지 마십시오

macOS: 같은 이름, 다른 ABI

dylib는 같은 DL 함수 집합을 두 가지 체계적인 변경과 함께 익스포트합니다. 호출 규약은 Stdcall이 아니라 Cdecl이며, 모든 익스포트 이름 앞에는 밑줄이 붙습니다(_DLCreateLibrary, _DLLoadFromFile 등). 두 변경 모두 순전히 기계적이므로 생성된 바인딩에는 이상적이지만 Windows 파일을 손으로 편집한 사본에는 위험합니다. 도구가 허용한다면 하나의 정본 함수 목록을 유지하고 거기서 플랫폼별 선언을 생성하십시오. 이를 건너뛰면 이 문서 맨 위에서 설명한 바로 그 스택 손상을 겪게 되며, 그것도 CI가 가장 적게 실행하는 플랫폼에서만 재현됩니다

COM과 ActiveX 호스트: Safecall과 Olevariant 페이로드

VB.NET, C#, VBScript, 그리고 레거시 자동화 호스트를 위해 OCX 빌드는 같은 파사드를 IDispatch 자동화 객체인 IPDFlibrary로 감싸며, 모든 메서드를 Safecall로 선언합니다. 이 규약은 오류가 여러분에게 도달하는 방식을 바꿔 놓습니다. Safecall은 내부 실패를 COM HRESULT로 번역하므로, C# 호출자는 예외를 캐치하는 반면 평평한 DLL이었다면 호출자가 스스로 확인해야 하는 조용한 정수를 반환했을 것입니다. 같은 동작이라도 어느 바이너리를 로드했는지에 따라 실패를 알리는 관용구가 두 가지로 갈립니다

바이너리 데이터는 COM에 특화된 또 다른 규칙을 따릅니다. 이 자동화 인터페이스에는 포인터 매개변수가 전혀 없습니다. 들어오는 이미지 바이트든 나가는 PDF 바이트든 바이너리에 해당하는 모든 것은 AddImageFromVariant, AppendToVariant 같은 메서드를 통해 Olevariant로 경계를 넘습니다. .NET에서 바이트 배열을 variant로 마샬링하는 것은 한 줄이면 됩니다. 같은 프로세스니까 상관없을 거라 생각하고 대신 원시 포인터를 건네려 하면, 디스패치 계층이 호출을 거부하거나 망가뜨립니다. 배포에서 발목을 잡는 등록 관련 세부사항이 하나 더 있습니다. COM 등록은 비트수마다 별도이므로, 32비트 regsvr32로 등록한 OCX는 64비트 호스트에게는 보이지 않습니다. 이 불일치는 그 유명한, 아무 도움도 안 되는 "클래스가 등록되지 않았습니다"라는 형태로 여러분의 손을 떠난 지 한참 지난 고객 컴퓨터에서 나타납니다

핸들 규율: 인스턴스가 문서를 소유합니다

평평한 API는 정수 핸들 위에서 동작합니다. DLCreateLibrary는 인스턴스를 반환합니다. 파일을 로드하면 그 인스턴스 안의 문서 ID가 반환됩니다. 서명 프로세스, 문자열 목록, 다이렉트 액세스 파일은 각각 자신만의 정수 핸들을 반환하며, 모두 같은 인스턴스 범위에 속합니다. 생명주기는 어떤 FFI 호스트에서 보든 동일하며, 여기서는 깔끔하게 읽히므로 Pascal로 보여드립니다:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // 워커 스레드당 하나의 인스턴스
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // DocumentID를 반환, 실패 시 0
    if Doc <> 0 then
    begin
      DLEncrypt(Inst, 'owner-secret', 'user-secret', 3,
        DLEncodePermissions(Inst, 1, 0, 0, 0, 0, 0, 0, 1));
      DLSaveToFile(Inst, 'out.pdf');
    end;
  finally
    DLReleaseLibrary(Inst);                      // 인스턴스가 소유한 모든 문서를 해제
  end;
end;

이 소유 트리에서 두 가지가 따라옵니다. DLReleaseLibrary는 여러분에게 엄밀히 필요한 유일한 정리 호출입니다. 인스턴스 아래의 모든 문서와 프로세스 핸들을 한 번에 무너뜨리기 때문입니다. 짧은 스크립트라면 그것으로 충분합니다. 오래 실행되는 서비스에서는 이것이 여분의 격식을 갖춘 느린 누수가 되므로, 인스턴스가 죽을 때까지 쌓아두는 대신 문서를 다 쓰면 그때그때 해제해야 합니다. 인스턴스는 또한 스레드 격리의 자연스러운 단위이기도 합니다. 각 워커 스레드에 자신만의 InstanceID를 주고, 단일 TPDFlib 객체를 스레드 간에 절대 공유하지 않는 것과 같은 이유로 외부 잠금 없이는 인스턴스를 스레드 간에 절대 공유하지 마십시오

반환된 문자열은 빌려온 것이지 소유한 것이 아닙니다

DLGetPageText처럼 텍스트를 반환하는 함수는 라이브러리 인스턴스가 소유하고 재활용하는 버퍼를 가리키는 PWideChar 또는 PAnsiChar를 돌려줍니다. 계약은 이렇습니다: 즉시 복사하고, 절대 해제하지 말 것

PDF Library for Delphi 타임라인: 빌린 DLGetPageText 포인터를 즉시 복사하는 것과 라이브러리가 내부 버퍼를 재활용할 때까지 들고 있는 것 대비
반환된 char 포인터는 인스턴스가 재활용하는 저장소를 빌려 쓰는 것이므로, 다음 라이브러리 호출 전에 복사해야 합니다
var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // 라이브러리가 소유한 버퍼를 가리키는 포인터
  PageText := P;                 // 지금 복사할 것; 이후 호출이 버퍼를 재사용할 수 있음
end;

C#에서 이는 다음 라이브러리 호출 전에 IntPtr을 관리되는 문자열로 마샬링해야 한다는 뜻입니다. Python ctypes에서는 포인터에서 와이드 문자열을 즉시 잘라내야 한다는 뜻입니다. 여러 호출에 걸쳐 원시 포인터를 붙잡고 있으면, 모든 단위 테스트는 통과하지만 운영 환경에서 두 요청이 처음으로 겹치는 순간 실패하는 버그를 작성한 셈이 됩니다. 두 번째 호출이 첫 번째 호출이 여전히 읽고 있던 버퍼를 재활용했기 때문입니다. 같은 소유권 규칙은 DLSetProgressCallback으로 등록한 콜백에 대해서는 반대 방향으로도 적용됩니다. 라이브러리가 콜백에 넘겨주는 모든 포인터는 그 콜백의 본문 동안만 유효하며, 콜백 객체 자체는 인스턴스가 여전히 그것을 호출할 수 있는 동안(가비지 컬렉션 호스트에서는 고정된 채로) 살아 있어야 합니다. 작업 도중 회수된 델리게이트는 몇 달 동안 문제없이 실행되던 .NET 바인딩에서 "무작위로" 나타나는 액세스 위반의 교과서적 원인입니다

바인딩 자체에 스모크 테스트를 만들어, 생성된 선언 집합을 배포하기 전에 반드시 실행하십시오. ABI 실수를 드러내는 경향이 있는 각 범주에서 호출을 하나씩 실행해 보십시오. 규약이 옳은지 증명하는 DLCreateLibrary 같은 매개변수 없는 함수, 인코딩이 옳은지 증명하는, 비 ASCII 문자를 가진 경로를 넘기는 문자열 입력 함수, 빌려온 버퍼 처리가 옳은지 증명하는 문자열 출력 함수, 그리고 오류가 여러분의 호스트에 어떻게 도달하는지 지켜볼 수 있도록 일부러 실패하는 연산 하나입니다. 15분이면 되는 작업이며, 그렇지 않았다면 몇 달 뒤 고객의 크래시 덤프로 도착했을 호출 규약 및 인코딩 결함을 잡아냅니다

PDF Library for Delphi 2x2 그리드 바인딩 스모크 테스트 프로브: 호출 규약, 문자열 인코딩, 빌린 버퍼, 실패 노출 커버
네 가지 저비용 프로브가 생성된 선언이 고객 머신에 도달하기 전에 호출 규약, 인코딩, 소유권 결함을 잡아냅니다

구체적인 사례: Python ctypes

Python ctypes는 제가 가장 자주 직접 작성된 채로 목격하는 바인딩이며, 크로스 플랫폼 분기를 보여주기에도 좋습니다. Windows에서는 ctypes.WinDLL로 라이브러리를 로드해 ctypes가 Stdcall을 적용하게 하고, 접미사 없는 W 함수를 바인딩하며, 모든 문자열 매개변수를 c_wchar_p로 선언하십시오. macOS에서는 Cdecl을 위해 ctypes.CDLL로 로드하고, 동일한 함수 목록을 유지하며, 앞의 밑줄 없이 이름을 해석하십시오. ctypes를 포함한 대부분의 FFI 계층은 macOS에서 밑줄 규약을 대신 접어 넣어 주지만, 그 위에 수백 개의 선언을 생성하기 전에 단 한 번의 해석된 호출로 확인해 둘 가정이 바로 그것입니다

바인딩 작업 뒤에는 명쾌한 답을 가진 배포 관련 질문이 둘 따라옵니다. 순수 DLL은 등록이 전혀 필요 없습니다. regsvr32는 ActiveX 빌드에만 적용되며, DLL은 파일 복사만으로 배포되므로, 레지스트리를 아예 건드리고 싶지 않은 Windows 서비스와 컨테이너에서 DLL을 선호하는 주된 이유가 됩니다. 스레드 안전성은 이미 위에서 다룬 규칙, 즉 스레드당 하나의 인스턴스로 귀결됩니다. 인스턴스 핸들은 엔진이 추적하는 가변 상태 전체, 선택된 문서, 렌더링 옵션, 추출 설정을 보유하므로, 두 스레드가 인스턴스를 공유하면 개별 호출 하나하나가 성공을 반환하더라도 서로의 상태가 뒤섞입니다

바인딩이 견고해지고 나면, 그 너머의 연산들은 Delphi 문서들이 깊이 있게 다루는 바로 그 내용이며, PDF 암호화 적용과 감사, 기존 문서에서 텍스트와 이미지 추출하기가 여기에 포함됩니다

세 가지 통합 계층 모두를 위한 바이너리 다운로드가 라이브러리와 함께 제공됩니다. 에디션과 라이선스에 대해서는 PDF Library for Delphi 제품 페이지를 참고하십시오