md2word 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 (27) hide show
  1. md2word-0.1.2/.github/workflows/desktop_release.yml +114 -0
  2. {md2word-0.1.1 → md2word-0.1.2}/.gitignore +3 -1
  3. {md2word-0.1.1 → md2word-0.1.2}/PKG-INFO +1 -1
  4. md2word-0.1.2/examples/desktop_app/README.md +21 -0
  5. md2word-0.1.2/examples/desktop_app/app.py +224 -0
  6. md2word-0.1.2/examples/desktop_app/index.html +480 -0
  7. {md2word-0.1.1 → md2word-0.1.2}/examples/example_zh.md +3 -3
  8. md2word-0.1.2/md2word_demo.spec +43 -0
  9. md2word-0.1.2/md2word_demo_console.spec +43 -0
  10. {md2word-0.1.1 → md2word-0.1.2}/pyproject.toml +1 -1
  11. {md2word-0.1.1 → md2word-0.1.2}/uv.lock +1 -1
  12. {md2word-0.1.1 → md2word-0.1.2}/.github/workflows/publish.yml +0 -0
  13. {md2word-0.1.1 → md2word-0.1.2}/LICENSE +0 -0
  14. {md2word-0.1.1 → md2word-0.1.2}/README.md +0 -0
  15. {md2word-0.1.1 → md2word-0.1.2}/README_zh.md +0 -0
  16. {md2word-0.1.1 → md2word-0.1.2}/examples/config_chinese.json +0 -0
  17. {md2word-0.1.1 → md2word-0.1.2}/examples/config_english.json +0 -0
  18. {md2word-0.1.1 → md2word-0.1.2}/examples/example.md +0 -0
  19. {md2word-0.1.1 → md2word-0.1.2}/src/md2word/__init__.py +0 -0
  20. {md2word-0.1.1 → md2word-0.1.2}/src/md2word/__main__.py +0 -0
  21. {md2word-0.1.1 → md2word-0.1.2}/src/md2word/config.py +0 -0
  22. {md2word-0.1.1 → md2word-0.1.2}/src/md2word/converter.py +0 -0
  23. {md2word-0.1.1 → md2word-0.1.2}/src/md2word/latex.py +0 -0
  24. {md2word-0.1.1 → md2word-0.1.2}/tests/__init__.py +0 -0
  25. {md2word-0.1.1 → md2word-0.1.2}/tests/test_config.py +0 -0
  26. {md2word-0.1.1 → md2word-0.1.2}/tests/test_converter.py +0 -0
  27. {md2word-0.1.1 → 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"
@@ -27,6 +27,8 @@ wheels/
27
27
  # PyInstaller
28
28
  *.manifest
29
29
  *.spec
30
+ !md2word_demo.spec
31
+ !md2word_demo_console.spec
30
32
 
31
33
  # Installer logs
32
34
  pip-log.txt
@@ -75,4 +77,4 @@ images/
75
77
  *.docx
76
78
  config.json
77
79
 
78
- .claude/
80
+ .claude/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: md2word
3
- Version: 0.1.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>
@@ -27,12 +27,12 @@
27
27
  代码块:
28
28
 
29
29
  ```python
30
- def 斐波那契(n):
30
+ def fibonacci(n):
31
31
  if n <= 1:
32
32
  return n
33
- return 斐波那契(n-1) + 斐波那契(n-2)
33
+ return fibonacci(n-1) + fibonacci(n-2)
34
34
 
35
- print(斐波那契(10))
35
+ print(fibonacci(10))
36
36
  ```
37
37
 
38
38
  ### 表格
@@ -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
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "md2word"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Convert Markdown files to Word documents with extensive customization"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -384,7 +384,7 @@ wheels = [
384
384
 
385
385
  [[package]]
386
386
  name = "md2word"
387
- version = "0.1.0"
387
+ version = "0.1.2"
388
388
  source = { editable = "." }
389
389
  dependencies = [
390
390
  { name = "html-for-docx" },
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