From d390209dab81b00cd2cbfa55f7da6c0f83ae7ddc Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 22 Aug 2025 03:49:08 +0800 Subject: [PATCH] feat: support importing TTF/ODF fonts, closes #979 and closes #1708 (#1864) --- .../public/locales/ar/translation.json | 14 +- .../public/locales/bo/translation.json | 18 +- .../public/locales/de/translation.json | 14 +- .../public/locales/el/translation.json | 14 +- .../public/locales/es/translation.json | 14 +- .../public/locales/fr/translation.json | 14 +- .../public/locales/hi/translation.json | 14 +- .../public/locales/id/translation.json | 14 +- .../public/locales/it/translation.json | 14 +- .../public/locales/ja/translation.json | 14 +- .../public/locales/ko/translation.json | 14 +- .../public/locales/nl/translation.json | 14 +- .../public/locales/pl/translation.json | 14 +- .../public/locales/pt/translation.json | 14 +- .../public/locales/ru/translation.json | 14 +- .../public/locales/th/translation.json | 14 +- .../public/locales/tr/translation.json | 14 +- .../public/locales/uk/translation.json | 14 +- .../public/locales/vi/translation.json | 14 +- .../public/locales/zh-CN/translation.json | 14 +- .../public/locales/zh-TW/translation.json | 14 +- .../app/library/components/KOSyncSettings.tsx | 1 + apps/readest-app/src/app/library/page.tsx | 21 +- .../app/reader/components/FoliateViewer.tsx | 37 ++- .../app/reader/components/FootnotePopup.tsx | 2 +- .../src/app/reader/components/Reader.tsx | 2 +- .../components/settings/CustomFonts.tsx | 171 ++++++++++ .../reader/components/settings/DialogMenu.tsx | 18 +- .../reader/components/settings/FontPanel.tsx | 68 +++- .../reader/components/sidebar/BookMenu.tsx | 1 + .../app/reader/components/sidebar/TOCView.tsx | 2 +- apps/readest-app/src/hooks/useFileSelector.ts | 124 +++++++ apps/readest-app/src/hooks/useTheme.ts | 2 +- apps/readest-app/src/services/appService.ts | 3 +- apps/readest-app/src/services/constants.ts | 6 +- .../src/services/nativeAppService.ts | 13 +- .../src/services/sync/KOSyncClient.ts | 12 +- .../readest-app/src/services/webAppService.ts | 4 +- apps/readest-app/src/store/customFontStore.ts | 302 ++++++++++++++++++ apps/readest-app/src/store/readerStore.ts | 3 +- apps/readest-app/src/store/settingsStore.ts | 6 + apps/readest-app/src/styles/fonts.ts | 207 ++++++++++++ apps/readest-app/src/types/settings.ts | 2 + apps/readest-app/src/types/system.ts | 2 +- apps/readest-app/src/utils/book.ts | 16 +- apps/readest-app/src/utils/font.ts | 263 ++++++++------- apps/readest-app/src/utils/path.ts | 16 + apps/readest-app/src/utils/txt.ts | 2 +- 48 files changed, 1398 insertions(+), 206 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/settings/CustomFonts.tsx create mode 100644 apps/readest-app/src/hooks/useFileSelector.ts create mode 100644 apps/readest-app/src/store/customFontStore.ts create mode 100644 apps/readest-app/src/styles/fonts.ts create mode 100644 apps/readest-app/src/utils/path.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index e99df398..9faea47f 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -520,5 +520,17 @@ "Failed to connect": "فشل في الاتصال", "Sync Server Connected": "تم الاتصال بخادم المزامنة", "Precision: {{precision}} digits after the decimal": "الدقة: {{precision}} أرقام بعد الفاصلة", - "Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}" + "Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}", + "Custom Fonts": "الخطوط المخصصة", + "Cancel Delete": "إلغاء الحذف", + "Import Font": "استيراد خط", + "Delete Font": "حذف الخط", + "Tips": "نصائح", + "Custom fonts can be selected from the Font Face menu": "يمكن اختيار الخطوط المخصصة من قائمة نوع الخط", + "Manage Custom Fonts": "إدارة الخطوط المخصصة", + "Select Files": "حدد الملفات", + "Select Image": "حدد صورة", + "Select Video": "حدد فيديو", + "Select Audio": "حدد صوت", + "Select Fonts": "حدد الخطوط" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 7a3a6bbb..e4a85d7a 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -498,7 +498,19 @@ "Remote Progress": "བརྒྱབ་འབྱུང་ཁུངས།", "All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།", "Page {{page}} of {{total}} ({{percentage}}%)": "ཤོག་ {{page}} དང་ {{total}} ({{percentage}}%)", - "Current position": "ད་ལྟ་བཞིན་འདེམས་པ།", - "Approximately page {{page}} of {{total}} ({{percentage}}%)": "ད་ལྟ་བཞིན་འདེམས་པ།", - "Approximately {{percentage}}%": "ད་ལྟ་བཞིན་འདེམས་པ།" + "Current position": "ད་ལྟའི་གནས་ས།", + "Approximately page {{page}} of {{total}} ({{percentage}}%)": "ཕྲན་བུ་ཤོག་ངོས་ {{page}} / {{total}} ({{percentage}}%)", + "Approximately {{percentage}}%": "ཕྲན་བུ་ {{percentage}}%", + "Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས།", + "Cancel Delete": "སུབ་པ་འདོར་བ།", + "Import Font": "ཡིག་གཟུགས་འདྲེན་འཇུག", + "Delete Font": "ཡིག་གཟུགས་སུབ་པ།", + "Tips": "གསལ་འདེབས།", + "Custom fonts can be selected from the Font Face menu": "རང་བཞིན་ཡིག་གཟུགས་ནི་ཡིག་གཟུགས་ངོ་བོའི་ཟ་འབྲིང་ནས་འདེམས་ཆོག", + "Manage Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས་འཛིན་སྐྱོང་།", + "Select Files": "ཡིག་ཆ་འདེམས་པ།", + "Select Image": "རི་མོ་འདེམས་པ།", + "Select Video": "བརྙན་ཟློས་འདེམས་པ།", + "Select Audio": "སྒྲ་ཟློས་འདེམས་པ།", + "Select Fonts": "ཡིག་གཟུགས་འདེམས་པ།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 2fa1e694..8560a709 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -504,5 +504,17 @@ "Failed to connect": "Verbindung fehlgeschlagen", "Sync Server Connected": "Sync-Server verbunden", "Precision: {{precision}} digits after the decimal": "Präzision: {{precision}} Nachkommastellen", - "Sync as {{userDisplayName}}": "Sync als {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Sync als {{userDisplayName}}", + "Custom Fonts": "Benutzerdefinierte Schriftarten", + "Cancel Delete": "Löschen abbrechen", + "Import Font": "Schriftart importieren", + "Delete Font": "Schriftart löschen", + "Tips": "Tipps", + "Custom fonts can be selected from the Font Face menu": "Benutzerdefinierte Schriftarten können aus dem Schriftart-Menü ausgewählt werden", + "Manage Custom Fonts": "Benutzerdefinierte Schriftarten verwalten", + "Select Files": "Dateien auswählen", + "Select Image": "Bild auswählen", + "Select Video": "Video auswählen", + "Select Audio": "Audio auswählen", + "Select Fonts": "Schriftarten auswählen" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index a7a1e5b7..0fb16ad8 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -504,5 +504,17 @@ "Failed to connect": "Αποτυχία σύνδεσης", "Sync Server Connected": "Ο διακομιστής συγχρονισμού είναι συνδεδεμένος", "Precision: {{precision}} digits after the decimal": "Ακρίβεια: {{precision}} ψηφία μετά την υποδιαστολή", - "Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}", + "Custom Fonts": "Προσαρμοσμένες Γραμματοσειρές", + "Cancel Delete": "Ακύρωση Διαγραφής", + "Import Font": "Εισαγωγή Γραμματοσειράς", + "Delete Font": "Διαγραφή Γραμματοσειράς", + "Tips": "Συμβουλές", + "Custom fonts can be selected from the Font Face menu": "Οι προσαρμοσμένες γραμματοσειρές μπορούν να επιλεγούν από το μενού Γραμματοσειράς", + "Manage Custom Fonts": "Διαχείριση Προσαρμοσμένων Γραμματοσειρών", + "Select Files": "Επιλογή Αρχείων", + "Select Image": "Επιλογή Εικόνας", + "Select Video": "Επιλογή Βίντεο", + "Select Audio": "Επιλογή Ήχου", + "Select Fonts": "Επιλογή Γραμματοσειρών" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 182435ea..6a1e287d 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -508,5 +508,17 @@ "Failed to connect": "Error al conectar", "Sync Server Connected": "Servidor de sincronización conectado", "Precision: {{precision}} digits after the decimal": "Precisión: {{precision}} dígitos después del decimal", - "Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}", + "Custom Fonts": "Fuentes Personalizadas", + "Cancel Delete": "Cancelar Eliminación", + "Import Font": "Importar Fuente", + "Delete Font": "Eliminar Fuente", + "Tips": "Consejos", + "Custom fonts can be selected from the Font Face menu": "Las fuentes personalizadas se pueden seleccionar desde el menú de Tipografía", + "Manage Custom Fonts": "Administrar Fuentes Personalizadas", + "Select Files": "Seleccionar Archivos", + "Select Image": "Seleccionar Imagen", + "Select Video": "Seleccionar Video", + "Select Audio": "Seleccionar Audio", + "Select Fonts": "Seleccionar Fuentes" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 9d778905..b2022260 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -508,5 +508,17 @@ "Failed to connect": "Échec de la connexion", "Sync Server Connected": "Serveur de synchronisation connecté", "Precision: {{precision}} digits after the decimal": "Précision : {{precision}} chiffres après la virgule", - "Sync as {{userDisplayName}}": "Synchroniser en tant que {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Synchroniser en tant que {{userDisplayName}}", + "Custom Fonts": "Polices Personnalisées", + "Cancel Delete": "Annuler la Suppression", + "Import Font": "Importer une Police", + "Delete Font": "Supprimer la Police", + "Tips": "Conseils", + "Custom fonts can be selected from the Font Face menu": "Les polices personnalisées peuvent être sélectionnées depuis le menu Police", + "Manage Custom Fonts": "Gérer les Polices Personnalisées", + "Select Files": "Sélectionner des Fichiers", + "Select Image": "Sélectionner une Image", + "Select Video": "Sélectionner une Vidéo", + "Select Audio": "Sélectionner un Audio", + "Select Fonts": "Sélectionner des Polices" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index e60ac4da..495d9577 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -504,5 +504,17 @@ "Failed to connect": "कनेक्ट करने में विफल", "Sync Server Connected": "सिंक सर्वर कनेक्टेड", "Precision: {{precision}} digits after the decimal": "सटीकता: {{precision}} दशमलव के बाद अंक", - "Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}" + "Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}", + "Custom Fonts": "कस्टम फ़ॉन्ट", + "Cancel Delete": "डिलीट रद्द करें", + "Import Font": "फ़ॉन्ट आयात करें", + "Delete Font": "फ़ॉन्ट डिलीट करें", + "Tips": "सुझाव", + "Custom fonts can be selected from the Font Face menu": "कस्टम फ़ॉन्ट को फ़ॉन्ट फेस मेनू से चुना जा सकता है", + "Manage Custom Fonts": "कस्टम फ़ॉन्ट प्रबंधन", + "Select Files": "फ़ाइलें चुनें", + "Select Image": "छवि चुनें", + "Select Video": "वीडियो चुनें", + "Select Audio": "ऑडियो चुनें", + "Select Fonts": "फ़ॉन्ट चुनें" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index ca70d957..40698755 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -500,5 +500,17 @@ "Failed to connect": "Gagal terhubung", "Sync Server Connected": "Server sinkronisasi terhubung", "Precision: {{precision}} digits after the decimal": "Presisi: {{precision}} digit setelah desimal", - "Sync as {{userDisplayName}}": "Sinkronkan sebagai {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Sinkronkan sebagai {{userDisplayName}}", + "Custom Fonts": "Font Kustom", + "Cancel Delete": "Batal Hapus", + "Import Font": "Impor Font", + "Delete Font": "Hapus Font", + "Tips": "Tips", + "Custom fonts can be selected from the Font Face menu": "Font kustom dapat dipilih dari menu Font Face", + "Manage Custom Fonts": "Kelola Font Kustom", + "Select Files": "Pilih File", + "Select Image": "Pilih Gambar", + "Select Video": "Pilih Video", + "Select Audio": "Pilih Audio", + "Select Fonts": "Pilih Font" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index b2046cce..f11a7de8 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -508,5 +508,17 @@ "Failed to connect": "Impossibile connettersi", "Sync Server Connected": "Server di sincronizzazione connesso", "Precision: {{precision}} digits after the decimal": "Precisione: {{precision}} cifre dopo la virgola", - "Sync as {{userDisplayName}}": "Sincronizza come {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Sincronizza come {{userDisplayName}}", + "Custom Fonts": "Font Personalizzati", + "Cancel Delete": "Annulla Eliminazione", + "Import Font": "Importa Font", + "Delete Font": "Elimina Font", + "Tips": "Suggerimenti", + "Custom fonts can be selected from the Font Face menu": "I font personalizzati possono essere selezionati dal menu Carattere", + "Manage Custom Fonts": "Gestisci Font Personalizzati", + "Select Files": "Seleziona File", + "Select Image": "Seleziona Immagine", + "Select Video": "Seleziona Video", + "Select Audio": "Seleziona Audio", + "Select Fonts": "Seleziona Font" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index b93dc5e9..d17665b0 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -500,5 +500,17 @@ "Failed to connect": "接続に失敗しました", "Sync Server Connected": "同期サーバーに接続されました", "Precision: {{precision}} digits after the decimal": "精度: {{precision}} 桁の小数点以下", - "Sync as {{userDisplayName}}": "{{userDisplayName}} として同期" + "Sync as {{userDisplayName}}": "{{userDisplayName}} として同期", + "Custom Fonts": "カスタムフォント", + "Cancel Delete": "削除をキャンセル", + "Import Font": "フォントをインポート", + "Delete Font": "フォントを削除", + "Tips": "ヒント", + "Custom fonts can be selected from the Font Face menu": "カスタムフォントは書体メニューから選択できます", + "Manage Custom Fonts": "カスタムフォント管理", + "Select Files": "ファイルを選択", + "Select Image": "画像を選択", + "Select Video": "動画を選択", + "Select Audio": "音声を選択", + "Select Fonts": "フォントを選択" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index e8d8ef4d..ec25ea41 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -500,5 +500,17 @@ "Failed to connect": "연결 실패", "Sync Server Connected": "동기화 서버에 연결됨", "Precision: {{precision}} digits after the decimal": "정밀도: {{precision}} 자리의 소수점 이하", - "Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화" + "Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화", + "Custom Fonts": "사용자 정의 글꼴", + "Cancel Delete": "삭제 취소", + "Import Font": "글꼴 가져오기", + "Delete Font": "글꼴 삭제", + "Tips": "팁", + "Custom fonts can be selected from the Font Face menu": "사용자 정의 글꼴은 글꼴 메뉴에서 선택할 수 있습니다", + "Manage Custom Fonts": "사용자 정의 글꼴 관리", + "Select Files": "파일 선택", + "Select Image": "이미지 선택", + "Select Video": "동영상 선택", + "Select Audio": "오디오 선택", + "Select Fonts": "글꼴 선택" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index e2c1353f..367c0788 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -504,5 +504,17 @@ "Failed to connect": "Verbinding mislukt", "Sync Server Connected": "Synchronisatie server verbonden", "Precision: {{precision}} digits after the decimal": "Precisie: {{precision}} cijfers na de komma", - "Sync as {{userDisplayName}}": "Synchroniseren als {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Synchroniseren als {{userDisplayName}}", + "Custom Fonts": "Aangepaste Lettertypen", + "Cancel Delete": "Verwijderen Annuleren", + "Import Font": "Lettertype Importeren", + "Delete Font": "Lettertype Verwijderen", + "Tips": "Tips", + "Custom fonts can be selected from the Font Face menu": "Aangepaste lettertypen kunnen worden geselecteerd vanuit het Lettertype menu", + "Manage Custom Fonts": "Aangepaste Lettertypen Beheren", + "Select Files": "Bestanden Selecteren", + "Select Image": "Afbeelding Selecteren", + "Select Video": "Video Selecteren", + "Select Audio": "Audio Selecteren", + "Select Fonts": "Lettertypen Selecteren" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 494a0eb4..d8e5fb3d 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -512,5 +512,17 @@ "Failed to connect": "Nie udało się połączyć", "Sync Server Connected": "Serwer synchronizacji połączony", "Precision: {{precision}} digits after the decimal": "Precyzja: {{precision}} cyfr po przecinku", - "Sync as {{userDisplayName}}": "Synchronizuj jako {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Synchronizuj jako {{userDisplayName}}", + "Custom Fonts": "Niestandardowe Czcionki", + "Cancel Delete": "Anuluj Usuwanie", + "Import Font": "Importuj Czcionkę", + "Delete Font": "Usuń Czcionkę", + "Tips": "Wskazówki", + "Custom fonts can be selected from the Font Face menu": "Niestandardowe czcionki można wybrać z menu Czcionka", + "Manage Custom Fonts": "Zarządzaj Niestandardowymi Czcionkami", + "Select Files": "Wybierz Pliki", + "Select Image": "Wybierz Obraz", + "Select Video": "Wybierz Wideo", + "Select Audio": "Wybierz Audio", + "Select Fonts": "Wybierz Czcionki" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 57df94aa..1476ef4c 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -508,5 +508,17 @@ "Failed to connect": "Falha ao conectar", "Sync Server Connected": "Servidor de Sincronização Conectado", "Precision: {{precision}} digits after the decimal": "Precisão: {{precision}} dígitos após a vírgula", - "Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}", + "Custom Fonts": "Fontes Personalizadas", + "Cancel Delete": "Cancelar Exclusão", + "Import Font": "Importar Fonte", + "Delete Font": "Excluir Fonte", + "Tips": "Dicas", + "Custom fonts can be selected from the Font Face menu": "Fontes personalizadas podem ser selecionadas no menu Fonte", + "Manage Custom Fonts": "Gerenciar Fontes Personalizadas", + "Select Files": "Selecionar Arquivos", + "Select Image": "Selecionar Imagem", + "Select Video": "Selecionar Vídeo", + "Select Audio": "Selecionar Áudio", + "Select Fonts": "Selecionar Fontes" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 6a795b82..f7dd02c2 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -512,5 +512,17 @@ "Failed to connect": "Не удалось подключиться", "Sync Server Connected": "Сервер синхронизации подключен", "Precision: {{precision}} digits after the decimal": "Точность: {{precision}} знаков после запятой", - "Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}", + "Custom Fonts": "Пользовательские Шрифты", + "Cancel Delete": "Отменить Удаление", + "Import Font": "Импортировать Шрифт", + "Delete Font": "Удалить Шрифт", + "Tips": "Советы", + "Custom fonts can be selected from the Font Face menu": "Пользовательские шрифты можно выбрать в меню Шрифт", + "Manage Custom Fonts": "Управление Пользовательскими Шрифтами", + "Select Files": "Выбрать Файлы", + "Select Image": "Выбрать Изображение", + "Select Video": "Выбрать Видео", + "Select Audio": "Выбрать Аудио", + "Select Fonts": "Выбрать Шрифты" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 43b262e7..4e9703c4 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -500,5 +500,17 @@ "Failed to connect": "ไม่สามารถเชื่อมต่อได้", "Sync Server Connected": "เซิร์ฟเวอร์ซิงค์เชื่อมต่อแล้ว", "Precision: {{precision}} digits after the decimal": "ความแม่นยำ: {{precision}} หลักหลังจุดทศนิยม", - "Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}" + "Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}", + "Custom Fonts": "ฟอนต์กำหนดเอง", + "Cancel Delete": "ยกเลิกการลบ", + "Import Font": "นำเข้าฟอนต์", + "Delete Font": "ลบฟอนต์", + "Tips": "เคล็ดลับ", + "Custom fonts can be selected from the Font Face menu": "ฟอนต์กำหนดเองสามารถเลือกได้จากเมนูฟอนต์", + "Manage Custom Fonts": "จัดการฟอนต์กำหนดเอง", + "Select Files": "เลือกไฟล์", + "Select Image": "เลือกรูปภาพ", + "Select Video": "เลือกวิดีโอ", + "Select Audio": "เลือกเสียง", + "Select Fonts": "เลือกฟอนต์" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 9685cd59..c57094ef 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -504,5 +504,17 @@ "Failed to connect": "Bağlanma başarısız oldu", "Sync Server Connected": "Senkronizasyon Sunucusu Bağlandı", "Precision: {{precision}} digits after the decimal": "Hassasiyet: {{precision}} ondalık basamaktan sonra", - "Sync as {{userDisplayName}}": "Senkronize et {{userDisplayName}} olarak" + "Sync as {{userDisplayName}}": "Senkronize et {{userDisplayName}} olarak", + "Custom Fonts": "Özel Yazı Tipleri", + "Cancel Delete": "Silmeyi İptal Et", + "Import Font": "Yazı Tipi İçe Aktar", + "Delete Font": "Yazı Tipini Sil", + "Tips": "İpuçları", + "Custom fonts can be selected from the Font Face menu": "Özel yazı tipleri Yazı Tipi menüsünden seçilebilir", + "Manage Custom Fonts": "Özel Yazı Tiplerini Yönet", + "Select Files": "Dosyaları Seç", + "Select Image": "Resim Seç", + "Select Video": "Video Seç", + "Select Audio": "Ses Seç", + "Select Fonts": "Yazı Tiplerini Seç" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 8b3d82d6..5a093d5f 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -512,5 +512,17 @@ "Failed to connect": "Не вдалося під'єднатися", "Sync Server Connected": "Сервер синхронізації під'єднано", "Precision: {{precision}} digits after the decimal": "Точність: {{precision}} знаків після коми", - "Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}", + "Custom Fonts": "Користувацькі Шрифти", + "Cancel Delete": "Скасувати Видалення", + "Import Font": "Імпортувати Шрифт", + "Delete Font": "Видалити Шрифт", + "Tips": "Поради", + "Custom fonts can be selected from the Font Face menu": "Користувацькі шрифти можна вибрати з меню Шрифт", + "Manage Custom Fonts": "Керування Користувацькими Шрифтами", + "Select Files": "Вибрати Файли", + "Select Image": "Вибрати Зображення", + "Select Video": "Вибрати Відео", + "Select Audio": "Вибрати Аудіо", + "Select Fonts": "Вибрати Шрифти" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 805b836d..41071de3 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -500,5 +500,17 @@ "Failed to connect": "Không thể kết nối", "Sync Server Connected": "Máy chủ đồng bộ đã kết nối", "Precision: {{precision}} digits after the decimal": "Độ chính xác: {{precision}} chữ số sau dấu thập phân", - "Sync as {{userDisplayName}}": "Đồng bộ dưới tên {{userDisplayName}}" + "Sync as {{userDisplayName}}": "Đồng bộ dưới tên {{userDisplayName}}", + "Custom Fonts": "Phông Chữ Tùy Chỉnh", + "Cancel Delete": "Hủy Xóa", + "Import Font": "Nhập Phông Chữ", + "Delete Font": "Xóa Phông Chữ", + "Tips": "Mẹo", + "Custom fonts can be selected from the Font Face menu": "Phông chữ tùy chỉnh có thể được chọn từ menu Phông Chữ", + "Manage Custom Fonts": "Quản Lý Phông Chữ Tùy Chỉnh", + "Select Files": "Chọn Tệp", + "Select Image": "Chọn Hình Ảnh", + "Select Video": "Chọn Video", + "Select Audio": "Chọn Âm Thanh", + "Select Fonts": "Chọn Phông Chữ" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index f886e48f..982cb532 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -500,5 +500,17 @@ "Failed to connect": "连接失败", "Sync Server Connected": "同步服务器已连接", "Precision: {{precision}} digits after the decimal": "精度:{{precision}} 位小数", - "Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步" + "Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步", + "Custom Fonts": "自定义字体", + "Cancel Delete": "取消删除", + "Import Font": "导入字体", + "Delete Font": "删除字体", + "Tips": "提示", + "Custom fonts can be selected from the Font Face menu": "可以从字形菜单中选择自定义字体", + "Manage Custom Fonts": "管理自定义字体", + "Select Files": "选择文件", + "Select Image": "选择图片", + "Select Video": "选择视频", + "Select Audio": "选择音频", + "Select Fonts": "选择字体" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 5db35896..a765d400 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -500,5 +500,17 @@ "Failed to connect": "連接失敗", "Sync Server Connected": "同步伺服器已連接", "Precision: {{precision}} digits after the decimal": "精度: {{precision}} 位小數", - "Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步" + "Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步", + "Custom Fonts": "自訂字型", + "Cancel Delete": "取消刪除", + "Import Font": "匯入字型", + "Delete Font": "刪除字型", + "Tips": "提示", + "Custom fonts can be selected from the Font Face menu": "可以從字型選單中選擇自訂字型", + "Manage Custom Fonts": "管理自訂字型", + "Select Files": "選擇檔案", + "Select Image": "選擇圖片", + "Select Video": "選擇影片", + "Select Audio": "選擇音訊", + "Select Fonts": "選擇字型" } diff --git a/apps/readest-app/src/app/library/components/KOSyncSettings.tsx b/apps/readest-app/src/app/library/components/KOSyncSettings.tsx index 1df39cfe..2b5772a5 100644 --- a/apps/readest-app/src/app/library/components/KOSyncSettings.tsx +++ b/apps/readest-app/src/app/library/components/KOSyncSettings.tsx @@ -87,6 +87,7 @@ export const KOSyncSettingsWindow: React.FC = () => { const formatOsName = (name: string): string => { if (!name) return ''; if (name.toLowerCase() === 'macos') return 'macOS'; + if (name.toLowerCase() === 'ios') return 'iOS'; return name.charAt(0).toUpperCase() + name.slice(1); }; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 13cffb3b..4d0ab560 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -10,20 +10,15 @@ import 'overlayscrollbars/overlayscrollbars.css'; import { Book } from '@/types/book'; import { AppService, DeleteAction } from '@/types/system'; import { navigateToLogin, navigateToReader } from '@/utils/nav'; -import { - formatAuthors, - formatTitle, - getFilename, - getPrimaryLanguage, - listFormater, -} from '@/utils/book'; +import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book'; import { eventDispatcher } from '@/utils/event'; import { ProgressPayload } from '@/utils/transfer'; import { throttle } from '@/utils/throttle'; +import { getFilename } from '@/utils/path'; import { parseOpenWithFiles } from '@/helpers/openWith'; import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater'; -import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants'; +import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants'; import { impactFeedback } from '@tauri-apps/plugin-haptics'; import { getCurrentWebview } from '@tauri-apps/api/webview'; @@ -153,12 +148,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP } else { fileExt = file.name.split('.').pop()?.toLowerCase(); } - return FILE_ACCEPT_FORMATS.includes(`.${fileExt}`); + return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`); }); if (supportedFiles.length === 0) { eventDispatcher.dispatch('toast', { message: _('No supported files found. Supported formats: {{formats}}', { - formats: FILE_ACCEPT_FORMATS, + formats: BOOK_ACCEPT_FORMATS, }), type: 'error', }); @@ -437,12 +432,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }; const selectFilesTauri = async () => { - const exts = appService?.isIOSApp ? [] : SUPPORTED_FILE_EXTS; + const exts = appService?.isIOSApp ? [] : SUPPORTED_BOOK_EXTS; const files = (await appService?.selectFiles(_('Select Books'), exts)) || []; if (appService?.isIOSApp) { return files.filter((file) => { const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown'; - return SUPPORTED_FILE_EXTS.includes(fileExt); + return SUPPORTED_BOOK_EXTS.includes(fileExt); }); } return files; @@ -452,7 +447,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP return new Promise((resolve) => { const fileInput = document.createElement('input'); fileInput.type = 'file'; - fileInput.accept = FILE_ACCEPT_FORMATS; + fileInput.accept = BOOK_ACCEPT_FORMATS; fileInput.multiple = true; fileInput.click(); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index c5cbb82e..87593a05 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -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(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; diff --git a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx index e174da37..f8d96c4c 100644 --- a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx +++ b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx @@ -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'; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index dc5ec721..db5af035 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -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'; diff --git a/apps/readest-app/src/app/reader/components/settings/CustomFonts.tsx b/apps/readest-app/src/app/reader/components/settings/CustomFonts.tsx new file mode 100644 index 00000000..a2d0de8d --- /dev/null +++ b/apps/readest-app/src/app/reader/components/settings/CustomFonts.tsx @@ -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 = ({ 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 ( +
+
+
+ +
+ {availableFonts.length > 0 && ( + + )} +
+ +
+
+
+
+
+ +
+
+ {_('Import Font')} +
+
+
+
+ + {availableFonts.map((font) => ( +
+
+
+ {font.name} +
+ {isDeleteMode && ( + + )} +
+
+ ))} +
+ +
+
+
{_('Tips')}:
+
    +
  • {_('Custom fonts can be selected from the Font Face menu')}
  • +
+
+
+
+ ); +}; + +export default CustomFonts; diff --git a/apps/readest-app/src/app/reader/components/settings/DialogMenu.tsx b/apps/readest-app/src/app/reader/components/settings/DialogMenu.tsx index 95460038..9db31a6f 100644 --- a/apps/readest-app/src/app/reader/components/settings/DialogMenu.tsx +++ b/apps/readest-app/src/app/reader/components/settings/DialogMenu.tsx @@ -14,10 +14,16 @@ interface DialogMenuProps { resetLabel?: string; } -const DialogMenu: React.FC = ({ setIsDropdownOpen, onReset, resetLabel }) => { +const DialogMenu: React.FC = ({ + 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 = ({ setIsDropdownOpen, onReset, res setIsDropdownOpen?.(false); }; + const handleManageCustomFont = () => { + setFontPanelView('custom-fonts'); + setIsDropdownOpen?.(false); + }; + return (
= ({ setIsDropdownOpen, onReset, res onClick={handleToggleGlobal} /> + {activePanel === 'Font' && ( + + )}
); }; diff --git a/apps/readest-app/src/app/reader/components/settings/FontPanel.tsx b/apps/readest-app/src/app/reader/components/settings/FontPanel.tsx index 607c1ed0..352c0d63 100644 --- a/apps/readest-app/src/app/reader/components/settings/FontPanel.tsx +++ b/apps/readest-app/src/app/reader/components/settings/FontPanel.tsx @@ -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 = ({ 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 = ({ bookKey, onRegisterReset const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!); const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!); const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!); + + const [customFonts, setCustomFonts] = useState(getFontFamilies()); const [CJKFonts, setCJKFonts] = useState(() => { - return genCJKFontsList(sysFonts); + return genCJKFontsList([...customFonts, ...sysFonts]); }); const resetToDefaults = useResetViewSettings(); @@ -150,6 +161,14 @@ const FontPanel: React.FC = ({ 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 = ({ 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 = ({ bookKey, onRegisterReset } }; + if (fontPanelView === 'custom-fonts') { + return ( +
+ +
+ ); + } + return (
@@ -326,14 +357,27 @@ const FontPanel: React.FC = ({ bookKey, onRegisterReset
-

{_('Font Face')}

+
+

{_('Font Face')}

+ +
= ({ bookKey, onRegisterReset = ({ bookKey, onRegisterReset className='config-item-bottom' family='monospace' label={_('Monospace Font')} - options={MONOSPACE_FONTS} + options={[...customFonts, ...MONOSPACE_FONTS]} moreOptions={sysFonts} selected={monospaceFont} onSelect={setMonospaceFont} diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx index a792727b..9ef295b8 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx @@ -93,6 +93,7 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen })
    {getVisibleLibrary() .filter((book) => book.format !== 'PDF' && book.format !== 'CBZ') + .filter((book) => !!book.downloadedAt) .slice(0, 20) .map((book) => ( { diff --git a/apps/readest-app/src/hooks/useFileSelector.ts b/apps/readest-app/src/hooks/useFileSelector.ts new file mode 100644 index 00000000..824b3a17 --- /dev/null +++ b/apps/readest-app/src/hooks/useFileSelector.ts @@ -0,0 +1,124 @@ +import { AppService } from '@/types/system'; +import { isTauriAppPlatform } from '@/services/environment'; +import { stubTranslation as _ } from '@/utils/misc'; +import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants'; + +export interface FileSelectorOptions { + accept?: string; + multiple?: boolean; + extensions?: string[]; + dialogTitle?: string; +} + +export interface SelectedFile { + // For Web file + file?: File; + + // For Tauri file + path?: string; +} + +export interface FileSelectionResult { + files: SelectedFile[]; + error?: string; +} + +const selectFileWeb = (options: FileSelectorOptions): Promise => { + return new Promise((resolve) => { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = options.accept || '*/*'; + fileInput.multiple = options.multiple || false; + fileInput.click(); + + fileInput.onchange = () => { + resolve(Array.from(fileInput.files || [])); + }; + }); +}; + +const selectFileTauri = async ( + options: FileSelectorOptions, + appService: AppService, + _: (key: string) => string, +): Promise => { + const exts = appService?.isIOSApp ? [] : options.extensions || []; + const title = options.dialogTitle || _('Select Files'); + const files = (await appService?.selectFiles(_(title), exts)) || []; + + if (appService?.isIOSApp && options.extensions) { + return files.filter((file: string) => { + const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown'; + return options.extensions!.includes(fileExt); + }); + } + + return files; +}; + +const processWebFiles = (files: File[]): SelectedFile[] => { + return files.map((file) => ({ + file, + })); +}; + +const processTauriFiles = (files: string[]): SelectedFile[] => { + return files.map((path) => ({ + path, + })); +}; + +export const useFileSelector = (appService: AppService | null, _: (key: string) => string) => { + const selectFiles = async (options: FileSelectorOptions = {}) => { + if (!appService) { + return { files: [], error: 'App service is not available' }; + } + try { + if (isTauriAppPlatform()) { + const filePaths = await selectFileTauri(options, appService, _); + const files = await processTauriFiles(filePaths); + return { files }; + } else { + const webFiles = await selectFileWeb(options); + const files = processWebFiles(webFiles); + return { files }; + } + } catch (error) { + return { + files: [], + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + }; + return { + selectFiles, + }; +}; + +export const FILE_SELECTION_PRESETS = { + images: { + accept: 'image/*', + extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], + dialogTitle: _('Select Image'), + }, + videos: { + accept: 'video/*', + extensions: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'], + dialogTitle: _('Select Video'), + }, + audio: { + accept: 'audio/*', + extensions: ['mp3', 'wav', 'ogg', 'flac', 'm4a'], + dialogTitle: _('Select Audio'), + }, + books: { + accept: BOOK_ACCEPT_FORMATS, + extensions: SUPPORTED_BOOK_EXTS, + dialogTitle: _('Select Books'), + }, + fonts: { + accept: '.ttf, .otf, .woff, .woff2', + extensions: ['ttf', 'otf', 'woff', 'woff2'], + dialogTitle: _('Select Fonts'), + }, +}; diff --git a/apps/readest-app/src/hooks/useTheme.ts b/apps/readest-app/src/hooks/useTheme.ts index 61beb6a6..40b58a42 100644 --- a/apps/readest-app/src/hooks/useTheme.ts +++ b/apps/readest-app/src/hooks/useTheme.ts @@ -94,7 +94,7 @@ export const useTheme = ({ applyCustomTheme(customTheme); }); localStorage.setItem('customThemes', JSON.stringify(customThemes)); - }, [settings]); + }, [settings.globalReadSettings?.customThemes]); useEffect(() => { const colorScheme = isDarkMode ? 'dark' : 'light'; diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 5721c7da..723800d9 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -7,18 +7,17 @@ import { getDir, getLocalBookFilename, getRemoteBookFilename, - getBaseFilename, getCoverFilename, getConfigFilename, getLibraryFilename, INIT_BOOK_CONFIG, formatTitle, formatAuthors, - getFilename, getPrimaryLanguage, getLibraryBackupFilename, } from '@/utils/book'; import { partialMD5 } from '@/utils/md5'; +import { getBaseFilename, getFilename } from '@/utils/path'; import { BookDoc, DocumentLoader, EXTS } from '@/libs/document'; import { DEFAULT_BOOK_LAYOUT, diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index cc4c8d17..530bdfd9 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -17,8 +17,9 @@ import { stubTranslation as _ } from '@/utils/misc'; export const LOCAL_BOOKS_SUBDIR = 'Readest/Books'; export const CLOUD_BOOKS_SUBDIR = 'Readest/Books'; +export const LOCAL_FONTS_SUBDIR = 'Readest/Fonts'; -export const SUPPORTED_FILE_EXTS = [ +export const SUPPORTED_BOOK_EXTS = [ 'epub', 'mobi', 'azw', @@ -29,7 +30,7 @@ export const SUPPORTED_FILE_EXTS = [ 'pdf', 'txt', ]; -export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', '); +export const BOOK_ACCEPT_FORMATS = SUPPORTED_BOOK_EXTS.map((ext) => `.${ext}`).join(', '); export const BOOK_UNGROUPED_NAME = ''; export const BOOK_UNGROUPED_ID = ''; @@ -495,7 +496,6 @@ export const ANDROID_FONTS = [ 'XiHeiti', ]; -export const CJK_NAMES_PATTENS = /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/; export const CJK_EXCLUDE_PATTENS = new RegExp( ['AlBayan', 'STIX', 'Kailasa', 'ITCTT', 'Luminari', 'Myanmar'].join('|'), 'i', diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index e5a0f2ae..4afff15e 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -28,12 +28,13 @@ import { type as osType } from '@tauri-apps/plugin-os'; import { Book } from '@/types/book'; import { FileSystem, BaseDir, AppPlatform } from '@/types/system'; import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc'; -import { getCoverFilename, getFilename } from '@/utils/book'; +import { getCoverFilename } from '@/utils/book'; +import { getFilename } from '@/utils/path'; import { copyURIToPath } from '@/utils/bridge'; import { NativeFile, RemoteFile } from '@/utils/file'; import { BaseAppService, ResolvedPath } from './appService'; -import { LOCAL_BOOKS_SUBDIR } from './constants'; +import { LOCAL_BOOKS_SUBDIR, LOCAL_FONTS_SUBDIR } from './constants'; declare global { interface Window { @@ -64,6 +65,13 @@ const resolvePath = (path: string, base: BaseDir): ResolvedPath => { fp: `${LOCAL_BOOKS_SUBDIR}/${path}`, base, }; + case 'Fonts': + return { + baseDir: BaseDirectory.AppData, + basePrefix: appDataDir, + fp: `${LOCAL_FONTS_SUBDIR}/${path}`, + base, + }; case 'None': return { baseDir: 0, @@ -71,6 +79,7 @@ const resolvePath = (path: string, base: BaseDir): ResolvedPath => { fp: path, base, }; + case 'Temp': default: return { baseDir: BaseDirectory.Temp, diff --git a/apps/readest-app/src/services/sync/KOSyncClient.ts b/apps/readest-app/src/services/sync/KOSyncClient.ts index 87961901..117d6a6e 100644 --- a/apps/readest-app/src/services/sync/KOSyncClient.ts +++ b/apps/readest-app/src/services/sync/KOSyncClient.ts @@ -4,6 +4,7 @@ import { KoreaderSyncChecksumMethod } from '@/types/settings'; import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; import { KoSyncProxyPayload } from '@/types/kosync'; import { isLanAddress } from '@/utils/network'; +import { getBaseFilename } from '@/utils/path'; import { getAPIBaseUrl, isTauriAppPlatform } from '../environment'; /** @@ -223,18 +224,9 @@ export class KOSyncClient { private getDocumentDigest(book: Book): string | undefined { if (this.checksumMethod === 'filename') { - const filename = this.getBaseFilename(book.sourceTitle || book.title); + const filename = getBaseFilename(book.sourceTitle || book.title); return md5(filename); } return book.hash; } - - private getBaseFilename(fullPath: string): string { - // Normalize path by replacing backslashes with forward slashes for cross-platform consistency - const normalizedPath = fullPath.replace(/\\/g, '/'); - // Get the last part of the path and remove the extension - const baseName = - normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath; - return baseName; - } } diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index 812f34c4..8e114eb3 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -5,7 +5,7 @@ import { getOSPlatform, isValidURL } from '@/utils/misc'; import { RemoteFile } from '@/utils/file'; import { isPWA } from './environment'; import { BaseAppService, ResolvedPath } from './appService'; -import { LOCAL_BOOKS_SUBDIR } from './constants'; +import { LOCAL_BOOKS_SUBDIR, LOCAL_FONTS_SUBDIR } from './constants'; const basePrefix = async () => ''; @@ -13,6 +13,8 @@ const resolvePath = (path: string, base: BaseDir): ResolvedPath => { switch (base) { case 'Books': return { baseDir: 0, basePrefix, fp: `${LOCAL_BOOKS_SUBDIR}/${path}`, base }; + case 'Fonts': + return { baseDir: 0, basePrefix, fp: `${LOCAL_FONTS_SUBDIR}/${path}`, base }; case 'None': return { baseDir: 0, basePrefix, fp: path, base }; default: diff --git a/apps/readest-app/src/store/customFontStore.ts b/apps/readest-app/src/store/customFontStore.ts new file mode 100644 index 00000000..5e0caa27 --- /dev/null +++ b/apps/readest-app/src/store/customFontStore.ts @@ -0,0 +1,302 @@ +import { create } from 'zustand'; +import { EnvConfigType } from '@/services/environment'; +import { CustomFont, createCustomFont, getFontFormat, getMimeType } from '@/styles/fonts'; +import { useSettingsStore } from './settingsStore'; + +interface FontStoreState { + fonts: CustomFont[]; + loading: boolean; + + setFonts: (fonts: CustomFont[]) => void; + addFont: (path: string, options?: { name?: string }) => CustomFont; + removeFont: (id: string) => boolean; + updateFont: (id: string, updates: Partial) => boolean; + getFont: (id: string) => CustomFont | undefined; + getAllFonts: () => CustomFont[]; + getAvailableFonts: () => CustomFont[]; + clearAllFonts: () => void; + + loadFont: (envConfig: EnvConfigType, fontId: string) => Promise; + loadFonts: (envConfig: EnvConfigType, fontIds: string[]) => Promise; + loadAllFonts: (envConfig: EnvConfigType) => Promise; + unloadFont: (fontId: string) => boolean; + unloadAllFonts: () => void; + + getFontFamilies: () => string[]; + getLoadedFonts: () => CustomFont[]; + isFontLoaded: (fontId: string) => boolean; + + loadCustomFonts: (envConfig: EnvConfigType) => Promise; + saveCustomFonts: (envConfig: EnvConfigType) => Promise; +} + +function toSettingsFont(font: CustomFont): CustomFont { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { blobUrl, loaded, error, ...settingsFont } = font; + return settingsFont; +} + +export const useCustomFontStore = create((set, get) => ({ + fonts: [], + loading: false, + + setFonts: (fonts) => set({ fonts }), + addFont: (path, options) => { + const font = createCustomFont(path, options); + const existingFont = get().fonts.find((f) => f.id === font.id); + if (existingFont) { + if (existingFont.deletedAt) { + get().updateFont(font.id, { + path: font.path, + downloadedAt: Date.now(), + deletedAt: undefined, + loaded: false, + blobUrl: undefined, + error: undefined, + }); + set((state) => ({ + fonts: [...state.fonts], + })); + return existingFont; + } + return existingFont; + } + + const newFont = { + ...font, + downloadedAt: Date.now(), + }; + + set((state) => ({ + fonts: [...state.fonts, newFont], + })); + + return newFont; + }, + + removeFont: (id) => { + const font = get().getFont(id); + if (!font) return false; + + if (font.blobUrl) { + URL.revokeObjectURL(font.blobUrl); + } + + const result = get().updateFont(id, { + deletedAt: Date.now(), + blobUrl: undefined, + loaded: false, + error: undefined, + }); + set((state) => ({ + fonts: [...state.fonts], + })); + return result; + }, + + updateFont: (id, updates) => { + const state = get(); + const fontIndex = state.fonts.findIndex((font) => font.id === id); + + if (fontIndex === -1) return false; + + set((state) => ({ + fonts: state.fonts.map((font, index) => + index === fontIndex ? { ...font, ...updates } : font, + ), + })); + + return true; + }, + + getFont: (id) => { + return get().fonts.find((font) => font.id === id); + }, + + getAllFonts: () => { + return get().fonts; + }, + + getAvailableFonts: () => { + return get().fonts.filter((font) => !font.deletedAt); + }, + + clearAllFonts: () => { + const { fonts } = get(); + fonts.forEach((font) => { + if (font.blobUrl) { + URL.revokeObjectURL(font.blobUrl); + } + }); + + set({ fonts: [] }); + }, + + loadFont: async (envConfig, fontId) => { + const font = get().getFont(fontId); + + if (!font) { + throw new Error(`Font with id "${fontId}" not found`); + } + + if (font.deletedAt) { + throw new Error(`Font "${font.name}" has been deleted`); + } + + if (font.loaded && font.blobUrl && !font.error) { + return font; + } + + try { + get().updateFont(fontId, { + loaded: false, + error: undefined, + }); + + const appService = await envConfig.getAppService(); + const fontFile = await appService.fs.openFile(font.path, 'Fonts'); + + const format = getFontFormat(font.path); + const mimeType = getMimeType(format); + const blob = new Blob([await fontFile.arrayBuffer()], { type: mimeType }); + const blobUrl = URL.createObjectURL(blob); + + get().updateFont(fontId, { + blobUrl, + loaded: true, + error: undefined, + }); + + const updatedFont = get().getFont(fontId)!; + return updatedFont; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + get().updateFont(fontId, { + loaded: false, + error: errorMessage, + blobUrl: undefined, + }); + + throw error; + } + }, + + loadFonts: async (envConfig, fontIds) => { + set({ loading: true }); + try { + const results = await Promise.allSettled(fontIds.map((id) => get().loadFont(envConfig, id))); + + return results + .filter( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled', + ) + .map((result) => result.value); + } finally { + set({ loading: false }); + } + }, + + loadAllFonts: async (envConfig) => { + const fontIds = get() + .getAvailableFonts() + .map((font) => font.id); + return await get().loadFonts(envConfig, fontIds); + }, + + unloadFont: (fontId) => { + const font = get().getFont(fontId); + + if (font?.blobUrl) { + URL.revokeObjectURL(font.blobUrl); + } + + return get().updateFont(fontId, { + blobUrl: undefined, + loaded: false, + error: undefined, + }); + }, + + unloadAllFonts: () => { + const fonts = get().getAllFonts(); + + fonts.forEach((font) => { + if (font.blobUrl) { + URL.revokeObjectURL(font.blobUrl); + } + }); + + fonts.forEach((font) => { + get().updateFont(font.id, { + blobUrl: undefined, + loaded: false, + error: undefined, + }); + }); + }, + + getFontFamilies: () => { + return get() + .getAvailableFonts() + .filter((font) => font.loaded && !font.error) + .map((font) => font.name); + }, + + getLoadedFonts: () => { + return get() + .getAvailableFonts() + .filter((font) => font.loaded && !font.error); + }, + + isFontLoaded: (fontId) => { + const font = get().getFont(fontId); + return font?.loaded === true && !font.error && !font.deletedAt; + }, + + loadCustomFonts: async (envConfig) => { + try { + const { settings } = useSettingsStore.getState(); + const currentFonts = get().fonts; + if (settings?.customFonts) { + const fonts = settings.customFonts.map((font) => { + const existingFont = currentFonts.find((f) => f.id === font.id); + return { + ...font, + loaded: existingFont?.loaded || false, + error: existingFont?.error, + blobUrl: existingFont?.blobUrl, + }; + }); + set({ fonts }); + await get().loadAllFonts(envConfig); + } + } catch (error) { + console.error('Failed to load custom fonts settings:', error); + } + }, + + saveCustomFonts: async (envConfig) => { + try { + const { settings, setSettings, saveSettings } = useSettingsStore.getState(); + const { fonts } = get(); + settings.customFonts = fonts.map(toSettingsFont); + setSettings(settings); + saveSettings(envConfig, settings); + } catch (error) { + console.error('Failed to save custom fonts settings:', error); + throw error; + } + }, +})); + +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + const store = useCustomFontStore.getState(); + const fonts = store.getAllFonts(); + fonts.forEach((font) => { + if (font.blobUrl) { + URL.revokeObjectURL(font.blobUrl); + } + }); + }); +} diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index dee0b4be..ef2d8a8c 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -13,7 +13,8 @@ import { EnvConfigType } from '@/services/environment'; import { FoliateView } from '@/types/view'; import { DocumentLoader, TOCItem } from '@/libs/document'; import { updateToc } from '@/utils/toc'; -import { formatTitle, getBaseFilename, getPrimaryLanguage } from '@/utils/book'; +import { formatTitle, getPrimaryLanguage } from '@/utils/book'; +import { getBaseFilename } from '@/utils/path'; import { SUPPORTED_LANGNAMES } from '@/services/constants'; import { useSettingsStore } from './settingsStore'; import { useBookDataStore } from './bookDataStore'; diff --git a/apps/readest-app/src/store/settingsStore.ts b/apps/readest-app/src/store/settingsStore.ts index bd0a1513..7d0868ce 100644 --- a/apps/readest-app/src/store/settingsStore.ts +++ b/apps/readest-app/src/store/settingsStore.ts @@ -2,20 +2,25 @@ import { create } from 'zustand'; import { SystemSettings } from '@/types/settings'; import { EnvConfigType } from '@/services/environment'; +export type FontPanelView = 'main-fonts' | 'custom-fonts'; + interface SettingsState { settings: SystemSettings; isFontLayoutSettingsDialogOpen: boolean; isFontLayoutSettingsGlobal: boolean; + fontPanelView: FontPanelView; setSettings: (settings: SystemSettings) => void; saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void; setFontLayoutSettingsDialogOpen: (open: boolean) => void; setFontLayoutSettingsGlobal: (global: boolean) => void; + setFontPanelView: (view: FontPanelView) => void; } export const useSettingsStore = create((set) => ({ settings: {} as SystemSettings, isFontLayoutSettingsDialogOpen: false, isFontLayoutSettingsGlobal: true, + fontPanelView: 'main-fonts', setSettings: (settings) => set({ settings }), saveSettings: async (envConfig: EnvConfigType, settings: SystemSettings) => { const appService = await envConfig.getAppService(); @@ -23,4 +28,5 @@ export const useSettingsStore = create((set) => ({ }, setFontLayoutSettingsDialogOpen: (open) => set({ isFontLayoutSettingsDialogOpen: open }), setFontLayoutSettingsGlobal: (global) => set({ isFontLayoutSettingsGlobal: global }), + setFontPanelView: (view) => set({ fontPanelView: view }), })); diff --git a/apps/readest-app/src/styles/fonts.ts b/apps/readest-app/src/styles/fonts.ts new file mode 100644 index 00000000..7b31874b --- /dev/null +++ b/apps/readest-app/src/styles/fonts.ts @@ -0,0 +1,207 @@ +import { isCJKEnv } from '@/utils/misc'; +import { getFilename } from '@/utils/path'; +import { md5Fingerprint } from '@/utils/md5'; + +export type FontFormat = 'ttf' | 'otf' | 'woff' | 'woff2'; + +const basicGoogleFonts = [ + { family: 'Bitter', weights: 'ital,wght@0,100..900;1,100..900' }, + { family: 'Fira Code', weights: 'wght@300..700' }, + { family: 'Literata', weights: 'ital,opsz,wght@0,7..72,200..900;1,7..72,200..900' }, + { family: 'Merriweather', weights: 'ital,opsz,wght@0,18..144,300..900;1,18..144,300..900' }, + { family: 'Noto Sans', weights: 'ital,wght@0,100..900;1,100..900' }, + { family: 'Open Sans', weights: 'ital,wght@0,300..800;1,300..800' }, + { family: 'Roboto', weights: 'ital,wght@0,100..900;1,100..900' }, + { family: 'Vollkorn', weights: 'ital,wght@0,400..900;1,400..900' }, +]; + +const cjkGoogleFonts = [ + { family: 'LXGW WenKai TC', weights: '' }, + { family: 'Noto Sans SC', weights: '' }, + { family: 'Noto Sans TC', weights: '' }, + { family: 'Noto Serif JP', weights: '' }, +]; + +const getAdditionalBasicFontLinks = () => ` + +`; + +const getAdditionalCJKFontLinks = () => ` + + + + + + + +`; + +const getAdditionalCJKFontFaces = () => ` + @font-face { + font-family: "FangSong"; + font-display: swap; + src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), local("Source Han Serif SC VF"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot"); + src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"), + url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"), + url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"), + url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.ttf") format("truetype"), + url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.svg#FangSong") format("svg"); + } + @font-face { + font-family: "Kaiti"; + font-display: swap; + src: local("Kai"), local("KaiTi"), local("AR PL UKai"), local("LXGW WenKai GB Screen"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot"); + src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"), + url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"), + url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"), + url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.ttf")format("truetype"), + url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.svg#STKaiti")format("svg"); + } + @font-face { + font-family: "Heiti"; + font-display: swap; + src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), local("Source Han Sans SC VF"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot"); + src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"), + url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"), + url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"), + url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.ttf")format("truetype"), + url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.svg#WenQuanYi Micro Hei")format("svg"); + } + @font-face { + font-family: "XiHeiti"; + font-display: swap; + src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), local("FZHei-B01"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot"); + src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot?#iefix")format("embedded-opentype"), + url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff2")format("woff2"), + url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff")format("woff"), + url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.ttf")format("truetype"), + url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.svg#STHeiti J Light")format("svg"); +} +`; + +export const mountAdditionalFonts = (document: Document, isCJK = false) => { + const mountCJKFonts = isCJK || isCJKEnv(); + let links = getAdditionalBasicFontLinks(); + if (mountCJKFonts) { + const style = document.createElement('style'); + style.textContent = getAdditionalCJKFontFaces(); + document.head.appendChild(style); + + links = `${links}\n${getAdditionalCJKFontLinks()}`; + } + + const parser = new DOMParser(); + const parsedDocument = parser.parseFromString(links, 'text/html'); + + Array.from(parsedDocument.head.children).forEach((child) => { + if (child.tagName === 'LINK') { + const link = document.createElement('link'); + link.rel = child.getAttribute('rel') || ''; + link.href = child.getAttribute('href') || ''; + link.crossOrigin = child.getAttribute('crossorigin') || ''; + + document.head.appendChild(link); + } + }); +}; + +export interface CustomFont { + id: string; + name: string; + path: string; + downloadedAt?: number; + deletedAt?: number; + + blobUrl?: string; + loaded?: boolean; + error?: string; +} + +export function getFontName(path: string): string { + const fileName = getFilename(path); + return fileName.replace(/\.(ttf|otf|woff|woff2)$/i, ''); +} + +export function getFontId(name: string): string { + return md5Fingerprint(name); +} + +export function getFontFormat(path: string): FontFormat { + const extension = path.toLowerCase().split('.').pop(); + switch (extension) { + case 'ttf': + return 'ttf'; + case 'otf': + return 'otf'; + case 'woff': + return 'woff'; + case 'woff2': + return 'woff2'; + default: + return 'ttf'; + } +} + +export function getMimeType(format: FontFormat): string { + const types = { ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2' }; + return types[format] || 'font/ttf'; +} + +export function getCSSFormatString(format: FontFormat): string { + const formats = { ttf: 'truetype', otf: 'opentype', woff: 'woff', woff2: 'woff2' }; + return formats[format] || 'truetype'; +} + +export function createFontFamily(name: string): string { + return name.replace(/\s+/g, ' ').trim(); +} + +export function createFontCSS(font: CustomFont): string { + const format = getFontFormat(font.path); + const cssFormat = getCSSFormatString(format); + const fontFamily = createFontFamily(font.name); + if (!font.blobUrl) { + throw new Error(`Blob URL not available for font: ${font.name}`); + } + + const css = ` + @font-face { + font-family: "${fontFamily}"; + src: url("${font.blobUrl}") format("${cssFormat}"); + font-display: swap; + } + `; + + return css; +} + +export function createCustomFont( + path: string, + options?: { + name?: string; + }, +): CustomFont { + const name = options?.name || getFontName(path); + return { id: getFontId(name), name, path }; +} + +export const mountCustomFont = (document: Document, font: CustomFont) => { + const fontStyleId = `custom-font-${font.id}`; + const styleElement = document.getElementById(fontStyleId) || document.createElement('style'); + styleElement.id = fontStyleId; + styleElement.textContent = createFontCSS(font); + + if (!styleElement.parentNode) { + document.head.appendChild(styleElement); + } +}; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 32b3f1d8..be8d4aa5 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -1,4 +1,5 @@ import { CustomTheme } from '@/styles/themes'; +import { CustomFont } from '@/styles/fonts'; import { HighlightColor, HighlightStyle, ViewSettings } from './book'; export type ThemeType = 'light' | 'dark' | 'auto'; @@ -42,6 +43,7 @@ export interface SystemSettings { librarySortBy: LibrarySortByType; librarySortAscending: boolean; libraryCoverFit: LibraryCoverFitType; + customFonts: CustomFont[]; koreaderSyncServerUrl: string; koreaderSyncUsername: string; diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 88cc8397..44914b3e 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -5,7 +5,7 @@ import { ProgressHandler } from '@/utils/transfer'; export type AppPlatform = 'web' | 'tauri'; export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown'; -export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None'; +export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Log' | 'Cache' | 'Temp' | 'None'; export type DeleteAction = 'cloud' | 'local' | 'both'; export interface FileSystem { diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index a19085ac..f6dace1a 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -1,7 +1,7 @@ import { EXTS } from '@/libs/document'; import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book'; import { SUPPORTED_LANGS } from '@/services/constants'; -import { getUserLang, isContentURI, isFileURI, isValidURL, makeSafeFilename } from './misc'; +import { getUserLang, makeSafeFilename } from './misc'; import { getStorageType } from './storage'; import { getDirFromLanguage } from './rtl'; import { code6392to6391, isValidLang, normalizedLangCode } from './lang'; @@ -37,20 +37,6 @@ export const getConfigFilename = (book: Book) => { export const isBookFile = (filename: string) => { return Object.values(EXTS).includes(filename.split('.').pop()!); }; -export const getFilename = (fileOrUri: string) => { - if (isValidURL(fileOrUri) || isContentURI(fileOrUri) || isFileURI(fileOrUri)) { - fileOrUri = decodeURI(fileOrUri); - } - const normalizedPath = fileOrUri.replace(/\\/g, '/'); - const parts = normalizedPath.split('/'); - const lastPart = parts.pop()!; - return lastPart.split('?')[0]!; -}; -export const getBaseFilename = (filename: string) => { - const normalizedPath = filename.replace(/\\/g, '/'); - const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || ''; - return baseName; -}; export const INIT_BOOK_CONFIG: BookConfig = { updatedAt: 0, diff --git a/apps/readest-app/src/utils/font.ts b/apps/readest-app/src/utils/font.ts index eead02ea..cc3976fb 100644 --- a/apps/readest-app/src/utils/font.ts +++ b/apps/readest-app/src/utils/font.ts @@ -1,112 +1,157 @@ -import { isCJKEnv } from './misc'; +import { getUserLang } from './misc'; -const basicGoogleFonts = [ - { family: 'Bitter', weights: 'ital,wght@0,100..900;1,100..900' }, - { family: 'Fira Code', weights: 'wght@300..700' }, - { family: 'Literata', weights: 'ital,opsz,wght@0,7..72,200..900;1,7..72,200..900' }, - { family: 'Merriweather', weights: 'ital,opsz,wght@0,18..144,300..900;1,18..144,300..900' }, - { family: 'Noto Sans', weights: 'ital,wght@0,100..900;1,100..900' }, - { family: 'Open Sans', weights: 'ital,wght@0,300..800;1,300..800' }, - { family: 'Roboto', weights: 'ital,wght@0,100..900;1,100..900' }, - { family: 'Vollkorn', weights: 'ital,wght@0,400..900;1,400..900' }, -]; - -const cjkGoogleFonts = [ - { family: 'LXGW WenKai TC', weights: '' }, - { family: 'Noto Sans SC', weights: '' }, - { family: 'Noto Sans TC', weights: '' }, - { family: 'Noto Serif JP', weights: '' }, -]; - -const getAdditionalBasicFontLinks = () => ` - -`; - -const getAdditionalCJKFontLinks = () => ` - - - - - - - -`; - -const getAdditionalCJKFontFaces = () => ` - @font-face { - font-family: "FangSong"; - font-display: swap; - src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), local("Source Han Serif SC VF"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot"); - src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"), - url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"), - url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"), - url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.ttf") format("truetype"), - url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.svg#FangSong") format("svg"); - } - @font-face { - font-family: "Kaiti"; - font-display: swap; - src: local("Kai"), local("KaiTi"), local("AR PL UKai"), local("LXGW WenKai GB Screen"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot"); - src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"), - url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"), - url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"), - url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.ttf")format("truetype"), - url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.svg#STKaiti")format("svg"); - } - @font-face { - font-family: "Heiti"; - font-display: swap; - src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), local("Source Han Sans SC VF"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot"); - src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"), - url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"), - url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"), - url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.ttf")format("truetype"), - url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.svg#WenQuanYi Micro Hei")format("svg"); - } - @font-face { - font-family: "XiHeiti"; - font-display: swap; - src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), local("FZHei-B01"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot"); - src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot?#iefix")format("embedded-opentype"), - url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff2")format("woff2"), - url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff")format("woff"), - url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.ttf")format("truetype"), - url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.svg#STHeiti J Light")format("svg"); -} -`; - -export const mountAdditionalFonts = (document: Document, isCJK = false) => { - const mountCJKFonts = isCJK || isCJKEnv(); - let links = getAdditionalBasicFontLinks(); - if (mountCJKFonts) { - const style = document.createElement('style'); - style.textContent = getAdditionalCJKFontFaces(); - document.head.appendChild(style); - - links = `${links}\n${getAdditionalCJKFontLinks()}`; - } - - const parser = new DOMParser(); - const parsedDocument = parser.parseFromString(links, 'text/html'); - - Array.from(parsedDocument.head.children).forEach((child) => { - if (child.tagName === 'LINK') { - const link = document.createElement('link'); - link.rel = child.getAttribute('rel') || ''; - link.href = child.getAttribute('href') || ''; - link.crossOrigin = child.getAttribute('crossorigin') || ''; - - document.head.appendChild(link); +function parseUnicodeString(dataView: DataView, offset: number, length: number): string { + const chars: string[] = []; + for (let i = 0; i < length; i += 2) { + const charCode = dataView.getUint16(offset + i, false); + if (charCode !== 0) { + chars.push(String.fromCharCode(charCode)); } - }); + } + return chars.join(''); +} + +function parseMacintoshString(dataView: DataView, offset: number, length: number): string { + const chars: string[] = []; + for (let i = 0; i < length; i++) { + const charCode = dataView.getUint8(offset + i); + chars.push(String.fromCharCode(charCode)); + } + return chars.join(''); +} + +function getLanguagePriority(platformID: number, languageID: number, userLanguage: string): number { + let priority = 0; + + // Base priority by platform (Unicode/Microsoft preferred) + if (platformID === 0) + priority += 100; // Unicode + else if (platformID === 3) + priority += 90; // Microsoft + else if (platformID === 1) priority += 50; // Macintosh + + // Language-specific priorities + const userLang = userLanguage.toLowerCase(); + + if (platformID === 0 || platformID === 3) { + if (userLang.startsWith('zh')) { + if (languageID === 0x0804) + priority += 50; // Simplified Chinese + else if (languageID === 0x0404) + priority += 45; // Traditional Chinese + else if (languageID === 0x0c04) + priority += 40; // Traditional Chinese + else if (languageID === 0x1004) priority += 35; // Simplified Chinese + } else if (userLang.startsWith('ja')) { + if (languageID === 0x0411) priority += 50; // Japanese + } else if (userLang.startsWith('ko')) { + if (languageID === 0x0412) priority += 50; // Korean + } else if (userLang.startsWith('en')) { + if (languageID === 0x0409) + priority += 50; // English (US) + else if (languageID === 0x0809) priority += 45; // English (UK) + } + + // Fallback: English + if (languageID === 0x0409) priority += 10; // English fallback + } else if (platformID === 1) { + // Macintosh platform language codes + if (userLang.startsWith('zh')) { + if (languageID === 33) + priority += 50; // Chinese (Simplified) + else if (languageID === 19) priority += 45; // Chinese (Traditional) + } else if (userLang.startsWith('ja')) { + if (languageID === 11) priority += 50; // Japanese + } else if (userLang.startsWith('ko')) { + if (languageID === 23) priority += 50; // Korean + } else if (userLang.startsWith('en')) { + if (languageID === 0) priority += 50; // English + } + + // Fallback: English + if (languageID === 0) priority += 10; // English fallback + } + + return priority; +} + +export const parseFontFamily = (fontData: ArrayBuffer, filename: string) => { + const fallbackName = filename.replace(/\.[^/.]+$/, ''); + try { + const dataView = new DataView(fontData); + const signature = dataView.getUint32(0, false); + if (signature !== 0x00010000 && signature !== 0x74727565 && signature !== 0x4f54544f) { + throw new Error('Unsupported font format'); + } + const numTables = dataView.getUint16(4, false); + let nameTableOffset = 0; + for (let i = 0; i < numTables; i++) { + const tableOffset = 12 + i * 16; + const tag = String.fromCharCode( + dataView.getUint8(tableOffset), + dataView.getUint8(tableOffset + 1), + dataView.getUint8(tableOffset + 2), + dataView.getUint8(tableOffset + 3), + ); + + if (tag === 'name') { + nameTableOffset = dataView.getUint32(tableOffset + 8, false); + break; + } + } + + if (nameTableOffset === 0) { + throw new Error('Name table not found'); + } + + const count = dataView.getUint16(nameTableOffset + 2, false); + const stringOffset = dataView.getUint16(nameTableOffset + 4, false); + + const userLanguage = getUserLang(); + const fontNames: Array<{ + name: string; + platformID: number; + languageID: number; + priority: number; + }> = []; + for (let i = 0; i < count; i++) { + const recordOffset = nameTableOffset + 6 + i * 12; + const platformID = dataView.getUint16(recordOffset, false); + const languageID = dataView.getUint16(recordOffset + 4, false); + const nameID = dataView.getUint16(recordOffset + 6, false); + const nameLength = dataView.getUint16(recordOffset + 8, false); + const nameOffsetInTable = dataView.getUint16(recordOffset + 10, false); + + if (nameID === 1) { + const stringStart = nameTableOffset + stringOffset + nameOffsetInTable; + let familyName = ''; + + if (platformID === 0 || platformID === 3) { + // Unicode/Microsoft platform + familyName = parseUnicodeString(dataView, stringStart, nameLength); + } else if (platformID === 1) { + // Macintosh platform + familyName = parseMacintoshString(dataView, stringStart, nameLength); + } + + if (familyName && familyName.trim()) { + const priority = getLanguagePriority(platformID, languageID, userLanguage); + fontNames.push({ + name: familyName.trim(), + platformID, + languageID, + priority, + }); + } + } + } + if (fontNames.length === 0) { + throw new Error('Font family name not found'); + } + fontNames.sort((a, b) => b.priority - a.priority); + return fontNames[0]!.name; + } catch (error) { + console.warn(`Failed to parse font: ${error}`); + return fallbackName; + } }; diff --git a/apps/readest-app/src/utils/path.ts b/apps/readest-app/src/utils/path.ts new file mode 100644 index 00000000..4bd22f80 --- /dev/null +++ b/apps/readest-app/src/utils/path.ts @@ -0,0 +1,16 @@ +import { isContentURI, isFileURI, isValidURL } from './misc'; + +export const getFilename = (fileOrUri: string) => { + if (isValidURL(fileOrUri) || isContentURI(fileOrUri) || isFileURI(fileOrUri)) { + fileOrUri = decodeURI(fileOrUri); + } + const normalizedPath = fileOrUri.replace(/\\/g, '/'); + const parts = normalizedPath.split('/'); + const lastPart = parts.pop()!; + return lastPart.split('?')[0]!; +}; +export const getBaseFilename = (filename: string) => { + const normalizedPath = filename.replace(/\\/g, '/'); + const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || ''; + return baseName; +}; diff --git a/apps/readest-app/src/utils/txt.ts b/apps/readest-app/src/utils/txt.ts index 10d245f2..aaf9084b 100644 --- a/apps/readest-app/src/utils/txt.ts +++ b/apps/readest-app/src/utils/txt.ts @@ -1,5 +1,5 @@ import { partialMD5 } from './md5'; -import { getBaseFilename } from './book'; +import { getBaseFilename } from './path'; import { detectLanguage } from './lang'; interface Metadata {