feat: support importing TTF/ODF fonts, closes #979 and closes #1708 (#1864)

This commit is contained in:
Huang Xin
2025-08-22 03:49:08 +08:00
committed by GitHub
parent 149f94be4c
commit d390209dab
48 changed files with 1398 additions and 206 deletions
@@ -7,6 +7,8 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePagination } from '../hooks/usePagination';
@@ -21,7 +23,7 @@ import {
getStyles,
transformStylesheet,
} from '@/utils/style';
import { mountAdditionalFonts } from '@/utils/font';
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
@@ -57,12 +59,14 @@ const FoliateViewer: React.FC<{
config: BookConfig;
contentInsets: Insets;
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
const { appService, envConfig } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const { settings } = useSettingsStore();
const { loadCustomFonts, getLoadedFonts } = useCustomFontStore();
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey);
const viewRef = useRef<FoliateView | null>(null);
@@ -79,12 +83,8 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
const {
syncState,
conflictDetails,
resolveConflictWithLocal,
resolveConflictWithRemote,
} = useKOSync(bookKey);
const { syncState, conflictDetails, resolveConflictWithLocal, resolveConflictWithRemote } =
useKOSync(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
@@ -144,6 +144,10 @@ const FoliateViewer: React.FC<{
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
getLoadedFonts().forEach((font) => {
mountCustomFont(detail.doc, font);
});
if (bookDoc.rendition?.layout === 'pre-paginated') {
applyFixedlayoutStyles(detail.doc, viewSettings);
}
@@ -335,6 +339,21 @@ const FoliateViewer: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
useEffect(() => {
const mountCustomFonts = async () => {
await loadCustomFonts(envConfig);
getLoadedFonts().forEach((font) => {
mountCustomFont(document, font);
const docs = viewRef.current?.renderer.getContents();
docs?.forEach(({ doc }) => mountCustomFont(doc, font));
});
};
if (settings.customFonts) {
mountCustomFonts();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.customFonts, envConfig]);
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
@@ -7,7 +7,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import { mountAdditionalFonts } from '@/utils/font';
import { mountAdditionalFonts } from '@/styles/fonts';
import { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
import { isCJKLang } from '@/utils/lang';
@@ -16,7 +16,7 @@ import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { eventDispatcher } from '@/utils/event';
import { interceptWindowOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/utils/font';
import { mountAdditionalFonts } from '@/styles/fonts';
import { isTauriAppPlatform } from '@/services/environment';
import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
@@ -0,0 +1,171 @@
import React, { useState } from 'react';
import { MdAdd, MdDelete } from 'react-icons/md';
import { IoMdCloseCircleOutline } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useCustomFontStore } from '@/store/customFontStore';
import { FILE_SELECTION_PRESETS, useFileSelector } from '@/hooks/useFileSelector';
import { mountCustomFont } from '@/styles/fonts';
import { parseFontFamily } from '@/utils/font';
import { getFilename } from '@/utils/path';
interface CustomFontsProps {
onBack: () => void;
}
const CustomFonts: React.FC<CustomFontsProps> = ({ onBack }) => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
const {
fonts: customFonts,
addFont,
loadFont,
removeFont,
getAvailableFonts,
saveCustomFonts,
} = useCustomFontStore();
const [isDeleteMode, setIsDeleteMode] = useState(false);
const { selectFiles } = useFileSelector(appService, _);
const handleImportFont = () => {
selectFiles({ ...FILE_SELECTION_PRESETS.fonts, multiple: true }).then(async (result) => {
if (result.error || result.files.length === 0) return;
if (!(await appService!.fs.exists('', 'Fonts'))) {
await appService!.fs.createDir('', 'Fonts');
}
for (const selectedFile of result.files) {
let fontPath: string;
let fontFile: File;
if (selectedFile.path) {
const filePath = selectedFile.path;
fontPath = getFilename(filePath);
await appService!.fs.copyFile(filePath, fontPath, 'Fonts');
fontFile = await appService!.fs.openFile(fontPath, 'Fonts');
} else if (selectedFile.file) {
const file = selectedFile.file;
fontPath = getFilename(file.name);
await appService!.fs.writeFile(fontPath, 'Fonts', file);
fontFile = file;
} else {
continue;
}
const fontFamily = parseFontFamily(await fontFile.arrayBuffer(), fontPath);
const customFont = addFont(fontPath, {
name: fontFamily,
});
if (customFont && !customFont.error) {
const loadedFont = await loadFont(envConfig, customFont.id);
mountCustomFont(document, loadedFont);
}
}
saveCustomFonts(envConfig);
});
};
const handleDeleteFont = (fontId: string) => {
const font = customFonts.find((f) => f.id === fontId);
if (font) {
if (removeFont(fontId)) {
appService!.fs.removeFile(font.path, 'Fonts');
saveCustomFonts(envConfig);
if (getAvailableFonts().length === 0) {
setIsDeleteMode(false);
}
}
}
};
const toggleDeleteMode = () => {
setIsDeleteMode(!isDeleteMode);
};
const availableFonts = customFonts
.filter((font) => !font.deletedAt)
.sort((a, b) => (b.downloadedAt || 0) - (a.downloadedAt || 0));
return (
<div className='w-full'>
<div className='mb-6 flex h-8 items-center justify-between'>
<div className='breadcrumbs py-1 text-sm font-medium'>
<ul>
<li>
<a onClick={onBack}>{_('Font')}</a>
</li>
<li>{_('Custom Fonts')}</li>
</ul>
</div>
{availableFonts.length > 0 && (
<button
onClick={toggleDeleteMode}
className={`btn btn-ghost btn-sm text-base-content gap-2`}
title={isDeleteMode ? _('Cancel Delete') : _('Delete Font')}
>
{isDeleteMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdDelete className='h-4 w-4' />
{_('Delete')}
</>
)}
</button>
)}
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='card border-primary/50 hover:border-primary/75 group h-12 border-2 transition-colors'>
<div
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
onClick={handleImportFont}
>
<div className='flex items-center gap-2'>
<div className='flex items-center justify-center'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
</div>
<div className='text-primary/85 group-hover:text-primary font-medium'>
{_('Import Font')}
</div>
</div>
</div>
</div>
{availableFonts.map((font) => (
<div key={font.id} className='card border-base-200 bg-base-200 h-12 border shadow-sm'>
<div className='card-body flex items-center justify-center p-2'>
<div
style={{
fontFamily: font.loaded ? `"${font.name}", serif` : 'serif',
fontWeight: 400,
}}
className='text-base-content line-clamp-1 max-w-[90%]'
>
{font.name}
</div>
{isDeleteMode && (
<button
onClick={() => handleDeleteFont(font.id)}
className='btn btn-ghost btn-xs absolute right-[-10px] top-[-10px] h-6 min-h-0 w-6 p-0 hover:bg-transparent'
title={_('Delete Font')}
>
<IoMdCloseCircleOutline className='text-base-content/75 h-6 w-6' />
</button>
)}
</div>
</div>
))}
</div>
<div className='bg-base-200/30 my-8 rounded-lg p-4'>
<div className='text-base-content/70'>
<div className='mb-1 text-xs font-medium'>{_('Tips')}:</div>
<ul className='list-inside list-disc space-y-1 text-xs'>
<li>{_('Custom fonts can be selected from the Font Face menu')}</li>
</ul>
</div>
</div>
</div>
);
};
export default CustomFonts;
@@ -14,10 +14,16 @@ interface DialogMenuProps {
resetLabel?: string;
}
const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, resetLabel }) => {
const DialogMenu: React.FC<DialogMenuProps> = ({
activePanel,
setIsDropdownOpen,
onReset,
resetLabel,
}) => {
const _ = useTranslation();
const iconSize = useResponsiveSize(16);
const { isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } = useSettingsStore();
const { setFontPanelView, isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } =
useSettingsStore();
const handleToggleGlobal = () => {
setFontLayoutSettingsGlobal(!isFontLayoutSettingsGlobal);
@@ -29,6 +35,11 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, res
setIsDropdownOpen?.(false);
};
const handleManageCustomFont = () => {
setFontPanelView('custom-fonts');
setIsDropdownOpen?.(false);
};
return (
<div
tabIndex={0}
@@ -49,6 +60,9 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, res
onClick={handleToggleGlobal}
/>
<MenuItem label={resetLabel || _('Reset Settings')} onClick={handleResetToDefaults} />
{activePanel === 'Font' && (
<MenuItem label={_('Manage Custom Fonts')} onClick={handleManageCustomFont} />
)}
</div>
);
};
@@ -1,11 +1,11 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdSettings } from 'react-icons/md';
import {
ANDROID_FONTS,
CJK_EXCLUDE_PATTENS,
CJK_FONTS_PATTENS,
CJK_NAMES_PATTENS,
CJK_SANS_SERIF_FONTS,
CJK_SERIF_FONTS,
IOS_FONTS,
@@ -17,27 +17,33 @@ import {
SERIF_FONTS,
WINDOWS_FONTS,
} from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
import { getSysFontsList } from '@/utils/bridge';
import { isCJKStr } from '@/utils/lang';
import { isTauriAppPlatform } from '@/services/environment';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { useResetViewSettings } from '../../hooks/useResetSettings';
import { SettingsPanelPanelProp } from './SettingsDialog';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
import CustomFonts from './CustomFonts';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
const genCJKFontsList = (sysFonts: string[]) => {
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
.filter((font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font))
.filter((font) => CJK_FONTS_PATTENS.test(font) || isCJKStr(font))
.filter((font) => !CJK_EXCLUDE_PATTENS.test(font))
.sort((a, b) => a.localeCompare(b));
};
const isSymbolicFontName = (font: string) =>
/emoji|icons|symbol|dingbats|ornaments|webdings|wingdings|miuiex/i.test(font);
interface FontFaceProps {
className?: string;
family: string;
@@ -85,8 +91,11 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const _ = useTranslation();
const { envConfig } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const { fontPanelView, setFontPanelView } = useSettingsStore();
const { fonts: allCustomFonts, getFontFamilies } = useCustomFontStore();
const viewSettings = getViewSettings(bookKey)!;
const view = getView(bookKey)!;
const iconSize18 = useResponsiveSize(18);
const fontFamilyOptions = [
{
@@ -130,8 +139,10 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
const [customFonts, setCustomFonts] = useState<string[]>(getFontFamilies());
const [CJKFonts, setCJKFonts] = useState<string[]>(() => {
return genCJKFontsList(sysFonts);
return genCJKFontsList([...customFonts, ...sysFonts]);
});
const resetToDefaults = useResetViewSettings();
@@ -150,6 +161,14 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
});
};
const handleManageCustomFonts = () => {
setFontPanelView('custom-fonts');
};
const handleBackToMain = () => {
setFontPanelView('main-fonts');
};
useEffect(() => {
onRegisterReset(handleReset);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -157,10 +176,14 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
useEffect(() => {
setCJKFonts((prev) => {
const newFonts = genCJKFontsList(sysFonts);
const newFonts = genCJKFontsList([...customFonts, ...sysFonts]);
return prev.length !== newFonts.length ? newFonts : prev;
});
}, [sysFonts]);
}, [customFonts, sysFonts]);
useEffect(() => {
setCustomFonts(getFontFamilies());
}, [allCustomFonts, getFontFamilies]);
useEffect(() => {
if (isTauriAppPlatform()) {
@@ -247,6 +270,14 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
}
};
if (fontPanelView === 'custom-fonts') {
return (
<div className='my-4 w-full'>
<CustomFonts onBack={handleBackToMain} />
</div>
);
}
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
@@ -326,14 +357,27 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Face')}</h2>
<div className='mb-2 flex items-center justify-between'>
<h2 className='font-medium'>{_('Font Face')}</h2>
<button
onClick={handleManageCustomFonts}
className='btn btn-ghost btn-xs gap-1 hover:bg-transparent'
title={_('Manage Custom Fonts')}
>
<MdSettings size={iconSize18} />
</button>
</div>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<FontFace
className='config-item-top'
family='serif'
label={_('Serif Font')}
options={[...SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SERIF_FONTS]}
options={[
...customFonts,
...SERIF_FONTS.filter(filterNonFreeFonts),
...CJK_SERIF_FONTS,
]}
moreOptions={sysFonts}
selected={serifFont}
onSelect={setSerifFont}
@@ -341,7 +385,11 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<FontFace
family='sans-serif'
label={_('Sans-Serif Font')}
options={[...SANS_SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SANS_SERIF_FONTS]}
options={[
...customFonts,
...SANS_SERIF_FONTS.filter(filterNonFreeFonts),
...CJK_SANS_SERIF_FONTS,
]}
moreOptions={sysFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
@@ -350,7 +398,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
className='config-item-bottom'
family='monospace'
label={_('Monospace Font')}
options={MONOSPACE_FONTS}
options={[...customFonts, ...MONOSPACE_FONTS]}
moreOptions={sysFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
@@ -93,6 +93,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<ul className='max-h-60 overflow-y-auto'>
{getVisibleLibrary()
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
.filter((book) => !!book.downloadedAt)
.slice(0, 20)
.map((book) => (
<MenuItem
@@ -182,7 +182,7 @@ const TOCView: React.FC<{
(activeItem as HTMLElement).setAttribute('aria-current', 'page');
}
}
// es-lint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeHref]);
const virtualItemSize = useMemo(() => {