مقاله فنی

Flatten کردن annotationهای PDF بدون /AP در Delphi

HotPDF v2.743.0 annotationهای PDF را که هیچ appearance stream از نوع /AP ندارند flatten می‌کند، به‌جای اینکه بی‌سروصدا آن‌ها را skip کند. FlattenLoadedAnnotations حالا widget بدون appearance را از مسیر EnsureLoadedFieldAppearanceStream عبور می‌دهد و برای markup بدون appearance، بر اساس propertyهای خود annotation، یک Form XObject می‌سازد؛ بنابراین valueهایی که در form دارای /NeedAppearances تایپ شده‌اند در page content باقی می‌مانند و هنگام flatten ناپدید نمی‌شوند. failureی که این تغییر را ضروری کرد شبیه no-op است. customer یک application form پرشده می‌فرستد که از browser به PDF print شده است. آن را در HotPDF load می‌کنید، FlattenLoadedAnnotations را call می‌کنید، مقدار 0 می‌گیرید، save می‌کنید و documentی تحویل می‌دهید که جای name و amount متقاضی، boxهای خالی دارد. هیچ exceptionی ایجاد نشد و چیزی log نشد. valueها تمام مدت داخل file بودند و در entry مربوط به /V هر field قرار داشتند، اما flatten pass چون هیچ‌کدام از widgetها appearance stream نداشتند، مستقیم از روی آن‌ها رد شد و چیزی را bake نکرد

چرا flatten کردن form چاپ‌شده از browser، valueهای تایپ‌شده را از دست می‌دهد؟

چون form دارای /NeedAppearances value را ذخیره می‌کند اما تصویر value را ذخیره نمی‌کند. ISO 32000-1 12.7.2 اجازه می‌دهد یک interactive form در dictionary مربوط به AcroForm مقدار /NeedAppearances true را تنظیم کند؛ این مقدار به viewer می‌گوید surface بصری هر field را هنگام open و از روی /V، /DA و /Q بسازد. producerهایی که form را ارزان تولید می‌کنند، از مسیر print مرورگر و filler سمت server گرفته تا بعضی front endهای scan، از این امکان استفاده می‌کنند و اصلاً /AP نمی‌نویسند. flattening طبق appearance algorithm در ISO 32000-1 12.5.5 یک کار transcription است: normal appearance stream مربوط به annotation را بگیرید، /BBox آن را روی /Rect map کنید، با operatorی از نوع Do آن را از page content stream فراخوانی کنید و سپس annotation را حذف کنید. بدون source stream چیزی برای transcription وجود ندارد. implementation اصلی HotPDF از v2.386.0 این وضعیت را «skip» در نظر می‌گرفت؛ تصمیمی که به‌تنهایی قابل دفاع و در مجموع فاجعه‌بار است، چون documentهایی که بیشترین نیاز به flatten دارند کمترین احتمال را برای داشتن appearance دارند. همین خلأ markup را هم می‌بلعید: یک Highlight از review tool، یک Square از redline pass و یک Ink signature، هر زمان producer به viewer برای رسم آن‌ها تکیه می‌کرد

HotPDF synthesis را کجا به FlattenLoadedAnnotations وصل می‌کند؟

نقطه hook عمداً دیر انتخاب شده است: بعد از شکست appearance lookup، نه پیش از آن. FlattenLoadedAnnotations همچنان ابتدا برای normal appearance از GetLoadedAnnotationAppearanceStream سؤال می‌کند و annotationی که از قبل آن را دارد دقیقاً همان‌طور که در v2.386.0 بود bake می‌شود. فقط نتیجه nil، آن هم برای annotationی با /Rect غیر-degenerate و بدون hidden flag، وارد مسیر synthesis می‌شود. ترتیب مهم است: document authorی که برای نوشتن /AP زحمت کشیده، byteهای خودش را پس می‌گیرد، نه بازسازی HotPDF از آن‌ها را

NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
  if Subtype= 'Widget' then
  begin
    FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
    if FieldIdx>= 0 then
      EnsureLoadedFieldAppearanceStream(FieldIdx);
    // دوباره سؤال کن: generator، /AP /N را به widget وصل کرده است
    NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
  end
  else
    NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;

از اینجا دو خانواده annotation از هم جدا می‌شوند. widget از طریق GetLoadedFormFieldIndexForAnnotation به field مالک خود resolve می‌شود و به EnsureLoadedFieldAppearanceStream تحویل داده می‌شود؛ همان field appearance generator که از v2.328.0 در این Delphi PDF library وجود داشته است. استفاده دوباره از آن به‌جای نوشتن renderer دوم برای field، تمام هدف است؛ این generator از قبل Type0 font، line wrapping، quadding، stateهای /AS برای checkbox و radio و rotation مربوط به /MK را پوشش می‌دهد و همان machinery پشت افزودن AcroForm field به PDF از قبل load‌شده است. برای caller هیچ چیز تغییر نمی‌کند: همان call یک‌خطی flatten حالا روی documentهایی که قبلاً صفر برمی‌گرداندند count غیرصفر می‌دهد

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: widgetها و markupهای بدون AP synthesize و سپس bake می‌شوند
  Flattened:= Doc.FlattenLoadedAnnotations;          // همه pageها و همه subtypeها
  // Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
  if Flattened= 0 then
    raise Exception.Create('nothing was flattened');
  Doc.SaveLoadedDocument('flattened.pdf');
finally
  Doc.Free;
end;

چرا QuadPoints و InkList در جای اشتباه قرار می‌گیرند؟

چون آن coordinateها در page user space هستند، در حالی که appearance stream ساخته‌شده در فضای /BBox خودش draw می‌کند و این دو origin یک نقطه نیستند. ISO 32000-1 Table 176، /QuadPoints را برای text markup annotationها در default user space تعریف می‌کند و Table 174 همین کار را برای endpointهای /L مربوط به line annotation انجام می‌دهد؛ /InkList نیز از همین convention پیروی می‌کند. HotPDF برای form ساخته‌شده /BBoxی برابر [0 0 W H] در نظر می‌گیرد که origin آن در lower-left مربوط به /Rect است. بنابراین هر pointی که از /QuadPoints، /L یا /InkList بیرون کشیده می‌شود، پیش از نوشته شدن در content stream باید با negated lower-left مربوط به /Rect translate شود. این را اشتباه انجام دهید و highlight روی خطی که 700 point بالاتر از page قرار دارد، 700 point بالاتر از box خودش draw می‌شود؛ در عمل یعنی هیچ‌جا دیده نمی‌شود. اصلاح، یک subtraction به‌ازای هر coordinate است و با cmای که bake بعداً emit می‌کند ترکیب می‌شود؛ آن matrix، /BBox را دوباره روی /Rect map می‌کند، پس دو مرحله در نهایت geometry مطلق صحیح می‌دهند

// endpointهای /L در page user space هستند (ISO 32000-1 Table 174)؛ origin مربوط به
// BBox در lower-left /Rect قرار دارد، پس با -(RL, RB) جابه‌جا می‌شود
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
  StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
  _FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
  _FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;

appearance ساخته‌شده برای markup واقعاً چه چیزی draw می‌کند؟

markup synthesizer فقط dictionary مربوط به annotation را می‌خواند و هیچ چیز دیگری را دخالت نمی‌دهد؛ همین خروجی را قابل پیش‌بینی نگه می‌دارد و درباره چیزهایی که نمی‌تواند بداند صادق می‌ماند. FreeText و Stamp، /Contents را با font و color استخراج‌شده از /DA و align‌شده با /Q، با padding برابر 2 pt، draw می‌کنند. Square و Circle، outline مربوط به re یا Bezier چهارقوسی را که با /C stroke شده draw می‌کنند و اگر /IC وجود داشته باشد با آن fill می‌کنند؛ width از /BS /W می‌آید. Line و Ink رأس‌های خود را stroke می‌کنند. Highlight هر quad را fill می‌کند، در حالی که Underline، StrikeOut و Squiggly یک rule را در پایین quad، وسط quad یا به‌شکل zigzag یک‌نقطه‌ای stroke می‌کنند. مقدار /CA کمتر از 1، یک ExtGState با entry از نوع ca می‌سازد که در ابتدای stream با /GSA gs reference می‌شود

text encoding از entry مربوط به /DR /Font در AcroForm انتخاب می‌شود که /DA آن را نام برده است. اگر /Subtype آن font از نوع Type0 باشد، HotPDF string را به‌صورت UTF-16BE hex literal با byte order mark از نوع FEFF می‌نویسد؛ در غیر این صورت literal string escape‌شده می‌نویسد، به‌طوری که parenthesis و backslash escape می‌شوند و byteهای بالاتر از 126 به شکل octal نوشته می‌شوند. operator مربوط به Tf از /DA قبل از BT emit می‌شود؛ این کار مجاز است، چون text state از مرز text object باقی می‌ماند و لازم نیست string مربوط به /DA را از هم باز کنیم. دو محدودیت باید روشن گفته شوند. line width برای wrapping و quadding با heuristic نیم em / یک em تخمین زده می‌شود نه metric واقعی font، پس alignment روی font متناسب نزدیک اما دقیق نیست. همچنین subtypeی که چیزی برای synthesize شدن ندارد، مانند Popup، Link یا Stampی که تنها محتوایش نام یک icon است، nil برمی‌گرداند و دقیقاً مثل قبل دست‌نخورده می‌ماند

swap موقت /Annots که cleanup کمک‌کننده را تنبیه می‌کند

FlattenOneWidget، مسیر per-widget مورد استفاده FlattenLoadedFormFields، یک دام aliasing است که هر تغییری در shared flatten loop باید آن را رعایت کند. این method موقتاً value مربوط به /Annots page را با یک array تک‌عضوی جایگزین می‌کند تا generic flatten pass فقط روی یک widget کار کند، سپس pointer اصلی PHPDFDictionaryItem را در block finally restore می‌کند. restore داخل dictionary slotی می‌نویسد که پیش از call آن را capture کرده است

DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
  Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
  DictItem^.Value:= Item;   // اگر loop داخلی این item را آزاد کرده باشد dangling است
  TemporaryAnnots.Free;
end;

یک tidy-up معقول‌به‌نظررس داخل shared inner loop اضافه کنید، مثلاً DeleteValue('Annots') وقتی array خالی می‌شود تا page ذخیره‌شده array خالی و بی‌مصرفی را حمل نکند؛ همین call، dictionary itemای را آزاد می‌کند که DictItem به آن اشاره دارد. سپس finally از طریق pointer dangling می‌نویسد و process با پیام «Invalid pointer operation» از بین می‌رود. دو test موجود فوراً آن را گرفتند و تنها به همین دلیل این موضوع یک footnote است نه support ticket. قاعده عمومی این است: پیش از افزودن cleanup به shared loop، callerها را از نظر قرارداد alias یا swap بررسی کنید. یک array خالی و باقی‌مانده /Annots فقط یک ایراد ظاهری است و ارزش معاوضه با guarantee مربوط به lifetime pointer را ندارد

چه چیزی bake نمی‌شود و هزینه flattening چیست؟

annotationهای hidden عمداً کنار گذاشته می‌شوند. annotationی که integer مربوط به /F آن bit position 2 را set کرده، طبق ISO 32000-1 12.5.3 hidden است و وقتی /AP هم ندارد، وسوسه واقعی این است که یکی synthesize شود و مانند بقیه bake شود. این کار bug با پیامد امنیتی است: bake کردن یک note نامرئی در page content آن را برای هر کسی که file را باز کند visible می‌کند. HotPDF آن annotationها را دقیقاً همان‌جا نگه می‌دارد و در return value count نمی‌کند. درباره بهای annotationهایی که واقعاً bake می‌شوند نیز با userها شفاف باشید. flattening برگشت‌ناپذیر است؛ annotation از array مربوط به /Annots page حذف می‌شود و visual آن حالا page content است، بنابراین دیگر امکان edit کردن field value، comment thread، تغییر state مربوط به /AS یا بازیابی structured data، جز از file اصلی، وجود ندارد. یک copy را flatten کنید و original را نگه دارید و فقط زمانی سراغ flatten بروید که document دیگر form نیست و به record تبدیل شده است. اگر مشکل شما به‌جای appearance-less بودن، XFA-backed است، مسیر جداگانه flatten کردن XFA به AcroForm در HotPDF نقطه شروع درست است و اگر هنوز در حال ساخت form هستید، یادداشت‌های وصل کردن action و validation برای fieldهای AcroForm سمت write را پوشش می‌دهند

یک نکته درباره verification وجود دارد که در غیر این صورت یک بعدازظهر از شما می‌گیرد. ExtractLoadedPageGlyphs داخل Form XObjectها descend نمی‌کند و appearance bake‌شده در یکی از آن‌ها قرار دارد؛ page content stream فقط sequenceی مانند q ... cm /FlatAn<n> Do Q را نگه می‌دارد. بنابراین glyph extraction روی page flatten‌شده چیزی گزارش نمی‌کند و این رفتار درست است، نه اینکه bake گم شده باشد. یا در سطح byte verify کنید و دنبال resource name یعنی /FlatAn، invocation از نوع Do و /Subtype /Form بگردید، یا از rendering pipeline استفاده کنید که XObjectها را expand می‌کند

flatten کردن annotation تا وقتی با documentهایی که مردم واقعاً تولید می‌کنند روبه‌رو نشوید، شبیه سه خط transcription است. اگر با filled form، review markup یا archival output در Delphi یا C++Builder کار می‌کنید، پیش از اینکه appearance generator خودتان را روی آن بنا کنید، ارزش دارد ببینید HotPDF Delphi PDF component سمت loaded-document مربوط به AcroForm و annotation را چگونه مدیریت می‌کند