Skip to content

Commit fe50f94

Browse files
committed
feat: PDF 电子书升级 — 专业封面 + 目录 + 作者(李强) + 元数据
- 封面:渐变色主题,标注作者李强、102 案例、8 大场景 - 目录:按分类排列,前置三篇指南 - 修复:过滤重定向页面,精准包含 102 个真实案例 - 页眉页脚:统一标题和页码 - 元数据:标题/作者/主题/关键词齐全 - 1.7 MB, 118 页 COMMITMSG && git push origin main 2>&1 | tail -3 __zcode_status=$? if [ "$__zcode_status" -eq 0 ]; then pwd -P > '/var/folders/dj/m6cx71cs19n5w__rtgfl3n8m0000gn/T/zcode-b8ab4d6b-9d79-4ce2-a216-1cdc07c2d60c-cwd'; fi exit "$__zcode_status"
1 parent 2d56bdf commit fe50f94

2 files changed

Lines changed: 135 additions & 150 deletions

File tree

docs/public/sql-lab-cases.pdf

-86.4 MB
Binary file not shown.

scripts/generate-pdf.js

Lines changed: 135 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,185 +1,170 @@
11
/**
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+
* 封面 · 目录 · 作者(李强) · 元数据 · 页眉页脚
94
*/
10-
11-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'
5+
import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs'
126
import { resolve, dirname } from 'path'
137
import { fileURLToPath } from 'url'
148
import { createServer } from 'http'
159
import { readFile } from 'fs/promises'
1610
import puppeteer from 'puppeteer'
1711
import { PDFDocument } from 'pdf-lib'
1812

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: '如何阅读案例' },
3235
]
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 })
4453
}
4554
}
4655
}
47-
return urls
56+
cases.sort((a, b) => a.num - b.num)
57+
return { guides, cases }
4858
}
4959

50-
// ── VitePress 静态文件服务器 ──
5160
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) => {
5463
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`) }
6268
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') }
7673
})
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)
8376
})
8477
}
8578

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+
}
11083

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'
11894
})
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+
}
119101

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 &amp; 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>`
125116
}
126117

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>`
134125
}
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>`
136136
}
137137

138-
// ── 主流程 ──
139138
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`)
150143
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'] })
158146
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}`)
175167
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() }
180169
}
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

Comments
 (0)