HotPDF v2.743.0 flattent PDF-annotaties zonder /AP-appearance-stream in plaats van ze stilletjes over te slaan. FlattenLoadedAnnotations stuurt een widget zonder appearance nu via EnsureLoadedFieldAppearanceStream en bouwt voor markup zonder appearance een Form XObject op basis van de eigen properties van de annotatie, zodat waarden die in een /NeedAppearances-form zijn ingevuld in de page content terechtkomen in plaats van bij het flattenen te verdwijnen. De fout die deze wijziging noodzakelijk maakte ziet eruit als een no-op. Een klant stuurt een ingevuld aanvraagformulier dat vanuit een browser naar PDF is geprint. Je laadt het in HotPDF, roept FlattenLoadedAnnotations aan, krijgt 0 terug, slaat het op en levert een document met lege vakken af waar de aanvrager een naam en bedrag heeft ingevuld. Er werd niets geworpen en niets gelogd. De waarden stonden de hele tijd in het bestand, in de /V-entry van elk veld, en de flatten-pass liep er recht langs omdat geen van die widgets een appearance-stream had om in te bakken
Waarom verdwijnen ingevulde waarden bij het flattenen van een vanuit de browser geprint formulier?
Omdat een /NeedAppearances-form de waarde opslaat zonder een afbeelding van die waarde op te slaan. ISO 32000-1 12.7.2 staat toe dat een interactief form /NeedAppearances true in het AcroForm-dictionary zet, waarmee de viewer wordt verteld het visuele oppervlak van elk veld bij het openen op te bouwen uit /V, /DA en /Q. Producers die goedkoop forms genereren — browser-printpaden, server-side fillers en sommige scanfrontends — grijpen dat aanbod aan en schrijven helemaal geen /AP. Flattening, zoals gedefinieerd door het appearance-algoritme in ISO 32000-1 12.5.5, is een transcriptietaak: neem de normale appearance-stream van de annotatie, map zijn /BBox op zijn /Rect, roep hem vanuit de page content-stream aan met een Do-operator en verwijder daarna de annotatie. Zonder bronstream is er niets om te transcriberen. De oorspronkelijke HotPDF-implementatie uit v2.386.0 behandelde dat als "skip", op zichzelf verdedigbaar en in het geheel rampzalig: de documenten die flattening het hardst nodig hebben, dragen het minst vaak appearances. Hetzelfde gat slokte markup op — een Highlight uit een reviewtool, een Square uit een redlinepass of een Ink-handtekening — telkens wanneer de producer erop vertrouwde dat de viewer die zou tekenen
Waar haakt HotPDF de synthese in FlattenLoadedAnnotations?
Het aangrijpingspunt ligt bewust laat: nadat de appearance-lookup faalt en niet ervoor. FlattenLoadedAnnotations vraagt nog steeds eerst met GetLoadedAnnotationAppearanceStream naar de normale appearance, en een annotatie die er al een heeft wordt precies zoals in v2.386.0 ingebakken. Alleen een nil-resultaat bij een annotatie met een niet-degeneratieve /Rect en zonder hidden-flag gaat het synthesespad in. Die volgorde doet ertoe: een documentauteur die de moeite heeft genomen een /AP te schrijven, krijgt zijn eigen bytes terug en niet een reconstructie daarvan door 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);
// opnieuw vragen: de generator heeft /AP /N aan de widget gekoppeld
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
end
else
NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;
Vanaf daar splitsen de twee annotatiefamilies. Een widget wordt via GetLoadedFormFieldIndexForAnnotation teruggevoerd naar het owning field en doorgegeven aan EnsureLoadedFieldAppearanceStream, de field-appearance-generator die sinds v2.328.0 in deze Delphi PDF-library zit. Hem hergebruiken in plaats van een tweede fieldrenderer te schrijven is precies het punt — hij dekt Type0-fonts, line wrapping, quadding, checkbox- en radio-/AS-states en /MK-rotatie al af, dezelfde machinery achter AcroForm-velden toevoegen aan een al geladen PDF. Al het andere gaat naar de markupsynthesizer. Voor de caller verandert niets: dezelfde flatten-call van één regel retourneert nu een count ongelijk aan nul voor documenten die vroeger nul retourneerden
Doc:= THotPDF.Create(nil);
try
Doc.LoadFromFile('needappearances-form.pdf');
// v2.743.0: AP-loze widgets en markup worden gesynthetiseerd en daarna ingebakken
Flattened:= Doc.FlattenLoadedAnnotations; // alle pagina's, alle 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;
Waarom komen QuadPoints en InkList op de verkeerde plek terecht?
Omdat die coördinaten in de user space van de pagina staan terwijl de gesynthetiseerde appearance-stream in zijn eigen /BBox-ruimte tekent, en de twee oorsprongen niet hetzelfde punt zijn. ISO 32000-1 Table 176 definieert /QuadPoints voor text-markupannotaties in de default user space, en Table 174 doet hetzelfde voor de /L-eindpunten van de line-annotatie; /InkList volgt dezelfde conventie. HotPDF geeft de gesynthetiseerde form een /BBox van [0 0 W H] waarvan de oorsprong op de lower-left corner van /Rect ligt. Elk punt uit /QuadPoints, /L of /InkList moet dus vóór het naar de contentstream wordt geschreven worden verschoven met de negatieve lower-left van /Rect. Doe je dit verkeerd, dan wordt een highlight op een regel die 700 punten hoger op de pagina staat 700 punten boven zijn eigen box getekend, wat in de praktijk betekent dat hij nergens verschijnt. De correctie is één aftrekking per coördinaat en werkt samen met de cm die de bake daarna emit — die matrix mapt de /BBox terug op /Rect, zodat de twee stappen elkaar opheffen tot correcte absolute geometrie
// /L-eindpunten staan in de user space van de pagina (ISO 32000-1 Table 174); de
// oorsprong van de form-BBox staat op de lower-left van /Rect, dus verschuif met -(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;
Wat tekent de gesynthetiseerde markup-appearance werkelijk?
De markupsynthesizer leest alleen het annotation dictionary en niets anders, waardoor de output voorspelbaar blijft en eerlijk blijft over wat hij niet kan weten. FreeText en Stamp tekenen /Contents met het font en de kleur die uit /DA zijn geparsed, uitgelijnd volgens /Q en met 2 pt padding. Square en Circle tekenen een re of een vierboog-Bezieromtrek, gestroked in /C en gevuld met /IC wanneer dat aanwezig is, met de breedte uit /BS /W. Line en Ink stroken hun vertices. Highlight vult elke quad, terwijl Underline, StrikeOut en Squiggly een regel stroken op de onderkant van de quad, op het midden van de quad of als zigzag van één punt. Een /CA onder 1 wordt een ExtGState met een ca-entry, gerefereerd als /GSA gs aan het begin van de stream
Tekstencoding wordt bepaald uit de /DR /Font-entry van het AcroForm waarnaar /DA verwijst. Als het /Subtype van dat font Type0 is, schrijft HotPDF de string als een UTF-16BE-hexliteral met de FEFF-byte-order-mark; anders schrijft het een escaped literal string, waarbij haakjes en backslashes worden ge-escaped en bytes boven 126 in octaal worden geschreven. De Tf-operator uit /DA wordt vóór BT uitgegeven, wat legaal is omdat de text state over de grens van het text object heen blijft bestaan, en zo hoeft de /DA-string niet uit elkaar te worden gehaald. Twee beperkingen moeten duidelijk worden genoemd. De linewidth voor wrapping en quadding wordt geschat met een half-em/full-em-heuristiek in plaats van echte fontmetrics, dus uitlijning op een proportioneel font is dichtbij maar niet exact. En een subtype waarvoor niets te synthetiseren valt — Popup, Link of een Stamp waarvan de enige content een icon name is — levert nil op en blijft onaangeraakt, precies zoals voorheen
De tijdelijke /Annots-swap die een behulpzame cleanup afstraft
FlattenOneWidget, het pad per widget dat door FlattenLoadedFormFields wordt gebruikt, is een aliasingvalkuil waar elke wijziging in de gedeelde flattenloop rekening mee moet houden. Het vervangt de /Annots-waarde van de pagina tijdelijk door een array met één element, zodat de generieke flattenpass op één widget werkt, en zet daarna de oorspronkelijke PHPDFDictionaryItem-pointer in een finally-blok terug. Het herstel schrijft terug naar een dictionaryslot waarvan de pointer vóór de call is opgeslagen
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 wanneer de inner loop dit item vrijgaf
TemporaryAnnots.Free;
end;
Voeg je een redelijk uitziende tidy-up toe in de gedeelde inner loop — DeleteValue('Annots') zodra de array leeg is, zodat de opgeslagen pagina geen lege overgebleven array bevat — dan maakt die call precies het dictionary-item vrij waar DictItem naar wijst. De finally schrijft daarna via een dangling pointer en het proces sterft met "Invalid pointer operation". Twee bestaande tests vingen dit onmiddellijk, wat de enige reden is dat het een voetnoot is en geen supportticket. De regel generaliseert: controleer vóór je cleanup aan een gedeelde loop toevoegt bij callers op alias- of swapcontracten. Een lege /Annots-array is een cosmetische onvolkomenheid en geen pointer-lifetimegarantie waard
Wat blijft ongebakken en wat kost flattening?
Hidden annotations worden bewust uitgesloten. Een annotatie waarvan het integer /F bitpositie 2 heeft ingesteld, is volgens ISO 32000-1 12.5.3 hidden, en wanneer die ook geen /AP heeft is er een echte verleiding om er één te synthetiseren en hem net als de rest in te bakken. Dat zou een bug met securitygevolgen zijn: een onzichtbare note in de page content bakken maakt hem zichtbaar voor iedereen die het bestand opent. HotPDF laat die annotaties precies staan waar ze staan en telt ze niet mee in de return value. Wees tegenover gebruikers even duidelijk over de prijs van de annotaties die wel worden ingebakken. Flattening is onomkeerbaar — de annotatie wordt uit de /Annots-array van de pagina verwijderd en zijn visual is nu page content, dus het veld kan niet meer worden bewerkt, er is geen comment thread, geen /AS-state-toggle en geen manier om de gestructureerde data terug te halen behalve uit het oorspronkelijke bestand. Flatten een kopie, bewaar het origineel en gebruik die kopie pas wanneer het document ophoudt een form te zijn en een record wordt. Als je probleem XFA-backed is in plaats van appearance-loos, is het afzonderlijke XFA naar AcroForm-flatteningpad in HotPDF het pad om mee te beginnen, en als je het form nog bouwt, behandelen de notities over AcroForm-field actions en validatie koppelen de write-kant
Eén verificatiecaveat, want anders kost het je een middag. ExtractLoadedPageGlyphs daalt niet af in Form XObjects, en een ingebakken appearance leeft binnen één ervan — de page content-stream bevat alleen een q ... cm /FlatAn<n> Do Q-sequence. Glyphextractie op een geflattenede pagina rapporteert daarom niets, en dat is correct gedrag en geen verloren bake. Controleer het op byteniveau, bijvoorbeeld op de resource-naam /FlatAn, de Do-aanroep en /Subtype /Form, of via de renderingpipeline, die XObjects wel expandt
Annotatieflattening ziet eruit als drie regels transcriptie totdat je de documenten tegenkomt die mensen echt genereren. Als je in Delphi of C++Builder met ingevulde forms, reviewmarkup of archiefoutput werkt, is het de moeite waard te lezen hoe de HotPDF Delphi PDF-component de loaded-document-kant van AcroForms en annotaties afhandelt voordat je er zelf een appearancegenerator bovenop bouwt