PDF Library for Delphi(PDFlibPas)在 v3.539.47 之前的版本,把 HTML 或 Markdown 畫進 PDF 時可能把跳脫過的文字解碼兩次。DrawHTMLText 與 DrawHTMLTextBox 先剖析 HTML、把結果正規化回 HTML、然後再剖析一次,所以寫成 <unsafe> 的文字到了第二次剖析就成了真標籤。從 v3.539.47 起,每個實體恰好解碼一次,而文字只要變回 HTML 之處都重新跳脫
把這問題暴露出來的場景再普通不過。客服系統把工單匯出成 PDF,客戶留言放進 HTML 範本。開發者做對了事,把留言跳脫過,<b> 變成了 <b>。在渲染器內部,這份跳脫被悄悄拆掉:留言以粗體現身、不認得的標籤名直接從頁面上消失、跳脫過的錨點變成一個可以點的連結註解。沒有例外、沒有警告,一份完全合法、內容卻跟資料對不上的 PDF
跳脫過的文字為什麼會在 PDF 裡變成真標籤?
跳脫文字之所以變成標記,是因為渲染器跑兩次剖析,中間的正規化步驟把已經解碼的文字寫回 HTML 時沒有再跳脫一次。第一次剖析做過的每一次解碼,到了第二次剖析手裡都成了活生生的語法
這兩次剖析存在有正當理由。第一次剖析建出標籤與文字元素的清單,NormalizeParsedHTML 接著解析樣式表串接:把 <style> 區塊裡的規則對到每個標籤、跟行內 style 屬性合併、把結果存在標籤上,再把整份元素清單序列化回 HTML 字串。版面階段剖析的是這份正規化後的字串。驅動 PDFlibPas HTML 渲染的 flexbox、CSS grid 與註腳排版的,正是同一套機器
缺陷出在文字的序列化方式。標籤按原始的原始碼形式寫回,文字卻以解碼後的形式寫回。第一次剖析把 <unsafe> 解成 <unsafe> 的那個字,落進正規化 HTML 時就是裸的角括號,第二次剖析便把它讀成一個元素。核心 bug 周圍還有三個較小的破口,指向同一個方向:
&不在支援的實體集合裡,所以R&D被原樣印出,也沒辦法把<這樣的實體拼寫當成文字寫出來- 繪圖階段在剖析早已結束之後,又把
替換了第二次,所以實體拼寫還是可能在最後關頭消失 - Markdown 程式碼的跳脫漏掉 ampersand,資料集匯出器又只跳脫角括號,所以程式碼或儲存格值裡的實體拼寫被當成標記解碼
| 抵達渲染器的輸入 | v3.539.47 之前 | v3.539.47 起 |
|---|---|---|
<unsafe> | 被當成標籤剖析,文字永遠到不了頁面 | <unsafe> 以文字畫出 |
<b>x</b> | x 以粗體畫出 | <b>x</b> 以文字畫出 |
R&D | R&D 原樣印出 | R&D |
&lt; | &lt; 原樣印出 | < |
含 的 Markdown 程式碼片段 | 變成不換行空格 | 以文字畫出 |
資料集儲存格值 < | < | < |
v3.539.47 怎麼讓 HTML 實體解碼變成單趟
PDFlibPas v3.539.47 用三個互相配合的改動把實體解碼變成單趟:剖析器最後才解碼 &、繪圖階段不再解碼任何東西,而每個把解碼文字寫回 HTML 的地方都先重新跳脫
文字內容支援的實體集合現在是 <、>、& 與 。其他一切,包括 A 這類數字參照與 " 這類具名實體,都保持原樣文字。這條邊界牽涉到您該怎麼跳脫自己的輸入,下面會示範
解碼器內部的順序是第一個修正。要是 & 先解碼,輸入 &lt; 會變成 <,下一次替換又把它變成 <——雙重解碼發生在同一趟裡面。所以 ANSI 文字路徑先替換 <、> 與 ,最後才替換 &,它產出的 ampersand 從此不會再被檢查。UTF-16 文字路徑則是單趟由左至右、兩位元組一步的掃描,原地改寫每個匹配後跳過它,結構上就給出同樣的保證
第二個修正把繪圖階段裡晚到的 替換拿掉。解碼只屬於剖析器,別無他處,所以抵達斷行器的文字就是定稿
第三個修正是一條邊界規則。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,把 & 的位元組拼進兩個字元的中間,之後每個字元都被剪掉一位元組
這不是什麼刁鑽的邊角案例。低位元組為零的字元都能供出前半;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 裡,行內程式碼片段與圍欄或縮排程式碼區塊現在把 & 映射成 &、< 映射成 <、> 映射成 >,空格則變成 、tab 變成四個,以保住縮排。一般 Markdown 散文只跳脫角括號,所以散文裡的裸 HTML 注入不了標籤,而作者照樣能刻意寫出 &,跟 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(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// 檢查 HTML:在程式碼裡,'&' 變成 '&','<' 變成 '<'
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 之前它只跳脫角括號,而且是故意的:渲染器不解碼 &,跳脫 ampersand 會讓每個含它的儲存格印出 &。那個權宜之計對舊渲染器是對的、一般而言是錯的,因為碰巧含 < 的儲存格值被解成了 <。渲染器修好之後,匯出器先跳脫 &,R&D < & 這樣的值便一字不差落進 PDF。如果您照這個路子做報表,在 Delphi 裡把 TDataSet 匯出成 PDF 報表那篇逐步解說涵蓋匯出器的其餘部分
為什麼 ampersand 必須排第一,值得說一次清楚。先跳脫 < 得到 <;第二步跳脫 &,它就變成 &lt;,正確的單次解碼會把它顯示成 < 而不是 <。循序替換鏈只有在一個條件下才正確:跳脫字元本身先於任何會引入它的東西被處理
面對 DrawHTMLTextBox,不可信文字該怎麼跳脫?
在 PDFlibPas 的 HTML 渲染下,跳脫不可信文字內容的方式是替換 &、再 <、再 >,各恰好一次,並且讓不可信資料完全遠離屬性值
uses
System.SysUtils, PDFlibrary;
// 為 PDFlibPas 的 HTML 文字內容跳脫不可信文字。
// '&' 必須先替換,否則已產出的 '<' 裡的
// ampersand 會被第二次跳脫
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;
在 v3.539.47 上,Try <a href="https://example.com">this</a> & <b> 這樣的留言會逐字元出現在頁面上。v3.539.47 之前,同樣的跳脫輸入可能生出一個活的連結註解——顯示問題因此升格成安全問題:工單留言不該有能力在您的員工信任的文件裡種下一個可點擊的 URL
注意這個函式不跳脫什麼。通用 HTML 跳脫器還會把 " 轉成 "、把 ' 轉成 ',對瀏覽器這是對的。PDFlibPas 的文字解碼只認得前面列的四個實體,這兩個會被原樣印成 " 與 '。引號在文字內容裡無害;它們只在屬性值裡要緊,而渲染器根本不解碼屬性裡的實體。所以安全的設計不是更好的跳脫器,而是一條規則:不可信資料絕不進 href、src 或 style。連結目標真的必須來自使用者資料時,自己對著 scheme 與字元的允許清單驗證,含引號或角括號的一律拒收
從這個修正直接推出兩條升級注意事項:
- 如果您的程式碼因為舊版會把
&原樣印出而停跳&,請把它加回來。不加的話,含<的使用者文字現在會顯示成<,仍是無害文字,但已不是使用者打的那串 - 不要跳脫兩次。過了兩道跳脫器的文字會把
<渲染成可見的<拼寫,所以找出資料進入 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 的修正必須在同一個版本裡加上 & 解碼、調整順序、拿掉晚到的解碼並加入重新跳脫。同一條原理在 PDF 內容被匯出成結構化文字時反著跑,如 從 Delphi 做 PDF 轉 Markdown 與 DOCX 的語意匯出一文,那裡每個字面字元都必須對目標語法恰好跳脫一次
速查清單
- 只要渲染的 HTML 或 Markdown 含使用者資料,就升級到 PDFlibPas v3.539.47 以上
- 文字內容先跳脫
&,再<與>;PDFlibPas 的文字不要轉換引號 - 只跳脫一次,就在資料進入 HTML 字串的那一個點
- 不可信的值不要放進
href、src與style,或對著允許清單驗證 - 文字裡只有
<、>、&與 會被解碼;其他實體保持原樣 LeftOverText原封不動傳回DrawHTMLTextBox,並給分頁迴圈設上限- Markdown 接續 token 只交給
DrawMarkdownTextBox或DrawMarkdownText - 絕不對 UTF-16 位元組緩衝區做位元組模式搜尋;在完整 code unit 上工作
HTML 與 Markdown 渲染、資料集報表匯出與版面引擎的其餘部分,都隨 PDF Library for Delphi 的原生 Pascal 原始碼出貨,支援 Delphi 與 Free Pascal。版本、平台支援與試用版下載請見 PDFlibPas 產品頁