md2word 0.1.0__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.
- md2word-0.1.2/.github/workflows/desktop_release.yml +114 -0
- md2word-0.1.2/.github/workflows/publish.yml +25 -0
- {md2word-0.1.0 → md2word-0.1.2}/.gitignore +3 -1
- {md2word-0.1.0 → md2word-0.1.2}/PKG-INFO +1 -1
- md2word-0.1.2/examples/desktop_app/README.md +21 -0
- md2word-0.1.2/examples/desktop_app/app.py +224 -0
- md2word-0.1.2/examples/desktop_app/index.html +480 -0
- md2word-0.1.2/md2word_demo.spec +43 -0
- md2word-0.1.2/md2word_demo_console.spec +43 -0
- {md2word-0.1.0 → md2word-0.1.2}/pyproject.toml +1 -1
- {md2word-0.1.0 → md2word-0.1.2}/uv.lock +1 -1
- {md2word-0.1.0 → md2word-0.1.2}/LICENSE +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/README.md +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/README_zh.md +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/examples/config_chinese.json +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/examples/config_english.json +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/examples/example.md +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/examples/example_zh.md +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/src/md2word/__init__.py +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/src/md2word/__main__.py +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/src/md2word/config.py +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/src/md2word/converter.py +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/src/md2word/latex.py +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/tests/__init__.py +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/tests/test_config.py +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/tests/test_converter.py +0 -0
- {md2word-0.1.0 → md2word-0.1.2}/tests/test_latex.py +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
name: Desktop App Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
name: Build (${{ matrix.platform }})
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
include:
|
|
19
|
+
- os: windows-latest
|
|
20
|
+
platform: windows
|
|
21
|
+
- os: macos-latest
|
|
22
|
+
platform: macos
|
|
23
|
+
- os: ubuntu-latest
|
|
24
|
+
platform: linux
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
- uses: actions/setup-python@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: "3.12"
|
|
30
|
+
cache: "pip"
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: |
|
|
33
|
+
python -m pip install --upgrade pip
|
|
34
|
+
python -m pip install . pyinstaller
|
|
35
|
+
- name: Build desktop app
|
|
36
|
+
run: python -m PyInstaller md2word_demo.spec
|
|
37
|
+
- name: Package artifact
|
|
38
|
+
shell: bash
|
|
39
|
+
env:
|
|
40
|
+
ARTIFACT_SUFFIX: ${{ matrix.platform }}
|
|
41
|
+
run: |
|
|
42
|
+
python - <<'PY'
|
|
43
|
+
import os
|
|
44
|
+
import pathlib
|
|
45
|
+
import sys
|
|
46
|
+
import zipfile
|
|
47
|
+
|
|
48
|
+
app_name = "md2word_demo"
|
|
49
|
+
dist_dir = pathlib.Path("dist")
|
|
50
|
+
if not dist_dir.exists():
|
|
51
|
+
print("dist folder not found", file=sys.stderr)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
candidates = [
|
|
55
|
+
dist_dir / f"{app_name}.app",
|
|
56
|
+
dist_dir / f"{app_name}.exe",
|
|
57
|
+
dist_dir / app_name,
|
|
58
|
+
]
|
|
59
|
+
target = next((p for p in candidates if p.exists()), None)
|
|
60
|
+
if target is None:
|
|
61
|
+
matches = list(dist_dir.glob(f"{app_name}*"))
|
|
62
|
+
if matches:
|
|
63
|
+
target = matches[0]
|
|
64
|
+
if target is None:
|
|
65
|
+
print("Build output not found in dist", file=sys.stderr)
|
|
66
|
+
print("dist entries:", [p.name for p in dist_dir.iterdir()], file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
release_dir = pathlib.Path("release")
|
|
70
|
+
release_dir.mkdir(exist_ok=True)
|
|
71
|
+
suffix = os.environ.get("ARTIFACT_SUFFIX", "unknown")
|
|
72
|
+
zip_path = release_dir / f"{app_name}-{suffix}.zip"
|
|
73
|
+
|
|
74
|
+
def add_path(zf, path, arc_root):
|
|
75
|
+
if path.is_dir():
|
|
76
|
+
for p in path.rglob("*"):
|
|
77
|
+
if p.is_file():
|
|
78
|
+
zf.write(p, arc_root / p.relative_to(path))
|
|
79
|
+
else:
|
|
80
|
+
zf.write(path, arc_root / path.name)
|
|
81
|
+
|
|
82
|
+
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
83
|
+
if target.is_dir():
|
|
84
|
+
add_path(zf, target, pathlib.Path(target.name))
|
|
85
|
+
else:
|
|
86
|
+
zf.write(target, target.name)
|
|
87
|
+
|
|
88
|
+
print(f"Packaged {zip_path}")
|
|
89
|
+
PY
|
|
90
|
+
- name: Upload artifact
|
|
91
|
+
uses: actions/upload-artifact@v4
|
|
92
|
+
with:
|
|
93
|
+
name: md2word_demo-${{ matrix.platform }}
|
|
94
|
+
path: release/*.zip
|
|
95
|
+
if-no-files-found: error
|
|
96
|
+
|
|
97
|
+
release:
|
|
98
|
+
name: Publish release
|
|
99
|
+
needs: build
|
|
100
|
+
runs-on: ubuntu-latest
|
|
101
|
+
steps:
|
|
102
|
+
- uses: actions/download-artifact@v4
|
|
103
|
+
with:
|
|
104
|
+
path: release
|
|
105
|
+
- name: Publish GitHub release
|
|
106
|
+
uses: ncipollo/release-action@v1
|
|
107
|
+
with:
|
|
108
|
+
tag: ${{ github.ref_name }}
|
|
109
|
+
name: Desktop App ${{ github.ref_name }}
|
|
110
|
+
commit: ${{ github.sha }}
|
|
111
|
+
allowUpdates: true
|
|
112
|
+
removeArtifacts: true
|
|
113
|
+
prerelease: false
|
|
114
|
+
artifacts: "release/**/*.zip"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build-and-publish:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- name: Install build tools
|
|
21
|
+
run: pip install build
|
|
22
|
+
- name: Build
|
|
23
|
+
run: python -m build
|
|
24
|
+
- name: Publish to PyPI
|
|
25
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: md2word
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Convert Markdown files to Word documents with extensive customization
|
|
5
5
|
Project-URL: Homepage, https://github.com/md2word/md2word
|
|
6
6
|
Project-URL: Documentation, https://github.com/md2word/md2word#readme
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# md2word 轻量桌面 Demo
|
|
2
|
+
|
|
3
|
+
这是一个放在 `examples/desktop_app` 的最小可运行示例:
|
|
4
|
+
- 后端只用 Python 标准库 + 本项目自身
|
|
5
|
+
- 前端用 Tailwind CDN,不需要构建工具
|
|
6
|
+
- 提供可视化完整配置界面(文档/图片/表格/样式)
|
|
7
|
+
|
|
8
|
+
## 运行
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
python examples/desktop_app/app.py
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
启动后打开:`http://127.0.0.1:7860`
|
|
15
|
+
|
|
16
|
+
## 说明
|
|
17
|
+
|
|
18
|
+
- 支持直接粘贴 Markdown 或加载本地 `.md` 文件
|
|
19
|
+
- 可选生成目录 (TOC)
|
|
20
|
+
- 点击“生成 Word”会下载 `.docx`
|
|
21
|
+
- 配置支持导入/导出 JSON
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import webbrowser
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from http import HTTPStatus
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
13
|
+
SRC = ROOT / "src"
|
|
14
|
+
if SRC.exists() and str(SRC) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(SRC))
|
|
16
|
+
|
|
17
|
+
from md2word import convert
|
|
18
|
+
from md2word.config import Config, DEFAULT_CONFIG
|
|
19
|
+
|
|
20
|
+
HERE = Path(__file__).resolve().parent
|
|
21
|
+
if hasattr(sys, "_MEIPASS"):
|
|
22
|
+
INDEX_PATH = Path(sys._MEIPASS) / "index.html"
|
|
23
|
+
else:
|
|
24
|
+
INDEX_PATH = HERE / "index.html"
|
|
25
|
+
DEFAULT_CONFIG_FULL = Config.from_dict(DEFAULT_CONFIG).to_dict()
|
|
26
|
+
LOG_PATH = Path(tempfile.gettempdir()) / "md2word_demo.log"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _safe_filename(name: str) -> str:
|
|
30
|
+
name = name.strip().replace("\\", "/")
|
|
31
|
+
name = name.split("/")[-1]
|
|
32
|
+
name = re.sub(r"[^A-Za-z0-9._-]+", "_", name)
|
|
33
|
+
return name or "output.docx"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Handler(BaseHTTPRequestHandler):
|
|
37
|
+
server_version = "md2word-demo/0.1"
|
|
38
|
+
|
|
39
|
+
def do_GET(self):
|
|
40
|
+
try:
|
|
41
|
+
_log(f"GET {self.path}")
|
|
42
|
+
if self.path in ("/", "/index.html"):
|
|
43
|
+
self._serve_index()
|
|
44
|
+
return
|
|
45
|
+
if self.path == "/health":
|
|
46
|
+
self._send_text("ok", status=HTTPStatus.OK)
|
|
47
|
+
return
|
|
48
|
+
if self.path == "/default-config":
|
|
49
|
+
self._send_json(DEFAULT_CONFIG_FULL, status=HTTPStatus.OK)
|
|
50
|
+
return
|
|
51
|
+
self._send_text("Not Found", status=HTTPStatus.NOT_FOUND)
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
_log(f"GET error: {exc!r}")
|
|
54
|
+
self._send_text("Internal Server Error", status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
55
|
+
|
|
56
|
+
def do_POST(self):
|
|
57
|
+
try:
|
|
58
|
+
_log(f"POST {self.path}")
|
|
59
|
+
if self.path != "/convert":
|
|
60
|
+
self._send_text("Not Found", status=HTTPStatus.NOT_FOUND)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
content_length = int(self.headers.get("Content-Length", "0"))
|
|
64
|
+
if content_length <= 0:
|
|
65
|
+
self._send_text("Missing request body", status=HTTPStatus.BAD_REQUEST)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
raw = self.rfile.read(content_length)
|
|
69
|
+
try:
|
|
70
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
self._send_text("Invalid JSON", status=HTTPStatus.BAD_REQUEST)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
markdown = (payload.get("markdown") or "").strip()
|
|
76
|
+
if not markdown:
|
|
77
|
+
self._send_text("Markdown content is empty", status=HTTPStatus.BAD_REQUEST)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
toc = bool(payload.get("toc"))
|
|
81
|
+
toc_title = (payload.get("toc_title") or "目录").strip() or "目录"
|
|
82
|
+
try:
|
|
83
|
+
toc_level = int(payload.get("toc_level", 3))
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
toc_level = 3
|
|
86
|
+
toc_level = max(1, min(9, toc_level))
|
|
87
|
+
|
|
88
|
+
filename = _safe_filename(payload.get("filename") or "output.docx")
|
|
89
|
+
if not filename.lower().endswith(".docx"):
|
|
90
|
+
filename = f"{filename}.docx"
|
|
91
|
+
|
|
92
|
+
config = None
|
|
93
|
+
config_payload = payload.get("config")
|
|
94
|
+
if isinstance(config_payload, dict):
|
|
95
|
+
try:
|
|
96
|
+
config = Config.from_dict(config_payload)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
self._send_text(f"Invalid config: {exc}", status=HTTPStatus.BAD_REQUEST)
|
|
99
|
+
return
|
|
100
|
+
elif isinstance(config_payload, str) and config_payload.strip():
|
|
101
|
+
try:
|
|
102
|
+
config_dict = json.loads(config_payload)
|
|
103
|
+
if not isinstance(config_dict, dict):
|
|
104
|
+
raise ValueError("Config JSON must be an object")
|
|
105
|
+
config = Config.from_dict(config_dict)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
self._send_text(f"Invalid config: {exc}", status=HTTPStatus.BAD_REQUEST)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
112
|
+
output_path = Path(tmp_dir) / filename
|
|
113
|
+
convert(
|
|
114
|
+
markdown,
|
|
115
|
+
output_path,
|
|
116
|
+
config=config,
|
|
117
|
+
toc=toc,
|
|
118
|
+
toc_title=toc_title,
|
|
119
|
+
toc_max_level=toc_level,
|
|
120
|
+
)
|
|
121
|
+
data = output_path.read_bytes()
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
self._send_text(f"Conversion failed: {exc}", status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
self.send_response(HTTPStatus.OK)
|
|
127
|
+
self.send_header(
|
|
128
|
+
"Content-Type",
|
|
129
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
130
|
+
)
|
|
131
|
+
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
|
132
|
+
self.send_header("Content-Length", str(len(data)))
|
|
133
|
+
self.end_headers()
|
|
134
|
+
self.wfile.write(data)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
_log(f"POST error: {exc!r}")
|
|
137
|
+
self._send_text("Internal Server Error", status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
138
|
+
|
|
139
|
+
def _serve_index(self):
|
|
140
|
+
if not INDEX_PATH.exists():
|
|
141
|
+
self._send_text("index.html not found", status=HTTPStatus.NOT_FOUND)
|
|
142
|
+
return
|
|
143
|
+
data = INDEX_PATH.read_bytes()
|
|
144
|
+
self.send_response(HTTPStatus.OK)
|
|
145
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
146
|
+
self.send_header("Content-Length", str(len(data)))
|
|
147
|
+
self.end_headers()
|
|
148
|
+
self.wfile.write(data)
|
|
149
|
+
|
|
150
|
+
def _send_text(self, text: str, status: HTTPStatus) -> None:
|
|
151
|
+
data = text.encode("utf-8")
|
|
152
|
+
self.send_response(status)
|
|
153
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
154
|
+
self.send_header("Content-Length", str(len(data)))
|
|
155
|
+
self.end_headers()
|
|
156
|
+
self.wfile.write(data)
|
|
157
|
+
|
|
158
|
+
def _send_json(self, payload: dict, status: HTTPStatus) -> None:
|
|
159
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
160
|
+
self.send_response(status)
|
|
161
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
162
|
+
self.send_header("Content-Length", str(len(data)))
|
|
163
|
+
self.end_headers()
|
|
164
|
+
self.wfile.write(data)
|
|
165
|
+
|
|
166
|
+
def log_message(self, fmt, *args):
|
|
167
|
+
line = "%s - - [%s] %s\n" % (self.address_string(), self.log_date_time_string(), fmt % args)
|
|
168
|
+
if sys.stderr:
|
|
169
|
+
sys.stderr.write(line)
|
|
170
|
+
else:
|
|
171
|
+
_log(line.strip())
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> int:
|
|
175
|
+
parser = argparse.ArgumentParser(description="Minimal md2word desktop demo (local web UI)")
|
|
176
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
|
|
177
|
+
parser.add_argument("--port", type=int, default=7860, help="Bind port (default: 7860)")
|
|
178
|
+
parser.add_argument("--no-browser", action="store_true", help="Do not open browser automatically")
|
|
179
|
+
args = parser.parse_args()
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
_show_error(f"无法启动服务:端口 {args.port} 可能被占用。\n{exc}")
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
url = f"http://{args.host}:{args.port}"
|
|
188
|
+
print(f"[INFO] Server running: {url}")
|
|
189
|
+
if not args.no_browser:
|
|
190
|
+
try:
|
|
191
|
+
webbrowser.open(url)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
try:
|
|
195
|
+
server.serve_forever()
|
|
196
|
+
except KeyboardInterrupt:
|
|
197
|
+
print("\n[INFO] Shutting down")
|
|
198
|
+
finally:
|
|
199
|
+
server.server_close()
|
|
200
|
+
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _show_error(message: str) -> None:
|
|
205
|
+
try:
|
|
206
|
+
import ctypes
|
|
207
|
+
|
|
208
|
+
ctypes.windll.user32.MessageBoxW(0, message, "md2word demo", 0x10)
|
|
209
|
+
except Exception:
|
|
210
|
+
print(f"[ERROR] {message}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _log(message: str) -> None:
|
|
214
|
+
try:
|
|
215
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
216
|
+
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
|
218
|
+
f.write(f"[{timestamp}] {message}\n")
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>md2word Desktop Demo</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
</head>
|
|
9
|
+
<body class="bg-slate-50 text-slate-900">
|
|
10
|
+
<div class="max-w-6xl mx-auto p-6">
|
|
11
|
+
<header class="mb-6">
|
|
12
|
+
<h1 class="text-2xl font-semibold">md2word 轻量桌面 Demo</h1>
|
|
13
|
+
<p class="text-slate-600 mt-1">本地运行,无额外依赖;Markdown 转 Word,并提供可视化完整配置界面</p>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<div class="grid gap-6 lg:grid-cols-2">
|
|
17
|
+
<section class="bg-white rounded-xl shadow p-5">
|
|
18
|
+
<div class="grid gap-4">
|
|
19
|
+
<label class="block">
|
|
20
|
+
<span class="text-sm font-medium">Markdown 内容</span>
|
|
21
|
+
<textarea id="markdown" rows="12" class="mt-2 w-full rounded-lg border border-slate-200 p-3 font-mono text-sm"></textarea>
|
|
22
|
+
</label>
|
|
23
|
+
|
|
24
|
+
<div class="flex flex-wrap gap-3 items-center">
|
|
25
|
+
<label class="inline-flex items-center gap-2">
|
|
26
|
+
<input id="toc" type="checkbox" class="h-4 w-4" />
|
|
27
|
+
<span class="text-sm">生成目录 (TOC)</span>
|
|
28
|
+
</label>
|
|
29
|
+
<label class="inline-flex items-center gap-2">
|
|
30
|
+
<span class="text-sm text-slate-600">目录标题</span>
|
|
31
|
+
<input id="tocTitle" type="text" value="目录" class="h-9 rounded border border-slate-200 px-2 text-sm" />
|
|
32
|
+
</label>
|
|
33
|
+
<label class="inline-flex items-center gap-2">
|
|
34
|
+
<span class="text-sm text-slate-600">目录层级</span>
|
|
35
|
+
<input id="tocLevel" type="number" min="1" max="9" value="3" class="h-9 w-20 rounded border border-slate-200 px-2 text-sm" />
|
|
36
|
+
</label>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="flex flex-wrap gap-3 items-center">
|
|
40
|
+
<input id="fileInput" type="file" accept=".md,.markdown,.txt" class="text-sm" />
|
|
41
|
+
<button id="loadFile" class="rounded bg-slate-200 px-3 py-2 text-sm">加载文件到编辑器</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="flex flex-wrap gap-3 items-center">
|
|
45
|
+
<button id="convertBtn" class="rounded bg-blue-600 px-4 py-2 text-white text-sm">生成 Word</button>
|
|
46
|
+
<span id="status" class="text-sm text-slate-600"></span>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<section class="bg-white rounded-xl shadow p-5">
|
|
52
|
+
<div class="flex flex-wrap gap-2 mb-4">
|
|
53
|
+
<button id="loadDefault" class="rounded bg-slate-200 px-3 py-2 text-sm">重置为默认</button>
|
|
54
|
+
<button id="exportConfig" class="rounded bg-slate-800 px-3 py-2 text-sm text-white">导出配置 JSON</button>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<details open class="mb-4">
|
|
58
|
+
<summary class="cursor-pointer text-sm font-semibold">文档配置</summary>
|
|
59
|
+
<div id="documentFields" class="mt-3 grid gap-3"></div>
|
|
60
|
+
</details>
|
|
61
|
+
|
|
62
|
+
<details class="mb-4">
|
|
63
|
+
<summary class="cursor-pointer text-sm font-semibold">图片配置</summary>
|
|
64
|
+
<div id="imageFields" class="mt-3 grid gap-3"></div>
|
|
65
|
+
</details>
|
|
66
|
+
|
|
67
|
+
<details class="mb-4">
|
|
68
|
+
<summary class="cursor-pointer text-sm font-semibold">表格配置</summary>
|
|
69
|
+
<div id="tableFields" class="mt-3 grid gap-3"></div>
|
|
70
|
+
</details>
|
|
71
|
+
|
|
72
|
+
<details open class="mb-4">
|
|
73
|
+
<summary class="cursor-pointer text-sm font-semibold">样式配置</summary>
|
|
74
|
+
<div class="mt-3 flex flex-wrap gap-2 items-center">
|
|
75
|
+
<input id="newStyleName" type="text" placeholder="新增样式名 (如 heading_4)" class="h-9 rounded border border-slate-200 px-2 text-sm" />
|
|
76
|
+
<button id="addStyle" class="rounded bg-slate-200 px-3 py-2 text-sm">新增样式</button>
|
|
77
|
+
</div>
|
|
78
|
+
<div id="stylesContainer" class="mt-4 grid gap-4"></div>
|
|
79
|
+
</details>
|
|
80
|
+
|
|
81
|
+
<details>
|
|
82
|
+
<summary class="cursor-pointer text-sm font-semibold">配置 JSON</summary>
|
|
83
|
+
<div class="mt-3 grid gap-3">
|
|
84
|
+
<textarea id="configJson" rows="8" class="w-full rounded border border-slate-200 p-2 font-mono text-xs"></textarea>
|
|
85
|
+
<div class="flex flex-wrap gap-2">
|
|
86
|
+
<button id="importConfig" class="rounded bg-slate-200 px-3 py-2 text-sm">从 JSON 导入</button>
|
|
87
|
+
<span id="configStatus" class="text-sm text-slate-600"></span>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</details>
|
|
91
|
+
</section>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<section class="bg-white rounded-xl shadow p-5 mt-6">
|
|
95
|
+
<h2 class="text-lg font-semibold mb-2">示例 Markdown</h2>
|
|
96
|
+
<pre id="example" class="text-sm whitespace-pre-wrap bg-slate-100 rounded p-3 font-mono"></pre>
|
|
97
|
+
<button id="useExample" class="mt-3 rounded bg-slate-800 px-3 py-2 text-sm text-white">填充示例</button>
|
|
98
|
+
</section>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<script>
|
|
102
|
+
const exampleText = `# 示例标题\n\n这是一个**粗体**与*斜体*示例。\n\n## 小节\n\n- 列表项 A\n- 列表项 B\n\n> 这是引用块\n\n\`\`\`python\nprint("Hello, md2word")\n\`\`\`\n\n$E = mc^2$\n`;
|
|
103
|
+
|
|
104
|
+
const markdownEl = document.getElementById("markdown");
|
|
105
|
+
const statusEl = document.getElementById("status");
|
|
106
|
+
const fileInput = document.getElementById("fileInput");
|
|
107
|
+
const configStatusEl = document.getElementById("configStatus");
|
|
108
|
+
|
|
109
|
+
document.getElementById("example").textContent = exampleText;
|
|
110
|
+
|
|
111
|
+
document.getElementById("useExample").addEventListener("click", () => {
|
|
112
|
+
markdownEl.value = exampleText;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
document.getElementById("loadFile").addEventListener("click", async () => {
|
|
116
|
+
if (!fileInput.files || !fileInput.files[0]) {
|
|
117
|
+
statusEl.textContent = "请选择一个 .md 文件";
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const file = fileInput.files[0];
|
|
121
|
+
const text = await file.text();
|
|
122
|
+
markdownEl.value = text;
|
|
123
|
+
statusEl.textContent = `已加载: ${file.name}`;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let defaultConfig = null;
|
|
127
|
+
let currentConfig = null;
|
|
128
|
+
|
|
129
|
+
const docFields = [
|
|
130
|
+
{ key: "default_font", label: "默认字体", type: "text" },
|
|
131
|
+
{ key: "page_width_inches", label: "页面宽度 (in)", type: "number" },
|
|
132
|
+
{ key: "page_height_inches", label: "页面高度 (in)", type: "number" },
|
|
133
|
+
{ key: "max_image_width_inches", label: "图片最大宽度 (in)", type: "number" },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const imageFields = [
|
|
137
|
+
{ key: "local_dir", label: "图片本地目录", type: "text" },
|
|
138
|
+
{ key: "download_timeout", label: "下载超时 (秒)", type: "number" },
|
|
139
|
+
{ key: "user_agent", label: "User-Agent", type: "text" },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const tableFields = [
|
|
143
|
+
{ key: "border_style", label: "边框样式", type: "select", options: ["single", "double", "dotted", "dashed", "none"] },
|
|
144
|
+
{ key: "border_color", label: "边框颜色 (HEX)", type: "text" },
|
|
145
|
+
{ key: "border_width", label: "边框宽度 (1/8 pt)", type: "number" },
|
|
146
|
+
{ key: "header_background_color", label: "表头背景色 (HEX)", type: "text", nullable: true },
|
|
147
|
+
{ key: "cell_background_color", label: "单元格背景色 (HEX)", type: "text", nullable: true },
|
|
148
|
+
{ key: "alternating_row_color", label: "斑马纹颜色 (HEX)", type: "text", nullable: true },
|
|
149
|
+
{ key: "cell_padding_top", label: "单元格内边距-上 (pt)", type: "number" },
|
|
150
|
+
{ key: "cell_padding_bottom", label: "单元格内边距-下 (pt)", type: "number" },
|
|
151
|
+
{ key: "cell_padding_left", label: "单元格内边距-左 (pt)", type: "number" },
|
|
152
|
+
{ key: "cell_padding_right", label: "单元格内边距-右 (pt)", type: "number" },
|
|
153
|
+
{ key: "width_mode", label: "表格宽度模式", type: "select", options: ["auto", "full", "fixed"] },
|
|
154
|
+
{ key: "width_inches", label: "固定宽度 (in)", type: "number", nullable: true },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
const styleFields = [
|
|
158
|
+
{ key: "font_name", label: "字体", type: "text" },
|
|
159
|
+
{ key: "font_size", label: "字号 (pt/中文字号)", type: "text" },
|
|
160
|
+
{ key: "bold", label: "加粗", type: "checkbox" },
|
|
161
|
+
{ key: "italic", label: "斜体", type: "checkbox" },
|
|
162
|
+
{ key: "color", label: "字体颜色 (HEX)", type: "text" },
|
|
163
|
+
{ key: "alignment", label: "对齐方式", type: "select", options: ["left", "center", "right", "justify"] },
|
|
164
|
+
{ key: "line_spacing_rule", label: "行距模式", type: "select", options: ["single", "1.5", "double", "multiple", "exact", "at_least"] },
|
|
165
|
+
{ key: "line_spacing_value", label: "行距数值", type: "number", nullable: true },
|
|
166
|
+
{ key: "line_spacing", label: "行距倍数", type: "number" },
|
|
167
|
+
{ key: "space_before", label: "段前间距 (pt)", type: "number" },
|
|
168
|
+
{ key: "space_after", label: "段后间距 (pt)", type: "number" },
|
|
169
|
+
{ key: "left_indent", label: "左缩进 (in)", type: "number" },
|
|
170
|
+
{ key: "first_line_indent", label: "首行缩进 (字符)", type: "number" },
|
|
171
|
+
{ key: "background_color", label: "背景色 (HEX)", type: "text", nullable: true },
|
|
172
|
+
{ key: "is_heading", label: "作为标题", type: "checkbox" },
|
|
173
|
+
{ key: "numbering_format", label: "标题编号格式", type: "select", nullable: true, options: ["chapter", "section", "chinese", "chinese_paren", "arabic", "arabic_paren", "arabic_bracket", "roman", "roman_lower", "letter", "letter_lower", "circle", "none"] },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
function deepClone(obj) {
|
|
177
|
+
return JSON.parse(JSON.stringify(obj));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getByPath(obj, path) {
|
|
181
|
+
return path.split(".").reduce((acc, key) => (acc ? acc[key] : undefined), obj);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function setByPath(obj, path, value) {
|
|
185
|
+
const parts = path.split(".");
|
|
186
|
+
let cur = obj;
|
|
187
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
188
|
+
const key = parts[i];
|
|
189
|
+
if (!cur[key] || typeof cur[key] !== "object") {
|
|
190
|
+
cur[key] = {};
|
|
191
|
+
}
|
|
192
|
+
cur = cur[key];
|
|
193
|
+
}
|
|
194
|
+
cur[parts[parts.length - 1]] = value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function createInput(field, path, value) {
|
|
198
|
+
const wrapper = document.createElement("label");
|
|
199
|
+
wrapper.className = "grid gap-1 text-sm";
|
|
200
|
+
|
|
201
|
+
const label = document.createElement("span");
|
|
202
|
+
label.className = "text-slate-600";
|
|
203
|
+
label.textContent = field.label;
|
|
204
|
+
wrapper.appendChild(label);
|
|
205
|
+
|
|
206
|
+
let input;
|
|
207
|
+
if (field.type === "select") {
|
|
208
|
+
input = document.createElement("select");
|
|
209
|
+
input.className = "h-9 rounded border border-slate-200 px-2 text-sm";
|
|
210
|
+
const nullable = !!field.nullable;
|
|
211
|
+
if (nullable) {
|
|
212
|
+
const opt = document.createElement("option");
|
|
213
|
+
opt.value = "";
|
|
214
|
+
opt.textContent = "(空)";
|
|
215
|
+
input.appendChild(opt);
|
|
216
|
+
}
|
|
217
|
+
field.options.forEach((opt) => {
|
|
218
|
+
const option = document.createElement("option");
|
|
219
|
+
option.value = opt;
|
|
220
|
+
option.textContent = opt;
|
|
221
|
+
input.appendChild(option);
|
|
222
|
+
});
|
|
223
|
+
input.value = value ?? "";
|
|
224
|
+
} else if (field.type === "checkbox") {
|
|
225
|
+
input = document.createElement("input");
|
|
226
|
+
input.type = "checkbox";
|
|
227
|
+
input.className = "h-4 w-4";
|
|
228
|
+
input.checked = Boolean(value);
|
|
229
|
+
} else {
|
|
230
|
+
input = document.createElement("input");
|
|
231
|
+
input.type = field.type === "number" ? "number" : "text";
|
|
232
|
+
input.step = field.type === "number" ? "any" : undefined;
|
|
233
|
+
input.className = "h-9 rounded border border-slate-200 px-2 text-sm";
|
|
234
|
+
if (value !== null && value !== undefined) {
|
|
235
|
+
input.value = value;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
input.dataset.path = path;
|
|
240
|
+
input.dataset.type = field.type;
|
|
241
|
+
if (field.nullable) {
|
|
242
|
+
input.dataset.nullable = "true";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
wrapper.appendChild(input);
|
|
246
|
+
return wrapper;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderFields(container, prefix, fields, config) {
|
|
250
|
+
container.innerHTML = "";
|
|
251
|
+
fields.forEach((field) => {
|
|
252
|
+
const path = `${prefix}.${field.key}`;
|
|
253
|
+
const value = getByPath(config, path);
|
|
254
|
+
container.appendChild(createInput(field, path, value));
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderStyles(config) {
|
|
259
|
+
const container = document.getElementById("stylesContainer");
|
|
260
|
+
container.innerHTML = "";
|
|
261
|
+
const styles = config.styles || {};
|
|
262
|
+
Object.keys(styles).forEach((styleName) => {
|
|
263
|
+
const card = document.createElement("div");
|
|
264
|
+
card.className = "border border-slate-200 rounded-lg p-3";
|
|
265
|
+
|
|
266
|
+
const header = document.createElement("div");
|
|
267
|
+
header.className = "flex items-center justify-between mb-3";
|
|
268
|
+
|
|
269
|
+
const title = document.createElement("div");
|
|
270
|
+
title.className = "font-semibold text-sm";
|
|
271
|
+
title.textContent = styleName;
|
|
272
|
+
|
|
273
|
+
const removeBtn = document.createElement("button");
|
|
274
|
+
removeBtn.className = "text-xs text-red-600";
|
|
275
|
+
removeBtn.textContent = "移除";
|
|
276
|
+
removeBtn.addEventListener("click", () => {
|
|
277
|
+
currentConfig = collectConfig();
|
|
278
|
+
delete currentConfig.styles[styleName];
|
|
279
|
+
renderAll(currentConfig);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
header.appendChild(title);
|
|
283
|
+
header.appendChild(removeBtn);
|
|
284
|
+
card.appendChild(header);
|
|
285
|
+
|
|
286
|
+
const grid = document.createElement("div");
|
|
287
|
+
grid.className = "grid gap-3";
|
|
288
|
+
styleFields.forEach((field) => {
|
|
289
|
+
const path = `styles.${styleName}.${field.key}`;
|
|
290
|
+
const value = getByPath(config, path);
|
|
291
|
+
grid.appendChild(createInput(field, path, value));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
card.appendChild(grid);
|
|
295
|
+
container.appendChild(card);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renderAll(config) {
|
|
300
|
+
renderFields(document.getElementById("documentFields"), "document", docFields, config);
|
|
301
|
+
renderFields(document.getElementById("imageFields"), "image", imageFields, config);
|
|
302
|
+
renderFields(document.getElementById("tableFields"), "table", tableFields, config);
|
|
303
|
+
renderStyles(config);
|
|
304
|
+
currentConfig = deepClone(config);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function getValueFromInput(input) {
|
|
308
|
+
const type = input.dataset.type;
|
|
309
|
+
const nullable = input.dataset.nullable === "true";
|
|
310
|
+
if (type === "checkbox") {
|
|
311
|
+
return input.checked;
|
|
312
|
+
}
|
|
313
|
+
if (type === "number") {
|
|
314
|
+
if (input.value === "") {
|
|
315
|
+
return nullable ? null : undefined;
|
|
316
|
+
}
|
|
317
|
+
const num = Number(input.value);
|
|
318
|
+
return Number.isFinite(num) ? num : undefined;
|
|
319
|
+
}
|
|
320
|
+
const value = input.value.trim();
|
|
321
|
+
if (nullable && value === "") {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function collectConfig() {
|
|
328
|
+
const config = deepClone(currentConfig || {});
|
|
329
|
+
document.querySelectorAll("[data-path]").forEach((input) => {
|
|
330
|
+
const value = getValueFromInput(input);
|
|
331
|
+
if (value === undefined) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
setByPath(config, input.dataset.path, value);
|
|
335
|
+
});
|
|
336
|
+
return config;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function loadDefaultConfig() {
|
|
340
|
+
const res = await fetch("/default-config");
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
throw new Error("无法获取默认配置");
|
|
343
|
+
}
|
|
344
|
+
defaultConfig = await res.json();
|
|
345
|
+
renderAll(defaultConfig);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
document.getElementById("loadDefault").addEventListener("click", async () => {
|
|
349
|
+
if (defaultConfig) {
|
|
350
|
+
renderAll(defaultConfig);
|
|
351
|
+
configStatusEl.textContent = "已重置为默认配置";
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await loadDefaultConfig();
|
|
356
|
+
configStatusEl.textContent = "已重置为默认配置";
|
|
357
|
+
} catch (err) {
|
|
358
|
+
configStatusEl.textContent = `失败: ${err}`;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
document.getElementById("exportConfig").addEventListener("click", () => {
|
|
363
|
+
const config = collectConfig();
|
|
364
|
+
document.getElementById("configJson").value = JSON.stringify(config, null, 2);
|
|
365
|
+
configStatusEl.textContent = "已导出到配置 JSON";
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
document.getElementById("importConfig").addEventListener("click", () => {
|
|
369
|
+
const text = document.getElementById("configJson").value.trim();
|
|
370
|
+
if (!text) {
|
|
371
|
+
configStatusEl.textContent = "请输入配置 JSON";
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const parsed = JSON.parse(text);
|
|
376
|
+
renderAll(parsed);
|
|
377
|
+
configStatusEl.textContent = "已从 JSON 导入配置";
|
|
378
|
+
} catch (err) {
|
|
379
|
+
configStatusEl.textContent = `JSON 解析失败: ${err}`;
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
document.getElementById("addStyle").addEventListener("click", () => {
|
|
384
|
+
const name = document.getElementById("newStyleName").value.trim();
|
|
385
|
+
if (!name) {
|
|
386
|
+
configStatusEl.textContent = "请输入样式名";
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
currentConfig = collectConfig();
|
|
390
|
+
currentConfig.styles = currentConfig.styles || {};
|
|
391
|
+
if (currentConfig.styles[name]) {
|
|
392
|
+
configStatusEl.textContent = `样式已存在: ${name}`;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const defaultFont = currentConfig.document?.default_font || "微软雅黑";
|
|
396
|
+
currentConfig.styles[name] = {
|
|
397
|
+
font_name: defaultFont,
|
|
398
|
+
font_size: 11,
|
|
399
|
+
bold: false,
|
|
400
|
+
italic: false,
|
|
401
|
+
color: "000000",
|
|
402
|
+
space_before: 0,
|
|
403
|
+
space_after: 6,
|
|
404
|
+
line_spacing: 1.0,
|
|
405
|
+
left_indent: 0,
|
|
406
|
+
background_color: null,
|
|
407
|
+
alignment: "left",
|
|
408
|
+
line_spacing_rule: "multiple",
|
|
409
|
+
line_spacing_value: null,
|
|
410
|
+
first_line_indent: 0,
|
|
411
|
+
is_heading: true,
|
|
412
|
+
numbering_format: null,
|
|
413
|
+
};
|
|
414
|
+
renderAll(currentConfig);
|
|
415
|
+
document.getElementById("newStyleName").value = "";
|
|
416
|
+
configStatusEl.textContent = `已添加样式: ${name}`;
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
document.getElementById("convertBtn").addEventListener("click", async () => {
|
|
420
|
+
const markdown = markdownEl.value.trim();
|
|
421
|
+
if (!markdown) {
|
|
422
|
+
statusEl.textContent = "请输入 Markdown 内容";
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const toc = document.getElementById("toc").checked;
|
|
427
|
+
const tocTitle = document.getElementById("tocTitle").value;
|
|
428
|
+
const tocLevel = parseInt(document.getElementById("tocLevel").value, 10) || 3;
|
|
429
|
+
|
|
430
|
+
let filename = "output.docx";
|
|
431
|
+
if (fileInput.files && fileInput.files[0]) {
|
|
432
|
+
const name = fileInput.files[0].name.replace(/\.[^.]+$/, "");
|
|
433
|
+
filename = `${name}.docx`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const config = collectConfig();
|
|
437
|
+
|
|
438
|
+
statusEl.textContent = "正在转换...";
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
const res = await fetch("/convert", {
|
|
442
|
+
method: "POST",
|
|
443
|
+
headers: { "Content-Type": "application/json" },
|
|
444
|
+
body: JSON.stringify({
|
|
445
|
+
markdown,
|
|
446
|
+
toc,
|
|
447
|
+
toc_title: tocTitle,
|
|
448
|
+
toc_level: tocLevel,
|
|
449
|
+
filename,
|
|
450
|
+
config,
|
|
451
|
+
}),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (!res.ok) {
|
|
455
|
+
const msg = await res.text();
|
|
456
|
+
statusEl.textContent = `失败: ${msg}`;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const blob = await res.blob();
|
|
461
|
+
const url = URL.createObjectURL(blob);
|
|
462
|
+
const a = document.createElement("a");
|
|
463
|
+
a.href = url;
|
|
464
|
+
a.download = filename;
|
|
465
|
+
document.body.appendChild(a);
|
|
466
|
+
a.click();
|
|
467
|
+
a.remove();
|
|
468
|
+
URL.revokeObjectURL(url);
|
|
469
|
+
statusEl.textContent = "已生成并开始下载";
|
|
470
|
+
} catch (err) {
|
|
471
|
+
statusEl.textContent = `请求失败: ${err}`;
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
loadDefaultConfig().catch((err) => {
|
|
476
|
+
configStatusEl.textContent = `无法加载默认配置: ${err}`;
|
|
477
|
+
});
|
|
478
|
+
</script>
|
|
479
|
+
</body>
|
|
480
|
+
</html>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# -*- mode: python ; coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
ROOT = Path(__file__).resolve().parent
|
|
6
|
+
APP_PATH = ROOT / "examples" / "desktop_app" / "app.py"
|
|
7
|
+
INDEX_HTML = ROOT / "examples" / "desktop_app" / "index.html"
|
|
8
|
+
|
|
9
|
+
a = Analysis(
|
|
10
|
+
[str(APP_PATH)],
|
|
11
|
+
pathex=[str(ROOT / "src")],
|
|
12
|
+
binaries=[],
|
|
13
|
+
datas=[(str(INDEX_HTML), ".")],
|
|
14
|
+
hiddenimports=[],
|
|
15
|
+
hookspath=[],
|
|
16
|
+
hooksconfig={},
|
|
17
|
+
runtime_hooks=[],
|
|
18
|
+
excludes=[],
|
|
19
|
+
noarchive=False,
|
|
20
|
+
optimize=0,
|
|
21
|
+
)
|
|
22
|
+
pyz = PYZ(a.pure)
|
|
23
|
+
|
|
24
|
+
exe = EXE(
|
|
25
|
+
pyz,
|
|
26
|
+
a.scripts,
|
|
27
|
+
a.binaries,
|
|
28
|
+
a.datas,
|
|
29
|
+
[],
|
|
30
|
+
name='md2word_demo',
|
|
31
|
+
debug=False,
|
|
32
|
+
bootloader_ignore_signals=False,
|
|
33
|
+
strip=False,
|
|
34
|
+
upx=True,
|
|
35
|
+
upx_exclude=[],
|
|
36
|
+
runtime_tmpdir=None,
|
|
37
|
+
console=False,
|
|
38
|
+
disable_windowed_traceback=False,
|
|
39
|
+
argv_emulation=False,
|
|
40
|
+
target_arch=None,
|
|
41
|
+
codesign_identity=None,
|
|
42
|
+
entitlements_file=None,
|
|
43
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# -*- mode: python ; coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
ROOT = Path(__file__).resolve().parent
|
|
6
|
+
APP_PATH = ROOT / "examples" / "desktop_app" / "app.py"
|
|
7
|
+
INDEX_HTML = ROOT / "examples" / "desktop_app" / "index.html"
|
|
8
|
+
|
|
9
|
+
a = Analysis(
|
|
10
|
+
[str(APP_PATH)],
|
|
11
|
+
pathex=[str(ROOT / "src")],
|
|
12
|
+
binaries=[],
|
|
13
|
+
datas=[(str(INDEX_HTML), ".")],
|
|
14
|
+
hiddenimports=[],
|
|
15
|
+
hookspath=[],
|
|
16
|
+
hooksconfig={},
|
|
17
|
+
runtime_hooks=[],
|
|
18
|
+
excludes=[],
|
|
19
|
+
noarchive=False,
|
|
20
|
+
optimize=0,
|
|
21
|
+
)
|
|
22
|
+
pyz = PYZ(a.pure)
|
|
23
|
+
|
|
24
|
+
exe = EXE(
|
|
25
|
+
pyz,
|
|
26
|
+
a.scripts,
|
|
27
|
+
a.binaries,
|
|
28
|
+
a.datas,
|
|
29
|
+
[],
|
|
30
|
+
name='md2word_demo_console',
|
|
31
|
+
debug=False,
|
|
32
|
+
bootloader_ignore_signals=False,
|
|
33
|
+
strip=False,
|
|
34
|
+
upx=True,
|
|
35
|
+
upx_exclude=[],
|
|
36
|
+
runtime_tmpdir=None,
|
|
37
|
+
console=True,
|
|
38
|
+
disable_windowed_traceback=False,
|
|
39
|
+
argv_emulation=False,
|
|
40
|
+
target_arch=None,
|
|
41
|
+
codesign_identity=None,
|
|
42
|
+
entitlements_file=None,
|
|
43
|
+
)
|
|
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
|