Το HotPDF v2.743.0 κάνει flatten PDF annotations που δεν έχουν /AP appearance stream αντί να τις παρακάμπτει σιωπηρά. Το FlattenLoadedAnnotations περνά πλέον ένα widget χωρίς appearance από το EnsureLoadedFieldAppearanceStream και δημιουργεί Form XObject για markup χωρίς appearance από τις ίδιες τις ιδιότητες της annotation, ώστε οι τιμές που πληκτρολογήθηκαν σε μια φόρμα /NeedAppearances να επιβιώνουν στο page content αντί να εξαφανίζονται κατά το flatten time. Η αποτυχία που επέβαλε αυτή την αλλαγή μοιάζει με no-op. Ένας customer στέλνει filled application form που τυπώθηκε σε PDF από browser. Το φορτώνεις στο HotPDF, καλείς FlattenLoadedAnnotations, παίρνεις πίσω 0, κάνεις save και παραδίδεις document με άδεια boxes εκεί όπου ο applicant έγραψε όνομα και ποσό. Δεν σηκώθηκε τίποτα και δεν γράφτηκε log. Οι τιμές βρίσκονταν σε όλο το διάστημα μέσα στο file, στο /V entry κάθε field, και το flatten pass τις προσπερνούσε επειδή κανένα από αυτά τα widgets δεν είχε appearance stream για baking
Γιατί το flattening μιας browser-printed φόρμας χάνει τις typed values
Επειδή μια φόρμα /NeedAppearances αποθηκεύει την τιμή χωρίς να αποθηκεύει εικόνα της τιμής. Το ISO 32000-1 12.7.2 επιτρέπει σε interactive form να ορίσει /NeedAppearances true στο AcroForm dictionary, λέγοντας στον viewer να κατασκευάσει το visual surface κάθε field κατά το άνοιγμα από τα /V, /DA και /Q. Producers που δημιουργούν φτηνά forms — browser print paths, server-side fillers και ορισμένα scanning front ends — εκμεταλλεύονται αυτή την επιλογή και δεν γράφουν καθόλου /AP. Το flattening, όπως ορίζεται από τον appearance algorithm στο ISO 32000-1 12.5.5, είναι δουλειά μεταγραφής: πάρε το normal appearance stream της annotation, αντιστοίχισέ το /BBox στο /Rect, κάλεσέ το από το page content stream με operator Do και μετά διέγραψε την annotation. Χωρίς source stream δεν υπάρχει τίποτε για μεταγραφή. Η αρχική υλοποίηση του HotPDF, από το v2.386.0, το αντιμετώπιζε ως «skip», κάτι υπερασπίσιμο μεμονωμένα και καταστροφικό συνολικά: τα documents που χρειάζονται περισσότερο flattening είναι εκείνα που είναι λιγότερο πιθανό να έχουν appearances. Το ίδιο κενό κατάπινε και markup — ένα Highlight από review tool, ένα Square από redline pass ή ένα Ink signature — όταν ο producer βασιζόταν στον viewer για να τα σχεδιάσει
Πού συνδέεται το synthesis στο FlattenLoadedAnnotations
Το hook point είναι σκόπιμα αργά: αφού αποτύχει το appearance lookup και όχι πριν. Το FlattenLoadedAnnotations εξακολουθεί πρώτα να ζητά το normal appearance από το GetLoadedAnnotationAppearanceStream, και annotation που έχει ήδη appearance ψήνεται ακριβώς όπως στο v2.386.0. Μόνο αποτέλεσμα nil, σε annotation με non-degenerate /Rect και χωρίς hidden flag, μπαίνει στη synthesis path. Η σειρά έχει σημασία: author που μπήκε στον κόπο να γράψει /AP παίρνει πίσω τα δικά του bytes και όχι ανακατασκευή του 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;
Από εκεί και πέρα οι δύο οικογένειες annotations χωρίζονται. Ένα widget επιλύεται πίσω στο owning field μέσω του GetLoadedFormFieldIndexForAnnotation και παραδίδεται στο EnsureLoadedFieldAppearanceStream, τον field appearance generator που υπάρχει σε αυτή τη Delphi PDF library από το v2.328.0. Η επαναχρησιμοποίησή του αντί για δεύτερο field renderer είναι όλη η ουσία — ήδη καλύπτει Type0 fonts, line wrapping, quadding, checkbox και radio /AS states και rotation /MK, τον ίδιο μηχανισμό πίσω από το adding 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 δεν είναι το ίδιο σημείο. Ο πίνακας 176 του ISO 32000-1 ορίζει το /QuadPoints για text markup annotations στο default user space, και ο πίνακας 174 κάνει το ίδιο για τα /L endpoints του line annotation· το /InkList ακολουθεί την ίδια σύμβαση. Το HotPDF δίνει στο synthesized form /BBox ίσο με [0 0 W H], με origin στην κάτω αριστερή γωνία του /Rect. Άρα κάθε point από /QuadPoints, /L ή /InkList πρέπει να μετατοπιστεί κατά το αρνημένο lower-left του /Rect πριν γραφτεί στο content stream. Αν το κάνεις λάθος, highlight σε line 700 points ψηλότερα στη σελίδα σχεδιάζεται 700 points πάνω από το δικό του box, που στην πράξη σημαίνει ότι σχεδιάζεται πουθενά. Η διόρθωση είναι μία αφαίρεση ανά coordinate και συντίθεται με το cm που εκπέμπει το bake μετά — εκείνο το matrix αντιστοιχίζει το /BBox πίσω στο /Rect, οπότε τα δύο βήματα ακυρώνονται σε σωστή absolute geometry
// Τα /L endpoints είναι σε page user space (ISO 32000-1 Table 174)· το form
// BBox origin βρίσκεται στο lower-left του /Rect, άρα 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 που parsed από το /DA, aligned από το /Q, με padding 2 pt. Τα Square και Circle σχεδιάζουν ένα re ή outline από four-arc Bezier, stroked με /C, filled με /IC όταν υπάρχει και με width από /BS /W. Τα Line και Ink κάνουν stroke τα vertices τους. Το Highlight γεμίζει κάθε quad, ενώ τα Underline, StrikeOut και Squiggly κάνουν stroke έναν κανόνα στο bottom του quad, στο midpoint του quad ή ως zigzag ενός point. Ένα /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, με escape σε parentheses και backslashes και με bytes πάνω από 126 γραμμένα σε octal. Ο operator Tf από το /DA εκπέμπεται πριν από το BT, κάτι νόμιμο επειδή το text state επιβιώνει στο boundary ανάμεσα στα text objects, και γλιτώνει το parsing του /DA string. Δύο όρια πρέπει να ειπωθούν καθαρά. Το line width για wrapping και quadding υπολογίζεται με heuristic half-em / full-em και όχι με πραγματικά font metrics, οπότε το alignment σε proportional font είναι κοντά αλλά όχι ακριβές. Και subtype χωρίς τίποτε synthesizable — Popup, Link ή Stamp του οποίου το μοναδικό content είναι icon name — δίνει nil και μένει ανέγγιχτο, ακριβώς όπως πριν
Το προσωρινό /Annots swap που τιμωρεί ένα βοηθητικό cleanup
Το FlattenOneWidget, η per-widget path που χρησιμοποιεί το FlattenLoadedFormFields, είναι aliasing trap που πρέπει να σέβεται κάθε αλλαγή μέσα στο shared flatten loop. Αντικαθιστά προσωρινά την τιμή /Annots της σελίδας με array ενός στοιχείου ώστε το generic flatten pass να λειτουργήσει σε ένα μόνο widget, και μετά επαναφέρει τον αρχικό pointer PHPDFDictionaryItem σε 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;
Πρόσθεσε ένα λογικό tidy-up στο shared inner loop — ένα DeleteValue('Annots') μόλις αδειάσει το array, ώστε η saved page να μη μεταφέρει vestigial empty array — και αυτό το call ελευθερώνει το ίδιο dictionary item στο οποίο δείχνει το DictItem. Το finally τότε γράφει μέσω dangling pointer και η process πέφτει με «Invalid pointer operation». Δύο υπάρχοντα tests το έπιασαν αμέσως, και αυτός είναι ο μόνος λόγος που παραμένει footnote αντί για support ticket. Ο κανόνας γενικεύεται: πριν προσθέσεις cleanup σε shared loop, έλεγξε τους callers για alias ή swap contracts. Ένα leftover empty /Annots array είναι cosmetic wart και δεν αξίζει να ανταλλάξεις γι’ αυτό εγγύηση pointer lifetime
Τι μένει unbaked και τι κοστίζει το flattening
Οι hidden annotations εξαιρούνται σκόπιμα. Annotation της οποίας το integer /F έχει ενεργό το bit στη θέση 2 είναι hidden κατά το ISO 32000-1 12.5.3, και όταν δεν έχει επίσης /AP υπάρχει πραγματικός πειρασμός να συνθέσεις ένα και να το κάνεις bake όπως τα υπόλοιπα. Αυτό θα ήταν bug με security συνέπειες: το baking ενός αόρατου note μέσα στο page content το κάνει ορατό σε όλους όσοι ανοίγουν το file. Το HotPDF αφήνει αυτές τις annotations ακριβώς εκεί που είναι και δεν τις μετρά στην return value. Το ίδιο καθαρά πρέπει να εξηγήσεις στους users το κόστος όσων τελικά γίνονται bake. Το flattening είναι irreversible — η annotation διαγράφεται από το /Annots array της page και το visual της είναι πλέον page content, άρα δεν υπάρχει πια editing του field value, comment thread, toggling του /AS state ή τρόπος ανάκτησης των structured data εκτός από το original file. Κάνε flatten αντίγραφο, κράτα το original και χρησιμοποίησέ το μόνο όταν το document παύει να είναι form και γίνεται record. Αν το πρόβλημά σου είναι XFA-backed και όχι appearance-less, ξεκίνα από το ξεχωριστό XFA to AcroForm flattening path στο HotPDF, και αν ακόμη χτίζεις τη form, οι σημειώσεις για wiring AcroForm field actions και validation καλύπτουν την πλευρά της εγγραφής
Μία caveat για verification, επειδή διαφορετικά θα σου κοστίσει ένα απόγευμα. Το ExtractLoadedPageGlyphs δεν κατεβαίνει μέσα σε Form XObjects, και ένα baked appearance ζει μέσα σε ένα τέτοιο object — το page content stream κρατά μόνο ακολουθία q ... cm /FlatAn<n> Do Q. Η glyph extraction σε flattened page επομένως δεν αναφέρει τίποτε, και αυτή είναι σωστή συμπεριφορά και όχι χαμένο bake. Κάνε verification είτε σε byte level, ελέγχοντας το resource name /FlatAn, το invocation Do και το /Subtype /Form, είτε μέσω rendering pipeline που κάνει expand τα XObjects
Το annotation flattening μοιάζει με τρεις γραμμές transcription μέχρι να συναντήσεις τα documents που παράγουν πραγματικά οι χρήστες. Αν δουλεύεις με filled forms, review markup ή archival output σε Delphi ή C++Builder, αξίζει να διαβάσεις πώς το HotPDF Delphi PDF component χειρίζεται την πλευρά loaded-document των AcroForms και annotations πριν χτίσεις δικό σου appearance generator πάνω του