HotPDF v2.743.0 aplatizează adnotările PDF care nu au un appearance stream /AP în loc să le ignore în tăcere. FlattenLoadedAnnotations trimite acum un widget fără appearance prin EnsureLoadedFieldAppearanceStream și construiește un Form XObject pentru markup fără appearance din proprietățile proprii ale adnotării, astfel încât valorile introduse într-un formular /NeedAppearances să ajungă în page content în loc să dispară la flatten. Failure-ul care a forțat schimbarea arată ca un no-op. Un client trimite un formular de cerere completat, tipărit în PDF dintr-un browser. Îl încarci în HotPDF, apelezi FlattenLoadedAnnotations, primești 0, salvezi și livrezi un document cu casete goale acolo unde solicitantul a introdus un nume și o sumă. Nimic nu a ridicat o excepție, nimic nu a fost logat. Valorile erau în fișier tot timpul, în entry-ul /V al fiecărui field, iar pass-ul de flatten a trecut direct pe lângă ele deoarece niciun widget nu purta un appearance stream de copt
De ce pierde flatten-ul valorile introduse într-un formular tipărit din browser?
Deoarece un formular /NeedAppearances stochează valoarea fără să stocheze o imagine a ei. ISO 32000-1 12.7.2 permite unui formular interactiv să seteze /NeedAppearances true în dictionary-ul AcroForm, spunând viewer-ului să construiască la deschidere suprafața vizuală a fiecărui field din /V, /DA și /Q. Producătorii care generează formulare ieftin — rute de print din browser, fillers server-side, unele front-end-uri de scanare — acceptă această posibilitate și nu scriu deloc /AP. Flattening-ul, așa cum este definit de appearance algorithm din ISO 32000-1 12.5.5, este o operație de transcriere: iei normal appearance stream-ul adnotării, îi mapezi /BBox pe /Rect, îl invoci din page content stream cu un operator Do, apoi ștergi adnotarea. Fără stream sursă nu există nimic de transcris. Implementarea originală HotPDF, din v2.386.0, trata cazul ca „skip”, justificabil izolat și dezastruos în agregat: documentele care au cel mai probabil nevoie de flatten sunt cele mai puțin probabil să poarte appearances. Aceeași gaură înghițea markup — un Highlight dintr-un tool de review, un Square dintr-un pass de redline, o semnătură Ink — ori de câte ori producătorul se baza pe viewer să îl deseneze
Unde introduce HotPDF synthesis-ul în FlattenLoadedAnnotations
Punctul de hook este intenționat târziu: după ce lookup-ul appearance eșuează, nu înainte. FlattenLoadedAnnotations cere în continuare mai întâi appearance-ul normal prin GetLoadedAnnotationAppearanceStream, iar o adnotare care are deja unul este baked exact ca în v2.386.0. Doar un rezultat nil, pe o adnotare cu un /Rect non-degenerate și fără hidden flag, intră în ruta de synthesis. Ordinea contează: un autor de document care și-a dat osteneala să scrie un /AP primește bytes-ii proprii înapoi, nu o reconstrucție HotPDF a lor
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);
// întreabă din nou: generatorul a atașat /AP /N widget-ului
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
end
else
NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;
De aici cele două familii de adnotări se separă. Un widget este rezolvat înapoi la field-ul deținător prin GetLoadedFormFieldIndexForAnnotation și trimis lui EnsureLoadedFieldAppearanceStream, generatorul de field appearances care există în această bibliotecă PDF Delphi din v2.328.0. Refolosirea lui în locul unui al doilea field renderer este întregul scop — acoperă deja fonturi Type0, line wrapping, quadding, stările /AS de checkbox și radio și rotația /MK, aceeași machinery din spatele adăugării câmpurilor AcroForm într-un PDF deja încărcat. Tot restul merge la markup synthesizer. Pentru caller nimic nu se schimbă: același apel one-line de flatten returnează acum un count non-zero pe documentele care înainte returnau zero
Doc:= THotPDF.Create(nil);
try
Doc.LoadFromFile('needappearances-form.pdf');
// v2.743.0: widget-urile și markup-ul fără AP sunt sintetizate, apoi baked
Flattened:= Doc.FlattenLoadedAnnotations; // toate paginile, toate subtype-urile
// Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
if Flattened= 0 then
raise Exception.Create('nothing was flattened');
Doc.SaveLoadedDocument('flattened.pdf');
finally
Doc.Free;
end;
De ce ajung QuadPoints și InkList în locul greșit?
Deoarece acele coordonate sunt în page user space, în timp ce appearance stream-ul sintetizat desenează în propriul spațiu /BBox, iar cele două origini nu sunt același punct. ISO 32000-1 Table 176 definește /QuadPoints pentru adnotări text markup în default user space, iar Table 174 face același lucru pentru endpoint-urile /L ale adnotării line; /InkList urmează aceeași convenție. HotPDF dă formei sintetizate un /BBox de [0 0 W H], cu originea în colțul lower-left al lui /Rect. Prin urmare fiecare punct scos din /QuadPoints, /L sau /InkList trebuie translatat prin lower-left-ul negat al lui /Rect înainte să fie scris în content stream. Greșește asta și un highlight pe o linie aflată la 700 de points mai sus pe pagină se desenează cu 700 de points deasupra propriei casete, ceea ce practic înseamnă că nu se desenează nicăieri. Corecția este o scădere per coordonată și se compune cu cm-ul emis de bake — acea matrice mapează /BBox înapoi pe /Rect, astfel încât cei doi pași se anulează într-o geometrie absolută corectă
// Endpoint-urile /L sunt în page user space (ISO 32000-1 Table 174); originea
// BBox-ului formei este în lower-left-ul /Rect, deci deplasează cu -(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;
Ce desenează efectiv appearance-ul markup sintetizat
Markup synthesizer citește doar annotation dictionary, ceea ce menține output-ul previzibil și onest cu privire la ce nu poate ști. FreeText și Stamp desenează /Contents folosind fontul și culoarea extrase din /DA, aliniate după /Q, cu padding de 2 pt. Square și Circle desenează un contur re sau un Bezier cu patru arce, stroked în /C, filled cu /IC când există, la lățimea din /BS /W. Line și Ink stroke-uiesc vârfurile. Highlight umple fiecare quad, în timp ce Underline, StrikeOut și Squiggly stroke-uiesc o linie la baza quad-ului, la mijlocul quad-ului sau ca zigzag de un punct. Un /CA sub 1 devine un ExtGState cu entry ca, referit ca /GSA gs la începutul stream-ului
Encoding-ul textului este decis din entry-ul AcroForm /DR /Font numit de /DA. Dacă subtype-ul fontului este /Subtype Type0, HotPDF scrie string-ul ca literal hex UTF-16BE cu byte order mark FEFF; altfel scrie un literal string escaped, cu parantezele și backslash-urile escaped și bytes peste 126 scriși în octal. Operatorul Tf din /DA este emis înainte de BT, lucru legal deoarece text state persistă peste limita text object-ului și evită desfacerea stringului /DA. Două limite trebuie enunțate clar. Line width pentru wrapping și quadding este estimată cu un heuristic half-em / full-em în loc de font metrics reale, așa că alinierea pe un font proporțional este apropiată, dar nu exactă. Iar un subtype fără nimic sintetizabil — Popup, Link, un Stamp al cărui singur content este un icon name — produce nil și rămâne neatins, exact ca înainte
Swap-ul temporar /Annots care pedepsește un cleanup bine intenționat
FlattenOneWidget, ruta per-widget folosită de FlattenLoadedFormFields, este o capcană de aliasing pe care orice schimbare în shared flatten loop trebuie să o respecte. Înlocuiește temporar valoarea /Annots a paginii cu un array de un singur element, astfel încât pass-ul generic de flatten să opereze pe un singur widget, apoi restaurează pointer-ul original PHPDFDictionaryItem într-un bloc finally. Restore-ul scrie înapoi într-un dictionary slot capturat înainte de apel
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 dacă inner loop a eliberat acest item
TemporaryAnnots.Free;
end;
Adaugă un cleanup care pare rezonabil în inner loop-ul partajat — un DeleteValue('Annots') după ce array-ul se golește, ca pagina salvată să nu poarte un array gol vestigial — iar acel apel eliberează chiar dictionary item-ul către care indică DictItem. finally scrie apoi printr-un pointer dangling și procesul moare cu „Invalid pointer operation”. Două teste existente l-au prins imediat, singurul motiv pentru care acesta este o notă de subsol, nu un support ticket. Regula se generalizează: înainte să adaugi cleanup într-un loop partajat, verifică caller-ii pentru contracte de alias sau swap. Un array /Annots gol este un defect cosmetic și nu merită schimbat pentru o garanție de lifetime a pointer-ului
Ce rămâne nebaked și cât costă flattening-ul
Adnotările hidden sunt excluse intenționat. O adnotare al cărei integer /F are setat bitul de poziție 2 este hidden conform ISO 32000-1 12.5.3, iar când nu are și /AP există tentația reală să îi sintetizezi unul și să îl bake-uiești ca pe restul. Asta ar fi un bug cu consecințe de securitate: coacerea unei note invizibile în page content o face vizibilă tuturor celor care deschid fișierul. HotPDF lasă acele adnotări exact acolo unde sunt și nu le numără în valoarea de retur. Fii la fel de clar cu utilizatorii despre prețul celor care sunt baked. Flattening-ul este ireversibil — adnotarea este ștearsă din array-ul /Annots al paginii, iar vizualul ei este acum page content, deci nu mai poți edita valoarea field-ului, nu mai ai comment thread, nu mai schimbi starea /AS și nu poți recupera datele structurate în afară de fișierul original. Fă flatten pe o copie, păstrează originalul și folosește-l doar când documentul încetează să mai fie formular și devine record. Dacă problema ta este bazată pe XFA, nu appearance-less, ruta separată de flattening XFA în AcroForm din HotPDF este cea de la care trebuie să începi, iar dacă încă construiești formularul, notițele despre conectarea acțiunilor și validării field-urilor AcroForm acoperă partea de write
O limitare de verificare merită menționată, altfel îți poate costa o după-amiază. ExtractLoadedPageGlyphs nu coboară în Form XObjects, iar un appearance baked trăiește în unul — page content stream conține doar o secvență q ... cm /FlatAn<n> Do Q. Glyph extraction pe o pagină flattened nu raportează deci nimic, iar acesta este comportamentul corect, nu un bake pierdut. Verifică fie la nivel de bytes, căutând resource name-ul /FlatAn, invocarea Do și /Subtype /Form, fie prin pipeline-ul de rendering, care extinde XObjects
Annotation flattening arată ca trei linii de transcriere până când întâlnești documentele pe care oamenii chiar le generează. Dacă lucrezi cu formulare completate, review markup sau output de arhivare în Delphi sau C++Builder, merită să citești cum tratează componenta PDF HotPDF pentru Delphi partea de loaded-document a AcroForms și adnotărilor înainte să îți construiești propriul appearance generator peste ea