From 9b5c55688a33862322242bd88792bfb606a5d4b2 Mon Sep 17 00:00:00 2001 From: xin61 Date: Tue, 7 Jul 2026 15:48:45 +0800 Subject: [PATCH] feat(editor): refine toc hierarchy and search scope --- tools/epub_review_editor/MAINTENANCE.md | 12 +- tools/epub_review_editor/README.md | 9 +- tools/epub_review_editor/server.py | 165 +++++++-- tools/epub_review_editor/static/app.js | 394 ++++++++++++++++----- tools/epub_review_editor/static/index.html | 6 +- tools/epub_review_editor/static/style.css | 79 ++++- tools/epub_review_editor/version.py | 2 +- 7 files changed, 530 insertions(+), 137 deletions(-) diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md index eb83538..fbeaa67 100644 --- a/tools/epub_review_editor/MAINTENANCE.md +++ b/tools/epub_review_editor/MAINTENANCE.md @@ -40,8 +40,8 @@ epub_review_sessions/ 结构接口 `/api/structure` 应保持向后兼容: - text chapter: `kind: "text"`、`parts`、`row_count`、`touched_count`、`marked_count` -- image chapter: `kind: "image"`、`images`、`parts: []`、`row_count: 0` -- 统计字段同时保留总数和细分:`chapter_count`、`text_chapter_count`、`image_chapter_count`、`image_count`、`asset_count` +- image item: 优先作为 text chapter 的 `items` 子项返回,字段为 `kind: "image"`、`images`、`parts: []`、`row_count: 0`;没有所属文字章时才保留顶层 image chapter +- 统计字段同时保留总数和细分:`chapter_count`、`text_chapter_count`、`image_chapter_count`、`top_level_image_chapter_count`、`image_count`、`asset_count`。其中 `image_chapter_count` 表示目录里的图片条目数,不一定是顶层章节数 - part 标题默认使用 `part0000` 形式,真实标题可保留在 `source_title` - 插图通过 `/api/asset/` 读取,必须继续使用 `extracted_path()` 防止路径穿越;只把 raster 图片纳入 `/api/structure` 与 `/api/asset` 白名单,不能任意读取解包目录文件 @@ -49,16 +49,20 @@ epub_review_sessions/ 1. EPUB 内部目录页的中文条目 2. `nav.xhtml` / `toc.xhtml` -3. 正文标题 +3. 正文 `` 标题 4. 文件名 fallback +不要把正文首段或第一句对白作为章节标题 fallback。宁可显示 `part0000`,也不要让目录被正文内容污染。 + ## 阅读与侧栏交互 验收口径: - 初始进入审校时,左侧侧栏隐藏,正文占主要空间。 - 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。 -- 目录可展开/收起章节,插图章节可点击阅读,文字 part 可点击定位。 +- 目录可展开/收起章节,插图作为所属章节的次级目录条目可点击阅读,文字 part 可点击定位。 +- 点击目录章节、part 或插图条目后,侧栏不得自动收起;用户仍可点“隐藏”或按 `Escape` 关闭。 +- 检索必须保留“当前范围 / 全书全局”切换;当前范围随当前章节或 part 更新,全局检索可跨章节返回并跳转。 - 阅读模式不显示每段编号、快速编辑、快速标记按钮。 - 点击正文段落打开右侧精修与重翻面板。 - 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。 diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index 4d503f5..d836455 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -1,6 +1,6 @@ # 双语 EPUB 审校编辑器 -当前版本:`0.4.1` +当前版本:`0.5.0` 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 @@ -24,6 +24,8 @@ `0.4.1` 修复后端/API 安全与结构细节:章节内插图与文字按 spine 顺序 flush,结构统计补充 text/image 细分字段;内部目录识别增加目录页/链接密度判定;结构和 `/api/asset` 仅允许已识别的 raster 图片并加 `nosniff`;重翻接口不再允许单次请求临时覆盖 Base URL 或模型后复用已保存密钥。 +`0.5.0` 改进长篇目录与检索:目录章节标题不再用正文首段猜测,章节内插图作为次级条目挂在所属章节下,阅读时可按原 spine 顺序穿插显示;点击目录不会自动收起侧栏,桌面端正文会为常驻侧栏让位;检索新增“当前范围 / 全书全局”切换。 + ## 独立安装 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: @@ -81,8 +83,9 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio - 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。 - 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。 -- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part,可展开/收起,点击 part 后只看该 part,点击章节则看整章连续正文。 -- 插图页会像文字章节一样出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片。 +- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图。 +- 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。 +- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。 - 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。 - 精修模式保留原有逐段编辑界面,用于较细的逐段复审。 - 可在网页中配置 OpenAI 兼容的 `base_url`、`model` 与 API Key,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index fd68049..2a8352b 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -799,9 +799,18 @@ def collect_asset_paths_from_structure(structure: dict[str, Any]) -> set[str]: asset_path = image.get("asset_path", "") if asset_path: paths.add(asset_path) + for item in chapter.get("items", []): + for image in item.get("images", []): + asset_path = image.get("asset_path", "") + if asset_path: + paths.add(asset_path) return paths +def image_item_count(chapters: list[dict[str, Any]]) -> int: + return sum(1 for chapter in chapters for item in chapter.get("items", []) if item.get("kind") == "image") + + def nav_target_key(base_dir: str, href: str) -> tuple[str, str]: path, fragment = normalize_href_with_fragment(base_dir, href) return path, fragment @@ -826,6 +835,7 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]: candidate_items: list[dict[str, Any]] = [] i = 0 while i < len(matches): + source_attrs = matches[i].group(1)[2:-1] source_inner = matches[i].group(2) link_match = re.search(r"]*href=['\"]([^'\"]+)['\"][^>]*>(.*?)", source_inner, flags=re.S | re.I) if not link_match: @@ -840,13 +850,20 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]: continue source_title = compact_text(strip_tags(link_match.group(2))) or compact_text(strip_tags(source_inner)) title = source_title - if i + 1 < len(matches): + if is_gray_source(source_attrs, source_inner) and i + 1 < len(matches): + next_attrs = matches[i + 1].group(1)[2:-1] next_inner = matches[i + 1].group(2) - if not is_gray_source(matches[i + 1].group(1)[2:-1], next_inner): - next_title = compact_text(strip_tags(next_inner)) + if not is_gray_source(next_attrs, next_inner): + next_link = re.search(r"]*href=['\"]([^'\"]+)['\"][^>]*>(.*?)", next_inner, flags=re.S | re.I) + same_target = True + if next_link: + next_target_file, next_target_fragment = nav_target_key(base_dir, next_link.group(1)) + same_target = next_target_file == target_file and next_target_fragment == target_fragment + next_title = compact_text(strip_tags(next_link.group(2) if next_link else next_inner)) if next_title: - title = next_title - i += 1 + if same_target: + title = next_title + i += 1 if title: candidate_items.append( { @@ -1000,13 +1017,31 @@ def build_structure(session_root: Path) -> dict[str, Any]: if start and item.get("title") and start not in start_titles: start_titles[start] = item["title"] - for entry_name in ordered_files: - heading = read_body_heading(session_root, entry_name) - if heading and entry_name not in start_titles and len(strip_tags(heading)) <= 80: - start_titles[entry_name] = heading + if not start_titles: + for item in nav_flat: + href = item.get("href", "") + if href not in ordered_index: + continue + start = first_content_file_at_or_after(href) + if start and item.get("title") and start not in start_titles: + start_titles[start] = item["title"] + + if not start_titles: + for entry_name in ordered_files: + heading = read_body_heading(session_root, entry_name) + if heading and entry_name not in start_titles and len(strip_tags(heading)) <= 80: + start_titles[entry_name] = heading if start_titles: starts = sorted(start_titles, key=lambda name: ordered_index.get(name, 10**9)) + first_start_pos = ordered_index.get(starts[0], len(all_ordered_files)) if starts else len(all_ordered_files) + preface_files = [ + entry_name + for entry_name in all_ordered_files[:first_start_pos] + if entry_name in rows_by_file or entry_name in image_by_file + ] + if preface_files: + chapter_specs.append({"title": "卷首", "files": preface_files, "start": preface_files[0]}) for index, start in enumerate(starts): start_pos = ordered_index.get(start, -1) next_pos = ordered_index.get(starts[index + 1], len(all_ordered_files)) if index + 1 < len(starts) else len(all_ordered_files) @@ -1057,8 +1092,6 @@ def build_structure(session_root: Path) -> dict[str, Any]: "source_title": title_override or nav_titles.get(entry_name) or read_body_heading(session_root, entry_name) - or first_content_text(session_root, entry_name) - or read_document_title(session_root, entry_name) or Path(entry_name).stem, "file": entry_name, "file_label": Path(entry_name).name, @@ -1070,21 +1103,23 @@ def build_structure(session_root: Path) -> dict[str, Any]: "last_row_id": file_rows[-1]["id"] if file_rows else "", } - def make_image_chapter( + def make_image_item( entry_name: str, image: dict[str, Any], title_override: str = "", image_index: int = 1, image_total: int = 1, ) -> dict[str, Any]: - nonlocal chapter_serial, image_serial - chapter_serial += 1 + nonlocal image_serial image_serial += 1 suffix = f"-{image_index:02d}" if image_total > 1 else "" label = part_display_label(entry_name, image_serial - 1) - default_title = "封面" if Path(entry_name).stem.lower() in {"titlepage", "cover"} else f"插图 {label}{suffix}" + is_cover = Path(entry_name).stem.lower() in {"titlepage", "cover"} + default_title = "封面" if is_cover else f"插图 {label}{suffix}" base_title = title_override or nav_titles.get(entry_name) or read_body_heading(session_root, entry_name) - if title_override and base_title: + if is_cover: + title = default_title + elif title_override and base_title: title = f"{base_title} · 插图{suffix}" else: title = base_title or default_title @@ -1093,7 +1128,7 @@ def build_structure(session_root: Path) -> dict[str, Any]: "id": f"IMG{image_serial:04d}-{image['index']:02d}", } return { - "id": f"C{chapter_serial:04d}", + "id": image_item["id"], "kind": "image", "title": title, "file": entry_name, @@ -1103,10 +1138,21 @@ def build_structure(session_root: Path) -> dict[str, Any]: "touched_count": 0, "marked_count": 0, "parts": [], - "_sort": ordered_index.get(entry_name, 10**9) + image_index / 100, + "first_image_id": image_item["id"], + "last_image_id": image_item["id"], } - def chapter_from_parts(chapter_id: str, title: str, parts: list[dict[str, Any]]) -> dict[str, Any]: + def image_chapter_from_item(chapter_id: str, item: dict[str, Any]) -> dict[str, Any]: + return { + **item, + "id": chapter_id, + "items": [item], + "_sort": ordered_index.get(item.get("file", ""), 10**9), + } + + def chapter_from_items(chapter_id: str, title: str, items: list[dict[str, Any]]) -> dict[str, Any]: + parts = [item for item in items if item.get("kind") == "text"] + images = [image for item in items if item.get("kind") == "image" for image in item.get("images", [])] return { "id": chapter_id, "kind": "text", @@ -1115,9 +1161,34 @@ def build_structure(session_root: Path) -> dict[str, Any]: "touched_count": sum(part["touched_count"] for part in parts), "marked_count": sum(part["marked_count"] for part in parts), "parts": parts, - "_sort": min((ordered_index.get(part["file"], 10**9) for part in parts), default=10**9), + "images": images, + "items": items, + "_sort": min((ordered_index.get(item.get("file", ""), 10**9) for item in items), default=10**9), } + def chapter_from_parts(chapter_id: str, title: str, parts: list[dict[str, Any]]) -> dict[str, Any]: + return chapter_from_items(chapter_id, title, parts) + + def fold_image_chapters_into_text(input_chapters: list[dict[str, Any]]) -> list[dict[str, Any]]: + folded: list[dict[str, Any]] = [] + last_text_chapter: dict[str, Any] | None = None + for chapter in input_chapters: + if chapter.get("kind") != "image": + folded.append(chapter) + last_text_chapter = chapter if chapter.get("kind") == "text" else last_text_chapter + continue + if last_text_chapter is None: + folded.append(chapter) + continue + image_items = chapter.get("items") or [chapter] + last_text_chapter.setdefault("items", []).extend(image_items) + last_text_chapter.setdefault("images", []).extend( + image + for item in image_items + for image in item.get("images", []) + ) + return folded + def flatten_node_files(node: dict[str, Any]) -> list[dict[str, str]]: items: list[dict[str, str]] = [] href = node.get("href", "") @@ -1136,7 +1207,9 @@ def build_structure(session_root: Path) -> dict[str, Any]: if entry_name in image_by_file: images = image_by_file.get(entry_name, []) for image_index, image in enumerate(images, start=1): - chapters.append(make_image_chapter(entry_name, image, title_override, image_index, len(images))) + chapter_serial += 1 + image_item = make_image_item(entry_name, image, title_override, image_index, len(images)) + chapters.append(image_chapter_from_item(f"C{chapter_serial:04d}", image_item)) return if entry_name in rows_by_file: chapter_serial += 1 @@ -1145,28 +1218,36 @@ def build_structure(session_root: Path) -> dict[str, Any]: def append_grouped_files(title: str, files: list[str]) -> None: nonlocal chapter_serial - text_parts: list[dict[str, Any]] = [] + chapter_items: list[dict[str, Any]] = [] text_title = title - def flush_text_parts() -> None: - nonlocal chapter_serial, text_parts, text_title + def flush_chapter_items() -> None: + nonlocal chapter_serial, chapter_items, text_title + if not chapter_items: + return + text_parts = [item for item in chapter_items if item.get("kind") == "text"] if not text_parts: + for item in chapter_items: + if item.get("kind") == "image": + chapter_serial += 1 + chapters.append(image_chapter_from_item(f"C{chapter_serial:04d}", item)) + chapter_items = [] + text_title = "" return chapter_serial += 1 - chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], text_parts)) - text_parts = [] + chapters.append(chapter_from_items(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], chapter_items)) + chapter_items = [] text_title = "" for file_index, entry_name in enumerate(files): if entry_name in image_by_file: - flush_text_parts() images = image_by_file.get(entry_name, []) for image_index, image in enumerate(images, start=1): - image_title = title if file_index == 0 and not text_parts else "" - chapters.append(make_image_chapter(entry_name, image, image_title, image_index, len(images))) + image_title = title if file_index == 0 and not chapter_items else "" + chapter_items.append(make_image_item(entry_name, image, image_title, image_index, len(images))) elif entry_name in rows_by_file: - text_parts.append(make_part(entry_name)) - flush_text_parts() + chapter_items.append(make_part(entry_name)) + flush_chapter_items() for spec in chapter_specs: node_files = [entry_name for entry_name in spec.get("files", []) if entry_name not in assigned_files] @@ -1207,17 +1288,33 @@ def build_structure(session_root: Path) -> dict[str, Any]: append_file_as_chapter(entry_name) chapters.sort(key=lambda chapter: chapter.get("_sort", 10**9)) + chapters = fold_image_chapters_into_text(chapters) for index, chapter in enumerate(chapters, start=1): chapter["id"] = f"C{index:04d}" chapter.pop("_sort", None) + if chapter.get("kind") == "text": + for item_index, item in enumerate(chapter.get("items", []), start=1): + item["parent_chapter_id"] = chapter["id"] + item["display_level"] = 1 + item["toc_title"] = item.get("title") or item.get("file_label") or item.get("id") or f"item{item_index:04d}" + else: + chapter.setdefault("display_level", 0) return { "chapters": chapters, "chapter_count": len(chapters), "text_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") != "image"), - "image_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") == "image"), - "part_count": sum(len(chapter["parts"]) for chapter in chapters), - "image_count": sum(len(chapter.get("images", [])) for chapter in chapters), + "image_chapter_count": image_item_count(chapters) or sum(1 for chapter in chapters if chapter.get("kind") == "image"), + "top_level_image_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") == "image"), + "part_count": sum(len(chapter.get("parts", [])) for chapter in chapters), + "image_count": sum( + len(chapter.get("images", [])) if chapter.get("kind") == "image" else sum( + len(item.get("images", [])) + for item in chapter.get("items", []) + if item.get("kind") == "image" + ) + for chapter in chapters + ), "asset_count": len(collect_asset_paths_from_structure({"chapters": chapters})), "row_count": len(rows), } diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index e77694f..b447bc2 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -5,6 +5,7 @@ let currentIndex = -1; let currentRow = null; let currentChapter = null; let currentPart = null; +let currentImageItem = null; let currentMode = "reading"; let showTouchedOnly = false; let dirty = false; @@ -12,6 +13,7 @@ let quickDirty = false; let hasSession = false; let expandedChapters = new Set(); let sidePanelMode = null; +let searchScope = "current"; let gptConfigLoaded = false; let gptCandidateHtml = ""; @@ -37,6 +39,8 @@ const dom = { tocPanel: $("tocPanel"), searchPanel: $("searchPanel"), searchInput: $("searchInput"), + searchScopeCurrentBtn: $("searchScopeCurrentBtn"), + searchScopeBookBtn: $("searchScopeBookBtn"), stats: $("stats"), expandTocBtn: $("expandTocBtn"), collapseTocBtn: $("collapseTocBtn"), @@ -238,14 +242,18 @@ function resetReviewState() { currentRow = null; currentChapter = null; currentPart = null; + currentImageItem = null; showTouchedOnly = false; dirty = false; quickDirty = false; expandedChapters = new Set(); sidePanelMode = null; + searchScope = "current"; gptCandidateHtml = ""; gptConfigLoaded = false; dom.showTouchedBtn.classList.remove("primary"); + dom.searchScopeCurrentBtn.classList.add("active"); + dom.searchScopeBookBtn.classList.remove("active"); dom.searchInput.value = ""; dom.sidebar.hidden = true; dom.tocPanel.hidden = true; @@ -352,10 +360,33 @@ function rowById(rowId) { return rows.find((item) => item.id === rowId) || null; } +function itemKind(item) { + return item && item.kind === "image" ? "image" : "text"; +} + +function isImageItem(item) { + return itemKind(item) === "image"; +} + function allParts() { return (structure.chapters || []).flatMap((chapter) => chapter.parts || []); } +function chapterItems(chapter) { + if (!chapter) return []; + if (Array.isArray(chapter.items) && chapter.items.length) return chapter.items; + if (isImageChapter(chapter)) return [{ ...chapter, id: `${chapter.id}:image`, parent_chapter_id: chapter.id, display_level: 1 }]; + return chapter.parts || []; +} + +function chapterImages(chapter) { + if (!chapter) return []; + if (isImageChapter(chapter)) return chapter.images || []; + return chapterItems(chapter) + .filter(isImageItem) + .flatMap((item) => item.images || []); +} + function allChapterScopes() { return (structure.chapters || []).map((chapter) => ({ type: "chapter", @@ -364,11 +395,20 @@ function allChapterScopes() { })); } +function activeChapterScopeId() { + if (currentImageItem && currentChapter) return currentChapter.id; + return currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id; +} + function chapterIsExpanded(chapter) { if (!chapter) return false; return expandedChapters.has(chapter.id); } +function chapterHasChildren(chapter) { + return chapterItems(chapter).length > 0; +} + function toggleChapterExpanded(chapter) { if (!chapter) return; if (expandedChapters.has(chapter.id)) { @@ -386,7 +426,8 @@ function expandAllChapters() { function collapseAllChapters() { expandedChapters = new Set(); - if (currentChapter) expandedChapters.add(currentChapter.id); + const activeId = activeChapterScopeId(); + if (activeId) expandedChapters.add(activeId); renderToc(); } @@ -424,10 +465,34 @@ function chapterForPart(part) { function chapterRows(chapter) { if (!chapter) return []; + if (isImageChapter(chapter)) return []; const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); return rows.filter((row) => ids.has(row.id)); } +function searchBaseRows() { + return searchScope === "book" ? rows : scopeRows(); +} + +function visibleRowsForScope() { + return filteredRows; +} + +function updateSearchScopeButtons() { + dom.searchScopeCurrentBtn.classList.toggle("active", searchScope === "current"); + dom.searchScopeBookBtn.classList.toggle("active", searchScope === "book"); +} + +function setSearchScope(scope) { + searchScope = scope === "book" ? "book" : "current"; + updateSearchScopeButtons(); + if (searchScope === "current" && (currentImageItem || (isImageChapter(currentChapter) && !currentPart))) { + currentRow = null; + currentIndex = -1; + } + applyFilter(true); +} + function scopeCoversRow(row) { if (!row) return false; if (currentPart) return (currentPart.row_ids || []).includes(row.id); @@ -442,6 +507,7 @@ function syncScopeToRow(row, forcePart = false) { if (!nextPart) return; const nextChapter = chapterForPart(nextPart); if (!nextChapter) return; + currentImageItem = null; if (forcePart || !currentChapter || currentChapter.id !== nextChapter.id || !scopeCoversRow(row)) { currentChapter = nextChapter; currentPart = nextPart; @@ -458,12 +524,20 @@ function partVisibleRows(part) { return filteredRows.filter((row) => ids.has(row.id)); } -function chapterVisibleRows(chapter) { - if (!chapter) return []; +function chapterVisibleRowsInFilter(chapter) { + if (!chapter) return filteredRows; const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); return filteredRows.filter((row) => ids.has(row.id)); } +function chapterVisibleRows(chapter) { + if (!chapter) return filteredRows; + if (searchScope === "book") { + return chapterVisibleRowsInFilter(chapter); + } + return filteredRows; +} + function scopeRows() { if (currentPart) return partRows(currentPart); if (currentChapter) return chapterRows(currentChapter); @@ -471,13 +545,16 @@ function scopeRows() { } function scopeVisibleRows() { - if (currentPart) return partVisibleRows(currentPart); - if (currentChapter) return chapterVisibleRows(currentChapter); + if (searchScope === "book") { + if (currentPart) return partVisibleRows(currentPart); + if (currentChapter) return chapterVisibleRows(currentChapter); + return filteredRows; + } return filteredRows; } function imageChapterMatchesSearch(chapter) { - if (!isImageChapter(chapter)) return false; + if (!isImageChapter(chapter) && !isImageItem(chapter)) return false; if (showTouchedOnly) return false; const q = dom.searchInput.value.trim().toLowerCase(); if (!q) return true; @@ -533,7 +610,13 @@ async function loadRows(keepSelection = false) { if (!currentPart) { currentChapter = (structure.chapters || [])[0] || null; - currentPart = currentChapter && !isImageChapter(currentChapter) && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null; + currentPart = currentChapter + && !isImageChapter(currentChapter) + && (currentChapter.parts || []).length === 1 + && chapterImages(currentChapter).length === 0 + ? currentChapter.parts[0] + : null; + applyFilter(false); } if (filteredRows.length > 0 && currentIndex < 0) { currentRow = filteredRows[0]; @@ -596,7 +679,13 @@ function updateStructureCountsFromRows() { chapter.row_count = 0; chapter.touched_count = 0; chapter.marked_count = 0; - if (isImageChapter(chapter)) continue; + if (isImageChapter(chapter)) { + chapter.row_count = 0; + chapter.touched_count = 0; + chapter.marked_count = 0; + chapter.items = chapterItems(chapter); + continue; + } for (const part of chapter.parts || []) { const partItems = partRows(part); part.row_count = partItems.length; @@ -606,6 +695,12 @@ function updateStructureCountsFromRows() { chapter.touched_count += part.touched_count; chapter.marked_count += part.marked_count; } + for (const item of chapter.items || []) { + if (!isImageItem(item)) { + const match = (chapter.parts || []).find((part) => part.id === item.id); + if (match) Object.assign(item, match); + } + } } } @@ -631,7 +726,7 @@ function rowMatchesSearch(row, q) { function applyFilter(shouldRender = true) { const q = dom.searchInput.value.trim().toLowerCase(); - filteredRows = rows.filter((row) => rowMatchesSearch(row, q)); + filteredRows = searchBaseRows().filter((row) => rowMatchesSearch(row, q)); if (currentRow) { currentIndex = filteredRows.findIndex((row) => row.id === currentRow.id); } @@ -656,7 +751,7 @@ function renderMode() { dom.editorPane.hidden = currentMode !== "polish"; dom.readingModeBtn.classList.toggle("active", currentMode === "reading"); dom.polishModeBtn.classList.toggle("active", currentMode === "polish"); - dom.polishModeBtn.disabled = !hasSession || (isImageChapter(currentChapter) && !currentPart); + dom.polishModeBtn.disabled = !hasSession || ((currentImageItem || isImageChapter(currentChapter)) && !currentPart); document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden); if (sidePanelMode) { dom.tocPanel.hidden = sidePanelMode !== "toc"; @@ -668,11 +763,13 @@ function renderToc() { dom.toc.innerHTML = ""; const frag = document.createDocumentFragment(); for (const chapter of structure.chapters || []) { + const items = chapterItems(chapter); const parts = chapter.parts || []; + const imageItems = items.filter(isImageItem); const chapterIsImage = isImageChapter(chapter); const isCurrentChapter = currentChapter && chapter.id === currentChapter.id; const containsCurrentPart = parts.some((part) => currentPart && part.id === currentPart.id); - const expanded = !chapterIsImage && (chapterIsExpanded(chapter) || containsCurrentPart); + const expanded = chapterIsExpanded(chapter) || containsCurrentPart || (isCurrentChapter && (!currentPart || currentImageItem)); const group = document.createElement("section"); group.className = "tocChapter"; if (chapterIsImage) group.classList.add("imageChapter"); @@ -687,13 +784,12 @@ function renderToc() { const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "tocToggle"; - toggle.textContent = chapterIsImage ? "图" : expanded ? "▾" : "▸"; - toggle.disabled = chapterIsImage || parts.length === 0; - toggle.setAttribute("aria-label", chapterIsImage ? "插图章节" : expanded ? "收起章节" : "展开章节"); + toggle.textContent = expanded ? "▾" : "▸"; + toggle.disabled = !chapterHasChildren(chapter); + toggle.setAttribute("aria-label", expanded ? "收起章节" : "展开章节"); toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); toggle.addEventListener("click", (event) => { event.stopPropagation(); - if (chapterIsImage) return; toggleChapterExpanded(chapter); }); row.appendChild(toggle); @@ -709,7 +805,7 @@ function renderToc() { } title.innerHTML = ` ${escapeHtml(chapter.title || chapter.id)} - ${chapterIsImage ? `${(chapter.images || []).length || 1} 图` : `${chapter.row_count || 0} 段`} + ${chapterIsImage ? `${chapterImages(chapter).length || 1} 图` : `${chapter.row_count || 0} 段${imageItems.length ? ` · ${imageItems.length} 图` : ""}`} `; title.addEventListener("click", () => selectChapter(chapter)); row.appendChild(title); @@ -718,54 +814,84 @@ function renderToc() { const partList = document.createElement("div"); partList.className = "tocParts"; partList.hidden = !expanded; - if (!chapterIsImage) for (const part of parts) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "tocPartButton"; - if (currentPart && part.id === currentPart.id) button.classList.add("active"); - const badges = []; - if (part.touched_count) badges.push(`记 ${part.touched_count}`); - if (part.marked_count) badges.push(`标 ${part.marked_count}`); - button.innerHTML = ` - ${escapeHtml(part.title || part.file_label || part.id)} - ${part.row_count || 0}${badges.length ? " · " + escapeHtml(badges.join(" · ")) : ""} - `; - button.addEventListener("click", () => selectPart(part)); - partList.appendChild(button); + for (const item of items) { + if (isImageItem(item)) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "tocImageButton"; + const imageActive = isCurrentChapter && !currentPart && ( + currentImageItem ? currentImageItem.id === item.id : chapterIsImage + ); + if (imageActive) button.classList.add("active"); + button.innerHTML = ` + ${escapeHtml(item.toc_title || item.title || item.file_label || "插图")} + ${(item.images || []).length || 1} 图 + `; + button.addEventListener("click", () => selectImageItem(chapter, item)); + partList.appendChild(button); + } else { + const part = item; + const button = document.createElement("button"); + button.type = "button"; + button.className = "tocPartButton"; + if (currentPart && part.id === currentPart.id) button.classList.add("active"); + const badges = []; + if (part.touched_count) badges.push(`记 ${part.touched_count}`); + if (part.marked_count) badges.push(`标 ${part.marked_count}`); + button.innerHTML = ` + ${escapeHtml(part.title || part.file_label || part.id)} + ${part.row_count || 0}${badges.length ? " · " + escapeHtml(badges.join(" · ")) : ""} + `; + button.addEventListener("click", () => selectPart(part)); + partList.appendChild(button); + } } group.appendChild(partList); frag.appendChild(group); } dom.toc.appendChild(frag); + requestAnimationFrame(() => { + dom.toc.querySelector(".tocPartButton.active, .tocImageButton.active, .tocChapterButton.active")?.scrollIntoView({ block: "nearest" }); + }); } function renderList() { const touched = rows.filter(rowTouched).length; const marked = rows.filter((row) => row.marked).length; + const visibleRows = visibleRowsForScope(); const visibleInScope = scopeVisibleRows(); const imageCount = structure.image_count || 0; - const isCurrentImage = isImageChapter(currentChapter) && !currentPart; - dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentChapter.images || []).length} 图` : `${visibleInScope.length} 段`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`; + const isBrowsingImage = !dom.searchInput.value.trim() && !showTouchedOnly; + const isCurrentImage = searchScope !== "book" && isBrowsingImage && !currentPart && (Boolean(currentImageItem) || isImageChapter(currentChapter)); + const scopeLabel = searchScope === "book" ? "全书" : "当前范围"; + dom.stats.textContent = `${scopeLabel}显示 ${visibleRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).length} 图` : `${visibleInScope.length} 段`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`; dom.rowList.innerHTML = ""; const frag = document.createDocumentFragment(); if (isCurrentImage) { const item = document.createElement("div"); item.className = "rowItem imageRowItem active"; - const images = currentChapter.images || []; item.innerHTML = `
${escapeHtml(currentChapter.id)} 插图 ${escapeHtml(currentChapter.file_label || "")}
-
${escapeHtml(images.map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}
+
${escapeHtml((currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}
`; frag.appendChild(item); dom.rowList.appendChild(frag); return; } - visibleInScope.forEach((row) => { + if (!visibleRows.length) { + const item = document.createElement("div"); + item.className = "rowItem emptyRowItem"; + item.textContent = dom.searchInput.value || showTouchedOnly ? "当前检索范围没有命中段落。" : "当前范围没有可检索段落。"; + frag.appendChild(item); + dom.rowList.appendChild(frag); + return; + } + visibleRows.forEach((row) => { const button = document.createElement("button"); button.type = "button"; button.className = "rowItem"; @@ -789,7 +915,12 @@ function renderList() { } function renderImageChapter(chapter) { - const images = chapter.images || []; + const items = chapterItems(chapter).filter(isImageItem); + if (items.length > 1) { + renderMixedChapterImages(chapter, items); + return; + } + const images = chapterImages(chapter); dom.chapterKicker.textContent = chapter.file_label || "插图"; dom.readerTitle.textContent = chapter.title || "插图"; dom.readingFlow.innerHTML = ""; @@ -813,6 +944,73 @@ function renderImageChapter(chapter) { dom.readingFlow.appendChild(frag); } +function createImageFigure(item) { + const frag = document.createDocumentFragment(); + (item.images || []).forEach((image, index) => { + const figure = document.createElement("figure"); + figure.className = "imageChapterFigure inlineImageFigure"; + figure.innerHTML = ` + ${escapeHtml(image.alt || item.title || +
+ ${escapeHtml(item.title || `插图 ${index + 1}`)} + ${escapeHtml(image.asset_name || image.asset_path || "")} +
+ `; + frag.appendChild(figure); + }); + return frag; +} + +function renderMixedChapterImages(chapter, imageItems, forceShow = false) { + dom.chapterKicker.textContent = chapter ? chapter.title : "插图"; + dom.readerTitle.textContent = imageItems.length === 1 + ? imageItems[0].title || "插图" + : chapter ? chapter.title : "插图"; + dom.readingFlow.innerHTML = ""; + const frag = document.createDocumentFragment(); + imageItems.forEach((item) => { + if (forceShow || imageChapterMatchesSearch(item)) frag.appendChild(createImageFigure(item)); + }); + if (!frag.childNodes.length) { + dom.readingFlow.innerHTML = '
当前筛选条件下没有插图。
'; + return; + } + dom.readingFlow.appendChild(frag); +} + +function rowPassesCurrentFilters(row) { + const q = dom.searchInput.value.trim().toLowerCase(); + return rowMatchesSearch(row, q); +} + +function rowsForReadingPart(part) { + const visible = searchScope === "book" + ? partRows(part).filter(rowPassesCurrentFilters) + : partVisibleRows(part); + if (visible.length || dom.searchInput.value || showTouchedOnly) return visible; + return partRows(part); +} + +function appendReadBlock(frag, row) { + const block = document.createElement("section"); + block.className = "readBlock"; + block.dataset.rowId = row.id; + if (currentRow && row.id === currentRow.id) block.classList.add("active"); + if (rowTouched(row)) block.classList.add("touched"); + if (row.marked) block.classList.add("marked"); + + block.innerHTML = ` +
+
${row.jp_html || escapeHtml(row.jp_text)}
+
${row.current_html || row.cn_html || escapeHtml(row.cn_text)}
+
+ `; + block.addEventListener("click", () => { + maybeSelectRow(row, { openQuick: true, scroll: false }); + }); + frag.appendChild(block); +} + function renderReading() { const selectedChapter = currentChapter || chapterForPart(currentPart); if (!currentPart && !selectedChapter) { @@ -823,6 +1021,10 @@ function renderReading() { } const selectedPart = currentPart; + if (!selectedPart && currentImageItem) { + renderMixedChapterImages(selectedChapter, [currentImageItem], true); + return; + } if (!selectedPart && isImageChapter(selectedChapter)) { renderImageChapter(selectedChapter); return; @@ -833,49 +1035,34 @@ function renderReading() { : selectedChapter.title || "未命名章节"; dom.readingFlow.innerHTML = ""; - const visibleRows = scopeVisibleRows(); - const sourceRows = visibleRows.length || dom.searchInput.value || showTouchedOnly ? visibleRows : scopeRows(); - if (!sourceRows.length) { - dom.readingFlow.innerHTML = '
当前筛选条件下没有段落。
'; + const frag = document.createDocumentFragment(); + + if (selectedPart) { + rowsForReadingPart(selectedPart).forEach((row) => appendReadBlock(frag, row)); + } else { + for (const item of chapterItems(selectedChapter)) { + if (isImageItem(item)) { + if (imageChapterMatchesSearch(item)) frag.appendChild(createImageFigure(item)); + continue; + } + const partRowsToRender = rowsForReadingPart(item); + if (!partRowsToRender.length) continue; + const divider = document.createElement("h3"); + divider.className = "partDivider"; + divider.textContent = item.title || item.file_label || item.file || "part"; + frag.appendChild(divider); + partRowsToRender.forEach((row) => appendReadBlock(frag, row)); + } + } + if (!frag.childNodes.length) { + dom.readingFlow.innerHTML = '
当前筛选条件下没有内容。
'; return; } - - const frag = document.createDocumentFragment(); - let lastFile = ""; - sourceRows.forEach((row) => { - if (!selectedPart && row.file !== lastFile) { - const part = partForRow(row); - if (part) { - const divider = document.createElement("h3"); - divider.className = "partDivider"; - divider.textContent = part.title || part.file_label || row.file; - frag.appendChild(divider); - } - lastFile = row.file; - } - const block = document.createElement("section"); - block.className = "readBlock"; - block.dataset.rowId = row.id; - if (currentRow && row.id === currentRow.id) block.classList.add("active"); - if (rowTouched(row)) block.classList.add("touched"); - if (row.marked) block.classList.add("marked"); - - block.innerHTML = ` -
-
${row.jp_html || escapeHtml(row.jp_text)}
-
${row.current_html || row.cn_html || escapeHtml(row.cn_text)}
-
- `; - block.addEventListener("click", () => { - maybeSelectRow(row, { openQuick: true, scroll: false }); - }); - frag.appendChild(block); - }); dom.readingFlow.appendChild(frag); } function renderEditor() { - if (isImageChapter(currentChapter) && !currentPart) { + if ((currentImageItem || isImageChapter(currentChapter)) && !currentPart) { dom.emptyState.hidden = false; dom.emptyState.textContent = "插图章节没有可编辑译文,请切回阅读模式查看图片。"; dom.editorCard.hidden = true; @@ -997,7 +1184,7 @@ async function saveRow(row, data, silent = false) { async function saveCurrent(silent = false) { if (!hasSession) return true; - if (isImageChapter(currentChapter) && !currentPart) return true; + if ((currentImageItem || isImageChapter(currentChapter)) && !currentPart) return true; if (!currentRow) return true; return saveRow(currentRow, collectCurrent(), silent); } @@ -1021,8 +1208,11 @@ async function maybeSelectRow(row, options = {}) { function selectRow(row, options = {}) { if (!row) return; currentRow = row; - currentIndex = filteredRows.findIndex((item) => item.id === row.id); + if (isImageChapter(currentChapter) && !currentPart) currentChapter = null; + currentImageItem = null; syncScopeToRow(row, Boolean(options.forcePart)); + applyFilter(false); + currentIndex = filteredRows.findIndex((item) => item.id === row.id); dirty = false; if (options.openEditor) { @@ -1042,24 +1232,24 @@ async function selectChapter(chapter) { if (!ok) return; currentChapter = chapter; currentPart = null; + currentImageItem = null; expandedChapters.add(chapter.id); + applyFilter(false); if (isImageChapter(chapter)) { currentRow = null; currentIndex = -1; closeQuickEditor(false); currentMode = "reading"; } else { - const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); - const visible = filteredRows.filter((row) => ids.has(row.id)); - const fallback = rows.filter((row) => ids.has(row.id)); - currentRow = visible[0] || fallback[0] || currentRow; + const visible = searchScope === "book" ? chapterVisibleRowsInFilter(chapter) : filteredRows; + const fallback = scopeRows(); + currentRow = visible[0] || fallback[0] || null; currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1; } renderAll(); if (currentMode === "reading") { dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); } - if (sidePanelMode === "toc") closeSidePanel(); } async function selectPart(part) { @@ -1068,16 +1258,34 @@ async function selectPart(part) { if (!ok) return; currentChapter = chapterForPart(part); currentPart = part; + currentImageItem = null; if (currentChapter) expandedChapters.add(currentChapter.id); + applyFilter(false); const visible = partVisibleRows(part); const fallback = partRows(part); - currentRow = visible[0] || fallback[0] || currentRow; + currentRow = visible[0] || fallback[0] || null; currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1; renderAll(); if (currentMode === "reading") { dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); } - if (sidePanelMode === "toc") closeSidePanel(); +} + +async function selectImageItem(chapter, item) { + if (!chapter || !item) return; + const ok = await maybeSaveBeforeSwitch(); + if (!ok) return; + currentChapter = chapter; + currentPart = null; + currentImageItem = item; + currentRow = null; + currentIndex = -1; + if (chapter) expandedChapters.add(chapter.id); + applyFilter(false); + closeQuickEditor(false); + currentMode = "reading"; + renderAll(); + dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); } function scrollRowIntoView(rowId) { @@ -1094,7 +1302,7 @@ async function setMode(mode, saveBefore = true) { const ok = await maybeSaveBeforeSwitch(); if (!ok) return; } - if (mode === "polish" && isImageChapter(currentChapter) && !currentPart) { + if (mode === "polish" && (currentImageItem || isImageChapter(currentChapter)) && !currentPart) { toast("插图章节没有可编辑译文,已保持阅读模式。"); mode = "reading"; } @@ -1105,7 +1313,7 @@ async function setMode(mode, saveBefore = true) { if (mode === "polish") { closeQuickEditor(false); if (!scopeCoversRow(currentRow)) currentRow = null; - if (!currentRow) currentRow = scopeVisibleRows()[0] || scopeRows()[0] || filteredRows[0] || null; + if (!currentRow) currentRow = visibleRowsForScope()[0] || scopeRows()[0] || filteredRows[0] || null; } withReaderAnchor(() => renderAll()); if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id); @@ -1114,8 +1322,11 @@ async function setMode(mode, saveBefore = true) { function openQuickEditor(row) { withReaderAnchor(() => { currentRow = row; - currentIndex = filteredRows.findIndex((item) => item.id === row.id); + if (isImageChapter(currentChapter) && !currentPart) currentChapter = null; + currentImageItem = null; syncScopeToRow(row); + applyFilter(false); + currentIndex = filteredRows.findIndex((item) => item.id === row.id); fillQuickEditor(row); dom.quickEditor.hidden = false; document.body.classList.add("quickOpen"); @@ -1274,17 +1485,18 @@ function clearMarks() { } async function go(delta) { - if (!filteredRows.length) return; - const index = currentIndex >= 0 ? currentIndex : filteredRows.findIndex((row) => currentRow && row.id === currentRow.id); - const next = Math.max(0, Math.min(filteredRows.length - 1, index + delta)); + const candidates = visibleRowsForScope(); + if (!candidates.length) return; + const index = Math.max(0, candidates.findIndex((row) => currentRow && row.id === currentRow.id)); + const next = Math.max(0, Math.min(candidates.length - 1, index + delta)); if (next === index) return; - await maybeSelectRow(filteredRows[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true }); + await maybeSelectRow(candidates[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true }); } async function goPart(delta) { const scopes = allChapterScopes(); if (!scopes.length) return; - const currentScopeId = currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id; + const currentScopeId = activeChapterScopeId(); const index = Math.max(0, scopes.findIndex((scope) => scope.id === currentScopeId)); const next = Math.max(0, Math.min(scopes.length - 1, index + delta)); if (next === index) return; @@ -1344,6 +1556,8 @@ function initEvents() { currentIndex = -1; applyFilter(true); }); + dom.searchScopeCurrentBtn.addEventListener("click", () => setSearchScope("current")); + dom.searchScopeBookBtn.addEventListener("click", () => setSearchScope("book")); dom.tocPanelBtn.addEventListener("click", () => { if (sidePanelMode === "toc") { closeSidePanel(); diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index 8e757eb..e257c36 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -9,7 +9,7 @@
-

双语 EPUB 审校编辑器 v0.4.1

+

双语 EPUB 审校编辑器 v0.5.0

正在载入……

@@ -75,6 +75,10 @@