浏览代码

commit 图片水印处理

林海 6 天之前
父节点
当前提交
f34f103da7
共有 2 个文件被更改,包括 210 次插入38 次删除
  1. 210 38
      app.py
  2. 二进制
      static/fonts/wqy-microhei.ttc

+ 210 - 38
app.py

@@ -7,7 +7,10 @@ import threading
 import urllib3
 import fitz  # PyMuPDF
 import base64
-from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, Response, stream_with_context
+import hashlib
+from io import BytesIO
+from PIL import Image, ImageDraw, ImageFont
+from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, Response, stream_with_context, send_file
 from werkzeug.utils import secure_filename
 from oss_utils import upload_to_oss
 from ocr_utils import extract_page_number
@@ -45,51 +48,143 @@ access_token_lock = threading.Lock()
 # 图片扩展名列表
 IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff'}
 
+# ── 水印配置 ──────────────────────────────────────────────
+WM_CACHE_DIR = '/tmp/wm_cache'
+WM_CACHE_TTL = 7 * 24 * 3600   # 7天(秒)
+WM_TEXT_LINES = [
+    '族谱资料  未经纸质原件持有人授权',
+    '禁止任何形式复制、传播',
+]
+WM_ALPHA    = 180    # 0-255,70% 不透明
+WM_ANGLE    = 30     # 斜放角度(度)
+WM_COLOR    = (150, 0, 0)
+WM_FONT_SIZE_RATIO = 52   # font_size = min(W,H) // ratio
+WM_FONT_PATHS = [           # 按优先级尝试(项目内字体优先,跨平台通用)
+    os.path.join(os.path.dirname(__file__), 'static', 'fonts', 'wqy-microhei.ttc'),
+    '/System/Library/Fonts/STHeiti Medium.ttc',        # macOS
+    '/System/Library/Fonts/STHeiti Light.ttc',         # macOS
+    '/System/Library/Fonts/PingFang.ttc',              # macOS
+    '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', # Linux (apt)
+    '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',   # Linux (apt)
+    '/usr/share/fonts/wqy-microhei/wqy-microhei.ttc', # Linux (yum)
+]
+os.makedirs(WM_CACHE_DIR, exist_ok=True)
+
+_wm_font_cache = {}
+
+def _get_wm_font(size):
+    if size in _wm_font_cache:
+        return _wm_font_cache[size]
+    font = None
+    for fp in WM_FONT_PATHS:
+        if os.path.exists(fp):
+            try:
+                font = ImageFont.truetype(fp, size)
+                break
+            except Exception:
+                continue
+    if font is None:
+        font = ImageFont.load_default()
+    _wm_font_cache[size] = font
+    return font
+
+
+def _apply_pillow_watermark(img_bytes):
+    """对图片二进制数据叠加平铺水印,返回 JPEG 字节。"""
+    orig = Image.open(BytesIO(img_bytes)).convert('RGBA')
+    W, H = orig.size
+
+    font_size = max(14, min(W, H) // WM_FONT_SIZE_RATIO)
+    font = _get_wm_font(font_size)
+    color_with_alpha = WM_COLOR + (WM_ALPHA,)
+
+    # 测量文字尺寸
+    dummy = Image.new('RGBA', (1, 1))
+    dd = ImageDraw.Draw(dummy)
+    bboxes = [dd.textbbox((0, 0), t, font=font) for t in WM_TEXT_LINES]
+    tw = max(b[2] - b[0] for b in bboxes) + 40
+    line_h = max(b[3] - b[1] for b in bboxes)
+    th = line_h * len(WM_TEXT_LINES) + 30
+
+    # 绘制单块水印贴片
+    tile = Image.new('RGBA', (tw, th), (0, 0, 0, 0))
+    td = ImageDraw.Draw(tile)
+    for i, line in enumerate(WM_TEXT_LINES):
+        td.text((20, 10 + i * (line_h + 8)), line, font=font, fill=color_with_alpha)
+
+    rotated = tile.rotate(WM_ANGLE, expand=True)
+    rw, rh = rotated.size
+
+    # 交错平铺到与原图等大的水印层
+    wm_layer = Image.new('RGBA', (W, H), (0, 0, 0, 0))
+    step_x = rw + max(10, W // 18)
+    step_y = rh + max(8,  H // 22)
+    row = 0
+    for py in range(-rh, H + rh, step_y):
+        ox = (row % 2) * (step_x // 2)
+        for px in range(-rw + ox, W + rw, step_x):
+            wm_layer.paste(rotated, (px, py), rotated)
+        row += 1
+
+    result = Image.alpha_composite(orig, wm_layer).convert('RGB')
+    buf = BytesIO()
+    result.save(buf, format='JPEG', quality=88)
+    return buf.getvalue()
+
+
+def _wm_cache_path(oss_url):
+    key = hashlib.sha256(oss_url.encode()).hexdigest()
+    return os.path.join(WM_CACHE_DIR, key + '.jpg')
+
+
+def _add_dynamic_watermark(img_bytes, username, dt_str):
+    """
+    在静态平铺水印基础上,叠加 1-2 个动态水印(当前用户名 + 查看时间)。
+    位置:左上角、右下角,横排,不旋转,半透明深红色。
+    """
+    img = Image.open(BytesIO(img_bytes)).convert('RGBA')
+    W, H = img.size
+
+    dyn_font_size = max(12, min(W, H) // 60)
+    dyn_font = _get_wm_font(dyn_font_size)
+    dyn_color = (150, 0, 0, 160)   # 深红,62% 不透明
+    dyn_text = f'{username}  {dt_str}'
+
+    overlay = Image.new('RGBA', (W, H), (0, 0, 0, 0))
+    od = ImageDraw.Draw(overlay)
+
+    # 测量文字宽高
+    bbox = od.textbbox((0, 0), dyn_text, font=dyn_font)
+    tw = bbox[2] - bbox[0]
+    th = bbox[3] - bbox[1]
+    pad = max(8, dyn_font_size // 2)
+
+    # 位置1:左上角
+    od.text((pad, pad), dyn_text, font=dyn_font, fill=dyn_color)
+    # 位置2:右下角
+    od.text((W - tw - pad, H - th - pad), dyn_text, font=dyn_font, fill=dyn_color)
+
+    result = Image.alpha_composite(img, overlay).convert('RGB')
+    buf = BytesIO()
+    result.save(buf, format='JPEG', quality=88)
+    return buf.getvalue()
+
+
 def add_oss_watermark(url, username=None):
     """
-    为图片URL添加阿里云OSS水印
-    :param url: 原始图片URL
-    :param username: 当前登录用户名,如果未提供则使用默认值
-    :return: 添加水印后的URL,如果不是图片则返回原始URL
+    将图片 URL 替换为本地水印代理 URL(展示层使用,数据库原链接不受影响)。
+    非图片格式直接返回原 URL。
     """
     if not url:
         return url
-    
-    # 检查是否已经有水印参数
-    if 'x-oss-process=image/watermark' in url:
-        return url
-    
-    # 检查是否为图片格式
-    lower_url = url.lower()
+
+    lower_url = url.lower().split('?')[0]
     is_image = any(lower_url.endswith(ext) for ext in IMAGE_EXTENSIONS)
-    
     if not is_image:
         return url
-    
-    # 生成水印内容:用户名_时间戳
-    if not username:
-        username = 'genealogy'
-    timestamp = int(time.time())
-    watermark_text = f"{username}_{timestamp}"
-    
-    # 对水印文字进行base64编码(需要URL安全的base64)
-    try:
-        encoded_text = base64.b64encode(watermark_text.encode('utf-8')).decode('utf-8')
-        # 替换URL不安全的字符
-        encoded_text = encoded_text.replace('+', '-').replace('/', '_').replace('=', '')
-    except Exception as e:
-        print(f"[Watermark] Error encoding watermark text: {e}")
-        return url
-    
-    # 构建水印参数
-    watermark_params = f"?x-oss-process=image/watermark,text_{encoded_text},type_d3F5LXplbmhlaQ,size_30,t_30,g_nw,x_50,y_50,rotate_30"
-    
-    # 添加水印参数到URL
-    if '?' in url:
-        # 如果URL已有参数,使用&连接
-        return f"{url}&{watermark_params[1:]}"
-    else:
-        return f"{url}{watermark_params}"
+
+    encoded = base64.urlsafe_b64encode(url.encode()).decode().rstrip('=')
+    return f'/manager/image/wm?u={encoded}'
 
 def get_wechat_access_token():
     """获取微信小程序access_token,带缓存和线程安全"""
@@ -860,6 +955,83 @@ def ensure_pdf_table():
     finally:
         conn.close()
 
+@app.route('/manager/image/wm')
+def image_watermark_proxy():
+    """
+    图片水印代理:下载 OSS 原图,叠加 Pillow 平铺水印后返回给浏览器。
+    原数据库中的 oss_url 字段保持不变,水印仅在展示层生效。
+    query param: u = base64url(oss_url)
+    """
+    if 'user_id' not in session:
+        return '', 401
+
+    u_param = request.args.get('u', '')
+    if not u_param:
+        return '', 400
+
+    # 还原原始 OSS URL
+    try:
+        padding = 4 - len(u_param) % 4
+        oss_url = base64.urlsafe_b64decode(u_param + '=' * (padding % 4)).decode()
+    except Exception:
+        return '', 400
+
+    # 安全校验:只允许代理已知域名的图片
+    allowed_hosts = ('file.chunsunqiuzhu.com', 'chunsunqiuzhu.com')
+    from urllib.parse import urlparse
+    parsed = urlparse(oss_url)
+    if not any(parsed.netloc.endswith(h) for h in allowed_hosts):
+        return '', 403
+
+    cache_path = _wm_cache_path(oss_url)
+
+    # 阶段1:获取静态平铺水印(优先从磁盘缓存读取)
+    static_bytes = None
+    if os.path.exists(cache_path):
+        age = time.time() - os.path.getmtime(cache_path)
+        if age < WM_CACHE_TTL:
+            try:
+                with open(cache_path, 'rb') as f:
+                    static_bytes = f.read()
+            except Exception:
+                static_bytes = None
+
+    if static_bytes is None:
+        # 下载原图
+        try:
+            resp = requests.get(oss_url, timeout=30)
+            resp.raise_for_status()
+        except Exception as e:
+            print(f'[WM Proxy] Failed to fetch {oss_url}: {e}')
+            return '', 502
+
+        # 叠加静态平铺水印
+        try:
+            static_bytes = _apply_pillow_watermark(resp.content)
+        except Exception as e:
+            print(f'[WM Proxy] Static watermark error for {oss_url}: {e}')
+            static_bytes = resp.content   # 降级:使用原图
+
+        # 写磁盘缓存
+        try:
+            with open(cache_path, 'wb') as f:
+                f.write(static_bytes)
+        except Exception as e:
+            print(f'[WM Proxy] Cache write error: {e}')
+
+    # 阶段2:在静态水印上实时叠加动态水印(用户名 + 当前时间,不缓存)
+    username = session.get('username', 'genealogy')
+    dt_str = datetime.now().strftime('%Y-%m-%d %H:%M')
+    try:
+        final_bytes = _add_dynamic_watermark(static_bytes, username, dt_str)
+    except Exception as e:
+        print(f'[WM Proxy] Dynamic watermark error: {e}')
+        final_bytes = static_bytes
+
+    return Response(final_bytes, mimetype='image/jpeg',
+                    headers={'Cache-Control': 'no-store'})
+
+
 @app.route('/manager/pdf_management')
 def pdf_management():
     if 'user_id' not in session:

二进制
static/fonts/wqy-microhei.ttc