Spaces:
Running
Running
| const mdInput = document.getElementById("mdInput"); | |
| const preview = document.getElementById("preview"); | |
| const copyBtn = document.getElementById("copyBtn"); | |
| const docxBtn = document.getElementById("docxBtn"); | |
| const clearBtn = document.getElementById("clearBtn"); | |
| const statusEl = document.getElementById("status"); | |
| function setStatus(message, type = "info") { | |
| const colors = { | |
| info: "rgba(255,255,255,0.62)", | |
| ok: "rgba(34,197,94,0.95)", | |
| warn: "rgba(245,158,11,0.95)", | |
| err: "rgba(239,68,68,0.95)", | |
| }; | |
| statusEl.textContent = message || ""; | |
| statusEl.style.color = colors[type] || colors.info; | |
| } | |
| function getWordCss() { | |
| return ` | |
| body{ | |
| font-family: Calibri, "Microsoft YaHei", "PingFang SC", "SimSun", Arial, sans-serif; | |
| font-size: 11pt; | |
| line-height: 1.6; | |
| color: #111; | |
| } | |
| h1,h2,h3{ font-weight: 700; margin: 0.9em 0 0.4em; } | |
| p{ margin: 0.5em 0; } | |
| ul,ol{ margin: 0.5em 0 0.5em 1.2em; } | |
| blockquote{ margin: 0.7em 0; padding: 0.3em 0.8em; border-left: 3px solid #7c5cff; background: #f6f4ff; } | |
| code{ font-family: Consolas, "Courier New", monospace; background: #f3f4f6; padding: 0.05em 0.25em; border-radius: 4px; } | |
| pre{ font-family: Consolas, "Courier New", monospace; background: #f6f7f9; padding: 10px 12px; border-radius: 6px; } | |
| pre code{ background: transparent; padding: 0; } | |
| table{ border-collapse: collapse; width: 100%; } | |
| th,td{ border: 1px solid #d1d5db; padding: 6px 8px; vertical-align: top; } | |
| th{ background: #f3f4f6; } | |
| a{ color: #1d4ed8; text-decoration: underline; } | |
| `; | |
| } | |
| function ensureDeps() { | |
| const ok = typeof window.marked?.parse === "function" && typeof window.DOMPurify?.sanitize === "function"; | |
| if (!ok) { | |
| setStatus("依赖加载失败:marked/DOMPurify 未就绪(可能被网络拦截)", "err"); | |
| } | |
| return ok; | |
| } | |
| function hasMathDeps() { | |
| return typeof window.renderMathInElement === "function" && typeof window.katex !== "undefined"; | |
| } | |
| function renderMath(root) { | |
| if (!hasMathDeps()) return; | |
| try { | |
| renderMathInElement(root, { | |
| delimiters: [ | |
| { left: "$$", right: "$$", display: true }, | |
| { left: "$", right: "$", display: false }, | |
| { left: "\\(", right: "\\)", display: false }, | |
| { left: "\\[", right: "\\]", display: true }, | |
| ], | |
| throwOnError: false, | |
| }); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| function normalizeMarkdownForMath(markdown) { | |
| let src = markdown || ""; | |
| const original = src; | |
| // 1. 块级公式: \[ ... \] 或 \\[ ... \\] → $$ ... $$ | |
| // 必须在 marked 解析前转换,否则反斜杠会被吃掉 | |
| src = src.replace(/\\{1,2}\[([\s\S]*?)\\{1,2}\]/g, (match, formula) => { | |
| return `\n$$${formula.trim()}$$\n`; | |
| }); | |
| // 2. 行内公式: \( ... \) 或 \\( ... \\) → $ ... $ | |
| src = src.replace(/\\{1,2}\(([\s\S]*?)\\{1,2}\)/g, (match, formula) => { | |
| return `$${formula}$`; | |
| }); | |
| // 3. AI 常见输出: 单独一行的 [ ... ] → $$ ... $$ | |
| src = src.replace(/(^|\n)\s*\[\s*\n([\s\S]*?)\n\s*\]\s*(?=\n|$)/g, "\n$$\n$2\n$$\n"); | |
| // Debug 输出 | |
| if (src !== original) { | |
| console.log("=== 公式规范化 ==="); | |
| console.log("原始:", original); | |
| console.log("转换后:", src); | |
| } | |
| return src; | |
| } | |
| function renderMarkdown(markdown) { | |
| if (!ensureDeps()) return { html: "", plain: markdown || "" }; | |
| marked.setOptions({ | |
| gfm: true, | |
| breaks: true, | |
| headerIds: false, | |
| mangle: false, | |
| }); | |
| const normalized = normalizeMarkdownForMath(markdown || ""); | |
| const rawHtml = marked.parse(normalized); | |
| const safeHtml = DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } }); | |
| return { html: safeHtml, plain: normalized }; | |
| } | |
| function updatePreview() { | |
| const { html } = renderMarkdown(mdInput.value); | |
| preview.innerHTML = html || `<div style="color: rgba(255,255,255,0.5);">这里会显示预览…</div>`; | |
| renderMath(preview); | |
| } | |
| function htmlToPlainText(html) { | |
| const div = document.createElement("div"); | |
| div.innerHTML = html; | |
| return div.innerText || div.textContent || ""; | |
| } | |
| function convertKatexToMathML(root) { | |
| const displayNodes = Array.from(root.querySelectorAll(".katex-display")); | |
| for (const displayNode of displayNodes) { | |
| const math = displayNode.querySelector(".katex-mathml math") || displayNode.querySelector("math"); | |
| if (!math) continue; | |
| const block = document.createElement("div"); | |
| block.appendChild(math.cloneNode(true)); | |
| displayNode.replaceWith(block); | |
| } | |
| const katexNodes = Array.from(root.querySelectorAll(".katex")); | |
| for (const node of katexNodes) { | |
| const math = node.querySelector(".katex-mathml math") || node.querySelector("math"); | |
| if (!math) continue; | |
| node.replaceWith(math.cloneNode(true)); | |
| } | |
| } | |
| function replaceKatexWithTexText(root) { | |
| const displayNodes = Array.from(root.querySelectorAll(".katex-display")); | |
| for (const displayNode of displayNodes) { | |
| const tex = | |
| displayNode.querySelector('annotation[encoding="application/x-tex"]')?.textContent || | |
| displayNode.querySelector(".katex-mathml annotation")?.textContent || | |
| ""; | |
| if (!tex.trim()) continue; | |
| const div = document.createElement("div"); | |
| div.textContent = `$$\n${tex}\n$$`; | |
| div.style.whiteSpace = "pre-wrap"; | |
| div.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; | |
| displayNode.replaceWith(div); | |
| } | |
| const inlineNodes = Array.from(root.querySelectorAll(".katex")); | |
| for (const node of inlineNodes) { | |
| const tex = | |
| node.querySelector('annotation[encoding="application/x-tex"]')?.textContent || | |
| node.querySelector(".katex-mathml annotation")?.textContent || | |
| ""; | |
| if (!tex.trim()) continue; | |
| const span = document.createElement("span"); | |
| span.textContent = `$${tex}$`; | |
| span.style.whiteSpace = "pre-wrap"; | |
| span.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; | |
| node.replaceWith(span); | |
| } | |
| } | |
| async function copyAsWord() { | |
| const markdown = mdInput.value.trim(); | |
| if (!markdown) { | |
| setStatus("先粘贴一些 Markdown 再复制", "warn"); | |
| return; | |
| } | |
| if (!ensureDeps()) return; | |
| const { html, plain: normalizedPlain } = renderMarkdown(markdown); | |
| const container = document.createElement("div"); | |
| container.innerHTML = html; | |
| renderMath(container); | |
| // Clipboard: 使用 MathML 让 Word 能识别为可编辑公式 | |
| convertKatexToMathML(container); | |
| const wordHtml = `<!doctype html><html><head><meta charset="utf-8"><style>${getWordCss()}</style></head><body>${container.innerHTML}</body></html>`; | |
| const plain = container.innerText || htmlToPlainText(html); | |
| try { | |
| if (!navigator.clipboard?.write) { | |
| throw new Error("Clipboard API 不可用"); | |
| } | |
| const item = new ClipboardItem({ | |
| "text/html": new Blob([wordHtml], { type: "text/html" }), | |
| "text/plain": new Blob([plain], { type: "text/plain" }), | |
| }); | |
| await navigator.clipboard.write([item]); | |
| setStatus("已复制:去 Word/WPS 直接粘贴即可", "ok"); | |
| } catch (e) { | |
| setStatus(`复制失败:${e?.message || e}`, "err"); | |
| } | |
| } | |
| async function downloadDocx() { | |
| const markdown = mdInput.value.trim(); | |
| if (!markdown) { | |
| setStatus("先粘贴一些 Markdown 再导出", "warn"); | |
| return; | |
| } | |
| const { plain: normalized } = renderMarkdown(markdown); | |
| setStatus("正在生成 DOCX…", "info"); | |
| try { | |
| const resp = await fetch("/api/docx", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ markdown: normalized, filename: "output.docx" }), | |
| }); | |
| if (!resp.ok) { | |
| const text = await resp.text().catch(() => ""); | |
| throw new Error(text || `HTTP ${resp.status}`); | |
| } | |
| const blob = await resp.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "output.docx"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| URL.revokeObjectURL(url); | |
| setStatus("已生成 DOCX(公式可编辑)", "ok"); | |
| } catch (e) { | |
| setStatus(`导出失败:${e?.message || e}`, "err"); | |
| } | |
| } | |
| mdInput.addEventListener("input", () => { | |
| updatePreview(); | |
| setStatus(""); | |
| }); | |
| copyBtn.addEventListener("click", copyAsWord); | |
| docxBtn.addEventListener("click", downloadDocx); | |
| clearBtn.addEventListener("click", () => { | |
| mdInput.value = ""; | |
| updatePreview(); | |
| setStatus(""); | |
| mdInput.focus(); | |
| }); | |
| updatePreview(); | |