Техническа статия

Flatten на PDF annotations без /AP stream в Delphi

HotPDF v2.743.0 flatten-ва PDF annotations, които нямат /AP appearance stream, вместо тихо да ги пропуска. FlattenLoadedAnnotations вече насочва widget без appearance през EnsureLoadedFieldAppearanceStream и изгражда Form XObject за markup без appearance от собствените properties на annotation-а, така че values, въведени в /NeedAppearances form, да оцелеят в page content вместо да изчезнат при flatten time. Failure-ът, който наложи тази промяна, изглежда като no-op. Customer изпраща filled application form, отпечатан до PDF от browser. Зареждате го в HotPDF, извиквате FlattenLoadedAnnotations, получавате 0, save-вате и доставяте document с празни boxes там, където applicant-ът е въвел име и сума. Нищо не е вдигнало exception, нищо не е log-нато. Values са били във file-а през цялото време, в /V entry-то на всяко field, а flatten pass-ът е минал право покрай тях, защото никой от тези widgets не е носил appearance stream за baking

Защо flatten на browser-printed form губи въведените values?

Защото /NeedAppearances form съхранява value, без да съхранява картина на value-то. ISO 32000-1 12.7.2 позволява interactive form да зададе /NeedAppearances true в AcroForm dictionary, което казва на viewer-а да изгради visual surface-а на всяко field при open от /V, /DA и /Q. Producers, които генерират forms евтино — browser print paths, server-side fillers, някои scanning front ends — приемат тази възможност и не записват /AP изобщо. Flattening-ът, определен от appearance algorithm-а в ISO 32000-1 12.5.5, е transcription job: вземете annotation normal appearance stream-а, map-нете неговия /BBox върху /Rect, извикайте го от page content stream-а с Do operator и после изтрийте annotation-а. Без source stream няма какво да се transcribe-не. Оригиналната HotPDF implementation от v2.386.0 третираше това като „skip“, което е защитимо изолирано и катастрофално в aggregate: document-ите, които най-вероятно се нуждаят от flatten, са тези, които най-малко вероятно носят appearances. Същата празнина поглъщаше markup — Highlight от review tool, Square от redline pass, Ink signature — когато producer-ът разчиташе viewer-ът да го нарисува

Къде HotPDF включва synthesis в FlattenLoadedAnnotations

Hook point-ът е умишлено късен: след като appearance lookup-ът fail-не, не преди него. FlattenLoadedAnnotations продължава първо да пита GetLoadedAnnotationAppearanceStream за normal appearance, а annotation, която вече има такъв, се bake-ва точно както във v2.386.0. Само nil result върху annotation с non-degenerate /Rect и без hidden flag влиза в synthesis path-а. Този ред има значение: document author, който е положил усилие да запише /AP, получава обратно собствените си bytes, а не HotPDF reconstruction на тях

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 families се разделят. Widget се resolve-ва обратно до owning field чрез GetLoadedFormFieldIndexForAnnotation и се подава на EnsureLoadedFieldAppearanceStream, field appearance generator-а, който е в тази Delphi PDF library от v2.328.0. Повторното му използване, вместо да се пише втори field renderer, е цялата идея — той вече покрива Type0 fonts, line wrapping, quadding, checkbox и radio /AS states и /MK rotation, същия machinery зад добавяне на AcroForm fields към вече loaded PDF. За caller-а нищо не се променя: същият едноредов flatten call вече връща non-zero count върху document-и, които преди са връщали zero

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: AP-less widgets и markup се synthesize-ват, после се bake-ват
  Flattened:= Doc.FlattenLoadedAnnotations;          // всички pages, всички subtypes
  // 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 попадат на грешното място?

Защото тези coordinates са в page user space, докато synthesized appearance stream рисува в собственото си /BBox space, а двете origins не са една и съща точка. ISO 32000-1 Table 176 дефинира /QuadPoints за text markup annotations в default user space, а Table 174 прави същото за /L endpoints на line annotation; /InkList следва същата convention. HotPDF дава на synthesized form /BBox от [0 0 W H], чийто origin стои в lower-left corner на /Rect. Затова всяка точка от /QuadPoints, /L или /InkList трябва да бъде translated с negated /Rect lower-left, преди да бъде записана в content stream. Сгрешете това и highlight върху line на 700 points нагоре по page ще се нарисува 700 points над собствената си box, което на практика означава никъде. Поправката е едно subtraction на coordinate и се съчетава с cm, което bake-ът emit-ва после — тази matrix връща /BBox върху /Rect, така че двете стъпки се cancel-ват до correct absolute geometry

// /L endpoints са в page user space (ISO 32000-1 Table 174); origin-ът на
// form BBox е в /Rect lower-left, затова се измества с -(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;

Какво всъщност рисува synthesized markup appearance?

Markup synthesizer-ът чете annotation dictionary и нищо друго, което прави output-а предвидим и честен за това, което не може да знае. FreeText и Stamp рисуват /Contents с font и color, parse-нати от /DA, подравнени по /Q, с 2 pt padding. Square и Circle рисуват re или четири-arc Bezier outline, stroke-нат в /C, filled с /IC, когато присъства, с width от /BS /W. Line и Ink stroke-ват vertex-ите си. Highlight fill-ва всеки quad, докато Underline, StrikeOut и Squiggly stroke-ват rule при quad bottom-а, quad midpoint-а или като one-point zigzag. /CA под 1 става ExtGState с ca entry, рефериран като /GSA gs в началото на stream-а

Text encoding-ът се решава от AcroForm /DR /Font entry, назован от /DA. Ако /Subtype на този font е Type0, HotPDF записва string-а като UTF-16BE hex literal с FEFF byte order mark; иначе записва escaped literal string, като parentheses и backslashes се escape-ват, а bytes над 126 се записват в octal. Tf operator-ът от /DA се emit-ва преди BT, което е legal, защото text state продължава през boundary-то на text object, и така се избягва разчленяването на /DA string-а. Две ограничения трябва да се кажат ясно. Line width за wrapping и quadding се оценява с half-em / full-em heuristic вместо с реални font metrics, така че alignment върху proportional font е близък, но не точен. А subtype без нищо synthesizable — Popup, Link, Stamp, чието единствено content е icon name — връща nil и остава недокоснат, точно както преди

Временният /Annots swap, който наказва helpful cleanup

FlattenOneWidget, per-widget path-ът, използван от FlattenLoadedFormFields, е aliasing trap, който всяка промяна в shared flatten loop трябва да уважава. Той временно заменя /Annots value-то на page с one-element array, така че generic flatten pass-ът да работи върху един widget, после възстановява оригиналния PHPDFDictionaryItem pointer във finally block. Restore-ът записва обратно в dictionary slot, който е captured преди call-а

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;   // dangling, ако inner loop е освободил този item
  TemporaryAnnots.Free;
end;

Добавете разумен cleanup вътре в shared inner loop — DeleteValue('Annots'), щом array-ят се изпразни, за да не остава в saved page vestigial empty array — и този call освобождава точно dictionary item-а, към който сочи DictItem. Тогава finally записва през dangling pointer и process-ът умира с „Invalid pointer operation“. Два съществуващи test-а го уловиха веднага, което е единствената причина това да е footnote, а не support ticket. Правилото се обобщава: преди да добавите cleanup към shared loop, проверете caller-ите за alias или swap contracts. Остатъчен празен /Annots array е cosmetic wart и не си струва да търгувате pointer lifetime guarantee за него

Какво остава небейкнато и каква е цената на flattening-а

Hidden annotations са изключени нарочно. Annotation, чийто integer /F има set bit на position 2, е hidden според ISO 32000-1 12.5.3 и когато няма /AP, има истинско изкушение да синтезирате един и да го bake-нете като останалите. Това би било bug със security последствия: baking на invisible note в page content го прави видим за всеки, който отвори file-а. HotPDF оставя тези annotations точно там, където са, и не ги брои в return value. Също толкова ясно кажете на users цената на тези, които действително се bake-ват. Flattening е irreversible — annotation-ът се изтрива от page /Annots array и visual-ът му вече е page content, така че няма повече editing на field value, comment thread, /AS state toggling или начин да възстановите structured data освен от original file. Flatten-вайте copy, пазете оригинала и използвайте това само когато document-ът престава да е form и става record. Ако проблемът ви е XFA-backed, а не appearance-less, отделният XFA към AcroForm flattening path в HotPDF е мястото, от което да започнете, а ако още изграждате form-а, бележките за свързване на AcroForm field actions и validation покриват write страната

Една verification caveat, защото иначе ще ви струва един следобед. ExtractLoadedPageGlyphs не слиза във Form XObjects, а baked appearance живее вътре в един — page content stream-ът съдържа само последователност q ... cm /FlatAn<n> Do Q. Следователно glyph extraction върху flattened page докладва нищо и това е correct behavior, а не изгубен bake. Verify-вайте или на byte level, като проверите за resource name /FlatAn, Do invocation и /Subtype /Form, или през rendering pipeline-а, който expand-ва XObjects

Annotation flattening изглежда като три реда transcription, докато не срещнете document-ите, които хората действително генерират. Ако работите с filled forms, review markup или archival output в Delphi или C++Builder, струва си да прочетете как HotPDF Delphi PDF component обработва loaded-document страната на AcroForms и annotations, преди да изградите собствен appearance generator върху него