مقال تقني

محرك قواعد Schematron لمعيار EN 16931 في Delphi عبر HotPDF

يتحقق HotPDF من قواعد عمل الفاتورة الإلكترونية بموجب EN 16931 عبر HPDFEInvoiceValidator، وهو محرك تأكيدات Schematron تنفذه المكتبة نفسها فوق دعم XPath 1.0 في MSXML بدلًا من معالج XSLT 2.0 مرخّص. تُحلّل HPDFEInvoiceValidator ملفات قواعد Factur-X الرسمية بصيغة .sch، وتُقيّم كل تأكيد يمكن التعبير عنه بـXPath 1.0، وتُعلّم الباقي كـ"متخطّى" بدلًا من السماح لتعبير غير مدعوم بإطلاق استثناء في منتصف التشغيل

ينحصر نطاق هذه المقالة داخل ذلك المحرك: كيف يحوّل المُحمِّل XML الخاص بـSchematron إلى إدخالات قواعد، وكيف يقرر تقييم assert وreport النجاح أو الفشل فعليًا، وكيف تُكتشف فجوة XPath 2.0 وتُتخطى، وكيف لا تزال الوحدة نفسها تُترجم (compile) على 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 المدمج في ويندوز — محرك XML الوحيد المضمون وجوده في كل تثبيت Delphi مدعوم دون إضافة اعتماد على طرف ثالث — ينفذ تلك المجموعة الفرعية بالضبط، أي XPath 1.0، وهذا ما جعل بناء محرك أصلي عمليًا بدلًا من ترخيص وقت تشغيل XSLT 2.0 منفصل. تربط HPDFSchematronFileForProfile مستوى توافق Factur-X المكتشَف بأحد خمسة ملفات قواعد مشحونة يمكن لهذا المحرك تحميلها — MINIMUM، وBASIC WL، وBASIC، وEN 16931، وEXTENDED — وكل واحد منها يحكم فقط على XML الفاتورة المستخرَج، لا على ملف PDF المحيط أبدًا؛ أما ما إذا كان ذلك الملف نفسه ملف PDF/A-3 صالحًا من الناحية البنيوية فهو سؤال منفصل تجيب عنه فحوصات توافق PDF/A وPDF/X وPDF/UA في HotPDF في مكان آخر من المكتبة

كيف يحوّل المحرك ملف .sch إلى إدخالات قواعد؟

تبدأ THPDFMSXMLSchematronEngine.Load باستدعاء CoInitializeEx(nil, COINIT_MULTITHREADED) قبل إنشاء أي شيء، لأن مضيف طرفية أو خدمة لم يستدعِ قط Application.Initialize ليس لديه شقة 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 يدويًا، مقارنًا الاسم المحلي لكل عقدة وعنوان مساحة الاسم مقابل السلسلة الحرفية 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 إطلاقًا، ثم تطوي أزواج اللاحقة/العنوان المجمَّعة في سلسلة 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 يجب أن تتحقق لكل عقدة تطابق مسار سياق القاعدة، لذا تُسجّل EvaluateAssert مخالفة عندما تعود مجموعة عقد نتيجة تعبير الاختبار فارغة؛ أما <report test="X"> فهو الصورة المعكوسة، إذ يُشير إلى مشكلة عندما تكون X صحيحة، لذا تُسجّل EvaluateReport مخالفة عندما تكون نتيجة الاختبار غير فارغة بدلًا من ذلك. تتشارك نقطتا الدخول الشكل ذا المرحلتين نفسه تحتهما — أولًا Doc.selectNodes(Entry.Context)، لإيجاد كل عقدة تنطبق عليها القاعدة، ثم 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 للاختبار لكل عقدة داخل كتلة try/except؛ عندما تُطلق MSXML استثناءً على تعبير سمح له فحص الرموز بالمرور — تركيب خارج الرموز الستة المعروفة، أو مسار سياق لا يمكنها حله — يُلتقط الاستثناء وتُسجَّل القاعدة كـSkipped بدلًا من نشرها إلى المستدعي. هذا التصميم ثنائي الطبقة هو سبب عدم إطلاق أي تركيب XPath 2.0 في أي مكان من مجموعة القواعد استثناءً يتجاوز HPDFValidateEInvoice أبدًا: كل واحد من تأكيداتها الـ424 إما يُقيَّم، أو يفشل، أو يُعلَّم متخطًى، وقدّر تقدير داخلي مقابل ملف القواعد ذلك حصة قابلة للتنفيذ عبر XPath 1.0 تبلغ نحو 350 من أصل 424 — وهو ما يكفي لجعل التقييم الجزئي يستحق العناء بدلًا من العودة إلى فحص على مستوى الحاوية فقط بمجرد ظهور تأكيد واحد من XPath 2.0

تغذية MSXML بـXML الفاتورة بترميز UTF-8 دون إفساده

لا تُسلّم 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 على الإطلاق، لذا يعرض قسم واجهتها أنواع قيم بسيطة فقط: سجلات، ومصفوفات ديناميكية، وواجهة واحدة، IHPDFESchematronEngine، بطرائق Load وValidate وLastSummary. كل نوع خاص بـMSXML — IXMLDOMDocument2، واستيراد 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 الخاصة بها نتيجة صناعية واحدة برقم قاعدة '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 بالفعل، دون عملية خارجية يُستدعى إليها ودون وقت تشغيل XSLT 2.0 يجب ترخيصه. يُشحن هذا المحرك كجزء من مكوّن HotPDF لـPDF الخاص بـDelphi وC++Builder، إلى جانب أدوات Factur-X وPDF/A على مستوى الحاوية التي يُبنى فوقها