مقاله فنی

Dynamic XFA در PDFium Component: تعداد صفحه یک delta است

وقتی یک فرم dynamic XFA در یک viewer دلفی صفحه اضافه یا کم می‌کند، PDFium Component از v3.126.1 تعداد جدید را از طریق TPdf.PageCount و TPdf.OnXfaPageCountChanged گزارش می‌کند، چون event بومی صفحه یک delta اضافه/حذف حمل می‌کند نه یک کل. کتابخانه‌های Windows V8 در v3.126.1 نواحی hit ورودی را هم با فیلدهای جابه‌جاشده می‌برند، و v3.126.2 handleهای صفحه کهنه را بعد از برگشتن callback مربوط به layout دوباره load می‌کند. باگ‌ریپورتی که این را شروع کرد یک فرم درخواست هزینه بود: دو بار روی Add Row کلیک کنید، فرم به دو صفحه رشد می‌کند، و نشانگر صفحه مفتخر می‌گوید 1 از 1. داخل فیلدی که به صفحه 2 رفته تایپ کنید و کلیدها جایی نامرئی فرود می‌آیند. هیچ‌کدام با فرم‌های نمونه با-طول-ثابت که همه اول با آن‌ها تست می‌کنند ظاهر نمی‌شود، و دانستن چرایش برای هر کسی که یک form viewer embed می‌کند می‌ارزد

وقتی یک فرم dynamic XFA دوباره صفحه‌بندی می‌کند چه اتفاقی می‌افتد؟

یک فرم dynamic XFA لیست صفحه ثابتی ندارد، پس تعداد صفحه‌اش خروجی layout است و هر بار که کاربر داده را ویرایش کند می‌تواند عوض شود. ‏XFA 3.3 فرم را درختی از subformها توصیف می‌کند؛ یک subform تکرارشونده با یک instanceManager کنترل می‌شود، و اسکریپتی مثل _Row.addInstance() یک سطر دیگر clone می‌کند. بعد پردازنده layout محتوا را دوباره به ناحیه‌های صفحه می‌ریزد، که ممکن است صفحه اضافه کند، صفحه کم کند، یا فیلدهای موجود را به صفحه دیگری ببرد. ‏ISO 32000-1 §12.7.8 فقط تعریف می‌کند پاکت‌های XFA چطور داخل PDF سوار می‌شوند؛ هرچه بعدش می‌افتد مال موتور XFA است، که در PDFium Component همان layout مربوط به XFA خود PDFium است که در پروسه میزبان اجرا می‌شود. پس یک viewer دلفی با سندی طرف است که تعداد صفحه و اندازه‌های صفحه و موقعیت widgetهایش همگی state زنده‌اند. وقتی میزبان خلافش را فرض کند سه چیز خراب می‌شود:

  • تعداد صفحه‌ای که میزبان برای ناوبری و بازه‌های اسکرول و نشانگرهای صفحه cache کرده کهنه می‌شود، یا بدتر، با عدد غلط به‌روز می‌شود
  • فیلدهایی که جابه‌جا می‌شوند حاشیه‌شان را در موقعیت جدید نشان می‌دهند در حالی که ویرایشگر و hit area ماوس در مختصات قدیمی می‌مانند
  • viewer یک page handle را نگه می‌دارد که layout جایگزینش کرده، پس کلیک‌ها و رنگ‌آمیزی‌ها می‌روند سراغ صفحه‌ای که در آن فرم دیگر وجود ندارد

پایا نگه داشتن ویرایش‌های سطر در طول ذخیره و باز کردن دوباره، مسئله جداگانه‌ای است با قواعد خودش؛ این مقاله می‌ماند با آنچه در زمان اجرا داخل viewer می‌گذرد

Dynamic XFA به کدام runtime مربوط به PDFium نیاز دارد؟

Dynamic XFA در PDFium Component به build مربوط به V8/XFA کتابخانه بومی نیاز دارد، که با متغیر سراسری EnableV8Engine در یونیت PDFium قبل از load شدن اولین سند انتخاب می‌شود. پروسه اولین باری که هر TPdf ای کتابخانه را load کند به یک DLL متعهد می‌شود، و یک build ساده PDFium اصلاً نمی‌تواند موتور XFA را اجرا کند. وقتی سندی باز می‌شود، ‏TPdf سرکی به فایل می‌کشد برای نشانه‌های XFA و خودکار به build مربوط به V8 سوییچ می‌کند، اما فقط اگر تا آن لحظه هیچ کتابخانه ساده‌ای در آن پروسه load نشده باشد. وقتی تعهد از قبل به مسیر اشتباه رفته، یک بار TPdf.OnXfaRuntimeMissing fire می‌شود تا میزبان به کاربر بگوید برنامه را ری‌استارت کند. ست کردن صریح فلگ موقع راه‌اندازی حدس‌زدن را حذف می‌کند. ساختار callback یعنی FPDF_FORMFILLINFO که eventهای XFA را حمل می‌کند هم باید با DLL بخواند؛ پیش‌زمینه در نسخه 2 مربوط به FPDF_FORMFILLINFO و ABI مربوط به callbackهای XFA است، و تشخیص فرم‌های XFA و خواندن پاکت‌هایشان پوشش می‌دهد تفکیک انواع فرم را قبل از اینکه viewer ای باز کنید

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // قبل از اینکه اولین TPdf کتابخانه بومی را load کند تصمیم بگیرید:
  // پروسه بعداً نمی‌تواند از pdfium.dll به pdfium.v8.dll سوییچ کند
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

چرا PageCount برای یک فرم دوصفحه‌ای عدد 1 را گزارش می‌کرد؟

قبل از v3.126.1، PDFium Component آرگومان page_count مربوط به event بومی صفحه را به‌عنوان کل سند ذخیره می‌کرد، و آن آرگومان در واقع قدرمطلق اختلاف بین تعداد صفحه جدید و قدیمی است. ‏PDFium بعد از اینکه یک گذر layout تمام شود FFI_PageEvent را با نوع event یعنی صفحه-اضافه-شد یا صفحه-حذف-شد raise می‌کند؛ در درون اول تعداد صفحه ذخیره‌شده‌اش را به‌روز می‌کند و بعد abs(new - old) را پاس می‌دهد. در layout اولیه تعداد قدیمی صفر است، پس delta برابر کل می‌شود، و یک نمونه استاتیک سه‌صفحه‌ای مطابق انتظار سه صفحه گزارش می‌کند. دقیقاً به همین دلیل فرم‌های تست با-طول-ثابت هرگز باگ را لو نمی‌دادند. اولین باری که یک فرم پویا از یک صفحه به دو صفحه رشد می‌کند delta برابر 1 است، و wrapper هم TPdf.PageCount را می‌گذاشت 1 و هم پارامتر NewCount مربوط به OnXfaPageCountChanged را. حذف یک سطر از یک فرم سه‌صفحه‌ای هم همان جور مسخرگی را در جهت مخالف تولید می‌کرد

انباشتن delta روی مقدار قبلی هم یک ترمیم امن نیست. ترتیب callbackهای مقداردهی اولیه و layout طوری است که wrapper نمی‌تواند همیشه به تعداد قبلی‌اش به‌عنوان خط مبنا اعتماد کند، پس یک جمع جاری می‌تواند drift کند. از v3.126.1 به بعد، callback آرگومان را به‌عنوان تعداد نادیده می‌گیرد و روی سند FPDF_GetPageCount را صدا می‌زند، که کل را از layout ای می‌خواند که همین الان تمام شده. بعدش sceneهای صفحه cache‌شده را پاک می‌کند، همان کل را به‌عنوان override تعداد-صفحه-XFA پشت TPdf.PageCount ذخیره می‌کند، و فقط بعد از آن OnXfaPageCountChanged را raise می‌کند. تا وقتی handler شما اجرا شود، ‏NewCount و FPdf.PageCount هم‌نظرند

نمودار dynamic XFA در PDFium Component که در آن اضافه کردن یک سطر فرم یک‌صفحه‌ای را به دو صفحه دوباره صفحه‌بندی می‌کند و FFI_PageEvent مقدار abs(new منهای old) را به‌عنوان delta پاس می‌دهد، پس wrapper قدیمی مقدار TPdf.PageCount را 1 گزارش می‌کرد در حالی که v3.126.1 مقدار FPDF_GetPageCount را می‌خواند و کل درست را گزارش می‌کند
event بومی صفحه یک delta اضافه-یا-حذف‌شده گزارش می‌کند نه یک کل، پس v3.126.1 آرگومان را نادیده می‌گیرد و layout تمام‌شده را قبل از raise کردن OnXfaPageCountChanged می‌خواند
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // مقدار v3.126.1 به بعد: NewCount کل layout تمام‌شده است، هرگز یک delta نه.
  // این داخل callback مربوط به layout در PDFium اجرا می‌شود: فقط state مربوط به UI میزبان را به‌روز کنید،
  // سند را از اینجا نبندید و صفحه‌ها را از اینجا reload نکنید
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // بعد از هر بار reload صفحه fire می‌شود، از جمله refresh معوق مربوط به XFA
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

این event فقط برای فرم‌های Full XFA که layoutشان در زمان اجرا عوض می‌شود fire می‌شود. اسناد Static XFA و AcroForm هرگز آن را raise نمی‌کنند، پس viewer ای که هر دو را هندل می‌کند می‌تواند همان handler را وصل نگه دارد. رها کردنش بدون اتصال هم امن است؛ override پشت TPdf.PageCount به هر حال اعمال می‌شود، و event این‌طور هست که میزبان هرچه cache کرده را refresh کند

چرا وقتی یک فیلد جابه‌جا می‌شود جعبه ورودی روی صفحه قدیمی می‌ماند؟

حاشیه جابه‌جا شد و ویرایشگر نه، چون notifier مربوط به XFA بومی یک مستطیل را با خودش مقایسه می‌کرد. وقتی layout هندسه widget ای را که از قبل load شده عوض می‌کند، قرار است PDFium مستطیل جدید را ببیند و روی widget مقدار PerformLayout را صدا بزند، که ویرایشگر متن و hit area آن را جابه‌جا می‌کند. چک، مقدار GetWidgetRect() را با RecacheWidgetRect() مقایسه می‌کرد. هر دو تابع یک ارجاع const به همان عضو برمی‌گردانند، و recache همان عضو را درجا بازنویسی می‌کند، پس مقایسه همیشه دو مقدار یکسان می‌دید و widgetهای loadشده از relayout می‌پریدند

symptom وقتی بیرون زد که یک تست ارتفاع یک subform را طوری عوض کرد که فیلدهای موجود به صفحه بعدی رد شوند. روی هر دو معماری V8، حاشیه فیلد در موقعیت جدیدش کشیده می‌شد در حالی که متن تایپ‌شده و hit area ماوس در مختصات Y قبلی می‌ماندند. یک relayout صریح فیکسش نمی‌کرد، و reload کردن صفحه هم نه، چون widget همچنان باور داشت هندسه‌اش به‌روز است. کتابخانه‌های Windows V8 همراه v3.126.1 مستطیل قدیمی را قبل از recache با مقدار کپی می‌کنند و آن کپی را مقایسه می‌کنند، پس widgetهای جابه‌جاشده relayout می‌شوند و مقدار ویرایش‌شده دقیقاً همان‌جایی ظاهر می‌شود که حاشیه است. این یک فیکس بومی است: با DLLها سفر می‌کند، پس به‌روز کردن یونیت‌های Pascal در حالی که یک pdfium.v8.dll قدیمی نگه داشته می‌شود hit areaهای جابه‌جاشده را سر جایشان می‌گذارد. چک regression ای که پشتش بود اول سطر باقی‌مانده را به یک مقدار غیر-پیش‌فرض ویرایش می‌کند و بعد همان مقدار را در موقعیت جدید فیلد می‌خواهد، چون سطری که با مقادیر پیش‌فرض بازسازی می‌شد وگرنه شبیه یک پاس به نظر می‌رسید

نمودار relayout مربوط به widget در PDFium Component که مقایسه با خود قدیمی را که در آن GetWidgetRect و RecacheWidgetRect یک عضو مشترک برمی‌گرداندند و widgetهای جابه‌جاشده از PerformLayout می‌پریدند، با چک کپی-با-مقدار در Windows V8 نسخه v3.126.1 مقایسه می‌کند که ویرایشگر و hit area ماوس را روی حاشیه بازکشیده‌شده می‌برد
مقایسه یک مستطیل با خودش هرگز شکست نمی‌خورد، پس حاشیه جابه‌جا می‌شد در حالی که متن تایپ‌شده و کلیک‌ها عقب می‌ماندند تا وقتی که چک اول با مقدار یک کپی ذخیره کرد

TPdfView چطور صفحه‌ها را بدون کشیدن handle از زیر پای PDFium دوباره load می‌کند؟

از v3.126.2 به بعد، TPdfView reload صفحه‌ای که بعد از یک تغییر layout مربوط به XFA می‌آید را تا unwind شدن کامل call stack بومی معوق نگه می‌دارد. event صفحه معمولاً وقتی fire می‌شود که PDFium هنوز در حال پردازش ورودی است: کاربر روی دکمه Add Row کلیک کرده، کلیک یک اسکریپت را اجرا کرده، اسکریپت تعداد instanceها را عوض کرده، و layout داخل همان فراخوانی بومی تمام شده است. بستن و باز کردن دوباره page handle در همان لحظه شیئی را آزاد می‌کرد که فراخواننده هنوز ازش استفاده می‌کند. قبل از v3.126.2، viewer فقط خودش را invalidate می‌کرد، پس page handle نمایش‌داده‌شده می‌توانست به state قبل-از-layout اشاره کند، و اگر کاربر موقع ناپدید شدن روی آخرین صفحه بود، شماره صفحه انتخابی از بازه بیرون می‌افتاد

refresh معوق در چند قدم کوچک کار می‌کند و همین قدم‌ها رفتاری را که از میزبان می‌بینید توضیح می‌دهند:

  1. callback مربوط به page event نما را «دارای refresh layout مربوط به XFA در انتظار» علامت می‌زند و یک پیام پنجره خصوصی post می‌کند؛ eventهای تکراری قبل از رسیدن پیام داخل یک refresh ادغام می‌شوند
  2. نمایی که هنوز window handle ندارد فلگ معوق را نگه می‌دارد و پیام را از CreateWnd post می‌کند، در حالی که عوض کردن سند یا غیرفعال کردن نما یا نابود کردنش فلگ را پاک می‌کند
  3. وقتی پیام می‌رسد، نما انتخاب متن و هایلایت جست‌وجو و اندیس فیلد-فوکوس‌شده را پاک می‌کند، چون هر سه به layout قدیمی اشاره می‌کردند
  4. صفحه انتخابی به PageCount جدید clamp می‌شود؛ شماره صفحه عوض‌شده از سوییچ صفحه عادی می‌گذرد، وگرنه صفحه فعلی reload می‌شود، و حالت fit دوباره اعمال می‌شود
  5. اگر layout اصلاً صفحه‌ای باقی نگذاشته باشد، نما به‌جای رنگ‌آمیزی صفحه‌ای که دیگر وجود ندارد page handle قدیمی‌اش را unload می‌کند
نمودار refresh معوق مربوط به XFA در TPdfView در PDFium Component که در آن یک page event داخل call stack مربوط به layout بومی فقط یک refresh معوق علامت می‌زند و یک پیام پنجره post می‌کند، که بعداً state انتخاب کهنه را پاک می‌کند، صفحه را به PageCount جدید clamp می‌کند و page handle را reload یا unload می‌کند
reload منتظر unwind شدن call stack بومی می‌ماند: یک پیام postشده eventهای تکراری را ادغام می‌کند، بعد نما صفحه را clamp می‌کند، reload می‌کند و OnPageChange را raise می‌کند

همان محدودیت روی کد خودتان هم اعمال می‌شود. ‏OnXfaPageCountChanged داخل همان callback مربوط به layout بومی اجرا می‌شود، پس با آن مثل یک اعلان رفتار کنید: برچسب‌ها و بازه‌های spinner و state نوار ابزار را همان‌جا به‌روز کنید، و هر چیز سنگین‌تری مثل بستن سند یا باز کردن سند دیگر را با یک پیام postشده به صف بگذارید تا بعد از برگشتن callback اجرا شود. ‏TPdfView.OnPageChange بعداً به شما می‌گوید کی نما واقعاً صفحه را reload کرده، و خواندن PdfView1.PageNumber در همان نقطه مقدار clamp‌شده را می‌دهد. پیمایش با کلید Tab و چک‌های FormType که یک form viewer موقع باز کردن اجرا می‌کند در ناوبری فیلدهای فرم PDF با PDFium Component پوشش داده شده

چرا کلیک روی یک فیلد Full XFA خطای «Cannot open text page» می‌دهد؟

صفحه‌های Full XFA هیچ text page مربوط به PDF ندارند، و قبل از v3.126.2 انتخاب متن پیش‌فرض viewer و تشخیص لینک به هر حال سعی می‌کردند یکی load کنند. با TPdfView.AllowUserTextSelection در پیش‌فرض True بودنش، hover از لایه متن کاراکتری زیر ماوس می‌خواست، و یک کلیک mouse-up یک probe خودکار URL روی متن صفحه اجرا می‌کرد. روی یک صفحه Full XFA متن‌صفحه باز نمی‌شود، پس یک کلیک معمولی به داخل یک فیلد می‌توانست به یک exception یعنی Cannot open text page برسد. از v3.126.2 هر دو مسیر داخلی وقتی TPdf.FormType برابر ftXfaFull است و runtime مربوط به XFA در دسترس است هیچ نتیجه‌ای برنمی‌گردانند، پس تنظیمات پیش‌فرض کار می‌کنند و ورودی فیلد در دسترس می‌ماند

خاموش کردن AllowUserTextSelection برای اسناد Full XFA همچنان یک انتخاب UI معقول است، چون هیچ متن صفحه‌ای برای انتخاب نیست و ژست‌های درگ نباید حالت انتخاب را شروع کنند. اما جانشین ارتقا نیست: در نسخه‌های قدیمی‌تر probe مربوط به URL موقع کلیک به آن ویژگی وابسته نبود، پس یک viewer می‌توانست با انتخاب غیرفعال هم به همان exception برسد

procedure TClaimForm.ConfigureViewerForForm;
begin
  // مقدار FormType از سند باز خوانده می‌شود، پس بعد از FPdf.Active := True صدا بزنید
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // روی صفحه‌های Full XFA هیچ لایه متنی PDF وجود ندارد؛ فیلدها قابل ویرایش می‌مانند
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

تایپ کردن هم در v3.126.2 ترمیم مخصوص خودش را خواست. ویرایشگر متنی بومی XFA وقتی یک کاراکتر می‌گیرد یک انتخاب را جایگزین نمی‌کند: ‏FORM_OnChar در محل caret درج می‌کند، و Backspace یک کاراکتر حذف می‌کند، پس انتخاب یک مقدار و تایپ روی آن متن قدیمی و جدید را کنار هم تولید می‌کرد. PDFium Component حالا به یاد می‌سپارد که کلیک روی یک فیلد متنی XFA فرود آمده و کاراکترهای تایپ‌شده و Backspace و Delete را هر وقت انتخابی وجود داشته باشد و سند اجازه پر-کردن-فرم یا ویرایش بدهد از طریق FORM_ReplaceSelection مسیریابی می‌کند. اینکه فیلد XFA فقط-خواندنی مجاز به تغییر است یا نه همچنان تصمیم ویرایشگر بومی است، پس فیلدی که در فرم فقط-خواندنی علامت خورده حتی در سندی که وگرنه اجازه پر کردن می‌دهد مقدارش را نگه می‌دارد. ست کردن TPdfView.AllowFormEvents روی False این مسیریابی صفحه‌کلید را هم متوقف می‌کند، که یک viewer فقط-خواندنی را فقط-خواندنی نگه می‌دارد

مرجع سریع: dynamic XFA در یک viewer دلفی

Symptomعلتفیکس‌شده در
تعداد صفحه بعد از رشد فرم به دو صفحه عدد 1 را نشان می‌دهدevent بومی صفحه یک delta اضافه/حذف پاس می‌دهد نه یک کلv3.126.1 (wrapper)
حاشیه فیلد جابه‌جا می‌شود، متن تایپ‌شده و hit area عقب می‌مانندwidget بارگذاری‌شده بعد از یک مقایسه-با-خود از relayout می‌پریدv3.126.1 (کتابخانه‌های Windows V8)
viewer رنگ‌آمیزی یا مسیریابی ورودی به state صفحه قبل-از-layoutpage handle بعد از repagination دوباره load نمی‌شدv3.126.2 (refresh معوق)
کلیک داخل یک فیلد خطای Cannot open text page می‌دهدانتخاب متن و probe مربوط به URL روی صفحه‌های بدون لایه متنیv3.126.2
تایپ روی یک مقدار انتخاب‌شده به‌جای جایگزینی اضافه می‌کندویرایشگر بومی XFA در محل caret درج می‌کندv3.126.2
  • مقدار EnableV8Engine را قبل از load شدن هر سندی روی True بگذارید، و OnXfaRuntimeMissing را برای حالتی که کتابخانه ساده اول load شده باشد هندل کنید
  • کل را از TPdf.PageCount یا پارامتر NewCount مربوط به OnXfaPageCountChanged بخوانید؛ هرگز خودتان تعداد صفحه جمع و تفریق نکنید
  • handler مربوط به OnXfaPageCountChanged را سبک نگه دارید، چون داخل callback مربوط به layout بومی اجرا می‌شود
  • نشانگر صفحه فعلی را در TPdfView.OnPageChange همگام کنید، که بعد از اینکه reload معوق شماره صفحه را clamp کرد fire می‌شود
  • ‏DLLهای Windows V8 نسخه v3.126.1 یا بعدتر را همراه یونیت‌ها deploy کنید؛ فیکس relayout مربوط به widget در کد بومی است
  • با فرمی تست کنید که واقعاً تعداد صفحه‌اش را عوض می‌کند و فیلد ویرایش‌شده‌ای را از مرز صفحه رد می‌کند، چون نمونه‌های با-طول-ثابت هر باگ این لیست را قایم می‌کنند

Dynamic XFA تعداد صفحه و هندسه فیلد را به مقادیر زنده تبدیل می‌کند، و viewer فقط وقتی درست می‌ماند که آن‌ها را از layout تمام‌شده بگیرد و صفحه‌ها را در لحظه‌ای امن reload کند. PDFium Component هر دو را داخل TPdf و TPdfView هندل می‌کند، پس میزبان فقط باید گوش بدهد. جزئیات و دانلودها در صفحه محصول PDFium Component برای Delphi است