리프 배열을 패치해 이름 하나를 추가해도 그 항목은 여전히 없음으로 읽힐 수 있습니다. PDFium 컴포넌트는 ISO 32000 name tree와 number tree를 위한 범용 편집기 TPdfTreeEditor로 답합니다. 엄격한 검증으로 트리를 로드하고, 메모리에서 항목을 편집하고, 올바른 /Limits로 균형 잡힌 노드 집합을 재빌드하며, 검증된 증분 리비전을 기록합니다
이 유닛을 만들게 한 실패는 바이트를 읽어야만 진단할 수 있는 종류입니다. 어떤 도구가 리프 /Names 배열에 키와 값을 추가하고 새 리비전을 쓰면, 모든 16진수 덤프가 키가 파일에 있다고 확인합니다. 그런데도 Acrobat은 첨부 파일이 존재하지 않는다고 말합니다. 순진한 의미에서 손상된 것은 없습니다. 트리가 단순히 더 이상 검색 알고리즘이 내려갈 수 있는 트리가 아닌 것입니다
패치된 리프 배열이 왜 검색을 깨는가
검색이 리프를 훑는 것이 아니라 /Limits를 통한 이진 하강이기 때문입니다. ISO 32000-1 §7.9.6은 name tree를 다음과 같이 정의합니다. /Kids가 /Names를 가진 리프로 내려가는 루트, 키와 값이 번갈아 나오는 배열, 전체 트리에 걸쳐 키가 바이트 비교로 정렬됨, 그리고 루트가 아닌 모든 노드가 자신의 최소 및 최대 키로 /Limits를 선언합니다. 준수하는 리더는 원하는 키를 각 자식 범위와 비교해 정확히 하나의 자식을 엽니다. 리프에 키를 추가하고 옛 /Limits를 그대로 두면, 하강은 실제로 그것을 가진 노드를 그냥 지나쳐 갑니다
number tree(§7.9.7)는 /Nums와 정수 키로 같은 모양을 가지며, 실질적인 트래픽을 담당합니다. §12.4.2의 /PageLabels는 number tree이고, §7.7.4의 카탈로그 /Names 카테고리는 /Dests, /EmbeddedFiles, /JavaScript를 name tree로 제공합니다. 그래서 네 불변식이 하나씩이 아니라 함께 성립해야 합니다. 키는 트리 순서로 엄격히 증가해야 하고, 모든 레벨이 실제 키 범위와 일치하는 한계를 게시해야 하며, 형제 범위가 겹치지 않아야 하고, 루트 객체 번호와 세대가 정확히 제자리에 있어야 합니다. 카탈로그와 다른 모든 참조자가 간접 참조로 그 루트를 가리키기 때문입니다. Delphi에서 PDF 첨부 파일 다루기에 관한 노트가 같은 구조의 소비자 쪽을 보여줍니다
TPdfTreeEditor가 로드를 거부하는 것은 무엇인가
예상보다 많은 것을, 그리고 의도적으로 거부합니다. TPdfTreeEditor.TryLoad는 TPdfSparseDictionaryReader를 통해 노드 그래프를 훑으며, 우연히 활성화된 리비전을 받아들이는 대신 참조가 지목한 정확한 세대에서 ReadDictionary로 모든 자식을 해석합니다. 잘 형성됐다고 증명할 수 없는 트리는 단 한 번의 편집도 허용되기 전에 거부되며, 이는 깨진 입력이 그럴듯해 보이는 깨진 출력이 되는 것을 막습니다
- 순환과 반복 참조, 둘 다
ptviCycle로 보고되어 체인 위로 되돌아가는/Kids항목이 재귀 대신 종료됩니다 - 혼합 노드 형태:
/Kids를/Names나/Nums와 함께 가진 사전은ptviInvalidNodeShape이며, 빈 비루트 리프도 마찬가지입니다 - 정렬되지 않았거나 중복된 키는
ptviUnsortedKey와ptviDuplicateKey로 보고되며, 하나의 리프 안이 아니라 전체 순회에 걸쳐 검사됩니다 - 실제로 존재하는 키와 어긋나는 한계는
ptviInvalidLimits로 보고됩니다 - 정확히 하나의 완전한 PDF 객체가 아닌 원시 값은
ptviInvalidValue로 보고됩니다 - 예산 초과:
ptviDepthLimit,ptviNodeLimit,ptviEntryLimit,ptviDeadlineExceeded가TPdfTreeEditOptions와 중앙 파서 예산 양쪽에 의해 제한됩니다
TPdfTreeEditOptions.Default는 그 트리 로컬 한계를 항목 1,000,000개, 노드 100,000개, 깊이 64, 리프당 항목 64개, 노드당 자식 32개로 설정합니다. 리프와 자식 수치는 단순한 가드가 아닙니다. 트리가 나중에 재빌드될 때 사용되는 팬아웃이며, 그래서 레코드는 둘 다 최소 2임을 검증합니다
uses
FPdfCompress;
var
Options: TPdfTreeEditOptions;
RootRef: TPdfTreeObjectReference;
Editor: TPdfTreeEditor;
Report: TPdfTreeValidationReport;
begin
Options := TPdfTreeEditOptions.Default;
RootRef.ObjectNumber := EmbeddedFilesRootObject; { 카탈로그 /Names /EmbeddedFiles }
RootRef.Generation := EmbeddedFilesRootGeneration;
if not TPdfTreeEditor.TryLoad(Source, ptkName, RootRef, Options,
Editor, Report) then
begin
Writeln('tree rejected: ', Report.ErrorMessage);
Exit;
end;
try
{ 값은 하나의 완전한 PDF 객체의 원시 바이트입니다 }
Editor.UpsertName('invoice-2026-09',
'<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
Editor.RemoveName('draft-2025-11');
finally
{ 아래 저장 이후 Editor.Free }
end;
end;
항목 값은 원시 PDF 객체 바이트로 유지됩니다
항목 값은 그대로 저장되며, 이것이 편집기를 여러분이 만들지 않은 파일에도 안전하게 겨누게 만드는 단 하나의 설계 결정입니다. TPdfTreeEntry는 NameKey, NumberKey, RawValue를 지니고, RawValue는 하나의 완전한 PDF 객체의 원시 바이트 묶음입니다. 사전, 배열, 문자열, 스트림 참조, 또는 맨몸의 간접 참조입니다. Delphi variant나 TStrings, 타입화된 레코드로 디코딩되는 것은 없습니다. 그 대가는 유닛이 전혀 모르는 값의 무손실 왕복입니다. 벤더 확장을 담은 /Filespec, 특이한 핏 타입의 목적지 배열, 개인 키가 든 /JavaScript 액션 사전 모두 바이트 단위로 재빌드를 살아남습니다. 편집기가 그 내용에 대해 어떤 의견도 가진 적이 없기 때문입니다. 또한 수정 API를 증명 가능한 것으로 줄입니다. UpsertName과 UpsertNumber는 제공된 값이 정확히 하나의 완전한 객체로 파싱되는지 검증하기만 하면 되고, RemoveName, RemoveNumber, Clear는 항목 목록만 건드립니다
루트 번호를 다시 매기지 않고 균형 트리를 재빌드하는 방법
하나의 고정점이 있는 두 번의 패스로 합니다. BuildBalancedObjects는 먼저 정렬된 항목 목록을 최대 MaxLeafEntries개의 리프로 자르고, 각 레벨을 최대 MaxKidsPerNode개의 부모로 접은 뒤 단일 노드가 남을 때까지 반복합니다. 한계는 아래에서 위로 계산됩니다. 리프는 자신의 첫 키와 마지막 키를, 내부 노드는 첫 자식의 하한과 마지막 자식의 상한을 취하므로, 형제 범위가 겹치는 것은 구조적으로 일어날 수 없습니다
고정점은 정체성입니다. 최종 루트는 항상 트리가 로드된 객체 번호와 세대를 받으므로, 카탈로그 참조는 유효하게 유지되고 문서의 다른 어떤 것도 다시 쓸 필요가 없습니다. 다른 모든 노드는 노드 종류와 전체 키 범위가 일치할 때만 이전에 로드된 노드의 참조를 재사용하고, 그렇지 않으면 활성 최대 객체 번호에 1을 더해 새 번호를 할당받습니다. 그 조합은 바뀌지 않은 서브트리가 기존 객체를 계속 가리키게 하면서, 내용이 움직인 노드는 조용히 재해석되는 대신 새 객체로 기록됨을 보장합니다. 같은 참조 재사용 원칙이 형제 유닛의 개요 편집기와 페이지 재매핑을 구동합니다
var
Serialized: TPdfTreeSerializedObjects;
I: Integer;
begin
{ FirstNewObjectNumber는 소스 리비전에서 옵니다 }
if Editor.BuildBalancedObjects(MaximumObjectNumber + 1,
Serialized, Report) then
begin
Writeln('nodes ', Report.NodeCount, ' depth ', Report.MaximumDepth);
for I := 0 to High(Serialized) do
Writeln(Serialized[I].Reference.ObjectNumber, ' ',
Serialized[I].Reference.Generation, ' obj');
end
else
for I := 0 to High(Report.Issues) do
Writeln('issue ', Ord(Report.Issues[I].Kind), ' at obj ',
Report.Issues[I].ObjectNumber, ': ', Report.Issues[I].Message);
end;
리비전을 쓰고 나서 결과를 다시 읽습니다
저장은 출력이 다시 열리고 비교된 다음에야 끝납니다. SaveIncremental은 소스 리비전 사실을 읽고, 문서 카탈로그 자체인 루트를 거부하며, 균형 노드 집합과 카탈로그 객체를 직렬화해 모든 것을 AppendPdfSparseUpdate에 넘깁니다. 그것은 기존 /Info 참조와 문서 /ID 배열을 보존하면서 §7.5.6에 따라 새 리비전을 덧붙입니다
그다음은 여러분 자신의 라이터에 복사해 넣을 가치가 있는 부분입니다. 편집기는 TPdfTreeEditor.TryLoad를 다시 호출하는데, 이번에는 같은 루트 참조와 같은 옵션으로 대상 스트림을 상대로 호출하며, 다시 로드된 항목 수와 모든 키와 RawValue를 쓰려 했던 것과 비교합니다. 재로드가 실패하거나 어떤 비교라도 다르면 대상 스트림은 반환 대신 리셋되므로, 절반만 쓰인 리비전이 성공으로 건네지는 일은 없습니다. 검증은 sparse lazy PDF 객체 인덱스에 관한 글에서 설명한 것과 같은 제한된 sparse 경로로 출력을 읽으며, 이것이 대형 파일에서도 검사 비용을 감당할 수 있게 합니다
var
Dest: TFileStream;
begin
Dest := TFileStream.Create(OutputName, fmCreate);
try
if Editor.SaveIncremental(Source, Dest, Report) then
Writeln('wrote ', Report.NodeCount, ' nodes, ',
Report.EntryCount, ' entries')
else
{ 대상은 이미 리셋됐고, 절반만 쓰인 것은 남지 않았습니다 }
Writeln('save refused: ', Report.ErrorMessage);
finally
Dest.Free;
Editor.Free;
end;
end;
암호화된 소스는 거부되며, 그것이 정직합니다
현재 구현은 암호화된 소스를 완전히 거부합니다. 이유는 방어적이기보다 구체적입니다. 트리 재빌드는 새 문자열 객체를 도입하고 새 스트림 참조를 도입할 수 있는데, §7.6은 그것들이 문서 키와 소유 객체 번호 및 세대로 객체별로 암호화되어야 한다고 요구합니다. 평문 문자열을 덧붙이면서 /Encrypt 트레일러를 앞으로 복사하면, 열리고 나서 새 키가 있는 바로 그 지점에서 쓰레기로 읽히는 파일이 생깁니다. 명확한 거부보다 나쁩니다. 두 가지 추가 한계도 솔직히 밝힐 가치가 있습니다. 편집기는 소스 스트림의 스냅샷 위에서 동작하므로, 편집기가 살아 있는 동안 소스를 수정하면 안 되고, 카탈로그를 트리 루트로 취급하지 않습니다. 카탈로그는 트리 노드 자체가 아니라 트리를 우연히 담고 있는 사전이기 때문입니다. /EmbeddedFiles, /Dests, /JavaScript, /PageLabels를 쓰는 Delphi, C++Builder 또는 Lazarus 코드를 유지하며 리프 배열을 손으로 패치해 왔다면, 트리 편집기와 검증된 증분 저장이 PDFiumPas Delphi PDFium 컴포넌트에 포함되어 있습니다