Compare commits

...

30 Commits

Author SHA1 Message Date
Huang Xin e472d1cf1e release: version 0.9.75 (#1873) 2025-08-22 17:47:24 +02:00
shaqtiktok 75dc04d598 fix(tts): reuse audio object in the same paragraph for better performance, closes #1777 (#1853)
* Remove stopInternal which causes audio delaying

* Change audio obj to not be recreated every sentence

* refactor abort signal handler

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-08-22 17:22:34 +02:00
Huang Xin fc4c033cf3 layout: layout tweaks on the custom fonts panel (#1871) 2025-08-22 12:35:20 +02:00
Huang Xin dbee054838 feat: apply custom fonts directly in custom font panel (#1870) 2025-08-22 12:17:57 +02:00
Huang Xin 45b805dcd4 layout: keep book title align center of the blank space when there are window buttons, closes #1867 (#1869) 2025-08-22 10:42:34 +02:00
Huang Xin a8ac54b51c layout: fix position of close button miss-match between reader and library page on Windows and Linux, closes #1866 (#1868) 2025-08-22 10:05:00 +02:00
German Shein 86f705eae9 feat: support author sorting (last name first), closes #1799 (#1865)
* [Issue #1799](https://github.com/readest/readest/issues/1799): Changing the name order when sorting the book by Author by placing the last name first in Arabic, Tibetan, German, English, Spanish, French, Hindi, Italian, Dutch, Polish, Portuguese, Russian, Thai, Turkish and Ukrainian languages

* refactor author sort

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-08-22 07:59:59 +02:00
Huang Xin d390209dab feat: support importing TTF/ODF fonts, closes #979 and closes #1708 (#1864) 2025-08-21 21:49:08 +02:00
Huang Xin 149f94be4c css: ensure workarounds don’t interfere with body background color (#1859) 2025-08-20 18:30:20 +02:00
Huang Xin ef1a1eab98 layout: consistent label font weight for settings panels (#1856) 2025-08-20 12:14:45 +02:00
lijiahao66666 dc7c6e14bc fix(translator): refresh translation when provider is changed in translator popup (#1855)
– Add the translate function to the dependency array in the TranslatorPopup component
– This change ensures that the translation content is re-fetched whenever the translate function is updated

Co-authored-by: 李家豪 <7904127+li_jiahaojj@user.noreply.gitee.com>
2025-08-20 11:54:20 +02:00
Huang Xin 8c31dc85f9 chore: bump next.js, opennext, wrangler and supabase to latest versions (#1851) 2025-08-20 05:57:28 +02:00
Huang Xin 7b8d1ddc35 chore: bump tauri to latest dev branch (#1849) 2025-08-20 05:15:44 +02:00
Huang Xin 8c12399732 layout: fix footer bar layout in landscape mode on Android, closes #1803 (#1843) 2025-08-19 15:49:38 +02:00
Huang Xin 93a4b1a448 css: fix unexpected align center for some EPUBs (#1842) 2025-08-19 15:05:26 +02:00
Huang Xin 04b0752b22 layout: fix drop down in TTS panel cannot open on older iOS, closes #1810 (#1841) 2025-08-19 15:02:02 +02:00
Huang Xin 5137c08204 chore: more aggressive cache for CI (#1839) 2025-08-19 10:21:20 +02:00
Huang Xin ec0b44fb9f reader: fix collapse of nested TOC for some EPUBs, closes #1820 and closes #1813 (#1837) 2025-08-19 09:55:25 +02:00
Huang Xin 0bf83dfe67 css: use monospace font for code blocks when overriding custom book fonts, closes #1805 (#1836) 2025-08-19 07:56:10 +02:00
Huang Xin b151b70cb9 fix: don't proxy network connection from Tailscale IP addresses, closes #1834 (#1835) 2025-08-19 05:02:29 +02:00
Huang Xin f2640359a0 release: version 0.9.72 (#1831) 2025-08-18 17:43:09 +02:00
Huang Xin 079aeaedb0 fix(sync): support custom koreader sync server hosted in LAN, closes #1800 (#1830) 2025-08-18 17:35:49 +02:00
Huang Xin bfacadb964 layout: fix options list align right on Windows (#1829) 2025-08-18 16:10:06 +02:00
Huang Xin 2e98ed44ee tts: fix edge tts, closes #1821 (#1828) 2025-08-18 14:44:38 +02:00
dependabot[bot] 952d2a3553 chore(deps): bump actions/checkout in the github-actions group (#1827)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 13:36:01 +02:00
StepanSad 225aa0ca59 Update translation.json (#1826) 2025-08-17 20:15:14 +08:00
Huang Xin 37f718b84b fix: adjust snap threshold to avoid pagination changes when toggling toolbars, closes #1807 (#1811) 2025-08-14 17:00:52 +02:00
Huang Xin db8f53a6e1 css: background color for dialogs and images, closes #1797 (#1798) 2025-08-13 15:37:52 +02:00
Huang Xin ce345ad67f fix(koplugin): add expected 301 status code (#1791) 2025-08-12 21:33:51 +02:00
Huang Xin 8e53e625a4 fix(koplugin): fix typo of saveSetting, closes #1788 (#1789) 2025-08-12 20:20:43 +02:00
85 changed files with 2471 additions and 1066 deletions
+6 -4
View File
@@ -11,7 +11,7 @@ jobs:
env:
RUSTFLAGS: '-C target-cpu=skylake'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
submodules: 'true'
- name: Install minimal stable with clippy and rustfmt
@@ -38,7 +38,7 @@ jobs:
- platform: 'web'
- platform: 'tauri'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
submodules: 'true'
@@ -57,8 +57,10 @@ jobs:
uses: actions/cache@v4
with:
path: apps/readest-app/.next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('apps/readest-app/package.json', 'pnpm-lock.yaml') }}
restore-keys: nextjs-${{ runner.os }}-
key: nextjs-${{ matrix.config.platform }}-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
nextjs-${{ matrix.config.platform }}-${{ github.sha }}-
nextjs-${{ matrix.config.platform }}-
- name: install Dependencies
working-directory: apps/readest-app
+35 -35
View File
@@ -17,7 +17,7 @@ jobs:
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v4
- name: get version
@@ -48,6 +48,39 @@ jobs:
core.setOutput('release_version', version);
core.setOutput('release_note', releaseNote);
update-release:
permissions:
contents: write
runs-on: ubuntu-latest
needs: get-release
steps:
- name: update release
id: update-release
uses: actions/github-script@v7
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_tag: ${{ needs.get-release.outputs.release_tag }}
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const { data } = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: process.env.release_tag,
})
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: body,
draft: false,
prerelease: false
})
build-tauri:
needs: get-release
permissions:
@@ -91,7 +124,7 @@ jobs:
runs-on: ${{ matrix.config.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: initialize git submodules
run: git submodule update --init --recursive
@@ -288,39 +321,6 @@ jobs:
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
update-release:
permissions:
contents: write
runs-on: ubuntu-latest
needs: [get-release, build-tauri]
steps:
- name: update release
id: update-release
uses: actions/github-script@v7
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_tag: ${{ needs.get-release.outputs.release_tag }}
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const { data } = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: process.env.release_tag,
})
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: body,
draft: false,
prerelease: false
})
upload-to-r2:
needs: [get-release, update-release]
uses: ./.github/workflows/upload-to-r2.yml
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
submodules: 'true'
- uses: amondnet/vercel-action@v25
+3
View File
@@ -40,3 +40,6 @@ next-env.d.ts
target
fastlane/report.xml
*.koplugin.zip
Generated
+374 -376
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -38,6 +38,7 @@ rust-version = "1.77.2"
tauri = { path = "packages/tauri/crates/tauri" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-os = { path = "packages/tauri-plugins/plugins/os" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
tauri-plugin-dialog = { path = "packages/tauri-plugins/plugins/dialog" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.71",
"version": "0.9.75",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -47,19 +47,19 @@
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.6.1",
"@opennextjs/cloudflare": "^1.6.5",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.50.2",
"@tauri-apps/api": "2.6.0",
"@supabase/supabase-js": "^2.55.0",
"@tauri-apps/api": "2.8.0",
"@tauri-apps/plugin-cli": "^2.4.0",
"@tauri-apps/plugin-deep-link": "^2.4.0",
"@tauri-apps/plugin-dialog": "^2.3.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-deep-link": "^2.4.1",
"@tauri-apps/plugin-dialog": "^2.3.2",
"@tauri-apps/plugin-fs": "^2.4.1",
"@tauri-apps/plugin-haptics": "^2.3.0",
"@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-http": "^2.5.1",
"@tauri-apps/plugin-log": "^2.6.0",
"@tauri-apps/plugin-opener": "^2.4.0",
"@tauri-apps/plugin-os": "^2.3.0",
@@ -84,7 +84,7 @@
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"marked": "^15.0.12",
"next": "15.3.3",
"next": "15.5.0",
"overlayscrollbars": "^2.11.4",
"overlayscrollbars-react": "^0.5.6",
"posthog-js": "^1.246.0",
@@ -105,7 +105,7 @@
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.7.0",
"@tauri-apps/cli": "2.8.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/cors": "^2.8.17",
@@ -137,6 +137,6 @@
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"wrangler": "^4.26.0"
"wrangler": "^4.31.0"
}
}
@@ -520,5 +520,18 @@
"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": "حدد الخطوط",
"Supported font formats: .ttf, .odf, .woff, .woff2": "تنسيقات الخط المدعومة: .ttf، .odf، .woff، .woff2"
}
@@ -498,7 +498,20 @@
"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": "ཡིག་གཟུགས་འདེམས་པ།",
"Supported font formats: .ttf, .odf, .woff, .woff2": "ཡིག་གཟུགས་འདེམས་པ་བྱས་ནས་བསྐྱར་བརྗེ་བ། ཡིག་གཟུགས་ཀྱི་སྒྲ་སྐད། .ttf, .odf, .woff, .woff2"
}
@@ -504,5 +504,18 @@
"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",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Unterstützte Schriftartenformate: .ttf, .odf, .woff, .woff2"
}
@@ -504,5 +504,18 @@
"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": "Επιλογή Γραμματοσειρών",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Υποστηριζόμενες μορφές γραμματοσειρών: .ttf, .odf, .woff, .woff2"
}
@@ -508,5 +508,18 @@
"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",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Formatos de fuente compatibles: .ttf, .odf, .woff, .woff2"
}
@@ -508,5 +508,18 @@
"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",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Formats de police pris en charge : .ttf, .odf, .woff, .woff2"
}
@@ -504,5 +504,18 @@
"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": "फ़ॉन्ट चुनें",
"Supported font formats: .ttf, .odf, .woff, .woff2": "समर्थित फ़ॉन्ट प्रारूप: .ttf, .odf, .woff, .woff2"
}
@@ -500,5 +500,18 @@
"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",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Format font yang didukung: .ttf, .odf, .woff, .woff2"
}
@@ -508,5 +508,18 @@
"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",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Formati di font supportati: .ttf, .odf, .woff, .woff2"
}
@@ -500,5 +500,18 @@
"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": "フォントを選択",
"Supported font formats: .ttf, .odf, .woff, .woff2": "サポートされているフォント形式:.ttf, .odf, .woff, .woff2"
}
@@ -500,5 +500,18 @@
"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": "글꼴 선택",
"Supported font formats: .ttf, .odf, .woff, .woff2": "지원되는 글꼴 형식: .ttf, .odf, .woff, .woff2"
}
@@ -504,5 +504,18 @@
"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",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Ondersteunde lettertypeformaten: .ttf, .odf, .woff, .woff2"
}
@@ -512,5 +512,18 @@
"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",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Obsługiwane formaty czcionek: .ttf, .odf, .woff, .woff2"
}
@@ -508,5 +508,18 @@
"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",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Formatos de fonte suportados: .ttf, .odf, .woff, .woff2"
}
@@ -512,5 +512,18 @@
"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": "Выбрать Шрифты",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Поддерживаемые форматы шрифтов: .ttf, .odf, .woff, .woff2"
}
@@ -500,5 +500,18 @@
"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": "เลือกฟอนต์",
"Supported font formats: .ttf, .odf, .woff, .woff2": "รูปแบบฟอนต์ที่รองรับ: .ttf, .odf, .woff, .woff2"
}
@@ -504,5 +504,18 @@
"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ç",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Desteklenen yazı tipi formatları: .ttf, .odf, .woff, .woff2"
}
@@ -93,7 +93,7 @@
"Slow": "Повільно",
"Solarized": "Сонячний",
"Speak": "Озвучити",
"Subjects": "Теми",
"Subjects": "Жанри",
"System Fonts": "Системні шрифти",
"Theme Color": "Колір теми",
"Theme Mode": "Режим теми",
@@ -469,26 +469,26 @@
"Disable Double Click": "Вимкнути подвійне натискання",
"TTS not supported for this document": "TTS не підтримується в цьому документі",
"Reset Password": "Скинути пароль",
"Show Reading Progress": "Показати прогрес читання",
"Reading Progress Style": "Стиль прогресу читання",
"Show Reading Progress": "Показати проґрес читання",
"Reading Progress Style": "Стиль проґресу читання",
"Page Number": "Номер сторінки",
"Percentage": "Процент",
"Percentage": "Відсотки",
"Deleted local copy of the book: {{title}}": "Видалено локальну копію книги: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Не вдалося видалити резервну копію книги в хмарі: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Не вдалося видалити локальну копію книги: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Ви впевнені, що хочете видалити локальну копію вибраної книги?",
"Remove from Cloud & Device": "Видалити з хмари та пристрою",
"Remove from Cloud Only": "Видалити тільки з хмари",
"Remove from Device Only": "Видалити тільки з пристрою",
"Disconnected": "Відключено",
"Remove from Cloud Only": "Видалити тільки із хмари",
"Remove from Device Only": "Видалити тільки із пристрою",
"Disconnected": "Від'єднано",
"KOReader Sync Settings": "Налаштування синхронізації KOReader",
"Sync Strategy": "Стратегія синхронізації",
"Ask on conflict": "Запитувати при конфлікті",
"Always use latest": "Завжди використовувати останню версію",
"Always use latest": "Завжди використовувати найновішу",
"Send changes only": "Відправити тільки зміни",
"Receive changes only": "Отримати тільки зміни",
"Disabled": "Вимкнено",
"Checksum Method": "Метод контрольної суми",
"Checksum Method": "Метод контролю суми",
"File Content (recommended)": "Вміст файлу (рекомендується)",
"File Name": "Ім'я файлу",
"Device Name": "Ім'я пристрою",
@@ -498,19 +498,32 @@
"Username": "Ім'я користувача",
"Your Username": "Ваше ім'я користувача",
"Password": "Пароль",
"Connect": "Підключитися",
"Connect": "Під'єднатися",
"KOReader Sync": "Синхронізація KOReader",
"Sync Conflict": "Конфлікт синхронізації",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання з \"{{deviceName}}\"?",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання із \"{{deviceName}}\"?",
"another device": "інший пристрій",
"Local Progress": "Локальний прогрес",
"Remote Progress": "Віддалений прогрес",
"Remote Progress": "Прогрес у хмарі",
"Page {{page}} of {{total}} ({{percentage}}%)": "Сторінка {{page}} з {{total}} ({{percentage}}%)",
"Current position": "Поточна позиція",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} з {{total}} ({{percentage}}%)",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} із {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Приблизно {{percentage}}%",
"Failed to connect": "Не вдалося підключитися",
"Sync Server Connected": "Сервер синхронізації підключено",
"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": "Вибрати Шрифти",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Підтримувані формати шрифтів: .ttf, .odf, .woff, .woff2"
}
@@ -500,5 +500,18 @@
"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ữ",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Định dạng phông chữ được hỗ trợ: .ttf, .odf, .woff, .woff2"
}
@@ -500,5 +500,18 @@
"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": "选择字体",
"Supported font formats: .ttf, .odf, .woff, .woff2": "支持的字体格式:.ttf, .odf, .woff, .woff2"
}
@@ -500,5 +500,18 @@
"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": "選擇字型",
"Supported font formats: .ttf, .odf, .woff, .woff2": "支援的字型格式:.ttf, .odf, .woff, .woff2"
}
+24 -1
View File
@@ -1,9 +1,32 @@
{
"releases": {
"0.9.75": {
"date": "2025-08-22",
"notes": [
"Fonts: Added support for importing local TTF and ODF fonts",
"TTS: Reduced the pause between sentences when using Edge TTS",
"TTS: Fixed an issue where voices list could not be opened on older versions of iOS",
"TOC: Fixed an issue where nested TOC items would not collapse properly in some books",
"Translator: Fixed an issue where translations in the popup did not refresh correctly",
"Library: Added option to sort authors with last name first",
"Layout: Fixed mismatch in close button position between the reader and library pages",
"Layout: Book title now remains centered in the available space",
"Layout: Fixed footer bar layout in landscape mode"
]
},
"0.9.72": {
"date": "2025-08-18",
"notes": [
"Fixed Edge TTS voice playback for EPUBs",
"Reduced accidental page flips when toggling toolbars",
"Fixed images with background colors not displaying correctly in some books",
"Added support for custom KOReader Sync Servers on your local network (LAN)"
]
},
"0.9.71": {
"date": "2025-08-13",
"notes": [
"Sync: Added two ways to sync reading progress with Koreader devices",
"Sync: Added two ways to sync reading progress with KOReader devices",
"EPUB: Applied monospace font settings",
"EPUB: Fixed initial text alignment for some EPUB files",
"PDF: Fixed issue opening large PDF files on Android",
+1 -1
View File
@@ -41,7 +41,7 @@ tauri-plugin-log = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-os = "2"
tauri-plugin-http = "2"
tauri-plugin-http = { version = "2", features = ["dangerous-settings"] }
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
@@ -87,6 +87,12 @@
},
{
"url": "https://translate.googleapis.com"
},
{
"url": "http://*:*"
},
{
"url": "https://*:*"
}
]
},
@@ -66,7 +66,7 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter android:autoVerify="true">
<intent-filter android:autoVerify="true" >
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -239,8 +239,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author);
const bAuthors = formatAuthors(b.author);
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
@@ -16,6 +16,7 @@ import { getOSPlatform } from '@/utils/misc';
type Option = {
value: string;
label: string;
disabled?: boolean;
};
type SelectProps = {
@@ -43,8 +44,8 @@ const StyledSelect: React.FC<SelectProps> = ({
)}
disabled={disabled}
>
{options.map(({ value, label }) => (
<option key={value} value={value}>
{options.map(({ value, label, disabled = false }) => (
<option key={value} value={value} disabled={disabled}>
{label}
</option>
))}
@@ -83,6 +84,13 @@ export const KOSyncSettingsWindow: React.FC = () => {
// Get the OS name once
useEffect(() => {
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);
};
const getOsName = async () => {
let name = '';
if (appService?.appPlatform === 'tauri') {
@@ -93,7 +101,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
name = platform;
}
}
setOsName(name ? name.charAt(0).toUpperCase() + name.slice(1) : '');
setOsName(formatOsName(name));
};
getOsName();
}, [appService]);
@@ -236,7 +244,6 @@ export const KOSyncSettingsWindow: React.FC = () => {
onClose={() => setIsOpen(false)}
title={_('KOReader Sync Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
bgClassName='!bg-black/60'
>
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
@@ -284,7 +291,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
{ value: 'filename', label: _('File Name') },
{ value: 'filename', label: _('File Name'), disabled: true },
]}
/>
</div>
@@ -335,34 +342,40 @@ export const KOSyncSettingsWindow: React.FC = () => {
type='text'
placeholder='https://koreader.sync.server'
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Username')}</span>
</label>
<input
type='text'
placeholder={_('Your Username')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Password')}</span>
</label>
<input
type='password'
placeholder={_('Your Password')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<form className='flex flex-col gap-4'>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Username')}</span>
</label>
<input
type='text'
placeholder={_('Your Username')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete='username'
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Password')}</span>
</label>
<input
type='password'
placeholder={_('Your Password')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete='current-password'
/>
</div>
</form>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
@@ -110,7 +110,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
windowButtonVisible ? 'sm:pr-4' : 'sm:pr-6',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
style={{
+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;
@@ -187,7 +187,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
appService?.isMobile ? 'fixed' : 'absolute',
// See: https://github.com/readest/readest/issues/1716
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
@@ -366,7 +367,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
</span>
<input
type='range'
className='text-base-content mx-2 w-full'
className='text-base-content mx-2 min-w-0 flex-1'
min={0}
max={100}
value={progressValid ? progressFraction * 100 : 0}
@@ -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';
@@ -40,6 +40,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const {
isTrafficLightVisible,
trafficLightInFullscreen,
setTrafficLightVisibility,
initializeTrafficLightStore,
@@ -52,6 +53,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const { isSideBarVisible } = useSidebarStore();
const iconSize16 = useResponsiveSize(16);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const handleToggleDropdown = (isOpen: boolean) => {
setIsDropdownOpen(isOpen);
if (!isOpen) setHoveredBookKey('');
@@ -133,9 +136,19 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<TranslationToggler bookKey={bookKey} />
</div>
<div className='header-title z-15 bg-base-100 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
<h2 className='line-clamp-1 max-w-[50%] text-center text-xs font-semibold'>
{bookTitle}
<div
className={clsx(
'header-title z-15 bg-base-100 pointer-events-none hidden flex-1 items-center justify-center sm:flex',
!windowButtonVisible && 'absolute inset-0',
)}
>
<h2
className={clsx(
'line-clamp-1 text-center text-xs font-semibold',
!windowButtonVisible && 'max-w-[50%]',
)}
>
{bookTitle} {bookTitle} {bookTitle} {bookTitle}
</h2>
</div>
@@ -154,16 +167,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<WindowButtons
className='window-buttons flex h-full items-center'
headerRef={headerRef}
showMinimize={
bookKeys.length == 1 &&
!appService?.hasTrafficLight &&
appService?.appPlatform !== 'web'
}
showMaximize={
bookKeys.length == 1 &&
!appService?.hasTrafficLight &&
appService?.appPlatform !== 'web'
}
showMinimize={bookKeys.length == 1 && windowButtonVisible}
showMaximize={bookKeys.length == 1 && windowButtonVisible}
onClose={() => {
setHoveredBookKey(null);
onCloseBook(bookKey);
@@ -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';
@@ -129,7 +129,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
fetchTranslation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, token, sourceLang, targetLang, provider]);
}, [text, token, sourceLang, targetLang, provider, translate]);
return (
<div>
@@ -176,9 +176,12 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
className='bg-gray-600 text-white/75'
value={targetLang}
onChange={handleTargetLangChange}
options={Object.entries(translatorLangs)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => ({ value: code, label: name }))}
options={[
{ value: '', label: _('System Language') },
...Object.entries(translatorLangs)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => ({ value: code, label: name })),
]}
/>
</div>
{loading ? (
@@ -187,7 +187,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
<div className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Color')}</h2>
<h2 className='font-medium'>{_('Override Book Color')}</h2>
<input
type='checkbox'
className='toggle'
@@ -264,7 +264,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
onChange={(event) => setCodeLanguage(event.target.value as CodeLanguage)}
options={CODE_LANGUAGES.map((lang) => ({
value: lang,
label: lang,
label: lang === 'auto-detect' ? _('Auto') : lang,
}))}
disabled={!codeHighlighting}
/>
@@ -0,0 +1,206 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { MdAdd, MdDelete } from 'react-icons/md';
import { IoMdCloseCircleOutline } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
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';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
interface CustomFontsProps {
bookKey: string;
onBack: () => void;
}
const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
const {
fonts: customFonts,
addFont,
loadFont,
removeFont,
getAvailableFonts,
saveCustomFonts,
} = useCustomFontStore();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const [isDeleteMode, setIsDeleteMode] = useState(false);
const { selectFiles } = useFileSelector(appService, _);
const currentDefaultFont =
viewSettings.defaultFont.toLowerCase() === 'serif' ? 'serif' : 'sans-serif';
const currentFontFamily =
currentDefaultFont === 'serif' ? viewSettings.serifFont : viewSettings.sansSerifFont;
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 handleSelectFont = (fontId: string) => {
const font = customFonts.find((f) => f.id === fontId);
if (font) {
if (currentDefaultFont === 'serif') {
saveViewSettings(envConfig, bookKey, 'serifFont', font.name);
} else {
saveViewSettings(envConfig, bookKey, 'sansSerifFont', font.name);
}
}
};
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'>
<ul>
<li>
<a className='font-semibold' onClick={onBack}>
{_('Font')}
</a>
</li>
<li className='font-medium'>{_('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={clsx(
'card h-12 border shadow-sm',
currentFontFamily === font.name
? 'border-primary/50 bg-primary/50'
: 'border-base-200 bg-base-200 cursor-pointer',
)}
onClick={() => handleSelectFont(font.id)}
>
<div className='card-body flex items-center justify-center p-2'>
<div
style={{
fontFamily: font.loaded ? `"${font.name}", sans-serif` : 'sans-serif',
fontWeight: 400,
}}
className='text-base-content line-clamp-1'
>
{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 text-sm sm:text-xs'>
<div className='mb-1 indent-2 font-medium'>{_('Tips')}:</div>
<ul className='list-outside list-disc space-y-1 ps-2'>
<li>{_('Supported font formats: .ttf, .odf, .woff, .woff2')}</li>
<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>
);
};
@@ -34,12 +34,7 @@ const FontItem: React.FC<FontItemProps> = ({ index, style, data }) => {
const option = options[index]!;
return (
<li
className='px-1 sm:px-2'
key={option.option}
style={style}
onClick={() => onSelect(option.option)}
>
<li className='px-2' key={option.option} style={style} onClick={() => onSelect(option.option)}>
<div className='flex w-full items-center overflow-hidden !px-0 text-sm'>
<span style={{ minWidth: `${iconSize16}px` }}>
{selected === option.option && (
@@ -138,7 +133,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
{/* More options with nested dropdown */}
{moreOptions && moreOptions.length > 0 && (
<li className='dropdown dropdown-left dropdown-top px-1 sm:px-2'>
<li className='dropdown dropdown-left dropdown-top px-2'>
<div className='flex items-center px-0 text-sm'>
<span style={{ minWidth: `${iconSize}px` }}>
<FiChevronLeft size={iconSize} />
@@ -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 { useResponsiveSize } from '@/hooks/useResponsiveSize';
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';
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,20 @@ 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(() => {
setSerifFont(viewSettings.serifFont);
setSansSerifFont(viewSettings.sansSerifFont);
setMonospaceFont(viewSettings.monospaceFont);
}, [viewSettings.serifFont, viewSettings.sansSerifFont, viewSettings.monospaceFont]);
useEffect(() => {
if (isTauriAppPlatform()) {
@@ -247,10 +276,18 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
}
};
if (fontPanelView === 'custom-fonts') {
return (
<div className='my-4 w-full'>
<CustomFonts bookKey={bookKey} onBack={handleBackToMain} />
</div>
);
}
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Font')}</h2>
<h2 className='font-medium'>{_('Override Book Font')}</h2>
<input
type='checkbox'
className='toggle'
@@ -326,14 +363,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 +391,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 +404,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}
@@ -370,7 +370,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Layout')}</h2>
<h2 className='font-medium'>{_('Override Book Layout')}</h2>
<input
type='checkbox'
className='toggle'
@@ -40,7 +40,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const tabsRef = useRef<HTMLDivElement | null>(null);
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const { setFontPanelView, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const tabConfig = [
{
@@ -85,6 +85,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
const handleSetActivePanel = (tab: SettingsPanelType) => {
setActivePanel(tab);
setFontPanelView('main-fonts');
localStorage.setItem('lastConfigPanel', tab);
};
@@ -115,6 +116,8 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
};
useEffect(() => {
setFontPanelView('main-fonts');
const container = tabsRef.current;
if (!container) return;
@@ -163,6 +166,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
isOpen={true}
onClose={handleClose}
className='modal-open'
bgClassName='sm:!bg-black/20'
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
snapHeight={appService?.isMobile ? 0.7 : undefined}
header={
@@ -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
@@ -13,12 +13,17 @@ import { getContentMd5 } from '@/utils/misc';
import { useTextTranslation } from '../../hooks/useTextTranslation';
import { FlatTOCItem, StaticListRow, VirtualListRow } from './TOCItem';
const getItemIdentifier = (item: TOCItem) => {
const href = item.href || '';
return `toc-item-${item.id}-${href}`;
};
const useFlattenedTOC = (toc: TOCItem[], expandedItems: Set<string>) => {
return useMemo(() => {
const flattenTOC = (items: TOCItem[], depth = 0): FlatTOCItem[] => {
const result: FlatTOCItem[] = [];
items.forEach((item, index) => {
const isExpanded = expandedItems.has(item.href || '');
const isExpanded = expandedItems.has(getItemIdentifier(item));
result.push({ item, depth, index, isExpanded });
if (item.subitems && isExpanded) {
result.push(...flattenTOC(item.subitems, depth + 1));
@@ -126,13 +131,13 @@ const TOCView: React.FC<{
}, [flatItems, activeHref]);
const handleToggleExpand = useCallback((item: TOCItem) => {
const href = item.href || '';
const itemId = getItemIdentifier(item);
setExpandedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(href)) {
newSet.delete(href);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(href);
newSet.add(itemId);
}
return newSet;
});
@@ -149,9 +154,10 @@ const TOCView: React.FC<{
);
const expandParents = useCallback((toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
const parentHrefs = parentPath.filter(Boolean) as string[];
setExpandedItems(new Set(parentHrefs));
const parentItems = findParentPath(toc, href)
.map((item) => getItemIdentifier(item))
.filter(Boolean);
setExpandedItems(new Set(parentItems));
}, []);
const scrollToActiveItem = useCallback(() => {
@@ -176,7 +182,8 @@ const TOCView: React.FC<{
(activeItem as HTMLElement).setAttribute('aria-current', 'page');
}
}
}, [activeHref, flatItems]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeHref]);
const virtualItemSize = useMemo(() => {
return window.innerWidth >= 640 && !viewSettings?.translationEnabled ? 37 : 57;
@@ -210,7 +217,7 @@ const TOCView: React.FC<{
setTimeout(scrollToActiveItem, appService?.isAndroidApp ? 300 : 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flatItems, scrollToActiveItem]);
}, [scrollToActiveItem]);
return flatItems.length > 256 ? (
<div
@@ -13,14 +13,18 @@ const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
return (
<div
className={clsx(
'relative h-full w-full',
'relative h-full w-full rounded-full',
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
)}
style={{
maskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
WebkitMaskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
}}
onClick={onClick}
>
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
<div
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
className='absolute -inset-full rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
style={{
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
}}
@@ -248,6 +248,7 @@ const TTSPanel = ({
<button
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1'
onClick={(e) => e.currentTarget.focus()}
>
<MdAlarm size={iconSize32} />
{timeoutCountdown && (
@@ -290,7 +291,11 @@ const TTSPanel = ({
</div>
<div className='dropdown dropdown-top'>
<button tabIndex={0} className='rounded-full p-1'>
<button
tabIndex={0}
className='rounded-full p-1'
onClick={(e) => e.currentTarget.focus()}
>
<RiVoiceAiFill size={iconSize32} />
</button>
<ul
@@ -59,7 +59,7 @@ export const useProgressSync = (bookKey: string) => {
config.xpointer = xpointerResult.xpointer;
}
} catch (error) {
console.error('Failed to convert CFI to XPointer', error);
console.warn('Failed to convert CFI to XPointer', error);
}
pushConfig(bookKey, config);
}
+1 -1
View File
@@ -161,7 +161,7 @@ const Dialog: React.FC<DialogProps> = ({
>
<div
className={clsx(
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20',
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/50',
appService?.hasRoundedWindow && 'rounded-window',
bgClassName,
)}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function Select({
value={value}
onChange={onChange}
className={clsx(
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
'select h-8 min-h-8 rounded-md border-none text-sm',
'bg-base-200 focus:outline-none focus:ring-0',
className,
)}
@@ -187,7 +187,6 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
title={editMode ? _('Edit Metadata') : _('Book Details')}
isOpen={isOpen}
onClose={handleClose}
bgClassName='sm:bg-black/50'
boxClassName={clsx(
editMode ? 'sm:min-w-[600px] sm:max-w-[600px]' : 'sm:min-w-[480px] sm:max-w-[480px]',
'sm:h-auto sm:max-h-[90%]',
@@ -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';
+34 -1
View File
@@ -1,4 +1,5 @@
import { md5 } from 'js-md5';
import { createHash } from 'crypto';
import { randomMd5 } from '@/utils/misc';
import { LRUCache } from '@/utils/lru';
import { genSSML } from '@/utils/ssml';
@@ -6,6 +7,7 @@ import { genSSML } from '@/utils/ssml';
const EDGE_SPEECH_URL =
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
const EDGE_API_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
const CHROMIUM_FULL_VERSION = '130.0.2849.68';
const EDGE_TTS_VOICES = {
'af-ZA': ['af-ZA-AdriNeural', 'af-ZA-WillemNeural'],
'am-ET': ['am-ET-AmehaNeural', 'am-ET-MekdesNeural'],
@@ -120,6 +122,31 @@ const EDGE_TTS_VOICES = {
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural'],
};
/**
* Generates the Sec-MS-GEC token value.
* This function generates a token value based on the current time in Windows file time format
* adjusted for clock skew, and rounded down to the nearest 5 minutes. The token is then hashed
* using SHA256 and returned as an uppercased hex digest.
*
* @returns The generated Sec-MS-GEC token value.
* @see https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570
*/
const WIN_EPOCH_OFFSET = 11644473600; // Windows epoch offset in seconds (1601 to 1970)
const S_TO_NS = 1000000000; // Seconds to nanoseconds conversion
const generateSecMsGec = () => {
let ticks = Math.floor(Date.now() / 1000);
// Switch to Windows file time epoch (1601-01-01 00:00:00 UTC)
ticks += WIN_EPOCH_OFFSET;
// Round down to the nearest 5 minutes (300 seconds)
ticks -= ticks % 300;
// Convert the ticks to 100-nanosecond intervals (Windows file time format)
ticks *= S_TO_NS / 100;
// Create the string to hash by concatenating the ticks and the trusted client token
const strToHash = `${ticks.toFixed(0)}${EDGE_API_TOKEN}`;
// Compute the SHA256 hash and return the uppercased hex digest
return createHash('sha256').update(strToHash, 'ascii').digest('hex').toUpperCase();
};
const genVoiceList = (voices: Record<string, string[]>) => {
return Object.entries(voices).flatMap(([lang, voices]) => {
return voices.map((id) => {
@@ -150,7 +177,13 @@ export class EdgeSpeechTTS {
async #fetchEdgeSpeechWs({ lang, text, voice, rate }: EdgeTTSPayload): Promise<Response> {
const connectId = randomMd5();
const url = `${EDGE_SPEECH_URL}?ConnectionId=${connectId}&TrustedClientToken=${EDGE_API_TOKEN}`;
const params = new URLSearchParams({
ConnectionId: connectId,
TrustedClientToken: EDGE_API_TOKEN,
'Sec-MS-GEC': generateSecMsGec(),
'Sec-MS-GEC-Version': `1-${CHROMIUM_FULL_VERSION}`,
});
const url = `${EDGE_SPEECH_URL}?${params.toString()}`;
const date = new Date().toString();
const configHeaders = {
'Content-Type': 'application/json; charset=utf-8',
+1 -1
View File
@@ -239,7 +239,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
response = await POST(nextReq);
} else {
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).end(`Method ${req.method} Not Allowed`);
return res.status(405).json({ error: 'Method Not Allowed' });
}
res.status(response.status);
+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,
@@ -1,8 +1,11 @@
import { md5 } from 'js-md5';
import { Book } from '@/types/book';
import { KoreaderSyncChecksumMethod } from '@/types/settings';
import { getAPIBaseUrl } from '../environment';
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';
/**
* Interface for KOSync progress response from the server
@@ -23,6 +26,7 @@ export class KOSyncClient {
private checksumMethod: KoreaderSyncChecksumMethod;
private deviceId: string;
private deviceName: string;
private isLanServer: boolean;
constructor(
serverUrl: string,
@@ -38,6 +42,7 @@ export class KOSyncClient {
this.checksumMethod = checksumMethod;
this.deviceId = deviceId;
this.deviceName = deviceName;
this.isLanServer = isLanAddress(this.serverUrl);
}
private async request(
@@ -57,6 +62,24 @@ export class KOSyncClient {
headers.set('X-Auth-Key', this.userkey);
}
if (this.isLanServer) {
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const directUrl = `${this.serverUrl}${endpoint}`;
return fetch(directUrl, {
method,
headers: {
accept: 'application/vnd.koreader.v1+json',
...Object.fromEntries(headers.entries()),
},
body,
danger: {
acceptInvalidCerts: true,
acceptInvalidHostnames: true,
},
});
}
const proxyUrl = `${getAPIBaseUrl()}/kosync`;
const proxyBody: KoSyncProxyPayload = {
@@ -201,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;
}
}
@@ -97,18 +97,17 @@ export class EdgeTTSClient implements TTSClient {
} as TTSMessageEvent;
return;
} else {
await this.stopInternal();
}
await this.stopInternal();
// Reuse the same Audio element inside the ssml session
this.#audioElement = new Audio();
const audio = this.#audioElement;
audio.setAttribute('x-webkit-airplay', 'deny');
audio.preload = 'auto';
for (const mark of marks) {
if (signal.aborted) {
yield {
code: 'error',
message: 'Aborted',
} as TTSMessageEvent;
break;
}
let abortHandler: null | (() => void) = null;
try {
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
@@ -116,11 +115,7 @@ export class EdgeTTSClient implements TTSClient {
const blob = await this.#edgeTTS.createAudio(
this.getPayload(voiceLang, mark.text, voiceId),
);
const url = URL.createObjectURL(blob);
this.#audioElement = new Audio(url);
const audio = this.#audioElement;
audio.setAttribute('x-webkit-airplay', 'deny');
audio.preload = 'auto';
audio.src = URL.createObjectURL(blob);
this.controller?.dispatchSpeakMark(mark);
@@ -135,27 +130,30 @@ export class EdgeTTSClient implements TTSClient {
audio.onended = null;
audio.onerror = null;
audio.pause();
audio.src = '';
URL.revokeObjectURL(url);
if (audio.src) {
URL.revokeObjectURL(audio.src);
audio.removeAttribute('src');
}
};
abortHandler = () => {
cleanUp();
resolve({ code: 'error', message: 'Aborted' });
};
if (signal.aborted) {
abortHandler();
return;
} else {
signal.addEventListener('abort', abortHandler);
}
audio.onended = () => {
cleanUp();
if (signal.aborted) {
resolve({ code: 'error', message: 'Aborted' });
} else {
resolve({ code: 'end', message: `Chunk finished: ${mark.name}` });
}
resolve({ code: 'end', message: `Chunk finished: ${mark.name}` });
};
audio.onerror = (e) => {
cleanUp();
console.warn('Audio playback error:', e);
resolve({ code: 'error', message: 'Audio playback error' });
};
if (signal.aborted) {
cleanUp();
resolve({ code: 'error', message: 'Aborted' });
return;
}
this.#isPlaying = true;
audio.play().catch((err) => {
cleanUp();
@@ -167,22 +165,20 @@ export class EdgeTTSClient implements TTSClient {
} catch (error) {
if (error instanceof Error && error.message === 'No audio data received.') {
console.warn('No audio data received for:', mark.text);
yield {
code: 'end',
message: `Chunk finished: ${mark.name}`,
} as TTSMessageEvent;
yield { code: 'end', message: `Chunk finished: ${mark.name}` } as TTSMessageEvent;
continue;
}
console.log('Error:', error);
yield {
code: 'error',
message: error instanceof Error ? error.message : String(error),
} as TTSMessageEvent;
const message = error instanceof Error ? error.message : String(error);
console.warn('TTS error for mark:', mark.text, message);
yield { code: 'error', message } as TTSMessageEvent;
break;
} finally {
if (abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
}
await this.stopInternal();
}
await this.stopInternal();
}
async pause() {
@@ -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 {
+21 -18
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,
@@ -105,21 +91,38 @@ export const flattenContributors = (
: formatLanguageMap(contributors?.name);
};
// prettier-ignore
const LASTNAME_AUTHOR_SORT_LANGS = [ 'ar', 'bo', 'de', 'en', 'es', 'fr', 'hi', 'it', 'nl', 'pl', 'pt', 'ru', 'th', 'tr', 'uk' ];
const formatAuthorName = (name: string, lastNameFirst: boolean) => {
if (!name) return '';
const parts = name.split(' ');
if (lastNameFirst && parts.length > 1) {
return `${parts[parts.length - 1]}, ${parts.slice(0, -1).join(' ')}`;
}
return name;
};
export const formatAuthors = (
contributors: string | string[] | Contributor | Contributor[],
bookLang?: string | string[],
sortAs?: boolean,
) => {
const langCode = getBookLangCode(bookLang) || 'en';
const lastNameFirst = !!sortAs && LASTNAME_AUTHOR_SORT_LANGS.includes(langCode);
return Array.isArray(contributors)
? listFormater(langCode === 'zh', langCode).format(
contributors.map((contributor) =>
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name),
typeof contributor === 'string'
? formatAuthorName(contributor, lastNameFirst)
: formatAuthorName(formatLanguageMap(contributor?.name), lastNameFirst),
),
)
: typeof contributors === 'string'
? contributors
: formatLanguageMap(contributors?.name);
? formatAuthorName(contributors, lastNameFirst)
: formatAuthorName(formatLanguageMap(contributors?.name), lastNameFirst);
};
export const formatTitle = (title: string | LanguageMap) => {
return typeof title === 'string' ? title : formatLanguageMap(title);
};
+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;
}
};
+58
View File
@@ -0,0 +1,58 @@
export const isLanAddress = (url: string) => {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return true;
}
// Check for IPv4 private ranges
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const match = hostname.match(ipv4Regex);
if (match) {
const [, a, b, c, d] = match.map(Number);
if (a === undefined || b === undefined || c === undefined || d === undefined) {
return false;
}
// Validate IP format
if (a > 255 || b > 255 || c > 255 || d > 255) {
return false;
}
// Check private IP ranges:
// 10.0.0.0/8 (10.0.0.0 to 10.255.255.255)
if (a === 10) return true;
// 172.16.0.0/12 (172.16.0.0 to 172.31.255.255)
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16 (192.168.0.0 to 192.168.255.255)
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 (link-local addresses)
if (a === 169 && b === 254) return true;
// Tailscale IPv4 range: 100.64.0.0/10 (100.64.0.0 to 100.127.255.255)
if (a === 100 && b >= 64 && b <= 127) return true;
}
// Check for IPv6 private addresses (simplified check)
if (hostname.includes(':')) {
if (
hostname.startsWith('::1') ||
hostname.startsWith('fe80:') ||
hostname.startsWith('fc00:') ||
hostname.startsWith('fd00:')
) {
return true;
}
}
return false;
} catch {
return false;
}
};
+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;
};
+12 -18
View File
@@ -82,17 +82,16 @@ const getFontStyles = (
font[size="7"] {
font-size: ${fontSize * 3}px;
}
pre, code, kbd {
font-family: var(--monospace);
}
/* hardcoded inline font size */
[style*="font-size: 16px"], [style*="font-size:16px"] {
font-size: 1rem !important;
}
body * {
pre, code, kbd {
font-family: var(--monospace);
}
body *:not(pre):not(code):not(kbd):not(pre *):not(code *):not(kbd *) {
${overrideFont ? 'font-family: revert !important;' : ''}
}
`;
return fontStyles;
};
@@ -131,9 +130,6 @@ const getColorStyles = (
${overrideColor ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''}
text-decoration: none;
}
p:has(img), span:has(img) {
background-color: ${bg};
}
body.pbg {
${isDarkMode ? `background-color: ${bg} !important;` : ''}
}
@@ -141,8 +137,8 @@ const getColorStyles = (
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
${!isDarkMode && overrideColor ? 'mix-blend-mode: multiply;' : ''}
}
/* horizontal rule */
*:has(hr) {
/* horizontal rule #1649 */
*:has(> hr[class]):not(body) {
background-color: ${bg};
}
hr {
@@ -324,7 +320,7 @@ const getLayoutStyles = (
width: auto;
height: auto;
}
.duokan-footnote img {
.duokan-footnote img:not([class]) {
width: 0.8em;
height: 0.8em;
}
@@ -497,14 +493,12 @@ export const transformStylesheet = (vw: number, vh: number, css: string) => {
const hasTextAlignCenter = /text-align\s*:\s*center\s*[;$]/.test(block);
const hasTextIndentZero = /text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?\s*[;$]/.test(block);
if (hasTextAlignCenter) {
if (hasTextAlignCenter && hasTextIndentZero) {
block = block.replace(/(text-align\s*:\s*center)(\s*;|\s*$)/g, '$1 !important$2');
if (hasTextIndentZero) {
block = block.replace(
/(text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?)(\s*;|\s*$)/g,
'$1 !important$2',
);
}
block = block.replace(
/(text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?)(\s*;|\s*$)/g,
'$1 !important$2',
);
return selector + block;
}
return match;
+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 {
+4 -4
View File
@@ -63,7 +63,7 @@ function ReadestSync:tryRefreshToken()
self.settings.refresh_token = response.refresh_token
self.settings.expires_at = response.expires_at
self.settings.expires_in = response.expires_in
G_reader_settings:writeSetting("readest_sync", self.settings)
G_reader_settings:saveSetting("readest_sync", self.settings)
else
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
end
@@ -229,8 +229,8 @@ function ReadestSync:doLogin(email, password, menu)
self.settings.refresh_token = response.refresh_token
self.settings.expires_at = response.expires_at
self.settings.expires_in = response.expires_in
G_reader_settings:writeSetting("readest_sync", self.settings)
G_reader_settings:saveSetting("readest_sync", self.settings)
if menu then
menu:updateItems()
end
@@ -261,7 +261,7 @@ function ReadestSync:logout(menu)
self.settings.refresh_token = nil
self.settings.expires_at = nil
self.settings.expires_in = nil
G_reader_settings:writeSetting("readest_sync", self.settings)
G_reader_settings:saveSetting("readest_sync", self.settings)
if menu then
menu:updateItems()
+3 -3
View File
@@ -1,19 +1,19 @@
{
"base_url": "http://web.readest.com/api",
"base_url": "https://web.readest.com/api",
"name": "readest-sync-api",
"methods": {
"pullChanges": {
"path": "/sync",
"method": "GET",
"required_params": ["since", "type", "book"],
"expected_status": [200, 400, 401, 403]
"expected_status": [200, 400, 301, 401, 403]
},
"pushChanges": {
"path": "/sync",
"method": "POST",
"required_params": ["books", "notes", "configs"],
"payload": ["books", "notes", "configs"],
"expected_status": [200, 201, 400, 401, 403]
"expected_status": [200, 201, 301, 400, 401, 403]
}
}
}
+227 -265
View File
File diff suppressed because it is too large Load Diff