diff --git a/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx b/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx
index 1db462bc..b2e86233 100644
--- a/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx
+++ b/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx
@@ -48,6 +48,25 @@ const makeLineRects = (count: number, pitch: number, height: number): RulerTestR
height,
}));
+// A relocate range whose client rects are three vertical text columns (tall thin
+// strips) in iframe-content coordinates. No frameElement -> rects map straight to
+// overlay coordinates. Columns are listed right-to-left (reading order for
+// vertical-rl), but the builder derives order from geometry + the rtl flag.
+const makeVerticalColumnsRange = (): {
+ getClientRects: () => RulerTestRect[];
+ startContainer: object;
+} => {
+ const columnRects: RulerTestRect[] = [
+ { top: 50, bottom: 950, left: 760, right: 776, width: 16, height: 900 }, // rightmost column
+ { top: 50, bottom: 950, left: 400, right: 416, width: 16, height: 900 }, // middle column
+ { top: 50, bottom: 950, left: 40, right: 56, width: 16, height: 900 }, // leftmost column
+ ];
+ return {
+ startContainer: { ownerDocument: { defaultView: {} } },
+ getClientRects: () => columnRects,
+ };
+};
+
// A single visible section whose iframe is offset by `frameTop` along the scroll
// axis (negative = scrolled down). `buildScrolledLineBoxes` walks these contents.
const makeScrolledContents = (
@@ -299,6 +318,89 @@ describe('ReadingRuler', () => {
expect(rulerTop()).toBeCloseTo(initialTop, 5);
});
+ // Regression: issue #4865 — for vertical-rl (Japanese vertical) books columns
+ // progress right-to-left, so advancing the ruler forward must move the band to
+ // the LEFT. It used to run backwards because rtl was false for vertical-rl.
+ it('advances the vertical-rl ruler band to the left on a forward move', async () => {
+ mockProgress = { range: makeVerticalColumnsRange(), pageinfo: { current: 0 } };
+ const verticalSettings = { ...viewSettings } as ViewSettings;
+
+ const { container } = render(
+ ,
+ );
+ const rulerLeft = () =>
+ parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.left);
+
+ // Mount snaps the band onto the first (rightmost) column, near the right edge.
+ let before = 0;
+ await waitFor(() => {
+ before = rulerLeft();
+ expect(before).toBeGreaterThan(80);
+ });
+
+ const consumed = eventDispatcher.dispatchSync('reading-ruler-move', {
+ bookKey: 'book-1',
+ direction: 'forward',
+ });
+
+ expect(consumed).toBe(true);
+ // Forward => next column to the left => band's left percentage decreases.
+ await waitFor(() => {
+ expect(rulerLeft()).toBeLessThan(before);
+ });
+ });
+
+ // The vertical-lr (Mongolian) counterpart moves the other way: forward => right.
+ it('advances the vertical-lr ruler band to the right on a forward move', async () => {
+ mockProgress = { range: makeVerticalColumnsRange(), pageinfo: { current: 0 } };
+ const verticalSettings = { ...viewSettings } as ViewSettings;
+
+ const { container } = render(
+ ,
+ );
+ const rulerLeft = () =>
+ parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.left);
+
+ // Mount snaps the band onto the first (leftmost) column, near the left edge.
+ let before = 100;
+ await waitFor(() => {
+ before = rulerLeft();
+ expect(before).toBeLessThan(20);
+ });
+
+ const consumed = eventDispatcher.dispatchSync('reading-ruler-move', {
+ bookKey: 'book-1',
+ direction: 'forward',
+ });
+
+ expect(consumed).toBe(true);
+ await waitFor(() => {
+ expect(rulerLeft()).toBeGreaterThan(before);
+ });
+ });
+
it('still snaps the ruler to lines when advancing by click in scrolled mode', async () => {
const lineRects = makeLineRects(50, 100, 40);
mockProgress = { range: {}, pageinfo: { current: 0 } };
diff --git a/apps/readest-app/src/__tests__/libs/document.test.ts b/apps/readest-app/src/__tests__/libs/document.test.ts
index 3f49af39..0fc4d949 100644
--- a/apps/readest-app/src/__tests__/libs/document.test.ts
+++ b/apps/readest-app/src/__tests__/libs/document.test.ts
@@ -1,7 +1,7 @@
-import { describe, it, expect, vi } from 'vitest';
+import { afterEach, describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
-import { DocumentLoader } from '@/libs/document';
+import { DocumentLoader, getDirection } from '@/libs/document';
if (typeof globalThis['CSS'] === 'undefined') {
(globalThis as Record)['CSS'] = {
@@ -94,3 +94,34 @@ describe('DocumentLoader.open', () => {
expect(result.format).toBe('EPUB');
}, 15000);
});
+
+describe('getDirection', () => {
+ afterEach(() => {
+ document.body.removeAttribute('style');
+ document.body.removeAttribute('dir');
+ document.documentElement.removeAttribute('dir');
+ document.body.innerHTML = '';
+ });
+
+ it('treats vertical-rl (Japanese vertical) as RTL so columns advance right-to-left', () => {
+ // vertical-rl reads top-to-bottom with columns progressing right-to-left, but
+ // its computed CSS `direction` stays `ltr`; the writing mode alone marks it RTL.
+ document.body.style.writingMode = 'vertical-rl';
+ expect(getDirection(document)).toEqual({ vertical: true, rtl: true });
+ });
+
+ it('treats vertical-lr (Mongolian) as vertical but left-to-right', () => {
+ document.body.style.writingMode = 'vertical-lr';
+ expect(getDirection(document)).toEqual({ vertical: true, rtl: false });
+ });
+
+ it('leaves horizontal-tb as neither vertical nor RTL', () => {
+ document.body.style.writingMode = 'horizontal-tb';
+ expect(getDirection(document)).toEqual({ vertical: false, rtl: false });
+ });
+
+ it('still honors an explicit rtl dir for horizontal text', () => {
+ document.body.setAttribute('dir', 'rtl');
+ expect(getDirection(document)).toEqual({ vertical: false, rtl: true });
+ });
+});
diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts
index c09c8b11..f2df9d6e 100644
--- a/apps/readest-app/src/libs/document.ts
+++ b/apps/readest-app/src/libs/document.ts
@@ -452,7 +452,14 @@ export const getDirection = (doc: Document) => {
}
}
const vertical = writingMode === 'vertical-rl' || writingMode === 'vertical-lr';
- const rtl = doc.body.dir === 'rtl' || direction === 'rtl' || doc.documentElement.dir === 'rtl';
+ // `vertical-rl` (Japanese/Chinese vertical) advances columns right-to-left even
+ // though its computed `direction` stays `ltr`, so the writing mode itself marks
+ // it RTL. Without this the reading ruler and page turns run backwards (#4865).
+ const rtl =
+ writingMode === 'vertical-rl' ||
+ doc.body.dir === 'rtl' ||
+ direction === 'rtl' ||
+ doc.documentElement.dir === 'rtl';
return { vertical, rtl };
};