מאמר טכני

מנוע כללי Schematron לפי EN 16931 ב-Delphi עם HotPDF

HotPDF מאמתת כללי עסק של חשבונית אלקטרונית לפי EN 16931 דרך HPDFEInvoiceValidator, מנוע קביעות (assertion) מסוג Schematron שהספרייה מממשת בעצמה מעל תמיכת ה-XPath 1.0 של MSXML במקום מעבד XSLT 2.0 מורשה. HPDFEInvoiceValidator מפענחת את קובצי הכללים הרשמיים .sch של Factur-X, מעריכה כל קביעה שהיא יכולה לבטא ב-XPath 1.0, ומסמנת את השאר כמדולגות (Skipped) במקום לתת לביטוי בלתי-נתמך להעלות חריגה באמצע ההרצה

ההיקף כאן נשאר בתוך המנוע הזה: איך הטוען הופך XML של Schematron לרשומות כלל, איך הערכת assert ו-report בפועל מחליטה עובר או נכשל, איך הפער של XPath 2.0 מתגלה ומדולג, ואיך אותה יחידה עדיין מתקמפלת ב-Delphi 7. שיבוץ PDF/A-3, מנגנון המכולה factur-x.xml / xrechnung.xml, וסיפור גרסאות ZUGFeRD 2.5 חיים בהמאמר הנלווה על חשבוניות אלקטרוניות ZUGFeRD ו-Factur-X ב-Delphi עם HotPDF, שהחלק הזה במכוון לא חוזר עליו

למה HotPDF בנתה מנוע Schematron משלה לפי EN 16931

HotPDF בנתה מנוע Schematron משלה משום שהקישור המוצהר של קובץ הכלל EN 16931 מגזים במה שהקביעות שלו באמת צריכות: הקובץ מגדיר queryBinding="xslt2" בראש, מבקש טכנית מעבד XSLT 2.0 / XPath 2.0 מלא, אבל קריאה בפועל בקביעות עצמן מראה שהרוב המכריע קורא רק לפונקציות XPath 1.0 כמו string-length ו-substring-after. ה-DOM המובנה של MSXML ב-Windows — מנוע ה-XML היחיד שמובטח שיהיה קיים בכל התקנת Delphi נתמכת בלי להוסיף תלות צד-שלישי — מממש בדיוק את קבוצת המשנה הזו, XPath 1.0, וזה מה שהפך מנוע ילידי למעשי במקום לרשות זמן ריצה נפרד של XSLT 2.0. HPDFSchematronFileForProfile ממפה רמת תאימות Factur-X שזוהתה לאחד מחמישה קובצי כלל שמסופקים שהמנוע הזה יכול לטעון — MINIMUM, ‏BASIC WL, ‏BASIC, ‏EN 16931, ו-EXTENDED — וכל אחד מהם שופט רק את ה-XML של החשבונית שחולץ, אף פעם לא את ה-PDF שמסביב; האם ה-PDF הזה עצמו הוא קובץ PDF/A-3 תקין מבחינה מבנית היא שאלה נפרדת שנענית דרך בדיקות התאימות PDF/A, ‏PDF/X, ו-PDF/UA של HotPDF במקום אחר בספרייה

איך המנוע הופך קובץ .sch לרשומות כלל?

THPDFMSXMLSchematronEngine.Load נפתחת בקריאה ל-CoInitializeEx(nil, COINIT_MULTITHREADED) לפני שהיא יוצרת משהו, משום שמארח קונסולה או שירות שאף פעם לא קרא ל-Application.Initialize אין לו עדיין דירה (apartment) של COM, בעוד מארח VCL עם ממשק גרפי כבר יש לו. המנוע מתייחס לתוצאה S_FALSE או RPC_E_CHANGED_MODE שהקריאה הזו יכולה להחזיר על תהליכון שכבר בתוך דירה כתקינה באותה מידה ולא כשגיאה. אחר כך היא מפענחת את קובץ ה-Schematron עם מסמך DOM מסוג MSXML 6.0 (‏CoDOMDocument60) ו-setProperty('SelectionLanguage', 'XPath'), משום ש-MSXML ברירת המחדל שלה היא הניב הישן יותר XSL-Pattern אלא אם קוד קורא בוחר ב-XPath במפורש. מכאן, עם זאת, הטוען אף פעם לא קורא ל-selectNodes כדי לעבור על המבנה של קובץ ה-.sch עצמו — כל אלמנט <pattern>, ‏<rule>, ‏<assert>, ו-<report> נמצא על ידי מעבר ידני על firstChild / nextSibling, השוואת שם מקומי ו-URI של מרחב שמות של כל node מול המחרוזת המילולית http://purl.oclc.org/dsdl/schematron

הגישה של המעבר הידני קיימת בגלל בעיית ביצה-ותרנגולת בקישורי <ns prefix="ram" uri="..."/> שכל קובץ Schematron של Factur-X מצהיר עליהם מראש. פתרון ביטוי XPath עם קידומת כמו ram:Name מול הקישורים הללו דורש שמאפיין SelectionNamespaces של MSXML כבר יחזיק אותם, אבל גילוי הקישורים מלכתחילה בדרך כלל היה אומר הרצת שאילתת XPath כמו //ns:ns — שבעצמה זקוקה ל-SelectionNamespaces מוגדר קודם. HPDFEInvoiceValidator שוברת את המעגל הזה על ידי איסוף כל אלמנט <ns> דרך אותו מעבר ידני על צמתי-בן לפני שהיא נוגעת ב-selectNodes בכלל, ואז מקפלת את זוגות הקידומת/URI שנאספו למחרוזת SelectionNamespaces אחת ששני המעבר על מבנה ה-.sch וכל הערכת כלל מאוחרת יותר משתמשים בה מחדש

// 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 שתואם את נתיב ההקשר של הכלל, כך ש-EvaluateAssert רושמת הפרה כאשר קבוצת הצמתים שהביטוי מחזיר ריקה; <report test="X"> היא התמונה ההפוכה, מסמנת בעיה כאשר X נכון, כך ש-EvaluateReport רושמת הפרה כאשר תוצאת הבדיקה אינה ריקה במקום זאת. שתי נקודות הכניסה חולקות אותה צורה דו-שלבית מתחת — Doc.selectNodes(Entry.Context) קודם, כדי למצוא כל node שהכלל חל עליו, ואז ContextNode.selectNodes(Entry.Test) מול כל אחד בתורו — שזה בדיוק מודל הקשר-ואז-בדיקה שמעבד Schematron אמיתי משתמש בו, רק מונע על ידי selectNodes מסוג XPath 1.0 של MSXML במקום מנוע ביצוע מודע-Schematron

איך המנוע מדלג על XPath 2.0 בלי לקרוס?

HPDFEInvoiceValidator מתגוננת מפני תחביר XPath 2.0 לא-נתמך בשתי שכבות, והראשונה אף פעם לא נותנת ל-MSXML לראות את הביטוי בכלל. לפני הערכת כל assert או report, XPath2Detected סורקת את מחרוזת ביטוי-הבדיקה הגולמית עבור שישה טוקנים מילוליים — xs:decimal, ‏xs:integer, ‏xs:string, ‏upper-case, ‏lower-case, ו-exists( — ואם אחד מהם קיים הכלל מסומן Skipped בחומרה stsInfo מיד, מתוך ההיגיון ש-MSXML אף פעם לא צריכה לקבל ביטוי שכבר ידוע שהיא דוחה

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;

השכבה השנייה תופסת מה שרשימת הטוקנים הסטטית מפספסת. גם קריאת ה-selectNodes של ההקשר וגם קריאת ה-selectNodes של בדיקה-לפי-node רצות בתוך בלוק try/except; כאשר MSXML מעלה חריגה על ביטוי שסריקת הטוקנים נתנה לעבור — מבנה מחוץ לשישה הטוקנים הידועים, או נתיב הקשר שהיא לא יכולה לפתור — החריגה נלכדת והכלל נרשם כ-Skipped במקום להתפשט אל הקוד הקורא. העיצוב הדו-שכבתי הזה הוא הסיבה שמבנה XPath 2.0 בכל מקום בקבוצת הכללים אף פעם לא מעלה חריגה מעבר ל-HPDFValidateEInvoice: כל אחת מ-424 הקביעות שלו או מוערכת, נכשלת, או מסומנת כמדולגת, והערכה פנימית מול קובץ הכלל הזה קבעה את חלק ה-XPath-1.0-הניתן-לביצוע בקירוב 350 מתוך 424 — מספיק כדי שהערכה חלקית תהיה שווה את המאמץ במקום ליפול לבדיקת-מכולה-בלבד ברגע שקביעת XPath 2.0 בודדת מופיעה

הזנת XML של חשבונית ב-UTF-8 ל-MSXML בלי לעוות אותו

THPDFMSXMLSchematronEngine.Validate לא מוסרת את בייטי החשבונית שחולצו ל-IXMLDOMDocument.loadXML, משום שהפונקציה הזו מצפה ל-BSTR — UTF-16 — והייתה מפרשת מחדש מערך בייטים גולמי ב-UTF-8 תחת ההנחה הזו ללא קשר למה שהצהרת <?xml encoding="UTF-8"?> של המסמך עצמו אומרת. במקום זאת HPDFEInvoiceValidator מעתיקה את הבייטים לתוך HGLOBAL שהוקצה עם GlobalAlloc, עוטפת אותו ב-IStream דרך CreateStreamOnHGlobal, וטוענת את הזרם הזה דרך IPersistStreamInit.Load, נתיב ש-MSXML מכבד על ידי קריאת הצהרת הקידוד מתוך זרם הבייטים עצמו במקום להניח UTF-16 מראש. אותה פונקציה בונה מחדש את SelectionNamespaces מקישורי הקידומת שהטוען כבר אסף בעת פענוח קובץ ה-.sch, כך שכלל שנכתב מול קידומת כמו ram: נפתר נכון מול מרחב השמות של XML החשבונית עצמו בכל הערכה, לא רק כשקובץ הכלל נותח לראשונה

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

שמירה על יחידה אחת מתקמפלת מ-Delphi 7 ועד היום

HPDFEInvoiceValidator.pas חייבת להתקמפל על כל גרסת Delphi ש-HotPDF תומכת בה, כולל גרסאות ללא כל קישור XML או XPath, כך שסעיף ה-interface שלה חושף רק סוגי ערך פשוטים: רשומות, מערכים דינמיים, וממשק בודד, IHPDFESchematronEngine, עם פונקציות Load, ‏Validate, ו-LastSummary. כל סוג ספציפי-ל-MSXML — IXMLDOMDocument2, ‏ה-import של Winapi.msxml, ‏THPDFMSXMLSchematronEngine עצמה — יושב בתוך בלוק {$IFDEF XE2+} יחיד בסעיף המימוש, בלתי-נראה לקוד קורא ולמהדר על שרשראות כלים ישנות יותר כאחד

{$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 דורש XE2 ומעלה — ומצביע לעבר מאמת חיצוני כמו veraPDF, ‏Mustang, או כלי תאימות ZUGFeRD לכיסוי מלא בינתיים. ה-Validate שלה מחזירה תוצאה סינתטית בודדת עם RuleID'ENGINE' ו-Skipped מוגדר, כך שקוד שעובר על BusinessRules לא זקוק לענף נפרד עבור "המנוע לא הצליח לרוץ" לעומת "כל כלל במקרה דולג" — שניהם נראים באותה צורה לקוד הקורא. HPDFValidateEInvoice מקפלת את זה בחן גם לתוך הפסיקה שלה: הבוליאני שהיא מחזירה הוא ContainerValid and ((not BusinessRulesEvaluated) or (BusinessRuleViolations = 0)), כך שמנוע לא-זמין מוריד את התוצאה לבדיקת-מכולה-בלבד במקום לכפות כישלון קשה על מהדר שממילא אף פעם לא היה מריץ כללי Schematron מלכתחילה

מנוע ה-XPath 1.0 של HPDFEInvoiceValidator לא מחליף מעבד Schematron/XSLT 2.0 מלא, ומעולם לא נועד לכך: מנוע המוגבל ל-XPath 1.0 תמיד ישאיר קומץ קביעות EN 16931 בלתי-מוערכות, וזה בדיוק מה שהדגל Skipped על כל תוצאה קיים כדי להפוך לגלוי במקום להסתיר. מה שהמנוע כן קונה הוא משוב כללי-עסק שרץ בכל מקום ש-HotPDF כבר רצה בו, ללא תהליך חיצוני להריץ אליו shell וללא זמן ריצה XSLT 2.0 לרשות. המנוע הזה נשלח כחלק מרכיב ה-PDF של HotPDF עבור Delphi ו-C++Builder, לצד כלי Factur-X ו-PDF/A ברמת-המכולה שהוא נבנה עליהם