200 lines
6.3 KiB
Python
200 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from _docx_common import (
|
||
DOCUMENT_OUTPUT_SUFFIXES,
|
||
WORD_INPUT_SUFFIXES,
|
||
SkillArgumentParser,
|
||
find_program,
|
||
input_file,
|
||
output_file,
|
||
publish_file,
|
||
run_cli,
|
||
run_program,
|
||
run_soffice_convert,
|
||
)
|
||
|
||
|
||
def _plain_text(document: Any) -> str:
|
||
chunks: list[str] = []
|
||
for paragraph in document.paragraphs:
|
||
chunks.append(paragraph.text)
|
||
for table_index, table in enumerate(document.tables, start=1):
|
||
chunks.append(f"\n[表格 {table_index}]")
|
||
for row in table.rows:
|
||
chunks.append("\t".join(cell.text for cell in row.cells))
|
||
return "\n".join(chunks).rstrip() + "\n"
|
||
|
||
|
||
def _markdown(document: Any) -> str:
|
||
chunks: list[str] = []
|
||
for paragraph in document.paragraphs:
|
||
style_name = paragraph.style.name if paragraph.style else ""
|
||
if style_name.startswith("Heading "):
|
||
try:
|
||
level = int(style_name.split()[-1])
|
||
except ValueError:
|
||
level = 1
|
||
chunks.append(f"{'#' * min(max(level, 1), 6)} {paragraph.text}")
|
||
elif style_name.startswith("List Bullet"):
|
||
chunks.append(f"- {paragraph.text}")
|
||
elif style_name.startswith("List Number"):
|
||
chunks.append(f"1. {paragraph.text}")
|
||
else:
|
||
chunks.append(paragraph.text)
|
||
chunks.append("")
|
||
for table in document.tables:
|
||
rows = [[cell.text.replace("|", "\\|") for cell in row.cells] for row in table.rows]
|
||
if not rows:
|
||
continue
|
||
chunks.append("| " + " | ".join(rows[0]) + " |")
|
||
chunks.append("| " + " | ".join("---" for _ in rows[0]) + " |")
|
||
for row in rows[1:]:
|
||
chunks.append("| " + " | ".join(row) + " |")
|
||
chunks.append("")
|
||
return "\n".join(chunks).rstrip() + "\n"
|
||
|
||
|
||
def _extract_text(
|
||
source: Path,
|
||
destination: Path,
|
||
*,
|
||
markdown: bool,
|
||
track_changes: str,
|
||
timeout: int,
|
||
) -> str:
|
||
pandoc = shutil.which("pandoc")
|
||
if pandoc:
|
||
target = "gfm" if markdown else "plain"
|
||
completed = run_program(
|
||
[
|
||
pandoc,
|
||
f"--track-changes={track_changes}",
|
||
"-t",
|
||
target,
|
||
"-o",
|
||
str(destination),
|
||
str(source),
|
||
],
|
||
timeout=timeout,
|
||
)
|
||
return "pandoc"
|
||
|
||
from docx import Document
|
||
|
||
if track_changes != "accept":
|
||
raise FileNotFoundError(
|
||
"运行环境缺少 pandoc;纯 Python 回退只支持 --track-changes accept"
|
||
)
|
||
document = Document(str(source))
|
||
text = _markdown(document) if markdown else _plain_text(document)
|
||
destination.write_text(text, encoding="utf-8")
|
||
return "python-docx"
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = SkillArgumentParser(
|
||
description="把 Word 文档转换为 DOCX、PDF、Markdown 或纯文本。"
|
||
)
|
||
parser.add_argument("--input", required=True)
|
||
parser.add_argument("--output", required=True)
|
||
parser.add_argument(
|
||
"--track-changes",
|
||
default="accept",
|
||
choices=["accept", "reject", "all"],
|
||
)
|
||
parser.add_argument("--timeout", type=int, default=120)
|
||
parser.add_argument("--overwrite", action="store_true")
|
||
return parser
|
||
|
||
|
||
def main() -> dict[str, Any]:
|
||
args = build_parser().parse_args()
|
||
source = input_file(args.input, WORD_INPUT_SUFFIXES)
|
||
destination = output_file(
|
||
args.output,
|
||
DOCUMENT_OUTPUT_SUFFIXES,
|
||
overwrite=args.overwrite,
|
||
)
|
||
if source == destination:
|
||
raise ValueError("输出路径不能与输入文件相同")
|
||
source_suffix = source.suffix.lower()
|
||
output_suffix = destination.suffix.lower()
|
||
engine = ""
|
||
office_output: dict[str, str] = {"stdout": "", "stderr": ""}
|
||
|
||
with tempfile.TemporaryDirectory(prefix="docx-convert-") as temp_name:
|
||
temp_dir = Path(temp_name)
|
||
working_source = source
|
||
if source_suffix in {".doc", ".dotx"}:
|
||
converted, office_output = run_soffice_convert(
|
||
source,
|
||
target_format="docx",
|
||
output_dir=temp_dir / "docx",
|
||
timeout=args.timeout,
|
||
filter_name="Office Open XML Text",
|
||
)
|
||
working_source = converted
|
||
source_suffix = ".docx"
|
||
|
||
descriptor, temp_output_name = tempfile.mkstemp(
|
||
prefix="converted-",
|
||
suffix=output_suffix,
|
||
dir=str(destination.parent),
|
||
)
|
||
os.close(descriptor)
|
||
temp_output = Path(temp_output_name)
|
||
temp_output.unlink()
|
||
try:
|
||
if output_suffix == ".docx":
|
||
if source.suffix.lower() == ".docx":
|
||
raise ValueError("输入和输出都是 .docx,无需转换")
|
||
shutil.copy2(working_source, temp_output)
|
||
engine = "libreoffice"
|
||
elif output_suffix == ".pdf":
|
||
converted, office_output = run_soffice_convert(
|
||
working_source,
|
||
target_format="pdf",
|
||
output_dir=temp_dir / "pdf",
|
||
timeout=args.timeout,
|
||
)
|
||
shutil.copy2(converted, temp_output)
|
||
engine = "libreoffice"
|
||
elif output_suffix in {".txt", ".md"}:
|
||
engine = _extract_text(
|
||
working_source,
|
||
temp_output,
|
||
markdown=output_suffix == ".md",
|
||
track_changes=args.track_changes,
|
||
timeout=args.timeout,
|
||
)
|
||
else:
|
||
raise ValueError("不支持的目标格式")
|
||
publish_file(temp_output, destination, overwrite=args.overwrite)
|
||
finally:
|
||
if temp_output.exists():
|
||
temp_output.unlink()
|
||
|
||
return {
|
||
"path": str(destination),
|
||
"source": str(source),
|
||
"source_format": source.suffix.lower(),
|
||
"output_format": output_suffix,
|
||
"engine": engine,
|
||
"track_changes": args.track_changes,
|
||
"office_stdout": office_output["stdout"],
|
||
"office_stderr": office_output["stderr"],
|
||
}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(run_cli(main))
|