مقاله فنی

واردات حاشیه‌نویسی FDF در Delphi: رفع صفرِ بی‌سروصدا

پیش از v3.539.30، TPDFlib.ImportAnnotationsFromFDFString در losLab PDF Library تعداد درایه‌های حاشیه‌نویسی FDF را برمی‌گرداند که تجزیه کرده بود، در حالی که هیچ‌کدام را به سند اضافه نمی‌کرد: هر درایه شمرده می‌شد و هر درایه دور ریخته می‌شد. از v3.539.30 واردکنندهٔ FDF کلیدها را با هر ترتیبی می‌خواند، /Rect را درست و مستقل از locale تجزیه می‌کند، و اکسپورت‌کنندهٔ متناظر هم /Rect واقعی حاشیه‌نویسی را می‌نویسد، پس یک export و import و export دوم FDF ای بایت‌به‌بایت یکسان تولید می‌کند. بقیهٔ این یادداشت توضیح می‌دهد که یک offset شروع اشتباه چطور یک خرابی بی‌سروصدای تمام‌عیار ساخت، سه نقص دیگر چه چیزی پشت آن پنهان بودند، و چطور می‌توانی خودت یک import را بررسی کنی به‌جای آنکه به مقدار برگشتی اعتماد کنی

سناریو معمولی است. یک بازبین روی قرارداد حاشیه‌نویسی می‌گذارد، کامنت‌ها به‌شکل یک فایل FDF سفر می‌کنند (Acrobat به آن Export Comments می‌گوید)، و سرویس Delphi شما آن‌ها را با ImportAnnotationsFromFDF داخل یک نسخهٔ تمیز ادغام می‌کند. فراخوانی 7 برمی‌گرداند، لاگ می‌گوید «7 کامنت import شد»، کار سبز می‌شود، و PDF خروجی هیچ کامنتی ندارد. هیچ چیزی raise نشد، هیچ چیزی هشدار نداد، و عدد محتمل به نظر می‌رسید چون همان شمار واقعی درایه‌های فایل بود. این بدترین شکلی است که یک باگ می‌تواند به خود بگیرد: تابعی که تنها سیگنال موفقیتش یک شمارنده است که مستقل از کاری که ادعا می‌کند گزارش‌اش می‌دهد محاسبه می‌شود

چرا ImportAnnotationsFromFDFString موفقیت گزارش می‌کرد ولی چیزی اضافه نمی‌کرد؟

واردکننده هر /Subtype را یک رشتهٔ خالی می‌خواند، و هلپری که حاشیه‌نویسی را می‌سازد روی subtype خالی زود خارج می‌شود، در حالی که فراخوان به هر حال result را یک واحد زیاد می‌کرد. یابندهٔ کلید موقعیت بلافاصله بعد از /Subtype را برمی‌گرداند، یعنی همان فاصلهٔ خالی قبل از مقدار. ReadName از آن فاصله شروع می‌کرد و در اولین نویسهٔ فاصله‌گذار متوقف می‌شد، پس قبل از خواندن هر چیزی می‌ایستاد. AddAnnotationToPage از ساختن حاشیه‌نویسی بدون subtype سر باز می‌زند، که در انزوا انتخاب دفاعی درستی است، اما یک procedure بدون مقدار برگشتی بود و Inc(Result) بیرونش نشسته بود. هر گارد به‌تنهایی معقول بود؛ با هم «هیچ کاری کار نکرد» را به «همه‌چیز کار کرد» تبدیل می‌کردند. fix کاری می‌کند که ReadName فاصله‌های خالی را رد کند، وجود / آغازینِ یک name object مربوط به PDF را الزامی بداند و در هر delimiter ای متوقف شود، از جمله [، ( و )، پس هم /Subtype/Text و هم /Subtype /Text نتیجه را Text می‌دهند

ImportAnnotationsFromFDFString در PDFlibPas پیدا می‌کرد /Subtype را، ReadName را روی فاصلهٔ خالی بعد از کلید شروع می‌کرد پس نام خالی برمی‌گشت، AddAnnotationToPage روی نبودن subtype خارج می‌شد و فراخوان به هر حال result را زیاد می‌کرد و هفت کامنت import شده گزارش می‌داد بدون اینکه چیزی به سند اضافه کند
هر گارد به‌تنهایی معقول بود؛ با هم هیچ کاری کار نکرد را به همه‌چیز کار کرد تبدیل می‌کردند، و برای همین مقدار برگشتی هرگز نباید تنها چیزی باشد که یک تست import بررسی می‌کند

مقدار برگشتی حتی بعد از آن fix هم لایق دقت بود. تا v3.539.39، ImportAnnotationsFromFDFString هنوز result را به‌ازای هر dictionary خوش‌ساخت در آرایهٔ /Annots زیاد می‌کرد، از جمله درایه‌هایی که /Page صفر-مبنایشان خارج از بازه بود یا /Subtype نداشتند، که هر دو از آن‌ها رد می‌شود. از PDFlibPas v3.539.40، ImportAnnotationsFromFDFString و ImportAnnotationsFromFDF تعداد حاشیه‌نویسی‌هایی را برمی‌گردانند که واقعاً اضافه شده‌اند، مثل import مربوط به XFDF: هلپر FDF یعنی AddAnnotationToPage حالا یک Boolean برمی‌گرداند و شمارنده فقط روی موفقیت حرکت می‌کند. اندازه‌گیری خود سند هنوز چک قوی‌تری است، چون روی نسخه‌های قدیمی‌تر هم برقرار است، پس طرح زیر AnnotationCount را روی هر صفحه قبل و بعد از import مقایسه می‌کند

function TotalAnnotations(Lib: TPDFlib): Integer;
var
  Page, Saved: Integer;
begin
  Result := 0;
  Saved := Lib.SelectedPage;
  for Page := 1 to Lib.PageCount do
    if Lib.SelectPage(Page) = 1 then
      Inc(Result, Lib.AnnotationCount);   // به‌ازای هر صفحهٔ انتخاب‌شده، شامل widgetها
  Lib.SelectPage(Saved);
end;

var
  Lib: TPDFlib;
  Before, Reported, Added: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contract.pdf', '');
    Before := TotalAnnotations(Lib);
    Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
    Added := TotalAnnotations(Lib) - Before;
    if Added <> Reported then   // از v3.539.40 برابر است
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

سه نقص دیگر پشت نقص اول

تعمیر فقط subtype سه باگ دیگر را در همان تابع آشکار می‌کرد، هر کدام فقط به این دلیل نامرئی مانده بودند که هیچ حاشیه‌نویسی هرگز به صفحه‌ای نرسیده بود. اول اینکه ReadNumber موقعیتش را به‌عنوان پارامتر مقدار می‌گرفت، پس خواندن چهار عدد /Rect پشت سر هم همان نقطه را چهار بار می‌خواند، و [ آغازین را هم رد نمی‌کرد، پس در عمل هیچ چیز نمی‌خواند. دوم اینکه FindKey یک مکان‌نمای رو-به-جلو را بین همهٔ جست‌وجوها به اشتراک می‌گذاشت. اکسپورت‌کننده /Subtype، /Rect، /Page، /Contents، /T، /Subj را می‌نویسد، اما واردکننده به ترتیب /Subtype، /Contents، /T، /Subj، /Page، /Rect را جست‌وجو می‌کرد؛ وقتی مکان‌نما یک بار از /Contents عبور می‌کرد، جست‌وجوی /Page و /Rect از درایهٔ جاری می‌گذشت و یا چیزی پیدا نمی‌کرد یا کلیدهای حاشیه‌نویسی بعدی را تطبیق می‌داد. کتابخانه نمی‌توانست خروجی خودش را بخواند. سوم اینکه اعداد از PLStrToFloat می‌گذشتند که جداکنندهٔ اعشار سیستم را دنبال می‌کند. ISO 32000-1 §12.7.7 فدرال FDF را همان سینتکس شیء PDF تعریف می‌کند و کلیدهای dictionary در PDF بدون ترتیب‌اند (§7.3.7)، پس هر parser ای از FDF که ترتیب کلید را فرض کند از همان ساخت با باطل است، هر ابزاری هم که فایل را تولید کرده باشد

واردکنندهٔ تعمیرشده اول هر درایه را کران‌دار می‌کند. FindDictEnd از << آغازین تا >> متناظرش راه می‌رود، dictionaryهای تودرتو را دنبال می‌کند و بدنهٔ رشته‌های literal را با escapeهای backslash شان رد می‌کند، پس یک >> داخل کامنتی مثل (see section >> 4) نمی‌تواند درایه را زودتر از موعد تمام کند. هر جست‌وجوی کلید بعد از آن از شروع خود درایه شروع می‌شود و تا انتهایش محدود است، که ترتیب کلید را بی‌اهمیت می‌کند و جلوی آن را می‌گیرد که یک حاشیه‌نویسی /Page مالِ دیگری را قرض بگیرد. تطبیق کلید delimiter راست بعد از نام را هم قبول می‌کند، چون /Contents(Hi) همان‌قدر معتبر است که /Contents (Hi)، در حالی که قاعدهٔ مرز-کلمه جلوی /Subj را می‌گیرد که ابتدای /Subtype را تطبیق بدهد و جلوی /T را که /Type را تطبیق بدهد. ReadNumber حالا موقعیتش را به‌عنوان پارامتر var می‌گیرد، فاصله‌های خالی و [ را رد می‌کند و با PLTryStrToFloatInvariant تجزیه می‌کند که روی یک توکن بدشکل به‌نرمی شکست می‌خورد به‌جای raise کردن. اگر هر کدام از چهار عدد مستطیل شکست بخورد، هر چهار تا به صفر برمی‌گردند به‌جای آنکه یک مستطیل نیمه‌خوانده تولید کنند

FindDictEnd در PDFlibPas حالا هر حاشیه‌نویسی FDF را از آغازین به متناظرش کران‌دار می‌کند، پس هر جست‌وجوی کلید از شروع درایه دوباره شروع می‌شود و در انتهایش می‌ایستد، و ReadNumber موقعیت var می‌گیرد، براکت را رد می‌کند و با PLTryStrToFloatInvariant تجزیه می‌کند
مکان‌نمای مشترک نمی‌توانست خروجی خود کتابخانه را بخواند: وقتی یک بار از /Contents می‌گذشت، جست‌وجوهای /Page و /Rect داخل کلیدهای حاشیه‌نویسی بعدی می‌رفتند، پس دیگر اجازه داده نمی‌شود ترتیب کلید مهم باشد

چرا رفت‌وبرگشت‌های FDF هر حاشیه‌نویسی را به اندازهٔ ارتفاع خودش جابه‌جا می‌کردند؟

اکسپورت‌کنندهٔ قدیمی مستطیل را در مدل مختصات اشتباه می‌نوشت. /Rect یک حاشیه‌نویسی [llx lly urx ury] است در default user space (ISO 32000-1 §12.5.2، با مستطیل‌های تعریف‌شده در §7.9.5)، و FDF همان آرایه را حمل می‌کند. ExportAnnotationsToFDFString اما GetAnnotRectEx را صدا می‌زد که Left و Top و Width و Height را در مختصات رسم کتابخانه گزارش می‌کند، همان فضایی که SetOrigin کنترلش می‌کند، و آن‌ها را به‌شکل [L T L+W T+H] سریالیزه می‌کرد. واردکننده، وقتی بالاخره کار می‌کرد، آن چهار مقدار را عیناً به‌عنوان یک مستطیل PDF برمی‌نوشت، پس لبهٔ بالا همان‌جا فرود می‌آمد که گوشهٔ پایین-چپ تعلق داشت و هر رفت‌وبرگشت حاشیه‌نویسی را به اندازهٔ ارتفاع خودش بالا می‌برد. اکسپورت‌کننده حالا اعداد /Rect خود حاشیه‌نویسی را کپی می‌کند، سه رقم اعشار، جداکنندهٔ نقطه، بدون توان، و فقط وقتی آرایهٔ ذخیره‌شده غایب است یا چهار عدد نیست به مستطیل محاسبه‌شده برمی‌گردد

PDFlibPas قبلاً /Rect مربوط به FDF را به‌شکل left و top و width و height در مختصات رسم سریالیزه می‌کرد، پس برگرداندن آن چهار عدد به‌شکل llx و lly و urx و ury لبهٔ بالا را همان‌جا فرود می‌آورد که گوشهٔ پایین-چپ تعلق داشت و هر حاشیه‌نویسی را در هر رفت‌وبرگشت به اندازهٔ ارتفاعش بالا می‌برد
اکسپورت‌کننده حالا اعداد /Rect خود حاشیه‌نویسی را کپی می‌کند؛ سه رقم اعشار، جداکنندهٔ نقطه، بدون توان، و تست رگرسیون export دوم را بایت‌به‌بایت با export اول مقایسه می‌کند

ارزشش را دارد تست رگرسیونی را که این را ثابت نگه می‌دارد کپی کنی، چون روی خود سند و روی یک export دوم assertion می‌گذارد، نه روی مقدار برگشتی واردکننده. به شمار مورد انتظار 2 دقت کن: AddNoteAnnotation یک حاشیه‌نویسی Text به‌همراه Popup اش می‌سازد و هر دو سفر می‌کنند. تست هم export و هم import را زیر یک جداکنندهٔ اعشار کاما اجرا می‌کند، که نیمهٔ دیگر این داستان همان‌جا زندگی می‌کند

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // حالا دو صفحه است
    Source.SelectPage(2);
    Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
      'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
    Target.NewPages(1);

    OldSep := FormatSettings.DecimalSeparator;
    FormatSettings.DecimalSeparator := ',';   // شبیه‌سازی یک دسکتاپ آلمانی یا فرانسوی
    try
      FDF := Source.ExportAnnotationsToFDFString;   // هنوز /Rect [50.5 ... می‌نویسد
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // یادداشت و popup اش
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

دقیق باش دربارهٔ اینکه مسیر FDF چه چیزی را حمل می‌کند. واردکننده هر درایه را به‌عنوان یک dictionary با /Type، /Subtype، /Rect، /Contents، /T و /Subj بازسازی می‌کند؛ رنگ و فلگ‌ها و سبک border و لینک‌های popup و appearance streamها بخشی از این مسیر نیستند، و اکسپورت‌کننده حاشیه‌نویسی‌های Widget را رد می‌کند چون فیلدهای فرم مالِ متدهای form-data هستند. نقشهٔ کلی‌ترِ اینکه چه داده‌ای از کدام مسیر سفر می‌کند در مرور دادوستد دادهٔ فرم FDF و XFDF و XFA است، و اگر لازم است ببینی واقعاً چه چیزی رسیده، خواننده‌های به‌ازای هر ایندکس مثل GetAnnotType و GetAnnotTitle و GetAnnotContentsEx در درون‌نگری outline و حاشیه‌نویسی و action پوشش داده شده‌اند

چطور فایل‌های FDF و XFDF با اعشار کاما را از exportهای قدیمی بخوانیم؟

برای FDF جواب بی‌ابهام است: کاما در سینتکس PDF delimiter نیست، پس توکن عددی که دقیقاً یک کاما و بدون نقطه دارد فقط می‌تواند یک اعشار باشد که روی ماشینی با locale کاما نوشته شده. نسخه‌های قبلی واقعاً چنین فایل‌هایی می‌نوشتند، مثلاً /Rect [10,500 20,250 40,750 60,125]، و ReadNumber جدید آن کامای تنها را قبل از تجزیه به نقطه تبدیل می‌کند. توکنی با دو کاما، یا یک کاما و یک نقطه، رد می‌شود به‌جای آنکه حدس زده شود. خواننده نشان‌گذاری توان را هم مصرف نمی‌کند، که با ISO 32000-1 §7.3.3 جور است: اعداد PDF هرگز از آن استفاده نمی‌کنند

XFDF سخت‌تر است، چون در attributeهای XML کاما همان جداکننده است. XFDF استاندارد (ISO 19444-1) می‌نویسد rect="50.5,80.25,70.75,100.125" و dashes="4,2"، در حالی که v3.539.28 و قبل‌ترها، روی سیستمی با locale کاما، می‌نوشتند rect="50,500 80,250 70,750 100,125" و opacity="0,600"، و هنگام خواندن یک opacity="0.6" استاندارد هم با EConvertError شکست می‌خوردند. از v3.539.29 هر دو جهت invariant هستند، و شکل قدیمی فقط وقتی توسط XFDFNormalizeLegacyDecimals شناسایی می‌شود که attribute با فاصلهٔ خالی به دقیقاً تعداد مورد انتظار توکن بشکند (چهار تا برای rect، یکی برای opacity و width) و هر توکن شکل ارقام-کاما-ارقام داشته باشد. یک rect استاندارد هرگز تطبیق نمی‌کند: یا یک توکن است با سه کاما، یا توکن‌هایی که به کاما ختم می‌شوند. dashes عمداً دست‌نخورده رها می‌شود، چون 4,2 می‌تواند دو طول خط‌چین باشد یا یک 4.2 قدیمی، و هیچ قاعده‌ای نمی‌تواند این دو را از هم تشخیص بدهد

const
  // کلیدها خارج از ترتیب اکسپورت‌کننده، به‌همراه اعشارهای کاما از یک export قدیمی با locale کاما
  LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
    '<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
    '/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
    '] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;               // یک سند تازه یک صفحه دارد
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // دوباره به‌شکل XFDF با اعشار نقطه‌ای اکسپورت شده: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

یک تست import حاشیه‌نویسی واقعاً باید روی چه چیزی assertion بگذارد؟

یک تست import مفید روی وضعیت سند هدف assertion می‌گذارد، هرگز فقط روی اینکه واردکننده دربارهٔ خودش چه می‌گوید. هیچ چیزی در سویت تست بعد از یک import از FDF وجود AnnotationCount را چک نمی‌کرد، و مقدار برگشتی، تنها عددی که کسی نگاهش می‌کرد، همان عددی بود که باگ دست‌نخورده باقی گذاشت. سه assertion هر یک از نقص‌های توصیف‌شده در اینجا را می‌گرفت: شمار حاشیه‌نویسی روی صفحهٔ مورد انتظار، یک فیلد که از طریق GetAnnotType یا GetAnnotContentsEx خوانده می‌شود، و یک export دوم که بایت‌به‌بایت با اولی مقایسه می‌شود. همین انضباط روی هر API ای که ساختار سند را انبوه بازنویسی می‌کند اعمال می‌شود، از جمله تثبیت فیلدهای توصیف‌شده در ادغام فیلدهای فرم تکراری: درخت حاصل را چک کن، نه یک جمع برگشتی را. متدهای حاشیه‌نویسی FDF و XFDF، با نوع فایل و رشته‌شان، در losLab PDF Library for Delphi and C++Builder عرضه می‌شوند، و v3.539.30 یا بعدتر نسخه‌ای است که باید اجرا کنی اگر کامنت‌ها باید از این سفر جان سالم به در ببرند، و v3.539.40 یا بعدتر اگر شمار برگشتی باید با چیزی که اضافه شده مطابق باشد