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
@@ -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": "حدد الخطوط"
}
@@ -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": "ཡིག་གཟུགས་འདེམས་པ།"
}
@@ -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"
}
@@ -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": "Επιλογή Γραμματοσειρών"
}
@@ -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"
}
@@ -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"
}
@@ -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": "फ़ॉन्ट चुनें"
}
@@ -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"
}
@@ -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"
}
@@ -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": "フォントを選択"
}
@@ -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": "글꼴 선택"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": "Выбрать Шрифты"
}
@@ -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": "เลือกฟอนต์"
}
@@ -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ç"
}
@@ -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": "Вибрати Шрифти"
}
@@ -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ữ"
}
@@ -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": "选择字体"
}
@@ -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": "選擇字型"
}
@@ -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);
};
+8 -13
View File
@@ -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();
@@ -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(() => {
@@ -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<File[]> => {
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<string[]> => {
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'),
},
};
+1 -1
View File
@@ -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';
+1 -2
View File
@@ -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,
+3 -3
View File
@@ -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',
@@ -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,
@@ -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;
}
}
@@ -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:
@@ -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<CustomFont>) => boolean;
getFont: (id: string) => CustomFont | undefined;
getAllFonts: () => CustomFont[];
getAvailableFonts: () => CustomFont[];
clearAllFonts: () => void;
loadFont: (envConfig: EnvConfigType, fontId: string) => Promise<CustomFont>;
loadFonts: (envConfig: EnvConfigType, fontIds: string[]) => Promise<CustomFont[]>;
loadAllFonts: (envConfig: EnvConfigType) => Promise<CustomFont[]>;
unloadFont: (fontId: string) => boolean;
unloadAllFonts: () => void;
getFontFamilies: () => string[];
getLoadedFonts: () => CustomFont[];
isFontLoaded: (fontId: string) => boolean;
loadCustomFonts: (envConfig: EnvConfigType) => Promise<void>;
saveCustomFonts: (envConfig: EnvConfigType) => Promise<void>;
}
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<FontStoreState>((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<CustomFont> => 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);
}
});
});
}
+2 -1
View File
@@ -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';
@@ -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<SettingsState>((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<SettingsState>((set) => ({
},
setFontLayoutSettingsDialogOpen: (open) => set({ isFontLayoutSettingsDialogOpen: open }),
setFontLayoutSettingsGlobal: (global) => set({ isFontLayoutSettingsGlobal: global }),
setFontPanelView: (view) => set({ fontPanelView: view }),
}));
+207
View File
@@ -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 = () => `
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${basicGoogleFonts
.map(
({ family, weights }) =>
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
)
.join('&')}&display=swap" crossorigin="anonymous">
`;
const getAdditionalCJKFontLinks = () => `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/hwmct/dist/%E6%B1%87%E6%96%87%E6%98%8E%E6%9C%9D%E4%BD%93/result.css' crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/jhlst/dist/%E4%BA%AC%E8%8F%AF%E8%80%81%E5%AE%8B%E4%BD%93v2_002/result.css' crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/syst/dist/SourceHanSerifCN/result.css' crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/GuanKiapTsingKhai/dist/GuanKiapTsingKhai-T/result.css' crossorigin="anonymous" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${cjkGoogleFonts
.map(
({ family, weights }) =>
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
)
.join('&')}&display=swap" crossorigin="anonymous" />
`;
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);
}
};
+2
View File
@@ -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;
+1 -1
View File
@@ -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 {
+1 -15
View File
@@ -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,
+154 -109
View File
@@ -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 = () => `
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${basicGoogleFonts
.map(
({ family, weights }) =>
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
)
.join('&')}&display=swap" crossorigin="anonymous">
`;
const getAdditionalCJKFontLinks = () => `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/hwmct/dist/%E6%B1%87%E6%96%87%E6%98%8E%E6%9C%9D%E4%BD%93/result.css' crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/jhlst/dist/%E4%BA%AC%E8%8F%AF%E8%80%81%E5%AE%8B%E4%BD%93v2_002/result.css' crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/syst/dist/SourceHanSerifCN/result.css' crossorigin="anonymous" />
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/GuanKiapTsingKhai/dist/GuanKiapTsingKhai-T/result.css' crossorigin="anonymous" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${cjkGoogleFonts
.map(
({ family, weights }) =>
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
)
.join('&')}&display=swap" crossorigin="anonymous" />
`;
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;
}
};
+16
View File
@@ -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;
};
+1 -1
View File
@@ -1,5 +1,5 @@
import { partialMD5 } from './md5';
import { getBaseFilename } from './book';
import { getBaseFilename } from './path';
import { detectLanguage } from './lang';
interface Metadata {