qparse 0.1.6__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {qparse-0.1.6/qparse.egg-info → qparse-0.1.7}/PKG-INFO +1 -1
  2. {qparse-0.1.6 → qparse-0.1.7}/pyproject.toml +1 -1
  3. {qparse-0.1.6 → qparse-0.1.7}/qparse/__init__.py +1 -1
  4. qparse-0.1.7/qparse/qmd_unpacker.py +451 -0
  5. {qparse-0.1.6 → qparse-0.1.7/qparse.egg-info}/PKG-INFO +1 -1
  6. qparse-0.1.6/qparse/qmd_unpacker.py +0 -169
  7. {qparse-0.1.6 → qparse-0.1.7}/LICENSE +0 -0
  8. {qparse-0.1.6 → qparse-0.1.7}/MANIFEST.in +0 -0
  9. {qparse-0.1.6 → qparse-0.1.7}/README.md +0 -0
  10. {qparse-0.1.6 → qparse-0.1.7}/qparse/__main__.py +0 -0
  11. {qparse-0.1.6 → qparse-0.1.7}/qparse/docx_render/__init__.py +0 -0
  12. {qparse-0.1.6 → qparse-0.1.7}/qparse/docx_render/docx_render.py +0 -0
  13. {qparse-0.1.6 → qparse-0.1.7}/qparse/docx_render/write_buffer.py +0 -0
  14. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/__init__.py +0 -0
  15. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/analysis_document.py +0 -0
  16. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/base_document.py +0 -0
  17. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/markdwon_document.py +0 -0
  18. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/__init__.py +0 -0
  19. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/answer_node.py +0 -0
  20. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/base_node.py +0 -0
  21. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/img_node.py +0 -0
  22. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/text_node.py +0 -0
  23. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/question_document.py +0 -0
  24. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/stem_document.py +0 -0
  25. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/table_document.py +0 -0
  26. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_loader.py +0 -0
  27. {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_render.py +0 -0
  28. {qparse-0.1.6 → qparse-0.1.7}/qparse/qmd_cli.py +0 -0
  29. {qparse-0.1.6 → qparse-0.1.7}/qparse/qmd_packer.py +0 -0
  30. {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/__init__.py +0 -0
  31. {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/default_render_option.py +0 -0
  32. {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/default_render_template.md +0 -0
  33. {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/referance.docx +0 -0
  34. {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/theme.json +0 -0
  35. {qparse-0.1.6 → qparse-0.1.7}/qparse/utils/__init__.py +0 -0
  36. {qparse-0.1.6 → qparse-0.1.7}/qparse/utils/html_full_protector.py +0 -0
  37. {qparse-0.1.6 → qparse-0.1.7}/qparse/utils/path_resolve.py +0 -0
  38. {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/SOURCES.txt +0 -0
  39. {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/dependency_links.txt +0 -0
  40. {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/entry_points.txt +0 -0
  41. {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/requires.txt +0 -0
  42. {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/top_level.txt +0 -0
  43. {qparse-0.1.6 → qparse-0.1.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qparse
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Structured Markdown lesson plans to DOCX, with .qmd pack/unpack tooling.
5
5
  Author: hanbuhuai
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qparse"
7
- version = "0.1.6"
7
+ version = "0.1.7"
8
8
  description = "Structured Markdown lesson plans to DOCX, with .qmd pack/unpack tooling."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -4,7 +4,7 @@ from .markdwon_render import MarkdwonRender
4
4
  from .qmd_packer import QmdPacker
5
5
  from .qmd_unpacker import QmdUnpacker
6
6
 
7
- __version__ = "0.1.6"
7
+ __version__ = "0.1.7"
8
8
 
9
9
  __all__ = [
10
10
  "MarkdownLoader",
@@ -0,0 +1,451 @@
1
+ # -*- coding: utf-8 -*-
2
+ """将 .qmd 文件解包为可编辑的工作目录。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ import shutil
9
+ import zipfile
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Tuple
12
+ from uuid import uuid4
13
+
14
+
15
+ class QmdUnpacker:
16
+ """
17
+ 解压 .qmd 包,并还原为编辑器工作区格式:
18
+ 1. 去掉 documents/{index}-{name}/ 包装,内容提升到根目录
19
+ 2. 图片落到 Markdown 同级 assets/,文件名用打包前的原名
20
+ 3. 把正文里的 ./assets/{sha256} 改回原文件名
21
+ 4. render.md 的 src 改回相对入口
22
+ 5. 生成 QEditor.code-workspace,删除包内部件
23
+ """
24
+
25
+ IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
26
+ HTML_IMG_PATTERN = re.compile(
27
+ r'<img\b[^>]*\bsrc\s*=\s*["\']([^"\']+)["\'][^>]*>',
28
+ re.IGNORECASE,
29
+ )
30
+ RENDER_SRC_PATTERN = re.compile(
31
+ r'(<render\b[^>]*\bsrc\s*=\s*["\'])([^"\']+)(["\'])',
32
+ re.IGNORECASE,
33
+ )
34
+ THEME_SRC_PATTERN = re.compile(
35
+ r'(theme\s*=\s*["\'])([^"\']+)(["\'])',
36
+ re.IGNORECASE,
37
+ )
38
+ DOC_DIR_PATTERN = re.compile(r"^(\d+)-(.+)$")
39
+ PACKAGE_DIRS = ("documents", "resources", "templates", "themes")
40
+ PACKAGE_FILES = ("manifest.json",)
41
+
42
+ def __init__(self, package: str):
43
+ self.package_path = Path(package).resolve()
44
+ if not self.package_path.exists():
45
+ raise FileNotFoundError(f"qmd 包不存在: {self.package_path}")
46
+ if not zipfile.is_zipfile(self.package_path):
47
+ raise ValueError(f"不是有效的 qmd 压缩包: {self.package_path}")
48
+
49
+ self.manifest: dict = {}
50
+ self.target_dir: Optional[Path] = None
51
+
52
+ def unpack(self, target: Optional[str] = None) -> Path:
53
+ """
54
+ 解包到目标目录,并还原成编辑器工作区布局。
55
+
56
+ :param target: 目标目录。为空时创建到包旁的 .qmd_runtime/<uuid>
57
+ :return: 解包后的根目录
58
+ """
59
+ if target:
60
+ self.target_dir = Path(target).resolve()
61
+ else:
62
+ self.target_dir = (
63
+ self.package_path.parent
64
+ / ".qmd_runtime"
65
+ / uuid4().hex
66
+ )
67
+
68
+ if self.target_dir.exists():
69
+ shutil.rmtree(self.target_dir)
70
+ self.target_dir.mkdir(parents=True, exist_ok=True)
71
+
72
+ with zipfile.ZipFile(self.package_path, "r") as zf:
73
+ zf.extractall(self.target_dir)
74
+
75
+ manifest_path = self.target_dir / "manifest.json"
76
+ if not manifest_path.exists():
77
+ raise FileNotFoundError(
78
+ f"缺少 manifest.json: {manifest_path}"
79
+ )
80
+ self.manifest = json.loads(
81
+ manifest_path.read_text(encoding="utf-8")
82
+ )
83
+
84
+ self._restore_editor_layout()
85
+ return self.target_dir
86
+
87
+ @property
88
+ def entry_path(self) -> Path:
89
+ if self.target_dir is None:
90
+ raise RuntimeError("请先调用 unpack()")
91
+ entry = self.manifest.get("entry", "render.md")
92
+ path = self.target_dir / Path(entry).name
93
+ if not path.exists():
94
+ path = self.target_dir / entry
95
+ if not path.exists():
96
+ raise FileNotFoundError(f"入口文件不存在: {path}")
97
+ return path
98
+
99
+ @property
100
+ def resources_dir(self) -> Path:
101
+ if self.target_dir is None:
102
+ raise RuntimeError("请先调用 unpack()")
103
+ return self.target_dir / "resources"
104
+
105
+ def get_resource_path(self, sha256: str) -> Optional[Path]:
106
+ resources = self.manifest.get("resources", {})
107
+ item = resources.get(sha256)
108
+ if not item:
109
+ return None
110
+ original_name = item.get("name") or Path(item["path"]).name
111
+ if self.target_dir is not None:
112
+ matches = list(self.target_dir.rglob(original_name))
113
+ for match in matches:
114
+ if match.is_file() and "assets" in match.parts:
115
+ return match
116
+ packaged = self.target_dir / item["path"]
117
+ if packaged.exists():
118
+ return packaged
119
+ return None
120
+
121
+ def _restore_editor_layout(self) -> None:
122
+ mapping = self._flatten_documents()
123
+ self._restore_document_assets(mapping)
124
+ self._restore_entry(mapping)
125
+ self._write_workspace_file()
126
+ self._cleanup_package_files()
127
+
128
+ def _flatten_documents(self) -> Dict[str, str]:
129
+ """
130
+ 把 documents/{index}-{name}/ 的内容提升到工作区。
131
+ 只有一个工作区文件夹时提到根目录;多个则提到 {name}/。
132
+ """
133
+ if self.target_dir is None:
134
+ return {}
135
+ documents = self.target_dir / "documents"
136
+ if not documents.is_dir():
137
+ return {}
138
+
139
+ roots = self._iter_document_roots(documents)
140
+ mapping: Dict[str, str] = {}
141
+ for pkg_name, src_root, dest_prefix in roots:
142
+ files = [path for path in src_root.rglob("*") if path.is_file()]
143
+ for file_path in files:
144
+ rel = file_path.relative_to(src_root).as_posix()
145
+ dest_rel = f"{dest_prefix}/{rel}" if dest_prefix else rel
146
+ dest_rel = dest_rel.lstrip("/")
147
+ mapping[f"documents/{pkg_name}/{rel}"] = dest_rel
148
+ dest = self.target_dir / dest_rel
149
+ dest.parent.mkdir(parents=True, exist_ok=True)
150
+ if dest.exists():
151
+ dest.unlink()
152
+ shutil.move(str(file_path), str(dest))
153
+ return mapping
154
+
155
+ def _iter_document_roots(
156
+ self, documents: Path
157
+ ) -> List[Tuple[str, Path, str]]:
158
+ named: List[Tuple[int, str, Path]] = []
159
+ external: Optional[Path] = None
160
+ for child in documents.iterdir():
161
+ if not child.is_dir():
162
+ continue
163
+ if child.name == "_external":
164
+ external = child
165
+ continue
166
+ match = self.DOC_DIR_PATTERN.match(child.name)
167
+ if match:
168
+ named.append((int(match.group(1)), match.group(2), child))
169
+ else:
170
+ named.append((10_000, child.name, child))
171
+ named.sort(key=lambda item: item[0])
172
+
173
+ lift_to_root = len(named) == 1
174
+ roots: List[Tuple[str, Path, str]] = []
175
+ for _index, name, child in named:
176
+ dest_prefix = "" if lift_to_root else name
177
+ roots.append((child.name, child, dest_prefix))
178
+ if external is not None:
179
+ roots.append(("_external", external, "_external"))
180
+ return roots
181
+
182
+ def _restore_document_assets(self, mapping: Dict[str, str]) -> None:
183
+ """按原文件名把 resources 拷到对应 Markdown 同级 assets/,并改写引用。"""
184
+ if self.target_dir is None:
185
+ return
186
+ resources: Dict[str, dict] = self.manifest.get("resources", {})
187
+ document_assets: Dict[str, List[str]] = self.manifest.get(
188
+ "document_assets",
189
+ {},
190
+ )
191
+ rename = self._build_asset_rename_map(resources)
192
+
193
+ if document_assets:
194
+ for doc_rel, sha_list in document_assets.items():
195
+ new_rel = mapping.get(doc_rel) or self._strip_documents_prefix(
196
+ doc_rel
197
+ )
198
+ doc_path = self.target_dir / new_rel
199
+ if not doc_path.exists():
200
+ continue
201
+ assets_dir = doc_path.parent / "assets"
202
+ for sha in sha_list:
203
+ item = resources.get(sha)
204
+ if not item:
205
+ continue
206
+ self._copy_resource_as_original(item, assets_dir)
207
+ self._rewrite_md_assets(doc_path, rename)
208
+ self._purge_packaged_asset_names(rename)
209
+ return
210
+
211
+ for md_path in self.target_dir.rglob("*.md"):
212
+ if self._is_package_path(md_path):
213
+ continue
214
+ names = self._asset_names_in_markdown(md_path)
215
+ if not names:
216
+ continue
217
+ assets_dir = md_path.parent / "assets"
218
+ for name in names:
219
+ source = self._find_packaged_resource(name, resources)
220
+ if source is None:
221
+ continue
222
+ assets_dir.mkdir(parents=True, exist_ok=True)
223
+ original = rename.get(name, name)
224
+ target = assets_dir / original
225
+ if not target.exists():
226
+ shutil.copy2(source, target)
227
+ self._rewrite_md_assets(md_path, rename)
228
+ self._purge_packaged_asset_names(rename)
229
+
230
+ def _copy_resource_as_original(self, item: dict, assets_dir: Path) -> None:
231
+ if self.target_dir is None:
232
+ return
233
+ packaged = (item.get("path") or "").replace("\\", "/")
234
+ if packaged.startswith("templates/") or packaged.startswith("themes/"):
235
+ return
236
+ source = self.target_dir / packaged
237
+ if not source.exists():
238
+ return
239
+ original_name = item.get("name") or Path(packaged).name
240
+ assets_dir.mkdir(parents=True, exist_ok=True)
241
+ target = assets_dir / original_name
242
+ if not target.exists():
243
+ shutil.copy2(source, target)
244
+
245
+ def _build_asset_rename_map(
246
+ self, resources: Dict[str, dict]
247
+ ) -> Dict[str, str]:
248
+ rename: Dict[str, str] = {}
249
+ for sha, item in resources.items():
250
+ packaged = (item.get("path") or "").replace("\\", "/")
251
+ if packaged.startswith("templates/") or packaged.startswith("themes/"):
252
+ continue
253
+ packaged_name = Path(packaged).name
254
+ original_name = item.get("name") or packaged_name
255
+ if packaged_name != original_name:
256
+ rename[packaged_name] = original_name
257
+ suffix = Path(packaged_name).suffix
258
+ sha_name = f"{sha}{suffix}"
259
+ if sha_name != original_name:
260
+ rename[sha_name] = original_name
261
+ return rename
262
+
263
+ def _rewrite_md_assets(self, md_path: Path, rename: Dict[str, str]) -> None:
264
+ if not rename:
265
+ return
266
+ text, newline = self._read_preserved(md_path)
267
+ rewritten = self._rewrite_asset_refs_in_text(text, rename)
268
+ if rewritten != text:
269
+ self._write_preserved(md_path, rewritten, newline)
270
+
271
+ def _rewrite_asset_refs_in_text(
272
+ self, text: str, rename: Dict[str, str]
273
+ ) -> str:
274
+ names = [name for name, original in rename.items() if name != original]
275
+ if not names:
276
+ return text
277
+ names.sort(key=len, reverse=True)
278
+ pattern = re.compile(
279
+ r"(?P<prefix>(?:\./)?assets[/\\])(?P<name>"
280
+ + "|".join(re.escape(name) for name in names)
281
+ + r")"
282
+ )
283
+
284
+ def repl(match: re.Match) -> str:
285
+ old = match.group("name")
286
+ return "./assets/" + rename.get(old, old)
287
+
288
+ return pattern.sub(repl, text)
289
+
290
+ def _purge_packaged_asset_names(self, rename: Dict[str, str]) -> None:
291
+ if self.target_dir is None or not rename:
292
+ return
293
+ packaged_names = set(rename.keys())
294
+ for assets_dir in self.target_dir.rglob("assets"):
295
+ if not assets_dir.is_dir() or self._is_package_path(assets_dir):
296
+ continue
297
+ for child in list(assets_dir.iterdir()):
298
+ if child.is_file() and child.name in packaged_names:
299
+ child.unlink()
300
+
301
+ def _restore_entry(self, mapping: Dict[str, str]) -> None:
302
+ if self.target_dir is None:
303
+ return
304
+ render_path = self.target_dir / Path(
305
+ self.manifest.get("entry", "render.md")
306
+ ).name
307
+ if not render_path.exists():
308
+ render_path = self.target_dir / self.manifest.get("entry", "render.md")
309
+ if not render_path.exists():
310
+ return
311
+
312
+ text, newline = self._read_preserved(render_path)
313
+ original = text
314
+
315
+ def replace_src(match: re.Match) -> str:
316
+ old = match.group(2).replace("\\", "/")
317
+ new = mapping.get(old) or self._strip_documents_prefix(old)
318
+ return f"{match.group(1)}{new}{match.group(3)}"
319
+
320
+ text = self.RENDER_SRC_PATTERN.sub(replace_src, text, count=1)
321
+
322
+ theme_rel = self.manifest.get("theme")
323
+ if theme_rel:
324
+ theme_src = self.target_dir / theme_rel
325
+ if theme_src.exists():
326
+ dest = self.target_dir / theme_src.name
327
+ if dest.resolve() != theme_src.resolve():
328
+ shutil.copy2(theme_src, dest)
329
+
330
+ def replace_theme(match: re.Match) -> str:
331
+ old = match.group(2).replace("\\", "/")
332
+ if old.startswith("themes/"):
333
+ return f"{match.group(1)}{dest.name}{match.group(3)}"
334
+ return match.group(0)
335
+
336
+ text = self.THEME_SRC_PATTERN.sub(replace_theme, text, count=1)
337
+
338
+ if text != original:
339
+ self._write_preserved(render_path, text, newline)
340
+
341
+ def _write_workspace_file(self) -> None:
342
+ if self.target_dir is None:
343
+ return
344
+ existing = list(self.target_dir.glob("*.code-workspace"))
345
+ if existing:
346
+ return
347
+ folders = (self.manifest.get("workspace") or {}).get("folders") or []
348
+ name = self.target_dir.name
349
+ if len(folders) == 1 and folders[0].get("name"):
350
+ name = folders[0]["name"]
351
+ payload = {
352
+ "folders": [{"path": str(self.target_dir), "name": name}],
353
+ "settings": {},
354
+ }
355
+ ws_path = self.target_dir / "QEditor.code-workspace"
356
+ ws_path.write_text(
357
+ json.dumps(payload, ensure_ascii=False, indent=2),
358
+ encoding="utf-8",
359
+ )
360
+
361
+ def _cleanup_package_files(self) -> None:
362
+ if self.target_dir is None:
363
+ return
364
+ for name in self.PACKAGE_DIRS:
365
+ path = self.target_dir / name
366
+ if path.is_dir():
367
+ shutil.rmtree(path, ignore_errors=True)
368
+ elif path.is_file():
369
+ path.unlink()
370
+ for name in self.PACKAGE_FILES:
371
+ path = self.target_dir / name
372
+ if path.exists():
373
+ path.unlink()
374
+
375
+ def _asset_names_in_markdown(self, md_path: Path) -> set:
376
+ text, _newline = self._read_preserved(md_path)
377
+ names = set()
378
+ for match in self.IMAGE_PATTERN.finditer(text):
379
+ url = match.group(2).strip().split(" ", 1)[0]
380
+ name = self._asset_name_from_url(url)
381
+ if name:
382
+ names.add(name)
383
+ for match in self.HTML_IMG_PATTERN.finditer(text):
384
+ name = self._asset_name_from_url(match.group(1))
385
+ if name:
386
+ names.add(name)
387
+ return names
388
+
389
+ def _find_packaged_resource(
390
+ self, name: str, resources: Dict[str, dict]
391
+ ) -> Optional[Path]:
392
+ if self.target_dir is None:
393
+ return None
394
+ direct = self.resources_dir / name
395
+ if direct.exists():
396
+ return direct
397
+ for _sha, item in resources.items():
398
+ packaged = (item.get("path") or "").replace("\\", "/")
399
+ original = item.get("name") or Path(packaged).name
400
+ if name in {original, Path(packaged).name}:
401
+ source = self.target_dir / packaged
402
+ if source.exists():
403
+ return source
404
+ alt = self.target_dir / "templates" / name
405
+ return alt if alt.exists() else None
406
+
407
+ def _is_package_path(self, path: Path) -> bool:
408
+ if self.target_dir is None:
409
+ return False
410
+ try:
411
+ rel = path.relative_to(self.target_dir)
412
+ except ValueError:
413
+ return False
414
+ parts = rel.parts
415
+ return bool(parts) and parts[0] in self.PACKAGE_DIRS
416
+
417
+ @staticmethod
418
+ def _strip_documents_prefix(rel: str) -> str:
419
+ parts = [part for part in Path(rel).as_posix().split("/") if part]
420
+ if len(parts) >= 2 and parts[0] == "documents":
421
+ if parts[1] == "_external":
422
+ return "/".join(parts[1:])
423
+ return "/".join(parts[2:])
424
+ return Path(rel).as_posix()
425
+
426
+ @staticmethod
427
+ def _asset_name_from_url(url: str) -> Optional[str]:
428
+ value = url.strip().strip('"').strip("'")
429
+ if not value:
430
+ return None
431
+ if value.startswith("http://") or value.startswith("https://"):
432
+ return None
433
+ path = Path(value.replace("\\", "/"))
434
+ if path.parts and path.parts[0] in {".", "..", "assets"}:
435
+ return path.name
436
+ if "assets/" in value.replace("\\", "/"):
437
+ return path.name
438
+ return None
439
+
440
+ @staticmethod
441
+ def _read_preserved(path: Path) -> Tuple[str, str]:
442
+ data = path.read_bytes()
443
+ newline = "\r\n" if b"\r\n" in data else "\n"
444
+ return data.decode("utf-8"), newline
445
+
446
+ @staticmethod
447
+ def _write_preserved(path: Path, text: str, newline: str) -> None:
448
+ body = text.replace("\r\n", "\n").replace("\r", "\n")
449
+ if newline == "\r\n":
450
+ body = body.replace("\n", "\r\n")
451
+ path.write_bytes(body.encode("utf-8"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qparse
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Structured Markdown lesson plans to DOCX, with .qmd pack/unpack tooling.
5
5
  Author: hanbuhuai
6
6
  License: MIT
@@ -1,169 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """将 .qmd 文件解包为可编辑的工作目录。"""
3
-
4
- from __future__ import annotations
5
-
6
- import json
7
- import re
8
- import shutil
9
- import zipfile
10
- from pathlib import Path
11
- from typing import Dict, List, Optional
12
- from uuid import uuid4
13
-
14
-
15
- class QmdUnpacker:
16
- """
17
- 解压 .qmd 包:
18
- 1. 展开 documents / resources / themes / templates
19
- 2. 按 manifest 将资源复制到对应 Markdown 同级的 assets/
20
- 3. 返回解包目录与入口 render.md 路径
21
- """
22
-
23
- IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
24
- HTML_IMG_PATTERN = re.compile(
25
- r'<img\b[^>]*\bsrc\s*=\s*["\']([^"\']+)["\'][^>]*>',
26
- re.IGNORECASE,
27
- )
28
-
29
- def __init__(self, package: str):
30
- self.package_path = Path(package).resolve()
31
- if not self.package_path.exists():
32
- raise FileNotFoundError(f"qmd 包不存在: {self.package_path}")
33
- if not zipfile.is_zipfile(self.package_path):
34
- raise ValueError(f"不是有效的 qmd 压缩包: {self.package_path}")
35
-
36
- self.manifest: dict = {}
37
- self.target_dir: Optional[Path] = None
38
-
39
- def unpack(self, target: Optional[str] = None) -> Path:
40
- """
41
- 解包到目标目录。
42
-
43
- :param target: 目标目录。为空时创建到包旁的 .qmd_runtime/<uuid>
44
- :return: 解包后的根目录
45
- """
46
- if target:
47
- self.target_dir = Path(target).resolve()
48
- else:
49
- self.target_dir = (
50
- self.package_path.parent
51
- / ".qmd_runtime"
52
- / uuid4().hex
53
- )
54
-
55
- if self.target_dir.exists():
56
- shutil.rmtree(self.target_dir)
57
- self.target_dir.mkdir(parents=True, exist_ok=True)
58
-
59
- with zipfile.ZipFile(self.package_path, "r") as zf:
60
- zf.extractall(self.target_dir)
61
-
62
- manifest_path = self.target_dir / "manifest.json"
63
- if not manifest_path.exists():
64
- raise FileNotFoundError(
65
- f"缺少 manifest.json: {manifest_path}"
66
- )
67
- self.manifest = json.loads(
68
- manifest_path.read_text(encoding="utf-8")
69
- )
70
-
71
- self._materialize_document_assets()
72
- return self.target_dir
73
-
74
- @property
75
- def entry_path(self) -> Path:
76
- if self.target_dir is None:
77
- raise RuntimeError("请先调用 unpack()")
78
- entry = self.manifest.get("entry", "render.md")
79
- path = self.target_dir / entry
80
- if not path.exists():
81
- raise FileNotFoundError(f"入口文件不存在: {path}")
82
- return path
83
-
84
- @property
85
- def resources_dir(self) -> Path:
86
- if self.target_dir is None:
87
- raise RuntimeError("请先调用 unpack()")
88
- return self.target_dir / "resources"
89
-
90
- def get_resource_path(self, sha256: str) -> Optional[Path]:
91
- resources = self.manifest.get("resources", {})
92
- item = resources.get(sha256)
93
- if not item:
94
- return None
95
- path = self.target_dir / item["path"]
96
- return path if path.exists() else None
97
-
98
- def _materialize_document_assets(self) -> None:
99
- """
100
- 按需将 resources 中的文件复制到文档同级 assets/,
101
- 方便编辑器直接预览图片。
102
- """
103
- document_assets: Dict[str, List[str]] = self.manifest.get(
104
- "document_assets",
105
- {},
106
- )
107
- resources: Dict[str, dict] = self.manifest.get("resources", {})
108
-
109
- if document_assets:
110
- for doc_rel, sha_list in document_assets.items():
111
- doc_path = self.target_dir / doc_rel
112
- if not doc_path.exists():
113
- continue
114
- assets_dir = doc_path.parent / "assets"
115
- assets_dir.mkdir(parents=True, exist_ok=True)
116
- for sha in sha_list:
117
- item = resources.get(sha)
118
- if not item:
119
- continue
120
- source = self.target_dir / item["path"]
121
- if not source.exists():
122
- continue
123
- target = assets_dir / Path(item["path"]).name
124
- if not target.exists():
125
- shutil.copy2(source, target)
126
- return
127
-
128
- # 兼容没有 document_assets 字段的旧包:扫描 Markdown 中的 ./assets/ 引用
129
- for md_path in self.target_dir.rglob("*.md"):
130
- text = md_path.read_text(encoding="utf-8")
131
- names = set()
132
- for match in self.IMAGE_PATTERN.finditer(text):
133
- url = match.group(2).strip().split(" ", 1)[0]
134
- name = self._asset_name_from_url(url)
135
- if name:
136
- names.add(name)
137
- for match in self.HTML_IMG_PATTERN.finditer(text):
138
- name = self._asset_name_from_url(match.group(1))
139
- if name:
140
- names.add(name)
141
- if not names:
142
- continue
143
-
144
- assets_dir = md_path.parent / "assets"
145
- assets_dir.mkdir(parents=True, exist_ok=True)
146
- for name in names:
147
- source = self.resources_dir / name
148
- if not source.exists():
149
- # 也可能在 templates/
150
- alt = self.target_dir / "templates" / name
151
- source = alt if alt.exists() else source
152
- if source.exists():
153
- target = assets_dir / name
154
- if not target.exists():
155
- shutil.copy2(source, target)
156
-
157
- @staticmethod
158
- def _asset_name_from_url(url: str) -> Optional[str]:
159
- value = url.strip().strip('"').strip("'")
160
- if not value:
161
- return None
162
- if value.startswith("http://") or value.startswith("https://"):
163
- return None
164
- path = Path(value.replace("\\", "/"))
165
- if path.parts and path.parts[0] in {".", "..", "assets"}:
166
- return path.name
167
- if "assets/" in value.replace("\\", "/"):
168
- return path.name
169
- return None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes