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

236 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - Markdown Conversion Profile Helpers
Write lightweight sidecar metadata for source_to_md conversion outputs.
Usage:
Imported by scripts/source_to_md/*.py
Examples:
write_conversion_profile(input_path="demo.pdf", markdown_path="demo.md", ...)
Dependencies:
None
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any
IMAGE_MANIFEST_NAME = "image_manifest.json"
PROFILE_SCHEMA = "ppt-master.source_to_md.profile.v1"
PROFILE_SUFFIX = ".conversion_profile.json"
def default_asset_dir(markdown_path: Path) -> Path:
"""Return the conventional companion asset directory for one Markdown output."""
return markdown_path.parent / f"{markdown_path.stem}_files"
def profile_path_for(markdown_path: Path) -> Path:
"""Return the sidecar profile path for one Markdown output."""
return markdown_path.with_name(f"{markdown_path.stem}{PROFILE_SUFFIX}")
def _display_path(path: Path | None, root: Path) -> str:
if path is None:
return ""
try:
return path.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return path.resolve().as_posix()
def _count_tables(lines: list[str]) -> int:
count = 0
in_table = False
separator_re = re.compile(
r"^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$"
)
for line in lines:
stripped = line.strip()
is_table_line = stripped.startswith("|") and stripped.endswith("|")
has_separator = bool(separator_re.match(stripped))
if is_table_line or has_separator:
if not in_table:
count += 1
in_table = True
continue
in_table = False
return count
def markdown_stats(markdown_path: Path) -> dict[str, int]:
"""Return low-cost Markdown structure counts for inspection/debugging."""
if not markdown_path.is_file():
return {
"line_count": 0,
"char_count": 0,
"heading_count": 0,
"table_count": 0,
"image_ref_count": 0,
"link_count": 0,
}
text = markdown_path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
return {
"line_count": len(lines),
"char_count": len(text),
"heading_count": sum(1 for line in lines if re.match(r"^#{1,6}\s+", line)),
"table_count": _count_tables(lines),
"image_ref_count": len(re.findall(r"!\[[^\]]*\]\([^)]+\)", text)),
"link_count": len(re.findall(r"(?<!!)\[[^\]]+\]\([^)]+\)", text)),
}
def _read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def _image_count_from_manifest(path: Path) -> int:
payload = _read_json(path)
if isinstance(payload, list):
return len(payload)
if isinstance(payload, dict):
items = payload.get("items")
if isinstance(items, list):
return len(items)
return 0
def build_conversion_profile(
*,
input_path: str,
markdown_path: str | Path,
converter: str,
conversion_type: str,
asset_dir: str | Path | None = None,
warnings: list[str] | None = None,
) -> dict[str, Any]:
"""Build a sidecar profile without changing the Markdown conversion result."""
markdown = Path(markdown_path)
root = markdown.parent
is_url = input_path.startswith(("http://", "https://"))
source = None if is_url else Path(input_path)
assets = Path(asset_dir) if asset_dir else default_asset_dir(markdown)
image_manifest = assets / IMAGE_MANIFEST_NAME
source_exists = bool(source and source.exists())
return {
"schema": PROFILE_SCHEMA,
"converter": converter,
"conversion_type": conversion_type,
"source": {
"path": input_path if is_url else _display_path(source, root),
"name": input_path if is_url else (source.name if source else input_path),
"suffix": "" if is_url else (source.suffix.lower() if source else ""),
"kind": "url" if is_url else "file",
"exists": source_exists,
"size_bytes": (
source.stat().st_size
if source_exists and source is not None and source.is_file()
else None
),
},
"outputs": {
"markdown": _display_path(markdown, root),
"asset_dir": _display_path(assets, root) if assets.is_dir() else "",
"image_manifest": (
_display_path(image_manifest, root) if image_manifest.is_file() else ""
),
"image_count": _image_count_from_manifest(image_manifest),
},
"markdown": markdown_stats(markdown),
"warnings": warnings or [],
}
def write_conversion_profile(
*,
input_path: str,
markdown_path: str | Path,
converter: str,
conversion_type: str,
asset_dir: str | Path | None = None,
warnings: list[str] | None = None,
) -> Path:
"""Write `<stem>.conversion_profile.json` beside one Markdown output."""
markdown = Path(markdown_path)
profile_path = profile_path_for(markdown)
profile = build_conversion_profile(
input_path=input_path,
markdown_path=markdown,
converter=converter,
conversion_type=conversion_type,
asset_dir=asset_dir,
warnings=warnings,
)
profile_path.write_text(
json.dumps(profile, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return profile_path
def write_conversion_profile_best_effort(
*,
input_path: str,
markdown_path: str | Path,
converter: str,
conversion_type: str,
asset_dir: str | Path | None = None,
warnings: list[str] | None = None,
) -> Path | None:
"""Write a profile sidecar, warning without changing converter success."""
try:
return write_conversion_profile(
input_path=input_path,
markdown_path=markdown_path,
converter=converter,
conversion_type=conversion_type,
asset_dir=asset_dir,
warnings=warnings,
)
except OSError as exc:
print(f"[WARN] Could not write conversion profile: {exc}", file=sys.stderr)
return None
def build_result_payload(
*,
input_path: str,
markdown_path: str | Path,
converter: str,
conversion_type: str,
asset_dir: str | Path | None = None,
profile_path: str | Path | None = None,
) -> dict[str, Any]:
"""Return a compact JSON payload for CLI consumers."""
markdown = Path(markdown_path)
assets = Path(asset_dir) if asset_dir else default_asset_dir(markdown)
image_manifest = assets / IMAGE_MANIFEST_NAME
profile = Path(profile_path) if profile_path else profile_path_for(markdown)
return {
"input": (
input_path
if input_path.startswith(("http://", "https://"))
else str(Path(input_path).resolve())
),
"markdown": str(markdown.resolve()),
"asset_dir": str(assets.resolve()) if assets.is_dir() else "",
"image_manifest": str(image_manifest.resolve()) if image_manifest.is_file() else "",
"conversion_profile": str(profile.resolve()) if profile.is_file() else "",
"converter": converter,
"conversion_type": conversion_type,
}