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