返回 ppt-master
_conversion_profile.py
根目录 / skills / ppt-master / scripts / source_to_md / _conversion_profile.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Markdown Conversion Profile Helpers
4
5 Write lightweight sidecar metadata for source_to_md conversion outputs.
6
7 Usage:
8 Imported by scripts/source_to_md/*.py
9
10 Examples:
11 write_conversion_profile(input_path="demo.pdf", markdown_path="demo.md", ...)
12
13 Dependencies:
14 None
15 """
16
17 from __future__ import annotations
18
19 import json
20 import re
21 import sys
22 from pathlib import Path
23 from typing import Any
24
25
26 IMAGE_MANIFEST_NAME = "image_manifest.json"
27 PROFILE_SCHEMA = "ppt-master.source_to_md.profile.v1"
28 PROFILE_SUFFIX = ".conversion_profile.json"
29
30
31 def default_asset_dir(markdown_path: Path) -> Path:
32 """Return the conventional companion asset directory for one Markdown output."""
33 return markdown_path.parent / f"{markdown_path.stem}_files"
34
35
36 def profile_path_for(markdown_path: Path) -> Path:
37 """Return the sidecar profile path for one Markdown output."""
38 return markdown_path.with_name(f"{markdown_path.stem}{PROFILE_SUFFIX}")
39
40
41 def _display_path(path: Path | None, root: Path) -> str:
42 if path is None:
43 return ""
44 try:
45 return path.resolve().relative_to(root.resolve()).as_posix()
46 except ValueError:
47 return path.resolve().as_posix()
48
49
50 def _count_tables(lines: list[str]) -> int:
51 count = 0
52 in_table = False
53 separator_re = re.compile(
54 r"^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$"
55 )
56 for line in lines:
57 stripped = line.strip()
58 is_table_line = stripped.startswith("|") and stripped.endswith("|")
59 has_separator = bool(separator_re.match(stripped))
60 if is_table_line or has_separator:
61 if not in_table:
62 count += 1
63 in_table = True
64 continue
65 in_table = False
66 return count
67
68
69 def markdown_stats(markdown_path: Path) -> dict[str, int]:
70 """Return low-cost Markdown structure counts for inspection/debugging."""
71 if not markdown_path.is_file():
72 return {
73 "line_count": 0,
74 "char_count": 0,
75 "heading_count": 0,
76 "table_count": 0,
77 "image_ref_count": 0,
78 "link_count": 0,
79 }
80
81 text = markdown_path.read_text(encoding="utf-8", errors="replace")
82 lines = text.splitlines()
83 return {
84 "line_count": len(lines),
85 "char_count": len(text),
86 "heading_count": sum(1 for line in lines if re.match(r"^#{1,6}\s+", line)),
87 "table_count": _count_tables(lines),
88 "image_ref_count": len(re.findall(r"!\[[^\]]*\]\([^)]+\)", text)),
89 "link_count": len(re.findall(r"(?<!!)\[[^\]]+\]\([^)]+\)", text)),
90 }
91
92
93 def _read_json(path: Path) -> Any:
94 try:
95 return json.loads(path.read_text(encoding="utf-8"))
96 except (OSError, json.JSONDecodeError):
97 return None
98
99
100 def _image_count_from_manifest(path: Path) -> int:
101 payload = _read_json(path)
102 if isinstance(payload, list):
103 return len(payload)
104 if isinstance(payload, dict):
105 items = payload.get("items")
106 if isinstance(items, list):
107 return len(items)
108 return 0
109
110
111 def build_conversion_profile(
112 *,
113 input_path: str,
114 markdown_path: str | Path,
115 converter: str,
116 conversion_type: str,
117 asset_dir: str | Path | None = None,
118 warnings: list[str] | None = None,
119 ) -> dict[str, Any]:
120 """Build a sidecar profile without changing the Markdown conversion result."""
121 markdown = Path(markdown_path)
122 root = markdown.parent
123 is_url = input_path.startswith(("http://", "https://"))
124 source = None if is_url else Path(input_path)
125 assets = Path(asset_dir) if asset_dir else default_asset_dir(markdown)
126 image_manifest = assets / IMAGE_MANIFEST_NAME
127 source_exists = bool(source and source.exists())
128
129 return {
130 "schema": PROFILE_SCHEMA,
131 "converter": converter,
132 "conversion_type": conversion_type,
133 "source": {
134 "path": input_path if is_url else _display_path(source, root),
135 "name": input_path if is_url else (source.name if source else input_path),
136 "suffix": "" if is_url else (source.suffix.lower() if source else ""),
137 "kind": "url" if is_url else "file",
138 "exists": source_exists,
139 "size_bytes": (
140 source.stat().st_size
141 if source_exists and source is not None and source.is_file()
142 else None
143 ),
144 },
145 "outputs": {
146 "markdown": _display_path(markdown, root),
147 "asset_dir": _display_path(assets, root) if assets.is_dir() else "",
148 "image_manifest": (
149 _display_path(image_manifest, root) if image_manifest.is_file() else ""
150 ),
151 "image_count": _image_count_from_manifest(image_manifest),
152 },
153 "markdown": markdown_stats(markdown),
154 "warnings": warnings or [],
155 }
156
157
158 def write_conversion_profile(
159 *,
160 input_path: str,
161 markdown_path: str | Path,
162 converter: str,
163 conversion_type: str,
164 asset_dir: str | Path | None = None,
165 warnings: list[str] | None = None,
166 ) -> Path:
167 """Write `<stem>.conversion_profile.json` beside one Markdown output."""
168 markdown = Path(markdown_path)
169 profile_path = profile_path_for(markdown)
170 profile = build_conversion_profile(
171 input_path=input_path,
172 markdown_path=markdown,
173 converter=converter,
174 conversion_type=conversion_type,
175 asset_dir=asset_dir,
176 warnings=warnings,
177 )
178 profile_path.write_text(
179 json.dumps(profile, ensure_ascii=False, indent=2) + "\n",
180 encoding="utf-8",
181 )
182 return profile_path
183
184
185 def write_conversion_profile_best_effort(
186 *,
187 input_path: str,
188 markdown_path: str | Path,
189 converter: str,
190 conversion_type: str,
191 asset_dir: str | Path | None = None,
192 warnings: list[str] | None = None,
193 ) -> Path | None:
194 """Write a profile sidecar, warning without changing converter success."""
195 try:
196 return write_conversion_profile(
197 input_path=input_path,
198 markdown_path=markdown_path,
199 converter=converter,
200 conversion_type=conversion_type,
201 asset_dir=asset_dir,
202 warnings=warnings,
203 )
204 except OSError as exc:
205 print(f"[WARN] Could not write conversion profile: {exc}", file=sys.stderr)
206 return None
207
208
209 def build_result_payload(
210 *,
211 input_path: str,
212 markdown_path: str | Path,
213 converter: str,
214 conversion_type: str,
215 asset_dir: str | Path | None = None,
216 profile_path: str | Path | None = None,
217 ) -> dict[str, Any]:
218 """Return a compact JSON payload for CLI consumers."""
219 markdown = Path(markdown_path)
220 assets = Path(asset_dir) if asset_dir else default_asset_dir(markdown)
221 image_manifest = assets / IMAGE_MANIFEST_NAME
222 profile = Path(profile_path) if profile_path else profile_path_for(markdown)
223 return {
224 "input": (
225 input_path
226 if input_path.startswith(("http://", "https://"))
227 else str(Path(input_path).resolve())
228 ),
229 "markdown": str(markdown.resolve()),
230 "asset_dir": str(assets.resolve()) if assets.is_dir() else "",
231 "image_manifest": str(image_manifest.resolve()) if image_manifest.is_file() else "",
232 "conversion_profile": str(profile.resolve()) if profile.is_file() else "",
233 "converter": converter,
234 "conversion_type": conversion_type,
235 }
236
236 lines PYTHON