Files
readest/apps/readest-app/src/__tests__/utils/readingRuler.test.ts
T
Huang Xin 789d031222 feat(reader): line-aware reading ruler (#4358)
Snap the reading ruler to real rendered text lines instead of stepping by a
fixed arithmetic height, so the band always frames whole lines.

- Snap to actual line geometry from the relocate range; the band is sized
  dynamically to the text block plus symmetric padding (round(fontSize *
  lineHeight * 0.3)), capped at (lines + 1) line heights so a tall image inside
  a block can't expand it to cover the whole figure.
- Drop block/container rects (Range.getClientRects aggregates multi-line element
  borders) so paragraphs aren't merged into one giant line and skipped.
- Column-aware in multi-column layouts: the band spans one column at a time and
  advances column by column.
- Confine the band to lines at least half visible within the viewport.
- Scrolled mode: snap to lines, and at a view edge scroll the view and realign
  the band to the start/end of the new view (works for vertical-rl too, which
  scrolls horizontally); paging snaps the view edge between lines so text isn't
  cut or repeated.
- Vertical writing mode: correct band centering and drag direction; Up/Down keys
  move the ruler while Left/Right turn pages (taps always move the ruler).
- Page turns keep the first/last line: forward lands on the first line of the new
  page, backward on the last line; the relayout re-snap anchors on the band's
  leading edge so it never skips a line.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:16:53 +02:00

376 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildLineBoxes,
buildReadingRulerColumns,
calculateReadingRulerPadding,
calculateReadingRulerSize,
clampReadingRulerPosition,
FIXED_LAYOUT_READING_RULER_LINE_HEIGHT,
filterVisibleLineBoxes,
getReadingRulerMoveDirection,
isReadingRulerMoveKey,
snapReadingRulerColumns,
snapReadingRulerToLines,
stepReadingRulerPosition,
} from '@/app/reader/utils/readingRuler';
describe('readingRuler utils', () => {
it('calculates fixed-layout ruler size from the configured line count', () => {
expect(
calculateReadingRulerSize(
3,
{
defaultFontSize: 16,
lineHeight: 1.5,
},
'PDF',
),
).toBe(3 * FIXED_LAYOUT_READING_RULER_LINE_HEIGHT);
});
it('calculates reflowable ruler size from font size and line height', () => {
expect(
calculateReadingRulerSize(
2,
{
defaultFontSize: 18,
lineHeight: 1.4,
},
'EPUB',
),
).toBe(50);
});
it('clamps the ruler center so the full ruler stays inside the viewport', () => {
expect(clampReadingRulerPosition(5, 1000, 200)).toBe(10);
expect(clampReadingRulerPosition(95, 1000, 200)).toBe(90);
expect(clampReadingRulerPosition(33, 1000, 200)).toBe(33);
});
it('centers the ruler when it is larger than the viewport', () => {
expect(clampReadingRulerPosition(10, 100, 120)).toBe(50);
expect(clampReadingRulerPosition(90, 100, 120)).toBe(50);
});
it('moves by exactly one ruler window per navigation step', () => {
expect(stepReadingRulerPosition(33, 1000, 200, 'forward')).toBe(53);
expect(stepReadingRulerPosition(33, 1000, 200, 'backward')).toBe(13);
});
it('clamps stepped movement at the edges', () => {
expect(stepReadingRulerPosition(85, 1000, 200, 'forward')).toBe(90);
expect(stepReadingRulerPosition(15, 1000, 200, 'backward')).toBe(10);
});
it('maps tap and key sides to logical ruler movement direction', () => {
expect(getReadingRulerMoveDirection('right', 'ltr')).toBe('forward');
expect(getReadingRulerMoveDirection('left', 'ltr')).toBe('backward');
expect(getReadingRulerMoveDirection('right', 'rtl')).toBe('backward');
expect(getReadingRulerMoveDirection('left', 'rtl')).toBe('forward');
expect(getReadingRulerMoveDirection('down', 'rtl')).toBe('forward');
expect(getReadingRulerMoveDirection('up', 'ltr')).toBe('backward');
});
it('restricts arrow-key ruler movement to up/down in vertical layout', () => {
// Vertical: only up/down move the ruler; left/right turn pages.
expect(isReadingRulerMoveKey('up', true)).toBe(true);
expect(isReadingRulerMoveKey('down', true)).toBe(true);
expect(isReadingRulerMoveKey('left', true)).toBe(false);
expect(isReadingRulerMoveKey('right', true)).toBe(false);
// Horizontal: every arrow moves the ruler.
expect(isReadingRulerMoveKey('up', false)).toBe(true);
expect(isReadingRulerMoveKey('left', false)).toBe(true);
expect(isReadingRulerMoveKey('right', false)).toBe(true);
});
it('pads each side by 0.3 of a line height', () => {
// round(16 * 1.5 * 0.3) = 7
expect(calculateReadingRulerPadding({ defaultFontSize: 16, lineHeight: 1.5 }, 'EPUB')).toBe(7);
// fixed-layout: round(28 * 0.3) = 8
expect(calculateReadingRulerPadding({ defaultFontSize: 16, lineHeight: 1.5 }, 'PDF')).toBe(
Math.round(FIXED_LAYOUT_READING_RULER_LINE_HEIGHT * 0.3),
);
});
});
type RectLike = {
top: number;
bottom: number;
left: number;
right: number;
width: number;
height: number;
};
const rect = (top: number, left: number, height: number, width: number): RectLike => ({
top,
left,
bottom: top + height,
right: left + width,
height,
width,
});
const container = { top: 0, left: 0, right: 300, bottom: 400 };
describe('buildLineBoxes', () => {
it('clusters horizontal fragments into one box per visual line', () => {
const rects = [
rect(0, 10, 16, 50), // line 1, fragment A
rect(0, 60, 16, 40), // line 1, fragment B (same vertical band)
rect(20, 10, 16, 80), // line 2
];
expect(buildLineBoxes(rects, false, false, container)).toEqual([
{ start: 0, end: 16 },
{ start: 20, end: 36 },
]);
});
it('ignores zero-size rects', () => {
const rects = [rect(0, 10, 16, 50), rect(20, 0, 0, 0), rect(40, 10, 16, 50)];
expect(buildLineBoxes(rects, false, false, container)).toEqual([
{ start: 0, end: 16 },
{ start: 40, end: 56 },
]);
});
it('returns sorted boxes even when rects are out of order', () => {
const rects = [rect(40, 10, 16, 50), rect(0, 10, 16, 50), rect(20, 10, 16, 50)];
expect(buildLineBoxes(rects, false, false, container).map((b) => b.start)).toEqual([0, 20, 40]);
});
it('maps vertical-rl columns as distance from the right edge', () => {
// container.right = 300; a column at left=260,right=276 -> [300-276, 300-260] = [24, 40]
const rects = [rect(0, 260, 200, 16)];
expect(buildLineBoxes(rects, true, true, container)).toEqual([{ start: 24, end: 40 }]);
});
it('maps vertical-lr columns as distance from the left edge', () => {
const rects = [rect(0, 24, 200, 16)];
expect(buildLineBoxes(rects, true, false, container)).toEqual([{ start: 24, end: 40 }]);
});
it('drops multi-line block rects so paragraphs are not merged into one box', () => {
// Range.getClientRects() includes the <p> border box (h=160) alongside its
// 4 line boxes; the tall block rect must be discarded, not merged.
const rects = [
rect(0, 10, 16, 400),
rect(20, 10, 16, 400),
rect(40, 10, 16, 400),
rect(60, 10, 16, 400),
rect(0, 10, 160, 400), // the paragraph block box
];
expect(buildLineBoxes(rects, false, false, container)).toEqual([
{ start: 0, end: 16 },
{ start: 20, end: 36 },
{ start: 40, end: 56 },
{ start: 60, end: 76 },
]);
});
});
describe('snapReadingRulerToLines', () => {
// 10 lines, each 16px tall, 20px apart, starting at 0.
const evenBoxes = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180].map((s) => ({
start: s,
end: s + 16,
}));
it('returns null when there are no line boxes', () => {
expect(snapReadingRulerToLines(0, 36, 2, 'forward', [])).toBeNull();
});
it('returns the next block of N lines as { start, end }', () => {
// current block covers lines[0..1] => [0,36]; next group is lines[2..3] => [40,76].
expect(snapReadingRulerToLines(0, 36, 2, 'forward', evenBoxes)).toEqual({ start: 40, end: 76 });
});
it('returns the previous block of N lines when moving backward', () => {
// current block covers lines[2..3] => [40,76]; previous group is lines[0..1] => [0,36].
expect(snapReadingRulerToLines(40, 76, 2, 'backward', evenBoxes)).toEqual({
start: 0,
end: 36,
});
});
it('returns the first group from the top with -Infinity', () => {
expect(snapReadingRulerToLines(-Infinity, -Infinity, 2, 'forward', evenBoxes)).toEqual({
start: 0,
end: 36,
});
});
it('returns the last group from the bottom with Infinity', () => {
expect(snapReadingRulerToLines(Infinity, Infinity, 2, 'backward', evenBoxes)).toEqual({
start: 160,
end: 196,
});
});
it('returns null at the bottom boundary so the page can flip', () => {
const boxes = [
{ start: 0, end: 16 },
{ start: 20, end: 36 },
];
expect(snapReadingRulerToLines(0, 36, 2, 'forward', boxes)).toBeNull();
});
it('returns null at the top boundary so the page can flip', () => {
const boxes = [
{ start: 80, end: 96 },
{ start: 100, end: 116 },
];
expect(snapReadingRulerToLines(80, 116, 2, 'backward', boxes)).toBeNull();
});
it('re-snapping forward from a block start stays on the same block (no skip)', () => {
// Anchoring the re-snap at the band's leading edge must return the same block,
// not advance one line. (Anchoring at the band CENTER, which sits inside a line,
// would skip to the next line — the page-turn "first line skipped" bug.)
const blockStart = 40; // lines[2..3] => [40,76]
expect(snapReadingRulerToLines(blockStart, blockStart, 2, 'forward', evenBoxes)).toEqual({
start: 40,
end: 76,
});
// A center anchor (sitting inside lines[2]) advances to the next group — the bug.
const center = 48; // inside lines[2] (40..56)
expect(snapReadingRulerToLines(center, center, 2, 'forward', evenBoxes)).toEqual({
start: 60,
end: 96,
});
});
});
describe('filterVisibleLineBoxes', () => {
it('keeps lines at least half visible within the viewport and drops the rest', () => {
const boxes = [
{ start: 0, end: 16 }, // fully visible
{ start: 190, end: 206 }, // 10/16 visible at the bottom edge -> kept
{ start: 196, end: 212 }, // 4/16 visible -> dropped
{ start: -8, end: 8 }, // 8/16 visible at the top edge -> kept
{ start: -12, end: 4 }, // 4/16 visible -> dropped
];
expect(filterVisibleLineBoxes(boxes, 200)).toEqual([
{ start: 0, end: 16 },
{ start: 190, end: 206 },
{ start: -8, end: 8 },
]);
});
it('returns all line boxes when the dimension is unknown', () => {
const boxes = [{ start: 500, end: 516 }];
expect(filterVisibleLineBoxes(boxes, 0)).toEqual(boxes);
});
});
describe('buildReadingRulerColumns', () => {
// Two columns in a 1000px-wide overlay: col0 centers < 500, col1 centers >= 500.
const colRect = (top: number, left: number): RectLike => rect(top, left, 16, 420);
it('splits rects into columns by x and lines by y', () => {
const rects = [colRect(0, 40), colRect(20, 40), colRect(0, 540), colRect(20, 540)];
const cols = buildReadingRulerColumns(rects, 2, 1000, false);
expect(cols).toEqual([
{
left: 40,
right: 460,
lines: [
{ start: 0, end: 16 },
{ start: 20, end: 36 },
],
},
{
left: 540,
right: 960,
lines: [
{ start: 0, end: 16 },
{ start: 20, end: 36 },
],
},
]);
});
it('orders columns right-to-left when rtl', () => {
const rects = [colRect(0, 40), colRect(0, 540)];
const cols = buildReadingRulerColumns(rects, 2, 1000, true);
expect(cols.map((c) => c.left)).toEqual([540, 40]);
});
it('returns a single full-width column when columnCount is 1', () => {
const rects = [colRect(0, 40), colRect(0, 540)];
const cols = buildReadingRulerColumns(rects, 1, 1000, false);
expect(cols).toHaveLength(1);
expect(cols[0]!.lines).toHaveLength(1); // both rects share top 0 -> one line
});
it('drops multi-line block rects inside a column', () => {
const rects = [
colRect(0, 40),
colRect(20, 40),
colRect(40, 40),
colRect(60, 40),
rect(0, 40, 160, 420), // paragraph block box in column 0
];
const cols = buildReadingRulerColumns(rects, 2, 1000, false);
expect(cols).toHaveLength(1);
expect(cols[0]!.lines).toEqual([
{ start: 0, end: 16 },
{ start: 20, end: 36 },
{ start: 40, end: 56 },
{ start: 60, end: 76 },
]);
});
});
describe('snapReadingRulerColumns', () => {
// col0: 5 lines (0..80); col1: 3 lines (0..40); each 16px tall, 20px apart.
const columns = [
{
left: 40,
right: 460,
lines: [0, 20, 40, 60, 80].map((s) => ({ start: s, end: s + 16 })),
},
{
left: 540,
right: 960,
lines: [0, 20, 40].map((s) => ({ start: s, end: s + 16 })),
},
];
it('advances within the active column', () => {
// col0, current block lines[0..1] => [0,36]; next block lines[2..3] => [40,76].
expect(snapReadingRulerColumns(0, 0, 36, 2, 'forward', columns)).toEqual({
columnIndex: 0,
start: 40,
end: 76,
});
});
it('jumps to the next column at the bottom of the current one', () => {
// col0 current block lines[3..4] => [60,96]; nothing below -> col1 first block [0,36].
expect(snapReadingRulerColumns(0, 60, 96, 2, 'forward', columns)).toEqual({
columnIndex: 1,
start: 0,
end: 36,
});
});
it('returns null past the last line of the last column', () => {
// col1 current block covers its last lines [20,56]; no next group anywhere.
expect(snapReadingRulerColumns(1, 20, 56, 2, 'forward', columns)).toBeNull();
});
it('jumps to the previous column when moving backward off the top', () => {
// col1 current block [0,36]; nothing above in col1 -> col0 last block [60,96].
expect(snapReadingRulerColumns(1, 0, 36, 2, 'backward', columns)).toEqual({
columnIndex: 0,
start: 60,
end: 96,
});
});
it('returns null when there are no columns', () => {
expect(snapReadingRulerColumns(0, 0, 36, 2, 'forward', [])).toBeNull();
});
});