Commit f1b8581d by 文周繁

Initial commit

parents
# Python
backend/.venv/
backend/.venv310/
backend/__pycache__/
backend/**/*.pyc
backend/.pytest_cache/
# Node
frontend/node_modules/
frontend/dist/
frontend/.vite/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.log
# 文档去水印工具
一个基于 Vue 3 + Flask 的本地文档去水印网站,支持自动识别并删除 DOC、DOCX、PDF 文件中的文字与图片水印。
## 功能特性
- 支持格式:`doc``docx``pdf`(扩展名大小写不敏感)
- 全自动水印识别,无需用户输入水印内容
- 支持文字水印与图片水印
- DOC 文件通过 LibreOffice 转换为 DOCX 处理后转回 DOC
- 处理完成后立即清理临时文件,服务器不保留任何上传文件或结果文件
- 结果文件名自动加 `_` 前缀,例如 `报告.docx``_报告.docx`
- 上传文件大小限制:20MB
## 技术栈
- 前端:Vue 3 + Vite
- 后端:Python 3.8+ + Flask
- 依赖库:
- `pikepdf` / `pdfplumber`:PDF 处理
- `lxml` / `python-docx`:DOCX 处理
- `Flask-CORS`:跨域支持
## 目录结构
```
WaterMarkRemoveTool/
├── backend/ # Flask 后端
│ ├── app.py # Flask 应用入口
│ ├── config.py # 配置(格式、大小、关键词等)
│ ├── requirements.txt # Python 依赖
│ ├── handlers/ # 各格式处理器
│ │ ├── docx_handler.py # DOCX 去水印
│ │ ├── doc_handler.py # DOC 去水印(调用 LibreOffice)
│ │ └── pdf_handler.py # PDF 去水印
│ └── utils/ # 工具函数
│ ├── file_utils.py # 临时目录、命名、清理
│ └── watermark_detector.py # 水印特征识别
└── frontend/ # Vue 3 前端
├── index.html
├── package.json
├── vite.config.js # 开发代理配置
└── src/
├── App.vue
├── main.js
├── api/watermark.js # 后端 API 调用
└── components/FileUploader.vue
```
## 环境要求
- Python 3.8+
- Node.js 18+
- LibreOffice(用于 DOC 转换,安装后确保 `soffice` 命令可用)
### 验证 LibreOffice
```bash
soffice --version
```
## 安装与运行
### 1. 克隆/进入项目目录
```bash
cd /home/hunter/Desktop/WaterMarkRemoveTool
```
### 2. 启动后端
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python app.py
```
后端默认运行在 `http://127.0.0.1:5000`
### 3. 启动前端(开发环境)
在另一个终端中:
```bash
cd frontend
npm install
npm run dev
```
前端默认运行在 `http://127.0.0.1:5173`,并通过 Vite 代理将 `/api` 请求转发到后端。
打开浏览器访问 `http://127.0.0.1:5173` 即可使用。
## 生产部署
```bash
cd frontend
npm run build
cd ../backend
source venv/bin/activate
python app.py
```
生产模式下,Flask 会直接提供 `frontend/dist` 目录中的静态文件。
## 主要 API
### `POST /api/remove-watermark`
上传文件并去除水印。
- Content-Type: `multipart/form-data`
- Field: `file`
- 成功:返回 `200` + 处理后的文件二进制流
- 响应头:`Content-Disposition: attachment; filename="_原始文件名"`
- 失败:返回 `400` JSON
- `{ "message": "错误信息" }`
### `GET /api/health`
健康检查。
- 返回:`{ "status": "ok" }`
## 去水印策略
### DOCX
1. 使用 `zipfile` 解压 DOCX 文件。
2. 解析 `word/header*.xml``word/footer*.xml`
3. 删除包含水印关键词的段落。
4. 删除 VML/DrawingML 水印 shape。
5. 删除孤立的图片水印段落。
6. 清理被引用但已删除的媒体文件及 relationships。
7. 重新打包为 DOCX。
### DOC
1. 使用 LibreOffice 将 DOC 转换为 DOCX。
2. 对 DOCX 执行上述去水印逻辑。
3. 再次使用 LibreOffice 将处理后的 DOCX 转回 DOC。
> 注意:DOC 转换过程可能轻微改变排版、字体等内容。
### PDF
1. 删除 `/Subtype /Watermark` 的 annotation。
2. 关闭或删除 Optional Content(图层)水印。
3. 使用 `pdfplumber` 识别文本水印,并在内容流中清除对应文本。
4. 删除在每一页重复出现或带有透明掩膜的图片 XObject。
## 配置说明
可在 `backend/config.py` 中修改:
- `ALLOWED_EXTENSIONS`:允许上传的文件扩展名
- `MAX_CONTENT_LENGTH`:最大上传文件大小(默认 20MB)
- `SOFFICE_TIMEOUT`:LibreOffice 转换超时时间(默认 120 秒)
- `WATERMARK_KEYWORDS`:用于启发式识别水印的关键词列表
## 限制与说明
- 不保证 100% 去除任意水印,尤其是复杂 PDF 水印或自定义图片水印。
- 全自动图片水印识别基于启发式规则,可能误删正文图片或漏删水印。
- 直接修改 PDF 内容流可能导致某些复杂 PDF 显示异常。
- DOC 文件经 LibreOffice 转换后排版可能存在细微变化。
- 不做用户系统、历史记录、文件持久化。
## 常见问题
### 上传 DOC 文件提示 LibreOffice 未安装
请确保系统已安装 LibreOffice:
```bash
sudo apt update
sudo apt install libreoffice
```
安装后验证:
```bash
soffice --version
```
### 前端无法连接后端
开发模式下,Vite 已配置代理将 `/api` 转发到 `http://127.0.0.1:5000`。请确保后端已启动。
### 处理后的 PDF 显示异常
某些复杂 PDF 的内容流结构特殊,自动清除水印文本时可能误伤正文。建议先用简单 PDF 测试。
## 开发与调试
```bash
# 后端调试
cd backend
source venv/bin/activate
python app.py
# 前端调试
cd frontend
npm run dev
```
## License
本项目仅供学习和本地使用。
import os
import shutil
import traceback
from pathlib import Path
from flask import Flask, after_this_request, jsonify, request, send_file, send_from_directory
from flask_cors import CORS
from werkzeug.utils import secure_filename
from config import ALLOWED_EXTENSIONS, MAX_CONTENT_LENGTH
from handlers.doc_handler import remove_watermark as remove_doc_watermark
from handlers.docx_handler import remove_watermark as remove_docx_watermark
from handlers.pdf_handler import remove_watermark as remove_pdf_watermark
from utils.file_utils import (
cleanup_dir,
is_allowed_file,
make_temp_dir,
prefixed_output_name,
)
BASE_DIR = Path(__file__).resolve().parent
FRONTEND_DIST = BASE_DIR.parent / "frontend" / "dist"
app = Flask(__name__, static_folder=str(FRONTEND_DIST) if FRONTEND_DIST.exists() else None)
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
CORS(app, resources={r"/api/*": {"origins": "*"}})
@app.errorhandler(413)
def request_entity_too_large(error):
return jsonify({"message": "文件大小超过限制(最大 20MB)"}), 413
@app.route("/api/remove-watermark", methods=["POST"])
def remove_watermark():
temp_dir = None
try:
if "file" not in request.files:
return jsonify({"message": "未找到上传文件"}), 400
file = request.files["file"]
if not file or not file.filename:
return jsonify({"message": "未选择文件"}), 400
original_filename = file.filename
if not is_allowed_file(original_filename, ALLOWED_EXTENSIONS):
return jsonify({"message": "仅支持 doc、docx、pdf 格式"}), 400
temp_dir = make_temp_dir()
ext = original_filename.rsplit(".", 1)[1].lower()
safe_name = secure_filename(original_filename)
input_path = temp_dir / safe_name
file.save(str(input_path))
output_name = prefixed_output_name(original_filename)
output_path = temp_dir / secure_filename(output_name)
if ext == "docx":
remove_docx_watermark(input_path, output_path)
elif ext == "doc":
remove_doc_watermark(input_path, output_path)
elif ext == "pdf":
remove_pdf_watermark(input_path, output_path)
else:
return jsonify({"message": "不支持的文件格式"}), 400
if not output_path.exists():
return jsonify({"message": "处理失败,未生成输出文件"}), 500
@after_this_request
def cleanup(response):
if temp_dir and temp_dir.exists():
shutil.rmtree(temp_dir, ignore_errors=True)
return response
return send_file(
str(output_path),
as_attachment=True,
download_name=output_name,
mimetype="application/octet-stream",
)
except RuntimeError as exc:
if temp_dir:
cleanup_dir(temp_dir)
return jsonify({"message": str(exc)}), 500
except Exception as exc:
if temp_dir:
cleanup_dir(temp_dir)
traceback.print_exc()
return jsonify({"message": f"处理失败: {str(exc)}"}), 500
@app.route("/api/health", methods=["GET"])
def health():
return jsonify({"status": "ok"}), 200
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve_frontend(path):
"""生产环境:提供前端静态文件。"""
if not FRONTEND_DIST.exists():
return jsonify({"message": "前端资源未构建"}), 404
target = FRONTEND_DIST / path
if path and target.exists() and target.is_file():
return send_from_directory(FRONTEND_DIST, path)
return send_from_directory(FRONTEND_DIST, "index.html")
if __name__ == "__main__":
# 开发环境默认端口 5000
port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port, debug=True)
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
# 允许格式(扩展名大小写不敏感)
ALLOWED_EXTENSIONS = {"doc", "docx", "pdf"}
# 上传文件大小限制(字节)
MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20 MB
# LibreOffice 转换超时(秒)
SOFFICE_TIMEOUT = 120
# 常见水印关键词(用于启发式识别)
WATERMARK_KEYWORDS = {
"机密", "秘密", "绝密", "内部", "样稿", "样张", "草稿", "草案",
"draft", "confidential", "sample", "prototype", "internal",
" watermark", "watermark", "do not copy", "top secret",
}
# 水印常见透明度阈值
WATERMARK_ALPHA_THRESHOLD = 0.6
# 重复 Form XObject 水印检测阈值
REPEATED_FORM_MIN_PAGES = 2
REPEATED_FORM_MIN_COVERAGE = 0.90
REPEATED_FORM_MAX_BBOX_AREA_RATIO = 0.25
REPEATED_FORM_SMALL_BBOX_AREA_RATIO = 0.05
REPEATED_FORM_WATERMARK_SCORE_THRESHOLD = 3
import shutil
import subprocess
from pathlib import Path
from typing import List
from config import SOFFICE_TIMEOUT
from handlers.docx_handler import remove_watermark as remove_docx_watermark
def _run_soffice(args: List[str], timeout: int = SOFFICE_TIMEOUT) -> None:
"""调用 LibreOffice 无头模式。"""
cmd = ["soffice", "--headless"] + args
try:
subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
timeout=timeout,
)
except FileNotFoundError as exc:
raise RuntimeError("LibreOffice 未安装或未找到 soffice 命令") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError("LibreOffice 转换超时") from exc
except subprocess.CalledProcessError as exc:
err = exc.stderr.decode("utf-8", errors="ignore") if exc.stderr else str(exc)
raise RuntimeError(f"LibreOffice 转换失败: {err}") from exc
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""DOC 文件:先转 DOCX,去水印,再转回 DOC。"""
temp_dir = input_path.parent
docx_path = temp_dir / (input_path.stem + ".docx")
cleaned_docx = temp_dir / (input_path.stem + "_cleaned.docx")
# 1) DOC -> DOCX
_run_soffice([
"--convert-to", "docx",
"--outdir", str(temp_dir),
str(input_path),
])
if not docx_path.exists():
raise RuntimeError("DOC 转换为 DOCX 失败")
# 2) 去水印
remove_docx_watermark(docx_path, cleaned_docx)
# 3) DOCX -> DOC
_run_soffice([
"--convert-to", "doc",
"--outdir", str(temp_dir),
str(cleaned_docx),
])
# LibreOffice 转换后生成的文件名与 cleaned_docx 的 stem 相同
generated_doc = temp_dir / (cleaned_docx.stem + ".doc")
if not generated_doc.exists():
raise RuntimeError("DOCX 转换回 DOC 失败")
shutil.copy2(generated_doc, output_path)
import re
import shutil
import zipfile
from pathlib import Path
from typing import Set
from xml.etree import ElementTree as ET
from utils.watermark_detector import (
NSMAP,
gather_image_rels_in_element,
is_watermark_shape,
looks_like_watermark_text,
register_all_namespaces,
)
def _tag(name: str, prefix: str = "w") -> str:
return f"{{{NSMAP[prefix]}}}{name}"
def _register_namespaces_from_xml(xml_text: str) -> None:
"""从 XML 文本中提取 xmlns 声明并注册,避免写回时前缀被改写。"""
for match in re.finditer(r'xmlns:([a-zA-Z0-9_]+)="([^"]+)"', xml_text):
prefix, uri = match.groups()
ET.register_namespace(prefix, uri)
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""去除 DOCX 文件中的文字和图片水印。"""
register_all_namespaces()
work_dir = input_path.parent / "docx_extracted"
work_dir.mkdir(parents=True, exist_ok=True)
try:
with zipfile.ZipFile(input_path, "r") as zin:
zin.extractall(work_dir)
word_dir = work_dir / "word"
rels_dir = word_dir / "_rels"
media_dir = word_dir / "media"
# 处理 header/footer XML
target_files = []
if word_dir.exists():
for f in word_dir.iterdir():
if f.is_file() and f.name.lower().startswith(("header", "footer")) and f.suffix == ".xml":
target_files.append(f)
removed_rels: Set[str] = set()
for xml_file in target_files:
try:
xml_text = xml_file.read_text(encoding="utf-8")
_register_namespaces_from_xml(xml_text)
root = ET.fromstring(xml_text)
changed = False
# 1) 删除包含水印文字的段落 / shape
# 页眉页脚中的段落
for p in root.findall(".//w:p", NSMAP):
text_nodes = p.findall(".//w:t", NSMAP)
text = "".join(t.text or "" for t in text_nodes)
if looks_like_watermark_text(text):
# 收集该段落中引用的图片 rels
removed_rels |= gather_image_rels_in_element(p, NSMAP)
# 删除段落
parent = None
for parent_candidate in root.iter():
if p in list(parent_candidate):
parent = parent_candidate
break
if parent is not None:
parent.remove(p)
changed = True
# 2) 删除 VML/DrawingML 水印 shape
for shape in root.findall(".//v:shape", NSMAP):
if is_watermark_shape(shape, NSMAP):
removed_rels |= gather_image_rels_in_element(shape, NSMAP)
parent = None
for parent_candidate in root.iter():
if shape in list(parent_candidate):
parent = parent_candidate
break
if parent is not None:
parent.remove(shape)
changed = True
# 3) 删除独立的图片水印(仅出现在 header/footer 中且大面积覆盖的通常难以自动识别,
# 这里删除看起来“孤立”的图片:即所在段落除图片外无其他可见文本且图片没有正文字号)
for pict in root.findall(".//w:pict", NSMAP):
# 判断其所在段落是否已无文本
parent_p = None
for parent_candidate in root.iter():
if pict in list(parent_candidate):
parent_p = parent_candidate
break
if parent_p is not None:
texts = parent_p.findall(".//w:t", NSMAP)
if all(not (t.text or "").strip() for t in texts):
removed_rels |= gather_image_rels_in_element(pict, NSMAP)
parent_p_parent = None
for ppc in root.iter():
if parent_p in list(ppc):
parent_p_parent = ppc
break
if parent_p_parent is not None:
parent_p_parent.remove(parent_p)
changed = True
if changed:
tree = ET.ElementTree(root)
tree.write(xml_file, encoding="UTF-8", xml_declaration=True)
except Exception:
# 解析失败时保留原文件,避免整个请求失败
pass
# 4) 删除被引用的 media 文件以及对应的 relationships
if removed_rels and rels_dir.exists():
rels_file = rels_dir / "document.xml.rels"
if not rels_file.exists():
rels_file = rels_dir / "document2.xml.rels"
# 实际上 header/footer 的 rels 分别存在各自的 .rels 中
for rels_f in rels_dir.iterdir():
if rels_f.suffix != ".rels":
continue
try:
rels_text = rels_f.read_text(encoding="utf-8")
_register_namespaces_from_xml(rels_text)
root = ET.fromstring(rels_text)
rel_ns = "http://schemas.openxmlformats.org/package/2006/relationships"
for rel in root.findall(f"{{{rel_ns}}}Relationship"):
rid = rel.get("Id")
if rid in removed_rels:
target = rel.get("Target")
root.remove(rel)
# 删除 media 文件
if target and media_dir.exists():
target_clean = target
if target_clean.startswith("media/"):
target_clean = target_clean[6:]
media_path = media_dir / target_clean
if media_path.exists():
media_path.unlink()
tree = ET.ElementTree(root)
tree.write(rels_f, encoding="UTF-8", xml_declaration=True)
except Exception:
pass
# 重新打包为 DOCX
output_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zout:
for file_path in work_dir.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(work_dir)
zout.write(file_path, arcname)
finally:
if work_dir.exists():
shutil.rmtree(work_dir, ignore_errors=True)
import os
import re
import shutil
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import pikepdf
import pdfplumber
from config import (
REPEATED_FORM_MAX_BBOX_AREA_RATIO,
REPEATED_FORM_MIN_COVERAGE,
REPEATED_FORM_MIN_PAGES,
REPEATED_FORM_SMALL_BBOX_AREA_RATIO,
REPEATED_FORM_WATERMARK_SCORE_THRESHOLD,
WATERMARK_KEYWORDS,
)
def _looks_like_watermark_text(text: str) -> bool:
if not text:
return False
lower = text.lower()
for kw in WATERMARK_KEYWORDS:
if kw in lower:
return True
if "水印" in text:
return True
return False
def _remove_annotations(pdf: pikepdf.Pdf) -> bool:
"""删除 /Subtype /Watermark 的 annotation。"""
changed = False
watermark_subtypes = {"/Watermark"}
for page in pdf.pages:
if "/Annots" not in page:
continue
annots = page.Annots
keep = []
for annot in annots:
try:
subtype = annot.get("/Subtype")
except Exception:
subtype = None
if subtype in watermark_subtypes:
changed = True
continue
keep.append(annot)
if changed:
if keep:
page.Annots = pdf.make_indirect(keep)
else:
del page.Annots
return changed
def _remove_optional_content_watermarks(pdf: pikepdf.Pdf) -> bool:
"""关闭或删除常见的 Optional Content(图层)水印。"""
changed = False
if "/OCProperties" not in pdf.Root:
return changed
oc = pdf.Root.OCProperties
# 收集看起来像水印的 OC 名称
watermark_oc_names = set()
for key in ("/OCGs", "/D"):
if key not in oc:
continue
if key == "/OCGs":
for ocg in oc.OCGs:
name = str(ocg.get("/Name", ""))
if _looks_like_watermark_text(name):
watermark_oc_names.add(ocg.objgen)
changed = True
elif key == "/D":
d = oc.D
base_state = str(d.get("/BaseState", "/ON"))
on_arr = d.get("/ON", [])
off_arr = d.get("/OFF", [])
new_on = []
new_off = []
for ocg in on_arr:
name = str(ocg.get("/Name", ""))
if _looks_like_watermark_text(name):
new_off.append(ocg)
changed = True
else:
new_on.append(ocg)
for ocg in off_arr:
new_off.append(ocg)
if changed:
d.ON = pdf.make_indirect(new_on)
d.OFF = pdf.make_indirect(new_off)
# 在内容流中删除对应 OCMD
if watermark_oc_names:
for page in pdf.pages:
_strip_ocmd_from_page(page)
return changed
def _strip_ocmd_from_page(page: pikepdf.Page):
"""移除页面资源中与水印 Optional Content 相关的 XObject。"""
resources = page.get("/Resources")
if not resources:
return
xobjects = resources.get("/XObject")
if not xobjects:
return
to_remove = []
for name, xobj in xobjects.items():
try:
if xobj.get("/OC"):
to_remove.append(name)
except Exception:
pass
for name in to_remove:
del xobjects[name]
def _extract_watermark_candidates(input_path: Path) -> List[Tuple[int, str, Tuple[float, float, float, float]]]:
"""用 pdfplumber 提取可能是水印的文本候选。
返回 [(page_index, text, bbox), ...]
"""
candidates = []
try:
with pdfplumber.open(str(input_path)) as pdf:
for i, page in enumerate(pdf.pages):
words = page.extract_words()
for w in words:
text = w.get("text", "")
if _looks_like_watermark_text(text):
bbox = (
float(w.get("x0", 0)),
float(w.get("top", 0)),
float(w.get("x1", 0)),
float(w.get("bottom", 0)),
)
candidates.append((i, text, bbox))
except Exception:
pass
return candidates
def _remove_content_stream_watermarks(pdf: pikepdf.Pdf, input_path: Path) -> bool:
"""基于 pdfplumber 检测到的候选水印,在内容流中替换对应文本。"""
candidates = _extract_watermark_candidates(input_path)
if not candidates:
return False
changed = False
# 按页分组
per_page = {}
for page_idx, text, bbox in candidates:
per_page.setdefault(page_idx, []).append((text, bbox))
for page_idx, items in per_page.items():
if page_idx >= len(pdf.pages):
continue
page = pdf.pages[page_idx]
content = page.get("/Contents")
if content is None:
continue
streams = []
if hasattr(content, "__iter__") and not isinstance(content, pikepdf.Stream):
streams = list(content)
else:
streams = [content]
for s in streams:
try:
raw = s.read_bytes()
decoded = raw.decode("latin-1", errors="ignore")
modified = decoded
for text, bbox in items:
# 简单策略:在水印文本所在 TJ/Tj 操作前使用 Td 移出页面
# 更安全的策略:将文本替换为空字符串(保留操作符)
# 这里使用保守方式:对于常见中文水印,尝试找到对应 BT...ET 块并清空其字符串
# 由于解析内容流较复杂,仅做启发式替换
patterns = [
rf"\({re.escape(text)}\)\s*Tj",
rf"\[(.*?)\]\s*TJ",
]
for pat in patterns:
if re.search(pat, modified, re.I):
modified = re.sub(pat, "() Tj", modified, count=1, flags=re.I)
changed = True
if modified != decoded:
s.write(modified.encode("latin-1"))
except Exception:
pass
return changed
def _remove_repeated_image_watermarks(pdf: pikepdf.Pdf) -> bool:
"""删除在每一页重复出现、大面积覆盖或低透明度的图片 XObject 水印。"""
changed = False
# 统计所有页面使用的 XObject
xobj_usage: Dict[Any, Any] = {}
page_count = len(pdf.pages)
for idx, page in enumerate(pdf.pages):
resources = page.get("/Resources")
if not resources:
continue
xobjects = resources.get("/XObject")
if not xobjects:
continue
for name, xobj in xobjects.items():
try:
objgen = xobj.objgen
subtype = xobj.get("/Subtype")
except Exception:
continue
if subtype != "/Image":
continue
xobj_usage.setdefault(objgen, {"count": 0, "pages": set(), "xobj": xobj})
xobj_usage[objgen]["count"] += 1
xobj_usage[objgen]["pages"].add(idx)
watermark_xobjs = set()
for objgen, info in xobj_usage.items():
xobj = info["xobj"]
# 规则 1:所有页面都出现
if len(info["pages"]) == page_count and page_count > 0:
# 单页 PDF 中“出现在所有页面”不能作为水印依据,
# 否则会把唯一的正文图片误删。
if page_count == 1:
continue
watermark_xobjs.add(objgen)
continue
# 规则 2:SoftMask / SMask 低透明度(通常透明度图像)
try:
if "/SMask" in xobj or "/Mask" in xobj:
watermark_xobjs.add(objgen)
continue
except Exception:
pass
if not watermark_xobjs:
return changed
for page in pdf.pages:
resources = page.get("/Resources")
if not resources:
continue
xobjects = resources.get("/XObject")
if not xobjects:
continue
to_remove = [name for name, xobj in xobjects.items() if xobj.objgen in watermark_xobjs]
for name in to_remove:
del xobjects[name]
changed = True
# 清理内容流中的 Do 指令(避免留下空白占位)
if to_remove:
_remove_do_operators(page, to_remove)
return changed
def _form_contains_masked_image(xobj: pikepdf.Object) -> bool:
"""递归检查 Form XObject 内部是否包含带透明蒙版的图片。"""
resources = xobj.get("/Resources")
if not resources:
return False
xobjects = resources.get("/XObject")
if not xobjects:
return False
for name, child in xobjects.items():
try:
subtype = child.get("/Subtype")
except Exception:
continue
if subtype == "/Image":
if "/SMask" in child or "/Mask" in child:
return True
elif subtype == "/Form":
if _form_contains_masked_image(child):
return True
return False
def _form_content_has_keyword(form: pikepdf.Object) -> bool:
"""检查 Form XObject 内容流中是否包含水印关键词。"""
content = form.get("/Contents")
if content is None:
return False
streams = []
if hasattr(content, "__iter__") and not isinstance(content, pikepdf.Stream):
streams = list(content)
else:
streams = [content]
for s in streams:
try:
text = s.read_bytes().decode("latin-1", errors="ignore")
for kw in WATERMARK_KEYWORDS:
if kw in text:
return True
except Exception:
pass
return False
def _form_bbox_area_ratio(form: pikepdf.Object, page: pikepdf.Page) -> Optional[float]:
"""计算 Form 的 BBox 占页面 MediaBox 面积的比例。"""
bbox = form.get("/BBox")
if not bbox or len(bbox) < 4:
return None
try:
x0, y0, x1, y1 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])
form_area = abs(x1 - x0) * abs(y1 - y0)
except Exception:
return None
mediabox = page.mediabox
page_area = abs(float(mediabox[2]) - float(mediabox[0])) * abs(
float(mediabox[3]) - float(mediabox[1])
)
if page_area == 0:
return None
return form_area / page_area
def _remove_repeated_form_watermarks(pdf: pikepdf.Pdf) -> bool:
"""删除在多数页面重复出现且符合水印特征的 Form XObject。"""
changed = False
page_count = len(pdf.pages)
if page_count < REPEATED_FORM_MIN_PAGES:
return changed
form_usage: Dict[Any, Any] = {}
for idx, page in enumerate(pdf.pages):
resources = page.get("/Resources")
if not resources:
continue
xobjects = resources.get("/XObject")
if not xobjects:
continue
seen_objgens: Set[Any] = set()
for name, xobj in xobjects.items():
try:
objgen = xobj.objgen
subtype = xobj.get("/Subtype")
except Exception:
continue
if subtype != "/Form":
continue
if objgen in seen_objgens:
continue
seen_objgens.add(objgen)
form_usage.setdefault(
objgen, {"count": 0, "pages": set(), "xobj": xobj}
)
form_usage[objgen]["count"] += 1
form_usage[objgen]["pages"].add(idx)
watermark_forms: Set[Any] = set()
for objgen, info in form_usage.items():
coverage = len(info["pages"]) / page_count
if coverage < REPEATED_FORM_MIN_COVERAGE:
continue
score = 0
if _form_contains_masked_image(info["xobj"]):
score += 2
if _form_content_has_keyword(info["xobj"]):
score += 2
sample_page_idx = next(iter(info["pages"]))
ratio = _form_bbox_area_ratio(info["xobj"], pdf.pages[sample_page_idx])
if ratio is not None and ratio <= REPEATED_FORM_MAX_BBOX_AREA_RATIO:
score += 1
if ratio is not None and ratio <= REPEATED_FORM_SMALL_BBOX_AREA_RATIO:
score += 1
if coverage >= 1.0:
score += 1
if score >= REPEATED_FORM_WATERMARK_SCORE_THRESHOLD:
watermark_forms.add(objgen)
if not watermark_forms:
return changed
for page in pdf.pages:
resources = page.get("/Resources")
if not resources:
continue
xobjects = resources.get("/XObject")
if not xobjects:
continue
to_remove = [
name for name, xobj in xobjects.items() if xobj.objgen in watermark_forms
]
for name in to_remove:
del xobjects[name]
changed = True
if to_remove:
_remove_do_operators(page, to_remove)
return changed
def _remove_do_operators(page: pikepdf.Page, names_to_remove: List[str]):
"""在页面内容流中删除对指定 XObject 名称的 Do 指令。"""
content = page.get("/Contents")
if content is None:
return
streams = []
if hasattr(content, "__iter__") and not isinstance(content, pikepdf.Stream):
streams = list(content)
else:
streams = [content]
name_set = set()
for n in names_to_remove:
s = str(n)
if s.startswith("/"):
s = s[1:]
name_set.add(pikepdf.Name(f"/{s}"))
for s in streams:
try:
instructions = pikepdf.parse_content_stream(s)
filtered = []
dropped = False
for operands, operator in instructions:
if (
operator == pikepdf.Operator("Do")
and operands
and operands[0] in name_set
):
dropped = True
continue
filtered.append((operands, operator))
if dropped:
s.write(pikepdf.unparse_content_stream(filtered))
except Exception:
pass
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""去除 PDF 水印并保存到新文件。"""
with pikepdf.open(str(input_path), allow_overwriting_input=False) as pdf:
_remove_annotations(pdf)
_remove_optional_content_watermarks(pdf)
_remove_content_stream_watermarks(pdf, input_path)
_remove_repeated_image_watermarks(pdf)
_remove_repeated_form_watermarks(pdf)
# 先写入临时文件,再移动到目标路径,避免覆盖输入文件时报错
output_path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_path = tempfile.mkstemp(
suffix=output_path.suffix, dir=output_path.parent
)
os.close(fd)
temp_path = Path(temp_path)
try:
pdf.save(str(temp_path))
shutil.move(str(temp_path), str(output_path))
finally:
if temp_path.exists():
temp_path.unlink(missing_ok=True)
Flask>=3.0
Flask-CORS>=4.0
Werkzeug>=3.0
python-docx>=1.1
pikepdf>=8.0
pdfplumber>=0.10
lxml>=5.0
import os
import shutil
import tempfile
from pathlib import Path
from typing import Optional, Set
from werkzeug.utils import secure_filename
def is_allowed_file(filename: str, allowed_extensions: Set[str]) -> bool:
"""校验文件扩展名是否在允许列表内(大小写不敏感)。"""
if not filename or "." not in filename:
return False
ext = filename.rsplit(".", 1)[1].lower()
return ext in allowed_extensions
def sanitize_filename(filename: str) -> str:
"""清理文件名,保留原始扩展名。"""
return secure_filename(filename)
def prefixed_output_name(filename: str) -> str:
"""结果文件名:原始文件名前加 '_'。"""
name = filename.strip()
if not name:
return "_output"
# 去掉路径
name = os.path.basename(name)
# 若已有前缀,不再重复添加
if name.startswith("_"):
return name
return "_" + name
def make_temp_dir() -> Path:
"""创建临时目录,返回 Path 对象。"""
return Path(tempfile.mkdtemp(prefix="wmr_"))
def cleanup_dir(directory: Optional[Path]) -> None:
"""清理临时目录。"""
if directory and directory.exists():
shutil.rmtree(directory, ignore_errors=True)
import re
from pathlib import Path
from typing import Dict, Set
from config import WATERMARK_ALPHA_THRESHOLD, WATERMARK_KEYWORDS
NSMAP = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"v": "urn:schemas-microsoft-com:vml",
"o": "urn:schemas-microsoft-com:office:office",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
"w10": "urn:schemas-microsoft-com:office:word",
}
def register_all_namespaces():
"""在 lxml 中注册常用命名空间,避免写回时前缀被改写。"""
try:
from lxml import etree
for prefix, uri in NSMAP.items():
etree.register_namespace(prefix, uri)
except Exception:
pass
def looks_like_watermark_text(text: str) -> bool:
"""判断文本是否像水印文字。"""
if not text:
return False
lower = text.lower()
for kw in WATERMARK_KEYWORDS:
if kw in lower:
return True
# 某些全角/半角通用的“水印”字样
if "水印" in text:
return True
return False
def is_watermark_shape(elem, namespaces: Dict[str, str]) -> bool:
"""判断 DOCX 中 VML/DrawingML shape 是否为水印。"""
try:
# VML 文字水印通常 type="#_x0000_t136"
shape_type = elem.get("type")
if shape_type and "t136" in shape_type:
return True
# style 中包含 opacity 较低
style = elem.get("style") or ""
if "opacity" in style.lower():
m = re.search(r"opacity[:=\s]*([0-9.]+)", style, re.I)
if m and float(m.group(1)) < WATERMARK_ALPHA_THRESHOLD:
return True
# 子节点包含水印关键词
text_nodes = elem.xpath(".//v:textpath | .//w:t | .//v:t", namespaces=namespaces)
for t in text_nodes:
val = t.text or t.get("string") or ""
if looks_like_watermark_text(val):
return True
except Exception:
pass
return False
def gather_image_rels_in_element(elem, namespaces: Dict[str, str]) -> Set[str]:
"""收集元素内引用的所有图片 relationship id。"""
rels = set()
try:
r_ns = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
for node in elem.xpath(".//a:blip | .//v:imagedata", namespaces=namespaces):
# DrawingML 图片通常使用 r:embed
rel = node.get(f"{r_ns}embed")
if rel:
rels.add(rel)
# VML 图片通常使用 r:id
rel = node.get(f"{r_ns}id")
if rel:
rels.add(rel)
# 兜底:任意 r: 开头的 relationship 属性
for attr, val in node.attrib.items():
if attr.startswith(r_ns) and val:
rels.add(val)
except Exception:
pass
return rels
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>去水印工具</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{
"name": "watermark-remove-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watermark-remove-frontend",
"version": "1.0.0",
"dependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
"integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
"integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
"integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
"integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
"integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
"integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
"integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
"integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
"integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
"integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
"integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
"integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
"integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
"integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
"integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
"integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
"integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
"integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
"integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
"integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
"integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
"integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
"integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
"integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
"integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
"integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
"integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/shared": "3.5.42",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz",
"integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.42",
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz",
"integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/compiler-core": "3.5.42",
"@vue/compiler-dom": "3.5.42",
"@vue/compiler-ssr": "3.5.42",
"@vue/shared": "3.5.42",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.19",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz",
"integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.42",
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz",
"integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz",
"integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.42",
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz",
"integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.42",
"@vue/runtime-core": "3.5.42",
"@vue/shared": "3.5.42",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz",
"integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.42",
"@vue/runtime-dom": "3.5.42",
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/shared": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz",
"integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
"integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
"@rollup/rollup-android-arm-eabi": "4.63.1",
"@rollup/rollup-android-arm64": "4.63.1",
"@rollup/rollup-darwin-arm64": "4.63.1",
"@rollup/rollup-darwin-x64": "4.63.1",
"@rollup/rollup-freebsd-arm64": "4.63.1",
"@rollup/rollup-freebsd-x64": "4.63.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
"@rollup/rollup-linux-arm-musleabihf": "4.63.1",
"@rollup/rollup-linux-arm64-gnu": "4.63.1",
"@rollup/rollup-linux-arm64-musl": "4.63.1",
"@rollup/rollup-linux-loong64-gnu": "4.63.1",
"@rollup/rollup-linux-loong64-musl": "4.63.1",
"@rollup/rollup-linux-ppc64-gnu": "4.63.1",
"@rollup/rollup-linux-ppc64-musl": "4.63.1",
"@rollup/rollup-linux-riscv64-gnu": "4.63.1",
"@rollup/rollup-linux-riscv64-musl": "4.63.1",
"@rollup/rollup-linux-s390x-gnu": "4.63.1",
"@rollup/rollup-linux-x64-gnu": "4.63.1",
"@rollup/rollup-linux-x64-musl": "4.63.1",
"@rollup/rollup-openbsd-x64": "4.63.1",
"@rollup/rollup-openharmony-arm64": "4.63.1",
"@rollup/rollup-win32-arm64-msvc": "4.63.1",
"@rollup/rollup-win32-ia32-msvc": "4.63.1",
"@rollup/rollup-win32-x64-gnu": "4.63.1",
"@rollup/rollup-win32-x64-msvc": "4.63.1",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vue": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.42",
"@vue/compiler-sfc": "3.5.42",
"@vue/runtime-dom": "3.5.42",
"@vue/server-renderer": "3.5.42",
"@vue/shared": "3.5.42"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}
}
}
{
"name": "watermark-remove-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}
<template>
<div class="app">
<header class="header">
<h1>文档去水印工具</h1>
<p class="subtitle">全自动识别并删除 DOC、DOCX、PDF 文件中的文字与图片水印</p>
</header>
<main class="main">
<FileUploader />
<div class="notes">
<h3>使用说明</h3>
<ul>
<li>上传文件后将由服务器自动处理,处理完成后自动下载。</li>
<li>下载文件名将自动加上下划线前缀,例如 <code>报告.docx</code><code>_报告.docx</code></li>
<li>服务器不会保留您的原始文件与结果文件,处理完成后立即删除。</li>
<li>对于复杂 PDF 水印或自定义图片水印,去除效果可能有限。</li>
<li>DOC 文件会经过 LibreOffice 转换为 DOCX 处理后再转回 DOC,排版可能存在细微变化。</li>
</ul>
</div>
</main>
<footer class="footer">
<p>本地运行 · 数据不留存</p>
</footer>
</div>
</template>
<script setup>
import FileUploader from './components/FileUploader.vue'
</script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background-color: #f5f7fa;
color: #303133;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background-color: #fff;
padding: 40px 20px 24px;
text-align: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.header h1 {
margin: 0 0 8px;
font-size: 28px;
color: #303133;
}
.subtitle {
margin: 0;
color: #606266;
font-size: 14px;
}
.main {
flex: 1;
padding: 32px 20px;
}
.notes {
max-width: 520px;
margin: 32px auto 0;
padding: 20px 24px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.notes h3 {
margin: 0 0 12px;
font-size: 16px;
}
.notes ul {
margin: 0;
padding-left: 20px;
font-size: 13px;
color: #606266;
line-height: 1.8;
}
.notes code {
background-color: #f4f4f5;
padding: 2px 6px;
border-radius: 4px;
font-family: Menlo, Monaco, Consolas, monospace;
}
.footer {
text-align: center;
padding: 20px;
color: #909399;
font-size: 12px;
}
</style>
const API_BASE = import.meta.env.VITE_API_BASE || ''
export async function removeWatermark(file, onProgress) {
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${API_BASE}/api/remove-watermark`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
let message = '处理失败'
try {
const err = await response.json()
message = err.message || message
} catch {
message = `${message} (${response.status})`
}
throw new Error(message)
}
const blob = await response.blob()
const disposition = response.headers.get('Content-Disposition') || ''
let filename = file.name
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match && match[1]) {
filename = decodeURIComponent(match[1].replace(/['"]/g, ''))
}
return { blob, filename }
}
<template>
<div class="uploader">
<div
class="drop-zone"
:class="{ dragging: isDragging, disabled: isProcessing }"
@dragenter.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@dragover.prevent
@drop.prevent="handleDrop"
@click="triggerFileInput"
>
<input
ref="fileInput"
type="file"
accept=".doc,.docx,.pdf"
class="hidden-input"
@change="handleFileChange"
/>
<p v-if="!selectedFile">点击或拖拽上传文件</p>
<p v-else class="file-name">已选择:{{ selectedFile.name }}</p>
<p class="hint">支持格式:doc、docx、pdf(最大 20MB)</p>
</div>
<div class="actions">
<button
class="btn primary"
:disabled="!selectedFile || isProcessing"
@click="submit"
>
{{ isProcessing ? '处理中...' : '去除水印' }}
</button>
<button
class="btn"
:disabled="isProcessing"
@click="reset"
>
重置
</button>
</div>
<div v-if="message" class="message" :class="messageType">
{{ message }}
</div>
<div v-if="isProcessing" class="progress">
<div class="progress-bar"></div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { removeWatermark } from '../api/watermark.js'
const ALLOWED_EXTENSIONS = ['doc', 'docx', 'pdf']
const MAX_SIZE = 20 * 1024 * 1024
const fileInput = ref(null)
const selectedFile = ref(null)
const isDragging = ref(false)
const isProcessing = ref(false)
const message = ref('')
const messageType = ref('')
function isAllowed(filename) {
const ext = filename.split('.').pop()?.toLowerCase()
return ALLOWED_EXTENSIONS.includes(ext)
}
function setMessage(text, type = 'info') {
message.value = text
messageType.value = type
}
function handleFile(file) {
if (!file) return
if (!isAllowed(file.name)) {
setMessage('仅支持 doc、docx、pdf 格式', 'error')
selectedFile.value = null
return
}
if (file.size > MAX_SIZE) {
setMessage('文件大小超过 20MB 限制', 'error')
selectedFile.value = null
return
}
selectedFile.value = file
setMessage('')
}
function handleDrop(e) {
isDragging.value = false
const file = e.dataTransfer?.files?.[0]
handleFile(file)
}
function handleFileChange(e) {
const file = e.target.files?.[0]
handleFile(file)
}
function triggerFileInput() {
if (isProcessing.value) return
fileInput.value?.click()
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
async function submit() {
if (!selectedFile.value || isProcessing.value) return
isProcessing.value = true
setMessage('')
try {
const { blob, filename } = await removeWatermark(selectedFile.value)
downloadBlob(blob, filename)
setMessage(`处理完成:${filename}`, 'success')
} catch (err) {
setMessage(err.message || '处理失败', 'error')
} finally {
isProcessing.value = false
}
}
function reset() {
selectedFile.value = null
message.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>
<style scoped>
.uploader {
max-width: 520px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.drop-zone {
border: 2px dashed #c0c4cc;
border-radius: 12px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
background-color: #fafafa;
}
.drop-zone:hover,
.drop-zone.dragging {
border-color: #409eff;
background-color: #f0f9ff;
}
.drop-zone.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.hidden-input {
display: none;
}
.file-name {
font-weight: 600;
color: #303133;
word-break: break-all;
}
.hint {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
.actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 20px;
}
.btn {
padding: 10px 24px;
border: 1px solid #dcdfe6;
border-radius: 8px;
background-color: #fff;
color: #606266;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s, border-color 0.2s;
}
.btn:hover:not(:disabled) {
border-color: #409eff;
color: #409eff;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.btn.primary {
background-color: #409eff;
color: #fff;
border-color: #409eff;
}
.btn.primary:hover:not(:disabled) {
background-color: #66b1ff;
border-color: #66b1ff;
}
.message {
margin-top: 16px;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
text-align: center;
}
.message.success {
background-color: #f0f9eb;
color: #67c23a;
}
.message.error {
background-color: #fef0f0;
color: #f56c6c;
}
.message.info {
background-color: #f4f4f5;
color: #909399;
}
.progress {
margin-top: 16px;
height: 6px;
background-color: #e4e7ed;
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
width: 30%;
height: 100%;
background-color: #409eff;
border-radius: 3px;
animation: loading 1.2s infinite ease-in-out;
}
@keyframes loading {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(340%);
}
}
</style>
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:5000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
})
File added
File added
File added
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment