戰(zhàn):HTML 分頁(yè)排版與 PDF 導(dǎo)出)
簡(jiǎn)介這份 paged.js 教程資料包面向具備一定 JavaScript 基礎(chǔ)的前端開發(fā)者以及需要把 HTML 內(nèi)容穩(wěn)定輸出為 PDF 的技術(shù)人員重點(diǎn)解決瀏覽器端分頁(yè)排版、打印樣式適配和文檔生成流程中的實(shí)際問(wèn)題。教程圍繞 CSS Paged Media 展開涉及核心概念、安裝引入、基礎(chǔ)示例、頁(yè)面尺寸與邊距設(shè)置、復(fù)雜布局處理、PDF 輸出、框架集成及調(diào)試優(yōu)化等主題并關(guān)注分頁(yè)斷點(diǎn)、打印媒體查詢等細(xì)節(jié)適合作為理解分頁(yè)機(jī)制和定制打印樣式的參考也可為文檔導(dǎo)出工具選型做技術(shù)預(yù)研。壓縮包共 32401 個(gè)文件整體約 40.16MB包含大量 png 圖示、js 腳本、otf/eot/woff/ttf 字體資源以及 html、svg、txt、md、json 等配套文件可用于對(duì)照閱讀、查看示例結(jié)構(gòu)和本地運(yùn)行驗(yàn)證。目錄組織清晰便于按主題檢索。已有 586 人學(xué)習(xí)可作為前端文檔導(dǎo)出、在線報(bào)告生成與 PDF 分頁(yè)排版的實(shí)用資料。1. 把 HTML 排成帶頁(yè)碼的書這份 Paged.js 教程卡在哪個(gè)位置手上有一批結(jié)構(gòu)規(guī)整的 HTML 文檔要輸出成帶頁(yè)碼、頁(yè)眉、目錄、腳注的 PDF。第一反應(yīng)通常是開 Word改版式改到懷疑人生或者上 LaTeX學(xué)習(xí)成本夠?qū)憙蓚€(gè)項(xiàng)目。這份以 PDF 形式整理的 Paged.js 教程給的是中間路線用 CSS Paged Media 那套聲明式規(guī)則描述版面由一段 JavaScript 在瀏覽器里把長(zhǎng)文檔真正切成一張張頁(yè)面盒再?gòu)臑g覽器打印成 PDF。它適合已經(jīng)在用 Web 技術(shù)棧產(chǎn)出文檔、又不想把排版交給桌面軟件的人也適合想搞清楚“分頁(yè)到底發(fā)生在哪一層”的前端??赐昴苣玫降淖钚¢]環(huán)是一頁(yè) A4、頁(yè)碼正確、目錄頁(yè)碼能自動(dòng)回填、導(dǎo)出不跑版。2. 分頁(yè)發(fā)生在哪一層Polisher、Chunker 與頁(yè)面盒的三角關(guān)系2.1 瀏覽器不實(shí)現(xiàn)的 CSS Paged Media被誰(shuí)補(bǔ)上了瀏覽器對(duì) CSS Paged Media 的支持少得可憐。page里的size和margin在打印時(shí)大致能生效但top-center、bottom-right這些 margin box以及counter(pages)、target-counter()、float: footnote主流瀏覽器基本一個(gè)都沒(méi)實(shí)現(xiàn)。Paged.js 是補(bǔ)這一層的庫(kù)它不是把頁(yè)面截圖而是真的操作 DOM把源內(nèi)容按渲染高度切成若干.pagedjs_page每個(gè)頁(yè)面盒里再掛.pagedjs_sheet、內(nèi)容區(qū).pagedjs_page_content、以及一圈 margin box 容器。內(nèi)部有三個(gè)環(huán)節(jié)值得記住名字因?yàn)榕佩e(cuò)時(shí)全靠它們定位。Polisher 負(fù)責(zé)處理樣式表把page規(guī)則抽出來(lái)轉(zhuǎn)成能作用在頁(yè)面盒上的 CSSChunker 負(fù)責(zé)切分它測(cè)量每個(gè)塊級(jí)元素的實(shí)際高度累加到超過(guò)版心高度就斷開生成 breakToken 繼續(xù)下一頁(yè)P(yáng)reviewer 串起整個(gè)流程并在不同階段拋出事件鉤子。樣式明明寫了卻沒(méi)生效先去 Polisher 處理后的 CSS 里找切頁(yè)位置莫名其妙去看 Chunker 量到的高度頁(yè)碼是空的那是 margin box 填充階段的事。三層分開看比在一堆.pagedjs_*類名里瞎猜快得多。這套做法有一個(gè)明確的天花板DOM 是真的被切開了。任何依賴“整體連續(xù)”的視覺(jué)效果在切口處都會(huì)斷比如帶圓角和陰影的卡片被劈成兩半、跨頁(yè)的豎線斷掉、box-shadow在下半頁(yè)被裁掉。這不是 bug是這套方案的物理邊界。接受不了就只能改寫內(nèi)容結(jié)構(gòu)把不可分割的東西包進(jìn)break-inside: avoid的容器里。2.2 最小工程裝包、引 polyfill、寫出第一個(gè) page先把依賴裝上。pagedjs這個(gè)包會(huì)帶出dist/paged.polyfill.js把它引到頁(yè)面里就會(huì)自動(dòng)接管分頁(yè)不需要寫任何初始化代碼。# 只做本地排版驗(yàn)證的話一個(gè)靜態(tài)服務(wù)就夠不用上構(gòu)建工具 npm init -y npm install pagedjs npx serve .然后是頁(yè)面骨架。下面這份可以直接跑注意page的寫法和普通 CSS 是同一套語(yǔ)法只是規(guī)則名不同。!doctype html html langzh-CN head meta charsetutf-8 !-- polyfill 必須在樣式之前引它會(huì)在 DOM 就緒后接管分頁(yè) -- script src./node_modules/pagedjs/dist/paged.polyfill.js/script style /* 紙張尺寸與版心印刷場(chǎng)景一律用 mm別用 px */ page { size: A4; margin: 22mm 18mm 20mm 18mm; /* 上 右 下 左 */ } /* 同一個(gè) page 可以重復(fù)聲明規(guī)則會(huì)合并 */ page { bottom-center { content: counter(page) / counter(pages); font-size: 9pt; color: #666; } } body { font-family: Noto Serif CJK SC, Songti SC, serif; font-size: 11pt; line-height: 1.75; } h1 { font-size: 20pt; margin: 0 0 6mm; } p { margin: 0 0 3mm; text-align: justify; } /* 屏幕預(yù)覽時(shí)給頁(yè)面盒一點(diǎn)間隔方便肉眼確認(rèn)切頁(yè)位置 寫進(jìn) media screen避免影響打印 */ media screen { body { background: #f2f2f2; margin: 0; } .pagedjs_page { margin: 12px auto; box-shadow: 0 1px 4px rgba(0,0,0,.18); } } /style /head body article h1 idcover模擬項(xiàng)目X 技術(shù)手冊(cè)/h1 p第一段正文。把要做成書的 HTML 直接放進(jìn)來(lái)就行不需要額外的包裝層。/p p第二段正文。分頁(yè)由 polyfill 自動(dòng)完成屏幕上會(huì)看到一張張獨(dú)立的紙。/p /article /body /html這里有幾個(gè)參數(shù)必須說(shuō)清楚。size: A4決定頁(yè)面盒的寬高如果打印對(duì)話框里又選了別的紙型兩邊會(huì)打架導(dǎo)出必翻車所以后面接自動(dòng)化時(shí)要用preferCSSPageSize把它固定住。margin是版心位置順序是上右下左頁(yè)眉頁(yè)腳就長(zhǎng)在這塊留白里所以想要頁(yè)眉不擠正文靠的是調(diào)margin-top不是在正文里加 padding。counter(page)是當(dāng)前頁(yè)碼counter(pages)是總頁(yè)數(shù)后者由 Paged.js 統(tǒng)計(jì)完所有頁(yè)面盒之后回填屏幕上第一眼看到的可能是空值等它排完就有了。提示page規(guī)則寫在任意style或外鏈 CSS 里都行但要保證它和正文樣式在同一份文檔里跨 iframe 引入的樣式表的page不一定被 Polisher 抓到。2.3 目錄頁(yè)碼回填target-counter 與 leader 的組合目錄頁(yè)碼是這類工具最容易讓人眼前一亮的功能寫法卻很簡(jiǎn)單給目錄項(xiàng)一個(gè)指向目標(biāo)的錨點(diǎn)然后用target-counter把目標(biāo)所在頁(yè)碼取出來(lái)當(dāng)偽元素內(nèi)容。/* 目錄項(xiàng)不要下劃線右側(cè)自動(dòng)補(bǔ)點(diǎn)線再跟頁(yè)碼 */ .toc a { text-decoration: none; color: inherit; } .toc a::after { /* leader() 生成填充點(diǎn)線target-counter 取目標(biāo)元素所在頁(yè)碼 */ content: leader(.) target-counter(attr(href), page); font-variant-numeric: tabular-nums; /* 頁(yè)碼等寬右側(cè)才對(duì)齊 */ } /* 目錄單獨(dú)占頁(yè)別和正文擠在一起 */ .toc { break-after: page; }對(duì)應(yīng)的 HTML 只需要保證href是同文檔錨點(diǎn)、目標(biāo)元素有idnav classtoc ul lia href#ch1第 1 章 環(huán)境與依賴/a/li lia href#ch2第 2 章 版式參數(shù)/a/li /ul /nav article h1 idch1第 1 章 環(huán)境與依賴/h1 !-- ... -- h1 idch2第 2 章 版式參數(shù)/h1 !-- ... -- /article參數(shù)上要注意兩點(diǎn)。attr(href)拿到的是完整字符串含#target-counter內(nèi)部會(huì)自己解析成元素引用所以href#ch1就夠了別寫成絕對(duì) URL。如果你還想讓目錄里顯示標(biāo)題文字而不是手寫把content換成target-text(attr(href))就能取到目標(biāo)元素的文本代價(jià)是層級(jí)和樣式全靠::before/::after拼多級(jí)目錄反而更麻煩我一般還是手寫標(biāo)題、只讓頁(yè)碼自動(dòng)回填。target-counter取不到值的時(shí)候不會(huì)報(bào)錯(cuò)只會(huì)靜默留空。第一次用很容易以為自己寫錯(cuò)了選擇器其實(shí)是錨點(diǎn)沒(méi)對(duì)上。排查順序固定成三步href和id是否嚴(yán)格一致、目標(biāo)元素是否在分頁(yè)流里被display: none藏掉的內(nèi)容不算、::after是否被更高優(yōu)先級(jí)的選擇器覆蓋。3. 版式參數(shù)怎么設(shè)page 規(guī)則、命名頁(yè)面與頁(yè)眉頁(yè)腳3.1 page 里真正會(huì)影響成品的六個(gè)參數(shù)page能寫的東西不少但決定成品長(zhǎng)相的就那么幾個(gè)。下面這張表是我反復(fù)改樣式之后沉淀下來(lái)的左邊是參數(shù)右邊是容易寫錯(cuò)的地方。參數(shù)常用取值實(shí)際作用容易踩的地方sizeA4/210mm 297mm頁(yè)面盒寬高和打印對(duì)話框紙型不一致導(dǎo)出尺寸跑偏margin22mm 18mm 20mm 18mm版心位置順序上右下左想給頁(yè)眉留位置卻只加大上邊距忘了 margin box 就長(zhǎng)在留白里margin boxtop-center等頁(yè)眉頁(yè)腳、頁(yè)碼落點(diǎn)用 padding 頂位置內(nèi)容溢出被裁page自定義名字把元素綁到命名頁(yè)面以為會(huì)繼承到該元素后面所有頁(yè)break-*page/avoid/right斷頁(yè)與避斷兩側(cè)同時(shí)聲明憑空多出空白頁(yè)bleed/marks3mm出血與裁切線在瀏覽器打印鏈路里表現(xiàn)不完整真正印刷一般交給后端拼版關(guān)于bleed和裁切線說(shuō)句實(shí)話走瀏覽器打印這條鏈路時(shí)出血很難做干凈因?yàn)榇蛴?duì)話框本身還會(huì)縮放。需要嚴(yán)格出血的項(xiàng)目通常的做法是先用 Paged.js 排出內(nèi)容頁(yè)再交給專業(yè)的拼版工具加出血和爬升。這不是 Paged.js 的短板是瀏覽器打印模型的限制。3.2 命名頁(yè)面封面、正文、附錄各用一套版心一本書里不同部分的版式往往不同封面滿版不留白正文雙欄帶頁(yè)眉附錄單欄不要頁(yè)碼。做法是定義多個(gè)命名page再用元素上的page屬性去綁定。/* 封面零邊距滿版 */ page cover { margin: 0; bottom-center { content: none; } /* 封面不要頁(yè)碼 */ } /* 每章起始頁(yè)不顯示頁(yè)眉讓標(biāo)題更醒目 */ page chapterStart { top-center { content: none; } } /* 附錄頁(yè)碼改羅馬數(shù)字 */ page appendix { bottom-center { content: counter(page, lower-roman); } } /* 綁定page 屬性寫在容器上 */ .cover { page: cover; break-after: page; } .chapter { page: chapterStart; break-before: page; } .appendix { page: appendix; break-before: right; }這里有個(gè)語(yǔ)義細(xì)節(jié)經(jīng)常被誤解page屬性影響的是元素起始的那一頁(yè)。一個(gè)章節(jié)容器如果跨了 6 頁(yè)只有第一頁(yè)套用chapterStart后面 5 頁(yè)回落到默認(rèn)page。很多人以為會(huì)一路繼承下去然后發(fā)現(xiàn)“第二章的頁(yè)眉怎么又冒出來(lái)了”其實(shí)行為一直是對(duì)的。要讓整章都不顯示頁(yè)眉得把頁(yè)眉的開關(guān)做到page級(jí)別而不是單頁(yè)級(jí)別或者接受“章首頁(yè)特殊、正文頁(yè)統(tǒng)一”這個(gè)更常見(jiàn)的排版慣例。break-before: right也值得單獨(dú)說(shuō)。它要求新內(nèi)容從右手頁(yè)開始如果當(dāng)前正好停在右手頁(yè)就會(huì)插入一張空白頁(yè)。雙面印刷這是標(biāo)準(zhǔn)做法但在屏幕上翻 PDF 時(shí)看到空白頁(yè)別急著當(dāng) bug 修先確認(rèn)是不是right在起作用。3.3 頁(yè)眉內(nèi)容從哪來(lái)string-set 取文本running element 取整塊頁(yè)眉最常見(jiàn)需求是“顯示當(dāng)前章節(jié)標(biāo)題”。兩種實(shí)現(xiàn)路線差別不小。第一種用string-set它從元素里取文本存成一個(gè)命名字符串margin box 再用string()取出來(lái)。/* 把每個(gè) h1 的文本存進(jìn) chaptertitle 這個(gè)字符串變量 */ h1 { string-set: chaptertitle content(text); } page { top-right { content: string(chaptertitle); font-size: 9pt; color: #555; } }第二種用 running element把一整塊 DOM 搬到頁(yè)眉里能帶 logo、能帶雙色文字。div classrunning-head 模擬項(xiàng)目X 技術(shù)手冊(cè) span classver內(nèi)部版/span /div.running-head { position: running(dochead); /* 從正文流里移除交給頁(yè)眉引用 */ } page { top-left { content: element(dochead); font-size: 9pt; } }怎么選頁(yè)眉只有一行純文字用string-set它有“當(dāng)前頁(yè)對(duì)應(yīng)的章節(jié)”這個(gè)語(yǔ)義跨頁(yè)時(shí)取哪一頁(yè)的值由第二個(gè)參數(shù)控制頁(yè)眉需要排版自由度圖標(biāo)、分隔線、左右分區(qū)用 running element但它本質(zhì)是把同一個(gè) DOM 節(jié)點(diǎn)搬來(lái)搬去同一個(gè) running element 被多處引用時(shí)行為會(huì)變得難以預(yù)測(cè)。string()的第二個(gè)參數(shù)CSS 規(guī)范里給了first、start、last幾種取法用來(lái)解決“標(biāo)題落在上一頁(yè)底部、正文在下一頁(yè)頁(yè)眉該顯示誰(shuí)”的問(wèn)題。實(shí)際用的時(shí)候先在目標(biāo)版本上把幾種取法都跑一遍看哪個(gè)符合你的排版習(xí)慣別默認(rèn)它們?nèi)紝?shí)現(xiàn)得一樣。我吃過(guò)這個(gè)虧本地看著對(duì)換一個(gè)渲染環(huán)境頁(yè)眉就變成了上一章的標(biāo)題最后靠start才穩(wěn)住。4. 長(zhǎng)文檔的四件硬活目錄、腳注、交叉引用、斷頁(yè)控制4.1 多級(jí)目錄層級(jí)、點(diǎn)線與章節(jié)起始頁(yè)單級(jí)目錄用 2.3 的寫法就夠了真正做手冊(cè)時(shí)目錄一般有三級(jí)。層級(jí)不要靠縮進(jìn)硬撐用類名控制樣式和結(jié)構(gòu)分開。.toc ul { list-style: none; padding: 0; margin: 0; } .toc li { margin: 0 0 1.6mm; font-size: 10.5pt; } /* 三級(jí)縮進(jìn)用 em 跟著字號(hào)走換字號(hào)不用改數(shù)值 */ .toc .lv2 { padding-left: 1.6em; font-size: 10pt; } .toc .lv3 { padding-left: 3.2em; font-size: 9.5pt; } /* 只有一級(jí)目錄加粗二級(jí)三級(jí)保持克制 */ .toc .lv1 { font-weight: 600; margin-top: 3mm; } /* 點(diǎn)線和頁(yè)碼對(duì)所有層級(jí)統(tǒng)一 */ .toc a::after { content: leader(.) target-counter(attr(href), page); }nav classtoc ul li classlv1a href#ch1第 1 章 環(huán)境與依賴/a/li li classlv2a href#sec111.1 依賴安裝/a/li li classlv2a href#sec121.2 目錄結(jié)構(gòu)/a/li li classlv1a href#ch2第 2 章 版式參數(shù)/a/li /ul /navleader(.)的行為是把剩余空間用點(diǎn)填滿所以目錄項(xiàng)本身必須是塊級(jí)或 flex 撐滿寬度否則點(diǎn)線會(huì)擠在文字后面。如果發(fā)現(xiàn)點(diǎn)線長(zhǎng)度忽長(zhǎng)忽短八成是某個(gè)li被設(shè)成了inline-block。還有一個(gè)隱藏問(wèn)題目錄本身頁(yè)數(shù)不定。三頁(yè)的目錄如果只留了一頁(yè)的位置后面會(huì)擠掉正文的第一頁(yè)頁(yè)碼全部錯(cuò)位。給目錄容器加break-after: page是必須的同時(shí)目錄項(xiàng)不要用break-inside: avoid否則一頁(yè)放不下時(shí)會(huì)推走一大塊中間留白。4.2 腳注float: footnote 和 footnote 區(qū)域腳注是 Paged.js 相對(duì)亮眼的功能。寫法是把腳注內(nèi)容直接放在正文引用處用float: footnote抽走。p 這段正文里有一個(gè)需要說(shuō)明的點(diǎn)span classfn這里的數(shù)值來(lái)自模擬項(xiàng)目X 的內(nèi)部測(cè)試記錄僅作示例。/span后面繼續(xù)。 /p/* 腳注本體從正文流里抽走落到本頁(yè)的腳注區(qū) */ .fn { float: footnote; font-size: 8.5pt; line-height: 1.5; } /* 正文里的上標(biāo)編號(hào) */ ::footnote-call { content: counter(footnote); vertical-align: super; font-size: 70%; line-height: 0; } /* 腳注區(qū)里的編號(hào)注意帶了點(diǎn)號(hào) */ ::footnote-marker { content: counter(footnote) . ; } /* 腳注區(qū)與正文之間的分隔線寫在頁(yè)面級(jí) */ page { footnote { border-top: 0.5pt solid #999; padding-top: 3mm; margin-top: 6mm; } }參數(shù)上有三點(diǎn)。counter(footnote)是 Paged.js 自己維護(hù)的不用手動(dòng)編號(hào)但要注意它默認(rèn)整篇連續(xù)編號(hào)想要每頁(yè)重編得在頁(yè)面上重置計(jì)數(shù)器這塊各版本行為不完全一致改之前先小樣本驗(yàn)證。腳注內(nèi)容必須寫在正文流的引用位置不要用 JS 事后插入因?yàn)榍许?yè)是在 DOM 就緒后立刻發(fā)生的后插入的節(jié)點(diǎn)趕不上這一輪布局。腳注區(qū)高度是參與版心計(jì)算的一條長(zhǎng)腳注會(huì)擠掉正文行數(shù)所以腳注盡量控制在兩行以內(nèi)超過(guò)就改成尾注。還有一個(gè)現(xiàn)實(shí)約束腳注擠不下時(shí)Paged.js 會(huì)把整段正文推到下一頁(yè)而不是把腳注截?cái)?。這意味著一個(gè)段落如果特別長(zhǎng)比如一整頁(yè)不分段腳注永遠(yuǎn)找不到落點(diǎn)。正文塊保持正常分段這個(gè)問(wèn)題基本不會(huì)出現(xiàn)。4.3 斷頁(yè)控制break-* 與 orphans/widows 的實(shí)際效果排版質(zhì)量的差距八成在斷頁(yè)控制上。默認(rèn)行為是“能塞就塞”標(biāo)題孤零零留在頁(yè)底、段落最后一行單獨(dú)跑到下一頁(yè)都是常見(jiàn)畫面。/* 標(biāo)題后面不許斷避免標(biāo)題落在頁(yè)底 */ h1, h2, h3 { break-after: avoid; } /* 標(biāo)題前面也不許斷避免標(biāo)題跑到新頁(yè)第一行卻和上文割裂 */ h1, h2, h3 { break-before: avoid; } /* 段落至少留 3 行在頁(yè)底至少 3 行帶到下一頁(yè) */ p { orphans: 3; widows: 3; } /* 圖表、代碼塊、引用塊整體不拆 */ figure, pre, blockquote, .callout { break-inside: avoid; } /* 表格允許跨頁(yè)但單行不許劈開表頭每頁(yè)重復(fù) */ table { break-inside: auto; } tr { break-inside: avoid; } thead { display: table-header-group; } /* 章節(jié)從新頁(yè)開始附錄從右手頁(yè)開始 */ .chapter { break-before: page; } .appendix { break-before: right; }orphans和widows是最容易被忽略的兩個(gè)屬性因?yàn)樗鼈冊(cè)谄渌胤綆缀跤貌坏?。前者控制?yè)底最少留幾行后者控制頁(yè)頂最少帶幾行值一般設(shè) 2 到 3。設(shè)成 1 等于關(guān)掉保護(hù)設(shè)成 4 以上在中文字號(hào)下會(huì)導(dǎo)致大量半空頁(yè)得不償失。break-inside: avoid不能濫用。給一個(gè) 800px 高的卡片加這條它會(huì)整個(gè)被推到下一頁(yè)上一頁(yè)留下大片空白給嵌套容器同時(shí)加可能出現(xiàn)“誰(shuí)都不讓誰(shuí)”最終整塊溢出。經(jīng)驗(yàn)做法是只在高度可控的元素上加圖、代碼塊、提示框并且加之前先想清楚這塊東西如果真的有 1.5 頁(yè)高避斷反而是災(zāi)難。5. 五個(gè)翻車現(xiàn)場(chǎng)空白頁(yè)、頁(yè)碼錯(cuò)位、字體競(jìng)態(tài)、表頭丟失、二次分頁(yè)5.1 章節(jié)之間憑空多出一張空白頁(yè)現(xiàn)象導(dǎo)出的 PDF 里某些章節(jié)之間夾著一張完全空白的頁(yè)屏幕上也能看到。原因基本是兩個(gè)。一是斷頁(yè)在兩側(cè)同時(shí)聲明上一章的容器寫了break-after: page下一章的容器又寫了break-before: page兩次強(qiáng)制斷頁(yè)疊加中間那張就是產(chǎn)物。二是元素高度剛好等于或略大于版心高度浮點(diǎn)誤差讓 Chunker 判定“放不下”于是推到下一頁(yè)而它本身幾乎沒(méi)有內(nèi)容看起來(lái)就是空白。解決斷頁(yè)只在一側(cè)聲明統(tǒng)一寫在章節(jié)容器的break-before上把break-after全刪掉。定位時(shí)打開控制臺(tái)數(shù)一下document.querySelectorAll(.pagedjs_page).length跟預(yù)期頁(yè)數(shù)對(duì)不上再逐段二分注釋掉break-*規(guī)則通常十分鐘內(nèi)能鎖定。我現(xiàn)在的習(xí)慣是所有斷頁(yè)規(guī)則集中寫在一個(gè)區(qū)塊里注釋標(biāo)明“只允許單側(cè)聲明”就是被這個(gè)坑教育出來(lái)的。5.2 正文第一頁(yè)顯示成第 2 頁(yè)現(xiàn)象封面不顯示頁(yè)碼但正文第一頁(yè)的頁(yè)腳寫著“2 / 48”全篇頁(yè)碼整體偏移一位。原因封面也在分頁(yè)流里占用了counter(page) 1。你只是把封面的頁(yè)碼隱藏了content: none計(jì)數(shù)器并沒(méi)有歸零。解決兩個(gè)方向。CSS 方向是在正文起始元素上加counter-reset: page 1讓計(jì)數(shù)器從這里重算但這是 Paged.js 的擴(kuò)展行為不是所有環(huán)境都穩(wěn)上生產(chǎn)前必須實(shí)測(cè)。工程方向更保險(xiǎn)封面單獨(dú)渲染一次正文單獨(dú)渲染一次最后在 PDF 層合并頁(yè)碼天然從 1 開始。項(xiàng)目里做正式交付物時(shí)我傾向后者雖然多一步但不用賭渲染器的實(shí)現(xiàn)細(xì)節(jié)。順帶一提前言用羅馬數(shù)字、正文用阿拉伯?dāng)?shù)字這種需求做法是把前言和正文拆成兩個(gè)命名頁(yè)面各自counter(page, lower-roman)和counter(page)再把counter-reset放在兩段的交界處。復(fù)雜歸復(fù)雜但這是唯一能同時(shí)滿足兩種編號(hào)體系的路子。5.3 字體和圖片沒(méi)加載完就開始切頁(yè)現(xiàn)象導(dǎo)出的 PDF 和屏幕上看到的完全是兩份東西行高變了、每行字?jǐn)?shù)變了、末尾多出好幾頁(yè)或者圖片位置全錯(cuò)后面內(nèi)容整體上移。原因Paged.js 在 DOM 就緒后立刻開始切頁(yè)此時(shí) Web 字體還在下載瀏覽器先用 fallback 字體渲染Chunker 量到的高度是錯(cuò)的。字體替換完成之后每行能塞的字?jǐn)?shù)變化整篇需要重排但分頁(yè)已經(jīng)結(jié)束。圖片同理沒(méi)寫死尺寸的img初始高度接近 0加載完撐開后面的分頁(yè)全廢。解決給所有圖片寫死width/height或aspect-ratio讓它在加載前就占好位置把分頁(yè)推遲到字體和圖片都就緒之后具體做法在 6.1 給代碼。字體這塊還有一個(gè)隱性成本中文字體動(dòng)輒幾 MB用font-display: swap會(huì)讓首屏更快但排版更不穩(wěn)做 PDF 輸出時(shí)反而應(yīng)該用font-display: block寧可等一會(huì)兒也別讓它換字。5.4 長(zhǎng)表格翻頁(yè)后表頭不見(jiàn)了現(xiàn)象跨頁(yè)的表格第二頁(yè)開始只有數(shù)據(jù)行讀者對(duì)不上哪列是什么。原因thead沒(méi)有聲明為table-header-group。默認(rèn)的table-header-group是瀏覽器給的但在分頁(yè)場(chǎng)景下需要顯式寫出來(lái)讓切頁(yè)邏輯知道“這一段每頁(yè)都要重復(fù)”。另一種情況是表格上加了break-inside: avoid整塊被推到下一頁(yè)雖然表頭在但上一頁(yè)留下半頁(yè)空白視覺(jué)上更難看。解決thead { display: table-header-group; }tr { break-inside: avoid; }保證單行不被劈開把表格上的break-inside: avoid去掉。如果表格有tfoot做合計(jì)行同樣處理。另外表格列寬盡量用table-layout: fixed加百分比自動(dòng)列寬在跨頁(yè)時(shí)可能因?yàn)閮?nèi)容分布變化而重算導(dǎo)致前后兩頁(yè)列寬不一致。5.5 打印預(yù)覽比屏幕多出幾頁(yè)現(xiàn)象屏幕上數(shù)出來(lái) 40 頁(yè)CtrlP 預(yù)覽變成 43 頁(yè)或者每頁(yè)底部?jī)?nèi)容被裁掉一截。原因二次分頁(yè)。Paged.js 已經(jīng)把內(nèi)容切成了固定高度的頁(yè)面盒瀏覽器打印時(shí)又按自己的page規(guī)則分了一次兩套規(guī)則打架。另一種是page.pdf()里還帶著 margin和 CSS 里的margin疊加版心被壓小。解決打印媒介下把page的margin歸零因?yàn)榘嫘倪吘嘁呀?jīng)由頁(yè)面盒自己承擔(dān)了headless 打印時(shí)用preferCSSPageSize: true讓渲染器服從 CSS 里的size同時(shí)把margin全部設(shè)成 0printBackground: true一定要開否則頁(yè)眉線、底紋、邊框全部消失看起來(lái)像樣式丟了。注意不要用瀏覽器的“另存為 PDF”做最終交付它受用戶本機(jī)紙型、縮放比例、頁(yè)眉頁(yè)腳開關(guān)影響同一份文件在不同機(jī)器上能出不同結(jié)果。要么走 headless要么在頁(yè)面里顯式提示用戶把頁(yè)邊距選“無(wú)”、縮放選“默認(rèn)”。6. 接進(jìn)自動(dòng)化等對(duì)完成信號(hào)再做版式回歸6.1 headless 打印auto:false 加一個(gè)顯式完成標(biāo)記自動(dòng)分頁(yè)最大的問(wèn)題是“什么時(shí)候算排完了”沒(méi)有標(biāo)準(zhǔn)答案。穩(wěn)妥做法是關(guān)掉自動(dòng)執(zhí)行手動(dòng)觸發(fā)并在after回調(diào)里打一個(gè)標(biāo)記外部等這個(gè)標(biāo)記。// print.js —— 用 Playwright 驅(qū)動(dòng)已經(jīng)接好 Paged.js 的頁(yè)面 const { chromium } require(playwright); (async () { const browser await chromium.launch(); const page await browser.newPage(); // 關(guān)鍵關(guān)掉 auto改成手動(dòng)觸發(fā)否則拿不到排完這個(gè)時(shí)刻 await page.addInitScript(() { window.PagedConfig { auto: false, after: (flow) { window.__pagedReady true; window.__pagedTotal flow.total; // 總頁(yè)數(shù)后面做回歸斷言用 }, }; }); await page.goto(http://localhost:8080/manual.html, { waitUntil: load }); // 字體是異步的等它落地再切頁(yè)否則行高全錯(cuò) await page.evaluate(async () { await document.fonts.ready; await window.PagedPolyfill.preview(); }); await page.waitForFunction(() window.__pagedReady true, { timeout: 60000 }); await page.pdf({ path: out.pdf, preferCSSPageSize: true, // 服從 CSS 的 page size別讓渲染器再猜 printBackground: true, // 頁(yè)眉線、底紋、邊框全靠它 margin: { top: 0, right: 0, bottom: 0, left: 0 }, // 版心邊距已在 CSS 里別疊加 }); await browser.close(); })();document.fonts.ready是這一段的重點(diǎn)它返回一個(gè) Promise字體全部就緒才 resolve。waitForFunction的超時(shí)要給足中文長(zhǎng)文檔加上幾 MB 字體一分鐘并不夸張。flow.total來(lái)自after回調(diào)是 Paged.js 統(tǒng)計(jì)出的總頁(yè)數(shù)比自己去數(shù) DOM 節(jié)點(diǎn)可靠。6.2 把頁(yè)數(shù)和關(guān)鍵頁(yè)文本變成斷言版式回歸不需要圖像比對(duì)太脆。頁(yè)數(shù)加關(guān)鍵頁(yè)文本這兩條斷言能擋住 90% 的樣式事故。// 改完樣式跑一遍頁(yè)數(shù)或關(guān)鍵頁(yè)內(nèi)容變了就先看 diff const total await page.evaluate(() window.__pagedTotal); const page5 await page.evaluate(() document.querySelectorAll(.pagedjs_page)[4].innerText.slice(0, 40) ); if (total ! 42) { throw new Error(頁(yè)數(shù)從 42 變成 ${total}先查字體加載和 break 規(guī)則); } if (!page5.includes(第 3 章)) { throw new Error(第 5 頁(yè)內(nèi)容異常${page5}); }頁(yè)數(shù)變了不一定是壞事但一定意味著有東西動(dòng)了可能是字體換了、可能是斷頁(yè)規(guī)則改了、也可能是某個(gè)圖片沒(méi)加載出來(lái)。把它當(dāng)紅燈逼自己每次改完樣式都跑一遍比事后發(fā)現(xiàn)交付物錯(cuò)版強(qiáng)太多。我現(xiàn)在的固定動(dòng)作是改完任何一條page或break-*規(guī)則先跑 6.1 的腳本出一份 PDF肉眼掃一遍頁(yè)眉頁(yè)腳再跑 6.2 的斷言兩條都過(guò)才算完。這套流程是從一次交付前夜發(fā)現(xiàn)全篇頁(yè)碼偏移一位開始養(yǎng)成的那次通宵重排的教訓(xùn)太貴。那份 PDF 教程我一般不開著通讀當(dāng)成屬性索引放在手邊遇到string()的取值或者footnote的寫法再翻效率比從頭看高得多。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取