기술 기사

Delphi에서의 Factur-X XMP용 PDF/A-3 확장 스키마

Factur-X 송장을 만들었고 모든 컨테이너 점검을 통과했습니다. 카탈로그에는 /AF 배열이 있고, EmbeddedFiles 이름 트리(name tree)는 올바른 파일 명세로 해결되며, 내장된 factur-x.xml은 올바른 /AFRelationshipAlternative를 가집니다. 내장된 ValidateFacturXInvoice 함수 역시 1을 반환합니다. 그러나 세무 포털들이 사용하는 참조 검사기인 veraPDF에 동일한 파일을 돌려보면, 전체 문서가 유효한 PDF/A-3가 아니라고 판정합니다. 구조는 올바릅니다. 메타데이터가 문제이며, 이는 전체 전자 송장 워크플로에서 놓치기 가장 쉬운 실패 중 하나입니다

그 이유를 온전히 이해할 가치가 있습니다. 이것은 시각적 페이지나 첨부 파일과는 전혀 무관하고 오로지 XMP가 자신을 어떻게 설명하는지와 관련된 PDF/A 결함 부류를 설명해주기 때문입니다. 이것이 바로 녹색(통과) 컨테이너 점검 뒤에 숨어 있는 함정입니다

파일을 실패하게 만드는 4개의 속성

Factur-X 송장은 후속(downstream) 소프트웨어가 내장된 XML을 파싱(parsing)하지 않고도 송장 프로파일을 읽을 수 있도록 자신의 XMP 패킷에 4개의 사용자 정의(custom) 속성을 씁니다. 이들은 Factur-X 네임스페이스 내에서 fx 접두사 아래에 존재합니다. 바로 fx:DocumentFileName, fx:DocumentType, fx:Version, 그리고 fx:ConformanceLevel입니다. 이들은 리더(reader)가 이 PDF가 factur-x.xml이라는 이름의 버전 1.0 EN 16931 송장을 싣고 있다는 사실을 알기 위해 필요한 바로 그 메타데이터입니다

이 4개의 속성 중 그 어느 것도 PDF/A가 사전 정의한 XMP 스키마에 속하지 않습니다. Dublin Core, XMP Basic, PDF, 그리고 PDF/A 식별(identification) 스키마는 규격을 준수하는 리더(conforming reader)에게 알려져 있지만 fx:는 그렇지 않습니다. veraPDF가 XMP를 훑고 가다가 알지 못하는 네임스페이스를 가진 속성에 도달하면, 해당 속성이 무엇을 의미하는지 알려줄 선언(declaration)을 찾습니다. 만약 이 선언이 없으면, 사전 정의된 스키마에서 가져오지 않은 모든 속성은 PDF/A 확장 스키마 내에 기술(describe)되어야 한다는 ISO 19005-3 6.6.2.3.1절 위반으로 실패를 보고합니다. 선언되지 않은 속성 4개가 파일이 거부될 수 있는 4가지 이유가 되며, 이 중 단 하나도 컨테이너 점검에는 보이지 않습니다

PDF/A가 벌거벗은 사용자 정의 속성을 거부하는 이유

이 규칙은 PDF/A의 목적을 기억하기 전까지는 현학적으로 보일 수 있습니다. 이 포맷은 수십 년 뒤, 2026년의 관례에 대해 전혀 들어본 적 없는 소프트웨어에 의해서도 파일이 열리고 이해될 수 있게 하려고 존재합니다. 규격을 준수하는 리더는 참고할 만한 외부 레지스트리 없이, 오직 문서 자체만으로 그 문서를 파악해내야 합니다

사용자 정의 메타데이터는 파일이 자체적인 설명(description)을 싣고 다니지 않는 한 그 약속을 깹니다. 덩그러니 놓인 벌거벗은 fx:ConformanceLevel 속성만 주어지면, 미래의 리더는 fx 접두사가 묶여 있는 네임스페이스 URI가 무엇인지, 그 값이 텍스트인지, 날짜인지, 정수인지, 혹은 속성이 문서 자체를 기술하는 것인지 아니면 외부 리소스를 기술하는 것인지 알 수 없습니다. PDF/A 확장 스키마 메커니즘이 이 간극을 메웁니다. 이것은 고정된 XMP 구조 속에서 파일이 네임스페이스, 접두사, 그리고 각 속성에 대해 값 타입(value type)과 카테고리(internal 혹은 external)를 선언할 수 있게 해 줍니다. 이 선언이 존재하게 되면 해당 속성은 자기 자신을 설명하는 셈이 되고, 6.6.2.3.1절은 충족됩니다. 선언이 없으면 검증기는 속성을 해독할 수 없는 것으로 취급하고 파일을 실패(fail) 처리할 수밖에 없습니다. 여기서는 카테고리 구분이 중요합니다. 이러한 송장 속성은 PDF 프로세서 외부에서 온 데이터를 기술하므로 internal 대신 external로 선언됩니다

확장 스키마 선언의 포함 내용

이 선언은 pdfaExtension, pdfaSchema, 그리고 pdfaProperty라는 AIIM이 정의한 3개의 네임스페이스를 사용하는 XMP 패킷 내 rdf:Description입니다. pdfaExtension:schemas bag 내부에는 Factur-X 스키마의 이름을 짓고, pdfaSchema:namespaceURIpdfaSchema:prefix를 제공한 다음, pdfaSchema:property 시퀀스(sequence) 안에 4개의 속성을 나열하는 하나의 스키마 항목(schema entry)이 위치합니다. 각 속성은 이름, Text라는 pdfaProperty:valueType, 그리고 external이라는 pdfaProperty:category를 실어 나릅니다. 아래의 예시 마크업은 해당 블록의 형태를 보여줍니다

<rdf:Description rdf:about=""
    xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
    xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
    xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
  <pdfaExtension:schemas>
    <rdf:Bag>
      <rdf:li rdf:parseType="Resource">
        <pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
        <pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
        <pdfaSchema:prefix>fx</pdfaSchema:prefix>
        <pdfaSchema:property>
          <rdf:Seq>
            <rdf:li rdf:parseType="Resource">
              <pdfaProperty:name>DocumentFileName</pdfaProperty:name>
              <pdfaProperty:valueType>Text</pdfaProperty:valueType>
              <pdfaProperty:category>external</pdfaProperty:category>
              <pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
            </rdf:li>
            <!-- DocumentType, Version, ConformanceLevel도 같은 방식으로 선언됨 -->
          </rdf:Seq>
        </pdfaSchema:property>
      </rdf:li>
    </rdf:Bag>
  </pdfaExtension:schemas>
</rdf:Description>

네임스페이스 URI와 접두사는 고정된 문자열이 아닙니다. 이들은 프로파일을 따라갑니다. Factur-X 문서는 fx 접두사와 함께 urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#을 사용하는 반면, zugferd-invoice.xml을 통해 선택된 ZUGFeRD 2.0 파일은 자신만의 스키마 이름 아래에 있는 다른 URI로 해결(resolve)됩니다. 확장 스키마는 속성 블록이 실제로 사용하는 것과 동일한 네임스페이스 URI를 선언해야 하며, 그러지 않으면 검증기는 여전히 둘을 연결할 수 없습니다. PDFlibPas는 전달한 파일 이름과 버전으로부터 두 값을 도출해 내므로, 선언과 속성 블록은 언제나 일치하게 됩니다

도우미가 어떻게 양쪽을 한꺼번에 쓰는가

PDFlibPas에서는 저 XML을 직접 손으로 조립하지 않습니다. 문서를 PDF/A-3 모드에 두고 하나의 메서드만 호출하면 됩니다. 제일 먼저 확정 지을 것은 호환성(conformance) 플래그인데, 왜냐하면 Factur-X는 PDF/A-3를 요구하기 때문입니다. SetPDFAMode(7)을 호출하면 PDF/A-3u 수준이 선택되며, 이는 식별(identification) 스키마 안의 pdfaid:part를 3으로, pdfaid:conformance를 U로 설정합니다. 이제 XMP 패킷은 어떠한 송장 메타데이터가 추가되기도 전에 올바른 part와 conformance를 지니게 됩니다

var
  FileID: Integer;
begin
  PDF.SetPDFAMode(7);            // PDF/A-3u: pdfaid:part=3, conformance=U
  PDF.NewDocument;
  // 이곳에 사람이 읽을 수 있는 송장 페이지를 그립니다

  FileID := PDF.AddFacturXAssociatedFileFromString(
    InvoiceXML,                  // 원시 UTF-8 XML 바이트
    'EN16931',                   // ConformanceLevel
    'factur-x.xml',              // 내장된 파일 이름
    'Factur-X invoice XML',      // /Desc 텍스트
    'Alternative',               // /AFRelationship
    '1.0',                       // 프로파일 버전
    '');                         // 선택적 국가 코드
  if FileID = 0 then
    Exit;                        // PDF/A-3가 아니거나 XML/프로파일 불일치

  PDF.SaveToFile('factur-x.pdf');
end;

AddFacturXAssociatedFileFromString에 대한 단일 호출은 실패했던 파일이 누락시켰던 바로 그 작업을 해냅니다. 이것은 여러분이 지명한 관계성(relationship)을 가지는 PDF/A-3 연관 파일로 XML을 내장하고, 선택한 프로파일의 스키마 이름, 네임스페이스 URI, 접두사와 함께 4개의 fx 속성을 기록합니다. 문서가 저장될 때, ApplyFacturXMetadata라 명명된 내부 단계가 속성 블록과 일치하는 pdfaExtension:schemas 선언 모두를 XMP 패킷 안에 주입(inject)하므로, 사용자 정의 속성들은 이미 설명된 상태로 도달하게 됩니다. 만약 문서가 PDF/A-3 모드가 아니거나 XML이 선언된 프로파일과 일치하지 않으면 이 메서드는 0을 반환하는데, 이는 형식을 제대로 갖추지 못한 송장이 애초에 파일에 도달하는 것을 막는 방어 기제(guard)와 동일합니다

컨테이너 점검이 보지 못하는 사각지대

이것은 아주 명백하게 지목해야 할 부분인데, 왜냐하면 버그가 숨는 원인이기 때문입니다. ValidateFacturXInvoice는 컨테이너를 점검합니다. 카탈로그에 /AF 항목이 있는지 확인하고, EmbeddedFiles 이름 트리가 존재하며, 송장 XML이 있고, 내장된 파일 이름이 프로파일과 일치하며, XML 속의 가이드라인 ID가 호환성 수준에 동의하고, /AFRelationship이 PDF/A-3가 허용하는 것 중 하나임을 확인합니다. 이들은 진짜 점검 사항들이며 진짜 결함들을 잡아냅니다. GetFacturXValidationIssuesMissingCatalogAF, NotPDFA3, ConformanceGuidelineMismatch, InvalidAFRelationship, 그리고 InvalidFileNameProfile과 같은 식별자들과 함께 이름별로 이들을 보고합니다

점검하지 않는 것은 XMP 확장 스키마가 존재하고 또 정확한지 여부입니다. 컨테이너가 흠결이 없지만 fx 속성들은 선언되지 않은 파일은 이슈 점검을 모두 통과하여 1을 반환합니다. 이 리스트에 있는 그 어떤 것도 pdfaExtension:schemas 블록을 검사하지 않기 때문입니다. 이것이 바로 수작업으로 지어진(hand-built) 송장, 또는 선언 없이 속성 블록만 써 내려간 파이프라인에서 만들어진 송장이 내장 검증기(built-in validator)를 무사통과(sail through)하고도 여전히 6.6.2.3.1절에서 veraPDF를 실패하게 만드는 정확한 이유입니다. 컨테이너 검증기와 PDF/A 메타데이터 검증기는 서로 다른 질문에 대답하며, 두 번째 질문에 대답하는 것은 오로지 완전한 PDF/A 검사기(checker)뿐입니다

어느 계층이 고장 났는지 알기 위한 이슈 읽기

두 계층은 독립적으로 실패하기 때문에, 올바른 진단 습관은 컨테이너 이슈를 먼저 읽되 깨끗한 결과가 나오더라도 이를 오직 컨테이너에 대한 성명서로만 취급하고 결코 PDF/A 메타데이터에 대한 것으로 간주하지 않는 것입니다. 내장 검증을 실행하고 이슈 리스트를 수집하여 이를 바탕으로 조치를 취한 연후에 외부 도구에 손을 뻗어야 합니다

var
  Issues: WideString;
begin
  if PDF.ValidateFacturXInvoice = 0 then
  begin
    Issues := PDF.GetFacturXValidationIssues('|');
    // 컨테이너 계층 식별자, 예를 들면:
    //   MissingCatalogAF, NotPDFA3, MissingEmbeddedFilesNameTree,
    //   ConformanceGuidelineMismatch, InvalidAFRelationship
    WriteLn('컨테이너 이슈: ', Issues);
  end
  else
    WriteLn('컨테이너 정상; PDF/A 검사기로 XMP 확장 스키마를 확인하세요.');
end;

이 호출이 이슈 이름을 반환한다면 잘못은 컨테이너에 있는 것이며 메시지가 어느 부분인지를 알려줍니다. 호출이 깨끗함을 반환했는데도 veraPDF가 계속 파일을 거부한다면 잘못은 거의 언제나 XMP 확장 스키마에 있으며, 이 경우 해결책은 속성 블록을 직접 구성하는 대신 AddFacturXAssociatedFileFromString이 메타데이터를 쓸 수 있도록 놔두는 것입니다. 마음속에서 이 두 질문을 분리해 두는 것이야말로 당황스러운 거부(baffling rejection)를 한 줄의 진단으로 바꿔줍니다. 컨테이너의 문제는 이슈 리스트를 통해서 표면 위로 떠오르고, 스키마-선언 문제는 오직 PDF/A 검증기를 통해서만 수면 위로 올라옵니다. 그리고 버그를 숨게 만드는 것은 바로 이 둘을 혼동하는 것입니다

파일이 여러분의 빌드를 떠나기 전 어떻게 프리플라이트(preflight) 단계를 실행하는지 등을 포함한 보다 넓은 의미의 PDF/A 및 PDF/UA 호환성 그림에 대해서는 Delphi에서의 PDF/A 및 PDF/UA 프리플라이트 안내서에서 다룹니다. 여러분의 송장이 접근성(accessibility)까지 갖춰야 한다면, PDF/A-3a와 태그 달린 PDF(tagged PDF)가 의존하는 구조 트리가 태그 달린 PDF 접근성 기사의 주제가 될 것입니다. 여기에 기술된 확장 스키마 처리는 이 블로그 전체에 걸쳐 문서화된 Factur-X, ZUGFeRD 및 XRechnung 프로파일 지원과 함께 PDFlibPas Delphi PDF Library의 일부로 제공됩니다