Files
2025-12-21 22:52:38 +08:00

158 lines
4.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Markdown Note 翻译辅助工具(零依赖)
* - HTML -> 占位 -> 翻译回填 -> HTML
* - 保护代码块/内联代码与链接/图片的 URL 属性,只翻译可见文本与可选的 img.alt
*/
// ---配置项---
const DEFAULT_OPTIONS = {
translateImageAlt: true, // 是否翻译 <img alt>
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') {
// 文本节点一般不出现在 <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}`;
}
// 可选:处理 <img alt>
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 },
};