مقاله فنی

round trip ظاهر حاشیه‌نویسی در دلفی با PDFium

در PDFium Component پیش از v3.121.1، خواندن یک حاشیه‌نویسی از طریق TPdf.Annotation[] و انتساب دوبارهٔ رکورد می‌توانست ورودی‌های /R و /D خالی به دیکشنری ظاهر /AP آن اضافه کند، حتی وقتی اصل فقط /N داشت. اعتبارسنج‌های PDF/A آن دیکشنری را رد می‌کنند. از v3.121.1 به بعد getter فقط ظاهری را گزارش می‌کند که واقعاً خوانده، پس یک round trip بدون تغییر چیزی جدید نمی‌نویسد. این خرابی ارزش دارد با جزئیات فهمیده شود، چون محرک معمولش fixی است که قصدش بیشتر سازگارتر کردن فایل بوده، نه کمتر

نمودار round trip حاشیه‌نویسی در PDFium Component که در آن افزودن afPrint از طریق TPdf.Annotation[] و SetAnnotationData هم‌زمان استریم‌های خالی /R و /D را از طریق FPDFAnnot_SetAP می‌نویسد و دیکشنری ظاهر تمیز PDF A را به دیکشنری‌ای تبدیل می‌کند که veraPDF رد می‌کند، تا v3.121.1 که فقط ظاهرهای واقعاً خوانده‌شده را گزارش می‌کند
خواندن یک حاشیه‌نویسی و نوشتن دوباره‌اش بدون تغییر قبلاً استریم‌های rollover و down خالی اضافه می‌کرد، و چیزی که PDF/A را می‌اندازد همین است، نه پرچم Printی که قصد داشتید اضافه کنید

وقتی یک حاشیه‌نویسی را بدون تغییر دوباره می‌نویسید چه چیزی خراب می‌شود؟

جواب کوتاه: حاشیه‌نویسی استریم‌های ظاهری می‌گیرد که هرگز نداشته، و فایلی که پیش از ویرایش شما از اعتبارسنجی PDF/A می‌گذشت بعدش می‌افتد. سناریوی معمول این‌طوری پیش می‌رود. آرشیو مشتری با حاشیه‌نویسی‌های square و text می‌رسد که پرچم Print ندارند، PDF/A می‌خواهد هر حاشیه‌نویسی چاپ شود، پس روی صفحه‌ها حلقه می‌زنید، afPrint اضافه می‌کنید و هر رکورد را دوباره منتسب می‌کنید. هیچ جای این کد به ظاهرها دست نمی‌زند. رکورد TPdf.Annotation[] یک TPdfAnnotation است و SetAnnotationData هر فیلدی که سنچینل Has*اش ست شده را می‌نویسد؛ دقیقاً همان‌طور که جفت‌های HasContents / ContentsText طراحی شده‌اند کار کنند. مشکل این بود که getter برای مدهایی که وجود نداشتند HasAppearanceRollover و HasAppearanceDown را True می‌گذاشت با رشته‌های خالی، و setter هم دو استریم خالی را وظیفه‌شناسانه می‌نوشت:

procedure MarkAnnotationsPrintable(const FileName: string);
var
  Pdf: TPdf;
  PageNo, I: Integer;
  A: TPdfAnnotation;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for PageNo := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := PageNo;
      for I := 0 to Pdf.AnnotationCount - 1 do
      begin
        A := Pdf.Annotation[I];
        if not (afPrint in A.Flags) then
        begin
          A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
          // پیش از v3.121.1 این انتساب وقتی حاشیه‌نویسی مبدأ فقط /AP/N داشت
          // استریم‌های خالی /AP/R و /AP/D را هم می‌نوشت
          Pdf.Annotation[I] := A;
        end;
      end;
    end;
    Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
  finally
    Pdf.Free;
  end;
end;

ISO 32000-1 §12.5.5 دیکشنری ظاهر را با سه ورودی تعریف می‌کند: /N برای ظاهر عادی، /R برای rollover و /D برای down. /R و /D اختیاری‌اند و وقتی غایب‌اند نمایشگر به /N برمی‌گردد. اما یک استریم /R خالی «غایب» نیست. استریم معتبری است که هیچ چیزی نقاشی نمی‌کند، پس نمایشگری که به ظاهرهای rollover احترام می‌گذارد همان لحظه که اشاره‌گر روی حاشیه‌نویسی می‌رود یک مستطیل خالی نشان می‌دهد. PDF/A سخت‌گیرتر هم هست: ISO 19005-1 (با Corrigendum 2) و ISO 19005-2 / 19005-3 در دیکشنری ظاهر حاشیه‌نویسی فقط /N را مجاز می‌دانند. veraPDF فایل round-trip‌شده را زیر قاعدهٔ 6.5.3-4 برای PDF/A-1 و قاعدهٔ 6.3.3-2 برای PDF/A-2 و PDF/A-3 گزارش می‌کند و TPdf.ValidatePdfA داخلی آن را با نام pvaiAnnotationApDictViolation فهرست می‌کند. همان ویرایشی که پرچم Print را برای رضایت یک بند استاندارد اضافه کرد، بند دیگری را شکست

دیکشنری ظاهر حاشیه‌نویسی از ISO 32000-1 با ورودی‌های normal و rollover و down: PDFium برای استریم غایب و استریم خالی موجود هر دو 2 بایت برمی‌گرداند، پس هر دو از طریق TPdf بدون محتوا خوانده می‌شوند، در حالی که فقط بررسی سطح بایتی مثل TPdf.ValidatePdfA استریم خالی‌ای را می‌یابد که PDF/A منعش می‌کند
/R غایب به /N برمی‌گردد؛ /R خالی یک مستطیل خالی نقاشی می‌کند و همچنان PDF/A را می‌اندازد، و از طریق رکورد این دو قابل تشخیص از هم نیستند

چرا FPDFAnnot_GetAP برای ظاهر غایب عدد 2 برمی‌گرداند؟

PDFium هرگز از FPDFAnnot_GetAP صفر برنمی‌گرداند، حتی وقتی استریم ظاهر درخواستی وجود ندارد. تابع همان الگوی معمول دو فراخوانی PDFium را دنبال می‌کند: بافر nil پاس بدهید تا اندازهٔ لازم به بایت بیاید، حافظه بگیرید، بعد دوباره صدا بزنید تا متن UTF-16LE کپی شود. اندازه همیشه شامل کاراکتر خاتمهٔ UTF-16 است، پس استریم غایب 2 بایت گزارش می‌کند، یک رشتهٔ خالی به‌علاوهٔ خاتمه‌اش. getter پیش از v3.121.1 شرط ByteLength >= SizeOf(FPDF_WCHAR) را آزمایش می‌کرد، شرطی که هر فراخوانی‌ای پاس می‌کند، پس هر سه پرچم HasAppearance* برای هر حاشیه‌نویسی با هر ظاهری True برمی‌گشت. round trip از طریق رکورد بعد از FPDFAnnot_SetAP می‌خواست برای هر مد رشتهٔ خالی ذخیره کند و PDFium استریمش را می‌ساخت. بدون exception، بدون هشدار، و صفحهٔ دیداری هم یکسان به نظر می‌رسید؛ به همین دلیل این عیب در یک fixture مربوط به veraPDF سر باز کرد نه در یک نمایشگر

اینکه FPDFAnnot_GetAP در PDFium استریم ظاهر غایب را چطور گزارش می‌کند: الگوی دو فراخوانی همیشه دست‌کم دو بایت برای خاتمهٔ UTF-16 برمی‌گرداند، گیت قدیمی که با SizeOf(FPDF_WCHAR) مقایسه می‌کرد هر فراخوانی را پاس می‌داد و همهٔ سنچینل‌های HasAppearance را true می‌گذاشت، و گیت v3.121.1 بیش از خاتمه به‌علاوهٔ تعداد بایت زوج می‌خواهد
دو بایت همان رشتهٔ خالی کدگذاری‌شده است، نه اثباتی برای وجود ظاهر؛ getter اصلاح‌شده هر چیزی به اندازه یا کمتر از طول خاتمه را بدون محتوا می‌گیرد و نوشتن دوباره بی‌سروصدا می‌ماند

v3.121.1 چطور تصمیم می‌گیرد که یک ظاهر وجود دارد

ReadAppearance، هلپر درون GetPageAnnotation که AppearanceNormal، AppearanceRollover و AppearanceDown را پر می‌کند، حالا نتیجه را فقط وقتی محتوا می‌داند که دست‌کم یک کاراکتر بیشتر از خاتمه حمل کند. فراخوانی اول باید بیشتر از SizeOf(FPDF_WCHAR) بایت و تعداد بایت زوج برگرداند، چون طول فرد نمی‌تواند UTF-16 باشد. فراخوانی دوم که واقعاً متن را کپی می‌کند هم دوباره راستی‌آزمایی می‌شود: طول برگشتی 2 یا کمتر، یا بزرگ‌تر از بافری که گرفته شده، HasValue را False و رشته را خالی می‌گذارد. سمت نوشتن هیچ چیز عوض نشده. SetAnnotationData همچنان FPDFAnnot_SetAP را فقط برای مدهایی صدا می‌زند که پرچم HasAppearance*شان True است، پس رکوردی که از حاشیه‌نویسی‌ای با فقط /N خوانده شده حالا فقط /N را دوباره می‌نویسد. fixture بازگشتی هر دو جهت را پوشش می‌دهد: یک حاشیه‌نویسی square با ظاهر عادی، خوانده و بدون تغییر دوباره‌نوشته‌شده، از PDF/A-1b و PDF/A-2b و PDF/A-3b می‌گذرد، در حالی که همان حاشیه‌نویسی با پرچم Print حذف‌شده فقط روی قاعدهٔ پرچم مورد انتظار می‌افتد و روی هیچ چیز دیگر

استریم غایب و خالی یکسان به نظر می‌رسند، پس getter محافظه‌کار می‌ماند

API بومی نمی‌تواند استریم ظاهر غایب را از استریمی که وجود دارد ولی خالی است تشخیص دهد، و PDFium Component ادعای برعکس هم نمی‌کند. هر دو حالت از FPDFAnnot_GetAP همان 2 بایت را برمی‌گردانند، پس هر دو به شکل HasAppearanceRollover = False با AppearanceRollover خالی خوانده می‌شوند. دو نتیجه دارد که باید طراحی‌تان را دورشان بچینید. اول، سنچینل False یعنی «محتوایی خوانده نشده، پس نوشتن دوباره این مد را به حال خود می‌گذارد»، نه «کلید /R در دیکشنری غایب است». دوم، رکورد نمی‌تواند استریم خالی‌ای که از قبل در فایل هست را تشخیص دهد: سندی که بیلد قدیمی‌تر یا ابزار دیگری خراب کرده تمیز خوانده می‌شود و انتساب دوبارهٔ رکورد نه ترمیمش می‌کند نه بدترش. برای پیدا کردن آن فایل‌ها به یک بررسی سطح بایت نیاز دارید؛ برای همین TPdf.ValidatePdfA و گردش کار اعتبارسنجی preflight مربوط به PDF/A با PDFium Component ساخته شده‌اند

چطور از روی عمد یک ظاهر را پاک کنیم؟

سنچینل را صریح ست می‌کنید و رشتهٔ خالی پاس می‌دهید؛ setter همان را می‌نویسد. بستن رشته‌های خالی در SetAnnotationData fix کُند و یک‌طرفهٔ این باگ بود، اما فراخواننده‌هایی را هم می‌شکست که از روی عمد یک ظاهر را پاک می‌کنند، همان قراردادی که HasContents و HasAuthor برای متن رعایت می‌کنند. پس fix تماماً در getter زندگی می‌کند و setter هرچه فراخواننده بخواهد ادامه می‌دهد:

// ظاهر rollover را عوض کن، بعد دوباره پاکش کن
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// A.HasAppearanceRollover برابر True است و متن به‌صورت 'q Q' round-trip می‌شود
A.HasAppearanceRollover := True;   // نیت را صریحاً دوباره اعلام کن
A.AppearanceRollover := '';        // از روی عمد یک استریم خالی بنویس
Pdf.Annotation[0] := A;

A := Pdf.Annotation[0];
// با HasAppearanceRollover = False و رشتهٔ خالی برمی‌گردد:
// اینجا استریم خالی و استریم غایب قابل تمایز نیستند

به یاد داشته باشید که /R یا /D خالی‌شدهٔ صریح هم زیر قواعد PDF/A که بالاتر نقل شد همچنان یک کلید اضافه حساب می‌شود. اگر هدف یک پروفایل آرشیوی است، نوشتن /N غیرخالی و دست نزدن به دو مد دیگر تنها شکلی است که اعتبارسنجی را پاس می‌کند. هر گردش کاری که حاشیه‌نویسی‌ها را بین اسناد جابه‌جا می‌کند، مثل خروجی و ورودی XFDF با PDFium Component، باید همین قاعده را رعایت کند: مدهایی را کپی کنید که مبدأ واقعاً داشته و بقیهٔ سنچینل‌ها را False بگذارید

الگوی خواندن-تغییر-نوشتنی که PDF/A سالم می‌ماند

به v3.121.1 یا بعدتر ارتقا بدهید، سنچینل‌های ظاهر را دقیقاً همان‌طور که getter برگردانده رها کنید، و فایل ذخیره‌شده را پیش از ارسال اعتبارسنجی کنید. چون استریم خالیِ مانده‌از-قبل غایب خوانده می‌شود، مرحلهٔ راستی‌آزمایی باید به سند سریال‌شده نگاه کند نه به رکورد، و به‌اندازه‌ای ارزان است که بعد از هر بسته اجرایش کنید:

uses
  PDFium, FPdfPdfa;  // FPdfPdfa تایپ TPdfAValidationIssue را اعلام می‌کند

function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
  Report: TPdfAValidationResult;
begin
  // سند بارگذاری‌شدهٔ فعلی در Pdf را اعتبارسنجی می‌کند، از جمله ویرایش‌هایی
  // که از زمان باز شدن از طریق Pdf.Annotation[] انجام شده
  Report := Pdf.ValidatePdfA;
  Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;

همین نظم برای هر پنلی که برای بازبینی رنگ صفحه‌ها را عوض می‌کند یا حاشیه‌نویسی می‌گذارد صدق می‌کند؛ گردش کاری که در ساخت گردش کار بازبینی حاشیه‌نویسی در دلفی با PDFium Component پوشش داده شده: رکورد یک عکس فوری از چیزی است که موتور می‌توانست بخواند، و سنچینلی که خودتان ست نکرده‌اید باید بدون تغییر برگردد. API کامل حاشیه‌نویسی، preflight مربوط به PDF/A و موتور بومی PDFium در PDFium Component برای دلفی، C++Builder و Lazarus با هم عرضه می‌شوند