/** * Markdown Note 翻译辅助工具(零依赖) * - HTML -> 占位 -> 翻译回填 -> HTML * - 保护代码块/内联代码与链接/图片的 URL 属性,只翻译可见文本与可选的 img.alt */ // ---配置项--- const DEFAULT_OPTIONS = { translateImageAlt: true, // 是否翻译 keepSurroundingPunctuation: false // 是否保持两端标点不翻译 }; // 占位符生成 const PH_PREFIX = "⟪T"; const PH_SUFFIX = "⟫"; // 判断是否在 code 上下文中 function isInCodeContext(node) { while (node) { if (node.nodeType === Node.ELEMENT_NODE) { const tag = node.tagName?.toLowerCase(); if (tag === 'code' || tag === 'pre') return true; } node = node.parentNode; } return false; } // 判断属性是否为 URL/不翻译属性 function isNonTranslatableAttr(name) { if (!name) return true; const n = name.toLowerCase(); return n === 'href' || n === 'src' || n.startsWith('data-') || n === 'title'; } // 拆分前后空白与标点 function splitLeadingTrailing(text, keepPunct) { if (!text) return { lead: '', core: '', trail: '' }; let leadWS = text.match(/^\s+/)?.[0] || ''; let trailWS = text.match(/\s+$/)?.[0] || ''; let core = text.slice(leadWS.length, text.length - trailWS.length); if (keepPunct && core) { const punctSet = new Set([',', ',', '.', '。', '!', '!', '?', '?', ':', ':', ';', ';']); let left = 0; while (left < core.length && punctSet.has(core[left])) left++; let right = core.length - 1; while (right >= left && punctSet.has(core[right])) right--; const leftP = core.slice(0, left); const mid = core.slice(left, right + 1); const rightP = core.slice(right + 1); return { lead: leadWS + leftP, core: mid, trail: rightP + trailWS }; } return { lead: leadWS, core, trail: trailWS }; } // 遍历文本节点并占位 function protectAndExtract(html, options = {}) { const opts = { ...DEFAULT_OPTIONS, ...options }; const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const body = doc.body; const texts = []; const placeholders = []; let index = 0; const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT; // 跳过纯空白 if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; // 代码上下文跳过 if (isInCodeContext(node)) return NodeFilter.FILTER_REJECT; // 属性文本不在 TreeWalker 中出现,这里只过滤可见文本节点 return NodeFilter.FILTER_ACCEPT; } }); const nodeRecords = []; while (walker.nextNode()) { const node = walker.currentNode; // 如果在链接或图片等标签内,仅翻译可见文本 const parentEl = node.parentElement; if (parentEl) { const tag = parentEl.tagName?.toLowerCase(); if (tag === 'a') { // 仅翻译节点文本,属性跳过 } else if (tag === 'img') { // 文本节点一般不出现在 下,这里忽略 } } const { lead, core, trail } = splitLeadingTrailing(node.nodeValue, opts.keepSurroundingPunctuation); if (!core) continue; // 全是空白或标点且设置保持 const ph = `${PH_PREFIX}${index}${PH_SUFFIX}`; texts.push(core); placeholders.push(ph); nodeRecords.push({ node, lead, trail, ph }); index++; } // 将文本节点替换为 占位结构:lead + PH + trail for (const rec of nodeRecords) { rec.node.nodeValue = `${rec.lead}${rec.ph}${rec.trail}`; } // 可选:处理 const imgList = Array.from(body.querySelectorAll('img[alt]')); const imgAltRecords = []; if (opts.translateImageAlt && imgList.length) { for (const img of imgList) { const alt = img.getAttribute('alt'); if (alt && alt.trim() && !isInCodeContext(img)) { const { lead, core, trail } = splitLeadingTrailing(alt, opts.keepSurroundingPunctuation); if (!core) continue; const ph = `${PH_PREFIX}${index}${PH_SUFFIX}`; texts.push(core); placeholders.push(ph); imgAltRecords.push({ el: img, lead, trail, ph }); index++; } } } for (const rec of imgAltRecords) { rec.el.setAttribute('alt', `${rec.lead}${rec.ph}${rec.trail}`); } // 返回占位后的 HTML 与文本数组 return { placeholderHTML: body.innerHTML, texts, placeholders }; } // 回填翻译 function restoreWithTranslations(placeholderHTML, placeholders, translations) { let html = placeholderHTML; for (let i = 0; i < placeholders.length; i++) { const ph = placeholders[i]; const tr = translations[i] ?? ''; // 只替换一次以保持顺序 html = html.replace(ph, tr); } return html; } export const MarkdownNoteTranslate = { protectAndExtract, restoreWithTranslations, constants: { PH_PREFIX, PH_SUFFIX }, };