기술 문서

PDF/A 구현 한계와 폰트 인코딩 검사

송장 아카이브가 깔끔하게 렌더링되고, 인쇄되고, 모든 폰트를 임베드하고 유효한 출력 인텐트를 지녀도, PDF/A 검증기는 리소스 딕셔너리 하나에 4,096개 항목이 들어 있다는 이유로 거부할 수 있습니다. 상한은 4,095입니다. PDF/A는 PDF Reference에서 작은 구현 한계 표를 물려받았고, 그중 하나라도 한 유닛을 넘는 파일은 어떤 뷰어도 불평하지 않아도 규격 준수에 실패합니다

같은 "그런데 화면에는 멀쩡해 보이는데요" 바구니에 떨어지는 또 다른 실패는 /Encoding 항목을 지닌 심볼릭 TrueType 폰트입니다. 체크박스, 바코드, 딩벳이 화면에서는 맞게 나오지만 검증기는 여전히 폰트 딕셔너리를 지적합니다. 두 문제 모두 사람이 페이지에서 볼 수 있는 것 밖의 객체 그래프에 삽니다. 그래서 파일이 아카이브에 들어가기 전에 기계적으로 검사할 가치가 있습니다

PDF/A가 물려받는 숫자들, 경계가 놓인 자리

PDFium Component는 모든 PDF/A 검증에서 네 개의 컨테이너 한계를 검사합니다. 이름 토큰은 최대 127바이트, 배열은 최대 8,191개 요소, 딕셔너리는 최대 4,095개 항목, 배열과 딕셔너리는 최대 28단계 중첩입니다. 네 경계 모두 포함 경계입니다. 127바이트 이름은 통과하고 128바이트 이름은 실패합니다. 정확히 8,191개 요소의 배열은 통과하고 하나 더 있으면 실패합니다

이름, 배열, 딕셔너리 한계는 규격 준수 리더가 그 크기까지만의 구조를 다룰 것만 요구되던 시절에서 옵니다. 요즘 데스크톱 뷰어는 훨씬 큰 배열을 불평 없이 열 수 있지만, 아카이브 파일은 기록 시스템이 아직 돌리고 있을 수 있는 오래된 것들을 포함해, 아직 아무도 검증해 본 적 없는 리더에서 열릴 것으로 기대됩니다. 그래서 PDF/A는 완화하는 대신 역사적 상한을 유지합니다

포함 대신 배타가 손으로 구운 검사가 보통 틀리는 지점입니다. Count > 8191이 아니라 Count >= 8191로 쓴 테스트는 규격을 준수하는 파일을 거부하고, 준수 파일을 거부하는 검증기는 어차피 믿음을 얻는 경향이 있어, 한 번도 없었던 문제 때문에 생성기가 "고쳐집니다". FPdfPdfa 유닛의 ValidatePdfACompliance 함수는 한계를 엄격히 넘어 셉니다. 그리고 파일 바이트를 직접 읽으므로 검사는 PDFium DLL을 로드하지 않고 돌아갑니다

Delphi에서 바이트 스캔으로 PDFium Component가 검사하는 네 포함 컨테이너 한계 다이어그램: 127바이트 이름 토큰, 8191 배열 요소, 4095 딕셔너리 항목, 28단계 배열·딕셔너리 중첩
네 한계 모두 포함 경계입니다 — 경계 값 자체는 통과하고 다음 유닛만 실패합니다
uses
  System.Classes, FPdfPdfa;

procedure CheckContainerLimits(const FileName: string; Log: TStrings);
var
  Src: TFileStream;
  Res: TPdfAValidationResult;
begin
  Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Res := ValidatePdfACompliance(Src);   // 바이트 스캔, PDFium DLL 불필요
    if pvaiNameOverLimit in Res.Issues then
      Log.Add('A name token is longer than 127 bytes');
    if pvaiArrayOverLimit in Res.Issues then
      Log.Add('An array holds more than 8191 elements');
    if pvaiDictOverLimit in Res.Issues then
      Log.Add('A dictionary holds more than 4095 entries');
    if pvaiNestingOverLimit in Res.Issues then
      Log.Add('Arrays and dictionaries nest deeper than 28 levels');
    if Res.IsCompliant then
      Log.Add('No PDF/A findings');
  finally
    Src.Free;
  end;
end;

네 플래그는 다른 모든 PDF/A 소겡 옆에서 TPdfAValidationResult의 Issues 집합 멤버이며, IsCompliant는 PDF/A 식별이 발견되고 집합이 비어 있을 때만 True를 돌려줍니다. PDF/A 식별이 전혀 없는 파일은 엄격한 PDF/A-1 규칙 세트로 검사되므로, 컨테이너 한계는 그 파일에도 여전히 적용됩니다

어떤 생성기가 이 한계에 부딪히나?

거의 언제나 루프 안에서 구조를 만드는 생성기입니다. 수천 개 필드를 가진 폼은 8,191개 항목을 넘어서는 AcroForm /Fields 배열이나 페이지 /Annots 배열을 만듭니다. 생성된 객체마다 폰트나 이미지 리소스를 하나씩 등록하고 기존 항목을 재사용하지 않는 리포트 엔진은 /Resources 하위 딕셔너리를 4,095개 키를 넘게 키웁니다. 중첩된 데이터 모델을 재귀로 순회해 구조 트리를 만드는 태그 문서는 아무도 보지 않는 사이 28단계를 넘을 수 있습니다

긴 이름의 기원은 다릅니다. 이름 토큰에 데이터를 포장하는 것입니다. 고객 참조 번호를 딴 분리 색소, 파일 경로를 딴 선택 콘텐츠 그룹, 여섯 단계 계층으로 조립된 필드 레이블. 이름은 만드는 데 비용이 들지 않고, 액센트 문자가 들어간 UTF-8 레이블이 관여하는 순간 127바이트는 빠르게 채워집니다. 그런 글자 하나가 두세 바이트를 먹기 때문입니다. 127바이트는 / 도입부 다음부터 다음 구분자나 공백까지 셉니다

해법은 언제나 구조적입니다. 배열을 나누고, 딕셔너리를 나누고, 재귀를 평탄화하고, 이름을 줄입니다. 파일에 PDF/A 마커를 주입하는 것은 이 소견 중 어느 것에도 도움이 되지 않습니다. 문제는 객체 그래프의 모양이고 XMP 메타데이터는 그것에 대해 아무 말도 하지 않기 때문입니다

스캔이 요소와 항목을 세는 방법

헥스 에디터에서 소견을 재현하려 할 때 셈 규칙이 문제가 됩니다. 중첩된 배열이나 딕셔너리는 부모의 요소 하나로 셉니다. 한계가 서술된 방식입니다. 1,000개 요소짜리 배열 열 개를 담은 10개 요소 배열은 괜찮습니다. 하나의 배열이 8,191을 넘지 않기 때문입니다. 딕셔너리 안에서는 키만 셉니다. 우연히 이름인 값은 항목 총수를 부풀리지 않습니다. <FEFF0041> 같은 헥스 문자열은 단일 토큰으로 건너뛰므로, 그 꺾쇠 괄호가 딕셔너리 중첩으로 오해되는 일은 없습니다

스캔은 압축 객체 스트림 안에 저장된 객체를 포함한 파일 전체의 객체 구조를 커버하며, 그것들은 셈 전에 펼쳐집니다. 페이지 콘텐츠나 이미지 데이터 같은 스트림 본문 자체는 먼저 지워지므로, 우연히 /나 [를 담은 바이트 나열이 거짓 이름이나 배열 소견을 일으킬 수 없습니다

같은 검증이 자체 플래그 아래 보고하는 인접한 숫자 한계가 있습니다. 파일이 PDF/A-2 이상을 선언하면, 32비트 부호 있는 범위 밖, 즉 -2,147,483,648에서 2,147,483,647 밖의 정수 토큰에 대해 pvaiIntegerImplementationLimit가 발생합니다. 바이트 오프셋이나 큰 카운터를 평범한 정수로 기록하는 생성기가 보통의 원인입니다

심볼릭 TrueType 폰트는 왜 /Encoding을 내려놔야 하나?

ISO 19005-1 §6.3.7은 심볼릭 TrueType 폰트의 문자 매핑으로 폰트 프로그램 자체의 내장 cmap만 인정합니다. 심볼릭 폰트는 제 사정대로 코드를 글리프에 매핑하며, Symbolic 플래그가 선언하는 바로 그것입니다. /Encoding 항목을 추가하면 파일은 이제 "바이트 0x41은 어느 글리프를 고르나"라는 질문에 두 개의 답을 주면서, 어느 쪽이 이기는지 파일 안 어디에도 말해 두지 않습니다. 리더들은 충돌을 제각각 해결하고, 같은 체크박스가 한 뷰어에서는 체크로 다른 뷰어에서는 글자 A로 나올 수 있습니다

ValidatePdfACompliance는 /Subtype /TrueType인 모든 폰트 딕셔너리를 찾고, /Encoding 키가 있는지 검사한 다음, 폰트 디스크립터의 /Flags 항목 비트 3(값 4)을 읽습니다. 디스크립터는 인라인으로 기록되었든 간접 객체로 참조되었든 따라갑니다. /WinAnsiEncoding이나 /MacRomanEncoding을 유지하는 비심볼릭 TrueType 폰트는 이 검사의 지적 대상이 아닙니다. 비심볼릭 폰트에 대해 PDF/A가 기대하는 것이 정확히 그 인코딩이기 때문입니다

Delphi PDFium Component 검사에서 /Encoding 항목으로 두 개의 경쟁 글리프 매핑을 만드는 심볼릭 TrueType 폰트와, WinAnsiEncoding을 정당하게 유지하는 비심볼릭 폰트의 대비 다이어그램
검사는 폰트 디스크립터에서 심볼릭 플래그를 읽고 인코딩의 존재가 아니라 모순에 반응합니다
// Res는 첫 예제처럼 ValidatePdfACompliance에서 온다
if pvaiSymbolicTrueTypeEncoding in Res.Issues then
  Log.Add('A symbolic TrueType font carries /Encoding; PDF/A admits only ' +
    'the font''s built-in cmap (ISO 19005-1 6.3.7)');

// 다른 트리거를 지닌 이웃 폰트 규칙: NON-symbolic인데
// Encoding 딕셔너리가 /Differences를 지닌 TrueType 폰트
if pvaiTrueTypeDifferencesForbidden in Res.Issues then
  Log.Add('A non-symbolic TrueType Encoding dictionary carries /Differences');

이 규칙을 걸리게 하는 폰트들은 예측 가능합니다. Symbol, Wingdings, 바코드 폰트, 아이콘 폰트. 비즈니스 문서에서 체크박스, 평점 별, 바코드로 등장하고, 모든 TrueType 폰트를 같은 방식으로 다루는 서브셋팅 단계가 그들에게도 인코딩을 기록합니다. 검증기가 "폰트 문제"라고 말할 때 아무도 보지 않는 폰트이기도 합니다. 본문 텍스트 폰트부터 검사되기 때문입니다

반대의 실수도 야외에서 등장합니다. 실제로는 WinAnsi로 인코딩된 평범한 텍스트 폰트에 생산자가 Symbolic 플래그를 설정하는 경우입니다. PDF/A 검사는 조항이 쓰인 대로 따라가 그 폰트를 보고하며, 이때 수리는 디스크립터에 속합니다. 인코딩을 지우는 대신 디스크립터에서 플래그를 고치십시오. 잘못 표시된 텍스트 폰트에서 /Encoding을 제거하면 텍스트가 서브셋이 우연히 담고 있는 cmap에 의존하게 되는데, 이는 시작했던 파일보다 더 나쁜 파일입니다. 모순의 어느 쪽을 제거할지 고르기 전에 폰트의 글리프 집합을 보십시오

프리플라이트 리포트에서 소견 읽기

프리플라이트 리포트 유닛은 Enterprise 에디션에 실려 있고, ValidatePdfACompliance는 모든 에디션에 있습니다. FPdfPreflightReport 유닛의 리포트는 네 컨테이너 한계를 Structure 카테고리로, 심볼릭 TrueType 인코딩 이슈를 Content 카테고리로 분류합니다. 이 분리는 의도적입니다. 구조 소견은 보통 생성기를 유지하는 사람에게 돌아가고, 콘텐츠 소견은 보통 폰트와 템플릿을 공급한 사람에게 돌아가며, 둘을 섞은 리포트는 두 사람을 각자 자기 것이 아닌 문제 쫓게 만듭니다

각 이슈는 enum 이름을 Code로 지니고 해법을 구체적으로 지목하는 권고를 담습니다. 예를 들어 "4,095개 항목을 넘지 않도록 딕셔너리를 나누십시오" 같은 것입니다. Structure 카테고리에는 다른 소견 둘도 있음에 유의하십시오. Level A 주장에서 누락된 태그 구조와, PDF/A-1 파일의 PDF 1.5 교차 참조 또는 객체 스트림입니다. 네 한계만 원한다면 코드로 필터하십시오

uses
  System.SysUtils, System.Classes, PDFium, FPdfPreflightReport;

procedure ReportStructureFindings(const FileName: string; Log: TStrings);
var
  Pdf: TPdf;
  Report: TPdfPreflightReport;
  Issues: TPdfPreflightIssues;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;          // 이 경로는 PDFium DLL을 로드한다
    if not Pdf.Active then
      Exit;
    Report := BuildPdfPreflightReport(Pdf, [ppsPdfA]);
    Issues := Report.FilterByCategory('Structure').FlattenIssues;
    for I := 0 to High(Issues) do
      Log.Add(Issues[I].Code + ': ' + Issues[I].Recommendation);
    Report.SaveJsonToFile(ChangeFileExt(FileName, '.preflight.json'));
  finally
    Pdf.Free;
  end;
end;

두 경로는 조금 다른 질문에 답합니다. BuildPdfPreflightReport는 TPdf.ValidatePdfA를 거치는데, 이것은 열린 문서를 임시 사본으로 저장하고 그 사본에 같은 바이트 스캔을 돌린 다음, 페이지에서 쓰이지만 임베드되지 않은 폰트 같은 PDFium 엔진이 필요한 검사를 얹습니다. ValidatePdfACompliance는 디스크 위의 정확한 바이트를 판정합니다. 구현 한계에는 원본 파일의 바이트 경로를 믿어야 합니다. 아카이브에 담으려는 바로 그 파일에 대해 보고하고, 네이티브 DLL을 배포할 수 없는 빌드 단계나 잠긴 서비스에서 돌기 때문입니다. 엔진 수준 검사와 파이프라인을 위한 JSON, SARIF, JUnit 출력까지 원할 때 리포트 경로를 쓰십시오

여기서 어디로 가나

폴더 전체에 이 검사들을 돌리고, 어느 소견이 릴리스를 막는지 정하고, 결과를 사람들이 읽을 리포트로 만드는 일은 Delphi의 PDF/A 프리플라이트 검증 산책문과 배치 프리플라이트 리포트 CLI에서 다룹니다. 애초에 어느 PDF/A 파트와 준수 수준을 겨냥할지는 PDF/A 아카이브 준수 노트의 주제이며, 그 선택은 정수 한계가 적용되는지를 포함해 여러 것을 결정합니다

여기서 보여 준 검증기들은 PDFium 엔진 위의 렌더링, 폼 채우기, 편집과 함께 Delphi, C++Builder, Lazarus용 PDFium Component에 실려 나옵니다. PDFium Component 제품 페이지에 지원되는 준수 표준, 에디션, 컴파일러 버전이 정리되어 있습니다