HotPDF v2.743.0 flatten PDF annotation ที่ไม่มี appearance stream /AP แทนการข้ามมันไปอย่างเงียบ ๆ ตอนนี้ FlattenLoadedAnnotations จะส่ง widget ที่ไม่มี appearance ผ่าน EnsureLoadedFieldAppearanceStream และสร้าง Form XObject ให้ markup ที่ไม่มี appearance จาก property ของ annotation เอง ดังนั้นค่าที่พิมพ์ลงใน form แบบ /NeedAppearances จะคงอยู่ใน page content แทนที่จะหายตอน flatten failure ที่บังคับให้เปลี่ยนแปลงนี้ดูเหมือน no-op ลูกค้าส่ง application form ที่กรอกแล้วและพิมพ์เป็น PDF จาก browser คุณโหลดมันใน HotPDF เรียก FlattenLoadedAnnotations ได้ค่า 0 แล้ว save และส่ง document ที่ช่องว่างตรงจุดที่ผู้สมัครพิมพ์ชื่อกับจำนวนเงินไว้ ไม่มี exception ไม่มี log ค่าเหล่านั้นอยู่ในไฟล์มาตลอดใน /V entry ของแต่ละ field แต่ flatten pass เดินผ่านไปตรง ๆ เพราะ widget เหล่านั้นไม่มี appearance stream ให้ bake
ทำไมการ flatten form ที่พิมพ์จาก browser จึงทำให้ค่าที่กรอกหาย
เพราะ form แบบ /NeedAppearances เก็บ value แต่ไม่ได้เก็บภาพของ value ISO 32000-1 12.7.2 อนุญาตให้ interactive form ตั้ง /NeedAppearances true ใน AcroForm dictionary เพื่อบอก viewer ให้สร้าง visual surface ของแต่ละ field ตอนเปิดจาก /V, /DA และ /Q producer ที่สร้าง form แบบประหยัด เช่น browser print path, server-side filler และ scanning front end บางตัว ใช้ความสามารถนี้แล้วไม่เขียน /AP เลย flattening ตาม appearance algorithm ใน ISO 32000-1 12.5.5 เป็นงานถอดความ คือเอา normal appearance stream ของ annotation มา map /BBox ลงบน /Rect เรียกมันจาก page content stream ด้วย operator Do แล้วลบ annotation เมื่อไม่มี source stream ก็ไม่มีอะไรให้ถอดความ implementation เดิมของ HotPDF ตั้งแต่ v2.386.0 ถือกรณีนี้เป็น "skip" ซึ่งดูมีเหตุผลเมื่อมองแยกเดี่ยว แต่เสียหายเมื่อเกิดกับเอกสารจำนวนมาก เพราะ document ที่ต้อง flatten มากที่สุดกลับเป็น document ที่มี appearance น้อยที่สุด ช่องว่างเดียวกันกลืน markup ไปด้วย ไม่ว่าจะเป็น Highlight จาก review tool, Square จาก redline pass หรือ Ink signature เมื่อ producer ฝากให้ viewer เป็นคนวาด
HotPDF เสียบการสังเคราะห์เข้า FlattenLoadedAnnotations ตรงไหน
จุด hook ตั้งใจวางไว้ช้า คือหลัง appearance lookup fail ไม่ใช่ก่อนหน้านั้น FlattenLoadedAnnotations ยังถาม GetLoadedAnnotationAppearanceStream หา normal appearance ก่อนเสมอ และ annotation ที่มีอยู่แล้วจะถูก bake เหมือนใน v2.386.0 ทุกประการ เฉพาะผลลัพธ์ nil ที่อยู่บน annotation ซึ่งมี /Rect ไม่เสื่อมสภาพและไม่มี hidden flag เท่านั้นจึงเข้า synthesis path ลำดับนี้สำคัญ เพราะ document author ที่ตั้งใจเขียน /AP จะได้ byte ของตัวเองกลับไป ไม่ใช่ reconstruction จาก 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;
จากตรงนี้ annotation สองตระกูลจะแยกทางกัน widget จะถูก resolve กลับไปยัง field เจ้าของผ่าน GetLoadedFormFieldIndexForAnnotation แล้วส่งต่อให้ EnsureLoadedFieldAppearanceStream ซึ่งเป็น field appearance generator ที่อยู่ใน Delphi PDF library นี้มาตั้งแต่ v2.328.0 การ reuse แทนการเขียน field renderer ตัวที่สองคือหัวใจ เพราะมันรองรับ Type0 font, line wrapping, quadding, checkbox และ radio /AS state รวมถึง /MK rotation อยู่แล้ว ซึ่งเป็น machinery เดียวกับ การเพิ่ม AcroForm field ลงใน PDF ที่โหลดแล้ว ทุกอย่างที่เหลือส่งไปยัง markup synthesizer สำหรับ caller ไม่มีอะไรเปลี่ยน flatten call บรรทัดเดียวกันจะคืน count ที่ไม่เป็นศูนย์บน document ที่เดิมคืนศูนย์
Doc:= THotPDF.Create(nil);
try
Doc.LoadFromFile('needappearances-form.pdf');
// v2.743.0: สังเคราะห์ AP-less widget และ markup แล้ว bake
Flattened:= Doc.FlattenLoadedAnnotations; // ทุกหน้าและทุก 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 ของตัวเอง และ origin ของสอง space ไม่ใช่จุดเดียวกัน ISO 32000-1 Table 176 กำหนด /QuadPoints ของ text markup annotation ใน default user space และ Table 174 กำหนดแบบเดียวกันให้ endpoint /L ของ line annotation ส่วน /InkList ใช้ convention เดียวกัน HotPDF ให้ synthesized form มี /BBox เป็น [0 0 W H] โดย origin อยู่ที่มุมล่างซ้ายของ /Rect ดังนั้นทุก point ที่ดึงจาก /QuadPoints, /L หรือ /InkList ต้อง translate ด้วยค่าติดลบของ lower-left ใน /Rect ก่อนเขียนลง content stream หากทำผิด highlight บน line ที่อยู่สูงขึ้นไป 700 point จะถูกวาดสูงกว่ากล่องของตัวเอง 700 point ซึ่งในทางปฏิบัติคือวาดไม่เห็น การแก้คือหักค่าหนึ่งครั้งต่อ coordinate และมันประกอบกับ cm ที่ bake emit ภายหลังได้ matrix นั้น map /BBox กลับไปยัง /Rect ดังนั้นสองขั้นตอนจึงหักล้างกันและได้ absolute geometry ที่ถูกต้อง
// /L endpoint อยู่ใน page user space (ISO 32000-1 Table 174); origin ของ
// form BBox อยู่ที่ lower-left ของ /Rect จึงต้องเลื่อนด้วย -(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 ที่ parse จาก /DA จัดแนวตาม /Q และเว้น padding 2 pt Square กับ Circle วาด re หรือเส้นรอบรูปแบบ four-arc Bezier โดย stroke ด้วย /C และ fill ด้วย /IC เมื่อมีค่า โดยใช้ width จาก /BS /W Line กับ Ink stroke vertex ของตัวเอง Highlight fill quad แต่ Underline, StrikeOut และ Squiggly stroke rule ที่ด้านล่างของ quad, กึ่งกลาง quad หรือเป็น zigzag หนึ่งจุดตามชนิด /CA ที่ต่ำกว่า 1 จะกลายเป็น ExtGState ที่มี entry ca และถูกอ้างเป็น /GSA gs ที่ต้น stream
การเลือก text encoding มาจาก /DR /Font ของ AcroForm ที่ /DA ระบุ หาก font มี /Subtype เป็น Type0 HotPDF จะเขียน string เป็น UTF-16BE hex literal พร้อม byte order mark FEFF มิฉะนั้นจะเขียนเป็น literal string ที่ escape parenthesis กับ backslash และเขียน byte ที่มากกว่า 126 เป็น octal operator Tf จาก /DA ถูก emit ก่อน BT ซึ่งทำได้เพราะ text state คงอยู่ข้ามขอบเขตของ text object และช่วยไม่ต้องแยก string /DA ออกเป็นส่วน ๆ มีข้อจำกัดสองอย่างที่ควรพูดให้ชัด width ของ line สำหรับ wrapping และ quadding ประเมินด้วย heuristic ครึ่ง em เต็ม em ไม่ใช่ real font metric ดังนั้นการจัดแนวบน proportional font จะใกล้เคียงแต่ไม่ตรงทั้งหมด และ subtype ที่ไม่มีอะไรให้สังเคราะห์ เช่น Popup, Link หรือ Stamp ที่มีเพียง icon name จะคืน nil และถูกปล่อยไว้เหมือนเดิม
temporary /Annots swap ที่ทำให้ cleanup แบบหวังดีพัง
FlattenOneWidget ซึ่งเป็น path ต่อ widget ที่ FlattenLoadedFormFields ใช้ เป็น aliasing trap ที่การเปลี่ยนแปลงใด ๆ ใน shared flatten loop ต้องเคารพ มันแทนค่า /Annots ของ page ชั่วคราวด้วย array ที่มี element เดียวเพื่อให้ generic flatten pass ทำงานกับ widget เดียว แล้ว restore pointer PHPDFDictionaryItem เดิมใน finally block การ restore เขียนกลับไปยัง dictionary slot ที่ capture ไว้ก่อน 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 free item นี้
TemporaryAnnots.Free;
end;
หากเพิ่ม cleanup ที่ดูสมเหตุสมผลเข้าไปใน shared inner loop เช่น DeleteValue('Annots') เมื่อ array ว่าง เพื่อไม่ให้ page ที่ save แล้วมี empty array หลงเหลืออยู่ call นั้นจะ free dictionary item ที่ pointer DictItem ชี้อยู่เอง จากนั้น finally จะเขียนผ่าน dangling pointer และ process จะตายด้วย "Invalid pointer operation" test เดิมสองตัวจับเรื่องนี้ได้ทันที ซึ่งเป็นเหตุผลเดียวที่มันเป็น footnote ไม่ใช่ support ticket กฎนี้ใช้ได้ทั่วไป ก่อนเพิ่ม cleanup ลง shared loop ให้ตรวจ caller ว่ามี alias หรือ swap contract หรือไม่ empty /Annots array ที่เหลือเป็นเพียง cosmetic wart และไม่คุ้มกับการแลก lifetime guarantee ของ pointer
อะไรจะไม่ถูก bake และต้นทุนของ flattening
annotation ที่ซ่อนอยู่ถูก exclude โดยตั้งใจ annotation ที่ integer /F มี bit position 2 เป็น 1 ถือว่าซ่อนตาม ISO 32000-1 12.5.3 และเมื่อมันไม่มี /AP ก็มีแรงล่อใจให้สังเคราะห์แล้ว bake เหมือนตัวอื่น แต่นั่นจะเป็น bug ที่มีผลด้าน security การ bake note ที่มองไม่เห็นลงใน page content ทำให้ทุกคนที่เปิดไฟล์มองเห็น HotPDF จึงปล่อย annotation เหล่านั้นไว้ที่เดิมและไม่นับใน return value ต้องอธิบายต้นทุนของ annotation ที่ถูก bake ให้ผู้ใช้เข้าใจเช่นกัน flattening ย้อนกลับไม่ได้ annotation จะถูกลบออกจาก array /Annots ของ page และ visual ของมันกลายเป็น page content จึงไม่มีการแก้ field value ต่อ ไม่มี comment thread ไม่มีการ toggle /AS state และกู้ structured data กลับมาไม่ได้ถ้าไม่มีไฟล์ต้นฉบับ flatten สำเนา เก็บต้นฉบับไว้ และใช้สำเนานั้นเมื่อ document หยุดเป็น form แล้วกลายเป็น record หากปัญหาของคุณเป็น XFA-backed ไม่ใช่ appearance-less ให้เริ่มจาก เส้นทาง XFA to AcroForm flattening ใน HotPDF แทน และหากกำลังสร้าง form อยู่ ให้อ่าน การ wiring AcroForm field action และ validation สำหรับฝั่งเขียน
มีข้อควรระวังในการตรวจสอบหนึ่งอย่าง เพราะไม่เช่นนั้นจะเสียเวลาไปทั้งบ่าย ExtractLoadedPageGlyphs ไม่ descend เข้า Form XObject และ baked appearance อยู่ภายในหนึ่งตัว page content stream จึงมีเพียง sequence q ... cm /FlatAn<n> Do Q การ extract glyph บน page ที่ flatten แล้วจึงไม่รายงานอะไร และนั่นเป็น behavior ที่ถูกต้อง ไม่ใช่ bake ที่หายไป ให้ตรวจระดับ byte โดยหา resource name /FlatAn, invocation Do และ /Subtype /Form หรือใช้ rendering pipeline ซึ่งจะ expand XObject ให้
annotation flattening ดูเหมือนงานถอดความสามบรรทัดจนกว่าจะเจอ document ที่คนสร้างจริง หากคุณทำงานกับ filled form, review markup หรือ archival output ใน Delphi หรือ C++Builder ควรอ่านว่า HotPDF Delphi PDF component จัดการ loaded-document side ของ AcroForm และ annotation อย่างไรก่อนสร้าง appearance generator ของคุณเองทับมัน