技術記事

Delphiで/APなしのPDF annotationをflattenする

HotPDF v2.743.0は、/AP appearance streamを持たないPDF annotationを黙ってskipせずflattenします。FlattenLoadedAnnotationsはappearanceのないwidgetをEnsureLoadedFieldAppearanceStreamへ送り、appearanceのないmarkupについてはannotation自身のpropertyからForm XObjectを構築します。そのため、/NeedAppearances formへ入力されたvalueがflatten時に消えず、page contentに残ります。この変更を迫ったfailureはno-opに見えます。customerがbrowserからPDFにprintした入力済みapplication formを送ってきます。HotPDFでloadし、FlattenLoadedAnnotationsを呼び、0を受け取り、applicantが入力したnameとamountのboxが空のdocumentをsaveして出荷します。exceptionもlogもありません。valueはずっとfile内にあり、各fieldの/V entryに入っていました。しかしどのwidgetもappearance streamを持たなかったため、flatten passはそのまま通り過ぎました

browserからprintしたformで入力valueが失われる理由

/NeedAppearances formはvalueを保存しても、そのvalueのpictureは保存しないためです。ISO 32000-1 12.7.2では、interactive formがAcroForm dictionaryに/NeedAppearances trueを設定できます。これはviewerに対し、/V/DA/Qからopen時に各fieldのvisual surfaceを構築するよう指示します。browser print path、server-side filler、scan front endの一部など、安価にformを生成するproducerはこの仕組みを利用し、/APをまったく書きません。ISO 32000-1 12.5.5のappearance algorithmで定義されるflatteningはtranscription jobです。annotationのnormal appearance streamを取り、/BBox/Rectへmapし、Do operatorでpage content streamからinvokeしてからannotationを削除します。source streamがなければtranscribeするものがありません。v2.386.0のoriginal HotPDF implementationはこれを「skip」と扱っていました。単独なら説明できても、全体では破滅的です。flatteningが最も必要なdocumentほどappearanceを持たないからです。同じ穴にmarkupも落ちました。review toolのHighlight、redline passのSquare、Ink signatureが、producerがviewerに描画を任せた場合に消えていました

FlattenLoadedAnnotationsへsynthesisをhookする場所

hook pointは意図的に遅く、appearance lookupが失敗した後です。FlattenLoadedAnnotationsはまずGetLoadedAnnotationAppearanceStreamにnormal appearanceを尋ね、すでに持っているannotationはv2.386.0とまったく同じようにbakeします。nil resultになり、かつannotationがnon-degenerateな/Rectを持ち、hidden flagもない場合だけsynthesis pathへ入ります。この順序が重要です。/APを書く手間をかけたdocument authorには、HotPDFのreconstructionではなく、自身のbyteが返るからです

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);
    // もう一度queryすると、generatorがwidgetへ/AP /Nを付けている
    NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
  end
  else
    NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;

ここからannotation familyは2つに分かれます。widgetはGetLoadedFormFieldIndexForAnnotationでowning fieldへ戻し、v2.328.0以降このDelphi PDF libraryにあるfield appearance generator、EnsureLoadedFieldAppearanceStreamへ渡します。二つ目のfield rendererを書くのではなく再利用することが要点です。すでにType0 font、line wrapping、quadding、checkboxとradioの/AS state、/MK rotationまで扱っており、loaded PDFへAcroForm fieldを追加する処理と同じ機構だからです。それ以外はmarkup synthesizerへ送ります。callerから見れば何も変わりません。以前は0を返していたdocumentでも、同じ1行のflatten callが今はnon-zero countを返します

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0:APのないwidgetとmarkupを合成してからbakeする
  Flattened:= Doc.FlattenLoadedAnnotations;          // 全page、全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;

QuadPointsとInkListが誤った位置に落ちる理由

これらのcoordinateはpage user spaceにありますが、synthesized appearance streamは自身の/BBox spaceに描画し、2つのoriginは同じ点ではないためです。ISO 32000-1 Table 176はtext markup annotationの/QuadPointsをdefault user spaceで定義し、Table 174はline annotationの/L endpointにも同じことをします。/InkListも同じconventionです。HotPDFはsynthesized formに[0 0 W H]/BBoxを与え、そのoriginを/Rectのlower-left cornerに置きます。そのため/QuadPoints/L/InkListから取り出したpointは、content streamへ書く前に/Rect lower-leftのnegated valueでtranslateする必要があります。これを誤ると、pageの700 point上にあるlineのhighlightは自身のboxよりさらに700 point上に描かれ、実際にはどこにも出ません。修正はcoordinateごとに1回のsubtractionです。後でbakeが出すcmともcomposeします。このmatrixが/BBox/Rectへ戻すため、2つのstepは相殺されて正しいabsolute geometryになります

// /L endpointはpage user space(ISO 32000-1 Table 174)にあり、formの
// BBox originは/Rectのlower-leftにあるため、-(RL, RB)だけshiftする
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は/DAからparseしたfontとcolorで/Contentsを描き、/Qでalignし、paddingは2 ptです。SquareとCircleはreまたは4つのarc Bezier outlineを/Cでstrokeし、/ICがあればfillし、widthは/BS /Wから取ります。LineとInkはvertexをstrokeします。Highlightは各quadをfillし、Underline、StrikeOut、Squigglyはquadのbottom、quadのmidpoint、または1 pointのzigzagにruleをstrokeします。1未満の/CAca entryを持つExtGStateになり、stream先頭で/GSA gsとして参照されます

text encodingは、/DAが示すAcroForm /DR /Font entryから決まります。そのfontの/SubtypeType0なら、HotPDFはFEFF byte order mark付きのUTF-16BE hex literalとしてstringを書きます。それ以外ではescaped literal stringを書き、parenthesisとbackslashをescapeし、126を超えるbyteをoctalで書きます。/DATf operatorはBTの前に出力します。text stateはtext object boundaryを越えてpersistするため合法で、/DA stringを分解する手間も省けます。2つの制限を明確にしておきます。wrappingとquaddingのline widthはreal font metricではなくhalf-em / full-em heuristicで推定するため、proportional fontでのalignmentは近似で正確ではありません。またsynthesizeできるものがないsubtype、Popup、Link、contentがicon nameだけのStampはnilを返し、以前と同じように未変更のまま残ります

helpful cleanupが問題にするtemporary /Annots swap

FlattenOneWidgetFlattenLoadedFormFieldsが使うper-widget pathで、shared flatten loop内の変更が尊重すべきaliasing trapです。pageの/Annots valueを一時的に1-element arrayへ置き換え、generic flatten passが単一widgetを処理できるようにし、その後finally blockでoriginalのPHPDFDictionaryItem pointerを戻します。restoreはcall前にcaptureしたdictionary slotへ書き戻します

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;   // inner loopがこのitemをfreeするとdanglingになる
  TemporaryAnnots.Free;
end;

shared inner loopの中へ、もっともらしいcleanupを追加します。arrayが空になったらDeleteValue('Annots')を呼び、保存したpageに役目を終えたempty arrayを残さないようにします。しかしそのcallはDictItemが指すdictionary item自身をfreeします。続くfinallyはdangling pointerを通じてwriteし、processは「Invalid pointer operation」で停止します。2つの既存testがすぐにこれを捕捉しました。だからfootnoteで済んでおり、support ticketにならずに済みました。教訓は一般化できます。shared loopへcleanupを追加する前に、callerのaliasまたはswap contractを確認してください。empty /Annots arrayが残るのは見た目の問題であり、pointer lifetime guaranteeと交換する価値はありません

bakeされないものとflatteningのコスト

hidden annotationは意図的に除外されます。/F integerのbit position 2がsetされたannotationはISO 32000-1 12.5.3によりhiddenです。しかも/APがないと、他と同じようにsynthesizeしてbakeしたくなります。それはsecurity consequenceのあるbugになります。invisible noteをpage contentへbakeすると、fileを開くすべての人に見えてしまうためです。HotPDFはそのannotationを完全にそのまま残し、return valueにもcountしません。bakeされるannotationについてはコストも同様に明確に伝えてください。flatteningはirreversibleです。annotationはpageの/Annots arrayから削除され、visualはpage contentになります。以後field valueをeditすることも、comment threadを使うことも、/AS stateをtoggleすることもできず、structured dataをoriginal fileなしに戻す方法もありません。copyをflattenし、originalを保管し、documentがformからrecordへ変わる場所でだけ使ってください。appearance-lessではなくXFA-backedが問題なら、HotPDFのXFAからAcroFormへのflattening pathから始めるべきです。まだformを構築中なら、AcroForm field actionとvalidationの配線にwrite sideの説明があります

verificationについて1つ注意があります。後で午後を失わないためです。ExtractLoadedPageGlyphsはForm XObjectへdescendせず、baked appearanceはその中にあります。page content streamにあるのはq ... cm /FlatAn<n> Do Q sequenceだけです。そのためflattened pageでglyph extractionを行っても何も報告されません。これはbakeが失われたのではなく正しい動作です。byte levelで/FlatAn resource name、Do invocation、/Subtype /Formをcheckするか、XObjectをexpandするrendering pipelineで確認してください

annotation flatteningは、実際に人々が生成するdocumentに出会うまでは3行のtranscriptionに見えます。DelphiまたはC++Builderでfilled form、review markup、archival outputを扱うなら、appearance generatorを自分で上に構築する前に、HotPDF Delphi PDF componentがloaded-document側のAcroFormとannotationをどう扱うかを読む価値があります