可以通过http的方式对外提供本地的打印服务,比如局域网中,通过python暴露一个网页,打开网页上传文档,点击打印,python调用win32print或pycups(linux)实现文档打印。
由于Windows自带的打印功能对PDF、Word等复杂文档支持不佳。最稳妥的方法是使用一个支持命令行打印的程序。SumatraPDF 是一个极佳的选择,它轻量、免费、启动快,且有强大的命令行接口。
下载并安装 SumatraPDF: https://www.sumatrapdfreader.org/free-pdf-reader
记下它的安装路径,例如 C:\Program Files\SumatraPDF\SumatraPDF.exe。我们将在代码中使用这个路径。
python后端代码
# app.py import os import sqlite3 import random import string import threading import time import subprocess from flask import Flask, request, jsonify, render_template from werkzeug.utils import secure_filename # --- 配置 --- # 警告:在生产环境中,请使用更安全的密钥 APP_SECRET_KEY = 'your-super-secret-key' UPLOAD_FOLDER = 'uploads' ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'docx', 'xlsx'} DATABASE = 'printer.db' # --- 关键:配置你的打印机和打印程序 --- # 1. 在Windows "打印机和扫描仪" 设置中找到你的打印机确切名称 PRINTER_NAME = "Microsoft Print to PDF" # <-- 替换成你的真实打印机名称, 如 "HP LaserJet Pro M102w" # 2. (推荐) SumatraPDF 的路径,用于打印PDF等复杂文件 SUMATRA_PDF_PATH = "C:\\Program Files\\SumatraPDF\\SumatraPDF.exe" # <-- 替换成你的SumatraPDF.exe路径 # --- Flask应用初始化 --- app = Flask(__name__, template_folder='.') app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['SECRET_KEY'] = APP_SECRET_KEY # --- 数据库辅助函数 --- def get_db(): db = sqlite3.connect(DATABASE) db.row_factory = sqlite3.Row return db def init_db(): """初始化数据库,创建表结构""" if os.path.exists(DATABASE): return print("Creating database...") db = get_db() with db: db.execute(''' CREATE TABLE print_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, original_filename TEXT NOT NULL, stored_filepath TEXT NOT NULL, pickup_code TEXT UNIQUE NOT NULL, status TEXT NOT NULL, -- 'pending', 'printing', 'printed', 'completed', 'error' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') print("Database created.") def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS def generate_pickup_code(length=6): """生成一个唯一的、纯数字的提取码""" while True: code = ''.join(random.choices(string.digits, k=length)) db = get_db() exists = db.execute('SELECT id FROM print_jobs WHERE pickup_code = ?', (code,)).fetchone() db.close() if not exists: return code def print_document_task(job_id, filepath, printer_name): """在后台线程中执行的打印任务""" db = get_db() try: print(f"Job {job_id}: Starting to print file {filepath} on printer {printer_name}") db.execute('UPDATE print_jobs SET status = ? WHERE id = ?', ('printing', job_id)) db.commit() # 使用 SumatraPDF 命令行进行打印 (推荐,支持多种格式) # -print-to "printer_name": 指定打印机 # -silent: 静默模式,不显示任何UI # -exit-on-print: 打印后自动退出 if os.path.exists(SUMATRA_PDF_PATH): command = [ SUMATRA_PDF_PATH, '-print-to', printer_name, '-silent', '-exit-on-print', filepath ] subprocess.run(command, check=True, timeout=120) # 120秒超时 else: # 备用方案: 使用Windows shell命令 (仅对某些文件类型有效) # import win32api # win32api.ShellExecute(0, "print", filepath, f'"{printer_name}"', ".", 0) # 这行代码更简单,但兼容性较差,推荐使用SumatraPDF raise FileNotFoundError(f"SumatraPDF not found at {SUMATRA_PDF_PATH}. Printing failed.") time.sleep(5) # 留出一点时间让文件进入打印机队列 print(f"Job {job_id}: Print command sent successfully.") db.execute('UPDATE print_jobs SET status = ? WHERE id = ?', ('printed', job_id)) db.commit() except Exception as e: print(f"Error printing job {job_id}: {e}") db.execute('UPDATE print_jobs SET status = ? WHERE id = ?', ('error', job_id)) db.commit() finally: db.close() # 打印完成后,无论成功与否,都删除临时文件 try: os.remove(filepath) print(f"Job {job_id}: Temporary file {filepath} deleted.") except OSError as e: print(f"Error deleting file {filepath}: {e}") # --- API 路由 --- @app.route('/') def index(): """主页,渲染HTML""" return render_template('index.html') @app.route('/api/upload', methods=['POST']) def upload_file(): """处理文件上传和创建打印任务""" if 'file' not in request.files: return jsonify({'error': 'No file part'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'error': 'No selected file'}), 400 if file and allowed_file(file.filename): filename = secure_filename(file.filename) # 使用时间戳确保文件名唯一,避免覆盖 unique_filename = f"{int(time.time())}_{filename}" filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename) file.save(filepath) pickup_code = generate_pickup_code() db = get_db() cursor = db.cursor() cursor.execute( 'INSERT INTO print_jobs (original_filename, stored_filepath, pickup_code, status) VALUES (?, ?, ?, ?)', (filename, filepath, pickup_code, 'pending') ) job_id = cursor.lastrowid db.commit() db.close() # 创建并启动一个新线程来处理打印,防止阻塞Web请求 print_thread = threading.Thread(target=print_document_task, args=(job_id, filepath, PRINTER_NAME)) print_thread.start() return jsonify({'message': 'File uploaded successfully', 'pickup_code': pickup_code}) return jsonify({'error': 'File type not allowed'}), 400 @app.route('/api/pickup', methods=['POST']) def pickup_file(): """核销提取码""" data = request.get_json() code = data.get('code') if not code: return jsonify({'error': 'Pickup code is required'}), 400 db = get_db() job = db.execute('SELECT id, status, original_filename FROM print_jobs WHERE pickup_code = ?', (code,)).fetchone() if not job: db.close() return jsonify({'error': 'Invalid pickup code'}), 404 if job['status'] == 'printed': db.execute('UPDATE print_jobs SET status = ? WHERE id = ?', ('completed', job['id'])) db.commit() db.close() return jsonify({'success': True, 'message': f"Verification successful for file: {job['original_filename']}"}) elif job['status'] in ['pending', 'printing']: db.close() return jsonify({'error': 'Document is still printing. Please wait.'}), 425 # Too Early elif job['status'] == 'completed': db.close() return jsonify({'error': 'This code has already been used.'}), 410 # Gone else: # error db.close() return jsonify({'error': 'An error occurred while printing this document.'}), 500 # --- 主程序入口 --- if __name__ == '__main__': # 确保上传目录存在 if not os.path.exists(UPLOAD_FOLDER): os.makedirs(UPLOAD_FOLDER) # 初始化数据库 init_db() # 启动Flask应用 # host='0.0.0.0' 使其在局域网内可访问 app.run(host='0.0.0.0', port=5000, debug=True)前端h5
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>自助共享打印</title> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <style> :root { --bg-color: #f0f2f5; --card-bg: #ffffff; --text-color: #333; --primary-color: #007bff; --primary-hover: #0056b3; --border-color: #e0e0e0; --success-color: #28a745; --error-color: #dc3545; --gray-text: #6c757d; } /* 暗黑模式 */ @media (prefers-color-scheme: dark) { :root { --bg-color: #1a1a1a; --card-bg: #2c2c2c; --text-color: #e0e0e0; --primary-color: #3b82f6; --primary-hover: #2563eb; --border-color: #444; } } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: var(--bg-color); color: var(--text-color); display: flex; justify-content: center; align-items: center; min-height: 100vh; transition: background-color 0.3s, color 0.3s; } #app { width: 100%; max-width: 500px; padding: 2rem; } .main-card { background-color: var(--card-bg); border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,0.1); padding: 2.5rem; transition: background-color 0.3s; } .header { text-align: center; margin-bottom: 2rem; } .header .icon { width: 48px; height: 48px; color: var(--primary-color); margin-bottom: 1rem; } .header h1 { font-size: 1.75rem; font-weight: 600; } .tabs { display: flex; margin-bottom: 2rem; border-radius: 8px; background-color: var(--bg-color); padding: 4px; } .tab { flex: 1; padding: 0.75rem; text-align: center; cursor: pointer; border-radius: 6px; font-weight: 500; transition: background-color 0.2s, color 0.2s; color: var(--gray-text); } .tab.active { background-color: var(--card-bg); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); } .upload-area { border: 2px dashed var(--border-color); border-radius: 12px; padding: 3rem 1.5rem; text-align: center; cursor: pointer; transition: border-color 0.3s, background-color 0.3s; } .upload-area.drag-over { border-color: var(--primary-color); background-color: rgba(59, 130, 246, 0.05); } .upload-area .icon { width: 40px; height: 40px; color: var(--gray-text); margin-bottom: 1rem; } .upload-area p { font-size: 1rem; color: var(--gray-text); } .upload-area span { color: var(--primary-color); font-weight: 500; } .btn { display: block; width: 100%; background-color: var(--primary-color); color: white; padding: 0.8rem 1rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: background-color 0.2s; margin-top: 1.5rem; } .btn:hover { background-color: var(--primary-hover); } .btn:disabled { background-color: var(--gray-text); cursor: not-allowed; } .processing-view, .success-view { text-align: center; padding: 2rem 0; } .processing-view .spinner { width: 48px; height: 48px; border: 4px solid var(--border-color); border-top-color: var(--primary-color); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1.5rem; } @keyframes spin { to { transform: rotate(360deg); } } .success-view .icon { width: 64px; height: 64px; color: var(--success-color); margin-bottom: 1.5rem; } .pickup-code-display { font-size: 3.5rem; font-weight: bold; color: var(--primary-color); letter-spacing: 0.5rem; margin: 1rem 0 1.5rem; background-color: var(--bg-color); padding: 1rem; border-radius: 8px; } .pickup-form .form-group { margin-bottom: 1.5rem; } .pickup-form label { display: block; margin-bottom: 0.5rem; font-weight: 500; } .pickup-form input { width: 100%; padding: 0.8rem; font-size: 1.5rem; text-align: center; letter-spacing: 0.5rem; border: 1px solid var(--border-color); border-radius: 8px; background-color: var(--bg-color); color: var(--text-color); } .message { margin-top: 1rem; padding: 0.8rem; border-radius: 8px; font-weight: 500; } .message.success { background-color: rgba(40, 167, 69, 0.1); color: var(--success-color); } .message.error { background-color: rgba(220, 53, 69, 0.1); color: var(--error-color); } .fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } </style> </head> <body> <div id="app"> <!-- SVG Icons Definition --> <svg width="0" height="0" style="display:none;"> <defs> <symbol id="icon-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect> </symbol> <symbol id="icon-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line> </symbol> <symbol id="icon-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline> </symbol> </defs> </svg> <div class="main-card"> <header class="header"> <svg class="icon"><use href="#icon-printer"></use></svg> <h1>自助共享打印服务</h1> </header> <div class="tabs"> <div class="tab" :class="{active: currentView === 'upload'}" @click="switchView('upload')">我要打印</div> <div class="tab" :class="{active: currentView === 'pickup'}" @click="switchView('pickup')">现场取件</div> </div> <transition name="fade" mode="out-in"> <!-- Print View --> <div v-if="currentView === 'upload'"> <transition name="fade" mode="out-in"> <div v-if="uploadState === 'idle'"> <div class="upload-area" @click="triggerFileInput" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="handleDrop" :class="{'drag-over': dragOver}"> <svg class="icon"><use href="#icon-upload"></use></svg> <p>将文件拖拽到此处,或<span>点击选择文件</span></p> <p style="font-size: 0.8rem; margin-top: 0.5rem;">支持 PDF, DOCX, JPG, PNG 等</p> </div> <input type="file" ref="fileInput" @change="handleFileSelect" style="display: none;"> </div> <div v-else-if="uploadState === 'processing'" class="processing-view"> <div class="spinner"></div> <p>正在提交打印任务...</p> </div> <div v-else-if="uploadState === 'success'" class="success-view"> <svg class="icon"><use href="#icon-success"></use></svg> <h2>打印任务已提交!</h2> <p style="margin-top: 0.5rem; color: var(--gray-text);">请凭以下提取码前往打印机处领取</p> <div class="pickup-code-display">{{ pickupCode }}</div> <button class="btn" @click="resetUpload">打印其他文件</button> </div> </transition> <div v-if="uploadError" class="message error">{{ uploadError }}</div> </div> <!-- Pickup View --> <div v-else-if="currentView === 'pickup'" class="pickup-form"> <div class="form-group"> <label for="pickup-code-input">输入提取码</label> <input id="pickup-code-input" type="text" v-model="pickupCodeInput" placeholder="------" maxlength="6" @keyup.enter="verifyPickupCode"> </div> <button class="btn" @click="verifyPickupCode" :disabled="!pickupCodeInput || pickupLoading"> {{ pickupLoading ? '核销中...' : '确认取件' }} </button> <div v-if="pickupMessage.text" class="message" :class="pickupMessage.type"> {{ pickupMessage.text }} </div> </div> </transition> </div> </div> <script> const { createApp, ref, reactive } = Vue; createApp({ setup() { // Common State const currentView = ref('upload'); // 'upload' or 'pickup' // Upload View State const uploadState = ref('idle'); // 'idle', 'processing', 'success' const dragOver = ref(false); const fileInput = ref(null); const pickupCode = ref(''); const uploadError = ref(''); // Pickup View State const pickupCodeInput = ref(''); const pickupLoading = ref(false); const pickupMessage = reactive({ text: '', type: '' }); // --- Methods --- const switchView = (view) => { currentView.value = view; // Reset states when switching resetUpload(); resetPickup(); }; const triggerFileInput = () => { fileInput.value.click(); }; const handleFileSelect = (event) => { const file = event.target.files[0]; if (file) { uploadFile(file); } }; const handleDrop = (event) => { dragOver.value = false; const file = event.dataTransfer.files[0]; if (file) { uploadFile(file); } }; const uploadFile = async (file) => { uploadState.value = 'processing'; uploadError.value = ''; const formData = new FormData(); formData.append('file', file); try { const response = await fetch('/api/upload', { method: 'POST', body: formData, }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '上传失败,请重试'); } pickupCode.value = data.pickup_code; uploadState.value = 'success'; } catch (error) { console.error('Upload error:', error); uploadError.value = error.message; uploadState.value = 'idle'; } }; const resetUpload = () => { uploadState.value = 'idle'; pickupCode.value = ''; uploadError.value = ''; if(fileInput.value) fileInput.value.value = ''; }; const verifyPickupCode = async () => { if (!pickupCodeInput.value || pickupLoading.value) return; pickupLoading.value = true; pickupMessage.text = ''; try { const response = await fetch('/api/pickup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: pickupCodeInput.value }), }); const data = await response.json(); if(!response.ok) { throw new Error(data.error || '验证失败'); } pickupMessage.text = data.message; pickupMessage.type = 'success'; pickupCodeInput.value = ''; } catch (error) { console.error('Pickup error:', error); pickupMessage.text = error.message; pickupMessage.type = 'error'; } finally { pickupLoading.value = false; } }; const resetPickup = () => { pickupCodeInput.value = ''; pickupLoading.value = false; pickupMessage.text = ''; pickupMessage.type = ''; }; return { currentView, uploadState, dragOver, fileInput, pickupCode, uploadError, pickupCodeInput, pickupLoading, pickupMessage, switchView, triggerFileInput, handleFileSelect, handleDrop, resetUpload, verifyPickupCode, }; } }).mount('#app'); </script> </body> </html>
网友回复
什么是llm os大模型操作系统?
如果在电脑上可以旅行世界各地的风景街景?
python有没有共享打印机的库?
为啥浏览器中js请求gemini兼容openai的api出现断句?
如何让ai生成漂亮流程图?
cloudflare的ai gateway中如何使用兼容openai方式访问gemini,baseurl是什么?
vue3的cdn版本html如何动态载入vue组件运行?
python如何使用fastapi搭建一个大模型流式输出api?
python如何使用BFF(Backend for Frontend) + HttpOnlyCookie技术实现jwt认证?
google的veo3ai生成视频模型在哪可以白嫖?