markdown2word / app.js
HFHash789's picture
Upload folder using huggingface_hub
e0effe1 verified
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();