Files
wehub-resource-sync 8ef9db2513
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:56:49 +08:00

506 lines
16 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - Unified Markdown Converter
Auto-detect source type and dispatch to the existing source_to_md converters.
Usage:
python3 scripts/source_to_md.py <file_or_url_or_dir> [<file_or_url_or_dir> ...] [options]
Examples:
python3 scripts/source_to_md.py paper.pdf
python3 scripts/source_to_md.py paper.pdf report.docx deck.pptx
python3 scripts/source_to_md.py ./sources -o ./markdown
python3 scripts/source_to_md.py report.docx -o report.md
python3 scripts/source_to_md.py deck.pptx --json
Dependencies:
Same as the backend converter selected for the input.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
_SOURCE_TO_MD_DIR = _SCRIPTS_DIR / "source_to_md"
if str(_SOURCE_TO_MD_DIR) not in sys.path:
sys.path.insert(0, str(_SOURCE_TO_MD_DIR))
from _conversion_profile import ( # noqa: E402
build_result_payload,
profile_path_for,
write_conversion_profile,
)
from _batch import expand_directory_inputs, unique_output_path # noqa: E402
from _dispatcher import ( # noqa: E402
build_conversion_command,
default_markdown_path,
detect_source_type,
is_url,
)
configure_utf8_stdio()
def resolve_output(output: str | None, input_arg: str) -> Path:
return Path(output) if output else default_markdown_path(input_arg)
def _print_status(message: str) -> None:
print(message, file=sys.stderr)
def _is_supported_directory_item(path: Path) -> bool:
return detect_source_type(str(path)) in {
"pdf", "doc", "excel", "pptx", "markdown", "text",
}
def _dispatch_output_arg(
input_arg: str,
conversion_type: str,
output_arg: str | None,
batch_mode: bool,
used_outputs: set[Path],
) -> str | None:
if output_arg and batch_mode and conversion_type == "web":
return None
if output_arg and batch_mode:
return str(
unique_output_path(
Path(output_arg),
default_markdown_path(input_arg).stem,
used_outputs,
)
)
if output_arg:
return output_arg
if batch_mode and conversion_type != "web":
return str(default_markdown_path(input_arg))
return None
def run_backend(command: list[str], script_name: str) -> int:
_print_status(f"[>>] {script_name} {' '.join(command[2:])}")
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except KeyboardInterrupt:
return 130
if result.stdout.strip():
print(result.stdout.strip(), file=sys.stderr)
if result.stderr.strip():
print(result.stderr.strip(), file=sys.stderr)
return result.returncode
def print_output(path: Path) -> None:
print(f"OUTPUT: {path.resolve()}")
def write_passthrough(
input_arg: str,
output: Path,
conversion_type: str,
json_output: bool = False,
) -> int:
"""Copy text-like input to Markdown and write the profile sidecar."""
source = Path(input_arg)
try:
text = source.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f"[ERROR] Cannot read {source}: {exc}", file=sys.stderr)
return 1
output.parent.mkdir(parents=True, exist_ok=True)
if output.resolve() != source.resolve():
output.write_text(text, encoding="utf-8")
profile = write_conversion_profile(
input_path=input_arg,
markdown_path=output,
converter="source_to_md.py",
conversion_type=conversion_type,
)
_print_status(f"[OK] Saved Markdown to: {output}")
_print_status(f" Wrote conversion profile -> {profile}")
print_output(output)
if json_output:
payload = build_result_payload(
input_path=input_arg,
markdown_path=output,
converter="source_to_md.py",
conversion_type=conversion_type,
profile_path=profile,
)
print(json.dumps(payload, ensure_ascii=False))
return 0
def ensure_profile(
input_arg: str,
output: Path,
converter: str,
conversion_type: str,
) -> Path:
"""Return an existing profile path, writing one if the backend did not."""
profile = profile_path_for(output)
if profile.is_file():
return profile
return write_conversion_profile(
input_path=input_arg,
markdown_path=output,
converter=converter,
conversion_type=conversion_type,
)
def print_json_result(
input_arg: str,
output: Path,
converter: str,
conversion_type: str,
profile: Path,
) -> None:
payload = build_result_payload(
input_path=input_arg,
markdown_path=output,
converter=converter,
conversion_type=conversion_type,
profile_path=profile,
)
print(json.dumps(payload, ensure_ascii=False))
def _read_emit_result(path: Path) -> Path | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
markdown = payload.get("markdown")
return Path(markdown) if isinstance(markdown, str) and markdown else None
def _pdf_image_mode(args: argparse.Namespace) -> str | None:
image_mode = args.images
if args.no_images:
image_mode = "none"
if args.filter_images:
image_mode = "filtered"
return image_mode
def _validate_image_options(args: argparse.Namespace) -> bool:
selected = sum(bool(value) for value in (args.images, args.no_images, args.filter_images))
if selected > 1:
print(
"[ERROR] --images, --no-images, and --filter-images are mutually exclusive",
file=sys.stderr,
)
return False
return True
def dispatch_single(
input_arg: str,
conversion_type: str,
output_arg: str | None,
args: argparse.Namespace,
unknown_args: list[str],
web_output_dir: str | None = None,
) -> int:
"""Dispatch one source to the matching existing converter."""
if conversion_type == "auto":
conversion_type = detect_source_type(input_arg)
if conversion_type == "markdown":
output = resolve_output(output_arg, input_arg)
return write_passthrough(input_arg, output, "markdown", args.json)
if conversion_type == "text":
output = resolve_output(output_arg, input_arg)
return write_passthrough(input_arg, output, "text", args.json)
if conversion_type == "web":
output = Path(output_arg) if output_arg else None
emit_result: Path | None = None
extra_args = list(unknown_args)
if output is None:
emit_file = tempfile.NamedTemporaryFile(
prefix="ppt-master-web-result-",
suffix=".json",
delete=False,
)
emit_file.close()
emit_result = Path(emit_file.name)
extra_args.extend(["--emit-result", str(emit_result)])
if web_output_dir:
extra_args.extend(["--dir", web_output_dir])
try:
route = build_conversion_command(
input_arg,
output,
forced_type="web",
extra_args=extra_args,
)
except ValueError as exc:
if emit_result:
emit_result.unlink(missing_ok=True)
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
rc = run_backend(route.command, route.script_name)
if rc != 0:
if emit_result:
emit_result.unlink(missing_ok=True)
return rc
output_path = route.output_path
if output_path is None and emit_result is not None:
output_path = _read_emit_result(emit_result)
emit_result.unlink(missing_ok=True)
if output_path and output_path.is_file():
profile = ensure_profile(input_arg, output_path, route.script_name, "web")
print_output(output_path)
if args.json:
print_json_result(input_arg, output_path, route.script_name, "web", profile)
return 0
if output is not None:
print(f"[ERROR] Expected Markdown output not found: {output}", file=sys.stderr)
else:
print("[ERROR] Web conversion did not report a Markdown output path", file=sys.stderr)
return 1
if conversion_type not in {"pdf", "doc", "excel", "pptx"}:
print(
f"[ERROR] Could not determine conversion type for {input_arg!r}. "
"Use -t pdf|doc|excel|pptx|web|markdown|text.",
file=sys.stderr,
)
return 1
if not is_url(input_arg) and not Path(input_arg).exists():
print(f"[ERROR] File not found: {input_arg}", file=sys.stderr)
return 1
output = resolve_output(output_arg, input_arg)
try:
route = build_conversion_command(
input_arg,
output,
forced_type=conversion_type,
extra_args=unknown_args,
pdf_image_mode=_pdf_image_mode(args),
render_vector_figures=args.render_vector_figures,
)
except ValueError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
rc = run_backend(route.command, route.script_name)
if rc != 0:
return rc
if not output.is_file():
print(f"[ERROR] Expected Markdown output not found: {output}", file=sys.stderr)
return 1
profile = ensure_profile(input_arg, output, route.script_name, conversion_type)
print_output(output)
if args.json:
print_json_result(input_arg, output, route.script_name, conversion_type, profile)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Auto-detect source type and convert to Markdown via source_to_md backends.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 scripts/source_to_md.py paper.pdf
python3 scripts/source_to_md.py paper.pdf report.docx deck.pptx
python3 scripts/source_to_md.py ./sources -o ./markdown
python3 scripts/source_to_md.py report.docx -o output.md
python3 scripts/source_to_md.py deck.pptx --json
python3 scripts/source_to_md.py https://example.com/article -o article.md
Backend-specific flags not listed here are passed through to the selected
converter, so existing converter behavior remains the source of truth.
""",
)
parser.add_argument("inputs", nargs="+", help="Input file(s), directories, or URL(s)")
parser.add_argument(
"-t",
"--type",
choices=["auto", "pdf", "doc", "excel", "pptx", "web", "markdown", "text"],
default="auto",
help="Force a conversion type (default: auto)",
)
parser.add_argument(
"-o",
"--output",
help="Output Markdown file for one input, or output directory for multiple inputs/directories",
)
parser.add_argument(
"--images",
choices=["all", "filtered", "none"],
help="PDF image extraction mode; maps to pdf_to_md.py --images",
)
parser.add_argument(
"--no-images",
action="store_true",
help="Alias for --images none on PDF inputs",
)
parser.add_argument(
"--filter-images",
action="store_true",
help="Alias for --images filtered on PDF inputs",
)
parser.add_argument(
"--render-vector-figures",
action="store_true",
help="Pass through to pdf_to_md.py for PDF vector figure rendering",
)
parser.add_argument(
"--json",
action="store_true",
help="Print a machine-readable result after successful conversion",
)
return parser
def _has_pdf_image_flags(args: argparse.Namespace) -> bool:
return bool(args.images or args.no_images or args.filter_images or args.render_vector_figures)
def _conversion_type_for_input(input_arg: str, requested_type: str) -> str:
if requested_type == "auto":
return detect_source_type(input_arg)
return requested_type
def _validate_pdf_image_flags(args: argparse.Namespace, conversion_types: list[str]) -> bool:
if not _has_pdf_image_flags(args):
return True
if any(conversion_type != "pdf" for conversion_type in conversion_types):
print("[ERROR] Image extraction flags are currently supported only for PDFs", file=sys.stderr)
return False
return True
def dispatch_many(
inputs: list[str],
args: argparse.Namespace,
unknown_args: list[str],
conversion_types: list[str],
batch_mode: bool = False,
initial_failures: list[str] | None = None,
) -> int:
success_count = 0
failed: list[str] = []
skipped: list[str] = list(initial_failures or [])
batch_mode = batch_mode or len(inputs) > 1
if args.output and batch_mode:
output_dir = Path(args.output)
if output_dir.exists() and not output_dir.is_dir():
print(f"[ERROR] Batch output path is not a directory: {args.output}", file=sys.stderr)
return 1
output_dir.mkdir(parents=True, exist_ok=True)
used_outputs: set[Path] = set()
for input_arg, conversion_type in zip(inputs, conversion_types):
output_arg = _dispatch_output_arg(
input_arg,
conversion_type,
args.output,
batch_mode,
used_outputs,
)
web_output_dir = (
args.output
if args.output and batch_mode and conversion_type == "web"
else None
)
if batch_mode:
_print_status(f"\n==> {input_arg}")
rc = dispatch_single(
input_arg,
conversion_type,
output_arg,
args,
unknown_args,
web_output_dir=web_output_dir,
)
if rc == 0:
success_count += 1
else:
failed.append(f"{input_arg}: exit {rc}")
if batch_mode:
_print_status(f"\n[Done] Success: {success_count}/{len(inputs)}, Failed: {len(failed)}")
if skipped:
_print_status("\n[Skipped directories]:")
for item in skipped:
_print_status(f" - {item}")
if failed:
_print_status("\n[Failed inputs]:")
for item in failed:
_print_status(f" - {item}")
if not inputs:
return 1
return 0 if not failed and not skipped else 1
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args, unknown_args = parser.parse_known_args(argv)
if not _validate_image_options(args):
return 2
inputs, expansion_errors, saw_directory = expand_directory_inputs(
args.inputs,
_is_supported_directory_item,
is_external_ref=is_url,
)
batch_mode = saw_directory or len(inputs) > 1
conversion_types = [_conversion_type_for_input(item, args.type) for item in inputs]
if not _validate_pdf_image_flags(args, conversion_types):
return 2
if unknown_args and any(
conversion_type in {"markdown", "text"} for conversion_type in conversion_types
):
print(
"[ERROR] Backend-specific flags cannot be used with markdown/text passthrough inputs",
file=sys.stderr,
)
return 2
return dispatch_many(
inputs,
args,
unknown_args,
conversion_types,
batch_mode=batch_mode,
initial_failures=expansion_errors,
)
if __name__ == "__main__":
raise SystemExit(main())