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():
- First pass: assign every item its true
doc_y and _page from its XPointer, with no
clamping.
- 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
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-listunder 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
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
.zipdue 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: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 theback of the spine — a very common epub layout.
i, ii, 1, 2, 3i, 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:
B behaves exactly like this.
Actual (file A)
The page number sticks on the last correctly-placed label (
i) through the entire body andonly "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 likewiseReadEpubNcxPageList/ReadEpubAdobePageMap): each<li>is resolved to anldomXPointerand appended viaLVPageMap::addPage(), soLVPageMap::_childrenis in list order. Each item keeps its resolvedldomXPointer, andLVPageMapItem::getDocY()returns the item's true document Y from that pointer — so thecorrect position is available.
The problem is in
LVDocView::updatePageMapInfo()increngine/src/lvdocview.cpp:The loop walks the items in list order and forces
_page/_doc_yto never decrease. For anyentry 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_yto a back-of-book value, and every later entry is clamped upto 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-listthat is not in spine order is conformant inputthat 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():doc_yand_pagefrom its XPointer, with noclamping.
doc_y(a stable sort, so unresolved / equal-positionentries keep their relative slot), then derive
_pagefrom the ordered positions.Representative shape:
LVPageMap::_childrenis private, so this needs a small sort helper onLVPageMap(it is afriend of
ldomDocument, andLVPtrVectoralready supports sorting with a comparator). Acomparator on
_doc_yis 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
_childrenis undesirable: keep list order for the menu, drop theclamp, 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 thesmaller 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 readinggetChild(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 sortof
_childrenby resolved_doc_y, tie-broken on original index to preserve page-listorder for entries that share a position.
It applies cleanly against current
master(git apply --checkpasses). One note: becausethe page map is serialized in
_childrenorder, already-cached books get corrected on thenext re-render (when
updatePageMapInfois re-run on page-info invalidation), notnecessarily on first reopen from cache.
crengine-pagelist-order-fix.patch