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.
- {qparse-0.1.1/qparse.egg-info → qparse-0.1.2}/PKG-INFO +1 -1
- {qparse-0.1.1 → qparse-0.1.2}/pyproject.toml +1 -1
- {qparse-0.1.1 → qparse-0.1.2}/qparse/__init__.py +1 -1
- {qparse-0.1.1 → qparse-0.1.2}/qparse/docx_render/docx_render.py +4 -12
- {qparse-0.1.1 → qparse-0.1.2}/qparse/docx_render/write_buffer.py +53 -38
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/img_node.py +16 -3
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_loader.py +192 -189
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_render.py +2 -4
- {qparse-0.1.1 → qparse-0.1.2}/qparse/qmd_packer.py +2 -6
- qparse-0.1.2/qparse/utils/__init__.py +25 -0
- qparse-0.1.2/qparse/utils/path_resolve.py +189 -0
- {qparse-0.1.1 → qparse-0.1.2/qparse.egg-info}/PKG-INFO +1 -1
- {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/SOURCES.txt +2 -1
- qparse-0.1.1/qparse/utils/__init__.py +0 -3
- {qparse-0.1.1 → qparse-0.1.2}/LICENSE +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/MANIFEST.in +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/README.md +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/__main__.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/docx_render/__init__.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/__init__.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/analysis_document.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/base_document.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/markdwon_document.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/__init__.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/answer_node.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/base_node.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/nodes/text_node.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/question_document.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/stem_document.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/markdwon_document/table_document.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/qmd_cli.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/qmd_unpacker.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/__init__.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/default_render_option.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/default_render_template.md +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/referance.docx +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/render_option/theme.json +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse/utils/html_full_protector.py +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/dependency_links.txt +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/entry_points.txt +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/requires.txt +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/qparse.egg-info/top_level.txt +0 -0
- {qparse-0.1.1 → qparse-0.1.2}/setup.cfg +0 -0
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from shutil import copy2, rmtree
|
|
5
|
-
from
|
|
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
|
-
|
|
21
|
-
|
|
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 =
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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=
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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''
|
|
35
|
+
return f''
|
|
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
|
|
25
|
-
return url
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
pattern
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
self.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
lines.append("")
|
|
146
|
-
|
|
147
|
-
table_answers
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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''
|
|
154
|
+
return f""
|
|
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()
|
|
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
|