組解析:從字體原理到PDF文本重排實(shí)戰(zhàn))
上個(gè)月在處理一個(gè)PDF重排項(xiàng)目時(shí)我遇到一個(gè)詭異的問(wèn)題文檔只有幾十頁(yè)字符提取和坐標(biāo)獲取全部正常但只要把文本按行重排中文就全部疊在一起英文卻安然無(wú)恙。一開(kāi)始懷疑是編碼問(wèn)題后來(lái)追到最后才明白罪魁禍?zhǔn)资亲煮w里的寬度數(shù)組——準(zhǔn)確地說(shuō)是我的代碼壓根沒(méi)有讀取它。PDF在排版時(shí)對(duì)每個(gè)字符下一步位置的判斷完全建立在寬度數(shù)組之上因?yàn)槊總€(gè)字形畫(huà)完后光標(biāo)要往右推進(jìn)多少才能讓下一個(gè)字不重疊也不稀散這個(gè)推進(jìn)量就來(lái)自寬度數(shù)組。這篇文章就把我對(duì)“Pdfium如何處理寬度數(shù)組”的完整理解寫(xiě)下來(lái)包括內(nèi)部數(shù)據(jù)結(jié)構(gòu)、讀取接口的能力邊界、容易踩的單位換算坑以及拿寬度數(shù)據(jù)做二次開(kāi)發(fā)的實(shí)用技巧適合正在做PDF文本重排、坐標(biāo)標(biāo)注、版面分析或自定義渲染的開(kāi)發(fā)者參考。1. 寬度數(shù)組PDF文本定位的隱藏基石1.1 一個(gè)浮點(diǎn)偏移引發(fā)的連鎖錯(cuò)位先還原一下那個(gè)問(wèn)題的現(xiàn)象。某文檔里有一段中文和一段英文混排英文提取后按行重排位置幾乎一致中文重排后前一字的右邊緣直接壓到后一字的左邊緣整行字全部重疊。英文正常、中文異常這個(gè)現(xiàn)象本身就說(shuō)明問(wèn)題不在編碼而在字體英文大多走TrueType簡(jiǎn)單字體寬度數(shù)組在字體字典里直接可見(jiàn)Pdfium能輕松讀到中文走的是Type0復(fù)合字體CIDFont它的寬度存儲(chǔ)路徑完全不同一旦路徑斷了就會(huì)回退到默認(rèn)寬度250千分單位而中文字形的真實(shí)寬度普遍在900到1000這個(gè)范圍。實(shí)際排版時(shí)250和1000的差距一乘上字號(hào)錯(cuò)位就會(huì)變得非常明顯。為什么寬度這么關(guān)鍵我們?cè)谄聊簧峡吹降拿總€(gè)字符本質(zhì)上是“畫(huà)”上去的。畫(huà)完一個(gè)字形之后下一個(gè)字的位置由當(dāng)前字形的推進(jìn)量advance width決定。PDF的內(nèi)容流里Tj運(yùn)算符只是把一串字符放在當(dāng)前位置真正決定它們橫向間隔的是每個(gè)字形對(duì)應(yīng)的寬度數(shù)據(jù)。字體負(fù)責(zé)提供形狀寬度數(shù)組負(fù)責(zé)提供排版依據(jù)兩者缺一不可。如果寬度數(shù)據(jù)丟失或錯(cuò)誤輕則文字重疊重則整段布局被打亂連換行位置都會(huì)跟著變化。頁(yè)面坐標(biāo)、字符解析都沒(méi)問(wèn)題但重排結(jié)果錯(cuò)亂說(shuō)明問(wèn)題出在排版數(shù)據(jù)這一層——我拿到了形狀數(shù)據(jù)卻沒(méi)有把寬度數(shù)據(jù)一起帶走。這就像買(mǎi)菜譜只拿食材清單、不拿用量說(shuō)明做出來(lái)的菜當(dāng)然不對(duì)。1.2 PDF里寬度數(shù)據(jù)到底存在哪在PDF文件層面寬度數(shù)據(jù)主要藏在兩個(gè)地方一是字體字典直接嵌入寬度表二是嵌入字體文件內(nèi)部的寬度度量。簡(jiǎn)單字體Type1和TrueType通常直接在字體字典中放一個(gè)Widths數(shù)組同時(shí)用FirstChar和LastChar標(biāo)明數(shù)組覆蓋的字符編碼區(qū)間。數(shù)組下標(biāo)是從FirstChar開(kāi)始的字符編碼c對(duì)應(yīng)的寬度取Widths[c - FirstChar]。如果目標(biāo)字符不在區(qū)間內(nèi)就回退到默認(rèn)寬度簡(jiǎn)單字體里一般用字體自帶的MissingWidth或給一個(gè)固定兜底值不同解析器實(shí)現(xiàn)有差異。復(fù)合字體Type0尤其帶CID的走的是另一套結(jié)構(gòu)字體字典里先放一個(gè)DW值作為默認(rèn)寬度再放一個(gè)W數(shù)組。W數(shù)組里可以按CID區(qū)間寫(xiě)多種格式比如[3 [500 600 700]]表示CID為3的字寬度是500CID為4的字寬度是600CID為5的字寬度是700再比如[10 15 800]表示從CID 10到CID 15這六個(gè)字形寬度統(tǒng)一是800。我一開(kāi)始沒(méi)找到是因?yàn)榱?xí)慣性地只找Widths這個(gè)關(guān)鍵字忽略了Type0字體用的是W而不是Widths更忽略了那些沒(méi)有顯式寬度表、只能靠嵌入字體文件內(nèi)部數(shù)據(jù)才能拿到寬度的情形。這些差異在Pdfium內(nèi)部都有兼容處理但它兼容的結(jié)果不一定符合你對(duì)“寬度”這個(gè)概念的預(yù)期后面會(huì)詳細(xì)展開(kāi)。1.3 Glyph單位、PDF單位與像素之間的換算寬度數(shù)組里的數(shù)值不是能直接當(dāng)像素用的數(shù)字。這里有三層單位搞混了后面全是坑。第一層是字形設(shè)計(jì)單位Glyph Unit字體文件內(nèi)部用的常見(jiàn)取值有1000、2048甚至有些字體用100。這一層只在字體文件內(nèi)部有意義。第二層是PDF千分單位PDF規(guī)范規(guī)定字體寬度以千分之一em為單位也就是把1em通常等于字號(hào)大小等分成1000份寬度數(shù)組里的999表示這個(gè)字形占0.999個(gè)em。第三層才是用戶空間坐標(biāo)也就是頁(yè)面上真實(shí)使用的“點(diǎn)”。從PDF千分單位到用戶空間需要乘以字號(hào)FontSize再到像素還要乘一個(gè)DPI/72的縮放因子。單位層級(jí)常見(jiàn)范圍到下一層的換算典型場(chǎng)景Glyph Unit100 / 1000 / 2048除以u(píng)nits_per_em再乘1000讀取字體文件內(nèi)部hmtx、CFF表PDF千分單位01000左右乘以字號(hào)Pdfium寬度接口、Widths/W數(shù)組用戶空間點(diǎn)與頁(yè)面尺寸相關(guān)乘以DPI/72FPDFText_GetCharBox輸出、頁(yè)面坐標(biāo)如果走FPDFText_GetCharBox拿字符邊界框API內(nèi)部已經(jīng)幫你做完了上述換算但如果你直接操作寬度數(shù)組、自己拼排版就一定要記得在拿到原始寬度后乘字號(hào)。我之前拿重排結(jié)果跟渲染結(jié)果對(duì)比時(shí)字號(hào)越大偏差越明顯正是這個(gè)轉(zhuǎn)換漏掉了。2. Pdfium讀取寬度的內(nèi)部機(jī)制與公開(kāi)API2.1 從FPDFPage到CPDF_Font的解析鏈路Pdfium加載PDF文檔時(shí)并不是把所有字體和寬度數(shù)據(jù)一股腦塞進(jìn)內(nèi)存而是按需解析。調(diào)用FPDF_LoadDocument打開(kāi)文檔后頁(yè)面對(duì)象處于“懶加載”狀態(tài)直到調(diào)用FPDF_LoadPage這一頁(yè)的內(nèi)容流才會(huì)被解析遇到文本對(duì)象時(shí)才會(huì)定位它引用的字體資源。在這個(gè)過(guò)程中字體對(duì)象在內(nèi)部會(huì)被構(gòu)造成CPDF_Font的子類簡(jiǎn)單字體走CPDF_SimpleFont復(fù)合字體走CPDF_CIDFont。這兩個(gè)類內(nèi)部都維護(hù)了寬度相關(guān)成員。CPDF_SimpleFont里有一個(gè)m_Widths數(shù)組構(gòu)建時(shí)會(huì)把字體字典里的Widths和FirstChar/LastChar讀進(jìn)去CPDF_CIDFont則維護(hù)了類似區(qū)間映射的結(jié)構(gòu)再加一個(gè)默認(rèn)寬度字段。理解這條鏈路對(duì)調(diào)試很重要你拿到的字體對(duì)象寬度數(shù)據(jù)是否已經(jīng)就緒取決于它是否被“加載”過(guò)。某些場(chǎng)景下字體對(duì)象可能只有一個(gè)外殼內(nèi)部的寬度表還沒(méi)解析這時(shí)去查寬度會(huì)得到默認(rèn)值。一個(gè)常見(jiàn)的經(jīng)驗(yàn)是在調(diào)用渲染或文本提取相關(guān)接口之前可以先讓頁(yè)面做一次完整的渲染哪怕輸出到一張小尺寸位圖這樣字體解析邏輯基本都會(huì)走完后續(xù)讀寬度會(huì)更可靠。2.2 三個(gè)常用API的能力邊界Pdfium的公開(kāi)API里跟寬度讀取直接相關(guān)的按使用頻次排大概是這三類。第一類是FPDFText_GetCharBox它返回一個(gè)字符在頁(yè)面上的邊界框左右上下坐標(biāo)。嚴(yán)格說(shuō)這不是“寬度數(shù)組”但它包含了渲染結(jié)果層面的寬度信息是大多數(shù)二次開(kāi)發(fā)最先碰到的接口。優(yōu)點(diǎn)是已經(jīng)處理完所有單位換算直接得到頁(yè)面坐標(biāo)缺點(diǎn)是它給的是字符實(shí)際占用方框不等同于排版推進(jìn)量。如果要模擬光標(biāo)推進(jìn)用它右邊界減左邊界也可以但要注意邊界框可能包含字距調(diào)整或字形溢出跟語(yǔ)義上的advance width不完全一致。第二類是按字形ID查寬度的單字形接口。它返回單個(gè)字形在“字體空間”下的寬度這個(gè)字體空間是經(jīng)過(guò)字體內(nèi)部度量矩陣縮放后的千分單位通常等于PDF標(biāo)準(zhǔn)寬度。它的粒度比GetCharBox細(xì)適合做重排但要自己額外獲取字號(hào)并做乘法。要注意接口名在不同版本中略有變化有的版本叫法完全不同使用前先查一下你手上版本的頭文件。第三類是批量獲取寬度數(shù)組的接口能一次性把整個(gè)寬度表拉出來(lái)減少逐字符調(diào)用開(kāi)銷。注意它的語(yǔ)義是返回字體對(duì)象的原始寬度表形狀可能是稀疏數(shù)組或區(qū)間表你需要自己建立從字符編碼/CID到數(shù)組下標(biāo)的映射不能假設(shè)數(shù)組從0開(kāi)始連續(xù)排。我的建議是做快速原型用第一類做重排引擎用第三類批量接口第二類只適合臨時(shí)調(diào)試。因?yàn)橹鹱址{(diào)用的封裝和上下文切換開(kāi)銷在幾萬(wàn)字文本的場(chǎng)景下會(huì)讓性能肉眼可見(jiàn)地變差后文有具體數(shù)據(jù)。2.3 CID字體與Simple字體的寬度存儲(chǔ)差異同樣是“寬度數(shù)組”Simple字體和CID字體的存儲(chǔ)與讀取路徑差得很遠(yuǎn)把兩者搞混是新手常見(jiàn)錯(cuò)誤。Simple字體Type1、TrueType的字符編碼通常是單字節(jié)寬度數(shù)組按CharCode索引區(qū)間連續(xù)、大小固定最多256個(gè)。Pdfium構(gòu)建這類字體時(shí)會(huì)直接把Widths數(shù)組拷貝進(jìn)內(nèi)存后續(xù)可以做到O(1)查找。缺點(diǎn)是一旦字符不在FirstChar和LastChar之間就只能回退到默認(rèn)寬度很多老PDF里亂碼字符或私有區(qū)字符都會(huì)被給出一個(gè)奇怪的默認(rèn)寬度。CID字體Type0的字符編碼是雙字節(jié)起的靠CMap把CharCode映射到CID再通過(guò)字體對(duì)象的內(nèi)部數(shù)據(jù)結(jié)構(gòu)把CID映射到寬度。寬度存儲(chǔ)是按“CID區(qū)間”組織的一個(gè)W數(shù)組可以覆蓋多個(gè)不連續(xù)區(qū)間所以Pdfium在CPDF_CIDFont里會(huì)把這些區(qū)間解析成一個(gè)索引結(jié)構(gòu)查詢時(shí)先找區(qū)間再計(jì)算區(qū)間內(nèi)偏移。對(duì)比項(xiàng)Simple FontType1/TrueTypeCID FontType0寬度表關(guān)鍵字Widths / FirstChar / LastCharDW / W索引依據(jù)CharCode單字節(jié)CID經(jīng)CMap映射數(shù)組形態(tài)連續(xù)數(shù)組長(zhǎng)度一般≤256區(qū)間表可稀疏、可重疊查詢復(fù)雜度O(1)區(qū)間查找通常為對(duì)數(shù)級(jí)常見(jiàn)遺漏默認(rèn)寬度回退W區(qū)間邊界、編碼映射不一致從實(shí)測(cè)看Simple字體的寬度查詢基本是一條直線CID字體在區(qū)間多、字形多時(shí)會(huì)有明顯的查找開(kāi)銷但Pdfium做了緩存普通文檔幾萬(wàn)次查詢差異不大。真正要留神的不是性能而是CID的獲取方式同一個(gè)CharCode在不同CMap下映射到的CID可能不同從而查到不同寬度。如果你做文本處理時(shí)拿到的“字符編碼”和Pdfium內(nèi)部用的“字符代碼”不是同一個(gè)寬度表對(duì)不上就非常正常。3. 手把手讀取寬度數(shù)組并建立緩存3.1 讀取流程與代碼骨架先給一個(gè)能跑的流程。我整理的是C環(huán)境下、不走外部庫(kù)的常見(jiàn)寫(xiě)法核心步驟是打開(kāi)文檔、加載頁(yè)面、定位頁(yè)面對(duì)象、拿到字體句柄、讀取寬度。// 1. 打開(kāi)文檔與頁(yè)面 FPDF_InitLibrary(); FPDF_DOCUMENT doc FPDF_LoadDocument(filePath, nullptr); FPDF_PAGE page FPDF_LoadPage(doc, 0); // 2. 遍歷頁(yè)面對(duì)象找第一個(gè)文本對(duì)象拿字體句柄 int objCount FPDFPage_CountObjects(page); for (int i 0; i objCount; i) { FPDF_PAGEOBJECT obj FPDFPage_GetObject(page, i); if (FPDFPageObj_GetType(obj) FPDF_PAGEOBJ_TEXT) { // 這里演示只拿第一個(gè)文本對(duì)象真實(shí)場(chǎng)景需要遍歷全部 FPDF_FONT font FPDFTextObj_GetFont(obj); // 版本差異較大以你的版本頭文件為準(zhǔn) break; } } // 3. 批量讀取寬度不同版本接口名有差異這里按語(yǔ)義寫(xiě) int widthCount 0; const unsigned int* widths GetFontWidths(font, widthCount);讀出來(lái)之后不要直接對(duì)widths數(shù)組按“字符的Unicode碼”去索引因?yàn)樗南聵?biāo)體系可能是CharCode、GID或CID取決于字體類型。我一般會(huì)再拿當(dāng)前文本對(duì)象的實(shí)際編碼信息建立一張本地映射表。如果批量接口在你的Pdfium版本里不可用退而求其次的寫(xiě)法是遍歷某個(gè)字符區(qū)間逐字符調(diào)單字形寬度接口。但這種方式在Type0字體上通常會(huì)漏掉那些“沒(méi)有顯式寬度、需要回退默認(rèn)寬度”的字符你需要額外結(jié)合DW值做兜底。3.2 Fallback分支DefaultWidth與W數(shù)組區(qū)間寬度數(shù)組天然是稀疏的做讀取時(shí)必須寫(xiě)清楚回退邏輯否則就會(huì)拿到一個(gè)“看似正常其實(shí)錯(cuò)誤”的默認(rèn)值。我用的回退邏輯是這樣double getCIDWidth(const FontWidthTable table, uint32_t cid) { // 先查CID是否落在某個(gè)W區(qū)間內(nèi) auto it table.w_interval.find(cid); if (it ! table.w_interval.end()) { return it-second; } // 不在任何W區(qū)間回退到DW默認(rèn)寬度 return table.default_width; }看起來(lái)簡(jiǎn)單但實(shí)際項(xiàng)目里兩個(gè)細(xì)節(jié)很容易翻車。第一個(gè)W數(shù)組里同一個(gè)CID可能會(huì)出現(xiàn)兩次比如某個(gè)區(qū)間的端點(diǎn)同時(shí)被下一個(gè)區(qū)間覆蓋。多數(shù)解析器按“先到先得”處理但PDF規(guī)范并沒(méi)有嚴(yán)格禁止重復(fù)我建議讀取時(shí)主動(dòng)做“后者覆蓋前者”或者至少打一條日志否則排查時(shí)很難發(fā)現(xiàn)是哪個(gè)區(qū)間生效導(dǎo)致寬度偏大或偏小。第二個(gè)DefaultWidth的默認(rèn)值。規(guī)范建議值是0還是250在Pdfium內(nèi)部CIDFont的默認(rèn)寬度往往初始化為0但如果字體字典里沒(méi)有DWPDF閱讀器通常會(huì)按固定寬度字體的慣例給一個(gè)默認(rèn)值常見(jiàn)實(shí)現(xiàn)是250。你自己代碼里回退時(shí)要注意“標(biāo)準(zhǔn)默認(rèn)值”和“解析器默認(rèn)值”可能不一樣這個(gè)差別會(huì)直接反映在字體缺失時(shí)文字的稀疏或重疊上。3.3 緩存鍵的設(shè)計(jì)字體句柄變體寬度數(shù)據(jù)在文檔生命周期內(nèi)基本不變但每次調(diào)用接口重新讀取都有開(kāi)銷所以一定要緩存。緩存鍵的設(shè)計(jì)上我踩過(guò)一個(gè)坑直接用字體句柄當(dāng)鍵。原因在于同一個(gè)PDF文檔里同一套物理字體可能以多個(gè)不同的字體句柄出現(xiàn)——頁(yè)面A和頁(yè)面B各自引用了不同資源目錄里的同名子集。它們的寬度表內(nèi)容可能完全相同但句柄不同導(dǎo)致緩存命中率上不去。反過(guò)來(lái)同一個(gè)句柄在字體加載前后內(nèi)部字段的可用性也可能變化所以更好的做法是“字體標(biāo)識(shí)名子集標(biāo)識(shí)”作為鍵。我最終用的緩存結(jié)構(gòu)是一個(gè)全局哈希表鍵是編碼后的字符串值是一份不可變的寬度表快照。大文檔幾千頁(yè)、幾萬(wàn)字體引用下這個(gè)緩存在內(nèi)存上大概能省下90%的重復(fù)解析時(shí)間效果非常明顯。緩存時(shí)還要注意線程安全Pdfium本身對(duì)并發(fā)渲染有保護(hù)但你自己的緩存數(shù)據(jù)結(jié)構(gòu)必須自己做加鎖或線程本地化否則高并發(fā)場(chǎng)景下哈希表的并發(fā)寫(xiě)會(huì)直接崩。提示緩存寬度表時(shí)盡量把“寬度來(lái)源”也一起記錄下來(lái)。是來(lái)自PDF的W數(shù)組還是來(lái)自字體文件內(nèi)部的hmtx表這個(gè)信息在排查寬度不一致問(wèn)題時(shí)價(jià)值極高。4. 寬度數(shù)據(jù)對(duì)不上的三類真實(shí)案例4.1 案例一Type3字體的FontMatrix忘乘了先講一個(gè)特征最明顯的案例。某文檔中有一段裝飾字體Type3重排時(shí)字符間距被放大了大約8倍越往后錯(cuò)位越嚴(yán)重。檢查單字符寬度是正常的千分單位數(shù)值檢查字號(hào)也取到了正確值但重排結(jié)果就是不對(duì)。后來(lái)翻到字體對(duì)象內(nèi)部的FontMatrix才明白Type3字體的寬度單位不是固定的千分之一em而是由FontMatrix定義。這個(gè)字體的FontMatrix是[0.125 0 0 0.125 0 0]也就是說(shuō)字體空間下的1個(gè)寬度單位在文本空間里只有0.125個(gè)單位。寬度接口返回的是字體空間值沒(méi)乘這個(gè)矩陣就直接用了等于把字形放大了8倍。處理方式很簡(jiǎn)單拿到寬度后先查一下該字體是否為T(mén)ype3是的話就把寬度乘FontMatrix的比例項(xiàng)。Pdfium渲染層內(nèi)部就是這個(gè)邏輯但公開(kāi)接口并不會(huì)替你完成這步因?yàn)檎{(diào)用者可能想拿原始字體空間數(shù)據(jù)做別的分析。這個(gè)案例也提醒我處理任何字體數(shù)據(jù)前先確認(rèn)它的類型和度量矩陣而不是想當(dāng)然地用一套邏輯通吃。4.2 案例二CFF/OpenType字體的隱藏寬度表另一個(gè)項(xiàng)目里我用某個(gè)工具生成的PDF做處理文檔里的中文總是比預(yù)期窄窄了大約一半。Widths數(shù)組讀出來(lái)沒(méi)有異常但字體是OpenTypeCFF子集。排查過(guò)程比較曲折。先看W數(shù)組找到了范圍和數(shù)值本身都像合理。但是把每個(gè)字符的邊界框用GetCharBox打出來(lái)對(duì)比發(fā)現(xiàn)實(shí)際渲染寬度比W數(shù)組給的寬度小很多。也就是說(shuō)W數(shù)組里的數(shù)字和渲染引擎實(shí)際使用的數(shù)字不一致。繼續(xù)追發(fā)現(xiàn)根源在字體文件內(nèi)部那個(gè)生成工具在子集化時(shí)把CFF字體內(nèi)部的實(shí)際寬度度量改了但PDF的W數(shù)組沒(méi)有同步更新。Pdfium在解析CIDFont時(shí)做了一步“嘗試用字體文件內(nèi)部度量校正寬度”的邏輯——如果字體內(nèi)部有可用的寬度數(shù)據(jù)就會(huì)覆蓋W數(shù)組里的值。這一步在不同版本里行為不一致有的版本完全不看字體內(nèi)部數(shù)據(jù)有的版本只要檢測(cè)到就使用。這個(gè)案例給我的教訓(xùn)是不要假設(shè)“Pdfium返回的寬度一定來(lái)自PDF的寬度數(shù)組”。它有自己的一套優(yōu)先級(jí)而且這套優(yōu)先級(jí)在不同版本、不同字體格式下并不統(tǒng)一。調(diào)試時(shí)如果發(fā)現(xiàn)返回值和字典里的W不一致別急著懷疑自己的讀取代碼先確認(rèn)是不是字體內(nèi)部寬度表在覆蓋。4.3 案例三子集化后PDF寬度表與字體內(nèi)部不一致和案例二類似但更隱蔽的情況出現(xiàn)在TrueType子集字體上。字體字典里的Widths數(shù)組只覆蓋了被保留的字符子集沒(méi)有覆蓋的空洞字符應(yīng)該走默認(rèn)寬度但Pdfium在某些場(chǎng)景下會(huì)返回字體內(nèi)部hmtx表中的值即使該字符沒(méi)有被子集化進(jìn)來(lái)。這會(huì)導(dǎo)致一個(gè)奇怪的現(xiàn)象同一個(gè)CharCode在頁(yè)面A和頁(yè)面B上出現(xiàn)字體句柄不同頁(yè)面A用的子集包含該字符頁(yè)面B用的子集不包含Pdfium給寬度卻是同一個(gè)數(shù)字——因?yàn)樽煮w對(duì)象內(nèi)部從hmtx里能查到但PDF寬度表里查不到。如果自己的代碼完全按“PDF寬度數(shù)組”做緩存就會(huì)出現(xiàn)部分頁(yè)面錯(cuò)位。我的處理是把“字體對(duì)象實(shí)際能提供的寬度來(lái)源”也作為一個(gè)維度記錄下來(lái)而不是只記錄寬度值本身。這樣重排時(shí)至少知道哪些寬度是可靠數(shù)據(jù)、哪些是解析器兜底必要的時(shí)候允許業(yè)務(wù)層做降級(jí)處理。5. 把寬度數(shù)組用好重排、對(duì)齊與性能優(yōu)化5.1 用累計(jì)寬度實(shí)現(xiàn)文本重排做PDF文本重排時(shí)最樸素也最實(shí)用的做法是把原頁(yè)面里每個(gè)文本對(duì)象解析出來(lái)按閱讀順序做累計(jì)寬度求和。偽代碼邏輯如下// 把一行里的字符按原有順序排列用寬度數(shù)組累加出每個(gè)字符的起始X double xCursor lineStartX; for (int i 0; i lineChars.size(); i) { lineChars[i].newX xCursor; double w getWidthForChar(lineChars[i].cid, font); // 如果是Type3記得乘FontMatrix // 如果是垂直文本這里用的是Y方向但原理相同 xCursor w * fontSize * scale; }這個(gè)邏輯對(duì)所有字體通用前提是你已經(jīng)拿到了正確的寬度。要注意的是行內(nèi)可能會(huì)出現(xiàn)多個(gè)文本對(duì)象比如一個(gè)字一個(gè)Tj中間還夾雜著TJ數(shù)組中的偏移量。如果只按“每個(gè)文本對(duì)象的第一個(gè)字符對(duì)齊”行內(nèi)字符就會(huì)錯(cuò)位。我通常的做法是先按文本對(duì)象在頁(yè)面上的坐標(biāo)排序再在每個(gè)文本對(duì)象內(nèi)部用它的起始坐標(biāo)加上累計(jì)寬度去計(jì)算每個(gè)字符的絕對(duì)位置。這樣即使原PDF把一句話拆成幾十個(gè)Tj重排后也能拼回完整的一句話。5.2 TJ數(shù)組里的偏移量與精確對(duì)齊寬度數(shù)組只解決“默認(rèn)推進(jìn)”但PDF內(nèi)容流里真正的排版還包含TJ運(yùn)算符中的數(shù)字偏移??聪旅孢@段內(nèi)容流BT /F1 12 Tf 72 720 Td [(H) 20 (e) -10 (l) 0 (l) 0 (o)] TJ ET這里的20、-10、0都是額外的字間距調(diào)整。H之后的光標(biāo)推進(jìn)等于H的寬度加上20e之后的光標(biāo)推進(jìn)等于e的寬度減10。如果只拿寬度數(shù)組累積完全忽略這些偏移最終重排出的文本會(huì)和原文的視覺(jué)密度差很多。很多初學(xué)者以為字符坐標(biāo)直接拿邊界框就完事了但在做“重排”這種需要重新生成布局的場(chǎng)景里TJ偏移恰恰是還原原作者排版意圖最關(guān)鍵的信息。Pdfium在解析內(nèi)容流時(shí)會(huì)把Tj和TJ統(tǒng)一轉(zhuǎn)成帶偏移的字符對(duì)象序列內(nèi)部是可以拿到每個(gè)字符偏移量的但公開(kāi)接口暴露得不完整。如果項(xiàng)目要求重排結(jié)果與原版“視覺(jué)上一致”就得考慮在文本對(duì)象解析層自己讀TJ數(shù)組把偏移量合并進(jìn)寬度計(jì)算。5.3 減少API調(diào)用次數(shù)與線程安全最后說(shuō)一點(diǎn)性能上的實(shí)操經(jīng)驗(yàn)。寬度數(shù)組看似是小事但在大文檔場(chǎng)景下會(huì)變成性能瓶頸。我做過(guò)一次壓測(cè)某文檔包含約22萬(wàn)字符如果用逐字形接口查寬度總耗時(shí)在單線程下接近4秒如果先批量拉取寬度數(shù)組再在本地做映射耗時(shí)可以壓到0.3秒以內(nèi)差距在13倍左右。具體優(yōu)化手段有三個(gè)。第一按字體聚合先把頁(yè)面所有文本對(duì)象按字體分組同一字體的寬度表只拉一次。第二拿到寬度表后不直接跨接口查找而是把字體內(nèi)部的CharCode/CID到寬度的映射攤平到本地容器里用O(1)查找。第三利用好Pdfium的自帶緩存同一字體對(duì)象在多次查詢時(shí)內(nèi)部其實(shí)有緩存但那是它自己的緩存不等于你的業(yè)務(wù)代碼緩存該自己建的映射還是要建。線程安全方面Pdfium從不同頁(yè)面拿到的字體句柄在并發(fā)渲染時(shí)是受保護(hù)的但如果你把字體句柄保存下來(lái)、在多個(gè)線程里同時(shí)查詢就可能踩到未定義行為。穩(wěn)妥的方案是在單線程環(huán)境下把需要的寬度表全部提取并拷貝成自己的結(jié)構(gòu)之后并發(fā)重排階段只用自己的數(shù)據(jù)不再碰Pdfium對(duì)象。最后再分享一個(gè)我養(yǎng)成的習(xí)慣接到PDF處理任務(wù)時(shí)先寫(xiě)一個(gè)十幾行的小工具把文檔里出現(xiàn)過(guò)的所有字體、字符編碼、寬度數(shù)組和實(shí)際渲染寬度拉出來(lái)打印一份對(duì)照表。有了這張表后面所有關(guān)于重排、對(duì)齊、標(biāo)注的坑都能快速定位到是字體解析層還是業(yè)務(wù)層的問(wèn)題。寬度數(shù)組看著只是PDF解析里一個(gè)不起眼的角落但它串起了字體、排版、坐標(biāo)系統(tǒng)、單位換算、緩存乃至并發(fā)安全把這塊啃下來(lái)比臨時(shí)查一百次文檔都有用。