기술 문서

HotPDF로 Delphi에서 PDF 페이지를 SVG로 내보내기

HotPDF는 로드된 PDF 문서의 한 페이지를 BuildLoadedPageSVG 호출 한 번으로 독립적인 SVG 마크업으로 내보내며, 이 함수는 완성된 SVG 문서 전체를 문자열로 반환합니다. 내보낸 마크업에는 페이지 지오메트리, 실제 SVG text 요소로 표현된 텍스트, 임베드된 래스터 이미지, 그리고 각 드로잉 연산 시점에 PDF 연산자가 설정해 둔 스트로크 상태가 함께 담깁니다

바로 이 마지막 부분에서 자체 제작 컨버터 대부분이 조용히 무너집니다. PDF 페이지를 SVG로 바꾸는 작업은 좌표 문제처럼 보이지만 실제로는 상태 문제입니다. PDF는 콘텐츠 스트림이 해석되는 동안 그래픽 상태가 변하는 스택 머신이고, SVG는 각 요소가 자기만의 표현 속성을 갖는 선언적 트리입니다. 인터프리터가 요소를 내보내는 순간 스냅샷하지 못한 것은 결과물에서 그냥 사라지며, 이 실패는 조용히 일어납니다. 즉 유효한 SVG가 만들어지긴 하지만 미묘하게 틀린 페이지를 렌더링하게 됩니다

PDF 페이지는 왜 그냥 SVG로 변환되지 않을까?

세 가지 불일치 때문에 이 변환은 결코 단순하지 않으며, 세 가지 모두 원본과 나란히 비교해 보기 전까지는 그럴듯해 보이는 결과물을 만들어냅니다. 첫 번째는 y축입니다. PDF 사용자 공간은 페이지 왼쪽 아래 모서리에서 위쪽으로 커지고, SVG는 왼쪽 위 모서리에서 아래쪽으로 커집니다. 페이지 전체를 한 번 뒤집으면 그리기 좌표는 맞지만 모든 글리프가 깨지는데, 캔버스 전체를 뒤집으면 글자 모양까지 좌우가 반전되기 때문입니다

두 번째 불일치는 상속입니다. PDF에서 qQ는 선 굵기, 선 끝 모양, 선 연결 모양, 마이터 한계, 대시 배열, 대시 위상, 알파를 포함하는 그래픽 상태를 push하고 pop합니다. SVG에서는 속성을 명시하지 않은 요소가 조상 그룹으로부터 상속받는데, 이는 전혀 다른 스코프 규칙입니다. 현재 변환 행렬만 추적하고 스트로크 상태를 놓치는 익스포터는 Q 이후 복원된 상태가 뒤이은 요소로 새어 들어가게 둡니다

세 번째는 PDF가 여러 가지를 값이 아니라 관례로 표현한다는 점입니다. 선 끝 모양과 연결 모양은 정수이고, 선 굵기 0은 보이지 않는 선이 아니라 디바이스 공간 헤어라인을 뜻하며, 페인팅 연산자의 별표 변형은 색상이 아니라 와인딩 규칙을 바꿉니다. 이 각각은 그대로 복사할 게 아니라 번역이 필요합니다

일반적인 경우를 위한 호출 한 번

웹 뷰어, 비교 도구, 디자인 인계용으로 페이지를 내보내는 평범한 작업이라면 API 표면은 함수 하나면 충분합니다. BuildLoadedPageSVG는 현재 로드된 문서 안에서 0부터 시작하는 페이지 인덱스를 받아 SVG 문서를 AnsiString으로 반환합니다:

var
  Pdf: THotPDF;
  I: Integer;
  Svg: AnsiString;
  Output: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('statements.pdf', '') <= 0 then
      Exit;                     // LoadFromFile returns the page count
    for I := 0 to Pdf.LoadedPageCount - 1 do
    begin
      Svg := Pdf.BuildLoadedPageSVG(I);
      if Length(Svg) = 0 then
        Continue;
      Output := TFileStream.Create(Format('page-%d.svg', [I + 1]), fmCreate);
      try
        Output.WriteBuffer(Svg[1], Length(Svg));
      finally
        Output.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

같은 내보내기 기능은 HotPDF 명령줄 도구의 export-svg 명령으로도 제공되며, Pascal 코드를 한 줄도 쓰지 않고 페이지를 텍스트로 diff 가능한 형태로 얻고 싶은 빌드 파이프라인이나 회귀 스크립트에서 유용합니다. SVG는 텍스트이므로 PDF 페이지를 비트맵으로 렌더링하기에서 설명한 래스터 경로와 자연스러운 짝을 이룹니다. 비트맵이 페이지가 어떻게 보이는지를 알려준다면, SVG는 페이지가 무엇으로 이루어져 있는지를 알려줍니다

PDF 텍스트는 SVG 텍스트 요소로 어떻게 매핑될까?

HotPDF는 텍스트 행렬 체인을 prefix 곱하기 CTM 곱하기 텍스트 행렬 곱하기 글리프 반전 순서로 구성하며, 이때 글리프 반전은 matrix(1,0,0,-1,0,0)을 오른쪽에서 곱하는 연산입니다. 이 오른쪽 인수는 오로지 글리프 형태에 적용된 페이지 단위 수직 반전을 상쇄하기 위해 존재하는데, 그렇지 않으면 뒤집힌 로컬 프레임 안에 그려진 SVG 텍스트가 거꾸로 보이게 됩니다. 보정을 특수 처리 코드가 아니라 행렬 안에 넣으면 회전, 반전, 기울어진 텍스트가 추가 분기 없이도 모두 올바르게 나옵니다

가로 위치 지정은 SVG text 요소의 다중 값 x 문법을 사용하는데, 문자마다 좌표 하나씩을 두며 각 글리프 이동량에 그 시점에 적용 중이던 문자 간격 Tc와 단어 간격 Tw를 더해 누적합니다. 가로 스케일 Tz는 별도로 내보내지 않고 텍스트 행렬의 a, c 열에 접어 넣기 때문에, 특수한 텍스트 속성을 무시하는 뷰어라도 PDF가 배치한 위치에 모든 글리프를 그대로 놓게 됩니다. 복합 스크립트 텍스트 셰이핑에서 다룬 복잡한 셰이핑을 거쳐 만들어진 텍스트도 같은 경로를 지나가는데, 콘텐츠 스트림이 해석될 시점에는 셰이퍼가 이미 클러스터를 위치가 정해진 글리프로 해결해 둔 상태이기 때문입니다

회전과 이미지: 거꾸로 하기 쉬운 두 가지 반전

0이 아닌 /Rotate 항목이 있는 페이지는 회전된 캔버스 높이를 기준으로 한 반전과 y축이 위로 향하는 표시 공간에서의 회전을 결합한 사전 변환이 필요합니다. 세 가지 회전 행렬은 90도일 때 (0,-1,1,0,0,W), 180도일 때 (-1,0,0,-1,W,H), 270도일 때 (0,1,-1,0,H,0)이며 W와 H는 회전 전 페이지 크기입니다. 이를 손으로 유도하면 정확히 세 군데에서 부호 오류가 나기 쉬우므로, 익스포터는 다른 모든 변환을 처리하는 것과 같은 행렬 곱셈 루틴을 통해 이를 구성합니다

임베드된 이미지 역시 자체적인 반전이 필요한데, PDF 이미지 공간은 첫 샘플 행을 단위 정사각형의 위쪽 모서리에 두는 반면 SVG image 요소는 y축이 아래로 향하는 로컬 프레임을 갖기 때문입니다. 따라서 내보내지는 변환은 CTM에 matrix(1,0,0,-1,0,1)을 오른쪽에서 곱한 값입니다. 이를 잘못 처리하면 나머지는 완벽한 페이지에서 사진만 수직으로 뒤집혀 나오는데, 이런 결함은 검토자가 즉시 알아채지만 자동화 테스트는 종종 놓치는 종류입니다

그래픽 상태 디바이스는 실제로 무엇을 보존할까?

HotPDF는 스트로크 상태 연산자 w, J, j, M, d를 별도의 선택적 디바이스 인터페이스를 통해 디스패치하므로, 기존 콘텐츠 디바이스의 vtable을 바꾸지 않고도, 그리고 이전 버전으로 빌드된 코드와의 바이너리 호환성을 깨지 않고도 스트로크 충실도를 추가할 수 있었습니다. 구체적으로, 내보내진 SVG는 원시 PDF 정수 대신 번역된 키워드를 받습니다:

// PDF integer enumerations become SVG keyword attributes
//   line cap  0, 1, 2  ->  butt, round, square
//   line join 0, 1, 2  ->  miter, round, bevel
//
// Zero line width means a device-space hairline in PDF, so the
// exporter emits vector-effect="non-scaling-stroke" to keep the
// stroke visible and near one device pixel wide after the CTM
//
// f* B* b* select the even-odd rule and emit fill-rule="evenodd",
// while f B b keep the SVG default of nonzero winding

Q에서 상태를 복원할 때는 불투명도, 선 굵기, 선 끝, 연결, 마이터 한계, 대시 배열, 대시 위상을 함께 다룹니다. 중첩된 Form XObject도 경계에서 같은 전체 상태 집합을 스냅샷하고 복원하므로, 스탬프 안에 정의된 대시 테두리가 뒤이은 페이지 콘텐츠로 패턴이 새어 나갈 수 없습니다. 다른 이유로 클리핑과 CTM 동작을 이미 추적하고 있다면, 이는 EMF 및 WMF 벡터 임포트에 등장하는 것과 같은 상태 모델이 반대 방향으로 동작하는 것입니다

배포하기 전에 알아둘 만한 경계

이 익스포터는 자신의 범위에 대해 정직하며, 그 경계를 미리 알아두는 편이 실제 운영 환경에서 발견하는 것보다 저렴합니다. 색상은 rg, RG, g, G 연산자를 통해 SVG 디바이스에 도달합니다. Separation, DeviceN, ICCBased 색상이 칠해지는 방식인 색상 공간과 scn을 통해 설정된 채우기는 해석된 RGB 삼중값으로 디바이스에 도달하지 않으므로, 이런 방식으로 별색을 사용하는 페이지는 지오메트리만 내보내고 그 색상은 내보내지 못합니다. 인쇄 지향 소스라면 대신 래스터화하거나 먼저 별색을 평면화하십시오. 페인팅 모델 자체는 Separation 및 DeviceN 별색 렌더링에서 다룹니다

디버깅 시간을 아껴줄 작은 참고 사항이 두 가지 있습니다. 16진수 색상 리터럴은 대문자로 내보내지므로, #ff0000을 기대하는 테스트는 완전히 올바른 #FF0000에 대해서도 실패합니다. 그리고 SVG 디바이스는 인터페이스를 통해 참조 카운트되므로, 해제는 객체에 Free를 호출하는 것이 아니라 인터페이스가 스코프를 벗어나게 하는 문제이며, 이는 페이지 콘텐츠와 함께 자체 마크업을 내보내도록 디바이스를 확장할 때 중요해지는 구분입니다

생성된 문서가 두 빌드 사이에서 정말로 달라졌는지 알아야 할 때, SVG 내보내기는 구조적 비교와 자연스럽게 짝을 이룹니다. 로드된 문서를 둘러싼 렌더링, 편집, 내보내기에 이르는 더 폭넓은 도구 모음은 HotPDF Delphi PDF 컴포넌트 페이지에 정리되어 있습니다