Bài viết kỹ thuật

Bộ máy quy tắc Schematron EN 16931 trong Delphi với HotPDF

HotPDF xác thực các quy tắc nghiệp vụ hóa đơn điện tử EN 16931 thông qua HPDFEInvoiceValidator, một bộ máy khẳng định Schematron mà thư viện tự triển khai trên nền hỗ trợ XPath 1.0 của MSXML thay vì một bộ xử lý XSLT 2.0 có bản quyền. HPDFEInvoiceValidator phân tích các file quy tắc .sch chính thức của Factur-X, đánh giá mọi khẳng định nó có thể diễn tả bằng XPath 1.0, và đánh dấu phần còn lại là bị bỏ qua thay vì để một biểu thức không được hỗ trợ ném ra ngoại lệ giữa chừng

Phạm vi ở đây chỉ giới hạn trong bộ máy đó: bộ nạp biến XML Schematron thành các mục quy tắc như thế nào, việc đánh giá assert và report thực sự quyết định đạt hay không đạt ra sao, khoảng trống XPath 2.0 được phát hiện và bỏ qua như thế nào, và cùng một unit đó vẫn biên dịch trên Delphi 7 ra sao. Việc nhúng PDF/A-3, cơ chế container factur-x.xml / xrechnung.xml, và câu chuyện về phiên bản ZUGFeRD 2.5 nằm trong bài viết đồng hành về hóa đơn điện tử ZUGFeRD và Factur-X trong Delphi với HotPDF, bài viết này cố tình không nhắc lại chúng

Vì sao HotPDF tự xây dựng bộ máy Schematron EN 16931 riêng

HotPDF tự xây dựng bộ máy Schematron riêng vì binding được khai báo trong file quy tắc EN 16931 đòi hỏi nhiều hơn những gì các khẳng định của nó thực sự cần: file đặt queryBinding="xslt2" ngay ở đầu, về mặt kỹ thuật là yêu cầu một bộ xử lý XSLT 2.0 / XPath 2.0 đầy đủ, nhưng đọc qua chính các khẳng định lại cho thấy đại đa số chỉ gọi các hàm XPath 1.0 như string-lengthsubstring-after. DOM MSXML tích hợp sẵn của Windows — engine XML duy nhất được đảm bảo tồn tại trên mọi bản cài đặt Delphi được hỗ trợ mà không cần thêm phụ thuộc bên thứ ba — lại vừa khéo triển khai đúng tập con đó, XPath 1.0, đó là điều khiến một bộ máy gốc trở nên khả thi thay vì phải mua bản quyền một runtime XSLT 2.0 riêng. HPDFSchematronFileForProfile ánh xạ một mức độ tuân thủ Factur-X đã phát hiện sang một trong năm file quy tắc đi kèm mà engine này có thể nạp — MINIMUM, BASIC WL, BASIC, EN 16931, và EXTENDED — và mỗi file trong số đó chỉ đánh giá XML hóa đơn đã trích xuất, không bao giờ đánh giá PDF bao quanh nó; việc PDF đó tự thân có phải một file PDF/A-3 hợp lệ về cấu trúc hay không là một câu hỏi riêng biệt được trả lời thông qua các kiểm tra tuân thủ PDF/A, PDF/X, và PDF/UA của HotPDF ở nơi khác trong thư viện

Bộ máy biến một file .sch thành các mục quy tắc như thế nào?

THPDFMSXMLSchematronEngine.Load bắt đầu bằng cách gọi CoInitializeEx(nil, COINIT_MULTITHREADED) trước khi tạo bất cứ thứ gì, vì một host console hay service chưa từng gọi Application.Initialize chưa có COM apartment nào, trong khi một host GUI VCL thì đã có sẵn; engine coi kết quả S_FALSE hoặc RPC_E_CHANGED_MODE mà lệnh gọi đó có thể trả về trên một luồng đã ở trong một apartment là hoàn toàn ổn thay vì là một lỗi. Sau đó nó phân tích file Schematron bằng một tài liệu DOM MSXML 6.0 (CoDOMDocument60) và setProperty('SelectionLanguage', 'XPath'), vì MSXML mặc định dùng phương ngữ XSL-Pattern cũ hơn của nó trừ khi caller chủ động chọn XPath. Từ đó, tuy nhiên, bộ nạp không bao giờ gọi selectNodes để duyệt cấu trúc riêng của file .sch — mọi phần tử <pattern>, <rule>, <assert>, và <report> đều được tìm thấy bằng cách duyệt firstChild / nextSibling thủ công, so sánh tên cục bộ và URI namespace của mỗi node với chuỗi ký tự http://purl.oclc.org/dsdl/schematron

Cách tiếp cận duyệt thủ công đó tồn tại vì một vấn đề kiểu con-gà-quả-trứng trong các binding <ns prefix="ram" uri="..."/> mà mọi file Schematron Factur-X khai báo ngay từ đầu. Việc giải quyết một biểu thức XPath có tiền tố như ram:Name theo các binding đó đòi hỏi thuộc tính SelectionNamespaces của MSXML phải đã chứa sẵn chúng, nhưng việc phát hiện các binding đó ngay từ đầu thông thường lại đòi hỏi chạy một truy vấn XPath như //ns:ns — thứ tự nó lại cần SelectionNamespaces được đặt trước. HPDFEInvoiceValidator phá vỡ vòng lặp đó bằng cách thu thập mọi phần tử <ns> qua cùng lượt duyệt node con thủ công trước khi đụng đến selectNodes, sau đó gấp các cặp tiền-tố/URI đã thu hoạch được vào một chuỗi SelectionNamespaces duy nhất mà cả lượt duyệt cấu trúc .sch lẫn mọi lượt đánh giá quy tắc sau này đều tái sử dụng

// 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 so với report: điều gì thực sự kích hoạt một vi phạm?

Schematron cho assert và report cực tính ngược nhau, và engine phải giữ đúng sự phân biệt đó nếu không số lượng vi phạm sẽ vô nghĩa. Một <assert test="X"> khai báo rằng X phải đúng cho mọi node khớp với đường dẫn ngữ cảnh của quy tắc, nên EvaluateAssert ghi nhận một vi phạm khi tập node kết quả của biểu thức test trả về rỗng; một <report test="X"> là hình ảnh gương, đánh dấu một vấn đề khi X đúng, nên EvaluateReport ghi nhận một vi phạm khi kết quả test không rỗng thay vào đó. Cả hai điểm vào đều chia sẻ cùng hình dạng hai giai đoạn bên dưới — trước tiên là Doc.selectNodes(Entry.Context), để tìm mọi node mà quy tắc áp dụng, sau đó lần lượt là ContextNode.selectNodes(Entry.Test) trên từng node — chính xác là mô hình ngữ-cảnh-rồi-đến-test mà một bộ xử lý Schematron thật sự dùng, chỉ khác là được điều khiển bởi selectNodes XPath 1.0 của MSXML thay vì một engine thực thi hiểu Schematron

Bộ máy bỏ qua XPath 2.0 mà không làm sập lượt chạy như thế nào?

HPDFEInvoiceValidator phòng vệ trước cú pháp XPath 2.0 không được hỗ trợ theo hai lớp, và lớp đầu tiên không bao giờ để MSXML nhìn thấy biểu thức đó. Trước khi đánh giá bất kỳ assert hay report nào, XPath2Detected quét chuỗi biểu thức test thô tìm sáu token ký tự — xs:decimal, xs:integer, xs:string, upper-case, lower-case, và exists( — và nếu bất kỳ token nào trong số đó xuất hiện, quy tắc được đánh dấu Skipped với mức độ nghiêm trọng stsInfo ngay lập tức, dựa trên lý lẽ rằng MSXML không bao giờ nên nhận một biểu thức đã biết trước là sẽ bị từ chối

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;

Lớp thứ hai bắt lấy bất cứ thứ gì danh sách token tĩnh bỏ sót. Cả lệnh gọi selectNodes ngữ cảnh lẫn lệnh gọi selectNodes test theo từng node đều chạy bên trong một khối try/except; khi MSXML ném lỗi trên một biểu thức mà lượt quét token đã cho qua — một cấu trúc nằm ngoài sáu token đã biết, hoặc một đường dẫn ngữ cảnh nó không thể giải quyết — ngoại lệ được bắt lại và quy tắc được ghi nhận là Skipped thay vì lan truyền ra caller. Thiết kế hai lớp đó là lý do vì sao một cấu trúc XPath 2.0 ở bất cứ đâu trong bộ quy tắc không bao giờ ném lỗi vượt qua HPDFValidateEInvoice: mỗi khẳng định trong số 424 khẳng định của nó hoặc được đánh giá, thất bại, hoặc bị đánh dấu bỏ qua, và một ước tính nội bộ trên file quy tắc đó đặt tỉ lệ có thể thực thi bằng XPath-1.0 vào khoảng 350 trên 424 — đủ để việc đánh giá một phần đáng làm thay vì rơi về một kiểm tra chỉ-container ngay khi một khẳng định XPath 2.0 duy nhất xuất hiện

Đưa XML hóa đơn UTF-8 vào MSXML mà không làm hỏng nó

THPDFMSXMLSchematronEngine.Validate không giao các byte hóa đơn đã trích xuất cho IXMLDOMDocument.loadXML, vì phương thức đó mong đợi một BSTR — UTF-16 — và sẽ diễn giải lại một mảng byte UTF-8 thô theo giả định đó bất kể khai báo <?xml encoding="UTF-8"?> riêng của tài liệu nói gì. Thay vào đó, HPDFEInvoiceValidator sao chép các byte vào một HGLOBAL được cấp phát bằng GlobalAlloc, bọc nó trong một IStream thông qua CreateStreamOnHGlobal, và nạp stream đó qua IPersistStreamInit.Load, một đường xử lý mà MSXML tôn trọng bằng cách đọc khai báo encoding từ chính luồng byte thay vì giả định UTF-16 ngay từ đầu. Cùng phương thức đó dựng lại SelectionNamespaces từ các binding tiền tố mà bộ nạp đã thu hoạch trong lúc phân tích file .sch, nên một quy tắc viết theo một tiền tố như ram: giải quyết đúng theo namespace riêng của XML hóa đơn ở mỗi lần đánh giá, không chỉ khi file quy tắc lần đầu được phân tích

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

Giữ một unit duy nhất biên dịch được từ Delphi 7 đến hiện tại

HPDFEInvoiceValidator.pas phải biên dịch được trên mọi phiên bản Delphi mà HotPDF hỗ trợ, kể cả các bản phát hành hoàn toàn không có binding XML hay XPath nào, nên phần interface của nó chỉ phơi bày các kiểu giá trị thuần túy: bản ghi, mảng động, và một interface duy nhất, IHPDFESchematronEngine, với các phương thức Load, Validate, và LastSummary. Mọi kiểu đặc thù của MSXML — IXMLDOMDocument2, import Winapi.msxml, chính THPDFMSXMLSchematronEngine — nằm trong một khối {$IFDEF XE2+} duy nhất trong phần implementation, vô hình với caller và với trình biên dịch trên các bộ công cụ cũ hơn như nhau

{$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}

Trên Delphi 7 và các phiên bản cũ hơn, HPDFCreateSchematronEngine thay vào đó trả về THPDFStubSchematronEngine: Load của nó luôn trả về False kèm một ErrorText nêu rõ khoảng trống thực sự — binding DOM MSXML yêu cầu XE2 trở lên — và chỉ tới một trình xác thực bên ngoài như veraPDF, Mustang, hay một công cụ tuân thủ ZUGFeRD để có độ bao phủ đầy đủ trong lúc chờ đợi. Validate của nó trả về một kết quả tổng hợp duy nhất với RuleID'ENGINE' và cờ Skipped được đặt, nên code lặp qua BusinessRules không cần một nhánh riêng cho "engine không thể chạy" so với "mọi quy tắc tình cờ đều bị bỏ qua" — cả hai đều có cùng hình dạng với caller. HPDFValidateEInvoice cũng gộp điều đó một cách mượt mà vào phán quyết cuối cùng: giá trị boolean nó trả về là ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), nên một engine không khả dụng hạ cấp kết quả xuống thành một kiểm tra chỉ-container thay vì ép buộc một lỗi cứng trên một trình biên dịch mà ngay từ đầu đã không bao giờ định chạy các quy tắc Schematron

Engine XPath 1.0 của HPDFEInvoiceValidator không thay thế một bộ xử lý Schematron/XSLT 2.0 đầy đủ, và nó cũng chưa bao giờ có ý định làm vậy: một engine bị giới hạn trong XPath 1.0 sẽ luôn để lại một số khẳng định EN 16931 chưa được đánh giá, và đó chính xác là điều mà cờ Skipped trên mỗi kết quả tồn tại để làm nó hiển thị thay vì che giấu. Điều engine này thực sự mang lại là phản hồi quy tắc nghiệp vụ chạy được ở bất cứ đâu HotPDF đã chạy sẵn, không cần shell ra một tiến trình ngoài nào và không cần mua bản quyền một runtime XSLT 2.0. Engine này đi kèm sẵn trong thành phần PDF HotPDF dành cho Delphi và C++Builder, cùng với các công cụ Factur-X và PDF/A cấp container mà nó được xây dựng dựa trên