This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { partialMD5 } from './md5';
|
||||
import { getBaseFilename } from './book';
|
||||
import { getBaseFilename } from './path';
|
||||
import { detectLanguage } from './lang';
|
||||
|
||||
interface Metadata {
|
||||
|
||||
Reference in New Issue
Block a user