Artikel Teknis

Impor Anotasi FDF di Delphi: Memperbaiki Silent Zero

Sebelum v3.539.30, TPDFlib.ImportAnnotationsFromFDFString di losLab PDF Library mengembalikan jumlah entri anotasi FDF yang berhasil di-parse tanpa menambahkan satu pun ke dokumen: setiap entri terhitung, setiap entri dibuang. Sejak v3.539.30 importer FDF membaca key dalam urutan apa pun, mem-parse /Rect dengan benar dan bebas locale, dan exporter pasangannya menulis /Rect asli anotasi, sehingga export, import, dan export kedua menghasilkan FDF yang identik byte demi byte. Sisa catatan ini menjelaskan bagaimana satu offset awal yang salah menghasilkan kegagalan senyap yang sempurna, tiga cacat lain apa yang bersembunyi di belakangnya, dan cara memeriksa impor sendiri alih-alih percaya pada return value

Skenarionya biasa saja. Seorang reviewer memberi catatan pada kontrak, komentar-komentar itu berpindah sebagai file FDF (Acrobat menyebutnya Export Comments), dan service Delphi Anda menggabungkannya ke salinan bersih lewat ImportAnnotationsFromFDF. Panggilan mengembalikan 7, log menulis "7 comments imported", job berubah hijau, dan PDF hasilnya tak punya komentar sama sekali. Tak ada yang raise, tak ada yang warning, dan angkanya tampak masuk akal karena memang jumlah entri sebenarnya di file itu. Itulah bentuk bug terburuk: fungsi yang satu-satunya sinyal suksesnya adalah counter yang dihitung terpisah dari pekerjaan yang diklaim dilaporkannya

Kenapa ImportAnnotationsFromFDFString melaporkan sukses tapi tak menambahkan apa pun?

Importer membaca setiap /Subtype sebagai string kosong, dan helper yang membuat anotasi keluar lebih awal pada subtype kosong sementara caller tetap menaikkan hasilnya. Key finder mengembalikan posisi tepat setelah /Subtype, yaitu whitespace sebelum value-nya. ReadName memulai dari spasi itu dan berhenti pada karakter whitespace pertama, jadi ia berhenti sebelum membaca apa pun. AddAnnotationToPage menolak membangun anotasi tanpa subtype — pilihan defensif yang benar secara terpisah — tapi ia procedure tanpa return value, dan Inc(Result) berada di luarnya. Masing-masing guard masuk akal sendiri-sendiri; bersama-sama mereka mengubah "tak ada yang jalan" menjadi "semua jalan". Perbaikannya membuat ReadName melewati whitespace, mensyaratkan / di depan sebuah PDF name object, dan berhenti pada delimiter apa pun, termasuk [, ( dan ), sehingga /Subtype/Text maupun /Subtype /Text sama-sama menghasilkan Text

ImportAnnotationsFromFDFString milik PDFlibPas menemukan /Subtype, memulai ReadName pada whitespace setelah key sehingga mengembalikan name kosong, AddAnnotationToPage keluar karena subtype tak ada, dan caller tetap menaikkan hasilnya, melaporkan tujuh komentar terimpor tanpa menambahkan satu pun ke dokumen
Setiap guard masuk akal sendiri-sendiri; bersama-sama mereka mengubah tak ada yang jalan menjadi semua jalan, dan itulah kenapa return value tak boleh menjadi satu-satunya yang diuji saat menguji impor

Return value-nya juga layak diwaspadai meski setelah perbaikan itu. Hingga v3.539.39, ImportAnnotationsFromFDFString tetap menaikkan hasilnya untuk setiap dictionary well-formed di array /Annots, termasuk entri yang /Page zero-based-nya di luar jangkauan atau yang /Subtype-nya hilang, yang keduanya dilewati. Sejak PDFlibPas v3.539.40, ImportAnnotationsFromFDFString dan ImportAnnotationsFromFDF mengembalikan jumlah anotasi yang benar-benar ditambahkan, seperti impor XFDF: helper FDF AddAnnotationToPage kini mengembalikan Boolean dan counter hanya bergerak saat sukses. Mengukur dokumen tetap jadi pengecekan yang lebih kuat karena berlaku juga pada versi lama, jadi sketsa di bawah membandingkan AnnotationCount di setiap halaman sebelum dan sesudah impor

function TotalAnnotations(Lib: TPDFlib): Integer;
var
  Page, Saved: Integer;
begin
  Result := 0;
  Saved := Lib.SelectedPage;
  for Page := 1 to Lib.PageCount do
    if Lib.SelectPage(Page) = 1 then
      Inc(Result, Lib.AnnotationCount);   // per halaman terpilih, widget termasuk
  Lib.SelectPage(Saved);
end;

var
  Lib: TPDFlib;
  Before, Reported, Added: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contract.pdf', '');
    Before := TotalAnnotations(Lib);
    Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
    Added := TotalAnnotations(Lib) - Before;
    if Added <> Reported then   // sama sejak v3.539.40
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

Tiga cacat lain di belakang cacat pertama

Memperbaiki subtype saja akan membuka tiga bug lain di fungsi yang sama, yang masing-masing tak terlihat hanya karena tak pernah ada anotasi yang sampai ke halaman. Pertama, ReadNumber menerima posisinya sebagai parameter value, jadi membaca empat angka /Rect secara berurutan membaca titik yang sama empat kali, dan ia tak melewati [ pembuka, sehingga praktisnya tak membaca apa pun. Kedua, FindKey memakai satu cursor maju yang sama untuk semua lookup. Exporter menulis /Subtype, /Rect, /Page, /Contents, /T, /Subj, tapi importer mencari dengan urutan /Subtype, /Contents, /T, /Subj, /Page, /Rect; begitu cursor melewati /Contents, pencarian /Page dan /Rect berlari melewati entri saat ini dan tak menemukan apa pun atau malah cocok dengan key anotasi berikutnya. Library tak bisa membaca output-nya sendiri. Ketiga, angka-angka melewati PLStrToFloat, yang mengikuti pemisah desimal sistem. ISO 32000-1 §12.7.7 mendefinisikan FDF sebagai sintaks objek PDF, dan key dictionary dalam PDF tak berurutan (§7.3.7), jadi parser FDF mana pun yang mengasumsikan urutan key salah sejak desainnya, apa pun tool yang menghasilkan filenya

Importer yang diperbaiki membatasi setiap entri lebih dulu. FindDictEnd berjalan dari << pembuka ke >> pasangannya, melacak dictionary bersarang dan melewati badan literal string beserta escape backslash-nya, sehingga >> di dalam komentar seperti (see section >> 4) tak bisa mengakhiri entri lebih awal. Setiap lookup key lalu dimulai dari awal entri itu sendiri dan dibatasi sampai akhirnya, yang membuat urutan key jadi tak relevan dan mencegah satu anotasi meminjam /Page milik anotasi lain. Pencocokan key juga menerima delimiter tepat setelah name, karena /Contents(Hi) sama validnya dengan /Contents (Hi), sementara aturan word-boundary menjaga /Subj dari mencocokkan awal /Subtype dan /T dari mencocokkan /Type. ReadNumber kini menerima posisinya sebagai parameter var, melewati whitespace dan [, dan mem-parse dengan PLTryStrToFloatInvariant, yang gagal dengan lembut pada token yang malformed alih-alih raise. Kalau salah satu dari empat angka rectangle gagal, keempatnya jatuh ke nol alih-alih menghasilkan rectangle yang terbaca setengah

FindDictEnd milik PDFlibPas kini membatasi setiap anotasi FDF dari << pembuka sampai >> pasangannya, sehingga setiap lookup key dimulai ulang dari awal entri dan berhenti di akhirnya, dan ReadNumber menerima posisi var, melewati bracket dan mem-parse dengan PLTryStrToFloatInvariant
Cursor bersama tak sanggup membaca export milik library sendiri: begitu melewati /Contents, pencarian /Page dan /Rect berlarut ke key anotasi berikutnya, jadi urutan key tak lagi boleh berpengaruh

Kenapa round-trip FDF menggeser setiap anotasi setinggi dirinya sendiri?

Exporter lama menulis rectangle dalam model koordinat yang salah. /Rect sebuah anotasi adalah [llx lly urx ury] dalam default user space (ISO 32000-1 §12.5.2, dengan rectangle didefinisikan di §7.9.5), dan FDF membawa array yang sama. ExportAnnotationsToFDFString justru memanggil GetAnnotRectEx, yang melaporkan Left, Top, Width, dan Height dalam koordinat gambar milik library, space yang dikendalikan SetOrigin, lalu menserialisasinya sebagai [L T L+W T+H]. Importer, begitu jalan, menulis kembali empat nilai itu apa adanya sebagai rectangle PDF, sehingga tepi atas mendarat di tempat sudut kiri bawah seharusnya dan setiap round trip menggeser anotasi ke atas setinggi dirinya sendiri. Exporter kini menyalin angka /Rect milik anotasi itu sendiri, tiga desimal, pemisah titik, tanpa exponent, dan hanya jatuh ke rectangle hasil perhitungan saat array tersimpan hilang atau bukan empat angka

PDFlibPas dahulu menserialisasi /Rect FDF sebagai left, top, width, height dalam koordinat gambar, sehingga mengimpor kembali keempat angka itu sebagai llx lly urx ury mendaratkan tepi atas di tempat sudut kiri bawah seharusnya dan menggeser setiap anotasi ke atas setinggi dirinya pada setiap round trip
Exporter kini menyalin angka /Rect milik anotasi itu sendiri — tiga desimal, pemisah titik, tanpa exponent — dan regression test membandingkan export kedua dengan yang pertama byte demi byte

Regression test yang mengunci perilaku ini layak disalin, karena ia meng-assert pada dokumen dan pada export kedua, bukan pada return value importer. Perhatikan count yang diharapkan 2: AddNoteAnnotation membuat anotasi Text plus Popup-nya, dan keduanya ikut berpindah. Test juga menjalankan export dan import di bawah pemisah desimal koma, dan di situlah separuh cerita lainnya berada

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // kini dua halaman
    Source.SelectPage(2);
    Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
      'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
    Target.NewPages(1);

    OldSep := FormatSettings.DecimalSeparator;
    FormatSettings.DecimalSeparator := ',';   // simulasikan desktop Jerman atau Prancis
    try
      FDF := Source.ExportAnnotationsToFDFString;   // tetap menulis /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // note-nya dan popup-nya
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Jelaskan dengan tegas apa yang dibawa jalur FDF. Importer membangun ulang setiap entri sebagai dictionary dengan /Type, /Subtype, /Rect, /Contents, /T, dan /Subj; warna, flag, gaya border, link popup, dan appearance stream bukan bagian dari jalur ini, dan exporter melewati anotasi Widget karena form field adalah urusan method form-data. Peta yang lebih besar soal data mana yang berpindah lewat method mana ada di tinjauan pertukaran data form FDF, XFDF dan XFA, dan kalau Anda perlu menginspeksi apa yang benar-benar sampai, reader per-indeks seperti GetAnnotType, GetAnnotTitle dan GetAnnotContentsEx dibahas di introspeksi outline, anotasi dan action

Bagaimana membaca file FDF dan XFDF berdesimal-koma dari export lama?

Untuk FDF jawabannya tegas: koma bukan delimiter dalam sintaks PDF, jadi token angka yang memuat tepat satu koma tanpa titik hanya bisa berupa desimal yang ditulis di mesin berlocale koma. Versi-versi lama memang pernah menulis file seperti itu, misalnya /Rect [10,500 20,250 40,750 60,125], dan ReadNumber yang baru mengubah satu koma itu menjadi titik sebelum mem-parse. Token dengan dua koma, atau koma dan titik sekaligus, ditolak alih-alih ditebak. Reader ini juga tak mengonsumsi notasi exponent, sejalan dengan ISO 32000-1 §7.3.3: angka PDF tak pernah memakainya

XFDF lebih rumit, karena dalam atribut XML koma adalah pemisah. XFDF standar (ISO 19444-1) menulis rect="50.5,80.25,70.75,100.125" dan dashes="4,2", sementara v3.539.28 dan sebelumnya, di sistem berlocale koma, menulis rect="50,500 80,250 70,750 100,125" dan opacity="0,600", dan juga gagal dengan EConvertError saat membaca opacity="0.6" standar. Sejak v3.539.29 kedua arah invariant, dan bentuk legacy dikenali XFDFNormalizeLegacyDecimals hanya ketika atribut terpecah atas whitespace menjadi tepat sebanyak token yang diharapkan (empat untuk rect, satu untuk opacity dan width) dan setiap token berbentuk digit-koma-digit. Rect standar tak pernah cocok: ia berupa satu token dengan tiga koma atau token-token yang berakhir dengan koma. dashes sengaja dibiarkan, karena 4,2 bisa dua panjang dash atau legacy 4.2, dan tak ada aturan yang bisa membedakannya

const
  // Key di luar urutan exporter, plus desimal koma dari export lama berlocale koma
  LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
    '<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
    '/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
    '] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;               // dokumen baru punya satu halaman
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Di-export ulang sebagai XFDF dengan desimal titik: rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Apa yang seharusnya di-assert oleh test impor anotasi?

Test impor yang berguna meng-assert pada state dokumen target, tak pernah hanya pada apa yang importer katakan tentang dirinya. Tak ada satu pun di test suite yang memeriksa AnnotationCount setelah impor FDF, dan return value — satu-satunya angka yang dilihat orang — justru angka yang ditinggal utuh oleh bug. Tiga assertion akan menangkap setiap cacat yang dijelaskan di sini: jumlah anotasi di halaman yang diharapkan, satu field dibaca kembali lewat GetAnnotType atau GetAnnotContentsEx, dan export kedua dibandingkan byte demi byte dengan yang pertama. Disiplin yang sama berlaku untuk API apa pun yang menulis ulang struktur dokumen secara massal, termasuk konsolidasi field yang dijelaskan di menggabungkan form field duplikat: periksa tree hasilnya, bukan total yang dikembalikan. Method anotasi FDF dan XFDF, beserta varian file dan stringnya, tersedia di losLab PDF Library for Delphi dan C++Builder, dan v3.539.30 ke atas adalah versi yang harus dipakai kalau komentar harus selamat dari perjalanan, v3.539.40 ke atas kalau count yang dikembalikan harus cocok dengan yang ditambahkan