Teknik Makale

PDFlibPas HTML-to-PDF: çift çözülmüş entity'leri düzeltmek

PDF Library for Delphi (PDFlibPas) v3.539.47 öncesi sürümler, HTML ya da Markdown'ı PDF'e çizerken kaçırılmış metni iki kez çözebiliyordu. DrawHTMLText ve DrawHTMLTextBox HTML'i ayrıştırır, onu HTML'e geri normalize eder ve sonra yeniden ayrıştırır; dolayısıyla <unsafe> yazılmış metin ikinci ayrıştırmaya gerçek bir tag olarak ulaşıyordu. v3.539.47'den beri her entity tam olarak bir kez çözülür ve metin, HTML'e geri döndüğü her yerde yeniden escape edilir

Bunu ortaya çıkaran senaryo sıradandır. Bir help desk talepleri PDF'e aktarır ve müşteri yorumu bir HTML şablonuna girer. Geliştirici doğru olanı yaptı ve yorumu escape etti; <b>, &lt;b&gt; oldu. Renderer'ın içinde o kaçış sessizce geri alındı: yorum bold çıktı, bilinmeyen bir tag adı sayfadan sade bir şekilde kayboldu ve escape edilmiş bir anchor tıklanabilir bir link annotation'a dönüştü. Ne istisna ne uyarı; veriden farklı bir şey söyleyen kusursuzca geçerli bir PDF

Kaçırılmış metin neden PDF'te gerçek bir tag'e dönüşür?

Kaçırılmış metin markup oldu, çünkü renderer iki ayrıştırma geçişi çalıştırıyor ve aralarındaki normalizasyon adımı, hâlihazırda çözülmüş metni yeniden escape etmeden HTML'e geri yazıyordu. İlk ayrıştırmanın yaptığı her çözüm, böylece ikinci ayrıştırmaya canlı sözdizimi olarak sunuluyordu

İki geçişin var olmasının iyi bir nedeni var. İlk ayrıştırma tag ve kelime elementlerinden oluşan bir liste kurar. NormalizeParsedHTML sonra stylesheet cascade'ini çözer: <style> bloklarından gelen kuralları her tag ile eşleştirir, inline style attribute'larıyla birleştirir, sonucu tag üzerinde saklar ve bütün element listesini bir HTML string'ine geri serileştirir. Layout geçişi o normalize edilmiş string'i ayrıştırır. PDFlibPas HTML render'ında flexbox, CSS grid ve footnote layoutu süren de aynı makinedir

Kusur, kelimelerin nasıl serileştirildiğindeydi. Tag'ler özgün kaynak biçimlerinden geri yazılırken kelimeler çözülmüş biçimleriyle geri yazılıyordu. İlk ayrıştırmanın &lt;unsafe&gt;ten <unsafe>'e çözdüğü bir kelime, normalize edilmiş HTML'e çıplak açı parantezleri olarak düşüyor ve ikinci ayrıştırma onu bir element olarak okuyordu. O çekirdek hatanın etrafında aynı yönü gösteren üç daha küçük sızıntı duruyordu:

  • &amp; desteklenen entity setinde yoktu; dolayısıyla R&amp;D literal basılıyor ve &lt; gibi bir literal entity yazımını metin olarak yazmanın yolu yoktu
  • Çizim aşaması &nbsp;'i, ayrıştırma çoktan bitmişken ikinci kez değiştiriyordu; böylece bir literal entity yazımı en sonda hâlâ kaybolabiliyordu
  • Markdown kod kaçışı ampersand'ı atlıyor, dataset exporter'ı yalnızca açı parantezlerini escape ediyordu; dolayısıyla kod içindeki ya da hücre değerlerindeki entity yazımları markup olarak çözülüyordu
DrawHTMLText için PDFlibPas HTML hattı: birinci ayrıştırma elementleri kurar, NormalizeParsedHTML onları HTML'e geri serileştirir ve ikinci ayrıştırma sonucu dizar; v3.539.47 öncesinde çözülmüş kelimeler escape edilmeden geri yazılıyor ve canlı tag'lere dönüşüyordu, v3.539.47'den beri her kelime sınırda yeniden escape edilir
Normalizasyon ürettiği şeyin markup olduğunu unuttuğunda çözülmüş kelimeler ayrıştırıcıya sözdizimi olarak geri girer; kaçırılmış bir yorumun bold olması ya da link bitirmesi böyleydi
Renderer'a ulaşan girdiv3.539.47 öncesiv3.539.47'den beri
&lt;unsafe&gt;Tag olarak ayrıştırıldı, metin sayfaya hiç ulaşmadı<unsafe> metin olarak çizilir
&lt;b&gt;x&lt;/b&gt;x bold çizildi<b>x</b> metin olarak çizilir
R&amp;DR&amp;D literal basıldıR&D
&amp;lt;&amp;lt; literal basıldı&lt;
&nbsp; içeren Markdown code spanNon-breaking space'e dönüştü&nbsp; metin olarak çizilir
Dataset hücre değeri &lt;<&lt;

v3.539.47 HTML entity çözümünü nasıl tek geçişli yapar?

PDFlibPas v3.539.47, entity çözümünü üç eşgüdümlü değişiklikle tek geçişli yapar: ayrıştırıcı &amp;'i en son çözer, çizim aşaması artık hiçbir şeyi çözmez ve çözülmüş kelimeleri HTML'e geri çeviren her yer onları önce yeniden escape eder

Text content için desteklenen entity seti artık &lt;, &gt;, &amp; ve &nbsp;'dir. &#65; gibi sayısal referanslar ile &quot; gibi isimli entity'ler dahil başka her şey literal metin olarak kalır. O sınır, kendi girdinizi nasıl escape edeceğiniz için önemlidir; aşağıda görüldüğü gibi

Decoder'ın içindeki sıra ilk düzeltmedir. &amp; önce çözülseydi &amp;lt; girdisi &lt; olurdu ve sonraki değiştirme onu <'e çevirirdi; tek geçişin içinde gerçekleşen bir çift çözüm. ANSI kelime yolu bu yüzden önce &lt;, &gt; ve &nbsp;'i, &amp;'i ise en son değiştirir; böylece ürettiği ampersand bir daha asla incelenmez. UTF-16 kelime yolu, her eşleşmeyi yerinde yeniden yazıp üzerinden geçen iki baytlık adımlarla yapılan tek bir soldan sağa taramadır; aynı garantiyi yapısal olarak verir

&amp;lt; gibi zincirli bir entity için PDFlibPas decoder sırası: ampersand önce çözülürse tek geçişin içinde gerçek bir açı parantezine çöker; lt, gt ve nbsp'nin ampersandan önce çözülmesi ise literal yazımı bozulmadan tutar ve metin sayfaya tam bir kez çözülmüş olarak ulaşır
Ampersand escape karakteridir; en son çözülmeli ve önce escape edilmelidir, yoksa tek bir geçiş iki kez çözebilir

İkinci düzeltme, geç kalmış &nbsp; değiştirmesini çizim aşamasından çıkarır. Çözüm ayrıştırıcıya aittir ve kimseye değil; dolayısıyla satır kırıcısına ulaşan kelime nihai metindir

Üçüncü düzeltme sınır kuralıdır. NormalizeParsedHTML artık her çözülmüş kelimeyi normalize edilmiş HTML'e eklemeden önce içindeki &, < ve >'yi escape eder. İkinci ayrıştırma onu birebir aynı metne geri çözer; dolayısıyla bütün hat üzerindeki net etki bir çözümdür. Devam string'i aynı kuralı izler: kutuya sığmayan kelimeler LeftOverText'e eklenmeden önce escape edilir ve kalanın gerisi, hâlihazırda escape edilmiş biçimde olan normalize edilmiş HTML'den kopyalanır. O artık kelimeleri toplayan döngü de kelime sayısıyla sınırlıdır; eski repeat döngüsü son kelimenin ötesine adım atabiliyordu

UTF-16BE kaçışı neden bayt düzeyinde bir replace kullanamaz?

UTF-16BE kaçışı bayt düzeyinde bir replace kullanamaz, çünkü ampersand'ın iki baytlık deseni birbirinden bağımsız iki karakterin üzerine yayılabilir. Doğru olan tek iş birimi, bütün 16 bitlik code unit'tir

Renderer Unicode kelimeleri, yüksek bayt önde olacak biçimde big-endian UTF-16 olarak bayt string'lerine paketlenmiş hâlde saklar. Bir ampersand 00 26'dır. Şimdi U+0100'ı (makronlu Latin büyük A, baytları 01 00) ardından U+2603'ü (kardan adam, baytları 26 03) alın. Bayt dizisi 01 00 26 03'tür ve ikinci ile üçüncü baytlar 00 26 okur. #0'&' için yapılan bir bayt araması var olmayan bir ampersand bulur, &amp;'in baytlarını iki karakterin ortasına ekler ve izleyen her karakteri bir bayt kaydırır

PDFlibPas UTF-16BE kaçışı tehlikesi: U+0100 ve U+2603 için 01 00 26 03 baytları iki karakter boyunca 00 26 desenini taşır; dolayısıyla ampersand için bayt düzeyinde arama bir entity'yi code point'in ortasına ekler. Code unit taraması yalnızca çift ofsetleri sınar
Bayt araması, hiçbir karakterin hiç taşımadığı bir ampersand bulur; bütün code unit'ler üzerinde çalışın, ham UTF-16 bayt buffer'ları üzerinde asla

Bu egzotik bir uç durum değildir. Düşük baytı sıfır olan her karakter ilk yarıyı sağlayabilir; en sık görülen CJK ideograflarından U+4E00 bu tanıma uyar. Açı parantezleri de aynı maruziyettedir: 00 3C ile 00 3E, CJK Extension A'da U+3C00 ile U+3EFF arasından bir karakterin böyle bir karakteri izlemesi hâlinde görünür. EscapeHTMLWord'deki düzeltme baytları bir WideString'e açar, karakter karakter escape eder ve sonucu yeniden paketler. Decoder tarafı hâlihazırda güvendeydi, çünkü desenleri yalnızca çift code unit sınırlarında sınar

Aynı kural kendi kodunuz için de geçerlidir. UTF-16 metnini bir TBytes olarak tutuyorsanız, örneğin TEncoding.BigEndianUnicode.GetBytes sonrasında, onu bayt desenleri için aramayın. String'e geri çevirin ve karakterler üzerinde çalışın

Markdown code block'ları ve dataset dışa aktarımları: önce ampersand'ı escape edin

v3.539.47'den beri PDFlibPas'ın içindeki iki HTML üreticisi de, Markdown converter ile dataset exporter, ampersand'ı açı parantezlerinden önce escape eder; böylece renderer'daki tek çözüm özgün metni birebir geri getirir

MarkdownToHTML'da inline code span'ler ile fenced ya da girintili code block'lar artık &'i &amp;'e, <'i &lt;'e ve >'yi &gt;'e eşler; boşluklar &nbsp; olur ve girintiyi korumak için bir tab dört tanesine dönüşür. Sıradan Markdown düzyazısı yalnızca açı parantezlerini escape eder; böylece düzyazıdaki ham HTML tag enjekte edemezken bir yazar &amp;'i hâlâ kasıtlı yazabilir, tam da Markdown yazarlarının beklediği gibi. DrawMarkdownText ile DrawMarkdownTextBox aynı dönüşümü kullanır; dolayısıyla kod PDF'te yazıldığı gibi görünür:

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
    // HTML'i inceleyin: kod içinde '&', '&amp;' olur ve '<', '&lt;' olur
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // sol üst köken, Y aşağı doğru büyür
    Lib.SetMeasurementUnits(0);  // point
    // Sayfa kodu yazıldığı gibi gösterir, entity yazımları dahil
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Asıl öğretici örnek dataset exporter'dır. v3.539.47 öncesinde yalnızca açı parantezlerini escape ediyordu ve bu kasıtlıydı: renderer &amp;'i çözmüyordu; dolayısıyla ampersand'ı escape etmek onu taşıyan her hücreye &amp; basacaktı. Geçici çözüm eski renderer için doğruydu, genelde yanlış: &lt; taşıyan bir hücre değeri <'e çözülüyordu. Renderer düzelince exporter önce &'i escape eder ve R&D &lt; &amp; &nbsp; gibi bir değer PDF'e birebir düşer. Raporları böyle kuruyorsanız Delphi'de bir TDataSet'i PDF raporuna aktarma gezintisi exporter'ın geri kalanını kapsar

Ampersand'ın neden önce gitmesi gerektiğini bir kez açıkça yazmaya değer. Önce <'i escape ederseniz &lt; alırsınız; sonra &'i escape ederseniz bu &amp;lt; olur ve doğru bir tek çözüm bunu < yerine &lt; gösterir. Sıralı bir replace zinciri yalnızca escape karakterinin kendisi, onu üreten her şeyden önce ele alındığında doğrudur

DrawHTMLTextBox için güvenilmeyen metni nasıl escape etmelisiniz?

PDFlibPas HTML render'ında güvenilmeyen text content'i, &'i, sonra <'i, sonra >'yi tam bir kez değiştirerek escape edin ve güvenilmeyen veriyi attribute değerlerinin tamamen dışında tutun

uses
  System.SysUtils, PDFlibrary;

// Güvenilmeyen metni PDFlibPas HTML text content'i için escape eder.
// '&' önce değiştirilmeli, yoksa hâlihazırda üretilmiş bir
// '&lt;' içindeki ampersand ikinci kez escape edilirdi
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;

v3.539.47'de Try <a href="https://example.com">this</a> & &lt;b&gt; gibi bir yorum sayfada karakter karakter görünür. v3.539.47 öncesinde aynı escape edilmiş girdi canlı bir link annotation üretebiliyordu; görüntü bozukluğunu güvenlik sorununa çeviren kısım da budur: bir talep yorumu, personelinizin güvendiği bir belgeye tıklanabilir bir URL ekleyebilmemeli

Fonksiyonun neyi escape etmediğine dikkat edin. Genel amaçlı HTML escaper'ları ayrıca " işaretini &quot;'e ve ' işaretini &#39;'e çevirir; bu bir tarayıcı için doğrudur. PDFlibPas metin çözümü yalnızca yukarıda sayılan dört entity'yi tanır; dolayısıyla o ikisi &quot; ile &#39; olarak literal basılırdı. Tırnaklar text content'te zararsızdır; yalnızca attribute değerlerinin içinde önem taşırlar ve renderer attribute'lardaki entity'leri hiç çözmüyor. Güvenli tasarım bu yüzden daha iyi bir escaper değil bir kuraldır: güvenilmeyen veri asla href'e, src'ye ya da style'a girmez. Bir link hedefi gerçekten kullanıcı verisinden gelmek zorundaysa kendiniz şema ve karakter allow-list'ine karşı doğrulayın ve tırnak ya da açı parantezi taşıyan her şeyi reddedin

Düzeltmeden doğrudan iki yükseltme notu çıkar:

  • Kodunuz, eski sürümler &amp;'i literal bastığı için &'i escape etmeyi bıraktıysa onu geri ekleyin. Onsuz, &lt; içeren kullanıcı metni artık < görüntüler; hâlâ zararsız metin ama artık kullanıcının yazdığı şey değil
  • İki kez escape etmeyin. İki escaper'dan geçen metin <'i görünür &lt; yazımıyla render eder; dolayısıyla verinizin HTML'e girdiği tek sınırı bulun ve yalnızca orada escape edin

LeftOverText ile sayfalama, kaçışları bozmadan

DrawHTMLTextBox sığmayan HTML'i döndürür; genelde adı LeftOverText'tir. v3.539.47'den beri o kalan, bir sonraki kutuya geçirdiğinizde literal entity yazımlarını ve escape edilmiş açı parantezlerini korur. Çağıranlar için kural basittir: onu değiştirmeden geri geçirin

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // point cinsinden A4 sayfa için boyutlandı
  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 hâlihazırda escape edilmiş engine HTML'idir: asla escape etmeyin ya da geri çözmeyin
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Kalanı opak sayın. O, stilleri hâlihazırda çözülmüş engine'in normalize edilmiş HTML'idir; dolayısıyla kendi escaper'ınızdan geçirmeyin, çözmeyin ve içine kullanıcı metni dikmeyin. Sayfa tavanı ucuz bir sigortadır: bir element kutuya hiç sığmayacaksa tavansız bir döngünün doğal bir çıkışı yoktur

Markdown'ın kendi devamı vardır. DrawMarkdownTextBox, içsel bir marker ile başlayan bir token döndürür; böylece sonraki çağrı dönüşümü atlayabilir. Onu DrawMarkdownTextBox'a ya da DrawMarkdownText'e verin, HTML giriş noktalarına değil; onlar marker'ı metin olarak çizerdi

Genel ders: bir kez çöz, her sınıtta yeniden kodla

Metni ayrıştıran, sonucu aynı sözdizimine geri serileştiren ve yeniden ayrıştıran her hattın çözümü tam olarak tek bir yerde gerçekleşen bir işlem sayması ve çözülmüş metnin yeniden sözdizime dönüştüğü her sınıtta yeniden kodlaması gerekir. Template engine'leri, HTML sanitizer'ları ve Markdown-to-HTML-to-PDF zincirleri aynı biçimi paylaşır ve bir serileştirici markup ürettiğini unuttuğunda aynı şekilde çöker

Biçimi bilince belirtiler öngörülebilir hâle gelir. Az yeniden kodlama veriyi sözdizimine çevirir; enjeksiyon yönü budur. Çok kodlama ya da iki kez koşan bir decoder ise entity yazımlarını okura gösterir ya da yer; görüntü yönü budur. Tek yönü düzeltmek genellikle ötekini bozar; PDFlibPas düzeltmesinin aynı sürümde &amp; çözümünü eklemesi, sırasını değiştirmesi, geç çözümü kaldırması ve yeniden kaçışı eklemesi bu yüzden zorunluydu. Aynı ilke ters yönde de işler; PDF içeriği yapılandırılmış metin olarak dışa aktarıldığında, Delphi'den PDF-to-Markdown ve DOCX semantik dışa aktarmadaki gibi, her literal karakter hedef sözdizimi için tam bir kez escape edilmelidir

Hızlı başvuru listesi

  • Kullanıcı verisi içeren HTML ya da Markdown render ediyorsanız PDFlibPas v3.539.47 ve sonrasına yükseltin
  • Text content'i önce &, sonra < ve > ile escape edin; PDFlibPas metni için tırnakları çevirmeyin
  • Bir kez escape edin, verinin HTML string'ine girdiği tek noktada
  • Güvenilmeyen değerleri href, src ve style dışında tutun ya da bir allow-list'e karşı doğrulayın
  • Metinde çözülmeyi yalnızca &lt;, &gt;, &amp; ve &nbsp;'den bekleyin; öbür entity'ler literal kalır
  • LeftOverText'i değiştirmeden DrawHTMLTextBox'a geri geçirin ve sayfa döngüsünü tavanlayın
  • Markdown devam token'larını yalnızca DrawMarkdownTextBox'a ya da DrawMarkdownText'e geçirin
  • UTF-16 bayt buffer'larında asla bayt deseni aramayın; bütün code unit'ler üzerinde çalışın

HTML ve Markdown render'ı, dataset rapor dışa aktarımı ve layout engine'in geri kalanı, Delphi ve Free Pascal için PDF Library for Delphi'nin native Pascal kaynak kodunda gelir. Sürümler, platform desteği ve deneme indirmesi için PDFlibPas ürün sayfasına bakın