init
This commit is contained in:
79
Dockerfile
Normal file
79
Dockerfile
Normal 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
13
build.sh
Normal 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
1495
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal 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
438
server.js
Normal 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内容..."><!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>示例文档</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
.content { line-height: 1.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Hello PDF!</h1>
|
||||||
|
<div class="content">
|
||||||
|
<p>这是一个通过HTML内容生成的PDF文档。</p>
|
||||||
|
<p>您可以在这里输入任何HTML内容。</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html></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": "<h1>Hello PDF</h1>",
|
||||||
|
"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;
|
||||||
Reference in New Issue
Block a user