|
1 | 1 | /** |
2 | | - * generate-pdf.js — 将 VitePress 文档站渲染为单个 PDF |
3 | | - * |
4 | | - * 用法: |
5 | | - * npm run docs:build # 先构建 |
6 | | - * npm run docs:pdf # 再生成 PDF |
7 | | - * |
8 | | - * 输出: docs/public/sql-lab-cases.pdf |
| 2 | + * generate-pdf.js — SQL Lab 电子书 PDF |
| 3 | + * 封面 · 目录 · 作者(李强) · 元数据 · 页眉页脚 |
9 | 4 | */ |
10 | | - |
11 | | -import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs' |
| 5 | +import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs' |
12 | 6 | import { resolve, dirname } from 'path' |
13 | 7 | import { fileURLToPath } from 'url' |
14 | 8 | import { createServer } from 'http' |
15 | 9 | import { readFile } from 'fs/promises' |
16 | 10 | import puppeteer from 'puppeteer' |
17 | 11 | import { PDFDocument } from 'pdf-lib' |
18 | 12 |
|
19 | | -const __dirname = dirname(fileURLToPath(import.meta.url)) |
20 | | -const PROJECT_ROOT = resolve(__dirname, '..') |
21 | | -const DIST_DIR = resolve(PROJECT_ROOT, 'docs/.vitepress/dist') |
22 | | -const OUTPUT_FILE = resolve(PROJECT_ROOT, 'docs/public/sql-lab-cases.pdf') |
23 | | -const BASE = '/sql-lab/' |
24 | | -const PORT = 4173 |
25 | | - |
26 | | -// ── 收集所有页面 URL ── |
27 | | -function collectUrls() { |
28 | | - const guideUrls = [ |
29 | | - '/guide/introduction', |
30 | | - '/guide/quick-start', |
31 | | - '/guide/how-to-read', |
| 13 | +const RD = resolve(dirname(fileURLToPath(import.meta.url)), '..') |
| 14 | +const DIST = resolve(RD, 'docs/.vitepress/dist') |
| 15 | +const OUT = resolve(RD, 'docs/public/sql-lab-cases.pdf') |
| 16 | +const AUTHOR = '李强' |
| 17 | +const PORT = 4175 |
| 18 | + |
| 19 | +const CATEGORIES = [ |
| 20 | + { d: 'indexing', n: '一、索引设计与失效', s: 1, e: 18 }, |
| 21 | + { d: 'query-rewrite', n: '二、查询改写', s: 19, e: 32 }, |
| 22 | + { d: 'join', n: '三、JOIN 优化', s: 33, e: 41 }, |
| 23 | + { d: 'ddl', n: '四、DDL 与大表', s: 42, e: 51 }, |
| 24 | + { d: 'architecture', n: '五、架构级优化', s: 52, e: 62 }, |
| 25 | + { d: 'transaction', n: '六、事务与锁', s: 63, e: 71 }, |
| 26 | + { d: 'optimizer', n: '七、优化器与 8.0 新特性', s: 72, e: 80 }, |
| 27 | + { d: 'tidb', n: '八、TiDB 分布式优化', s: 81, e: 102 }, |
| 28 | +] |
| 29 | + |
| 30 | +function collect() { |
| 31 | + const guides = [ |
| 32 | + { u: '/guide/introduction', t: '项目介绍' }, |
| 33 | + { u: '/guide/quick-start', t: '快速开始' }, |
| 34 | + { u: '/guide/how-to-read', t: '如何阅读案例' }, |
32 | 35 | ] |
33 | | - |
34 | | - const cats = ['indexing', 'query-rewrite', 'join', 'ddl', |
35 | | - 'architecture', 'transaction', 'optimizer', 'tidb'] |
36 | | - |
37 | | - const urls = [...guideUrls] |
38 | | - for (const cat of cats) { |
39 | | - const d = resolve(DIST_DIR, 'cases', cat) |
40 | | - if (!existsSync(d)) continue |
41 | | - for (const f of readdirSync(d)) { |
42 | | - if (f.endsWith('.html')) { |
43 | | - urls.push(`/cases/${cat}/${f.replace('.html', '')}`) |
| 36 | + const cases = [] |
| 37 | + for (const c of CATEGORIES) { |
| 38 | + const dir = resolve(DIST, 'cases', c.d) |
| 39 | + if (!existsSync(dir)) continue |
| 40 | + for (const f of readdirSync(dir).filter(x => x.endsWith('.html'))) { |
| 41 | + const slug = f.replace('.html', '') |
| 42 | + const m = slug.match(/^(\d+)/) |
| 43 | + if (!m) continue |
| 44 | + const num = parseInt(m[1], 10) |
| 45 | + if (num >= c.s && num <= c.e) { |
| 46 | + // 跳过重定向页面 |
| 47 | + const fp = resolve(dir, f) |
| 48 | + const content = readFileSync(fp, 'utf-8') |
| 49 | + if (content.includes('http-equiv="refresh"')) continue |
| 50 | + // 相同编号只保留第一个(实际案例优先于重定向) |
| 51 | + if (cases.find(x => x.num === num)) continue |
| 52 | + cases.push({ u: `/cases/${c.d}/${slug}`, num, cat: c.n, slug }) |
44 | 53 | } |
45 | 54 | } |
46 | 55 | } |
47 | | - return urls |
| 56 | + cases.sort((a, b) => a.num - b.num) |
| 57 | + return { guides, cases } |
48 | 58 | } |
49 | 59 |
|
50 | | -// ── VitePress 静态文件服务器 ── |
51 | 60 | function startServer() { |
52 | | - return new Promise((resolvePromise, reject) => { |
53 | | - const server = createServer(async (req, res) => { |
| 61 | + return new Promise((ok, fail) => { |
| 62 | + const s = createServer(async (req, res) => { |
54 | 63 | let url = new URL(req.url, `http://localhost:${PORT}`).pathname |
55 | | - if (url.startsWith(BASE)) url = url.slice(BASE.length - 1) |
56 | | - if (url === '' || url === '/') url = '/index.html' |
57 | | - |
58 | | - let filePath = resolve(DIST_DIR, `.${url}`) |
59 | | - if (!existsSync(filePath)) filePath = `${filePath}.html` |
60 | | - if (!existsSync(filePath)) filePath = resolve(DIST_DIR, `.${url}/index.html`) |
61 | | - |
| 64 | + if (url.startsWith('/sql-lab/')) url = url.slice(9) |
| 65 | + if (!url || url === '/') url = '/index.html' |
| 66 | + let fp = resolve(DIST, `.${url}`) |
| 67 | + if (!existsSync(fp)) { fp += '.html'; if (!existsSync(fp)) fp = resolve(DIST, `.${url}/index.html`) } |
62 | 68 | try { |
63 | | - const content = await readFile(filePath) |
64 | | - const ext = filePath.endsWith('.html') ? 'text/html' |
65 | | - : filePath.endsWith('.js') ? 'application/javascript' |
66 | | - : filePath.endsWith('.css') ? 'text/css' |
67 | | - : filePath.endsWith('.svg') ? 'image/svg+xml' |
68 | | - : filePath.endsWith('.json') ? 'application/json' |
69 | | - : 'application/octet-stream' |
70 | | - res.writeHead(200, { 'Content-Type': ext }) |
71 | | - res.end(content) |
72 | | - } catch { |
73 | | - res.writeHead(404) |
74 | | - res.end('Not Found') |
75 | | - } |
| 69 | + const ct = await readFile(fp) |
| 70 | + res.writeHead(200, { 'Content-Type': fp.endsWith('.html') ? 'text/html' : fp.endsWith('.js') ? 'text/javascript' : fp.endsWith('.css') ? 'text/css' : 'text/plain' }) |
| 71 | + res.end(ct) |
| 72 | + } catch { res.writeHead(404); res.end('404') } |
76 | 73 | }) |
77 | | - |
78 | | - server.listen(PORT, () => { |
79 | | - console.log(` 📡 http://localhost:${PORT}${BASE}`) |
80 | | - resolvePromise(server) |
81 | | - }) |
82 | | - server.on('error', reject) |
| 74 | + s.listen(PORT, () => ok(s)) |
| 75 | + s.on('error', fail) |
83 | 76 | }) |
84 | 77 | } |
85 | 78 |
|
86 | | -// ── 渲染单页为 PDF Buffer ── |
87 | | -async function renderPage(browser, url, index, total) { |
88 | | - const page = await browser.newPage() |
89 | | - try { |
90 | | - const fullUrl = `http://localhost:${PORT}${BASE}${url.replace(/^\//, '')}` |
91 | | - process.stdout.write(` [${String(index).padStart(3)}/${total}] ${fullUrl} `.padEnd(80) + '\r') |
92 | | - |
93 | | - await page.goto(fullUrl, { waitUntil: 'networkidle0', timeout: 30000 }) |
94 | | - |
95 | | - // 移除导航栏、侧边栏、页脚 |
96 | | - await page.evaluate(() => { |
97 | | - const remove = sel => document.querySelectorAll(sel).forEach(e => e.remove()) |
98 | | - remove('.VPNav') |
99 | | - remove('.VPSidebar') |
100 | | - remove('.VPLocalNav') |
101 | | - remove('.VPFooter') |
102 | | - remove('.DocFooter') |
103 | | - remove('.edit-link') |
104 | | - remove('.prev-next') |
105 | | - remove('.VPDocAside') |
106 | | - remove('.VPNavScreen') |
107 | | - }) |
108 | | - |
109 | | - await page.evaluate(() => document.fonts.ready) |
| 79 | +async function htmlPdf(browser, html) { |
| 80 | + const p = await browser.newPage() |
| 81 | + try { await p.setContent(html, { waitUntil: 'load' }); await p.evaluate(() => document.fonts.ready); return await p.pdf({ format: 'A4', printBackground: true, margin: { top: 0, bottom: 0, left: 0, right: 0 } }) } finally { await p.close() } |
| 82 | +} |
110 | 83 |
|
111 | | - const pdfBuf = await page.pdf({ |
112 | | - format: 'A4', |
113 | | - margin: { top: '20mm', bottom: '22mm', left: '18mm', right: '18mm' }, |
114 | | - printBackground: true, |
115 | | - displayHeaderFooter: true, |
116 | | - headerTemplate: '<div style="font-size:8px;color:#999;text-align:center;width:100%;padding:4px 0;border-bottom:1px solid #e8e8e8">SQL Lab · MySQL + TiDB 优化实战案例集</div>', |
117 | | - footerTemplate: '<div style="font-size:8px;color:#999;text-align:center;width:100%;padding:4px 0;border-top:1px solid #e8e8e8"><span class="pageNumber"></span> / <span class="totalPages"></span></div>', |
| 84 | +async function pagePdf(browser, url, i, t) { |
| 85 | + const p = await browser.newPage() |
| 86 | + try { |
| 87 | + const full = `http://localhost:${PORT}/sql-lab/${url.replace(/^\//, '')}` |
| 88 | + process.stdout.write(` [${String(i).padStart(3)}/${t}] ${url.slice(0, 60).padEnd(62)}\r`) |
| 89 | + await p.goto(full, { waitUntil: 'networkidle0', timeout: 30000 }) |
| 90 | + await p.evaluate(() => { |
| 91 | + for (const s of '.VPNav,.VPSidebar,.VPLocalNav,.VPFooter,.DocFooter,.edit-link,.prev-next,.VPDocAside,.VPNavScreen,.VPSkipLink'.split(',')) document.querySelectorAll(s).forEach(e => e.remove()) |
| 92 | + const d = document.querySelector('.VPDoc'); if (d) d.style.padding = '0 36px' |
| 93 | + const c = document.querySelector('.VPContent'); if (c) c.style.paddingLeft = '0' |
118 | 94 | }) |
| 95 | + await p.evaluate(() => document.fonts.ready) |
| 96 | + const buf = await p.pdf({ format: 'A4', margin: { top: '22mm', bottom: '24mm', left: '18mm', right: '18mm' }, printBackground: true, displayHeaderFooter: true, headerTemplate: '<div style="font-size:7px;color:#bbb;text-align:center;width:100%;padding:4px 0;border-bottom:1px solid #eee">SQL Lab · MySQL + TiDB 优化实战案例集</div>', footerTemplate: '<div style="font-size:7px;color:#bbb;text-align:center;width:100%;padding:4px 0;border-top:1px solid #eee">- <span class="pageNumber"></span> -</div>' }) |
| 97 | + process.stdout.write(` [${String(i).padStart(3)}/${t}] ✅\n`) |
| 98 | + return buf |
| 99 | + } finally { await p.close() } |
| 100 | +} |
119 | 101 |
|
120 | | - process.stdout.write(` [${String(index).padStart(3)}/${total}] ✅ ${url}\n`) |
121 | | - return pdfBuf |
122 | | - } finally { |
123 | | - await page.close() |
124 | | - } |
| 102 | +function coverHtml() { |
| 103 | + return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><style> |
| 104 | +*{margin:0;padding:0;box-sizing:border-box} |
| 105 | +body{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;display:flex;align-items:center;justify-content:center;width:210mm;height:297mm;background:linear-gradient(160deg,#0f2b1d 0%,#1e4a30 35%,#2d6b44 65%,#3a8a54 100%);color:#fff} |
| 106 | +.c{text-align:center;padding:56px 80px} |
| 107 | +.c .ic{font-size:64px;margin-bottom:20px} |
| 108 | +.c h1{font-size:54px;font-weight:800;letter-spacing:8px;margin-bottom:12px;text-shadow:0 3px 12px rgba(0,0,0,.25)} |
| 109 | +.c .st{font-size:22px;font-weight:300;opacity:.92;margin-bottom:36px;line-height:1.6;letter-spacing:3px} |
| 110 | +.c .dv{width:120px;height:2px;background:rgba(255,255,255,.4);margin:0 auto 36px} |
| 111 | +.c .info{font-size:14px;opacity:.8;line-height:2;margin-bottom:48px} |
| 112 | +.c .info span{margin:0 12px;padding:4px 16px;border:1px solid rgba(255,255,255,.3);border-radius:16px} |
| 113 | +.c .au{font-size:26px;font-weight:600;letter-spacing:6px;margin-top:40px} |
| 114 | +.c .ft{position:absolute;bottom:32px;left:0;right:0;text-align:center;font-size:11px;opacity:.4;letter-spacing:2px} |
| 115 | +</style></head><body><div class="c"><div class="ic">🐳</div><h1>SQL Lab</h1><div class="st">MySQL + TiDB<br>优化实战案例集</div><div class="dv"></div><div class="info"><span>102 个案例</span><span>8 大场景</span><span>Docker 复现</span><br><span>MySQL 5.7 & 8.0</span><span>TiDB v7.5</span></div><div class="au">${AUTHOR}</div><div class="ft">2026 · https://slowleelab.github.io/sql-lab/</div></div></body></html>` |
125 | 116 | } |
126 | 117 |
|
127 | | -// ── 合并 PDF ── |
128 | | -async function mergePdfs(pdfBuffers) { |
129 | | - const merged = await PDFDocument.create() |
130 | | - for (const buf of pdfBuffers) { |
131 | | - const doc = await PDFDocument.load(buf) |
132 | | - const pages = await merged.copyPages(doc, doc.getPageIndices()) |
133 | | - pages.forEach(p => merged.addPage(p)) |
| 118 | +function tocHtml(guides, cases) { |
| 119 | + let rows = '' |
| 120 | + for (const g of guides) rows += `<tr class="gd"><td></td><td>${g.t}</td></tr>` |
| 121 | + let lc = '' |
| 122 | + for (const c of cases) { |
| 123 | + if (c.cat !== lc) { rows += `<tr class="ct"><td colspan="2">${c.cat}</td></tr>`; lc = c.cat } |
| 124 | + rows += `<tr><td class="nm">${String(c.num).padStart(2, '0')}</td><td>${c.slug.replace(/^\d+-/, '').replace(/-/g, ' ')}</td></tr>` |
134 | 125 | } |
135 | | - return Buffer.from(await merged.save()) |
| 126 | + return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><style> |
| 127 | +*{margin:0;padding:0;box-sizing:border-box} |
| 128 | +body{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;padding:50px 60px;color:#333} |
| 129 | +h1{font-size:30px;text-align:center;margin-bottom:36px;padding-bottom:18px;border-bottom:2px solid #3a8a54;letter-spacing:4px} |
| 130 | +table{width:100%;border-collapse:collapse;font-size:13px} |
| 131 | +tr.gd td{font-weight:600;color:#3a8a54;padding:8px 8px 4px;font-size:15px} |
| 132 | +tr.ct td{color:#3a8a54;font-weight:700;padding:14px 8px 4px;font-size:14px;border-top:1px solid #e0e0e0} |
| 133 | +td{padding:4px 8px;line-height:1.6;border-bottom:1px dotted #e8e8e8} |
| 134 | +td.nm{width:36px;text-align:right;color:#999;padding-right:12px;font-size:11px} |
| 135 | +</style></head><body><h1>目 录</h1><table>${rows}</table></body></html>` |
136 | 136 | } |
137 | 137 |
|
138 | | -// ── 主流程 ── |
139 | 138 | async function main() { |
140 | | - console.log('📄 SQL Lab PDF Generator\n') |
141 | | - |
142 | | - if (!existsSync(resolve(DIST_DIR, 'index.html'))) { |
143 | | - console.error('❌ 未找到构建产物,请先运行: npm run docs:build') |
144 | | - process.exit(1) |
145 | | - } |
146 | | - |
147 | | - const urls = collectUrls() |
148 | | - console.log(` 📚 ${urls.length} 个页面 (3 指南 + ${urls.length - 3} 案例)\n`) |
149 | | - |
| 139 | + console.log('📖 SQL Lab PDF 电子书\n') |
| 140 | + if (!existsSync(resolve(DIST, 'index.html'))) { console.error('❌ 请先 npm run docs:build'); process.exit(1) } |
| 141 | + const { guides, cases } = collect() |
| 142 | + console.log(` 📚 ${guides.length} 前言 + ${cases.length} 案例\n`) |
150 | 143 | const server = await startServer() |
151 | | - console.log(' 🚀 Headless Chrome...') |
152 | | - const browser = await puppeteer.launch({ |
153 | | - headless: true, |
154 | | - executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', |
155 | | - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], |
156 | | - }) |
157 | | - |
| 144 | + console.log(` 📡 :${PORT}`) |
| 145 | + const browser = await puppeteer.launch({ headless: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', args: ['--no-sandbox'] }) |
158 | 146 | try { |
159 | | - console.log(' 📖 渲染页面...\n') |
160 | | - const pdfs = [] |
161 | | - for (let i = 0; i < urls.length; i++) { |
162 | | - const buf = await renderPage(browser, urls[i], i + 1, urls.length) |
163 | | - pdfs.push(buf) |
164 | | - } |
165 | | - |
166 | | - console.log('\n 🔗 合并 PDF...') |
167 | | - const merged = await mergePdfs(pdfs) |
168 | | - const outDir = dirname(OUTPUT_FILE) |
169 | | - if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }) |
170 | | - writeFileSync(OUTPUT_FILE, merged) |
171 | | - |
172 | | - const sizeMB = (merged.length / 1024 / 1024).toFixed(2) |
173 | | - console.log(`\n ✅ docs/public/sql-lab-cases.pdf`) |
174 | | - console.log(` 📦 ${sizeMB} MB | 📄 ${urls.length}+ 页`) |
| 147 | + console.log('\n 📄 封面 + 📋 目录...') |
| 148 | + const pdfs = [await htmlPdf(browser, coverHtml()), await htmlPdf(browser, tocHtml(guides, cases))] |
| 149 | + const total = guides.length + cases.length |
| 150 | + console.log(`\n 📖 渲染 ${total} 页...\n`) |
| 151 | + for (const g of guides) pdfs.push(await pagePdf(browser, g.u, pdfs.length - 1, total)) |
| 152 | + for (let i = 0; i < cases.length; i++) pdfs.push(await pagePdf(browser, cases[i].u, i + 1, cases.length)) |
| 153 | + console.log('\n 🔗 合并 + 元数据...') |
| 154 | + const doc = await PDFDocument.create() |
| 155 | + doc.setTitle('SQL Lab · MySQL + TiDB 优化实战案例集') |
| 156 | + doc.setAuthor(AUTHOR) |
| 157 | + doc.setSubject('102 个 MySQL + TiDB 优化实战案例 — Docker 一键复现,EXPLAIN 量化对比') |
| 158 | + doc.setKeywords(['MySQL', 'TiDB', 'SQL优化', 'EXPLAIN', '索引', '事务', '分布式']) |
| 159 | + doc.setCreator(`SQL Lab (${AUTHOR})`) |
| 160 | + doc.setProducer('SQL Lab PDF Generator') |
| 161 | + for (const buf of pdfs) { const s = await PDFDocument.load(buf); const pp = await doc.copyPages(s, s.getPageIndices()); for (const pg of pp) doc.addPage(pg) } |
| 162 | + const outData = Buffer.from(await doc.save()) |
| 163 | + const d = dirname(OUT); if (!existsSync(d)) mkdirSync(d, { recursive: true }) |
| 164 | + writeFileSync(OUT, outData) |
| 165 | + console.log(`\n ✅ ${OUT}`) |
| 166 | + console.log(` 📦 ${(outData.length / 1024 / 1024).toFixed(1)} MB | 👤 ${AUTHOR}`) |
175 | 167 | console.log(` 🌐 https://slowleelab.github.io/sql-lab/sql-lab-cases.pdf`) |
176 | | - } finally { |
177 | | - await browser.close() |
178 | | - server.close() |
179 | | - } |
| 168 | + } finally { await browser.close(); server.close() } |
180 | 169 | } |
181 | | - |
182 | | -main().catch(err => { |
183 | | - console.error('❌', err.message) |
184 | | - process.exit(1) |
185 | | -}) |
| 170 | +main().catch(e => { console.error('❌', e.message); process.exit(1) }) |
0 commit comments