feat: python测试程序 word->html->word

This commit is contained in:
zwt13703 2026-07-08 19:44:35 +08:00
parent b1de886828
commit c7e1baf417
8 changed files with 879 additions and 0 deletions

View File

@ -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"]

View File

@ -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. 典型复杂样例人工抽检。

596
word_html_converter/converter.py Executable file
View File

@ -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.

View File

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

View File

@ -0,0 +1,6 @@
beautifulsoup4>=4.12
lxml>=5.0
tinycss2>=1.2
python-docx>=1.1
pillow>=10.0
pymupdf>=1.24