페이지만 다루는 편집기로 태그가 지정된 PDF에서 Link 주석을 지우면 페이지는 멀쩡히 그려지는데, 구조 트리에는 사라진 주석을 위한 OBJR과 ParentTree 항목, Link 요소가 그대로 남습니다. PDFium Component의 RemovePdfPageAnnotations는 주석을 페이지에서 빼고, 요청하면 그 구조 조각들도 함께 치웁니다
태그 주석이 구조 트리에 연결되는 자리
태그 주석은 네 객체가 만나는 지점에 앉으며, 깨끗한 삭제는 그 넷을 모두 들러야 합니다. ISO 32000-1 §14.7.4.3은 구조 요소의 콘텐츠가 주석 같은 온전한 PDF 객체일 때 요소의 /K 배열이 객체 참조 사전(표 325)으로 그것을 이름 짓는다고 말합니다: /Type /OBJR, 주석을 가리키는 필수 간접 참조 /Obj, 그리고 요소 자신의 페이지를 겹쳐 쓰는 선택적 /Pg가 그것입니다. 이것으로 트리에서 페이지로 향하는 순방향이 커버됩니다
역방향 경로는 §14.7.4.4입니다. 부모 트리(parent tree)는 콘텐츠 항목인 모든 객체에 항목을 두는 번호 트리(§7.9.7)이고, 그 객체가 실은 정수로 키가 매겨집니다. 주석은 /StructParent 키를 달며 부모 트리 값은 소유 요소를 향한 단일 간접 참조입니다. 마크드 콘텐츠를 담은 콘텐츠 스트림을 지닌 페이지는 대신 /StructParents를 달며 그 값은 MCID로 색인되는 배열입니다. 표 326은 어떤 객체든 두 키를 많아야 하나만 허용하는데, 객체는 그 자체로 온전한 콘텐츠 항목이거나 마크드 콘텐츠를 담는 그릇이거나 둘 중 하나이기 때문입니다. 하이퍼링크에 대해서는 §14.8.4.4.2가 Link 요소가 보이는 단어들을 마크드 콘텐츠 참조(MCR)로 담는 동시에 링크 주석을 향한 OBJR도 담는 것을 허용합니다. 주석을 지워 버리면 청산 목록은 다음과 같습니다:
| 위치 | 담고 있는 것 | 깨끗한 제거가 하는 일 |
|---|---|---|
페이지 /Annots | 주석을 향한 간접 참조 | 참조를 떼어 내고, 남은 주석은 순서를 지킵니다 |
Link 요소 /K | /Obj와 /Pg를 지닌 OBJR | OBJR 콘텐츠 항목을 제거합니다 |
| 주석 사전 | /StructParent 정수 키 | 객체가 더 이상 콘텐츠 항목이 아니므로 키를 제거합니다 |
ParentTree /Nums 잎 | Link 요소 참조와 짝을 이루는 키 | 키와 값 쌍을 제거합니다 |
| 구조 트리 루트 | /ParentTreeNextKey | 그대로 둡니다 |
유령 링크는 어떻게 주석보다 오래 살아남는가?
페이지 수준 편집기는 주석을 /Annots에서 빼 내는 방식으로 지우고, 이 지점에서 두 갈래 중 하나로 갑니다. 도구가 주석 객체까지 해제하면 OBJR의 /Obj는 이제 정의되지 않은 객체를 가리키고, §7.3.10은 리더에게 그것을 null로 다루라고 말합니다. Link 요소가 아무것에도 해석되지 않는 콘텐츠 항목을 소유하는 셈입니다. 도구가 객체를 파일에 남겨 두면 OBJR은 여전히 해석되지만, 그 /Pg가 가리키는 페이지의 /Annots는 더는 그것을 나열하지 않습니다. 두 경우 모두 Link 요소는 마크드 콘텐츠 자식들을 지키므로 "데이터시트를 내려받기"라는 단어들은 여전히 링크로 태그되어 있고, 읽기 순서대로 트리를 순회하는 스크린 리더는 페이지의 그 어떤 것도 활성화하지 않을 링크를 읽어 주려 들 수 있습니다. ISO 14289-1에 대해 검증하는 PAC와 veraPDF 같은 PDF/UA 검사기는 페이지가 나열하는 것과 트리가 주장하는 것을 비교하므로, 그려진 페이지가 완벽해 보일 때에도 두쪽의 어긋남은 거기서 드러날 수 있습니다
콘텐츠 삭제에는 이 문제의 다른 판이 있습니다. /P <</MCID 4>> BDC … EMC 안의 텍스트 객체를 지우고 연산자는 남겨 두면, 페이지는 빈 마크드 콘텐츠 시퀀스를 지니고 그 MCR은 여전히 <P> 요소에 앉아 있습니다. 리더가 받는 것은 단어 없는 문단입니다. BDC는 지우고 EMC는 남기면 콘텐츠 스트림은 그 자체로 깨집니다. §14.6은 모든 마크드 콘텐츠 시퀀스가 하나의 콘텐츠 스트림 안에서 BDC나 BMC와 짝이 맞는 EMC로 괄호되기를 요구하며, 고아가 된 EMC는 페이지의 이후 모든 시퀀스를 잘못된 열기 기호와 짝지어 놓습니다
RemovePdfPageAnnotations로 주석 제거하기
RemovePdfPageAnnotations는 FPdfDocumentStructure에 있으며, 소스 스트림과 페이지 트리 순서의 0 기반 페이지 인덱스, 제거할 주석들의 객체 번호, TPdfAnnotationRemovalOptions 레코드, 그리고 새 파일과 TPdfAnnotationRemovalReport를 위한 out 매개변수 둘을 받습니다. TPdfAnnotationRemovalOptions.Default에서는 페이지만 손댑니다. 하나의 증분 리비전에 짧아진 /Annots 배열을 지닌 페이지 사전(마지막 주석이 사라면 /Annots 키 자체가 없어집니다)과 바뀌지 않은 카탈로그가 실려서, 객체 번호와 문서 ID, 그 밖의 모든 페이지 키가 살아남습니다. 주석 객체는 몸통을 지키고, 그 번호를 아직 쥐고 있는 호출자는 읽을 수 있습니다. 구조 트리는 원래 그대로 남는데, Link 요소를 대체 주석으로 향하게 하려던 경우 태그가 온전히 지켜집니다. 무엇이든 쓰기 전에 입력을 검사합니다:
- 각 번호는 선택된 페이지의
/Annots에 있어야 하고/Type /Annot객체를 가리켜야 하며, 한 호출에 두 번 나열된 번호는 거부됩니다 - 위젯 주석은 거부됩니다. 위젯은 자기 양식 필드에 속하고, 페이지에서 떼어 내면 어느 페이지에도 위젯이 그려지지 않는 필드가 남기 때문입니다
- 암호화된 소스는 거절됩니다. 구조 편집은 복호화된 바이트를 필요로 하므로, 파일을 미리 복호화해 두십시오
MaxOutputBytes와MaxWork,CancellationToken이 작업을 묶으며, 실패하면 출력 없이False를 돌려주고 어느 단계에서 멈췄는지ErrorMessage가 말해 줍니다
태그가 지정된 문서라면 구조 트리가 이미 어느 주석 번호가 중요한지 알려 줍니다. ReadPdfDocumentStructure 스냅샷의 모든 OBJR 항목은 참조된 객체 번호를 실으며, 페이지 번호는 OBJR 자신의 /Pg에서 오고, 그것이 없으면 요소의 것에서 옵니다. 아래 루틴은 한 페이지의 Link 요소가 참조하는 모든 주석을 모아 정리 옵션 둘을 모두 켠 채 제거합니다. 페이지 필터를 지키십시오. 다른 페이지에서 온 번호 하나가 일괄 전체를 거부시킵니다
uses
System.SysUtils, System.Classes, System.IOUtils,
FPdfCompress, FPdfDocumentStructure;
function RemoveTaggedLinks(const InName, OutName: string;
PageIndex: Integer): TPdfAnnotationRemovalReport;
var
Source: TMemoryStream;
Pages: TPdfSparsePageDictionaries;
ReadOptions: TPdfStructureEditOptions;
Snapshot: TPdfDocumentStructureSnapshot;
ReadReport: TPdfStructureEditReport;
Item: TPdfDocumentStructureContentItem;
Targets: array of Integer;
Options: TPdfAnnotationRemovalOptions;
Report: TPdfAnnotationRemovalReport;
Output: TBytes;
N, I, J: Integer;
Known: Boolean;
begin
Report := Default(TPdfAnnotationRemovalReport);
Source := TMemoryStream.Create;
try
Source.LoadFromFile(InName);
if not ReadPdfSparsePageDictionaries(Source, Pages) or
(PageIndex < 0) or (PageIndex > High(Pages)) then
raise Exception.Create('Page index is outside the page tree');
ReadOptions := TPdfStructureEditOptions.Default;
if not ReadPdfDocumentStructure(Source, ReadOptions, Snapshot, ReadReport) then
raise Exception.Create(ReadReport.ErrorMessage);
// 이 페이지의 Link 요소가 가리키는 모든 주석
for N := 0 to High(Snapshot.Nodes) do
if Snapshot.Nodes[N].RoleName = 'Link' then
for I := 0 to High(Snapshot.Nodes[N].ContentItems) do
begin
Item := Snapshot.Nodes[N].ContentItems[I];
if (Item.Kind <> psckObjectReference) or
(Item.PageObjectNumber <> Pages[PageIndex].ObjectNumber) then
Continue;
Known := False;
for J := 0 to High(Targets) do
if Targets[J] = Item.ReferencedObjectNumber then Known := True;
if not Known then
begin
SetLength(Targets, Length(Targets) + 1);
Targets[High(Targets)] := Item.ReferencedObjectNumber;
end;
end;
if Length(Targets) = 0 then
Exit(Report);
Options := TPdfAnnotationRemovalOptions.Default;
Options.RemoveOwnedStructurePeers := True; // OBJR 항목과 ParentTree 항목
Options.RemoveEmptiedStructureNodes := True; // 안에 아무것도 남지 않은 요소들
if not RemovePdfPageAnnotations(Source, PageIndex, Targets, Options,
Output, Report) then
raise Exception.Create(Report.ErrorMessage);
TFile.WriteAllBytes(OutName, Output);
Result := Report;
finally
Source.Free;
end;
end;
ParentTree 키는 어떻게 되는가
RemoveOwnedStructurePeers가 켜져 있으면 /Obj가 그 페이지에서 제거된 주석인 OBJR마다 구조 편집기를 통해 요소를 떠나고, 역방향 색인은 그 덕에 발이 맞습니다. 주석의 /StructParent 아래 저장된 키와 값 쌍은 /Nums 잎에서 빼 내고, /StructParent 키 자체도 주석 사전에서 제거합니다. 표 326이 그 키를 구조 콘텐츠 항목인 객체에만 요구하기 때문입니다. /ParentTreeNextKey는 낮아지지 않습니다. §14.7.4.4는 그 값이 쓰이는 모든 키보다 크기만을 요구하는데 제거가 그것을 깨뜨릴 수 없고, 번호 트리 키는 연속일 필요도 없으니 /ParentTreeNextKey를 따르는 라이터는 해방된 키를 다시 나눠 주는 일이 없습니다. 살아남은 주석은 객체 번호와 /Annots에서의 위치, ParentTree 키를 모두 지키며, 함수는 쓴 페이지를 다시 읽고 살아남은 주석이 사라졌거나 자리를 바꿨으면 결과를 거부합니다. 기대: 태그 링크가 하나인 페이지에서 Report.RemovedAnnotations와 Report.RemovedObjectReferences는 둘 다 1이고, Report.SourceSha256과 Report.OutputSha256은 어느 바이트가 들어가고 어느 바이트가 나왔는지 기록합니다
비워진 요소도 언제 함께 지우는가?
RemoveEmptiedStructureNodes는 OBJR 제거가 콘텐츠 항목도 자식 요소도 없이 남겨 둔 요소를, 가장 깊은 것부터 제거합니다. 여러 차례에 걸쳐 돕니다. 각 차례는 구조를 다시 읽고 이제 비었는 요소를 제거하며 그 부모를 다음 차례 후보에 더하므로, 감싸는 요소의 사슬은 아래에서 위로 무너지고 구조 트리 루트는 언제나 남습니다. 스티키 노트를 태그하는 Annot 요소는 보통 OBJR 외에는 아무것도 지니지 않고(§14.8.4.4.3이 그것을 허용합니다), 비워지고 사라집니다. Link 요소는 다릅니다. 보이는 단어들의 마크드 콘텐츠 참조를 여전히 소유하므로 비어 있지 않고, 주석 없는 Link로서 살아남습니다. RemovePdfPageAnnotations는 이름을 바꾸지 않습니다. 그 단어들이 링크로 남아야 하는지는 구조 문제가 아니라 편집 판단의 영역이기 때문입니다. 순수한 삭제를 원하면 두 옵션을 모두 켠 뒤 OBJR 없이 남은 Link 요소를 Span으로 강등하십시오. 보조 기술은 그 단어들을 어디에도 가지 않는 링크 대신 평문으로 읽습니다
function DemoteUnlinkedLinks(const Data: TBytes; out Fixed: TBytes): Integer;
var
Source: TBytesStream;
Options: TPdfStructureEditOptions;
Snapshot: TPdfDocumentStructureSnapshot;
Ops: TPdfStructureEditOperations;
Report: TPdfStructureEditReport;
N: Integer;
begin
Result := 0;
Fixed := Data;
Source := TBytesStream.Create(Data);
try
Options := TPdfStructureEditOptions.Default;
if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
raise Exception.Create(Report.ErrorMessage);
for N := 0 to High(Snapshot.Nodes) do
if (Snapshot.Nodes[N].RoleName = 'Link') and
(Snapshot.Nodes[N].ObjectReferenceCount = 0) and
(Length(Snapshot.Nodes[N].ContentItems) > 0) then
begin
SetLength(Ops, Length(Ops) + 1);
Ops[High(Ops)] := Default(TPdfStructureEditOperation);
Ops[High(Ops)].Kind := psekSetRole;
Ops[High(Ops)].NodeID := Snapshot.Nodes[N].NodeID;
Ops[High(Ops)].Value := 'Span';
end;
if Length(Ops) = 0 then Exit;
// 스냅샷 다이제스트가 도중의 바이트 변경을 막습니다
if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
Options, Fixed, Report) then
raise Exception.Create(Report.ErrorMessage);
Result := Length(Ops);
finally
Source.Free;
end;
end;
삭제가 교체의 절반이라면 기본값을 지키십시오. 제자리에 남은 비워진 요소는 여전히 자기 역할과 제목, 대체 텍스트, 요소 ID를 실습니다. 이후 단계가 psekAddObjectReference 연산으로 새 주석을 요소에 붙이면, 그 연산은 주석에 새 /StructParent 키를 주고 부모 트리에 새 MCR과 OBJR 항목을 등록하는 같은 규칙에 따라 /ParentTreeNextKey를 앞당기며, 요소는 읽기 순서에서 자기 자리를 지킵니다. 그 요소를 제거했다가 재생성하면 그 값들은 버려지고 새 객체 번호가 주어집니다
태그 콘텐츠 삭제: 소유권은 BDC에서 그 EMC까지
주석은 페이지 객체가 아니므로 텍스트와 경로, 이미지는 다른 API를 거칩니다. FPdfStyledEdit의 일괄 객체 편집기, 곧 PlanStyledPdfObjectEdits와 ExecuteStyledPdfObjectEdits인데, 그 TPdfObjectEditOptions는 같은 의미의 RemoveOwnedStructurePeers와 RemoveEmptiedStructureNodes를 실어 나릅니다. 태그된 마크드 콘텐츠 시퀀스는 BDC와 짝 EMC 사이의 모든 객체가 삭제될 때에만 구조 항목과 함께 사라집니다. 그 경우 두 연산자는 콘텐츠 스트림을 떠나고, MCR은 요소를 떠나며, 페이지 ParentTree 배열에서 그 MCID의 슬롯은 닫히는 대신 null로 놓입니다. 페이지의 다른 모든 MCID가 같은 배열로 색인되기 때문입니다. 살아남는 콘텐츠까지 감싸는 시퀀스는 MCR과 함께 남습니다. 제거된 MCID를 살아남는 콘텐츠에 재사용하는 페이지는 그 자체로 거부됩니다. MCID는 페이지 콘텐츠 스트림에 한정되고 구조 항목을 없애면 살아남는 쪽이 고아가 되기 때문입니다. 옵션을 False로 두면 일괄 작업은 대신 제거 경계에서 BDC와 EMC 짝을 지키므로, 구조 트리는 해석을 계속하고 그 안의 것은 하나도 바뀌지 않습니다
소유권은 BDC에서 짝이 되는 EMC의 끝까지 재어야 합니다. 첫 버전은 구문 분석된 마크가 자기 위치를 기록하는 곳인 BDC 연산자의 바이트 범위만으로 재었다가, 둘러싸인 객체를 하나도 찾지 못했습니다. 짝지어진 실수로는 BDC에 제거 표시를 해 놓고 파트너를 놓치는 것이 있는데, 그러면 위에서 묘사한 그 불균형 스트림이 정확히 쓰여 나옵니다. 그래서 편집기는 항상 짝의 두 절반을 함께 제거합니다. 아래 루틴은 하나의 MCID를 실은 모든 최상위 객체를 지웁니다. ExecuteStyledPdfObjectEdits는 EditOptions.PreviewOnly가 설정되지 않았으면 결과로 Pdf를 다시 적재하고, Report.RemovedStructurePeers와 Report.RemovedStructureNodes는 트리를 떠난 것을 세어 줍니다. 함수는 그 리포트를 돌려줍니다
uses
System.SysUtils, PDFium, FPdfStyledEdit;
function DeleteTaggedRun(Pdf: TPdf; PageIndex, Mcid: Integer): TPdfObjectEditReport;
var
Ops: TPdfObjectEditOperations;
NoSemanticEdits: TPdfObjectSemanticEdits;
Options: TPdfObjectEditOptions;
Plan: TPdfObjectEditPlan;
Preview: TBytes;
Report: TPdfObjectEditReport;
I: Integer;
begin
Pdf.PageNumber := PageIndex + 1; // PageNumber는 1 기반입니다
for I := 0 to Pdf.ObjectCount - 1 do
if Pdf.PageObjectMarkedContentID(I) = Mcid then
begin
SetLength(Ops, Length(Ops) + 1);
Ops[High(Ops)] := Default(TPdfObjectEditOperation);
Ops[High(Ops)].Kind := poekRemove;
Ops[High(Ops)].ObjectIndex := I;
end;
if Length(Ops) = 0 then
raise Exception.CreateFmt('No object on page %d carries MCID %d',
[PageIndex + 1, Mcid]);
NoSemanticEdits := nil;
Options := TPdfObjectEditOptions.Default;
Options.RemoveOwnedStructurePeers := True;
Options.RemoveEmptiedStructureNodes := True;
Plan := PlanStyledPdfObjectEdits(Pdf, PageIndex, Ops, NoSemanticEdits, Options);
if not ExecuteStyledPdfObjectEdits(Pdf, Plan, Options, Preview, Report) then
raise Exception.Create(Report.ErrorMessage);
Result := Report;
end;
두 호출 중 어느 쪽이든 제거된 주석은 파일에서 자기 객체를 여전히 차지합니다. 페이지 참조만 거둬 들이기 때문입니다. OBJR이 사라지고 나면 카탈로그 그래프의 그 어떤 것도 그 객체에 닿지 않으므로, TPdf.SaveAsReachable이 다시 쓴 사본에서 그 고아 객체를 쓸어 내는 일도 가능합니다. 기본 옵션에서는 OBJR이 주석을 도달 가능하게 지키고 청소도 그것을 지킵니다. 삭제가 최종일 때는 두 구조 옵션을 켠 채 제거를 실행하십시오. 그러면 Delphi용 PDFium Component가 되돌려 쓰는 파일의 페이지와 구조 트리, 부모 트리가 모두 같은 주석 집합을 묘사합니다