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.
- {qparse-0.1.6/qparse.egg-info → qparse-0.1.7}/PKG-INFO +1 -1
- {qparse-0.1.6 → qparse-0.1.7}/pyproject.toml +1 -1
- {qparse-0.1.6 → qparse-0.1.7}/qparse/__init__.py +1 -1
- qparse-0.1.7/qparse/qmd_unpacker.py +451 -0
- {qparse-0.1.6 → qparse-0.1.7/qparse.egg-info}/PKG-INFO +1 -1
- qparse-0.1.6/qparse/qmd_unpacker.py +0 -169
- {qparse-0.1.6 → qparse-0.1.7}/LICENSE +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/MANIFEST.in +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/README.md +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/__main__.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/docx_render/__init__.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/docx_render/docx_render.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/docx_render/write_buffer.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/__init__.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/analysis_document.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/base_document.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/markdwon_document.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/__init__.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/answer_node.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/base_node.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/img_node.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/nodes/text_node.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/question_document.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/stem_document.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_document/table_document.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_loader.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/markdwon_render.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/qmd_cli.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/qmd_packer.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/__init__.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/default_render_option.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/default_render_template.md +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/referance.docx +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/render_option/theme.json +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/utils/__init__.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/utils/html_full_protector.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse/utils/path_resolve.py +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/SOURCES.txt +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/dependency_links.txt +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/entry_points.txt +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/requires.txt +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/qparse.egg-info/top_level.txt +0 -0
- {qparse-0.1.6 → qparse-0.1.7}/setup.cfg +0 -0
|
@@ -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,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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|