HFHash789 commited on
Commit
e0effe1
·
verified ·
1 Parent(s): 08f0803

Upload folder using huggingface_hub

Browse files
Files changed (9) hide show
  1. .dockerignore +6 -0
  2. .gitignore +10 -0
  3. Dockerfile +14 -0
  4. README.md +11 -6
  5. app.js +257 -0
  6. index.html +58 -0
  7. requirements.txt +4 -0
  8. server.py +88 -0
  9. styles.css +226 -0
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ __pycache__
4
+ *.pyc
5
+ .DS_Store
6
+
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+
4
+ # Downloaded static export (not part of mk2word app)
5
+ aioutput2doc/aioutput2doc.com/_next/
6
+ aioutput2doc/aioutput2doc.com/en/
7
+ aioutput2doc/aioutput2doc.com/en.html
8
+ aioutput2doc/aioutput2doc.com/favicon.ico
9
+ aioutput2doc/cdn.jsdelivr.net/
10
+
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ COPY . .
5
+
6
+ RUN apt-get update && apt-get install -y --no-install-recommends pandoc \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ RUN python -m pip install --no-cache-dir -r requirements.txt
10
+
11
+ ENV PORT=7860
12
+ EXPOSE 7860
13
+
14
+ CMD ["sh", "-c", "uvicorn server:app --host 0.0.0.0 --port ${PORT}"]
README.md CHANGED
@@ -1,10 +1,15 @@
1
  ---
2
- title: Markdown2word
3
- emoji: 🏃
4
- colorFrom: indigo
5
- colorTo: blue
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
1
  ---
2
+ title: "markdown2word"
3
+ emoji: "🚀"
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ ### 🚀 一键部署
11
+ [![Deploy with HFSpaceDeploy](https://img.shields.io/badge/Deploy_with-HFSpaceDeploy-green?style=social&logo=rocket)](https://github.com/kfcx/HFSpaceDeploy)
12
+
13
+ 本项目由[HFSpaceDeploy](https://github.com/kfcx/HFSpaceDeploy)一键部署
14
+
15
+
app.js ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mdInput = document.getElementById("mdInput");
2
+ const preview = document.getElementById("preview");
3
+ const copyBtn = document.getElementById("copyBtn");
4
+ const docxBtn = document.getElementById("docxBtn");
5
+ const clearBtn = document.getElementById("clearBtn");
6
+ const statusEl = document.getElementById("status");
7
+
8
+ function setStatus(message, type = "info") {
9
+ const colors = {
10
+ info: "rgba(255,255,255,0.62)",
11
+ ok: "rgba(34,197,94,0.95)",
12
+ warn: "rgba(245,158,11,0.95)",
13
+ err: "rgba(239,68,68,0.95)",
14
+ };
15
+ statusEl.textContent = message || "";
16
+ statusEl.style.color = colors[type] || colors.info;
17
+ }
18
+
19
+ function getWordCss() {
20
+ return `
21
+ body{
22
+ font-family: Calibri, "Microsoft YaHei", "PingFang SC", "SimSun", Arial, sans-serif;
23
+ font-size: 11pt;
24
+ line-height: 1.6;
25
+ color: #111;
26
+ }
27
+ h1,h2,h3{ font-weight: 700; margin: 0.9em 0 0.4em; }
28
+ p{ margin: 0.5em 0; }
29
+ ul,ol{ margin: 0.5em 0 0.5em 1.2em; }
30
+ blockquote{ margin: 0.7em 0; padding: 0.3em 0.8em; border-left: 3px solid #7c5cff; background: #f6f4ff; }
31
+ code{ font-family: Consolas, "Courier New", monospace; background: #f3f4f6; padding: 0.05em 0.25em; border-radius: 4px; }
32
+ pre{ font-family: Consolas, "Courier New", monospace; background: #f6f7f9; padding: 10px 12px; border-radius: 6px; }
33
+ pre code{ background: transparent; padding: 0; }
34
+ table{ border-collapse: collapse; width: 100%; }
35
+ th,td{ border: 1px solid #d1d5db; padding: 6px 8px; vertical-align: top; }
36
+ th{ background: #f3f4f6; }
37
+ a{ color: #1d4ed8; text-decoration: underline; }
38
+ `;
39
+ }
40
+
41
+ function ensureDeps() {
42
+ const ok = typeof window.marked?.parse === "function" && typeof window.DOMPurify?.sanitize === "function";
43
+ if (!ok) {
44
+ setStatus("依赖加载失败:marked/DOMPurify 未就绪(可能被网络拦截)", "err");
45
+ }
46
+ return ok;
47
+ }
48
+
49
+ function hasMathDeps() {
50
+ return typeof window.renderMathInElement === "function" && typeof window.katex !== "undefined";
51
+ }
52
+
53
+ function renderMath(root) {
54
+ if (!hasMathDeps()) return;
55
+ try {
56
+ renderMathInElement(root, {
57
+ delimiters: [
58
+ { left: "$$", right: "$$", display: true },
59
+ { left: "$", right: "$", display: false },
60
+ { left: "\\(", right: "\\)", display: false },
61
+ { left: "\\[", right: "\\]", display: true },
62
+ ],
63
+ throwOnError: false,
64
+ });
65
+ } catch {
66
+ // ignore
67
+ }
68
+ }
69
+
70
+ function normalizeMarkdownForMath(markdown) {
71
+ let src = markdown || "";
72
+ const original = src;
73
+
74
+ // 1. 块级公式: \[ ... \] 或 \\[ ... \\] → $$ ... $$
75
+ // 必须在 marked 解析前转换,否则反斜杠会被吃掉
76
+ src = src.replace(/\\{1,2}\[([\s\S]*?)\\{1,2}\]/g, (match, formula) => {
77
+ return `\n$$${formula.trim()}$$\n`;
78
+ });
79
+
80
+ // 2. 行内公式: \( ... \) 或 \\( ... \\) → $ ... $
81
+ src = src.replace(/\\{1,2}\(([\s\S]*?)\\{1,2}\)/g, (match, formula) => {
82
+ return `$${formula}$`;
83
+ });
84
+
85
+ // 3. AI 常见输出: 单独一行的 [ ... ] → $$ ... $$
86
+ src = src.replace(/(^|\n)\s*\[\s*\n([\s\S]*?)\n\s*\]\s*(?=\n|$)/g, "\n$$\n$2\n$$\n");
87
+
88
+ // Debug 输出
89
+ if (src !== original) {
90
+ console.log("=== 公式规范化 ===");
91
+ console.log("原始:", original);
92
+ console.log("转换后:", src);
93
+ }
94
+
95
+ return src;
96
+ }
97
+
98
+ function renderMarkdown(markdown) {
99
+ if (!ensureDeps()) return { html: "", plain: markdown || "" };
100
+
101
+ marked.setOptions({
102
+ gfm: true,
103
+ breaks: true,
104
+ headerIds: false,
105
+ mangle: false,
106
+ });
107
+
108
+ const normalized = normalizeMarkdownForMath(markdown || "");
109
+ const rawHtml = marked.parse(normalized);
110
+ const safeHtml = DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } });
111
+ return { html: safeHtml, plain: normalized };
112
+ }
113
+
114
+ function updatePreview() {
115
+ const { html } = renderMarkdown(mdInput.value);
116
+ preview.innerHTML = html || `<div style="color: rgba(255,255,255,0.5);">这里会显示预览…</div>`;
117
+ renderMath(preview);
118
+ }
119
+
120
+ function htmlToPlainText(html) {
121
+ const div = document.createElement("div");
122
+ div.innerHTML = html;
123
+ return div.innerText || div.textContent || "";
124
+ }
125
+
126
+ function convertKatexToMathML(root) {
127
+ const displayNodes = Array.from(root.querySelectorAll(".katex-display"));
128
+ for (const displayNode of displayNodes) {
129
+ const math = displayNode.querySelector(".katex-mathml math") || displayNode.querySelector("math");
130
+ if (!math) continue;
131
+ const block = document.createElement("div");
132
+ block.appendChild(math.cloneNode(true));
133
+ displayNode.replaceWith(block);
134
+ }
135
+
136
+ const katexNodes = Array.from(root.querySelectorAll(".katex"));
137
+ for (const node of katexNodes) {
138
+ const math = node.querySelector(".katex-mathml math") || node.querySelector("math");
139
+ if (!math) continue;
140
+ node.replaceWith(math.cloneNode(true));
141
+ }
142
+ }
143
+
144
+ function replaceKatexWithTexText(root) {
145
+ const displayNodes = Array.from(root.querySelectorAll(".katex-display"));
146
+ for (const displayNode of displayNodes) {
147
+ const tex =
148
+ displayNode.querySelector('annotation[encoding="application/x-tex"]')?.textContent ||
149
+ displayNode.querySelector(".katex-mathml annotation")?.textContent ||
150
+ "";
151
+ if (!tex.trim()) continue;
152
+ const div = document.createElement("div");
153
+ div.textContent = `$$\n${tex}\n$$`;
154
+ div.style.whiteSpace = "pre-wrap";
155
+ div.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
156
+ displayNode.replaceWith(div);
157
+ }
158
+
159
+ const inlineNodes = Array.from(root.querySelectorAll(".katex"));
160
+ for (const node of inlineNodes) {
161
+ const tex =
162
+ node.querySelector('annotation[encoding="application/x-tex"]')?.textContent ||
163
+ node.querySelector(".katex-mathml annotation")?.textContent ||
164
+ "";
165
+ if (!tex.trim()) continue;
166
+ const span = document.createElement("span");
167
+ span.textContent = `$${tex}$`;
168
+ span.style.whiteSpace = "pre-wrap";
169
+ span.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
170
+ node.replaceWith(span);
171
+ }
172
+ }
173
+
174
+ async function copyAsWord() {
175
+ const markdown = mdInput.value.trim();
176
+ if (!markdown) {
177
+ setStatus("先粘贴一些 Markdown 再复制", "warn");
178
+ return;
179
+ }
180
+ if (!ensureDeps()) return;
181
+
182
+ const { html, plain: normalizedPlain } = renderMarkdown(markdown);
183
+
184
+ const container = document.createElement("div");
185
+ container.innerHTML = html;
186
+ renderMath(container);
187
+ // Clipboard: 使用 MathML 让 Word 能识别为可编辑公式
188
+ convertKatexToMathML(container);
189
+
190
+ const wordHtml = `<!doctype html><html><head><meta charset="utf-8"><style>${getWordCss()}</style></head><body>${container.innerHTML}</body></html>`;
191
+ const plain = container.innerText || htmlToPlainText(html);
192
+
193
+ try {
194
+ if (!navigator.clipboard?.write) {
195
+ throw new Error("Clipboard API 不可用");
196
+ }
197
+ const item = new ClipboardItem({
198
+ "text/html": new Blob([wordHtml], { type: "text/html" }),
199
+ "text/plain": new Blob([plain], { type: "text/plain" }),
200
+ });
201
+ await navigator.clipboard.write([item]);
202
+ setStatus("已复制:去 Word/WPS 直接粘贴即可", "ok");
203
+ } catch (e) {
204
+ setStatus(`复制失败:${e?.message || e}`, "err");
205
+ }
206
+ }
207
+
208
+ async function downloadDocx() {
209
+ const markdown = mdInput.value.trim();
210
+ if (!markdown) {
211
+ setStatus("先粘贴一些 Markdown 再导出", "warn");
212
+ return;
213
+ }
214
+
215
+ const { plain: normalized } = renderMarkdown(markdown);
216
+ setStatus("正在生成 DOCX…", "info");
217
+
218
+ try {
219
+ const resp = await fetch("/api/docx", {
220
+ method: "POST",
221
+ headers: { "Content-Type": "application/json" },
222
+ body: JSON.stringify({ markdown: normalized, filename: "output.docx" }),
223
+ });
224
+ if (!resp.ok) {
225
+ const text = await resp.text().catch(() => "");
226
+ throw new Error(text || `HTTP ${resp.status}`);
227
+ }
228
+ const blob = await resp.blob();
229
+ const url = URL.createObjectURL(blob);
230
+ const a = document.createElement("a");
231
+ a.href = url;
232
+ a.download = "output.docx";
233
+ document.body.appendChild(a);
234
+ a.click();
235
+ a.remove();
236
+ URL.revokeObjectURL(url);
237
+ setStatus("已生成 DOCX(公式可编辑)", "ok");
238
+ } catch (e) {
239
+ setStatus(`导出失败:${e?.message || e}`, "err");
240
+ }
241
+ }
242
+
243
+ mdInput.addEventListener("input", () => {
244
+ updatePreview();
245
+ setStatus("");
246
+ });
247
+
248
+ copyBtn.addEventListener("click", copyAsWord);
249
+ docxBtn.addEventListener("click", downloadDocx);
250
+ clearBtn.addEventListener("click", () => {
251
+ mdInput.value = "";
252
+ updatePreview();
253
+ setStatus("");
254
+ mdInput.focus();
255
+ });
256
+
257
+ updatePreview();
index.html ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>MK2Word</title>
7
+ <link rel="stylesheet" href="styles.css" />
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
9
+ </head>
10
+ <body>
11
+ <main class="wrap">
12
+ <header class="header">
13
+ <div>
14
+ <h1 class="title">MK2Word</h1>
15
+ <p class="subtitle">粘贴 Markdown → 一键复制为 Word 可粘贴的富文本</p>
16
+ </div>
17
+ <div class="actions">
18
+ <button id="copyBtn" class="btn btn-primary" type="button">复制为 Word 格式</button>
19
+ <button id="docxBtn" class="btn" type="button">下载 DOCX(可编辑公式)</button>
20
+ <button id="clearBtn" class="btn" type="button">清空</button>
21
+ </div>
22
+ </header>
23
+
24
+ <section class="grid">
25
+ <div class="panel">
26
+ <div class="panel-head">
27
+ <h2>输入(Markdown)</h2>
28
+ <div class="small">支持表格/任务列表等常见 GFM</div>
29
+ </div>
30
+ <textarea
31
+ id="mdInput"
32
+ class="textarea"
33
+ placeholder="把 AI 生成的 Markdown 粘贴到这里…"
34
+ spellcheck="false"
35
+ ></textarea>
36
+ <div class="hint">
37
+ 粘贴到 Word 时建议用普通粘贴(Ctrl+V),不要“只保留文本”。
38
+ </div>
39
+ </div>
40
+
41
+ <div class="panel">
42
+ <div class="panel-head">
43
+ <h2>预览</h2>
44
+ <div id="status" class="status" aria-live="polite"></div>
45
+ </div>
46
+ <div id="preview" class="preview"></div>
47
+ </div>
48
+ </section>
49
+ </main>
50
+
51
+ <!-- CDN 方式:无需构建,直接可用。若你需要离线/内网环境,再改为本地打包引入。 -->
52
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
53
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.11/dist/purify.min.js"></script>
54
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
55
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
56
+ <script src="app.js"></script>
57
+ </body>
58
+ </html>
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.27.1
3
+ pydantic==2.10.4
4
+
server.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.responses import FileResponse, Response
11
+ from pydantic import BaseModel
12
+
13
+
14
+ BASE_DIR = Path(__file__).resolve().parent
15
+
16
+ DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
17
+
18
+
19
+ class DocxRequest(BaseModel):
20
+ markdown: str
21
+ filename: str | None = None
22
+
23
+
24
+ app = FastAPI()
25
+
26
+
27
+ @app.get("/")
28
+ def index():
29
+ return FileResponse(BASE_DIR / "index.html")
30
+
31
+
32
+ @app.get("/{path:path}")
33
+ def static_files(path: str):
34
+ if path.startswith("api/"):
35
+ raise HTTPException(status_code=404)
36
+
37
+ candidate = (BASE_DIR / path).resolve()
38
+ if candidate == BASE_DIR or BASE_DIR not in candidate.parents:
39
+ raise HTTPException(status_code=404)
40
+ if not candidate.exists() or not candidate.is_file():
41
+ raise HTTPException(status_code=404)
42
+
43
+ media_type, _ = mimetypes.guess_type(str(candidate))
44
+ return FileResponse(candidate, media_type=media_type)
45
+
46
+
47
+ @app.post("/api/docx")
48
+ def export_docx(req: DocxRequest):
49
+ markdown = (req.markdown or "").strip()
50
+ if not markdown:
51
+ raise HTTPException(status_code=400, detail="empty markdown")
52
+
53
+ filename = (req.filename or "output.docx").strip() or "output.docx"
54
+ if not filename.lower().endswith(".docx"):
55
+ filename += ".docx"
56
+
57
+ with tempfile.TemporaryDirectory() as td:
58
+ td_path = Path(td)
59
+ input_md = td_path / "input.md"
60
+ output_docx = td_path / "output.docx"
61
+ input_md.write_text(markdown, encoding="utf-8")
62
+
63
+ cmd = [
64
+ "pandoc",
65
+ str(input_md),
66
+ "--from",
67
+ "markdown+tex_math_dollars+tex_math_single_backslash",
68
+ "--to",
69
+ "docx",
70
+ "--output",
71
+ str(output_docx),
72
+ ]
73
+ try:
74
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
75
+ except FileNotFoundError as e:
76
+ raise HTTPException(status_code=500, detail="pandoc not installed") from e
77
+ except subprocess.CalledProcessError as e:
78
+ detail = (e.stderr or b"").decode("utf-8", errors="replace")[-4000:]
79
+ raise HTTPException(status_code=400, detail=f"pandoc failed: {detail}") from e
80
+
81
+ data = output_docx.read_bytes()
82
+
83
+ return Response(
84
+ content=data,
85
+ media_type=DOCX_MIME,
86
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
87
+ )
88
+
styles.css ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0b1020;
3
+ --panel: rgba(255, 255, 255, 0.06);
4
+ --panel-border: rgba(255, 255, 255, 0.10);
5
+ --text: rgba(255, 255, 255, 0.92);
6
+ --muted: rgba(255, 255, 255, 0.62);
7
+ --brand: #7c5cff;
8
+ --brand-2: #22c55e;
9
+ }
10
+
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ html,
16
+ body {
17
+ height: 100%;
18
+ }
19
+
20
+ body {
21
+ margin: 0;
22
+ color: var(--text);
23
+ background: radial-gradient(1200px 800px at 20% 0%, rgba(124, 92, 255, 0.25), transparent 60%),
24
+ radial-gradient(900px 600px at 80% 20%, rgba(34, 197, 94, 0.18), transparent 55%),
25
+ var(--bg);
26
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
27
+ "Segoe UI Emoji";
28
+ }
29
+
30
+ .wrap {
31
+ max-width: 1200px;
32
+ margin: 0 auto;
33
+ padding: 28px 18px 40px;
34
+ }
35
+
36
+ .header {
37
+ display: flex;
38
+ align-items: flex-start;
39
+ justify-content: space-between;
40
+ gap: 16px;
41
+ margin-bottom: 18px;
42
+ }
43
+
44
+ .title {
45
+ margin: 0 0 6px;
46
+ font-size: 28px;
47
+ letter-spacing: 0.2px;
48
+ }
49
+
50
+ .subtitle {
51
+ margin: 0;
52
+ color: var(--muted);
53
+ }
54
+
55
+ .actions {
56
+ display: flex;
57
+ gap: 10px;
58
+ flex-wrap: wrap;
59
+ }
60
+
61
+ .btn {
62
+ appearance: none;
63
+ border: 1px solid var(--panel-border);
64
+ background: rgba(255, 255, 255, 0.06);
65
+ color: var(--text);
66
+ padding: 10px 12px;
67
+ border-radius: 10px;
68
+ cursor: pointer;
69
+ font-weight: 600;
70
+ }
71
+
72
+ .btn:hover {
73
+ background: rgba(255, 255, 255, 0.10);
74
+ }
75
+
76
+ .btn:active {
77
+ transform: translateY(1px);
78
+ }
79
+
80
+ .btn-primary {
81
+ border-color: rgba(124, 92, 255, 0.45);
82
+ background: linear-gradient(135deg, rgba(124, 92, 255, 0.9), rgba(124, 92, 255, 0.55));
83
+ }
84
+
85
+ .btn-primary:hover {
86
+ background: linear-gradient(135deg, rgba(124, 92, 255, 0.98), rgba(124, 92, 255, 0.62));
87
+ }
88
+
89
+ .grid {
90
+ display: grid;
91
+ grid-template-columns: 1fr 1fr;
92
+ gap: 14px;
93
+ }
94
+
95
+ @media (max-width: 980px) {
96
+ .grid {
97
+ grid-template-columns: 1fr;
98
+ }
99
+ }
100
+
101
+ .panel {
102
+ border: 1px solid var(--panel-border);
103
+ background: var(--panel);
104
+ border-radius: 14px;
105
+ overflow: hidden;
106
+ min-height: 520px;
107
+ display: flex;
108
+ flex-direction: column;
109
+ }
110
+
111
+ .panel-head {
112
+ padding: 14px 14px 10px;
113
+ border-bottom: 1px solid rgba(255, 255, 255, 0.10);
114
+ display: flex;
115
+ align-items: baseline;
116
+ justify-content: space-between;
117
+ gap: 12px;
118
+ }
119
+
120
+ .panel-head h2 {
121
+ margin: 0;
122
+ font-size: 15px;
123
+ letter-spacing: 0.2px;
124
+ }
125
+
126
+ .small {
127
+ color: var(--muted);
128
+ font-size: 12px;
129
+ }
130
+
131
+ .status {
132
+ font-size: 12px;
133
+ color: var(--muted);
134
+ min-height: 1.2em;
135
+ text-align: right;
136
+ }
137
+
138
+ .textarea {
139
+ width: 100%;
140
+ flex: 1;
141
+ resize: none;
142
+ padding: 14px;
143
+ border: 0;
144
+ outline: none;
145
+ background: transparent;
146
+ color: var(--text);
147
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
148
+ font-size: 13px;
149
+ line-height: 1.6;
150
+ }
151
+
152
+ .hint {
153
+ padding: 10px 14px 14px;
154
+ color: var(--muted);
155
+ font-size: 12px;
156
+ border-top: 1px solid rgba(255, 255, 255, 0.10);
157
+ }
158
+
159
+ .preview {
160
+ flex: 1;
161
+ padding: 14px 16px 18px;
162
+ overflow: auto;
163
+ }
164
+
165
+ .preview :first-child {
166
+ margin-top: 0;
167
+ }
168
+
169
+ .preview h1,
170
+ .preview h2,
171
+ .preview h3 {
172
+ margin: 14px 0 10px;
173
+ }
174
+
175
+ .preview p {
176
+ margin: 10px 0;
177
+ }
178
+
179
+ .preview a {
180
+ color: rgba(124, 92, 255, 0.95);
181
+ }
182
+
183
+ .preview pre {
184
+ background: rgba(0, 0, 0, 0.35);
185
+ border: 1px solid rgba(255, 255, 255, 0.10);
186
+ padding: 10px 12px;
187
+ border-radius: 10px;
188
+ overflow: auto;
189
+ }
190
+
191
+ .preview code {
192
+ background: rgba(0, 0, 0, 0.25);
193
+ padding: 1px 5px;
194
+ border-radius: 6px;
195
+ }
196
+
197
+ .preview pre code {
198
+ background: transparent;
199
+ padding: 0;
200
+ }
201
+
202
+ .preview blockquote {
203
+ margin: 12px 0;
204
+ padding: 8px 12px;
205
+ border-left: 3px solid rgba(124, 92, 255, 0.7);
206
+ background: rgba(255, 255, 255, 0.04);
207
+ color: rgba(255, 255, 255, 0.82);
208
+ }
209
+
210
+ .preview table {
211
+ width: 100%;
212
+ border-collapse: collapse;
213
+ margin: 12px 0;
214
+ }
215
+
216
+ .preview th,
217
+ .preview td {
218
+ border: 1px solid rgba(255, 255, 255, 0.12);
219
+ padding: 8px 10px;
220
+ vertical-align: top;
221
+ }
222
+
223
+ .preview th {
224
+ background: rgba(255, 255, 255, 0.06);
225
+ }
226
+