Articolo tecnico

Appiattire in Delphi annotazioni PDF senza stream /AP

HotPDF v2.743.0 appiattisce le annotazioni PDF che non hanno uno stream di appearance /AP invece di ignorarle silenziosamente. FlattenLoadedAnnotations ora passa un widget senza appearance a EnsureLoadedFieldAppearanceStream e costruisce un Form XObject per il markup privo di appearance a partire dalle proprietà dell'annotazione, così i valori digitati in un form /NeedAppearances sopravvivono nel contenuto della pagina invece di sparire durante il flattening. Il guasto che ha imposto questa modifica sembra un non-evento. Un cliente invia un modulo compilato, stampato in PDF da un browser. Lo caricate in HotPDF, chiamate FlattenLoadedAnnotations, ottenete 0, salvate e consegnate un documento con caselle vuote dove il richiedente aveva inserito nome e importo. Nessuna eccezione, nessun log. I valori erano nel file da sempre, dentro l'entry /V di ogni campo, e il passaggio di flattening li aveva oltrepassati perché nessuno di quei widget portava uno stream di appearance da incorporare

Perché il flattening di un form stampato dal browser perde i valori inseriti?

Perché un form /NeedAppearances memorizza il valore senza memorizzare un'immagine del valore. ISO 32000-1 12.7.2 consente a un interactive form di impostare /NeedAppearances true nel dizionario AcroForm, dicendo al viewer di costruire all'apertura la superficie visiva di ogni campo a partire da /V, /DA e /Q. I producer che generano form in modo economico, percorsi di stampa del browser, filler server-side, alcuni front end di scansione, colgono questa possibilità e non scrivono affatto /AP. Il flattening, come definito dall'appearance algorithm in ISO 32000-1 12.5.5, è un lavoro di trascrizione: prendere lo stream di appearance normale dell'annotazione, mappare il suo /BBox sul suo /Rect, invocarlo dal content stream della pagina con un operatore Do e poi eliminare l'annotazione. Senza stream sorgente non c'è nulla da trascrivere. L'implementazione originale HotPDF, dalla v2.386.0, trattava il caso come "skip", difendibile in isolamento e disastroso in aggregato: i documenti che hanno più bisogno del flattening sono quelli meno propensi ad avere appearance. La stessa lacuna inghiottiva il markup, un Highlight di uno strumento di review, un Square di un passaggio di redline, una firma Ink, quando il producer si affidava al viewer per disegnarlo

Dove HotPDF inserisce la sintesi in FlattenLoadedAnnotations

Il punto di aggancio è volutamente tardivo: dopo che la lookup dell'appearance fallisce, non prima. FlattenLoadedAnnotations chiede ancora per prima cosa a GetLoadedAnnotationAppearanceStream lo stream normale, e un'annotazione che ne possiede già uno viene incorporata esattamente come nella v2.386.0. Solo un risultato nil, su un'annotazione con /Rect non degenere e senza hidden flag, entra nel percorso di sintesi. L'ordine conta: l'autore di un documento che si è preso la briga di scrivere un /AP riceve indietro i propri byte, non una ricostruzione 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);
    // chiedere di nuovo: il generatore ha collegato /AP /N al widget
    NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
  end
  else
    NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;

Da qui le due famiglie di annotazioni si separano. Un widget viene ricondotto al field proprietario tramite GetLoadedFormFieldIndexForAnnotation e passato a EnsureLoadedFieldAppearanceStream, il generatore dell'appearance dei field presente in questa libreria PDF Delphi dalla v2.328.0. Riutilizzarlo invece di scrivere un secondo field renderer è il punto centrale: copre già font Type0, wrapping delle righe, quadding, stati /AS di checkbox e radio e rotazione /MK, la stessa macchina dietro l'aggiunta di campi AcroForm a un PDF già caricato. Tutto il resto va al markup synthesizer. Per il caller nulla cambia: la stessa chiamata di flattening ora restituisce un conteggio non zero sui documenti che prima restituivano zero

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: widget e markup senza AP vengono sintetizzati e poi incorporati
  Flattened:= Doc.FlattenLoadedAnnotations;          // tutte le pagine, tutti i subtype
  // Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
  if Flattened= 0 then
    raise Exception.Create('nothing was flattened');
  Doc.SaveLoadedDocument('flattened.pdf');
finally
  Doc.Free;
end;

Perché QuadPoints e InkList finiscono nel posto sbagliato?

Perché quelle coordinate sono nello user space della pagina, mentre lo stream di appearance sintetizzato disegna nel proprio spazio /BBox, e le due origini non sono lo stesso punto. La tabella 176 di ISO 32000-1 definisce /QuadPoints per le text markup annotation nello user space predefinito, e la tabella 174 fa lo stesso per gli endpoint /L della line annotation; /InkList segue la stessa convenzione. HotPDF assegna al form sintetizzato un /BBox di [0 0 W H] con origine nell'angolo inferiore sinistro di /Rect. Ogni punto estratto da /QuadPoints, /L o /InkList deve quindi essere traslato del lower-left negato di /Rect prima di essere scritto nel content stream. Sbagliate questo e un highlight su una riga a 700 punti di altezza disegnerà 700 punti sopra la propria casella, cioè in pratica da nessuna parte. La correzione è una sottrazione per coordinata e si compone con il cm emesso dal bake: quella matrice riporta il /BBox su /Rect, così i due passaggi si annullano fino a ottenere la geometria assoluta corretta

// Gli endpoint /L sono nello user space della pagina (ISO 32000-1 Table 174); l'origine
// del BBox del form è il lower-left di /Rect, quindi spostare di -(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;

Cosa disegna davvero il markup appearance sintetizzato

Il markup synthesizer legge solo il dizionario dell'annotazione, mantenendo l'output prevedibile e onesto sui dati che non può conoscere. FreeText e Stamp disegnano /Contents usando il font e il colore estratti da /DA, allineati secondo /Q, con 2 pt di padding. Square e Circle disegnano un contorno re o una curva di Bezier a quattro archi tracciata in /C, riempita con /IC quando presente, usando la larghezza di /BS /W. Line e Ink tracciano i propri vertici. Highlight riempie ogni quad, mentre Underline, StrikeOut e Squiggly tracciano una linea sul fondo del quad, al suo punto medio o come zigzag di un punto. Un /CA inferiore a 1 diventa un ExtGState con entry ca, referenziata come /GSA gs all'inizio dello stream

La codifica del testo viene decisa dall'entry /DR /Font dell'AcroForm nominata da /DA. Se il /Subtype di quel font è Type0, HotPDF scrive la stringa come literal hex UTF-16BE con byte order mark FEFF; altrimenti scrive una literal string con parentesi e backslash escaped e byte sopra 126 espressi in ottale. L'operatore Tf da /DA viene emesso prima di BT, comportamento legale perché il text state persiste oltre il confine del text object, e evita di scomporre la stringa /DA. Due limiti vanno dichiarati chiaramente. La larghezza della riga per wrapping e quadding viene stimata con un'euristica half-em / full-em invece che con metriche reali del font, quindi l'allineamento su un font proporzionale è vicino ma non esatto. Inoltre un subtype senza nulla di sintetizzabile, Popup, Link, uno Stamp il cui unico contenuto è un nome di icona, restituisce nil e resta invariato, esattamente come prima

Il temporary /Annots swap che punisce una pulizia troppo zelante

FlattenOneWidget, il percorso per singolo widget usato da FlattenLoadedFormFields, è una trappola di aliasing che ogni modifica dentro il flatten loop condiviso deve rispettare. Sostituisce temporaneamente il valore /Annots della pagina con un array a un elemento così il passaggio generico di flattening opera su un solo widget, poi ripristina il puntatore originale PHPDFDictionaryItem in un blocco finally. Il ripristino riscrive in uno slot del dizionario catturato prima della chiamata

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 se il loop interno ha liberato questo item
  TemporaryAnnots.Free;
end;

Aggiungere una pulizia apparentemente ragionevole dentro il loop interno condiviso, un DeleteValue('Annots') quando l'array si svuota, così la pagina salvata non conserva un array vuoto vestigiale, libera proprio il dictionary item puntato da DictItem. Il finally scrive allora attraverso un puntatore dangling e il processo termina con "Invalid pointer operation". Due test esistenti l'hanno rilevato immediatamente, ed è l'unico motivo per cui questo è una nota a margine invece di un ticket di supporto. La regola è generale: prima di aggiungere cleanup a un loop condiviso, controllate i caller alla ricerca di contratti di alias o swap. Un array /Annots vuoto residuo è un difetto cosmetico e non vale una garanzia sulla durata del puntatore

Cosa resta non incorporato e quanto costa il flattening

Le annotazioni hidden sono escluse deliberatamente. Un'annotazione il cui intero /F ha il bit in posizione 2 impostato è hidden secondo ISO 32000-1 12.5.3 e, quando non possiede neppure /AP, c'è una vera tentazione di sintetizzarlo e incorporarlo come il resto. Sarebbe un bug con conseguenze di sicurezza: incorporare una nota invisibile nel contenuto della pagina la rende visibile a chiunque apra il file. HotPDF lascia esattamente dove sono queste annotazioni e non le conta nel valore restituito. Siate altrettanto chiari con gli utenti sul prezzo di quelle che vengono incorporate. Il flattening è irreversibile: l'annotazione viene eliminata dall'array /Annots della pagina e il suo aspetto diventa contenuto di pagina, quindi non è più possibile modificare il valore del campo, il thread dei commenti, lo stato /AS o recuperare i dati strutturati senza il file originale. Appiattite una copia, conservate l'originale e usate questa operazione solo quando il documento smette di essere un form e diventa un record. Se il problema riguarda XFA invece dell'assenza di appearance, il percorso separato da XFA ad AcroForm flattening in HotPDF è quello da cui partire, e se state ancora costruendo il form, le note su collegare action e validazione dei campi AcroForm coprono il lato di scrittura

Una cautela sulla verifica, perché altrimenti vi costerà un pomeriggio. ExtractLoadedPageGlyphs non entra nei Form XObject, e un appearance incorporato vive dentro uno di essi: il content stream della pagina contiene solo una sequenza q ... cm /FlatAn<n> Do Q. L'estrazione dei glifi su una pagina appiattita quindi non riporta nulla, ed è comportamento corretto, non un bake perduto. Verificate a livello di byte, cercando il nome di risorsa /FlatAn, l'invocazione Do e /Subtype /Form, oppure tramite la pipeline di rendering, che espande gli XObject

Il flattening delle annotazioni sembra una trascrizione di tre righe finché non si incontrano i documenti realmente generati. Se lavorate con form compilati, markup di review o output di archiviazione in Delphi o C++Builder, vale la pena leggere come il componente PDF Delphi HotPDF gestisce il lato dei documenti caricati di AcroForms e annotazioni prima di costruire sopra di esso un appearance generator personale