feat: python测试程序 word->html->word
This commit is contained in:
parent
b1de886828
commit
c7e1baf417
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libreoffice libreoffice-writer fonts-noto-cjk fonts-dejavu fontconfig \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
COPY converter.py /app/converter.py
|
||||||
|
COPY demo /app/demo
|
||||||
|
|
||||||
|
ENTRYPOINT ["python", "/app/converter.py"]
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
# Word ↔ HTML High-Fidelity Converter
|
||||||
|
|
||||||
|
一个基于开源工具的 `.docx` 与 `.html` 双向转换程序,目标是尽量保持 Word 文档在浏览器和再次转回 Word 后的视觉效果一致。
|
||||||
|
|
||||||
|
> 现实说明:DOCX 和 HTML/CSS 的排版模型不同,严格意义上的“完全无损双向转换”不能只靠普通 HTML 实现。本项目采用 LibreOffice Writer 的转换引擎做高保真转换,再做 HTML/CSS 后处理;同时提供 PDF 渲染级视觉对比报告,便于验收。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- DOCX → HTML
|
||||||
|
- 保留字体、字号、颜色、粗体、斜体、下划线、删除线
|
||||||
|
- 保留段落对齐、缩进、行距、页边距、分页符
|
||||||
|
- 保留表格结构、边框、合并单元格、部分背景色样式
|
||||||
|
- 保留图片、大小、位置、页眉页脚文本
|
||||||
|
- 将 `<style>` 中的规则尽量内联到元素 `style` 中,降低浏览器打开时样式丢失概率
|
||||||
|
|
||||||
|
- HTML → DOCX
|
||||||
|
- 使用 LibreOffice 的 Writer HTML 导入器,解析 HTML/CSS 并导出 DOCX
|
||||||
|
- 对本程序生成的 HTML 回转 DOCX,视觉一致性更好
|
||||||
|
|
||||||
|
- 验收校验
|
||||||
|
- DOCX → PDF 渲染
|
||||||
|
- 将原始 DOCX 与回转 DOCX 的 PDF 页面转图片后做像素差异报告
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
### 1. 安装 LibreOffice
|
||||||
|
|
||||||
|
Linux 示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libreoffice libreoffice-writer fonts-noto-cjk fontconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS:安装 LibreOffice 后,通常路径为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/LibreOffice.app/Contents/MacOS/soffice
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:安装 LibreOffice 后,通常路径为:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
C:\Program Files\LibreOffice\program\soffice.com
|
||||||
|
```
|
||||||
|
|
||||||
|
如程序找不到 LibreOffice,可以设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SOFFICE_BIN=/path/to/soffice
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装 Python 依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速使用
|
||||||
|
|
||||||
|
### DOCX 转 HTML
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python converter.py docx2html demo/demo.docx output/demo.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML 转 DOCX
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python converter.py html2docx output/demo2.html output/demo2.back.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一键往返转换
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python converter.py roundtrip demo/demo.docx output/roundtrip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一键往返并生成视觉差异报告
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python converter.py roundtrip demo/demo.docx output/roundtrip --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
生成文件示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
output/roundtrip/demo.html
|
||||||
|
output/roundtrip/demo.roundtrip.docx
|
||||||
|
output/roundtrip/demo.visual-report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
视觉报告字段说明:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exact_page_count": true,
|
||||||
|
"page_reports": [
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"same_size": true,
|
||||||
|
"mean_abs_diff_0_255": 4.2738
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `exact_page_count=true`:页数一致
|
||||||
|
- `same_size=true`:渲染页面尺寸一致
|
||||||
|
- `mean_abs_diff_0_255`:平均像素差异,越低越接近;复杂文档可设自己的验收阈值
|
||||||
|
|
||||||
|
## 生成测试 DOCX
|
||||||
|
|
||||||
|
项目里已经包含 `demo/demo.docx`。如果要重新生成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python demo/make_demo_docx.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 使用
|
||||||
|
|
||||||
|
构建镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t word-html-converter .
|
||||||
|
```
|
||||||
|
|
||||||
|
DOCX 转 HTML:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD:/work" word-html-converter \
|
||||||
|
docx2html /work/demo/demo.docx /work/output/demo.html
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML 转 DOCX:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD:/work" word-html-converter \
|
||||||
|
html2docx /work/output/demo.html /work/output/demo.back.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键设计说明
|
||||||
|
|
||||||
|
### 为什么不用 Mammoth 或 Pandoc 作为主引擎?
|
||||||
|
|
||||||
|
- Mammoth 更适合把 DOCX 转成语义清晰的 HTML,它明确不是为了逐像素复制 Word 样式。
|
||||||
|
- Pandoc 很适合文档格式互转,但更偏结构化内容转换,不适合要求高度还原 Word 页面排版的场景。
|
||||||
|
- LibreOffice Writer 的 DOCX/HTML 导入导出更接近真实办公软件排版结果,所以本项目把它作为主转换内核。
|
||||||
|
|
||||||
|
### 为什么 HTML 转 DOCX 要强制 `HTML (StarWriter)` 输入过滤器?
|
||||||
|
|
||||||
|
LibreOffice 默认可能把 HTML 当作 Web 文档打开,导致导出 DOCX 时出现“no export filter”或排版丢失。本项目使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--infilter="HTML (StarWriter)"
|
||||||
|
```
|
||||||
|
|
||||||
|
让 HTML 作为 Writer 文档导入,再导出 Office Open XML DOCX。
|
||||||
|
|
||||||
|
### 可选归档模式
|
||||||
|
|
||||||
|
DOCX 转 HTML 时可以加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python converter.py docx2html input.docx output.html --embed-source
|
||||||
|
```
|
||||||
|
|
||||||
|
这样会把原始 DOCX 以 base64 形式嵌入 HTML。HTML 转 DOCX 时可加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python converter.py html2docx output.html restored.docx --prefer-embedded-source
|
||||||
|
```
|
||||||
|
|
||||||
|
这适合“HTML 只用于预览/存档,希望完全恢复原始 DOCX”的场景。注意:如果用户在 HTML 中编辑了内容,使用该模式会恢复原 DOCX,不会合并 HTML 编辑内容。
|
||||||
|
|
||||||
|
## 已知边界
|
||||||
|
|
||||||
|
以下内容在开源转换链路中很难保证完全一致,需要单独测试:
|
||||||
|
|
||||||
|
- 复杂浮动图片、环绕方式、文本框、艺术字、SmartArt
|
||||||
|
- Word 域、目录、脚注尾注、批注、修订痕迹
|
||||||
|
- 复杂多级编号、样式继承、主题字体
|
||||||
|
- 页面级精确排版,如不同 Word/LibreOffice 版本的字体度量差异
|
||||||
|
- 浏览器编辑 HTML 后再转 DOCX,不能保证所有 CSS 都能被 Writer 完整识别
|
||||||
|
|
||||||
|
## 推荐验收标准
|
||||||
|
|
||||||
|
建议不要用“字节级相同”验收 DOCX,因为二次生成的 DOCX 内部 XML 顺序、关系 ID、压缩结果通常会变。建议用:
|
||||||
|
|
||||||
|
1. 页数一致;
|
||||||
|
2. 关键表格行列、合并单元格、图片数量一致;
|
||||||
|
3. PDF 渲染视觉差异低于业务阈值;
|
||||||
|
4. 典型复杂样例人工抽检。
|
||||||
|
|
@ -0,0 +1,596 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
word-html-converter
|
||||||
|
Open-source DOCX <-> HTML conversion wrapper focused on high visual fidelity.
|
||||||
|
|
||||||
|
Core engine: LibreOffice headless conversion.
|
||||||
|
HTML post-processing: inline CSS, keep @page rules, normalize metadata.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
python converter.py docx2html input.docx output.html
|
||||||
|
python converter.py html2docx input.html output.docx
|
||||||
|
python converter.py roundtrip input.docx output_dir
|
||||||
|
python converter.py verify original.docx converted.docx report.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
|
||||||
|
import tinycss2
|
||||||
|
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||||
|
|
||||||
|
APP_NAME = "word-html-converter"
|
||||||
|
DEFAULT_DOCX_TO_HTML_FILTERS = [
|
||||||
|
# Writer/Web HTML filter preserves more page/header/footer information than the clean XHTML filter.
|
||||||
|
"html",
|
||||||
|
# Fallback clean XHTML Writer filter.
|
||||||
|
"html:XHTML Writer File:UTF8",
|
||||||
|
]
|
||||||
|
DEFAULT_HTML_TO_DOCX_FILTERS = [
|
||||||
|
'docx:"Office Open XML Text"',
|
||||||
|
"docx",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConvertResult:
|
||||||
|
source: str
|
||||||
|
output: str
|
||||||
|
command: List[str]
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
filter_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def find_soffice(explicit: Optional[str] = None) -> str:
|
||||||
|
"""Find LibreOffice/soffice executable on Linux/macOS/Windows."""
|
||||||
|
candidates: List[str] = []
|
||||||
|
if explicit:
|
||||||
|
candidates.append(explicit)
|
||||||
|
if os.environ.get("SOFFICE_BIN"):
|
||||||
|
candidates.append(os.environ["SOFFICE_BIN"])
|
||||||
|
|
||||||
|
candidates.extend([
|
||||||
|
"soffice",
|
||||||
|
"libreoffice",
|
||||||
|
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
|
||||||
|
r"C:\Program Files\LibreOffice\program\soffice.com",
|
||||||
|
r"C:\Program Files\LibreOffice\program\soffice.exe",
|
||||||
|
r"C:\Program Files (x86)\LibreOffice\program\soffice.com",
|
||||||
|
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
|
||||||
|
])
|
||||||
|
for c in candidates:
|
||||||
|
if not c:
|
||||||
|
continue
|
||||||
|
if Path(c).exists():
|
||||||
|
return str(Path(c))
|
||||||
|
resolved = shutil.which(c)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
raise ConversionError(
|
||||||
|
"LibreOffice executable not found. Install LibreOffice, or set SOFFICE_BIN=/path/to/soffice."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def file_uri(path: Path) -> str:
|
||||||
|
return path.resolve().as_uri()
|
||||||
|
|
||||||
|
|
||||||
|
def run_soffice_convert(
|
||||||
|
source: Path,
|
||||||
|
outdir: Path,
|
||||||
|
convert_to: str,
|
||||||
|
soffice_bin: Optional[str] = None,
|
||||||
|
timeout: int = 120,
|
||||||
|
input_filter: Optional[str] = None,
|
||||||
|
) -> Tuple[subprocess.CompletedProcess[str], List[str]]:
|
||||||
|
source = source.resolve()
|
||||||
|
outdir = outdir.resolve()
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
soffice = find_soffice(soffice_bin)
|
||||||
|
|
||||||
|
# Use an isolated LO user profile so conversion works even if desktop LO is open.
|
||||||
|
profile_dir = Path(tempfile.mkdtemp(prefix="lo-profile-"))
|
||||||
|
profile_uri = file_uri(profile_dir)
|
||||||
|
cmd = [
|
||||||
|
soffice,
|
||||||
|
f"-env:UserInstallation={profile_uri}",
|
||||||
|
"--headless",
|
||||||
|
"--nologo",
|
||||||
|
"--nofirststartwizard",
|
||||||
|
"--nodefault",
|
||||||
|
"--nolockcheck",
|
||||||
|
"--norestore",
|
||||||
|
]
|
||||||
|
if input_filter:
|
||||||
|
cmd.append(f"--infilter={input_filter}")
|
||||||
|
cmd.extend([
|
||||||
|
"--convert-to",
|
||||||
|
convert_to,
|
||||||
|
"--outdir",
|
||||||
|
str(outdir),
|
||||||
|
str(source),
|
||||||
|
])
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||||
|
return proc, cmd
|
||||||
|
|
||||||
|
|
||||||
|
def find_converted_file(before: set[Path], outdir: Path, source_stem: str, expected_ext: str) -> Optional[Path]:
|
||||||
|
expected = outdir / f"{source_stem}{expected_ext}"
|
||||||
|
if expected.exists():
|
||||||
|
return expected
|
||||||
|
after = set(outdir.iterdir())
|
||||||
|
created = [p for p in after - before if p.is_file() and p.suffix.lower() == expected_ext.lower()]
|
||||||
|
if created:
|
||||||
|
return max(created, key=lambda p: p.stat().st_mtime)
|
||||||
|
candidates = list(outdir.glob(f"{source_stem}*{expected_ext}"))
|
||||||
|
if candidates:
|
||||||
|
return max(candidates, key=lambda p: p.stat().st_mtime)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def convert_with_fallbacks(
|
||||||
|
source: Path,
|
||||||
|
output: Path,
|
||||||
|
filters: Iterable[str],
|
||||||
|
expected_ext: str,
|
||||||
|
soffice_bin: Optional[str] = None,
|
||||||
|
timeout: int = 120,
|
||||||
|
input_filter: Optional[str] = None,
|
||||||
|
) -> ConvertResult:
|
||||||
|
source = source.resolve()
|
||||||
|
output = output.resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
last_error = ""
|
||||||
|
with tempfile.TemporaryDirectory(prefix="convert-work-") as tmp:
|
||||||
|
tmpdir = Path(tmp)
|
||||||
|
for filter_name in filters:
|
||||||
|
before = set(tmpdir.iterdir())
|
||||||
|
proc, cmd = run_soffice_convert(source, tmpdir, filter_name, soffice_bin, timeout, input_filter=input_filter)
|
||||||
|
converted = find_converted_file(before, tmpdir, source.stem, expected_ext)
|
||||||
|
if proc.returncode == 0 and converted and converted.exists():
|
||||||
|
if output.exists():
|
||||||
|
output.unlink()
|
||||||
|
shutil.move(str(converted), str(output))
|
||||||
|
# Move sidecar image/assets generated by HTML export.
|
||||||
|
for item in tmpdir.iterdir():
|
||||||
|
if item.is_file() and item.name != output.name:
|
||||||
|
dest = output.parent / item.name
|
||||||
|
if dest.exists():
|
||||||
|
dest.unlink()
|
||||||
|
shutil.move(str(item), str(dest))
|
||||||
|
return ConvertResult(
|
||||||
|
source=str(source),
|
||||||
|
output=str(output),
|
||||||
|
command=cmd,
|
||||||
|
stdout=proc.stdout,
|
||||||
|
stderr=proc.stderr,
|
||||||
|
filter_name=filter_name,
|
||||||
|
)
|
||||||
|
last_error = (
|
||||||
|
f"Filter failed: {filter_name}\n"
|
||||||
|
f"Return code: {proc.returncode}\nSTDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}"
|
||||||
|
)
|
||||||
|
raise ConversionError(last_error or "No LibreOffice conversion filter succeeded.")
|
||||||
|
|
||||||
|
|
||||||
|
def style_to_dict(style: str) -> Dict[str, str]:
|
||||||
|
result: Dict[str, str] = {}
|
||||||
|
if not style:
|
||||||
|
return result
|
||||||
|
declarations = tinycss2.parse_declaration_list(style, skip_comments=True, skip_whitespace=True)
|
||||||
|
for d in declarations:
|
||||||
|
if getattr(d, "type", None) == "declaration" and not d.name.startswith("--"):
|
||||||
|
value = tinycss2.serialize(d.value).strip()
|
||||||
|
if d.important:
|
||||||
|
value = f"{value} !important"
|
||||||
|
result[d.name.lower()] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dict_to_style(d: Dict[str, str]) -> str:
|
||||||
|
return "; ".join(f"{k}: {v}" for k, v in d.items() if v) + (";" if d else "")
|
||||||
|
|
||||||
|
|
||||||
|
def merge_inline_style(tag, declarations: Dict[str, str]) -> None:
|
||||||
|
current = style_to_dict(tag.get("style", ""))
|
||||||
|
current.update(declarations)
|
||||||
|
tag["style"] = dict_to_style(current)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_css_rules(css: str) -> Tuple[List[Tuple[str, Dict[str, str]]], str]:
|
||||||
|
"""Return normal CSS rules plus preserved at-rules such as @page/@media."""
|
||||||
|
normal_rules: List[Tuple[str, Dict[str, str]]] = []
|
||||||
|
preserved_at_rules: List[str] = []
|
||||||
|
rules = tinycss2.parse_stylesheet(css, skip_comments=True, skip_whitespace=True)
|
||||||
|
for rule in rules:
|
||||||
|
if rule.type == "qualified-rule":
|
||||||
|
selector = tinycss2.serialize(rule.prelude).strip()
|
||||||
|
declarations: Dict[str, str] = {}
|
||||||
|
for d in tinycss2.parse_declaration_list(rule.content, skip_comments=True, skip_whitespace=True):
|
||||||
|
if getattr(d, "type", None) == "declaration":
|
||||||
|
value = tinycss2.serialize(d.value).strip()
|
||||||
|
if d.important:
|
||||||
|
value = f"{value} !important"
|
||||||
|
declarations[d.name.lower()] = value
|
||||||
|
if selector and declarations:
|
||||||
|
normal_rules.append((selector, declarations))
|
||||||
|
elif rule.type == "at-rule":
|
||||||
|
# @page is important for print/page-like preview; @media may contain print rules.
|
||||||
|
preserved_at_rules.append(tinycss2.serialize([rule]).strip())
|
||||||
|
return normal_rules, "\n".join(preserved_at_rules)
|
||||||
|
|
||||||
|
|
||||||
|
def inline_css(html: str, keep_style_tag: bool = True) -> str:
|
||||||
|
"""Inline style rules from <style> tags. This is intentionally conservative.
|
||||||
|
|
||||||
|
It handles most simple selectors generated by LibreOffice. Unsupported complex selectors
|
||||||
|
are skipped rather than breaking conversion.
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
style_tags = soup.find_all("style")
|
||||||
|
preserved_css: List[str] = []
|
||||||
|
|
||||||
|
for st in style_tags:
|
||||||
|
css = st.string or st.get_text() or ""
|
||||||
|
normal_rules, at_rules = parse_css_rules(css)
|
||||||
|
if at_rules:
|
||||||
|
preserved_css.append(at_rules)
|
||||||
|
for selector, declarations in normal_rules:
|
||||||
|
selectors = [s.strip() for s in selector.split(",") if s.strip()]
|
||||||
|
for sel in selectors:
|
||||||
|
# Browser-only pseudo-selectors do not make sense for DOCX reconstruction.
|
||||||
|
if ":" in sel and not re.search(r":(first-child|last-child|nth-child)", sel):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
matches = soup.select(sel)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for tag in matches:
|
||||||
|
merge_inline_style(tag, declarations)
|
||||||
|
if not keep_style_tag:
|
||||||
|
st.decompose()
|
||||||
|
|
||||||
|
if keep_style_tag:
|
||||||
|
head = soup.head or soup.new_tag("head")
|
||||||
|
if not soup.head and soup.html:
|
||||||
|
soup.html.insert(0, head)
|
||||||
|
if preserved_css:
|
||||||
|
new_style = soup.new_tag("style")
|
||||||
|
new_style.string = "\n".join(preserved_css)
|
||||||
|
head.append(new_style)
|
||||||
|
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_html_metadata(html_path: Path, source_docx: Optional[Path] = None) -> None:
|
||||||
|
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
if soup.html is None:
|
||||||
|
new_html = soup.new_tag("html")
|
||||||
|
new_html.extend(soup.contents)
|
||||||
|
soup.append(new_html)
|
||||||
|
if soup.head is None:
|
||||||
|
head = soup.new_tag("head")
|
||||||
|
soup.html.insert(0, head)
|
||||||
|
if not soup.head.find("meta", attrs={"charset": True}):
|
||||||
|
meta = soup.new_tag("meta", charset="utf-8")
|
||||||
|
soup.head.insert(0, meta)
|
||||||
|
if not soup.head.find("meta", attrs={"name": "generator"}):
|
||||||
|
meta = soup.new_tag("meta")
|
||||||
|
meta["name"] = "generator"
|
||||||
|
meta["content"] = f"{APP_NAME}; LibreOffice headless"
|
||||||
|
soup.head.append(meta)
|
||||||
|
if source_docx and not soup.head.find("meta", attrs={"name": "docx-source-sha256"}):
|
||||||
|
meta = soup.new_tag("meta")
|
||||||
|
meta["name"] = "docx-source-sha256"
|
||||||
|
meta["content"] = sha256_file(source_docx)
|
||||||
|
soup.head.append(meta)
|
||||||
|
html_path.write_text(str(soup), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def embed_source_docx(html_path: Path, source_docx: Path) -> None:
|
||||||
|
"""Optional: embed original docx for audit/archival round-trip fallback.
|
||||||
|
|
||||||
|
This does NOT merge arbitrary HTML edits back into the original docx. It is useful for
|
||||||
|
proving byte-exact preservation when the HTML is only used for preview/storage.
|
||||||
|
"""
|
||||||
|
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
if soup.body is None:
|
||||||
|
body = soup.new_tag("body")
|
||||||
|
if soup.html:
|
||||||
|
soup.html.append(body)
|
||||||
|
else:
|
||||||
|
soup.append(body)
|
||||||
|
old = soup.find(id="__source_docx_base64__")
|
||||||
|
if old:
|
||||||
|
old.decompose()
|
||||||
|
script = soup.new_tag("script")
|
||||||
|
script["type"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document+base64"
|
||||||
|
script["id"] = "__source_docx_base64__"
|
||||||
|
script.string = base64.b64encode(source_docx.read_bytes()).decode("ascii")
|
||||||
|
soup.body.append(script)
|
||||||
|
html_path.write_text(str(soup), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def restore_embedded_docx_if_present(html_path: Path, output_docx: Path) -> bool:
|
||||||
|
html = html_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
script = soup.find(id="__source_docx_base64__")
|
||||||
|
if not script or not script.string:
|
||||||
|
return False
|
||||||
|
output_docx.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_docx.write_bytes(base64.b64decode(script.string.strip()))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def docx_to_html(
|
||||||
|
source_docx: Path,
|
||||||
|
output_html: Path,
|
||||||
|
soffice_bin: Optional[str] = None,
|
||||||
|
inline: bool = True,
|
||||||
|
embed_source: bool = False,
|
||||||
|
timeout: int = 120,
|
||||||
|
) -> ConvertResult:
|
||||||
|
if source_docx.suffix.lower() != ".docx":
|
||||||
|
raise ConversionError("Input file must be .docx")
|
||||||
|
result = convert_with_fallbacks(
|
||||||
|
source=source_docx,
|
||||||
|
output=output_html,
|
||||||
|
filters=DEFAULT_DOCX_TO_HTML_FILTERS,
|
||||||
|
expected_ext=".html",
|
||||||
|
soffice_bin=soffice_bin,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
ensure_html_metadata(output_html, source_docx)
|
||||||
|
if inline:
|
||||||
|
html = output_html.read_text(encoding="utf-8", errors="replace")
|
||||||
|
output_html.write_text(inline_css(html, keep_style_tag=True), encoding="utf-8")
|
||||||
|
if embed_source:
|
||||||
|
embed_source_docx(output_html, source_docx)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_docx(
|
||||||
|
source_html: Path,
|
||||||
|
output_docx: Path,
|
||||||
|
soffice_bin: Optional[str] = None,
|
||||||
|
timeout: int = 120,
|
||||||
|
prefer_embedded_source: bool = False,
|
||||||
|
) -> ConvertResult:
|
||||||
|
if source_html.suffix.lower() not in {".html", ".htm", ".xhtml"}:
|
||||||
|
raise ConversionError("Input file must be .html/.htm/.xhtml")
|
||||||
|
if prefer_embedded_source and restore_embedded_docx_if_present(source_html, output_docx):
|
||||||
|
return ConvertResult(
|
||||||
|
source=str(source_html.resolve()),
|
||||||
|
output=str(output_docx.resolve()),
|
||||||
|
command=["restore-embedded-docx"],
|
||||||
|
stdout="Restored embedded source DOCX.",
|
||||||
|
stderr="",
|
||||||
|
filter_name="embedded-docx",
|
||||||
|
)
|
||||||
|
return convert_with_fallbacks(
|
||||||
|
source=source_html,
|
||||||
|
output=output_docx,
|
||||||
|
filters=DEFAULT_HTML_TO_DOCX_FILTERS,
|
||||||
|
expected_ext=".docx",
|
||||||
|
soffice_bin=soffice_bin,
|
||||||
|
timeout=timeout,
|
||||||
|
input_filter="HTML (StarWriter)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_pdf(source: Path, output_pdf: Path, soffice_bin: Optional[str] = None, timeout: int = 120) -> ConvertResult:
|
||||||
|
return convert_with_fallbacks(
|
||||||
|
source=source,
|
||||||
|
output=output_pdf,
|
||||||
|
filters=['pdf:"writer_pdf_Export"', "pdf"],
|
||||||
|
expected_ext=".pdf",
|
||||||
|
soffice_bin=soffice_bin,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_docx_visual_similarity(
|
||||||
|
original_docx: Path,
|
||||||
|
converted_docx: Path,
|
||||||
|
report_json: Path,
|
||||||
|
soffice_bin: Optional[str] = None,
|
||||||
|
dpi: int = 120,
|
||||||
|
timeout: int = 120,
|
||||||
|
) -> Dict[str, object]:
|
||||||
|
"""Render two DOCX files to PDF, then compare page images.
|
||||||
|
|
||||||
|
Requires PyMuPDF and Pillow. The score is pragmatic, not a formal proof.
|
||||||
|
exact_page_count=true and low mean_abs_diff are good signs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import fitz # type: ignore
|
||||||
|
from PIL import ImageChops, ImageStat # type: ignore
|
||||||
|
except Exception as exc:
|
||||||
|
raise ConversionError("verify requires pymupdf and pillow: pip install pymupdf pillow") from exc
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="verify-") as tmp:
|
||||||
|
tmpdir = Path(tmp)
|
||||||
|
pdf1 = tmpdir / "original.pdf"
|
||||||
|
pdf2 = tmpdir / "converted.pdf"
|
||||||
|
convert_to_pdf(original_docx, pdf1, soffice_bin, timeout)
|
||||||
|
convert_to_pdf(converted_docx, pdf2, soffice_bin, timeout)
|
||||||
|
|
||||||
|
doc1 = fitz.open(str(pdf1))
|
||||||
|
doc2 = fitz.open(str(pdf2))
|
||||||
|
pages = min(len(doc1), len(doc2))
|
||||||
|
page_reports: List[Dict[str, object]] = []
|
||||||
|
zoom = dpi / 72.0
|
||||||
|
matrix = fitz.Matrix(zoom, zoom)
|
||||||
|
for i in range(pages):
|
||||||
|
p1 = doc1.load_page(i).get_pixmap(matrix=matrix, alpha=False)
|
||||||
|
p2 = doc2.load_page(i).get_pixmap(matrix=matrix, alpha=False)
|
||||||
|
img1 = p1.pil_image()
|
||||||
|
img2 = p2.pil_image()
|
||||||
|
same_size = img1.size == img2.size
|
||||||
|
if not same_size:
|
||||||
|
# Compare common area, record size mismatch.
|
||||||
|
w = min(img1.width, img2.width)
|
||||||
|
h = min(img1.height, img2.height)
|
||||||
|
img1 = img1.crop((0, 0, w, h))
|
||||||
|
img2 = img2.crop((0, 0, w, h))
|
||||||
|
diff = ImageChops.difference(img1, img2)
|
||||||
|
stat = ImageStat.Stat(diff)
|
||||||
|
mean_abs_diff = sum(stat.mean) / len(stat.mean)
|
||||||
|
page_reports.append({
|
||||||
|
"page": i + 1,
|
||||||
|
"same_size": same_size,
|
||||||
|
"mean_abs_diff_0_255": round(mean_abs_diff, 4),
|
||||||
|
})
|
||||||
|
|
||||||
|
report: Dict[str, object] = {
|
||||||
|
"original": str(original_docx.resolve()),
|
||||||
|
"converted": str(converted_docx.resolve()),
|
||||||
|
"original_sha256": sha256_file(original_docx),
|
||||||
|
"converted_sha256": sha256_file(converted_docx),
|
||||||
|
"original_pages": len(doc1),
|
||||||
|
"converted_pages": len(doc2),
|
||||||
|
"exact_page_count": len(doc1) == len(doc2),
|
||||||
|
"dpi": dpi,
|
||||||
|
"pages_compared": pages,
|
||||||
|
"page_reports": page_reports,
|
||||||
|
}
|
||||||
|
report_json.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_docx2html(args: argparse.Namespace) -> None:
|
||||||
|
r = docx_to_html(
|
||||||
|
Path(args.input),
|
||||||
|
Path(args.output),
|
||||||
|
soffice_bin=args.soffice,
|
||||||
|
inline=not args.no_inline_css,
|
||||||
|
embed_source=args.embed_source,
|
||||||
|
timeout=args.timeout,
|
||||||
|
)
|
||||||
|
print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_html2docx(args: argparse.Namespace) -> None:
|
||||||
|
r = html_to_docx(
|
||||||
|
Path(args.input),
|
||||||
|
Path(args.output),
|
||||||
|
soffice_bin=args.soffice,
|
||||||
|
timeout=args.timeout,
|
||||||
|
prefer_embedded_source=args.prefer_embedded_source,
|
||||||
|
)
|
||||||
|
print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_roundtrip(args: argparse.Namespace) -> None:
|
||||||
|
src = Path(args.input).resolve()
|
||||||
|
outdir = Path(args.output_dir).resolve()
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
html = outdir / f"{src.stem}.html"
|
||||||
|
docx_back = outdir / f"{src.stem}.roundtrip.docx"
|
||||||
|
report = outdir / f"{src.stem}.visual-report.json"
|
||||||
|
r1 = docx_to_html(src, html, args.soffice, inline=not args.no_inline_css, embed_source=args.embed_source, timeout=args.timeout)
|
||||||
|
r2 = html_to_docx(html, docx_back, args.soffice, timeout=args.timeout, prefer_embedded_source=args.prefer_embedded_source)
|
||||||
|
payload = {"docx2html": asdict(r1), "html2docx": asdict(r2)}
|
||||||
|
if args.verify:
|
||||||
|
payload["verify"] = verify_docx_visual_similarity(src, docx_back, report, args.soffice, args.dpi, args.timeout)
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_verify(args: argparse.Namespace) -> None:
|
||||||
|
report = verify_docx_visual_similarity(
|
||||||
|
Path(args.original),
|
||||||
|
Path(args.converted),
|
||||||
|
Path(args.report),
|
||||||
|
soffice_bin=args.soffice,
|
||||||
|
dpi=args.dpi,
|
||||||
|
timeout=args.timeout,
|
||||||
|
)
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(description="Bidirectional high-fidelity DOCX <-> HTML converter using open-source tools.")
|
||||||
|
p.add_argument("--soffice", default=None, help="Path to LibreOffice soffice. Can also use SOFFICE_BIN env var.")
|
||||||
|
p.add_argument("--timeout", type=int, default=120, help="Conversion timeout seconds. Default: 120")
|
||||||
|
sub = p.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
a = sub.add_parser("docx2html", help="Convert DOCX to HTML")
|
||||||
|
a.add_argument("input")
|
||||||
|
a.add_argument("output")
|
||||||
|
a.add_argument("--no-inline-css", action="store_true", help="Do not inline CSS from <style> tags")
|
||||||
|
a.add_argument("--embed-source", action="store_true", help="Embed original DOCX in HTML as base64 for archival fallback")
|
||||||
|
a.set_defaults(func=cmd_docx2html)
|
||||||
|
|
||||||
|
a = sub.add_parser("html2docx", help="Convert HTML to DOCX")
|
||||||
|
a.add_argument("input")
|
||||||
|
a.add_argument("output")
|
||||||
|
a.add_argument("--prefer-embedded-source", action="store_true", help="If HTML contains embedded original DOCX, restore it instead of converting")
|
||||||
|
a.set_defaults(func=cmd_html2docx)
|
||||||
|
|
||||||
|
a = sub.add_parser("roundtrip", help="DOCX -> HTML -> DOCX")
|
||||||
|
a.add_argument("input")
|
||||||
|
a.add_argument("output_dir")
|
||||||
|
a.add_argument("--no-inline-css", action="store_true")
|
||||||
|
a.add_argument("--embed-source", action="store_true")
|
||||||
|
a.add_argument("--prefer-embedded-source", action="store_true")
|
||||||
|
a.add_argument("--verify", action="store_true", help="Generate PDF-render visual diff report")
|
||||||
|
a.add_argument("--dpi", type=int, default=120)
|
||||||
|
a.set_defaults(func=cmd_roundtrip)
|
||||||
|
|
||||||
|
a = sub.add_parser("verify", help="Render two DOCX files to PDF and compare page images")
|
||||||
|
a.add_argument("original")
|
||||||
|
a.add_argument("converted")
|
||||||
|
a.add_argument("report")
|
||||||
|
a.add_argument("--dpi", type=int, default=120)
|
||||||
|
a.set_defaults(func=cmd_verify)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
args.func(args)
|
||||||
|
return 0
|
||||||
|
except ConversionError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
|
@ -0,0 +1,71 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from docx import Document
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.shared import Cm, Pt, RGBColor
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
out_dir = Path(__file__).resolve().parent
|
||||||
|
img_path = out_dir / "demo-image.png"
|
||||||
|
image = Image.new("RGB", (420, 160), "white")
|
||||||
|
d = ImageDraw.Draw(image)
|
||||||
|
d.rectangle([10, 10, 410, 150], outline="black", width=2)
|
||||||
|
d.text((30, 60), "DOCX embedded image", fill="black")
|
||||||
|
image.save(img_path)
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
section = doc.sections[0]
|
||||||
|
section.top_margin = Cm(2)
|
||||||
|
section.bottom_margin = Cm(2)
|
||||||
|
section.left_margin = Cm(2)
|
||||||
|
section.right_margin = Cm(2)
|
||||||
|
section.header.paragraphs[0].text = "页眉:Word/HTML 双向转换测试"
|
||||||
|
section.footer.paragraphs[0].text = "页脚:保留页眉页脚测试"
|
||||||
|
|
||||||
|
style = doc.styles["Normal"]
|
||||||
|
style.font.name = "Microsoft YaHei"
|
||||||
|
style.font.size = Pt(12)
|
||||||
|
|
||||||
|
h = doc.add_heading("Word 转 HTML 测试文件", level=1)
|
||||||
|
h.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.first_line_indent = Cm(0.74)
|
||||||
|
p.paragraph_format.line_spacing = 1.5
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||||
|
r = p.add_run("这是一段测试文本:")
|
||||||
|
r.font.size = Pt(12)
|
||||||
|
r.font.color.rgb = RGBColor(0, 0, 0)
|
||||||
|
r = p.add_run("加粗")
|
||||||
|
r.bold = True
|
||||||
|
r = p.add_run("、斜体")
|
||||||
|
r.italic = True
|
||||||
|
r = p.add_run("、下划线")
|
||||||
|
r.underline = True
|
||||||
|
r = p.add_run("、删除线")
|
||||||
|
r.font.strike = True
|
||||||
|
r = p.add_run("、红色文字。")
|
||||||
|
r.font.color.rgb = RGBColor(192, 0, 0)
|
||||||
|
|
||||||
|
for text in ["一级项目 1", "一级项目 2"]:
|
||||||
|
doc.add_paragraph(text, style="List Bullet")
|
||||||
|
for text in ["编号项目 1", "编号项目 2"]:
|
||||||
|
doc.add_paragraph(text, style="List Number")
|
||||||
|
|
||||||
|
table = doc.add_table(rows=3, cols=3)
|
||||||
|
table.style = "Table Grid"
|
||||||
|
for i, row in enumerate(table.rows):
|
||||||
|
for j, cell in enumerate(row.cells):
|
||||||
|
cell.text = f"R{i+1}C{j+1}"
|
||||||
|
# Merge first row cells 1-2
|
||||||
|
merged = table.cell(0, 0).merge(table.cell(0, 1))
|
||||||
|
merged.text = "合并单元格"
|
||||||
|
|
||||||
|
p = doc.add_paragraph("下方是一张嵌入图片:")
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||||
|
doc.add_picture(str(img_path), width=Cm(8))
|
||||||
|
|
||||||
|
doc.add_page_break()
|
||||||
|
doc.add_paragraph("第二页内容,用于测试分页。")
|
||||||
|
|
||||||
|
doc.save(out_dir / "demo.docx")
|
||||||
|
print(out_dir / "demo.docx")
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,6 @@
|
||||||
|
beautifulsoup4>=4.12
|
||||||
|
lxml>=5.0
|
||||||
|
tinycss2>=1.2
|
||||||
|
python-docx>=1.1
|
||||||
|
pillow>=10.0
|
||||||
|
pymupdf>=1.24
|
||||||
Loading…
Reference in New Issue