[Feature & Bugfix] Reading Workspace Enhancements: Real-time TOC Tracking, EPUB Formatting Toolbar & Themes, Configurable Single/Two-Page Spread Layout, EPUB Archive Normalization ("Could not load this section"), and Navigation Prefetch Optimization
[Feature & Bugfix] Reading Workspace Enhancements: Real-time TOC Tracking, EPUB Formatting Toolbar & Themes, Configurable Single/Two-Page Spread Layout, EPUB Archive Normalization ("Could not load this section"), and Navigation Prefetch Optimization
Issue Title
[Feature & Bugfix] Reading Workspace: Real-time TOC tracking, EPUB formatting toolbar & themes, configurable single/two-page spread layout, EPUB archive normalization ("Could not load this section"), and navigation prefetch optimization
Overview
This issue summarizes five closely related improvements and bug fixes for the DeepTutor Reading Workspace (/reading and /reading/[workspaceId]) identified during comprehensive testing and daily usage of document and e-book reading features:
- Bugfix (Left Sidebar TOC / Outline Tracking): The left sidebar Table of Contents / Outline does not update or highlight the active chapter as the user reads or turns pages in the document.
- Feature (EPUB Formatting Toolbar & Themes): The EPUB reader lacks text formatting and theme customization controls (font size, serif/sans-serif switch, line width, and sepia/night/auto themes), which are currently only implemented in the plain-text/markdown reader.
- Enhancement (Configurable Reading Layout: Single-Page vs. Two-Page Spread): The EPUB reader hardcodes
spread: "auto", forcing two-page book spreads on screens wider than 800px without any user setting. Rather than hardcoding either layout, we provide an interactive toolbar toggle switch (Single PagevsTwo-Page Spread) with localStorage persistence and adaptive container width. - Bugfix (EPUB Archive Normalization / "Could not load this section"): Uploading valid EPUB archives that contain top-level directory wrappers or macOS AppleDouble (
__MACOSX/) files causes the browser reader to crash with"Could not load this section"(无法加载这一节). - Performance (Prefetch Flooding & Immutable Chunk Caching): Next.js App Router default
<Link>viewport prefetching triggers 59 parallel RSC requests on page load, saturating HTTP/1.1 browser connection pools and delaying reading navigation.
1. Bug: Left Sidebar TOC / Outline Does Not Track Reading Progress
Problem Description
When reading a document (EPUB, PDF, or Markdown) in /reading/[workspaceId]:
- As the user scrolls, clicks the "Next / Previous" page buttons, or navigates using keyboard arrows (
←/→), the reader view updates and the top header displays the updated locator (e.g.Section 5 / 100). - However, the left sidebar Table of Contents / Outline (
activeRow) never updates. It remains permanently highlighted on Chapter 1 (or whatever chapter was last manually clicked in the sidebar). - Readers lose track of which chapter they are currently in.
Root Cause
In ReadingWorkspacePage (ri):
// Parent component holds activeLocator state
const { activeLocator, setActiveLocator } = useReadingWorkspace();
// activeLocator is passed to sidebar
<Sidebar outline={outline} activeLocator={activeLocator} ... />
// BUT DocumentReader (e0) is instantiated without an onLocatorChange callback:
<DocumentReader
sessionId={sessionId}
externalJump={externalJump}
onHeadingsChange={onHeadingsChange}
onActiveHeadingChange={onActiveHeadingChange}
headingJump={headingJump}
bookmarks={bookmarks}
onToggleBookmark={onToggleBookmark}
onClose={onClose}
// Missing: onLocatorChange={setActiveLocator}
/>Inside DocumentReader (e0), the page relocation handler onVisibleLocatorChange (e$) updates its internal local state en(locator) and non-reactive viewport metadata reportViewport({ locator }), but never notifies the parent ReadingWorkspacePage:
const onVisibleLocatorChange = useCallback((locator) => {
setLocalLocator(locator);
reportViewport({ locator });
// Never calls parent setActiveLocator!
}, [reportViewport]);Because setActiveLocator is never invoked, activeLocator in ReadingWorkspacePage remains static, preventing the sidebar's activeRow = outline.reduce((acc, row) => row.locator <= activeLocator ? row : acc, null) from advancing.
Proposed Fix
- Add
onLocatorChange?: (locator: number) => voidtoDocumentReader(e0) props. - In
ReadingWorkspacePage, passonLocatorChange={setActiveLocator}to<DocumentReader />. - In
DocumentReader, invokeonLocatorChange?.(locator)withinonVisibleLocatorChange. - In the sidebar outline renderer (
tg), add a smooth scroll-into-view hook on active rows:<li ref={(el) => { if (isActive && el) el.scrollIntoView({ block: "nearest", behavior: "smooth" }); }} ...>
2. Feature: EPUB Reader Formatting Toolbar & Reading Themes
Problem Description
DeepTutor implements a text formatting toolbar in the plain-text/markdown reader component (ex), featuring:
- Font size adjustment (
Smaller text/Larger text, 12px – 28px) - Font family toggle (
Use serif font/Use sans-serif font) - Line width toggle (
48/64/84/104characters) - Theme toggle (Light / Sepia
#f4ecd8/ Night#16181d) - Display reset
However, for native EPUB books (render_mode === "epub"), the reader component (eE) only renders floating circular Previous/Next buttons. It lacks any formatting controls:
- Readers cannot enlarge text on high-resolution screens or shrink text on smaller laptops.
- Readers cannot switch to eye-care/sepia or dark mode for nocturnal reading.
- Text spans across the entire browser viewport width on desktop monitors.
Proposed Solution
- Port the Toolbar to the EPUB Reader (
eE): Add the top toolbar above the EPUB container with the same iconography (lucide-reacticonsMinus,Plus,Type,AlignJustify,Palette,RotateCcw). - Hook into
epub.jsTheme & Style APIs:useEffect(() => { const rendition = renditionRef.current; if (!rendition) return; // Font size rendition.themes.fontSize(`${fontSize}px`); // Font family const fontFamily = serif ? "ui-serif, Georgia, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', serif !important" : "ui-sans-serif, system-ui, -apple-system, 'PingFang SC', sans-serif !important"; rendition.themes.override("font-family", fontFamily, true); // Themes if (readerTheme === "sepia") { rendition.themes.override("background", "#f4ecd8", true); rendition.themes.override("color", "#473c2c", true); } else if (readerTheme === "night") { rendition.themes.override("background", "#16181d", true); rendition.themes.override("color", "#e8e5df", true); } else { rendition.themes.removeOverride("background"); rendition.themes.removeOverride("color"); } }, [fontSize, serif, readerTheme]); - Line Width & Container Layout:
Constrain the rendition container with
maxWidth: calc(${lineWidth}ch + 4rem)andmargin: 0 auto. Triggerrendition.resize()on line width changes. - Iframe Background Seamless Styling:
In
hooks.content.register(), apply theme background colors directly to the iframebodyto avoid white flashes during chapter transitions. - Preference Persistence:
Save settings to
localStorage["dt.reader.textPreferences"]so preferences persist across book sessions.
3. Enhancement: Configurable EPUB Layout (Single-Page Mode vs. Two-Page Spread)
Problem Description
In the current EPUB reader initialization:
rendition = book.renderTo(container, {
width: "100%",
height: "100%",
flow: "paginated",
spread: "auto", // Hardcoded
allowScriptedContent: false
});- Why hardcoding
spread: "auto"hurts standard reading: Underspread: "auto",epub.jsautomatically enables two-page side-by-side spreads (Two-Page Spread) whenever the viewport width exceeds 800px. On standard desktop displays, text is divided into two columns, forcing readers into a book-simulation pattern: reading down the left column, jumping eyes to the top-right column, and then turning the page ("看完左边看右边"). For most digital readers, this breaks continuous reading flow. - Why hardcoding
spread: "none"is also undesirable: Simply hardcodingspread: "none"locks all users into single-column reading. Readers with ultra-wide monitors (e.g. 27–34" 2K/4K displays) or those who prefer a traditional physical book simulation lose the ability to read in dual-page spreads.
Proposed Solution: Interactive Toolbar Toggle, ResizeObserver & Multi-Column Hardening
Rather than hardcoding either layout mode, the reader should provide user choice with a robust, layout-safe implementation:
Toolbar Layout Toggle Switch: Add a layout switch button to the EPUB formatting toolbar:
<ToolbarButton label={spreadMode === "none" ? "Switch to two-page spread (双页开本)" : "Switch to single-page view (单页排版)"} icon={spreadMode === "none" ? SinglePageIcon : TwoPageSpreadIcon} active={spreadMode === "auto"} onClick={() => setSpreadMode(spreadMode === "none" ? "auto" : "none")} />Root Cause of Layout Corruption (Three-Column Bug & Text/Image Overlap):
- Why 3 columns appear ("三页显示"): In
epub.js's underlying layout engine (IframeView.prototype.columns), CSS multi-column styling only setscolumn-widthandcolumn-gap, but never specifiescolumn-count. Under the W3C CSS Multi-column specification, whencolumn-countisauto, the browser fits as many columns as possible: $$\text{columns} = \left\lfloor \frac{\text{containerWidth} + \text{gap}}{\text{column-width} + \text{gap}} \right\rfloor$$ When the left sidebar is collapsed, the reading container expands from ~900px to >1300px. Without an upper limit on column count, the browser automatically creates 3 columns, breaking pagination step calculations. - Why sidebar toggling desynchronizes epub.js:
epub.jsnatively only listens to browser-levelwindow.onresize. Collapsing or expanding an internal UI sidebar changes thedivwidth but does not firewindow.resize, leavingepub.js's internal stage dimensions completely stale. - Why text & images overlap in single-page mode: Calling
rendition.spread("none")alters internal settings but does not re-anchor the active view to the current CFI. Outdated CSS columntranslateXtransforms remain in the iframe DOM while the column width snaps to 100%, causing text and images from subsequent columns to overlap into the current viewport.
- Why 3 columns appear ("三页显示"): In
Robust 4-Layer Hardening Architecture:
- Layer 1: Container-level
ResizeObserver: Attach aResizeObserverto the reader mount container (ref={containerRef}). Whenever the sidebar toggles, the window resizes, or custom line widths change, debounce (100ms) until CSS transitions settle, then pass exact pixel dimensionsrendition.resize(width, height)and realign viarendition.display(currentCfi). - Layer 2: Strict CSS
column-countEnforcement: In the theme stylesheet, explicitly overridecolumn-count:- Single-page mode:
column-count: 1 !important;(physically prevents 2 or 3 columns). - Two-page mode:
column-count: 2 !important;(strictly caps columns at 2, eliminating the 3-column bug on wide screens).
- Single-page mode:
- Layer 3: Image Viewport Containment:
Add CSS rule:This prevents oversized illustration heights from breaking multi-column layout and overlapping subsequent paragraph text.
img { max-width: 100% !important; max-height: 85vh !important; height: auto !important; object-fit: contain !important; } - Layer 4: Atomic Re-layout on Spread Toggle:
When toggling
spreadMode, captureconst cfi = rendition.currentLocation()?.start?.cfi, updaterendition.spread(mode), applycolumn-count, and after container transition, triggerrendition.resize(width, height)followed byrendition.display(cfi).
- Layer 1: Container-level
Adaptive Container Width: Adapt container
maxWidthto provide optimal line length in both modes:- Single-Page Mode (
spread: "none"):maxWidth: calc(${lineWidth}ch + 4rem)(e.g. 84ch centered, ideal for line scanning). - Two-Page Spread Mode (
spread: "auto"):maxWidth: calc(${lineWidth * 2}ch + 6rem)(e.g. 168ch centered, giving each column full comfortable width).
- Single-Page Mode (
Persistence & Reset:
- Stored in
localStorage["dt.reader.textPreferences"].spreadMode("none" | "auto"). - Default on clean install:
"none"(single-page mode). - Resetting reader display restores
spreadMode: "none".
- Stored in
4. Bugfix: EPUB Archive Normalization (Fixes "Could not load this section" / "无法加载这一节")
Problem Description
When users upload EPUB files created or downloaded on macOS (e.g. via Safari, Finder "Compress", or certain calibre/converter exports):
- The EPUB uploads successfully, but when opening the book at
/reading/rw_<workspace_id>, the UI displays a fatal alert:Could not load this section. (无法加载这一节。) - In the backend, the extractor mistakenly registers dozens or hundreds of phantom chapters (e.g.
._index_split_000.html).
Root Cause
- Nested Top-Level Folder Wrapper: In many archives, contents are enclosed inside a top-level directory instead of being at the ZIP archive root:
MyBook.epub/ ├── mimetype ├── META-INF/container.xml └── OEBPS/content.opfepub.jsstrictly expectsMETA-INF/container.xmlat the root of the ZIP container. When it is nested underMyBook.epub/META-INF/container.xml,epub.jsfails to find the container manifest and crashes. - macOS AppleDouble Junk (
__MACOSX/&._*files): macOS Finder compression creates AppleDouble resource forks (__MACOSX/._*). DeepTutor's spine extractor treats these hidden files as real HTML sections, generating corrupt spine references that cannot be loaded.
Proposed Fix
In deeptutor/utils/document_extractor.py and deeptutor/reading/store.py, normalize the uploaded EPUB archive before storing raw bytes and extracting the spine:
def normalize_epub_archive(data: bytes) -> bytes:
"""Unwrap top-level directory wrappers, strip __MACOSX junk, and ensure valid OCF structure."""
with zipfile.ZipFile(io.BytesIO(data)) as zf:
names = [n for n in zf.namelist() if not n.startswith("__MACOSX/") and not Path(n).name.startswith("._")]
# Detect common root prefix
prefix = ""
if "META-INF/container.xml" not in names:
candidates = [n for n in names if n.endswith("META-INF/container.xml")]
if candidates:
prefix = candidates[0].rsplit("META-INF/container.xml", 1)[0]
# Repack archive with mimetype stored uncompressed as the first entry
out = io.BytesIO()
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as new_zf:
# Write mimetype first (ZIP_STORED per IDPF OCF specification)
new_zf.writestr("mimetype", b"application/epub+zip", compress_type=zipfile.ZIP_STORED)
for item in zf.infolist():
name = item.filename
if name.startswith("__MACOSX/") or Path(name).name.startswith("._"):
continue
clean_name = name[len(prefix):] if prefix and name.startswith(prefix) else name
if clean_name and clean_name != "mimetype" and not clean_name.endswith("/"):
new_zf.writestr(clean_name, zf.read(name), compress_type=zipfile.ZIP_DEFLATED)
return out.getvalue()Call normalize_epub_archive in ReadingStore.ingest and before extracting the spine in extract_epub_spine.
⚡ 5. Performance: Next.js App Router Prefetch Flooding & Static Chunk Caching
Problem Description
- Next.js Sidebar Prefetch Flooding:
- Next.js App Router
<Link>components by default perform speculative prefetching when links enter the browser viewport. - In the main sidebar (18 menu items across multiple segment payloads), 59 concurrent
_rscrequests are dispatched simultaneously on page load (bringing total initial requests to 172+). - Over HTTP/1.1, browsers enforce a strict limit of 6 TCP connections per origin. When a user clicks a menu item or a book in
/reading, the actual navigation request gets queued behind 59 prefetch requests, causing several seconds of UI freeze.
- Next.js App Router
- Immutable Static Chunk Deadlock:
next-serverstandalone setsCache-Control: public, max-age=31536000, immutableon/_next/static/chunks/.- When client code is updated or patched, Safari and other browsers lock onto stale chunks in memory, causing client-side routing mismatches, timeout retries, or blank page error boundaries.
Proposed Fix
- Throttled / On-Demand Prefetching:
Set
prefetch={false}(orprefetch: falsein<Link>) on static sidebar navigation links so speculative prefetching does not flood the connection pool. - ETag-based Revalidation:
In
next/dist/server/lib/router-server.js, configure static chunk caching to:This allows browsers to perform instant 304 ETag revalidation (<1ms) without risk of immutable cache deadlocks.res.setHeader('Cache-Control', 'no-cache, must-revalidate');
✅ Verification & Results
We implemented and verified these enhancements in a production environment:
- TOC Progress Tracking: Verified in Headless Chromium. Reading through chapters automatically updates and highlights the left sidebar TOC item in real-time, and auto-scrolls the active row into view.
- EPUB Toolbar & Themes: Verified font size changes (12px – 28px), font family switching, line width cycling, and seamless theme switching (Sepia
#f4ecd8, Night#16181d, Auto). - Single-Page vs Two-Page Spread Layout Toggle: Verified toolbar toggle switches seamlessly between centered single-page layout (
spread: "none") and physical book-like two-page spread (spread: "auto") dynamically without page reload, with preference saved tolocalStorage. - EPUB Archive Normalization: Successfully resolved the
"Could not load this section"error on previously failing EPUB files with top-level directory wrappers and__MACOSX/AppleDouble files. - Performance: Page load requests dropped from 172 to 79, and client-side menu navigation latency dropped from several seconds to ~115ms.
Source: HKUDS/DeepTutor