HotPDF ตรวจสอบกฎธุรกิจของใบแจ้งหนี้อิเล็กทรอนิกส์ตาม EN 16931 ผ่าน HPDFEInvoiceValidator ซึ่งเป็นเอนจิ้นตรวจสอบ Schematron ที่ไลบรารีสร้างขึ้นเองบนพื้นฐานการรองรับ XPath 1.0 ของ MSXML แทนที่จะใช้ตัวประมวลผล XSLT 2.0 แบบมีสัญญาอนุญาต HPDFEInvoiceValidator parse ไฟล์กฎ .sch ของ Factur-X ฉบับทางการ ประเมินทุก assertion ที่มันแสดงออกได้ด้วย XPath 1.0 และทำเครื่องหมายส่วนที่เหลือว่าถูกข้าม แทนที่จะปล่อยให้ expression ที่ไม่รองรับยก exception ขึ้นมากลางการทำงาน
ขอบเขตของบทความนี้จำกัดอยู่แค่ภายในเอนจิ้นนี้ ตัวโหลดแปลง Schematron XML ให้เป็น rule entry อย่างไร การประเมิน assert และ report ตัดสินผ่านหรือไม่ผ่านได้อย่างไรจริงๆ ช่องว่างของ XPath 2.0 ถูกตรวจจับและข้ามได้อย่างไร และ unit เดียวกันนี้ยังคง compile บน Delphi 7 ได้อย่างไร เรื่องการฝัง PDF/A-3, กลไก container ของ factur-x.xml / xrechnung.xml และเรื่องราวการกำหนดเวอร์ชัน ZUGFeRD 2.5 อยู่ในบทความคู่กันเรื่อง ZUGFeRD และ Factur-X e-invoice ใน Delphi ด้วย HotPDF ซึ่งบทความนี้ตั้งใจไม่พูดซ้ำ
ทำไม HotPDF ถึงสร้างเอนจิ้น Schematron ของ EN 16931 เอง
HotPDF สร้างเอนจิ้น Schematron ของตัวเองเพราะ binding ที่ประกาศไว้ในไฟล์กฎ EN 16931 พูดเกินจริงกว่าที่ assertion ของมันต้องการจริงๆ ไฟล์ตั้ง queryBinding="xslt2" ไว้ด้านบนสุด ซึ่งทางเทคนิคแล้วขอตัวประมวลผล XSLT 2.0 / XPath 2.0 เต็มรูปแบบ แต่เมื่ออ่าน assertion เหล่านั้นเองจะพบว่าส่วนใหญ่เรียกใช้แค่ฟังก์ชัน XPath 1.0 อย่าง string-length และ substring-after เท่านั้น MSXML DOM ในตัวของ Windows ซึ่งเป็น XML engine เดียวที่รับประกันว่ามีอยู่ในทุกการติดตั้ง Delphi ที่รองรับโดยไม่ต้องเพิ่ม dependency จากบุคคลที่สาม บังเอิญ implement เซตย่อยนั้นพอดี คือ XPath 1.0 ซึ่งเป็นสิ่งที่ทำให้เอนจิ้นแบบเนทีฟใช้งานได้จริง แทนที่จะต้องขอสัญญาอนุญาต XSLT 2.0 runtime แยกต่างหาก HPDFSchematronFileForProfile แม็ประดับความสอดคล้อง Factur-X ที่ตรวจพบไปยังหนึ่งในห้าไฟล์กฎที่แถมมาซึ่งเอนจิ้นนี้โหลดได้ ได้แก่ MINIMUM, BASIC WL, BASIC, EN 16931 และ EXTENDED และทุกไฟล์ตัดสินแค่ XML ใบแจ้งหนี้ที่ดึงออกมาเท่านั้น ไม่เคยตัดสิน PDF ที่ห่อหุ้มมันเลย ส่วนคำถามว่า PDF นั้นเองเป็นไฟล์ PDF/A-3 ที่ถูกต้องตามโครงสร้างหรือไม่เป็นคำถามแยกต่างหากที่ตอบผ่านการตรวจสอบความสอดคล้อง PDF/A, PDF/X และ PDF/UA ของ HotPDFในส่วนอื่นของไลบรารี
เอนจิ้นแปลงไฟล์ .sch ให้เป็น rule entry ได้อย่างไร
THPDFMSXMLSchematronEngine.Load เริ่มด้วยการเรียก CoInitializeEx(nil, COINIT_MULTITHREADED) ก่อนจะสร้างอะไรทั้งสิ้น เพราะ console หรือ service host ที่ไม่เคยเรียก Application.Initialize ยังไม่มี COM apartment เลย ในขณะที่ GUI VCL host มีอยู่แล้ว เอนจิ้นถือว่าผลลัพธ์ S_FALSE หรือ RPC_E_CHANGED_MODE ที่การเรียกนั้นอาจคืนกลับมาบน thread ที่อยู่ใน apartment อยู่แล้วนั้นโอเคพอๆ กัน ไม่ใช่ข้อผิดพลาด จากนั้นมัน parse ไฟล์ Schematron ด้วย MSXML 6.0 DOM document (CoDOMDocument60) และ setProperty('SelectionLanguage', 'XPath') เพราะ MSXML ใช้ dialect XSL-Pattern แบบเก่าเป็นค่าเริ่มต้น เว้นแต่ผู้เรียกจะเลือกใช้ XPath อย่างชัดเจน แต่จากจุดนั้น ตัวโหลดไม่เคยเรียก selectNodes เพื่อเดินผ่านโครงสร้างของไฟล์ .sch เองเลย ทุก element <pattern>, <rule>, <assert> และ <report> ถูกค้นหาด้วยการเดินผ่าน firstChild / nextSibling ด้วยมือ เปรียบเทียบชื่อ local และ namespace URI ของแต่ละ node กับ string ตรงตัว http://purl.oclc.org/dsdl/schematron
วิธีเดินด้วยมือนี้มีอยู่เพราะปัญหาไก่กับไข่ใน binding <ns prefix="ram" uri="..."/> ที่ทุกไฟล์ Schematron ของ Factur-X ประกาศไว้ล่วงหน้า การแก้ expression XPath ที่มี prefix อย่าง ram:Name เทียบกับ binding เหล่านั้น ต้องการให้ property SelectionNamespaces ของ MSXML มีค่าเหล่านั้นอยู่แล้ว แต่การค้นหา binding ตั้งแต่แรกตามปกติจะหมายถึงการรัน XPath query อย่าง //ns:ns ซึ่งตัวมันเองก็ต้องการให้ SelectionNamespaces ถูกตั้งไว้ก่อนเช่นกัน HPDFEInvoiceValidator ตัดวงจรนี้ด้วยการเก็บรวบรวมทุก element <ns> ผ่านการเดิน child-node ด้วยมือแบบเดียวกันก่อนที่จะแตะ selectNodes เลย แล้วจึงพับคู่ prefix/URI ที่เก็บได้เข้าเป็น string SelectionNamespaces เดียว ซึ่งทั้งการเดินโครงสร้าง .sch และการประเมิน rule ในภายหลังทุกครั้งนำกลับมาใช้ซ้ำ
// 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 เทียบกับ report: อะไรที่ทำให้เกิดการละเมิดจริงๆ
Schematron ให้ assert กับ report มีขั้วตรงข้ามกัน และเอนจิ้นต้องรักษาความแตกต่างนั้นไว้อย่างเป๊ะ ไม่เช่นนั้นจำนวนการละเมิดที่นับได้จะไม่มีความหมายเลย <assert test="X"> ประกาศว่า X ต้องเป็นจริงสำหรับทุก node ที่ match กับ context path ของ rule นั้น ดังนั้น EvaluateAssert จะบันทึกการละเมิดเมื่อ node-set ผลลัพธ์ของ test expression ว่างเปล่า ส่วน <report test="X"> เป็นภาพสะท้อนตรงข้าม คือ flag ปัญหาเมื่อ X เป็นจริง ดังนั้น EvaluateReport จะบันทึกการละเมิดเมื่อผลลัพธ์ของ test ไม่ว่างเปล่าแทน ทั้งสองจุดเข้าใช้รูปแบบสองขั้นตอนเดียวกันข้างใต้ คือ Doc.selectNodes(Entry.Context) ก่อน เพื่อหาทุก node ที่ rule นั้นใช้ได้ แล้วจึง ContextNode.selectNodes(Entry.Test) กับแต่ละตัวตามลำดับ ซึ่งเป็นโมเดล context-แล้ว-test แบบเดียวกับที่ตัวประมวลผล Schematron จริงใช้ เพียงแต่ขับเคลื่อนด้วย selectNodes ของ XPath 1.0 จาก MSXML แทนที่จะเป็นเอนจิ้นที่รู้จัก Schematron โดยตรง
เอนจิ้นข้าม XPath 2.0 โดยไม่ทำให้การทำงานล่มได้อย่างไร
HPDFEInvoiceValidator ป้องกัน syntax ของ XPath 2.0 ที่ไม่รองรับด้วยสองชั้น และชั้นแรกไม่เคยปล่อยให้ MSXML เห็น expression นั้นเลย ก่อนจะประเมิน assert หรือ report ใดๆ XPath2Detected จะสแกน test-expression string ดิบหาโทเค็นตรงตัวหกตัว ได้แก่ xs:decimal, xs:integer, xs:string, upper-case, lower-case และ exists( และถ้าพบตัวใดตัวหนึ่ง rule นั้นจะถูกทำเครื่องหมายเป็น Skipped ด้วยระดับความรุนแรง stsInfo ทันที ด้วยเหตุผลว่า MSXML ไม่ควรได้รับ expression ที่รู้อยู่แล้วว่ามันจะปฏิเสธ
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;
ชั้นที่สองดักจับสิ่งที่รายการโทเค็นแบบ static พลาดไป ทั้งการเรียก selectNodes ของ context และการเรียก selectNodes ของ test ต่อ node รันอยู่ภายใน block try/except เมื่อ MSXML ยก exception จาก expression ที่การสแกนโทเค็นปล่อยผ่านไป เช่น โครงสร้างนอกเหนือจากหกโทเค็นที่รู้จัก หรือ context path ที่มัน resolve ไม่ได้ exception จะถูกจับและ rule จะถูกบันทึกเป็น Skipped แทนที่จะส่งต่อไปยังผู้เรียก การออกแบบสองชั้นนี้เองที่ทำให้โครงสร้าง XPath 2.0 ตรงไหนก็ตามในชุด rule ไม่เคยยก exception ผ่าน HPDFValidateEInvoice ออกไปเลย ทุก assertion จากทั้งหมด 424 ตัวจะถูกประเมิน ล้มเหลว หรือถูกทำเครื่องหมายว่าข้าม และการประมาณการภายในกับไฟล์กฎนั้นพบว่าสัดส่วนที่ XPath 1.0 ประมวลผลได้อยู่ที่ประมาณ 350 จาก 424 ตัว มากพอที่การประเมินบางส่วนจะคุ้มค่าที่จะทำ แทนที่จะ fallback ไปเป็นการตรวจสอบแค่ container ทันทีที่ assertion ของ XPath 2.0 ตัวเดียวปรากฏขึ้น
การป้อน XML ใบแจ้งหนี้แบบ UTF-8 ให้ MSXML โดยไม่ทำให้เพี้ยน
THPDFMSXMLSchematronEngine.Validate ไม่ได้ส่งไบต์ของใบแจ้งหนี้ที่ดึงออกมาไปให้ IXMLDOMDocument.loadXML เพราะ method นั้นคาดหวัง BSTR ซึ่งเป็น UTF-16 และจะตีความ array ไบต์ UTF-8 ดิบใหม่ภายใต้สมมติฐานนั้น ไม่ว่า declaration <?xml encoding="UTF-8"?> ของเอกสารเองจะบอกว่าอย่างไรก็ตาม HPDFEInvoiceValidator แทนที่จะทำแบบนั้น จะ copy ไบต์เข้าไปใน HGLOBAL ที่จัดสรรผ่าน GlobalAlloc ห่อมันด้วย IStream ผ่าน CreateStreamOnHGlobal แล้วโหลด stream นั้นผ่าน IPersistStreamInit.Load ซึ่งเป็นเส้นทางที่ MSXML เคารพด้วยการอ่าน encoding declaration จาก byte stream เองแทนที่จะสมมติว่าเป็น UTF-16 ไว้ล่วงหน้า method เดียวกันนี้สร้าง SelectionNamespaces ขึ้นใหม่จาก prefix binding ที่ตัวโหลดเก็บรวบรวมไว้แล้วขณะ parse ไฟล์ .sch ดังนั้น rule ที่เขียนโดยใช้ prefix อย่าง ram: จะ resolve ถูกต้องเทียบกับ namespace ของ XML ใบแจ้งหนี้เองในทุกการประเมิน ไม่ใช่แค่ตอนที่ไฟล์กฎถูก 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
การรักษาให้ unit เดียว compile ได้ตั้งแต่ Delphi 7 จนถึงปัจจุบัน
HPDFEInvoiceValidator.pas ต้อง compile ได้บนทุกเวอร์ชัน Delphi ที่ HotPDF รองรับ รวมถึงรุ่นที่ไม่มี XML หรือ XPath binding ใดๆ เลย ดังนั้นส่วน interface ของมันจึงเปิดเผยแค่ value type ธรรมดาเท่านั้น ได้แก่ record, dynamic array และ interface เดียวคือ IHPDFESchematronEngine ซึ่งมี method Load, Validate และ LastSummary ทุก type ที่เจาะจงกับ MSXML ได้แก่ IXMLDOMDocument2, การ import Winapi.msxml, THPDFMSXMLSchematronEngine เอง อยู่ภายใน block {$IFDEF XE2+} เดียวในส่วน implementation มองไม่เห็นทั้งจากผู้เรียกและจากคอมไพเลอร์บน toolchain รุ่นเก่าเหมือนกัน
{$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}
บน Delphi 7 และรุ่นก่อนหน้า HPDFCreateSchematronEngine จะส่ง THPDFStubSchematronEngine กลับมาแทน โดย Load ของมันจะคืนค่า False เสมอ พร้อม ErrorText ที่ระบุช่องว่างจริงๆ ว่า MSXML DOM binding ต้องการ XE2 หรือใหม่กว่า และชี้ไปยังตัวตรวจสอบภายนอกอย่าง veraPDF, Mustang หรือเครื่องมือตรวจสอบความสอดคล้อง ZUGFeRD สำหรับความครอบคลุมเต็มรูปแบบในระหว่างนี้ Validate ของมันคืนผลลัพธ์สังเคราะห์หนึ่งรายการที่มี RuleID เป็น 'ENGINE' และตั้งค่า Skipped ไว้ ดังนั้นโค้ดที่วนซ้ำ BusinessRules จึงไม่ต้องมี branch แยกสำหรับ "เอนจิ้นทำงานไม่ได้" กับ "ทุก rule บังเอิญถูกข้าม" ทั้งสองกรณีมีรูปร่างเหมือนกันสำหรับผู้เรียก HPDFValidateEInvoice พับสิ่งนี้เข้าไปในคำตัดสินของมันอย่างนุ่มนวลเช่นกัน ค่า boolean ที่มันคืนกลับมาคือ ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)) ดังนั้นเอนจิ้นที่ใช้งานไม่ได้จะลดระดับผลลัพธ์ลงเป็นการตรวจสอบแค่ container แทนที่จะบังคับให้ล้มเหลวอย่างหนักบนคอมไพเลอร์ที่ไม่มีทางรัน Schematron rule ได้ตั้งแต่แรกอยู่แล้ว
เอนจิ้น XPath 1.0 ของ HPDFEInvoiceValidator ไม่ได้แทนที่ตัวประมวลผล Schematron/XSLT 2.0 เต็มรูปแบบ และมันไม่เคยตั้งใจจะเป็นแบบนั้นด้วย เอนจิ้นที่จำกัดอยู่แค่ XPath 1.0 จะเหลือ assertion ของ EN 16931 ส่วนหนึ่งที่ไม่ได้ถูกประเมินเสมอ ซึ่งเป็นสิ่งที่ flag Skipped บนแต่ละผลลัพธ์มีไว้เพื่อทำให้มองเห็นได้ แทนที่จะซ่อนมันไว้ สิ่งที่เอนจิ้นนี้ให้มาคือ feedback ด้านกฎธุรกิจที่ทำงานได้ทุกที่ที่ HotPDF ทำงานอยู่แล้ว โดยไม่ต้องมี process ภายนอกให้เรียกใช้และไม่ต้องขอสัญญาอนุญาต XSLT 2.0 runtime เอนจิ้นนี้มาพร้อมกับคอมโพเนนต์ PDF ของ HotPDF สำหรับ Delphi และ C++Builder ควบคู่ไปกับเครื่องมือระดับ container ของ Factur-X และ PDF/A ที่มันสร้างต่อยอดขึ้นมา