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
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
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
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