1274 lines
43 KiB
TypeScript
1274 lines
43 KiB
TypeScript
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
|
|
* `<link rel="stylesheet">` 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 <rt> styling is user-configurable: the font size (relative to
|
|
// the word, in em) and the color. An empty color keeps the default muted,
|
|
// theme-adaptive look (inherit + 0.7 opacity); a set color paints at full opacity.
|
|
const getRubyStyles = (viewSettings: ViewSettings) => {
|
|
const fontSize = viewSettings.wordLensGlossFontSize || 0.5;
|
|
const color = viewSettings.wordLensGlossColor || '';
|
|
return `
|
|
rt {
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
rp {
|
|
display: none !important;
|
|
}
|
|
ruby.wl-gloss {
|
|
cursor: help;
|
|
}
|
|
ruby.wl-gloss > rt {
|
|
font-size: ${fontSize}em;
|
|
line-height: 1.1;
|
|
${color ? `color: ${color};\n opacity: 1;` : 'opacity: 0.7;'}
|
|
font-weight: normal;
|
|
text-align: center;
|
|
}
|
|
`;
|
|
};
|
|
|
|
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';
|
|
systemIsDarkMode = localStorage.getItem('systemIsDarkMode') === 'true';
|
|
customThemes = JSON.parse(localStorage.getItem('customThemes') || '[]');
|
|
}
|
|
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,
|
|
customFonts: CustomFont[] = [],
|
|
) => {
|
|
if (!themeCode) {
|
|
themeCode = getThemeCode();
|
|
}
|
|
const pageLayoutStyles = getPageLayoutStyles(
|
|
viewSettings.marginTopPx,
|
|
viewSettings.marginRightPx,
|
|
viewSettings.marginBottomPx,
|
|
viewSettings.marginLeftPx,
|
|
1.0,
|
|
viewSettings.writingMode!,
|
|
viewSettings.vertical!,
|
|
);
|
|
const paragraphLayoutStyles = viewSettings.useBookLayout
|
|
? ''
|
|
: getParagraphLayoutStyles(
|
|
viewSettings.overrideLayout!,
|
|
viewSettings.paragraphMargin!,
|
|
viewSettings.lineHeight!,
|
|
viewSettings.wordSpacing!,
|
|
viewSettings.letterSpacing!,
|
|
viewSettings.textIndent!,
|
|
viewSettings.fullJustification!,
|
|
viewSettings.hyphenation!,
|
|
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!,
|
|
);
|
|
// Inline `@font-face` rules for the caller-supplied custom fonts so
|
|
// they ship to the iframe synchronously with the rest of the
|
|
// stylesheet. Paginator injects this CSS into the iframe `<style>`
|
|
// before the 'load' event fires, so the first paint already resolves
|
|
// the configured font instead of falling back to serif/sans-serif and
|
|
// visibly swapping a moment later. Blob URLs are already in memory, so
|
|
// no network round-trip happens here.
|
|
const customFontFaces = getCustomFontFaces(customFonts);
|
|
const colorStyles = getColorStyles(
|
|
viewSettings.overrideColor!,
|
|
viewSettings.invertImgColorInDark!,
|
|
themeCode,
|
|
viewSettings.backgroundTextureId,
|
|
viewSettings.isEink,
|
|
);
|
|
const translationStyles = getTranslationStyles(viewSettings.showTranslateSource!);
|
|
const warichuStyles = getWarichuStyles();
|
|
const rubyStyles = getRubyStyles(viewSettings);
|
|
const userStylesheet = viewSettings.userStylesheet!;
|
|
// The `@namespace` declaration must lead the stylesheet: a `@namespace` rule
|
|
// placed after any style or `@font-face` rule is invalid and silently ignored,
|
|
// which drops the namespaced `aside[epub|type~="footnote"]` selector and lets
|
|
// the footnote aside's border show as a stray horizontal line (#4438). Keep it
|
|
// ahead of the inlined custom `@font-face` rules.
|
|
const epubNamespace = `@namespace epub "http://www.idpf.org/2007/ops";`;
|
|
return `${epubNamespace}\n${customFontFaces}\n${pageLayoutStyles}\n${paragraphLayoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${warichuStyles}\n${rubyStyles}\n${userStylesheet}`;
|
|
};
|
|
|
|
// Build a CSS chunk of `@font-face` rules for the given user custom
|
|
// fonts. The caller (a reader component) owns the font store and passes
|
|
// in the loaded fonts, keeping this util free of store dependencies.
|
|
// Fonts without a blob URL are skipped; createFontCSS throws when the
|
|
// blob URL is unset, so the inner try/catch keeps a single bad font from
|
|
// breaking the whole stylesheet.
|
|
const getCustomFontFaces = (fonts: CustomFont[]): string =>
|
|
fonts
|
|
.filter((font) => !!font.blobUrl)
|
|
.map((font) => {
|
|
try {
|
|
return createFontCSS(font);
|
|
} catch {
|
|
return '';
|
|
}
|
|
})
|
|
.join('\n');
|
|
|
|
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 = (css: string, vw: number, vh: number, vertical: boolean) => {
|
|
const isMobile = ['ios', 'android'].includes(getOSPlatform());
|
|
const fontScale = isMobile ? 1.25 : 1;
|
|
const isInlineStyle = !css.includes('{');
|
|
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;
|
|
});
|
|
|
|
// clip nowrapped elements
|
|
css = css.replace(ruleRegex, (match, selector, block) => {
|
|
const hasWhiteSpaceNowrap = /white-space\s*:\s*nowrap\s*[;$]/.test(block);
|
|
if (hasWhiteSpaceNowrap) {
|
|
if (!/overflow\s*:/.test(block)) {
|
|
block = block.replace(/}$/, ' overflow: clip !important; }');
|
|
}
|
|
return selector + block;
|
|
}
|
|
return match;
|
|
});
|
|
|
|
if (isInlineStyle) {
|
|
const hasPageBreakAfterAlways = /page-break-after\s*:\s*always\s*[;]?/.test(css);
|
|
if (hasPageBreakAfterAlways && !/margin-bottom\s*:/.test(css)) {
|
|
css = css.replace(/;?\s*$/, '') + '; margin-bottom: calc(var(--available-height) * 1px)';
|
|
}
|
|
} else {
|
|
css = css.replace(ruleRegex, (match, selector, block) => {
|
|
const hasPageBreakAfterAlways = /page-break-after\s*:\s*always\s*[;$]/.test(block);
|
|
if (hasPageBreakAfterAlways) {
|
|
if (!/margin-bottom\s*:/.test(block)) {
|
|
block = block.replace(/}$/, ' margin-bottom: calc(var(--available-height) * 1px); }');
|
|
}
|
|
return selector + block;
|
|
}
|
|
return match;
|
|
});
|
|
}
|
|
|
|
// Process duokan-bleed
|
|
css = css.replace(ruleRegex, (_, selector, block) => {
|
|
if (vertical) return selector + block;
|
|
|
|
const directions: string[] = [];
|
|
let hasBleed = false;
|
|
for (const dir of ['top', 'bottom', 'left', 'right']) {
|
|
const bleedRegex = new RegExp(`duokan-bleed\\s*:\\s*[^;]*${dir}[^;]*;`);
|
|
const marginRegex = new RegExp(`margin-${dir}\\s*:`);
|
|
if (bleedRegex.test(block) && !marginRegex.test(block)) {
|
|
hasBleed = true;
|
|
directions.push(dir);
|
|
block = block.replace(
|
|
/}$/,
|
|
` margin-${dir}: calc(-1 * var(--page-margin-${dir})) !important; }`,
|
|
);
|
|
}
|
|
}
|
|
if (hasBleed) {
|
|
if (!/position\s*:/.test(block)) {
|
|
block = block.replace(/}$/, ' position: relative !important; }');
|
|
}
|
|
if (!/overflow\s*:/.test(block)) {
|
|
block = block.replace(/}$/, ' overflow: hidden !important; }');
|
|
}
|
|
if (!/display\s*:/.test(block)) {
|
|
block = block.replace(/}$/, ' display: flow-root !important; }');
|
|
}
|
|
if (directions.includes('left') && directions.includes('right')) {
|
|
block = block
|
|
.replace(/}$/, ' width: calc(var(--full-width) * 1px) !important; }')
|
|
.replace(/}$/, ' min-width: calc(var(--full-width) * 1px) !important; }')
|
|
.replace(/}$/, ' max-width: calc(var(--full-width) * 1px) !important; }');
|
|
}
|
|
if (directions.includes('top') && directions.includes('bottom')) {
|
|
block = block
|
|
.replace(/}$/, ' height: calc(var(--full-height) * 1px) !important; }')
|
|
.replace(/}$/, ' min-height: calc(var(--full-height) * 1px) !important; }')
|
|
.replace(/}$/, ' max-height: calc(var(--full-height) * 1px) !important; }');
|
|
}
|
|
}
|
|
return selector + block;
|
|
});
|
|
|
|
// unset font-family for body when set to serif or sans-serif
|
|
css = css.replace(ruleRegex, (_, selector, block) => {
|
|
if (/\bbody\b/i.test(selector)) {
|
|
const hasSerifFont = /font-family\s*:\s*serif\s*(?:;|\}|$)/.test(block);
|
|
const hasSansSerifFont = /font-family\s*:\s*sans-serif\s*(?:;|\}|$)/.test(block);
|
|
if (hasSerifFont) {
|
|
block = block.replace(/font-family\s*:\s*serif\s*(;|\}|$)/gi, 'font-family: unset$1');
|
|
}
|
|
if (hasSansSerifFont) {
|
|
block = block.replace(/font-family\s*:\s*sans-serif\s*(;|\}|$)/gi, 'font-family: unset$1');
|
|
}
|
|
}
|
|
return selector + block;
|
|
});
|
|
|
|
// clip hardcoded pixel widths to available width when they exceed viewport
|
|
css = css.replace(ruleRegex, (match, selector, block) => {
|
|
const widthMatch = /(?:^|[^a-z-])width\s*:\s*(\d+(?:\.\d+)?)px/.exec(block);
|
|
const pxWidth = widthMatch ? parseFloat(widthMatch[1] ?? '0') : 0;
|
|
if (pxWidth > vw && !/max-width\s*:/.test(block)) {
|
|
block = block.replace(
|
|
/}$/,
|
|
' width: 100%; max-width: calc(var(--available-width) * 1px); box-sizing: border-box; }',
|
|
);
|
|
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(/font-size\s*:\s*(\d*\.?\d+)(px|rem|em|%)?/gi, (_, size, unit = 'px') => {
|
|
return `font-size: max(${size}${unit}, var(--min-font-size, 8px))`;
|
|
})
|
|
// remove no-op backdrop-filter: brightness(100%); see #3895
|
|
.replace(/backdrop-filter\s*:\s*brightness\(100%\)\s*[;]?/gi, '')
|
|
.replace(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * vw) / 100 + 'px')
|
|
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * vh) / 100 + 'px')
|
|
.replace(/([\s;])-webkit-user-select\s*:\s*none/gi, '$1-webkit-user-select: unset')
|
|
.replace(/([\s;])-moz-user-select\s*:\s*none/gi, '$1-moz-user-select: unset')
|
|
.replace(/([\s;])-ms-user-select\s*:\s*none/gi, '$1-ms-user-select: unset')
|
|
.replace(/([\s;])-o-user-select\s*:\s*none/gi, '$1-o-user-select: unset')
|
|
.replace(/([\s;])user-select\s*:\s*none/gi, '$1user-select: unset')
|
|
.replace(/(font-family\s*:[^;]*?)\bsans-serif\b/gi, '$1READEST_SS_PLACEHOLDER')
|
|
.replace(/(font-family\s*:[^;]*?)\bserif\b(?!-)/gi, '$1var(--serif, serif)')
|
|
.replace(/READEST_SS_PLACEHOLDER/g, 'var(--sans-serif, sans-serif)')
|
|
.replace(/(font-family\s*:[^;]*?)\bmonospace\b/gi, '$1var(--monospace, 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)');
|
|
|
|
const { isDarkMode, bg } = getThemeCode();
|
|
if (isDarkMode) {
|
|
css = css.replace(ruleRegex, (match, selector, block) => {
|
|
const rewritten = block.replace(
|
|
/background(-color)?\s*:\s*([^;!}]+)(\s*!important)?(?=\s*[;!}])/gi,
|
|
(decl: string, _prop: string, value: string, important?: string) => {
|
|
const raw = value.trim().split(/\s+/)[0] ?? '';
|
|
if (!isLightCssColor(raw)) return decl;
|
|
return `background-color: ${bg}${important ?? ''}`;
|
|
},
|
|
);
|
|
return rewritten === block ? match : selector + rewritten;
|
|
});
|
|
}
|
|
|
|
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 applyScrollModeClass = (document: Document, isScrollMode: boolean) => {
|
|
document.body.classList.remove('scroll-mode', 'paginated-mode');
|
|
document.body.classList.add(isScrollMode ? 'scroll-mode' : 'paginated-mode');
|
|
};
|
|
|
|
/**
|
|
@param document should be the global `document`
|
|
*/
|
|
export const applyScrollbarStyle = (document: Document, hideScrollbar: boolean) => {
|
|
const styleId = 'scrollbar-hide-style';
|
|
let styleEl = document.getElementById(styleId) as HTMLStyleElement;
|
|
|
|
if (hideScrollbar) {
|
|
if (!styleEl) {
|
|
styleEl = document.createElement('style');
|
|
styleEl.id = styleId;
|
|
document.head.appendChild(styleEl);
|
|
}
|
|
styleEl.textContent = 'foliate-view::part(container) { scrollbar-width: none; }';
|
|
} else {
|
|
if (styleEl) {
|
|
styleEl.textContent = 'foliate-view::part(container) { scrollbar-width: thin; }';
|
|
}
|
|
}
|
|
};
|
|
|
|
export const applyImageStyle = (document: Document) => {
|
|
document.querySelectorAll('img').forEach((img) => {
|
|
const widthAttr = img.getAttribute('width');
|
|
if (widthAttr && (widthAttr.endsWith('%') || widthAttr.endsWith('vw'))) {
|
|
const percentage = parseFloat(widthAttr);
|
|
if (!isNaN(percentage)) {
|
|
img.style.width = `${(percentage / 100) * window.innerWidth}px`;
|
|
img.removeAttribute('width');
|
|
}
|
|
}
|
|
|
|
const heightAttr = img.getAttribute('height');
|
|
if (heightAttr && (heightAttr.endsWith('%') || heightAttr.endsWith('vh'))) {
|
|
const percentage = parseFloat(heightAttr);
|
|
if (!isNaN(percentage)) {
|
|
img.style.height = `${(percentage / 100) * window.innerHeight}px`;
|
|
img.removeAttribute('height');
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
});
|
|
document.querySelectorAll('hr').forEach((hr) => {
|
|
const computedStyle = window.getComputedStyle(hr);
|
|
if (computedStyle.backgroundImage && computedStyle.backgroundImage !== 'none') {
|
|
hr.classList.add('background-img');
|
|
}
|
|
});
|
|
};
|
|
|
|
export const keepTextAlignment = (document: Document) => {
|
|
// Why two-phase: the previous version read getComputedStyle and wrote
|
|
// classList.add inside the same forEach pass. classList.add invalidates
|
|
// the document's style cache (CSS selectors may target the class on
|
|
// descendants), so the next getComputedStyle() in the loop forced the
|
|
// browser to recompute style for the whole document. With ~hundreds of
|
|
// p/div/blockquote/dd elements per chapter (a typical Harry Potter
|
|
// section) that turned the loop into N x layout — visible on a release
|
|
// Android build as a 1210ms "Forced reflow" violation in the browser
|
|
// console and the dominant chunk of "Layout = 32.8% of TBT" in the
|
|
// open-book Performance trace.
|
|
//
|
|
// Two-phase read-then-write keeps the loop O(N) elements + 1 recalc
|
|
// instead of O(N) recalcs.
|
|
const win = document.defaultView ?? window;
|
|
const els = document.querySelectorAll('div, p, blockquote, dd');
|
|
const alignClasses = new Array<string | null>(els.length);
|
|
// Read pass: collect computed text-align for every element. The browser
|
|
// computes style once for the whole document on the first call, then
|
|
// every subsequent getComputedStyle in this pass reuses that result.
|
|
for (let i = 0; i < els.length; i++) {
|
|
const align = win.getComputedStyle(els[i]!).textAlign;
|
|
if (align === 'center') alignClasses[i] = 'aligned-center';
|
|
else if (align === 'left') alignClasses[i] = 'aligned-left';
|
|
else if (align === 'right') alignClasses[i] = 'aligned-right';
|
|
else if (align === 'justify') alignClasses[i] = 'aligned-justify';
|
|
else alignClasses[i] = null;
|
|
}
|
|
// Write pass: applies all classList changes in a single batch. Style
|
|
// invalidation happens once at the end, when the next layout-affecting
|
|
// operation forces a flush.
|
|
for (let i = 0; i < els.length; i++) {
|
|
const cls = alignClasses[i];
|
|
if (cls) els[i]!.classList.add(cls);
|
|
}
|
|
};
|
|
|
|
export const applyFixedlayoutStyles = (
|
|
document: Document,
|
|
viewSettings: ViewSettings,
|
|
themeCode?: ThemeCode,
|
|
) => {
|
|
if (!themeCode) {
|
|
themeCode = getThemeCode();
|
|
}
|
|
const { bg, fg, primary, isDarkMode } = themeCode;
|
|
const isEink = viewSettings.isEink;
|
|
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);
|
|
}
|
|
${isEink ? getEinkSelectionStyles() : ''}
|
|
#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);
|
|
};
|