feat(reader): support single-page mode for fixed-layout epubs

Adds a fixedLayoutSpread preference to EPUB reader settings to allow forcing single-page display. This is particularly useful for image-based EPUBs like comics and manga.

Closes #487
This commit is contained in:
neon
2026-07-01 15:16:17 +00:00
parent 68a395095a
commit 219815941b
13 changed files with 566 additions and 127 deletions
@@ -200,6 +200,7 @@ describe('ReaderPreferencesService', () => {
flow: 'paginated' as const,
overrideBookFormatting: true,
footerDisplayMode: 0 as const,
fixedLayoutSpread: 'auto' as const,
};
it('accepts valid footerDisplayMode values (0, 1, 2) in full epub defaults', async () => {
@@ -225,6 +226,15 @@ describe('ReaderPreferencesService', () => {
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 20, { footerDisplayMode: 1 });
});
it('accepts partial epub per-book settings with fixedLayoutSpread', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
await service.upsertPreference(user, 22, { fixedLayoutSpread: 'none' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 22, { fixedLayoutSpread: 'none' });
});
it('rejects partial epub per-book settings with invalid footerDisplayMode', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
@@ -233,11 +243,26 @@ describe('ReaderPreferencesService', () => {
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
});
it('rejects partial epub per-book settings with invalid fixedLayoutSpread', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
await expect(service.upsertPreference(user, 23, { fixedLayoutSpread: 'two-page' })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
});
it('requires footerDisplayMode when saving full epub defaults', async () => {
const withoutFooterMode = { ...validEpubDefaults };
delete (withoutFooterMode as Record<string, unknown>).footerDisplayMode;
await expect(service.upsertDefault(1, 'epub', withoutFooterMode)).rejects.toThrow(BadRequestException);
});
it('requires fixedLayoutSpread when saving full epub defaults', async () => {
const withoutSpread = { ...validEpubDefaults };
delete (withoutSpread as Record<string, unknown>).fixedLayoutSpread;
await expect(service.upsertDefault(1, 'epub', withoutSpread)).rejects.toThrow(BadRequestException);
});
});
});
@@ -25,6 +25,7 @@ const EPUB_SETTINGS_SCHEMA = z
flow: z.enum(['paginated', 'scrolled']),
overrideBookFormatting: z.boolean(),
footerDisplayMode: z.union([z.literal(0), z.literal(1), z.literal(2)]),
fixedLayoutSpread: z.enum(['auto', 'none']),
})
.strict();