diff --git a/compress_comic.py b/compress_comic.py new file mode 100644 index 0000000..4dcc2fa --- /dev/null +++ b/compress_comic.py @@ -0,0 +1,115 @@ +import cv2 +import zipfile +import argparse +import os +import re +import numpy as np +from io import BytesIO +from tqdm import tqdm +from concurrent.futures import ThreadPoolExecutor +import threading + +# 线程锁,用于安全写入 ZIP 和打印 +output_zip_lock = threading.Lock() +print_lock = threading.Lock() + +def natural_sort_key(s): + return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)] + +def resize_long_image(img, max_short_edge=640): + """ + 专为长图优化:限制短边(通常是宽度),保持比例 + 例如:宽 1000px, 高 5000px → 缩放为 宽 640px, 高 3200px + """ + h, w = img.shape[:2] + short_edge = min(w, h) + if short_edge <= max_short_edge: + return img # 不需要缩放 + + scale = max_short_edge / short_edge + new_w = int(w * scale) + new_h = int(h * scale) + + # 使用高质量插值缩小 + resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + return resized + +def convert_to_webp_data(img, quality=95): + encode_param = [int(cv2.IMWRITE_WEBP_QUALITY), quality] + success, buffer = cv2.imencode('.webp', img, encode_param) + if not success: + raise RuntimeError("WebP 编码失败") + return buffer.tobytes() + +def process_image(args): + """单张图像处理函数(用于多线程)""" + filename, img_data = args + try: + nparr = np.frombuffer(img_data, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None: + with print_lock: + tqdm.write(f"⚠️ 无法解码图像: {filename}") + return None + + # 缩放长图(限制短边) + img = resize_long_image(img, max_short_edge=640) + + # 转为 WebP + webp_bytes = convert_to_webp_data(img, quality=95) + + webp_name = os.path.splitext(filename)[0] + '.webp' + return (webp_name, webp_bytes) + + except Exception as e: + with print_lock: + tqdm.write(f"❌ 处理 {filename} 失败: {e}") + return None + +def main(): + parser = argparse.ArgumentParser(description="压缩漫画ZIP:长图优化 + 多线程加速") + parser.add_argument('-i', '--input', required=True, help='输入的ZIP文件路径') + parser.add_argument('--workers', type=int, default=4, help='并行线程数(默认4)') + args = parser.parse_args() + + input_zip_path = args.input + if not os.path.isfile(input_zip_path): + print(f"❌ 错误:文件 {input_zip_path} 不存在") + return + + base_name = os.path.splitext(input_zip_path)[0] + output_zip_path = f"{base_name}-lite.zip" + + with zipfile.ZipFile(input_zip_path, 'r') as input_zip: + image_files = [f for f in input_zip.namelist() if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tiff'))] + image_files.sort(key=natural_sort_key) + + if not image_files: + print("⚠️ 警告:ZIP中未找到图片文件") + return + + print(f"📦 找到 {len(image_files)} 张图片(包括长图),使用 {args.workers} 个线程处理...") + + # 读取所有图像数据用于多线程处理 + img_data_list = [(filename, input_zip.read(filename)) for filename in image_files] + + # 多线程处理 + results = [] + with ThreadPoolExecutor(max_workers=args.workers) as executor: + # 提交所有任务 + futures = [executor.submit(process_image, item) for item in img_data_list] + # 使用 tqdm 显示进度 + for future in tqdm(futures, desc="🚀 压缩中", unit="img"): + result = future.result() + if result is not None: + results.append(result) + + # 写入输出 ZIP(主线程完成,避免并发写 ZIP) + with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as output_zip: + for webp_name, webp_bytes in results: + output_zip.writestr(webp_name, webp_bytes) + + print(f"✅ 压缩完成!输出文件:{output_zip_path}") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/templates/book.html.j2 b/templates/book.html.j2 index 18be80e..5beabb9 100644 --- a/templates/book.html.j2 +++ b/templates/book.html.j2 @@ -213,6 +213,7 @@

更新时间: {{time}}

👍{{socre["like"]}} 👎{{socre["dislike"]}}

+ {%if islogin == "admin"%} @@ -277,6 +278,33 @@ alert('请求失败:' + error.message); }); } + + // 继续阅读:依据本地存储定位到上次分页 + (function setupContinueRead(){ + const btn = document.getElementById('continue-read'); + if(!btn) return; + const key = `ComiPy:reader:{{ id }}`; + let state = null; + try { + const raw = localStorage.getItem(key); + state = raw ? JSON.parse(raw) : null; + } catch(_) {} + + if(!state || !state.page){ + btn.title = '尚无阅读记录'; + // 保持原文案“继续阅读” + } else { + btn.title = `上次阅读到第 ${state.page} 页`; + btn.textContent = `继续阅读 第 ${state.page} 页`; + } + btn.addEventListener('click', function(){ + if(state && state.page){ + window.location.href = `/view/{{ id }}#p=${state.page}`; + } else { + window.location.href = `/view/{{ id }}`; + } + }); + })(); diff --git a/templates/view.html.j2 b/templates/view.html.j2 index 4769058..dc3b1a3 100644 --- a/templates/view.html.j2 +++ b/templates/view.html.j2 @@ -102,6 +102,10 @@ align-items: center; gap: 8px; margin: 16px 0; + padding: 0 8px; /* 给两侧留白,避免被屏幕边缘贴边或裁剪 */ + box-sizing: border-box; + scroll-padding-inline: 12px; /* 滚动对齐时保留左右内边距 */ + scrollbar-gutter: stable both-edges; /* 预留滚动条空间,避免内容被遮挡 */ } .page-button { @@ -116,6 +120,8 @@ transition: all 0.2s ease; min-width: 36px; font-family: inherit; + white-space: nowrap; /* 防止中文自动换行成竖排 */ + flex: 0 0 auto; /* 在可横向滚动容器中保持宽度,不被压缩 */ } .page-button:hover:not(:disabled) { @@ -140,6 +146,7 @@ border-color: #1a73e8; padding: 8px 24px; font-size: 14px; + min-width: 88px; /* 桌面端保证能容纳“上一页/下一页” */ } .page-button.nav-button:hover:not(:disabled) { @@ -163,6 +170,29 @@ font-weight: 400; } + .page-jump { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + flex: 0 0 auto; /* 避免被压缩导致输入框过小 */ + } + + .page-input { + width: 84px; + padding: 7px 8px; + border: 1px solid #dadce0; + border-radius: 4px; + font-size: 14px; + font-family: inherit; + outline: none; + } + + .page-input:focus { + border-color: #1a73e8; + box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.15); + } + .loading-placeholder { background: linear-gradient(90deg, #f1f3f4 25%, #e8eaed 50%, #f1f3f4 75%); background-size: 200% 100%; @@ -201,19 +231,73 @@ .container { margin: 8px; - padding: 16px 12px; + padding: 12px 10px; } .page-button { - padding: 6px 12px; + padding: 8px 12px; /* 提高触摸目标尺寸 */ font-size: 13px; min-width: 32px; } .page-button.nav-button { - padding: 6px 16px; + padding: 8px 16px; + font-size: 13px; + min-width: 72px; /* 移动端最小宽度,避免断行 */ + } + + .page-input { + width: 72px; + padding: 6px 8px; font-size: 13px; } + /* 小屏分页支持横向滚动,减少换行高度 */ + .pagination-container { + flex-wrap: nowrap; + justify-content: flex-start; /* 小屏靠左对齐,避免左侧被裁剪 */ + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + gap: 6px; + padding: 0 12px; /* 左右更充足的可视留白 */ + scroll-padding-inline: 12px; /* 小屏滚动对齐保留内边距 */ + overscroll-behavior-x: contain; /* 避免横向滚动影响页面整体 */ + } + .pagination-container::-webkit-scrollbar { height: 6px; } + .pagination-container::-webkit-scrollbar-thumb { background: #dadce0; border-radius: 3px; } + } + + /* 移动端悬浮操作按钮(FAB) */ + .mobile-fabs { + position: fixed; + right: 12px; + bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + display: none; /* 桌面端隐藏 */ + flex-direction: column; + gap: 10px; + z-index: 1100; + } + .mobile-fabs .fab { + width: 44px; + height: 44px; + border-radius: 50%; + border: 1px solid #dadce0; + background: #ffffff; + color: #1a73e8; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 6px rgba(60,64,67,.3); + cursor: pointer; + user-select: none; + font-size: 18px; + } + .mobile-fabs .fab:disabled { + color: #9aa0a6; + border-color: #e0e0e0; + } + @media (max-width: 768px) { + .mobile-fabs { display: inline-flex; } } @@ -228,6 +312,12 @@
+ +