#!/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