Технічна стаття

Flatten PDF annotations без /AP stream у Delphi

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

Чому flattening browser-надрукованої form втрачає введені values?

Тому що /NeedAppearances form зберігає value, але не picture цього 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: взяти normal appearance stream annotation, відобразити його /BBox на /Rect, викликати його з page content stream оператором Do, а потім видалити annotation. Коли source stream немає, транскрибувати нічого. Початкова реалізація HotPDF із v2.386.0 трактувала це як "skip", що окремо виглядає захисно, а в aggregate стає руйнівним: documents, яким найчастіше потрібен flatten, найімовірніше не мають appearances. Та сама прогалина поглинала markup — Highlight із review tool, Square із redline pass, Ink signature — щоразу, коли producer покладався на viewer, який мав це намалювати

Де HotPDF підключає synthesis у FlattenLoadedAnnotations

Hook point навмисно пізній: після невдалого appearance lookup, а не перед ним. FlattenLoadedAnnotations як і раніше спочатку запитує normal appearance через GetLoadedAnnotationAppearanceStream, і annotation, яка вже його має, bake-иться точно так, як у v2.386.0. Лише nil result для annotation із non-degenerate /Rect і без hidden flag входить у synthesis path. Цей порядок важливий: author document, який витратив час і записав /AP, отримує власні bytes, а не reconstruction від 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 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. Усе інше йде до markup synthesizer. Для caller нічого не змінюється: той самий one-line flatten call тепер повертає non-zero count для documents, які раніше повертали zero

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: AP-less widgets і markup синтезуються, потім 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. Тому кожну point із /QuadPoints, /L або /InkList перед записом у content stream потрібно зсунути на negated /Rect lower-left. Якщо це зробити неправильно, highlight на line, що лежить на 700 points вище page, намалюється ще на 700 points вище власного box і практично зникне. Correction — це одна subtraction для кожної coordinate, і він поєднується з cm, який bake виводить після цього: matrix повертає /BBox на /Rect, тож два кроки скасовуються до correct absolute geometry

// /L endpoints — page user space (ISO 32000-1 Table 174); origin form
// BBox стоїть у /Rect lower-left, тому виконуємо shift на -(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, витягнутими з /DA, aligned через /Q, із padding 2 pt. Square та Circle малюють re або outline із чотирьох arc Bezier, stroked у /C, filled через /IC, якщо він є, із width з /BS /W. Line та Ink stroke-ять свої vertices. Highlight fill-ить кожну quad, а Underline, StrikeOut та Squiggly stroke-ять rule на quad bottom, quad midpoint або як one-point zigzag. /CA нижче 1 стає ExtGState із entry ca, на який посилаються як /GSA gs на початку stream

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

Тимчасова /Annots swap, яка карає за корисне cleanup

FlattenOneWidget, per-widget path, який використовує FlattenLoadedFormFields, є aliasing trap, яку має враховувати будь-яка зміна у shared flatten loop. Він тимчасово замінює page /Annots value на array з одним element, щоб generic flatten pass працював з одним widget, а потім у блоці finally відновлює original PHPDFDictionaryItem pointer. Restore записує назад у dictionary slot, pointer на який було захоплено до 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;

Додайте в shared inner loop розумний на вигляд tidy-up — DeleteValue('Annots'), коли array спорожніє, щоб saved page не носила vestigial empty array, — і цей call звільнить саме той dictionary item, на який вказує DictItem. Потім finally запише через dangling pointer, і process завершиться з "Invalid pointer operation". Два existing tests одразу це виявили, і лише тому це footnote, а не support ticket. Правило узагальнюється: перед додаванням cleanup до shared loop перевірте callers на alias або swap contracts. Залишений empty /Annots array — косметична вада, і не варта обміну pointer lifetime guarantee на crash

Що залишається unbaked і якою є ціна flattening

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

Одне verification caveat, бо інакше воно забере у вас afternoon. ExtractLoadedPageGlyphs не спускається в Form XObjects, а baked appearance живе всередині одного — page content stream містить лише послідовність q ... cm /FlatAn<n> Do Q. Тому glyph extraction на flattened page нічого не повертає, і це правильна поведінка, а не втрачений bake. Перевіряйте або на byte level, шукаючи resource name /FlatAn, invocation Do і /Subtype /Form, або через rendering pipeline, який розгортає XObjects

Annotation flattening виглядає як три lines transcription, доки не зустрінеш documents, які люди справді генерують. Якщо ви працюєте з filled forms, review markup або archival output у Delphi чи C++Builder, варто прочитати, як HotPDF Delphi PDF component працює з loaded-document side AcroForms та annotations, перш ніж будувати власний appearance generator поверх нього