PDFium 컴포넌트의 FPDF_RenderPageBitmap 함수는 rotate 인자를 받는데, PDFium은 이를 페이지가 이미 자신의 /Rotate 항목에 가지고 있는 회전값 위에 항상 추가로 더한다. 그래서 페이지에 저장된 회전값을 읽어 그 같은 값을 렌더 호출에 다시 넣으면 페이지는 두 번 회전한다. 똑같은 실수가 fit-zoom 연산에서도 나타난다: /Rotate가 90도나 270도일 때 페이지의 회전되지 않은 너비와 높이로 썸네일 크기를 정하면 잘못된 종횡비가 나오는데, 렌더링된 비트맵은 너비와 높이가 뒤바뀐 채로 나오기 때문이다
이 실패는 무엇을 찾아야 하는지 알고 나면 발견하기 쉽지만, 그 전까지는 놓치기 쉽다. 세로와 가로가 섞인 원본 스캔 인보이스 묶음이 도착하고, 누군가 이를 아카이브하기 전에 Acrobat에서 절반을 90도 회전으로 바로잡으면, PDFium 위에 만든 Delphi 뷰어의 썸네일 스트립은 그 특정 페이지들을 옆으로, 거꾸로, 혹은 잘못된 방향의 상자에 눌려 찌그러진 채로 렌더링한다. 예외는 전혀 발생하지 않는다. 오류도 전혀 로그에 남지 않는다. 픽셀은 그저 잘못되어 있을 뿐이고, 그것도 누군가 나중에 회전시킨 페이지의 부분집합에서만 그렇다 — 회전되지 않은 테스트 PDF를 상대로 한 전체 QA 패스는 통과하고서 실제 파일의 47페이지에서 프로덕션에 나타나는, 정확히 그런 종류의 버그다
PDFium은 왜 페이지를 두 번 회전시키는가?
PDFium은 렌더러에 무엇이 전달되든 상관없이 비트맵을 렌더링할 때마다 페이지 자체의 /Rotate 값을 자동으로 적용한다. PDFiumPas에서 TPdf.RenderPage, TPdf.RenderTile, TPdf.RenderPageThumbnail의 ro0, ro90, ro180, ro270 값인 TRotation으로 노출되는 FPDF_RenderPageBitmap의 rotate 매개변수는 페이지가 최종적으로 놓여야 할 각도를 설정하지 않는다; rotate 매개변수는 페이지 딕셔너리가 이미 명시한 것 위에 얼마나 많은 추가 회전을 얹을지를 설정하며, 이것이 이 메서드들 모두가 기본값을 ro0으로 두는 이유다
TPdf.PageRotation은 FPDFPage_GetRotation을 통해 바로 그 /Rotate 값을 읽으며, 애플리케이션 코드는 렌더링과 무관한 이유로 이를 필요로 하는 경우가 많다. 이를테면 페이지 공간 안에서 주석을 어떻게 배치할지 결정하는 것 같은 경우다. 함정은 한 줄이다: PageRotation을 RenderPage의 Rotation 인자에 넣어, 그 호출이 페이지를 똑바로 정규화해 줄 것으로 기대하는 것이다. 이미 /Rotate 90으로 저장된 페이지는 PDFium을 포함한 어떤 표준을 준수하는 뷰어에서도 회전된 채로 올바르게 표시된다; 그 위에 ro90을 다시 더하면 페이지는 의도한 90도 대신 180도로 돌아가며, 아예 회전이 없는 페이지는 아무 이유 없이 원치 않는 4분의 1 회전을 겪는다
// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);
// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);
Rotation 매개변수는 실제로 무엇을 위한 것인가
Rotation 매개변수는 진짜로 다른 역할을 위해 API 안에 자리 잡고 있다: 페이지의 저장된 방향과는 무관한 뷰 전용 회전을 추가하는 것으로, 회전 뷰 툴바 버튼이 밑에 있는 파일을 건드리지 않고 적용하는 것과 같은 종류다. TPdfView는 정확히 이 이유로 두 개념을 두 개의 별도 속성으로 유지한다. TPdfView.PageRotation은 페이지 자체의 /Rotate를 그대로 반영하며, FPDFPage_SetRotation을 통해 새 값을 문서에 다시 쓸 수도 있다; TPdfView.Rotation은 기본값이 ro0이고 파일을 절대 건드리지 않는 일시적인 뷰 전용 속성이다. 첫 번째 속성을 읽어 두 번째에 넣는 것, 그것이 이 버그의 전부를 한 문장으로 요약한 것이다
// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
case PdfView.Rotation of
ro0: PdfView.Rotation := ro90;
ro90: PdfView.Rotation := ro180;
ro180: PdfView.Rotation := ro270;
ro270: PdfView.Rotation := ro0;
end;
end;
// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
case PdfView.PageRotation of
ro0: PdfView.PageRotation := ro90;
ro90: PdfView.PageRotation := ro180;
ro180: PdfView.PageRotation := ro270;
ro270: PdfView.PageRotation := ro0;
end;
end;
Fit-zoom 크기 계산은 왜 같은 방식으로 깨지는가?
Fit-zoom 크기 계산은 거울상 같은 이유로 깨진다: 계산이 잘못된 각도가 아니라 잘못된 숫자 쌍에서 시작한다. 썸네일 상자 크기를 정하는 일반적인 방법은 PDFium에 페이지의 너비와 높이를 물어보고, 그 종횡비를 가용한 상자와 비교해, 그 안에 맞는 가장 큰 사각형을 계산하는 것이다 — 이는 회전되지 않은 페이지에서는 깔끔하게 동작한다. 너비와 높이가 페이지의 고유하고 회전되지 않은 크기를 보고하는 호출에서 왔을 때, 같은 계산은 /Rotate 90이나 /Rotate 270 페이지에서 조용히 실패한다: /Rotate 90을 가진 A4 세로 페이지는 여전히 대략 595 x 842 포인트를 보고하지만, PDFium은 회전이 적용되면 이를 올바르게 대략 842 x 595로 렌더링하며, 회전되지 않은 쌍으로부터 계산된 맞춤 상자는 완전히 잘못된 방향으로 형태가 잡히게 된다
FPDF_GetPageSizeByIndex는 설계상 그 고유하고 회전되지 않은 크기를 보고하는 구체적인 예시 중 하나다. 이는 모든 페이지를 로드하지 않고 페이지 치수를 스캔하는 데는 편리하지만, 그것을 감안하는 것을 잊은 fit-zoom 연산에는 위험하다. 이 수정은 문제의 이름을 붙이는 것에서 곧바로 따라 나온다: fit 연산을 하기 전에 페이지의 회전을 확인하고, 그 회전이 90도나 270도이면 너비와 높이를 바꾸고, 바뀐 쌍으로 fit 상자를 계산하되, 실제 렌더 호출에는 여전히 ro0을 넘긴다. PDFium이 여전히 실제 회전을 적용하는 유일한 주체이기 때문이다
Fit 연산을 재발명하지 않고 썸네일을 올바르게 만들기
TPdf.RenderPageThumbnail은 이미 이 수정을 담고 있으므로, 올바른 썸네일로 가는 가장 짧은 길은 fit-and-rotate 로직을 손으로 다시 조립하는 대신 이를 호출하는 것이다. 1부터 시작하는 페이지 인덱스와 최대 너비·높이가 주어지면, RenderPageThumbnail은 fit 상자를 계산하고, 내부적으로 /Rotate 90이나 270에 대해 이를 보정하며, 문서의 현재 페이지를 건드리거나 OnPageChange 이벤트를 발생시키지 않고 호출자가 소유하는 비트맵을 반환한다 — 이는 같은 TPdf 인스턴스에서 실시간 뷰어와 나란히 만들어진 썸네일 스트립에서 중요하다
// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
PgW, PgH, Swap: Integer;
begin
PgW := Round(PageW);
PgH := Round(PageH);
if PgW < 1 then PgW := 1;
if PgH < 1 then PgH := 1;
if Rotation in [ro90, ro270] then
begin
Swap := PgW;
PgW := PgH;
PgH := Swap;
end;
Result := (MaxW > 0) and (MaxH > 0);
if not Result then
Exit;
if PgW * MaxH > PgH * MaxW then
begin
FitW := MaxW;
FitH := (MaxW * PgH) div PgW;
end
else
begin
FitH := MaxH;
FitW := (MaxH * PgW) div PgH;
end;
end;
FitBox 헬퍼는 어쨌든 가지고 있을 가치가 있는데, RenderPageThumbnail은 단일 비트맵 경우만 다루기 때문이다. 커스텀 썸네일 그리드, 인쇄 미리보기 스트립, 또는 여러 페이지를 독립적인 상자에 배치하는 페이지 선택 대화상자는 매 타일마다 새 비트맵을 원하지 않으면서도 같은 회전 인식 fit 연산이 필요하다. TPdfView 자체의 fit-page와 fit-width 확대 모드도 내부적으로 동일한 발상에 기댄다. 가용한 클라이언트 영역과 비교하기 전에, 뷰의 현재 회전에 따라 확대 비율 계산에 페이지의 너비와 높이 중 무엇을 쓸지 선택한다. 그런 뷰어에서 확대와 스크롤 성능이 다음 문제라면, PDFium 기반 Delphi 뷰어에서의 렌더 캐싱과 부드러운 확대에 관한 자매 글이 올바른 크기 계산이 끝나는 지점에서 바로 이어받는다
고객이 발견하기 전에 이중 회전 발견하기
이중 회전에는 신뢰할 만한 시각적 특징이 하나 있다: 들어올 때 90도 회전되었던 페이지가 문서의 나머지에 대해 90도가 아니라 180도 회전된 것처럼 나온다면, 이는 추가된 ro90이 페이지 자체의 ro90 위에 쌓였기 때문이지 그것을 대체한 것이 아니기 때문이다. /Rotate 0 페이지로만 만들어진 테스트 픽스처는 이를 절대 잡아내지 못한다. ro0에 ro0을 더해도 여전히 ro0이므로 버그는 보이지 않는 채로 남기 때문이다; 썸네일이나 fit-zoom 코드 경로를 신뢰하려면 최소한 /Rotate 90으로 저장된 페이지 하나와 /Rotate 270으로 저장된 페이지 하나가 픽스처에 필요하다
PDFium 컴포넌트로 PDF 페이지를 JPEG로 렌더링하기에서 다룬 기본적인 페이지-비트맵 파이프라인은 이미 특수 케이스 코드 없이도 회전된 페이지를 올바르게 렌더링하는데, 정확히 Rotation을 기본값인 ro0으로 남겨두고 PDFium이 스스로 /Rotate를 적용하도록 두기 때문이다. 이중 회전 버그는 애플리케이션 코드가 PageRotation을 다시 읽어 그것이 있어서는 안 될 곳에 넣기 시작할 때에만 나타난다
여기서 설명한 회전 인식 렌더 호출과 썸네일 크기 계산은 같은 TPdf와 TPdfView 클래스 위에 만들어진 나머지 렌더링·뷰잉·텍스트 추출 API와 함께 Delphi와 C++Builder용 PDFium 컴포넌트의 일부다