Skip to content

EPUB page numbers freeze when the page-list nav is not in spine/reading order #688

Description

@albertmichaelj

Full disclosure, I diagnosed the bug and wrote the below issue with the help of Claude. I do not know this code base well enough to propose a fix on my own, and I cannot say with any certainty that the below fix is not going to have some unintended consequences, which is why I am not opening a pull request (just an issue with a potential fix already outlined in case it is helpful). The problem is hopefully clear, and this is something that I run into constantly because I have re-ordered all of my page lists to correspond to the print order (though I have not changed the spine). The reason for this is that in most readers (including Apple Books and Readest), the UI that shows "Page # of Total Page #" is broken if you do not reorder the page list. Specifically, in the very common case in which some frontmatter is moved to the backmatter (often copyright), you end up with something that looks like "Page 45 of iv". If you re-order the page list (which is both conformant to the ePUB spec, see below, and something that occurs on a reasonably large number of commercially available ebooks) to match the logical page order instead of ePUB spine order, other readers correctly display the total number of pages (as the final symbol in the page list). However, crengine does not handle page lists that are not ordered in ePUB spine order as described below.

Thank you for your time in reviewing this issue, and please let me know how I can make things clearer or provide testing/feedback in any useful way!

Summary

crengine's source page numbers ("reference pages") are computed from the EPUB page-list
under the assumption that the list is already in reading order. When a (spec-valid) page
list is not in spine order, the displayed page number freezes on the last in-order label
and does not advance until the reading position physically reaches the out-of-order target.

The engine actually resolves each page target to its true position and then discards that
information
by forcing the values to be monotonically non-decreasing in list order. The
current page is order-independent by definition, so this is a logic error, not a limitation
of the input.

Environment

  • crengine: master
  • Reproduced in KOReader (reference pages / "page N of M" footer). Apple Books, Readest, and other
    reading systems handle the same files correctly because they resolve target positions
    rather than trusting list order.

Steps to reproduce

Two minimal EPUBs are attached (both have had the extension changed to .zip due to file upload limitations). Both have an identical spine and content and differ only in the order of the <nav epub:type="page-list"> entries:

  • Spine (reading order): title(i) → ch1(1) → ch2(2) → ch3(3) → copyright(ii)
    i.e. a front-matter page (ii, on the copyright page) that the conversion placed at the
    back of the spine — a very common epub layout.
  • A_logical_order_FREEZES.epub — page list in logical/print order: i, ii, 1, 2, 3
  • B_reading_order_OK.epub — page list in reading/spine order: i, 1, 2, 3, ii (control)

Open each and page through it, watching the source page number.

B_reading_order_OK.zip
A_logical_order_FREEZES.zip

Expected

The current page label is the label of the nearest page-break at or before the reading
position. This is purely a function of each target's resolved position, so both files must
behave identically:

page 0 → i    page 1 → 1    page 2 → 2    page 3 → 3    page 4 → ii

B behaves exactly like this.

Actual (file A)

 label     target   true_page   used_page
     i      title           0           0
    ii  copyright           4           4
     1        ch1           1           4   <-- real position overwritten
     2        ch2           2           4   <-- real position overwritten
     3        ch3           3           4   <-- real position overwritten

rendered page 0 → 'i'
rendered page 1 → 'i'   (frozen; expected 1)
rendered page 2 → 'i'   (frozen; expected 2)
rendered page 3 → 'i'   (frozen; expected 3)
rendered page 4 → 'ii'

The page number sticks on the last correctly-placed label (i) through the entire body and
only "unfreezes" when the reading position reaches the out-of-order entry near the end.

Root cause

The page map is populated in XML/list order in crengine/src/epubfmt.cpp
(ReadEpubNavPageMap, and likewise ReadEpubNcxPageList / ReadEpubAdobePageMap): each
<li> is resolved to an ldomXPointer and appended via LVPageMap::addPage(), so
LVPageMap::_children is in list order. Each item keeps its resolved ldomXPointer, and
LVPageMapItem::getDocY() returns the item's true document Y from that pointer — so the
correct position is available.

The problem is in LVDocView::updatePageMapInfo() in crengine/src/lvdocview.cpp:

// Ensure page and doc_y never go backward
int prev_page = 0;
int prev_doc_y = 0;
for (int i = 0; i < pagemap->getChildCount(); i++) {
    LVPageMapItem * item = pagemap->getChild(i);
    if (!item->getXPointer().isNull()) {
        int doc_y = item->getDocY(true);          // true position (correct!)
        int page  = /* FindNearestPage(doc_y) ... */;
        item->_page = page;
        if ( item->_page < prev_page )            // <-- clamp in LIST order
            item->_page = prev_page;
        else
            prev_page = item->_page;
        if ( item->_doc_y < prev_doc_y )
            item->_doc_y = prev_doc_y;            // <-- same for doc_y
        else
            prev_doc_y = item->_doc_y;
    }
    else { item->_page = prev_page; item->_doc_y = prev_doc_y; }
}

The loop walks the items in list order and forces _page/_doc_y to never decrease. For any
entry whose true position is earlier than its list predecessor, the real value is replaced by
the predecessor's. One out-of-order entry (a front-matter page whose file sits at the back)
raises prev_page/prev_doc_y to a back-of-book value, and every later entry is clamped up
to it — which is the freeze.

Since epubcheck removed the reading-order requirement for the page list (fixes
epubcheck#1237) and the EPUB Accessibility guidance explicitly says a page list whose
front/back matter was shuffled during conversion should point to the equivalent location and
should not be renumbered, a page-list that is not in spine order is conformant input
that the engine currently mishandles.

Proposed fix

The monotonic invariant that downstream code relies on should be established from the
resolved positions, not from list order. Concretely, in updatePageMapInfo():

  1. First pass: assign every item its true doc_y and _page from its XPointer, with no
    clamping
    .
  2. Order the page map by resolved doc_y (a stable sort, so unresolved / equal-position
    entries keep their relative slot), then derive _page from the ordered positions.

Representative shape:

// pass 1: resolve true positions, no clamping
for (int i = 0; i < pagemap->getChildCount(); i++) {
    LVPageMapItem * item = pagemap->getChild(i);
    int doc_y = item->getXPointer().isNull() ? -1 : item->getDocY(true);
    item->setDocY(doc_y);
    int page = -1;
    if (doc_y >= 0) {
        page = m_pages.FindNearestPage(doc_y, 0);
        page = (page < 0 || page >= getPageCount(true)) ? -1 : getExternalPageNumber(page);
    }
    item->setPage(page);
}
// pass 2: order by resolved position instead of clamping in list order
pagemap->sortByDocY();   // new LVPageMap helper; stable sort on _children by _doc_y
// (optional) forward-fill only genuinely-unresolved (doc_y < 0) items from neighbours

LVPageMap::_children is private, so this needs a small sort helper on LVPageMap (it is a
friend of ldomDocument, and LVPtrVector already supports sorting with a comparator). A
comparator on _doc_y is enough.

Note this reorders the page-map, which is the "go to page" list shown to the user. For a
spec-conformant reading-order list this is a no-op; for a shuffled list it makes the jump
list follow reading order, which is the desired behaviour (jumping to a page lands at the
right spot).

Alternative, if reordering _children is undesirable: keep list order for the menu, drop the
clamp, and make the current-page lookup a position-based floor search (largest resolved
doc_y ≤ current position) instead of relying on the monotonic _page. Sorting is the
smaller change and fixes both the current-page display and page jumping at once.

Patch

A patch implementing the sorting approach is attached (crengine-pagelist-order-fix.patch).
It is crengine-only (no koreader-base/frontend change needed — the frontend keeps reading
getChild(i)/getPage()/getLabel(), now in reading order):

  • LVDocView::updatePageMapInfo() (lvdocview.cpp): split into resolve → sortByDocY()
    forward-fill, so positions are established before the "never go backward" step, which then
    only smooths equal/rounded positions instead of masking real reorderings.
  • LVPageMap::sortByDocY() + compareByDocY() (lvtinydom.h / lvtinydom.cpp): stable sort
    of _children by resolved _doc_y, tie-broken on original index to preserve page-list
    order for entries that share a position.

It applies cleanly against current master (git apply --check passes). One note: because
the page map is serialized in _children order, already-cached books get corrected on the
next re-render (when updatePageMapInfo is re-run on page-info invalidation), not
necessarily on first reopen from cache.

crengine-pagelist-order-fix.patch

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions