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, ...(defaultCJKFont !== serif ? [defaultCJKFont] : []), ...SERIF_FONTS.filter( (font) => font !== serif && font !== defaultCJKFont && !lastSerifFonts.includes(font), ), ...CJK_SERIF_FONTS.filter((font) => font !== serif && font !== defaultCJKFont), ...lastSerifFonts.filter( (font) => SERIF_FONTS.includes(font) && !lastSerifFonts.includes(defaultCJKFont), ), ...FALLBACK_FONTS, ]; const sansSerifFonts = [ sansSerif, ...(defaultCJKFont !== sansSerif ? [defaultCJKFont] : []), ...SANS_SERIF_FONTS.filter((font) => font !== sansSerif && font !== defaultCJKFont), ...CJK_SANS_SERIF_FONTS.filter((font) => font !== sansSerif && font !== defaultCJKFont), ...FALLBACK_FONTS, ]; const monospaceFonts = [monospace, ...MONOSPACE_FONTS.filter((font) => font !== monospace)]; const defaultFontFamily = defaultFont.toLowerCase() === 'serif' ? '--serif' : '--sans-serif'; 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; --font-size: ${fontSize}px; --min-font-size: ${minFontSize}px; --font-weight: ${fontWeight}; } html, body { font-size: ${fontSize}px !important; font-weight: ${fontWeight}; -webkit-text-size-adjust: none; text-size-adjust: none; } /* lower specificity than ebook built-in font styles */ html { font-family: var(${defaultFontFamily}) ${overrideFont ? '!important' : ''}; } /* higher specificity than ebook built-in font styles */ html body { ${overrideFont ? `font-family: var(${defaultFontFamily}) !important;` : ''} } 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; } pre, code, kbd { font-family: var(--monospace); } body *:not(pre):not(code):not(kbd):not(pre *):not(code *):not(kbd *) { ${overrideFont ? 'font-family: revert !important;' : ''} } `; return fontStyles; }; const getColorStyles = ( overrideColor: boolean, invertImgColorInDark: boolean, themeCode: ThemeCode, backgroundTextureId: string, isEink: boolean, ) => { const { bg, fg, primary, isDarkMode } = themeCode; const colorStyles = ` html { --bg-texture-id: ${backgroundTextureId}; --theme-bg-color: ${bg}; --theme-fg-color: ${fg}; --theme-primary-color: ${primary}; --override-color: ${overrideColor}; color-scheme: ${isDarkMode ? 'dark' : 'light'}; } html, body { color: ${fg}; } html[has-background], body[has-background] { --background-set: var(--theme-bg-color); } html { background-color: var(--theme-bg-color, transparent); background: var(--background-set, none); } section, blockquote, div, p, font, h1, h2, h3, h4, h5, h6, li, span { ${overrideColor ? `background-color: ${bg} !important;` : ''} ${overrideColor ? `color: ${fg} !important;` : ''} ${overrideColor ? `border-color: ${fg} !important;` : ''} } pre, span { /* inline code blocks */ ${overrideColor ? `background-color: ${bg} !important;` : ''} } a:any-link { ${overrideColor ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''} text-decoration: ${isEink ? 'underline' : 'none'}; } body.pbg { ${isDarkMode ? `background-color: ${bg} !important;` : ''} } img { ${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''} ${!isDarkMode && overrideColor ? 'mix-blend-mode: multiply;' : ''} } /* horizontal rule #1649 */ *:has(> hr[class]):not(body) { background-color: ${bg}; } hr { mix-blend-mode: multiply; } /* inline images */ p img, span img, sup img { mix-blend-mode: ${isDarkMode ? 'screen' : 'multiply'}; } /* code */ body.theme-dark code { ${isDarkMode ? `color: ${fg}cc;` : ''} ${isDarkMode ? `background: color-mix(in srgb, ${bg} 90%, #000);` : ''} ${isDarkMode ? `background-color: color-mix(in srgb, ${bg} 90%, #000);` : ''} } blockquote, table * { ${isDarkMode ? `background: color-mix(in srgb, ${bg} 80%, #000);` : ''} ${isDarkMode ? `background-color: color-mix(in srgb, ${bg} 80%, #000);` : ''} } /* override inline hardcoded text color */ font[color="#000000"], font[color="#000"], font[color="black"], font[color="rgb(0,0,0)"], font[color="rgb(0, 0, 0)"], *[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, body { ${writingMode === 'auto' ? '' : `writing-mode: ${writingMode} !important;`} text-align: var(--default-text-align); max-height: unset; } body { overflow: unset; zoom: ${zoomLevel}; } svg, img { height: auto; width: auto; background-color: transparent !important; } /* enlarge the clickable area of links */ a { position: relative !important; } a::before { content: ''; position: absolute; top: -10px; left: -10px; right: -10px; bottom: -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: ${textIndent}em ${overrideLayout ? '!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.aligned-center, blockquote.aligned-center, dd.aligned-center, div.aligned-center { text-align: center ${overrideLayout ? '!important' : ''}; } p.aligned-right, blockquote.aligned-right, dd.aligned-right, div.aligned-right { text-align: right ${overrideLayout ? '!important' : ''}; } p.aligned-justify, blockquote.aligned-justify, dd.aligned-justify, div.aligned-justify { ${!justify && overrideLayout ? 'text-align: initial !important;' : ''}; } 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"], p.aligned-center, blockquote.aligned-center, dd.aligned-center, div.aligned-center, li p, ol p, ul p { 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; } /* some epubs set insane inline-block for p */ p { display: block; } /* inline images without dimension */ .ie6 img { width: unset; height: unset; } sup img { height: 1em; } img.has-text-siblings { height: 1em; vertical-align: middle; } :is(div) > img.has-text-siblings[style*="object-fit"] { display: block; height: auto; vertical-align: unset; } .duokan-footnote img:not([class]) { 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 { cursor: default; pointer-events: none; text-decoration: none; padding: unset; margin: unset; } ol { margin: 0; padding: 0; } p, li, blockquote, dd { margin: unset !important; text-indent: unset !important; } div { margin: unset !important; padding: unset !important; } dt { font-weight: bold; line-height: 1.6; } .epubtype-footnote, aside[epub|type~="endnote"], aside[epub|type~="footnote"], aside[epub|type~="note"], aside[epub|type~="rearnote"] { display: block; } `; 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!, 1.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; // Only for backward compatibility, new viewSettings.zoomLevel will always be 100 for EPUBs const zoomScale = (viewSettings.zoomLevel || 100) / 100.0; const fontStyles = getFontStyles( viewSettings.serifFont!, viewSettings.sansSerifFont!, viewSettings.monospaceFont!, viewSettings.defaultFont!, viewSettings.defaultCJKFont!, viewSettings.defaultFontSize! * fontScale * zoomScale, viewSettings.minimumFontSize!, viewSettings.fontWeight!, viewSettings.overrideFont!, ); const colorStyles = getColorStyles( viewSettings.overrideColor!, viewSettings.invertImgColorInDark!, themeCode, viewSettings.backgroundTextureId, viewSettings.isEink, ); 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 && hasTextIndentZero) { block = block.replace(/(text-align\s*:\s*center)(\s*;|\s*$)/g, '$1 !important$2'); 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;])font-family\s*:\s*monospace/gi, '$1font-family: var(--monospace)') .replace(/([\s;])font-weight\s*:\s*normal/gi, '$1font-weight: var(--font-weight)') .replace(/([\s;])color\s*:\s*black/gi, '$1color: var(--theme-fg-color)') .replace(/([\s;])color\s*:\s*#000000/gi, '$1color: var(--theme-fg-color)') .replace(/([\s;])color\s*:\s*#000/gi, '$1color: var(--theme-fg-color)') .replace(/([\s;])color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, '$1color: var(--theme-fg-color)'); return css; }; export const applyThemeModeClass = (document: Document, isDarkMode: boolean) => { document.body.classList.remove('theme-light', 'theme-dark'); document.body.classList.add(isDarkMode ? 'theme-dark' : 'theme-light'); }; 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(), ); const isInline = Array.from(parent.childNodes).every( (node) => node.nodeType !== Node.ELEMENT_NODE || (node as Element).tagName !== 'BR', ); if (hasTextSiblings && isInline) { img.classList.add('has-text-siblings'); } }); }; export const keepTextAlignment = (document: Document) => { document.querySelectorAll('div, p, blockquote, dd').forEach((el) => { const computedStyle = window.getComputedStyle(el); if (computedStyle.textAlign === 'center') { el.classList.add('aligned-center'); } else if (computedStyle.textAlign === 'right') { el.classList.add('aligned-right'); } else if (computedStyle.textAlign === 'justify') { el.classList.add('aligned-justify'); } }); }; export const applyFixedlayoutStyles = ( document: Document, viewSettings: ViewSettings, themeCode?: ThemeCode, ) => { if (!themeCode) { themeCode = getThemeCode(); } const { bg, fg, primary, isDarkMode } = themeCode; const overrideColor = viewSettings.overrideColor!; const invertImgColorInDark = viewSettings.invertImgColorInDark!; const darkMixBlendMode = bg === '#000000' ? 'luminosity' : 'overlay'; const existingStyleId = 'fixed-layout-styles'; let style = document.getElementById(existingStyleId) as HTMLStyleElement; if (style) { style.remove(); } style = document.createElement('style'); style.id = existingStyleId; style.textContent = ` html { --theme-bg-color: ${bg}; --theme-fg-color: ${fg}; --theme-primary-color: ${primary}; color-scheme: ${isDarkMode ? 'dark' : 'light'}; } body { position: relative; background-color: var(--theme-bg-color); } #canvas { display: inline-block; width: fit-content; height: fit-content; background-color: var(--theme-bg-color); } img, canvas { ${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''} ${overrideColor ? `mix-blend-mode: ${isDarkMode ? darkMixBlendMode : 'multiply'};` : ''} } img.singlePage { position: relative; } `; document.head.appendChild(style); };