feat(reader): add epub typography controls

Preserve publisher typography by default while allowing explicit spacing and indentation overrides across reader clients.

Ref #1206
This commit is contained in:
neon
2026-09-01 00:15:28 +00:00
parent 53cfe16b04
commit 2912429ca6
13 changed files with 1075 additions and 98 deletions
@@ -77,7 +77,9 @@ describe('ReaderPreferencesService', () => {
it('verifies file access before loading a per-book preference', async () => {
const user = makeUser({ id: 12 });
mockRepo.findPreference.mockResolvedValueOnce({ settings: { zoomMode: 'fit-width' } });
mockRepo.findPreference.mockResolvedValueOnce({
settings: { zoomMode: 'fit-width' },
});
const result = await service.getPreference(user, 42);
@@ -98,15 +100,25 @@ describe('ReaderPreferencesService', () => {
const user = makeUser({ id: 4 });
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'pdf' });
await service.upsertPreference(user, 10, { zoomMode: 'custom', customScale: 1.4 });
await service.upsertPreference(user, 10, {
zoomMode: 'custom',
customScale: 1.4,
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, { zoomMode: 'custom', customScale: 1.4 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, {
zoomMode: 'custom',
customScale: 1.4,
});
});
it('accepts new PDF layout choices and normalizes legacy wrapped mode', async () => {
const user = makeUser({ id: 4 });
await service.upsertPreference(user, 10, { scrollMode: 'wrapped', spread: 'auto', zoomMode: 'automatic' });
await service.upsertPreference(user, 10, {
scrollMode: 'wrapped',
spread: 'auto',
zoomMode: 'automatic',
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, {
scrollMode: 'vertical',
@@ -119,9 +131,17 @@ describe('ReaderPreferencesService', () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'cbz' });
await service.upsertPreference(user, 11, { fitMode: 'fit-page', direction: 'rtl', spreadGap: 24 });
await service.upsertPreference(user, 11, {
fitMode: 'fit-page',
direction: 'rtl',
spreadGap: 24,
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 11, { fitMode: 'fit-page', direction: 'rtl', spreadGap: 24 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 11, {
fitMode: 'fit-page',
direction: 'rtl',
spreadGap: 24,
});
});
it('rejects comic spread gaps outside the supported range', async () => {
@@ -177,7 +197,9 @@ describe('ReaderPreferencesService', () => {
});
it('fills the gapless default for cbx payloads saved before spread gaps were supported', async () => {
const legacyCbxDefaults: Partial<typeof validCbxDefaults> = { ...validCbxDefaults };
const legacyCbxDefaults: Partial<typeof validCbxDefaults> = {
...validCbxDefaults,
};
delete legacyCbxDefaults.spreadGap;
await service.upsertDefault(3, 'cbx', legacyCbxDefaults);
@@ -224,6 +246,9 @@ describe('ReaderPreferencesService', () => {
fontSize: 16,
lineHeight: 1.5,
paragraphSpacing: 0,
letterSpacing: null,
wordSpacing: null,
textIndent: null,
maxColumnCount: 2,
gap: 0.05,
maxInlineSize: 720,
@@ -240,14 +265,27 @@ describe('ReaderPreferencesService', () => {
for (const mode of [0, 1, 2] as const) {
vi.clearAllMocks();
mockRepo.upsertDefault.mockResolvedValue(undefined);
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: mode });
await service.upsertDefault(1, 'epub', {
...validEpubDefaults,
footerDisplayMode: mode,
});
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(1, 'epub', expect.objectContaining({ footerDisplayMode: mode }));
}
});
it('rejects invalid footerDisplayMode values in epub defaults', async () => {
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: 3 })).rejects.toThrow(BadRequestException);
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: -1 })).rejects.toThrow(BadRequestException);
await expect(
service.upsertDefault(1, 'epub', {
...validEpubDefaults,
footerDisplayMode: 3,
}),
).rejects.toThrow(BadRequestException);
await expect(
service.upsertDefault(1, 'epub', {
...validEpubDefaults,
footerDisplayMode: -1,
}),
).rejects.toThrow(BadRequestException);
});
it('rejects a full epub default that omits the font style keys', async () => {
@@ -262,81 +300,181 @@ describe('ReaderPreferencesService', () => {
await expect(service.upsertDefault(1, 'epub', missingParagraphSpacing)).rejects.toThrow(BadRequestException);
});
it('accepts publisher typography defaults represented by null', async () => {
await service.upsertDefault(1, 'epub', validEpubDefaults);
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(
1,
'epub',
expect.objectContaining({
letterSpacing: null,
wordSpacing: null,
textIndent: null,
}),
);
});
it.each(['letterSpacing', 'wordSpacing', 'textIndent'] as const)('rejects a full epub default that omits %s', async (field) => {
const missingField = Object.fromEntries(Object.entries(validEpubDefaults).filter(([key]) => key !== field));
await expect(service.upsertDefault(1, 'epub', missingField)).rejects.toThrow(BadRequestException);
});
it('accepts a full epub default carrying a font weight and style', async () => {
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontWeight: 700, fontStyle: 'italic' });
await service.upsertDefault(1, 'epub', {
...validEpubDefaults,
fontWeight: 700,
fontStyle: 'italic',
});
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(1, 'epub', expect.objectContaining({ fontWeight: 700, fontStyle: 'italic' }));
});
it.each([[1], [350], [1000]])('accepts the CSS font weight %s', async (weight) => {
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontWeight: weight });
await service.upsertDefault(1, 'epub', {
...validEpubDefaults,
fontWeight: weight,
});
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(1, 'epub', expect.objectContaining({ fontWeight: weight }));
});
it.each([[0], [1001], [400.5]])('rejects the unsupported font weight %s', async (weight) => {
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontWeight: weight })).rejects.toThrow(BadRequestException);
await expect(
service.upsertDefault(1, 'epub', {
...validEpubDefaults,
fontWeight: weight,
}),
).rejects.toThrow(BadRequestException);
});
it('rejects an unsupported font style', async () => {
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontStyle: 'oblique' })).rejects.toThrow(BadRequestException);
await expect(
service.upsertDefault(1, 'epub', {
...validEpubDefaults,
fontStyle: 'oblique',
}),
).rejects.toThrow(BadRequestException);
});
it('accepts partial epub per-book settings with a font weight and style', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 26, { fontWeight: 300, fontStyle: 'italic' });
await service.upsertPreference(user, 26, {
fontWeight: 300,
fontStyle: 'italic',
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 26, { fontWeight: 300, fontStyle: 'italic' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 26, {
fontWeight: 300,
fontStyle: 'italic',
});
});
it.each([[0], [0.8], [2]])('accepts paragraph spacing %s em', async (paragraphSpacing) => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 27, { paragraphSpacing });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 27, { paragraphSpacing });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 27, {
paragraphSpacing,
});
});
it.each([[-0.1], [2.1]])('rejects paragraph spacing %s em', async (paragraphSpacing) => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 27, { paragraphSpacing })).rejects.toThrow(BadRequestException);
});
it('accepts explicit zero and null typography overrides in partial epub settings', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 28, {
letterSpacing: 0,
wordSpacing: null,
textIndent: 0,
});
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 28, {
letterSpacing: 0,
wordSpacing: null,
textIndent: 0,
});
});
it.each([
['letterSpacing', -0.01],
['letterSpacing', 0.21],
['wordSpacing', -0.01],
['wordSpacing', 0.51],
['textIndent', -0.01],
['textIndent', 4.01],
])('rejects out-of-range typography setting %s=%s', async (field, value) => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 29, { [field]: value })).rejects.toThrow(BadRequestException);
});
it('accepts partial epub per-book settings with footerDisplayMode', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 20, { footerDisplayMode: 1 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 20, { footerDisplayMode: 1 });
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' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 22, { fixedLayoutSpread: 'none' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 22, { fixedLayoutSpread: 'none' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 22, {
fixedLayoutSpread: 'none',
});
});
it('accepts the minimum epub font size', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await service.upsertPreference(user, 24, { fontSize: 6 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 24, { fontSize: 6 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 24, {
fontSize: 6,
});
});
it('rejects epub font sizes below the minimum', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 25, { fontSize: 5 })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
@@ -344,7 +482,9 @@ describe('ReaderPreferencesService', () => {
it('rejects partial epub per-book settings with invalid footerDisplayMode', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 21, { footerDisplayMode: 5 })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
@@ -352,7 +492,9 @@ describe('ReaderPreferencesService', () => {
it('rejects partial epub per-book settings with invalid fixedLayoutSpread', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
mockBookService.verifyFileAccess.mockResolvedValueOnce({
format: 'epub',
});
await expect(service.upsertPreference(user, 23, { fixedLayoutSpread: 'two-page' })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
@@ -6,8 +6,14 @@ import {
CSS_FONT_WEIGHT_MIN,
EPUB_FONT_SIZE_MAX,
EPUB_FONT_SIZE_MIN,
EPUB_LETTER_SPACING_MAX,
EPUB_LETTER_SPACING_MIN,
EPUB_PARAGRAPH_SPACING_MAX,
EPUB_PARAGRAPH_SPACING_MIN,
EPUB_TEXT_INDENT_MAX,
EPUB_TEXT_INDENT_MIN,
EPUB_WORD_SPACING_MAX,
EPUB_WORD_SPACING_MIN,
READER_GROUP_DEFAULTS,
getFormatGroup,
type ReaderFormatGroup,
@@ -31,6 +37,9 @@ const EPUB_SETTINGS_SCHEMA = z
fontSize: z.number().min(EPUB_FONT_SIZE_MIN).max(EPUB_FONT_SIZE_MAX),
lineHeight: z.number().min(0.8).max(3),
paragraphSpacing: z.number().min(EPUB_PARAGRAPH_SPACING_MIN).max(EPUB_PARAGRAPH_SPACING_MAX),
letterSpacing: z.number().min(EPUB_LETTER_SPACING_MIN).max(EPUB_LETTER_SPACING_MAX).nullable(),
wordSpacing: z.number().min(EPUB_WORD_SPACING_MIN).max(EPUB_WORD_SPACING_MAX).nullable(),
textIndent: z.number().min(EPUB_TEXT_INDENT_MIN).max(EPUB_TEXT_INDENT_MAX).nullable(),
maxColumnCount: z.number().int().min(1).max(10),
gap: z.number().min(0).max(0.5),
maxInlineSize: z.number().int().min(400).max(1600),