Artikel Teknis

Entity Tak Ter-decode Ganda di HTML dan Markdown PDFlibPas

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 &lt;b&gt;. 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 &lt;unsafe&gt; 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:

  • &amp; tak termasuk dalam set entity yang didukung, jadi R&amp;D tercetak literal dan tak ada cara menulis ejaan entity literal seperti &lt; sebagai teks
  • Tahap menggambar mengganti &nbsp; 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
Pipeline HTML PDFlibPas untuk DrawHTMLText di mana parse pertama membangun elemen, NormalizeParsedHTML menserialisasinya kembali menjadi HTML dan parse kedua me-layout hasilnya; sebelum v3.539.47 word hasil decode ditulis ulang tanpa escape dan menjadi tag hidup, sejak v3.539.47 setiap word di-escape ulang di boundary
Word hasil decode masuk kembali ke parser sebagai sintaks ketika normalizer lupa bahwa ia menghasilkan markup, itulah cara komentar hasil escape jadi tebal atau tumbuh link
Input yang sampai ke rendererSebelum v3.539.47Sejak v3.539.47
&lt;unsafe&gt;Ter-parse sebagai tag, teks tak pernah sampai ke halaman<unsafe> digambar sebagai teks
&lt;b&gt;x&lt;/b&gt;x digambar tebal<b>x</b> digambar sebagai teks
R&amp;DR&amp;D tercetak literalR&D
&amp;lt;&amp;lt; tercetak literal&lt;
Code span Markdown yang memuat &nbsp;Menjadi non-breaking space&nbsp; digambar sebagai teks
Nilai sel dataset &lt;<&lt;

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 &amp; 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 &lt;, &gt;, &amp;, dan &nbsp;. Selain itu, termasuk referensi numerik seperti &#65; dan named entity seperti &quot;, 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 &amp; di-decode lebih dulu, input &amp;lt; akan menjadi &lt; dan penggantian berikutnya mengubahnya menjadi <, double decode yang terjadi di dalam satu pass. Path word ANSI karenanya mengganti &lt;, &gt;, dan &nbsp; lebih dulu, dan &amp; 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

Urutan decoder PDFlibPas untuk entity berantai seperti &amp;lt: men-decode ampersand lebih dulu menciutkannya menjadi angle bracket sungguhan di dalam satu pass, sedangkan men-decode lt, gt dan nbsp sebelum ampersand menjaga ejaan literal tetap utuh sehingga teks sampai ke halaman ter-decode tepat satu kali
Ampersand adalah escape character, jadi ia harus di-decode paling akhir dan di-escape paling awal, atau satu pass bisa men-decode dua kali

Perbaikan kedua membuang penggantian &nbsp; 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 &amp; ke tengah dua karakter, dan menggeser setiap karakter berikutnya satu byte

Bahaya escaping UTF-16BE di PDFlibPas di mana byte 01 00 26 03 untuk U+0100 dan U+2603 memuat pola 00 26 melintasi dua karakter, sehingga pencarian ampersand level byte menyelipkan entity ke tengah sebuah code point; scan code unit hanya menguji offset genap
Pencarian byte menemukan ampersand yang tak pernah dimiliki karakter mana pun; bekerja pada code unit utuh, bukan pada byte buffer UTF-16 mentah

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 &amp;, < ke &lt;, dan > ke &gt;, sementara spasi menjadi &nbsp; 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 &amp; 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(''&lt;tag&gt; &amp; R&amp;D'');' + sLineBreak +
        '```';
  Lib := TPDFlib.Create;
  try
    // Periksa HTML-nya: di code, '&' menjadi '&amp;' dan '<' menjadi '&lt;'
    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 &amp;, jadi meng-escape ampersand justru akan mencetak &amp; di setiap sel yang memuatnya. Workaround itu benar untuk renderer lama dan salah secara umum, karena nilai sel yang kebetulan memuat &lt; ter-decode menjadi <. Dengan renderer yang sudah diperbaiki, exporter meng-escape & lebih dulu, dan nilai seperti R&D &lt; &amp; &nbsp; 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 &lt;; escape & kemudian dan itu menjadi &amp;lt;, yang oleh satu decode yang benar ditampilkan sebagai &lt; 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
// '&lt;' yang sudah terbentuk akan ter-escape kali kedua
function EscapeHTMLText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [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> & &lt;b&gt; 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 &quot; dan ' ke &#39;, yang memang tepat untuk browser. Decode teks PDFlibPas hanya mengenali empat entity yang disebut di atas, jadi keduanya akan tercetak literal sebagai &quot; dan &#39;. 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 &amp; secara literal, tambahkan kembali. Tanpa itu, teks pengguna yang memuat &lt; 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 &lt;, 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 &amp;, 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, dan style, atau validasi terhadap allow-list
  • Harapkan hanya &lt;, &gt;, &amp;, dan &nbsp; yang ter-decode di teks; entity lain tetap literal
  • Teruskan LeftOverText kembali ke DrawHTMLTextBox tanpa diubah dan batasi loop halaman
  • Serahkan token continuation Markdown hanya ke DrawMarkdownTextBox atau DrawMarkdownText
  • 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