qparse 0.1.1__tar.gz → 0.1.2__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.1/qparse.egg-info → qparse-0.1.2}/PKG-INFO +1 -1
  2. {qparse-0.1.1 → qparse-0.1.2}/pyproject.toml +1 -1
  3. {qparse-0.1.1 → qparse-0.1.2}/qparse/__init__.py +1 -1
  4. {qparse-0.1.1 → qparse-0.1.2}/qparse/docx_render/docx_render.py +4 -12
  5. {qparse-0.1.1 → qparse-0.1.2}/qparse/docx_render/write_buffer.py +53 -38
  6. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/img_node.py +16 -3
  7. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_loader.py +192 -189
  8. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_render.py +2 -4
  9. {qparse-0.1.1 → qparse-0.1.2}/qparse/qmd_packer.py +2 -6
  10. qparse-0.1.2/qparse/utils/__init__.py +25 -0
  11. qparse-0.1.2/qparse/utils/path_resolve.py +189 -0
  12. {qparse-0.1.1 → qparse-0.1.2/qparse.egg-info}/PKG-INFO +1 -1
  13. {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/SOURCES.txt +2 -1
  14. qparse-0.1.1/qparse/utils/__init__.py +0 -3
  15. {qparse-0.1.1 → qparse-0.1.2}/LICENSE +0 -0
  16. {qparse-0.1.1 → qparse-0.1.2}/MANIFEST.in +0 -0
  17. {qparse-0.1.1 → qparse-0.1.2}/README.md +0 -0
  18. {qparse-0.1.1 → qparse-0.1.2}/qparse/__main__.py +0 -0
  19. {qparse-0.1.1 → qparse-0.1.2}/qparse/docx_render/__init__.py +0 -0
  20. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/__init__.py +0 -0
  21. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/analysis_document.py +0 -0
  22. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/base_document.py +0 -0
  23. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/markdwon_document.py +0 -0
  24. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/__init__.py +0 -0
  25. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/answer_node.py +0 -0
  26. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/base_node.py +0 -0
  27. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/text_node.py +0 -0
  28. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/question_document.py +0 -0
  29. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/stem_document.py +0 -0
  30. {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/table_document.py +0 -0
  31. {qparse-0.1.1 → qparse-0.1.2}/qparse/qmd_cli.py +0 -0
  32. {qparse-0.1.1 → qparse-0.1.2}/qparse/qmd_unpacker.py +0 -0
  33. {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/__init__.py +0 -0
  34. {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/default_render_option.py +0 -0
  35. {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/default_render_template.md +0 -0
  36. {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/referance.docx +0 -0
  37. {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/theme.json +0 -0
  38. {qparse-0.1.1 → qparse-0.1.2}/qparse/utils/html_full_protector.py +0 -0
  39. {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/dependency_links.txt +0 -0
  40. {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/entry_points.txt +0 -0
  41. {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/requires.txt +0 -0
  42. {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/top_level.txt +0 -0
  43. {qparse-0.1.1 → qparse-0.1.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qparse
3
- Version: 0.1.1
3
+ Version: 0.1.2
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.1"
7
+ version = "0.1.2"
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.1"
7
+ __version__ = "0.1.2"
8
8
 
9
9
  __all__ = [
10
10
  "MarkdownLoader",
@@ -2,9 +2,8 @@
2
2
 
3
3
  from pathlib import Path
4
4
  from shutil import copy2, rmtree
5
- from uuid import uuid4
5
+ from tempfile import mkdtemp
6
6
  from qparse.markdwon_document import *
7
- from typing import List
8
7
  from .write_buffer import *
9
8
 
10
9
  class DocxRender():
@@ -17,13 +16,9 @@ class DocxRender():
17
16
  self._run_time_dir = None
18
17
 
19
18
  def __enter__(self):
20
- self._run_time_dir = self.src.parent.joinpath(
21
- ".qmd_runtime",
22
- uuid4().hex
23
- )
24
- self._run_time_dir.mkdir(parents=True, exist_ok=True)
19
+ # 用系统临时目录,避免中文工作区路径让 Windows pandoc 找不到图片
20
+ self._run_time_dir = Path(mkdtemp(prefix="qmd_runtime_"))
25
21
  self.render_buffer = DocumentBuffer(self)
26
-
27
22
  return self
28
23
 
29
24
  def __exit__(self, exc_type, exc_value, traceback):
@@ -36,10 +31,7 @@ class DocxRender():
36
31
  @property
37
32
  def run_time_dir(self)->Path:
38
33
  if self._run_time_dir is None:
39
- self._run_time_dir = self.src.parent.joinpath(
40
- ".qmd_runtime",
41
- uuid4().hex
42
- )
34
+ self._run_time_dir = Path(mkdtemp(prefix="qmd_runtime_"))
43
35
  return self._run_time_dir
44
36
  @property
45
37
  def dist(self):
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
  from typing import List,Union
4
4
  from pathlib import Path
5
+ import os
5
6
  import re
6
7
  import pypandoc
7
8
  from docxcompose.composer import Composer
@@ -12,6 +13,7 @@ from docx.oxml import OxmlElement
12
13
  from docx.oxml.ns import qn
13
14
  from docx.enum.text import WD_ALIGN_PARAGRAPH
14
15
  from docx.shared import Inches
16
+ from qparse.utils.path_resolve import rewrite_local_images_for_pandoc
15
17
 
16
18
  class BaseBuffer():
17
19
 
@@ -29,9 +31,42 @@ class BaseBuffer():
29
31
  def create_docx_file_path(self):
30
32
  return self.create_temp_file_path(f".{id(self)}.docx")
31
33
 
34
+ def _localize_images(self, text: str) -> str:
35
+ cache = getattr(self._render, "_pandoc_image_cache", None)
36
+ if cache is None:
37
+ cache = {}
38
+ self._render._pandoc_image_cache = cache
39
+ return rewrite_local_images_for_pandoc(text, self._render.run_time_dir, cache)
40
+
41
+ def _pandoc_extra_args(self, with_reference: bool = False):
42
+ runtime = Path(self._render.run_time_dir).resolve()
43
+ args = [
44
+ "--mathjax",
45
+ "--no-highlight",
46
+ f"--resource-path={runtime}",
47
+ ]
48
+ if with_reference:
49
+ args.append(f"--reference-doc={Path(self.referance_path).as_posix()}")
50
+ return args
51
+
52
+ def _convert_with_pandoc(self, source_file: Path, outputfile: Path, fmt: str, extra_args):
53
+ old_cwd = os.getcwd()
54
+ runtime = Path(self._render.run_time_dir).resolve()
55
+ try:
56
+ os.chdir(str(runtime))
57
+ pypandoc.convert_file(
58
+ source_file=Path(source_file).name,
59
+ to="docx",
60
+ format=fmt,
61
+ outputfile=str(Path(outputfile).resolve()),
62
+ extra_args=extra_args,
63
+ )
64
+ finally:
65
+ os.chdir(old_cwd)
66
+
32
67
  def write_to_text_file(self):
33
68
  fpath= self.create_temp_file_path(".md")
34
- fpath.write_text(self.text,encoding='utf-8')
69
+ fpath.write_text(self._localize_images(self.text), encoding='utf-8')
35
70
  return fpath
36
71
  def _restore_outer_blank_lines(self, docx_path):
37
72
  lines = self.text.splitlines()
@@ -59,16 +94,11 @@ class BaseBuffer():
59
94
  def write_to_docx_file(self):
60
95
  temp_md_file_path = self.write_to_text_file()
61
96
  fpath= self.create_temp_file_path(".docx")
62
- pypandoc.convert_file(
63
- source_file=temp_md_file_path.as_posix(),
64
- to="docx",
65
- format="markdown+tex_math_dollars+hard_line_breaks",
66
- outputfile=fpath.as_posix(),
67
- extra_args=[
68
- "--mathjax",
69
- "--no-highlight",
70
- f"--reference-doc={Path(self.referance_path).as_posix()}"
71
- ]
97
+ self._convert_with_pandoc(
98
+ temp_md_file_path,
99
+ fpath,
100
+ "markdown+tex_math_dollars+hard_line_breaks",
101
+ self._pandoc_extra_args(with_reference=True),
72
102
  )
73
103
  self._restore_outer_blank_lines(fpath)
74
104
  return fpath
@@ -154,16 +184,11 @@ class MarkdwonTextBuffer(BaseBuffer):
154
184
  def write_to_docx_file(self):
155
185
  temp_md_file_path = self.write_to_text_file()
156
186
  fpath= self.create_temp_file_path(".docx")
157
- pypandoc.convert_file(
158
- source_file=temp_md_file_path.as_posix(),
159
- to="docx",
160
- format="markdown+tex_math_dollars+hard_line_breaks",
161
- outputfile=fpath.as_posix(),
162
- extra_args=[
163
- "--mathjax",
164
- "--no-highlight",
165
- f"--reference-doc={Path(self.referance_path).as_posix()}"
166
- ]
187
+ self._convert_with_pandoc(
188
+ temp_md_file_path,
189
+ fpath,
190
+ "markdown+tex_math_dollars+hard_line_breaks",
191
+ self._pandoc_extra_args(with_reference=True),
167
192
  )
168
193
  self._restore_outer_blank_lines(fpath)
169
194
  self._adjust_tables(fpath)
@@ -196,12 +221,7 @@ class HtmlTextBuffer(MarkdwonTextBuffer):
196
221
 
197
222
  def write_to_text_file(self):
198
223
  fpath= self.create_temp_file_path(".html")
199
- fpath.write_text(self.text,encoding='utf-8')
200
- return fpath
201
-
202
- def write_to_text_file(self):
203
- fpath= self.create_temp_file_path(".html")
204
- fpath.write_text(self.text,encoding='utf-8')
224
+ fpath.write_text(self._localize_images(self.text), encoding='utf-8')
205
225
  return fpath
206
226
 
207
227
  def write_to_docx_file(self):
@@ -214,7 +234,7 @@ class HtmlTextBuffer(MarkdwonTextBuffer):
214
234
  to="docx",
215
235
  format="html+tex_math_dollars",
216
236
  outputfile=fpath.as_posix(),
217
- extra_args=["--mathjax", "--no-highlight"]
237
+ extra_args=self._pandoc_extra_args(with_reference=False),
218
238
  )
219
239
  return fpath
220
240
  class TableHtmlTextBuffer(HtmlTextBuffer):
@@ -286,16 +306,11 @@ class TableHtmlTextBuffer(HtmlTextBuffer):
286
306
  def write_to_docx_file(self):
287
307
  temp_html_file_path = self.write_to_text_file()
288
308
  fpath = self.create_docx_file_path()
289
- pypandoc.convert_file(
290
- source_file=temp_html_file_path.as_posix(),
291
- to="docx",
292
- format="html+tex_math_dollars",
293
- outputfile=fpath.as_posix(),
294
- extra_args=[
295
- "--mathjax",
296
- "--no-highlight",
297
- f"--reference-doc={Path(self.referance_path).as_posix()}"
298
- ]
309
+ self._convert_with_pandoc(
310
+ temp_html_file_path,
311
+ fpath,
312
+ "html+tex_math_dollars",
313
+ self._pandoc_extra_args(with_reference=True),
299
314
  )
300
315
  self._adjust_docx(fpath)
301
316
  return fpath
@@ -3,6 +3,7 @@ from bs4 import BeautifulSoup,element
3
3
  from typing import Union
4
4
  import copy
5
5
  from .base_node import BaseNode
6
+ from qparse.utils.path_resolve import to_pandoc_local_url
6
7
 
7
8
  class ImgNode(BaseNode):
8
9
  def __init__(self, node, render_option):
@@ -14,7 +15,11 @@ class ImgNode(BaseNode):
14
15
  return None
15
16
 
16
17
  def dump_soup(self):
17
- return copy.deepcopy(self._soup)
18
+ soup = copy.deepcopy(self._soup)
19
+ src = soup.attrs.get("src", "")
20
+ if src:
21
+ soup.attrs["src"] = to_pandoc_local_url(src)
22
+ return soup
18
23
  def dump_markdwon(self):
19
24
  alt = self._soup.attrs.get("alt", "")
20
25
  src = self._soup.attrs.get("src", "")
@@ -23,9 +28,17 @@ class ImgNode(BaseNode):
23
28
  if not src:
24
29
  return ""
25
30
 
31
+ src_out = to_pandoc_local_url(src)
32
+ src_out = f"<{src_out}>" if (" " in src_out or "\\" in src_out) else src_out
26
33
  if title:
27
- return f'![{alt}]({src} "{title}")'
28
- return f'![{alt}]({src})'
34
+ return f'![{alt}]({src_out} "{title}")'
35
+ return f'![{alt}]({src_out})'
36
+
37
+ def dump_docx_buffer(self):
38
+ text = self.dump_markdwon()
39
+ if not text:
40
+ return []
41
+ return [{'type': 'markdwonText', 'text': text}]
29
42
 
30
43
 
31
44
 
@@ -1,189 +1,192 @@
1
- # -*- coding: utf-8 -*-
2
- from __future__ import annotations
3
-
4
- from cgitb import text
5
- import re,copy
6
- from pathlib import Path
7
-
8
- from bs4 import BeautifulSoup, element
9
-
10
- from .utils import HtmlFullProtector
11
-
12
-
13
- class MarkdownLoader:
14
- def __init__(self, src: str):
15
- self._src = Path(src)
16
- self._protector = HtmlFullProtector()
17
- self._reference_answer = BeautifulSoup("<div></div>", "html.parser").div
18
-
19
- def _read_text(self, path: Path) -> str:
20
- return path.read_text(encoding="utf-8")
21
-
22
- def _to_absolute_url(self, raw_url: str, base_dir: Path) -> str:
23
- url = raw_url.strip().strip('"').strip("'")
24
- if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url) or url.startswith("/"):
25
- return url
26
- return str((base_dir / url).resolve())
27
-
28
- def _replace_images(self, text: str, base_dir: Path) -> str:
29
- pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
30
-
31
- def repl(match: re.Match) -> str:
32
- alt = match.group(1)
33
- url = self._to_absolute_url(match.group(2), base_dir)
34
- return f'<img alt="{alt}" src="{url}" />'
35
-
36
- return pattern.sub(repl, text)
37
-
38
- def _replace_imports(self, text: str, base_dir: Path) -> str:
39
- pattern = re.compile(r'^\s*@import\s+["\']([^"\']+)["\']\s*$', re.MULTILINE)
40
-
41
- def repl(match: re.Match) -> str:
42
- url = self._to_absolute_url(match.group(1), base_dir)
43
- return f'<markdwon src="{url}"></markdwon>'
44
-
45
- return pattern.sub(repl, text)
46
-
47
- def _load_markdown(self, path: Path) -> str:
48
- base_dir = path.resolve().parent
49
- text = self._read_text(path.resolve())
50
- text = self._replace_images(text, base_dir)
51
- text = self._replace_imports(text, base_dir)
52
- return text
53
-
54
- def _expand_imports(self, soup: BeautifulSoup, current_path: Path, stack: set[Path] | None = None, depth: int = 0) -> None:
55
- stack = set() if stack is None else stack
56
- current_resolved = current_path.resolve()
57
- if current_resolved in stack:
58
- return
59
- stack.add(current_resolved)
60
-
61
- # 给根 soup 加上 depth 属性!如果是根的话 depth 是 0!
62
- if hasattr(soup, "markdwon_depth"):
63
- soup.markdwon_depth = depth
64
- else:
65
- # 对于 BeautifulSoup 根,我们给根的标签(如果是 markdwon 的话)加 depth
66
- if soup.contents and hasattr(soup.contents[0], "name") and soup.contents[0].name == "markdwon":
67
- soup.contents[0]["depth"] = depth
68
-
69
- for node in list(soup.find_all("markdwon")):
70
- src = node.get("src")
71
- if not src:
72
- continue
73
-
74
- imported_path = Path(src).resolve()
75
- if imported_path in stack:
76
- node["error"] = "circular import"
77
- continue
78
-
79
- if not imported_path.exists():
80
- node["error"] = "import not found"
81
- continue
82
-
83
- # 这个 markdwon 标签应该是 depth + 1
84
- node["depth"] = depth + 1
85
- imported_markdown = self._load_markdown(imported_path)
86
- protected = self._protector.encode(imported_markdown)
87
- decoded = self._protector.decode_to_html_entity(protected)
88
- imported_soup = BeautifulSoup(decoded, "html.parser")
89
- self._expand_imports(imported_soup, imported_path, stack, depth + 1)
90
- node.clear()
91
- node.extend(list(imported_soup.contents))
92
-
93
- stack.remove(current_resolved)
94
-
95
- def _decorate_questions(self, soup: BeautifulSoup) -> None:
96
- answer_class_map = {
97
- "chiose": "choice",
98
- "multiple-choice": "multiple-choice",
99
- "multiple-chiose": "multiple-choice",
100
- "blank": "blank",
101
- "answer": "answer",
102
- }
103
-
104
- for question_id, question in enumerate(soup.find_all("question"), start=1):
105
- answer_types = []
106
- question["question_id"] = str(question_id)
107
-
108
- for span in question.find_all("span"):
109
- class_list = span.get("class", [])
110
- for class_name in class_list:
111
- answer_type = answer_class_map.get(class_name)
112
- if not answer_type:
113
- continue
114
- span["type"] = "answer"
115
- if answer_type not in answer_types:
116
- answer_types.append(answer_type)
117
-
118
- if answer_types:
119
- question["answer_types"] = ",".join(answer_types)
120
-
121
- def get_soup(self) -> BeautifulSoup:
122
- markdown = self._load_markdown(self._src)
123
- protected = self._protector.encode(markdown)
124
- decoded = self._protector.decode_to_html_entity(protected)
125
- root_src = str(self._src.resolve())
126
- wrapped = f'<markdwon src="{root_src}" depth="0">{decoded}</markdwon>'
127
- soup = BeautifulSoup(wrapped, "html.parser")
128
- self._expand_imports(soup, self._src, set(), depth=0)
129
- self._decorate_questions(soup)
130
-
131
- return copy.deepcopy(soup.select_one('markdwon'))
132
-
133
- def get_reference_answer_markdwon(self) -> str:
134
- lines = []
135
- table_header = []
136
- table_answers = []
137
-
138
- def flush_table():
139
- nonlocal table_header, table_answers
140
- if not table_header:
141
- return
142
- lines.append("| " + " | ".join(table_header) + " |")
143
- lines.append("| " + " | ".join(["---"] * len(table_header)) + " |")
144
- lines.append("| " + " | ".join(table_answers) + " |")
145
- lines.append("")
146
- table_header = []
147
- table_answers = []
148
-
149
- soup = self.get_soup()
150
-
151
- for question in soup.find_all("question"):
152
- answer_nodes = question.find_all(
153
- "span",
154
- attrs={"type": "answer"}
155
- )
156
- if not answer_nodes:
157
- continue
158
-
159
- answer = "".join(
160
- answer_node.get_text()
161
- for answer_node in answer_nodes
162
- )
163
- answer_question = answer_nodes[0].find_parent("question")
164
- if answer_question is None:
165
- continue
166
-
167
- question_id = answer_question.get("question_id", "")
168
- answer_types = set(
169
- answer_type.strip()
170
- for answer_type in answer_question.get(
171
- "answer_types",
172
- ""
173
- ).split(",")
174
- if answer_type.strip()
175
- )
176
-
177
- if "answer" in answer_types:
178
- flush_table()
179
- lines.append(f"{question_id}、{answer}")
180
- lines.append("")
181
- continue
182
-
183
- table_header.append(question_id)
184
- table_answers.append(answer)
185
-
186
- flush_table()
187
- return "\n".join(lines).rstrip() + "\n"
188
-
189
-
1
+ # -*- coding: utf-8 -*-
2
+ from __future__ import annotations
3
+
4
+ from cgitb import text
5
+ import re,copy
6
+ from pathlib import Path
7
+
8
+ from bs4 import BeautifulSoup, element
9
+
10
+ from .utils import HtmlFullProtector, is_remote_url, resolve_resource_path
11
+
12
+
13
+ class MarkdownLoader:
14
+ def __init__(self, src: str):
15
+ self._src = Path(src)
16
+ self._protector = HtmlFullProtector()
17
+ self._reference_answer = BeautifulSoup("<div></div>", "html.parser").div
18
+
19
+ def _read_text(self, path: Path) -> str:
20
+ return path.read_text(encoding="utf-8")
21
+
22
+ def _to_absolute_url(self, raw_url: str, base_dir: Path) -> str:
23
+ url = raw_url.strip().strip('"').strip("'")
24
+ if is_remote_url(url):
25
+ return url
26
+ try:
27
+ return resolve_resource_path(url, base_dir).as_posix()
28
+ except ValueError:
29
+ return url
30
+
31
+ def _replace_images(self, text: str, base_dir: Path) -> str:
32
+ pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
33
+
34
+ def repl(match: re.Match) -> str:
35
+ alt = match.group(1)
36
+ url = self._to_absolute_url(match.group(2), base_dir)
37
+ return f'<img alt="{alt}" src="{url}" />'
38
+
39
+ return pattern.sub(repl, text)
40
+
41
+ def _replace_imports(self, text: str, base_dir: Path) -> str:
42
+ pattern = re.compile(r'^\s*@import\s+["\']([^"\']+)["\']\s*$', re.MULTILINE)
43
+
44
+ def repl(match: re.Match) -> str:
45
+ url = self._to_absolute_url(match.group(1), base_dir)
46
+ return f'<markdwon src="{url}"></markdwon>'
47
+
48
+ return pattern.sub(repl, text)
49
+
50
+ def _load_markdown(self, path: Path) -> str:
51
+ base_dir = path.resolve().parent
52
+ text = self._read_text(path.resolve())
53
+ text = self._replace_images(text, base_dir)
54
+ text = self._replace_imports(text, base_dir)
55
+ return text
56
+
57
+ def _expand_imports(self, soup: BeautifulSoup, current_path: Path, stack: set[Path] | None = None, depth: int = 0) -> None:
58
+ stack = set() if stack is None else stack
59
+ current_resolved = current_path.resolve()
60
+ if current_resolved in stack:
61
+ return
62
+ stack.add(current_resolved)
63
+
64
+ # 给根 soup 加上 depth 属性!如果是根的话 depth 是 0!
65
+ if hasattr(soup, "markdwon_depth"):
66
+ soup.markdwon_depth = depth
67
+ else:
68
+ # 对于 BeautifulSoup 根,我们给根的标签(如果是 markdwon 的话)加 depth
69
+ if soup.contents and hasattr(soup.contents[0], "name") and soup.contents[0].name == "markdwon":
70
+ soup.contents[0]["depth"] = depth
71
+
72
+ for node in list(soup.find_all("markdwon")):
73
+ src = node.get("src")
74
+ if not src:
75
+ continue
76
+
77
+ imported_path = Path(src).resolve()
78
+ if imported_path in stack:
79
+ node["error"] = "circular import"
80
+ continue
81
+
82
+ if not imported_path.exists():
83
+ node["error"] = "import not found"
84
+ continue
85
+
86
+ # 这个 markdwon 标签应该是 depth + 1
87
+ node["depth"] = depth + 1
88
+ imported_markdown = self._load_markdown(imported_path)
89
+ protected = self._protector.encode(imported_markdown)
90
+ decoded = self._protector.decode_to_html_entity(protected)
91
+ imported_soup = BeautifulSoup(decoded, "html.parser")
92
+ self._expand_imports(imported_soup, imported_path, stack, depth + 1)
93
+ node.clear()
94
+ node.extend(list(imported_soup.contents))
95
+
96
+ stack.remove(current_resolved)
97
+
98
+ def _decorate_questions(self, soup: BeautifulSoup) -> None:
99
+ answer_class_map = {
100
+ "chiose": "choice",
101
+ "multiple-choice": "multiple-choice",
102
+ "multiple-chiose": "multiple-choice",
103
+ "blank": "blank",
104
+ "answer": "answer",
105
+ }
106
+
107
+ for question_id, question in enumerate(soup.find_all("question"), start=1):
108
+ answer_types = []
109
+ question["question_id"] = str(question_id)
110
+
111
+ for span in question.find_all("span"):
112
+ class_list = span.get("class", [])
113
+ for class_name in class_list:
114
+ answer_type = answer_class_map.get(class_name)
115
+ if not answer_type:
116
+ continue
117
+ span["type"] = "answer"
118
+ if answer_type not in answer_types:
119
+ answer_types.append(answer_type)
120
+
121
+ if answer_types:
122
+ question["answer_types"] = ",".join(answer_types)
123
+
124
+ def get_soup(self) -> BeautifulSoup:
125
+ markdown = self._load_markdown(self._src)
126
+ protected = self._protector.encode(markdown)
127
+ decoded = self._protector.decode_to_html_entity(protected)
128
+ root_src = str(self._src.resolve())
129
+ wrapped = f'<markdwon src="{root_src}" depth="0">{decoded}</markdwon>'
130
+ soup = BeautifulSoup(wrapped, "html.parser")
131
+ self._expand_imports(soup, self._src, set(), depth=0)
132
+ self._decorate_questions(soup)
133
+
134
+ return copy.deepcopy(soup.select_one('markdwon'))
135
+
136
+ def get_reference_answer_markdwon(self) -> str:
137
+ lines = []
138
+ table_header = []
139
+ table_answers = []
140
+
141
+ def flush_table():
142
+ nonlocal table_header, table_answers
143
+ if not table_header:
144
+ return
145
+ lines.append("| " + " | ".join(table_header) + " |")
146
+ lines.append("| " + " | ".join(["---"] * len(table_header)) + " |")
147
+ lines.append("| " + " | ".join(table_answers) + " |")
148
+ lines.append("")
149
+ table_header = []
150
+ table_answers = []
151
+
152
+ soup = self.get_soup()
153
+
154
+ for question in soup.find_all("question"):
155
+ answer_nodes = question.find_all(
156
+ "span",
157
+ attrs={"type": "answer"}
158
+ )
159
+ if not answer_nodes:
160
+ continue
161
+
162
+ answer = "".join(
163
+ answer_node.get_text()
164
+ for answer_node in answer_nodes
165
+ )
166
+ answer_question = answer_nodes[0].find_parent("question")
167
+ if answer_question is None:
168
+ continue
169
+
170
+ question_id = answer_question.get("question_id", "")
171
+ answer_types = set(
172
+ answer_type.strip()
173
+ for answer_type in answer_question.get(
174
+ "answer_types",
175
+ ""
176
+ ).split(",")
177
+ if answer_type.strip()
178
+ )
179
+
180
+ if "answer" in answer_types:
181
+ flush_table()
182
+ lines.append(f"{question_id}、{answer}")
183
+ lines.append("")
184
+ continue
185
+
186
+ table_header.append(question_id)
187
+ table_answers.append(answer)
188
+
189
+ flush_table()
190
+ return "\n".join(lines).rstrip() + "\n"
191
+
192
+
@@ -8,6 +8,7 @@ from .docx_render import DocxRender
8
8
  from .markdwon_document import MarkdwonDocument
9
9
  from bs4 import BeautifulSoup,element
10
10
  from typing import Union
11
+ from .utils.path_resolve import resolve_resource_path
11
12
 
12
13
  class RenderToEnum(Enum):
13
14
  docx = 'docx'
@@ -35,10 +36,7 @@ class MarkdwonRender():
35
36
  self._markdwon_soup = self._markdwon_loader.get_soup()
36
37
 
37
38
  def _resolve_path(self, raw: str) -> Path:
38
- path = Path(raw)
39
- if not path.is_absolute():
40
- path = self._src.parent / path
41
- return path.resolve()
39
+ return resolve_resource_path(raw, self._src.parent)
42
40
  @property
43
41
  def soup(self):
44
42
  return copy.deepcopy(self._markdwon_soup)
@@ -427,15 +427,11 @@ class QmdPacker:
427
427
  return text
428
428
 
429
429
  def _resolve_path(self, raw: str, base_dir: Path) -> Path:
430
- value = raw.strip().strip('"').strip("'")
431
- path = Path(value)
432
- if path.is_absolute():
433
- return path.resolve()
434
- return (base_dir / path).resolve()
430
+ return resolve_resource_path(raw, base_dir, workspace_root=self.workspace_root)
435
431
 
436
432
  @staticmethod
437
433
  def _is_remote_url(url: str) -> bool:
438
- return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url))
434
+ return is_remote_url(url)
439
435
 
440
436
  @staticmethod
441
437
  def _to_posix(path: Path | str) -> str:
@@ -0,0 +1,25 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .html_full_protector import HtmlFullProtector
3
+ from .path_resolve import (
4
+ find_workspace_root,
5
+ is_posix_root_relative,
6
+ is_remote_url,
7
+ is_windows_absolute,
8
+ local_path_from_src,
9
+ resolve_resource_path,
10
+ rewrite_local_images_for_pandoc,
11
+ to_pandoc_local_url,
12
+ )
13
+
14
+ __all__ = [
15
+ "HtmlFullProtector",
16
+ "find_workspace_root",
17
+ "is_posix_root_relative",
18
+ "is_remote_url",
19
+ "is_windows_absolute",
20
+ "local_path_from_src",
21
+ "resolve_resource_path",
22
+ "rewrite_local_images_for_pandoc",
23
+ "to_pandoc_local_url",
24
+ ]
25
+
@@ -0,0 +1,189 @@
1
+ # -*- coding: utf-8 -*-
2
+ """解析 Markdown 资源路径。
3
+
4
+ 约定:
5
+ - http(s):// 等协议 URL 视为远程,不解析成本地路径
6
+ - Windows 盘符 / UNC 才是真正的绝对路径
7
+ - 以 / 开头、无盘符的 POSIX 风格路径(如 /assets/foo.png)视为工作区根相对路径
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ _REMOTE_URL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
16
+ _WIN_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]")
17
+
18
+
19
+ def is_remote_url(url: str) -> bool:
20
+ return bool(_REMOTE_URL_RE.match((url or "").strip()))
21
+
22
+
23
+ def is_windows_absolute(url: str) -> bool:
24
+ value = (url or "").strip()
25
+ if _WIN_DRIVE_RE.match(value):
26
+ return True
27
+ return value.startswith("\\\\") or value.startswith("//")
28
+
29
+
30
+ def is_posix_root_relative(url: str) -> bool:
31
+ value = (url or "").strip()
32
+ if not value.startswith("/"):
33
+ return False
34
+ if is_remote_url(value) or is_windows_absolute(value):
35
+ return False
36
+ return True
37
+
38
+
39
+ def find_workspace_root(start: Path) -> Path:
40
+ """从当前文件/目录向上查找工作区根(含 render.md 或 *.code-workspace)。"""
41
+ start = Path(start).resolve()
42
+ if start.is_file():
43
+ start = start.parent
44
+ for parent in [start, *start.parents]:
45
+ if (parent / "render.md").is_file():
46
+ return parent
47
+ if any(parent.glob("*.code-workspace")):
48
+ return parent
49
+ return start
50
+
51
+
52
+ def to_pandoc_local_url(raw: str) -> str:
53
+ """把本地绝对路径转成 pandoc 能识别的 file:/// URI。
54
+
55
+ Windows 上 ``E:/foo.png`` 会被 pandoc 当成协议 ``E:``,导致丢图。
56
+ 注意:Windows pandoc 对百分号编码的 file:/// 仍可能失败,导出时应再走
57
+ ``rewrite_local_images_for_pandoc`` 拷到临时目录用相对路径。
58
+ """
59
+ value = (raw or "").strip().strip('"').strip("'").strip("<>")
60
+ if not value:
61
+ return raw or ""
62
+ if is_remote_url(value) or value.lower().startswith("file:"):
63
+ return value
64
+ if is_windows_absolute(value) or Path(value).is_absolute():
65
+ try:
66
+ return Path(value).resolve().as_uri()
67
+ except Exception:
68
+ posix = Path(value).as_posix()
69
+ if not posix.startswith("/"):
70
+ posix = "/" + posix
71
+ return "file://" + posix
72
+ return value
73
+
74
+
75
+ def local_path_from_src(raw: str) -> Optional[Path]:
76
+ """从 markdown/html 的 src 解析出存在的本地文件路径。"""
77
+ value = (raw or "").strip().strip('"').strip("'").strip("<>")
78
+ if not value:
79
+ return None
80
+ try:
81
+ if value.lower().startswith("file:"):
82
+ from urllib.parse import unquote, urlparse
83
+
84
+ parsed = urlparse(value)
85
+ path = unquote(parsed.path or "")
86
+ netloc = unquote(parsed.netloc or "")
87
+ if netloc and netloc.lower() not in ("localhost", "127.0.0.1"):
88
+ path = f"//{netloc}{path}"
89
+ elif re.match(r"^/[A-Za-z]:", path):
90
+ path = path[1:]
91
+ candidate = Path(path)
92
+ elif is_remote_url(value):
93
+ return None
94
+ else:
95
+ candidate = Path(value)
96
+ if candidate.exists() and candidate.is_file():
97
+ return candidate.resolve()
98
+ except Exception:
99
+ return None
100
+ return None
101
+
102
+
103
+ _MD_IMG_RE = re.compile(
104
+ r"!\[([^\]]*)\]\(\s*<?([^>\s)]+)>?(?:\s+\"([^\"]*)\")?\s*\)"
105
+ )
106
+ _HTML_SRC_RE = re.compile(
107
+ r'(<img\b[^>]*?\bsrc\s*=\s*["\'])([^"\']+)(["\'])',
108
+ re.IGNORECASE | re.DOTALL,
109
+ )
110
+
111
+
112
+ def rewrite_local_images_for_pandoc(
113
+ text: str,
114
+ dest_dir: Path,
115
+ cache: Optional[dict] = None,
116
+ ) -> str:
117
+ """把文本里的本地图片拷到 dest_dir,并改成 ``img_0001.png`` 相对路径。
118
+
119
+ Windows 上 pandoc 无法可靠打开盘符路径或百分号编码的 file:/// URI。
120
+ """
121
+ from shutil import copy2
122
+
123
+ if not text:
124
+ return text
125
+ dest_dir = Path(dest_dir)
126
+ dest_dir.mkdir(parents=True, exist_ok=True)
127
+ copied = cache if cache is not None else {}
128
+
129
+ def localize(src: str) -> str:
130
+ path = local_path_from_src(src)
131
+ if path is None:
132
+ return src
133
+ key = str(path)
134
+ if key in copied:
135
+ return copied[key]
136
+ suffix = path.suffix.lower()
137
+ if not re.match(r"^\.[a-z0-9]{1,8}$", suffix):
138
+ suffix = ".png"
139
+ name = f"img_{len(copied) + 1:04d}{suffix}"
140
+ target = dest_dir / name
141
+ try:
142
+ copy2(path, target)
143
+ except Exception:
144
+ return src
145
+ # 不要带 ./ :pandoc 会把 ./foo 只当成 cwd 相对路径,搜不到 --resource-path
146
+ copied[key] = name
147
+ return name
148
+
149
+ def md_repl(match: re.Match) -> str:
150
+ alt, src, title = match.group(1), match.group(2), match.group(3)
151
+ new_src = localize(src)
152
+ if title:
153
+ return f'![{alt}]({new_src} "{title}")'
154
+ return f"![{alt}]({new_src})"
155
+
156
+ def html_repl(match: re.Match) -> str:
157
+ return f"{match.group(1)}{localize(match.group(2))}{match.group(3)}"
158
+
159
+ text = _MD_IMG_RE.sub(md_repl, text)
160
+ text = _HTML_SRC_RE.sub(html_repl, text)
161
+ return text
162
+
163
+
164
+ def resolve_resource_path(
165
+ raw: str,
166
+ base_dir: Path,
167
+ workspace_root: Optional[Path] = None,
168
+ ) -> Path:
169
+ """把 markdown 中的本地资源路径解析成绝对 Path。"""
170
+ value = (raw or "").strip().strip('"').strip("'")
171
+ if " " in value and not value.startswith("<"):
172
+ value = value.split(" ", 1)[0]
173
+ value = value.strip("<>")
174
+ if not value:
175
+ raise ValueError("empty resource path")
176
+ if is_remote_url(value):
177
+ raise ValueError(f"remote url: {value}")
178
+ if is_windows_absolute(value):
179
+ return Path(value).resolve()
180
+ if is_posix_root_relative(value):
181
+ root = Path(workspace_root).resolve() if workspace_root else find_workspace_root(base_dir)
182
+ candidate = (root / value.lstrip("/")).resolve()
183
+ if candidate.exists():
184
+ return candidate
185
+ alt = (Path(base_dir) / value.lstrip("/")).resolve()
186
+ if alt.exists():
187
+ return alt
188
+ return candidate
189
+ return (Path(base_dir) / value).resolve()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qparse
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Structured Markdown lesson plans to DOCX, with .qmd pack/unpack tooling.
5
5
  Author: hanbuhuai
6
6
  License: MIT
@@ -36,4 +36,5 @@ qparse/render_option/default_render_template.md
36
36
  qparse/render_option/referance.docx
37
37
  qparse/render_option/theme.json
38
38
  qparse/utils/__init__.py
39
- qparse/utils/html_full_protector.py
39
+ qparse/utils/html_full_protector.py
40
+ qparse/utils/path_resolve.py
@@ -1,3 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- from .html_full_protector import HtmlFullProtector
3
-
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes