فهرست منبع

add:增加在pdf上增加签章

zoie 2 هفته پیش
والد
کامیت
a967270331
8فایلهای تغییر یافته به همراه1931 افزوده شده و 10 حذف شده
  1. 3 0
      README.md
  2. 28 1
      app.py
  3. 70 9
      drag_signature.py
  4. 445 0
      font_comparison.html
  5. 433 0
      font_preview.html
  6. 563 0
      generate_handwriting.py
  7. 388 0
      process_image.py
  8. 1 0
      requirements.txt

+ 3 - 0
README.md

@@ -1,5 +1,8 @@
 pdf文件处理
 
+pip3 install rembg -i https://mirrors.aliyun.com/pypi/simple/
+
+
 ## 功能列表
 
 1. **add_signature** - 添加公章(自动识别签名位置)

+ 28 - 1
app.py

@@ -11,6 +11,8 @@ from add_watermark import add_watermark_to_pdf
 from extract_table import extract_temp_time, extract_pdf_table_to_excel, extract_temp_by_datetime_pattern, allowed_file, \
     safe_filename, extract_temperature_data_from_pdf, extract_data_from_pdf_5
 from drag_signature import add_signature_at_position, add_multiple_signatures
+from process_image import create_process_image_route
+from generate_handwriting import create_handwriting_route
 from lib import Qiniu
 from werkzeug.utils import secure_filename
 from flask_cors import CORS
@@ -274,7 +276,7 @@ def drag_signature():
         "pdf_url": "PDF文件URL",
         "signatures": [
             {
-                "signature_url": "签名/电子章URL",
+                "signature_url": "签名/电子章URL或base64字符串(支持data URL格式,如data:image/png;base64,...)",
                 "page_num": 页码(从0开始),
                 "x": X坐标(相对于页面左上角,单位:点),
                 "y": Y坐标(相对于页面左上角,单位:点),
@@ -328,6 +330,31 @@ def drag_signature():
         }), 500
 
 
+# 注册图片处理接口
+create_process_image_route(app)
+
+# 注册手写字体生成接口
+create_handwriting_route(app)
+
+
+@app.route('/font_preview')
+def font_preview():
+    """字体预览页面"""
+    return send_file('font_preview.html')
+
+
+@app.route('/font_comparison')
+def font_comparison():
+    """字体对比预览页面"""
+    return send_file('font_comparison.html')
+
+
+@app.route('/drag_signature')
+def drag_signature_page():
+    """PDF签名拖放工具页面"""
+    return send_file('drag_signature.html')
+
+
 if __name__ == '__main__':
     print("项目地址:", os.path.dirname(__file__))
     if len(sys.argv) != 2:

+ 70 - 9
drag_signature.py

@@ -1,6 +1,8 @@
 import io
 import os
 import tempfile
+import base64
+import re
 from reportlab.lib.pagesizes import A4
 from PyPDF2 import PdfReader, PdfWriter
 from reportlab.pdfgen import canvas
@@ -29,6 +31,65 @@ def download_image_from_url(url, output_path):
         raise Exception(f"不支持的图片类型: {content_type}")
 
 
+def save_image_from_base64(base64_string, output_path):
+    """从base64字符串保存图片到本地"""
+    try:
+        # 移除可能的数据URL前缀(如 data:image/png;base64,)
+        base64_data = re.sub(r'^data:image/[^;]+;base64,', '', base64_string)
+        
+        # 解码base64数据
+        image_data = base64.b64decode(base64_data)
+        
+        # 保存到文件
+        with open(output_path, 'wb') as f:
+            f.write(image_data)
+        
+        return output_path
+    except Exception as e:
+        raise Exception(f"无法解析base64图片: {e}")
+
+
+def get_image_from_source(source, output_path):
+    """
+    从URL或base64字符串获取图片并保存到本地
+    
+    参数:
+    - source: 可以是URL字符串或base64字符串(支持data URL格式)
+    - output_path: 输出文件路径
+    
+    返回:
+    - 输出文件路径
+    """
+    # 判断是否为base64格式
+    # 1. 以data:image/开头的是data URL格式的base64
+    # 2. 以http://或https://开头的是URL
+    # 3. 其他情况可能是纯base64字符串
+    if source.startswith('data:image/'):
+        # data URL格式的base64
+        return save_image_from_base64(source, output_path)
+    elif source.startswith('http://') or source.startswith('https://'):
+        # URL格式
+        try:
+            return download_image_from_url(source, output_path)
+        except Exception as e:
+            logger.warning(f"尝试作为URL处理失败: {e},尝试作为base64处理")
+            # 如果URL处理失败,尝试作为base64处理(可能是错误的URL格式)
+            try:
+                return save_image_from_base64(source, output_path)
+            except:
+                raise Exception(f"无法处理签名图片,既不是有效的URL也不是有效的base64: {e}")
+    else:
+        # 可能是纯base64字符串,先尝试base64,失败则尝试URL
+        try:
+            return save_image_from_base64(source, output_path)
+        except Exception as e1:
+            logger.warning(f"尝试作为base64处理失败: {e1},尝试作为URL处理")
+            try:
+                return download_image_from_url(source, output_path)
+            except Exception as e2:
+                raise Exception(f"无法处理签名图片,既不是有效的base64也不是有效的URL: base64错误={e1}, URL错误={e2}")
+
+
 def add_signature_at_position(input_pdf, output_pdf, signature_url, page_num, x, y, width=None, height=None):
     """
     在PDF的指定位置添加签名或电子章
@@ -36,7 +97,7 @@ def add_signature_at_position(input_pdf, output_pdf, signature_url, page_num, x,
     参数:
     - input_pdf: 输入PDF文件路径
     - output_pdf: 输出PDF文件路径
-    - signature_url: 签名/电子章的URL(可以是图片
+    - signature_url: 签名/电子章的URL或base64字符串(支持data URL格式,如data:image/png;base64,...
     - page_num: 页码(从0开始)
     - x: X坐标(相对于PDF页面左下角,单位:点)
     - y: Y坐标(相对于PDF页面左下角,单位:点)
@@ -44,15 +105,15 @@ def add_signature_at_position(input_pdf, output_pdf, signature_url, page_num, x,
     - height: 签名高度(可选,单位:点,默认按比例)
     """
     try:
-        # 下载签名图片
+        # 下载签名图片(支持URL和base64)
         temp_signature = os.path.join(tempfile.gettempdir(), f"temp_signature_{os.urandom(8).hex()}")
         try:
-            # 尝试下载为图片
-            download_image_from_url(signature_url, temp_signature)
+            # 支持URL和base64格式
+            get_image_from_source(signature_url, temp_signature)
             signature_path = temp_signature
         except Exception as e:
-            logger.error(f"下载签名失败: {e}")
-            raise Exception(f"无法下载签名图片: {e}")
+            logger.error(f"获取签名图片失败: {e}")
+            raise Exception(f"无法获取签名图片: {e}")
         
         # 读取PDF
         reader = PdfReader(input_pdf)
@@ -147,7 +208,7 @@ def add_multiple_signatures(input_pdf, output_pdf, signatures):
     - output_pdf: 输出PDF文件路径
     - signatures: 签名列表,每个元素为字典:
         {
-            'signature_url': '签名URL',
+            'signature_url': '签名URL或base64字符串(支持data URL格式)',
             'page_num': 页码(从0开始),
             'x': X坐标,
             'y': Y坐标,
@@ -185,9 +246,9 @@ def add_multiple_signatures(input_pdf, output_pdf, signatures):
                 
                 for sig in signatures_by_page[page_index]:
                     try:
-                        # 下载签名图片
+                        # 获取签名图片(支持URL和base64)
                         temp_signature = os.path.join(tempfile.gettempdir(), f"temp_sig_{os.urandom(8).hex()}")
-                        download_image_from_url(sig['signature_url'], temp_signature)
+                        get_image_from_source(sig['signature_url'], temp_signature)
                         
                         # 打开图片并获取尺寸
                         img = Image.open(temp_signature)

+ 445 - 0
font_comparison.html

@@ -0,0 +1,445 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>字体对比预览</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+        }
+
+        .container {
+            max-width: 1400px;
+            margin: 0 auto;
+            background: white;
+            border-radius: 12px;
+            box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
+            padding: 30px;
+        }
+
+        h1 {
+            color: #333;
+            margin-bottom: 30px;
+            text-align: center;
+            font-size: 28px;
+            font-weight: 600;
+        }
+
+        .input-section {
+            background: #f8f9fa;
+            padding: 20px;
+            border-radius: 8px;
+            margin-bottom: 30px;
+        }
+
+        .form-row {
+            display: grid;
+            grid-template-columns: 1fr auto;
+            gap: 15px;
+            align-items: end;
+        }
+
+        .form-group {
+            flex: 1;
+        }
+
+        label {
+            display: block;
+            margin-bottom: 8px;
+            color: #555;
+            font-weight: 500;
+            font-size: 14px;
+        }
+
+        input[type="text"],
+        input[type="number"],
+        input[type="color"] {
+            width: 100%;
+            padding: 12px;
+            border: 2px solid #e0e0e0;
+            border-radius: 6px;
+            font-size: 14px;
+            transition: border-color 0.3s;
+        }
+
+        input[type="text"]:focus,
+        input[type="number"]:focus {
+            outline: none;
+            border-color: #667eea;
+        }
+
+        .form-controls {
+            display: grid;
+            grid-template-columns: auto auto auto;
+            gap: 10px;
+            align-items: end;
+        }
+
+        .color-input-group {
+            display: flex;
+            gap: 10px;
+            align-items: center;
+        }
+
+        .color-input-group input[type="color"] {
+            width: 50px;
+            height: 46px;
+            cursor: pointer;
+            padding: 2px;
+        }
+
+        .color-input-group input[type="text"] {
+            flex: 1;
+        }
+
+        button {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            border: none;
+            padding: 12px 30px;
+            border-radius: 6px;
+            font-size: 16px;
+            font-weight: 500;
+            cursor: pointer;
+            transition: transform 0.2s, box-shadow 0.2s;
+            white-space: nowrap;
+        }
+
+        button:hover {
+            transform: translateY(-2px);
+            box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+        }
+
+        button:active {
+            transform: translateY(0);
+        }
+
+        button:disabled {
+            background: #ccc;
+            cursor: not-allowed;
+            transform: none;
+        }
+
+        .checkbox-group {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+        }
+
+        .checkbox-group input[type="checkbox"] {
+            width: 18px;
+            height: 18px;
+            cursor: pointer;
+        }
+
+        .preview-grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+            gap: 20px;
+            margin-top: 20px;
+        }
+
+        .font-preview-card {
+            background: white;
+            border: 2px solid #e0e0e0;
+            border-radius: 8px;
+            padding: 15px;
+            transition: all 0.3s;
+        }
+
+        .font-preview-card:hover {
+            border-color: #667eea;
+            box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
+        }
+
+        .font-name {
+            font-size: 14px;
+            font-weight: 600;
+            color: #333;
+            margin-bottom: 10px;
+            padding-bottom: 8px;
+            border-bottom: 1px solid #e0e0e0;
+        }
+
+        .font-preview-image {
+            width: 100%;
+            height: auto;
+            border-radius: 4px;
+            background: 
+                repeating-linear-gradient(45deg, #f0f0f0, #f0f0f0 10px, #ffffff 10px, #ffffff 20px);
+            padding: 10px;
+        }
+
+        .loading {
+            text-align: center;
+            padding: 60px 20px;
+            color: #667eea;
+            font-size: 16px;
+        }
+
+        .loading-spinner {
+            border: 4px solid #f3f3f3;
+            border-top: 4px solid #667eea;
+            border-radius: 50%;
+            width: 40px;
+            height: 40px;
+            animation: spin 1s linear infinite;
+            margin: 0 auto 20px;
+        }
+
+        @keyframes spin {
+            0% { transform: rotate(0deg); }
+            100% { transform: rotate(360deg); }
+        }
+
+        .error {
+            background: #fee;
+            color: #c33;
+            padding: 15px;
+            border-radius: 6px;
+            margin-top: 15px;
+            border-left: 4px solid #c33;
+        }
+
+        .stats {
+            text-align: center;
+            color: #666;
+            margin-bottom: 20px;
+            font-size: 14px;
+        }
+
+        .empty-state {
+            text-align: center;
+            padding: 60px 20px;
+            color: #999;
+        }
+
+        .empty-state-icon {
+            font-size: 64px;
+            margin-bottom: 20px;
+        }
+
+        @media (max-width: 768px) {
+            .form-row {
+                grid-template-columns: 1fr;
+            }
+            
+            .form-controls {
+                grid-template-columns: 1fr;
+            }
+
+            .preview-grid {
+                grid-template-columns: 1fr;
+            }
+
+            .container {
+                padding: 20px;
+            }
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1>🎨 字体对比预览</h1>
+        
+        <div class="input-section">
+            <div class="form-row">
+                <div class="form-group">
+                    <label for="textInput">输入文字</label>
+                    <input type="text" id="textInput" placeholder="请输入要预览的文字" value="字体预览">
+                </div>
+                <div class="form-controls">
+                    <div class="form-group">
+                        <label for="fontSize">字体大小</label>
+                        <input type="number" id="fontSize" value="60" min="10" max="200">
+                    </div>
+                    <div class="form-group">
+                        <label for="textColor">文字颜色</label>
+                        <div class="color-input-group">
+                            <input type="color" id="textColor" value="#000000">
+                            <input type="text" id="textColorInput" value="#000000" placeholder="#000000">
+                        </div>
+                    </div>
+                    <button id="generateBtn" onclick="generateAllPreviews()">生成所有预览</button>
+                </div>
+            </div>
+            <div style="margin-top: 15px;">
+                <div class="checkbox-group">
+                    <input type="checkbox" id="handwritingEffect" checked>
+                    <label for="handwritingEffect" style="margin: 0;">启用手写效果(轻微位置扰动)</label>
+                </div>
+            </div>
+        </div>
+
+        <div id="stats" class="stats" style="display: none;"></div>
+
+        <div id="previewContainer">
+            <div class="empty-state">
+                <div class="empty-state-icon">📝</div>
+                <div>请输入文字并点击"生成所有预览"查看所有字体效果</div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        // 自动检测环境,本地开发时不添加前缀,生产环境添加 /PdfProcessing 前缀
+        const isLocal = window.location.hostname === 'localhost' || 
+                       window.location.hostname === '127.0.0.1' ||
+                       window.location.hostname === '';
+        const API_BASE = window.location.origin + (isLocal ? '' : '/PdfProcessing');
+
+        // 颜色输入同步
+        document.getElementById('textColor').addEventListener('input', function(e) {
+            document.getElementById('textColorInput').value = e.target.value;
+        });
+
+        document.getElementById('textColorInput').addEventListener('input', function(e) {
+            const color = e.target.value;
+            if (/^#[0-9A-F]{6}$/i.test(color)) {
+                document.getElementById('textColor').value = color;
+            }
+        });
+
+        // 将十六进制颜色转换为RGB数组
+        function hexToRgb(hex) {
+            const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+            return result ? [
+                parseInt(result[1], 16),
+                parseInt(result[2], 16),
+                parseInt(result[3], 16)
+            ] : [0, 0, 0];
+        }
+
+        // 生成所有字体预览
+        async function generateAllPreviews() {
+            const textInput = document.getElementById('textInput');
+            const fontSize = parseInt(document.getElementById('fontSize').value);
+            const textColor = hexToRgb(document.getElementById('textColor').value);
+            const handwritingEffect = document.getElementById('handwritingEffect').checked;
+            
+            if (!textInput.value.trim()) {
+                alert('请输入要预览的文字');
+                return;
+            }
+
+            const generateBtn = document.getElementById('generateBtn');
+            const previewContainer = document.getElementById('previewContainer');
+            const statsDiv = document.getElementById('stats');
+            
+            generateBtn.disabled = true;
+            generateBtn.textContent = '生成中...';
+            previewContainer.innerHTML = `
+                <div class="loading">
+                    <div class="loading-spinner"></div>
+                    正在生成所有字体预览,请稍候...
+                </div>
+            `;
+            statsDiv.style.display = 'none';
+
+            try {
+                const response = await fetch(`${API_BASE}/batch_generate_preview`, {
+                    method: 'POST',
+                    headers: {
+                        'Content-Type': 'application/json',
+                    },
+                    body: JSON.stringify({
+                        text: textInput.value,
+                        font_size: fontSize,
+                        padding: 20,
+                        background_color: [255, 255, 255, 0], // 透明背景
+                        text_color: textColor,
+                        use_handwriting_effect: handwritingEffect
+                    })
+                });
+
+                const data = await response.json();
+
+                if (!data.success) {
+                    throw new Error(data.error || '生成失败');
+                }
+
+                if (!data.previews || data.previews.length === 0) {
+                    previewContainer.innerHTML = `
+                        <div class="empty-state">
+                            <div class="empty-state-icon">😕</div>
+                            <div>没有生成任何预览,请检查字体文件是否存在</div>
+                        </div>
+                    `;
+                    return;
+                }
+
+                // 显示统计信息
+                statsDiv.textContent = `共生成 ${data.total} 个字体预览`;
+                statsDiv.style.display = 'block';
+
+                // 生成预览卡片
+                const grid = document.createElement('div');
+                grid.className = 'preview-grid';
+
+                data.previews.forEach(preview => {
+                    const card = document.createElement('div');
+                    card.className = 'font-preview-card';
+                    
+                    const fontName = document.createElement('div');
+                    fontName.className = 'font-name';
+                    fontName.textContent = preview.font_name;
+                    
+                    const previewImg = document.createElement('img');
+                    previewImg.className = 'font-preview-image';
+                    // 确保image_base64格式正确(支持带前缀或不带前缀)
+                    let imageSrc = preview.image_base64;
+                    if (imageSrc && !imageSrc.startsWith('data:image/') && !imageSrc.startsWith('http')) {
+                        // 如果是纯base64字符串,添加前缀
+                        imageSrc = 'data:image/png;base64,' + imageSrc;
+                    }
+                    previewImg.src = imageSrc;
+                    previewImg.alt = preview.font_name;
+                    previewImg.title = '点击右键保存图片';
+                    // 添加错误处理
+                    previewImg.onerror = function() {
+                        console.error('图片加载失败:', preview.font_name, imageSrc.substring(0, 50) + '...');
+                        this.alt = '图片加载失败';
+                        this.style.border = '2px solid red';
+                    };
+                    
+                    card.appendChild(fontName);
+                    card.appendChild(previewImg);
+                    grid.appendChild(card);
+                });
+
+                previewContainer.innerHTML = '';
+                previewContainer.appendChild(grid);
+
+            } catch (error) {
+                console.error('生成预览失败:', error);
+                previewContainer.innerHTML = `
+                    <div class="error">
+                        生成失败: ${error.message}
+                    </div>
+                `;
+            } finally {
+                generateBtn.disabled = false;
+                generateBtn.textContent = '生成所有预览';
+            }
+        }
+
+        // 支持回车键生成
+        document.getElementById('textInput').addEventListener('keypress', function(e) {
+            if (e.key === 'Enter') {
+                generateAllPreviews();
+            }
+        });
+    </script>
+</body>
+</html>
+

+ 433 - 0
font_preview.html

@@ -0,0 +1,433 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>字体预览工具</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+        }
+
+        .container {
+            max-width: 1200px;
+            margin: 0 auto;
+            background: white;
+            border-radius: 12px;
+            box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
+            padding: 30px;
+        }
+
+        h1 {
+            color: #333;
+            margin-bottom: 30px;
+            text-align: center;
+            font-size: 28px;
+            font-weight: 600;
+        }
+
+        .form-section {
+            margin-bottom: 25px;
+        }
+
+        label {
+            display: block;
+            margin-bottom: 8px;
+            color: #555;
+            font-weight: 500;
+            font-size: 14px;
+        }
+
+        input[type="text"],
+        select {
+            width: 100%;
+            padding: 12px;
+            border: 2px solid #e0e0e0;
+            border-radius: 6px;
+            font-size: 14px;
+            transition: border-color 0.3s;
+        }
+
+        input[type="text"]:focus,
+        select:focus {
+            outline: none;
+            border-color: #667eea;
+        }
+
+        input[type="number"],
+        input[type="color"] {
+            padding: 8px;
+            border: 2px solid #e0e0e0;
+            border-radius: 6px;
+            font-size: 14px;
+            transition: border-color 0.3s;
+        }
+
+        input[type="number"]:focus {
+            outline: none;
+            border-color: #667eea;
+        }
+
+        .form-row {
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 15px;
+            margin-bottom: 15px;
+        }
+
+        .form-row.full-width {
+            grid-template-columns: 1fr;
+        }
+
+        .form-group {
+            display: flex;
+            flex-direction: column;
+        }
+
+        .form-group label {
+            margin-bottom: 5px;
+        }
+
+        button {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            border: none;
+            padding: 12px 30px;
+            border-radius: 6px;
+            font-size: 16px;
+            font-weight: 500;
+            cursor: pointer;
+            transition: transform 0.2s, box-shadow 0.2s;
+            width: 100%;
+        }
+
+        button:hover {
+            transform: translateY(-2px);
+            box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
+        }
+
+        button:active {
+            transform: translateY(0);
+        }
+
+        button:disabled {
+            background: #ccc;
+            cursor: not-allowed;
+            transform: none;
+        }
+
+        .preview-section {
+            margin-top: 30px;
+            padding: 25px;
+            background: #f8f9fa;
+            border-radius: 8px;
+            min-height: 200px;
+        }
+
+        .preview-title {
+            font-size: 16px;
+            font-weight: 600;
+            color: #333;
+            margin-bottom: 15px;
+        }
+
+        .preview-image {
+            max-width: 100%;
+            height: auto;
+            border-radius: 6px;
+            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+            background: 
+                repeating-linear-gradient(45deg, #f0f0f0, #f0f0f0 10px, #ffffff 10px, #ffffff 20px);
+            padding: 20px;
+            display: block;
+            margin: 0 auto;
+        }
+
+        .loading {
+            text-align: center;
+            padding: 40px;
+            color: #667eea;
+            font-size: 16px;
+        }
+
+        .error {
+            background: #fee;
+            color: #c33;
+            padding: 15px;
+            border-radius: 6px;
+            margin-top: 15px;
+            border-left: 4px solid #c33;
+        }
+
+        .success {
+            background: #efe;
+            color: #3c3;
+            padding: 15px;
+            border-radius: 6px;
+            margin-top: 15px;
+            border-left: 4px solid #3c3;
+        }
+
+        .checkbox-group {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+        }
+
+        .checkbox-group input[type="checkbox"] {
+            width: 18px;
+            height: 18px;
+            cursor: pointer;
+        }
+
+        .color-input-group {
+            display: flex;
+            gap: 10px;
+            align-items: center;
+        }
+
+        .color-input-group input[type="color"] {
+            width: 50px;
+            height: 40px;
+            cursor: pointer;
+        }
+
+        .color-input-group input[type="text"] {
+            flex: 1;
+        }
+
+        select {
+            cursor: pointer;
+        }
+
+        @media (max-width: 768px) {
+            .form-row {
+                grid-template-columns: 1fr;
+            }
+            
+            .container {
+                padding: 20px;
+            }
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1>🎨 字体预览工具</h1>
+        
+        <div class="form-section">
+            <label for="fontSelect">选择字体</label>
+            <select id="fontSelect">
+                <option value="">加载中...</option>
+            </select>
+        </div>
+
+        <div class="form-section">
+            <label for="textInput">输入文字</label>
+            <input type="text" id="textInput" placeholder="请输入要预览的文字" value="字体预览">
+        </div>
+
+        <div class="form-row">
+            <div class="form-group">
+                <label for="fontSize">字体大小</label>
+                <input type="number" id="fontSize" value="60" min="10" max="200">
+            </div>
+            <div class="form-group">
+                <label for="padding">内边距</label>
+                <input type="number" id="padding" value="20" min="0" max="100">
+            </div>
+        </div>
+
+        <div class="form-row">
+            <div class="form-group">
+                <label for="textColor">文字颜色</label>
+                <div class="color-input-group">
+                    <input type="color" id="textColor" value="#000000">
+                    <input type="text" id="textColorInput" value="#000000" placeholder="#000000">
+                </div>
+            </div>
+            <div class="form-group">
+                <label for="backgroundColor">背景颜色(透明)</label>
+                <div class="color-input-group">
+                    <input type="color" id="backgroundColor" value="#ffffff" disabled>
+                    <input type="text" id="backgroundColorInput" value="透明" placeholder="透明背景" disabled>
+                </div>
+            </div>
+        </div>
+
+        <div class="form-section">
+            <div class="checkbox-group">
+                <input type="checkbox" id="handwritingEffect" checked>
+                <label for="handwritingEffect" style="margin: 0;">启用手写效果(轻微位置扰动)</label>
+            </div>
+        </div>
+
+        <button id="generateBtn" onclick="generatePreview()">生成预览</button>
+
+        <div class="preview-section">
+            <div class="preview-title">预览效果</div>
+            <div id="previewContainer">
+                <div class="loading">请选择字体并输入文字,然后点击"生成预览"</div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        // 自动检测环境,本地开发时不添加前缀,生产环境添加 /PdfProcessing 前缀
+        const isLocal = window.location.hostname === 'localhost' || 
+                       window.location.hostname === '127.0.0.1' ||
+                       window.location.hostname === '';
+        const API_BASE = window.location.origin + (isLocal ? '' : '/PdfProcessing');
+        let fonts = [];
+
+        // 加载字体列表
+        async function loadFonts() {
+            try {
+                const response = await fetch(`${API_BASE}/get_font_list`);
+                const data = await response.json();
+                
+                if (data.success && data.fonts) {
+                    fonts = data.fonts;
+                    const fontSelect = document.getElementById('fontSelect');
+                    fontSelect.innerHTML = '<option value="">请选择字体</option>';
+                    
+                    fonts.forEach(font => {
+                        const option = document.createElement('option');
+                        option.value = font.path;
+                        option.textContent = font.name;
+                        fontSelect.appendChild(option);
+                    });
+                } else {
+                    throw new Error(data.error || '加载字体列表失败');
+                }
+            } catch (error) {
+                console.error('加载字体列表失败:', error);
+                document.getElementById('fontSelect').innerHTML = 
+                    '<option value="">加载失败,请刷新页面重试</option>';
+            }
+        }
+
+        // 颜色输入同步
+        document.getElementById('textColor').addEventListener('input', function(e) {
+            document.getElementById('textColorInput').value = e.target.value;
+        });
+
+        document.getElementById('textColorInput').addEventListener('input', function(e) {
+            const color = e.target.value;
+            if (/^#[0-9A-F]{6}$/i.test(color)) {
+                document.getElementById('textColor').value = color;
+            }
+        });
+
+        // 将十六进制颜色转换为RGB数组
+        function hexToRgb(hex) {
+            const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+            return result ? [
+                parseInt(result[1], 16),
+                parseInt(result[2], 16),
+                parseInt(result[3], 16)
+            ] : [0, 0, 0];
+        }
+
+        // 生成预览
+        async function generatePreview() {
+            const fontSelect = document.getElementById('fontSelect');
+            const textInput = document.getElementById('textInput');
+            const fontSize = parseInt(document.getElementById('fontSize').value);
+            const padding = parseInt(document.getElementById('padding').value);
+            const textColor = hexToRgb(document.getElementById('textColor').value);
+            const handwritingEffect = document.getElementById('handwritingEffect').checked;
+            
+            if (!fontSelect.value) {
+                alert('请先选择字体');
+                return;
+            }
+            
+            if (!textInput.value.trim()) {
+                alert('请输入要预览的文字');
+                return;
+            }
+
+            const generateBtn = document.getElementById('generateBtn');
+            const previewContainer = document.getElementById('previewContainer');
+            
+            generateBtn.disabled = true;
+            generateBtn.textContent = '生成中...';
+            previewContainer.innerHTML = '<div class="loading">正在生成预览...</div>';
+
+            try {
+                const response = await fetch(`${API_BASE}/generate_handwriting`, {
+                    method: 'POST',
+                    headers: {
+                        'Content-Type': 'application/json',
+                    },
+                    body: JSON.stringify({
+                        text: textInput.value,
+                        font_size: fontSize,
+                        padding: padding,
+                        background_color: [255, 255, 255, 0], // 透明背景
+                        text_color: textColor,
+                        font_path: fontSelect.value,
+                        use_handwriting_effect: handwritingEffect
+                    })
+                });
+
+                if (!response.ok) {
+                    // 如果响应不是图片,尝试解析为JSON错误信息
+                    const errorData = await response.json().catch(() => null);
+                    throw new Error(errorData?.error || `HTTP错误: ${response.status}`);
+                }
+
+                // 检查响应类型是否为图片
+                const contentType = response.headers.get('content-type');
+                if (contentType && contentType.startsWith('image/')) {
+                    // 创建图片URL(使用Blob)
+                    const blob = await response.blob();
+                    const imageUrl = URL.createObjectURL(blob);
+                    
+                    previewContainer.innerHTML = `
+                        <img src="${imageUrl}" alt="字体预览" class="preview-image">
+                        <div class="success" style="margin-top: 15px;">
+                            生成成功!点击图片可以右键保存。
+                        </div>
+                    `;
+                } else {
+                    // 如果不是图片,尝试解析为JSON
+                    const data = await response.json();
+                    throw new Error(data.error || '生成失败');
+                }
+            } catch (error) {
+                console.error('生成预览失败:', error);
+                previewContainer.innerHTML = `
+                    <div class="error">
+                        生成失败: ${error.message}
+                    </div>
+                `;
+            } finally {
+                generateBtn.disabled = false;
+                generateBtn.textContent = '生成预览';
+            }
+        }
+
+        // 页面加载时获取字体列表
+        window.addEventListener('DOMContentLoaded', loadFonts);
+
+        // 支持回车键生成
+        document.getElementById('textInput').addEventListener('keypress', function(e) {
+            if (e.key === 'Enter') {
+                generatePreview();
+            }
+        });
+    </script>
+</body>
+</html>
+

+ 563 - 0
generate_handwriting.py

@@ -0,0 +1,563 @@
+import os
+import uuid
+import random
+import math
+import glob
+import io
+import base64
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from PIL import Image, ImageDraw, ImageFont
+from flask import request, jsonify, send_file
+from lib import Qiniu
+import logging
+
+# 配置日志
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+def get_font_list():
+    """
+    获取"数字"文件夹下所有可用的字体文件列表
+    
+    返回:
+    - 字体文件路径列表,每个元素为字典:{"path": 路径, "name": 字体名称}
+    """
+    font_dir = './手写字体'
+    font_list = []
+    
+    if os.path.exists(font_dir):
+        # 查找所有字体文件(支持 .ttf, .TTF, .otf, .OTF)
+        font_extensions = ['*.ttf', '*.TTF', '*.otf', '*.OTF']
+        font_files = []
+        for ext in font_extensions:
+            font_files.extend(glob.glob(os.path.join(font_dir, ext)))
+        
+        # 验证每个字体文件是否有效
+        for font_file in font_files:
+            try:
+                # 尝试加载字体以验证是否有效
+                ImageFont.truetype(font_file, 60)
+                # 获取字体文件名(不包含路径)
+                font_name = os.path.basename(font_file)
+                # 去掉扩展名作为显示名称
+                display_name = os.path.splitext(font_name)[0]
+                font_list.append({
+                    "path": font_file,
+                    "name": display_name,
+                    "filename": font_name
+                })
+            except Exception as e:
+                logger.warning(f"跳过无效字体文件 {font_file}: {e}")
+                continue
+    
+    # 按名称排序
+    font_list.sort(key=lambda x: x['name'])
+    return font_list
+
+
+def find_font_file():
+    """
+    查找可用的字体文件
+    优先从"数字"文件夹随机选择一个字体,如果没有则使用系统默认字体
+    """
+    # 首先尝试从"数字"文件夹随机选择字体
+    font_dir = './数字'
+    if os.path.exists(font_dir):
+        # 查找所有字体文件(支持 .ttf, .TTF, .otf, .OTF)
+        font_extensions = ['*.ttf', '*.TTF', '*.otf', '*.OTF']
+        font_files = []
+        for ext in font_extensions:
+            font_files.extend(glob.glob(os.path.join(font_dir, ext)))
+        
+        if font_files:
+            # 随机选择一个字体文件
+            selected_font = random.choice(font_files)
+            try:
+                # 尝试加载字体以验证是否有效
+                ImageFont.truetype(selected_font, 60)
+                logger.info(f"从数字文件夹随机选择字体: {selected_font}")
+                return selected_font
+            except Exception as e:
+                logger.warning(f"无法加载字体文件 {selected_font}: {e}")
+                # 如果选中的字体无效,尝试其他字体
+                for font_file in font_files:
+                    try:
+                        ImageFont.truetype(font_file, 60)
+                        logger.info(f"使用备用字体: {font_file}")
+                        return font_file
+                    except:
+                        continue
+    
+    # 如果"数字"文件夹没有可用字体,尝试系统默认字体
+    font_paths = [
+        # macOS 系统字体
+        '/System/Library/Fonts/Supplemental/Arial.ttf',
+        '/System/Library/Fonts/Helvetica.ttc',
+        # Windows 系统字体(如果需要在Windows上运行)
+        'C:/Windows/Fonts/simhei.ttf',
+        'C:/Windows/Fonts/msyh.ttf',
+        # Linux 系统字体
+        '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
+        '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
+    ]
+    
+    # 尝试查找系统字体文件
+    for font_path in font_paths:
+        if os.path.exists(font_path):
+            try:
+                return font_path
+            except:
+                continue
+    
+    # 如果找不到,返回 None,将使用 PIL 默认字体
+    logger.warning("未找到可用字体文件,将使用 PIL 默认字体")
+    return None
+
+
+def add_handwriting_effect(draw, text, x, y, font, font_size, text_color=(0, 0, 0)):
+    """
+    添加手写效果:轻微的位置扰动
+    注意:对于中文字符,建议使用手写字体文件而不是字符级别的偏移
+    """
+    chars = list(text)
+    current_x = x
+    
+    for i, char in enumerate(chars):
+        # 每个字符的轻微随机偏移(模拟手写的不规则性)
+        offset_x = random.randint(-1, 1)
+        offset_y = random.randint(-1, 1)
+        
+        # 绘制字符
+        draw.text((current_x + offset_x, y + offset_y), char, font=font, fill=text_color)
+        
+        # 获取字符宽度
+        try:
+            bbox = draw.textbbox((0, 0), char, font=font)
+            char_width = bbox[2] - bbox[0]
+        except:
+            # 如果失败,使用简单估算
+            # 中文字符通常宽度约为字体大小,英文和数字约为字体大小的0.6倍
+            if ord(char) > 127:  # 中文字符
+                char_width = font_size
+            else:  # 英文或数字
+                char_width = int(font_size * 0.6)
+        
+        current_x += char_width
+
+
+def generate_handwriting_image(text, font_size=60, width=None, height=None, 
+                               background_color=(255, 255, 255), 
+                               text_color=(0, 0, 0),
+                               padding=20,
+                               font_path=None,
+                               use_handwriting_effect=True):
+    """
+    生成手写字体图片
+    
+    参数:
+    - text: 要生成的文字
+    - font_size: 字体大小(默认60)
+    - width: 图片宽度(None 表示自动计算)
+    - height: 图片高度(None 表示自动计算)
+    - background_color: 背景颜色(RGB元组,默认白色;如果传入RGBA元组,则支持透明度)
+    - text_color: 文字颜色(RGB元组,默认黑色)
+    - padding: 内边距(默认20像素)
+    - font_path: 字体文件路径(None 表示使用默认字体或自动查找)
+    - use_handwriting_effect: 是否使用手写效果(默认True)
+    
+    返回:
+    - PIL Image 对象
+    """
+    try:
+        # 加载字体
+        if font_path and os.path.exists(font_path):
+            try:
+                font = ImageFont.truetype(font_path, font_size)
+            except:
+                logger.warning(f"无法加载字体文件 {font_path},使用默认字体")
+                font = ImageFont.load_default()
+        else:
+            # 尝试查找字体文件
+            found_font_path = find_font_file()
+            if found_font_path:
+                try:
+                    font = ImageFont.truetype(found_font_path, font_size)
+                except:
+                    font = ImageFont.load_default()
+            else:
+                font = ImageFont.load_default()
+        
+        # 将背景颜色转换为RGBA格式(支持透明背景)
+        if len(background_color) == 3:
+            # RGB格式,转换为RGBA,alpha设为0(完全透明)
+            bg_color_rgba = (*background_color, 0)
+        elif len(background_color) == 4:
+            # 已经是RGBA格式
+            bg_color_rgba = background_color
+        else:
+            # 默认透明背景
+            bg_color_rgba = (255, 255, 255, 0)
+        
+        # 创建临时绘图对象来测量文字尺寸(使用RGBA模式)
+        temp_img = Image.new('RGBA', (100, 100), bg_color_rgba)
+        temp_draw = ImageDraw.Draw(temp_img)
+        
+        # 获取文字边界框
+        try:
+            bbox = temp_draw.textbbox((0, 0), text, font=font)
+            text_width = bbox[2] - bbox[0]
+            text_height = bbox[3] - bbox[1]
+            # bbox的偏移量(某些字符可能超出基线)
+            bbox_offset_x = bbox[0]
+            bbox_offset_y = bbox[1]
+        except:
+            # 如果失败,使用简单估算
+            text_width = len(text) * font_size * 0.6
+            text_height = font_size * 1.2
+            bbox_offset_x = 0
+            bbox_offset_y = 0
+        
+        # 计算图片尺寸
+        if width is None:
+            width = int(text_width + padding * 2)
+        if height is None:
+            height = int(text_height + padding * 2)
+        
+        # 创建图片(使用RGBA模式支持透明背景)
+        image = Image.new('RGBA', (width, height), bg_color_rgba)
+        draw = ImageDraw.Draw(image)
+        
+        # 计算文字起始位置(完美居中)
+        # 考虑bbox的偏移量,确保文字在图片正中央
+        x = (width - text_width) // 2 - bbox_offset_x
+        y = (height - text_height) // 2 - bbox_offset_y
+        
+        # 绘制文字
+        if use_handwriting_effect:
+            # 使用手写效果(每个字符轻微偏移)
+            add_handwriting_effect(draw, text, x, y, font, font_size, text_color)
+        else:
+            # 直接绘制文字
+            draw.text((x, y), text, font=font, fill=text_color)
+        
+        logger.info(f"成功生成文字图片: {text[:20]}...")
+        return image
+        
+    except Exception as e:
+        logger.error(f"生成文字图片失败: {e}")
+        raise Exception(f"生成文字图片失败: {str(e)}")
+
+
+def generate_and_upload_handwriting(text, font_size=60, width=None, height=None,
+                                     background_color=(255, 255, 255),
+                                     text_color=(0, 0, 0),
+                                     padding=20,
+                                     font_path=None,
+                                     use_handwriting_effect=True):
+    """
+    生成手写字体图片并上传到七牛云
+    
+    参数:
+    - text: 要生成的文字
+    - font_size: 字体大小(默认60)
+    - width: 图片宽度(None 表示自动计算)
+    - height: 图片高度(None 表示自动计算)
+    - background_color: 背景颜色(RGB元组,默认白色)
+    - text_color: 文字颜色(RGB元组,默认黑色)
+    - padding: 内边距(默认20像素)
+    - font_path: 字体文件路径(None 表示使用默认字体或自动查找)
+    - use_handwriting_effect: 是否使用手写效果(默认True)
+    
+    返回:
+    - 七牛云上的图片URL
+    """
+    temp_file = None
+    try:
+        # 创建临时目录
+        temp_dir = "./temp"
+        if not os.path.exists(temp_dir):
+            os.makedirs(temp_dir)
+        
+        # 生成图片
+        image = generate_handwriting_image(
+            text=text,
+            font_size=font_size,
+            width=width,
+            height=height,
+            background_color=background_color,
+            text_color=text_color,
+            padding=padding,
+            font_path=font_path,
+            use_handwriting_effect=use_handwriting_effect
+        )
+        
+        # 保存临时文件
+        temp_file = os.path.join(temp_dir, f"handwriting_{uuid.uuid4()}.png")
+        image.save(temp_file, "PNG")
+        
+        # 上传到七牛云
+        file_key = f"UpImage/{uuid.uuid4()}.png"
+        image_url = Qiniu.upload_to_qiniu(temp_file, file_key)
+        
+        logger.info(f"成功上传文字图片到七牛云: {image_url}")
+        return image_url
+        
+    except Exception as e:
+        logger.error(f"生成并上传文字图片失败: {e}")
+        raise
+    finally:
+        # 清理临时文件
+        if temp_file and os.path.exists(temp_file):
+            try:
+                os.remove(temp_file)
+            except Exception as e:
+                logger.warning(f"清理临时文件失败: {e}")
+
+
+def create_handwriting_route(app):
+    """
+    创建手写字体图片生成接口路由
+    
+    参数:
+    - app: Flask应用实例
+    """
+    @app.route('/get_font_list', methods=['GET'])
+    def get_font_list_api():
+        """
+        获取可用字体列表接口
+        
+        返回:
+        {
+            "success": true,
+            "fonts": [
+                {"path": "字体路径", "name": "字体名称", "filename": "文件名"}
+            ]
+        }
+        """
+        try:
+            fonts = get_font_list()
+            return jsonify({
+                "success": True,
+                "fonts": fonts
+            })
+        except Exception as e:
+            logger.error(f"获取字体列表失败: {e}")
+            return jsonify({
+                "success": False,
+                "error": str(e)
+            }), 500
+    
+    def _generate_single_font_preview(font, text, font_size, width, height, 
+                                      background_color, text_color, padding, 
+                                      use_handwriting_effect):
+        """
+        辅助函数:生成单个字体的预览图片
+        用于多线程并行处理
+        """
+        try:
+            # 生成图片
+            image = generate_handwriting_image(
+                text=text,
+                font_size=font_size,
+                width=width,
+                height=height,
+                background_color=background_color,
+                text_color=text_color,
+                padding=padding,
+                font_path=font['path'],
+                use_handwriting_effect=use_handwriting_effect
+            )
+            
+            # 将图片转换为base64
+            img_io = io.BytesIO()
+            image.save(img_io, 'PNG')
+            img_io.seek(0)
+            img_base64 = base64.b64encode(img_io.read()).decode('utf-8')
+            
+            return {
+                "font_path": font['path'],
+                "font_name": font['name'],
+                "image_base64": "data:image/png;base64," + img_base64
+            }
+        except Exception as e:
+            logger.warning(f"生成字体 {font['name']} 预览失败: {e}")
+            return None
+    
+    @app.route('/batch_generate_preview', methods=['POST'])
+    def batch_generate_preview():
+        """
+        批量生成字体预览接口(返回多个图片的base64编码)
+        
+        请求参数(JSON):
+        {
+            "text": "要预览的文字",
+            "font_size": 60,
+            "padding": 20,
+            "background_color": [255, 255, 255, 0],
+            "text_color": [0, 0, 0],
+            "use_handwriting_effect": true
+        }
+        
+        返回:
+        {
+            "success": true,
+            "previews": [
+                {
+                    "font_path": "字体路径",
+                    "font_name": "字体名称",
+                    "image_base64": "base64编码的图片数据"
+                }
+            ]
+        }
+        """
+        try:
+            data = request.get_json()
+            if not data:
+                return jsonify({
+                    "success": False,
+                    "error": "请求参数为空"
+                }), 400
+            
+            text = data.get('text')
+            if not text:
+                return jsonify({
+                    "success": False,
+                    "error": "缺少text参数"
+                }), 400
+            
+            # 获取可选参数
+            font_size = data.get('font_size', 60)
+            width = data.get('width')
+            height = data.get('height')
+            background_color = tuple(data.get('background_color', [255, 255, 255]))
+            text_color = tuple(data.get('text_color', [0, 0, 0]))
+            padding = data.get('padding', 20)
+            use_handwriting_effect = data.get('use_handwriting_effect', True)
+            
+            # 获取所有字体
+            fonts = get_font_list()
+            if not fonts:
+                return jsonify({
+                    "success": False,
+                    "error": "没有可用的字体"
+                }), 400
+            
+            previews = []
+            
+            # 使用线程池并行处理,最多同时处理10个字体(可根据需要调整)
+            max_workers = min(10, len(fonts))  # 根据字体数量动态调整线程数
+            
+            with ThreadPoolExecutor(max_workers=max_workers) as executor:
+                # 提交所有任务
+                future_to_font = {
+                    executor.submit(
+                        _generate_single_font_preview,
+                        font, text, font_size, width, height,
+                        background_color, text_color, padding,
+                        use_handwriting_effect
+                    ): font for font in fonts
+                }
+                
+                # 收集结果
+                for future in as_completed(future_to_font):
+                    result = future.result()
+                    if result is not None:
+                        previews.append(result)
+            
+            return jsonify({
+                "success": True,
+                "previews": previews,
+                "total": len(previews)
+            })
+            
+        except Exception as e:
+            logger.error(f"批量生成预览失败: {e}")
+            return jsonify({
+                "success": False,
+                "error": str(e)
+            }), 500
+    
+    @app.route('/generate_handwriting', methods=['POST'])
+    def generate_handwriting():
+        """
+        生成手写字体图片接口(直接返回图片文件)
+        
+        请求参数(JSON):
+        {
+            "text": "要生成的文字",  // 必需
+            "font_size": 60,  // 可选,字体大小(默认60)
+            "width": null,  // 可选,图片宽度(null表示自动计算)
+            "height": null,  // 可选,图片高度(null表示自动计算)
+            "background_color": [255, 255, 255] 或 [255, 255, 255, 0],  // 可选,背景颜色RGB或RGBA(默认透明背景,alpha=0)
+            "text_color": [0, 0, 0],  // 可选,文字颜色RGB(默认黑色)
+            "padding": 20,  // 可选,内边距(默认20像素)
+            "font_path": null,  // 可选,字体文件路径(null表示使用默认字体)
+            "use_handwriting_effect": true  // 可选,是否使用手写效果(默认true)
+        }
+        
+        返回:
+        - PNG图片文件(直接返回,不返回JSON)
+        """
+        try:
+            # 获取请求参数
+            data = request.get_json()
+            
+            if not data:
+                return jsonify({
+                    "success": False,
+                    "error": "请求参数为空"
+                }), 400
+            
+            text = data.get('text')
+            if not text:
+                return jsonify({
+                    "success": False,
+                    "error": "缺少text参数"
+                }), 400
+            
+            # 获取可选参数
+            font_size = data.get('font_size', 60)
+            width = data.get('width')
+            height = data.get('height')
+            background_color = tuple(data.get('background_color', [255, 255, 255]))
+            text_color = tuple(data.get('text_color', [0, 0, 0]))
+            padding = data.get('padding', 20)
+            font_path = data.get('font_path')
+            use_handwriting_effect = data.get('use_handwriting_effect', True)
+            
+            # 生成图片
+            image = generate_handwriting_image(
+                text=text,
+                font_size=font_size,
+                width=width,
+                height=height,
+                background_color=background_color,
+                text_color=text_color,
+                padding=padding,
+                font_path=font_path,
+                use_handwriting_effect=use_handwriting_effect
+            )
+            
+            # 将图片保存到内存中的字节流
+            img_io = io.BytesIO()
+            image.save(img_io, 'PNG')
+            img_io.seek(0)
+            
+            logger.info(f"成功生成文字图片: {text[:20]}...")
+            
+            # 直接返回图片文件
+            return send_file(
+                img_io,
+                mimetype='image/png',
+                as_attachment=False,
+                download_name='handwriting.png'
+            )
+            
+        except Exception as e:
+            logger.error(f"接口处理失败: {e}")
+            return jsonify({
+                "success": False,
+                "error": str(e)
+            }), 500
+

+ 388 - 0
process_image.py

@@ -0,0 +1,388 @@
+import os
+import uuid
+import tempfile
+import requests
+import base64
+import re
+from PIL import Image
+from flask import Flask, request, jsonify
+from lib import Qiniu
+import logging
+
+# 配置日志
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# 尝试导入rembg,如果没有安装则提示
+try:
+    from rembg import remove
+    REMBG_AVAILABLE = True
+except ImportError:
+    REMBG_AVAILABLE = False
+    logger.warning("rembg库未安装,请运行: pip install rembg")
+
+
+def is_base64_image(image_str):
+    """
+    检测字符串是否为base64编码的图片
+    
+    参数:
+    - image_str: 待检测的字符串
+    
+    返回:
+    - bool: True表示是base64图片,False表示不是
+    """
+    # 检查是否包含data:image协议头
+    if image_str.startswith('data:image'):
+        return True
+    
+    # 检查是否看起来像base64编码(长度合理且包含base64字符)
+    # base64字符集:A-Z, a-z, 0-9, +, /, =
+    if len(image_str) > 100:  # base64图片通常很长
+        # 移除可能的空白字符
+        cleaned = image_str.strip().replace('\n', '').replace('\r', '')
+        # 检查是否主要由base64字符组成
+        base64_pattern = re.compile(r'^[A-Za-z0-9+/]+={0,2}$')
+        return bool(base64_pattern.match(cleaned))
+    
+    return False
+
+
+def save_base64_image(base64_str, output_path):
+    """
+    将base64编码的图片保存到本地文件
+    
+    参数:
+    - base64_str: base64编码的图片字符串(支持带data:image协议头或纯base64)
+    - output_path: 保存路径
+    
+    返回:
+    - 保存的文件路径
+    """
+    try:
+        # 如果包含data:image协议头,提取base64部分
+        if base64_str.startswith('data:image'):
+            # 格式: data:image/png;base64,iVBORw0KG...
+            base64_data = base64_str.split(',', 1)[1] if ',' in base64_str else base64_str
+        else:
+            base64_data = base64_str
+        
+        # 移除可能的空白字符
+        base64_data = base64_data.strip().replace('\n', '').replace('\r', '')
+        
+        # 解码base64
+        image_data = base64.b64decode(base64_data)
+        
+        # 保存到文件
+        with open(output_path, 'wb') as f:
+            f.write(image_data)
+        
+        logger.info(f"成功保存base64图片: {output_path}")
+        return output_path
+    except Exception as e:
+        logger.error(f"保存base64图片失败: {e}")
+        raise Exception(f"保存base64图片失败: {str(e)}")
+
+
+def download_image_from_url(url, output_path):
+    """
+    从URL下载图片到本地
+    
+    参数:
+    - url: 图片URL
+    - output_path: 保存路径
+    
+    返回:
+    - 保存的文件路径
+    """
+    try:
+        response = requests.get(url, stream=True, timeout=30)
+        response.raise_for_status()
+        
+        # 检查是否为图片类型
+        content_type = response.headers.get('content-type', '').lower()
+        if 'image' not in content_type:
+            logger.warning(f"URL可能不是图片类型: {content_type}")
+        
+        # 保存图片
+        with open(output_path, 'wb') as f:
+            for chunk in response.iter_content(chunk_size=8192):
+                f.write(chunk)
+        
+        logger.info(f"成功下载图片: {output_path}")
+        return output_path
+    except Exception as e:
+        logger.error(f"下载图片失败: {e}")
+        raise Exception(f"下载图片失败: {str(e)}")
+
+
+def remove_background_rembg(input_path, output_path):
+    """
+    使用rembg库进行抠图处理
+    
+    参数:
+    - input_path: 输入图片路径
+    - output_path: 输出PNG图片路径(带透明背景)
+    
+    返回:
+    - 输出文件路径
+    """
+    if not REMBG_AVAILABLE:
+        raise Exception("rembg库未安装,请运行: pip install rembg")
+    
+    try:
+        # 读取原始图片
+        with open(input_path, 'rb') as input_file:
+            input_data = input_file.read()
+        
+        # 使用rembg进行抠图
+        output_data = remove(input_data)
+        
+        # 保存为PNG格式(PNG支持透明通道)
+        with open(output_path, 'wb') as output_file:
+            output_file.write(output_data)
+        
+        logger.info(f"成功完成抠图处理: {output_path}")
+        return output_path
+    except Exception as e:
+        logger.error(f"抠图处理失败: {e}")
+        raise Exception(f"抠图处理失败: {str(e)}")
+
+
+def remove_background_simple(input_path, output_path):
+    """
+    简单的背景移除方法(适用于简单背景)
+    如果rembg不可用,可以使用这个简单方法作为备选
+    
+    参数:
+    - input_path: 输入图片路径
+    - output_path: 输出PNG图片路径
+    
+    返回:
+    - 输出文件路径
+    """
+    try:
+        # 打开图片并转换为RGBA格式
+        image = Image.open(input_path).convert("RGBA")
+        datas = image.getdata()
+        
+        new_data = []
+        # 简单阈值法:移除接近白色的背景
+        threshold = 240  # 阈值,可调整
+        
+        for item in datas:
+            # 如果RGB值都接近白色(大于阈值),则设为透明
+            if item[0] > threshold and item[1] > threshold and item[2] > threshold:
+                new_data.append((255, 255, 255, 0))  # 设置为透明
+            else:
+                new_data.append(item)
+        
+        image.putdata(new_data)
+        image.save(output_path, "PNG")
+        
+        logger.info(f"使用简单方法完成抠图处理: {output_path}")
+        return output_path
+    except Exception as e:
+        logger.error(f"简单抠图处理失败: {e}")
+        raise Exception(f"抠图处理失败: {str(e)}")
+
+
+def trim_image(input_path, output_path):
+    """
+    裁剪图片的空白边缘,只保留有图像的部分
+    
+    参数:
+    - input_path: 输入图片路径(应该是RGBA格式的PNG)
+    - output_path: 输出裁剪后的图片路径
+    
+    返回:
+    - 输出文件路径
+    """
+    try:
+        # 打开图片并确保是RGBA格式
+        image = Image.open(input_path).convert("RGBA")
+        
+        # 获取图片的alpha通道(透明度通道)
+        # 如果alpha值为0或接近0,则认为是透明/空白区域
+        bbox = image.getbbox()
+        
+        if bbox is None:
+            # 如果整个图片都是透明的,返回原图
+            logger.warning("图片完全透明,无法裁剪")
+            image.save(output_path, "PNG")
+            return output_path
+        
+        # 裁剪图片到有效区域
+        trimmed_image = image.crop(bbox)
+        trimmed_image.save(output_path, "PNG")
+        
+        logger.info(f"成功裁剪图片: 原始尺寸 {image.size} -> 裁剪后尺寸 {trimmed_image.size}")
+        return output_path
+    except Exception as e:
+        logger.error(f"裁剪图片失败: {e}")
+        raise Exception(f"裁剪图片失败: {str(e)}")
+
+
+def process_image_url(image_url, use_rembg=True, trim_edges=False):
+    """
+    处理图片:支持URL或base64编码 -> 抠图 -> (可选)裁剪空白边缘 -> 保存为PNG -> 上传到七牛云
+    
+    参数:
+    - image_url: 图片URL或base64编码的图片字符串
+    - use_rembg: 是否使用rembg库(True)或简单方法(False)
+    - trim_edges: 是否裁剪空白边缘(适用于公章等需要去除空白的图片)
+    
+    返回:
+    - 七牛云上的新图片URL
+    """
+    temp_input = None
+    temp_output = None
+    temp_trimmed = None
+    
+    try:
+        # 创建临时目录
+        temp_dir = "./temp"
+        if not os.path.exists(temp_dir):
+            os.makedirs(temp_dir)
+        
+        # 检测输入类型:URL还是base64
+        is_base64 = is_base64_image(image_url)
+        
+        if is_base64:
+            # 处理base64图片
+            logger.info("检测到base64编码的图片")
+            # base64图片默认保存为.png(因为通常base64传输PNG或JPEG)
+            file_ext = '.png'
+            temp_input = os.path.join(temp_dir, f"input_{uuid.uuid4()}{file_ext}")
+            save_base64_image(image_url, temp_input)
+        else:
+            # 处理URL图片
+            logger.info(f"检测到URL图片: {image_url[:100]}...")
+            # 从URL中提取文件扩展名,如果没有则默认为.jpg
+            url_path = image_url.split('?')[0]  # 移除查询参数
+            file_ext = os.path.splitext(url_path)[1] or '.jpg'
+            # 确保扩展名是常见的图片格式
+            if file_ext.lower() not in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']:
+                file_ext = '.jpg'
+            
+            # 下载原始图片
+            temp_input = os.path.join(temp_dir, f"input_{uuid.uuid4()}{file_ext}")
+            download_image_from_url(image_url, temp_input)
+        
+        # 检查文件是否存在
+        if not os.path.exists(temp_input):
+            raise Exception("下载的图片文件不存在")
+        
+        # 抠图处理
+        temp_output = os.path.join(temp_dir, f"output_{uuid.uuid4()}.png")
+        
+        if use_rembg and REMBG_AVAILABLE:
+            remove_background_rembg(temp_input, temp_output)
+        else:
+            if use_rembg:
+                logger.warning("rembg不可用,使用简单方法")
+            remove_background_simple(temp_input, temp_output)
+        
+        # 检查输出文件是否存在
+        if not os.path.exists(temp_output):
+            raise Exception("抠图处理后的文件不存在")
+        
+        # 如果需要裁剪空白边缘(适用于公章)
+        final_output = temp_output
+        if trim_edges:
+            temp_trimmed = os.path.join(temp_dir, f"trimmed_{uuid.uuid4()}.png")
+            trim_image(temp_output, temp_trimmed)
+            final_output = temp_trimmed
+        
+        # 上传到七牛云
+        file_key = f"UpImage/{uuid.uuid4()}.png"
+        new_image_url = Qiniu.upload_to_qiniu(final_output, file_key)
+        
+        logger.info(f"成功处理图片并上传: {new_image_url}")
+        return new_image_url
+        
+    except Exception as e:
+        logger.error(f"处理图片失败: {e}")
+        raise
+    finally:
+        # 清理临时文件
+        try:
+            if temp_input and os.path.exists(temp_input):
+                os.remove(temp_input)
+            if temp_output and os.path.exists(temp_output):
+                os.remove(temp_output)
+            if temp_trimmed and os.path.exists(temp_trimmed):
+                os.remove(temp_trimmed)
+        except Exception as e:
+            logger.warning(f"清理临时文件失败: {e}")
+
+
+def create_process_image_route(app):
+    """
+    创建图片处理接口路由
+    
+    参数:
+    - app: Flask应用实例
+    """
+    @app.route('/process_image', methods=['POST'])
+    def process_image():
+        """
+        图片抠图处理接口
+        
+        请求参数(JSON):
+        {
+            "image_url": "图片URL链接 或 base64编码的图片字符串",
+            "use_rembg": true,  // 可选,是否使用rembg库(默认true)
+            "is_seal": false    // 可选,是否是公章(默认false)。如果是公章,会自动裁剪空白边缘
+        }
+        
+        注意:
+        - image_url 支持两种格式:
+          1. HTTP/HTTPS URL链接
+          2. base64编码的图片(支持 "data:image/png;base64,..." 格式或纯base64字符串)
+        
+        返回:
+        {
+            "success": true,
+            "image_url": "处理后的图片URL",
+            "message": "处理成功"
+        }
+        """
+        try:
+            # 获取请求参数
+            data = request.get_json()
+            
+            if not data:
+                return jsonify({
+                    "success": False,
+                    "error": "请求参数为空"
+                }), 400
+            
+            image_url = data.get('image_url')
+            if not image_url:
+                return jsonify({
+                    "success": False,
+                    "error": "缺少image_url参数"
+                }), 400
+            
+            # 可选参数:是否使用rembg
+            use_rembg = data.get('use_rembg', True)
+            # 可选参数:是否是公章(公章需要裁剪空白边缘)
+            is_seal = data.get('is_seal', False)
+            
+            # 处理图片
+            new_image_url = process_image_url(image_url, use_rembg=use_rembg, trim_edges=is_seal)
+            
+            return jsonify({
+                "success": True,
+                "image_url": new_image_url,
+                "message": "图片处理成功"
+            })
+            
+        except Exception as e:
+            logger.error(f"接口处理失败: {e}")
+            return jsonify({
+                "success": False,
+                "error": str(e)
+            }), 500
+

+ 1 - 0
requirements.txt

@@ -33,3 +33,4 @@ tzdata==2025.2
 urllib3==2.5.0
 Wand==0.6.13
 Werkzeug==3.1.3
+rembg==2.0.50