diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html
index 03d2d186a..856675891 100644
--- a/packages/app-expo/assets/reader/reader.html
+++ b/packages/app-expo/assets/reader/reader.html
@@ -343,17 +343,29 @@
}
}
- function getMobileReflowMaxInlineSize() {
- const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 720;
- const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 393;
- return Math.ceil(Math.max(720, viewportHeight, viewportWidth));
- }
-
function applyMobileReflowRendererSizing(renderer) {
if (!renderer) return;
renderer.setAttribute('max-column-count', '1');
renderer.setAttribute('max-block-size', '1440px');
- renderer.setAttribute('max-inline-size', getMobileReflowMaxInlineSize() + 'px');
+ }
+
+ // Mobile is always single-page (no spread), so there is no column gutter to
+ // balance — the foliate gap only contributes the page's internal side
+ // padding. Same derivation as the desktop reader: paper = 99% of the window
+ // (breathing room even at margin 0), outer text inset = margin + 0.5%·H.
+ // All against renderer.clientWidth (foliate resolves the gap % against it).
+ function applyMobileReflowMargins(renderer, margin) {
+ if (!renderer) return;
+ const windowWidth =
+ renderer.clientWidth || window.innerWidth || document.documentElement.clientWidth || 393;
+ const paper = windowWidth * 0.99;
+ const g = margin / paper;
+ renderer.setAttribute('margin-top', margin + 'px');
+ renderer.setAttribute('margin-bottom', margin + 'px');
+ renderer.setAttribute('margin-left', '0px');
+ renderer.setAttribute('margin-right', '0px');
+ renderer.setAttribute('gap', g * 100 + '%');
+ renderer.setAttribute('max-inline-size', paper - margin + g * windowWidth + 'px');
}
function applyRendererFlowMode(renderer) {
@@ -1639,10 +1651,12 @@
const rendererPages = view && view.renderer && typeof view.renderer.pages === 'number'
? view.renderer.pages
: null;
- const actualPage = rendererPage != null && rendererPages != null && rendererPages > 2
+ // renderer.page is 0-based and renderer.pages is the real page count:
+ // the renderer adds no padding pages around a section.
+ const actualPage = rendererPage != null && rendererPages != null && rendererPages > 0
? {
- current: Math.max(1, Math.min(rendererPage, rendererPages - 2)),
- total: Math.max(1, rendererPages - 2),
+ current: Math.min(Math.max(rendererPage + 1, 1), rendererPages),
+ total: rendererPages,
}
: null;
postToRN('relocate', {
@@ -1781,11 +1795,8 @@
const renderer = el.renderer;
if (renderer && !el.isFixedLayout) {
applyMobileReflowRendererSizing(renderer);
- // Default gap/margin
- const margin = msg.pageMargin || 16;
- const gapPercent = Math.max(1, Math.round((margin / 393) * 100));
- renderer.setAttribute('gap', gapPercent + '%');
- renderer.setAttribute('margin', margin + 'px');
+ // Default margins (synchronized with the desktop reader formula)
+ applyMobileReflowMargins(renderer, msg.pageMargin || 16);
} else if (renderer && el.isFixedLayout) {
const paginatedLayout =
msg.paginatedLayout === 'single' || msg.paginatedLayout === 'double'
@@ -2047,9 +2058,7 @@
const BASELINE_FONT_SIZE = 16;
const layoutScale = currentFontSize / BASELINE_FONT_SIZE;
const margin = Math.round(settings.pageMargin * layoutScale);
- const gapPercent = Math.max(1, Math.round((margin / 393) * 100));
- renderer.setAttribute('gap', gapPercent + '%');
- renderer.setAttribute('margin', margin + 'px');
+ applyMobileReflowMargins(renderer, margin);
}
if (view && view.isFixedLayout) {
@@ -5045,8 +5054,8 @@
diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html
index 8f0cf51f0..ebb03c00b 100644
--- a/packages/app-expo/assets/reader/reader.template.html
+++ b/packages/app-expo/assets/reader/reader.template.html
@@ -1604,10 +1604,12 @@
const rendererPages = view && view.renderer && typeof view.renderer.pages === 'number'
? view.renderer.pages
: null;
- const actualPage = rendererPage != null && rendererPages != null && rendererPages > 2
+ // renderer.page is 0-based and renderer.pages is the real page count:
+ // the renderer adds no padding pages around a section.
+ const actualPage = rendererPage != null && rendererPages != null && rendererPages > 0
? {
- current: Math.max(1, Math.min(rendererPage, rendererPages - 2)),
- total: Math.max(1, rendererPages - 2),
+ current: Math.min(Math.max(rendererPage + 1, 1), rendererPages),
+ total: rendererPages,
}
: null;
postToRN('relocate', {
diff --git a/packages/app-expo/src/screens/reader/chapter-page-numbering-contract.test.ts b/packages/app-expo/src/screens/reader/chapter-page-numbering-contract.test.ts
new file mode 100644
index 000000000..9ec731ee5
--- /dev/null
+++ b/packages/app-expo/src/screens/reader/chapter-page-numbering-contract.test.ts
@@ -0,0 +1,45 @@
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../..");
+
+function readSource(relativePath: string): string {
+ return readFileSync(resolve(repositoryRoot, relativePath), "utf8");
+}
+
+describe("chapter page numbering", () => {
+ it("maps the 0-based renderer page onto a 1-based page number on both platforms", () => {
+ const desktopViewer = readSource("packages/app/src/components/reader/FoliateViewer.tsx");
+ const template = readSource("packages/app-expo/assets/reader/reader.template.html");
+
+ for (const source of [desktopViewer, template]) {
+ expect(source).toMatch(
+ /current: Math\.min\(Math\.max\(rendererPage \+ 1, 1\), rendererPages\)/,
+ );
+ expect(source).toMatch(/total: rendererPages,/);
+ }
+ });
+
+ it("no longer assumes padding pages around a section", () => {
+ const desktopViewer = readSource("packages/app/src/components/reader/FoliateViewer.tsx");
+ const template = readSource("packages/app-expo/assets/reader/reader.template.html");
+
+ // renderer.pages is the real page count, so the old "minus one padding page on
+ // each side" conversion clamped the first and last page of every section.
+ for (const source of [desktopViewer, template]) {
+ expect(source).not.toContain("rendererPages - 2");
+ expect(source).toMatch(/rendererPages != null && rendererPages > 0/);
+ }
+ });
+
+ it("keeps the built reader in sync with the template", () => {
+ const builtReader = readSource("packages/app-expo/assets/reader/reader.html");
+
+ expect(builtReader).toMatch(
+ /current: Math\.min\(Math\.max\(rendererPage \+ 1, 1\), rendererPages\)/,
+ );
+ expect(builtReader).not.toContain("rendererPages - 2");
+ });
+});
diff --git a/packages/app/src/components/reader/FoliateViewer.tsx b/packages/app/src/components/reader/FoliateViewer.tsx
index 2ef4c5244..0e6b8c250 100644
--- a/packages/app/src/components/reader/FoliateViewer.tsx
+++ b/packages/app/src/components/reader/FoliateViewer.tsx
@@ -2060,13 +2060,15 @@ export const FoliateViewer = forwardRef
viewRef.current?.renderer && typeof viewRef.current.renderer.pages === "number"
? viewRef.current.renderer.pages
: null;
+ // renderer.page is 0-based and renderer.pages is the real page count:
+ // the renderer adds no padding pages around a section.
const detail: RelocateDetail =
- rendererPage != null && rendererPages != null && rendererPages > 2
+ rendererPage != null && rendererPages != null && rendererPages > 0
? {
...rawDetail,
page: {
- current: Math.max(1, Math.min(rendererPage, rendererPages - 2)),
- total: Math.max(1, rendererPages - 2),
+ current: Math.min(Math.max(rendererPage + 1, 1), rendererPages),
+ total: rendererPages,
},
}
: rawDetail;