证件裁切拼版工具

一个基于 Web 的证件照片自动裁切与 A4 拼版工具,支持批量处理和手动透视裁剪。

功能特性

批量处理 注:这个不理想!!

  • 自动检测证件边缘并裁切

  • 支持多种证件类型自动识别

  • 可选画质增强功能

  • 批量下载或打包 ZIP

手动透视裁剪

  • 上传单张图片进行精确裁剪

  • 可拖拽 4 个角点精确定位证件边缘

  • 支持扩大/缩小裁切范围

  • 裁切后可调整图片大小(50%~200%)

A4 拼版

  • 多张证件自动排版到 A4 页面

  • 预览拼版效果

  • 导出 PDF 文件

  • 直接打印功能

支持的证件类型

证件 尺寸 300DPI 像素
身份证 85.6mm × 54.0mm 1011 × 638 px
户口本 148mm × 105mm 1748 × 1240 px
出生证 130mm × 180mm 1535 × 2126 px
驾驶证 85.6mm × 54.0mm 1011 × 638 px
房产证 260mm × 180mm 3071 × 2126 px
护照 125mm × 88mm 1476 × 1039 px
银行卡 85.6mm × 53.98mm 1011 × 637 px

安装

环境要求

  • Python 3.8+

  • OpenCV

  • FastAPI

  • Uvicorn

  • Pillow

  • NumPy

安装依赖

复制代码
pip install fastapi uvicorn opencv-python-headless numpy pillow python-multipart

运行

复制代码
python main.py

启动后访问 http://localhost:8000

使用说明

批量处理

  1. 切换到"批量处理"标签页

  2. 拖拽或点击上传多张证件照片

  3. 选择证件类型(可选"自动识别")

  4. 选择输出精度(150/200/300 DPI)

  5. 勾选是否启用画质增强

  6. 点击"开始批量处理"

  7. 处理完成后可单独下载或打包下载

手动透视裁剪

  1. 切换到"透视裁剪"标签页

  2. 选择证件类型

  3. 上传一张证件照片

  4. 拖拽四个角点精确定位证件边缘

  5. 可使用"扩大范围"按钮调整裁切区域

  6. 点击"裁切"执行裁剪

  7. 裁切后可调整图片大小

  8. 点击"下载"保存结果

A4 拼版

  1. 在批量处理或透视裁剪完成后

  2. 点击"添加到合并列表"

  3. 在右侧合并面板中查看已添加的图片

  4. 点击"预览"查看拼版效果

  5. 点击"PDF"导出 PDF 文件

  6. 点击"打印"直接打印

API 接口

POST /api/process

批量处理证件图片

参数:

  • files: 上传的图片文件列表

  • doc_type: 证件类型(auto/id_card/household/birth_cert/driver_license/property_cert/passport/bank_card)

  • dpi: 输出精度(150/200/300)

  • enhance: 是否增强画质(true/false)

  • layout: 布局方式(single/dual)

POST /api/crop

手动透视裁剪

参数:

  • file: 图片文件

  • points: 四个角点坐标 JSON

  • doc_type: 证件类型

  • dpi: 输出精度

  • enhance: 是否增强画质

POST /api/merge-a4

合并到 A4

参数:

  • ids: 图片 ID 列表

  • format: 输出格式(img/pdf)

GET /api/dl/{id}

下载单个文件

GET /api/dl-pdf/{pdf_id}

下载 PDF 文件

POST /api/dl-all

打包下载所有文件

技术栈

  • 后端: FastAPI + OpenCV + Pillow

  • 前端: 原生 HTML/CSS/JavaScript

  • 图像处理: OpenCV 透视变换、边缘检测、CLAHE 增强

代码:

python 复制代码
"""
证件裁切拼版工具 v3
====================
批量自动处理 + 手动透视裁剪

pip install fastapi uvicorn opencv-python-headless numpy pillow python-multipart
python main.py → http://localhost:8000
"""

import io, uuid, math, zipfile, base64, json
from typing import List, Dict, Optional, Tuple
from urllib.parse import quote

import cv2
import numpy as np
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from PIL import Image
import uvicorn

app = FastAPI(title="证件裁切拼版")
store: Dict[str, bytes] = {}

# ════════════════════════════════════════════════════════════════
#  证件标准尺寸
# ════════════════════════════════════════════════════════════════

DOC_TYPES: Dict[str, dict] = {
    "id_card":    {"name": "身份证", "width_mm": 85.6,  "height_mm": 54.0,  "ratio": 85.6/54.0,
                   "desc": "标准二代身份证 · 85.6mm × 54.0mm"},
    "household":  {"name": "户口本", "width_mm": 148.0, "height_mm": 105.0, "ratio": 148.0/105.0,
                   "desc": "户口本单页 · 148mm × 105mm"},
    "birth_cert": {"name": "出生证", "width_mm": 130.0, "height_mm": 180.0, "ratio": 130.0/180.0,
                   "desc": "出生医学证明 · 130mm × 180mm"},
    "driver_license": {"name": "驾驶证", "width_mm": 85.6, "height_mm": 54.0, "ratio": 85.6/54.0,
                       "desc": "机动车驾驶证 · 85.6mm × 54.0mm"},
    "property_cert": {"name": "房产证", "width_mm": 260.0, "height_mm": 180.0, "ratio": 260.0/180.0,
                      "desc": "不动产权证书 · 260mm × 180mm"},
    "passport":   {"name": "护照", "width_mm": 125.0, "height_mm": 88.0, "ratio": 125.0/88.0,
                   "desc": "普通护照 · 125mm × 88mm"},
    "bank_card":  {"name": "银行卡", "width_mm": 85.6, "height_mm": 53.98, "ratio": 85.6/53.98,
                   "desc": "银行卡/信用卡 · 85.6mm × 53.98mm"},
}


def _mm2px(mm: float, dpi: int) -> int:
    return int(mm * dpi / 25.4)


def _target_size(doc_type: str, dpi: int):
    info = DOC_TYPES.get(doc_type)
    if not info:
        return None, None
    return _mm2px(info["width_mm"], dpi), _mm2px(info["height_mm"], dpi)


# ════════════════════════════════════════════════════════════════
#  图像处理核心
# ════════════════════════════════════════════════════════════════

def _read(data: bytes) -> np.ndarray:
    img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
    if img is None:
        raise ValueError("无法解析图片")
    return img


def _order_pts(pts: np.ndarray) -> np.ndarray:
    r = np.zeros((4, 2), dtype="float32")
    s, d = pts.sum(axis=1), np.diff(pts, axis=1).flatten()
    r[0], r[2] = pts[np.argmin(s)], pts[np.argmax(s)]
    r[1], r[3] = pts[np.argmin(d)], pts[np.argmax(d)]
    return r


def _find_quad(edge_map, min_area):
    cnts, _ = cv2.findContours(edge_map, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    for c in sorted(cnts, key=cv2.contourArea, reverse=True)[:12]:
        if cv2.contourArea(c) < min_area:
            continue
        peri = cv2.arcLength(c, True)
        for eps in [0.01, 0.02, 0.03, 0.04, 0.05]:
            approx = cv2.approxPolyDP(c, eps * peri, True)
            if len(approx) == 4:
                return approx.reshape(4, 2).astype("float32")
    return None


def detect_document(img: np.ndarray):
    h, w = img.shape[:2]
    max_dim = 1200
    scale = min(1.0, max_dim / max(h, w))
    proc = cv2.resize(img, None, fx=scale, fy=scale) if scale < 1 else img.copy()
    min_area = proc.shape[0] * proc.shape[1] * 0.03
    gray = cv2.cvtColor(proc, cv2.COLOR_BGR2GRAY)
    candidates = []

    blur5 = cv2.GaussianBlur(gray, (5, 5), 0)
    for lo, hi in [(20,60),(30,90),(40,120),(50,150),(75,200)]:
        e = cv2.dilate(cv2.Canny(blur5, lo, hi), np.ones((3,3),np.uint8), 1)
        q = _find_quad(e, min_area)
        if q is not None:
            candidates.append(q)

    bf = cv2.bilateralFilter(gray, 11, 75, 75)
    blur_b = cv2.GaussianBlur(bf, (3,3), 0)
    for lo, hi in [(20,60),(30,100),(50,150)]:
        e = cv2.dilate(cv2.Canny(blur_b, lo, hi), np.ones((3,3),np.uint8), 1)
        q = _find_quad(e, min_area)
        if q is not None:
            candidates.append(q)

    adapt = cv2.adaptiveThreshold(blur5, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                   cv2.THRESH_BINARY_INV, 15, 3)
    adapt = cv2.morphologyEx(adapt, cv2.MORPH_CLOSE, np.ones((5,5),np.uint8))
    q = _find_quad(adapt, min_area)
    if q is not None:
        candidates.append(q)

    k3 = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))
    grad = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, k3)
    _, gb = cv2.threshold(grad, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    q = _find_quad(cv2.dilate(gb, k3, 1), min_area)
    if q is not None:
        candidates.append(q)

    blur3 = cv2.GaussianBlur(gray, (3,3), 0)
    e2 = cv2.dilate(cv2.Canny(blur3, 15, 50), np.ones((3,3),np.uint8), 2)
    q = _find_quad(e2, min_area)
    if q is not None:
        candidates.append(q)

    if not candidates:
        return None
    best = max(candidates, key=cv2.contourArea)
    if scale < 1:
        best = best / scale
    return _order_pts(best)


def perspective_warp(img: np.ndarray, pts: np.ndarray) -> np.ndarray:
    rect = _order_pts(pts)
    tl, tr, br, bl = rect
    w = max(int(np.linalg.norm(br-bl)), int(np.linalg.norm(tr-tl)), 1)
    h = max(int(np.linalg.norm(tr-br)), int(np.linalg.norm(tl-bl)), 1)
    dst = np.array([[0,0],[w-1,0],[w-1,h-1],[0,h-1]], dtype="float32")
    warped = cv2.warpPerspective(img, cv2.getPerspectiveTransform(rect, dst), (w, h),
                                  borderMode=cv2.BORDER_REPLICATE)
    m = max(int(min(w, h) * 0.01), 1)
    if 2*m < w and 2*m < h:
        warped = warped[m:h-m, m:w-m]
    return warped


def remove_shadows(img: np.ndarray) -> np.ndarray:
    return img


def enhance_image(img: np.ndarray) -> np.ndarray:
    lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
    l, a, b = cv2.split(lab)
    l = cv2.createCLAHE(clipLimit=1.0, tileGridSize=(8,8)).apply(l)
    return cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)


def resize_to_standard(img, doc_type, dpi):
    tw, th = _target_size(doc_type, dpi)
    if tw is None:
        return img
    return cv2.resize(img, (tw, th), interpolation=cv2.INTER_LANCZOS4)


def classify_type(pts: np.ndarray) -> str:
    rect = cv2.minAreaRect(pts.astype(np.float32))
    w, h = rect[1]
    if w < h:
        w, h = h, w
    if h == 0:
        return "unknown"
    ratio = w / h
    best, best_d = "unknown", 999
    for dt, info in DOC_TYPES.items():
        d = abs(ratio - info["ratio"])
        if d < best_d and d < 0.30:
            best, best_d = dt, d
    return best


def process_image(data, doc_type="auto", dpi=300, do_enhance=True):
    img = _read(data)
    corners = detect_document(img)
    if corners is not None:
        warped = perspective_warp(img, corners)
        detected = True
    else:
        h, w = img.shape[:2]
        m = int(min(h, w) * 0.03)
        warped = img[m:h-m, m:w-m]
        detected = False
    if do_enhance:
        warped = enhance_image(warped)
    if doc_type == "auto":
        if detected and corners is not None:
            doc_type = classify_type(corners)
        else:
            h, w = warped.shape[:2]
            ratio = max(w, h) / max(min(w, h), 1)
            if 1.40 <= ratio <= 1.75:
                doc_type = "id_card"
            elif 1.15 <= ratio <= 1.50:
                doc_type = "household"
            else:
                doc_type = "unknown"
    if doc_type in DOC_TYPES:
        warped = resize_to_standard(warped, doc_type, dpi)
    return warped, detected, doc_type


def _to_bytes(img, q=95):
    return cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, q])[1].tobytes()


def _to_thumb(img, side=400):
    h, w = img.shape[:2]
    s = side / max(h, w)
    s = cv2.resize(img, None, fx=s, fy=s) if s < 1 else img
    b = cv2.imencode(".jpg", s, [cv2.IMWRITE_JPEG_QUALITY, 78])[1]
    return "data:image/jpeg;base64," + base64.b64encode(b).decode()


def a4_layout(imgs, dpi=300, allow_scale=True):
    aw, ah = _mm2px(210, dpi), _mm2px(297, dpi)
    mg, gap = _mm2px(12, dpi), _mm2px(5, dpi)
    avail_w = aw - 2*mg
    pages, canvas = [], np.full((ah, aw, 3), 255, dtype=np.uint8)
    y = mg
    for im in imgs:
        h, w = im.shape[:2]
        rem = ah - mg - y
        if w > avail_w:
            sc_w = avail_w / w
            rh, rw = int(h * sc_w), avail_w
            rs = cv2.resize(im, (rw, rh), interpolation=cv2.INTER_LANCZOS4)
        elif allow_scale:
            sc = min(avail_w/w, rem/h, 1.0)
            if sc < 1:
                rh, rw = int(h*sc), int(w*sc)
                rs = cv2.resize(im, (rw, rh), interpolation=cv2.INTER_LANCZOS4)
            else:
                rs, rh, rw = im, h, w
        else:
            rs, rh, rw = im, h, w
        if rh > rem and y > mg:
            pages.append(canvas)
            canvas = np.full((ah, aw, 3), 255, dtype=np.uint8)
            y = mg
            rem = ah - 2*mg
            if w > avail_w:
                sc_w = avail_w / w
                rh, rw = int(h * sc_w), avail_w
                rs = cv2.resize(im, (rw, rh), interpolation=cv2.INTER_LANCZOS4)
            elif allow_scale:
                sc = min(avail_w/w, rem/h, 1.0)
                if sc < 1:
                    rh, rw = int(h*sc), int(w*sc)
                    rs = cv2.resize(im, (rw, rh), interpolation=cv2.INTER_LANCZOS4)
                else:
                    rs, rh, rw = im, h, w
            else:
                rs, rh, rw = im, h, w
        x = mg + (avail_w - rw) // 2
        canvas[y:y+rh, x:x+rw] = rs
        y += rh + gap
    pages.append(canvas)
    return pages


# ════════════════════════════════════════════════════════════════
#  API 接口
# ════════════════════════════════════════════════════════════════

@app.get("/", response_class=HTMLResponse)
async def index():
    return HTML


@app.post("/api/process")
async def api_process(
    files: List[UploadFile] = File(...),
    doc_type: str = Form("auto"), dpi: str = Form("300"),
    enhance: str = Form("true"), layout: str = Form("individual"),
):
    dpi_int = int(dpi)
    do_enh = enhance.lower() == "true"
    results, processed = [], []
    for f in files:
        try:
            img, det, dt = process_image(await f.read(), doc_type, dpi_int, do_enh)
            processed.append(img)
            rid = uuid.uuid4().hex[:8]
            store[rid] = _to_bytes(img)
            info = DOC_TYPES.get(dt, {})
            results.append({"id": rid, "name": f.filename, "detected": det,
                            "type": dt, "type_name": info.get("name","未识别"),
                            "type_desc": info.get("desc","自定义尺寸"),
                            "w": int(img.shape[1]), "h": int(img.shape[0]),
                            "dpi": dpi_int, "thumb": _to_thumb(img)})
        except Exception as e:
            results.append({"name": f.filename, "error": str(e)})
    if layout == "a4" and processed:
        for pi, page in enumerate(a4_layout(processed, dpi_int)):
            aid = uuid.uuid4().hex[:8]
            store[aid] = _to_bytes(page, 98)
            results.insert(0, {"id": aid, "name": f"A4拼版_{pi+1}.jpg", "detected": True,
                               "type": "a4", "type_name": "A4 拼版",
                               "type_desc": f"210mm × 297mm · 第{pi+1}页",
                               "w": int(page.shape[1]), "h": int(page.shape[0]),
                               "dpi": dpi_int, "thumb": _to_thumb(page, 320), "layout": True})
    return {"results": results, "total": len(results),
            "detected": sum(1 for r in results if r.get("detected"))}


@app.post("/api/detect")
async def api_detect(file: UploadFile = File(...)):
    img = _read(await file.read())
    corners = detect_document(img)
    h, w = img.shape[:2]
    if corners is None:
        m = 0.10
        corners = np.array([[w*m,h*m],[w*(1-m),h*m],[w*(1-m),h*(1-m)],[w*m,h*(1-m)]], dtype="float32")
        return {"points": corners.tolist(), "detected": False, "w": w, "h": h}
    return {"points": corners.tolist(), "detected": True, "w": w, "h": h}


@app.post("/api/crop")
async def api_crop(
    file: UploadFile = File(...),
    points: str = Form(...),
    doc_type: str = Form("auto"), dpi: str = Form("300"),
    enhance: str = Form("true"),
):
    pts = json.loads(points)
    dpi_int = int(dpi)
    do_enh = enhance.lower() == "true"
    img = _read(await file.read())
    corners = np.array(pts, dtype="float32")
    warped = perspective_warp(img, corners)
    if do_enh:
        warped = enhance_image(warped)
    if doc_type == "auto":
        doc_type = classify_type(corners)
    if doc_type in DOC_TYPES:
        warped = resize_to_standard(warped, doc_type, dpi_int)
    rid = uuid.uuid4().hex[:8]
    store[rid] = _to_bytes(warped)
    h, w = warped.shape[:2]
    info = DOC_TYPES.get(doc_type, {})
    return {"id": rid, "type": doc_type, "type_name": info.get("name","自定义"),
            "type_desc": info.get("desc","自定义尺寸"),
            "w": int(w), "h": int(h), "dpi": dpi_int, "thumb": _to_thumb(warped)}


@app.get("/api/dl/{rid}")
async def api_download(rid: str):
    if rid not in store:
        raise HTTPException(404, "文件不存在或已过期")
    return StreamingResponse(io.BytesIO(store[rid]), media_type="image/jpeg",
                             headers={"Content-Disposition": f"attachment; filename={rid}.jpg; filename*=UTF-8''{quote(f'{rid}.jpg')}"})


@app.post("/api/dl-all")
async def api_download_all(body: dict):
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for i, rid in enumerate(body.get("ids", [])):
            if rid in store:
                zf.writestr(f"processed_{i+1:02d}.jpg", store[rid])
    buf.seek(0)
    return StreamingResponse(buf, media_type="application/zip",
                             headers={"Content-Disposition": f"attachment; filename=docs.zip; filename*=UTF-8''{quote('证件裁切_批量处理.zip')}"})


@app.post("/api/merge-a4")
async def api_merge_a4(body: dict):
    ids = body.get("ids", [])
    output_format = body.get("format", "jpg")
    if len(ids) < 1:
        raise HTTPException(400, "请提供至少一张图片")
    imgs = []
    for rid in ids:
        if rid not in store:
            raise HTTPException(404, f"图片 {rid} 不存在或已过期")
        img = _read(store[rid])
        imgs.append(img)
    pages = a4_layout(imgs, 300, allow_scale=False)
    results = []
    for pi, page in enumerate(pages):
        aid = uuid.uuid4().hex[:8]
        store[aid] = _to_bytes(page, 98)
        results.append({
            "id": aid,
            "name": f"A4拼版_{pi+1}.jpg",
            "w": int(page.shape[1]),
            "h": int(page.shape[0]),
            "dpi": 300,
            "thumb": _to_thumb(page, 320)
        })
    if output_format == "pdf":
        pdf_buf = io.BytesIO()
        pil_pages = []
        for page in pages:
            pil_img = Image.fromarray(cv2.cvtColor(page, cv2.COLOR_BGR2RGB))
            pil_pages.append(pil_img)
        if pil_pages:
            pil_pages[0].save(pdf_buf, format="PDF", save_all=True, append_images=pil_pages[1:], resolution=300.0)
            pdf_buf.seek(0)
            pdf_id = uuid.uuid4().hex[:8]
            store[pdf_id] = pdf_buf.read()
            return {"results": results, "total": len(results), "pdf_id": pdf_id}
    return {"results": results, "total": len(results)}


@app.get("/api/dl-pdf/{pdf_id}")
async def api_download_pdf(pdf_id: str):
    if pdf_id not in store:
        raise HTTPException(404, "PDF文件不存在或已过期")
    return StreamingResponse(io.BytesIO(store[pdf_id]), media_type="application/pdf",
                             headers={"Content-Disposition": f"attachment; filename=docs.pdf; filename*=UTF-8''{quote('证件裁切_A4拼版.pdf')}"})


@app.post("/api/resize")
async def api_resize(body: dict):
    rid = body.get("id")
    scale = body.get("scale", 100)
    if rid not in store:
        raise HTTPException(404, "图片不存在或已过期")
    if scale < 10 or scale > 400:
        raise HTTPException(400, "缩放比例必须在 10%-400% 之间")
    img = _read(store[rid])
    if scale != 100:
        s = scale / 100.0
        h, w = img.shape[:2]
        nw, nh = int(w * s), int(h * s)
        img = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LANCZOS4 if s > 1 else cv2.INTER_AREA)
    new_id = uuid.uuid4().hex[:8]
    store[new_id] = _to_bytes(img)
    h, w = img.shape[:2]
    return {
        "id": new_id,
        "w": int(w),
        "h": int(h),
        "thumb": _to_thumb(img)
    }


# ════════════════════════════════════════════════════════════════
#  前端页面
# ════════════════════════════════════════════════════════════════

HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>证件裁切拼版</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#080b12;--sf:#0d111c;--card:#141b2d;--card-h:#1a2340;
  --bdr:#1e2a42;--bdr-l:#2a3a5c;
  --acc:#c8982a;--acc-h:#ddb43a;--acc-bg:rgba(200,152,42,.06);
  --ok:#34c47a;--warn:#d4a030;--err:#e04545;
  --tx:#e2ded6;--tx2:#8890a4;--tx3:#48506a;
  --r:10px;--rs:6px;
}
html{font-size:14px}
body{
  background:var(--bg);color:var(--tx);
  font-family:'DM Mono','Menlo','PingFang SC','Microsoft YaHei',monospace;
  line-height:1.65;min-height:100vh;
}
body::before{
  content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
  background:radial-gradient(ellipse at 20% 10%,rgba(200,152,42,.03),transparent 55%),
             radial-gradient(ellipse at 85% 85%,rgba(52,196,122,.015),transparent 50%);
}
.app{max-width:1100px;margin:0 auto;padding:40px 24px 88px;position:relative;z-index:1}

/* ── Header & Tabs ─────────────────────────── */
.header{margin-bottom:32px}
.header h1{
  font-family:'Noto Serif SC',Georgia,serif;font-size:clamp(24px,4.5vw,38px);
  font-weight:600;letter-spacing:.02em;margin-bottom:2px;
}
.hl{color:var(--acc)}
.sub{color:var(--tx2);font-size:11.5px;letter-spacing:.03em;margin-bottom:16px}
.tabs{
  display:inline-flex;gap:2px;background:var(--sf);
  border:1px solid var(--bdr);border-radius:var(--rs);padding:3px;
}
.tab-btn{
  padding:7px 22px;font:inherit;font-size:12px;color:var(--tx3);
  background:transparent;border:none;border-radius:4px;cursor:pointer;
  transition:all .2s;white-space:nowrap;
}
.tab-btn.active{background:var(--acc);color:#0a0a0a;font-weight:500}
.tab-btn:hover:not(.active){color:var(--tx);background:var(--card)}
.tab-panel{display:none}.tab-panel.active{display:block}

/* ── Settings Bar ──────────────────────────── */
.sbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
.sg{display:flex;align-items:center;gap:5px}
.sg-l{color:var(--tx2);font-size:11px;white-space:nowrap}
.seg{display:inline-flex;background:var(--sf);border:1px solid var(--bdr);border-radius:var(--rs);overflow:hidden}
.seg-b{
  padding:5px 13px;font:inherit;font-size:11px;color:var(--tx3);
  background:transparent;border:none;cursor:pointer;transition:all .15s;white-space:nowrap;
}
.seg-b:not(:last-child){border-right:1px solid var(--bdr)}
.seg-b.on{background:var(--acc);color:#0a0a0a;font-weight:500}
.seg-b:hover:not(.on){color:var(--tx);background:var(--card)}
.sel{
  padding:5px 10px;font:inherit;font-size:11px;color:var(--tx);
  background:var(--sf);border:1px solid var(--bdr);border-radius:var(--rs);
  cursor:pointer;min-width:100px;
}
.sel:focus{outline:none;border-color:var(--acc)}
.chk{
  display:flex;align-items:center;gap:5px;color:var(--tx2);font-size:11px;cursor:pointer;user-select:none;
}
.chk input[type=checkbox]{
  appearance:none;width:15px;height:15px;border:1.5px solid var(--bdr-l);
  border-radius:3px;background:transparent;cursor:pointer;position:relative;transition:all .15s;flex-shrink:0;
}
.chk input:checked{background:var(--acc);border-color:var(--acc)}
.chk input:checked::after{
  content:'';position:absolute;left:3.5px;top:.5px;width:5px;height:8px;
  border:solid #0a0a0a;border-width:0 1.8px 1.8px 0;transform:rotate(45deg);
}

/* ── Upload Zone (batch) ───────────────────── */
.drop-zone{
  border:2px dashed var(--bdr);border-radius:var(--r);background:var(--sf);
  text-align:center;cursor:pointer;transition:all .3s;position:relative;overflow:hidden;
}
.drop-zone.full{padding:48px 24px}
.drop-zone.compact{padding:14px 24px}
.drop-zone:hover,.drop-zone.drag-over{border-color:var(--acc);background:var(--acc-bg)}
.drop-zone input{position:absolute;inset:0;opacity:0;cursor:pointer;z-index:2}
.dz-full{transition:opacity .25s}.drop-zone.compact .dz-full{display:none}
.dz-compact{display:none;align-items:center;gap:8px;justify-content:center}
.drop-zone.compact .dz-compact{display:flex}
.dz-icon{width:44px;height:44px;margin:0 auto 12px;stroke:var(--tx3);opacity:.6;animation:dzF 3.5s ease-in-out infinite}
.drop-zone:hover .dz-icon{stroke:var(--acc)}
.drop-zone h3{font-family:'Noto Serif SC',Georgia,serif;font-size:17px;font-weight:400;margin-bottom:3px}
.drop-zone .hint{color:var(--tx3);font-size:11px}
.dz-c-icon{width:18px;height:18px;stroke:var(--acc);flex-shrink:0}
.dz-c-text{color:var(--tx2);font-size:12px}

/* ── File List ─────────────────────────────── */
.fsec{margin-top:12px;display:none}.fsec.show{display:block}
.fbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.fbar span{color:var(--tx2);font-size:11px}
.btn-link{
  background:none;border:none;color:var(--tx3);font:inherit;font-size:11px;
  cursor:pointer;padding:2px 6px;border-radius:4px;transition:all .15s;
}
.btn-link:hover{color:var(--err);background:rgba(224,69,69,.08)}
.fscroll{
  display:flex;gap:8px;overflow-x:auto;padding:2px 2px 6px;
  scrollbar-width:thin;scrollbar-color:var(--bdr) transparent;
}
.fscroll::-webkit-scrollbar{height:3px}
.fscroll::-webkit-scrollbar-thumb{background:var(--bdr);border-radius:2px}
.fi{
  flex:0 0 96px;background:var(--card);border:1px solid var(--bdr);
  border-radius:var(--rs);padding:4px;position:relative;animation:fadeUp .3s ease both;
}
.fi img{width:100%;aspect-ratio:5/3;object-fit:cover;border-radius:3px;display:block;background:#080c14}
.fi .fn{font-size:9px;color:var(--tx2);margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fi .fs{font-size:8px;color:var(--tx3)}
.fi .fx{
  position:absolute;top:2px;right:2px;width:16px;height:16px;border-radius:50%;
  background:rgba(0,0,0,.6);border:none;color:#ccc;font-size:9px;
  cursor:pointer;display:flex;align-items:center;justify-content:center;
  opacity:0;transition:opacity .15s;z-index:1;
}
.fi:hover .fx{opacity:1}

/* ── Action Bar ────────────────────────────── */
.act{display:none;align-items:center;justify-content:flex-end;gap:10px;margin-top:14px}
.act.show{display:flex}

/* ── Buttons ───────────────────────────────── */
.btn{
  padding:8px 18px;border-radius:var(--rs);border:1px solid var(--bdr);
  background:var(--card);color:var(--tx);font-family:inherit;font-size:12px;
  cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;
  gap:6px;white-space:nowrap;text-decoration:none;
}
.btn:hover{border-color:var(--bdr-l);background:var(--card-h)}
.btn:disabled{opacity:.3;cursor:not-allowed}
.btn-p{background:var(--acc);color:#0a0a0a;border-color:var(--acc);font-weight:500}
.btn-p:hover:not(:disabled){background:var(--acc-h);transform:translateY(-1px);box-shadow:0 4px 20px rgba(200,152,42,.25)}
.btn-sm{padding:4px 10px;font-size:10px;border-radius:4px}
.btn-accent{background:var(--acc);color:#0a0a0a;border-color:var(--acc);font-weight:500}
.btn-accent:hover{background:var(--acc-h)}

/* ── Processing ────────────────────────────── */
.proc{display:none;flex-direction:column;align-items:center;gap:12px;padding:40px 0}
.proc.show{display:flex}
.spinner{width:30px;height:30px;border:3px solid var(--bdr);border-top-color:var(--acc);border-radius:50%;animation:spin .7s linear infinite}
.proc p{color:var(--tx2);font-size:12px}

/* ── Results ───────────────────────────────── */
.results{display:none;margin-top:40px}.results.show{display:block}
.rh{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap;gap:10px}
.rh h2{font-family:'Noto Serif SC',Georgia,serif;font-size:22px;font-weight:400}
.rh .st{color:var(--tx2);font-size:11px;margin-top:2px}
.rg{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}
.rc{background:var(--card);border:1px solid var(--bdr);border-radius:var(--r);overflow:hidden;animation:fadeUp .4s ease both;transition:border-color .2s}
.rc:hover{border-color:var(--bdr-l)}
.rci{position:relative;width:100%;background:#080c14;display:flex;align-items:center;justify-content:center;overflow:hidden}
.rci img{width:100%;height:auto;max-height:240px;object-fit:contain}
.rcb{position:absolute;top:8px;left:8px;padding:2px 8px;border-radius:3px;font-size:9px;font-weight:500;letter-spacing:.03em}
.b-id{background:#c8982a;color:#0a0a0a}.b-hk{background:#34c47a;color:#0a0a0a}.b-bc{background:#e88c30;color:#0a0a0a}.b-dl{background:#5888e8;color:#fff}.b-pc{background:#9b59b6;color:#fff}.b-pp{background:#e74c3c;color:#fff}.b-bk{background:#1abc9c;color:#0a0a0a}.b-a4{background:#5888e8;color:#fff}.b-unk{background:var(--bdr-l);color:var(--tx2)}
.rcf{padding:10px 12px}
.rcr{display:flex;align-items:center;justify-content:space-between;margin-bottom:3px}
.rcs{display:flex;align-items:center;gap:5px;font-size:10px}
.dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
.dot-ok{background:var(--ok)}.dot-w{background:var(--warn)}
.rcm{font-size:10px;color:var(--tx3)}
.rcd{font-size:10px;color:var(--tx2);margin-top:2px}
.rca{display:flex;gap:5px;margin-top:8px;justify-content:flex-end}
.rce{padding:28px 14px;text-align:center}
.rce .ei{font-size:22px;margin-bottom:5px;display:block}
.rce p{font-size:11px;color:var(--tx2)}.rce .em{font-size:10px;color:var(--err);margin-top:3px}

/* ── Crop Tool ─────────────────────────────── */
.crop-upload{
  border:2px dashed var(--bdr);border-radius:var(--r);background:var(--sf);
  text-align:center;padding:52px 24px;cursor:pointer;transition:all .3s;
  position:relative;overflow:hidden;
}
.crop-upload:hover,.crop-upload.drag-over{border-color:var(--acc);background:var(--acc-bg)}
.crop-upload input{position:absolute;inset:0;opacity:0;cursor:pointer;z-index:2}
.crop-upload .cu-icon{width:44px;height:44px;margin:0 auto 12px;stroke:var(--tx3);opacity:.6}
.crop-upload:hover .cu-icon{stroke:var(--acc)}
.crop-upload h3{font-family:'Noto Serif SC',Georgia,serif;font-size:17px;font-weight:400;margin-bottom:3px}
.crop-upload .hint{color:var(--tx3);font-size:11px}

.crop-editor{display:none;margin-top:16px}
.crop-editor.show{display:block}

.crop-toolbar{
  display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;
}
.crop-fname{font-size:12px;color:var(--tx);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}
.crop-finfo{font-size:10px;color:var(--tx3)}
.crop-spacer{flex:1}

.crop-canvas-wrap{
  position:relative;width:100%;height:480px;
  background:#080c14;border:1px solid var(--bdr);border-radius:var(--r);overflow:hidden;
}
#cropCanvas{display:block;width:100%;height:100%;cursor:crosshair}

.magnifier{
  position:absolute;width:130px;height:130px;border-radius:50%;
  border:2.5px solid var(--acc);pointer-events:none;display:none;z-index:10;
  box-shadow:0 4px 24px rgba(0,0,0,.6);
}

.crop-info{
  display:flex;align-items:center;justify-content:space-between;
  margin-top:8px;padding:0 2px;
}
.crop-info span{font-size:11px;color:var(--tx3)}
.crop-coord{font-family:'DM Mono',monospace;color:var(--tx2)!important;font-size:10px!important}

.crop-actions{
  display:flex;align-items:center;gap:10px;margin-top:14px;flex-wrap:wrap;
}
.crop-spacer2{flex:1}
.expand-ctrl{display:flex;align-items:center;gap:5px}
.expand-ctrl label{font-size:11px;color:var(--tx2)}
.expand-ctrl input[type=number]{width:55px;padding:4px 6px;font:inherit;font-size:11px;
  background:var(--sf);border:1px solid var(--bdr);border-radius:var(--rs);color:var(--tx);text-align:center}

.crop-result{display:none;margin-top:20px}
.crop-result.show{display:block}
.crop-result h3{
  font-family:'Noto Serif SC',Georgia,serif;font-size:18px;
  font-weight:400;margin-bottom:12px;
}
.cr-preview{
  background:var(--card);border:1px solid var(--bdr);border-radius:var(--r);
  padding:16px;display:flex;align-items:center;justify-content:center;
  max-height:400px;overflow:auto;
}
.cr-preview img{max-width:100%;max-height:368px;object-fit:contain;border-radius:var(--rs)}
.cr-info{margin-top:10px;font-size:11px;color:var(--tx2);display:flex;gap:16px;flex-wrap:wrap}
.cr-size-ctrl{
  display:flex;align-items:center;gap:6px;justify-content:center;
  margin-top:12px;padding:8px;background:var(--sf);border-radius:var(--rs);
}
.cr-size-ctrl label{font-size:11px;color:var(--tx2)}
.cr-size-ctrl .btn-sm{min-width:50px}
.cr-size-ctrl .btn-sm.on{background:var(--acc);color:#0a0a0a;font-weight:500}
.cr-actions{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap}

.merge-panel{display:none;margin-top:20px;background:var(--card);border:1px solid var(--bdr);border-radius:var(--r);padding:14px}
.merge-panel.show{display:block}
.merge-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.merge-title{font-size:12px;color:var(--tx2)}
.merge-list{display:flex;gap:8px;overflow-x:auto;padding:4px 0 8px;min-height:60px}
.merge-item{flex:0 0 72px;background:var(--sf);border:1px solid var(--bdr);border-radius:var(--rs);padding:4px;position:relative}
.merge-item img{width:100%;aspect-ratio:5/3;object-fit:cover;border-radius:3px;display:block}
.merge-item .mi-del{
  position:absolute;top:2px;right:2px;width:14px;height:14px;border-radius:50%;
  background:rgba(224,69,69,.85);border:none;color:#fff;font-size:9px;
  cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s
}
.merge-item:hover .mi-del{opacity:1}
.merge-actions{margin-top:10px;display:flex;justify-content:center;gap:10px}

.preview-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:1000;align-items:center;justify-content:center;padding:20px}
.preview-modal.show{display:flex}
.preview-content{background:var(--card);border:1px solid var(--bdr);border-radius:var(--r);max-width:90vw;max-height:90vh;overflow:auto}
.preview-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--bdr);position:sticky;top:0;background:var(--card)}
.preview-header span{font-size:13px;color:var(--tx)}
.preview-pages{padding:16px;display:flex;flex-direction:column;gap:16px;align-items:center}
.preview-pages img{max-width:100%;max-height:70vh;border-radius:var(--rs);box-shadow:0 4px 20px rgba(0,0,0,.3)}
.preview-pages .page-label{font-size:11px;color:var(--tx2);margin-top:-8px}

/* ── Reference ─────────────────────────────── */
.ref{margin-top:28px;padding:14px 18px;background:var(--sf);border:1px solid var(--bdr);border-radius:var(--r)}
.ref h3{font-family:'Noto Serif SC',Georgia,serif;font-size:13px;font-weight:400;margin-bottom:8px;color:var(--tx2)}
.rg2{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:8px}
.rf{background:var(--card);border:1px solid var(--bdr);border-radius:var(--rs);padding:8px 12px}
.rf .rn{font-size:11px;font-weight:500;margin-bottom:3px}.rf .rd{font-size:10px;color:var(--tx2)}.rf .rp{font-size:10px;color:var(--tx3);margin-top:2px}

/* ── Footer ────────────────────────────────── */
.footer{margin-top:40px;padding-top:18px;border-top:1px solid var(--bdr);text-align:center;color:var(--tx3);font-size:10px;line-height:2}

/* ── Toast ─────────────────────────────────── */
.toast{
  position:fixed;top:18px;left:50%;transform:translateX(-50%) translateY(-80px);
  background:var(--card);border:1px solid var(--bdr);color:var(--tx);
  padding:9px 22px;border-radius:var(--rs);font-size:11px;z-index:999;
  transition:transform .35s cubic-bezier(.22,1,.36,1);
  box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:90vw;text-align:center;
}
.toast.show{transform:translateX(-50%) translateY(0)}.toast.ok{border-color:var(--ok)}.toast.err{border-color:var(--err)}

/* ── Animations ────────────────────────────── */
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
@keyframes dzF{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
@keyframes spin{to{transform:rotate(360deg)}}

/* ── Responsive ────────────────────────────── */
@media(max-width:640px){
  .app{padding:22px 12px 56px}
  .sbar{gap:6px}.seg-b{padding:4px 9px;font-size:10px}
  .act,.crop-actions{justify-content:center}
  .btn-p{width:100%;justify-content:center}
  .rg{grid-template-columns:1fr}
  .rh{flex-direction:column;align-items:flex-start}
  .crop-canvas-wrap{height:55vh}
  .magnifier{width:100px;height:100px}
  .crop-fname{max-width:140px}
}
</style>
</head>
<body>
<div class="app">

  <header class="header">
    <h1>证件裁切<span class="hl">拼版</span></h1>
    <p class="sub">身份证 · 户口本 · 自动检测裁切 · 手动透视校正 · 标准尺寸输出</p>
    <nav class="tabs">
      <button class="tab-btn active" data-tab="batch">批量处理</button>
      <button class="tab-btn" data-tab="crop">透视裁剪</button>
    </nav>
  </header>

  <!-- ═══════════ 批量处理 ═══════════ -->
  <div class="tab-panel active" id="batchPanel">

    <div class="sbar">
      <div class="sg"><span class="sg-l">证件类型</span>
        <select class="sel" id="typeSel">
          <option value="auto">自动识别</option>
          <option value="id_card">身份证</option>
          <option value="household">户口本</option>
          <option value="birth_cert">出生证</option>
          <option value="driver_license">驾驶证</option>
          <option value="property_cert">房产证</option>
          <option value="passport">护照</option>
          <option value="bank_card">银行卡</option>
        </select>
      </div>
      <div class="sg"><span class="sg-l">精度</span>
        <div class="seg" id="dpiSeg">
          <button class="seg-b" data-v="150">150</button>
          <button class="seg-b" data-v="200">200</button>
          <button class="seg-b on" data-v="300">300 DPI</button>
        </div>
      </div>
      <label class="chk"><input type="checkbox" id="enhance" checked><span>画质增强</span></label>
      <div class="sg"><span class="sg-l">输出</span>
        <div class="seg" id="laySeg">
          <button class="seg-b on" data-v="individual">逐张</button>
          <button class="seg-b" data-v="a4">A4 拼版</button>
        </div>
      </div>
    </div>

    <div class="drop-zone full" id="dropZone">
      <input type="file" id="fileInput" multiple accept="image/*">
      <div class="dz-full">
        <svg class="dz-icon" viewBox="0 0 48 48" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="6" width="32" height="36" rx="4"/><line x1="16" y1="16" x2="32" y2="16"/><line x1="16" y1="22" x2="28" y2="22"/><line x1="16" y1="28" x2="24" y2="28"/><circle cx="20" cy="36" r="2"/><circle cx="28" cy="36" r="2"/></svg>
        <h3>拖入证件照片,或点击选择</h3>
        <p class="hint">支持 JPG / PNG · 批量选择 · 自动检测边缘</p>
      </div>
      <div class="dz-compact">
        <svg class="dz-c-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
        <span class="dz-c-text">继续添加照片</span>
      </div>
    </div>

    <div class="fsec" id="fsec">
      <div class="fbar"><span id="fcount"></span><button class="btn-link" id="clearBtn">清除全部</button></div>
      <div class="fscroll" id="flist"></div>
    </div>

    <div class="act" id="act">
      <button class="btn btn-p" id="procBtn">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
        开始批量处理
      </button>
    </div>

    <div class="proc" id="proc"><div class="spinner"></div><p id="procText">正在检测证件边缘并校正...</p></div>

    <div class="results" id="results">
      <div class="rh">
        <div><h2>处理完成</h2><p class="st" id="stext"></p></div>
        <button class="btn btn-accent" id="dlAllBtn">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
          全部下载 ZIP
        </button>
      </div>
      <div class="rg" id="rgrid"></div>
    </div>

    <div class="ref">
      <h3>标准尺寸参考</h3>
      <div class="rg2">
        <div class="rf"><div class="rn" style="color:var(--acc)">身份证</div><div class="rd">85.6mm × 54.0mm</div><div class="rp">300DPI → 1011 × 638 px</div></div>
        <div class="rf"><div class="rn" style="color:var(--ok)">户口本</div><div class="rd">148mm × 105mm</div><div class="rp">300DPI → 1748 × 1240 px</div></div>
        <div class="rf"><div class="rn" style="color:#e88c30">出生证</div><div class="rd">130mm × 180mm</div><div class="rp">300DPI → 1535 × 2126 px</div></div>
        <div class="rf"><div class="rn" style="color:#5888e8">驾驶证</div><div class="rd">85.6mm × 54.0mm</div><div class="rp">300DPI → 1011 × 638 px</div></div>
        <div class="rf"><div class="rn" style="color:#9b59b6">房产证</div><div class="rd">260mm × 180mm</div><div class="rp">300DPI → 3071 × 2126 px</div></div>
        <div class="rf"><div class="rn" style="color:#e74c3c">护照</div><div class="rd">125mm × 88mm</div><div class="rp">300DPI → 1476 × 1039 px</div></div>
        <div class="rf"><div class="rn" style="color:#1abc9c">银行卡</div><div class="rd">85.6mm × 53.98mm</div><div class="rp">300DPI → 1011 × 637 px</div></div>
        <div class="rf"><div class="rn" style="color:#5888e8">A4 纸张</div><div class="rd">210mm × 297mm</div><div class="rp">300DPI → 2480 × 3508 px</div></div>
      </div>
    </div>
  </div>

  <!-- ═══════════ 透视裁剪 ═══════════ -->
  <div class="tab-panel" id="cropPanel">

    <div class="sbar">
      <div class="sg"><span class="sg-l">证件类型</span>
        <select class="sel" id="cropTypeSel">
          <option value="auto">自动识别</option>
          <option value="id_card">身份证</option>
          <option value="household">户口本</option>
          <option value="birth_cert">出生证</option>
          <option value="driver_license">驾驶证</option>
          <option value="property_cert">房产证</option>
          <option value="passport">护照</option>
          <option value="bank_card">银行卡</option>
        </select>
      </div>
      <div class="sg"><span class="sg-l">精度</span>
        <div class="seg" id="cropDpiSeg">
          <button class="seg-b" data-v="150">150</button>
          <button class="seg-b" data-v="200">200</button>
          <button class="seg-b on" data-v="300">300 DPI</button>
        </div>
      </div>
      <div class="sg chk"><input type="checkbox" id="cropEnhance" checked><label for="cropEnhance">增强图像</label></div>
    </div>

    <div class="crop-upload" id="cropUpload">
      <input type="file" id="cropFileInput" accept="image/*">
      <svg class="cu-icon" viewBox="0 0 48 48" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 36l8-8"/><path d="M14 36l14-14"/><path d="M28 36l8-8"/><rect x="6" y="6" width="36" height="36" rx="3" stroke-dasharray="4 3"/><circle cx="6" cy="6" r="3" fill="currentColor" opacity=".5"/><circle cx="42" cy="6" r="3" fill="currentColor" opacity=".5"/><circle cx="42" cy="42" r="3" fill="currentColor" opacity=".5"/><circle cx="6" cy="42" r="3" fill="currentColor" opacity=".5"/></svg>
      <h3>选择一张证件照片进行透视裁剪</h3>
      <p class="hint">上传后可手动拖拽 4 个角点精确定位证件边缘</p>
    </div>

    <div class="crop-editor" id="cropEditor">
      <div class="crop-toolbar">
        <span class="crop-fname" id="cropFname"></span>
        <span class="crop-finfo" id="cropFinfo"></span>
        <span class="crop-spacer"></span>
        <button class="btn btn-sm" id="cropChangeBtn">更换文件</button>
      </div>
      <div class="crop-canvas-wrap" id="cropWrap">
        <canvas id="cropCanvas"></canvas>
        <canvas id="magCanvas" class="magnifier" width="130" height="130"></canvas>
      </div>
      <div class="crop-info">
        <span>拖拽角点调整 · 方向键微调 · Shift+方向键快速移动</span>
        <span class="crop-coord" id="cropCoord"></span>
      </div>
      <div class="crop-actions">
        <button class="btn" id="cropDetectBtn">
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
          重新检测
        </button>
        <button class="btn" id="cropResetBtn">重置角点</button>
        <div class="expand-ctrl">
          <label>扩大</label>
          <input type="number" id="expandPx" value="20" min="0" max="200" step="10">
          <label>px</label>
          <button class="btn btn-sm" id="expandBtn">四边扩展</button>
        </div>
        <span class="crop-spacer2"></span>
        <button class="btn btn-p" id="cropExecBtn">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
          执行裁切
        </button>
      </div>
    </div>

    <div class="crop-result" id="cropResult">
      <h3>裁切完成</h3>
      <div class="cr-preview"><img id="crImg"></div>
      <div class="cr-info" id="crInfo"></div>
      <div class="cr-size-ctrl">
        <label>调整尺寸:</label>
        <button class="btn btn-sm" id="crSize50Btn">50%</button>
        <button class="btn btn-sm" id="crSize75Btn">75%</button>
        <button class="btn btn-sm on" id="crSize100Btn">100%</button>
        <button class="btn btn-sm" id="crSize150Btn">150%</button>
        <button class="btn btn-sm" id="crSize200Btn">200%</button>
      </div>
      <div class="cr-actions">
        <button class="btn" id="crBackBtn">返回调整</button>
        <button class="btn" id="crNewBtn">裁下一张</button>
        <button class="btn btn-p" id="crAddMergeBtn">添加到合并列表</button>
        <a class="btn btn-accent" id="crDlBtn" download>
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
          下载结果
        </a>
      </div>
    </div>

    <div class="merge-panel" id="mergePanel">
      <div class="merge-header">
        <span class="merge-title">待合并图片 (<span id="mergeCount">0</span>张)</span>
        <button class="btn-link" id="mergeClearBtn">清空列表</button>
      </div>
      <div class="merge-list" id="mergeList"></div>
      <div class="merge-actions">
        <button class="btn" id="mergePreviewBtn" disabled>预览</button>
        <button class="btn btn-accent" id="mergeA4Btn" disabled>
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
          下载 JPG
        </button>
        <button class="btn btn-p" id="mergePdfBtn" disabled>
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
          下载 PDF
        </button>
        <button class="btn" id="mergePrintBtn" disabled>
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><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"/><rect x="6" y="14" width="12" height="8"/></svg>
          打印
        </button>
      </div>
    </div>

    <div class="preview-modal" id="previewModal">
      <div class="preview-content">
        <div class="preview-header">
          <span>A4 拼版预览</span>
          <button class="btn-link" id="closePreviewBtn">关闭</button>
        </div>
        <div class="preview-pages" id="previewPages"></div>
      </div>
    </div>
  </div>

  <footer class="footer">拍摄建议:深色纯色背景 · 光线均匀 · 证件完整入镜 · 避免反光</footer>
</div>

<div class="toast" id="toast"></div>

<script>
(function(){
/* ═══════════════════════════════════════════
   共用工具
   ═══════════════════════════════════════════ */
const $=id=>document.getElementById(id);
function fmtSz(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB'}
function toast(m,t){const el=$('toast');el.textContent=m;el.className='toast show '+(t||'');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),3200)}
function segVal(c){const a=c.querySelector('.seg-b.on');return a?a.dataset.v:''}
function initSeg(c){c.querySelectorAll('.seg-b').forEach(b=>b.addEventListener('click',()=>{c.querySelectorAll('.seg-b').forEach(x=>x.classList.remove('on'));b.classList.add('on')}))}

/* ═══════════════════════════════════════════
   Tab 切换
   ═══════════════════════════════════════════ */
document.querySelectorAll('.tab-btn').forEach(t=>t.addEventListener('click',()=>{
  document.querySelectorAll('.tab-btn').forEach(x=>x.classList.remove('active'));
  document.querySelectorAll('.tab-panel').forEach(x=>x.classList.remove('active'));
  t.classList.add('active');
  $(t.dataset.tab+'Panel').classList.add('active');
  if(t.dataset.tab==='crop') cropEditor.resize();
}));

/* ═══════════════════════════════════════════
   批量处理
   ═══════════════════════════════════════════ */
let bFiles=[], bResults=[];

document.addEventListener('DOMContentLoaded',()=>{
  initSeg($('dpiSeg'));initSeg($('laySeg'));
  initSeg($('cropDpiSeg'));
  const dz=$('dropZone');
  ['dragenter','dragover'].forEach(e=>dz.addEventListener(e,ev=>{ev.preventDefault();dz.classList.add('drag-over')}));
  ['dragleave','drop'].forEach(e=>dz.addEventListener(e,ev=>{ev.preventDefault();dz.classList.remove('drag-over')}));
  dz.addEventListener('drop',ev=>bAdd(ev.dataTransfer.files));
  $('fileInput').addEventListener('change',ev=>{bAdd(ev.target.files);ev.target.value=''});
  $('clearBtn').addEventListener('click',()=>{bFiles=[];bRender()});
  $('procBtn').addEventListener('click',bProcess);
  $('dlAllBtn').addEventListener('click',bDlAll);
});

function bAdd(list){for(const f of list)if(f.type.startsWith('image/'))bFiles.push(f);bRender()}
function bRemove(i){bFiles.splice(i,1);bRender()}
function bRender(){
  const sec=$('fsec'),bar=$('act'),dz=$('dropZone');
  if(!bFiles.length){sec.classList.remove('show');bar.classList.remove('show');dz.classList.remove('compact');dz.classList.add('full');return}
  dz.classList.remove('full');dz.classList.add('compact');sec.classList.add('show');bar.classList.add('show');
  $('fcount').textContent='已选择 '+bFiles.length+' 个文件';
  const list=$('flist');list.innerHTML='';
  bFiles.forEach((f,i)=>{const d=document.createElement('div');d.className='fi';d.style.animationDelay=(i*.04)+'s';
    d.innerHTML='<img src="'+URL.createObjectURL(f)+'"><button class="fx" data-i="'+i+'">&times;</button><div class="fn">'+f.name+'</div><div class="fs">'+fmtSz(f.size)+'</div>';list.appendChild(d)});
  list.onclick=ev=>{const b=ev.target.closest('.fx');if(b)bRemove(+b.dataset.i)};
}

async function bProcess(){
  if(!bFiles.length)return;
  const btn=$('procBtn'),p=$('proc');btn.disabled=true;btn.textContent='处理中...';p.classList.add('show');$('results').classList.remove('show');
  const fd=new FormData();bFiles.forEach(f=>fd.append('files',f));
  fd.append('doc_type',$('typeSel').value);fd.append('dpi',segVal($('dpiSeg')));
  fd.append('enhance',$('enhance').checked?'true':'false');fd.append('layout',segVal($('laySeg')));
  try{
    const r=await fetch('/api/process',{method:'POST',body:fd});if(!r.ok)throw new Error(r.status);
    const d=await r.json();bResults=d.results;bRenderResults(d);toast('完成:'+d.total+' 张,检测 '+d.detected+' 张','ok');
  }catch(e){toast('失败:'+e.message,'err')}finally{p.classList.remove('show');btn.disabled=false;btn.innerHTML='<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>开始批量处理'}
}

function bRenderResults(data){
  $('results').classList.add('show');$('stext').textContent='共 '+data.total+' 张 · 检测成功 '+data.detected+' 张';
  const g=$('rgrid');g.innerHTML='';
  data.results.forEach((r,i)=>{const c=document.createElement('div');c.className='rc';c.style.animationDelay=(i*.06)+'s';
    if(r.error){c.innerHTML='<div class="rce"><span class="ei">⚠</span><p>'+r.name+'</p><p class="em">'+r.error+'</p></div>'}
    else{const bc=r.type==='id_card'?'b-id':r.type==='household'?'b-hk':r.type==='birth_cert'?'b-bc':r.type==='driver_license'?'b-dl':r.type==='property_cert'?'b-pc':r.type==='passport'?'b-pp':r.type==='bank_card'?'b-bk':r.type==='a4'?'b-a4':'b-unk';
      c.innerHTML='<div class="rci"><img src="'+r.thumb+'"><span class="rcb '+bc+'">'+r.type_name+'</span></div><div class="rcf"><div class="rcr"><span class="rcs"><span class="dot '+(r.detected?'dot-ok':'dot-w')+'"></span>'+(r.detected?'自动检测':'兜底裁切')+'</span><span class="rcm">'+r.w+'×'+r.h+' · '+r.dpi+'DPI</span></div><div class="rcd">'+r.type_desc+'</div><div class="rca"><a href="/api/dl/'+r.id+'" download class="btn btn-sm">下载</a></div></div>'}
    g.appendChild(c)});
  $('results').scrollIntoView({behavior:'smooth',block:'start'});
}

async function bDlAll(){
  const ids=bResults.filter(r=>r.id).map(r=>r.id);if(!ids.length){toast('无可下载文件','err');return}
  try{const r=await fetch('/api/dl-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});if(!r.ok)throw new Error(r.status);
    const a=document.createElement('a');a.href=URL.createObjectURL(await r.blob());a.download='证件裁切_批量处理.zip';a.click();toast('ZIP 已开始下载','ok')}catch(e){toast(e.message,'err')}
}

/* ═══════════════════════════════════════════
   透视裁剪编辑器
   ═══════════════════════════════════════════ */
const cropEditor={
  canvas:null,ctx:null,wrap:null,mag:null,magCtx:null,
  img:null,imgW:0,imgH:0,points:[],
  scale:1,ox:0,oy:0,cssW:0,cssH:0,
  activeIdx:-1,hoverIdx:-1,prevHover:-1,isDragging:false,
  R:14,file:null,fileData:null,
  detectedPts:null,

  init(){
    this.canvas=$('cropCanvas');this.ctx=this.canvas.getContext('2d');
    this.wrap=$('cropWrap');this.mag=$('magCanvas');this.magCtx=this.mag.getContext('2d');
    this.resize();

    // 事件
    this.canvas.addEventListener('mousedown',e=>this.onDown(e));
    this.canvas.addEventListener('mousemove',e=>this.onMove(e));
    window.addEventListener('mouseup',e=>this.onUp(e));
    this.canvas.addEventListener('touchstart',e=>{e.preventDefault();this.onDown(e)},{passive:false});
    this.canvas.addEventListener('touchmove',e=>{e.preventDefault();this.onMove(e)},{passive:false});
    window.addEventListener('touchend',e=>this.onUp(e));
    window.addEventListener('keydown',e=>this.onKey(e));
    window.addEventListener('resize',()=>this.resize());

    // 按钮
    $('cropDetectBtn').addEventListener('click',()=>this.autoDetect());
    $('cropResetBtn').addEventListener('click',()=>{this.resetPts();this.draw()});
    $('expandBtn').addEventListener('click',()=>this.expandPoints());
    $('cropExecBtn').addEventListener('click',()=>this.execute());
    $('cropChangeBtn').addEventListener('click',()=>this.changeFile());
    $('crBackBtn').addEventListener('click',()=>{ $('cropResult').classList.remove('show');$('cropEditor').classList.add('show') });
    $('crNewBtn').addEventListener('click',()=>this.changeFile());

    // 上传
    const cu=$('cropUpload');
    ['dragenter','dragover'].forEach(e=>cu.addEventListener(e,ev=>{ev.preventDefault();cu.classList.add('drag-over')}));
    ['dragleave','drop'].forEach(e=>cu.addEventListener(e,ev=>{ev.preventDefault();cu.classList.remove('drag-over')}));
    cu.addEventListener('drop',ev=>{if(ev.dataTransfer.files[0])this.loadFile(ev.dataTransfer.files[0])});
    $('cropFileInput').addEventListener('change',ev=>{if(ev.target.files[0])this.loadFile(ev.target.files[0]);ev.target.value=''});
  },

  resize(){
    const r=this.wrap.getBoundingClientRect();
    this.cssW=Math.floor(r.width);this.cssH=Math.floor(r.height);
    this.canvas.width=this.cssW;this.canvas.height=this.cssH;
    if(this.img)this.fitImage();
    this.draw();
  },

  loadFile(file){
    this.file=file;
    const reader=new FileReader();
    reader.onload=e=>{
      const img=new Image();
      img.onload=()=>{
        this.img=img;this.imgW=img.width;this.imgH=img.height;
        $('cropFname').textContent=file.name;
        $('cropFinfo').textContent=img.width+'×'+img.height+' · '+fmtSz(file.size);
        this.fitImage();
        $('cropUpload').style.display='none';
        $('cropEditor').classList.add('show');
        $('cropResult').classList.remove('show');
        this.autoDetect();
      };
      img.src=e.target.result;
      this.fileData=e.target.result;
    };
    reader.readAsDataURL(file);
  },

  changeFile(){
    $('cropEditor').classList.remove('show');
    $('cropResult').classList.remove('show');
    $('cropUpload').style.display='';
    this.img=null;this.file=null;this.fileData=null;this.draw();
  },

  fitImage(){
    if(!this.img)return;
    const pad=16;
    this.scale=Math.min((this.cssW-2*pad)/this.imgW,(this.cssH-2*pad)/this.imgH);
    this.ox=(this.cssW-this.imgW*this.scale)/2;
    this.oy=(this.cssH-this.imgH*this.scale)/2;
  },

  toCanvas(ix,iy){return{x:ix*this.scale+this.ox,y:iy*this.scale+this.oy}},
  toImage(cx,cy){return{x:(cx-this.ox)/this.scale,y:(cy-this.oy)/this.scale}},
  clamp(p){p.x=Math.max(0,Math.min(this.imgW,p.x));p.y=Math.max(0,Math.min(this.imgH,p.y))},

  resetPts(){
    const w=this.imgW,h=this.imgH,m=0.10;
    this.points=[{x:w*m,y:h*m},{x:w*(1-m),y:h*m},{x:w*(1-m),y:h*(1-m)},{x:w*m,y:h*(1-m)}];
  },

  expandPoints(){
    if(!this.img||this.points.length!==4)return;
    const px=parseInt($('expandPx').value)||0;
    if(px<=0)return;
    const cx=(this.points[0].x+this.points[1].x+this.points[2].x+this.points[3].x)/4;
    const cy=(this.points[0].y+this.points[1].y+this.points[2].y+this.points[3].y)/4;
    this.points=this.points.map(p=>{
      const dx=p.x-cx,dy=p.y-cy;
      const dist=Math.sqrt(dx*dx+dy*dy);
      if(dist===0)return p;
      const scale=(dist+px)/dist;
      return{x:cx+dx*scale,y:cy+dy*scale};
    });
    this.points.forEach(p=>this.clamp(p));
    this.draw();
    toast('已向四边扩展 '+px+' 像素','ok');
  },

  async autoDetect(){
    if(!this.file)return;
    toast('正在检测文档边缘...','');
    const fd=new FormData();fd.append('file',this.file);
    try{
      const r=await fetch('/api/detect',{method:'POST',body:fd});
      if(!r.ok)throw new Error(r.status);
      const d=await r.json();
      this.points=d.points.map(p=>({x:p[0],y:p[1]}));
      this.detectedPts=JSON.parse(JSON.stringify(this.points));
      this.activeIdx=-1;
      this.draw();
      if(d.detected)toast('边缘检测成功,可拖拽角点微调','ok');
      else toast('未检测到明显边缘,已使用默认区域','err');
    }catch(e){this.resetPts();this.draw();toast('检测失败,使用默认区域','err')}
  },

  /* ── 绘制 ─────────────────────────────── */
  draw(){
    const ctx=this.ctx,cw=this.cssW,ch=this.cssH;
    ctx.clearRect(0,0,cw,ch);
    ctx.fillStyle='#080c14';ctx.fillRect(0,0,cw,ch);
    if(!this.img)return;

    // 图片
    ctx.drawImage(this.img,this.ox,this.oy,this.imgW*this.scale,this.imgH*this.scale);

    const pts=this.points.map(p=>this.toCanvas(p.x,p.y));

    // 暗化遮罩
    ctx.save();ctx.fillStyle='rgba(0,0,0,.55)';
    ctx.beginPath();ctx.rect(0,0,cw,ch);
    ctx.moveTo(pts[0].x,pts[0].y);
    for(let i=1;i<4;i++)ctx.lineTo(pts[i].x,pts[i].y);
    ctx.closePath();ctx.fill('evenodd');ctx.restore();

    // 四边形边框
    ctx.strokeStyle='#c8982a';ctx.lineWidth=2;ctx.setLineDash([]);
    ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);
    for(let i=1;i<4;i++)ctx.lineTo(pts[i].x,pts[i].y);
    ctx.closePath();ctx.stroke();

    // 3×3 辅助线
    ctx.strokeStyle='rgba(200,152,42,.15)';ctx.lineWidth=1;
    for(let i=1;i<=2;i++){
      const t=i/3;
      ctx.beginPath();
      ctx.moveTo(pts[0].x+(pts[3].x-pts[0].x)*t,pts[0].y+(pts[3].y-pts[0].y)*t);
      ctx.lineTo(pts[1].x+(pts[2].x-pts[1].x)*t,pts[1].y+(pts[2].y-pts[1].y)*t);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(pts[0].x+(pts[1].x-pts[0].x)*t,pts[0].y+(pts[1].y-pts[0].y)*t);
      ctx.lineTo(pts[3].x+(pts[2].x-pts[3].x)*t,pts[3].y+(pts[2].y-pts[3].y)*t);
      ctx.stroke();
    }

    // 角点手柄
    pts.forEach((p,i)=>{
      const act=i===this.activeIdx,hov=i===this.hoverIdx;
      const r=act?this.R+3:this.R;
      ctx.save();
      ctx.shadowColor='rgba(0,0,0,.45)';ctx.shadowBlur=10;ctx.shadowOffsetY=2;
      ctx.beginPath();ctx.arc(p.x,p.y,r,0,Math.PI*2);
      ctx.fillStyle=act?'#ddb43a':hov?'#c8982a':'rgba(200,152,42,.85)';
      ctx.fill();ctx.strokeStyle='#fff';ctx.lineWidth=2.5;ctx.stroke();
      ctx.restore();
      // 编号
      ctx.fillStyle='#0a0a0a';ctx.font='bold 11px DM Mono,monospace';
      ctx.textAlign='center';ctx.textBaseline='middle';
      ctx.fillText(String(i+1),p.x,p.y+1);
    });
  },

  /* ── 交互 ─────────────────────────────── */
  getPos(e){
    const r=this.canvas.getBoundingClientRect();
    if(e.touches&&e.touches.length)return{x:e.touches[0].clientX-r.left,y:e.touches[0].clientY-r.top};
    return{x:e.clientX-r.left,y:e.clientY-r.top};
  },

  hitTest(cx,cy){
    for(let i=0;i<4;i++){
      const p=this.toCanvas(this.points[i].x,this.points[i].y);
      if((cx-p.x)**2+(cy-p.y)**2<=(this.R+6)**2)return i;
    }
    return-1;
  },

  onDown(e){
    if(!this.img)return;
    const pos=this.getPos(e),idx=this.hitTest(pos.x,pos.y);
    if(idx>=0){this.activeIdx=idx;this.isDragging=true;this.canvas.style.cursor='grabbing';this.draw();this.showMag(pos.x,pos.y,idx)}
  },

  onMove(e){
    if(!this.img)return;
    const pos=this.getPos(e);
    if(this.isDragging&&this.activeIdx>=0){
      const ip=this.toImage(pos.x,pos.y);this.clamp(ip);
      this.points[this.activeIdx]=ip;this.draw();this.showMag(pos.x,pos.y,this.activeIdx);
      $('cropCoord').textContent='X:'+Math.round(ip.x)+' Y:'+Math.round(ip.y);
      return;
    }
    const idx=this.hitTest(pos.x,pos.y);
    this.canvas.style.cursor=idx>=0?'grab':'crosshair';
    this.hoverIdx=idx;
    if(idx!==this.prevHover){this.draw();this.prevHover=idx}
  },

  onUp(){
    if(this.isDragging){this.isDragging=false;this.hideMag();this.canvas.style.cursor='crosshair'}
  },

  onKey(e){
    if(this.activeIdx<0||!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return;
    e.preventDefault();
    const step=e.shiftKey?10:1,p=this.points[this.activeIdx];
    if(e.key==='ArrowLeft')p.x-=step;if(e.key==='ArrowRight')p.x+=step;
    if(e.key==='ArrowUp')p.y-=step;if(e.key==='ArrowDown')p.y+=step;
    this.clamp(p);this.draw();
    $('cropCoord').textContent='X:'+Math.round(p.x)+' Y:'+Math.round(p.y);
  },

  /* ── 放大镜 ───────────────────────────── */
  showMag(cx,cy,idx){
    const mag=this.mag,ctx=this.magCtx,sz=130,half=65,zoom=5;
    // 位置: 角点在上方时放下面,反之亦然
    let mx=cx-half,my=cy<200?cy+35:cy-sz-35;
    mx=Math.max(4,Math.min(this.cssW-sz-4,mx));my=Math.max(4,Math.min(this.cssH-sz-4,my));
    mag.style.display='block';mag.style.left=mx+'px';mag.style.top=my+'px';

    const ip=this.points[idx],srcR=sz/(this.scale*zoom*2);
    ctx.clearRect(0,0,sz,sz);
    ctx.save();ctx.beginPath();ctx.arc(half,half,half-3,0,Math.PI*2);ctx.clip();

    // 放大的图片
    ctx.drawImage(this.img,ip.x-srcR,ip.y-srcR,srcR*2,srcR*2,0,0,sz,sz);

    // 网格
    ctx.strokeStyle='rgba(200,152,42,.12)';ctx.lineWidth=.5;
    for(let i=1;i<8;i++){const g=i*sz/8;ctx.beginPath();ctx.moveTo(g,0);ctx.lineTo(g,sz);ctx.moveTo(0,g);ctx.lineTo(sz,g);ctx.stroke()}

    // 十字线
    ctx.strokeStyle='rgba(200,152,42,.5)';ctx.lineWidth=1;
    ctx.beginPath();ctx.moveTo(half,0);ctx.lineTo(half,sz);ctx.moveTo(0,half);ctx.lineTo(sz,half);ctx.stroke();

    // 中心点
    ctx.fillStyle='#c8982a';ctx.beginPath();ctx.arc(half,half,3,0,Math.PI*2);ctx.fill();

    ctx.restore();
    // 圆形边框
    ctx.strokeStyle='#c8982a';ctx.lineWidth=2.5;
    ctx.beginPath();ctx.arc(half,half,half-1,0,Math.PI*2);ctx.stroke();
  },

  hideMag(){this.mag.style.display='none'},

  /* ── 执行裁切 ─────────────────────────── */
  async execute(){
    if(!this.file)return;
    const btn=$('cropExecBtn');btn.disabled=true;btn.textContent='裁切中...';
    const fd=new FormData();fd.append('file',this.file);
    fd.append('points',JSON.stringify(this.points.map(p=>[p.x,p.y])));
    fd.append('doc_type',$('cropTypeSel').value);fd.append('dpi',segVal($('cropDpiSeg')));
    fd.append('enhance',$('cropEnhance').checked?'true':'false');
    try{
      const r=await fetch('/api/crop',{method:'POST',body:fd});
      if(!r.ok)throw new Error(r.status);
      const d=await r.json();
      this.currentResult=d;
      this.currentScale=100;
      this.updateResultUI();
      $('cropEditor').classList.remove('show');
      $('cropResult').classList.add('show');
      toast('裁切完成','ok');
    }catch(e){toast('裁切失败:'+e.message,'err')}
    finally{btn.disabled=false;btn.innerHTML='<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>执行裁切'}
  },
  currentScale:100,
  updateResultUI(){
    if(!this.currentResult)return;
    $('crImg').src=this.currentResult.thumb;
    $('crInfo').innerHTML='<span>'+this.currentResult.type_name+'</span><span>'+this.currentResult.w+' × '+this.currentResult.h+' px</span><span>'+this.currentResult.dpi+' DPI</span>';
    $('crDlBtn').href='/api/dl/'+this.currentResult.id;
    this.updateScaleButtons();
  },
  updateScaleButtons(){
    const scales=[50,75,100,150,200];
    scales.forEach(s=>{
      const btn=$('crSize'+s+'Btn');
      if(btn)btn.classList.toggle('on',s===this.currentScale);
    });
  },
  async resizeImage(scale){
    if(!this.currentResult)return;
    if(scale===this.currentScale)return;
    toast('正在调整尺寸...','');
    try{
      const r=await fetch('/api/resize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:this.currentResult.id,scale})});
      if(!r.ok)throw new Error(r.status);
      const d=await r.json();
      this.currentResult={...this.currentResult,id:d.id,w:d.w,h:d.h,thumb:d.thumb};
      this.currentScale=scale;
      this.updateResultUI();
      toast('尺寸已调整为 '+scale+'%','ok');
    }catch(e){toast('调整失败:'+e.message,'err')}
  }
};

let mergeList=[], mergePreviewData=null;
function mergeRender(){
  const panel=$('mergePanel'),list=$('mergeList'),btn=$('mergeA4Btn'),pbtn=$('mergePreviewBtn'),pdfbtn=$('mergePdfBtn'),printbtn=$('mergePrintBtn');
  if(!mergeList.length){panel.classList.remove('show');btn.disabled=true;pbtn.disabled=true;pdfbtn.disabled=true;printbtn.disabled=true;return}
  panel.classList.add('show');btn.disabled=false;pbtn.disabled=false;pdfbtn.disabled=false;printbtn.disabled=false;
  $('mergeCount').textContent=mergeList.length;
  list.innerHTML='';
  mergeList.forEach((item,i)=>{
    const d=document.createElement('div');d.className='merge-item';
    d.innerHTML='<img src="'+item.thumb+'"><button class="mi-del" data-i="'+i+'">&times;</button>';
    list.appendChild(d);
  });
  list.onclick=ev=>{const b=ev.target.closest('.mi-del');if(b){mergeList.splice(+b.dataset.i,1);mergeRender()}};
}
function mergeAdd(){
  if(!cropEditor.currentResult)return;
  mergeList.push({...cropEditor.currentResult});
  mergeRender();
  toast('已添加到合并列表','ok');
}
async function mergePreview(){
  if(mergeList.length<1){toast('请先添加图片到合并列表','err');return}
  const ids=mergeList.map(x=>x.id);
  try{
    const r=await fetch('/api/merge-a4',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
    if(!r.ok)throw new Error(r.status);
    const d=await r.json();
    mergePreviewData=d;
    const pages=$('previewPages');pages.innerHTML='';
    d.results.forEach((item,i)=>{
      pages.innerHTML+='<img src="'+item.thumb+'"><div class="page-label">第 '+(i+1)+' 页 · '+item.w+'×'+item.h+'</div>';
    });
    $('previewModal').classList.add('show');
  }catch(e){toast('预览失败:'+e.message,'err')}
}
function closePreview(){$('previewModal').classList.remove('show')}
async function mergeA4(){
  if(mergeList.length<1){toast('请先添加图片到合并列表','err');return}
  const ids=mergeList.map(x=>x.id);
  try{
    const r=await fetch('/api/merge-a4',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
    if(!r.ok)throw new Error(r.status);
    const d=await r.json();
    if(d.results&&d.results[0]){
      const a=document.createElement('a');
      a.href='/api/dl/'+d.results[0].id;
      a.download=d.results[0].name;
      a.click();
      toast('JPG 已生成并开始下载','ok');
    }
  }catch(e){toast('合并失败:'+e.message,'err')}
}
async function mergePdf(){
  if(mergeList.length<1){toast('请先添加图片到合并列表','err');return}
  const ids=mergeList.map(x=>x.id);
  try{
    const r=await fetch('/api/merge-a4',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,format:'pdf'})});
    if(!r.ok)throw new Error(r.status);
    const d=await r.json();
    if(d.pdf_id){
      const a=document.createElement('a');
      a.href='/api/dl-pdf/'+d.pdf_id;
      a.download='证件裁切_A4拼版.pdf';
      a.click();
      toast('PDF 已生成并开始下载','ok');
    }
  }catch(e){toast('PDF生成失败:'+e.message,'err')}
}
async function mergePrint(){
  if(mergeList.length<1){toast('请先添加图片到合并列表','err');return}
  const ids=mergeList.map(x=>x.id);
  toast('正在生成打印页面...','');
  try{
    const r=await fetch('/api/merge-a4',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});
    if(!r.ok)throw new Error(r.status);
    const d=await r.json();
    if(d.results&&d.results.length){
      const printWin=window.open('','_blank');
      let html='<!DOCTYPE html><html><head><title>打印 - A4拼版</title><style>@page{size:A4;margin:0}body{margin:0;padding:0}img{max-width:100%;height:auto;page-break-after:always}@media print{img{page-break-after:always}}</style></head><body>';
      for(const item of d.results){
        html+='<img src="/api/dl/'+item.id+'" style="width:100%">';
      }
      html+='</body></html>';
      printWin.document.write(html);
      printWin.document.close();
      printWin.onload=function(){printWin.print()};
      toast('打印页面已打开','ok');
    }
  }catch(e){toast('打印失败:'+e.message,'err')}
}
function mergeClear(){mergeList=[];mergeRender()}

document.addEventListener('DOMContentLoaded',()=>{
  cropEditor.init();
  $('crAddMergeBtn').addEventListener('click',mergeAdd);
  $('crSize50Btn').addEventListener('click',()=>cropEditor.resizeImage(50));
  $('crSize75Btn').addEventListener('click',()=>cropEditor.resizeImage(75));
  $('crSize100Btn').addEventListener('click',()=>cropEditor.resizeImage(100));
  $('crSize150Btn').addEventListener('click',()=>cropEditor.resizeImage(150));
  $('crSize200Btn').addEventListener('click',()=>cropEditor.resizeImage(200));
  $('mergePreviewBtn').addEventListener('click',mergePreview);
  $('mergeA4Btn').addEventListener('click',mergeA4);
  $('mergePdfBtn').addEventListener('click',mergePdf);
  $('mergePrintBtn').addEventListener('click',mergePrint);
  $('mergeClearBtn').addEventListener('click',mergeClear);
  $('closePreviewBtn').addEventListener('click',closePreview);
  $('previewModal').addEventListener('click',ev=>{if(ev.target===$('previewModal'))closePreview()});
});
})();
</script>
</body>
</html>"""

# ════════════════════════════════════════════════════════════════
#  启动
# ════════════════════════════════════════════════════════════════

if __name__ == "__main__":
    print("\n  证件裁切拼版工具 v3")
    print("  ───────────────────")
    print("  批量处理 + 手动透视裁剪")
    print("  身份证: 85.6mm × 54.0mm")
    print("  户口本: 148mm × 105mm")
    print("  出生证: 130mm × 180mm")
    print("  驾驶证: 85.6mm × 54.0mm")
    print("  房产证: 260mm × 180mm")
    print("  护照: 125mm × 88mm")
    print("  银行卡: 85.6mm × 53.98mm")
    print("  访问: http://localhost:8000\n")
    uvicorn.run(app, host="0.0.0.0", port=8000)
相关推荐
2401_833033621 小时前
golang如何实现MQTT主题通配符路由_golang MQTT主题通配符路由实现策略
jvm·数据库·python
AI精钢1 小时前
修复 AI Gateway 图片 MIME 类型错误:用魔数检测替代扩展名猜测
网络·人工智能·python·gateway·aigc
m0_596749092 小时前
Golang怎么实现方法集与接口的匹配_Golang如何理解值类型和指针类型实现接口的区别【详解】
jvm·数据库·python
隔壁小红馆2 小时前
隐藏odoo特有
python·odoo17·odoo18
lifewange3 小时前
pytest 找不到文件?直接在 pytest.ini 配置根目录 + 路径(最简单方案)
开发语言·python·pytest
yuanpan3 小时前
Python 桌面 GUI 入门开发:从 tkinter 窗口到简易记事本
开发语言·python
川石课堂软件测试3 小时前
软件测试|常见面试题整理
数据库·python·jmeter·mysql·appium·postman·prometheus
做个文艺程序员3 小时前
Multi-Agent 系统实战:用 Python + LangGraph 搭建多智能体协作工作流
python·多智能体·langgraph·multi-agent
bang冰冰3 小时前
Trae工具安装和使用教程(新手零基础入门,全程无坑)
java·人工智能·python