|
|
@@ -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:
|