diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md
index a65ce04..5231726 100644
--- a/tools/epub_review_editor/MAINTENANCE.md
+++ b/tools/epub_review_editor/MAINTENANCE.md
@@ -32,6 +32,7 @@ epub_review_sessions/
- `rows.json` 是初始解析结果,不应因用户编辑而重写。
- `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。
+- `state.json.reading_position` 保存当前阅读位置,字段包括 `mode`、`scope`、`chapter_id`、`part_id`、`image_item_id`、`row_id`、`scroll_top`、`offset`、`updated_at`;缺省时必须回退到首页/首章。
- `session.json` 与 `app_state.json` 只保存会话定位信息。
- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。
- `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。
@@ -67,6 +68,7 @@ epub_review_sessions/
- 阅读模式不显示每段编号、快速编辑、快速标记按钮。
- 点击正文段落打开右侧精修与重翻面板。
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
+- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。
- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。
## 编辑、反馈与导出
@@ -156,3 +158,4 @@ Prompt 硬规则:
- 配置保存后 `/api/gpt/config` 不包含密钥字段
- `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致
- 重翻候选事件包含 `glossary_path` 与 `glossary_matches`
+- `/api/reading-position` 能保存/读取位置;刷新或重启后回到上次阅读处
diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md
index afebe20..0a1ea68 100644
--- a/tools/epub_review_editor/README.md
+++ b/tools/epub_review_editor/README.md
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
-当前版本:`0.7.0`
+当前版本:`0.8.0`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -30,6 +30,8 @@
`0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。
+`0.8.0` 新增阅读位置恢复:阅读器会自动记录当前章节/part/插图、当前可见段落和滚动位置;重新打开服务、刷新页面或切回已有会话时,会回到上次阅读的位置。
+
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -86,6 +88,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
## 能做什么
- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。
+- 每个审校会话会自动保存上次阅读位置,下次打开会回到上次所在章节、part、插图或段落附近。
- 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。
- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图。
- 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。
diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py
index 7bec4df..aa2e4e6 100644
--- a/tools/epub_review_editor/server.py
+++ b/tools/epub_review_editor/server.py
@@ -36,7 +36,7 @@ except ImportError:
"MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更",
}
- version = "0.7.0"
+ version = "0.8.0"
P_RE = re.compile(r"(
]*>)(.*?)(
)", re.S | re.I)
@@ -760,6 +760,45 @@ def session_epub_path(session_root: Path) -> Path:
return Path(source_epub or fallback)
+def normalize_reading_position(data: Any) -> dict[str, Any]:
+ if not isinstance(data, dict):
+ return {}
+
+ def field(name: str, limit: int = 160) -> str:
+ return str(data.get(name) or "").strip()[:limit]
+
+ def nonnegative_int(name: str, limit: int = 10_000_000) -> int:
+ try:
+ value = int(float(data.get(name) or 0))
+ except (TypeError, ValueError):
+ value = 0
+ return max(0, min(value, limit))
+
+ mode = field("mode", 20)
+ if mode not in {"reading", "polish"}:
+ mode = "reading"
+ scope = field("scope", 20)
+ if scope not in {"chapter", "part", "image", "none"}:
+ scope = "none"
+ position = {
+ "mode": mode,
+ "scope": scope,
+ "chapter_id": field("chapter_id"),
+ "part_id": field("part_id"),
+ "image_item_id": field("image_item_id"),
+ "row_id": field("row_id"),
+ "scroll_top": nonnegative_int("scroll_top"),
+ "offset": nonnegative_int("offset", 5000),
+ "updated_at": field("updated_at", 80) or now_iso(),
+ }
+ return position
+
+
+def read_reading_position(session_root: Path) -> dict[str, Any]:
+ state = read_json(session_root / "review_state" / "state.json", {})
+ return normalize_reading_position(state.get("reading_position", {}))
+
+
def summarize_session(session_root: Path) -> dict[str, Any]:
manifest = read_json(session_manifest_path(session_root), {})
state = read_json(session_root / "review_state" / "state.json", {})
@@ -787,6 +826,7 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
"latest_export": latest_export,
+ "reading_position": normalize_reading_position(state.get("reading_position", {})),
}
@@ -1902,6 +1942,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
"marked_count": sum(1 for row in rows if row.get("marked")),
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
+ "reading_position": read_reading_position(session_root),
}
)
@@ -1979,6 +2020,28 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
return no_active_response()
return jsonify(build_structure(session_root))
+ @app.route("/api/reading-position", methods=["GET", "POST"])
+ def api_reading_position():
+ session_root, _epub_path = active_session()
+ if session_root is None:
+ return no_active_response()
+ if request.method == "GET":
+ return jsonify({"reading_position": read_reading_position(session_root)})
+ data = request.get_json(silent=True) or {}
+ if not data and request.data:
+ try:
+ data = json.loads(request.data.decode("utf-8", errors="replace"))
+ except json.JSONDecodeError:
+ data = {}
+ position = normalize_reading_position(data)
+ with app.config["LOCK"]:
+ state_path = session_root / "review_state" / "state.json"
+ state = read_json(state_path, {"edits": {}})
+ state["reading_position"] = position
+ state["updated_at"] = now_iso()
+ write_json(state_path, state)
+ return jsonify({"status": "ok", "reading_position": position})
+
@app.route("/api/asset/")
def api_asset(entry_name: str):
session_root, _epub_path = active_session()
diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js
index a53754b..698090d 100644
--- a/tools/epub_review_editor/static/app.js
+++ b/tools/epub_review_editor/static/app.js
@@ -19,6 +19,13 @@ let gptCandidateHtml = "";
let glossaryLoaded = false;
let glossaryEntries = [];
let glossaryDirty = false;
+let savedReadingPosition = null;
+let restoringReadingPosition = false;
+let readingPositionSaveTimer = null;
+let readingPositionInFlight = false;
+let readingPositionSavePromise = null;
+let pendingReadingPosition = null;
+let readingPositionReady = false;
const $ = (id) => document.getElementById(id);
@@ -223,6 +230,81 @@ function withReaderAnchor(action) {
return result;
}
+function imageItemById(chapter, imageItemId) {
+ if (!chapter || !imageItemId) return null;
+ return chapterItems(chapter).find((item) => isImageItem(item) && item.id === imageItemId) || null;
+}
+
+function currentReadingPosition() {
+ const anchor = readerAnchor();
+ const chapter = currentChapter || chapterForPart(currentPart);
+ const scope = currentImageItem ? "image" : currentPart ? "part" : chapter ? "chapter" : "none";
+ return {
+ mode: currentMode,
+ scope,
+ chapter_id: chapter ? chapter.id : "",
+ part_id: currentPart ? currentPart.id : "",
+ image_item_id: currentImageItem ? currentImageItem.id : "",
+ row_id: (anchor && anchor.rowId) || (currentRow && currentRow.id) || "",
+ scroll_top: dom.readerPane ? Math.max(0, Math.round(dom.readerPane.scrollTop || 0)) : 0,
+ offset: anchor ? Math.max(0, Math.round(anchor.offset || 0)) : 0,
+ updated_at: new Date().toISOString(),
+ };
+}
+
+async function postReadingPosition(position) {
+ await api("/api/reading-position", {
+ method: "POST",
+ body: JSON.stringify(position),
+ });
+}
+
+async function saveReadingPositionNow(position) {
+ readingPositionInFlight = true;
+ const promise = postReadingPosition(position);
+ readingPositionSavePromise = promise;
+ try {
+ await promise;
+ savedReadingPosition = position;
+ } catch (_error) {
+ pendingReadingPosition = position;
+ } finally {
+ if (readingPositionSavePromise === promise) readingPositionSavePromise = null;
+ readingPositionInFlight = false;
+ if (pendingReadingPosition) saveReadingPositionSoon();
+ }
+}
+
+function saveReadingPositionSoon() {
+ if (!hasSession || !readingPositionReady || restoringReadingPosition) return;
+ const position = currentReadingPosition();
+ pendingReadingPosition = position;
+ clearTimeout(readingPositionSaveTimer);
+ readingPositionSaveTimer = setTimeout(async () => {
+ if (readingPositionInFlight || !pendingReadingPosition) return;
+ const next = pendingReadingPosition;
+ pendingReadingPosition = null;
+ await saveReadingPositionNow(next);
+ }, 650);
+}
+
+async function flushReadingPosition() {
+ if (!hasSession || !readingPositionReady || restoringReadingPosition) return;
+ clearTimeout(readingPositionSaveTimer);
+ if (readingPositionSavePromise) {
+ await readingPositionSavePromise.catch(() => {});
+ }
+ const position = pendingReadingPosition || currentReadingPosition();
+ pendingReadingPosition = null;
+ await saveReadingPositionNow(position);
+}
+
+function sendReadingPositionBeacon() {
+ if (!hasSession || !readingPositionReady || restoringReadingPosition || !navigator.sendBeacon) return;
+ const blob = new Blob([JSON.stringify(currentReadingPosition())], { type: "application/json" });
+ navigator.sendBeacon("/api/reading-position", blob);
+}
+
function openSidePanel(mode) {
if (!hasSession) return;
sidePanelMode = mode;
@@ -268,6 +350,14 @@ function resetReviewState() {
glossaryLoaded = false;
glossaryEntries = [];
glossaryDirty = false;
+ savedReadingPosition = null;
+ restoringReadingPosition = false;
+ pendingReadingPosition = null;
+ readingPositionInFlight = false;
+ readingPositionSavePromise = null;
+ readingPositionReady = false;
+ clearTimeout(readingPositionSaveTimer);
+ readingPositionSaveTimer = null;
dom.showTouchedBtn.classList.remove("primary");
dom.searchScopeCurrentBtn.classList.add("active");
dom.searchScopeBookBtn.classList.remove("active");
@@ -596,6 +686,7 @@ async function loadSession() {
await showStartScreen(session);
return session;
}
+ savedReadingPosition = session.reading_position || null;
setShellMode(true);
const chapterCount = structure.chapter_count || (structure.chapters || []).length;
const partCount = structure.part_count || allParts().length;
@@ -645,13 +736,80 @@ async function loadRows(keepSelection = false) {
renderAll();
}
+function applySavedReadingPosition(position) {
+ if (!position || !(structure.chapters || []).length) return false;
+ restoringReadingPosition = true;
+ try {
+ const row = position.row_id ? rowById(position.row_id) : null;
+ const chapter = chapterById(position.chapter_id);
+ const part = partById(position.part_id);
+ const imageItem = imageItemById(chapter, position.image_item_id);
+ if (position.scope === "image" && chapter && imageItem) {
+ currentChapter = chapter;
+ currentPart = null;
+ currentImageItem = imageItem;
+ currentRow = null;
+ currentIndex = -1;
+ expandedChapters.add(chapter.id);
+ } else if (part) {
+ currentPart = part;
+ currentChapter = chapterForPart(part) || chapter;
+ currentImageItem = null;
+ if (currentChapter) expandedChapters.add(currentChapter.id);
+ currentRow = row && (part.row_ids || []).includes(row.id) ? row : partRows(part)[0] || null;
+ currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
+ } else if (chapter) {
+ currentChapter = chapter;
+ currentPart = null;
+ currentImageItem = null;
+ expandedChapters.add(chapter.id);
+ const scopedRows = chapterRows(chapter);
+ currentRow = row && scopedRows.some((item) => item.id === row.id) ? row : scopedRows[0] || null;
+ currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
+ } else if (row) {
+ currentRow = row;
+ syncScopeToRow(row);
+ currentIndex = filteredRows.findIndex((item) => item.id === row.id);
+ } else {
+ return false;
+ }
+ currentMode = position.mode === "polish" && currentRow ? "polish" : "reading";
+ applyFilter(false);
+ currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1;
+ renderAll();
+ return true;
+ } finally {
+ restoringReadingPosition = false;
+ }
+}
+
+function restoreReadingPositionScroll(position) {
+ if (!position || currentMode !== "reading") return;
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ const rowId = position.row_id || "";
+ const target = rowId ? dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`) : null;
+ if (target) {
+ const paneRect = dom.readerPane.getBoundingClientRect();
+ const nextOffset = target.getBoundingClientRect().top - paneRect.top;
+ dom.readerPane.scrollTop += nextOffset - (position.offset || 0);
+ } else {
+ dom.readerPane.scrollTop = Math.max(0, position.scroll_top || 0);
+ }
+ });
+ });
+}
+
async function loadActiveSession(options = {}) {
resetReviewState();
currentMode = "reading";
setShellMode(true);
await loadStructure();
await loadRows(Boolean(options.keepSelection));
- await loadSession();
+ const session = await loadSession();
+ const restored = applySavedReadingPosition(session.reading_position);
+ if (restored) restoreReadingPositionScroll(session.reading_position);
+ readingPositionReady = true;
await refreshSessions().catch(() => {});
if (options.message) toast(options.message, 4200);
}
@@ -1079,6 +1237,7 @@ function renderReading() {
return;
}
dom.readingFlow.appendChild(frag);
+ if (!restoringReadingPosition) saveReadingPositionSoon();
}
function renderEditor() {
@@ -1210,6 +1369,7 @@ async function saveCurrent(silent = false) {
}
async function maybeSaveBeforeSwitch() {
+ await flushReadingPosition();
if (currentMode === "reading" && quickDirty && currentRow && !dom.quickEditor.hidden) {
return saveCurrent(true);
}
@@ -1244,6 +1404,7 @@ function selectRow(row, options = {}) {
}
if (options.scroll) scrollRowIntoView(row.id);
+ saveReadingPositionSoon();
}
async function selectChapter(chapter) {
@@ -1270,6 +1431,7 @@ async function selectChapter(chapter) {
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
+ saveReadingPositionSoon();
}
async function selectPart(part) {
@@ -1289,6 +1451,7 @@ async function selectPart(part) {
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
+ saveReadingPositionSoon();
}
async function selectImageItem(chapter, item) {
@@ -1306,6 +1469,7 @@ async function selectImageItem(chapter, item) {
currentMode = "reading";
renderAll();
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
+ saveReadingPositionSoon();
}
function scrollRowIntoView(rowId) {
@@ -1337,6 +1501,7 @@ async function setMode(mode, saveBefore = true) {
}
withReaderAnchor(() => renderAll());
if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id);
+ saveReadingPositionSoon();
}
function openQuickEditor(row) {
@@ -1353,6 +1518,7 @@ function openQuickEditor(row) {
renderAll();
});
loadGptConfig().catch(() => {});
+ saveReadingPositionSoon();
}
function closeQuickEditor(save = false) {
@@ -1366,6 +1532,7 @@ function closeQuickEditor(save = false) {
quickDirty = false;
renderAll();
});
+ saveReadingPositionSoon();
}
function markDirty() {
@@ -1849,10 +2016,19 @@ function initEvents() {
});
window.addEventListener("beforeunload", (event) => {
+ sendReadingPositionBeacon();
if (!dirty && !quickDirty) return;
event.preventDefault();
event.returnValue = "";
});
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "hidden") {
+ sendReadingPositionBeacon();
+ } else {
+ saveReadingPositionSoon();
+ }
+ });
+ dom.readerPane.addEventListener("scroll", saveReadingPositionSoon, { passive: true });
}
async function main() {
diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html
index 5c39212..e25649b 100644
--- a/tools/epub_review_editor/static/index.html
+++ b/tools/epub_review_editor/static/index.html
@@ -9,7 +9,7 @@
-
双语 EPUB 审校编辑器 v0.7.0
+
双语 EPUB 审校编辑器 v0.8.0
正在载入……
diff --git a/tools/epub_review_editor/version.py b/tools/epub_review_editor/version.py
index 409c73c..c12ebcf 100644
--- a/tools/epub_review_editor/version.py
+++ b/tools/epub_review_editor/version.py
@@ -1,4 +1,4 @@
-version = "0.7.0"
+version = "0.8.0"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",