태깅된 PDF에서 각 페이지 상단의 머리글은 보통 /Artifact <</Type /Pagination>> BDC … EMC 안에 들어가고, 그 아래 첫 문단은 /P <</MCID 0>> BDC … EMC 안에 들어갑니다. 둘 다 ISO 32000-1 §14.6이 말하는 마크드 콘텐츠이지만, 구조 트리에서 접근할 수 있는 것은 문단뿐입니다. 마크드 콘텐츠 식별자를 가진 쪽이 문단뿐이고, 바로 이 정수 하나가 그려진 글리프를 /H1이나 /P 같은 구조 요소와 묶어 주기 때문입니다. 따라서 태깅을 감사하거나 보수하는 코드는 모든 페이지 객체에서 세 가지(태그, 속성 목록, MCID)를 읽은 뒤, 그 숫자가 그 페이지의 구조 요소가 실제로 주장하는 MCID와 맞는지 확인해야 합니다
Delphi에서 BDC 마크와 속성 목록 읽기
PDFium Component에서 TPdf.PageObjectMarks(Index)는 Index 위치의 페이지 객체를 감싸고 있는 모든 마크를 TPdfContentMark 레코드 배열로 돌려줍니다. 각 레코드는 태그를 Name에, 속성 목록을 담은 Params 배열, 그리고 Handle을 가집니다. 이름은 맨 앞 슬래시 없이 돌아오므로 /P 시퀀스는 P로, 아티팩트는 Artifact로 보고됩니다. 마크드 콘텐츠 시퀀스는 중첩될 수 있는데, /Sect 안에 /P, 그 안에 /Span으로 둘러싸인 글리프 실행은 같은 객체에 세 개의 레코드를, 가장 바깥쪽부터 순서대로 보고합니다
ISO 32000-1 §14.6.2는 BDC의 속성 목록 피연산자에 두 가지 형태를 허용합니다. /P <</MCID 3>> BDC처럼 인라인 딕셔너리를 쓰거나, /OC /oc1 BDC처럼 페이지 리소스의 /Properties 하위 딕셔너리를 가리키는 이름을 쓰는 것입니다. 옵셔널 콘텐츠(§8.11.3.2)는 후자 형태로 기술됩니다. PDFium은 페이지를 파싱하면서 이름 형태를 해석하므로, 어느 쪽이든 Params에는 해당 시퀀스에 적용되는 딕셔너리의 항목이 나열됩니다
TPdf.PageObjectMarkedContentID(Index)는 구조 트리가 관심을 갖는 질문에 답합니다. 객체의 마크들에서 발견된 MCID를 돌려주고, 어느 것도 정수 /MCID를 담고 있지 않으면 -1을 돌려줍니다. 앞선 예제의 머리글은 -1을 돌려주는데, 이것이 올바른 값입니다. §14.8.2.2는 아티팩트를 논리 구조에서 배제하므로 아티팩트에는 연결할 대상이 없습니다
var
Marks: TPdfContentMarks;
M: TPdfContentMark;
P: TPdfContentMarkParam;
I: Integer;
begin
Pdf.PageNumber := 1; // PageNumber는 1부터 시작
for I := 0 to Pdf.ObjectCount - 1 do // 페이지 객체 인덱스는 0부터 시작
begin
Marks := Pdf.PageObjectMarks(I);
if Length(Marks) = 0 then
Continue;
Memo1.Lines.Add(Format('object %d, MCID %d',
[I, Pdf.PageObjectMarkedContentID(I)]));
for M in Marks do // 가장 바깥쪽 마크부터
begin
Memo1.Lines.Add(' mark ' + M.Name);
for P in M.Params do
case P.Kind of
pmpInt: Memo1.Lines.Add(' ' + P.Key + ' = ' + IntToStr(P.IntValue));
pmpFloat: Memo1.Lines.Add(' ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
pmpString: Memo1.Lines.Add(' ' + P.Key + ' = ' + P.StringValue);
pmpBlob: Memo1.Lines.Add(' ' + P.Key + ' = ' +
IntToStr(Length(P.BlobValue)) + ' bytes');
pmpUnknown: Memo1.Lines.Add(' ' + P.Key + ' = (no readable value)');
end;
end;
end;
end;
pmpUnknown이 알려 주는 것과 알려 주지 않는 것
각 TPdfContentMarkParam은 Key와 Kind, 그리고 그 Kind가 고르는 하나의 값 필드를 가집니다. 대응 관계는 외울 수 있을 만큼 짧습니다:
- 정수 값인 숫자는
pmpInt가 되며IntValue가 채워지고FloatValue에도 같은 숫자가 들어갑니다. 그래서/MCID 3이든 우연히 나온3.0이든 모두 정수로 읽힙니다 - 소수 부분을 가진 숫자는
pmpFloat가 됩니다 - PDF 문자열과 PDF 이름은 모두
pmpString이 됩니다. UTF-16 바이트 오더 마크(FE FF또는FF FE)로 시작하는 텍스트 문자열은StringValue로 디코딩되므로/ActualText가 읽을 수 있는 텍스트로 도착합니다 - 그 밖의 값 타입은 PDFium이 원시 바이트를 넘겨 줄 때
pmpBlob으로 돌아오고, 그렇지 않으면pmpUnknown으로 남습니다
pmpUnknown은 구분할 수 없는 두 가지 경우를 포괄합니다. 하나는 PDFium이 FPDF_OBJECT_UNKNOWN으로 보고하고 바이트를 하나도 넘겨 주지 않는 값이고, 다른 하나는 목록에는 나타나지만 다시 읽을 수 없는 키입니다. 손상된 파일에서 비 ASCII 바이트로 적힌 키는 목록에는 나타나면서도 pmpUnknown으로 돌아올 수 있습니다. 어느 쪽이든 레코드의 생김새는 같습니다
pmpUnknown은 "사용할 수 있는 값이 없다"는 뜻으로 취급하고, 마크를 그냥 받아들이기보다는 보고하세요. 워크플로가 /Lang이나 /ActualText의 존재에 의존한다면 알아볼 수 있는 Kind로 해당 키를 찾아야 하며, 알 수 없는 Kind만 보고 속성이 없다고 결론 내려서는 안 됩니다
Handle은 현재 로드된 페이지 안에서 마크를 식별하며, 마크가 제거되거나 페이지 객체가 파괴되거나 페이지가 언로드되는 순간 무효화됩니다. 페이지를 전환하기 전에 Name과 Params를 자신의 구조체로 복사해 두세요. 리스트 뷰나 백그라운드 감사처럼 현재 페이지보다 오래 살아남아야 하는 데이터에는 ReadPageObjectsSnapshot이 이름과 매개변수만 담고 핸들은 전혀 갖지 않는 TPdfContentMarkSnapshot 레코드를 돌려줍니다. 같은 규칙이 페이지 객체 핸들 일반에도 적용되며, 이에 대해서는 변환 후 페이지 객체 핸들이 무효화되는 문제 노트에서 다룹니다
마크 추가, 갱신, 삭제
편집은 네 개의 호출로 커버됩니다. AddPageObjectMark(Index, Name, Params)는 객체에 새 마크를 만들고 매개변수를 순서대로 적용한 뒤, 그 객체에서 새 마크의 인덱스를 돌려줍니다. 나머지 세 호출은 이 값을 MarkIndex로 받습니다. 이름은 슬래시 없이 넣습니다. 'P', 'Span'처럼요. 키 하나를 바꿀 때는 SetPageObjectMarkParam(Index, MarkIndex, Param)이 해당 키를 삽입하거나 대체하고 마크의 다른 매개변수는 그대로 둡니다. 삭제 계열은 더 단순합니다. RemovePageObjectMarkParam(Index, MarkIndex, Key)는 키를 하나 버리고, RemovePageObjectMark(Index, MarkIndex)는 마크 자체를 버립니다
AddPageObjectMark는 마크와 그 매개변수를 한 단계로 취급합니다. 매개변수 하나가 거부되면(pmpUnknown Kind, 빈 키, 빈 blob) 예외가 전파되기 전에 방금 만든 마크가 다시 제거되므로, 객체가 속성 목록이 반쯤 빠진 태그를 붙인 채로 남는 일은 없습니다
이 호출들은 모두 돌아오기 전에 페이지 콘텐츠 스트림을 다시 만듭니다. SaveAs는 콘텐츠 스트림을 지금 있는 그대로 기록할 뿐 PDFium 객체 모델에서 다시 생성하지 않기 때문에, 이 점이 중요합니다. 메모리 상의 객체에만 존재하던 마크는 저장된 파일에 BDC 연산자 하나 없는 결과를 남깁니다. ObjectHandle을 통해 PDFium 임포트를 직접 다뤄 페이지 객체를 편집하기도 한다면, 저장하기 전에 UpdatePage를 직접 호출하세요
호출할 때마다 스트림 전체를 다시 만드는 방식은 몇 번의 편집에는 괜찮지만 수백 번이면 낭비입니다. 태깅되지 않은 보고서를 페이지마다 태깅하는 작업은 후자에 해당하며, 이때 편집은 BeginPageObjectEditTransaction과 CommitPageObjectEditTransaction 안에 넣어야 합니다. 트랜잭션 안에서 각 편집은 페이지에 변경 표시만 하고, 커밋이 콘텐츠 스트림을 한 번만 생성합니다. RollbackPageObjectEditTransaction은 페이지를 원래 상태로 되돌립니다. 트랜잭션은 한 페이지에 묶이며, 열려 있는 동안 PageNumber를 다른 페이지로 바꾸면 예외가 발생합니다. 서명된 문서에서는 begin 호출이 AllowSignedDocument = True를 넘겨 주지 않으면 시작을 거부하는데, 페이지 콘텐츠 편집은 어차피 서명을 깨뜨리기 때문입니다
문자열 매개변수에는 결정이 하나 더 필요합니다. pmpString 값은 바이트 문자열로 기록되는데, /Lang (en-US) 같은 ASCII 값에는 정확히 맞습니다. 악센트, 합자, CJK 문자가 들어간 대체 텍스트는 FE FF 접두사를 붙인 UTF-16BE로 인코딩된 PDF 텍스트 문자열(§7.9.2.2)에 들어가야 합니다. 그 바이트들은 pmpBlob으로 넘기세요. PDFium이 문자열 객체로 저장하고, 앞의 리더가 다시 pmpString으로 StringValue에 디코딩해 돌려줍니다
// 라이브러리가 제공하는 것이 아닌 직접 만든 헬퍼: BOM을 붙인 UTF-16BE PDF 텍스트 문자열
function Utf16TextString(const S: string): TBytes;
var
I: Integer;
begin
SetLength(Result, 2 + Length(S) * 2);
Result[0] := $FE;
Result[1] := $FF;
for I := 1 to Length(S) do
begin
Result[I * 2] := Byte(Ord(S[I]) shr 8);
Result[I * 2 + 1] := Byte(Ord(S[I]));
end;
end;
procedure TagWord(Pdf: TPdf; ObjectIndex, Mcid: Integer; const Spoken: string);
var
Params: TPdfContentMarkParams;
Lang: TPdfContentMarkParam;
MarkIndex: Integer;
begin
Pdf.BeginPageObjectEditTransaction;
try
SetLength(Params, 2);
Params[0].Key := 'MCID';
Params[0].Kind := pmpInt;
Params[0].IntValue := Mcid;
Params[1].Key := 'ActualText';
Params[1].Kind := pmpBlob; // 텍스트 문자열로 저장되며 pmpString으로 다시 읽힘
Params[1].BlobValue := Utf16TextString(Spoken);
MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);
Lang := Default(TPdfContentMarkParam);
Lang.Key := 'Lang';
Lang.Kind := pmpString; // ASCII 값이므로 평범한 바이트 문자열로 충분
Lang.StringValue := 'de-DE';
Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
except
Pdf.RollbackPageObjectEditTransaction;
raise;
end;
Pdf.CommitPageObjectEditTransaction; // 배치 전체에 콘텐츠 스트림 재구축은 한 번
end;
커밋이 try 블록 바깥에 있는 것은 의도적입니다. 콘텐츠 생성이 실패하면 CommitPageObjectEditTransaction이 재발생 전에 스스로 페이지를 복원하고, 롤백을 한 번 더 호출하면 실제 오류를 덮고 "No page-object edit transaction is active"만 다시 올라올 뿐입니다
한 문단이 MCID 5 시퀀스 세 개로 끝나는 이유
AddPageObjectMark 호출은 값이 완전히 같더라도 매번 새로운, 독립된 마크 딕셔너리를 만들기 때문입니다. 한 줄에 하나의 텍스트 객체로 나뉜 문단을 세 번의 호출로 태깅하면 각각 /MCID 5를 넘겼다 해도 마크 세 개가 따로 붙습니다. PDFium이 콘텐츠 스트림을 재생성할 때 연속된 객체를 하나의 BDC/EMC 쌍 안에 유지하는 조건은 같은 마크를 공유하는 경우뿐이고, 마크의 동일성을 비교하므로 값이 같은 것은 인정되지 않습니다. 그 결과 저장된 페이지에는 /P <</MCID 5>> BDC … EMC가 세 번 연달아 기록됩니다
ISO 32000-1 §14.7.4.2는 MCID를 하나의 콘텐츠 스트림 안에서 마크드 콘텐츠 시퀀스를 고유하게 식별하는 것으로 기술합니다. 하나의 식별자를 세 시퀀스가 공유하면 이 요건을 충족하지 못하고, 페이지의 parent tree 항목을 통해 MCID를 해석하는 도구들은 구조 요소가 세 개 중 어느 쪽을 의미하는지 추측해야만 합니다
SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex)는 복사본을 새로 만드는 대신 원본 마크의 속성 딕셔너리 그 자체를 대상 객체에 붙입니다. 첫 줄에 마크를 한 번 추가하고 나머지 줄에는 공유하되, 객체들이 그리기 순서에서 연속되게 유지하면 재생성된 스트림이 세 줄 모두 하나의 시퀀스로 감쌉니다
procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: 문단 각 줄의 객체 인덱스, 그리기 순서에서 연속적
var
Params: TPdfContentMarkParams;
MarkIndex, I: Integer;
begin
SetLength(Params, 1);
Params[0].Key := 'MCID';
Params[0].Kind := pmpInt;
Params[0].IntValue := Mcid;
Pdf.BeginPageObjectEditTransaction;
try
MarkIndex := Pdf.AddPageObjectMark(LineObjects[0], 'P', Params);
for I := 1 to High(LineObjects) do
Pdf.SharePageObjectMark(LineObjects[I], LineObjects[0], MarkIndex);
except
Pdf.RollbackPageObjectEditTransaction;
raise;
end;
Pdf.CommitPageObjectEditTransaction;
end;
공유에는 이미 존재하는 마크를 새 콘텐츠에 복사할 때의 장점도 있습니다. 예컨대 다시 그린 바닥글 규칙 선 위로 아티팩트를 확장하는 경우입니다. PDFium이 기록할 수 있는 매개변수 종류는 정수, 실수, 문자열, 바이트 blob뿐이고 name 종류는 없습니다. /Artifact <</Type /Pagination>>를 pmpString으로 읽어 AddPageObjectMark로 다시 만들면 §14.8.2.2가 name /Pagination을 기대하는 자리에 문자열인 (Pagination)이 나옵니다. 공유된 마크는 원본 딕셔너리이므로 name이 name으로 유지됩니다. 원본 마크가 페이지에 이미 있다면 항상 공유하고, 한 번도 존재하지 않았던 마크를 기록할 때만 Params부터 다시 만드세요
MCID를 구조 트리와 대조하기
페이지 객체의 MCID는 같은 페이지의 구조 요소가 그것을 주장할 때만 의미가 있습니다. StructureElementsForPage[PageNumber]는 페이지의 요소들을 TPdfStructureElement 레코드로 돌려주고, 세 필드가 MCID를 운반합니다. MCID가 없는 요소는 -1이 되는 MarkedContentID, 여러 시퀀스를 소유한 요소를 위한 MarkedContentIDs, 그리고 자식 중 요소(마크드 콘텐츠가 아닌) 자리에 -1이 들어가는 ChildMarkedContentIDs입니다. 감사는 이 세 가지를 모두 봐야 합니다. 구조 요소의 /K 항목 하나가 자식 요소와 마크드 콘텐츠 참조를 한 배열에 섞어 담을 수 있기 때문입니다
아래 감사는 두 가지 소견을 보고합니다. 어느 요소도 주장하지 않는 MCID를 가진 텍스트나 이미지, 혹은 MCID가 아예 없는 콘텐츠는 보조 기술이 건너뛸 콘텐츠입니다. MCID가 주장된 Artifact는 §14.8.2.2에 정면으로 반합니다. 페이지 객체는 ReadPageObjectsSnapshot에서 오므로 결과에 핸들이 없어 페이지 전환 후에도 살아남습니다
// 라이브러리가 제공하는 것이 아닌 직접 만든 헬퍼
function McidClaimed(const Elements: TPdfStructureElements; Mcid: Integer): Boolean;
var
E: TPdfStructureElement;
Id: Integer;
begin
for E in Elements do
begin
if E.MarkedContentID = Mcid then
Exit(True);
for Id in E.MarkedContentIDs do
if Id = Mcid then
Exit(True);
for Id in E.ChildMarkedContentIDs do
if Id = Mcid then
Exit(True);
end;
Result := False;
end;
procedure AuditPage(Pdf: TPdf; PageNo: Integer; Log: TStrings);
var
Snap: TPdfPageObjectsSnapshot;
Status: TPdfPageObjectSnapshotStatus;
Elements: TPdfStructureElements;
Obj: TPdfPageObjectSnapshot;
IsArtifact: Boolean;
J: Integer;
begin
Snap := Pdf.ReadPageObjectsSnapshot(PageNo,
TPdfPageObjectSnapshotOptions.Full, Status);
if not Status.Completed then
Log.Add(Format('page %d: snapshot hit a limit, results are partial', [PageNo]));
Elements := Pdf.StructureElementsForPage[PageNo];
for Obj in Snap.Objects do
begin
IsArtifact := False;
for J := 0 to High(Obj.Marks) do
if Obj.Marks[J].Name = 'Artifact' then
IsArtifact := True;
if IsArtifact then
begin
if (Obj.MarkedContentID >= 0) and McidClaimed(Elements, Obj.MarkedContentID) then
Log.Add(Format('object %d: artifact claimed by the structure tree', [Obj.Index]));
end
else if Obj.ObjectType in [otText, otImage] then
if (Obj.MarkedContentID < 0) or not McidClaimed(Elements, Obj.MarkedContentID) then
Log.Add(Format('object %d: content with no structure owner', [Obj.Index]));
end;
end;
경로 객체는 일부러 검사 대상에서 뺐습니다. 장식용 선과 표 테두리는 흔히 태깅되지 않은 채로 있고, 하나하나 지적하면 텍스트 소견이 묻힙니다. Form XObject에는 반대로 주의가 필요합니다. 폼 안의 MCID는 그 폼 자체의 /StructParents 네임스페이스에 속하므로, 페이지의 MCID 0과 폼 안의 MCID 0은 다른 시퀀스입니다. 이를 올바르게 읽는 방법은 태깅된 Form XObject의 MCID 네임스페이스 워크스루가 다룹니다
마크는 여전히 태깅된 문서의 절반일 뿐입니다. ISO 32000-1 §14.7은 이 MCID들을 참조하는 요소를 가진 구조 트리, 카탈로그에 /Marked true가 붙은 /MarkInfo 딕셔너리, 그리고 표준 구조 타입이거나 롤 맵을 통해 표준으로 사상된 구조 타입을 요구합니다. 카탈로그가 문서를 태깅됨으로 선언하는지는 Pdf.IsTagged가 알려 줍니다. 어느 요소도 가리키지 않는 MCID를 가진 /P 마크는, 구조 트리가 한 번도 언급하지 않는 페이지 위에서 태깅되었다고 주장하는 콘텐츠를 낳습니다
그 작업 중 구조 트리 쪽은 PDF/UA 구조 트리 검증 워크스루가 MCID 감사가 멈춘 지점에서 이어받습니다. 태그가 궁극적으로 존재하는 이유인 읽기 경험에 대해서는 Delphi로 접근성 있는 PDF 리더 만들기 노트를 참고하세요
여기서 사용한 호출들(마크 읽기, 편집 트랜잭션, 공유 마크, 페이지 객체 스냅샷, 구조 요소)은 PDFium Component가 Delphi, C++Builder, Lazarus 프로젝트에 PDFium 엔진 위로 제공하는 것과 같은 VCL API의 일부입니다. 접근성과 페이지 편집 API의 나머지는 PDFium Component 제품 페이지에서 확인할 수 있습니다