Bài viết kỹ thuật

Flatten annotation PDF không có /AP stream trong Delphi

HotPDF v2.743.0 flatten PDF annotation không có /AP appearance stream thay vì âm thầm bỏ qua chúng. FlattenLoadedAnnotations giờ route widget không có appearance qua EnsureLoadedFieldAppearanceStream và dựng Form XObject cho markup không có appearance từ chính property của annotation, để value được nhập vào form /NeedAppearances vẫn đi vào page content thay vì biến mất lúc flatten. Failure buộc phải sửa trông như một no-op. Customer gửi một application form đã điền, print thành PDF từ browser. Bạn load nó trong HotPDF, gọi FlattenLoadedAnnotations, nhận lại 0, save rồi giao một document có các box trống nơi applicant đã nhập tên và số tiền. Không exception, không log. Value đã nằm trong file suốt thời gian đó, trong entry /V của từng field, và flatten pass đi thẳng qua chúng vì không widget nào có appearance stream để bake

Vì sao flatten form in từ browser làm mất giá trị đã nhập?

Vì form /NeedAppearances lưu value mà không lưu picture của value. ISO 32000-1 12.7.2 cho phép interactive form đặt /NeedAppearances true trong AcroForm dictionary, báo viewer lúc mở phải dựng visual surface cho từng field từ /V, /DA/Q. Producer tạo form tiết kiệm — browser print path, server-side filler, một số scanning front end — tận dụng điều đó và không ghi /AP. Flatten, theo appearance algorithm trong ISO 32000-1 12.5.5, là công việc transcription: lấy normal appearance stream của annotation, map /BBox lên /Rect, invoke nó từ page content stream bằng operator Do, rồi xóa annotation. Không có source stream thì không có gì để transcribe. Implementation HotPDF ban đầu từ v2.386.0 coi đó là "skip", hợp lý khi nhìn riêng nhưng tai hại trên diện rộng: document cần flatten nhất thường là document ít có appearance nhất. Khoảng trống tương tự nuốt luôn markup — Highlight từ review tool, Square từ redline pass, Ink signature — khi producer dựa vào viewer để vẽ

HotPDF gắn synthesis vào FlattenLoadedAnnotations ở đâu?

Điểm hook được đặt có chủ ý ở cuối: sau khi lookup appearance fail chứ không phải trước đó. FlattenLoadedAnnotations vẫn hỏi GetLoadedAnnotationAppearanceStream normal appearance trước, và annotation đã có appearance được bake chính xác như v2.386.0. Chỉ kết quả nil, trên annotation có /Rect không suy biến và không có hidden flag, mới đi vào synthesis path. Thứ tự này quan trọng: document author đã bỏ công ghi /AP sẽ nhận lại byte của họ, không phải bản dựng lại từ 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);
    // hỏi lại: generator đã gắn /AP /N vào widget
    NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
  end
  else
    NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;

Từ đó hai họ annotation tách ra. Widget được resolve ngược về owning field qua GetLoadedFormFieldIndexForAnnotation rồi đưa cho EnsureLoadedFieldAppearanceStream, field appearance generator đã có trong thư viện PDF Delphi này từ v2.328.0. Tái sử dụng nó thay vì viết renderer thứ hai là toàn bộ mục đích — nó đã bao phủ Type0 font, line wrapping, quadding, checkbox và radio /AS state cùng /MK rotation, cũng là machinery phía sau việc thêm AcroForm field vào PDF đã load. Mọi thứ khác đi tới markup synthesizer. Với caller không có gì đổi: cùng một lệnh flatten một dòng giờ trả về count khác 0 trên document trước đây trả về 0

Doc:= THotPDF.Create(nil);
try
  Doc.LoadFromFile('needappearances-form.pdf');
  // v2.743.0: widget và markup không có AP được synthesize rồi bake
  Flattened:= Doc.FlattenLoadedAnnotations;          // mọi page, mọ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;

Vì sao QuadPoints và InkList rơi sai vị trí?

Vì các tọa độ đó ở page user space trong khi synthesized appearance stream vẽ trong /BBox space riêng, và hai origin không trùng một điểm. ISO 32000-1 Table 176 định nghĩa /QuadPoints cho text markup annotation trong default user space, Table 174 làm điều tương tự cho endpoint /L của line annotation, còn /InkList theo cùng convention. HotPDF cho synthesized form /BBox[0 0 W H], origin nằm ở lower-left corner của /Rect. Vì vậy mọi point lấy từ /QuadPoints, /L hoặc /InkList phải được dịch bằng lower-left /Rect bị negate trước khi ghi vào content stream. Làm sai, highlight trên line cách đầu page 700 point sẽ vẽ cao hơn box của chính nó 700 point, thực tế là biến mất. Bản sửa chỉ là một phép trừ cho mỗi coordinate và kết hợp với cm mà bake emit sau đó — matrix đó map /BBox trở lại /Rect, nên hai bước triệt tiêu để ra geometry tuyệt đối đúng

// Endpoint /L ở page user space (ISO 32000-1 Table 174); origin của
// form BBox ở lower-left /Rect, nên dịch bằng -(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 thực sự vẽ gì?

Markup synthesizer chỉ đọc annotation dictionary và không đọc gì khác, nhờ đó output dễ đoán và trung thực về những gì nó không thể biết. FreeText và Stamp vẽ /Contents bằng font và color parse từ /DA, align theo /Q, padding 2 pt. Square và Circle vẽ outline re hoặc Bezier bốn cung, stroke bằng /C, fill bằng /IC nếu có, width lấy từ /BS /W. Line và Ink stroke các vertex. Highlight fill từng quad, còn Underline, StrikeOut và Squiggly stroke một rule ở đáy quad, trung điểm quad hoặc dạng zigzag một point. /CA dưới 1 trở thành ExtGState có entry ca, được tham chiếu như /GSA gs ở đầu stream

Text encoding được quyết định từ entry /DR /Font của AcroForm có name do /DA chỉ ra. Nếu /Subtype của font là Type0, HotPDF ghi string dưới dạng UTF-16BE hex literal với byte order mark FEFF; nếu không, nó ghi escaped literal string, escape parenthesis và backslash, còn byte trên 126 viết ở octal. Operator Tf từ /DA được emit trước BT, hợp lệ vì text state tồn tại qua boundary của text object và tránh phải tách string /DA. Có hai giới hạn cần nói rõ. Line width dùng cho wrapping và quadding được ước lượng bằng heuristic half-em / full-em thay vì font metric thật, nên alignment trên proportional font gần đúng nhưng không tuyệt đối. Ngoài ra subtype không có gì có thể synthesize — Popup, Link, Stamp chỉ có icon name — sẽ cho nil và được giữ nguyên như trước

Temporary /Annots swap phạt một lần cleanup có thiện ý

FlattenOneWidget, path theo từng widget được FlattenLoadedFormFields dùng, là một aliasing trap mà mọi thay đổi trong shared flatten loop phải tôn trọng. Nó tạm thời thay value /Annots của page bằng array một phần tử để generic flatten pass chỉ xử lý một widget, rồi khôi phục pointer PHPDFDictionaryItem gốc trong block finally. Việc restore ghi lại vào dictionary slot mà nó capture trước lời gọi

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 nếu inner loop đã free item này
  TemporaryAnnots.Free;
end;

Thêm một cleanup trông hợp lý trong shared inner loop — DeleteValue('Annots') khi array rỗng để page đã save không còn empty array thừa — và lời gọi đó free chính dictionary item mà pointer DictItem đang trỏ tới. Sau đó finally ghi qua dangling pointer và process chết với "Invalid pointer operation". Hai test hiện có bắt được ngay, đó là lý do đây chỉ là footnote chứ chưa thành support ticket. Quy tắc tổng quát là trước khi thêm cleanup vào shared loop, kiểm tra alias hoặc swap contract ở caller. Một empty /Annots array còn sót chỉ là cosmetic wart, không đáng đánh đổi guarantee về pointer lifetime

Những gì vẫn không bake, và chi phí của flatten

Hidden annotation bị loại có chủ ý. Annotation có integer /F bật bit position 2 là hidden theo ISO 32000-1 12.5.3, và khi nó cũng không có /AP rất dễ muốn synthesize rồi bake như phần còn lại. Đó sẽ là bug có hệ quả security: bake một note vô hình vào page content khiến nó visible với mọi người mở file. HotPDF để annotation đó nguyên vị trí và không tính vào return value. Cũng cần nói rõ với user cái giá của những annotation thực sự được bake. Flatten là irreversible — annotation bị xóa khỏi array /Annots của page và visual của nó giờ là page content, nên không còn edit field value, comment thread, toggle state /AS hay khôi phục structured data nếu không có file gốc. Hãy flatten một bản copy, giữ file gốc và chỉ dùng bản đã flatten khi document không còn là form mà đã thành record. Nếu vấn đề của bạn là XFA-backed chứ không phải appearance-less, hãy bắt đầu từ XFA to AcroForm flattening path trong HotPDF, còn nếu vẫn đang dựng form, ghi chú về wiring AcroForm field action và validation bao phủ phía write

Có một caveat về verification, nếu không nó sẽ tốn của bạn cả buổi chiều. ExtractLoadedPageGlyphs không đi vào Form XObject, còn baked appearance lại nằm bên trong một Form — page content stream chỉ giữ chuỗi q ... cm /FlatAn<n> Do Q. Vì vậy glyph extraction trên page đã flatten không báo gì, và đó là behavior đúng chứ không phải bake bị mất. Hãy verify ở byte level bằng cách kiểm tra resource name /FlatAn, invocation Do/Subtype /Form, hoặc qua rendering pipeline vì pipeline đó có expand XObject

Annotation flatten trông như ba dòng transcription cho đến khi gặp document mà người ta thực sự tạo ra. Nếu bạn làm việc với filled form, review markup hoặc archival output trong Delphi hay C++Builder, nên đọc cách HotPDF Delphi PDF component xử lý phía loaded-document của AcroForm và annotation trước khi tự xây appearance generator trên đó