技術文章

PDFlibPas 修正 HTML 與 Markdown 的實體雙重解碼

PDF Library for Delphi(PDFlibPas)在 v3.539.47 之前的版本,把 HTML 或 Markdown 畫進 PDF 時可能把跳脫過的文字解碼兩次。DrawHTMLText 與 DrawHTMLTextBox 先剖析 HTML、把結果正規化回 HTML、然後再剖析一次,所以寫成 <unsafe> 的文字到了第二次剖析就成了真標籤。從 v3.539.47 起,每個實體恰好解碼一次,而文字只要變回 HTML 之處都重新跳脫

把這問題暴露出來的場景再普通不過。客服系統把工單匯出成 PDF,客戶留言放進 HTML 範本。開發者做對了事,把留言跳脫過,<b> 變成了 &lt;b&gt;。在渲染器內部,這份跳脫被悄悄拆掉:留言以粗體現身、不認得的標籤名直接從頁面上消失、跳脫過的錨點變成一個可以點的連結註解。沒有例外、沒有警告,一份完全合法、內容卻跟資料對不上的 PDF

跳脫過的文字為什麼會在 PDF 裡變成真標籤?

跳脫文字之所以變成標記,是因為渲染器跑兩次剖析,中間的正規化步驟把已經解碼的文字寫回 HTML 時沒有再跳脫一次。第一次剖析做過的每一次解碼,到了第二次剖析手裡都成了活生生的語法

這兩次剖析存在有正當理由。第一次剖析建出標籤與文字元素的清單,NormalizeParsedHTML 接著解析樣式表串接:把 <style> 區塊裡的規則對到每個標籤、跟行內 style 屬性合併、把結果存在標籤上,再把整份元素清單序列化回 HTML 字串。版面階段剖析的是這份正規化後的字串。驅動 PDFlibPas HTML 渲染的 flexbox、CSS grid 與註腳排版的,正是同一套機器

缺陷出在文字的序列化方式。標籤按原始的原始碼形式寫回,文字卻以解碼後的形式寫回。第一次剖析把 &lt;unsafe&gt; 解成 <unsafe> 的那個字,落進正規化 HTML 時就是裸的角括號,第二次剖析便把它讀成一個元素。核心 bug 周圍還有三個較小的破口,指向同一個方向:

  • &amp; 不在支援的實體集合裡,所以 R&amp;D 被原樣印出,也沒辦法把 &lt; 這樣的實體拼寫當成文字寫出來
  • 繪圖階段在剖析早已結束之後,又把 &nbsp; 替換了第二次,所以實體拼寫還是可能在最後關頭消失
  • Markdown 程式碼的跳脫漏掉 ampersand,資料集匯出器又只跳脫角括號,所以程式碼或儲存格值裡的實體拼寫被當成標記解碼
PDFlibPas 的 DrawHTMLText HTML 管線示意圖:第一次剖析建出元素,NormalizeParsedHTML 把它們序列化回 HTML,第二次剖析做排版;v3.539.47 之前解碼後的文字原樣寫回、復活成活標籤,v3.539.47 起每個字都在邊界重新跳脫
正規化器忘了自己產出的是標記時,解碼後的文字就會以語法身分重回剖析器,跳脫過的留言就是這樣變粗體、長出連結的
抵達渲染器的輸入v3.539.47 之前v3.539.47 起
&lt;unsafe&gt;被當成標籤剖析,文字永遠到不了頁面<unsafe> 以文字畫出
&lt;b&gt;x&lt;/b&gt;x 以粗體畫出<b>x</b> 以文字畫出
R&amp;DR&amp;D 原樣印出R&D
&amp;lt;&amp;lt; 原樣印出&lt;
含 &nbsp; 的 Markdown 程式碼片段變成不換行空格&nbsp; 以文字畫出
資料集儲存格值 &lt;<&lt;

v3.539.47 怎麼讓 HTML 實體解碼變成單趟

PDFlibPas v3.539.47 用三個互相配合的改動把實體解碼變成單趟:剖析器最後才解碼 &amp;、繪圖階段不再解碼任何東西,而每個把解碼文字寫回 HTML 的地方都先重新跳脫

文字內容支援的實體集合現在是 &lt;、&gt;、&amp; 與 &nbsp;。其他一切,包括 &#65; 這類數字參照與 &quot; 這類具名實體,都保持原樣文字。這條邊界牽涉到您該怎麼跳脫自己的輸入,下面會示範

解碼器內部的順序是第一個修正。要是 &amp; 先解碼,輸入 &amp;lt; 會變成 &lt;,下一次替換又把它變成 <——雙重解碼發生在同一趟裡面。所以 ANSI 文字路徑先替換 &lt;、&gt; 與 &nbsp;,最後才替換 &amp;,它產出的 ampersand 從此不會再被檢查。UTF-16 文字路徑則是單趟由左至右、兩位元組一步的掃描,原地改寫每個匹配後跳過它,結構上就給出同樣的保證

PDFlibPas 對 &amp;lt; 這類鏈式實體的解碼順序示意圖:先解碼 ampersand 會在同一趟內把它塌縮成真的角括號,先解碼 lt、gt 與 nbsp 再解碼 ampersand 則保住原樣拼寫,文字抵達頁面時恰好解碼一次
ampersand 就是跳脫字元,所以必須最後解碼、最先跳脫,否則同一趟可能解碼兩次

第二個修正把繪圖階段裡晚到的 &nbsp; 替換拿掉。解碼只屬於剖析器,別無他處,所以抵達斷行器的文字就是定稿

第三個修正是一條邊界規則。NormalizeParsedHTML 現在把每個解碼文字裡的 &、< 與 > 都跳脫過,才附加到正規化 HTML。第二次剖析把它解回一模一樣的文字,整條管線的淨效果因此是一次解碼。接續字串走同一條規則:裝不進框子的文字先跳脫才附加到 LeftOverText,其餘剩餘部分則從本來就是跳脫形式的正規化 HTML 複製而來。收集這些剩餘文字的迴圈現在也以字數為界,舊的 repeat 迴圈可能踏過最後一個字

UTF-16BE 的跳脫為什麼不能用位元組層級的替換?

UTF-16BE 的跳脫不能用位元組層級替換,因為 ampersand 的兩位元組模式可能橫跨兩個不相干的字元。唯一正確的工作單位是完整的 16 位元 code unit

渲染器把 Unicode 文字存成打包進位元組字串的 big-endian UTF-16,高位元組在前。ampersand 是 00 26。現在拿 U+0100(帶長音符的拉丁大寫 A,位元組 01 00)後面跟著 U+2603(雪人符號,位元組 26 03)。位元組序列是 01 00 26 03,第二與第三個位元組讀出來正是 00 26。按位元組搜尋 #0'&' 會找到一個不存在的 ampersand,把 &amp; 的位元組拼進兩個字元的中間,之後每個字元都被剪掉一位元組

PDFlibPas 的 UTF-16BE 跳脫陷阱示意圖:U+0100 與 U+2603 的位元組 01 00 26 03 跨越兩個字元含著 00 26 模式,按位元組搜尋 ampersand 會把實體拼進一個 code point 的中間;code unit 掃描只測偶數偏移
位元組搜尋會找到沒有任何字元真正擁有的 ampersand;請在完整的 code unit 上工作,別碰原始 UTF-16 位元組緩衝區

這不是什麼刁鑽的邊角案例。低位元組為零的字元都能供出前半;CJK 最常用字元之一的 U+4E00 就符合。角括號有同樣的曝險:只要這類字元後面跟著 CJK Extension A 裡 U+3C00 到 U+3EFF 的字元,00 3C 與 00 3E 就會出現。EscapeHTMLWord 的修法是把位元組拆成 WideString、逐字元跳脫、再把結果打包回去。解碼端本來就安全,因為它只在偶數 code unit 邊界上測模式

同一條規則也適用於您自己的程式碼。要是您曾把 UTF-16 文字放在 TBytes 裡,例如呼叫過 TEncoding.BigEndianUnicode.GetBytes 之後,不要對它做位元組模式搜尋。轉回字串,在字元上工作

Markdown 程式碼區塊與資料集匯出:先跳脫 ampersand

從 v3.539.47 起,PDFlibPas 內的兩個 HTML 產生器——Markdown 轉換器與資料集匯出器——都先跳脫 ampersand 再跳脫角括號,渲染器裡那一次解碼因此還原出與原文完全相同的文字

在 MarkdownToHTML 裡,行內程式碼片段與圍欄或縮排程式碼區塊現在把 & 映射成 &amp;、< 映射成 &lt;、> 映射成 &gt;,空格則變成 &nbsp;、tab 變成四個,以保住縮排。一般 Markdown 散文只跳脫角括號,所以散文裡的裸 HTML 注入不了標籤,而作者照樣能刻意寫出 &amp;,跟 Markdown 作者的預期差不多。DrawMarkdownText 與 DrawMarkdownTextBox 用同一套轉換,所以程式碼在 PDF 裡現出的是打進去的那個模樣:

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:在程式碼裡,'&' 變成 '&amp;','<' 變成 '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // 左上角原點,Y 向下增長
    Lib.SetMeasurementUnits(0);  // points
    // 頁面按原樣顯示程式碼,含實體拼寫
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

資料集匯出器是最有教育意義的案例。v3.539.47 之前它只跳脫角括號,而且是故意的:渲染器不解碼 &amp;,跳脫 ampersand 會讓每個含它的儲存格印出 &amp;。那個權宜之計對舊渲染器是對的、一般而言是錯的,因為碰巧含 &lt; 的儲存格值被解成了 <。渲染器修好之後,匯出器先跳脫 &,R&D &lt; &amp; &nbsp; 這樣的值便一字不差落進 PDF。如果您照這個路子做報表,在 Delphi 裡把 TDataSet 匯出成 PDF 報表那篇逐步解說涵蓋匯出器的其餘部分

為什麼 ampersand 必須排第一,值得說一次清楚。先跳脫 < 得到 &lt;;第二步跳脫 &,它就變成 &amp;lt;,正確的單次解碼會把它顯示成 &lt; 而不是 <。循序替換鏈只有在一個條件下才正確:跳脫字元本身先於任何會引入它的東西被處理

面對 DrawHTMLTextBox,不可信文字該怎麼跳脫?

在 PDFlibPas 的 HTML 渲染下,跳脫不可信文字內容的方式是替換 &、再 <、再 >,各恰好一次,並且讓不可信資料完全遠離屬性值

uses
  System.SysUtils, PDFlibrary;

// 為 PDFlibPas 的 HTML 文字內容跳脫不可信文字。
// '&' 必須先替換,否則已產出的 '&lt;' 裡的
// ampersand 會被第二次跳脫
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 上,Try <a href="https://example.com">this</a> & &lt;b&gt; 這樣的留言會逐字元出現在頁面上。v3.539.47 之前,同樣的跳脫輸入可能生出一個活的連結註解——顯示問題因此升格成安全問題:工單留言不該有能力在您的員工信任的文件裡種下一個可點擊的 URL

注意這個函式不跳脫什麼。通用 HTML 跳脫器還會把 " 轉成 &quot;、把 ' 轉成 &#39;,對瀏覽器這是對的。PDFlibPas 的文字解碼只認得前面列的四個實體,這兩個會被原樣印成 &quot; 與 &#39;。引號在文字內容裡無害;它們只在屬性值裡要緊,而渲染器根本不解碼屬性裡的實體。所以安全的設計不是更好的跳脫器,而是一條規則:不可信資料絕不進 href、src 或 style。連結目標真的必須來自使用者資料時,自己對著 scheme 與字元的允許清單驗證,含引號或角括號的一律拒收

從這個修正直接推出兩條升級注意事項:

  • 如果您的程式碼因為舊版會把 &amp; 原樣印出而停跳 &,請把它加回來。不加的話,含 &lt; 的使用者文字現在會顯示成 <,仍是無害文字,但已不是使用者打的那串
  • 不要跳脫兩次。過了兩道跳脫器的文字會把 < 渲染成可見的 &lt; 拼寫,所以找出資料進入 HTML 的那一個邊界,只在那裡跳脫

用 LeftOverText 分頁而不弄壞跳脫

DrawHTMLTextBox 回傳裝不下的 HTML,一般稱作 LeftOverText,從 v3.539.47 起,把這份剩餘傳給下一個框子時,實體拼寫與跳脫過的角括號都原樣保留。呼叫方的規則很簡單:原封不動傳回去

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // 以 points 計的 A4 頁面尺寸
  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 已是跳脫過的引擎 HTML:絕不再跳脫或反跳脫
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

把剩餘部分當成不透明。它是引擎的正規化 HTML、樣式已經解析完畢,所以別把它送進您自己的跳脫器、別解碼它、也別把使用者文字拼進去。頁數上限是便宜的保險:某個元素要是永遠裝不進框子,沒有上限的迴圈找不到自然的出口

Markdown 有自己的接續方式。DrawMarkdownTextBox 回傳一個以內部標記開頭的 token,下一次呼叫便可以跳過轉換;把它交回 DrawMarkdownTextBox 或 DrawMarkdownText,別交給 HTML 進入點,那會把標記當成文字畫出來

通用教訓:解碼一次,在每個邊界重新編碼

任何「剖析文字、把結果序列化回同一套語法、再剖析一次」的管線,都必須把解碼當成只發生在一個地方的操作,並且在解碼文字重新變成語法的每個邊界重新編碼。樣板引擎、HTML 消毒器與 Markdown 到 HTML 再到 PDF 的鏈條都是這個形狀,序列化器一忘記自己產出的是標記,就以同樣的方式壞掉

知道這個形狀之後,症狀是可以預測的。重新編碼太少,資料變成語法,這是注入方向。編碼太多,或解碼器跑了兩次,讀者會看到實體拼寫、或拼寫被吃掉,這是顯示方向。只修一個方向通常會弄壞另一個,這就是為什麼 PDFlibPas 的修正必須在同一個版本裡加上 &amp; 解碼、調整順序、拿掉晚到的解碼並加入重新跳脫。同一條原理在 PDF 內容被匯出成結構化文字時反著跑,如 從 Delphi 做 PDF 轉 Markdown 與 DOCX 的語意匯出一文,那裡每個字面字元都必須對目標語法恰好跳脫一次

速查清單

  • 只要渲染的 HTML 或 Markdown 含使用者資料,就升級到 PDFlibPas v3.539.47 以上
  • 文字內容先跳脫 &,再 < 與 >;PDFlibPas 的文字不要轉換引號
  • 只跳脫一次,就在資料進入 HTML 字串的那一個點
  • 不可信的值不要放進 href、src 與 style,或對著允許清單驗證
  • 文字裡只有 &lt;、&gt;、&amp; 與 &nbsp; 會被解碼;其他實體保持原樣
  • LeftOverText 原封不動傳回 DrawHTMLTextBox,並給分頁迴圈設上限
  • Markdown 接續 token 只交給 DrawMarkdownTextBox 或 DrawMarkdownText
  • 絕不對 UTF-16 位元組緩衝區做位元組模式搜尋;在完整 code unit 上工作

HTML 與 Markdown 渲染、資料集報表匯出與版面引擎的其餘部分,都隨 PDF Library for Delphi 的原生 Pascal 原始碼出貨,支援 Delphi 與 Free Pascal。版本、平台支援與試用版下載請見 PDFlibPas 產品頁