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 || `
这里会显示预览…
`; 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 = `${container.innerHTML}`; 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();