import { MONOSPACE_FONTS, SANS_SERIF_FONTS, SERIF_FONTS, FALLBACK_FONTS, CJK_SANS_SERIF_FONTS, CJK_SERIF_FONTS, } from '@/services/constants'; import { ViewSettings } from '@/types/book'; import { themes, Palette, CustomTheme, generateLightPalette, generateDarkPalette, } from '@/styles/themes'; import { getOSPlatform } from './misc'; const getFontStyles = ( serif: string, sansSerif: string, monospace: string, defaultFont: string, defaultCJKFont: string, fontSize: number, minFontSize: number, fontWeight: number, overrideFont: boolean, ) => { const lastSerifFonts = ['Georgia', 'Times New Roman']; const serifFonts = [ serif, ...SERIF_FONTS.filter( (font) => font !== serif && font !== defaultCJKFont && !lastSerifFonts.includes(font), ), ...(defaultCJKFont !== serif ? [defaultCJKFont] : []), ...CJK_SERIF_FONTS.filter((font) => font !== serif && font !== defaultCJKFont), ...lastSerifFonts.filter( (font) => SERIF_FONTS.includes(font) && !lastSerifFonts.includes(defaultCJKFont), ), ...FALLBACK_FONTS, ]; const sansSerifFonts = [ sansSerif, ...SANS_SERIF_FONTS.filter((font) => font !== sansSerif && font !== defaultCJKFont), ...(defaultCJKFont !== sansSerif ? [defaultCJKFont] : []), ...CJK_SANS_SERIF_FONTS.filter((font) => font !== sansSerif && font !== defaultCJKFont), ...FALLBACK_FONTS, ]; const monospaceFonts = [monospace, ...MONOSPACE_FONTS.filter((font) => font !== monospace)]; const fontStyles = ` html { --serif: ${serifFonts.map((font) => `"${font}"`).join(', ')}, serif; --sans-serif: ${sansSerifFonts.map((font) => `"${font}"`).join(', ')}, sans-serif; --monospace: ${monospaceFonts.map((font) => `"${font}"`).join(', ')}, monospace; } html, body { font-family: var(${defaultFont.toLowerCase() === 'serif' ? '--serif' : '--sans-serif'}) ${overrideFont ? '!important' : ''}; font-size: ${fontSize}px !important; font-weight: ${fontWeight}; -webkit-text-size-adjust: none; text-size-adjust: none; } font[size="1"] { font-size: ${minFontSize}px; } font[size="2"] { font-size: ${minFontSize * 1.5}px; } font[size="3"] { font-size: ${fontSize}px; } font[size="4"] { font-size: ${fontSize * 1.2}px; } font[size="5"] { font-size: ${fontSize * 1.5}px; } font[size="6"] { font-size: ${fontSize * 2}px; } font[size="7"] { font-size: ${fontSize * 3}px; } /* hardcoded inline font size */ [style*="font-size: 16px"], [style*="font-size:16px"] { font-size: 1rem !important; } body * { ${overrideFont ? 'font-family: revert !important;' : ''} } `; return fontStyles; }; const getColorStyles = ( overrideColor: boolean, invertImgColorInDark: boolean, themeCode: ThemeCode, ) => { const { bg, fg, primary, isDarkMode } = themeCode; const colorStyles = ` html { --theme-bg-color: ${bg}; --theme-fg-color: ${fg}; --theme-primary-color: ${primary}; color-scheme: ${isDarkMode ? 'dark' : 'light'}; } html, body { color: ${fg}; } div, p, h1, h2, h3, h4, h5, h6 { ${overrideColor ? `background-color: ${bg} !important;` : ''} ${overrideColor ? `color: ${fg} !important;` : ''} } pre, span { /* inline code blocks */ ${overrideColor ? `background-color: ${bg} !important;` : ''} } a:any-link { ${overrideColor ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''} text-decoration: none; } p:has(img), span:has(img) { background-color: ${bg}; } body.pbg { ${isDarkMode ? `background-color: ${bg} !important;` : ''} } img { ${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''} } /* inline images */ p img, span img, sup img { mix-blend-mode: ${isDarkMode ? 'screen' : 'multiply'}; } /* override inline hardcoded text color */ *[style*="color: rgb(0,0,0)"], *[style*="color: rgb(0, 0, 0)"], *[style*="color: #000"], *[style*="color: #000000"], *[style*="color: black"], *[style*="color:rgb(0,0,0)"], *[style*="color:rgb(0, 0, 0)"], *[style*="color:#000"], *[style*="color:#000000"], *[style*="color:black"] { color: ${fg} !important; } /* for the Gutenberg eBooks */ #pg-header * { color: inherit !important; } .x-ebookmaker, .x-ebookmaker-cover, .x-ebookmaker-coverpage { background-color: unset !important; } /* for the Feedbooks eBooks */ .chapterHeader, .chapterHeader * { border-color: unset; background-color: ${bg} !important; } `; return colorStyles; }; const getLayoutStyles = ( overrideLayout: boolean, paragraphMargin: number, lineSpacing: number, wordSpacing: number, letterSpacing: number, textIndent: number, justify: boolean, hyphenate: boolean, zoomLevel: number, writingMode: string, vertical: boolean, ) => { const layoutStyle = ` @namespace epub "http://www.idpf.org/2007/ops"; html { --default-text-align: ${justify ? 'justify' : 'start'}; hanging-punctuation: allow-end last; orphans: 2; widows: 2; } [align="left"] { text-align: left; } [align="right"] { text-align: right; } [align="center"] { text-align: center; } [align="justify"] { text-align: justify; } :is(hgroup, header) p { text-align: unset; hyphens: unset; } pre { white-space: pre-wrap !important; tab-size: 2; } html[has-background], body[has-background] { --background-set: var(--theme-bg-color); } html, body { ${writingMode === 'auto' ? '' : `writing-mode: ${writingMode} !important;`} text-align: var(--default-text-align); max-height: unset; } html { background-color: var(--theme-bg-color, transparent); background: var(--background-set, none); } body { overflow: unset; zoom: ${zoomLevel}; } svg, img { height: auto; width: auto; background-color: transparent !important; } /* enlarge the clickable area of links */ a { padding: 10px; margin: -10px; } p, blockquote, dd, div:not(:has(*:not(b, a, em, i, strong, u, span))) { line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''}; word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''}; letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''}; text-indent: ${vertical ? textIndent * 1.2 : textIndent}em ${overrideLayout ? '!important' : ''}; ${justify ? `text-align: justify ${overrideLayout ? '!important' : ''};` : ''} ${!justify && overrideLayout ? 'text-align: unset !important;' : ''}; -webkit-hyphens: ${hyphenate ? 'auto' : 'manual'}; hyphens: ${hyphenate ? 'auto' : 'manual'}; -webkit-hyphenate-limit-before: 3; -webkit-hyphenate-limit-after: 2; -webkit-hyphenate-limit-lines: 2; hanging-punctuation: allow-end last; widows: 2; } p:has(> img:only-child), p:has(> span:only-child > img:only-child), p:has(> img:not(.has-text-siblings)), p:has(> a:first-child + img:last-child) { text-indent: initial !important; } blockquote[align="center"], div[align="center"], p[align="center"], dd[align="center"] { text-indent: initial !important; } p { ${vertical ? `margin-left: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''} ${vertical ? `margin-right: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''} ${!vertical ? `margin-top: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''} ${!vertical ? `margin-bottom: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''} } div { ${vertical && overrideLayout ? `margin-left: ${paragraphMargin}em !important;` : ''} ${vertical && overrideLayout ? `margin-right: ${paragraphMargin}em !important;` : ''} ${!vertical && overrideLayout ? `margin-top: ${paragraphMargin}em !important;` : ''} ${!vertical && overrideLayout ? `margin-bottom: ${paragraphMargin}em !important;` : ''} } h1, h2, h3, h4, h5, h6 { text-align: initial; } :lang(zh), :lang(ja), :lang(ko) { widows: 1; orphans: 1; } pre { white-space: pre-wrap !important; } .epubtype-footnote, aside[epub|type~="endnote"], aside[epub|type~="footnote"], aside[epub|type~="note"], aside[epub|type~="rearnote"] { display: none; } /* Now begins really dirty hacks to fix some badly designed epubs */ img.pi { ${vertical ? 'transform: rotate(90deg);' : ''} ${vertical ? 'transform-origin: center;' : ''} ${vertical ? 'height: 2em;' : ''} ${vertical ? `width: ${lineSpacing}em;` : ''} ${vertical ? `vertical-align: unset;` : ''} } .duokan-footnote-content, .duokan-footnote-item { display: none; } .calibre { color: unset; } /* inline images without dimension */ sup img { height: 1em; } img.has-text-siblings { height: 1em; vertical-align: baseline; } .ie6 img { width: auto; height: auto; } .duokan-footnote img { width: 0.8em; height: 0.8em; } /* workaround for some badly designed epubs */ div.left *, p.left * { text-align: left; } div.right *, p.right * { text-align: right; } div.center *, p.center * { text-align: center; } div.justify *, p.justify * { text-align: justify; } .nonindent, .noindent { text-indent: unset !important; } `; return layoutStyle; }; export const getFootnoteStyles = () => ` .duokan-footnote-content, .duokan-footnote-item { display: block !important; } body { padding: 1em !important; } a:any-link { text-decoration: none; padding: unset; margin: unset; } ol { margin: 0; padding: 0; } p, li, blockquote, dd { margin: unset !important; text-indent: unset !important; } `; const getTranslationStyles = (showSource: boolean) => ` .translation-source { } .translation-target { } .translation-target.hidden { display: none !important; } .translation-target-block { display: block !important; ${showSource ? 'margin: 0.5em 0 !important;' : ''} } .translation-target-toc { display: block !important; overflow: hidden; text-overflow: ellipsis; } `; export interface ThemeCode { bg: string; fg: string; primary: string; palette: Palette; isDarkMode: boolean; } export const getThemeCode = () => { let themeMode = 'auto'; let themeColor = 'default'; let systemIsDarkMode = false; let customThemes: CustomTheme[] = []; if (typeof window !== 'undefined') { themeColor = localStorage.getItem('themeColor') || 'default'; themeMode = localStorage.getItem('themeMode') || 'auto'; customThemes = JSON.parse(localStorage.getItem('customThemes') || '[]'); systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; } const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode); let currentTheme = themes.find((theme) => theme.name === themeColor); if (!currentTheme) { const customTheme = customThemes.find((theme) => theme.name === themeColor); if (customTheme) { currentTheme = { name: customTheme.name, label: customTheme.label, colors: { light: generateLightPalette(customTheme.colors.light), dark: generateDarkPalette(customTheme.colors.dark), }, }; } } if (!currentTheme) currentTheme = themes[0]; const defaultPalette = isDarkMode ? currentTheme!.colors.dark : currentTheme!.colors.light; return { bg: defaultPalette['base-100'], fg: defaultPalette['base-content'], primary: defaultPalette.primary, palette: defaultPalette, isDarkMode, } as ThemeCode; }; export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) => { if (!themeCode) { themeCode = getThemeCode(); } const layoutStyles = getLayoutStyles( viewSettings.overrideLayout!, viewSettings.paragraphMargin!, viewSettings.lineHeight!, viewSettings.wordSpacing!, viewSettings.letterSpacing!, viewSettings.textIndent!, viewSettings.fullJustification!, viewSettings.hyphenation!, viewSettings.zoomLevel! / 100.0, viewSettings.writingMode!, viewSettings.vertical!, ); // scale the font size on-the-fly so that we can sync the same font size on different devices const isMobile = ['ios', 'android'].includes(getOSPlatform()); const fontScale = isMobile ? 1.25 : 1; const fontStyles = getFontStyles( viewSettings.serifFont!, viewSettings.sansSerifFont!, viewSettings.monospaceFont!, viewSettings.defaultFont!, viewSettings.defaultCJKFont!, viewSettings.defaultFontSize! * fontScale, viewSettings.minimumFontSize!, viewSettings.fontWeight!, viewSettings.overrideFont!, ); const colorStyles = getColorStyles( viewSettings.overrideColor!, viewSettings.invertImgColorInDark!, themeCode, ); const translationStyles = getTranslationStyles(viewSettings.showTranslateSource!); const userStylesheet = viewSettings.userStylesheet!; return `${layoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${userStylesheet}`; }; export const applyTranslationStyle = (viewSettings: ViewSettings) => { const styleId = 'translation-style'; const existingStyle = document.getElementById(styleId); if (existingStyle) { existingStyle.remove(); } const styleElement = document.createElement('style'); styleElement.id = styleId; styleElement.textContent = getTranslationStyles(viewSettings.showTranslateSource); document.head.appendChild(styleElement); }; export const transformStylesheet = (vw: number, vh: number, css: string) => { const isMobile = ['ios', 'android'].includes(getOSPlatform()); const fontScale = isMobile ? 1.25 : 1; const ruleRegex = /([^{]+)({[^}]+})/g; css = css.replace(ruleRegex, (match, selector, block) => { const hasTextAlignCenter = /text-align\s*:\s*center\s*[;$]/.test(block); const hasTextIndentZero = /text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?\s*[;$]/.test(block); if (hasTextAlignCenter) { block = block.replace(/(text-align\s*:\s*center)(\s*;|\s*$)/g, '$1 !important$2'); if (hasTextIndentZero) { block = block.replace( /(text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?)(\s*;|\s*$)/g, '$1 !important$2', ); } return selector + block; } return match; }); // replace absolute font sizes with rem units // replace vw and vh as they cause problems with layout // replace hardcoded colors css = css .replace(/font-size\s*:\s*xx-small/gi, 'font-size: 0.6rem') .replace(/font-size\s*:\s*x-small/gi, 'font-size: 0.75rem') .replace(/font-size\s*:\s*small/gi, 'font-size: 0.875rem') .replace(/font-size\s*:\s*medium/gi, 'font-size: 1rem') .replace(/font-size\s*:\s*large/gi, 'font-size: 1.2rem') .replace(/font-size\s*:\s*x-large/gi, 'font-size: 1.5rem') .replace(/font-size\s*:\s*xx-large/gi, 'font-size: 2rem') .replace(/font-size\s*:\s*xxx-large/gi, 'font-size: 3rem') .replace(/font-size\s*:\s*(\d+(?:\.\d+)?)px/gi, (_, px) => { const rem = parseFloat(px) / fontScale / 16; return `font-size: ${rem}rem`; }) .replace(/font-size\s*:\s*(\d+(?:\.\d+)?)pt/gi, (_, pt) => { const rem = parseFloat(pt) / fontScale / 12; return `font-size: ${rem}rem`; }) .replace(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * vw) / 100 + 'px') .replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * vh) / 100 + 'px') .replace(/[\s;]color\s*:\s*black/gi, 'color: var(--theme-fg-color)') .replace(/[\s;]color\s*:\s*#000000/gi, 'color: var(--theme-fg-color)') .replace(/[\s;]color\s*:\s*#000/gi, 'color: var(--theme-fg-color)') .replace(/[\s;]color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)'); return css; }; export const applyImageStyle = (document: Document) => { document.querySelectorAll('img').forEach((img) => { const parent = img.parentNode; if (!parent || parent.nodeType !== Node.ELEMENT_NODE) return; const hasTextSiblings = Array.from(parent.childNodes).some( (node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(), ); if (hasTextSiblings) { img.classList.add('has-text-siblings'); } }); };