This commit is contained in:
2025-10-20 15:18:52 +08:00
parent 12f8b35304
commit e5d7db21e3
5 changed files with 2046 additions and 0 deletions

79
Dockerfile Normal file
View File

@@ -0,0 +1,79 @@
# ===============================
# 极致最小化分步构建 Node 18 + Playwright
# ===============================
# -------------------------------
# Stage 1: 构建层安装依赖、Chromium
# -------------------------------
FROM node:18-slim AS builder
WORKDIR /app
# 复制 package.json / package-lock.json
COPY package*.json ./
# 安装生产依赖(包含 Playwright
RUN npm install --omit=dev && npm cache clean --force
# 复制自定义中文字体并刷新缓存
COPY ./fonts/* /usr/share/fonts/
RUN apt update && \
apt install -y --no-install-recommends fontconfig && \
fc-cache -fv && \
rm -rf /var/lib/apt/lists/*
# 安装 Playwright Chromium headless
RUN npx playwright install chromium --only-shell
# 复制应用代码
COPY server.js ./
# -------------------------------
# Stage 2: 运行层(最终镜像)
# -------------------------------
FROM node:18-slim AS runtime
WORKDIR /app
# 安装依赖其中lib开头的是 playwright 需要的依赖。不同版本可能不一样
RUN apt update && \
apt install -y --no-install-recommends \
fontconfig \
libglib2.0-0 \
libnspr4 \
libnss3 \
libdbus-1-3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libatspi2.0-0 \
libx11-6 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libxcb1 \
libxkbcommon0 \
libasound2 && \
rm -rf /var/lib/apt/lists/*
# 复制字体
COPY --from=builder /usr/share/fonts/ /usr/share/fonts/
RUN fc-cache -fv
# 复制 package.json 和 node_modules包含 Playwright 及依赖)
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/node_modules /app/node_modules
# 复制应用代码
COPY --from=builder /app/server.js ./
# 复制 Playwright Chromium 缓存
COPY --from=builder /root/.cache/ms-playwright /root/.cache/ms-playwright
# 暴露端口
EXPOSE 3000
# 启动应用
CMD ["npm", "start"]

13
build.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# 创建并启用支持多平台的 buildx 构建器
docker buildx create --name multiarch-builder --driver docker-container --use
docker buildx inspect --bootstrap
# 现在可以一次性构建并推送多平台镜像
docker buildx build --platform linux/amd64,linux/arm64 \
-f Dockerfile \
-t yhl452493373/html2pdf-server:latest \
--push .
echo "多平台镜像构建并推送完成"

1495
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "html-to-pdf-service",
"version": "1.0.0",
"description": "HTML to PDF conversion service using Playwright",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.40.0",
"cors": "^2.8.5",
"multer": "^1.4.5-lts.1",
"body-parser": "^1.20.2",
"morgan": "^1.10.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

438
server.js Normal file
View File

@@ -0,0 +1,438 @@
const express = require('express');
const { chromium } = require('playwright');
const cors = require('cors');
const bodyParser = require('body-parser');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件
app.use(cors());
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
// 确保输出目录存在
const outputDir = path.join(__dirname, 'output');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
class PdfService {
constructor() {
this.browser = null;
}
async initialize() {
if (this.browser) return;
this.browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-web-security'
]
});
console.log('Playwright浏览器初始化成功');
}
async generatePdf(options) {
if (!this.browser) {
await this.initialize();
}
const context = await this.browser.newContext();
const page = await context.newPage();
try {
const {
html,
url,
landscape = false,
format = 'A4',
margin = { top: 20, right: 20, bottom: 20, left: 20 },
printBackground = true,
timeout = 60000
} = options;
page.setDefaultTimeout(timeout);
if (url) {
console.log(`正在生成PDF: ${url}`);
await page.goto(url, {
waitUntil: 'networkidle',
timeout: timeout
});
} else if (html) {
console.log('正在转换HTML到PDF');
await page.setContent(html, {
waitUntil: 'networkidle',
timeout: timeout
});
} else {
throw new Error('必须提供html或url参数');
}
// 生成PDF
const pdfBuffer = await page.pdf({
landscape,
format,
margin,
printBackground,
preferCSSPageSize: false
});
return pdfBuffer;
} finally {
await context.close();
}
}
async close() {
if (this.browser) {
await this.browser.close();
}
}
}
const pdfService = new PdfService();
// 健康检查端点
app.get('/health', async (req, res) => {
try {
await pdfService.initialize();
res.json({
status: 'healthy',
service: 'HTML to PDF Service',
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message
});
}
});
// 通过URL生成PDF
app.get('/generate', async (req, res) => {
try {
const { url, download = 'false' } = req.query;
if (!url) {
return res.status(400).send(`
<html>
<body style="font-family: Arial, sans-serif; margin: 40px;">
<h1>PDF生成服务</h1>
<p>请提供URL参数例如</p>
<code>/generate?url=https://example.com</code>
</body>
</html>
`);
}
const pdfBuffer = await pdfService.generatePdf({
url: url,
timeout: 60000
});
const filename = `document-${Date.now()}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
if (download === 'true') {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
} else {
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
}
res.send(pdfBuffer);
} catch (error) {
console.error('PDF生成错误:', error);
res.status(500).send(`
<html>
<body style="font-family: Arial, sans-serif; margin: 40px;">
<h1>PDF生成失败</h1>
<p>错误信息: ${error.message}</p>
<a href="/">返回首页</a>
</body>
</html>
`);
}
});
// 通过HTML内容生成PDF - POST接口
app.post('/generate/html', async (req, res) => {
try {
const { html, download = false, filename = `document-${Date.now()}.pdf` } = req.body;
if (!html) {
return res.status(400).json({
success: false,
error: 'HTML内容不能为空'
});
}
console.log('正在通过HTML内容生成PDF');
const pdfBuffer = await pdfService.generatePdf({
html: html,
timeout: 60000
});
res.setHeader('Content-Type', 'application/pdf');
if (download) {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
} else {
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
}
res.send(pdfBuffer);
} catch (error) {
console.error('HTML内容转PDF错误:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// 通过HTML内容生成PDF - GET接口表单提交
app.get('/generate/html-form', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>HTML转PDF</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.container { max-width: 800px; margin: 0 auto; }
textarea {
width: 100%;
height: 300px;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
}
input[type="text"] {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 10px 20px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 5px;
}
.button-group { margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<h1>HTML内容转PDF</h1>
<form id="htmlForm">
<label for="filename">文件名:</label>
<input type="text" id="filename" name="filename" value="document.pdf" placeholder="输入文件名">
<label for="htmlContent">HTML内容:</label>
<textarea id="htmlContent" name="html" placeholder="请输入HTML内容...">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;示例文档&lt;/title&gt;
&lt;style&gt;
body { font-family: Arial, sans-serif; margin: 40px; }
h1 { color: #333; }
.content { line-height: 1.6; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Hello PDF!&lt;/h1&gt;
&lt;div class="content"&gt;
&lt;p&gt;这是一个通过HTML内容生成的PDF文档。&lt;/p&gt;
&lt;p&gt;您可以在这里输入任何HTML内容。&lt;/p&gt;
&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;</textarea>
<div class="button-group">
<button type="button" onclick="generatePDF(false)">在线查看PDF</button>
<button type="button" onclick="generatePDF(true)">下载PDF</button>
</div>
</form>
<a href="/" style="color: #007bff;">返回首页</a>
</div>
<script>
function generatePDF(download) {
const form = document.getElementById('htmlForm');
const formData = new FormData(form);
const data = {
html: document.getElementById('htmlContent').value,
download: download,
filename: document.getElementById('filename').value
};
fetch('/generate/html', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => {
if (!response.ok) {
return response.json().then(err => { throw new Error(err.error); });
}
return response.blob();
})
.then(blob => {
if (download) {
// 下载文件
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
// 在新窗口打开
const url = window.URL.createObjectURL(blob);
window.open(url, '_blank');
window.URL.revokeObjectURL(url);
}
})
.catch(error => {
alert('生成PDF失败: ' + error.message);
});
}
</script>
</body>
</html>
`);
});
// 首页
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>PDF生成服务</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.container { max-width: 800px; margin: 0 auto; }
.button {
display: inline-block;
padding: 10px 20px;
margin: 10px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 5px;
}
.button:hover { background: #0056b3; }
.button-green {
background: #28a745;
}
.button-green:hover {
background: #218838;
}
form { margin: 20px 0; }
input[type="text"] {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
}
.feature-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>PDF生成服务</h1>
<div class="feature-card">
<h2>🔗 URL转PDF</h2>
<p>通过网页URL生成PDF文档</p>
<form action="/generate" method="GET">
<label for="url">输入URL:</label>
<input type="text" id="url" name="url" placeholder="https://example.com">
<br>
<label>
<input type="checkbox" name="download" value="true"> 下载
</label>
<br><br>
<button type="submit" style="padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">生成PDF</button>
</form>
</div>
<div class="feature-card">
<h2>📝 HTML内容转PDF</h2>
<p>通过HTML代码直接生成PDF文档</p>
<a class="button button-green" href="/generate/html-form">使用HTML转PDF工具</a>
</div>
<div style="margin-top: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
<h3>API接口说明</h3>
<p><strong>URL转PDF:</strong> GET /generate?url=https://example.com</p>
<p><strong>HTML转PDF:</strong> POST /generate/html</p>
<pre>
// 请求示例
{
"html": "&lt;h1&gt;Hello PDF&lt;/h1&gt;",
"download": true,
"filename": "document.pdf"
}
</pre>
</div>
</div>
</body>
</html>
`);
});
// 优雅关闭
process.on('SIGINT', async () => {
console.log('正在关闭服务...');
await pdfService.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('收到终止信号,正在关闭服务...');
await pdfService.close();
process.exit(0);
});
// 启动服务
app.listen(PORT, async () => {
console.log(`HTML转PDF服务运行在端口 ${PORT}`);
console.log(`访问地址: http://localhost:${PORT}`);
console.log(`URL转PDF: http://localhost:${PORT}/generate?url=https://example.com`);
console.log(`HTML转PDF表单: http://localhost:${PORT}/generate/html-form`);
});
module.exports = app;