feat(reader): add contrast option to PDF/CBZ view menu (#4800)

Add a Contrast stepper to the reader View menu for fixed-layout
(PDF/CBZ) documents. It increases and decreases page contrast via a
CSS filter on the rendered page images, applies to the whole book,
and is stored per-book (local to the current document).

The filter is built in applyFixedlayoutStyles by combining any
dark-mode invert with the contrast amount into a single filter
declaration. Persisted with skipGlobal so it never touches global
view settings, and added to FoliateViewer's effect dependencies so
the change re-applies across all rendered pages.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-26 11:16:36 +08:00
committed by GitHub
parent 370a516620
commit 0b4993407c
7 changed files with 179 additions and 2 deletions
@@ -505,6 +505,7 @@ describe('services/constants', () => {
expect(typeof DEFAULT_BOOK_STYLE.wordSpacing).toBe('number');
expect(typeof DEFAULT_BOOK_STYLE.letterSpacing).toBe('number');
expect(typeof DEFAULT_BOOK_STYLE.textIndent).toBe('number');
expect(DEFAULT_BOOK_STYLE.contrast).toBe(100);
});
it('has boolean style flags', () => {
@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/utils/misc', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
getOSPlatform: vi.fn(() => 'macos' as const),
};
});
import { applyFixedlayoutStyles, ThemeCode } from '@/utils/style';
import { ViewSettings } from '@/types/book';
import {
DEFAULT_BOOK_FONT,
DEFAULT_BOOK_LAYOUT,
DEFAULT_BOOK_LANGUAGE,
DEFAULT_BOOK_STYLE,
DEFAULT_VIEW_CONFIG,
DEFAULT_TTS_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
DEFAULT_ANNOTATOR_CONFIG,
DEFAULT_SCREEN_CONFIG,
} from '@/services/constants';
function makeViewSettings(overrides: Partial<ViewSettings> = {}): ViewSettings {
return {
...DEFAULT_BOOK_FONT,
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_LANGUAGE,
...DEFAULT_BOOK_STYLE,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_TRANSLATOR_CONFIG,
...DEFAULT_ANNOTATOR_CONFIG,
...DEFAULT_SCREEN_CONFIG,
...overrides,
} as ViewSettings;
}
function makeThemeCode(overrides: Partial<ThemeCode> = {}): ThemeCode {
return {
bg: '#ffffff',
fg: '#000000',
primary: '#3366cc',
isDarkMode: false,
palette: {
'base-100': '#ffffff',
'base-200': '#f0f0f0',
'base-300': '#e0e0e0',
'base-content': '#000000',
neutral: '#808080',
'neutral-content': '#ffffff',
primary: '#3366cc',
secondary: '#6699cc',
accent: '#33cc99',
},
...overrides,
};
}
/** Run applyFixedlayoutStyles on a fresh document and return the injected CSS. */
function fixedLayoutCss(vs: ViewSettings, theme: ThemeCode): string {
const doc = document.implementation.createHTMLDocument('test');
applyFixedlayoutStyles(doc, vs, theme);
return doc.getElementById('fixed-layout-styles')?.textContent ?? '';
}
describe('applyFixedlayoutStyles contrast filter', () => {
it('does not apply a contrast filter at the default 100%', () => {
const css = fixedLayoutCss(makeViewSettings({ contrast: 100 }), makeThemeCode());
expect(css).not.toContain('contrast(');
});
it('applies a contrast filter when contrast is increased above 100%', () => {
const css = fixedLayoutCss(makeViewSettings({ contrast: 150 }), makeThemeCode());
expect(css).toContain('filter: contrast(150%)');
});
it('applies a contrast filter when contrast is decreased below 100%', () => {
const css = fixedLayoutCss(makeViewSettings({ contrast: 75 }), makeThemeCode());
expect(css).toContain('filter: contrast(75%)');
});
it('applies contrast in light mode too (independent of invertImgColorInDark)', () => {
const css = fixedLayoutCss(
makeViewSettings({ contrast: 150, invertImgColorInDark: true }),
makeThemeCode({ isDarkMode: false }),
);
expect(css).toContain('contrast(150%)');
expect(css).not.toContain('invert(100%)');
});
it('combines invert and contrast into a single filter declaration in dark mode', () => {
const css = fixedLayoutCss(
makeViewSettings({ contrast: 150, invertImgColorInDark: true }),
makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' }),
);
expect(css).toContain('filter: invert(100%) contrast(150%)');
});
it('treats an undefined contrast as 100% (no filter, backward compatible)', () => {
const css = fixedLayoutCss(
makeViewSettings({ contrast: undefined as unknown as number }),
makeThemeCode(),
);
expect(css).not.toContain('contrast(');
});
});
@@ -834,6 +834,7 @@ const FoliateViewer: React.FC<{
viewSettings?.overrideColor,
viewSettings?.invertImgColorInDark,
viewSettings?.applyThemeToPDF,
viewSettings?.contrast,
viewSettings?.hideScrollbar,
]);
@@ -5,13 +5,21 @@ import { useRouter } from 'next/navigation';
import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck, MdInfoOutline } from 'react-icons/md';
import { MdRemove, MdAdd, MdContrast } from 'react-icons/md';
import { MdSync, MdSyncProblem } from 'react-icons/md';
import { IoMdExpand } from 'react-icons/io';
import { IoShareOutline } from 'react-icons/io5';
import { TbArrowAutofitWidth } from 'react-icons/tb';
import { TbColumns1, TbColumns2 } from 'react-icons/tb';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import {
MAX_ZOOM_LEVEL,
MIN_ZOOM_LEVEL,
ZOOM_STEP,
MAX_CONTRAST,
MIN_CONTRAST,
CONTRAST_STEP,
} from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
@@ -60,6 +68,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
viewSettings?.paragraphMode?.enabled ?? false,
);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const [contrast, setContrast] = useState(viewSettings!.contrast ?? 100);
const [zoomMode, setZoomMode] = useState(viewSettings!.zoomMode!);
const [spreadMode, setSpreadMode] = useState(viewSettings!.spreadMode!);
const [keepCoverSpread, setKeepCoverSpread] = useState(viewSettings!.keepCoverSpread!);
@@ -71,6 +80,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
const resetZoom = () => setZoomLevel(100);
const increaseContrast = () =>
setContrast((prev) => Math.min(prev + CONTRAST_STEP, MAX_CONTRAST));
const decreaseContrast = () =>
setContrast((prev) => Math.max(prev - CONTRAST_STEP, MIN_CONTRAST));
const resetContrast = () => setContrast(100);
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
const toggleWebtoonMode = () => setWebtoonMode(!webtoonMode);
const toggleParagraphMode = () => {
@@ -159,6 +173,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
useEffect(() => {
if (contrast === viewSettings.contrast) return;
saveViewSettings(envConfig, bookKey, 'contrast', contrast, true, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contrast]);
useEffect(() => {
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark, true, true);
@@ -257,6 +277,42 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
</button>
</div>
<div
title={_('Contrast')}
className={clsx('mt-2 flex items-center justify-between rounded-md')}
>
<button
title={_('Decrease Contrast')}
onClick={decreaseContrast}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
contrast <= MIN_CONTRAST && 'btn-disabled text-gray-400',
)}
>
<MdRemove />
</button>
<button
title={_('Reset Contrast')}
className={clsx(
'hover:bg-base-300 text-base-content flex h-8 min-h-8 w-[50%] items-center justify-center gap-1 rounded-md p-1 text-center',
)}
onClick={resetContrast}
>
<MdContrast size={16} />
{Math.round(contrast)}%
</button>
<button
title={_('Increase Contrast')}
onClick={increaseContrast}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
contrast >= MAX_CONTRAST && 'btn-disabled text-gray-400',
)}
>
<MdAdd />
</button>
</div>
<>
<div
title={_('Zoom Mode')}
@@ -297,6 +297,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
keepCoverSpread: true,
invertImgColorInDark: false,
applyThemeToPDF: false,
contrast: 100,
};
export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
@@ -838,6 +839,10 @@ export const MAX_ZOOM_LEVEL = 500;
export const MIN_ZOOM_LEVEL = 50;
export const ZOOM_STEP = 10;
export const MAX_CONTRAST = 300;
export const MIN_CONTRAST = 50;
export const CONTRAST_STEP = 10;
export const SHOW_UNREAD_STATUS_BADGE = false;
export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
+1
View File
@@ -240,6 +240,7 @@ export interface BookStyle {
keepCoverSpread: boolean;
invertImgColorInDark: boolean;
applyThemeToPDF: boolean;
contrast: number;
}
export interface BookFont {
+6 -1
View File
@@ -1235,6 +1235,11 @@ export const applyFixedlayoutStyles = (
const isEink = viewSettings.isEink;
const overrideColor = viewSettings.overrideColor!;
const invertImgColorInDark = viewSettings.invertImgColorInDark!;
const contrast = viewSettings.contrast ?? 100;
const imgFilters: string[] = [];
if (isDarkMode && invertImgColorInDark) imgFilters.push('invert(100%)');
if (contrast !== 100) imgFilters.push(`contrast(${contrast}%)`);
const imgFilter = imgFilters.length ? `filter: ${imgFilters.join(' ')};` : '';
const darkMixBlendMode = bg === '#000000' ? 'luminosity' : 'overlay';
const existingStyleId = 'fixed-layout-styles';
let style = document.getElementById(existingStyleId) as HTMLStyleElement;
@@ -1262,7 +1267,7 @@ export const applyFixedlayoutStyles = (
background-color: var(--theme-bg-color);
}
img, canvas {
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
${imgFilter}
${overrideColor ? `mix-blend-mode: ${isDarkMode ? darkMixBlendMode : 'multiply'};` : ''}
}
img.singlePage {