From 0b4993407c0ad26fa72d24c315c0e5b3bb66e718 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 26 Jun 2026 11:16:36 +0800 Subject: [PATCH] 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) --- .../src/__tests__/services/constants.test.ts | 1 + .../utils/fixed-layout-styles.test.ts | 108 ++++++++++++++++++ .../app/reader/components/FoliateViewer.tsx | 1 + .../src/app/reader/components/ViewMenu.tsx | 58 +++++++++- apps/readest-app/src/services/constants.ts | 5 + apps/readest-app/src/types/book.ts | 1 + apps/readest-app/src/utils/style.ts | 7 +- 7 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/src/__tests__/utils/fixed-layout-styles.test.ts diff --git a/apps/readest-app/src/__tests__/services/constants.test.ts b/apps/readest-app/src/__tests__/services/constants.test.ts index 06d792d0..50fcc7ff 100644 --- a/apps/readest-app/src/__tests__/services/constants.test.ts +++ b/apps/readest-app/src/__tests__/services/constants.test.ts @@ -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', () => { diff --git a/apps/readest-app/src/__tests__/utils/fixed-layout-styles.test.ts b/apps/readest-app/src/__tests__/utils/fixed-layout-styles.test.ts new file mode 100644 index 00000000..e15592b8 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/fixed-layout-styles.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@/utils/misc', async (importOriginal) => { + const actual = await importOriginal>(); + 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 { + 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 { + 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('); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index e035f460..1062a28b 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -834,6 +834,7 @@ const FoliateViewer: React.FC<{ viewSettings?.overrideColor, viewSettings?.invertImgColorInDark, viewSettings?.applyThemeToPDF, + viewSettings?.contrast, viewSettings?.hideScrollbar, ]); diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx index b1115fe2..af22bf01 100644 --- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx @@ -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 = ({ 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 = ({ 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 = ({ // 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 = ({ +
+ + + +
+ <>
= { @@ -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 = { diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 660c662a..99fd5d07 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -240,6 +240,7 @@ export interface BookStyle { keepCoverSpread: boolean; invertImgColorInDark: boolean; applyThemeToPDF: boolean; + contrast: number; } export interface BookFont { diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 579063c2..deb3d6c5 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -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 {