Artikel Teknis

Mesin Aturan Schematron EN 16931 di Delphi dengan HotPDF

HotPDF memvalidasi aturan bisnis faktur elektronik EN 16931 lewat HPDFEInvoiceValidator, sebuah mesin assertion Schematron yang diimplementasikan sendiri oleh library ini di atas dukungan XPath 1.0 milik MSXML, alih-alih menggunakan processor XSLT 2.0 berlisensi. HPDFEInvoiceValidator mem-parse file aturan .sch resmi Factur-X, mengevaluasi setiap assertion yang bisa diekspresikannya dalam XPath 1.0, dan menandai sisanya sebagai dilewati alih-alih membiarkan sebuah ekspresi yang tidak didukung memunculkan exception di tengah proses

Cakupan di sini tetap berada di dalam mesin tersebut: bagaimana loader mengubah XML Schematron menjadi entri aturan, bagaimana evaluasi assert dan report benar-benar memutuskan lulus atau gagal, bagaimana celah XPath 2.0 dideteksi dan dilewati, dan bagaimana unit yang sama tetap bisa dikompilasi di Delphi 7. Penyematan PDF/A-3, mekanisme container factur-x.xml / xrechnung.xml, dan kisah versi ZUGFeRD 2.5 dibahas di artikel pendamping tentang faktur elektronik ZUGFeRD dan Factur-X di Delphi dengan HotPDF, yang sengaja tidak diulang di sini

Mengapa HotPDF membangun mesin Schematron EN 16931 sendiri

HotPDF membangun mesin Schematron sendiri karena binding yang dideklarasikan file aturan EN 16931 melebih-lebihkan apa yang sebenarnya dibutuhkan assertion-nya: file tersebut mengatur queryBinding="xslt2" di bagian atas, secara teknis meminta processor XSLT 2.0 / XPath 2.0 penuh, tetapi membaca assertion itu sendiri menunjukkan sebagian besar hanya memanggil fungsi XPath 1.0 seperti string-length dan substring-after. MSXML DOM bawaan Windows — satu-satunya mesin XML yang dijamin ada pada setiap instalasi Delphi yang didukung tanpa menambah ketergantungan pihak ketiga — kebetulan mengimplementasikan tepat subset itu, XPath 1.0, yang membuat sebuah mesin native menjadi praktis alih-alih melisensikan runtime XSLT 2.0 terpisah. HPDFSchematronFileForProfile memetakan level kesesuaian Factur-X yang terdeteksi ke salah satu dari lima file aturan yang disertakan yang bisa dimuat mesin ini — MINIMUM, BASIC WL, BASIC, EN 16931, dan EXTENDED — dan setiap satunya hanya menilai XML faktur yang diekstrak, tidak pernah PDF sekelilingnya; apakah PDF itu sendiri secara struktural adalah file PDF/A-3 yang valid adalah pertanyaan terpisah yang dijawab lewat pemeriksaan kesesuaian PDF/A, PDF/X, dan PDF/UA milik HotPDF di tempat lain dalam library

Bagaimana mesin ini mengubah file .sch menjadi entri aturan?

THPDFMSXMLSchematronEngine.Load dibuka dengan memanggil CoInitializeEx(nil, COINIT_MULTITHREADED) sebelum membuat apa pun, karena sebuah host konsol atau service yang tidak pernah memanggil Application.Initialize belum memiliki apartment COM, sementara host GUI VCL sudah memilikinya; mesin ini memperlakukan hasil S_FALSE atau RPC_E_CHANGED_MODE yang bisa dikembalikan pemanggilan itu pada thread yang sudah berada di dalam sebuah apartment sebagai sama-sama baik-baik saja, bukan sebagai error. Kemudian ia mem-parse file Schematron dengan sebuah dokumen DOM MSXML 6.0 (CoDOMDocument60) dan setProperty('SelectionLanguage', 'XPath'), karena MSXML secara default menggunakan dialek XSL-Pattern lamanya kecuali pemanggil secara eksplisit memilih XPath. Dari sana, bagaimanapun, loader tidak pernah memanggil selectNodes untuk menelusuri struktur file .sch itu sendiri — setiap elemen <pattern>, <rule>, <assert>, dan <report> ditemukan dengan menelusuri firstChild / nextSibling secara manual, membandingkan nama lokal dan namespace URI setiap node terhadap string literal http://purl.oclc.org/dsdl/schematron

Pendekatan penelusuran manual itu ada karena masalah ayam-dan-telur pada binding <ns prefix="ram" uri="..."/> yang dideklarasikan setiap file Schematron Factur-X di bagian awal. Menyelesaikan sebuah ekspresi XPath berprefix seperti ram:Name terhadap binding tersebut membutuhkan properti SelectionNamespaces milik MSXML yang sudah memegangnya, tetapi menemukan binding tersebut sejak awal biasanya berarti menjalankan sebuah query XPath seperti //ns:ns — yang itu sendiri membutuhkan SelectionNamespaces diatur lebih dulu. HPDFEInvoiceValidator memutus siklus itu dengan mengumpulkan setiap elemen <ns> lewat penelusuran child-node manual yang sama sebelum menyentuh selectNodes sama sekali, lalu melipat pasangan prefix/URI yang terkumpul menjadi satu string SelectionNamespaces yang digunakan kembali baik oleh penelusuran struktur .sch maupun setiap evaluasi aturan belakangan

// Schematron <ns> bindings must be known before any prefixed XPath can
// run, so this walk cannot itself use selectNodes -- it is done by hand.
ChildNode := Root.firstChild;
while ChildNode <> nil do
begin
  if (ChildNode.baseName = 'ns') and
     (ChildNode.namespaceURI = 'http://purl.oclc.org/dsdl/schematron') then
    AddNamespace(AttrValue(ChildNode, 'prefix'), AttrValue(ChildNode, 'uri'));
  ChildNode := ChildNode.nextSibling;
end;
Doc.setProperty('SelectionNamespaces', BuildSelectorNamespaces);

Assert versus report: apa sebenarnya yang memicu pelanggaran?

Schematron memberikan assert dan report polaritas yang berlawanan, dan mesin ini harus mempertahankan perbedaan itu secara tepat atau jumlah pelanggarannya tidak berarti apa-apa. Sebuah <assert test="X"> mendeklarasikan bahwa X harus berlaku untuk setiap node yang cocok dengan context path aturan tersebut, sehingga EvaluateAssert mencatat sebuah pelanggaran ketika node-set hasil dari ekspresi test kembali kosong; sebuah <report test="X"> adalah kebalikannya, menandai sebuah masalah ketika X bernilai true, sehingga EvaluateReport mencatat sebuah pelanggaran ketika hasil test tidak kosong. Kedua titik masuk itu berbagi bentuk dua-tahap yang sama di baliknya — Doc.selectNodes(Entry.Context) lebih dulu, untuk menemukan setiap node tempat aturan itu berlaku, lalu ContextNode.selectNodes(Entry.Test) terhadap masing-masing satu per satu — yang persis merupakan model context-lalu-test yang digunakan sebuah processor Schematron sungguhan, hanya saja dijalankan oleh selectNodes XPath 1.0 milik MSXML alih-alih mesin eksekusi yang sadar-Schematron

Bagaimana mesin ini melewati XPath 2.0 tanpa membuat proses crash?

HPDFEInvoiceValidator bertahan dari sintaks XPath 2.0 yang tidak didukung dalam dua lapisan, dan lapisan pertama tidak pernah membiarkan MSXML melihat ekspresi tersebut sama sekali. Sebelum mengevaluasi assert atau report apa pun, XPath2Detected memindai string ekspresi test mentah untuk enam token literal — xs:decimal, xs:integer, xs:string, upper-case, lower-case, dan exists( — dan jika salah satu dari token itu ada, aturan tersebut langsung ditandai Skipped dengan severity stsInfo, dengan alasan bahwa MSXML seharusnya tidak pernah diserahi ekspresi yang sudah diketahui akan ditolaknya

const
  // MSXML implements XPath 1.0 only; presence of any of these tokens marks
  // the assertion as skipped instead of letting MSXML reject the expression.
  XPATH2_TOKENS: array[0..5] of string = ('xs:decimal', 'xs:integer',
    'xs:string', 'upper-case', 'lower-case', 'exists(');

function XPath2Detected(const TestExpr: string): Boolean;
var
  Token: string;
begin
  Result := False;
  for Token in XPATH2_TOKENS do
    if Pos(Token, TestExpr) > 0 then
      Exit(True);
end;

Lapisan kedua menangkap apa pun yang terlewat oleh daftar token statis. Baik pemanggilan selectNodes context maupun pemanggilan selectNodes test per-node berjalan di dalam blok try/except; ketika MSXML memunculkan exception pada sebuah ekspresi yang lolos dari pemindaian token — sebuah konstruksi di luar enam token yang diketahui, atau sebuah context path yang tidak bisa diselesaikannya — exception tersebut ditangkap dan aturan itu dicatat sebagai Skipped alih-alih diteruskan ke pemanggil. Desain dua-lapis itulah yang membuat sebuah konstruksi XPath 2.0 di mana pun dalam kumpulan aturan tidak pernah memunculkan exception melewati HPDFValidateEInvoice: setiap satu dari 424 assertion-nya baik dievaluasi, gagal, atau ditandai dilewati, dan sebuah estimasi internal terhadap file aturan tersebut menempatkan porsi yang bisa dieksekusi XPath-1.0 di sekitar 350 dari 424 — cukup banyak sehingga evaluasi parsial layak dilakukan alih-alih jatuh kembali ke pemeriksaan container-saja begitu satu assertion XPath 2.0 muncul

Memberi XML faktur UTF-8 ke MSXML tanpa merusaknya

THPDFMSXMLSchematronEngine.Validate tidak menyerahkan byte faktur yang diekstrak ke IXMLDOMDocument.loadXML, karena metode itu mengharapkan sebuah BSTR — UTF-16 — dan akan menafsirkan ulang sebuah array byte UTF-8 mentah di bawah asumsi itu terlepas dari apa yang dinyatakan deklarasi <?xml encoding="UTF-8"?> milik dokumen itu sendiri. HPDFEInvoiceValidator sebagai gantinya menyalin byte tersebut ke dalam sebuah HGLOBAL hasil GlobalAlloc, membungkusnya dalam sebuah IStream lewat CreateStreamOnHGlobal, dan memuat stream tersebut lewat IPersistStreamInit.Load, sebuah jalur yang dihormati MSXML dengan membaca deklarasi encoding dari byte stream itu sendiri alih-alih mengasumsikan UTF-16 sejak awal. Metode yang sama membangun ulang SelectionNamespaces dari binding prefix yang sudah dikumpulkan loader saat mem-parse file .sch, sehingga sebuah aturan yang ditulis terhadap sebuah prefix seperti ram: terselesaikan dengan benar terhadap namespace milik XML faktur itu sendiri pada setiap evaluasi, bukan hanya saat file aturan pertama kali di-parse

HMem := GlobalAlloc(GMEM_MOVEABLE, Length(XMLBytes));
P := GlobalLock(HMem);
Move(XMLBytes[0], P^, Length(XMLBytes));
GlobalUnlock(HMem);
CreateStreamOnHGlobal(HMem, True, Stream);  // stream owns HMem from here
(Doc as IPersistStreamInit).Load(Stream);   // honours the XML encoding declaration

Menjaga satu unit tetap bisa dikompilasi dari Delphi 7 hingga sekarang

HPDFEInvoiceValidator.pas harus bisa dikompilasi pada setiap versi Delphi yang didukung HotPDF, termasuk rilis yang sama sekali tidak memiliki binding XML atau XPath, sehingga bagian interface-nya hanya mengekspos tipe nilai polos: record, dynamic array, dan satu interface tunggal, IHPDFESchematronEngine, dengan metode Load, Validate, dan LastSummary. Setiap tipe khusus MSXML — IXMLDOMDocument2, import Winapi.msxml, THPDFMSXMLSchematronEngine itu sendiri — berada di dalam satu blok {$IFDEF XE2+} pada bagian implementasi, tidak terlihat baik oleh pemanggil maupun oleh compiler pada toolchain yang lebih lama

{$IFDEF XE2+}
function HPDFCreateSchematronEngine: IHPDFESchematronEngine;
begin
  Result := THPDFMSXMLSchematronEngine.Create;   // real MSXML-backed engine
end;
{$ELSE}
function HPDFCreateSchematronEngine: IHPDFESchematronEngine;
begin
  Result := THPDFStubSchematronEngine.Create;    // Delphi 7: reports itself unavailable
end;
{$ENDIF}

Pada Delphi 7 dan sebelumnya, HPDFCreateSchematronEngine sebagai gantinya menyerahkan kembali THPDFStubSchematronEngine: Load-nya selalu mengembalikan False dengan sebuah ErrorText yang menyebutkan celah sebenarnya — binding DOM MSXML membutuhkan XE2 atau lebih baru — dan mengarahkan ke validator eksternal seperti veraPDF, Mustang, atau sebuah tool kesesuaian ZUGFeRD untuk cakupan penuh sementara itu. Validate-nya mengembalikan satu hasil sintetis tunggal dengan RuleID 'ENGINE' dan Skipped diset, sehingga kode yang mengiterasi BusinessRules tidak membutuhkan cabang terpisah untuk "mesin tidak bisa berjalan" versus "setiap aturan kebetulan dilewati" — keduanya terlihat sama bentuknya bagi pemanggil. HPDFValidateEInvoice melipat ini secara mulus ke dalam vonisnya juga: boolean yang dikembalikannya adalah ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), sehingga sebuah mesin yang tidak tersedia menurunkan hasilnya menjadi pemeriksaan container-saja alih-alih memaksa kegagalan keras pada sebuah compiler yang memang tidak pernah akan menjalankan aturan Schematron sejak awal

Mesin XPath 1.0 milik HPDFEInvoiceValidator tidak menggantikan sebuah processor Schematron/XSLT 2.0 penuh, dan memang tidak pernah dimaksudkan untuk itu: sebuah mesin yang terbatas pada XPath 1.0 akan selalu meninggalkan segelintir assertion EN 16931 yang tidak dievaluasi, dan itu persis apa yang ingin dibuat terlihat oleh flag Skipped pada setiap hasil, bukan disembunyikan. Yang didapat dari mesin ini adalah umpan balik aturan bisnis yang berjalan di mana pun HotPDF sudah berjalan, tanpa proses eksternal yang perlu dipanggil dan tanpa runtime XSLT 2.0 yang perlu dilisensikan. Mesin ini disertakan sebagai bagian dari komponen PDF HotPDF untuk Delphi dan C++Builder, berdampingan dengan tooling Factur-X dan PDF/A tingkat-container yang menjadi landasannya