一款有逐行翻译功能的电子书阅读器软件。Electron + React
环境搭建 1 2 3 4 5 6 7 8 9 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh) " brew install node node --version npm --version
1 2 3 4 5 6 7 8 9 10 11 mkdir ebook-readercd ebook-reader npm init -y npm install electron@latest react@latest react-dom@latest npm install --save-dev @babel/core @babel/preset-react @babel/preset-env npm install --save-dev webpack webpack-cli babel-loader
注:npm install electron@latest时速度慢,额外添加electron的镜像,参考https://juejin.cn/post/6855526489904349198
创建项目结构 1 2 3 4 5 mkdir srctouch main.jstouch index.htmltouch webpack.config.jstouch .babelrc
核心文件说明
index.html:应用程序入口页面,定义了一个div容器id="root"
,React组件会挂载在这里。script src="./dist/bundle.js"
说明 React 代码会被 Webpack 或其他打包工具编译为 bundle.js
,然后在这里加载。
index.js:React应用程序入口文件。负责初始化 React 应用,并把 App.js
组件渲染到 index.html
的 root
容器里。通过 createRoot(container).render(<App />)
挂载 App
组件。
App.js:React前端界面,主React组件
main.js:Electron主进程
webpack.config.js:打包配置文件,用于将React代码编译成浏览器可运行的代码
database.js:数据库相关代码
用户选择文件 -> main.js 处理文件读取 文本分割后通过 IPC 通信传递给前端显示
设置配置文件 webpack.config.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 const path = require ('path' );module .exports = { mode : 'development' , target : 'electron-renderer' , entry : './src/index.js' , output : { path : path.resolve (__dirname, 'dist' ), filename : 'bundle.js' }, module : { rules : [ { test : /\.(js|jsx)$/ , exclude : /node_modules/ , use : { loader : 'babel-loader' } } ] }, resolve : { extensions : ['.js' , '.jsx' ] } };
.babelrc
1 2 3 { "presets" : [ "@babel/preset-react" , "@babel/preset-env" ] }
package.json,加入
1 2 3 4 5 6 7 { "scripts" : { "start" : "webpack && electron ." , "build" : "webpack" , "watch" : "webpack --watch" } }
.gitignore
1 2 3 node_modules/ dist/ .DS_ Store
添加基础代码 src/index.js
1 2 3 4 5 6 7 import React from 'react' ;import { createRoot } from 'react-dom/client' ;import App from './App' ;const container = document .getElementById ('root' );const root = createRoot (container); root.render (<App /> );
src/App.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import React , { useState } from 'react' ;const { ipcRenderer } = window .require ('electron' );function App ( ) { const [lines, setLines] = useState ([]); const handleFileSelect = async ( ) => { const content = await ipcRenderer.invoke ('select-file' ); setLines (content); }; return ( <div style ={{ padding: '20px ' }}> <button onClick ={handleFileSelect} > 选择文件 </button > <div style ={{ marginTop: '20px ' }}> {lines.map((line, index) => ( <div key ={index} style ={{ marginBottom: '10px ' }}> {line} </div > ))} </div > </div > ); }export default App ;
index.html
1 2 3 4 5 6 7 8 9 10 <!DOCTYPE html > <html > <head > <title > eBook Reader</title > </head > <body > <div id ="root" > </div > <script src ="./dist/bundle.js" > </script > </body > </html >
main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 const { app, BrowserWindow , ipcMain, dialog } = require ('electron' );const path = require ('path' );const fs = require ('fs' );function createWindow ( ) { const win = new BrowserWindow ({ width : 800 , height : 600 , webPreferences : { nodeIntegration : true , contextIsolation : false } }); win.loadFile ('index.html' ); } app.whenReady ().then (createWindow); ipcMain.handle ('select-file' , async () => { const result = await dialog.showOpenDialog ({ properties : ['openFile' ], filters : [ { name : 'Text Files' , extensions : ['txt' ] } ] }); if (!result.canceled ) { const filePath = result.filePaths [0 ]; const content = fs.readFileSync (filePath, 'utf8' ); return content.split ('\n' ); } return []; });
运行项目 1 2 3 4 5 npm install npm start
添加数据库 目前数据库中存储两个字段,原文和译文。翻译时先查找数据库中是否有原文,如果有直接获取,没有再调用翻译api。 使用better-sqlite3时出现了一些问题:
1 2 3 4 5 Error: The module '/LineByLine/node_modules/better-sqlite3/build/Release/better_sqlite3.node' was compiled against a different Node.js version using NODE_MODULE_VERSION 131. This version of Node.js requires NODE_MODULE_VERSION 132. Please try re-compiling or re-installing the module (for instance, using `npm rebuild` or `npm install`).
故暂时使用sqlite3:npm install sqlite3
。
database.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 const sqlite3 = require ('sqlite3' ).verbose ();const path = require ('path' ); const db = new sqlite3.Database (path.join (__dirname, 'data.db' ), (err ) => { if (err) { console .error ('Database connection error:' , err); } else { console .log ('Connected to database' ); } }); db.serialize (() => { db.run (` CREATE TABLE IF NOT EXISTS translations ( id INTEGER PRIMARY KEY AUTOINCREMENT, original TEXT UNIQUE, translated TEXT ) ` ); });function getTranslation (text ) { return new Promise ((resolve, reject ) => { db.get ('SELECT translated FROM translations WHERE original = ?' , [text], (err, row ) => { if (err) reject (err); resolve (row ? row.translated : null ); }); }); }function saveTranslation (original, translated ) { return new Promise ((resolve, reject ) => { const sql = ` INSERT INTO translations (original, translated) VALUES (?, ?) ON CONFLICT(original) DO UPDATE SET translated = excluded.translated ` ; db.run (sql, [original, translated], (err ) => { if (err) reject (err); resolve (); }); }); }module .exports = { db, getTranslation, saveTranslation };
main.js加入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const db = require ('./database' ); ipcMain.handle ('get-translation' , async (event, text) => { try { return await getTranslation (text); } catch (err) { console .error ('Translation query error:' , err); return null ; } }); ipcMain.handle ('save-translation' , async (event, original, translated) => { try { await saveTranslation (original, translated); } catch (err) { console .error ('Translation save error:' , err); } });
加入部分翻译功能 模拟翻译接口 部分翻译功能可以使用各大翻译软件的接口,为了便于测试,编写一个本地的接口模拟翻译接口npm install express
mockTranslateServer.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 const express = require ('express' );const app = express ();const port = 3000 ;const mockTranslations = { 'hello' : '你好' , 'world' : '世界' , 'book' : '书' , 'read' : '阅读' , 'welcome' : '欢迎' , }; app.use (express.json ()); app.post ('/translate' , (req, res ) => {const requestBody = req.body ;if (!Array .isArray (requestBody) || !requestBody[0 ]?.text ) {return res.status (400 ).json ({error : {code : 400 ,message : "Invalid request format" } }); }const textToTranslate = requestBody[0 ].text ;let translatedText;if (mockTranslations[textToTranslate.toLowerCase ()]) { translatedText = mockTranslations[textToTranslate.toLowerCase ()]; } else { translatedText = `${textToTranslate} [已翻译]` ; }const response = [{ translations : [{ text : translatedText, to : "zh" }] }];setTimeout (() => { res.json (response); }, Math .random () * 400 + 100 ); }); app.use ((err, req, res, next ) => { console .error (err.stack ); res.status (500 ).json ({ error : { code : 500 , message : "Internal server error" } }); }); app.listen (port, () => { console .log (`Mock translation server running at http://localhost:${port} ` ); });
node mockTranslateServer.js
,开启模拟接口
翻译文本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 const fetch = (...args ) => import ('node-fetch' ).then (({ default : fetch } ) => fetch (...args));const TRANSLATOR_API_KEY = '' const TRANSLATOR_URL = 'http://localhost:3000/translate' ; ipcMain.handle ('translate-text' , async (event, text) => { const existingTranslation = await getTranslation (text); if (existingTranslation) { return existingTranslation; } const response = await fetch (TRANSLATOR_URL , { method : 'POST' , headers : { 'Ocp-Apim-Subscription-Key' : TRANSLATOR_API_KEY , 'Content-Type' : 'application/json' , }, body : JSON .stringify ([{ text }]), }); const data = await response.json (); const translatedText = data[0 ]?.translations [0 ]?.text || '' ; await saveTranslation (text, translatedText); return translatedText; });
修改App.js,点击翻译按钮后显示译文
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const translateLine = async (line ) => { const translated = await ipcRenderer.invoke ('translate-text' , line); setTranslations ((prev ) => ({ ...prev, [line]: translated })); };return ( <div style ={{ padding: '20px ' }}> <button onClick ={handleFileSelect} > 选择文件</button > <div style ={{ marginTop: '20px ' }}> {lines.map((line, index) => ( <div key ={index} style ={{ marginBottom: '10px ' }}> <p > {line}</p > <button onClick ={() => translateLine(line)}>翻译</button > <p style ={{ color: 'gray ' }}> {translations[line]}</p > </div > ))} </div > </div > );
修改为按行显示,修改App.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 const TITLES = [ 'M(?:r|rs|s)\\.' , 'D(?:r)\\.' , 'P(?:rof)\\.' , 'R(?:ev)\\.' , 'H(?:on)\\.' , 'J(?:r)\\.' , 'S(?:r|t)\\.' , 'U\\.S\\.' ];const handleFileSelect = async ( ) => { try { const fileContent = await ipcRenderer.invoke ('select-file' ); if (!fileContent || fileContent.length === 0 ) { console .warn ("文件内容为空" ); return ; } const text = fileContent.join (' ' ).replace (/\n/g , ' ' ); const sentenceEndRegex = new RegExp ( `(?<!${TITLES.join('|' )} \\s?)` + `(?<=[。!?.!?])\\s+` , 'gi' ); const sentences = text .split (sentenceEndRegex) .map (s => s.trim ()) .filter (s => s.length > 0 ); setLines (sentences); } catch (error) { console .error ("读取文件失败:" , error); } };
读取pdf文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 else if (fileExt === '.pdf' ) { const pdfjsLib = await import ('pdfjs-dist/build/pdf.mjs' ); const pdf = await pdfjsLib.getDocument (filePath).promise ; const numPages = pdf.numPages ; const textContent = []; for (let pageNum = 1 ; pageNum <= numPages; pageNum++) { const page = await pdf.getPage (pageNum); const content = await page.getTextContent (); const pageText = content.items .map ((item ) => item.str ).join (' ' ); textContent.push (pageText); } content = textContent; }
读取epub文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 else if (fileExt === '.epub' ) { const EPub = require ('epub' ); return new Promise ((resolve, reject ) => { const epub = new EPub (filePath, '/images/' , '/chapters/' ); epub.on ('end' , async () => { try { const chapters = []; const chapterPromises = epub.flow .map (chapter => { return new Promise ((resolveChapter, rejectChapter ) => { epub.getChapter (chapter.id , (error, text ) => { if (error) { rejectChapter (error); } else { const textContent = text.replace (/<[^>]+>/g , '' ) .replace (/\n\s*\n/g , '\n' ) .trim (); resolveChapter (textContent); } }); }); }); const chapterContents = await Promise .all (chapterPromises); resolve (chapterContents); } catch (error) { reject (error); } }); epub.on ('error' , error => { reject (error); }); epub.parse (); }); }
电子书阅读器 先做阅读器,然后再加上句子拆分和翻译功能。