상류 어딘가에서 넘어온 PDF 폴더를 물려받았고, 작업은 단순해 보입니다. 어떤 북마크가 외부 URL 로 점프하는지, 어떤 것은 JavaScript 를 실행하는지, 내부 링크는 실제로 어디에 떨어지는지만 알려 달라는 것입니다. 그런데 API reference 를 열어 보면 라이브러리는 이런 action 을 모두 만들 수는 있어도 다시 읽어 오는 기능은 거의 제공하지 않습니다. 이 비대칭은 PDF 도구 전반에 흔합니다. https://example.com 을 여는 북마크를 쓰는 일은 한 줄이면 되지만, 이미 존재하는 북마크에게 "무슨 일을 하고, 목표는 무엇인가"를 묻는 순간 /A, /S, /Dest 와 수많은 fit-type 분기로 이루어진 raw object tree 를 손으로 걸어야 하고, 대개 처음부터 제대로 맞추는 사람은 거의 없습니다
PDFlibPas 는 Delphi 와 C++Builder 용 native Object Pascal PDF 라이브러리이며, 오랫동안 똑같은 빈틈을 안고 있었습니다. write 쪽 setter 는 풍부했지만, getter 는 고작 TPDFObject 하나를 돌려주고 나머지는 직접 파고들라고 맡겼습니다. v3.77.0 릴리스는 action 종류, action payload, destination geometry 를 plain record 로 보고하는 소수의 typed introspection call 을 추가해 이 공백의 일부를 메웠습니다. 이 글은 그 call 이 ISO 32000-1 의 action 및 destination 모델에 어떻게 대응하는지, 그리고 손으로 같은 코드를 작성하면 조용히 잘못되기 쉬운 세 가지 구체적 함정을 다룹니다
action 읽기가 쓰기보다 어려운 이유
PDF 에서 action 은 subtype 을 나타내는 /S key 를 가진 dictionary 입니다. GoTo, GoToR, URI, Launch, Named, JavaScript 그리고 좀처럼 볼 일 없는 긴 꼬리가 있습니다(ISO 32000-1 §12.6.4). 문제는 payload 가 subtype 마다 다른 key 에 담기며, 공통된 "target 을 달라" 슬롯이 없다는 점입니다. URI action 은 주소를 /URI 에 둡니다. GoToR 와 Launch 는 file specification 을 /F 에 둡니다. JavaScript 는 script 를 /JS 에 두는데, 이것은 string 일 수도 있고 stream 일 수도 있습니다. GoTo action 은 payload 자체가 없고, target 은 /D 에 매달린 destination 으로 따로 해석해야 합니다
action 을 쓸 때는 종류를 이미 알고 있으므로 이런 차이가 문제가 되지 않습니다. 읽을 때는 먼저 /S 로 분기한 다음, 그 subtype 에 맞는 key 에서 값을 읽고, 같은 논리 개념인 "이 action 이 가리키는 것" 이 세 가지 서로 호환되지 않는 방식으로 인코딩될 수 있다는 점까지 감당해야 합니다. typed getter 가 흡수하는 것이 바로 이 분기입니다. GetOutlineActionInfo 와 GetAnnotActionInfo 는 모두 TPDFlibActionInfo record 를 반환합니다
type
TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
akLaunch, akNamed, akJavaScript);
TPDFlibActionInfo = record
Kind: TPDFlibActionKind;
URI: AnsiString; // populated for akURI
JavaScript: WideString; // populated for akJavaScript
FileName: AnsiString; // populated for akGoToR / akLaunch
OpenInNewWindow: Boolean; // akGoToR / akLaunch
end;
어느 필드가 의미 있는지는 Kind 가 알려 줍니다. Kind 가 akURI 이면 URI 를 읽고 나머지는 무시하면 됩니다. akGoTo 이면 payload 필드는 아무 것도 해당되지 않으므로, 뒤에서 설명할 destination 으로 넘어가면 됩니다. 북마크나 주석에 action 자체가 없을 때 정직하게 akNone 을 돌려준다는 점도 중요합니다. 의미를 추측해야 하는 0 값이 아닙니다
북마크를 찾기 위해 outline tree 걷기
북마크를 해석하려면 먼저 handle 이 필요합니다. PDFlibPas 는 outline node 를 정수 ID 로 식별하며, FindOutlineByTitle 는 보이는 제목 문자열로 node 를 찾되 검색 범위를 명시적으로 제어할 수 있게 합니다
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
이 Depth 인자는 잠깐 멈춰서 볼 가치가 있는 부분입니다. osdSiblingsOnly 는 시작 node 수준의 sibling chain 만 훑고 멈추므로 peer 북마크는 찾지만 peer 의 자식으로는 절대 내려가지 않습니다. osdChildrenOnly 는 시작 node 의 직계 child, 즉 한 단계 아래만 봅니다. osdFullSubTree 는 branch 전체를 재귀적으로 훑습니다. 잘못된 값을 고르면 에러가 아니라 조용한 miss 가 납니다. 두 단계 아래에 있는 title 을 sibling-only 검색으로 찾으면 결과는 그냥 0이 되고, 북마크가 없다고 결론 내리게 됩니다. 문서 루트부터 검색하려면 시작 ID 로 GetFirstOutline 을 넘기면 됩니다
var
Lib: TPDFlib;
FoundID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') = 1 then
begin
// Search the whole tree from the root for a nested bookmark.
FoundID := Lib.FindOutlineByTitle('Appendix B',
Lib.GetFirstOutline, osdFullSubTree);
if FoundID <> 0 then
// FoundID is now a handle you can pass to the action and
// destination getters below.
;
end;
finally
Lib.Free;
end;
end;
matching 은 exact title 문자열을 WideString 으로 비교하므로 대소문자를 구분하며, 저장된 Unicode 텍스트를 그대로 존중합니다. 소스 PDF 의 producer 가 제각각이라면, 검색할 title 을 문서가 저장한 방식과 동일하게 normalize 하지 않으면 존재하는 북마크를 놓쳤다고 착각하게 됩니다
북마크의 action 과 target 해석하기
handle 을 얻었다면 typed view 는 GetOutlineActionInfo 가 제공합니다. 패턴은 단순합니다. 호출하고, Kind 로 분기하고, 그 kind 가 채우는 필드를 읽으면 됩니다
var
Info: TPDFlibActionInfo;
begin
Info := Lib.GetOutlineActionInfo(FoundID);
case Info.Kind of
akURI:
Writeln('Opens URL: ', Info.URI);
akGoToR, akLaunch:
Writeln('Opens file: ', Info.FileName,
' (new window: ', Info.OpenInNewWindow, ')');
akJavaScript:
Writeln('Runs script: ', string(Info.JavaScript));
akGoTo:
Writeln('Jumps within this document'); // see destination below
akNamed:
Writeln('Named action (NextPage, Print, etc.)');
akNone:
Writeln('Bookmark has no action');
end;
end;
첫 번째 진짜 함정은 여기 있습니다. 구현 과정에서 테스트 피드백이 실제로 드러내 준 부분이기도 합니다. 오래된 getter 인 GetActionURL 이 하나 있고, 이것으로 URI action 을 읽으려 드는 것이 겉보기에 가장 그럴듯한 실수입니다. GetActionURL 은 /F key 를 통해 file specification 을 해석합니다. 이것은 target 이 실제 파일인 GoToR 와 Launch 에는 올바르지만, URI action 에는 완전히 잘못된 key 입니다. URI action 의 주소는 action 자신의 /URI key 에 놓인 plain string 이며 file spec 이 아닙니다. URI action 을 file-spec 경로에 태우면 결과는 비어 있거나 엉뚱해집니다. typed getter 는 내부적으로 /URI 를 직접 읽어 akURI 를 처리하고, file-specification resolver 는 akGoToR 와 akLaunch 에만 호출합니다. 손으로 쓴 구현이 자주 흐리는 구분이 정확히 이것입니다
destination fit type 과 그 뒤의 geometry
하나의 akGoTo action 은 "이 문서 안에서 이동한다"는 뜻이지만, 어디로 가는지와 어떻게 가는지는 알려 주지 않습니다. 그것은 destination 의 역할이며, destination 은 많은 사람이 예상하는 것보다 더 많은 뉘앙스를 담고 있습니다. PDF destination 은 단순한 page number 가 아니라, viewer 가 그 page 를 어떤 방식으로 frame 해야 하는지를 설명하는 "fit" specification 이 붙은 page 입니다(ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo 는 이것을 record 로 반환합니다
type
TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);
TPDFlibDestinationInfo = record
Kind: TPDFlibDestinationKind;
Page: Integer; // 1-based; 0 when unresolved
Left, Top, Right, Bottom, Zoom: Double;
end;
여덟 가지 fit kind 는 서로 다른 framing 질문에 답합니다. dkXYZ 는 특정 점을 좌상단에 놓고 명시적 zoom 을 적용하므로 Left, Top, Zoom 을 사용합니다. dkFit 은 page 전체를 창에 맞추므로 좌표를 무시합니다. dkFitH 와 dkFitV 는 page 너비 또는 높이를 맞추며 top edge 나 left edge 하나만 중요합니다. 흥미로운 것은 dkFitR 입니다. 지정된 rectangle 을 맞추므로 네 변이 모두 의미를 가집니다. dkFitB* 계열은 page 전체가 아니라 visible content 의 bounding box 를 기준으로 같은 동작을 합니다. 각 kind 에서 어떤 필드가 살아 있는지 아는 것이 destination 을 정확히 읽는 것과, 우연히 0인 쓰레기 좌표를 출력하는 것의 차이입니다

구현 내부에는 알아둘 만한 의도적인 정렬 하나가 있습니다. 이것이 매핑이 왜 신뢰할 만한지 설명해 줍니다. 내부 GetDestType 은 여덟 fit kind 에 대해 XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV 순서의 정수 1..8 을 반환합니다. TPDFlibDestinationKind 는 그 ordinal 이 일대일로 맞아떨어지도록 선언되어 있습니다. dkXYZ 는 ordinal 1, dkFitBV 는 ordinal 8 이고, dkNone 이 0에 놓입니다. 그래서 변환은 범위 검사 하나를 곁들인 direct ordinal cast 이지, enum 이 커질수록 틀어질 수 있는 lookup table 이 아닙니다. 사소해 보이지만, 순진하게 구현하면 enumeration 순서가 바뀌는 순간 off-by-one bug 로 터지기 좋은 종류의 디테일입니다
var
Dest: TPDFlibDestinationInfo;
begin
Dest := Lib.GetOutlineDestinationInfo(FoundID);
if Dest.Page = 0 then
Exit; // destination did not resolve
case Dest.Kind of
dkXYZ:
Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
dkFitR:
Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
dkFit, dkFitB:
Writeln(Format('Page %d, fit whole page', [Dest.Page]));
else
Writeln(Format('Page %d, fit kind %d',
[Dest.Page, Ord(Dest.Kind)]));
end;
end;
값이 Page 가 0이면 destination 이 해석되지 않았다는 신호입니다. 보통 action 에 destination 이 없거나 named destination 을 찾지 못한 경우입니다. 좌표를 믿기 전에 먼저 확인해야 합니다. 또한 GetOutlineDestinationInfo 는 destination 이 존재할 수 있는 두 위치를 모두 봅니다. 북마크 자체의 /Dest 와, 내장된 GoTo action 의 /D 입니다. producer 가 어느 형식을 썼는지 미리 알 필요는 없습니다
annotation action 과 SelectPage 함정
link annotation 은 북마크와 같은 방식으로 action 을 가지며, GetAnnotActionInfo 도 동일한 TPDFlibActionInfo record 를 같은 kind-then-payload 패턴으로 반환합니다. 하지만 여기에는 outline 에는 없는 stateful 함정이 하나 있습니다. 그것이 세 번째 함정입니다
annotation 은 page 에 속하며, PDFlibPas 는 현재 page 의 annotation 을 그 page 를 선택한 뒤에야 유효해지는 state 를 통해 노출합니다. 먼저 GetAnnotActionInfo 를 호출하고 그 전에 SelectPage(N) 을 부르지 않으면 annotation handle 이 0이 되고, 호출 결과는 akNone 이 됩니다. 그러면 그 page 에 action 이 있는 annotation 이 없다고 잘못 결론 내리게 됩니다. 해결은 한 줄이지만 page 를 루프 돌 때 잊기 쉽습니다
var
P: Integer;
Info: TPDFlibActionInfo;
begin
for P := 1 to Lib.PageCount do
begin
Lib.SelectPage(P); // mandatory before touching annotations
// GetAnnotActionID(1) <> 0 is the reliable "has an action"
// test. CheckPageAnnots returns a boolean-style flag, not a
// count, so it is the weaker signal here.
if Lib.GetAnnotActionID(1) <> 0 then
begin
Info := Lib.GetAnnotActionInfo(1);
if Info.Kind = akURI then
Writeln(Format('Page %d link -> %s', [P, Info.URI]));
end;
end;
end;
이 루프에는 의도된 점이 두 가지 있습니다. 첫째, SelectPage(P) 는 매 반복마다 annotation 접근보다 앞에 옵니다. per-page annotation state 는 carry-over 되지 않습니다. 둘째, 존재 여부 검사는 GetAnnotActionID(1) <> 0 을 사용하며 CheckPageAnnots 는 쓰지 않습니다. 후자는 count 가 아니라 boolean 스타일 flag 를 보고하므로, 첫 번째 annotation 이 실제로 존재하고 읽을 수 있는 action 을 갖고 있는지를 묻는 데는 non-zero action ID 가 더 정확합니다. 또 하나 짚어둘 만한 점은 annotation 의 JavaScript action script 가 /JS 에서 직접 읽힌다는 것입니다. script 가 그런 방식으로 저장돼 있으면 stream 을 decode 하고, 아니면 string 을 읽으므로 흔한 두 인코딩 모두를 견딥니다
read-side introspection 이 차지하는 자리
이 getter 들은 의도적으로 좁은 범위를 가집니다. 라이브러리에 이미 있던 정수 handle 기반 action 및 destination 레이어 위에 세워진 순수 읽기 기능이므로, write path 를 건드리지 않고 편집 중인 문서에도 위험을 더하지 않습니다. 파일 안에 실제로 무엇이 있는지를 보고할 뿐, 정책에 따라 검증하거나 다시 쓰지는 않습니다. 만약 목표가 반대로 이런 action 을 가진 북마크와 link annotation 을 직접 생성하는 것이라면, 그건 write 쪽 이야기이며 Delphi에서 interactive form action 과 JavaScript 만들기 가 그 과정을 다룹니다. PDF 의 navigation graph 가 아니라 보이는 콘텐츠와 구조를 뽑아내고 싶다면 PDFlibPas로 텍스트, 이미지, 폰트 추출하기 를 보면 됩니다
기억해 둘 솔직한 경계가 있습니다. introspection 은 producer 가 실제로 기록한 것만 봅니다. generator 가 malformed 하게 남겨 둔 북마크 action 이나, 한 번도 정의되지 않은 named target 을 가리키는 destination 은 예외가 아니라 akNone 또는 page 0 으로 드러납니다. 이것은 신뢰할 수 없는 파일을 감사하는 read API 로서는 올바른 동작이지만, 이런 0 결과를 well-formed input 의 보장으로 읽어서는 안 되고 "없거나 해석되지 않음"으로 취급해야 합니다. 여기서 보인 typed action 및 destination introspection 은 PDFlibPas, 즉 Delphi 와 C++Builder 용 native PDF library 의 일부입니다