Commit ae677090 by 文周繁

feat:增加对DOTX,DOTM,DOT,XML的去水印功能

parent f1b8581d
......@@ -9,8 +9,13 @@ 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.docm_handler import remove_watermark as remove_docm_watermark
from handlers.docx_handler import remove_watermark as remove_docx_watermark
from handlers.dot_handler import remove_watermark as remove_dot_watermark
from handlers.dotm_handler import remove_watermark as remove_dotm_watermark
from handlers.dotx_handler import remove_watermark as remove_dotx_watermark
from handlers.pdf_handler import remove_watermark as remove_pdf_watermark
from handlers.xml_handler import remove_watermark as remove_xml_watermark
from utils.file_utils import (
cleanup_dir,
is_allowed_file,
......@@ -44,7 +49,7 @@ def remove_watermark():
original_filename = file.filename
if not is_allowed_file(original_filename, ALLOWED_EXTENSIONS):
return jsonify({"message": "仅支持 doc、docx、pdf 格式"}), 400
return jsonify({"message": "仅支持 doc、docx、docm、dotx、dotm、dot、xml、pdf 格式"}), 400
temp_dir = make_temp_dir()
ext = original_filename.rsplit(".", 1)[1].lower()
......@@ -59,6 +64,16 @@ def remove_watermark():
remove_docx_watermark(input_path, output_path)
elif ext == "doc":
remove_doc_watermark(input_path, output_path)
elif ext == "docm":
remove_docm_watermark(input_path, output_path)
elif ext == "dotx":
remove_dotx_watermark(input_path, output_path)
elif ext == "dotm":
remove_dotm_watermark(input_path, output_path)
elif ext == "dot":
remove_dot_watermark(input_path, output_path)
elif ext == "xml":
remove_xml_watermark(input_path, output_path)
elif ext == "pdf":
remove_pdf_watermark(input_path, output_path)
else:
......
......@@ -4,7 +4,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
# 允许格式(扩展名大小写不敏感)
ALLOWED_EXTENSIONS = {"doc", "docx", "pdf"}
ALLOWED_EXTENSIONS = {"doc", "docx", "docm", "pdf", "dotx", "dotm", "dot", "xml"}
# 上传文件大小限制(字节)
MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20 MB
......@@ -12,6 +12,11 @@ MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20 MB
# LibreOffice 转换超时(秒)
SOFFICE_TIMEOUT = 120
# UNO 直改去水印(doc/dot/xml,需系统 python3 的 uno 模块)
UNO_SCRIPT_REL = "scripts/lo_uno_remove.py"
UNO_CONNECT_TIMEOUT = 30
SYSTEM_PYTHON = "/usr/bin/python3"
# 常见水印关键词(用于启发式识别)
WATERMARK_KEYWORDS = {
"机密", "秘密", "绝密", "内部", "样稿", "样张", "草稿", "草案",
......
import logging
import shutil
import subprocess
from pathlib import Path
......@@ -5,6 +6,17 @@ from typing import List
from config import SOFFICE_TIMEOUT
from handlers.docx_handler import remove_watermark as remove_docx_watermark
from utils.lo_uno import (
UnoProcessError,
UnoTimeoutError,
UnoUnavailableError,
uno_remove_watermark,
)
logger = logging.getLogger(__name__)
# UNO 直改另存 DOC 所用的 FilterName
FILTER_NAME = "MS Word 97"
def _run_soffice(args: List[str], timeout: int = SOFFICE_TIMEOUT) -> None:
......@@ -27,7 +39,7 @@ def _run_soffice(args: List[str], timeout: int = SOFFICE_TIMEOUT) -> None:
raise RuntimeError(f"LibreOffice 转换失败: {err}") from exc
def remove_watermark(input_path: Path, output_path: Path) -> None:
def _remove_watermark_via_conversion(input_path: Path, output_path: Path) -> None:
"""DOC 文件:先转 DOCX,去水印,再转回 DOC。"""
temp_dir = input_path.parent
docx_path = temp_dir / (input_path.stem + ".docx")
......@@ -59,3 +71,20 @@ def remove_watermark(input_path: Path, output_path: Path) -> None:
raise RuntimeError("DOCX 转换回 DOC 失败")
shutil.copy2(generated_doc, output_path)
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""优先 UNO 直改(不经过 docx 转换),失败回退两步转换方案。"""
try:
removed = uno_remove_watermark(input_path, output_path, FILTER_NAME)
logger.info("UNO 直改去水印完成: removed=%d (%s)", removed, input_path.name)
return
except UnoUnavailableError as exc:
logger.warning("UNO 不可用,回退转换方案: %s", exc)
except UnoTimeoutError as exc:
logger.warning("UNO 处理超时,回退转换方案: %s", exc)
except UnoProcessError as exc:
logger.warning("UNO 处理失败,回退转换方案: %s", exc)
except Exception:
logger.warning("UNO 直改发生未知异常,回退转换方案", exc_info=True)
_remove_watermark_via_conversion(input_path, output_path)
from pathlib import Path
from handlers.docx_handler import remove_watermark as remove_docx_watermark
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""去除 DOCM(启用宏的 Word 文档)文件中的文字和图片水印。
DOCM 与 DOCX 同为 OOXML ZIP 结构,水印处理逻辑完全一致;
宏文件(vbaProject.bin)不受影响,会随原样重新打包保留。
"""
remove_docx_watermark(input_path, output_path)
......@@ -90,24 +90,50 @@ def remove_watermark(input_path: Path, output_path: Path) -> None:
# 3) 删除独立的图片水印(仅出现在 header/footer 中且大面积覆盖的通常难以自动识别,
# 这里删除看起来“孤立”的图片:即所在段落除图片外无其他可见文本且图片没有正文字号)
def _paragraph_visible_texts(paragraph) -> list:
texts = paragraph.findall(".//w:t", NSMAP)
# 文本框(txbxContent)内的文字不算段落可见文本,
# 否则文本框水印会让段落被误判为“有内容”而跳过删除
tb_texts = set(paragraph.findall(".//w:txbxContent//w:t", NSMAP))
return [t for t in texts if t not in tb_texts]
def _remove_paragraph(paragraph) -> bool:
parent = None
for parent_candidate in root.iter():
if paragraph in list(parent_candidate):
parent = parent_candidate
break
if parent is not None:
parent.remove(paragraph)
return True
return False
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):
if all(not (t.text or "").strip() for t in _paragraph_visible_texts(parent_p)):
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)
if _remove_paragraph(parent_p):
changed = True
# 3b) 删除 DrawingML 文本框水印(LibreOffice 转换 VML 水印的产物),
# 即段落中没有可见文本、只包含文本框的情况
for drawing in root.findall(".//w:drawing", NSMAP):
if not drawing.findall(".//w:txbxContent", NSMAP):
continue
parent_p = None
for parent_candidate in root.iter():
if drawing in list(parent_candidate):
parent_p = parent_candidate
break
if parent_p is not None:
if all(not (t.text or "").strip() for t in _paragraph_visible_texts(parent_p)):
removed_rels |= gather_image_rels_in_element(drawing, NSMAP)
if _remove_paragraph(parent_p):
changed = True
if changed:
......
import logging
import shutil
from pathlib import Path
from handlers.doc_handler import _run_soffice
from handlers.docx_handler import remove_watermark as remove_docx_watermark
from utils.lo_uno import (
UnoProcessError,
UnoTimeoutError,
UnoUnavailableError,
uno_remove_watermark,
)
logger = logging.getLogger(__name__)
# UNO 直改另存 DOT 所用的 FilterName
FILTER_NAME = "MS Word 97 Vorlage"
def _remove_watermark_via_conversion(input_path: Path, output_path: Path) -> None:
"""DOT 文件:先转 DOCX,去水印,再转回 DOT。"""
temp_dir = input_path.parent
docx_path = temp_dir / (input_path.stem + ".docx")
cleaned_docx = temp_dir / (input_path.stem + "_cleaned.docx")
# 1) DOT -> DOCX
_run_soffice([
"--convert-to", "docx",
"--outdir", str(temp_dir),
str(input_path),
])
if not docx_path.exists():
raise RuntimeError("DOT 转换为 DOCX 失败")
# 2) 去水印
remove_docx_watermark(docx_path, cleaned_docx)
# 3) DOCX -> DOT
_run_soffice([
"--convert-to", "dot",
"--outdir", str(temp_dir),
str(cleaned_docx),
])
generated_dot = temp_dir / (cleaned_docx.stem + ".dot")
if not generated_dot.exists():
raise RuntimeError("DOCX 转换回 DOT 失败")
shutil.copy2(generated_dot, output_path)
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""优先 UNO 直改(不经过 docx 转换),失败回退两步转换方案。"""
try:
removed = uno_remove_watermark(input_path, output_path, FILTER_NAME)
logger.info("UNO 直改去水印完成: removed=%d (%s)", removed, input_path.name)
return
except UnoUnavailableError as exc:
logger.warning("UNO 不可用,回退转换方案: %s", exc)
except UnoTimeoutError as exc:
logger.warning("UNO 处理超时,回退转换方案: %s", exc)
except UnoProcessError as exc:
logger.warning("UNO 处理失败,回退转换方案: %s", exc)
except Exception:
logger.warning("UNO 直改发生未知异常,回退转换方案", exc_info=True)
_remove_watermark_via_conversion(input_path, output_path)
from pathlib import Path
from handlers.docx_handler import remove_watermark as remove_docx_watermark
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""去除 DOTM(启用宏的 Word 模板)文件中的文字和图片水印。
DOTM 与 DOCX 同为 OOXML ZIP 结构,水印处理逻辑完全一致;
宏文件(vbaProject.bin)不受影响,会随原样重新打包保留。
"""
remove_docx_watermark(input_path, output_path)
from pathlib import Path
from handlers.docx_handler import remove_watermark as remove_docx_watermark
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""去除 DOTX(Word 模板)文件中的文字和图片水印。
DOTX 与 DOCX 同为 OOXML ZIP 结构,水印处理逻辑完全一致。
"""
remove_docx_watermark(input_path, output_path)
import logging
import shutil
from pathlib import Path
from handlers.doc_handler import _run_soffice
from handlers.docx_handler import remove_watermark as remove_docx_watermark
from utils.lo_uno import (
UnoProcessError,
UnoTimeoutError,
UnoUnavailableError,
uno_remove_watermark,
)
logger = logging.getLogger(__name__)
# LibreOffice 输出 Word 2003 XML 所用的过滤器(--convert-to 需要带 xml: 前缀)
WORD2003_XML_FILTER = "xml:MS Word 2003 XML"
# UNO 直改另存 Word 2003 XML 所用的 FilterName(storeToURL 不带前缀)
FILTER_NAME = "MS Word 2003 XML"
def _remove_watermark_via_conversion(input_path: Path, output_path: Path) -> None:
"""Word 2003 XML 文件:先转 DOCX,去水印,再转回 XML。"""
temp_dir = input_path.parent
docx_path = temp_dir / (input_path.stem + ".docx")
cleaned_docx = temp_dir / (input_path.stem + "_cleaned.docx")
# 1) XML -> DOCX
_run_soffice([
"--convert-to", "docx",
"--outdir", str(temp_dir),
str(input_path),
])
if not docx_path.exists():
raise RuntimeError("XML 转换为 DOCX 失败")
# 2) 去水印
remove_docx_watermark(docx_path, cleaned_docx)
# 3) DOCX -> Word 2003 XML
_run_soffice([
"--convert-to", WORD2003_XML_FILTER,
"--outdir", str(temp_dir),
str(cleaned_docx),
])
generated_xml = temp_dir / (cleaned_docx.stem + ".xml")
if not generated_xml.exists():
raise RuntimeError("DOCX 转换回 XML 失败")
shutil.copy2(generated_xml, output_path)
def remove_watermark(input_path: Path, output_path: Path) -> None:
"""优先 UNO 直改(不经过 docx 转换),失败回退两步转换方案。"""
try:
removed = uno_remove_watermark(input_path, output_path, FILTER_NAME)
logger.info("UNO 直改去水印完成: removed=%d (%s)", removed, input_path.name)
return
except UnoUnavailableError as exc:
logger.warning("UNO 不可用,回退转换方案: %s", exc)
except UnoTimeoutError as exc:
logger.warning("UNO 处理超时,回退转换方案: %s", exc)
except UnoProcessError as exc:
logger.warning("UNO 处理失败,回退转换方案: %s", exc)
except Exception:
logger.warning("UNO 直改发生未知异常,回退转换方案", exc_info=True)
_remove_watermark_via_conversion(input_path, output_path)
#!/usr/bin/env python3
"""LibreOffice UNO 直改去水印脚本(须由系统 python3 执行,venv 无 uno)。
用 headless soffice listener 直接加载原格式文档(doc/dot/xml),删除 DrawPage 上的
水印 shape,再以原格式另存,避免"转 docx 再转回"的两次格式转换。
水印识别规则(先收集后删除):
1. shape Name 以 "PowerPlusWaterMarkObject" 开头(Word 文字水印的固定命名)→ 命中
2. CustomShape 的 CustomShapeGeometry -> "TextPath" -> "Text" 提取文字,
与 --keywords 传入的关键词子串匹配(大小写不敏感)→ 命中
3. TextEmbeddedObject(OLE 对象)一律跳过
限制:doc 中的图片水印不做识别(命名模式未知,易误删页眉图片),此类文件输出等价原文件。
退出码:
0 成功(stdout 末行 RESULT: removed=N)
2 uno 模块或 soffice 命令缺失
3 listener 启动/连接失败
4 文档加载失败
5 处理/保存失败
"""
import argparse
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
EXIT_OK = 0
EXIT_MISSING = 2
EXIT_LISTENER = 3
EXIT_LOAD = 4
EXIT_PROCESS = 5
CONNECT_RETRY_INTERVAL = 0.3
try:
import uno
from com.sun.star.beans import PropertyValue
from com.sun.star.connection import NoConnectException
UNO_OK = True
except ImportError:
UNO_OK = False
def pick_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("", 0))
return sock.getsockname()[1]
def start_listener(port: int, profile_dir: Path) -> subprocess.Popen:
cmd = [
"soffice",
"--headless", "--invisible", "--nologo", "--norestore", "--nolockcheck",
f"--accept=socket,host=127.0.0.1,port={port};urp;",
f"-env:UserInstallation=file://{profile_dir}",
]
return subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
def connect(port: int, deadline: float):
local_ctx = uno.getComponentContext()
resolver = local_ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.bridge.UnoUrlResolver", local_ctx
)
url = f"uno:socket,host=127.0.0.1,port={port};urp;StarOffice.ComponentContext"
while time.monotonic() < deadline:
try:
return resolver.resolve(url)
except NoConnectException:
time.sleep(CONNECT_RETRY_INTERVAL)
raise NoConnectException(f"连接 127.0.0.1:{port} 超时", None)
def make_prop(name: str, value) -> "PropertyValue":
prop = PropertyValue()
prop.Name = name
prop.Value = value
return prop
def get_custom_shape_type(shape) -> str:
try:
geom = shape.getPropertyValue("CustomShapeGeometry")
except Exception:
return ""
for pv in geom:
if pv.Name == "Type" and isinstance(pv.Value, str):
return pv.Value
return ""
def extract_text_path_text(shape) -> str:
"""从 CustomShapeGeometry 的 TextPath 中提取文字。"""
try:
geom = shape.getPropertyValue("CustomShapeGeometry")
except Exception:
return ""
text_path = None
for pv in geom:
if pv.Name == "TextPath":
text_path = pv.Value
break
if not text_path:
return ""
for pv in text_path:
if pv.Name == "Text" and isinstance(pv.Value, str):
return pv.Value
return ""
def is_watermark_shape(shape, keywords) -> bool:
try:
if shape.supportsService("com.sun.star.text.TextEmbeddedObject"):
return False
except Exception:
pass
name = ""
try:
name = shape.getPropertyValue("Name") or ""
except Exception:
pass
if name.lower().startswith("powerpluswatermarkobject"):
return True
try:
if shape.supportsService("com.sun.star.drawing.CustomShape"):
# fontwork 系(Word 文字水印即 fontwork-plain-text):文字可能在
# TextPath geometry 中,也可能在 shape 正文(Word 2003 XML 导入如此)
if get_custom_shape_type(shape).startswith("fontwork"):
parts = [extract_text_path_text(shape)]
try:
parts.append(shape.getString() or "")
except Exception:
pass
text = " ".join(parts).lower()
if text and any(k in text for k in keywords):
return True
except Exception:
pass
return False
def stop_listener(proc: subprocess.Popen, kill_after: float = 3.0) -> None:
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=kill_after)
except subprocess.TimeoutExpired:
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
def remove_lock_files(*paths: Path) -> None:
for p in paths:
lock = p.parent / (".~lock." + p.name + "#")
try:
lock.unlink()
except OSError:
pass
def run(args) -> int:
input_path = Path(args.input).resolve()
output_path = Path(args.output).resolve()
keywords = [k.strip().lower() for k in args.keywords.split(",") if k.strip()]
profile_dir = Path(tempfile.mkdtemp(prefix=f"lo_uno_{os.getpid()}_"))
proc = None
desktop = None
removed = 0
try:
# 启动 listener 并连接;失败则清理后换端口重试一次
ctx = None
last_exc = None
for attempt in range(2):
if attempt > 0:
shutil.rmtree(profile_dir, ignore_errors=True)
profile_dir = Path(tempfile.mkdtemp(prefix=f"lo_uno_{os.getpid()}_"))
port = pick_free_port()
proc = start_listener(port, profile_dir)
try:
connect_timeout = min(float(args.connect_timeout), float(args.timeout))
ctx = connect(port, time.monotonic() + connect_timeout)
break
except Exception as exc:
last_exc = exc
stop_listener(proc)
proc = None
if ctx is None:
print(f"ERROR: UNO listener 启动/连接失败: {last_exc}", file=sys.stderr)
return EXIT_LISTENER
smgr = ctx.ServiceManager
desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
in_url = uno.systemPathToFileUrl(str(input_path))
out_url = uno.systemPathToFileUrl(str(output_path))
try:
doc = desktop.loadComponentFromURL(
in_url, "_blank", 0,
(make_prop("Hidden", True), make_prop("ReadOnly", False)),
)
except Exception as exc:
print(f"ERROR: 文档加载失败: {exc}", file=sys.stderr)
return EXIT_LOAD
if doc is None:
print("ERROR: 文档加载失败(loadComponentFromURL 返回 None)", file=sys.stderr)
return EXIT_LOAD
try:
draw_page = doc.getDrawPage()
to_remove = []
for i in range(draw_page.getCount()):
shape = draw_page.getByIndex(i)
if is_watermark_shape(shape, keywords):
to_remove.append(shape)
for shape in to_remove:
draw_page.remove(shape)
removed += 1
doc.storeToURL(
out_url,
(make_prop("FilterName", args.filter), make_prop("Overwrite", True)),
)
except Exception as exc:
print(f"ERROR: 处理/保存失败: {exc}", file=sys.stderr)
return EXIT_PROCESS
finally:
try:
doc.close(False)
except Exception:
pass
finally:
if desktop is not None:
try:
desktop.terminate()
except Exception:
pass
if proc is not None:
stop_listener(proc)
shutil.rmtree(profile_dir, ignore_errors=True)
remove_lock_files(input_path, output_path)
print(f"RESULT: removed={removed}")
return EXIT_OK
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="LibreOffice UNO 直改去水印")
parser.add_argument("--input", required=True, help="输入文件路径")
parser.add_argument("--output", required=True, help="输出文件路径")
parser.add_argument("--filter", required=True, help="另存 FilterName,如 MS Word 97")
parser.add_argument("--keywords", default="", help="水印关键词,逗号分隔")
parser.add_argument("--timeout", type=float, default=120, help="处理超时(秒)")
parser.add_argument("--connect-timeout", type=float, default=30, help="listener 连接超时(秒)")
args = parser.parse_args(argv)
if not UNO_OK:
print("ERROR: python3-uno 模块不可用", file=sys.stderr)
return EXIT_MISSING
if shutil.which("soffice") is None:
print("ERROR: 未找到 soffice 命令", file=sys.stderr)
return EXIT_MISSING
return run(args)
if __name__ == "__main__":
sys.exit(main())
"""LibreOffice UNO 直改去水印的 venv 侧编排(本模块永不 import uno)。
通过子进程调用系统 python3 执行 backend/scripts/lo_uno_remove.py:
UNO 脚本自持 listener 生命周期,本模块只负责编排(预检、超时、进程组清理、结果解析)。
"""
import os
import re
import signal
import subprocess
from pathlib import Path
from config import (
BASE_DIR,
SOFFICE_TIMEOUT,
SYSTEM_PYTHON,
UNO_CONNECT_TIMEOUT,
UNO_SCRIPT_REL,
WATERMARK_KEYWORDS,
)
UNO_SCRIPT = BASE_DIR / UNO_SCRIPT_REL
_RESULT_RE = re.compile(r"^RESULT:\s*removed=(\d+)\s*$", re.MULTILINE)
_uno_checked = False
class UnoUnavailableError(RuntimeError):
"""UNO 环境不可用(uno 模块/soffice 缺失,或 listener 无法启动连接)。"""
class UnoTimeoutError(RuntimeError):
"""UNO 处理超时。"""
class UnoProcessError(RuntimeError):
"""UNO 加载/处理/保存失败。"""
def _check_uno_available() -> None:
global _uno_checked
if _uno_checked:
return
try:
subprocess.run(
[SYSTEM_PYTHON, "-c", "import uno"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=15,
check=True,
)
except FileNotFoundError as exc:
raise UnoUnavailableError(f"系统 python3 不存在: {SYSTEM_PYTHON}") from exc
except subprocess.TimeoutExpired as exc:
raise UnoUnavailableError("系统 python3 import uno 预检超时") from exc
except subprocess.CalledProcessError as exc:
raise UnoUnavailableError("系统 python3 缺少 uno 模块(未安装 python3-uno)") from exc
_uno_checked = True
def _kill_process_group(proc: subprocess.Popen) -> None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
proc.kill()
except Exception:
pass
def uno_remove_watermark(
input_path: Path,
output_path: Path,
filter_name: str,
timeout: int = SOFFICE_TIMEOUT,
) -> int:
"""UNO 直改去水印,返回删除的 shape 数。失败按三类异常抛出。"""
_check_uno_available()
cmd = [
SYSTEM_PYTHON,
str(UNO_SCRIPT),
"--input", str(input_path),
"--output", str(output_path),
"--filter", filter_name,
"--keywords", ",".join(WATERMARK_KEYWORDS),
"--timeout", str(timeout),
"--connect-timeout", str(UNO_CONNECT_TIMEOUT),
]
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
out, err = proc.communicate(timeout=timeout + 30)
except subprocess.TimeoutExpired as exc:
_kill_process_group(proc)
proc.communicate()
raise UnoTimeoutError(f"UNO 处理超时({timeout + 30}s): {input_path.name}") from exc
stdout = out.decode("utf-8", errors="ignore")
stderr = err.decode("utf-8", errors="ignore")
if proc.returncode == 0:
match = _RESULT_RE.search(stdout)
if not match:
raise UnoProcessError(f"UNO 脚本输出异常: {stdout!r}")
return int(match.group(1))
message = stderr.strip() or stdout.strip()
if proc.returncode in (2, 3):
raise UnoUnavailableError(f"UNO 不可用(退出码 {proc.returncode}): {message}")
if proc.returncode in (4, 5):
raise UnoProcessError(f"UNO 处理失败(退出码 {proc.returncode}): {message}")
raise UnoProcessError(f"UNO 脚本异常退出(码 {proc.returncode}): {message}")
......@@ -2,7 +2,7 @@
<div class="app">
<header class="header">
<h1>文档去水印工具</h1>
<p class="subtitle">全自动识别并删除 DOC、DOCX、PDF 文件中的文字与图片水印</p>
<p class="subtitle">全自动识别并删除 DOC、DOCX、DOCM、DOTX、DOTM、DOT、XML、PDF 文件中的文字与图片水印</p>
</header>
<main class="main">
<FileUploader />
......@@ -13,13 +13,14 @@
<li>下载文件名将自动加上下划线前缀,例如 <code>报告.docx</code><code>_报告.docx</code></li>
<li>服务器不会保留您的原始文件与结果文件,处理完成后立即删除。</li>
<li>对于复杂 PDF 水印或自定义图片水印,去除效果可能有限。</li>
<li>DOC 文件会经过 LibreOffice 转换为 DOCX 处理后再转回 DOC,排版可能存在细微变化。</li>
<li>DOC 文件通过 LibreOffice UNO 直接识别并删除水印,保持原格式保存,不经过格式转换;特殊情况下会自动回退到转换方案,排版可能存在细微变化。</li>
<li>DOCM 文件直接按 DOCX 结构处理,文档中的宏会被完整保留。</li>
<li>DOTX / DOTM 模板文件与 DOCX 结构相同,处理逻辑一致(DOTM 的宏同样保留)。</li>
<li>DOT 与 XML 文件与 DOC 相同,优先直接原格式去除水印,失败时回退转换方案。</li>
<li>DOC / DOT / XML 中的图片水印暂不支持识别,仅处理文字水印。</li>
</ul>
</div>
</main>
<footer class="footer">
<p>本地运行 · 数据不留存</p>
</footer>
</div>
</template>
......
......@@ -12,13 +12,13 @@
<input
ref="fileInput"
type="file"
accept=".doc,.docx,.pdf"
accept=".doc,.docx,.docm,.dotx,.dotm,.dot,.xml,.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>
<p class="hint">支持格式:doc、docx、docm、dotx、dotm、dot、xml、pdf(最大 20MB)</p>
</div>
<div class="actions">
......@@ -52,7 +52,7 @@
import { ref } from 'vue'
import { removeWatermark } from '../api/watermark.js'
const ALLOWED_EXTENSIONS = ['doc', 'docx', 'pdf']
const ALLOWED_EXTENSIONS = ['doc', 'docx', 'docm', 'dotx', 'dotm', 'dot', 'xml', 'pdf']
const MAX_SIZE = 20 * 1024 * 1024
const fileInput = ref(null)
......@@ -75,7 +75,7 @@ function setMessage(text, type = 'info') {
function handleFile(file) {
if (!file) return
if (!isAllowed(file.name)) {
setMessage('仅支持 doc、docx、pdf 格式', 'error')
setMessage('仅支持 doc、docx、docm、dotx、dotm、dot、xml、pdf 格式', 'error')
selectedFile.value = null
return
}
......
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