#1447·DeepTutor

[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

Author: githubhelinCreated Sep 13, 2026Updated Sep 15, 2026
Labelsenhancement

[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:

  1. 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.
  2. 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.
  3. 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 Page vs Two-Page Spread) with localStorage persistence and adaptive container width.
  4. 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" (无法加载这一节).
  5. 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):

typescript
// 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:

typescript
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

  1. Add onLocatorChange?: (locator: number) => void to DocumentReader (e0) props.
  2. In ReadingWorkspacePage, pass onLocatorChange={setActiveLocator} to <DocumentReader />.
  3. In DocumentReader, invoke onLocatorChange?.(locator) within onVisibleLocatorChange.
  4. In the sidebar outline renderer (tg), add a smooth scroll-into-view hook on active rows:
    typescript
    <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 / 104 characters)
  • 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

  1. Port the Toolbar to the EPUB Reader (eE): Add the top toolbar above the EPUB container with the same iconography (lucide-react icons Minus, Plus, Type, AlignJustify, Palette, RotateCcw).
  2. Hook into epub.js Theme & Style APIs:
    typescript
    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]);
  3. Line Width & Container Layout: Constrain the rendition container with maxWidth: calc(${lineWidth}ch + 4rem) and margin: 0 auto. Trigger rendition.resize() on line width changes.
  4. Iframe Background Seamless Styling: In hooks.content.register(), apply theme background colors directly to the iframe body to avoid white flashes during chapter transitions.
  5. 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:

javascript
rendition = book.renderTo(container, {
  width: "100%",
  height: "100%",
  flow: "paginated",
  spread: "auto", // Hardcoded
  allowScriptedContent: false
});
  • Why hardcoding spread: "auto" hurts standard reading: Under spread: "auto", epub.js automatically 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 hardcoding spread: "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:

  1. Toolbar Layout Toggle Switch: Add a layout switch button to the EPUB formatting toolbar:

    typescript
    <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")}
    />
  2. 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 sets column-width and column-gap, but never specifies column-count. Under the W3C CSS Multi-column specification, when column-count is auto, 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.js natively only listens to browser-level window.onresize. Collapsing or expanding an internal UI sidebar changes the div width but does not fire window.resize, leaving epub.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 column translateX transforms 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.
  3. Robust 4-Layer Hardening Architecture:

    • Layer 1: Container-level ResizeObserver: Attach a ResizeObserver to 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 dimensions rendition.resize(width, height) and realign via rendition.display(currentCfi).
    • Layer 2: Strict CSS column-count Enforcement: In the theme stylesheet, explicitly override column-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).
    • Layer 3: Image Viewport Containment: Add CSS rule:
      css
      img {
        max-width: 100% !important;
        max-height: 85vh !important;
        height: auto !important;
        object-fit: contain !important;
      }
      This prevents oversized illustration heights from breaking multi-column layout and overlapping subsequent paragraph text.
    • Layer 4: Atomic Re-layout on Spread Toggle: When toggling spreadMode, capture const cfi = rendition.currentLocation()?.start?.cfi, update rendition.spread(mode), apply column-count, and after container transition, trigger rendition.resize(width, height) followed by rendition.display(cfi).
  4. Adaptive Container Width: Adapt container maxWidth to 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).
  5. 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".

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):

  1. 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. (无法加载这一节。)
  2. In the backend, the extractor mistakenly registers dozens or hundreds of phantom chapters (e.g. ._index_split_000.html).

Root Cause

  1. 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.opf
    epub.js strictly expects META-INF/container.xml at the root of the ZIP container. When it is nested under MyBook.epub/META-INF/container.xml, epub.js fails to find the container manifest and crashes.
  2. 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:

python
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

  1. 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 _rsc requests 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.
  2. Immutable Static Chunk Deadlock:
    • next-server standalone sets Cache-Control: public, max-age=31536000, immutable on /_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

  1. Throttled / On-Demand Prefetching: Set prefetch={false} (or prefetch: false in <Link>) on static sidebar navigation links so speculative prefetching does not flood the connection pool.
  2. ETag-based Revalidation: In next/dist/server/lib/router-server.js, configure static chunk caching to:
    javascript
    res.setHeader('Cache-Control', 'no-cache, must-revalidate');
    This allows browsers to perform instant 304 ETag revalidation (<1ms) without risk of immutable cache deadlocks.

✅ Verification & Results

We implemented and verified these enhancements in a production environment:

  1. 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.
  2. EPUB Toolbar & Themes: Verified font size changes (12px – 28px), font family switching, line width cycling, and seamless theme switching (Sepia #f4ecd8, Night #16181d, Auto).
  3. 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 to localStorage.
  4. 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.
  5. Performance: Page load requests dropped from 172 to 79, and client-side menu navigation latency dropped from several seconds to ~115ms.