网页转pdf
大约 5 分钟约 1393 字...
网页转pdf
文章以Markdown格式存储,并通过GitSite工具生成HTML静态页面。现在,要把一个页面转成一个PDF文件,最简单的方法是用浏览器的打印功能,可以直接将页面输出为PDF。
这个可以使本博客的文章变成PDF。
思路与代码
| # | 流程 | 说明 | 
|---|---|---|
| 1 | 打开网页 | 无头 Chrome 访问目标地址 | 
| 2 | 注入标记 | 在每个标题后插入 <span class="bmk">[BMK-n]</span>,打印时不可见 | 
| 3 | 等待渲染 | 滚动到底 → 回顶部 → 等待全部 <img> 和 Mermaid SVG 加载完成 | 
| 4 | 生成 PDF | 调用 page.PrintToPDF 得到 main.pdf | 
| 5 | 提取书签 | pdftotext -layout 按 \f 分页 → 搜 BMK-n 得页码 → 输出 bookmark.txt | 
| 6 | 合并封面 | 若存在 front.pdf / back.pdf 则用 pdftk cat 合并 | 
| 7 | 写入书签 | pdftk merged.pdf update_info_utf8 bookmark.txt output final.pdf | 
| 步骤 | 作用 | 
|---|---|
| 1. 打开网页 | 用 Chrome 无头浏览器加载目标网页 | 
| 2. 注入隐藏书签标记 | 给每个标题(h1/h2/h3)后面插入看不见的白字 token(如 [BMK-0]) | 
| 3. 等待内容加载完整 | 滚动页面、等待图片、Mermaid 图表渲染完成 | 
| 4. 打印成 PDF | 用 Chrome 的 PrintToPDF 生成正文 PDF(main.pdf) | 
| 5. 提取书签结构 | 根据白字 token 在 PDF 中的页码,生成准确的书签文件(bookmark.txt) | 
| 6. 合并前后封面(如有) | 支持可选的前后封面 PDF 合并 | 
| 7. 写入书签 | 用 pdftk 把书签写入最终 PDF(output.pdf) | 
package main
import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"
	"github.com/chromedp/cdproto/page"
	"github.com/chromedp/chromedp"
)
const rawURL = "https://yesuifeng.cc/sap/abap/porestful.html"
// const rawURL = "https://yesuifeng.cc/sap/fico/accountdetermination.html"
var batch = time.Now().Format("060102150405") // 批次号
// ---------- 文件命名 ----------
func tmpDir() string {
	dir := "tmp"
	_ = os.MkdirAll(dir, 0755)
	return dir
}
func mainPDF() string    { return filepath.Join(tmpDir(), batch+"_main.pdf") }
func mergedPDF() string  { return filepath.Join(tmpDir(), batch+"_merged.pdf") }
func finalPDF() string   { return filepath.Join(tmpDir(), batch+"_output.pdf") }
func bookmarkFn() string { return filepath.Join(tmpDir(), batch+"_bookmark.txt") }
// ---------- 主入口 ----------
func main() {
	ctx, cancel := chromedp.NewContext(context.Background())
	defer cancel()
	mainPdf := mainPDF()
	finalPdf := finalPDF()
	var markersJSON string
	// 1. 生成正文 PDF
	if err := chromedp.Run(ctx,
		chromedp.Navigate(rawURL),
		chromedp.WaitReady("body"),
		// 注入不可见的书签标记,并返回 level/text/token 列表
		chromedp.Evaluate(`(function(){
            const style=document.createElement('style');
            style.textContent='@media print{.bmk{color:#fff!important;font-size:1px!important}} .bmk{color:#fff;font-size:1px}';
            document.head.appendChild(style);
            const list=[]; let idx=0;
            for(const h of document.querySelectorAll('h1,h2,h3')){
                const level=parseInt(h.tagName.substring(1));
                const text=(h.innerText||'').trim();
                if(!text) continue;
                const token='BMK-'+(idx++);
                const span=document.createElement('span');
                span.className='bmk';
                span.textContent=' ['+token+']';
                h.appendChild(span);
                list.push({level,text,token});
            }
            return JSON.stringify(list);
        })()`, &markersJSON),
		waitImages(),
		chromedp.Sleep(2*time.Second),
		printToPDF(mainPdf),
	); err != nil {
		panic(err)
	}
	// 2. 书签(基于注入的标记 token 定位页码)
	bookmark, err := extractBookmarkFromMarkers(mainPdf, markersJSON)
	if err != nil {
		panic(err)
	}
	if err := os.WriteFile(bookmarkFn(), []byte(bookmark), 0644); err != nil {
		panic(err)
	}
	// 3. 合并
	mergedPdf := mergedPDF()
	if err := mergePDFs(mergedPdf, mainPdf); err != nil {
		panic(err)
	}
	// 4. 写入书签
	cmd := exec.Command("pdftk", mergedPdf, "update_info_utf8", bookmarkFn(), "output", finalPdf)
	if err := cmd.Run(); err != nil {
		panic(err)
	}
	fmt.Println("✅ 完整 PDF 已生成:", finalPdf)
}
// ---------- 下面依次定义所有函数 ----------
func waitImages() chromedp.ActionFunc {
	return chromedp.ActionFunc(func(ctx context.Context) error {
		// 1. 分批滚动
		for i := 0; i < 20; i++ {
			if err := chromedp.Evaluate(`window.scrollBy(0, innerHeight)`, nil).Do(ctx); err != nil {
				return err
			}
			if err := chromedp.Sleep(800 * time.Millisecond).Do(ctx); err != nil {
				return err
			}
		}
		// 2. 回顶部
		if err := chromedp.Evaluate(`window.scrollTo(0, 0)`, nil).Do(ctx); err != nil {
			return err
		}
		// 3. 等待 <img> 加载完成
		if err := chromedp.Evaluate(`Promise.all(Array.from(document.images).map(img => {
            if (img.complete) return Promise.resolve();
            return new Promise((res, rej) => {
                img.addEventListener('load', res);
                img.addEventListener('error', rej);
            });
        }))`, nil).Do(ctx); err != nil {
			return err
		}
		// 4. 等 Mermaid SVG 渲染
		if err := chromedp.Evaluate(`
			new Promise(resolve => {
				const t=setInterval(()=>{
					const mm=[...document.querySelectorAll('.mermaid')];
					if(mm.length>0 && mm.every(d=>d.querySelector('svg'))){ clearInterval(t); resolve();}
				},300);
			})
		`, nil).Do(ctx); err != nil {
			return err
		}
		// 5. 缓冲
		return chromedp.Sleep(3 * time.Second).Do(ctx)
	})
}
func printToPDF(outPath string) chromedp.ActionFunc {
	return chromedp.ActionFunc(func(ctx context.Context) error {
		pdf, _, err := page.PrintToPDF().
			WithPaperWidth(8.27).
			WithPaperHeight(11.69).
			WithMarginTop(0.8).
			WithMarginBottom(0.8).
			WithMarginLeft(0.5).
			WithMarginRight(0.5).
			WithDisplayHeaderFooter(true).
			WithHeaderTemplate("Header").
			WithFooterTemplate(`<div style="font-size:10px;text-align:center;width:100%;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>`).
			Do(ctx)
		if err != nil {
			return err
		}
		return os.WriteFile(outPath, pdf, 0644)
	})
}
func extractBookmark(pdfPath string) (string, error) {
	out, err := exec.Command("pdftotext", "-layout", pdfPath, "-").Output()
	if err != nil {
		return "", err
	}
	text := string(out)
	// re := regexp.MustCompile(`chapter-(\w+)`)
	// 👇 改动 1:只抓 chapter-xxx 或 # 开头的编号标题
	re := regexp.MustCompile(`(?mi)(?:chapter-(\w+)|^#\s*(\d+\..+))$`)
	matches := re.FindAllStringSubmatch(text, -1)
	lines := []string{}
	page := 1
	for _, m := range matches {
		lines = append(lines,
			"BookmarkBegin",
			"BookmarkTitle: chapter-"+m[1],
			"BookmarkLevel: 1",
			"BookmarkPageNumber: "+strconv.Itoa(page),
		)
		page++
	}
	return strings.Join(lines, "\n"), nil
}
func mergePDFs(merged, mainPdf string) error {
	args := []string{}
	front := filepath.Join(tmpDir(), batch+"_front.pdf")
	back := filepath.Join(tmpDir(), batch+"_back.pdf")
	if _, err := os.Stat(front); err == nil {
		args = append(args, front)
	}
	args = append(args, mainPdf)
	if _, err := os.Stat(back); err == nil {
		args = append(args, back)
	}
	cmd := exec.Command("pdftk", append(args, "cat", "output", merged)...)
	return cmd.Run()
}
// 基于 DOM 抓取到的 h1/h2/h3 标题生成书签
func extractBookmarkFromHeadings(pdfPath string, headingsJSON string) (string, error) {
	// 解析来自页面的标题数组
	type heading struct {
		Level int    `json:"level"`
		Text  string `json:"text"`
	}
	var headings []heading
	if err := json.Unmarshal([]byte(headingsJSON), &headings); err != nil {
		return "", err
	}
	// 将生成的 PDF 转成文本并按换页符分页
	out, err := exec.Command("pdftotext", "-layout", pdfPath, "-").Output()
	if err != nil {
		return "", err
	}
	pages := strings.Split(string(out), "\f")
	lines := []string{}
	for _, h := range headings {
		t := strings.TrimSpace(h.Text)
		if t == "" {
			continue
		}
		// 在文本页中查找首次出现的页码
		pageNum := 1
		found := false
		for i, p := range pages {
			if strings.Contains(p, t) {
				pageNum = i + 1
				found = true
				break
			}
		}
		if !found {
			pageNum = 1
		}
		level := h.Level
		if level < 1 {
			level = 1
		}
		if level > 6 {
			level = 6
		}
		lines = append(lines,
			"BookmarkBegin",
			"BookmarkTitle: "+t,
			"BookmarkLevel: "+strconv.Itoa(level),
			"BookmarkPageNumber: "+strconv.Itoa(pageNum),
		)
	}
	return strings.Join(lines, "\n"), nil
}
// 基于打印前注入的 token 精确定位标题所在页,生成书签
func extractBookmarkFromMarkers(pdfPath string, markersJSON string) (string, error) {
	type marker struct {
		Level int    `json:"level"`
		Text  string `json:"text"`
		Token string `json:"token"`
	}
	var markers []marker
	if err := json.Unmarshal([]byte(markersJSON), &markers); err != nil {
		return "", err
	}
	out, err := exec.Command("pdftotext", "-layout", pdfPath, "-").Output()
	if err != nil {
		return "", err
	}
	pages := strings.Split(string(out), "\f")
	lines := []string{}
	for _, m := range markers {
		t := strings.TrimSpace(m.Text)
		if t == "" || m.Token == "" {
			continue
		}
		pageNum := 1
		for i, p := range pages {
			if strings.Contains(p, m.Token) {
				pageNum = i + 1
				break
			}
		}
		level := m.Level
		if level < 1 {
			level = 1
		}
		if level > 6 {
			level = 6
		}
		lines = append(lines,
			"BookmarkBegin",
			"BookmarkTitle: "+t,
			"BookmarkLevel: "+strconv.Itoa(level),
			"BookmarkPageNumber: "+strconv.Itoa(pageNum),
		)
	}
	return strings.Join(lines, "\n"), nil
}参考文献
你认为这篇文章怎么样?
0
0
0
0
0
0





