HotPDF Delphi Component는 로드된 PDF에서 THotPDF.DeletePage로 페이지를 삭제하고, 버전 2.751.0부터는 그 호출이 페이지를 가리키고 있는 모든 문서 수준 참조도 함께 정리합니다. /Names /Dests 트리의 명명된 목적지, 예전 방식의 카탈로그 /Dests 딕셔너리, 북마크 /GoTo 액션, /StructTreeRoot 아래의 구조 요소, ParentTree, 주석의 OBJR 항목, 그리고 남아 있는 페이지의 링크 주석입니다. 페이지 트리는 다른 무엇도 삭제된 객체에 도달할 수 없게 된 뒤에 마지막으로 다시 만들어집니다
이것이 막아 주는 실패는 재현하기는 쉽고 진단하기는 어렵습니다. 태그가 있는 보고서의 표지 페이지를 삭제하고 저장한 뒤 결과를 열어 보면, Acrobat은 페이지 수를 맞게 보여 주지만 "Contents" 북마크는 이제 아무 데도 닿지 않고, 접근성 검사기는 페이지가 없는 구조 요소를 보고하며, 엄격한 검증기는 해제된 객체에 대한 참조를 나열합니다. 페이지 트리에는 잘못된 것이 없습니다. 문제는 PDF 페이지가 /Pages의 잎일 뿐만 아니라 카탈로그의 절반이 가리키는 대상이라는 점이고, 그 잎을 없애면 그 모든 포인터가 매달린 채로 남는다는 것입니다
/Kids에서 페이지를 빼는 것만으로 충분하지 않은 이유는?
ISO 32000-1이 적어도 일곱 개의 독립적인 구조가 페이지 객체를 참조할 수 있게 허용하고, 그중 페이지 트리는 하나뿐이기 때문입니다. /Kids에서 페이지를 빼고 /Count를 줄이면 §7.7.3은 만족하고, 나머지 모든 참조는 xref에서 해제되었거나 다시 쓴 파일에 아예 없는 객체를 가리키는 포인터가 됩니다. 그런 포인터 중 하나를 따라가는 뷰어는 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)는 자신의 marked content가 사는 페이지를 지정하는
/Pg키를 달고 있고, 그/K자식들은 그 페이지에 묶인 marked content 참조와 객체 참조(§14.7.4.3)일 수 있습니다 ParentTree(§14.7.4.4)는 페이지와 주석의/StructParents번호를 다시 구조 요소로 매핑하며, 요소는 루트부터 이어지는/K사슬에는 아예 나타나지 않고 여기에만 살 수 있습니다- 다른 페이지의 링크 주석(§12.5.6.5)은 그 페이지를 대상으로 하는
/Dest나/GoTo액션을 달고 있고, 카탈로그의/OpenAction도 그럴 수 있습니다
THotPDF.DeletePage는 페이지 트리를 건드리기 전에 무엇을 정리할까요?
로드된 문서에서 THotPDF.DeletePage(PageIndex)는 먼저 참조 정리를 전부 수행하고, 그다음 DeleteObj로 페이지 객체를 삭제 표시하고, AcroForm 필드 트리에서 위젯 주석을 떼어 내고, 내부 페이지 배열을 밀고, 마지막으로 RebuildLoadedPageTree를 호출해 /Kids, /Count, 남은 각 페이지의 /Parent를 다시 씁니다. 정리는 정해진 순서로 카탈로그를 방문합니다. /Names /Dests 이름 트리, 예전 방식의 /Dests 딕셔너리, /OpenAction, 아웃라인 트리, ParentTree를 포함한 /StructTreeRoot, 그리고 마지막으로 남아 있는 모든 페이지의 /Annots 배열입니다. 각 단계는 규격이 그 구조에 페이지 없이 무엇을 허용하는지에 따라 참조를 제거할지, 대상을 바꿀지, 그대로 둘지 정합니다. 이 모든 것이 실행되기 전에 가드 두 개가 적용됩니다. DeletePage는 범위를 벗어난 인덱스에 Invalid page number를 내고 마지막 페이지 제거를 거부합니다. 자식이 0개인 /Pages 노드는 유효한 PDF가 아니기 때문입니다. DeletePages는 다른 로드된 문서 페이지 연산과 같은 1 기반 "1,3-5,7-" 표기를 받고, 선택된 인덱스 중 가장 큰 것부터 아래로 반복해서 작업 중에도 써 넣은 인덱스가 유효하게 유지됩니다
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
begin
// 0 기반입니다: 표지 페이지를 제거합니다. 이것을 가리키던
// 명명된 목적지와 북마크, 구조 트리, ParentTree,
// 링크 주석이 /Pages 트리를 다시 만들기 전에
// 정리됩니다.
Pdf.DeletePage(0);
// 일괄 처리는 1 기반 범위 표기이며, 내부적으로
// 높은 인덱스부터 처리해 앞선 인덱스가 유효하게 유지됩니다.
Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
명명된 목적지와 북마크는 어떻게 다르게 처리될까요?
명명된 목적지는 제거하고 북마크는 대상을 바꿉니다. 더 이상 존재하지 않는 이름은 받아들일 만한 결과이지만, 목적지가 없는 북마크는 눈에 보이는 결함이기 때문입니다. /Names /Dests 트리에서 HotPDF는 모든 노드를 순회하며, 맨 배열 형태와 /D 키를 가진 딕셔너리 형태 양쪽의 목적지를 삭제된 페이지와 대조하고, 배열의 첫 원소가 그 페이지이면 이름/값 쌍을 제거합니다. /Names와 /Kids가 둘 다 비게 된 노드는 삭제 표시되고 부모에서 연결이 끊기므로, 트리에 빈 잎이 남지 않습니다. 같은 검사가 예전 방식의 카탈로그 /Dests 딕셔너리에도 돌고, 카탈로그 /OpenAction이 삭제된 페이지에서 열리던 것이면 그냥 버립니다. 여기서 경계 하나가 있습니다. 이름 트리 노드가 항목을 잃으면 HotPDF는 새 최솟값과 최댓값 키를 다시 계산하는 대신 그 노드의 /Limits 쌍을 삭제하는데, 뷰어들은 그것 없이도 이름을 잘 해석하지만 ISO 32000-1 §7.9.6을 읽는 엄격한 적합성 검사기는 /Limits가 없는 비루트 노드를 지적할 수 있습니다
아웃라인 항목은 반대입니다. RetargetOutlineDestinations는 아웃라인 루트에서 /First와 /Next를 순회하며, 방문 목록과 깊이 제한 128을 둬서 손상된 순환 트리가 호출을 멈추지 못하게 합니다. 그리고 그 페이지를 겨냥한 모든 /Dest 배열이나 /GoTo 액션의 /D 배열에 대해 첫 원소를 NearestRetainedPage로 교체합니다. 삭제된 페이지 뒤에 오던 페이지이고, 삭제된 페이지가 마지막이었다면 그 앞의 페이지입니다. 페이지 참조 뒤의 뷰 파라미터는 그대로 둡니다. 그래서 삭제된 장 도입부를 가리키던 북마크는 사이드바에서 사라지는 대신 남은 내용의 첫 페이지에 내려앉고, 이것이 잘라 낸 문서에 대해 검토자들이 기대하는 동작입니다. 다만 목적지 검사는 명시적 배열만 매칭합니다. /Dest가 예전에 삭제된 페이지로 해석되던 이름 문자열인 아웃라인 항목은 대상이 바뀌지 않는데, 이름 트리 항목이 사라졌기 때문에 그 참조는 이제 해제된 객체가 아니라 아무것도 아닌 것으로 해석되고, 뷰어는 이를 죽은 북마크로 취급합니다. 아웃라인 트리 자체의 구조, 즉 /First, /Next, 그리고 직관적이지 않은 /Count 의미는 로드된 PDF에 북마크와 명명된 목적지를 추가하는 가이드에서 다룹니다
// 정리를 믿지 말고 검증하십시오.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
ShowMessage('Named destination "cover" was pruned');
// 표지를 대상으로 하던 북마크는 이제 그 뒤에 오던
// 페이지로 해석됩니다(삭제 후 0 기반 인덱스 0).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
ShowMessage('Bookmark retargeted to the nearest retained page');
구조 트리와 ParentTree에는 무슨 일이 생길까요?
삭제된 페이지 때문에만 존재하는 구조 요소는 제거되고, 여러 페이지에 걸친 요소는 /Pg 키를 잃지만 자식은 유지합니다. PruneStructureElement는 /StructTreeRoot에서 /K 사슬을 깊이 128까지 내려가며, §14.7.2가 허용하는 /K의 배열 형태와 단일 딕셔너리 형태를 모두 처리합니다. 각 요소에 대해 먼저 자식을 정리하고, 그다음 요소 자체를 평가합니다. 정리 결과 /K가 비었으면 그 요소는 삭제 표시되고 부모가 그것을 떨어뜨립니다. 요소 자신의 /Pg가 삭제된 페이지를 지정하는데 요소에 아직 자식과 /P 부모가 있으면 /Pg만 제거합니다. 요소의 /Pg는 그 marked content 자식들의 기본 페이지이고, 그 자식들이 명시적으로 다른 페이지를 참조할 수 있기 때문입니다. /Pg가 삭제된 페이지이고 아래에 아무것도 남지 않은 요소만 통째로 제거됩니다
ParentTree도 같은 처리를 받는데, 이유는 개발 중에 물렸던 바로 그것입니다. 구조 요소는 ParentTree에서만 도달 가능하고 다른 어디에서도 도달할 수 없을 수 있습니다. 이 숫자 트리는 /StructParents 정수를 단일 요소나 요소 배열로 매핑하고, PruneParentTreeNode는 찾은 모든 값에 PruneStructureElement를 실행하고, 정리되어 사라진 값은 제거하고, 값 배열이 빈 /Nums 쌍은 삭제하며, /Nums와 /Kids가 둘 다 사라진 노드는 연결을 끊습니다. /K의 자손만 정리했다면 그 고아 요소들이 /Pg를 통해 해제된 페이지를, /MCR 자식을 통해 해제된 marked content 참조를 계속 가리켰을 것입니다. 구조 순서로 텍스트를 추출한다면 이는 직접적인 문제입니다. 구조 순서 텍스트 추출이 바로 이 트리들을 순회하고, /Pg가 null인 요소는 읽기 순서에서 조용히 빠져 버리는 문단이기 때문입니다
남아 있는 페이지의 어떤 링크 주석이 제거될까요?
남아 있는 페이지의 링크 주석 중 /Dest 배열이나 /GoTo 액션이 삭제된 페이지를 가리키는 것은 구조 트리 소유권과 함께 제거됩니다. RemoveRetainedPageDestinationAnnotations는 대상이 아닌 모든 페이지의 /Annots 배열을 순회하며, 아웃라인에 쓰던 것과 같은 목적지 검사를 적용하고, 일치하는 주석을 삭제 표시하고 배열에서 빼낸 다음, PruneAnnotationReferencesInStructureTree를 호출해 /Obj가 그 주석을 지목하던 OBJR 딕셔너리를 구조 요소에서 제거합니다. OBJR가 유일한 자식이었다면 요소 자체도 제거됩니다. OBJR를 그대로 두면 /Obj가 존재하는 객체를 참조해야 한다는 §14.7.4.3을 위반하고, PDF/UA 검사에서 뒤에 주석이 없는 태그된 링크로 나타납니다. 북마크와의 비대칭을 눈여겨보십시오. 링크는 대상을 바꾸지 않고 제거합니다. 본문에 "3페이지 참조"라고 적힌 상호 참조는 3페이지가 사라지면 틀린 것이고, 그것을 4페이지로 돌리면 북마크가 가장 가까운 장에 내려앉는 것과는 다른 종류의 거짓말이 됩니다. 워크플로가 그 링크들을 보존해야 한다면 DeletePage를 호출하기 전에 직접 대상을 바꾸십시오
제거된 /MCR이나 /OBJR을 해제 목록에 등록하면 안 되는 이유는?
marked content 참조와 객체 참조는 보통 부모 요소의 /K 배열 안에 있는 직접 딕셔너리이고, 증분 변경 레지스트리는 직접 객체를 그것을 담고 있는 가장 가까운 간접 객체로 해석하기 때문입니다. RemoveArrayItem이 /K 배열에서 자식을 빼낼 때 메모리상 객체를 해제하는 것은 그것이 THPDFLink이거나 간접이 아닌 값일 때뿐이고, MarkRemovedObject는 객체 번호가 0보다 클 때만 객체를 해제 목록에 등록합니다. 이 정리의 첫 버전은 그 구분을 하지 않았고, 증분 저장에서의 결과는 레지스트리가 하도록 설계된 그대로였습니다. RegisterIncrementalChange가 직접 /MCR에서 그래프 트랜잭션 루트까지, 즉 그것을 소유한 남아 있는 구조 요소까지 거슬러 올라가 그 요소를 null로 써 버렸습니다. 페이지 하나를 잃은 문서가 다른 페이지의 태그된 콘텐츠가 조용히 태그를 잃은 채로 돌아온 것입니다. 직접 자식에 대한 유일하게 올바른 조치는 TouchContainer로 컨테이너를 변경 표시해서 컨테이너가 다시 쓰이게 하고, 해제 목록은 건드리지 않는 것입니다
// 증분 업데이트: 변경된 컨테이너와 해제된 페이지 객체만
// 덧붙는 섹션에 들어갑니다.
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// /K에서 직접 /MCR을 잃은 남아 있는 구조 요소는
// 제자리에서 다시 쓰이고, null로 쓰이지 않습니다.
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
같은 주의가 DeletePage가 로드된 문서에서 의도적으로 해제하지 않는 것들도 결정합니다. 삭제된 페이지의 콘텐츠 스트림, XObject, 위젯이 아닌 주석은 객체로 남겨 둡니다. 로드된 파일은 그것들 중 무엇이든 남아 있는 페이지와 공유할 수 있고, 삭제 시점에 그렇지 않다는 것을 싸게 증명할 방법이 없기 때문입니다. 정확성에는 페이지 트리 참조를 제거하는 것으로 충분합니다. 그 객체들이 여전히 차지하는 바이트는 별개의 문제이고, 객체 의존성 그래프와 보유 바이트 분석이 잘라 낸 문서가 아직 무엇을 들고 있는지 측정하는 도구입니다
DeletePage와 DeleteLoadedPage 중 무엇을 호출해야 할까요?
사용자에게 보이는 페이지 제거에는 DeletePage를 호출하고, 문서 전체를 재배치하면서 문서 수준 참조를 남길 가치가 없는 경우에는 DeleteLoadedPage를 남겨 두십시오. 버전 2.508.0에서 추가된 THotPDF.DeleteLoadedPage(PageIndex)는 가벼운 변형입니다. 내부 페이지 배열을 밀고, RebuildLoadedKidsArray를 호출해 /Kids와 /Count를 다시 쓰고, 렌더링된 페이지 캐시를 무효화하고, OnLoadedDocumentModified를 발생시킵니다. 이름 트리, 아웃라인, 구조 트리, 다른 페이지의 주석은 순회하지 않고, 페이지 객체를 삭제 표시하지도 않습니다. N-up 재배치 안에서 쓰기 좋은 도구인데, HotPDF가 새로 조립한 시트를 덧붙이고 원본 페이지를 전부 DeleteLoadedPage(0)로 제거하는 경우입니다. 원본 페이지들은 통째로 교체되는 것이고, 시트 콘텐츠는 페이지 객체가 아니라 그 리소스를 참조하기 때문입니다. "이 계약서에서 7페이지를 빼라" 같은 평범한 작업에서는 DeletePage가 태그와 북마크, 상호 링크를 가진 문서를 검증기를 통과할 만큼 일관되게 남기는 유일한 호출이며, SaveLoadedDocument를 통한 전체 재작성과 SaveIncrementalUpdate를 통한 증분 업데이트 양쪽 모두에서 그렇습니다. 두 메서드 모두 Delphi와 C++Builder용 HotPDF Delphi Component에 들어 있고, 외부 뷰어 런타임이나 의존성이 필요하지 않습니다