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 { createFontCSS, CustomFont } from '@/styles/fonts';
import { getOSPlatform } from './misc';
import { SCROLL_WRAPPER_CLASS, SCROLL_WRAPPER_FIT_CLASS } from './scrollable';
/**
* Build the resolved CSS font-family lists (serif / sans-serif / monospace)
* from the user's font settings. Each value is a ready-to-use `font-family`
* string ending in the matching generic family. Shared by getFontStyles (which
* exposes them as CSS variables inside the reader iframe) and getBaseFontFamily
* (which applies the body font directly to top-level UI such as the RSVP overlay).
*/
const buildFontFamilyLists = (
serif: string,
sansSerif: string,
monospace: string,
defaultCJKFont: string,
) => {
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 quote = (fonts: string[]) => fonts.map((font) => `"${font}"`).join(', ');
return {
serif: `${quote(serifFonts)}, serif`,
sansSerif: `${quote(sansSerifFonts)}, sans-serif`,
monospace: `${quote(monospaceFonts)}, monospace`,
};
};
/**
* Resolve the body font-family string (serif or sans-serif chain, per the
* user's "Default Font" setting) for use outside the reader iframe — e.g. the
* RSVP overlay, which renders in the top document and can't read the iframe's
* --serif/--sans-serif CSS variables. Custom fonts are already mounted in the
* top document, so the returned chain resolves them by family name.
*/
export const getBaseFontFamily = (viewSettings: ViewSettings): string => {
const families = buildFontFamilyLists(
viewSettings.serifFont!,
viewSettings.sansSerifFont!,
viewSettings.monospaceFont!,
viewSettings.defaultCJKFont!,
);
return viewSettings.defaultFont!.toLowerCase() === 'serif' ? families.serif : families.sansSerif;
};
const getFontStyles = (
serif: string,
sansSerif: string,
monospace: string,
defaultFont: string,
defaultCJKFont: string,
fontSize: number,
minFontSize: number,
fontWeight: number,
overrideFont: boolean,
) => {
const families = buildFontFamilyLists(serif, sansSerif, monospace, defaultCJKFont);
const defaultFontFamily = defaultFont.toLowerCase() === 'serif' ? '--serif' : '--sans-serif';
const fontStyles = `
html {
--serif: ${families.serif};
--sans-serif: ${families.sansSerif};
--monospace: ${families.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, code, kbd, .code):not(pre *, code *, kbd *, .code *) {
${overrideFont ? 'font-family: revert !important;' : ''}
}
`;
return fontStyles;
};
/** True for #fff, #f5f5f5, rgb(255,…), etc. Used when rewriting EPUB CSS in dark mode. */
const isLightCssColor = (value: string): boolean => {
const v = value.trim().toLowerCase();
if (v === 'white') return true;
const hex = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hex) {
const h = hex[1]!;
const expand =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
const r = parseInt(expand.slice(0, 2), 16);
const g = parseInt(expand.slice(2, 4), 16);
const b = parseInt(expand.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.85;
}
const rgb = v.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
if (rgb?.[1] != null && rgb[2] != null && rgb[3] != null) {
const r = parseInt(rgb[1], 10);
const g = parseInt(rgb[2], 10);
const b = parseInt(rgb[3], 10);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.85;
}
return false;
};
const getDarkModeLightBackgroundOverrides = (bg: string) => `
/* Callout boxes often use inline white/light backgrounds while html/body set dark fg. */
*[style*="background-color: #fff"], *[style*="background-color:#fff"],
*[style*="background-color: #ffffff"], *[style*="background-color:#ffffff"],
*[style*="background-color: white"], *[style*="background-color:white"],
*[style*="background: #fff"], *[style*="background:#fff"],
*[style*="background: #ffffff"], *[style*="background:#ffffff"],
*[style*="background: white"], *[style*="background:white"],
*[style*="background-color: rgb(255"], *[style*="background-color:rgb(255"],
*[style*="background: rgb(255"], *[style*="background:rgb(255"] {
background-color: ${bg} !important;
}
/* Force transparent, not the theme bg: the dark page fill already comes from
the paginator container / reader grid cell, while an opaque body paints over
the host background texture (#4446) — and foliate captures docBackground once
per section load, so the body must stay transparent regardless of texture
state. Book-forced light page backgrounds still get neutralized (#4392) since
the theme-dark fill shows through. */
body.theme-dark {
background-color: transparent !important;
}
`;
const getEinkSelectionStyles = () => {
return `
::selection {
color: var(--theme-bg-color);
background: var(--theme-fg-color);
}
::-moz-selection {
color: var(--theme-bg-color);
background: var(--theme-fg-color);
}
`;
};
const getColorStyles = (
overrideColor: boolean,
invertImgColorInDark: boolean,
themeCode: ThemeCode,
backgroundTextureId: string,
isEink: boolean,
) => {
const { bg, fg, primary, isDarkMode } = themeCode;
const hasBackgroundTexture = !!backgroundTextureId && backgroundTextureId !== 'none';
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};
}
${isEink ? getEinkSelectionStyles() : ''}
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);
}
body {
${isEink ? `background-color: ${bg} !important;` : ''}
}
section, aside, blockquote, article, nav, header, footer, main, figure,
div, p, font, h1, h2, h3, h4, h5, h6, li, span {
${overrideColor && !hasBackgroundTexture ? `background-color: ${bg} !important;` : ''}
${overrideColor && !hasBackgroundTexture ? `color: ${fg} !important;` : ''}
${overrideColor && !hasBackgroundTexture ? `border-color: ${fg} !important;` : ''}
}
pre, span { /* inline code blocks */
${overrideColor ? `background-color: ${bg} !important;` : ''}
}
a:any-link {
${overrideColor ? `color: ${primary} !important;` : isDarkMode ? `color: lightblue;` : ''}
text-decoration: ${isEink ? 'underline' : 'none'};
}
body.pbg {
${isDarkMode ? `background-color: ${bg} !important;` : ''}
}
img {
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
${isDarkMode && overrideColor ? 'filter: grayscale(100%) contrast(1.2) brightness(1.2);' : ''}
${overrideColor ? 'mix-blend-mode: multiply;' : ''}
}
svg, img {
${overrideColor ? `background-color: transparent !important;` : ''};
}
/* horizontal rule #1649 */
*:has(> hr.background-img):not(body) {
background-color: ${bg};
}
hr.background-img {
mix-blend-mode: multiply;
}
p[width][height] > img:only-child {
mix-blend-mode: multiply;
}
/* inline images */
*:has(> img.has-text-siblings):not(body) {
${overrideColor ? `background-color: ${bg};` : ''}
}
p img.has-text-siblings, span img.has-text-siblings, sup img.has-text-siblings {
mix-blend-mode: ${isDarkMode ? 'screen' : 'multiply'};
}
table:has(> colgroup) {
table-layout: fixed;
}
td, th {
word-break: break-word;
overflow-wrap: anywhere;
}
/* 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 {
${isDarkMode ? `background: color-mix(in srgb, ${bg} 80%, #000);` : ''}
}
/* Only tint table descendants when the user has opted into color override.
By default, leave them transparent so a plain table (and the invisible
spacer cells some books use for vertical layout) keeps the page
background instead of a different shade. Illegible light/zebra table
backgrounds are handled separately by the dark-mode light-background
rewriters (getDarkModeLightBackgroundOverrides / transformStylesheet).
See #4419 (and #2377, which this gate originally fixed). */
blockquote, table * {
${isDarkMode && overrideColor ? `background: color-mix(in srgb, ${bg} 80%, #000);` : ''}
${isDarkMode && overrideColor ? `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;
}
${isDarkMode && !overrideColor ? getDarkModeLightBackgroundOverrides(bg) : ''}
/* 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;
}
.calibre {
color: unset;
background-color: unset;
}
`;
return colorStyles;
};
const getPageLayoutStyles = (
marginTop: number,
marginRight: number,
marginBottom: number,
marginLeft: number,
zoomLevel: number,
writingMode: string,
vertical: boolean,
) => `
html {
--margin-top: ${marginTop}px;
--margin-right: ${marginRight}px;
--margin-bottom: ${marginBottom}px;
--margin-left: ${marginLeft}px;
}
html, body {
${writingMode === 'auto' ? '' : `writing-mode: ${writingMode} !important;`}
max-height: unset;
-webkit-touch-callout: none;
-webkit-user-select: text;
}
body {
overflow: unset;
zoom: ${zoomLevel};
padding: unset;
margin: unset;
}
svg:where(:not([width])), img:where(:not([width])) {
width: auto;
}
svg:where(:not([height])), img:where(:not([height])) {
height: auto;
}
figure > div:has(img) {
height: auto !important;
}
/* enlarge the clickable area of links */
a {
position: relative !important;
}
a::before {
content: '';
position: absolute;
inset: -10px;
}
.${SCROLL_WRAPPER_CLASS} {
display: block;
overflow: auto;
max-width: 100%;
touch-action: pan-x pan-y;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.${SCROLL_WRAPPER_FIT_CLASS} {
overflow: visible;
}
.${SCROLL_WRAPPER_CLASS} > table {
display: table !important;
max-width: 100%;
}
pre, code, math {
white-space: pre-wrap !important;
scrollbar-width: none;
}
math {
overflow: auto;
}
table, math {
max-width: calc(var(--available-width) * 1px);
max-height: calc(var(--available-height) * 1px);
}
.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 */
body {
line-height: unset;
}
.duokan-footnote-content,
.duokan-footnote-item {
display: none;
}
.duokan-image-gallery-cell {
height: calc(var(--available-height) * 1px);
}
.duokan-image-gallery-cell img {
height: 90%;
}
div:has(> img, > svg) {
max-width: 100% !important;
}
body.paginated-mode td:has(img), body.paginated-mode td :has(img) {
max-height: calc(var(--available-height) * 0.8 * 1px);
}
figure.code {
overflow: unset !important;
}
/* 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 {
${vertical ? 'width: 1em;' : 'height: 1em;'}
vertical-align: baseline;
}
: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;
}
div:has(img.singlepage) {
position: relative;
width: auto;
height: auto;
}
/* some mobi */
p[width][height] > img:only-child {
width: unset !important;
height: unset !important;
}
/* page break */
body.paginated-mode div[style*="page-break-after: always"],
body.paginated-mode div[style*="page-break-after:always"],
body.paginated-mode p[style*="page-break-after: always"],
body.paginated-mode p[style*="page-break-after:always"] {
margin-bottom: calc(var(--available-height) * 1px);
}
.br {
display: flow-root;
}
.h5_mainbody {
overflow: unset !important;
}
`;
// Paragraph-scoped CSS controlled by the Paragraph section of the Layout
// panel. Gated by BookStyle.useBookLayout: when true, this chunk is skipped
// and the book's own paragraph formatting takes over.
const getParagraphLayoutStyles = (
overrideLayout: boolean,
paragraphMargin: number,
lineSpacing: number,
wordSpacing: number,
letterSpacing: number,
textIndent: number,
justify: boolean,
hyphenate: boolean,
vertical: boolean,
) => `
html {
--default-text-align: ${justify ? 'justify' : 'start'};
hanging-punctuation: allow-end last;
orphans: 2;
widows: 2;
}
html, body {
text-align: var(--default-text-align);
}
[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;
}
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'} ${overrideLayout ? '!important' : ''};
hyphens: ${hyphenate ? 'auto' : 'manual'} ${overrideLayout ? '!important' : ''};
-webkit-hyphenate-limit-before: 3;
-webkit-hyphenate-limit-after: 2;
-webkit-hyphenate-limit-lines: 2;
hanging-punctuation: allow-end last;
widows: 2;
}
li {
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'} ${overrideLayout ? '!important' : ''};
hyphens: ${hyphenate ? 'auto' : 'manual'} ${overrideLayout ? '!important' : ''};
}
p.aligned-center, blockquote.aligned-center,
dd.aligned-center, div.aligned-center {
text-align: center ${overrideLayout ? '!important' : ''};
}
p.aligned-left, blockquote.aligned-left,
dd.aligned-left, div.aligned-left {
${justify && overrideLayout ? 'text-align: justify !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, td p {
text-indent: initial !important;
}
p {
${vertical ? `margin-left: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${vertical ? `margin-right: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${vertical ? `margin-top: unset ${overrideLayout ? '!important' : ''};` : ''}
${vertical ? `margin-bottom: unset ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-top: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-bottom: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-left: unset ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-right: unset ${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;` : ''}
}
p > font:only-child {
display: flow-root;
}
:lang(zh), :lang(ja), :lang(ko) {
widows: 1;
orphans: 1;
}
/* 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; }
img.pi {
${vertical ? 'transform: rotate(90deg);' : ''}
${vertical ? 'transform-origin: center;' : ''}
${vertical ? 'height: 2em;' : ''}
${vertical ? `width: ${lineSpacing}em;` : ''}
${vertical ? `vertical-align: unset;` : ''}
}
.nonindent, .noindent {
text-indent: unset !important;
}
`;
export const getFootnoteStyles = () => `
.duokan-footnote-content,
.duokan-footnote-item {
display: block !important;
}
body {
padding: 1em !important;
overflow-wrap: break-word;
}
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;
}
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;
}
`;
/**
* Baseline stylesheet injected into every dictionary card's shadow root
* (alongside any loose `.css` files imported with the bundle and any
* `` references resolved from the MDD).
*
* The seam exists so app-wide rules can be added in one place without
* touching the provider code. Currently it ships:
*/
export const getDictStyles = (bg: string, fg: string, isDarkMode: boolean) => {
void fg;
return `
a:empty {
background-color: transparent;
mix-blend-mode: multiply;
}
a img {
mix-blend-mode: multiply;
}
div[data-dict-kind="mdict"] .entry_name {
font-size: 1.2em;
margin-block-start: 0.5em;
margin-block-end: 0.5em;
}
div[data-dict-kind="mdict"] .juan_drop {
${isDarkMode ? `background-color: color-mix(in srgb, ${bg} 80%, #000);` : ''}
}
`;
};
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;
}
`;
const getWarichuStyles = () => `
/* Warichu (割注/夹注) — double-line inline annotation */
.warichu-pending {
display: inline;
font-size: 0.5em;
line-height: 1.1;
}
.warichu-chunk {
display: inline-block;
line-height: 1.1;
font-size: 0.5em;
text-indent: 0;
vertical-align: middle !important;
width: 1lh !important;
text-align: center !important;
}
.warichu-chunk .warichu-line {
display: inline;
}
.warichu-open,
.warichu-close {
display: inline;
font-size: 0.5em;
vertical-align: middle;
line-height: 1.1;
}
`;
// Word Lens gloss