표지 페이지를 잘라낸 태그 보고서는 페이지 수는 멀쩡한 채로 열리면서도 세 곳에서 깨져 있을 수 있습니다. "목차" 북마크는 클릭해도 아무 반응이 없고, 접근성 검사기는 어느 페이지에도 속하지 않는 구조 요소를 보고하며, 엄격한 검증기는 프리 객체를 가리키는 참조를 지적합니다. 페이지 트리 자체는 정상입니다. ISO 32000-1 §7.7.3은 페이지가 /Kids에서 빠지고 /Count가 1 줄어들 것만 요구하는데, 파일은 그 조항을 지키면서도 카탈로그의 절반이 사라진 페이지를 여전히 가리키고 있을 수 있습니다
하나의 페이지를 참조할 수 있는 일곱 가지 구조
이 가운데 페이지 트리는 하나뿐입니다. 나머지는 카탈로그, 아웃라인, 논리 구조, 다른 페이지의 주석에 흩어져 있고, 페이지 객체가 파일에서 사라지는 순간 전부 null로 해석됩니다. 그 null을 뷰어가 어떻게 처리할지는 뷰어 재량이라서, 같은 잘라낸 파일이 뷰어 두 곳에서 서로 다르게 오작동할 수 있습니다
/Names/Dests아래의 이름 트리(§7.7.4, §12.3.2.3)는 이름을 목적지 배열에 매핑하며 배열의 첫 요소가 페이지입니다- 카탈로그에 바로 들어 있는 1.2 이전 방식의
/Dests딕셔너리는 같은 종류의 배열을 이름으로 키잉해 담고 있습니다 - 아웃라인 항목(§12.3.3)은 인라인
/Dest또는/S /GoTo와/D배열을 갖는/A액션을 통해 페이지에 도달합니다 - 구조 요소(§14.7.2)는 마킹된 콘텐츠가 속한 페이지를 가리키는
/Pg키를 갖고,/K자식은 그 페이지에 묶인 마킹된 콘텐츠 참조와 객체 참조(§14.7.4.3)일 수 있습니다 ParentTree(§14.7.4.4)는 페이지와 주석의/StructParents번호를 구조 요소로 되돌려 매핑하며, 어떤 요소는 루트에서/K체인을 타고 내려가면 어디에도 나타나지 않으면서 여기에는 존재할 수 있습니다- 다른 페이지의 링크 주석(§12.5.6.5)은 그 페이지를 겨냥한
/Dest나/GoTo액션을 갖고, 카탈로그의/OpenAction도 마찬가지일 수 있습니다
/Kids를 만지기 전 THotPDF.DeletePage가 하는 일
로드된 문서에서 THotPDF.DeletePage(PageIndex)는 그런 구조들을 전부 먼저 훑고 페이지 트리는 마지막에 다시 씁니다. 인덱스는 0부터 시작합니다. 무엇을 바꾸기 전에 두 개의 가드가 먼저 동작합니다. 범위를 벗어난 인덱스는 Invalid page number를 던지고, 마지막 남은 페이지를 지우라는 요청 역시 예외를 던지는데, 자식이 0개인 /Pages 노드는 유효한 문서를 기술하지 않기 때문입니다
훑는 순서는 고정되어 있습니다. /Names /Dests 트리, 구식 카탈로그 /Dests 딕셔너리, /OpenAction, 아웃라인, ParentTree를 포함한 /StructTreeRoot, 그리고 마지막으로 남는 모든 페이지의 /Annots 배열 순입니다. 그 뒤에야 페이지 객체가 삭제 표시되고, 위젯 주석이 AcroForm 필드 트리에서 떼어지며, /Kids, /Count, 남는 페이지 각각의 /Parent가 다시 쓰입니다. 폼 쪽에서는 위젯이 빠진 뒤 자식이 하나도 없는 부모 필드도 /Fields까지 거슬러 올라가며 제거되므로, 잘라낸 폼에는 화면에 아무것도 없는 필드 항목이 남지 않습니다
DeletePages는 '1,3-5,7-' 같은 1 기반 범위 문자열, 즉 다른 로드 문서 페이지 연산들이 쓰는 표기법을 받아서, 선택된 페이지 중 가장 높은 곳부터 내려가며 삭제합니다. 위에서 아래로 진행하기 때문에 페이지가 사라지는 동안에도 여러분이 적어 둔 번호는 계속 유효합니다. 모든 페이지를 덮는 범위는 DeletePage가 마지막 페이지를 거절하는 것과 같은 이유로 예외를 던지고, 함수는 지운 페이지 수를 반환합니다
var
Pdf: THotPDF;
Removed: Integer;
begin
Pdf := THotPDF.Create(nil);
try
// LoadFromFile은 로드한 페이지 수를 반환합니다
if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
begin
// 0 기반: 페이지 0이 표지입니다
Pdf.DeletePage(0);
// 1 기반 범위, 현재 문서 기준으로 매긴 번호입니다
Removed := Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
이름은 사라지고 북마크는 옮겨갑니다
삭제된 페이지를 겨냥한 네임드 디스티네이션은 제거됩니다. 그 페이지를 겨냥한 북마크는 대상이 바뀝니다. /Names /Dests 트리의 모든 노드를 방문하며 각 목적지를 사양이 허용하는 두 형태, 즉 날것 배열과 /D 키를 가진 딕셔너리 양쪽으로 시험하고, 배열의 첫 요소가 삭제된 페이지이면 이름/값 쌍을 거둡니다. /Names도 /Kids도 남지 않은 중간 노드는 부모에서 떼어내므로 트리에 텅 빈 잎이 남지 않습니다. 구식 카탈로그 /Dests 딕셔너리는 키 하나하나에 같은 시험을 적용하고, 삭제된 페이지에서 열리던 카탈로그 /OpenAction은 버려서 시작 뷰를 뷰어 기본값에 맡깁니다
항목을 잃은 노드는 /Limits 배열도 잃습니다. 새 최저/최고 키는 다시 계산되지 않지만 뷰어는 그 없이도 이름을 해석합니다. §7.9.6은 중간 노드와 잎 노드에 /Limits를 요구하므로, 파이프라인의 프리플라이트 단계가 이 규칙을 엄격하게 강제한다면 잘라낸 파일을 같은 작업 안에서 그 단계에 통과시켜 보세요
아웃라인은 방문 목록과 깊이 제한 128을 두고 /First와 /Next로 순회하므로, 제자리로 되돌아가는 깨진 /Next 체인이 호출을 매달리게 할 수 없습니다. 삭제된 페이지를 겨냥한 /Dest 배열과 /GoTo /D 배열마다 첫 요소를 뒤따르던 페이지로 바꾸고, 삭제된 페이지가 마지막이었다면 앞선 페이지로 바꿉니다. 페이지 참조 뒤의 뷰 파라미터는 그대로 유지되므로 /XYZ 점프는 left, top, zoom 값을 유지한 채 새 페이지에 적용합니다
이 비대칭은 의도된 것입니다. 아무것도 해석되지 않는 이름은 합법적이고 무해하고, 존재하지 않는 목적지를 가리키는 GoTo는 그저 아무 일도 하지 않습니다. null 목적지를 지닌 북마크는 더 곤란합니다. 사이드바에 그대로 앉아 클릭되는 것처럼 보이지만 실제로는 실패하죠. 가장 가까운 생존 페이지에 내려앉는 쪽이 불완전한 두 선택지 중 나은 쪽입니다. 대상 재지정은 명시적 목적지 배열에만 적용됩니다. /Dest가 이름 문자열인 아웃라인 항목은 이름 트리를 통해 해석되고, 훑기가 그 이름을 거둔 뒤에는 북마크가 어디에도 가지 않으므로, 네임드 북마크를 많이 쓰는 문서는 삭제 뒤 검사를 한 번 해볼 만합니다. 아래의 검사는 ResolveLoadedNamedDestination으로 이름을 읽는데, 이 함수는 루트 노드의 /Names 배열만 봅니다. 평평한 이름 트리에서는 의미 있는 답을 주지만 /Kids로 나뉜 트리에서는 이름마다 -1을 반환합니다. 직관적이지 않은 /Count 의미론을 포함해 아웃라인 구조 자체는 로드된 PDF에 북마크와 네임드 디스티네이션 추가하기 가이드에서 다룹니다
// 결과를 가정하지 말고 확인하세요.
// Memo1은 여러분 폼에 있는 TMemo입니다.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination은 루트 /Names 배열만 읽으므로,
// 이 검사는 평평한 이름 트리에서만 의미가 있습니다. /Kids를
// 쓰는 트리에서는 삭제 여부와 무관하게 이름마다 -1을 반환합니다
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// 표지를 가리키던 북마크는 이제 뒤따르던 페이지로 해석되며,
// 삭제 후 0 기반 인덱스로 0입니다
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
[Pdf.GetLoadedBookmarkPageIndex('Contents')]));
구조 트리와 ParentTree 다듬기
삭제된 페이지만을 위해 존재하던 구조 요소는 제거되고, 여러 페이지에 걸친 요소는 /Pg 키를 잃지만 자식은 유지합니다. 순회는 /StructTreeRoot에서 시작해 /K를 깊이 128까지 내려가며, §14.7.2가 허용하는 /K의 배열 형태와 단일 딕셔너리 형태를 모두 받아들입니다. 각 요소의 자식은 요소 자신을 판정하기 전에 다듬어집니다. 다듬기가 /K를 비웠다면 그 요소는 제거되고 부모가 이를 떼어냅니다. 자기 /Pg가 삭제된 페이지를 가리키지만 아직 자식과 /P 부모가 있다면 /Pg만 거둡니다. 요소의 /Pg는 자기 페이지를 이름 짓지 않는 마킹된 콘텐츠 자식을 위한 기본 페이지일 뿐이고, 그 자식들은 다른 페이지에 있을 수 있기 때문입니다. /Pg가 삭제된 페이지이면서 밑에 아무것도 없는 요소는 통째로 제거되고, /Pg가 그 페이지를 가리키는 마킹된 콘텐츠 참조 딕셔너리가 마지막 규칙에 걸립니다
ParentTree에도 같은 처우를 적용하는 데는 놓치기 쉬운 이유가 있습니다. 어떤 요소는 ParentTree에서는 닿이지만 그 외 어디에서도 닿이지 않을 수 있습니다. 이 번호 트리는 /StructParents 정수를 단일 요소나 요소 배열에 매핑합니다. 모든 값이 앞의 규칙으로 다듬어지고, 제거된 값은 배열에서 빠지며, 배열이 비게 된 /Nums 쌍은 삭제되고, /Nums도 /Kids도 없는 노드는 떼어냅니다. 이 단계를 건너뛰면 고아들이 해제된 페이지를 /Pg로 계속 가리킵니다. 태그 순서로 텍스트를 뽑는 사람에게는 바로 와닿는 문제입니다. 구조 순서 텍스트 추출이 걷는 게 정확히 이 트리들이고, null /Pg를 가진 요소는 읽기 순서에서 빠지는 단락입니다
남는 페이지의 링크는 대상을 바꾸지 않고 왜 제거할까?
남는 페이지의 링크 주석 중 /Dest 배열이나 /GoTo 액션이 삭제된 페이지를 겨냥하고 있으면 그 페이지의 /Annots에서 제거되고 태그도 함께 거둡니다. /Obj로 그 주석을 가리키던 OBJR 딕셔너리는 구조 요소에서 빠집니다. 그 OBJR이 요소의 유일한 자식이었다면 요소도 제거되고 ParentTree를 다시 다듬습니다. OBJR을 남겨두면 /Obj가 실제 객체를 참조할 것으로 기대하는 §14.7.4.3을 깨고, PDF/UA 검사기는 객체 참조가 어디로도 이어지지 않는 Link 요소를 보게 됩니다
북마크는 옮기고 링크는 옮기지 않습니다. "3페이지 참조"라는 본문 크로스레퍼런스는 3페이지가 사라진 순간 틀린 말이 되고, 조용히 4페이지를 가리키게 바꾸면 깨진 링크가 오해를 부르는 링크로 변합니다. 다음 장에 내려앉는 북마크는 내비게이션이지만 본문 링크는 내용에 대한 주장입니다. 제거가 더 안전한 기본값입니다. 워크플로가 그 링크들의 생존을 필요로 한다면 DeletePage를 부르기 전에 대상을 다시 써두세요. 호출 뒤에는 이미 /Annots 배열에서 빠져 나간 뒤입니다
증분 저장과 직접 자식 함정
DeletePage는 BeginIncrementalUpdate와 SaveIncrementalUpdate 사이에서도 똑같이 동작합니다. 덧붙는 구간에는 훑기가 바꾼 객체들과, 페이지나 삭제된 링크 주석처럼 자체 객체 번호를 지녔던 제거 객체의 프리 엔트리가 들어갑니다. 원래 %%EOF 위의 모든 것은 §7.5.6이 요구하듯 바이트 단위로 그대로입니다
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// 직접 /MCR 자식을 잃은 구조 요소는
// 더 짧은 /K 배열로 다시 쓴 형태로 덧붙습니다
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
손으로 직접 짠 프루너가 틀리는 지점이 바로 여기입니다. 마킹된 콘텐츠 참조(/Type /MCR)와 객체 참조(/Type /OBJR)는 보통 부모 요소의 /K 배열 안에 직접 놓인 딕셔너리입니다. 직접 객체에는 객체 번호도 xref 엔트리도 없어서 자체적으로는 프리될 수 없습니다. 요령은 제거된 자식을 무조건 "프리"하는 것이고, 직접 객체를 그것을 담는 간접 객체로 대응시키는 작성기는 결국 그 보관자를 프리하는데, 그 보관자야말로 남는 구조 요소입니다. 그 요소는 null로 기록되고 한 번도 손대지 않은 페이지들의 태그 붙은 콘텐츠는 태그를 잃습니다. HotPDF는 객체 번호가 있을 때만 제거 객체를 프리 리스트로 보내고, 직접 자식에 대해서는 담고 있는 요소를 변경 표시해 자식이 빠진 채로 요소가 다시 쓰이게 하며 프리 리스트는 청결하게 유지됩니다
같은 주의가 로드된 문서에서 DeletePage가 손대지 않을 것들을 결정합니다. 삭제된 페이지의 콘텐츠 스트림, XObject, 비위젯 주석은 객체로 남습니다. 로드된 파일은 그중 무엇이든 남는 페이지와 공유하고 있을 수 있는데 삭제 시점에 그렇지 않음을 증명할 값싼 방법이 없기 때문입니다. 페이지 트리 참조를 떼어내는 것만으로 올바른 파일이 됩니다. 그 잔해가 여전히 몇 바이트를 먹는지는 별개의 측정 문제이고 객체 의존성 그래프와 잔존 바이트 분석이 그 도구입니다
DeletePage인가 DeleteLoadedPage인가?
사용자가 요청한 페이지 제거라면 DeletePage를 쓰세요. THotPDF.DeleteLoadedPage(PageIndex)는 가벼운 변형입니다. 같은 두 가드를 적용하고, 로드된 페이지 목록을 밀고, /Kids와 /Count를 다시 쓰고, 렌더링 페이지 캐시를 무효화하고, OnLoadedDocumentModified를 발생시킵니다. 이름 트리, 아웃라인, 구조 트리, 다른 페이지의 주석은 만지지 않고 페이지 객체에 삭제 표시도 하지 않습니다. ComposeLoadedPagesNUp이 딱 맞는 작업입니다. 소스 페이지들을 폼 XObject로 그리는 새로 조판한 시트를 덧붙인 뒤 각 원본을 DeleteLoadedPage(0)으로 떼어냅니다. 시트는 페이지 객체가 아니라 페이지의 콘텐츠를 참조하기 때문입니다. 태그와 북마크와 크로스링크가 붙은 파일에서 "이 계약서의 7페이지를 지워라"라면, SaveLoadedDocument로 저장하든 SaveIncrementalUpdate로 덧붙이든 모든 참조를 일관되게 남기는 호출은 DeletePage입니다
두 메서드와 범위 기반 DeletePages, 그리고 위에서 쓴 증분 업데이트 경로는 모두 HotPDF Delphi Component의 일부입니다. 이 컴포넌트는 기계에 뷰어 런타임 없이 Delphi와 C++Builder 코드에서 로드된 PDF를 편집합니다