FPDFPage_TransFormWithClip이 페이지를 다시 쓰면, 여러분이 이미 들고 있는 모든 FPDF_PAGEOBJECT 핸들은 여전히 변환 이전의 파싱 결과를 기술합니다. Delphi와 C++Builder용 PDFium Component는 이를 TransformPageContent 내부에서 해결하는데, 텍스트 페이지를 언로드하고, 콘텐츠를 재생성한 다음, 페이지를 다시 로드해서 이후의 조회가 새 좌표를 보게 합니다
이 증상은 조용합니다. 인쇄 여백을 추가하려고 0.9 스케일을 적용한 뒤 PageObjectInfo를 읽으면, 호출 전과 정확히 같은 숫자를 얻습니다. 예외도 없고, 오류 코드도 없고, 로그에도 아무것도 없습니다. 이는 편집 후 낡아지는 텍스트 페이지에 관한 글에서 설명한, 캐시된 텍스트 페이지 문제와는 다른 실패입니다. 그쪽은 캐시가 여러분이 버리고 다시 만들 수 있는 단일 FPDF_TEXTPAGE 핸들이지만, 여기서 문제는 여러분 자신의 변수 안에 있는 모든 페이지 객체 핸들과, 대부분의 호출자가 그냥 버려버리는 반환 코드로만 실패를 알려주는 게터 부류입니다
왜 페이지 객체 경계는 오류 없이 낡아지는가
페이지 객체 핸들은 특정 콘텐츠 스트림의 파싱된 표현을 가리키는 포인터이며, 전체 페이지 변환은 그 콘텐츠 스트림을 새것으로 교체하기 때문입니다. PDFium은 여러분의 콜 스택을 뒤져가며 패치할 핸들을 찾아다니지 않습니다. 새로운 객체 그래프를 만들고 예전 것은 있던 그대로 남겨두므로, 예전 핸들에 대한 읽기는 더 이상 파일이 말하는 것과 일치하지 않는 구조에 대한, 완전히 유효한 읽기입니다
ISO 32000-1 §7.8.2는 콘텐츠 스트림을 페이지를 그리는 연산자의 시퀀스로 정의하고, §8.3.3은 현재 변환 행렬이 사용자 공간을 디바이스 공간으로 매핑하는 방식을 정의합니다. 페이지 수준 변환은 개별 객체 좌표를 제자리에서 편집하는 것이 아니라 그 연산자들을 감싸고 다시 쓰는 방식으로 표현됩니다. 그래서 객체가 가진 좌표는 전혀 바뀌지 않을 수 있습니다. 바뀌는 것은 그것들이 그려질 때 적용되는 행렬입니다. 예전 행렬 아래에서 파싱된 어떤 핸들이든 예전 행렬 아래에서 기하 질문에 답하며, 아무런 불평 없이 답합니다
FPDFPage_TransFormWithClip이 실제로 다시 쓰는 것
페이지를 다시 쓰지, 여러분의 스냅샷을 다시 쓰지 않습니다. FPDFPage_TransFormWithClip은 FS_MATRIX와 FS_RECTF 클립 사각형을 받아 페이지 콘텐츠 전체에 둘 다 적용합니다. 여백, 조판 스케일링, 이상한 크기의 페이지를 목표 상자에 맞춰 정규화하는 데는 맞는 호출입니다. 기존 핸들이 그대로 따라올 것이라 기대하고 손을 뻗기에는 잘못된 호출이며, 이것이 페이지 콘텐츠만 건드린다는 점도 기억할 가치가 있습니다. 주석은 별개의 계층이며 TransformPageAnnotations가 필요한데, 이는 같은 6개의 행렬 계수를 FPDFPage_TransformAnnots로 전달합니다
var
Info: TPdfPageObjectInfo;
Scale: FS_MATRIX;
Clip: TPdfRectangle;
begin
Pdf.PageNumber:= 1;
Info:= Pdf.PageObjectInfo(0); // snapshot taken before the transform
Scale.a:= 0.9; Scale.b:= 0.0;
Scale.c:= 0.0; Scale.d:= 0.9;
Scale.e:= 29.7; Scale.f:= 42.0; // 5% margin, A4 in points
Clip:= Pdf.GetPageBox(pbMedia);
Pdf.TransformPageContent(Scale, Clip);
// Info.Bounds still holds pre-transform geometry, and Info.Handle now
// points into a page that TransformPageContent has already replaced
end;
TransformPageContent가 사용하는 새로고침 순서
네 단계이며 이 순서대로입니다: 텍스트 페이지 언로드, 변환, 콘텐츠 생성, 페이지 재로드. TPdf.TransformPageContent는 정확히 그 순서로 실행됩니다. CheckPageActive를 호출하고, 행렬과 클립을 네이티브 레코드 형태로 복사하고, UnloadTextPage를 호출한 다음, FPDFPage_TransFormWithClip을 호출하고, FPDFPage_GenerateContent를 감싸는 래퍼인 UpdatePage를 호출하고, 마지막으로 ReloadPage를 호출합니다
각 단계는 자기 자리를 정당화합니다. UnloadTextPage가 먼저인 이유는 캐시된 FPDF_TEXTPAGE가 예전 행렬 아래에서 계산된 문자 상자를 가지고 있기 때문이며, 이는 그로부터 만들어진 웹 링크 목록과 진행 중인 찾기 세션도 함께 지웁니다. FPDFPage_GenerateContent는 재로드 이전에 실행되어야 하는데, 변환은 콘텐츠 스트림에 직렬화될 때까지 인메모리 페이지 안에서만 존재하기 때문이며, 재로드는 그러지 않으면 변경되지 않은 스트림을 다시 파싱해버립니다. ReloadPage는 현재 페이지 인덱스에 대해 FPDF_LoadPage로 마무리되며, 이것만이 실제로 새로운 객체 그래프를 만들어줍니다
// After the transform, re-enumerate. Do not reuse anything captured earlier.
var
I: Integer;
Info: TPdfPageObjectInfo;
begin
Pdf.TransformPageContent(Scale, Clip); // unload text page, transform,
// generate content, reload page
for I:= 0 to Pdf.ObjectCount- 1 do
begin
Info:= Pdf.PageObjectInfo(I); // handle and bounds from the new parse
if Info.Bounds.Right> PageWidth then
Log('object '+ IntToStr(I)+ ' still overflows after scaling');
end;
end;
이 시퀀스를 직접 작성할 일이 있다면 ReloadPage의 세부 사항 하나는 베낄 가치가 있습니다. 새 페이지를 먼저 로드하고 그다음에야 필드에 커밋하므로, 페이지 로드가 실패하면 현재 네이티브 페이지와 그로부터 파생된 모든 캐시를 그대로 둔 채, 반쯤 헐린 상태로 떨어지지 않습니다. 재로드는 공짜가 아닙니다. 페이지 전체를 다시 파싱하는 비용을 치르는 것이지만, 그것은 조회마다가 아니라 변환마다 한 번씩 치르는 비용이며, 이보다 더 저렴한 올바른 대안은 없습니다
재로드 너머로 핸들을 넘겨서는 안 된다
재로드 이후, 예전 핸들은 단순히 낡은 것을 넘어 매달린(dangling) 상태입니다. 이전의 FPDF_PAGE는 닫혔고, 그것에 속했던 FPDF_PAGEOBJECT 값들은 해제된 메모리를 가리키는 포인터입니다. TPdfPageObjectInfo는 Handle 필드에 네이티브 핸들을 노출하는데, 이는 객체를 하위 수준 호출에 곧바로 넘길 때 정말로 유용하지만, 페이지를 재로드하는 작업을 거쳐서까지 폼 필드나 목록에 보관해두면 똑같이 정말로 위험합니다. 스냅샷 레코드는 콘텐츠를 재생성하는 다음 호출까지만 유효하다고 취급하십시오. PDFium 경계에서의 ABI와 메모리 안전 노트에서 논의하는 소유권 규칙과 같은 정신입니다
게터가 실패하고도 유효한 데이터처럼 보일 수 있는가
그럴 수 있으며, 이것이 같은 문제의 나머지 절반입니다. FPDFPageObj_GetRotatedBounds와 FPDFPageObj_GetIsActive는 출력 인자 게터입니다. int 성공 플래그를 반환하고 실제 답은 참조 인자에 씁니다. 둘 다 만들어졌지만 아직 페이지가 다시 파싱되지 않은 객체에 대해 FALSE를 반환할 수 있습니다. 그럴 때 출력 인자는 건드려지지 않은 채로 남고, Default(TPdfPageObjectInfo)로 초기화된 Pascal 레코드는 전부 0이므로, 호출자는 네 점이 모두 원점에 있는 사각형과 False인 Active 플래그를 보게 됩니다. 실패한 호출이 조용히 그럴듯해 보이는 데이터로 승격된 것입니다
TPdfPageObjectInfo는 명시적인 센티널로 이에 대응합니다. HasRotatedBounds는 FPDFPageObj_GetRotatedBounds 호출의 결과를 담고, HasActiveState는 FPDFPageObj_GetIsActive 호출의 결과를 담으며, 기하와 상태 필드는 대응하는 센티널이 True일 때만 채워집니다. 레코드 전체에서 같은 형태가 다른 출력 인자 게터들에 대해서도 반복됩니다. HasMatrix, HasFillColor, HasStrokeColor, HasStrokeWidth는 모두 같은 것을 의미합니다. 네이티브 호출이 성공했고 옆에 있는 필드가 의미 있다는 것입니다
Info:= Pdf.PageObjectInfo(I);
if Info.HasRotatedBounds then
// RotatedBounds is array [1..4] of TPdfPoint, in draw order
UseQuad(Info.RotatedBounds[1], Info.RotatedBounds[2],
Info.RotatedBounds[3], Info.RotatedBounds[4])
else
// the native call failed; fall back to the axis-aligned rectangle
UseRect(Info.Bounds);
if Info.HasActiveState and (not Info.Active) then
SkipObject(I); // genuinely inactive
// if HasActiveState is False, the object state is unknown, not inactive
이 패턴은 반환 코드와 출력 인자를 조합하는 규약을 따르는 모든 PDFium 게터로 일반화되며, 이런 게터는 아주 많습니다. 어떤 래퍼가 그 규약을 평범한 함수 반환값으로 뭉개버린다면, "답이 0이다"와 "답이 없다"를 구별하는 유일한 신호를 버린 것입니다. 필드당 Boolean 하나를 더 두는 데는 1바이트가 들지만, 기본값으로 채워진 레코드가 측정값으로 오인되는 전체 버그 범주를 제거해줍니다
여전히 물릴 수 있는 곳
정직한 한계가 세 가지 있습니다. 첫째, 새로고침은 페이지 단위입니다. 두 번째 페이지를 변환해도 여러분이 첫 번째 페이지에 대해 들고 있는 핸들은 영향받지 않지만, 이제 서로 다른 시점에 파싱된 두 페이지를 가지게 되며 어느 스냅샷이 어느 페이지에서 왔는지 기억하는 것은 여러분 몫입니다. 둘째, 콘텐츠 재생성 전반에 걸쳐 인덱스 안정성은 보장되지 않습니다. 재로드 이후 인덱스 3은 새 파싱에서 인덱스 3이 무엇이든 그것이므로, 위치가 유지된다고 가정하는 대신 타입과 기하로 객체를 다시 식별하십시오. 셋째, FPDFPage_TransFormWithClip의 클립 사각형은 페이지 콘텐츠에 적용될 뿐 어떤 페이지 상자도 다시 크기 조정하지 않습니다. 여백을 만들기 위해 콘텐츠를 축소했다면 MediaBox는 여전히 원래 크기 그대로이며, 뷰어는 그림이 안에서 줄어든 원본 시트를 보여줄 것입니다. 이 중 어느 것도 이색적이지 않습니다. 파싱된 상태로의 포인터를 나눠주고 수명 관리는 호출자에게 맡기는 C API의 평범한 결과입니다. 해결책은 다른 어디서나 통하는 것과 같습니다. 스냅샷이 언제 만료되는지 정확히 정의하고, 그 경계에서 새로고침하고, 실패한 호출이 값인 척하게 두지 마십시오
행렬 동작을 좀 더 일반적으로 다루고 있다면, 변환이 어디에 도달할지 결정하는 곱셈 순서는 prepend, append, pivot에 관한 글에서 다룹니다. 여기서 설명한 변환과 페이지 객체 API는 Delphi와 C++Builder용 PDFium Component와 함께 제공되며, 제품 페이지에는 페이지 객체 스냅샷 레코드와 그 센티널 필드에 대한 전체 레퍼런스가 실려 있습니다