news 2026/10/23 6:27:55

让你的EPUB电子书自带查拼音功能

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
让你的EPUB电子书自带查拼音功能

EPUB电子书中遇到不认识的字如果直接在当前界面查拼音,那会十分方便,如下动图演示的这样:

实现这个效果需要你的电子书阅读器能够响应selectionchange事件。据我测试,Calibre阅读器是对浏览器功能兼容最佳的EPUB阅读器,可惜只支持PC(Windows、MacOS、Linux)。下面所有操作只保证在Calibre阅读器中能够实现预期效果。

一、拼音数据的准备

要查拼音,肯定先要准备拼音数据。拼音数据可以有很多途径获得,我的方法是这样的:

1、安装pypinyin库

打开一个目录,创建python虚拟环境.venv(如果不怕污染全局环境也可以不创建),然后:

pip install pypinyin

2、改造拼音文件

安装完成后,到

创建虚拟环境的目录\.venv\Lib\site-packages\pypinyin

下找到两个文件:pinyin_dict.json和phrases_dict.json,前者是单字拼音库,多音字保留多个拼音,后者是词语拼音库。pinyin_dict.json的键是字符的十进制Unicode编码,不直观,可以用下面的函数转换成汉字键:

def convert_codepoint_json_to_hanzi_json(json_file): with open(json_file, 'r', encoding='utf-8') as file: raw_data = json.load(file) new_dict = {} for code_str, pinyin in raw_data.items(): # key字符串转十进制整数码点 code_point = int(code_str) hanzi_char = chr(code_point) new_dict[hanzi_char] = pinyin with open(json_file, 'w', encoding='utf-8') as file: json.dump(new_dict, file, ensure_ascii=False, indent=2)

为简化起见,我们可以将其中的多音字只保留第一个常用读音。用支持正则表达式的文本编辑器(建议emeditor,正则表达式引擎比较强)打开转换后的文件,查找:

,[^"]+?(")

替换为“\1”(或者$1之类,看你的编辑器支持的反向引用格式),另存为新文件(原文件保留,还需要用),这样就删除了多音字。当然,也可以考虑保留多音字,用户只选择一个字时,标注出所有拼音,用户选择了多个字且不是固定词语时,单字上标注多个拼音不好看,这个时候对多音字就只标注排第一的读音,以下按这个思路实现。对于词语拼音文件,只有包含了多音字的词语需要保留。再次打开原文件,查找:

\s*".+?"[^,"]+?",

替换为空,这样就删除了单音字行。然后查找:

:.+?\n

替换为“,”,再把开头和结尾的大括号改成中括号,这样就成了一个多音字数组,另存为新文件,用下面的函数过滤掉词语拼音文件中不含多音字的项目:

def filter_phrases(): # ========== 配置路径 ========== pinyin_path = r'保存多音字数组的文件\pinyin_dict.json' phrases_path = r'词语拼音文件\phrases_dict.json' output_phrases_path = r'过滤掉不含多音字后的词语拼音文件\phrases_filtered.json' # ============================== # 1. 加载pinyin_dict.json,它的值是字数组 ["〇", "㐤", …] with open(pinyin_path, "r", encoding="utf-8") as f: single_char_list = json.load(f) # 转为集合,快速查找 single_char_set = set(single_char_list) print(f"单字库一共 {len(single_char_set)} 个汉字") # 2. 加载词组字典 with open(phrases_path, "r", encoding="utf-8") as f: phrases_data = json.load(f) print(f"原始词组数量: {len(phrases_data)}") # 3. 过滤:保留【词组中至少包含一个字存在于single_char_set】的条目 filtered_phrases = {} for phrase, pinyin_arr in phrases_data.items(): # 遍历词组里每个字符,只要任意一个字符在单字集合内,则保留该词组 has_overlap = any(ch in single_char_set for ch in phrase) if has_overlap: filtered_phrases[phrase] = pinyin_arr # 4. 保存过滤后的词组文件 with open(output_phrases_path, "w", encoding="utf-8") as f: json.dump(filtered_phrases, f, ensure_ascii=False, indent=2) print(f"过滤后词组数量: {len(filtered_phrases)}") print(f"已保存至: {output_phrases_path}")

然后还可以将转换后的文件中多余的空白和换行替换掉以减小文件的磁盘/内存空间占用。将准备好的拼音数据文件放到EPUB根目录下的scripts文件夹中。

二、准备HTML文件

1、提供显示拼音的按钮

很简单,在HTML文件的</body>标签前面添加

<button id="pinyin-float-btn">拼音</button>

可以用EmEditor的“搜索→在文件中替换”功能,搜索“</body>”替换为“\t<button id="pinyin-float-btn">\n</body>”,指定搜索目录和文件扩展名,从而将整个目录下的所有HTML文件全部添加上这个按钮。

2、给拼音按钮和拼音元素添加样式

在样式文件中添加:

#pinyin-float-btn {

/* 以下四行将拼音按钮固定在视口右侧纵向居中,其他配置请自行取舍*/
position: fixed;
top: 50%;
transform: translatey(-50%);
right: 12px;
z-index: 2147483647;
padding: 4px 8px;
background: #f00;
color: #fff;
border: none;
border-radius: 4px;
font-size: 14px;
pointer-events: auto;
user-select: none;
-webkit-user-select: none;
display: none;
}
ruby {
ruby-position: over; /* 注音显示在文字上部,相互居中对齐 */
}
rt {
font-size: 0.8em;
line-height: 1.2; /* 与上一行一起决定拼音显示大小及拼音与文字之间的纵向距离 */
color: red;
}

三、编写拼音显示JS

/* 防止EPUB阅读器发生变量重定义错误,全部代码在闭包中运行,不同页面中的变量都是局部变量,互不干扰*/ (function(){ /******************************查选择文本的拼音******************/ let cachedSelectedText = ''; // 已选择的文本 let cachedSeclectedRect = null; // 标记:是否是用户主动选择文本 let userSelectActive = false; let dictLoading = false; // 加载拼音数据的全局锁 let floatBtn; /** 响应selectionchange事件,控制拼音按钮显隐,并缓存选择的文本 * Calibre阅读器发生选择文本变化时,会弹出自身控件,这时网页元素不能再响应 * 事件,所以只能将选择文本缓存起来,先点击一次使阅读器自身控件消失,再点击 * 拼音按钮显示拼音 */ function handleSelection() { //if (!floatBtn) return; const sel = window.getSelection(); const currentText = sel.toString(); // 判断:当前存在非空选区 → 用户选中文字,更新缓存 if (currentText.trim() !== '') { cachedSelectedText = currentText; userSelectActive = true; // 获取选区包围盒 if (sel.rangeCount > 0) { const range = sel.getRangeAt(0); const rect = range.getBoundingClientRect(); cachedSeclectedRect = rect; // 先让按钮显示,才能拿到自身宽高 floatBtn.style.display = 'block'; positionEle(floatBtn) ; } } else { // currentText为空:区分两种场景 // 如果userSelectActive=true:说明是Calibre原生控件点击导致清空选区,不覆盖cachedSelectedText! // 只重置标记,保留缓存的文字 if (userSelectActive) { // 不执行 cachedSelectedText = '';保留上次选中内容 userSelectActive = false; } else { // 本来就没有选区,清空缓存 cachedSelectedText = ''; cachedSeclectedRect = null; } } } /** 将HTML部件放在选区下方,下方无法放置则放到上方 */ function positionEle(ele) { const eleH = ele.offsetHeight; const eleW = ele.offsetWidth; const gap = 6; // 和选区之间的间距 let top, left; // 优先放选区下方 if (cachedSeclectedRect.bottom + gap + eleH <= window.innerHeight) { top = cachedSeclectedRect.bottom + gap; } else { // 下方放不下,放到选区上方 top = cachedSeclectedRect.top - gap - eleH; // 防止贴到上边视口外面 if (top < 4) top = 4; } // 水平对齐选区左边缘,不越出视口右侧 left = cachedSeclectedRect.left; if (left + eleW > window.innerWidth) { left = window.innerWidth - eleW - 4; } // 防止贴到左边视口外面 if (left < 4) left = 4; ele.style.top = top + 'px'; ele.style.left = left + 'px'; } /**************创建拼音数据**************/ // 加载单个json文件,blob方案,避免发生键值文本不规范引起的查找失败 async function loadJsonFile(jsonPath) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', jsonPath, true); xhr.responseType = 'blob'; xhr.onload = async () => { if (xhr.status >= 200 && xhr.status < 300) { const blob = xhr.response; const text = await blob.text(); try { const rawObj = JSON.parse(text); resolve(rawObj); } catch (err) { reject(err); } } else { reject(new Error('Load failed, status:' + xhr.status)); } }; xhr.onerror = () => reject(new Error('Network error')); xhr.send(null); }); } // 构造拼音数据字典 async function buildPinyinCodeMap() { // 已挂载拼音数据,直接返回 if (window.PINYIN_CODE_MAP && window.PHRASES_CODE_MAP) return true; if (dictLoading) return false; // 正在加载中,直接返回,防止并发请求 dictLoading = true; try { // 相对路径,xhtml在OEBPS/Text,json放在OEBPS/scripts,按需修改路径 const rawSingle = await loadJsonFile('../scripts/pinyin_dict.json'); const rawPhrase = await loadJsonFile('../scripts/phrases_dict.json'); // ========== 单字字典 PINYIN_CODE_MAP:key=字符码点16进制 ========== const PINYIN_CODE_MAP = {}; for (const [hanzi, pinyinStr] of Object.entries(rawSingle)) { const ch = hanzi.normalize('NFC'); const cpHex = ch.codePointAt(0).toString(16); PINYIN_CODE_MAP[cpHex] = pinyinStr; } // ========== 词语字典 PHRASES_CODE_MAP:key=词语原文(NFC归一),value拼音数组 ========== const PHRASES_CODE_MAP = {}; for (const [phrase, pinyinArr] of Object.entries(rawPhrase)) { const phraseNorm = phrase.normalize('NFC'); PHRASES_CODE_MAP[phraseNorm] = pinyinArr; } // 挂载全局,供查询逻辑使用 window.PINYIN_CODE_MAP = PINYIN_CODE_MAP; window.PHRASES_CODE_MAP = PHRASES_CODE_MAP; //console.log(`Dict loaded: single=${Object.keys(PINYIN_CODE_MAP).length}, phrase=${Object.keys(PHRASES_CODE_MAP).length}`); return true; } catch (e) { console.error('Build pinyin map error:', e); return false; } finally { dictLoading = false; // 无论拼音数据加载成功失败,释放锁 } } /** * @param {string} selectedText 用户选中的文本 * @returns {string} 拼装好的带ruby的HTML */ function renderPinyinHtml(selectedText) { const txt = selectedText.normalize('NFC'); const phraseMap = window.PHRASES_CODE_MAP; const charMap = window.PINYIN_CODE_MAP; let htmlOut = ''; // 1. 选择文本长度>1,优先查词语字典,查不到逐字处理 if (txt.length > 1) { if (phraseMap[txt]) { // 1.1词语匹配成功:不存在多音字 const pyList = phraseMap[txt]; // 遍历:汉字、拼音一一配对 for(let i=0; i<txt.length; i++){ const ch = txt[i]; const py = pyList[i]; htmlOut += `<ruby>${ch}<rt>${py}\u00A0</rt></ruby>\u00A0`; } return htmlOut; } // 1.2. 词语没找到,逐字处理,为了美观多音字只取排第一的拼音 for (const ch of txt) { const cp = ch.codePointAt(0); // 过滤零宽字符 if ([0x200B, 0x200C, 0x200D].includes(cp)) continue; const cpHex = cp.toString(16); const pyStr = charMap[cpHex]; if (pyStr) { const py = pyStr.split(',')[0]; htmlOut += `<ruby>${ch}<rt>${py}\u00A0</rt></ruby>\u00A0`; } else { htmlOut += ch; } } return htmlOut; } // 2. 选择文本只有单个字:多音字标注出所有拼音 const cp = txt.codePointAt(0); const cpHex = cp.toString(16); const pyStr = charMap[cpHex].replace(',', ' / '); if (pyStr) { htmlOut += `<ruby>${txt}<rt>${pyStr}</rt></ruby>`; } else { htmlOut += txt; } return htmlOut; } function prepareStyle() { if (!document.querySelector('#phonetic-style')) { const style = document.createElement('style'); style.id = 'phonetic-style'; style.textContent = ` #note‑overlay { position:fixed; inset:0; background:rgba(0,0,0,0.55); z-index:2147483647; align-items:center; justify-content:center; padding:16px; box-sizing:border-box; display:none; } #note‑popup‑box { background:#fff; color:#000; max-width:85vw; max-height:80vh; width:min(32em, 85vw); overflow:auto; border-radius:8px; padding:14px; box-sizing:border-box; box-shadow:0 4px 14px rgba(0,0,0,0.3); } #note‑popup‑box button { float:right; border:none; background:#f00; color:#fff; border-radius:4px; padding:3px 8px; cursor:pointer; margin-bottom:8px; } #pinyin-float-btn { user-select: none; -webkit-user-select: none; display: none; position:fixed; padding:4px 8px; background:#f00; color:#fff; border:none; border-radius:4px; font-size:14px; pointer-events:auto; display:none; z-index:2147483647; } ruby { ruby-position: over; } rt { font-size: 0.8em; line-height: 1.5; color: red; }`; document.head.appendChild(style); } } function init() { prepareStyle(); const noteOverlay = document.createElement('div'); noteOverlay.id = 'note‑overlay'; // 创建弹窗盒子 const notePopup = document.createElement('div'); notePopup.id = 'note‑popup‑box'; // 关闭按钮 const closeBtn = document.createElement('button'); closeBtn.textContent = '\u00D7'; const noteContentWrap = document.createElement('div'); noteContentWrap.className = 'note‑content'; notePopup.appendChild(closeBtn); notePopup.appendChild(noteContentWrap); noteOverlay.appendChild(notePopup); document.body.appendChild(noteOverlay); // 关闭弹窗函数 function closeNotePopup() { noteOverlay.style.display = 'none'; noteContentWrap.innerHTML = ''; } closeBtn.addEventListener('click', closeNotePopup); // 点击遮罩背景关闭,点击弹窗内部不关闭 noteOverlay.addEventListener('click', function(e) { if (e.target === noteOverlay) { closeNotePopup(); } }); // HTML页面中已经有拼音按钮则取消下面一行的注释 //floatBtn = document.getElementById('pinyin-float-btn'); // 不存在则创建按钮 if (!floatBtn) { floatBtn = document.createElement('button'); floatBtn.id = 'pinyin-float-btn'; floatBtn.textContent = '\u62fc\u97f3'; // 拼音 document.body.appendChild(floatBtn); } // =========事件委托:监听整个document,适配Calibre翻页重建DOM,不需要重复绑定========= document.addEventListener('click', function(e) { // 优先级1:判断是否点击拼音按钮 const targetBtn = e.target.closest('button#pinyin-float-btn'); if (targetBtn) { e.preventDefault(); e.stopPropagation(); if (!cachedSelectedText.trim()) return; noteContentWrap.innerHTML = renderPinyinHtml(cachedSelectedText); noteOverlay.style.display = 'flex'; positionEle(noteOverlay); targetBtn.style.display = 'none'; return; } }); // 监听选择变化,显示拼音按钮 document.addEventListener('selectionchange', handleSelection); } function whenDOMReady(callback) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', callback, { once: true }); } else { callback(); } } whenDOMReady(async function() { const ok = await buildPinyinCodeMap(); // 确保拼音数据库创建成功 if (ok) { init(); } }); })();

代码中的注释比较详尽。需要注意的是,在Calibre阅读器中选择文本后,不但激发selectchange事件(此时window.getSelection().toString()为选择的文本),还会弹出自己的控件(参见开篇的动图),在自己的控件隐藏之前,书籍页面的任何HTML元素都不响应事件,而该控件的隐藏会导致选区消失,因而再次触发selectchange事件(此时window.getSelection().toString()仍然为选择的文本,但选择的文本已经是空字符串),所以要在标记是用户选择文本引发的selectchange事件还是Calibre控件消失引发的selectchange事件,并缓存用户选择的文本,而查拼音时第一次点击拼音按钮(或Calibre控件以外的其它位置)只能起到让Calibre控件隐藏的作用,再次点击拼音按钮才会显示拼音(开篇的动图也演示了这一点)。文件保存为phonetic.js。

四、在浏览器中测试

将样式文件和JS脚本导入HTML中,可以先在浏览器中测试效果。由于JS中使用了 XHR 读取拼音数据,本地浏览器直接用file://打开时浏览器安全策略会拦截 XHR 读本地文件,导致 CORS 报错。如果安装了python——当然,没安装的话前面的语音文件准备也没法利用我提供的脚本完成——可以在根目录执行:

python -m http.server

然后访问:

http://localhost:8000/Text/test.htm

本地目录结构如下图:

test.htm的示例代码:

<?xml version='1.0' encoding='utf-8'?> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="zh-CN"> <head> <link href="../Styles/styles.css" rel="stylesheet" type="text/css"/> <script src="../scripts/phonetic.js"></script> <title>test</title> </head> <body> <p>随便写些测试内容</p> <button id="pinyin-float-btn">拼音</button> </body> </html>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 6:26:39

YimMenu 完整指南:GTA5 防崩溃菜单的功能、编译与 Lua 扩展教程

YimMenu 完整指南&#xff1a;GTA5 防崩溃菜单的功能、编译与 Lua 扩展教程 【免费下载链接】YimMenu YimMenu, a GTA V menu protecting against a wide ranges of the public crashes and improving the overall experience. 项目地址: https://gitcode.com/GitHub_Trendin…

作者头像 李华
网站建设 2026/10/23 6:25:45

TL431在反激电路中的原理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 6:25:35

游戏API网关安全设计(进阶篇)最佳实践与踩坑记录

本文深入探讨游戏API网关安全设计&#xff08;进阶篇&#xff09;&#xff0c;涵盖背景分析、原理剖析、实战步骤、配置示例、优化建议和避坑指南。最近在处理游戏与SDK防护相关项目时&#xff0c;游戏API网关安全设计&#xff08;进阶篇&#xff09;成为团队讨论最多的话题。经…

作者头像 李华
网站建设 2026/10/23 6:23:04

SSH.NET 安装配置与排错指南:C# 远程执行与 SFTP 传输

简介&#xff1a;面向 C#/.NET 开发者及需要维护远程连接程序的运维人员&#xff0c;提供 SSH.NET 库 Renci.SshNet.dll 的更新版本&#xff0c;重点解决旧版库在连接加密级别较高的 Linux 系统&#xff08;如 openEuler 22.0.3&#xff09;时出现的握手失败、算法不兼容等问题…

作者头像 李华
网站建设 2026/10/23 6:22:35

数字传输核心技术与实战复习:编码、PCM、复用及介质选择

1. 数字传输到底在解决什么问题很多人复习计算机网络的时候&#xff0c;翻到第四章“数字传输”就开始头疼。前面第三章讲数据链路层还勉强能理解&#xff0c;到了第四章&#xff0c;什么曼彻斯特编码、差分曼彻斯特编码、NRZ、AMI、PCM、TDM、FDM……一堆缩写砸过来&#xff0…

作者头像 李华
网站建设 2026/10/23 6:19:42

PCL2:多版本 Minecraft 一键启动管理器

PCL2&#xff1a;多版本 Minecraft 一键启动管理器 PCL2&#xff08;Plain Craft Launcher 2&#xff09;是一个免费开源的 Minecraft 启动器&#xff0c;主要解决一个问题&#xff1a;你同时玩着 1.16.5 的整合包、1.20.1 的纯净版和一台服务器客户端时&#xff0c;不用在每个…

作者头像 李华