PDF Library for Delphi (PDFlibPas) versi sebelum v3.539.47 bisa men-decode teks hasil escape dua kali saat menggambar HTML atau Markdown ke dalam PDF. DrawHTMLText dan DrawHTMLTextBox mem-parse HTML-nya, menormalisasi ulang menjadi HTML, lalu mem-parse lagi, sehingga teks yang ditulis sebagai <unsafe> sampai ke parse kedua sebagai tag sungguhan. Sejak v3.539.47 setiap entity di-decode tepat satu kali dan teks di-escape ulang di mana pun ia berubah kembali menjadi HTML
Skenario yang membuka bug ini sehari-hari saja. Sebuah help desk meng-export tiket ke PDF, dan komentar pelanggan masuk ke template HTML. Developernya sudah berbuat benar dan meng-escape komentar itu, sehingga <b> menjadi <b>. Di dalam renderer, escaping itu diam-diam dibatalkan: komentar keluar tebal, nama tag yang tak dikenal begitu saja lenyap dari halaman, dan anchor hasil escape berubah menjadi link annotation yang bisa diklik. Tanpa exception, tanpa warning, sebuah PDF yang sangat valid tapi isinya berbeda dari datanya
Kenapa teks hasil escape menjadi tag sungguhan di PDF?
Teks hasil escape menjadi markup karena renderer menjalankan dua pass parse, dan langkah normalisasi di antara keduanya menulis teks yang sudah ter-decode kembali menjadi HTML tanpa meng-escape-nya lagi. Setiap decode yang dilakukan parse pertama kini tersedia bagi parse kedua sebagai sintaks hidup
Dua pass itu ada karena alasan yang bagus. Parse pertama membangun daftar elemen tag dan word. NormalizeParsedHTML lalu menyelesaikan cascade stylesheet: ia mencocokkan rule dari blok <style> terhadap tiap tag, menggabungkannya dengan atribut style inline, menyimpan hasilnya di tag, dan menserialisasi seluruh daftar elemen kembali menjadi string HTML. Pass layout mem-parse string ternormalisasi itu. Mesinnya sama dengan yang menggerakkan layout flexbox, CSS grid, dan footnote di rendering HTML PDFlibPas
Cacatnya ada di cara word diserialisasi. Tag ditulis kembali dari bentuk sumber aslinya, sedangkan word ditulis kembali dalam bentuk hasil decode-nya. Word yang oleh parse pertama sudah di-decode dari <unsafe> menjadi <unsafe> mendarat di HTML ternormalisasi sebagai angle bracket mentah, dan parse kedua membacanya sebagai elemen. Mengelilingi bug inti itu ada tiga kebocoran kecil yang mengarah ke tempat yang sama:
&tak termasuk dalam set entity yang didukung, jadiR&Dtercetak literal dan tak ada cara menulis ejaan entity literal seperti<sebagai teks- Tahap menggambar mengganti
untuk kali kedua, setelah parsing sudah selesai, sehingga ejaan entity literal masih bisa lenyap di ujung paling akhir - Escaping code Markdown melewatkan ampersand, dan dataset exporter hanya meng-escape angle bracket, sehingga ejaan entity di dalam code atau nilai sel ter-decode sebagai markup
| Input yang sampai ke renderer | Sebelum v3.539.47 | Sejak v3.539.47 |
|---|---|---|
<unsafe> | Ter-parse sebagai tag, teks tak pernah sampai ke halaman | <unsafe> digambar sebagai teks |
<b>x</b> | x digambar tebal | <b>x</b> digambar sebagai teks |
R&D | R&D tercetak literal | R&D |
&lt; | &lt; tercetak literal | < |
Code span Markdown yang memuat | Menjadi non-breaking space | digambar sebagai teks |
Nilai sel dataset < | < | < |
Bagaimana v3.539.47 membuat decode entity HTML menjadi single-pass
PDFlibPas v3.539.47 membuat decode entity menjadi single-pass dengan tiga perubahan yang saling terkoordinasi: parser men-decode & paling akhir, tahap menggambar tak lagi men-decode apa pun, dan setiap tempat yang mengubah word hasil decode kembali menjadi HTML meng-escape-nya lebih dulu
Set entity yang didukung untuk text content kini adalah <, >, &, dan . Selain itu, termasuk referensi numerik seperti A dan named entity seperti ", tetap teks literal. Boundary itu penting untuk cara Anda meng-escape input sendiri, seperti ditunjukkan di bawah
Urutan di dalam decoder adalah perbaikan pertama. Andai & di-decode lebih dulu, input &lt; akan menjadi < dan penggantian berikutnya mengubahnya menjadi <, double decode yang terjadi di dalam satu pass. Path word ANSI karenanya mengganti <, >, dan lebih dulu, dan & paling akhir, sehingga ampersand yang ia hasilkan tak pernah diperiksa lagi. Path word UTF-16 adalah satu scan kiri-ke-kanan dalam langkah dua byte yang menulis ulang tiap match di tempatnya lalu melewatinya, yang memberi jaminan yang sama secara struktural
Perbaikan kedua membuang penggantian yang terlambat dari tahap menggambar. Decode adalah milik parser dan tak di tempat lain, jadi word yang sampai ke line breaker adalah teks final
Perbaikan ketiga adalah aturan boundary. NormalizeParsedHTML kini meng-escape &, <, dan > di setiap word hasil decode sebelum menambahkannya ke HTML ternormalisasi. Parse kedua men-decode-nya kembali menjadi teks yang persis sama, jadi efek netto di seluruh pipeline adalah satu decode. String continuation mengikuti aturan yang sama: word yang tak muat di box di-escape sebelum ditambahkan ke LeftOverText, dan sisa sisanya disalin dari HTML ternormalisasi yang sudah berbentuk ter-escape. Loop yang mengumpulkan word sisa itu kini juga dibatasi jumlah word, sementara repeat loop lama bisa melangkahi word terakhir
Kenapa escaping UTF-16BE tak bisa memakai replace level byte?
Escaping UTF-16BE tak bisa memakai replace level byte karena pola dua byte untuk ampersand bisa menjulur melintasi dua karakter yang tak berhubungan. Satuan kerja yang benar satu-satunya adalah satu code unit 16-bit utuh
Renderer menyimpan word Unicode sebagai UTF-16 big-endian yang dipadatkan ke byte string, high byte lebih dulu. Ampersand adalah 00 26. Sekarang ambil U+0100 (Latin capital A with macron, byte 01 00) disusul U+2603 (si snowman, byte 26 03). Urutan byte-nya 01 00 26 03, dan byte kedua-ketiga terbaca 00 26. Pencarian byte untuk #0'&' menemukan ampersand yang tak pernah ada, menyelipkan byte & ke tengah dua karakter, dan menggeser setiap karakter berikutnya satu byte
Itu bukan corner case eksotis. Karakter mana pun yang low byte-nya nol bisa menyumbang paruh pertama; U+4E00, salah satu ideograf CJK paling sering muncul, memenuhi syarat. Angle bracket punya eksposur yang sama: 00 3C dan 00 3E muncul setiap kali karakter seperti itu disusul karakter dari U+3C00 sampai U+3EFF di CJK Extension A. Perbaikan di EscapeHTMLWord meng-unpack byte-nya menjadi WideString, meng-escape per karakter, lalu mem-pack hasilnya lagi. Sisi decoder sudah aman karena ia hanya menguji pola di boundary code unit genap
Aturan yang sama berlaku untuk code Anda sendiri. Kalau suatu saat Anda memegang teks UTF-16 sebagai TBytes, misalnya setelah TEncoding.BigEndianUnicode.GetBytes, jangan mencarinya dengan pola byte. Konversikan kembali menjadi string dan bekerja pada karakter
Code block Markdown dan export dataset: escape ampersand lebih dulu
Sejak v3.539.47 kedua produsen HTML di dalam PDFlibPas, converter Markdown dan dataset exporter, meng-escape ampersand sebelum angle bracket, sehingga satu decode di renderer mengembalikan teks asli dengan persis
Di MarkdownToHTML, inline code span serta code block fenced maupun indented kini memetakan & ke &, < ke <, dan > ke >, sementara spasi menjadi dan tab menjadi empat buah agar indentasi terjaga. Prosa Markdown biasa hanya meng-escape angle bracket, jadi HTML mentah di prosa tak bisa menyuntik tag sementara penulis masih bisa menulis & dengan sengaja, persis seperti yang diharapkan penulis Markdown. DrawMarkdownText dan DrawMarkdownTextBox memakai konversi yang sama, jadi code muncul di PDF persis seperti diketik:
uses
System.SysUtils, PDFlibrary;
procedure RenderCodeSample;
var
Lib: TPDFlib;
Md, Html: WideString;
begin
Md := 'Comparison helper:' + sLineBreak + sLineBreak +
'```' + sLineBreak +
'if (A < B) and (Flags <> 0) then' + sLineBreak +
' WriteLn(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// Periksa HTML-nya: di code, '&' menjadi '&' dan '<' menjadi '<'
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // origin kiri atas, Y tumbuh ke bawah
Lib.SetMeasurementUnits(0); // points
// Halaman menampilkan code persis seperti diketik, ejaan entity termasuk
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
Dataset exporter adalah kasus yang paling instruktif. Sebelum v3.539.47 ia hanya meng-escape angle bracket, dan itu memang disengaja: renderer tak men-decode &, jadi meng-escape ampersand justru akan mencetak & di setiap sel yang memuatnya. Workaround itu benar untuk renderer lama dan salah secara umum, karena nilai sel yang kebetulan memuat < ter-decode menjadi <. Dengan renderer yang sudah diperbaiki, exporter meng-escape & lebih dulu, dan nilai seperti R&D < & mendarat di PDF apa adanya. Kalau Anda membangun laporan dengan cara itu, walkthrough di meng-export TDataSet ke laporan PDF di Delphi membahas sisanya
Kenapa ampersand harus lebih dulu layak dijelaskan sekali. Escape < lebih dulu Anda dapat <; escape & kemudian dan itu menjadi &lt;, yang oleh satu decode yang benar ditampilkan sebagai < alih-alih <. Rantai replace berurutan hanya benar ketika escape character-nya sendiri ditangani sebelum apa pun yang memperkenalkannya
Bagaimana seharusnya Anda meng-escape teks tak terpercaya untuk DrawHTMLTextBox?
Untuk rendering HTML PDFlibPas, escape text content tak terpercaya dengan mengganti &, lalu <, lalu >, tepat satu kali, dan jauhkan data tak terpercaya dari nilai atribut sepenuhnya
uses
System.SysUtils, PDFlibrary;
// Meng-escape teks tak terpercaya untuk HTML text content PDFlibPas.
// '&' harus diganti lebih dulu, kalau tidak ampersand di dalam
// '<' yang sudah terbentuk akan ter-escape kali kedua
function EscapeHTMLText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
Result := StringReplace(Result, '>', '>', [rfReplaceAll]);
end;
procedure RenderTicket(const CustomerComment: string);
var
Lib: TPDFlib;
Html: WideString;
begin
Lib := TPDFlib.Create;
try
Lib.SetOrigin(1);
Lib.SetMeasurementUnits(0);
Html := '<p><b>Customer comment</b></p>' +
'<p>' + EscapeHTMLText(CustomerComment) + '</p>';
Lib.DrawHTMLText(50, 50, 495, Html);
Lib.SaveToFile('ticket.pdf');
finally
Lib.Free;
end;
end;
Di v3.539.47 komentar seperti Try <a href="https://example.com">this</a> & <b> muncul di halaman per karakter. Sebelum v3.539.47 input hasil escape yang sama bisa menghasilkan link annotation yang hidup, dan itulah bagian yang mengubah glitch tampilan menjadi masalah keamanan: komentar tiket tak seharusnya bisa menanam URL yang bisa diklik di dokumen yang dipercaya staf Anda
Perhatikan apa yang tak di-escape oleh fungsi itu. HTML escaper serba guna juga mengonversi " ke " dan ' ke ', yang memang tepat untuk browser. Decode teks PDFlibPas hanya mengenali empat entity yang disebut di atas, jadi keduanya akan tercetak literal sebagai " dan '. Tanda kutip tak berbahaya di text content; ia hanya penting di dalam nilai atribut, dan renderer sama sekali tak men-decode entity di atribut. Desain yang aman karenanya bukan escaper yang lebih baik melainkan satu aturan: data tak terpercaya tak pernah masuk ke href, src, atau style. Kalau target link benar-benar harus datang dari data pengguna, validasilah sendiri terhadap allow-list scheme dan karakter, dan tolak apa pun yang memuat tanda kutip atau angle bracket
Dua catatan upgrade mengikuti langsung dari perbaikan ini:
- Kalau code Anda berhenti meng-escape
&karena versi lama mencetak&secara literal, tambahkan kembali. Tanpa itu, teks pengguna yang memuat<kini tampil sebagai<, masih teks yang tak berbahaya tapi bukan lagi apa yang diketik pengguna - Jangan escape dua kali. Teks yang lewat dua escaper merender
<sebagai ejaan terlihat<, jadi temukan satu boundary tempat data Anda masuk ke HTML dan escape hanya di situ
Paginasi dengan LeftOverText tanpa merusak escape
DrawHTMLTextBox mengembalikan HTML yang tak muat, biasanya disebut LeftOverText, dan sejak v3.539.47 sisanya itu melestarikan ejaan entity literal dan angle bracket hasil escape ketika Anda meneruskannya ke box berikutnya. Aturannya bagi caller sederhana: teruskan kembali tanpa diubah
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // ukuran untuk halaman A4 dalam points
BoxHeight = 740;
MaxPages = 500;
procedure RenderLongHTML(Lib: TPDFlib; const Html: WideString);
var
Rest: WideString;
Pages: Integer;
begin
Lib.SetOrigin(1);
Lib.SetMeasurementUnits(0);
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Html);
Pages := 1;
while (Rest <> '') and (Pages < MaxPages) do
begin
Lib.NewPage;
Inc(Pages);
// LeftOverText adalah HTML engine yang sudah ter-escape: jangan escape atau unescape lagi
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
end;
if Rest <> '' then
raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;
Perlakukan sisanya sebagai opaque. Itu adalah HTML ternormalisasi milik engine dengan style yang sudah diselesaikan, jadi jangan menjalankannya lewat escaper Anda sendiri, jangan men-decode-nya, dan jangan menyelipkan teks pengguna ke dalamnya. Batas halaman adalah asuransi murah: kalau ada elemen yang tak akan pernah muat di box, loop tanpa batas tak punya pintu keluar alami
Markdown punya continuation-nya sendiri. DrawMarkdownTextBox mengembalikan token yang diawali marker internal agar panggilan berikutnya bisa melewati konversi; serahkan kembali ke DrawMarkdownTextBox atau DrawMarkdownText, bukan ke entry point HTML yang akan menggambar marker itu sebagai teks
Pelajaran umumnya: decode sekali, re-encode di setiap boundary
Pipeline apa pun yang mem-parse teks, menserialisasi hasilnya kembali menjadi sintaks yang sama lalu mem-parse lagi harus memperlakukan decode sebagai operasi yang terjadi di tepat satu tempat, dan harus re-encode di setiap boundary tempat teks hasil decode menjadi sintaks lagi. Template engine, HTML sanitizer, dan rantai Markdown-ke-HTML-ke-PDF berbagi bentuk ini dan gagal dengan cara yang sama ketika sebuah serializer lupa bahwa ia menghasilkan markup
Gejalanya bisa diprediksi begitu Anda mengenali bentuknya. Re-encode yang terlalu sedikit mengubah data menjadi sintaks, itu arah injeksi. Encoding terlalu banyak, atau decoder yang jalan dua kali, menampilkan ejaan entity ke pembaca atau menelannya, itu arah tampilan. Memperbaiki satu arah saja biasanya merusak arah lainnya, dan itulah kenapa perbaikan PDFlibPas harus menambahkan decode &, mengurutkannya ulang, membuang decode yang terlambat, dan menambahkan re-escaping di release yang sama. Prinsip yang sama berlaku sebaliknya saat konten PDF di-export sebagai structured text, seperti di export semantik PDF ke Markdown dan DOCX dari Delphi, di mana setiap karakter literal harus di-escape untuk sintaks target tepat satu kali
Checklist referensi cepat
- Upgrade ke PDFlibPas v3.539.47 atau lebih baru jika Anda merender HTML atau Markdown yang memuat data pengguna
- Escape text content dengan
&lebih dulu, lalu<dan>; jangan konversi tanda kutip untuk teks PDFlibPas - Escape sekali saja, di satu titik tempat data masuk ke string HTML
- Jauhkan nilai tak terpercaya dari
href,src, danstyle, atau validasi terhadap allow-list - Harapkan hanya
<,>,&, dan yang ter-decode di teks; entity lain tetap literal - Teruskan
LeftOverTextkembali keDrawHTMLTextBoxtanpa diubah dan batasi loop halaman - Serahkan token continuation Markdown hanya ke
DrawMarkdownTextBoxatauDrawMarkdownText - Jangan pernah mencari pola byte di byte buffer UTF-16; bekerja pada code unit utuh
Rendering HTML dan Markdown, export laporan dataset, dan sisanya dari layout engine dikirim dalam source Pascal native milik PDF Library for Delphi, untuk Delphi dan Free Pascal. Lihat halaman produk PDFlibPas untuk edisi, dukungan platform, dan unduhan trial