图
PDF打印服务器
一个基于FastAPI的PDF打印服务器,支持通过网页界面上传PDF文件并打印到局域网内的物理打印机。
功能特性
- 📄 支持PDF文件上传和打印
- 🖨️ 自动检测系统打印机列表
- 📱 响应式设计,支持移动端和桌面端
- 🔄 实时打印任务状态监控
- 💾 自动保存用户设置(打印机、打印份数)
- 🔒 安全的文件处理,临时文件自动清理
- 🌐 支持局域网多设备访问
- 🎯 可靠的打印份数控制(支持1-100份)
系统要求
操作系统
- Windows 10/11 (推荐,功能最完整)
软件依赖
- Python 3.8+
- SumatraPDF (Windows用户必需)
- 系统打印机驱动
安装步骤
1. 安装Python依赖
pip install fastapi uvicorn psutil
Windows用户额外安装:
pip install pywin32
2. 安装SumatraPDF (仅Windows)
- 下载SumatraPDF:www.sumatrapdfreader.org/download-fr…
- 安装到默认路径:
C:\Program Files\SumatraPDF
3. 克隆或下载代码
git clone https://github.com/yourusername/pdf-print-server.git
cd pdf-print-server
4. 创建必要的目录结构
pdf-print-server/
├── uploads/ # 自动创建,用于临时存储上传的文件
├── static/ # 前端静态文件
│ └── index.html # 前端页面
└── server.py # 后端服务器代码
使用方法
1. 启动服务器
python server.py
服务器启动后将显示:
============================================================
PDF打印服务器启动 - SumatraPDF修复版
============================================================
本地访问: http://localhost:8083
局域网访问: http://192.168.1.100:8083
服务器地址: 192.168.1.100
系统: Windows
============================================================
2. 访问网页界面
- 在服务器本机:打开浏览器访问
http://localhost:8083 - 在局域网其他设备:打开浏览器访问
http://服务器IP:8083
3. 使用流程
- 选择PDF文件:点击"选择PDF文件"按钮或拖放文件到区域
- 选择打印机:从下拉列表中选择要使用的打印机
- 设置打印份数:输入需要打印的份数(1-100)
- 开始打印:点击"上传并打印"按钮
- 查看状态:在右侧任务状态区域查看打印进度
API接口
主要接口
| 端点 | 方法 | 描述 |
|---|---|---|
/ | GET | 前端页面 |
/api/printers | GET | 获取打印机列表 |
/api/upload | POST | 上传并打印PDF文件 |
/api/tasks | GET | 获取所有任务状态 |
/api/tasks/{task_id} | GET | 获取特定任务状态 |
/api/health | GET | 服务器健康检查 |
/api/sumatra-test | GET | 测试SumatraPDF安装 |
上传文件参数
curl -X POST http://localhost:8083/api/upload \
-F "file=@document.pdf" \
-F "printer=HP LaserJet" \
-F "copies=2"
配置文件说明
服务器配置
在 server.py 中可以修改以下配置:
# 端口配置
PORT = 8083 # 默认端口
# 文件上传限制
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
# 临时文件清理
FILE_CLEANUP_HOURS = 1 # 1小时后清理临时文件
前端配置
前端设置自动保存在浏览器的LocalStorage中:
- 选择的打印机
- 打印份数
- 刷新页面后设置保持不变
故障排除
常见问题
1. 打印机列表为空
- 检查打印机是否已连接并安装驱动
- Windows:确保打印机已设置为"默认打印机"
- Linux:确保CUPS服务正在运行
2. 打印失败
- Windows:检查SumatraPDF是否正确安装
- 查看服务器控制台日志获取详细错误信息
- 确保PDF文件没有损坏
3. 打印份数不起作用
- Windows:确认SumatraPDF版本支持
-print-settings "Nx"参数 - 尝试使用打印对话框方案
4. 无法访问网页
- 检查防火墙设置,确保8083端口开放
- 确保服务器和客户端在同一局域网
日志查看
服务器日志包含详细的操作信息:
- 文件上传状态
- 打印命令执行情况
- 错误信息
安全注意事项
- 文件安全:上传的文件会存储在临时目录,打印完成后自动删除
- 访问控制:当前版本无认证,建议在内网安全环境使用
- 文件大小限制:默认限制为10MB,防止大文件攻击
- 端口安全:建议在路由器中限制8083端口的访问
开发说明
项目结构
pdf-print-server/
├── server.py # FastAPI后端服务器
├── static/
│ ├── index.html # 前端HTML页面
│ └── (CSS/JS内联) # 样式和脚本
├── uploads/ # 临时文件目录
├── requirements.txt # Python依赖
└── README.md # 项目说明
更新日志
v1.3.0 (最新)
- 根据SumatraPDF官方文档修正打印份数设置
- 增加打印对话框备用方案
- 增强错误处理和日志记录
- 新增SumatraPDF测试接口
v1.2.0
- 修复移动端显示问题
- 添加异步任务处理
- 改进打印份数备用方案
- 增加系统健康检查
v1.1.0
- 添加设置自动保存功能
- 优化移动端用户体验
- 改进打印机列表获取
v1.0.0
- 初始版本发布
- 基本PDF上传和打印功能
- 打印机列表自动检测
- 任务状态监控
v1.0.0 Html代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF打印服务器</title>
<style>
:root {
--primary-color: #4361ee;
--secondary-color: #3a0ca3;
--success-color: #2ecc71;
--error-color: #e74c3c;
--light-bg: #f8f9fa;
--dark-text: #333;
--light-text: #666;
--border-color: #ddd;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: var(--dark-text);
background-color: #f0f2f5;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: white;
border-radius: 8px;
box-shadow: var(--shadow);
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
header p {
font-size: 1.1rem;
opacity: 0.9;
}
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
@media (max-width: 768px) {
.main-content {
grid-template-columns: 1fr;
}
}
.card {
background: white;
border-radius: 8px;
padding: 25px;
box-shadow: var(--shadow);
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
}
.card h2 {
color: var(--primary-color);
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid var(--light-bg);
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--dark-text);
}
.form-group input[type="file"],
.form-group select,
.form-group input[type="number"] {
width: 100%;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
background-color: white;
}
.form-group input[type="number"] {
max-width: 100px;
}
.primary-btn {
background-color: var(--primary-color);
color: white;
border: none;
padding: 14px 24px;
font-size: 1.1rem;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
width: 100%;
transition: background-color 0.3s;
margin-top: 10px;
}
.primary-btn:hover {
background-color: var(--secondary-color);
}
.primary-btn:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.progress-bar {
height: 8px;
background-color: #e0e0e0;
border-radius: 4px;
margin: 20px 0;
overflow: hidden;
display: none;
}
.progress-fill {
height: 100%;
background-color: var(--success-color);
width: 0%;
transition: width 0.3s ease;
}
.message {
padding: 12px;
border-radius: 4px;
margin-top: 15px;
font-weight: 500;
display: none;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
display: block;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
display: block;
}
.message.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
display: block;
}
#taskStatus {
min-height: 100px;
max-height: 300px;
overflow-y: auto;
}
.task-item {
padding: 15px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.task-item:last-child {
border-bottom: none;
}
.task-info {
flex: 1;
}
.task-filename {
font-weight: 600;
color: var(--dark-text);
margin-bottom: 5px;
}
.task-details {
font-size: 0.9rem;
color: var(--light-text);
}
.task-status {
padding: 5px 10px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-queued {
background-color: #fff3cd;
color: #856404;
}
.status-processing {
background-color: #d1ecf1;
color: #0c5460;
}
.status-completed {
background-color: #d4edda;
color: #155724;
}
.status-failed {
background-color: #f8d7da;
color: #721c24;
}
.info-box {
background: white;
border-radius: 8px;
padding: 25px;
box-shadow: var(--shadow);
}
.info-box h3 {
color: var(--primary-color);
margin-bottom: 15px;
}
.info-box ol {
padding-left: 20px;
margin-bottom: 20px;
}
.info-box li {
margin-bottom: 8px;
}
.server-info {
background-color: var(--light-bg);
padding: 15px;
border-radius: 6px;
border-left: 4px solid var(--primary-color);
font-family: monospace;
}
.server-info strong {
display: block;
margin-bottom: 5px;
font-family: inherit;
}
.footer {
text-align: center;
margin-top: 30px;
color: var(--light-text);
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>PDF打印服务器</h1>
<p>上传PDF文件并通过连接到服务器的打印机打印</p>
</header>
<div class="main-content">
<div class="card">
<h2>上传PDF文件</h2>
<div class="form-group">
<label for="pdfFile">选择PDF文件:</label>
<input type="file" id="pdfFile" accept=".pdf" required>
</div>
<div class="form-group">
<label for="printerSelect">选择打印机:</label>
<select id="printerSelect">
<option value="">加载中...</option>
</select>
</div>
<div class="form-group">
<label for="copies">打印份数 (1-100):</label>
<input type="number" id="copies" min="1" max="100" value="1">
</div>
<button id="uploadBtn" class="primary-btn">上传并打印</button>
<div id="uploadProgress" class="progress-bar">
<div class="progress-fill" style="width: 0%"></div>
</div>
<div id="message" class="message"></div>
</div>
<div class="card">
<h2>打印任务状态</h2>
<div id="taskStatus">
<p>暂无任务</p>
</div>
</div>
</div>
<div class="info-box">
<h3>使用说明</h3>
<ol>
<li>确保您的设备与服务器在同一局域网中</li>
<li>选择要打印的PDF文件(最大10MB)</li>
<li>选择连接到服务器的打印机</li>
<li>点击"上传并打印"按钮</li>
<li>打印完成后,文件将自动删除</li>
</ol>
<div class="server-info">
<strong>服务器地址:</strong> <span id="serverAddress">获取中...</span>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// 获取DOM元素
const pdfFileInput = document.getElementById('pdfFile');
const printerSelect = document.getElementById('printerSelect');
const copiesInput = document.getElementById('copies');
const uploadBtn = document.getElementById('uploadBtn');
const uploadProgress = document.getElementById('uploadProgress');
const progressFill = document.querySelector('.progress-fill');
const messageDiv = document.getElementById('message');
const taskStatusDiv = document.getElementById('taskStatus');
const serverAddressSpan = document.getElementById('serverAddress');
// 设置服务器地址显示
const serverUrl = `${window.location.protocol}//${window.location.hostname}:${window.location.port}`;
serverAddressSpan.textContent = serverUrl;
// 获取可用打印机列表
async function fetchPrinters() {
try {
const response = await fetch('/api/printers');
const data = await response.json();
printerSelect.innerHTML = '';
data.printers.forEach(printer => {
const option = document.createElement('option');
option.value = printer;
option.textContent = printer;
printerSelect.appendChild(option);
});
if (data.printers.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = '未检测到打印机';
printerSelect.appendChild(option);
}
} catch (error) {
showMessage('无法获取打印机列表: ' + error.message, 'error');
printerSelect.innerHTML = '<option value="">错误</option>';
}
}
// 上传文件
async function uploadFile() {
const file = pdfFileInput.files[0];
if (!file) {
showMessage('请先选择PDF文件', 'error');
return;
}
if (!file.name.toLowerCase().endsWith('.pdf')) {
showMessage('只支持PDF文件', 'error');
return;
}
if (file.size > 10 * 1024 * 1024) {
showMessage('文件大小不能超过10MB', 'error');
return;
}
const printer = printerSelect.value;
const copies = parseInt(copiesInput.value) || 1;
// 准备表单数据
const formData = new FormData();
formData.append('file', file);
if (printer) formData.append('printer', printer);
formData.append('copies', copies);
// 显示进度条
uploadProgress.style.display = 'block';
progressFill.style.width = '0%';
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
hideMessage();
try {
// 使用XMLHttpRequest以获取上传进度
const xhr = new XMLHttpRequest();
// 进度事件
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
progressFill.style.width = `${percent}%`;
}
});
// 完成事件
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
showMessage(`文件已提交打印! 任务ID: ${response.task_id}`, 'success');
// 清空输入
pdfFileInput.value = '';
copiesInput.value = '1';
// 添加任务到状态列表
addTaskToList({
id: response.task_id,
filename: file.name,
printer: printer || '默认打印机',
copies: copies,
status: response.status
});
} else {
const error = JSON.parse(xhr.responseText);
showMessage(`上传失败: ${error.detail || '服务器错误'}`, 'error');
}
resetUploadButton();
});
// 错误事件
xhr.addEventListener('error', () => {
showMessage('网络错误,请检查连接', 'error');
resetUploadButton();
});
// 发送请求
xhr.open('POST', '/api/upload');
xhr.send(formData);
} catch (error) {
showMessage('上传出错: ' + error.message, 'error');
resetUploadButton();
}
}
function resetUploadButton() {
uploadBtn.disabled = false;
uploadBtn.textContent = '上传并打印';
uploadProgress.style.display = 'none';
progressFill.style.width = '0%';
}
function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = `message ${type}`;
}
function hideMessage() {
messageDiv.style.display = 'none';
}
function addTaskToList(task) {
// 移除"暂无任务"提示
if (taskStatusDiv.querySelector('p')) {
taskStatusDiv.innerHTML = '';
}
const taskElement = document.createElement('div');
taskElement.className = 'task-item';
taskElement.id = `task-${task.id}`;
const statusClass = `status-${task.status}`;
const statusText = task.status === 'queued' ? '排队中' :
task.status === 'processing' ? '处理中' :
task.status === 'completed' ? '已完成' : '失败';
taskElement.innerHTML = `
<div class="task-info">
<div class="task-filename">${task.filename}</div>
<div class="task-details">
打印机: ${task.printer} | 份数: ${task.copies}
</div>
</div>
<div class="task-status ${statusClass}">${statusText}</div>
`;
// 添加到顶部
taskStatusDiv.insertBefore(taskElement, taskStatusDiv.firstChild);
// 如果任务完成或失败,不再更新
if (task.status !== 'completed' && task.status !== 'failed') {
// 每5秒检查一次状态
setTimeout(() => checkTaskStatus(task.id), 5000);
}
}
async function checkTaskStatus(taskId) {
try {
const response = await fetch(`/api/tasks/${taskId}`);
const task = await response.json();
const taskElement = document.getElementById(`task-${taskId}`);
if (taskElement) {
const statusElement = taskElement.querySelector('.task-status');
const statusClass = `status-${task.status}`;
const statusText = task.status === 'queued' ? '排队中' :
task.status === 'processing' ? '处理中' :
task.status === 'completed' ? '已完成' : '失败';
statusElement.className = `task-status ${statusClass}`;
statusElement.textContent = statusText;
// 如果任务还在进行中,继续检查
if (task.status === 'queued' || task.status === 'processing') {
setTimeout(() => checkTaskStatus(taskId), 5000);
}
}
} catch (error) {
console.error('获取任务状态失败:', error);
}
}
// 事件监听
uploadBtn.addEventListener('click', uploadFile);
// 页面加载时获取打印机列表
fetchPrinters();
// 每30秒刷新一次打印机列表
setInterval(fetchPrinters, 30000);
});
</script>
</body>
</html>
v1.0 后端代码:
import os
import uuid
import subprocess
import platform
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from typing import Optional
import uvicorn
app = FastAPI(title="PDF打印服务器", version="1.0.0")
# 创建必要的目录
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
# 挂载静态文件目录
app.mount("/static", StaticFiles(directory="static"), name="static")
# 全局状态存储任务信息
tasks = {}
def get_system_printers():
"""获取系统可用的打印机列表"""
system = platform.system()
printers = []
try:
if system == "Windows":
import win32print
printers = [p[2] for p in win32print.EnumPrinters(win32print.PRINTER_ENUM_LOCAL)]
elif system == "Linux":
result = subprocess.run(["lpstat", "-p"], capture_output=True, text=True)
for line in result.stdout.splitlines():
if line.startswith("printer"):
printers.append(line.split()[1])
elif system == "Darwin": # macOS
result = subprocess.run(["lpstat", "-p"], capture_output=True, text=True)
for line in result.stdout.splitlines():
if "printer" in line:
printers.append(line.split()[1])
except Exception:
pass
return printers or ["Default Printer"]
def print_pdf(file_path: str, printer_name: str = None, copies: int = 1):
"""打印PDF文件"""
system = platform.system()
file_path = str(file_path)
try:
if system == "Windows":
# 使用SumatraPDF进行打印(轻量级且支持命令行)
cmd = ["SumatraPDF.exe", "-print-to", printer_name or "", "-print-settings", f"x{copies}", file_path]
subprocess.run(cmd, check=True, timeout=30)
elif system == "Linux":
# 使用lp命令
cmd = ["lp", "-n", str(copies)]
if printer_name:
cmd.extend(["-d", printer_name])
cmd.append(file_path)
subprocess.run(cmd, check=True, timeout=30)
elif system == "Darwin": # macOS
# 使用lp命令
cmd = ["lp", "-n", str(copies)]
if printer_name:
cmd.extend(["-d", printer_name])
cmd.append(file_path)
subprocess.run(cmd, check=True, timeout=30)
return True
except Exception as e:
print(f"打印失败: {e}")
return False
@app.get("/", response_class=HTMLResponse)
async def root():
"""返回主页面"""
with open("static/index.html", "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read(), status_code=200)
@app.get("/api/printers")
async def get_printers():
"""获取可用打印机列表"""
printers = get_system_printers()
return {"printers": printers}
@app.post("/api/upload")
async def upload_pdf(
file: UploadFile = File(..., description="PDF文件"),
printer: Optional[str] = Form(None),
copies: int = Form(1, ge=1, le=100)
):
"""上传PDF文件并添加到打印队列"""
if not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="只支持PDF文件")
# 保存文件
filename = f"{uuid.uuid4().hex}.pdf"
file_path = UPLOAD_DIR / filename
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
# 创建任务ID
task_id = uuid.uuid4().hex
tasks[task_id] = {
"status": "queued",
"filename": file.filename,
"printer": printer,
"copies": copies
}
# 执行打印(在实际应用中应使用后台任务)
try:
success = print_pdf(file_path, printer, copies)
tasks[task_id]["status"] = "completed" if success else "failed"
except Exception as e:
tasks[task_id]["status"] = "failed"
tasks[task_id]["error"] = str(e)
return {"task_id": task_id, "status": tasks[task_id]["status"]}
@app.get("/api/tasks/{task_id}")
async def get_task_status(task_id: str):
"""获取任务状态"""
if task_id not in tasks:
raise HTTPException(status_code=404, detail="任务不存在")
return tasks[task_id]
if __name__ == "__main__":
# 获取本地IP地址
import socket
hostname = socket.gethostname()
local_ip = socket.gethostbyname(hostname)
print(f"服务器启动: http://{local_ip}:8083")
print(f"局域网内其他设备可访问: http://{local_ip}:8083")
uvicorn.run(app, host="0.0.0.0", port=8083)