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