feat(reader): reference page numbers from EPUB page-list with manual page count fallback (#4549)

Add a 'Reference Pages' reading progress style that shows physical book
page numbers in the footer progress info:

- When the book carries a page list (EPUB3 nav page-list or EPUB2 NCX
  pageList — foliate-js already parses both and resolves the current
  pageItem on relocate; it was just never consumed), display the current
  page label and use the highest numeric label as the total, so a
  trailing roman-numeral index page can't corrupt the total (#672).
- When the book has none, a per-book 'Reference Page Count' input
  appears; the reading fraction is mapped linearly onto the entered
  count (#4542). The count is saved per book only and never propagates
  to global view settings.
- Falls back to percentage display when neither source is available.

Verified with the sample books from #672: Caleb's Crossing (EPUB3
page-list, 419 pages) and Count Zero (EPUB2 NCX pageList/page-map,
346 pages — chapter 2 lands exactly on page 22 per its page-map), plus
a stripped no-pagelist copy for the manual-count path (175/350 at 50%).

Closes #672
Closes #4542

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-12 14:44:18 +08:00
committed by GitHub
parent ceddee3793
commit 9dc41e7adf
43 changed files with 340 additions and 38 deletions
@@ -1840,5 +1840,7 @@
"Start Delay": "تأخير البدء",
"Off": "إيقاف",
"Look up": "بحث",
"Search on Goodreads": "البحث في Goodreads"
"Search on Goodreads": "البحث في Goodreads",
"Reference Pages": "صفحات مرجعية",
"Reference Page Count": "عدد الصفحات المرجعية"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "শুরুর বিলম্ব",
"Off": "বন্ধ",
"Look up": "খুঁজুন",
"Search on Goodreads": "Goodreads-এ অনুসন্ধান করুন"
"Search on Goodreads": "Goodreads-এ অনুসন্ধান করুন",
"Reference Pages": "রেফারেন্স পৃষ্ঠা",
"Reference Page Count": "রেফারেন্স পৃষ্ঠার সংখ্যা"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "འགོ་འཛུགས་ཕྱིར་འགྱངས།",
"Off": "ཁ་རྒྱག",
"Look up": "འཚོལ་བ།",
"Search on Goodreads": "Goodreads ནང་འཚོལ།"
"Search on Goodreads": "Goodreads ནང་འཚོལ།",
"Reference Pages": "དཔེ་སྟོན་ཤོག་ངོས།",
"Reference Page Count": "དཔེ་སྟོན་ཤོག་ངོས་ཀྱི་གྲངས།"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "Startverzögerung",
"Off": "Aus",
"Look up": "Nachschlagen",
"Search on Goodreads": "Auf Goodreads suchen"
"Search on Goodreads": "Auf Goodreads suchen",
"Reference Pages": "Referenzseiten",
"Reference Page Count": "Anzahl der Referenzseiten"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "Καθυστέρηση έναρξης",
"Off": "Ανενεργό",
"Look up": "Αναζήτηση",
"Search on Goodreads": "Αναζήτηση στο Goodreads"
"Search on Goodreads": "Αναζήτηση στο Goodreads",
"Reference Pages": "Σελίδες αναφοράς",
"Reference Page Count": "Αριθμός σελίδων αναφοράς"
}
@@ -1741,5 +1741,7 @@
"Start Delay": "Retardo de inicio",
"Off": "Desactivado",
"Look up": "Buscar",
"Search on Goodreads": "Buscar en Goodreads"
"Search on Goodreads": "Buscar en Goodreads",
"Reference Pages": "Páginas de referencia",
"Reference Page Count": "Número de páginas de referencia"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "تأخیر شروع",
"Off": "خاموش",
"Look up": "جستجو",
"Search on Goodreads": "جستجو در Goodreads"
"Search on Goodreads": "جستجو در Goodreads",
"Reference Pages": "صفحات مرجع",
"Reference Page Count": "تعداد صفحات مرجع"
}
@@ -1741,5 +1741,7 @@
"Start Delay": "Délai de démarrage",
"Off": "Désactivé",
"Look up": "Rechercher",
"Search on Goodreads": "Rechercher sur Goodreads"
"Search on Goodreads": "Rechercher sur Goodreads",
"Reference Pages": "Pages de référence",
"Reference Page Count": "Nombre de pages de référence"
}
@@ -1741,5 +1741,7 @@
"Start Delay": "השהיית התחלה",
"Off": "כבוי",
"Look up": "חיפוש",
"Search on Goodreads": "חיפוש ב-Goodreads"
"Search on Goodreads": "חיפוש ב-Goodreads",
"Reference Pages": "עמודי ייחוס",
"Reference Page Count": "מספר עמודי ייחוס"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "आरंभ विलंब",
"Off": "बंद",
"Look up": "खोजें",
"Search on Goodreads": "Goodreads पर खोजें"
"Search on Goodreads": "Goodreads पर खोजें",
"Reference Pages": "संदर्भ पृष्ठ",
"Reference Page Count": "संदर्भ पृष्ठ संख्या"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "Indítási késleltetés",
"Off": "Ki",
"Look up": "Keresés",
"Search on Goodreads": "Keresés a Goodreadson"
"Search on Goodreads": "Keresés a Goodreadson",
"Reference Pages": "Referenciaoldalak",
"Reference Page Count": "Referenciaoldalak száma"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "Tunda mulai",
"Off": "Mati",
"Look up": "Cari",
"Search on Goodreads": "Cari di Goodreads"
"Search on Goodreads": "Cari di Goodreads",
"Reference Pages": "Halaman referensi",
"Reference Page Count": "Jumlah halaman referensi"
}
@@ -1741,5 +1741,7 @@
"Start Delay": "Ritardo di avvio",
"Off": "Disattivato",
"Look up": "Cerca",
"Search on Goodreads": "Cerca su Goodreads"
"Search on Goodreads": "Cerca su Goodreads",
"Reference Pages": "Pagine di riferimento",
"Reference Page Count": "Numero di pagine di riferimento"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "開始遅延",
"Off": "オフ",
"Look up": "調べる",
"Search on Goodreads": "Goodreadsで検索"
"Search on Goodreads": "Goodreadsで検索",
"Reference Pages": "参照ページ",
"Reference Page Count": "参照ページ数"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "시작 지연",
"Off": "끄기",
"Look up": "찾기",
"Search on Goodreads": "Goodreads에서 검색"
"Search on Goodreads": "Goodreads에서 검색",
"Reference Pages": "참조 페이지",
"Reference Page Count": "참조 페이지 수"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "Lengah mula",
"Off": "Mati",
"Look up": "Cari",
"Search on Goodreads": "Cari di Goodreads"
"Search on Goodreads": "Cari di Goodreads",
"Reference Pages": "Halaman rujukan",
"Reference Page Count": "Bilangan halaman rujukan"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "Startvertraging",
"Off": "Uit",
"Look up": "Opzoeken",
"Search on Goodreads": "Zoeken op Goodreads"
"Search on Goodreads": "Zoeken op Goodreads",
"Reference Pages": "Referentiepagina's",
"Reference Page Count": "Aantal referentiepagina's"
}
@@ -1774,5 +1774,7 @@
"Start Delay": "Opóźnienie startu",
"Off": "Wył.",
"Look up": "Wyszukaj",
"Search on Goodreads": "Szukaj w Goodreads"
"Search on Goodreads": "Szukaj w Goodreads",
"Reference Pages": "Strony referencyjne",
"Reference Page Count": "Liczba stron referencyjnych"
}
@@ -1741,5 +1741,7 @@
"Start Delay": "Atraso de início",
"Off": "Desligado",
"Look up": "Pesquisar",
"Search on Goodreads": "Pesquisar no Goodreads"
"Search on Goodreads": "Pesquisar no Goodreads",
"Reference Pages": "Páginas de referência",
"Reference Page Count": "Número de páginas de referência"
}
@@ -1741,5 +1741,7 @@
"Start Delay": "Atraso de início",
"Off": "Desligado",
"Look up": "Pesquisar",
"Search on Goodreads": "Pesquisar no Goodreads"
"Search on Goodreads": "Pesquisar no Goodreads",
"Reference Pages": "Páginas de referência",
"Reference Page Count": "Número de páginas de referência"
}
@@ -1741,5 +1741,7 @@
"Start Delay": "Întârziere la pornire",
"Off": "Dezactivat",
"Look up": "Caută",
"Search on Goodreads": "Caută pe Goodreads"
"Search on Goodreads": "Caută pe Goodreads",
"Reference Pages": "Pagini de referință",
"Reference Page Count": "Numărul paginilor de referință"
}
@@ -1774,5 +1774,7 @@
"Start Delay": "Задержка старта",
"Off": "Выкл.",
"Look up": "Найти",
"Search on Goodreads": "Искать в Goodreads"
"Search on Goodreads": "Искать в Goodreads",
"Reference Pages": "Страницы бумажной книги",
"Reference Page Count": "Число страниц бумажной книги"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "ආරම්භක ප්‍රමාදය",
"Off": "අක්‍රිය",
"Look up": "සොයන්න",
"Search on Goodreads": "Goodreads හි සොයන්න"
"Search on Goodreads": "Goodreads හි සොයන්න",
"Reference Pages": "යොමු පිටු",
"Reference Page Count": "යොමු පිටු ගණන"
}
@@ -1774,5 +1774,7 @@
"Start Delay": "Zakasnitev začetka",
"Off": "Izklopljeno",
"Look up": "Poišči",
"Search on Goodreads": "Išči v Goodreads"
"Search on Goodreads": "Išči v Goodreads",
"Reference Pages": "Referenčne strani",
"Reference Page Count": "Število referenčnih strani"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "Startfördröjning",
"Off": "Av",
"Look up": "Slå upp",
"Search on Goodreads": "Sök på Goodreads"
"Search on Goodreads": "Sök på Goodreads",
"Reference Pages": "Referenssidor",
"Reference Page Count": "Antal referenssidor"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "தொடக்க தாமதம்",
"Off": "அணை",
"Look up": "தேடு",
"Search on Goodreads": "Goodreads இல் தேடு"
"Search on Goodreads": "Goodreads இல் தேடு",
"Reference Pages": "குறிப்புப் பக்கங்கள்",
"Reference Page Count": "குறிப்புப் பக்கங்களின் எண்ணிக்கை"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "หน่วงเวลาเริ่มต้น",
"Off": "ปิด",
"Look up": "ค้นหา",
"Search on Goodreads": "ค้นหาใน Goodreads"
"Search on Goodreads": "ค้นหาใน Goodreads",
"Reference Pages": "หน้าอ้างอิง",
"Reference Page Count": "จำนวนหน้าอ้างอิง"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "Başlangıç gecikmesi",
"Off": "Kapalı",
"Look up": "Ara",
"Search on Goodreads": "Goodreads'te ara"
"Search on Goodreads": "Goodreads'te ara",
"Reference Pages": "Referans sayfaları",
"Reference Page Count": "Referans sayfa sayısı"
}
@@ -1774,5 +1774,7 @@
"Start Delay": "Затримка старту",
"Off": "Вимк.",
"Look up": "Знайти",
"Search on Goodreads": "Шукати в Goodreads"
"Search on Goodreads": "Шукати в Goodreads",
"Reference Pages": "Сторінки паперової книги",
"Reference Page Count": "Кількість сторінок паперової книги"
}
@@ -1708,5 +1708,7 @@
"Start Delay": "Boshlash kechikishi",
"Off": "Oʻchiq",
"Look up": "Qidirish",
"Search on Goodreads": "Goodreads'da qidirish"
"Search on Goodreads": "Goodreads'da qidirish",
"Reference Pages": "Bosma sahifalar",
"Reference Page Count": "Bosma sahifalar soni"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "Độ trễ bắt đầu",
"Off": "Tắt",
"Look up": "Tra cứu",
"Search on Goodreads": "Tìm trên Goodreads"
"Search on Goodreads": "Tìm trên Goodreads",
"Reference Pages": "Trang tham chiếu",
"Reference Page Count": "Số trang tham chiếu"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "开始延迟",
"Off": "关闭",
"Look up": "查询",
"Search on Goodreads": "在 Goodreads 中搜索"
"Search on Goodreads": "在 Goodreads 中搜索",
"Reference Pages": "参考页码",
"Reference Page Count": "参考总页数"
}
@@ -1675,5 +1675,7 @@
"Start Delay": "開始延遲",
"Off": "關閉",
"Look up": "查詢",
"Search on Goodreads": "在 Goodreads 搜尋"
"Search on Goodreads": "在 Goodreads 搜尋",
"Reference Pages": "參考頁碼",
"Reference Page Count": "參考總頁數"
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest';
import { getReferencePageInfo } from '@/utils/progress';
const makePageList = (labels: string[]) => labels.map((label) => ({ label, href: '#' }));
describe('getReferencePageInfo', () => {
describe('with a page list from the book (issue #672)', () => {
it('uses the current page item label and the highest numeric label as total', () => {
const pageList = makePageList(['1', '2', '3', '4', '5']);
const info = getReferencePageInfo({
pageList,
pageItem: { label: '3' },
fraction: 0.5,
referencePageCount: 0,
});
expect(info).toEqual({ current: '3', total: 5 });
});
it('keeps non-numeric current labels (front matter roman numerals) as-is', () => {
const pageList = makePageList(['i', 'ii', '1', '2', '3']);
const info = getReferencePageInfo({
pageList,
pageItem: { label: 'ii' },
fraction: 0.1,
referencePageCount: 0,
});
expect(info).toEqual({ current: 'ii', total: 3 });
});
it('ignores trailing non-numeric labels when computing the total', () => {
// Reported in #672: a book ends with an index page labeled "XII" after
// page 553 — the total must stay 553, not "XII".
const pageList = makePageList(['551', '552', '553', 'XII']);
const info = getReferencePageInfo({
pageList,
pageItem: { label: '552' },
fraction: 0.6,
referencePageCount: 0,
});
expect(info).toEqual({ current: '552', total: 553 });
});
it('falls back to the entry count when no label is numeric', () => {
const pageList = makePageList(['i', 'ii', 'iii']);
const info = getReferencePageInfo({
pageList,
pageItem: { label: 'iii' },
fraction: 0.9,
referencePageCount: 0,
});
expect(info).toEqual({ current: 'iii', total: 3 });
});
it('counts pages in nested subitems', () => {
const pageList = [
{ label: '1', href: '#', subitems: makePageList(['2', '3']) },
{ label: '4', href: '#' },
];
const info = getReferencePageInfo({
pageList,
pageItem: { label: '2' },
fraction: 0.3,
referencePageCount: 0,
});
expect(info).toEqual({ current: '2', total: 4 });
});
it('estimates the current page linearly when no page item is resolved yet', () => {
// e.g. on the cover, before the first page anchor
const pageList = makePageList(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']);
const info = getReferencePageInfo({
pageList,
pageItem: null,
fraction: 0.45,
referencePageCount: 0,
});
expect(info).toEqual({ current: '5', total: 10 });
});
it('prefers the page list over a user-entered page count', () => {
const pageList = makePageList(['1', '2', '3']);
const info = getReferencePageInfo({
pageList,
pageItem: { label: '2' },
fraction: 0.5,
referencePageCount: 999,
});
expect(info).toEqual({ current: '2', total: 3 });
});
});
describe('with a user-entered page count (issue #4542)', () => {
it('maps the reading fraction linearly onto the page count', () => {
const info = getReferencePageInfo({
pageList: null,
pageItem: null,
fraction: 0.5,
referencePageCount: 350,
});
expect(info).toEqual({ current: '175', total: 350 });
});
it('shows page 1 at the start of the book', () => {
const info = getReferencePageInfo({
pageList: undefined,
pageItem: null,
fraction: 0,
referencePageCount: 350,
});
expect(info).toEqual({ current: '1', total: 350 });
});
it('shows the last page at the end of the book', () => {
const info = getReferencePageInfo({
pageList: [],
pageItem: null,
fraction: 1,
referencePageCount: 350,
});
expect(info).toEqual({ current: '350', total: 350 });
});
});
it('returns null when the book has no page list and no page count is set', () => {
expect(
getReferencePageInfo({
pageList: null,
pageItem: null,
fraction: 0.5,
referencePageCount: 0,
}),
).toBeNull();
});
});
@@ -149,6 +149,7 @@ const FoliateViewer: React.FC<{
bookKey,
detail.cfi,
detail.tocItem,
detail.pageItem,
detail.section,
pageInfo,
detail.time,
@@ -6,7 +6,7 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { formatNumber, formatProgress } from '@/utils/progress';
import { formatNumber, formatProgress, getReferencePageInfo } from '@/utils/progress';
import { saveViewSettings } from '@/helpers/settings';
import { eventDispatcher } from '@/utils/event';
import { SIZE_PER_LOC, SIZE_PER_TIME_UNIT } from '@/services/constants';
@@ -51,7 +51,18 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
const lang = localStorage?.getItem('i18nextLng') || '';
const localize = isVertical && lang.toLowerCase().startsWith('zh');
const pageInfo = bookData?.isFixedLayout ? section : pageinfo;
const progressInfo = formatProgress(pageInfo?.current, pageInfo?.total, template, localize, lang);
const referenceInfo =
readingProgressStyle === 'reference'
? getReferencePageInfo({
pageList: bookData?.bookDoc?.pageList,
pageItem: progress?.pageItem,
fraction: pageInfo && pageInfo.total > 0 ? (pageInfo.current + 1) / pageInfo.total : 0,
referencePageCount: viewSettings.referencePageCount,
})
: null;
const progressInfo = referenceInfo
? `${referenceInfo.current}${isVertical ? ' · ' : ' / '}${referenceInfo.total}`
: formatProgress(pageInfo?.current, pageInfo?.total, template, localize, lang);
const { page: current = 0, pages: total = 0 } = view?.renderer || {};
const pagesLeft = bookData?.isFixedLayout
@@ -7,7 +7,7 @@ import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { formatProgress } from '@/utils/progress';
import { formatProgress, getReferencePageInfo } from '@/utils/progress';
import type { FooterBarChildProps } from './types';
import { getNavigationIcon } from './utils';
import Button from '@/components/Button';
@@ -38,7 +38,18 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
const { section, pageinfo } = progress || {};
const template = progressStyle === 'fraction' ? '{current} / {total}' : '{percent}%';
const pageInfo = bookData?.isFixedLayout ? section : pageinfo;
const progressInfo = formatProgress(pageInfo?.current, pageInfo?.total, template, false, 'en', 0);
const referenceInfo =
progressStyle === 'reference'
? getReferencePageInfo({
pageList: bookData?.bookDoc?.pageList,
pageItem: progress?.pageItem,
fraction: progressFraction,
referencePageCount: viewSettings?.referencePageCount,
})
: null;
const progressInfo = referenceInfo
? `${referenceInfo.current} / ${referenceInfo.total}`
: formatProgress(pageInfo?.current, pageInfo?.total, template, false, 'en', 0);
const rangeInputRef = useRef<HTMLInputElement>(null);
@@ -85,6 +85,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
);
const [tapToToggleFooter, setTapToToggleFooter] = useState(viewSettings.tapToToggleFooter);
const [progressStyle, setProgressStyle] = useState(viewSettings.progressStyle);
const [referencePageCount, setReferencePageCount] = useState(viewSettings.referencePageCount);
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation);
const resetToDefaults = useResetViewSettings();
@@ -390,6 +391,11 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progressStyle]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'referencePageCount', referencePageCount, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [referencePageCount]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'tapToToggleFooter', tapToToggleFooter, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -718,10 +724,22 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
options={[
{ value: 'fraction', label: _('Page Number') },
{ value: 'percentage', label: _('Percentage') },
{ value: 'reference', label: _('Reference Pages') },
]}
disabled={!showProgressInfo}
/>
</SettingsRow>
{progressStyle === 'reference' && !bookData?.bookDoc?.pageList?.length && (
<NumberInput
label={_('Reference Page Count')}
value={referencePageCount}
onChange={setReferencePageCount}
disabled={!showProgressInfo}
min={0}
max={10000}
data-setting-id='settings.layout.referencePageCount'
/>
)}
<SettingsSwitchRow
label={_('Show Current Time')}
checked={showCurrentTime}
+1
View File
@@ -84,6 +84,7 @@ export interface BookDoc {
};
dir: string;
toc?: Array<TOCItem>;
pageList?: Array<TOCItem>;
sections: Array<SectionItem>;
transformTarget?: EventTarget;
splitTOCHref(href: string): Array<string | number>;
@@ -339,6 +339,7 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
tapToToggleFooter: false,
showPaginationButtons: false,
progressStyle: 'fraction',
referencePageCount: 0,
progressInfoMode: 'all',
animated: false,
@@ -68,6 +68,7 @@ interface ReaderStore {
key: string,
location: string,
tocItem: TOCItem,
pageItem: BookProgress['pageItem'],
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
@@ -361,6 +362,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
key: string,
location: string,
tocItem: TOCItem,
pageItem: BookProgress['pageItem'],
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
@@ -417,6 +419,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
location,
sectionHref: tocItem?.href,
sectionLabel: tocItem?.label,
pageItem,
section,
pageinfo,
timeinfo,
+3 -1
View File
@@ -273,7 +273,8 @@ export interface ViewConfig {
showBatteryPercentage: boolean;
tapToToggleFooter: boolean;
showPaginationButtons: boolean;
progressStyle: 'percentage' | 'fraction';
progressStyle: 'percentage' | 'fraction' | 'reference';
referencePageCount: number;
progressInfoMode: ProgressBarMode;
animated: boolean;
@@ -378,6 +379,7 @@ export interface BookProgress {
sectionLabel: string;
section: PageInfo;
pageinfo: PageInfo;
pageItem?: { label?: string; href?: string } | null;
timeinfo: TimeInfo;
index: number;
range: Range;
+54
View File
@@ -25,6 +25,60 @@ export function formatProgress(
}
}
export interface ReferencePageItem {
label?: string;
subitems?: ReferencePageItem[] | null;
}
export interface ReferencePageInfo {
current: string;
total: number;
}
const collectLabels = (items: ReferencePageItem[]): string[] =>
items.flatMap((item) => [
item.label?.trim() ?? '',
...(item.subitems?.length ? collectLabels(item.subitems) : []),
]);
const estimatePage = (fraction: number, total: number) =>
Math.min(total, Math.max(1, Math.ceil(fraction * total)));
/**
* Resolve the physical-book page info shown by the 'reference' progress style.
* Prefers the book's own page list (EPUB page-list nav / NCX pageList); when
* the book has none, maps the reading fraction linearly onto a user-entered
* page count. Returns null when neither source is available.
*/
export function getReferencePageInfo({
pageList,
pageItem,
fraction,
referencePageCount,
}: {
pageList?: ReferencePageItem[] | null;
pageItem?: ReferencePageItem | null;
fraction: number;
referencePageCount?: number;
}): ReferencePageInfo | null {
if (pageList?.length) {
const labels = collectLabels(pageList).filter(Boolean);
// The last entries may be non-numeric (e.g. a roman-numeral index page),
// so the total is the highest numeric label, not the last one.
const numericLabels = labels.filter((label) => /^\d+$/.test(label));
const total = numericLabels.length ? Math.max(...numericLabels.map(Number)) : labels.length;
const current = pageItem?.label?.trim() || String(estimatePage(fraction, total));
return { current, total };
}
if (referencePageCount && referencePageCount > 0) {
return {
current: String(estimatePage(fraction, referencePageCount)),
total: referencePageCount,
};
}
return null;
}
export function formatNumber(
number: number | undefined,
localize: boolean = false,