Hello World

LineByLine

2025/03/02
loading

一款有逐行翻译功能的电子书阅读器软件。Electron + React

环境搭建

1
2
3
4
5
6
7
8
9
# 如果没有安装 Homebrew,先安装它
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 使用 Homebrew 安装 Node.js
brew install node

# 验证安装
node --version
npm --version
1
2
3
4
5
6
7
8
9
10
11
# 创建项目文件夹
mkdir ebook-reader
cd 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 src
touch main.js
touch index.html
touch webpack.config.js
touch .babelrc

核心文件说明

  • index.html:应用程序入口页面,定义了一个div容器id="root",React组件会挂载在这里。script src="./dist/bundle.js" 说明 React 代码会被 Webpack 或其他打包工具编译为 bundle.js,然后在这里加载。
  • index.js:React应用程序入口文件。负责初始化 React 应用,并把 App.js 组件渲染到 index.htmlroot 容器里。通过 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': '欢迎',
// 可以根据需要添加更多
};

// 支持 JSON 请求体
app.use(express.json());

// 模拟翻译 API 端点
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}[已翻译]`;
}

// 模拟 API 响应格式
const response = [{
translations: [{
text: translatedText,
to: "zh"
}]
}];

// 模拟随机延迟(100-500ms),更真实地模拟网络请求
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;
}

// 如果没有,调用翻译 API
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)\\.', // Mr., Mrs., Ms.
'D(?:r)\\.', // Dr.
'P(?:rof)\\.', // Prof.
'R(?:ev)\\.', // Rev.
'H(?:on)\\.', // Hon.
'J(?:r)\\.', // Jr.
'S(?:r|t)\\.', // Sr., St.
'U\\.S\\.' // 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' // 添加 'i' 标志,大小写不敏感
);

// 按标点符号拆分
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
// 读取 pdf 文件
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 = [];

// 使用 Promise.all 和 map 来并行处理所有章节
const chapterPromises = epub.flow.map(chapter => {
return new Promise((resolveChapter, rejectChapter) => {
epub.getChapter(chapter.id, (error, text) => {
if (error) {
rejectChapter(error);
} else {
// 将 HTML 转换为纯文本
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();
});
}

电子书阅读器

先做阅读器,然后再加上句子拆分和翻译功能。

CATALOG
  1. 1. 环境搭建
    1. 1.1. 创建项目结构
    2. 1.2. 核心文件说明
  2. 2. 设置配置文件
  3. 3. 添加基础代码
  4. 4. 运行项目
  5. 5. 添加数据库
  6. 6. 加入部分翻译功能
    1. 6.1. 模拟翻译接口
    2. 6.2. 翻译文本
  7. 7. 读取pdf文件
  8. 8. 读取epub文件
  • 电子书阅读器