기술 문서

Delphi에서 /ParentTree와 /StructParents 동기화

PDFium Component는 FPdfDocumentStructure.pas의 EditPdfDocumentStructure로 Delphi에서 기존 PDF 콘텐츠에 태그를 답니다. psekAddMarkedContent와 psekAddObjectReference 연산은 구조 요소의 /K에 MCR이나 OBJR을 덧붙이는 데 그치지 않습니다. /StructParents나 /StructParent 키를 할당하고 ISO 32000-1 §14.7.4.4가 요구하는 역 인덱스인 짝 /ParentTree 항목을 씁니다

두 번째 절반이 바로 태그 개조가 보통 틀리는 곳입니다. BDC 래퍼와 MCR을 더하는 것은 쉽고, 위에서 아래로 구조 트리를 걷는 뷰어는 기쁘게 새 콘텐츠를 찾습니다. 그러나 보조 기술은 흔히 반대 방향으로 갑니다. 페이지의 콘텐츠 한 조각에서 출발해 어느 구조 요소가 그것을 소유하는지 묻죠. 페이지에 /StructParents 키가 없거나 /ParentTree에 새 MCID의 항목이 없으면 그 조회는 실패하고, 검사기는 이를 누락되거나 일치하지 않는 부모 트리로 보고합니다. 파일은 태그된 것처럼 보이지만 여전히 동작하지 않습니다

/ParentTree는 실제로 무엇을 매핑하나

/ParentTree는 정수 키를 구조 요소로 매핑하는 넘버 트리이며, 키는 트리가 아니라 콘텐츠에서 옵니다. ISO 32000-1 §14.7.4.4는 마크드 콘텐츠를 담는 모든 페이지나 Form XObject에 /StructParents 정수를 주고, 그 키 아래의 부모 트리 값은 MCID로 인덱스되는 배열입니다. 배열의 3번 항목은 <</MCID 3>>을 소유하는 구조 요소입니다. 콘텐츠 항목 통째로 쓰이는 어노테이션이나 XObject는 대신 단일 /StructParent 키를 받으며 그 부모 트리 값은 배열이 아니라 구조 요소 자체입니다

Delphi에서 PDFium Component가 쓴 PDF/UA 부모 트리 역 인덱스: MCID 3을 실은 콘텐츠 마크가 페이지 /StructParents 키 17을 통해 /ParentTree 넘버 트리로 해석되며, 그 배열 항목 3이 마크드 콘텐츠를 소유하는 구조 요소임
태그된 PDF에서 읽기 순서가 중요합니다. 보조 기술은 페이지의 MCID에서 /StructParents를 거쳐 /ParentTree 배열로 걷는데, 키나 항목이 하나 없어도 그 조회는 깨집니다. 구조 트리 자체는 멀쩡해 보이더라도

컨테이너 형식은 §7.9.7의 범용 넘버 트리입니다. 리프는 키와 값 쌍을 키 오름차순으로 정렬한 평면 배열인 /Nums를 담습니다. 루트 아래의 중간 및 리프 노드는 커버하는 가장 작은 키와 가장 큰 키를 담은 /Limits를 갖는 반면, 루트 자체에는 /Limits가 없어야 합니다. 그 위에 구조 트리 루트는 /ParentTreeNextKey(§14.7.2), 즉 라이터가 넘겨 줄 다음 키를 실을 수 있습니다. 따라서 새 콘텐츠에 태그를 다는 모든 편집은 합의해야 하는 최대 네 객체를 건드립니다. 콘텐츠 스트림, 페이지나 폼 딕셔너리, 부모 트리 리프, 구조 트리 루트입니다

순방향에도 §14.7.4.2와 §14.7.4.3이 정한 규칙이 있습니다. 페이지 자체 콘텐츠 스트림의 콘텐츠에 대한 마크드 콘텐츠 참조(MCR)는 /Pg와 /MCID만 필요합니다. 마크드 콘텐츠가 Form XObject 안에 살면 MCR은 그 스트림을 이름 짓는 /Stm도 필요합니다. MCID는 콘텐츠 스트림별로 번호가 매겨지기 때문입니다. 객체 참조(OBJR)는 /Obj로 어노테이션이나 XObject를, /Pg로 그 페이지를 이름 짓습니다

EditPdfDocumentStructure로 콘텐츠 스트림에 태그 달기

EditPdfDocumentStructure는 스냅샷으로 보호되는 배치 편집기입니다. 먼저 ReadPdfDocumentStructure를 호출해 TPdfDocumentStructureSnapshot을 얻은 뒤, 그 SourceSha256을 TPdfStructureEditOperation 레코드 배열과 함께 돌려줍니다. 그 사이 소스 바이트가 바뀌었으면 편집은 출력 없이 거부됩니다. 기존 요소는 NodeID의 객체 번호로 지정되고 0은 구조 트리 루트를 뜻합니다

uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;

function TagContentStream(const FileName, OutName: string;
  ElementID, StreamObj, StreamGen: Integer): Boolean;
var
  Source: TMemoryStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  Output: TBytes;
begin
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(FileName);
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);

    SetLength(Ops, 1);
    Ops[0] := Default(TPdfStructureEditOperation);
    Ops[0].Kind := psekAddMarkedContent;
    Ops[0].NodeID := ElementID;            // 객체 번호로 지정한 기존 요소
    Ops[0].TargetObjectNumber := StreamObj; // 페이지 콘텐츠 스트림 또는 페이지 Form XObject
    Ops[0].TargetGeneration := StreamGen;

    Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
      Ops, Options, Output, Report);
    if not Result then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
  finally
    Source.Free;
  end;
end;

그 하나의 연산에서 EditPdfDocumentStructure는 대상 스트림을 /P <</MCID n>> BDC ... EMC로 감싸고, 요소의 /K에 MCR을 덧붙이고, 없으면 페이지(또는 폼)에 /StructParents 키를 주고, 그 키의 부모 트리 배열에 요소 n을 씁니다. 결과는 원본 바이트 뒤의 증분 업데이트로 쓰이며 Report.CreatedMarkedContents가 몇 개의 MCR이 만들어졌는지 알려 줍니다. 스트림에 이미 균형 잡힌 BDC/EMC 쌍이 있으면 마지막 최상위 EMC 뒤의 태그 없는 꼬리만 감싸지고, 그렇지 않으면 스트림 전체가 감싸집니다

새 MCID와 부모 트리 키는 어떻게 정해지나

새 MCID는 현재 최댓값 더하기 1이고, 새 부모 트리 키는 Max(MaxKey + 1, ParentTreeNextKey)입니다. MCID의 경우 EditPdfDocumentStructure는 그 페이지(또는 그 Form XObject)에 이미 기록된 MCR들과 스트림 자체 속성 목록에서 찾은 모든 /MCID를 통틀어 최댓값을 잡습니다. 둘 다 세는 것이 중요합니다. 구조 트리는 콘텐츠가 여전히 마크를 실은 채 MCR을 잃었을 수 있고, 그 번호를 재사용하면 두 요소가 같은 콘텐츠를 청구하게 됩니다

키 논리는 같은 방어적 발상을 따릅니다. 소유자가 이미 /StructParents 값을 갖고 있으면 기존 배열이 MCID 인덱스에서 확장되고, 빈틈은 null이 채우며, 새 키는 할당되지 않습니다. 없다면 컴포넌트는 모든 /Nums 리프에서 최고 키를 스캔해 /ParentTreeNextKey와 비교해 큰 쪽을 택합니다. 이미 쓰이는 키보다 낮은 낡은 /ParentTreeNextKey와 마지막 항목을 넘어 키를 예약하는 것 모두 처리됩니다. 두 값 중 큰 쪽은 항상 자유이기 때문입니다. 할당 후 /ParentTreeNextKey는 새 키 더하기 1로 진전됩니다

Delphi용 PDFium Component 구조 편집기의 MCID와 부모 트리 키 할당: 새 MCID는 MCR과 스트림 속성 목록에 이미 기록된 최댓값보다 하나 크고, 키는 최고 /Nums 키 더하기 1과 /ParentTreeNextKey 중 큰 쪽이며, 빈틈은 null이 채움
EditPdfDocumentStructure는 새 MCID를 MCR과 스트림 내 마크 양쪽에서 유도하고 부모 트리 키를 방어적으로 할당합니다. 낡은 /ParentTreeNextKey나 재사용된 MCID가 두 요소로 하여금 같은 콘텐츠를 청구하게 만들 수 없습니다

새 쌍은 덧붙여지는 게 아니라 오름차순으로 /Nums에 삽입됩니다. 넘버 트리 조회가 그 순서에 의존하기 때문입니다. 리프가 /Limits를 실고 있으면 경계는 다시 쓴 배열에서 재계산됩니다. 구조 트리 루트에 /ParentTree가 전혀 없을 때, 즉 순방향 트리만 쓴 도구가 태그한 파일에서 흔한 때에는, EditPdfDocumentStructure가 새 간접 << /Nums [] >> 객체를 합성해 첫 항목을 삽입하기 전에 /StructTreeRoot에서 연결합니다

Form XObject, /StmOwn, 객체 참조

Form XObject 안의 마크드 콘텐츠는 그 폼이 정확히 한 페이지에서 쓰일 때만 지원됩니다. EditPdfDocumentStructure가 폼에 대해 쓰는 MCR은 /Pg와 폼 스트림을 이름 짓는 /Stm, 페이지를 이름 짓는 /StmOwn을 실며, /StructParents 키는 페이지가 아니라 폼 딕셔너리에 갑니다. 세 페이지가 그리는 폼에는 그럴듯한 소유자가 셋이므로, 어떤 단일 /StmOwn이든 셋 중 둘에 대해 틀립니다. 컴포넌트는 추측하는 대신 오류로 편집을 거부합니다

Delphi에서 PDFium Component로 Form XObject 콘텐츠에 태그를 달 때의 단일 소유자 규칙: 한 페이지가 쓰는 폼 위의 MCR은 /Pg, /Stm, /StmOwn을 실고 /StructParents는 폼 딕셔너리에 가지만, 세 페이지가 그리는 폼은 /StmOwn을 모호하게 만들어 편집이 거부됨
/StmOwn 키는 한 페이지가 폼을 소유할 때만 의미가 있습니다. 그래서 공유 폼과 공유 어노테이션은 추측 가능한 소유자로 태그되는 대신 거부됩니다

같은 유일성 규칙이 psekAddObjectReference에도 적용됩니다. 대상은 정확히 한 페이지의 /Annots에 나타나는 어노테이션이거나 정확히 한 페이지의 리소스에 나타나는 Form이나 Image XObject여야 하며, 그 외는 거부됩니다. OBJR은 /Obj와 /Pg를 받고, 대상은 /StructParent 키를 받으며(있다면 재사용), 부모 트리 값은 요소에 대한 평범한 참조입니다. 시각적으로 태그됐지만 트리와 연결된 적 없는 링크 어노테이션이나 그림 이미지의 정석적인 수정입니다

// 태그 없는 이미지를 새 Figure 요소로 감싼 뒤 두 가지로 묶습니다:
// 이미지 XObject의 OBJR로, 그리고 페이지의 마크드 콘텐츠로
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // 임시 ID, 음수이고 유일해야 함
Ops[0].ParentNodeID := SectID;    // 기존 Sect 요소
Ops[0].Value := 'Figure';         // 표준 역할 또는 RoleMap 항목

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // 위에서 만든 컨테이너를 참조
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;

Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;

if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
  Output, Report) then
  Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
    Report.InsertedNodes[0].ObjectNumber,
    Report.CreatedObjectReferences, Report.CreatedMarkedContents]));

음수 NodeID 값은 같은 배치에서 이전에 만든 컨테이너를 이름 짓고, Report.InsertedNodes는 요청된 각 ID를 받은 객체 번호로 매핑합니다. psekInsertContainer는 표준 PDF 1.7, PDF 2.0, PDF/UA 역할인 P, H1에서 H6, Figure, Table, LI와 /RoleMap에 이미 매핑된 커스텀 역할을 받습니다. 두 콘텐츠 연산에 BeforeNodeID를 설정하면 새 MCR이나 OBJR이 끝이 아니라 기존 자식 앞에 놓입니다

한 배치에서 같은 스트림을 두 번 편집하면 왜 신경 써야 하나

배치는 같은 객체를 반복해서 만질 수 있고, 나중의 모든 연산은 이전 연산의 결과를 봐야 합니다. 같은 페이지에 대한 두 psekAddMarkedContent 연산은 모두 그 콘텐츠 스트림을 다시 쓰고, 모두 같은 부모 트리 리프를 갱신합니다. 각 편집을 별개의 덧붙인 객체로 스테이징하는 구현은 업데이트에 객체 12의 두 버전을 씁니다. 리더가 어느 쪽을 유지하느냐에 따라 첫 BDC 래퍼나 첫 /Nums 쌍을 잃고, 두 번째 MCID는 낡은 바이트에서 계산됐으므로 첫 번째와 충돌할 수 있습니다

EditPdfDocumentStructure는 스테이징된 업데이트를 (ObjectNumber, Generation)으로 키 매기고 기존 항목을 제자리에서 덮어쓰며, 동시에 그 객체의 메모리 내 사본을 새로 고칩니다. 따라서 두 번째 연산은 이미 감싸진 스트림을 파싱해 MCID 0을 보고, MCID 1을 할당하고, 같은 부모 트리 배열을 확장합니다. 한 객체, 한 최종 버전입니다. 무엇이든 반환하기 전에 출력은 다시 읽혀 메모리 내 모델과 비교됩니다. 요소 수, 콘텐츠 항목 수, 유지된 모든 MCR이나 OBJR이 일치해야 하며, 그렇지 않으면 호출은 출력 없이 실패합니다

압축 스트림도 같은 경로를 따릅니다. /Filter /FlateDecode(또는 한 원소 [/FlateDecode] 배열)를 갖고 /DecodeParms predictor가 없는 콘텐츠 스트림은 32 MiB나 MaxOutputBytes 중 작은 쪽으로 제한된 한도 있는 디코더로 풀리고, 감싸지고, 새 /Length와 함께 /FlateDecode로 다시 압축됩니다. 한도를 넘어 풀릴 스트림은 잘리는 대신 거부되며, 신뢰할 수 없는 소스의 파일에서는 이것이 중요합니다

PDF/UA 수리 실행 전에 알아 둘 한계

EditPdfDocumentStructure는 설계상 보수적이며, 대부분의 한계는 조용히 깨진 파일이 아니라 명확한 Report.ErrorMessage로 드러납니다. 배치 수리를 계획할 때 다음을 유의하십시오:

  • 필터 없는 스트림과 평범한 FlateDecode 스트림만 감쌀 수 있습니다. 다른 필터나 predictor가 있는 Flate는 거부됩니다
  • 콘텐츠 스트림은 정확히 한 페이지의 /Contents에 속해야 하고, Form XObject, 어노테이션, XObject는 정확히 한 페이지에서 쓰여야 합니다
  • 래퍼는 항상 /P 태그를 씁니다. 의미 역할은 BDC 태그 이름이 아니라 구조 요소의 /S에서 옵니다
  • 부모 트리 동기화는 평면 /Nums 루트나 한 단계의 /Kids 리프를 읽습니다. 더 깊은 중간 노드는 내려가지 않으므로, 그런 파일은 편집 뒤에 재검증하십시오
  • 서명이나 서명 필드를 담은 파일은 즉시 거부되므로, 구조 수리는 서명 전이 아니라 서명 뒤에 하십시오

수리 파이프라인에서 검증 단계는 선택이 아닙니다. 배치 전후로 PDFium Component의 PDF/UA 구조 트리 검증을 돌리고, 어느 페이지 객체가 새 마크 안에 끝났는지 확인해야 할 때는 BDC 태그와 MCID를 읽는 페이지 객체 마크드 콘텐츠 API를 쓰십시오. 깨끗한 부모 트리는 조회가 해석된다는 것만 말해 줍니다. 읽기 순서나 대체 텍스트가 맞는지는 말해 주지 않습니다

FAQ

EditPdfDocumentStructure는 /ParentTree 전체를 재구축하나요?

아니요. EditPdfDocumentStructure는 현재 배치에서 태그를 다는 콘텐츠의 항목만 갱신하며, 기존 /StructParents 키를 재사용하고 필요한 곳에 새 키를 할당하므로, 기존 부모 트리 항목은 그대로입니다

PDF에 구조 트리는 있는데 /ParentTree가 없으면 어떻게 되나요?

컴포넌트는 간접 << /Nums [] >> 넘버 트리를 만들고 /StructTreeRoot에서 연결한 뒤, 태그되는 콘텐츠의 첫 키를 삽입합니다

여러 페이지가 공유하는 Form XObject의 콘텐츠에 태그를 달 수 있나요?

이 연산으로는 아닙니다. 공유 폼은 /StmOwn을 모호하게 만들므로, psekAddMarkedContent는 폼이 정확히 한 페이지에서 참조되기를 요구하며 그 외에는 오류를 보고합니다

구조 트리 읽기와 편집, PDF/UA 검증, 마크드 콘텐츠 접근은 Delphi 및 C++Builder용 PDFium Component의 일부이며, Delphi, C++Builder, Lazarus 프로젝트를 위해 PDFium을 감쌉니다