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)
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",
"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