Compare commits

..

26 Commits

Author SHA1 Message Date
Huang Xin cc3cc58de0 release: version 0.9.78 (#1975) 2025-09-06 10:50:18 +02:00
Huang Xin 80d60266c2 settings: default to override background color in fixed layout documents (#1974) 2025-09-06 10:40:18 +02:00
54wedge f06cdb5899 txt: nest chapters inside volumes when converting to epub (#1972)
* txt: nest chapters inside volumes

* txt: readability

* txt: format
2025-09-06 10:28:26 +02:00
Huang Xin dfe68988aa fix(kosync): use 1-based indices in xpointer, closes #1968 (#1973) 2025-09-06 07:40:32 +02:00
Huang Xin d0264de3eb compat: more accessible iframes for PDFs, closes #1136 (#1971) 2025-09-05 18:56:15 +02:00
Huang Xin 77c4303938 feat(pdf): support setting zoom level, zoom mode and spread mode for PDF (#1969)
This should close #1717, close #1276, close #669.
2025-09-05 18:20:15 +02:00
Huang Xin 5724d2eeec feat(tts): support going previous/next by sentence in bottom TTS bar, closes #1816 (#1967) 2025-09-05 10:34:30 +02:00
54wedge 64007b1188 txt: improve zh chapter extraction (#1966)
* txt: improve zh chapter extraction

* txt: fix regexp

* txt: one more keyword
2025-09-05 10:31:26 +02:00
Huang Xin 50ee7bd8be doc: change badge colors for last commit and commit activity 2025-09-04 11:44:11 +02:00
Huang Xin 2bcbde4bf1 doc: fixed badge-language-coverage URL encoding 2025-09-04 11:39:27 +02:00
Huang Xin 99f75dd3b6 i18n: add Bengali(BN), Sinhala (SI) and Tamil (TA) translations (#1965) 2025-09-04 11:37:29 +02:00
Huang Xin 2b9132b4a8 feat(translator): preprocess some common mistranslated publishing terms (#1964) 2025-09-04 09:38:17 +02:00
Huang Xin 82782be16e fix: prevent layout shift when virtual keyboard pops up on Android, closes #1959 (#1963) 2025-09-04 08:04:51 +02:00
Huang Xin 7067129887 fix(tts): fixed headings cannot be highlighted in TTS (#1955) 2025-09-03 16:42:59 +02:00
Huang Xin 6e706ac8f7 feat: add inline editor for bookmarks in the sidebar, closes #1909 (#1954) 2025-09-03 11:06:35 +02:00
Huang Xin 39b902c927 epub: parse correct book front cover for some EPUBs, closes #1913 (#1953) 2025-09-03 07:50:26 +02:00
Huang Xin 94a9cd7c9d doc: update README and add link of readest subreddit (#1952) 2025-09-03 07:24:54 +02:00
Huang Xin 0dae7db140 fix: improved sensitivity of text selection with stylus, closes #706 (#1950) 2025-09-02 18:51:45 +02:00
Huang Xin 32954e025c fix(tts): media control in the lock screen and with airpods for iOS, closes #1407 (#1949) 2025-09-02 17:24:16 +02:00
Huang Xin 61dda9a517 feat: group custom fonts into families, closes #1934 (#1945) 2025-09-01 17:49:46 +02:00
Huang Xin 99a4cb50ac feat: add an option to show persistent TTS bar at the bottom, closes #1818 (#1944) 2025-09-01 14:23:28 +02:00
Huang Xin f4b00d6dec fix: get rid of invalid grid insets (#1943) 2025-09-01 07:05:48 +02:00
Huang Xin f69422a61c fix: conditional Back key interception on Android (#1942) 2025-09-01 06:16:31 +02:00
Huang Xin 7f4c157a90 chore: build with install package permission by default for Android (#1939) 2025-08-31 16:53:29 +02:00
Huang Xin 7d9724ff8b fix: close books with the system back key on Android, closes #1933 (#1938) 2025-08-31 10:15:28 +02:00
Huang Xin 06db7f36f8 refactor: rename ConfirmSyncDialog to KOSyncResolver (#1937) 2025-08-31 05:46:43 +02:00
83 changed files with 3009 additions and 430 deletions
-5
View File
@@ -210,11 +210,6 @@ jobs:
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
MANIFEST="src-tauri/gen/android/app/src/main/AndroidManifest.xml"
PERMISSION_LINE='<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>'
grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST" || \
sed -i "/android.permission.INTERNET/a \ $PERMISSION_LINE" "$MANIFEST"
pushd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
+24 -19
View File
@@ -11,14 +11,15 @@
[![Web App][badge-web-app]][link-web-readest]
[![OS][badge-platforms]][link-website]
<br>
[![][badge-hellogithub]][link-hellogithub]
[![][badge-discord]][link-discord]
[![Discord][badge-discord]][link-discord]
[![Reddit][badge-reddit]][link-reddit]
[![AGPL Licence][badge-license]](LICENSE)
[![Latest release][badge-release]][link-gh-releases]
[![Language Coverage][badge-language-coverage]]()
[![Donate][badge-donate]][link-donate]
<br>
[![Latest release][badge-release]][link-gh-releases]
[![Last commit][badge-last-commit]][link-gh-commits]
[![Commits][badge-commit-activity]][link-gh-pulse]
[![][badge-hellogithub]][link-hellogithub]
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
</div>
@@ -67,16 +68,16 @@
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **Feature** | **Description** | **Priority** |
| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------ |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -109,9 +110,9 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
### Platform-Specific Downloads
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Web: Visit [https://web.readest.com][link-web-readest].
- macOS / iOS / iPadOS : Search and install **Readest** on the [App Store][link-appstore], _also_ available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit and download **Readest** at [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
## Requirements
@@ -303,12 +304,14 @@ The following fonts are utilized in this software, either bundled within the app
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=blue
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest?color=blue
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
[badge-donate]: https://donate.readest.com/badge.svg
[badge-deepwiki]: https://deepwiki.com/badge.svg
[badge-reddit]: https://img.shields.io/reddit/subreddit-subscribers/readest?style=flat&logo=reddit&color=F37E41
[badge-language-coverage]: https://img.shields.io/badge/coverage-52%25%20population%20🌍-green
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
@@ -322,3 +325,5 @@ The following fonts are utilized in this software, either bundled within the app
[link-koreader]: https://github.com/koreader/koreader
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
[link-deepwiki]: https://deepwiki.com/readest/readest
[link-kosync-wiki]: https://github.com/readest/readest/wiki/Sync-with-Koreader-devices
[link-reddit]: https://reddit.com/r/readest/
@@ -28,6 +28,9 @@ module.exports = {
'ar',
'th',
'bo',
'bn',
'ta',
'si',
'zh-CN',
'zh-TW',
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.76",
"version": "0.9.78",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -494,7 +494,6 @@
"Always use latest": "استخدام الأحدث دائمًا",
"Send changes only": "إرسال التغييرات فقط",
"Receive changes only": "استلام التغييرات فقط",
"Disabled": "معطل",
"Checksum Method": "طريقة التحقق من الصحة",
"File Content (recommended)": "محتوى الملف (موصى به)",
"File Name": "اسم الملف",
@@ -532,5 +531,12 @@
"Select Fonts": "حدد الخطوط",
"Supported font formats: .ttf, .otf, .woff, .woff2": "تنسيقات الخط المدعومة: .ttf، .otf، .woff، .woff2",
"Push Progress": "تقدم الدفع",
"Pull Progress": "تقدم السحب"
"Pull Progress": "تقدم السحب",
"Previous Paragraph": "الفقرة السابقة",
"Previous Sentence": "الجملة السابقة",
"Pause": "إيقاف مؤقت",
"Play": "تشغيل",
"Next Sentence": "الجملة التالية",
"Next Paragraph": "الفقرة التالية",
"Separate Cover Page": "صفحة غلاف منفصلة"
}
@@ -0,0 +1,526 @@
{
"Email address": "ইমেইল ঠিকানা",
"Your Password": "আপনার পাসওয়ার্ড",
"Your email address": "আপনার ইমেইল ঠিকানা",
"Your password": "আপনার পাসওয়ার্ড",
"Sign in": "সাইন ইন",
"Signing in...": "সাইন ইন করা হচ্ছে...",
"Sign in with {{provider}}": "{{provider}} দিয়ে সাইন ইন",
"Already have an account? Sign in": "ইতিমধ্যে অ্যাকাউন্ট আছে? সাইন ইন করুন",
"Create a Password": "একটি পাসওয়ার্ড তৈরি করুন",
"Sign up": "সাইন আপ",
"Signing up...": "সাইন আপ করা হচ্ছে...",
"Don't have an account? Sign up": "অ্যাকাউন্ট নেই? সাইন আপ করুন",
"Check your email for the confirmation link": "নিশ্চিতকরণ লিঙ্কের জন্য ইমেইল চেক করুন",
"Signing in ...": "সাইন ইন করা হচ্ছে...",
"Send a magic link email": "ম্যাজিক লিঙ্ক ইমেইল পাঠান",
"Check your email for the magic link": "ম্যাজিক লিঙ্কের জন্য ইমেইল চেক করুন",
"Send reset password instructions": "পাসওয়ার্ড রিসেট নির্দেশনা পাঠান",
"Sending reset instructions ...": "রিসেট নির্দেশনা পাঠানো হচ্ছে...",
"Forgot your password?": "পাসওয়ার্ড ভুলে গেছেন?",
"Check your email for the password reset link": "পাসওয়ার্ড রিসেট লিঙ্কের জন্য ইমেইল চেক করুন",
"Phone number": "ফোন নম্বর",
"Your phone number": "আপনার ফোন নম্বর",
"Token": "টোকেন",
"Your OTP token": "আপনার ওটিপি টোকেন",
"Verify token": "টোকেন যাচাই করুন",
"Sign in with Google": "গুগল দিয়ে সাইন ইন",
"Sign in with Apple": "অ্যাপল দিয়ে সাইন ইন",
"Sign in with GitHub": "গিটহাব দিয়ে সাইন ইন",
"New Password": "নতুন পাসওয়ার্ড",
"Your new password": "আপনার নতুন পাসওয়ার্ড",
"Update password": "পাসওয়ার্ড আপডেট করুন",
"Updating password ...": "পাসওয়ার্ড আপডেট করা হচ্ছে...",
"Your password has been updated": "আপনার পাসওয়ার্ড আপডেট হয়েছে",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "কিছু ভুল হয়েছে। চিন্তা করবেন না, আমাদের দল জানানো হয়েছে এবং আমরা সমাধানে কাজ করছি।",
"Error Details:": "ত্রুটির বিস্তারিত:",
"Try Again": "আবার চেষ্টা করুন",
"Go Back": "ফিরে যান",
"Your Library": "আপনার লাইব্রেরি",
"Need help?": "সাহায্য দরকার?",
"Contact Support": "সাপোর্টের সাথে যোগাযোগ",
"Open": "খুলুন",
"Group": "গ্রুপ",
"Details": "বিস্তারিত",
"Delete": "মুছুন",
"Cancel": "বাতিল",
"Confirm Deletion": "মুছে ফেলা নিশ্চিত করুন",
"Are you sure to delete {{count}} selected book(s)?_one": "আপনি কি নিশ্চিত যে নির্বাচিত বইটি মুছবেন?",
"Are you sure to delete {{count}} selected book(s)?_other": "আপনি কি নিশ্চিত যে {{count}}টি নির্বাচিত বই মুছবেন?",
"Deselect Book": "বই অনির্বাচিত করুন",
"Select Book": "বই নির্বাচন করুন",
"Show Book Details": "বইয়ের বিস্তারিত দেখান",
"Download Book": "বই ডাউনলোড করুন",
"Upload Book": "বই আপলোড করুন",
"Deselect Group": "গ্রুপ অনির্বাচিত করুন",
"Select Group": "গ্রুপ নির্বাচন করুন",
"Untitled Group": "নামবিহীন গ্রুপ",
"Group Books": "বই গ্রুপ করুন",
"Remove From Group": "গ্রুপ থেকে সরান",
"Create New Group": "নতুন গ্রুপ তৈরি করুন",
"Save": "সংরক্ষণ",
"Confirm": "নিশ্চিত করুন",
"From Local File": "স্থানীয় ফাইল থেকে",
"Search in {{count}} Book(s)..._one": "বইটিতে খুঁজুন...",
"Search in {{count}} Book(s)..._other": "{{count}}টি বইয়ে খুঁজুন...",
"Search Books...": "বই খুঁজুন...",
"Clear Search": "খোঁজ পরিষ্কার করুন",
"Import Books": "বই আমদানি করুন",
"Select Multiple Books": "একাধিক বই নির্বাচন করুন",
"Select Books": "বই নির্বাচন করুন",
"Deselect": "অনির্বাচিত করুন",
"Select All": "সব নির্বাচন করুন",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} হিসেবে লগ ইন",
"Logged in": "লগ ইন করেছেন",
"Account": "অ্যাকাউন্ট",
"Sign In": "সাইন ইন",
"Auto Upload Books to Cloud": "স্বয়ংক্রিয় ক্লাউডে বই আপলোড",
"Auto Import on File Open": "ফাইল খোলার সময় স্বয়ংক্রিয় আমদানি",
"Open Last Book on Start": "শুরুতে শেষ বইটি খুলুন",
"Check Updates on Start": "শুরুতে আপডেট চেক করুন",
"Open Book in New Window": "নতুন উইন্ডোতে বই খুলুন",
"Fullscreen": "পূর্ণ স্ক্রিন",
"Always on Top": "সবসময় উপরে",
"Always Show Status Bar": "সবসময় স্ট্যাটাস বার দেখান",
"Keep Screen Awake": "স্ক্রিন জাগিয়ে রাখুন",
"Reload Page": "পৃষ্ঠা রিলোড করুন",
"Dark Mode": "ডার্ক মোড",
"Light Mode": "লাইট মোড",
"Auto Mode": "অটো মোড",
"Upgrade to Readest Premium": "রিডেস্ট প্রিমিয়ামে আপগ্রেড",
"Download Readest": "রিডেস্ট ডাউনলোড",
"About Readest": "রিডেস্ট সম্পর্কে",
"Help improve Readest": "রিডেস্ট উন্নত করতে সাহায্য করুন",
"Sharing anonymized statistics": "বেনামী পরিসংখ্যান শেয়ার করা",
"List": "তালিকা",
"Grid": "গ্রিড",
"Crop": "কাটুন",
"Fit": "ফিট",
"Title": "শিরোনাম",
"Author": "লেখক",
"Format": "ফরম্যাট",
"Date Read": "পড়ার তারিখ",
"Date Added": "যোগ করার তারিখ",
"Ascending": "ঊর্ধ্বক্রম",
"Descending": "অবতরণ ক্রম",
"Book Covers": "বইয়ের মলাট",
"Sort by...": "সাজানোর ভিত্তি...",
"No supported files found. Supported formats: {{formats}}": "কোন সমর্থিত ফাইল পাওয়া যায়নি। সমর্থিত ফরম্যাট: {{formats}}",
"No chapters detected.": "কোন অধ্যায় শনাক্ত হয়নি।",
"Failed to parse the EPUB file.": "EPUB ফাইল পার্স করতে ব্যর্থ।",
"This book format is not supported.": "এই বই ফরম্যাট সমর্থিত নয়।",
"Failed to import book(s): {{filenames}}": "বই আমদানি ব্যর্থ: {{filenames}}",
"Book uploaded: {{title}}": "বই আপলোড হয়েছে: {{title}}",
"Insufficient storage quota": "অপর্যাপ্ত স্টোরেজ কোটা",
"Failed to upload book: {{title}}": "বই আপলোড ব্যর্থ: {{title}}",
"Book downloaded: {{title}}": "বই ডাউনলোড হয়েছে: {{title}}",
"Failed to download book: {{title}}": "বই ডাউনলোড ব্যর্থ: {{title}}",
"Book deleted: {{title}}": "বই মুছে ফেলা হয়েছে: {{title}}",
"Deleted cloud backup of the book: {{title}}": "বইয়ের ক্লাউড ব্যাকআপ মুছে ফেলা হয়েছে: {{title}}",
"Deleted local copy of the book: {{title}}": "বইয়ের স্থানীয় কপি মুছে ফেলা হয়েছে: {{title}}",
"Failed to delete book: {{title}}": "বই মুছতে ব্যর্থ: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "বইয়ের ক্লাউড ব্যাকআপ মুছতে ব্যর্থ: {{title}}",
"Failed to delete local copy of the book: {{title}}": "বইয়ের স্থানীয় কপি মুছতে ব্যর্থ: {{title}}",
"Welcome to your library. You can import your books here and read them anytime.": "আপনার লাইব্রেরিতে স্বাগতম। আপনি এখানে বই আমদানি করে যেকোনো সময় পড়তে পারেন।",
"Copied to notebook": "নোটবুকে কপি করা হয়েছে",
"No annotations to export": "রপ্তানি করার জন্য কোন টীকা নেই",
"Exported from Readest": "রিডেস্ট থেকে রপ্তানি করা হয়েছে",
"Highlights & Annotations": "হাইলাইট ও টীকা",
"Untitled": "নামবিহীন",
"Note": "নোট",
"Copied to clipboard": "ক্লিপবোর্ডে কপি করা হয়েছে",
"Copy": "কপি",
"Delete Highlight": "হাইলাইট মুছুন",
"Highlight": "হাইলাইট",
"Annotate": "টীকা",
"Search": "খুঁজুন",
"Dictionary": "অভিধান",
"Wikipedia": "উইকিপিডিয়া",
"Translate": "অনুবাদ",
"Speak": "বলুন",
"Login Required": "লগইন প্রয়োজন",
"Quota Exceeded": "কোটা অতিক্রম",
"Unable to fetch the translation. Please log in first and try again.": "অনুবাদ আনতে পারছি না। প্রথমে লগইন করুন এবং আবার চেষ্টা করুন।",
"Unable to fetch the translation. Try again later.": "অনুবাদ আনতে পারছি না। পরে আবার চেষ্টা করুন।",
"Original Text": "মূল টেক্সট",
"Auto Detect": "স্বয়ংক্রিয় শনাক্তকরণ",
"(detected)": "(শনাক্ত)",
"Translated Text": "অনূদিত টেক্সট",
"System Language": "সিস্টেম ভাষা",
"Loading...": "লোড হচ্ছে...",
"No translation available.": "কোন অনুবাদ উপলব্ধ নয়।",
"Translated by {{provider}}.": "{{provider}} দ্বারা অনূদিত।",
"Bookmark": "বুকমার্ক",
"Next Section": "পরবর্তী বিভাগ",
"Previous Section": "পূর্ববর্তী বিভাগ",
"Next Page": "পরবর্তী পৃষ্ঠা",
"Previous Page": "পূর্ববর্তী পৃষ্ঠা",
"Go Forward": "এগিয়ে যান",
"Small": "ছোট",
"Large": "বড়",
"Sync Conflict": "সিঙ্ক দ্বন্দ্ব",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" থেকে পড়ার অগ্রগতি সিঙ্ক করবেন?",
"another device": "অন্য ডিভাইস",
"Local Progress": "স্থানীয় অগ্রগতি",
"Remote Progress": "দূরবর্তী অগ্রগতি",
"Failed to connect": "সংযোগ ব্যর্থ",
"Disconnected": "সংযোগ বিচ্ছিন্ন",
"KOReader Sync Settings": "KOReader সিঙ্ক সেটিংস",
"Sync as {{userDisplayName}}": "{{userDisplayName}} হিসেবে সিঙ্ক",
"Sync Server Connected": "সিঙ্ক সার্ভার সংযুক্ত",
"Sync Strategy": "সিঙ্ক কৌশল",
"Ask on conflict": "দ্বন্দ্বে জিজ্ঞাসা করুন",
"Always use latest": "সবসময় সর্বশেষ ব্যবহার করুন",
"Send changes only": "শুধু পরিবর্তন পাঠান",
"Receive changes only": "শুধু পরিবর্তন গ্রহণ করুন",
"Checksum Method": "চেকসাম পদ্ধতি",
"File Content (recommended)": "ফাইল কন্টেন্ট (সুপারিশকৃত)",
"File Name": "ফাইলের নাম",
"Device Name": "ডিভাইসের নাম",
"Connect to your KOReader Sync server.": "আপনার KOReader সিঙ্ক সার্ভারে সংযোগ করুন।",
"Server URL": "সার্ভার URL",
"Username": "ইউজারনেম",
"Your Username": "আপনার ইউজারনেম",
"Password": "পাসওয়ার্ড",
"Connect": "সংযোগ",
"Notebook": "নোটবুক",
"No notes match your search": "আপনার খোঁজের সাথে কোন নোট মিলছে না",
"Excerpts": "উদ্ধৃতি",
"Notes": "নোট",
"Add your notes here...": "এখানে আপনার নোট যোগ করুন...",
"Search notes and excerpts...": "নোট ও উদ্ধৃতি খুঁজুন...",
"{{time}} min left in chapter": "অধ্যায়ে {{time}} মিনিট বাকি",
"{{count}} pages left in chapter_one": "অধ্যায়ে ১ পৃষ্ঠা বাকি",
"{{count}} pages left in chapter_other": "অধ্যায়ে {{count}} পৃষ্ঠা বাকি",
"Theme Mode": "থিম মোড",
"Invert Image In Dark Mode": "ডার্ক মোডে ছবি উল্টান",
"Override Book Color": "বইয়ের রঙ পরিবর্তন",
"Theme Color": "থিম রঙ",
"Custom": "কাস্টম",
"Code Highlighting": "কোড হাইলাইটিং",
"Enable Highlighting": "হাইলাইটিং সক্রিয়",
"Code Language": "কোড ভাষা",
"Auto": "অটো",
"Scroll": "স্ক্রল",
"Scrolled Mode": "স্ক্রল মোড",
"Continuous Scroll": "ক্রমাগত স্ক্রল",
"Overlap Pixels": "ওভারল্যাপ পিক্সেল",
"Click": "ক্লিক",
"Clicks for Page Flip": "পৃষ্ঠা উল্টানোর জন্য ক্লিক",
"Swap Clicks Area": "ক্লিক এরিয়া অদলবদল",
"Disable Double Click": "ডাবল ক্লিক নিষ্ক্রিয়",
"Volume Keys for Page Flip": "পৃষ্ঠা উল্টানোর জন্য ভলিউম কী",
"Animation": "অ্যানিমেশন",
"Paging Animation": "পৃষ্ঠা অ্যানিমেশন",
"Security": "নিরাপত্তা",
"Allow JavaScript": "জাভাস্ক্রিপ্ট অনুমতি",
"Enable only if you trust the file.": "শুধুমাত্র ফাইলে বিশ্বাস থাকলে সক্রিয় করুন।",
"Font": "ফন্ট",
"Custom Fonts": "কাস্টম ফন্ট",
"Cancel Delete": "মুছা বাতিল",
"Delete Font": "ফন্ট মুছুন",
"Import Font": "ফন্ট আমদানি",
"Tips": "টিপস",
"Supported font formats: .ttf, .otf, .woff, .woff2": "সমর্থিত ফন্ট ফরম্যাট: .ttf, .otf, .woff, .woff2",
"Custom fonts can be selected from the Font Face menu": "কাস্টম ফন্ট Font Face মেনু থেকে নির্বাচন করা যায়",
"Global Settings": "গ্লোবাল সেটিংস",
"Apply to All Books": "সব বইয়ে প্রয়োগ",
"Apply to This Book": "এই বইয়ে প্রয়োগ",
"Reset Settings": "সেটিংস রিসেট",
"Manage Custom Fonts": "কাস্টম ফন্ট ব্যবস্থাপনা",
"System Fonts": "সিস্টেম ফন্ট",
"Serif Font": "সেরিফ ফন্ট",
"Sans-Serif Font": "স্যান্স-সেরিফ ফন্ট",
"Override Book Font": "বইয়ের ফন্ট পরিবর্তন",
"Font Size": "ফন্ট সাইজ",
"Default Font Size": "ডিফল্ট ফন্ট সাইজ",
"Minimum Font Size": "সর্বনিম্ন ফন্ট সাইজ",
"Font Weight": "ফন্ট ওয়েট",
"Font Family": "ফন্ট ফ্যামিলি",
"Default Font": "ডিফল্ট ফন্ট",
"CJK Font": "CJK ফন্ট",
"Font Face": "ফন্ট ফেস",
"Monospace Font": "মনোস্পেস ফন্ট",
"Language": "ভাষা",
"Interface Language": "ইন্টারফেস ভাষা",
"Translation": "অনুবাদ",
"Enable Translation": "অনুবাদ সক্রিয়",
"Show Source Text": "উৎস টেক্সট দেখান",
"Translation Service": "অনুবাদ সেবা",
"Translate To": "অনুবাদ করুন",
"Override Book Layout": "বইয়ের লেআউট পরিবর্তন",
"Writing Mode": "লেখার মোড",
"Default": "ডিফল্ট",
"Horizontal Direction": "অনুভূমিক দিক",
"Vertical Direction": "উল্লম্ব দিক",
"RTL Direction": "RTL দিক",
"Border Frame": "বর্ডার ফ্রেম",
"Double Border": "ডাবল বর্ডার",
"Border Color": "বর্ডার রঙ",
"Paragraph": "অনুচ্ছেদ",
"Paragraph Margin": "অনুচ্ছেদ মার্জিন",
"Line Spacing": "লাইন স্পেসিং",
"Word Spacing": "শব্দ স্পেসিং",
"Letter Spacing": "অক্ষর স্পেসিং",
"Text Indent": "টেক্সট ইনডেন্ট",
"Full Justification": "পূর্ণ সারিবদ্ধতা",
"Hyphenation": "হাইফেনেশন",
"Page": "পৃষ্ঠা",
"Top Margin (px)": "উপরের মার্জিন (px)",
"Bottom Margin (px)": "নিচের মার্জিন (px)",
"Left Margin (px)": "বাম মার্জিন (px)",
"Right Margin (px)": "ডান মার্জিন (px)",
"Column Gap (%)": "কলাম গ্যাপ (%)",
"Maximum Number of Columns": "সর্বোচ্চ কলাম সংখ্যা",
"Maximum Column Height": "সর্বোচ্চ কলাম উচ্চতা",
"Maximum Column Width": "সর্বোচ্চ কলাম প্রস্থ",
"Header & Footer": "হেডার ও ফুটার",
"Show Header": "হেডার দেখান",
"Show Footer": "ফুটার দেখান",
"Show Remaining Time": "বাকি সময় দেখান",
"Show Remaining Pages": "বাকি পৃষ্ঠা দেখান",
"Show Reading Progress": "পড়ার অগ্রগতি দেখান",
"Reading Progress Style": "পড়ার অগ্রগতির স্টাইল",
"Page Number": "পৃষ্ঠা নম্বর",
"Percentage": "শতাংশ",
"Apply also in Scrolled Mode": "স্ক্রল মোডেও প্রয়োগ করুন",
"Screen": "স্ক্রিন",
"Orientation": "দিকবিন্যাস",
"Portrait": "পোর্ট্রেট",
"Landscape": "ল্যান্ডস্কেপ",
"Apply": "প্রয়োগ",
"Custom Content CSS": "কাস্টম কন্টেন্ট CSS",
"Enter CSS for book content styling...": "বই কন্টেন্ট স্টাইলিংয়ের জন্য CSS লিখুন...",
"Custom Reader UI CSS": "কাস্টম রিডার UI CSS",
"Enter CSS for reader interface styling...": "রিডার ইন্টারফেস স্টাইলিংয়ের জন্য CSS লিখুন...",
"Layout": "লেআউট",
"Color": "রঙ",
"Behavior": "আচরণ",
"Reset {{settings}}": "{{settings}} রিসেট",
"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": "সারা বিশ্বটাই একটা মঞ্চ,\nআর সব নর-নারী কেবল অভিনেতা;\nতাদের আছে প্রস্থান ও প্রবেশ,\nআর একজন মানুষ তার জীবনে অনেক চরিত্র পালন করে,\nতার অভিনয় সাত যুগের।\n\n— উইলিয়াম শেক্সপিয়র",
"(from 'As You Like It', Act II)": "('অ্যাজ ইউ লাইক ইট', দ্বিতীয় অঙ্ক থেকে)",
"Custom Theme": "কাস্টম থিম",
"Theme Name": "থিমের নাম",
"Text Color": "টেক্সট রঙ",
"Background Color": "ব্যাকগ্রাউন্ড রঙ",
"Link Color": "লিঙ্ক রঙ",
"Preview": "প্রিভিউ",
"Font & Layout": "ফন্ট ও লেআউট",
"Book Cover": "বইয়ের মলাট",
"More Info": "আরো তথ্য",
"Parallel Read": "সমান্তরাল পড়া",
"Disable": "নিষ্ক্রিয়",
"Enable": "সক্রিয়",
"KOReader Sync": "KOReader সিঙ্ক",
"Push Progress": "অগ্রগতি পুশ",
"Pull Progress": "অগ্রগতি পুল",
"Export Annotations": "টীকা রপ্তানি",
"Sort TOC by Page": "পৃষ্ঠা অনুযায়ী TOC সাজান",
"Edit": "সম্পাদনা",
"Search...": "খুঁজুন...",
"Book": "বই",
"Chapter": "অধ্যায়",
"Match Case": "কেস মিলান",
"Match Whole Words": "পূর্ণ শব্দ মিলান",
"Match Diacritics": "ডায়াক্রিটিক্স মিলান",
"TOC": "সূচিপত্র",
"Sidebar": "সাইডবার",
"Disable Translation": "অনুবাদ নিষ্ক্রিয়",
"Translation Not Available": "অনুবাদ উপলব্ধ নয়",
"TTS not supported for PDF": "PDF এর জন্য TTS সমর্থিত নয়",
"TTS not supported for this document": "এই ডকুমেন্টের জন্য TTS সমর্থিত নয়",
"No Timeout": "কোন টাইমআউট নেই",
"{{value}} minute": "{{value}} মিনিট",
"{{value}} minutes": "{{value}} মিনিট",
"{{value}} hour": "{{value}} ঘন্টা",
"{{value}} hours": "{{value}} ঘন্টা",
"Voices for {{lang}}": "{{lang}} এর জন্য কণ্ঠস্বর",
"Slow": "ধীর",
"Fast": "দ্রুত",
"{{engine}}: {{count}} voices_one": "{{engine}}: ১টি কণ্ঠস্বর",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}টি কণ্ঠস্বর",
"Sign in to Sync": "সিঙ্কের জন্য সাইন ইন",
"Synced at {{time}}": "{{time}} এ সিঙ্ক হয়েছে",
"Never synced": "কখনো সিঙ্ক হয়নি",
"Reading Progress Synced": "পড়ার অগ্রগতি সিঙ্ক হয়েছে",
"Page {{page}} of {{total}} ({{percentage}}%)": "{{total}} এর {{page}} পৃষ্ঠা ({{percentage}}%)",
"Current position": "বর্তমান অবস্থান",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "প্রায় {{total}} এর {{page}} পৃষ্ঠা ({{percentage}}%)",
"Approximately {{percentage}}%": "প্রায় {{percentage}}%",
"Delete Your Account?": "আপনার অ্যাকাউন্ট মুছবেন?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "এই কাজ পূর্বাবস্থায় ফেরানো যাবে না। ক্লাউডে আপনার সব ডেটা স্থায়ীভাবে মুছে যাবে।",
"Delete Permanently": "স্থায়ীভাবে মুছুন",
"Restore Purchase": "ক্রয় পুনরুদ্ধার",
"Manage Subscription": "সাবস্ক্রিপশন পরিচালনা",
"Reset Password": "পাসওয়ার্ড রিসেট",
"Sign Out": "সাইন আউট",
"Delete Account": "অ্যাকাউন্ট মুছুন",
"Upgrade to {{plan}}": "{{plan}} এ আপগ্রেড",
"Complete Your Subscription": "আপনার সাবস্ক্রিপশন সম্পূর্ণ করুন",
"Coming Soon": "শীঘ্রই আসছে",
"Upgrade to Plus or Pro": "প্লাস বা প্রো তে আপগ্রেড",
"Current Plan": "বর্তমান প্ল্যান",
"Plan Limits": "প্ল্যান সীমা",
"Failed to delete user. Please try again later.": "ইউজার মুছতে ব্যর্থ। পরে আবার চেষ্টা করুন।",
"Failed to create checkout session": "চেকআউট সেশন তৈরি করতে ব্যর্থ",
"No purchases found to restore.": "পুনরুদ্ধার করার জন্য কোন ক্রয় পাওয়া যায়নি।",
"Failed to restore purchases.": "ক্রয় পুনরুদ্ধার করতে ব্যর্থ।",
"Failed to manage subscription.": "সাবস্ক্রিপশন পরিচালনা করতে ব্যর্থ।",
"Failed to load subscription plans.": "সাবস্ক্রিপশন প্ল্যান লোড করতে ব্যর্থ।",
"Loading profile...": "প্রোফাইল লোড হচ্ছে...",
"Processing your payment...": "আপনার পেমেন্ট প্রক্রিয়া করা হচ্ছে...",
"Please wait while we confirm your subscription.": "আমরা আপনার সাবস্ক্রিপশন নিশ্চিত করার সময় অপেক্ষা করুন।",
"Payment Processing": "পেমেন্ট প্রক্রিয়াকরণ",
"Your payment is being processed. This usually takes a few moments.": "আপনার পেমেন্ট প্রক্রিয়া করা হচ্ছে। এটি সাধারণত কয়েক মুহূর্ত সময় নেয়।",
"Payment Failed": "পেমেন্ট ব্যর্থ",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "আমরা আপনার সাবস্ক্রিপশন প্রক্রিয়া করতে পারিনি। আবার চেষ্টা করুন বা সমস্যা অব্যাহত থাকলে সাপোর্টের সাথে যোগাযোগ করুন।",
"Back to Profile": "প্রোফাইলে ফিরুন",
"Subscription Successful!": "সাবস্ক্রিপশন সফল!",
"Thank you for your subscription! Your payment has been processed successfully.": "আপনার সাবস্ক্রিপশনের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়া করা হয়েছে।",
"Email:": "ইমেইল:",
"Plan:": "প্ল্যান:",
"Amount:": "পরিমাণ:",
"Subscription ID:": "সাবস্ক্রিপশন ID:",
"Go to Library": "লাইব্রেরিতে যান",
"Need help? Contact our support team at support@readest.com": "সাহায্য দরকার? support@readest.com এ আমাদের সাপোর্ট টিমের সাথে যোগাযোগ করুন",
"Free Plan": "ফ্রি প্ল্যান",
"month": "মাস",
"year": "বছর",
"Cross-Platform Sync": "ক্রস-প্ল্যাটফর্ম সিঙ্ক",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "আপনার সব ডিভাইসে লাইব্রেরি, অগ্রগতি, হাইলাইট এবং নোট নির্বিঘ্নে সিঙ্ক করুন—আর কখনো আপনার জায়গা হারাবেন না।",
"Customizable Reading": "কাস্টমাইজযোগ্য পড়া",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "নিখুঁত পড়ার অভিজ্ঞতার জন্য সামঞ্জস্যযোগ্য ফন্ট, লেআউট, থিম এবং উন্নত ডিসপ্লে সেটিংস দিয়ে প্রতিটি বিস্তারিত ব্যক্তিগতকরণ করুন।",
"AI Read Aloud": "AI পড়ে শোনানো",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "প্রাকৃতিক শোনা AI কণ্ঠস্বর দিয়ে হ্যান্ডস-ফ্রি পড়া উপভোগ করুন যা আপনার বইগুলোকে জীবন্ত করে তোলে।",
"AI Translations": "AI অনুবাদ",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Google, Azure, বা DeepL এর শক্তি দিয়ে যেকোনো টেক্সট তাৎক্ষণিক অনুবাদ করুন—যেকোনো ভাষায় কন্টেন্ট বুঝুন।",
"Community Support": "কমিউনিটি সাপোর্ট",
"Connect with fellow readers and get help fast in our friendly community channels.": "সহ পাঠকদের সাথে যুক্ত হোন এবং আমাদের বন্ধুত্বপূর্ণ কমিউনিটি চ্যানেলে দ্রুত সাহায্য পান।",
"Cloud Sync Storage": "ক্লাউড সিঙ্ক স্টোরেজ",
"AI Translations (per day)": "AI অনুবাদ (প্রতিদিন)",
"Plus Plan": "প্লাস প্ল্যান",
"Includes All Free Plan Benefits": "সব ফ্রি প্ল্যান সুবিধা অন্তর্ভুক্ত",
"Unlimited AI Read Aloud Hours": "সীমাহীন AI পড়ে শোনানোর সময়",
"Listen without limits—convert as much text as you like into immersive audio.": "সীমা ছাড়াই শুনুন—যত খুশি টেক্সটকে নিমজ্জনকারী অডিওতে রূপান্তর করুন।",
"More AI Translations": "আরো AI অনুবাদ",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "আরো দৈনিক ব্যবহার এবং উন্নত অপশন দিয়ে উন্নত অনুবাদ ক্ষমতা আনলক করুন।",
"DeepL Pro Access": "DeepL Pro অ্যাক্সেস",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "উপলব্ধ সবচেয়ে নির্ভুল অনুবাদ ইঞ্জিন দিয়ে দৈনিক ১,০০,০০০ অক্ষর পর্যন্ত অনুবাদ করুন।",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "৫ GB ক্লাউড স্টোরেজ দিয়ে আপনার সম্পূর্ণ পড়ার সংগ্রহ নিরাপদে সংরক্ষণ এবং অ্যাক্সেস করুন।",
"Priority Support": "অগ্রাধিকার সাপোর্ট",
"Enjoy faster responses and dedicated assistance whenever you need help.": "যখনই সাহায্যের প্রয়োজন হয় দ্রুত প্রতিক্রিয়া এবং নিবেদিত সহায়তা উপভোগ করুন।",
"Pro Plan": "প্রো প্ল্যান",
"Includes All Plus Plan Benefits": "সব প্লাস প্ল্যান সুবিধা অন্তর্ভুক্ত",
"Early Feature Access": "আর্লি ফিচার অ্যাক্সেস",
"Be the first to explore new features, updates, and innovations before anyone else.": "অন্য কারো আগে নতুন ফিচার, আপডেট এবং উদ্ভাবন অন্বেষণ করার প্রথম হয়ে উঠুন।",
"Advanced AI Tools": "উন্নত AI টুলস",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "স্মার্ট পড়া, অনুবাদ এবং কন্টেন্ট আবিষ্কারের জন্য শক্তিশালী AI টুলস ব্যবহার করুন।",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "উপলব্ধ সবচেয়ে নির্ভুল অনুবাদ ইঞ্জিন দিয়ে দৈনিক ৫,০০,০০০ অক্ষর পর্যন্ত অনুবাদ করুন।",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "২০ GB ক্লাউড স্টোরেজ দিয়ে আপনার সম্পূর্ণ পড়ার সংগ্রহ নিরাপদে সংরক্ষণ এবং অ্যাক্সেস করুন।",
"Version {{version}}": "সংস্করণ {{version}}",
"Check Update": "আপডেট চেক করুন",
"Already the latest version": "ইতিমধ্যে সর্বশেষ সংস্করণ",
"Checking for updates...": "আপডেট চেক করা হচ্ছে...",
"Error checking for updates": "আপডেট চেক করতে ত্রুটি",
"Drop to Import Books": "বই আমদানি করতে ড্রপ করুন",
"Terms of Service": "সেবার শর্তাবলী",
"Privacy Policy": "গোপনীয়তা নীতি",
"Enter book title": "বইয়ের শিরোনাম লিখুন",
"Subtitle": "উপশিরোনাম",
"Enter book subtitle": "বইয়ের উপশিরোনাম লিখুন",
"Enter author name": "লেখকের নাম লিখুন",
"Series": "সিরিজ",
"Enter series name": "সিরিজের নাম লিখুন",
"Series Index": "সিরিজ ইনডেক্স",
"Enter series index": "সিরিজ ইনডেক্স লিখুন",
"Total in Series": "সিরিজের মোট",
"Enter total books in series": "সিরিজের মোট বই সংখ্যা লিখুন",
"Publisher": "প্রকাশক",
"Enter publisher": "প্রকাশক লিখুন",
"Publication Date": "প্রকাশনার তারিখ",
"YYYY or YYYY-MM-DD": "YYYY বা YYYY-MM-DD",
"Identifier": "শনাক্তকারী",
"Subjects": "বিষয়",
"Fiction, Science, History": "কল্পকাহিনী, বিজ্ঞান, ইতিহাস",
"Description": "বিবরণ",
"Enter book description": "বইয়ের বিবরণ লিখুন",
"Change cover image": "মলাট ছবি পরিবর্তন",
"Replace": "প্রতিস্থাপন",
"Unlock cover": "মলাট আনলক",
"Lock cover": "মলাট লক",
"Auto-Retrieve Metadata": "মেটাডেটা স্বয়ংক্রিয় পুনরুদ্ধার",
"Auto-Retrieve": "স্বয়ংক্রিয় পুনরুদ্ধার",
"Unlock all fields": "সব ফিল্ড আনলক",
"Unlock All": "সব আনলক",
"Lock all fields": "সব ফিল্ড লক",
"Lock All": "সব লক",
"Reset": "রিসেট",
"Are you sure to delete the selected book?": "আপনি কি নিশ্চিত যে নির্বাচিত বইটি মুছবেন?",
"Are you sure to delete the cloud backup of the selected book?": "আপনি কি নিশ্চিত যে নির্বাচিত বইয়ের ক্লাউড ব্যাকআপ মুছবেন?",
"Are you sure to delete the local copy of the selected book?": "আপনি কি নিশ্চিত যে নির্বাচিত বইয়ের স্থানীয় কপি মুছবেন?",
"Edit Metadata": "মেটাডেটা সম্পাদনা",
"Book Details": "বইয়ের বিস্তারিত",
"Unknown": "অজানা",
"Remove from Cloud & Device": "ক্লাউড ও ডিভাইস থেকে সরান",
"Remove from Cloud Only": "শুধু ক্লাউড থেকে সরান",
"Remove from Device Only": "শুধু ডিভাইস থেকে সরান",
"Published": "প্রকাশিত",
"Updated": "আপডেট হয়েছে",
"Added": "যোগ করা হয়েছে",
"File Size": "ফাইল সাইজ",
"No description available": "কোন বিবরণ উপলব্ধ নয়",
"Locked": "লক করা",
"Select Metadata Source": "মেটাডেটা সোর্স নির্বাচন",
"Keep manual input": "ম্যানুয়াল ইনপুট রাখুন",
"A new version of Readest is available!": "রিডেস্টের একটি নতুন সংস্করণ উপলব্ধ!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "রিডেস্ট {{newVersion}} উপলব্ধ (ইনস্টল করা সংস্করণ {{currentVersion}})।",
"Download and install now?": "এখনই ডাউনলোড এবং ইনস্টল করবেন?",
"Downloading {{downloaded}} of {{contentLength}}": "{{contentLength}} এর {{downloaded}} ডাউনলোড হচ্ছে",
"Download finished": "ডাউনলোড সম্পূর্ণ",
"DOWNLOAD & INSTALL": "ডাউনলোড ও ইনস্টল",
"Changelog": "পরিবর্তন লগ",
"Software Update": "সফটওয়্যার আপডেট",
"What's New in Readest": "রিডেস্টে নতুন কী",
"Select Files": "ফাইল নির্বাচন",
"Select Image": "ছবি নির্বাচন",
"Select Video": "ভিডিও নির্বাচন",
"Select Audio": "অডিও নির্বাচন",
"Select Fonts": "ফন্ট নির্বাচন",
"Storage": "স্টোরেজ",
"{{percentage}}% of Cloud Sync Space Used.": "ক্লাউড সিঙ্ক স্পেসের {{percentage}}% ব্যবহৃত।",
"Translation Characters": "অনুবাদ অক্ষর",
"{{percentage}}% of Daily Translation Characters Used.": "দৈনিক অনুবাদ অক্ষরের {{percentage}}% ব্যবহৃত।",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "দৈনিক অনুবাদ কোটা শেষ। AI অনুবাদ ব্যবহার চালিয়ে যেতে আপনার প্ল্যান আপগ্রেড করুন।",
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "গুগল বুকস",
"Open Library": "ওপেন লাইব্রেরি",
"Azure Translator": "Azure অনুবাদক",
"DeepL": "DeepL",
"Google Translate": "গুগল ট্রান্সলেট",
"Yandex Translate": "ইয়ান্ডেক্স ট্রান্সলেট",
"Gray": "ধূসর",
"Sepia": "সেপিয়া",
"Grass": "ঘাস",
"Cherry": "চেরি",
"Sky": "আকাশ",
"Solarized": "সোলারাইজড",
"Gruvbox": "গ্রুভবক্স",
"Nord": "নর্ড",
"Contrast": "কন্ট্রাস্ট",
"Sunset": "সূর্যাস্ত",
"Reveal in Finder": "ফাইন্ডারে দেখান",
"Reveal in File Explorer": "ফাইল এক্সপ্লোরারে দেখান",
"Reveal in Folder": "ফোল্ডারে দেখান",
"Previous Paragraph": "পূর্ববর্তী অনুচ্ছেদ",
"Previous Sentence": "পূর্ববর্তী বাক্য",
"Pause": "বিরতি",
"Play": "চালান",
"Next Sentence": "পরবর্তী বাক্য",
"Next Paragraph": "পরবর্তী অনুচ্ছেদ",
"Separate Cover Page": "আলাদা কভার পৃষ্ঠা"
}
@@ -476,7 +476,6 @@
"Always use latest": "དེ་ལས་འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Send changes only": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Receive changes only": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Disabled": "བཀག་པ།",
"Checksum Method": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"File Content (recommended)": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"File Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
@@ -512,5 +511,12 @@
"Select Fonts": "ཡིག་གཟུགས་འདེམས་པ།",
"Supported font formats: .ttf, .otf, .woff, .woff2": "ཡིག་གཟུགས་འདེམས་པ་བྱས་ནས་བསྐྱར་བརྗེ་བ། ཡིག་གཟུགས་ཀྱི་སྒྲ་སྐད། .ttf, .otf, .woff, .woff2",
"Push Progress": "འདེགས་པའི་འཕེལ་རིམ།",
"Pull Progress": "འདྲུད་པའི་འཕེལ་རིམ།"
"Pull Progress": "འདྲུད་པའི་འཕེལ་རིམ།",
"Previous Paragraph": "སྔོན་གྱི་དོན་ཚན།",
"Previous Sentence": "སྔོན་གྱི་ཚིག་གྲུབ།",
"Pause": "སྐར་མ་འཇོག",
"Play": "གཏོང་བ།",
"Next Sentence": "རྗེས་ཀྱི་ཚིག་གྲུབ།",
"Next Paragraph": "རྗེས་ཀྱི་དོན་ཚན།",
"Separate Cover Page": "སྟོང་ངོས་གི་ཤོག་དཀར་པ།"
}
@@ -478,7 +478,6 @@
"Always use latest": "Immer die neueste Version verwenden",
"Send changes only": "Nur Änderungen senden",
"Receive changes only": "Nur Änderungen empfangen",
"Disabled": "Deaktiviert",
"Checksum Method": "Prüfziffernverfahren",
"File Content (recommended)": "Dateiinhalte (empfohlen)",
"File Name": "Dateiname",
@@ -516,5 +515,12 @@
"Select Fonts": "Schriftarten auswählen",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Unterstützte Schriftartenformate: .ttf, .otf, .woff, .woff2",
"Push Progress": "Fortschritt senden",
"Pull Progress": "Fortschritt empfangen"
"Pull Progress": "Fortschritt empfangen",
"Previous Paragraph": "Vorheriger Absatz",
"Previous Sentence": "Vorheriger Satz",
"Pause": "Pause",
"Play": "Wiedergabe",
"Next Sentence": "Nächster Satz",
"Next Paragraph": "Nächster Absatz",
"Separate Cover Page": "Titelseite separat anzeigen"
}
@@ -478,7 +478,6 @@
"Always use latest": "Χρησιμοποίησε πάντα την τελευταία έκδοση",
"Send changes only": "Αποστολή μόνο αλλαγών",
"Receive changes only": "Λήψη μόνο αλλαγών",
"Disabled": "Απενεργοποιημένο",
"Checksum Method": "Μέθοδος ελέγχου",
"File Content (recommended)": "Περιεχόμενο αρχείου (συνιστάται)",
"File Name": "Όνομα αρχείου",
@@ -516,5 +515,12 @@
"Select Fonts": "Επιλογή Γραμματοσειρών",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Υποστηριζόμενες μορφές γραμματοσειρών: .ttf, .otf, .woff, .woff2",
"Push Progress": "Αποστολή προόδου",
"Pull Progress": "Λήψη προόδου"
"Pull Progress": "Λήψη προόδου",
"Previous Paragraph": "Προηγούμενη παράγραφος",
"Previous Sentence": "Προηγούμενη πρόταση",
"Pause": "Παύση",
"Play": "Αναπαραγωγή",
"Next Sentence": "Επόμενη πρόταση",
"Next Paragraph": "Επόμενη παράγραφος",
"Separate Cover Page": "Ξεχωριστή σελίδα εξωφύλλου"
}
@@ -83,7 +83,6 @@
"Always use latest": "Usar siempre el más reciente",
"Send changes only": "Solo enviar cambios",
"Receive changes only": "Solo recibir cambios",
"Disabled": "Desactivado",
"Checksum Method": "Método de identificación",
"File Content (recommended)": "Contenido del archivo (recomendado)",
"File Name": "Nombre del archivo",
@@ -520,5 +519,12 @@
"Select Fonts": "Seleccionar Fuentes",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formatos de fuente compatibles: .ttf, .otf, .woff, .woff2",
"Push Progress": "Empujar progreso",
"Pull Progress": "Tirar progreso"
"Pull Progress": "Tirar progreso",
"Previous Paragraph": "Párrafo anterior",
"Previous Sentence": "Oración anterior",
"Pause": "Pausar",
"Play": "Reproducir",
"Next Sentence": "Siguiente oración",
"Next Paragraph": "Siguiente párrafo",
"Separate Cover Page": "Portada separada"
}
@@ -482,7 +482,6 @@
"Always use latest": "Toujours utiliser la dernière version",
"Send changes only": "Envoyer uniquement les modifications",
"Receive changes only": "Recevoir uniquement les modifications",
"Disabled": "Désactivé",
"Checksum Method": "Méthode de somme de contrôle",
"File Content (recommended)": "Contenu du fichier (recommandé)",
"File Name": "Nom du fichier",
@@ -520,5 +519,12 @@
"Select Fonts": "Sélectionner des Polices",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formats de police pris en charge : .ttf, .otf, .woff, .woff2",
"Push Progress": "Pousser la Progression",
"Pull Progress": "Tirer la Progression"
"Pull Progress": "Tirer la Progression",
"Previous Paragraph": "Paragraphe précédent",
"Previous Sentence": "Phrase précédente",
"Pause": "Pause",
"Play": "Lecture",
"Next Sentence": "Phrase suivante",
"Next Paragraph": "Paragraphe suivant",
"Separate Cover Page": "Page de couverture séparée"
}
@@ -478,7 +478,6 @@
"Always use latest": "हमेशा नवीनतम का उपयोग करें",
"Send changes only": "केवल परिवर्तन भेजें",
"Receive changes only": "केवल परिवर्तन प्राप्त करें",
"Disabled": "अक्षम",
"Checksum Method": "चेकसम विधि",
"File Content (recommended)": "फाइल सामग्री (अनुशंसित)",
"File Name": "फाइल नाम",
@@ -515,6 +514,13 @@
"Select Audio": "ऑडियो चुनें",
"Select Fonts": "फ़ॉन्ट चुनें",
"Supported font formats: .ttf, .otf, .woff, .woff2": "समर्थित फ़ॉन्ट प्रारूप: .ttf, .otf, .woff, .woff2",
"Push progress": "पुश प्रगति",
"Pull progress": "पुल प्रगति"
"Push Progress": "पुश प्रगति",
"Pull Progress": "पुल प्रगति",
"Previous Paragraph": "पिछला पैराग्राफ",
"Previous Sentence": "पिछला वाक्य",
"Pause": "रोकें",
"Play": "चलाएं",
"Next Sentence": "अगला वाक्य",
"Next Paragraph": "अगला पैराग्राफ",
"Separate Cover Page": "अलग कवर पृष्ठ"
}
@@ -474,7 +474,6 @@
"Always use latest": "Selalu gunakan yang terbaru",
"Send changes only": "Kirim perubahan saja",
"Receive changes only": "Terima perubahan saja",
"Disabled": "Dinonaktifkan",
"Checksum Method": "Metode Checksum",
"File Content (recommended)": "Konten File (disarankan)",
"File Name": "Nama File",
@@ -512,5 +511,12 @@
"Select Fonts": "Pilih Font",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Format font yang didukung: .ttf, .otf, .woff, .woff2",
"Push Progress": "Dorong Progres",
"Pull Progress": "Tarik Progres"
"Pull Progress": "Tarik Progres",
"Previous Paragraph": "Paragraf Sebelumnya",
"Previous Sentence": "Kalimat Sebelumnya",
"Pause": "Jeda",
"Play": "Putar",
"Next Sentence": "Kalimat Berikutnya",
"Next Paragraph": "Paragraf Berikutnya",
"Separate Cover Page": "Halaman Sampul Terpisah"
}
@@ -482,7 +482,6 @@
"Always use latest": "Usa sempre l'ultima versione",
"Send changes only": "Invia solo le modifiche",
"Receive changes only": "Ricevi solo le modifiche",
"Disabled": "Disabilitato",
"Checksum Method": "Metodo di Controllo",
"File Content (recommended)": "Contenuto del File (consigliato)",
"File Name": "Nome del File",
@@ -519,6 +518,13 @@
"Select Audio": "Seleziona Audio",
"Select Fonts": "Seleziona Font",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formati di font supportati: .ttf, .otf, .woff, .woff2",
"Push progress": "Progresso push",
"Pull progress": "Progresso pull"
"Push Progress": "Progresso push",
"Pull Progress": "Progresso pull",
"Previous Paragraph": "Paragrafo precedente",
"Previous Sentence": "Frase precedente",
"Pause": "Pausa",
"Play": "Riproduci",
"Next Sentence": "Frase successiva",
"Next Paragraph": "Paragrafo successivo",
"Separate Cover Page": "Pagina di copertina separata"
}
@@ -474,7 +474,6 @@
"Always use latest": "最新を常に使用",
"Send changes only": "変更のみを送信",
"Receive changes only": "変更のみを受信",
"Disabled": "無効",
"Checksum Method": "チェックサム方式",
"File Content (recommended)": "ファイル内容(推奨)",
"File Name": "ファイル名",
@@ -512,5 +511,12 @@
"Select Fonts": "フォントを選択",
"Supported font formats: .ttf, .otf, .woff, .woff2": "サポートされているフォント形式:.ttf, .otf, .woff, .woff2",
"Push Progress": "進捗をプッシュ",
"Pull Progress": "進捗をプル"
"Pull Progress": "進捗をプル",
"Previous Paragraph": "前の段落",
"Previous Sentence": "前の文",
"Pause": "一時停止",
"Play": "再生",
"Next Sentence": "次の文",
"Next Paragraph": "次の段落",
"Separate Cover Page": "別の表紙ページ"
}
@@ -474,7 +474,6 @@
"Always use latest": "항상 최신 버전 사용",
"Send changes only": "변경 사항만 전송",
"Receive changes only": "변경 사항만 수신",
"Disabled": "비활성화",
"Checksum Method": "체크섬 방법",
"File Content (recommended)": "파일 내용 (권장)",
"File Name": "파일 이름",
@@ -512,5 +511,12 @@
"Select Fonts": "글꼴 선택",
"Supported font formats: .ttf, .otf, .woff, .woff2": "지원되는 글꼴 형식: .ttf, .otf, .woff, .woff2",
"Push Progress": "진행 상황 푸시",
"Pull Progress": "진행 상황 끌어오기"
"Pull Progress": "진행 상황 끌어오기",
"Previous Paragraph": "이전 단락",
"Previous Sentence": "이전 문장",
"Pause": "일시정지",
"Play": "재생",
"Next Sentence": "다음 문장",
"Next Paragraph": "다음 단락",
"Separate Cover Page": "별도의 표지 페이지"
}
@@ -478,7 +478,6 @@
"Always use latest": "Altijd de nieuwste gebruiken",
"Send changes only": "Verzend alleen wijzigingen",
"Receive changes only": "Ontvang alleen wijzigingen",
"Disabled": "Uitgeschakeld",
"Checksum Method": "Checksum-methode",
"File Content (recommended)": "Bestandsinhoud (aanbevolen)",
"File Name": "Bestandsnaam",
@@ -516,5 +515,12 @@
"Select Fonts": "Lettertypen Selecteren",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Ondersteunde lettertypeformaten: .ttf, .otf, .woff, .woff2",
"Push Progress": "Voortgang pushen",
"Pull Progress": "Voortgang pullen"
"Pull Progress": "Voortgang pullen",
"Previous Paragraph": "Vorige alinea",
"Previous Sentence": "Vorige zin",
"Pause": "Pauzeren",
"Play": "Afspelen",
"Next Sentence": "Volgende zin",
"Next Paragraph": "Volgende alinea",
"Separate Cover Page": "Afzonderlijke omslagpagina"
}
@@ -486,7 +486,6 @@
"Always use latest": "Zawsze używaj najnowszej",
"Send changes only": "Wyślij tylko zmiany",
"Receive changes only": "Odbierz tylko zmiany",
"Disabled": "Wyłączone",
"Checksum Method": "Metoda sumy kontrolnej",
"File Content (recommended)": "Zawartość pliku (zalecane)",
"File Name": "Nazwa pliku",
@@ -524,5 +523,12 @@
"Select Fonts": "Wybierz Czcionki",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Obsługiwane formaty czcionek: .ttf, .otf, .woff, .woff2",
"Push Progress": "Wyślij postęp",
"Pull Progress": "Pobierz postęp"
"Pull Progress": "Pobierz postęp",
"Previous Paragraph": "Poprzedni akapit",
"Previous Sentence": "Poprzednie zdanie",
"Pause": "Pauza",
"Play": "Odtwórz",
"Next Sentence": "Następne zdanie",
"Next Paragraph": "Następny akapit",
"Separate Cover Page": "Osobna strona okładki"
}
@@ -482,7 +482,6 @@
"Always use latest": "Sempre usar o mais recente",
"Send changes only": "Enviar apenas alterações",
"Receive changes only": "Receber apenas alterações",
"Disabled": "Desativado",
"Checksum Method": "Método de Verificação",
"File Content (recommended)": "Conteúdo do Arquivo (recomendado)",
"File Name": "Nome do Arquivo",
@@ -520,5 +519,12 @@
"Select Fonts": "Selecionar Fontes",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formatos de fonte suportados: .ttf, .otf, .woff, .woff2",
"Push Progress": "Enviar Progresso",
"Pull Progress": "Obter Progresso"
"Pull Progress": "Obter Progresso",
"Previous Paragraph": "Parágrafo anterior",
"Previous Sentence": "Frase anterior",
"Pause": "Pausar",
"Play": "Reproduzir",
"Next Sentence": "Próxima frase",
"Next Paragraph": "Próximo parágrafo",
"Separate Cover Page": "Páginas de Capa Separadas"
}
@@ -486,7 +486,6 @@
"Always use latest": "Всегда использовать последнюю",
"Send changes only": "Отправлять только изменения",
"Receive changes only": "Получать только изменения",
"Disabled": "Отключено",
"Checksum Method": "Метод контрольной суммы",
"File Content (recommended)": "Содержимое файла (рекомендуется)",
"File Name": "Имя файла",
@@ -524,5 +523,12 @@
"Select Fonts": "Выбрать Шрифты",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Поддерживаемые форматы шрифтов: .ttf, .otf, .woff, .woff2",
"Push Progress": "Отправить прогресс",
"Pull Progress": "Получить прогресс"
"Pull Progress": "Получить прогресс",
"Previous Paragraph": "Предыдущий абзац",
"Previous Sentence": "Предыдущее предложение",
"Pause": "Пауза",
"Play": "Воспроизвести",
"Next Sentence": "Следующее предложение",
"Next Paragraph": "Следующий абзац",
"Separate Cover Page": "Отдельная страница обложки"
}
@@ -0,0 +1,526 @@
{
"Email address": "ඊමේල් ලිපිනය",
"Your Password": "ඔබේ මුරපදය",
"Your email address": "ඔබේ ඊමේල් ලිපිනය",
"Your password": "ඔබේ මුරපදය",
"Sign in": "ඇතුල් වන්න",
"Signing in...": "ඇතුල් වෙමින්...",
"Sign in with {{provider}}": "{{provider}} සමඟ ඇතුල් වන්න",
"Already have an account? Sign in": "දැනටමත් ගිණුමක් තිබේද? ඇතුල් වන්න",
"Create a Password": "මුරපදයක් සාදන්න",
"Sign up": "ලියාපදිංචි වන්න",
"Signing up...": "ලියාපදිංචි වෙමින්...",
"Don't have an account? Sign up": "ගිණුමක් නැද්ද? ලියාපදිංචි වන්න",
"Check your email for the confirmation link": "තහවුරු කිරීමේ සබැඳිය සඳහා ඔබේ ඊමේල් පරීක්ෂා කරන්න",
"Signing in ...": "ඇතුල් වෙමින්...",
"Send a magic link email": "මැජික් සබැඳි ඊමේල් යවන්න",
"Check your email for the magic link": "මැජික් සබැඳිය සඳහා ඔබේ ඊමේල් පරීක්ෂා කරන්න",
"Send reset password instructions": "මුරපදය නැවත සැකසීමේ උපදෙස් යවන්න",
"Sending reset instructions ...": "නැවත සැකසීමේ උපදෙස් යවමින්...",
"Forgot your password?": "මුරපදය අමතකද?",
"Check your email for the password reset link": "මුරපදය නැවත සැකසීමේ සබැඳිය සඳහා ඊමේල් පරීක්ෂා කරන්න",
"Phone number": "දුරකථන අංකය",
"Your phone number": "ඔබේ දුරකථන අංකය",
"Token": "ටෝකනය",
"Your OTP token": "ඔබේ OTP ටෝකනය",
"Verify token": "ටෝකනය තහවුරු කරන්න",
"Sign in with Google": "Google සමඟ ඇතුල් වන්න",
"Sign in with Apple": "Apple සමඟ ඇතුල් වන්න",
"Sign in with GitHub": "GitHub සමඟ ඇතුල් වන්න",
"New Password": "නව මුරපදය",
"Your new password": "ඔබේ නව මුරපදය",
"Update password": "මුරපදය යාවත්කාලීන කරන්න",
"Updating password ...": "මුරපදය යාවත්කාලීන කරමින්...",
"Your password has been updated": "ඔබේ මුරපදය යාවත්කාලීන කරන ලදී",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "යමක් වැරදුණි. කනගාටු නොවන්න, අපේ කණ්ඩායමට දැනුම් දී ඇති අතර අපි විසඳුමක් සඳහා කටයුතු කරමින් සිටිමු.",
"Error Details:": "දෝෂ විස්තර:",
"Try Again": "නැවත උත්සාහ කරන්න",
"Go Back": "ආපසු යන්න",
"Your Library": "ඔබේ පුස්තකාලය",
"Need help?": "උදව් අවශ්‍යද?",
"Contact Support": "සහාය සඳහා සම්බන්ධ වන්න",
"Open": "විවෘත කරන්න",
"Group": "කණ්ඩායම",
"Details": "විස්තර",
"Delete": "මකන්න",
"Cancel": "අවලංගු කරන්න",
"Confirm Deletion": "මකා දැමීම තහවුරු කරන්න",
"Are you sure to delete {{count}} selected book(s)?_one": "ඔබට විශ්වාසද තෝරාගත් පොත මකා දැමීමට?",
"Are you sure to delete {{count}} selected book(s)?_other": "ඔබට විශ්වාසද තෝරාගත් පොත් {{count}} මකා දැමීමට?",
"Deselect Book": "පොත අනිශ්චිත කරන්න",
"Select Book": "පොත තෝරන්න",
"Show Book Details": "පොතේ විස්තර පෙන්වන්න",
"Download Book": "පොත බාගන්න",
"Upload Book": "පොත උඩුගත කරන්න",
"Deselect Group": "කණ්ඩායම අනිශ්චිත කරන්න",
"Select Group": "කණ්ඩායම තෝරන්න",
"Untitled Group": "නම් නැති කණ්ඩායම",
"Group Books": "පොත් කණ්ඩායම් කරන්න",
"Remove From Group": "කණ්ඩායමෙන් ඉවත් කරන්න",
"Create New Group": "නව කණ්ඩායමක් සාදන්න",
"Save": "සුරකින්න",
"Confirm": "තහවුරු කරන්න",
"From Local File": "දේශීය ගොනුවෙන්",
"Search in {{count}} Book(s)..._one": "පොතක සොයන්න...",
"Search in {{count}} Book(s)..._other": "පොත් {{count}} කින් සොයන්න...",
"Search Books...": "පොත් සොයන්න...",
"Clear Search": "සෙවීම මකන්න",
"Import Books": "පොත් ආයාත කරන්න",
"Select Multiple Books": "පොත් කිහිපයක් තෝරන්න",
"Select Books": "පොත් තෝරන්න",
"Deselect": "අනිශ්චිත කරන්න",
"Select All": "සියල්ල තෝරන්න",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} ලෙස පුරන්ගද්",
"Logged in": "පුරනගද්",
"Account": "ගිණුම",
"Sign In": "ඇතුල් වන්න",
"Auto Upload Books to Cloud": "ස්වයංක්‍රීයව cloud එකට පොත් උඩුගත කරන්න",
"Auto Import on File Open": "ගොනුව විවෘත කිරීමේදී ස්වයංක්‍රීයව ආයාත කරන්න",
"Open Last Book on Start": "ආරම්භයේදී අන්තිම පොත විවෘත කරන්න",
"Check Updates on Start": "ආරම්භයේදී යාවත්කාලීන පරීක්ෂා කරන්න",
"Open Book in New Window": "නව කවුළුවක පොත විවෘත කරන්න",
"Fullscreen": "සම්පූර්ණ තිරය",
"Always on Top": "සෑම විටම ඉහළින්",
"Always Show Status Bar": "සෑම විටම තත්ත්ව තීරුව පෙන්වන්න",
"Keep Screen Awake": "තිරය අවදි තබන්න",
"Reload Page": "පිටුව නැවත පූරණය කරන්න",
"Dark Mode": "අඳුරු ආකාරය",
"Light Mode": "ආලෝකමය ආකාරය",
"Auto Mode": "ස්වයංක්‍රීය ආකාරය",
"Upgrade to Readest Premium": "Readest Premium වෙත උසස් කරන්න",
"Download Readest": "Readest බාගන්න",
"About Readest": "Readest ගැන",
"Help improve Readest": "Readest වැඩිදියුණු කිරීමට උදව් කරන්න",
"Sharing anonymized statistics": "අනන්‍ය සංඛ්‍යාලේඛන බෙදාගැනීම",
"List": "ලැයිස්තුව",
"Grid": "ජාලකය",
"Crop": "කපන්න",
"Fit": "ගැලපෙන",
"Title": "නම",
"Author": "කතෘ",
"Format": "ආකෘතිය",
"Date Read": "කියැවූ දිනය",
"Date Added": "එක් කළ දිනය",
"Ascending": "ආරෝහණ",
"Descending": "අවරෝහණ",
"Book Covers": "පොත් ආවරණ",
"Sort by...": "අනුව සකස් කරන්න...",
"No supported files found. Supported formats: {{formats}}": "සහාය දක්වන ගොනු හමුනොවිණි. සහාය දක්වන ආකෘති: {{formats}}",
"No chapters detected.": "කොටස් හඳුනානොගැනිණි.",
"Failed to parse the EPUB file.": "EPUB ගොනුව විග්‍රහ කිරීමට අසමත්.",
"This book format is not supported.": "මෙම පොත ආකෘතියට සහාය නොදක්වයි.",
"Failed to import book(s): {{filenames}}": "පොත් ආයාත කිරීමට අසමත්: {{filenames}}",
"Book uploaded: {{title}}": "පොත උඩුගත විය: {{title}}",
"Insufficient storage quota": "ප්‍රමාණවත් නොවන ගබඩා ප්‍රමාණය",
"Failed to upload book: {{title}}": "පොත උඩුගත කිරීමට අසමත්: {{title}}",
"Book downloaded: {{title}}": "පොත බාගන්නා ලදී: {{title}}",
"Failed to download book: {{title}}": "පොත බාගැනීමට අසමත්: {{title}}",
"Book deleted: {{title}}": "පොත මකා දමන ලදී: {{title}}",
"Deleted cloud backup of the book: {{title}}": "පොතේ cloud backup මකා දමන ලදී: {{title}}",
"Deleted local copy of the book: {{title}}": "පොතේ දේශීය පිටපත මකා දමන ලදී: {{title}}",
"Failed to delete book: {{title}}": "පොත මකා දැමීමට අසමත්: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "පොතේ cloud backup මකා දැමීමට අසමත්: {{title}}",
"Failed to delete local copy of the book: {{title}}": "පොතේ දේශීය පිටපත මකා දැමීමට අසමත්: {{title}}",
"Welcome to your library. You can import your books here and read them anytime.": "ඔබේ පුස්තකාලයට සාදරයෙන් පිළිගනිමු. ඔබට මෙහි ඔබේ පොත් ආයාත කර ඕනෑම වේලාවක කියවිය හැක.",
"Copied to notebook": "සටහන් පොතට පිටපත් කරන ලදී",
"No annotations to export": "නිර්යාත කිරීමට සටහන් නැත",
"Exported from Readest": "Readest වෙතින් නිර්යාත කරන ලදී",
"Highlights & Annotations": "ඉස්මතු කිරීම් සහ සටහන්",
"Untitled": "නම් නැති",
"Note": "සටහන",
"Copied to clipboard": "clipboard එකට පිටපත් කරන ලදී",
"Copy": "පිටපත් කරන්න",
"Delete Highlight": "ඉස්මතු කිරීම මකන්න",
"Highlight": "ඉස්මතු කරන්න",
"Annotate": "සටහන් තබන්න",
"Search": "සොයන්න",
"Dictionary": "ශබ්දකෝෂය",
"Wikipedia": "විකිපීඩියා",
"Translate": "පරිවර්තනය කරන්න",
"Speak": "කථා කරන්න",
"Login Required": "පිවිසීම අවශ්‍ය",
"Quota Exceeded": "ප්‍රමාණය ඉක්මවන ලදී",
"Unable to fetch the translation. Please log in first and try again.": "පරිවර්තනය ලබා ගැනීමට නොහැකි. කරුණාකර පළමුව ඇතුල් වී නැවත උත්සාහ කරන්න.",
"Unable to fetch the translation. Try again later.": "පරිවර්තනය ලබා ගැනීමට නොහැකි. පසුව නැවත උත්සාහ කරන්න.",
"Original Text": "මුල් පෙළ",
"Auto Detect": "ස්වයංක්‍රීයව අනාවරණය",
"(detected)": "(අනාවරණය විය)",
"Translated Text": "පරිවර්තනය කළ පෙළ",
"System Language": "පද්ධති භාෂාව",
"Loading...": "පූරණය වෙමින්...",
"No translation available.": "පරිවර්තනය ලබා ගත නොහැක.",
"Translated by {{provider}}.": "{{provider}} විසින් පරිවර්තනය කරන ලදී.",
"Bookmark": "පොත් සලකුණ",
"Next Section": "ඊළඟ කොටස",
"Previous Section": "පෙර කොටස",
"Next Page": "ඊළඟ පිටුව",
"Previous Page": "පෙර පිටුව",
"Go Forward": "ඉදිරියට යන්න",
"Small": "කුඩා",
"Large": "විශාල",
"Sync Conflict": "සමමුහුර්ත ගැටුම",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" වෙතින් කියවීමේ ප්‍රගතිය සමමුහුර්ත කරන්නද?",
"another device": "වෙනත් උපකරණයක්",
"Local Progress": "දේශීය ප්‍රගතිය",
"Remote Progress": "දුරස්ථ ප්‍රගතිය",
"Failed to connect": "සම්බන්ධ වීමට අසමත්",
"Disconnected": "විසන්ධි වුණි",
"KOReader Sync Settings": "KOReader සමමුහුර්ත සැකසුම්",
"Sync as {{userDisplayName}}": "{{userDisplayName}} ලෙස සමමුහුර්ත කරන්න",
"Sync Server Connected": "සමමුහුර්ත සේවාදායකය සම්බන්ධ විය",
"Sync Strategy": "සමමුහුර්ත උපාය",
"Ask on conflict": "ගැටුමේදී විමසන්න",
"Always use latest": "සෑම විටම නවතම භාවිතා කරන්න",
"Send changes only": "වෙනස්කම් පමණක් යවන්න",
"Receive changes only": "වෙනස්කම් පමණක් ලබන්න",
"Checksum Method": "පරීක්ෂණ ක්‍රමය",
"File Content (recommended)": "ගොනු අන්තර්ගතය (නිර්දේශිත)",
"File Name": "ගොනුවේ නම",
"Device Name": "උපකරණයේ නම",
"Connect to your KOReader Sync server.": "ඔබේ KOReader සමමුහුර්ත සේවාදායකයට සම්බන්ධ වන්න.",
"Server URL": "සේවාදායක URL",
"Username": "පරිශීලක නම",
"Your Username": "ඔබේ පරිශීලක නම",
"Password": "මුරපදය",
"Connect": "සම්බන්ධ කරන්න",
"Notebook": "සටහන් පොත",
"No notes match your search": "ඔබේ සෙවීමට ගැලපෙන සටහන් නැත",
"Excerpts": "උදාහරණ",
"Notes": "සටහන්",
"Add your notes here...": "මෙහි ඔබේ සටහන් එක් කරන්න...",
"Search notes and excerpts...": "සටහන් සහ උදාහරණ සොයන්න...",
"{{time}} min left in chapter": "පරිච්ඡේදයේ මිනිත්තු {{time}} ක් ඉතිරි",
"{{count}} pages left in chapter_one": "පරිච්ඡේදයේ පිටුව 1 ක් ඉතිරි",
"{{count}} pages left in chapter_other": "පරිච්ඡේදයේ පිටු {{count}} ක් ඉතිරි",
"Theme Mode": "තේමා ආකාරය",
"Invert Image In Dark Mode": "අඳුරු ආකාරයේදී රූපය පරිවර්තනය කරන්න",
"Override Book Color": "පොතේ වර්ණය අභිබවන්න",
"Theme Color": "තේමා වර්ණය",
"Custom": "අභිරුචි",
"Code Highlighting": "කේත ඉස්මතු කිරීම",
"Enable Highlighting": "ඉස්මතු කිරීම සක්‍රීය කරන්න",
"Code Language": "කේත භාෂාව",
"Auto": "ස්වයංක්‍රීය",
"Scroll": "අනුචලනය",
"Scrolled Mode": "අනුචලන ආකාරය",
"Continuous Scroll": "අඛණ්ඩ අනුචලනය",
"Overlap Pixels": "අතිච්ඡාදන පික්සල්",
"Click": "ක්ලික්",
"Clicks for Page Flip": "පිටු පෙරැළීම සඳහා ක්ලික්",
"Swap Clicks Area": "ක්ලික් ප්‍රදේශය මාරු කරන්න",
"Disable Double Click": "ද්විත්ව ක්ලික් අබල කරන්න",
"Volume Keys for Page Flip": "පිටු පෙරැළීම සඳහා ශබ්ද යතුර",
"Animation": "සජීවිකරණය",
"Paging Animation": "පිටු සජීවිකරණය",
"Security": "ආරක්ෂාව",
"Allow JavaScript": "JavaScript ට ඉඩ දෙන්න",
"Enable only if you trust the file.": "ගොනුව විශ්වාසදායක නම් පමණක් සක්‍රීය කරන්න.",
"Font": "අකුරු වර්ගය",
"Custom Fonts": "අභිරුචි අකුරු",
"Cancel Delete": "මකා දැමීම අවලංගු කරන්න",
"Delete Font": "අකුරු මකන්න",
"Import Font": "අකුරු ආයාත කරන්න",
"Tips": "ඉඟි",
"Supported font formats: .ttf, .otf, .woff, .woff2": "සහාය දක්වන අකුරු ආකෘති: .ttf, .otf, .woff, .woff2",
"Custom fonts can be selected from the Font Face menu": "Font Face මෙනුවෙන් අභිරුචි අකුරු තෝරා ගත හැක",
"Global Settings": "ගෝලීය සැකසුම්",
"Apply to All Books": "සියලු පොත් වලට අනුගත කරන්න",
"Apply to This Book": "මෙම පොතට අනුගත කරන්න",
"Reset Settings": "සැකසුම් නැවත සකස් කරන්න",
"Manage Custom Fonts": "අභිරුචි අකුරු කළමනාකරණය කරන්න",
"System Fonts": "පද්ධති අකුරු",
"Serif Font": "Serif අකුරු",
"Sans-Serif Font": "Sans-Serif අකුරු",
"Override Book Font": "පොතේ අකුරු අභිබවන්න",
"Font Size": "අකුරු ප්‍රමාණය",
"Default Font Size": "සාමාන්‍ය අකුරු ප්‍රමාණය",
"Minimum Font Size": "අවම අකුරු ප්‍රමාණය",
"Font Weight": "අකුරු බර",
"Font Family": "අකුරු පවුල",
"Default Font": "සාමාන්‍ය අකුරු",
"CJK Font": "CJK අකුරු",
"Font Face": "අකුරු මුහුණ",
"Monospace Font": "Monospace අකුරු",
"Language": "භාෂාව",
"Interface Language": "අතුරුමුහුණතේ භාෂාව",
"Translation": "පරිවර්තනය",
"Enable Translation": "පරිවර්තනය සක්‍රීය කරන්න",
"Show Source Text": "මූලාශ්‍ර පෙළ පෙන්වන්න",
"Translation Service": "පරිවර්තන සේවාව",
"Translate To": "වෙත පරිවර්තනය කරන්න",
"Override Book Layout": "පොතේ පිරිසැලසුම අභිබවන්න",
"Writing Mode": "ලේඛන ආකාරය",
"Default": "සාමාන්‍ය",
"Horizontal Direction": "තිරස් දිශාව",
"Vertical Direction": "සිරස් දිශාව",
"RTL Direction": "RTL දිශාව",
"Border Frame": "මායිම් රාමුව",
"Double Border": "ද්විත්ව මායිම",
"Border Color": "මායිම් වර්ණය",
"Paragraph": "ඡේදය",
"Paragraph Margin": "ඡේද මායිම",
"Line Spacing": "පේළි අන්තරය",
"Word Spacing": "වචන අන්තරය",
"Letter Spacing": "අකුරු අන්තරය",
"Text Indent": "පෙළ අභ්‍යන්තරය",
"Full Justification": "සම්පූර්ණ සාධාරණීකරණය",
"Hyphenation": "යොමු කිරීම",
"Page": "පිටුව",
"Top Margin (px)": "ඉහළ මායිම (px)",
"Bottom Margin (px)": "පහළ මායිම (px)",
"Left Margin (px)": "වම් මායිම (px)",
"Right Margin (px)": "දකුණු මායිම (px)",
"Column Gap (%)": "තීරු පරතරය (%)",
"Maximum Number of Columns": "අවරෝධ තීරු ගණන",
"Maximum Column Height": "අවරෝධ තීරු උස",
"Maximum Column Width": "අවරෝධ තීරු පළල",
"Header & Footer": "ශිර්ෂය සහ පාදය",
"Show Header": "ශිර්ෂය පෙන්වන්න",
"Show Footer": "පාදය පෙන්වන්න",
"Show Remaining Time": "ඉතිරි වේලාව පෙන්වන්න",
"Show Remaining Pages": "ඉතිරි පිටු පෙන්වන්න",
"Show Reading Progress": "කියවීමේ ප්‍රගතිය පෙන්වන්න",
"Reading Progress Style": "කියවීමේ ප්‍රගති ශෛලිය",
"Page Number": "පිටු අංකය",
"Percentage": "ප්‍රතිශතය",
"Apply also in Scrolled Mode": "අනුචලන ආකාරයේදීද අනුගත කරන්න",
"Screen": "තිරය",
"Orientation": "දිශානතිය",
"Portrait": "සිරස්",
"Landscape": "තිරස්",
"Apply": "අනුගත කරන්න",
"Custom Content CSS": "අභිරුචි අන්තර්ගත CSS",
"Enter CSS for book content styling...": "පොතේ අන්තර්ගත ශෛලිකරණය සඳහා CSS ඇතුල් කරන්න...",
"Custom Reader UI CSS": "අභිරුචි කියවන්නා UI CSS",
"Enter CSS for reader interface styling...": "කියවන්නා අතුරුමුහුණත ශෛලිකරණය සඳහා CSS ඇතුල් කරන්න...",
"Layout": "පිරිසැලසුම",
"Color": "වර්ණය",
"Behavior": "හැසිරීම",
"Reset {{settings}}": "{{settings}} නැවත සකස් කරන්න",
"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": "සියලු ලෝකය වේදිකාවක්,\nසහ සියලු පුරුෂයන් සහ ගැහැණියන් හුදු රංගන ශිල්පීන්;\nඔවුන්ට පිටවීම් සහ ඇතුල්වීම් ඇත,\nසහ එක් මනුෂ්‍යයෙක් ඔහුගේ කාලයේ බොහෝ භාගයන් ඉටු කරයි,\nඔහුගේ ක්‍රියා වයස් හතකි.\n\n— විලියම් ශේක්ස්පියර්",
"(from 'As You Like It', Act II)": "('ඔබට අවශ්‍ය ලෙස', දෙවන අංකයෙන්)",
"Custom Theme": "අභිරුචි තේමාව",
"Theme Name": "තේමා නාමය",
"Text Color": "පෙළ වර්ණය",
"Background Color": "පසුබිම් වර්ණය",
"Link Color": "සබැඳි වර්ණය",
"Preview": "පෙර දසුන",
"Font & Layout": "අකුරු සහ පිරිසැලසුම",
"Book Cover": "පොත් ආවරණය",
"More Info": "තවත් තොරතුරු",
"Parallel Read": "සමාන්තර කියවීම",
"Disable": "අබල කරන්න",
"Enable": "සක්‍රීය කරන්න",
"KOReader Sync": "KOReader සමමුහුර්තය",
"Push Progress": "ප්‍රගතිය ඉදිරියට",
"Pull Progress": "ප්‍රගතිය අදින්න",
"Export Annotations": "සටහන් නිර්යාත කරන්න",
"Sort TOC by Page": "පිටුව අනුව TOC සකස් කරන්න",
"Edit": "සංස්කරණය",
"Search...": "සොයන්න...",
"Book": "පොත",
"Chapter": "පරිච්ඡේදය",
"Match Case": "අකුරු ගැළපීම",
"Match Whole Words": "සම්පූර්ණ වචන ගැළපීම",
"Match Diacritics": "ධ්වනි ලකුණු ගැළපීම",
"TOC": "සූචිය",
"Sidebar": "පැති තීරුව",
"Disable Translation": "පරිවර්තනය අබල කරන්න",
"Translation Not Available": "පරිවර්තනය ලබා ගත නොහැක",
"TTS not supported for PDF": "PDF සඳහා TTS සහාය නොදක්වයි",
"TTS not supported for this document": "මෙම ලේඛනය සඳහා TTS සහාය නොදක්වයි",
"No Timeout": "කාල සීමාවක් නැත",
"{{value}} minute": "මිනිත්තු {{value}}",
"{{value}} minutes": "මිනිත්තු {{value}}",
"{{value}} hour": "පැය {{value}}",
"{{value}} hours": "පැය {{value}}",
"Voices for {{lang}}": "{{lang}} සඳහා හඬ",
"Slow": "මන්දගාමී",
"Fast": "වේගවත්",
"{{engine}}: {{count}} voices_one": "{{engine}}: හඬ 1",
"{{engine}}: {{count}} voices_other": "{{engine}}: හඬ {{count}}",
"Sign in to Sync": "සමමුහුර්ත කිරීම සඳහා ඇතුල් වන්න",
"Synced at {{time}}": "{{time}} වේලාවේ සමමුහුර්ත විය",
"Never synced": "කිසි විටෙක සමමුහුර්ත නොවිණි",
"Reading Progress Synced": "කියවීමේ ප්‍රගතිය සමමුහුර්ත විය",
"Page {{page}} of {{total}} ({{percentage}}%)": "{{total}} න් {{page}} පිටුව ({{percentage}}%)",
"Current position": "වර්තමාන ස්ථානය",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "දළ වශයෙන් {{total}} න් {{page}} පිටුව ({{percentage}}%)",
"Approximately {{percentage}}%": "දළ වශයෙන් {{percentage}}%",
"Delete Your Account?": "ඔබේ ගිණුම මකන්නද?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "මෙම ක්‍රියාව අවලංගු කළ නොහැක. cloud හි ඔබේ සියලු දත්ත ස්ථිරවම මකා දැමෙනු ඇත.",
"Delete Permanently": "ස්ථිරවම මකන්න",
"Restore Purchase": "මිලදී ගැනීම ප්‍රතිෂ්ඨාපනය කරන්න",
"Manage Subscription": "දායකත්වය කළමනාකරණය කරන්න",
"Reset Password": "මුරපදය නැවත සකස් කරන්න",
"Sign Out": "ඉවත් වන්න",
"Delete Account": "ගිණුම මකන්න",
"Upgrade to {{plan}}": "{{plan}} වෙත උසස් කරන්න",
"Complete Your Subscription": "ඔබේ දායකත්වය සම්පූර්ණ කරන්න",
"Coming Soon": "ඉක්මනින් පැමිණේ",
"Upgrade to Plus or Pro": "Plus හෝ Pro වෙත උසස් කරන්න",
"Current Plan": "වර්තමාන සැලැස්ම",
"Plan Limits": "සැලැස්මේ සීමා",
"Failed to delete user. Please try again later.": "පරිශීලකයා මකා දැමීමට අසමත්. කරුණාකර පසුව නැවත උත්සාහ කරන්න.",
"Failed to create checkout session": "checkout session නිර්මාණය කිරීමට අසමත්",
"No purchases found to restore.": "ප්‍රතිෂ්ඨාපනය කිරීමට මිලදී ගැනීම් නොමැත.",
"Failed to restore purchases.": "මිලදී ගැනීම් ප්‍රතිෂ්ඨාපනය කිරීමට අසමත්.",
"Failed to manage subscription.": "දායකත්වය කළමනාකරණය කිරීමට අසමත්.",
"Failed to load subscription plans.": "දායකත්ව සැලැස්ම් පූරණය කිරීමට අසමත්.",
"Loading profile...": "පැතිකඩ පූරණය වෙමින්...",
"Processing your payment...": "ඔබේ ගෙවීම සැකසෙමින්...",
"Please wait while we confirm your subscription.": "අපි ඔබේ දායකත්වය තහවුරු කරන අතරතුර කරුණාකර රැඳී සිටින්න.",
"Payment Processing": "ගෙවීම් සැකසීම",
"Your payment is being processed. This usually takes a few moments.": "ඔබේ ගෙවීම සැකසෙමින් පවතී. මෙය සාමාන්‍යයෙන් මොහොතකින් සිදු වේ.",
"Payment Failed": "ගෙවීම අසමත්",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "අපට ඔබේ දායකත්වය සැකසීමට නොහැකි විය. කරුණාකර නැවත උත්සාහ කරන්න හෝ ගැටලුව දිගටම පවතින්නේ නම් සහාය සඳහා සම්බන්ධ වන්න.",
"Back to Profile": "පැතිකඩට ආපසු",
"Subscription Successful!": "දායකත්වය සාර්ථක!",
"Thank you for your subscription! Your payment has been processed successfully.": "ඔබේ දායකත්වයට ස්තූතිය! ඔබේ ගෙවීම සාර්ථකව සැකසිණි.",
"Email:": "ඊමේල්:",
"Plan:": "සැලැස්ම:",
"Amount:": "මුදල:",
"Subscription ID:": "දායකත්ව ID:",
"Go to Library": "පුස්තකාලයට යන්න",
"Need help? Contact our support team at support@readest.com": "උදව් අවශ්‍යද? support@readest.com හි අපගේ සහාය කණ්ඩායමට සම්බන්ධ වන්න",
"Free Plan": "නොමිලේ සැලැස්ම",
"month": "මාසය",
"year": "වර්ෂය",
"Cross-Platform Sync": "හරස් වේදිකා සමමුහුර්තය",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "ඔබේ සියලු උපකරණ හරහා ඔබේ පුස්තකාලය, ප්‍රගතිය, ඉස්මතු කිරීම් සහ සටහන් බාධාවකින් තොරව සමමුහුර්ත කරන්න - ඔබේ ස්ථානය නැවත කිසි දිනෙක නැති නොකරන්න.",
"Customizable Reading": "අභිරුචිකරණය කළ හැකි කියවීම",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "පරිපූර්ණ කියවීමේ අත්දැකීම සඳහා සකස් කළ හැකි අකුරු, පිරිසැලසුම්, තේමා සහ උසස් ප්‍රදර්ශන සැකසුම් සමඟ සෑම විස්තරයක්ම පුද්ගලීකරණය කරන්න.",
"AI Read Aloud": "AI පවසා කියවීම",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "ඔබේ පොත් ජීවයට ගෙන එන ස්වභාවික ශබ්ද සහිත AI හඬ සමඟ අත් නිදහස් කියවීම රස විඳින්න.",
"AI Translations": "AI පරිවර්තන",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Google, Azure, හෝ DeepL හි බලය සමඟ ඕනෑම පෙළක් ක්ෂණිකව පරිවර්තනය කරන්න - ඕනෑම භාෂාවකින් අන්තර්ගතය තේරුම් ගන්න.",
"Community Support": "ප්‍රජා සහාය",
"Connect with fellow readers and get help fast in our friendly community channels.": "සහෘදී පාඨකයන් සමඟ සම්බන්ධ වන්න සහ අපගේ මිත්‍රශීලී ප්‍රජා නාලිකා වල ඉක්මනින් උදව් ලබා ගන්න.",
"Cloud Sync Storage": "Cloud සමමුහුර්ත ගබඩාව",
"AI Translations (per day)": "AI පරිවර්තන (දිනකට)",
"Plus Plan": "Plus සැලැස්ම",
"Includes All Free Plan Benefits": "සියලු නොමිලේ සැලැස්මේ ප්‍රතිලාභ ඇතුළත්",
"Unlimited AI Read Aloud Hours": "අසීමිත AI පවසා කියවීමේ පැය",
"Listen without limits—convert as much text as you like into immersive audio.": "සීමාවකින් තොරව සවන් දෙන්න - ඔබට අවශ්‍ය තරම් පෙළ ගිලී යන ශ්‍රව්‍ය බවට පරිවර්තනය කරන්න.",
"More AI Translations": "තවත් AI පරිවර්තන",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "වැඩි දෛනික භාවිතය සහ උසස් විකල්ප සමඟ වැඩිදියුණු කළ පරිවර්තන හැකියාවන් අනවරණය කරන්න.",
"DeepL Pro Access": "DeepL Pro ප්‍රවේශය",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "ලබා ගත හැකි වඩාත් නිරවද්‍ය පරිවර්තන එන්ජිම සමඟ දිනකට අක්ෂර 100,000 ක් දක්වා පරිවර්තනය කරන්න.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "GB 5 ක් දක්වා cloud ගබඩාව සමඟ ඔබේ සම්පූර්ණ කියවීමේ එකතුව ආරක්ෂිතව ගබඩා කරන්න සහ ප්‍රවේශ වන්න.",
"Priority Support": "ප්‍රමුඛතා සහාය",
"Enjoy faster responses and dedicated assistance whenever you need help.": "ඔබට උදව් අවශ්‍ය වන විට ඉක්මන් ප්‍රතිචාර සහ කැප වූ සහාය රස විඳින්න.",
"Pro Plan": "Pro සැලැස්ම",
"Includes All Plus Plan Benefits": "සියලු Plus සැලැස්මේ ප්‍රතිලාභ ඇතුළත්",
"Early Feature Access": "පූර්ව විශේෂාංග ප්‍රවේශය",
"Be the first to explore new features, updates, and innovations before anyone else.": "අන් කවුරුන්ටත් වඩා පළමුව නව විශේෂාංග, යාවත්කාලීන සහ නවෝත්පාදන ගවේෂණය කරන්න.",
"Advanced AI Tools": "උසස් AI මෙවලම්",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "දක්ෂ කියවීම, පරිවර්තනය සහ අන්තර්ගත සොයාගැනීම සඳහා ප්‍රබල AI මෙවලම් භාවිතා කරන්න.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "ලබා ගත හැකි වඩාත් නිරවද්‍ය පරිවර්තන එන්ජිම සමඟ දිනකට අක්ෂර 500,000 ක් දක්වා පරිවර්තනය කරන්න.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "GB 20 ක් දක්වා cloud ගබඩාව සමඟ ඔබේ සම්පූර්ණ කියවීමේ එකතුව ආරක්ෂිතව ගබඩා කරන්න සහ ප්‍රවේශ වන්න.",
"Version {{version}}": "සංස්කරණය {{version}}",
"Check Update": "යාවත්කාලීන පරීක්ෂා කරන්න",
"Already the latest version": "දැනටමත් නවතම සංස්කරණය",
"Checking for updates...": "යාවත්කාලීන සඳහා පරීක්ෂා කරමින්...",
"Error checking for updates": "යාවත්කාලීන පරීක්ෂා කිරීමේ දෝෂය",
"Drop to Import Books": "පොත් ආයාත කිරීමට අතහරින්න",
"Terms of Service": "සේවා නියමයන්",
"Privacy Policy": "පෞද්ගලිකත්ව ප්‍රතිපත්තිය",
"Enter book title": "පොතේ නම ඇතුල් කරන්න",
"Subtitle": "උප නාමය",
"Enter book subtitle": "පොතේ උප නාමය ඇතුල් කරන්න",
"Enter author name": "කතුවරයාගේ නම ඇතුල් කරන්න",
"Series": "කතා මාලාව",
"Enter series name": "කතා මාලාවේ නම ඇතුල් කරන්න",
"Series Index": "කතා මාලා සූචිය",
"Enter series index": "කතා මාලා සූචිය ඇතුල් කරන්න",
"Total in Series": "කතා මාලාවේ මුළු ගණන",
"Enter total books in series": "කතා මාලාවේ මුළු පොත් ගණන ඇතුල් කරන්න",
"Publisher": "ප්‍රකාශක",
"Enter publisher": "ප්‍රකාශක ඇතුල් කරන්න",
"Publication Date": "ප්‍රකාශන දිනය",
"YYYY or YYYY-MM-DD": "YYYY හෝ YYYY-MM-DD",
"Identifier": "හඳුනාගැනීම",
"Subjects": "විෂයන්",
"Fiction, Science, History": "ප්‍රබන්ධ, විද්‍යාව, ඉතිහාසය",
"Description": "විස්තරය",
"Enter book description": "පොතේ විස්තරය ඇතුල් කරන්න",
"Change cover image": "ආවරණ රූපය වෙනස් කරන්න",
"Replace": "ආදේශ කරන්න",
"Unlock cover": "ආවරණය අගුළු ඇරින්න",
"Lock cover": "ආවරණය අගුළු දමන්න",
"Auto-Retrieve Metadata": "Metadata ස්වයංක්‍රීයව ලබා ගන්න",
"Auto-Retrieve": "ස්වයංක්‍රීයව ලබා ගන්න",
"Unlock all fields": "සියලු ක්ෂේත්‍ර අගුළු ඇරින්න",
"Unlock All": "සියල්ල අගුළු ඇරින්න",
"Lock all fields": "සියලු ක්ෂේත්‍ර අගුළු දමන්න",
"Lock All": "සියල්ල අගුළු දමන්න",
"Reset": "නැවත සකස් කරන්න",
"Are you sure to delete the selected book?": "ඔබට විශ්වාසද තෝරාගත් පොත මකා දැමීමට?",
"Are you sure to delete the cloud backup of the selected book?": "ඔබට විශ්වාසද තෝරාගත් පොතේ cloud backup මකා දැමීමට?",
"Are you sure to delete the local copy of the selected book?": "ඔබට විශ්වාසද තෝරාගත් පොතේ දේශීය පිටපත මකා දැමීමට?",
"Edit Metadata": "Metadata සංස්කරණය කරන්න",
"Book Details": "පොතේ විස්තර",
"Unknown": "නොදන්නා",
"Remove from Cloud & Device": "Cloud සහ උපකරණයෙන් ඉවත් කරන්න",
"Remove from Cloud Only": "Cloud එකෙන් පමණක් ඉවත් කරන්න",
"Remove from Device Only": "උපකරණයෙන් පමණක් ඉවත් කරන්න",
"Published": "ප්‍රකාශිතය",
"Updated": "යාවත්කාලීන කරන ලදී",
"Added": "එක් කරන ලදී",
"File Size": "ගොනු ප්‍රමාණය",
"No description available": "විස්තරයක් ලබා ගත නොහැක",
"Locked": "අගුළු දමන ලදී",
"Select Metadata Source": "Metadata මූලාශ්‍රය තෝරන්න",
"Keep manual input": "අතින් ඇතුල් කළ දේ තබාගන්න",
"A new version of Readest is available!": "Readest හි නව සංස්කරණයක් ලබා ගත හැක!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} ලබා ගත හැක (ස්ථාපිත සංස්කරණය {{currentVersion}}).",
"Download and install now?": "දැන් බාගන්න සහ ස්ථාපනය කරන්නද?",
"Downloading {{downloaded}} of {{contentLength}}": "{{contentLength}} න් {{downloaded}} බාගනිමින්",
"Download finished": "බාගැනීම සම්පූර්ණයි",
"DOWNLOAD & INSTALL": "බාගන්න සහ ස්ථාපනය කරන්න",
"Changelog": "වෙනස්කම් ලිපිය",
"Software Update": "මෘදුකාංග යාවත්කාලීන",
"What's New in Readest": "Readest හි නවතම දේ",
"Select Files": "ගොනු තෝරන්න",
"Select Image": "රූපය තෝරන්න",
"Select Video": "වීඩියෝව තෝරන්න",
"Select Audio": "ශ්‍රව්‍ය තෝරන්න",
"Select Fonts": "අකුරු තෝරන්න",
"Storage": "ගබඩාව",
"{{percentage}}% of Cloud Sync Space Used.": "Cloud සමමුහුර්ත ඉඩෙන් {{percentage}}% භාවිතා කර ඇත.",
"Translation Characters": "පරිවර්තන අක්ෂර",
"{{percentage}}% of Daily Translation Characters Used.": "දෛනික පරිවර්තන අක්ෂර වලින් {{percentage}}% භාවිතා කර ඇත.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "දෛනික පරිවර්තන කෝටාව ළඟා විය. AI පරිවර්තන භාවිතා කරමින් සිටීමට ඔබේ සැලැස්ම උසස් කරන්න.",
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
"Azure Translator": "Azure Translator",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Yandex Translate": "Yandex Translate",
"Gray": "අළු",
"Sepia": "සීපියා",
"Grass": "තණකොළ",
"Cherry": "චෙරි",
"Sky": "අහස",
"Solarized": "සූර්යකිරණ",
"Gruvbox": "Gruvbox",
"Nord": "Nord",
"Contrast": "පරස්පර",
"Sunset": "හිරු බැස්ම",
"Reveal in Finder": "Finder හි පෙන්වන්න",
"Reveal in File Explorer": "File Explorer හි පෙන්වන්න",
"Reveal in Folder": "ෆෝල්ඩරයේ පෙන්වන්න",
"Previous Paragraph": "කලින් ඡේදය",
"Previous Sentence": "කලින් වාක්‍යය",
"Pause": "විරාමය",
"Play": "වාදනය කරන්න",
"Next Sentence": "මීළඟ වාක්‍යය",
"Next Paragraph": "මීළඟ ඡේදය",
"Separate Cover Page": "වෙන් වූ ආවරණ පිටුව"
}
@@ -0,0 +1,526 @@
{
"Email address": "மின்னஞ்சல் முகவரி",
"Your Password": "உங்கள் கடவுச்சொல்",
"Your email address": "உங்கள் மின்னஞ்சல் முகவரி",
"Your password": "உங்கள் கடவுச்சொல்",
"Sign in": "உள்நுழையவும்",
"Signing in...": "உள்நுழைகிறது...",
"Sign in with {{provider}}": "{{provider}} மூலம் உள்நுழையவும்",
"Already have an account? Sign in": "ஏற்கனவே கணக்கு உள்ளதா? உள்நுழையவும்",
"Create a Password": "கடவுச்சொல் உருவாக்கவும்",
"Sign up": "பதிவு செய்யவும்",
"Signing up...": "பதிவு செய்கிறது...",
"Don't have an account? Sign up": "கணக்கு இல்லையா? பதிவு செய்யவும்",
"Check your email for the confirmation link": "உறுதிப்படுத்தல் இணைப்பிற்கு உங்கள் மின்னஞ்சலைச் சரிபார்க்கவும்",
"Signing in ...": "உள்நுழைகிறது...",
"Send a magic link email": "மேஜிக் இணைப்பு மின்னஞ்சல் அனுப்பவும்",
"Check your email for the magic link": "மேஜிக் இணைப்பிற்கு உங்கள் மின்னஞ்சலைச் சரிபார்க்கவும்",
"Send reset password instructions": "கடவுச்சொல் மீட்டமைப்பு வழிமுறைகளை அனுப்பவும்",
"Sending reset instructions ...": "மீட்டமைப்பு வழிமுறைகள் அனுப்புகிறது...",
"Forgot your password?": "கடவுச்சொல் மறந்துவிட்டதா?",
"Check your email for the password reset link": "கடவுச்சொல் மீட்டமைப்பு இணைப்பிற்கு மின்னஞ்சலைச் சரிபார்க்கவும்",
"Phone number": "தொலைபேசி எண்",
"Your phone number": "உங்கள் தொலைபேசி எண்",
"Token": "டோக்கன்",
"Your OTP token": "உங்கள் OTP டோக்கன்",
"Verify token": "டோக்கனை சரிபார்க்கவும்",
"Sign in with Google": "Google மூலம் உள்நுழையவும்",
"Sign in with Apple": "Apple மூலம் உள்நுழையவும்",
"Sign in with GitHub": "GitHub மூலம் உள்நுழையவும்",
"New Password": "புதிய கடவுச்சொல்",
"Your new password": "உங்கள் புதிய கடவுச்சொல்",
"Update password": "கடவுச்சொல் புதுப்பிக்கவும்",
"Updating password ...": "கடவுச்சொல் புதுப்பிக்கிறது...",
"Your password has been updated": "உங்கள் கடவுச்சொல் புதுப்பிக்கப்பட்டது",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "ஏதோ தவறு நடந்துள்ளது. கவலைப்பட வேண்டாம், எங்கள் குழு அறிவிக்கப்பட்டுள்ளது மற்றும் நாங்கள் தீர்வு செய்து கொண்டிருக்கிறோம்.",
"Error Details:": "பிழை விவரங்கள்:",
"Try Again": "மீண்டும் முயற்சிக்கவும்",
"Go Back": "பின் செல்லவும்",
"Your Library": "உங்கள் நூலகம்",
"Need help?": "உதவி தேவையா?",
"Contact Support": "ஆதரவை தொடர்பு கொள்ளவும்",
"Open": "திறக்கவும்",
"Group": "குழு",
"Details": "விவரங்கள்",
"Delete": "நீக்கவும்",
"Cancel": "ரத்து செய்யவும்",
"Confirm Deletion": "நீக்குதலை உறுதிசெய்யவும்",
"Are you sure to delete {{count}} selected book(s)?_one": "தேர்ந்தெடுக்கப்பட்ட புத்தகத்தை நீக்க உறுதியா?",
"Are you sure to delete {{count}} selected book(s)?_other": "தேர்ந்தெடுக்கப்பட்ட {{count}} புத்தகங்களை நீக்க உறுதியா?",
"Deselect Book": "புத்தகத்தை தேர்வுநீக்கவум்",
"Select Book": "புத்தகத்தை தேர்வு செய்யவும்",
"Show Book Details": "புத்தக விவரங்களைக் காட்டவும்",
"Download Book": "புத்தகத்தை பதிவிறக்கவும்",
"Upload Book": "புத்தகத்தை பதிவேற்றவும்",
"Deselect Group": "குழுவை தேர்வுநீক்கவும்",
"Select Group": "குழுவை தேர்வு செய்யவும்",
"Untitled Group": "பெயரிடப்படாத குழு",
"Group Books": "புத்தகங்களை குழுவாக்கவும்",
"Remove From Group": "குழுவிலிருந்து நீக்கவும்",
"Create New Group": "புதிய குழு உருவாக்கவும்",
"Save": "சேமிக்கவும்",
"Confirm": "உறுதிசெய்யவும்",
"From Local File": "உள்ளூர் கோப்பிலிருந்து",
"Search in {{count}} Book(s)..._one": "புத்தகத்தில் தேடவும்...",
"Search in {{count}} Book(s)..._other": "{{count}} புத்தகங்களில் தேடவும்...",
"Search Books...": "புத்தகங்களைத் தேடவும்...",
"Clear Search": "தேடலை அழிக்கவும்",
"Import Books": "புத்தகங்களை இறக்குமதி செய்யவும்",
"Select Multiple Books": "பல புத்தகங்களை தேர்வு செய்யவும்",
"Select Books": "புத்தகங்களை தேர்வு செய்யவும்",
"Deselect": "தேர்வுநீக்கவும்",
"Select All": "அனைத்தையும் தேர்வு செய்யவும்",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} ஆக உள்நுழைந்துள்ளீர்கள்",
"Logged in": "உள்நுழைந்துள்ளீர்கள்",
"Account": "கணக்கு",
"Sign In": "உள்நுழையவும்",
"Auto Upload Books to Cloud": "புத்தகங்களை cloud இல் தானாக பதிவேற்றவும்",
"Auto Import on File Open": "கோப்பு திறக்கும்போது தானாக இறக்குமதி",
"Open Last Book on Start": "தொடங்கும்போது கடைசி புத்தகத்தைத் திறக்கவும்",
"Check Updates on Start": "தொடங்கும்போது புதுப்பிப்புகளைச் சரிபார்க்கவும்",
"Open Book in New Window": "புதிய சாளரத்தில் புத்தகத்தைத் திறக்கவும்",
"Fullscreen": "முழுத்திரை",
"Always on Top": "எப்போதும் மேலே",
"Always Show Status Bar": "எப்போதும் நிலைப் பட்டியைக் காட்டவும்",
"Keep Screen Awake": "திரையை விழித்திருக்கச் செய்யவும்",
"Reload Page": "பக்கத்தை மீண்டும் ஏற்றவும்",
"Dark Mode": "இருட்டு பயன்முறை",
"Light Mode": "வெளிச்ச பயன்முறை",
"Auto Mode": "தானியங்கி பயன்முறை",
"Upgrade to Readest Premium": "Readest Premium க்கு மேம்படுத்தவும்",
"Download Readest": "Readest பதிவிறக்கவும்",
"About Readest": "Readest பற்றி",
"Help improve Readest": "Readest மேம்படுத்த உதவவும்",
"Sharing anonymized statistics": "அடையாள விவரங்களை நீக்கிய புள்ளிவிவரங்களைப் பகிர்தல்",
"List": "பட்டியல்",
"Grid": "கட்டம்",
"Crop": "வெட்டவும்",
"Fit": "பொருத்தவும்",
"Title": "தலைப்பு",
"Author": "ஆசிரியர்",
"Format": "வடிவம்",
"Date Read": "படித்த தேதி",
"Date Added": "சேர்த்த தேதி",
"Ascending": "ஏறுவரிசை",
"Descending": "இறங்குவரிசை",
"Book Covers": "புத்தக அட்டைகள்",
"Sort by...": "வரிசைப்படுத்து...",
"No supported files found. Supported formats: {{formats}}": "ஆதரிக்கப்படும் கோப்புகள் இல்லை. ஆதரிக்கப்படும் வடிவங்கள்: {{formats}}",
"No chapters detected.": "அத்தியாயங்கள் கண்டறியப்படவில்லை.",
"Failed to parse the EPUB file.": "EPUB கோப்பை பகுப்பாய்வு செய்ய முடியவில்லை.",
"This book format is not supported.": "இந்த புத்தக வடிவம் ஆதரிக்கப்படவில்லை.",
"Failed to import book(s): {{filenames}}": "புத்தகங்களை இறக்குமதி செய்ய முடியவில்லை: {{filenames}}",
"Book uploaded: {{title}}": "புத்தகம் பதிவேற்றப்பட்டது: {{title}}",
"Insufficient storage quota": "போதுமான சேமிப்பக ஒதுக்கீடு இல்லை",
"Failed to upload book: {{title}}": "புத்தகத்தை பதிவேற்ற முடியவில்லை: {{title}}",
"Book downloaded: {{title}}": "புத்தகம் பதிவிறக்கப்பட்டது: {{title}}",
"Failed to download book: {{title}}": "புத்தகத்தை பதிவிறக்க முடியவில்லை: {{title}}",
"Book deleted: {{title}}": "புத்தகம் நீக்கப்பட்டது: {{title}}",
"Deleted cloud backup of the book: {{title}}": "புத்தகத்தின் cloud backup நீக்கப்பட்டது: {{title}}",
"Deleted local copy of the book: {{title}}": "புத்தகத்தின் உள்ளூர் நகல் நீக்கப்பட்டது: {{title}}",
"Failed to delete book: {{title}}": "புத்தகத்தை நீக்க முடியவில்லை: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "புத்தகத்தின் cloud backup நீக்க முடியவில்லை: {{title}}",
"Failed to delete local copy of the book: {{title}}": "புத்தகத்தின் உள்ளூர் நகல் நீக்க முடியவில்லை: {{title}}",
"Welcome to your library. You can import your books here and read them anytime.": "உங்கள் நூலகத்திற்கு வரவேற்கிறோம். நீங்கள் இங்கே உங்கள் புத்தகங்களை இறக்குமதி செய்து எப்போது வேண்டுமானாலும் படிக்கலாம்.",
"Copied to notebook": "குறிப்பேட்டில் நகலெடுக்கப்பட்டது",
"No annotations to export": "ஏற்றுமதி செய்ய குறிப்புகள் இல்லை",
"Exported from Readest": "Readest இல் இருந்து ஏற்றுமதி செய்யப்பட்டது",
"Highlights & Annotations": "முத்திரைகள் மற்றும் குறிப்புகள்",
"Untitled": "பெயரிடப்படாதது",
"Note": "குறிப்பு",
"Copied to clipboard": "clipboard இல் நகலெடுக்கப்பட்டது",
"Copy": "நகல்",
"Delete Highlight": "முத்திரையை நீக்கவும்",
"Highlight": "முத்திரை",
"Annotate": "குறிப்பிடவும்",
"Search": "தேடவும்",
"Dictionary": "அகராதி",
"Wikipedia": "விக்கிபீடியா",
"Translate": "மொழிபெயர்க்கவும்",
"Speak": "பேசவும்",
"Login Required": "உள்நுழைவு தேவை",
"Quota Exceeded": "ஒதுக்கீடு மீறப்பட்டது",
"Unable to fetch the translation. Please log in first and try again.": "மொழிபெயர்ப்பைப் பெற முடியவில்லை. முதலில் உள்நுழைந்து மீண்டும் முயற்சிக்கவும்.",
"Unable to fetch the translation. Try again later.": "மொழிபெயர்ப்பைப் பெற முடியவில்லை. பின்னர் மீண்டும் முயற்சிக்கவும்.",
"Original Text": "மூல உரை",
"Auto Detect": "தானாக கண்டறிதல்",
"(detected)": "(கண்டறியப்பட்டது)",
"Translated Text": "மொழிபெயர்க்கப்பட்ட உரை",
"System Language": "கணினி மொழி",
"Loading...": "ஏற்றுகிறது...",
"No translation available.": "மொழிபெயர்ப்பு கிடைக்கவில்லை.",
"Translated by {{provider}}.": "{{provider}} மூலம் மொழிபெயர்க்கப்பட்டது.",
"Bookmark": "புக்மார்க்",
"Next Section": "அடுத்த பிரிவு",
"Previous Section": "முந்தைய பிரிவு",
"Next Page": "அடுத்த பக்கம்",
"Previous Page": "முந்தைய பக்கம்",
"Go Forward": "முன்னோக்கி செல்லவும்",
"Small": "சிறிய",
"Large": "பெரிய",
"Sync Conflict": "ஒத்திசைவு முரண்பாடு",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" இல் இருந்து வாசிப்பு முன்னேற்றத்தை ஒத்திசைக்கவா?",
"another device": "மற்றொரு சாதனம்",
"Local Progress": "உள்ளூர் முன்னேற்றம்",
"Remote Progress": "தொலை முன்னேற்றம்",
"Failed to connect": "இணைக்க முடியவில்லை",
"Disconnected": "துண்டிக்கப்பட்டது",
"KOReader Sync Settings": "KOReader ஒத்திசைவு அமைப்புகள்",
"Sync as {{userDisplayName}}": "{{userDisplayName}} ஆக ஒத்திசைக்கவும்",
"Sync Server Connected": "ஒத்திசைவு சர்வர் இணைக்கப்பட்டது",
"Sync Strategy": "ஒத்திசைவு உத்தி",
"Ask on conflict": "முரண்பாட்டில் கேட்கவும்",
"Always use latest": "எப்போதும் சமீபத்தியதைப் பயன்படுத்தவும்",
"Send changes only": "மாற்றங்களை மட்டும் அனுப்பவும்",
"Receive changes only": "மாற்றங்களை மட்டும் பெறவும்",
"Checksum Method": "சரிபார்ப்பு முறை",
"File Content (recommended)": "கோப்பு உள்ளடக்கம் (பரிந்துரைக்கப்பட்டது)",
"File Name": "கோப்பு பெயர்",
"Device Name": "சாதன பெயர்",
"Connect to your KOReader Sync server.": "உங்கள் KOReader ஒத்திசைவு சர்வருடன் இணையவும்.",
"Server URL": "சர்வர் URL",
"Username": "பயனர்பெயர்",
"Your Username": "உங்கள் பயனர்பெயர்",
"Password": "கடவுச்சொல்",
"Connect": "இணைக்கவும்",
"Notebook": "குறிப்பேடு",
"No notes match your search": "உங்கள் தேடலுக்கு பொருந்தும் குறிப்புகள் இல்லை",
"Excerpts": "உதாரணங்கள்",
"Notes": "குறிப்புகள்",
"Add your notes here...": "உங்கள் குறிப்புகளை இங்கே சேர்க்கவும்...",
"Search notes and excerpts...": "குறிப்புகள் மற்றும் உதாரணங்களைத் தேடவும்...",
"{{time}} min left in chapter": "அத்தியாயத்தில் {{time}} நிமிடங்கள் மீதமுள்ளன",
"{{count}} pages left in chapter_one": "அத்தியாயத்தில் 1 பக்கம் மீதமுள்ளது",
"{{count}} pages left in chapter_other": "அத்தியாயத்தில் {{count}} பக்கங்கள் மீதமுள்ளன",
"Theme Mode": "தீம் பயன்முறை",
"Invert Image In Dark Mode": "இருட்டு பயன்முறையில் படத்தை தலைகீழாக மாற்றவும்",
"Override Book Color": "புத்தக நிறத்தை மேலெழுதவும்",
"Theme Color": "தீம் நிறம்",
"Custom": "தனிப்பயன்",
"Code Highlighting": "குறியீடு முத்திரை",
"Enable Highlighting": "முத்திரையிடுதலை இயக்கவும்",
"Code Language": "குறியீடு மொழி",
"Auto": "தானியங்கி",
"Scroll": "உருட்டவும்",
"Scrolled Mode": "உருட்டல் பயன்முறை",
"Continuous Scroll": "தொடர்ச்சியான உருட்டல்",
"Overlap Pixels": "ஒன்றுடன் ஒன்று பிக்சல்கள்",
"Click": "கிளிக்",
"Clicks for Page Flip": "பக்கம் புரட்டுவதற்கு கிளிக்கள்",
"Swap Clicks Area": "கிளிக் பகுதியை மாற்றவும்",
"Disable Double Click": "இரட்டை கிளிக்கை முடக்கவும்",
"Volume Keys for Page Flip": "பக்கம் புரட்டுவதற்கு ஒலி விசைகள்",
"Animation": "அனிமேஷன்",
"Paging Animation": "பக்க அனிமேஷன்",
"Security": "பாதுகாப்பு",
"Allow JavaScript": "JavaScript அனுமதிக்கவும்",
"Enable only if you trust the file.": "கோப்பை நம்பினால் மட்டும் இயக்கவும்.",
"Font": "எழுத்துரு",
"Custom Fonts": "தனிப்பயன் எழுத்துருக்கள்",
"Cancel Delete": "நீக்குதலை ரத்துசெய்யவும்",
"Delete Font": "எழுத்துருவை நீக்கவும்",
"Import Font": "எழுத்துருவை இறக்குமதி செய்யவும்",
"Tips": "குறிப்புகள்",
"Supported font formats: .ttf, .otf, .woff, .woff2": "ஆதரிக்கப்படும் எழுத்துரு வடிவங்கள்: .ttf, .otf, .woff, .woff2",
"Custom fonts can be selected from the Font Face menu": "Font Face மெனுவிலிருந்து தனிப்பயன் எழுத்துருக்களை தேர்வு செய்யலாம்",
"Global Settings": "உலகளாவிய அமைப்புகள்",
"Apply to All Books": "அனைத்து புத்தகங்களுக்கும் பொருந்தும்",
"Apply to This Book": "இந்த புத்தகத்திற்கு பொருந்தும்",
"Reset Settings": "அமைப்புகளை மீட்டமைக்கவும்",
"Manage Custom Fonts": "தனிப்பயன் எழுத்துருக்களை நிர்வகிக்கவும்",
"System Fonts": "கணினி எழுத்துருக்கள்",
"Serif Font": "Serif எழுத்துரு",
"Sans-Serif Font": "Sans-Serif எழுத்துரு",
"Override Book Font": "புத்தக எழுத்துருவை மேலெழுதவும்",
"Font Size": "எழுத்துரு அளவு",
"Default Font Size": "இயல்புநிலை எழுத்துரு அளவு",
"Minimum Font Size": "குறைந்தபட்ச எழுத்துரு அளவு",
"Font Weight": "எழுத்துரு எடை",
"Font Family": "எழுத்துரு குடும்பம்",
"Default Font": "இயல்புநிலை எழுத்துரு",
"CJK Font": "CJK எழுத்துரு",
"Font Face": "எழுத்துரு முகம்",
"Monospace Font": "Monospace எழுத்துரு",
"Language": "மொழி",
"Interface Language": "இடைமுக மொழி",
"Translation": "மொழிபெயர்ப்பு",
"Enable Translation": "மொழிபெயர்ப்பை இயக்கவும்",
"Show Source Text": "மூல உரையைக் காட்டவும்",
"Translation Service": "மொழிபெயர்ப்பு சேவை",
"Translate To": "இதற்கு மொழிபெயர்க்கவும்",
"Override Book Layout": "புத்தக அமைப்பை மேலெழுதவும்",
"Writing Mode": "எழுதும் பயன்முறை",
"Default": "இயல்புநிலை",
"Horizontal Direction": "கிடைமட்ட திசை",
"Vertical Direction": "செங்குத்து திசை",
"RTL Direction": "RTL திசை",
"Border Frame": "எல்லை சட்டகம்",
"Double Border": "இரட்டை எல்லை",
"Border Color": "எல்லை நிறம்",
"Paragraph": "பத்தி",
"Paragraph Margin": "பத்தி விளிம்பு",
"Line Spacing": "வரி இடைவெளி",
"Word Spacing": "சொல் இடைவெளி",
"Letter Spacing": "எழுத்து இடைவெளி",
"Text Indent": "உரை உள்தள்ளல்",
"Full Justification": "முழு நியாயம்",
"Hyphenation": "இணைக்கோடு",
"Page": "பக்கம்",
"Top Margin (px)": "மேல் விளிம்பு (px)",
"Bottom Margin (px)": "கீழ் விளிம்பு (px)",
"Left Margin (px)": "இடது விளிம்பு (px)",
"Right Margin (px)": "வலது விளிம்பு (px)",
"Column Gap (%)": "நெடுவரிசை இடைவெளி (%)",
"Maximum Number of Columns": "அதிகபட்ச நெடுவரிசை எண்ணிக்கை",
"Maximum Column Height": "அதிகபட்ச நெடுவரிசை உயரம்",
"Maximum Column Width": "அதிகபட்ச நெடுவரிசை அகலம்",
"Header & Footer": "தலைப்பு மற்றும் அடிக்குறிப்பு",
"Show Header": "தலைப்பைக் காட்டவும்",
"Show Footer": "அடிக்குறிப்பைக் காட்டவும்",
"Show Remaining Time": "மீதமுள்ள நேரத்தைக் காட்டவும்",
"Show Remaining Pages": "மீதமுள்ள பக்கங்களைக் காட்டவும்",
"Show Reading Progress": "வாசிப்பு முன்னேற்றத்தைக் காட்டவும்",
"Reading Progress Style": "வாசிப்பு முன்னேற்ற பாணி",
"Page Number": "பக்க எண்",
"Percentage": "சதவீதம்",
"Apply also in Scrolled Mode": "உருட்டல் பயன்முறையிலும் பொருந்தும்",
"Screen": "திரை",
"Orientation": "திசைநிலை",
"Portrait": "நிலைமான்",
"Landscape": "கிடைமான்",
"Apply": "பொருந்தும்",
"Custom Content CSS": "தனிப்பயன் உள்ளடக்க CSS",
"Enter CSS for book content styling...": "புத்தக உள்ளடக்க வடிவமைப்பிற்கு CSS ஐ உள்ளிடவும்...",
"Custom Reader UI CSS": "தனிப்பயன் வாசிப்பான் UI CSS",
"Enter CSS for reader interface styling...": "வாசிப்பான் இடைமுக வடிவமைப்பிற்கு CSS ஐ உள்ளிடவும்...",
"Layout": "அமைப்பு",
"Color": "நிறம்",
"Behavior": "நடத்தை",
"Reset {{settings}}": "{{settings}} மீட்டமைக்கவும்",
"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": "உலகம் முழுவதும் ஒரு மேடை,\nஅதில் ஆண்களும் பெண்களும் வெறும் நடிகர்கள்;\nஅவர்களுக்கு வெளியேறுதலும் நுழைவும் உண்டு,\nஒரு மனிதன் தன் காலத்தில் பல பாத்திரங்களை வகிக்கிறான்,\nஅவனுடைய செயல்கள் ஏழு வயதுகளாக இருக்கின்றன.\n\n— வில்லியம் ஷேக்ஸ்பியர்",
"(from 'As You Like It', Act II)": "('ஆஸ் யூ லைக் இட்', இரண்டாவது அங்கத்திலிருந்து)",
"Custom Theme": "தனிப்பயன் தீம்",
"Theme Name": "தீம் பெயர்",
"Text Color": "உரை நிறம்",
"Background Color": "பின்னணி நிறம்",
"Link Color": "இணைப்பு நிறம்",
"Preview": "முன்னோட்டம்",
"Font & Layout": "எழுத்துரு மற்றும் அமைப்பு",
"Book Cover": "புத்தக அட்டை",
"More Info": "மேலும் தகவல்",
"Parallel Read": "இணை வாசிப்பு",
"Disable": "முடக்கவும்",
"Enable": "இயக்கவும்",
"KOReader Sync": "KOReader ஒத்திசைவு",
"Push Progress": "முன்னேற்றத்தை அனுப்பவும்",
"Pull Progress": "முன்னேற்றத்தை இழுக்கவும்",
"Export Annotations": "குறிப்புகளை ஏற்றுமதி செய்யவும்",
"Sort TOC by Page": "பக்கத்தின்படி TOC வரிசைப்படுத்தவும்",
"Edit": "திருத்தவும்",
"Search...": "தேடவும்...",
"Book": "புத்தகம்",
"Chapter": "அத்தியாயம்",
"Match Case": "எழுத்து பொருத்தம்",
"Match Whole Words": "முழு வார்த்தைகள் பொருத்தம்",
"Match Diacritics": "உச்சரிப்பு குறிகள் பொருத்தம்",
"TOC": "உள்ளடக்கம்",
"Sidebar": "பக்கப்பட்டி",
"Disable Translation": "மொழிபெயர்ப்பை முடக்கவும்",
"Translation Not Available": "மொழிபெயர்ப்பு கிடைக்கவில்லை",
"TTS not supported for PDF": "PDF க்கு TTS ஆதரிக்கப்படவில்லை",
"TTS not supported for this document": "இந்த ஆவணத்திற்கு TTS ஆதரிக்கப்படவில்லை",
"No Timeout": "நேர வரம்பு இல்லை",
"{{value}} minute": "{{value}} நிமிடம்",
"{{value}} minutes": "{{value}} நிமிடங்கள்",
"{{value}} hour": "{{value}} மணிநேரம்",
"{{value}} hours": "{{value}} மணிநேரங்கள்",
"Voices for {{lang}}": "{{lang}} க்கான குரல்கள்",
"Slow": "மெதுவாக",
"Fast": "வேகமாக",
"{{engine}}: {{count}} voices_one": "{{engine}}: 1 குரல்",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} குரல்கள்",
"Sign in to Sync": "ஒத்திசைக்க உள்நுழையவும்",
"Synced at {{time}}": "{{time}} இல் ஒத்திசைக்கப்பட்டது",
"Never synced": "ஒத்திசைக்கப்படவில்லை",
"Reading Progress Synced": "வாசிப்பு முன்னேற்றம் ஒத்திசைக்கப்பட்டது",
"Page {{page}} of {{total}} ({{percentage}}%)": "{{total}} இல் {{page}} பக்கம் ({{percentage}}%)",
"Current position": "தற்போதைய நிலை",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "தோராயமாக {{total}} இல் {{page}} பக்கம் ({{percentage}}%)",
"Approximately {{percentage}}%": "தோராயமாக {{percentage}}%",
"Delete Your Account?": "உங்கள் கணக்கை நீக்கவா?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "இந்த செயலை மாற்ற முடியாது. cloud இல் உள்ள உங்கள் எல்லா தரவும் நிரந்தரமாக நீக்கப்படும்.",
"Delete Permanently": "நிரந்தரமாக நீக்கவும்",
"Restore Purchase": "வாங்குதலை மீட்டெடுக்கவும்",
"Manage Subscription": "சந்தாவை நிர்வகிக்கவும்",
"Reset Password": "கடவுச்சொல் மீட்டமைக்கவும்",
"Sign Out": "வெளியேறவும்",
"Delete Account": "கணக்கை நீக்கவும்",
"Upgrade to {{plan}}": "{{plan}} க்கு மேம்படுத்தவும்",
"Complete Your Subscription": "உங்கள் சந்தாவை நிறைவு செய்யவும்",
"Coming Soon": "விரைவில் வரும்",
"Upgrade to Plus or Pro": "Plus அல்லது Pro க்கு மேம்படுத்தவும்",
"Current Plan": "தற்போதைய திட்டம்",
"Plan Limits": "திட்ட வரம்புகள்",
"Failed to delete user. Please try again later.": "பயனரை நீக்க முடியவில்லை. பின்னர் மீண்டும் முயற்சிக்கவும்.",
"Failed to create checkout session": "checkout session உருவாக்க முடியவில்லை",
"No purchases found to restore.": "மீட்டெடுக்க வாங்குதல்கள் இல்லை.",
"Failed to restore purchases.": "வாங்குதல்களை மீட்டெடுக்க முடியவில்லை.",
"Failed to manage subscription.": "சந்தாவை நிர்வகிக்க முடியவில்லை.",
"Failed to load subscription plans.": "சந்தா திட்டங்களை ஏற்ற முடியவில்லை.",
"Loading profile...": "சுயவிவரம் ஏற்றுகிறது...",
"Processing your payment...": "உங்கள் கட்டணத்தை செயல்படுத்துகிறது...",
"Please wait while we confirm your subscription.": "நாங்கள் உங்கள் சந்தாவை உறுதிசெய்யும் வரை காத்திருக்கவும்.",
"Payment Processing": "கட்டண செயலாக்கம்",
"Your payment is being processed. This usually takes a few moments.": "உங்கள் கட்டணம் செயல்படுத்தப்படுகிறது. இது பொதுவாக சில நிமிடங்கள் ஆகும்.",
"Payment Failed": "கட்டணம் தோல்வியடைந்தது",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "உங்கள் சந்தாவை செயல்படுத்த முடியவில்லை. மீண்டும் முயற்சிக்கவும் அல்லது பிரச்சனை தொடர்ந்தால் ஆதரவைத் தொடர்பு கொள்ளவும்.",
"Back to Profile": "சுயவிவரத்திற்கு திரும்பவும்",
"Subscription Successful!": "சந்தா வெற்றிகரமானது!",
"Thank you for your subscription! Your payment has been processed successfully.": "உங்கள் சந்தாவிற்கு நன்றி! உங்கள் கட்டணம் வெற்றிகரமாக செயல்படுத்தப்பட்டது.",
"Email:": "மின்னஞ்சல்:",
"Plan:": "திட்டம்:",
"Amount:": "தொகை:",
"Subscription ID:": "சந்தா ID:",
"Go to Library": "நூலகத்திற்கு செல்லவும்",
"Need help? Contact our support team at support@readest.com": "உதவி தேவையா? support@readest.com இல் எங்கள் ஆதரவு குழுவைத் தொடர்பு கொள்ளவும்",
"Free Plan": "இலவச திட்டம்",
"month": "மாதம்",
"year": "வருடம்",
"Cross-Platform Sync": "குறுக்கு தளம் ஒத்திசைவு",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "உங்கள் அனைத்து சாதனங்களிலும் உங்கள் நூலகம், முன்னேற்றம், முத்திரைகள் மற்றும் குறிப்புகளை தடையின்றி ஒத்திசைக்கவும் - உங்கள் இடத்தை மீண்டும் இழக்க வேண்டாம்.",
"Customizable Reading": "தனிப்பயனாக்கக்கூடிய வாசிப்பு",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "சரியான வாசிப்பு அனுபவத்திற்காக சரிசெய்யக்கூடிய எழுத்துருக்கள், அமைப்புகள், தீம்கள் மற்றும் மேம்பட்ட காட்சி அமைப்புகளுடன் ஒவ்வொரு விவரத்தையும் தனிப்பயனாக்கவும்.",
"AI Read Aloud": "AI உரை வாசிப்பு",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "உங்கள் புத்தகங்களை உயிர்ப்பிக்கும் இயற்கையான ஒலியுடைய AI குரல்களுடன் கைகள் இல்லாமல் வாசிப்பை அனுபவிக்கவும்.",
"AI Translations": "AI மொழிபெயர்ப்புகள்",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Google, Azure அல்லது DeepL இன் சக்தியுடன் எந்த உரையையும் உடனடியாக மொழிபெயர்க்கவும் - எந்த மொழியிலும் உள்ளடக்கத்தை புரிந்துகொள்ளவும்.",
"Community Support": "சமூக ஆதரவு",
"Connect with fellow readers and get help fast in our friendly community channels.": "சக வாசகர்களுடன் இணையுங்கள் மற்றும் எங்கள் நட்பு சமூக சேனல்களில் விரைவாக உதவி பெறுங்கள்.",
"Cloud Sync Storage": "Cloud ஒத்திசைவு சேமிப்பு",
"AI Translations (per day)": "AI மொழிபெயர்ப்புகள் (ஒரு நாளுக்கு)",
"Plus Plan": "Plus திட்டம்",
"Includes All Free Plan Benefits": "அனைத்து இலவச திட்ட நன்மைகளும் உள்ளடங்கும்",
"Unlimited AI Read Aloud Hours": "வரம்பற்ற AI வாசிப்பு மணிநேரங்கள்",
"Listen without limits—convert as much text as you like into immersive audio.": "வரம்பு இல்லாமல் கேளுங்கள் - நீங்கள் விரும்பும் அளவு உரையை அழுத்தமான ஆடியோவாக மாற்றுங்கள்.",
"More AI Translations": "மேலும் AI மொழிபெயர்ப்புகள்",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "அதிக தினசரி பயன்பாடு மற்றும் மேம்பட்ட விருப்பங்களுடன் மேம்பட்ட மொழிபெயர்ப்பு திறன்களை திறக்கவும்.",
"DeepL Pro Access": "DeepL Pro அணுகல்",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "கிடைக்கக்கூடிய மிகவும் துல்லியமான மொழிபெயர்ப்பு இயந்திரத்துடன் தினசரி 100,000 எழுத்துகள் வரை மொழிபெயர்க்கவும்.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "5 GB cloud சேமிப்புடன் உங்கள் முழு வாசிப்பு சேகரிப்பையும் பாதுகாப்பாக சேமித்து அணுகவும்.",
"Priority Support": "முன்னுரிமை ஆதரவு",
"Enjoy faster responses and dedicated assistance whenever you need help.": "உங்களுக்கு உதவி தேவைப்படும் போதெல்லாம் விரைவான பதில்கள் மற்றும் அர்ப்பணிப்புள்ள உதவியை அனுபவிக்கவும்.",
"Pro Plan": "Pro திட்டம்",
"Includes All Plus Plan Benefits": "அனைத்து Plus திட்ட நன்மைகளும் உள்ளடங்கும்",
"Early Feature Access": "முந்தைய அம்சம் அணுகல்",
"Be the first to explore new features, updates, and innovations before anyone else.": "மற்றவர்களுக்கு முன்பே புதிய அம்சங்கள், புதுப்பிப்புகள் மற்றும் கண்டுபிடிப்புகளை ஆராயும் முதல் நபராக இருங்கள்.",
"Advanced AI Tools": "மேம்பட்ட AI கருவிகள்",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "புத்திசாலித்தனமான வாசிப்பு, மொழிபெயர்ப்பு மற்றும் உள்ளடக்க கண்டுபிடிப்புக்காக சக்திவாய்ந்த AI கருவிகளைப் பயன்படுத்துங்கள்.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "கிடைக்கக்கூடிய மிகவும் துல்லியமான மொழிபெயர்ப்பு இயந்திரத்துடன் தினசரி 500,000 எழுத்துகள் வரை மொழிபெயர்க்கவும்.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "20 GB cloud சேமிப்புடன் உங்கள் முழு வாசிப்பு சேகரிப்பையும் பாதுகாப்பாக சேமித்து அணுகவும்.",
"Version {{version}}": "பதிப்பு {{version}}",
"Check Update": "புதுப்பிப்பு சரிபார்க்கவும்",
"Already the latest version": "ஏற்கனவே சமீபத்திய பதிப்பு",
"Checking for updates...": "புதுப்பிப்புகளுக்கு சரிபார்க்கிறது...",
"Error checking for updates": "புதுப்பிப்புகள் சரிபார்க்கும்போது பிழை",
"Drop to Import Books": "புத்தகங்களை இறக்குமதி செய்ய இழுத்து விடவும்",
"Terms of Service": "சேவை விதிமுறைகள்",
"Privacy Policy": "தனியுரிமைக் கொள்கை",
"Enter book title": "புத்தக தலைப்பை உள்ளிடவும்",
"Subtitle": "துணைத்தலைப்பு",
"Enter book subtitle": "புத்தக துணைத்தலைப்பை உள்ளிடவும்",
"Enter author name": "ஆசிரியர் பெயரை உள்ளிடவும்",
"Series": "தொடர்",
"Enter series name": "தொடர் பெயரை உள்ளிடவும்",
"Series Index": "தொடர் குறியீடு",
"Enter series index": "தொடர் குறியீட்டை உள்ளிடவும்",
"Total in Series": "தொடரில் மொத்தம்",
"Enter total books in series": "தொடரில் மொத்த புத்தகங்களை உள்ளிடவும்",
"Publisher": "வெளியீட்டாளர்",
"Enter publisher": "வெளியீட்டாளரை உள்ளிடவும்",
"Publication Date": "வெளியீட்டு தேதி",
"YYYY or YYYY-MM-DD": "YYYY அல்லது YYYY-MM-DD",
"Identifier": "அடையாளங்காட்டி",
"Subjects": "பாடங்கள்",
"Fiction, Science, History": "கற்பனை, அறிவியல், வரலாறு",
"Description": "விளக்கம்",
"Enter book description": "புத்தக விளக்கத்தை உள்ளிடவும்",
"Change cover image": "அட்டை படத்தை மாற்றவும்",
"Replace": "மாற்றவும்",
"Unlock cover": "அட்டையை திறக்கவும்",
"Lock cover": "அட்டையை பூட்டவும்",
"Auto-Retrieve Metadata": "Metadata ஐ தானாக பெறவும்",
"Auto-Retrieve": "தானாக பெறவும்",
"Unlock all fields": "அனைத்து புலங்களையும் திறக்கவும்",
"Unlock All": "அனைத்தையும் திறக்கவும்",
"Lock all fields": "அனைத்து புலங்களையும் பூட்டவும்",
"Lock All": "அனைத்தையும் பூட்டவும்",
"Reset": "மீட்டமைக்கவும்",
"Are you sure to delete the selected book?": "தேர்ந்தெடுக்கப்பட்ட புத்தகத்தை நீக்க உறுதியா?",
"Are you sure to delete the cloud backup of the selected book?": "தேர்ந்தெடுக்கப்பட்ட புத்தகத்தின் cloud backup ஐ நீக்க உறுதியா?",
"Are you sure to delete the local copy of the selected book?": "தேர்ந்தெடுக்கப்பட்ட புத்தகத்தின் உள்ளூர் நகலை நீக்க உறுதியா?",
"Edit Metadata": "Metadata ஐ திருத்தவும்",
"Book Details": "புத்தக விவரங்கள்",
"Unknown": "தெரியாதது",
"Remove from Cloud & Device": "Cloud மற்றும் சாதனத்திலிருந்து நீக்கவும்",
"Remove from Cloud Only": "Cloud இலிருந்து மட்டும் நீக்கவும்",
"Remove from Device Only": "சாதனத்திலிருந்து மட்டும் நீக்கவும்",
"Published": "வெளியிடப்பட்டது",
"Updated": "புதுப்பிக்கப்பட்டது",
"Added": "சேர்க்கப்பட்டது",
"File Size": "கோப்பு அளவு",
"No description available": "விளக்கம் கிடைக்கவில்லை",
"Locked": "பூட்டப்பட்டது",
"Select Metadata Source": "Metadata மூலத்தைத் தேர்வுசெய்யவும்",
"Keep manual input": "கைமுறை உள்ளீட்டைத் தக்கவைக்கவும்",
"A new version of Readest is available!": "Readest இன் புதிய பதிப்பு கிடைக்கிறது!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} கிடைக்கிறது (நிறுவப்பட்ட பதிப்பு {{currentVersion}}).",
"Download and install now?": "இப்போது பதிவிறக்கி நிறுவ வேண்டுமா?",
"Downloading {{downloaded}} of {{contentLength}}": "{{contentLength}} இல் {{downloaded}} பதிவிறக்கம்",
"Download finished": "பதிவிறக்கம் முடிந்தது",
"DOWNLOAD & INSTALL": "பதிவிறக்கம் மற்றும் நிறுவல்",
"Changelog": "மாற்ற பதிவு",
"Software Update": "மென்பொருள் புதுப்பிப்பு",
"What's New in Readest": "Readest இல் புதியது",
"Select Files": "கோப்புகளைத் தேர்வுசெய்யவும்",
"Select Image": "படத்தைத் தேர்வுசெய்யவும்",
"Select Video": "வீடியோவைத் தேர்வுசெய்யவும்",
"Select Audio": "ஆடியோவைத் தேர்வுசெய்யவும்",
"Select Fonts": "எழுத்துருக்களைத் தேர்வுசெய்யவும்",
"Storage": "சேமிப்பு",
"{{percentage}}% of Cloud Sync Space Used.": "Cloud Sync இடத்தில் {{percentage}}% பயன்படுத்தப்பட்டுள்ளது.",
"Translation Characters": "மொழிபெயர்ப்பு எழுத்துகள்",
"{{percentage}}% of Daily Translation Characters Used.": "தினசரி மொழிபெயர்ப்பு எழுத்துகளில் {{percentage}}% பயன்படுத்தப்பட்டுள்ளது.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "தினசரி மொழிபெயர்ப்பு ஒதுக்கீட்டை அடைந்துவிட்டது. AI மொழிபெயர்ப்புகளைத் தொடர்ந்து பயன்படுத்த உங்கள் திட்டத்தை மேம்படுத்தவும்.",
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
"Azure Translator": "Azure Translator",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Yandex Translate": "Yandex Translate",
"Gray": "சாம்பல்",
"Sepia": "செபியா",
"Grass": "புல்",
"Cherry": "செர்ரி",
"Sky": "வானம்",
"Solarized": "சூரியமயம்",
"Gruvbox": "Gruvbox",
"Nord": "Nord",
"Contrast": "மாறுபாடு",
"Sunset": "சூரிய அஸ்தமனம்",
"Reveal in Finder": "Finder இல் காட்டவும்",
"Reveal in File Explorer": "File Explorer இல் காட்டவும்",
"Reveal in Folder": "கோப்புறையில் காட்டவும்",
"Previous Paragraph": "முந்தைய பத்தி",
"Previous Sentence": "முந்தைய வாக்கியம்",
"Pause": "இடைநிறுத்தம்",
"Play": "இயக்கு",
"Next Sentence": "அடுத்த வாக்கியம்",
"Next Paragraph": "அடுத்த பத்தி",
"Separate Cover Page": "தனித்த அட்டை பக்கம்"
}
@@ -474,7 +474,6 @@
"Always use latest": "ใช้เวอร์ชันล่าสุดเสมอ",
"Send changes only": "ส่งการเปลี่ยนแปลงเท่านั้น",
"Receive changes only": "รับการเปลี่ยนแปลงเท่านั้น",
"Disabled": "ปิดใช้งาน",
"Checksum Method": "วิธีการตรวจสอบความถูกต้อง",
"File Content (recommended)": "เนื้อหาไฟล์ (แนะนำ)",
"File Name": "ชื่อไฟล์",
@@ -512,5 +511,12 @@
"Select Fonts": "เลือกฟอนต์",
"Supported font formats: .ttf, .otf, .woff, .woff2": "รูปแบบฟอนต์ที่รองรับ: .ttf, .otf, .woff, .woff2",
"Push Progress": "ส่งความคืบหน้า",
"Pull Progress": "ดึงความคืบหน้า"
"Pull Progress": "ดึงความคืบหน้า",
"Previous Paragraph": "ย่อหน้าก่อนหน้า",
"Previous Sentence": "ประโยคก่อนหน้า",
"Pause": "หยุดชั่วคราว",
"Play": "เล่น",
"Next Sentence": "ประโยคถัดไป",
"Next Paragraph": "ย่อหน้าถัดไป",
"Separate Cover Page": "หน้าปกแยกต่างหาก"
}
@@ -478,7 +478,6 @@
"Always use latest": "Her Zaman En Sonunu Kullan",
"Send changes only": "Sadece Değişiklikleri Gönder",
"Receive changes only": "Sadece Değişiklikleri Al",
"Disabled": "Devre Dışı",
"Checksum Method": "Kontrol Toplamı Yöntemi",
"File Content (recommended)": "Dosya İçeriği (önerilen)",
"File Name": "Dosya Adı",
@@ -516,5 +515,12 @@
"Select Fonts": "Yazı Tiplerini Seç",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Desteklenen yazı tipi formatları: .ttf, .otf, .woff, .woff2",
"Push Progress": "İlerlemeyi Gönder",
"Pull Progress": "İlerlemeyi Al"
"Pull Progress": "İlerlemeyi Al",
"Previous Paragraph": "Önceki Paragraf",
"Previous Sentence": "Önceki Cümle",
"Pause": "Duraklat",
"Play": "Oynat",
"Next Sentence": "Sonraki Cümle",
"Next Paragraph": "Sonraki Paragraf",
"Separate Cover Page": "Ayrı Kapak Sayfası"
}
@@ -486,7 +486,6 @@
"Always use latest": "Завжди використовувати найновішу",
"Send changes only": "Відправити тільки зміни",
"Receive changes only": "Отримати тільки зміни",
"Disabled": "Вимкнено",
"Checksum Method": "Метод контролю суми",
"File Content (recommended)": "Вміст файлу (рекомендується)",
"File Name": "Ім'я файлу",
@@ -524,5 +523,12 @@
"Select Fonts": "Вибрати Шрифти",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Підтримувані формати шрифтів: .ttf, .otf, .woff, .woff2",
"Push Progress": "Надіслати прогрес",
"Pull Progress": "Отримати прогрес"
"Pull Progress": "Отримати прогрес",
"Previous Paragraph": "Попередній абзац",
"Previous Sentence": "Попереднє речення",
"Pause": "Пауза",
"Play": "Відтворити",
"Next Sentence": "Наступне речення",
"Next Paragraph": "Наступний абзац",
"Separate Cover Page": "Окрема сторінка обкладинки"
}
@@ -474,7 +474,6 @@
"Always use latest": "Luôn sử dụng phiên bản mới nhất",
"Send changes only": "Chỉ gửi thay đổi",
"Receive changes only": "Chỉ nhận thay đổi",
"Disabled": "Đã tắt",
"Checksum Method": "Phương pháp kiểm tra",
"File Content (recommended)": "Nội dung tệp (được khuyến nghị)",
"File Name": "Tên tệp",
@@ -512,5 +511,12 @@
"Select Fonts": "Chọn Phông Chữ",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Định dạng phông chữ được hỗ trợ: .ttf, .otf, .woff, .woff2",
"Push Progress": "Đẩy tiến độ",
"Pull Progress": "Kéo tiến độ"
"Pull Progress": "Kéo tiến độ",
"Previous Paragraph": "Đoạn trước",
"Previous Sentence": "Câu trước",
"Pause": "Tạm dừng",
"Play": "Phát",
"Next Sentence": "Câu tiếp theo",
"Next Paragraph": "Đoạn tiếp theo",
"Separate Cover Page": "Trang Bìa Riêng"
}
@@ -474,7 +474,6 @@
"Always use latest": "始终使用最新",
"Send changes only": "仅发送更改",
"Receive changes only": "仅接收更改",
"Disabled": "已禁用",
"Checksum Method": "校验和方法",
"File Content (recommended)": "文件内容(推荐)",
"File Name": "文件名",
@@ -512,5 +511,12 @@
"Select Fonts": "选择字体",
"Supported font formats: .ttf, .otf, .woff, .woff2": "支持的字体格式:.ttf, .otf, .woff, .woff2",
"Push Progress": "推送阅读进度",
"Pull Progress": "拉取阅读进度"
"Pull Progress": "拉取阅读进度",
"Previous Paragraph": "上一段",
"Previous Sentence": "上一句",
"Pause": "暂停",
"Play": "播放",
"Next Sentence": "下一句",
"Next Paragraph": "下一段",
"Separate Cover Page": "单独封面页"
}
@@ -474,7 +474,6 @@
"Always use latest": "始終使用最新",
"Send changes only": "僅發送更改",
"Receive changes only": "僅接收更改",
"Disabled": "已禁用",
"Checksum Method": "校驗和方法",
"File Content (recommended)": "文件內容(推薦)",
"File Name": "文件名",
@@ -512,5 +511,12 @@
"Select Fonts": "選擇字型",
"Supported font formats: .ttf, .otf, .woff, .woff2": "支援的字型格式:.ttf, .otf, .woff, .woff2",
"Push Progress": "推送閱讀進度",
"Pull Progress": "拉取閱讀進度"
"Pull Progress": "拉取閱讀進度",
"Previous Paragraph": "上一段",
"Previous Sentence": "上一句",
"Pause": "暫停",
"Play": "播放",
"Next Sentence": "下一句",
"Next Paragraph": "下一段",
"Separate Cover Page": "單獨封面頁"
}
+18
View File
@@ -1,5 +1,23 @@
{
"releases": {
"0.9.78": {
"date": "2025-09-06",
"notes": [
"TTS: Added option to keep a persistent TTS bar at the bottom of the screen",
"TTS: Added previous/next sentence navigation controls in the TTS bar",
"TTS: Added media control support on iOS lock screen and AirPods",
"TTS: Fixed an issue where headings could not be highlighted for TTS",
"Sync: Fixed occasional sync failures between KOReader and Readest",
"Fonts: Grouped font styles into custom font families for easier selection",
"Reader: Improved text selection sensitivity when using a stylus",
"Reader: Books can now be properly closed using the system back button on Android",
"EPUB: Fixed parsing to correctly detect the front cover in some EPUB files",
"PDF: Added zoom level, zoom mode, and spread mode settings for PDFs",
"Reader: Bookmarks can now be edited directly in the sidebar",
"Layout: Prevented layout shift when the virtual keyboard appears on Android",
"TXT: Improved chapter detection and added support for nested table of contents in TXT files"
]
},
"0.9.76": {
"date": "2025-08-30",
"notes": [
@@ -23,6 +23,8 @@ VERSION_CODE=$((10#$MAJOR * 10000 + 10#$MINOR * 1000 + 10#$PATCH))
echo "🔢 Computed versionCode: $VERSION_CODE"
PROPERTIES_FILE="./src-tauri/gen/android/app/tauri.properties"
MANIFEST="./src-tauri/gen/android/app/src/main/AndroidManifest.xml"
PERMISSION_LINE='<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>'
if [[ ! -f "$PROPERTIES_FILE" ]]; then
echo "❌ File not found: $PROPERTIES_FILE"
@@ -36,9 +38,31 @@ mv "$tmpfile" "$PROPERTIES_FILE"
echo "✅ Updated $PROPERTIES_FILE"
# --- REMOVE PERMISSION BEFORE BUILD ---
if grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST"; then
echo "🧹 Removing REQUEST_INSTALL_PACKAGES from AndroidManifest.xml"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "/REQUEST_INSTALL_PACKAGES/d" "$MANIFEST"
else
sed -i "/REQUEST_INSTALL_PACKAGES/d" "$MANIFEST"
fi
fi
echo "🚀 Running: pnpm tauri android build"
pnpm tauri android build
# --- ADD PERMISSION BACK AFTER BUILD ---
if ! grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST"; then
echo "♻️ Restoring REQUEST_INSTALL_PACKAGES in AndroidManifest.xml"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "/android.permission.INTERNET/a\\
$PERMISSION_LINE
" "$MANIFEST"
else
sed -i "/android.permission.INTERNET/a \ $PERMISSION_LINE" "$MANIFEST"
fi
fi
source .env.google-play.local
if [[ -z "$GOOGLE_PLAY_JSON_KEY_FILE" ]]; then
echo "❌ GOOGLE_PLAY_JSON_KEY_FILE is not set"
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -88,7 +88,7 @@ describe('CFIToXPointerConverter', () => {
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[0]',
xpointer: '/body/DocFragment[2]/body/div/p[1]',
});
});
@@ -99,7 +99,7 @@ describe('CFIToXPointerConverter', () => {
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[1]',
xpointer: '/body/DocFragment[2]/body/div/p[2]',
});
});
@@ -110,7 +110,7 @@ describe('CFIToXPointerConverter', () => {
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[2]',
xpointer: '/body/DocFragment[2]/body/div/p[3]',
});
});
});
@@ -126,9 +126,9 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toEqual('/body/DocFragment[3]/body/div/p[0]/text().6');
expect(xpointer.pos0).toEqual('/body/DocFragment[3]/body/div/p[0]/text().6');
expect(xpointer.pos1).toEqual('/body/DocFragment[3]/body/div/p[1]/text().16');
expect(xpointer.xpointer).toEqual('/body/DocFragment[3]/body/div/p[1]/text().6');
expect(xpointer.pos0).toEqual('/body/DocFragment[3]/body/div/p[1]/text().6');
expect(xpointer.pos1).toEqual('/body/DocFragment[3]/body/div/p[2]/text().16');
});
it('should convert range CFI within same element', () => {
@@ -147,8 +147,8 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.pos0).toMatch(/p\[0\]/);
expect(xpointer.pos1).toMatch(/p\[2\]/);
expect(xpointer.pos0).toMatch(/p\[1\]/);
expect(xpointer.pos1).toMatch(/p\[3\]/);
});
});
@@ -163,7 +163,7 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[0\]/);
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]/);
});
it('should handle elements with IDs', () => {
@@ -172,7 +172,7 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[0\]/);
expect(xpointer.xpointer).toMatch(/\/body\/section\[2\]\/p\[1\]/);
});
it('should handle inline elements', () => {
@@ -181,7 +181,7 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[1\]/);
expect(xpointer.xpointer).toMatch(/\/body\/section\[2\]\/p\[2\]/);
});
});
@@ -212,7 +212,7 @@ describe('CFIToXPointerConverter', () => {
});
it('should convert XPointer to CFI for first element', () => {
const xpointer = '/body/DocFragment[2]/body/div/p[0]';
const xpointer = '/body/DocFragment[2]/body/div/p[1]';
const cfi = converter.xPointerToCFI(xpointer);
// Verify by converting back to XPointer
@@ -221,7 +221,7 @@ describe('CFIToXPointerConverter', () => {
});
it('should convert XPointer to CFI for second element', () => {
const xpointer = '/body/DocFragment[2]/body/div/p[1]';
const xpointer = '/body/DocFragment[2]/body/div/p[2]';
const cfi = converter.xPointerToCFI(xpointer);
const backToXPointer = converter.cfiToXPointer(cfi);
@@ -229,14 +229,14 @@ describe('CFIToXPointerConverter', () => {
});
it('should convert XPointer with text offset to CFI', () => {
const xpointer = '/body/DocFragment[2]/body/div[0]/p[0]/text().6';
const xpointer = '/body/DocFragment[2]/body/div[0]/p[1]/text().6';
const cfi = converter.xPointerToCFI(xpointer);
expect(cfi).toBe('epubcfi(/6/4!/4/2/2/1:6)');
});
it('should convert range XPointer to CFI', () => {
const pos0 = '/body/DocFragment[2]/body/div/p[0]/text().6';
const pos1 = '/body/DocFragment[2]/body/div/p[1]/text().16';
const pos0 = '/body/DocFragment[2]/body/div/p[1]/text().6';
const pos1 = '/body/DocFragment[2]/body/div/p[2]/text().16';
const cfi = converter.xPointerToCFI(pos0, pos1);
const xpointer = converter.cfiToXPointer(cfi);
@@ -269,7 +269,7 @@ describe('CFIToXPointerConverter', () => {
it('should handle CFI without spine step prefix', () => {
// Test the adjustSpineIndex method handles CFIs that don't start with /6/n!
const converter = new XCFI(simpleDoc, 3); // Use different spine index
const xpointer = '/body/DocFragment[4]/body/div/p[0]';
const xpointer = '/body/DocFragment[4]/body/div/p[1]';
const cfi = converter.xPointerToCFI(xpointer);
// Verify the spine step is correctly added/adjusted
@@ -331,7 +331,7 @@ describe('CFIToXPointerConverter', () => {
const cfi = 'epubcfi(/6/6!/4/2/2)'; // Empty p element
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[0]');
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[1]');
});
it('should handle whitespace-only text nodes', () => {
@@ -353,7 +353,7 @@ describe('CFIToXPointerConverter', () => {
const cfi = 'epubcfi(/6/6!/4/2/4)'; // Second p element
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[1]');
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[2]');
});
it('should handle deeply nested elements', () => {
@@ -379,7 +379,7 @@ describe('CFIToXPointerConverter', () => {
const cfi = 'epubcfi(/6/6!/4/2/2/2/2)'; // Deeply nested p
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/section/article/p[0]');
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/section/article/p[1]');
});
});
});
@@ -123,6 +123,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
bookKey={bookKey}
bookDoc={bookDoc}
config={config}
gridInsets={gridInsets}
contentInsets={contentInsets}
/>
{viewSettings.vertical && viewSettings.scrolled && (
@@ -46,7 +46,7 @@ import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import Spinner from '@/components/Spinner';
import ConfirmSyncDialog from './ConfirmSyncDialog';
import KOSyncConflictResolver from './KOSyncResolver';
declare global {
interface Window {
@@ -58,16 +58,18 @@ const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
config: BookConfig;
gridInsets: Insets;
contentInsets: Insets;
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
}> = ({ bookKey, bookDoc, config, gridInsets, 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 { getViewState, getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewRef = useRef<FoliateView | null>(null);
@@ -86,8 +88,7 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
const { syncState, conflictDetails, resolveConflictWithLocal, resolveConflictWithRemote } =
useKOSync(bookKey);
const { syncState, conflictDetails, resolveWithLocal, resolveWithRemote } = useKOSync(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
@@ -255,6 +256,12 @@ const FoliateViewer: React.FC<{
}
}
if (bookDoc.rendition?.layout === 'pre-paginated' && bookDoc.sections) {
bookDoc.rendition.spread = viewSettings.spreadMode;
const coverSide = bookDoc.dir === 'rtl' ? 'right' : 'left';
bookDoc.sections[0]!.pageSpread = viewSettings.keepCoverSpread ? '' : coverSide;
}
await view.open(bookDoc);
// make sure we can listen renderer events after opening book
viewRef.current = view;
@@ -290,9 +297,14 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
view.renderer.setAttribute('max-column-count', maxColumnCount);
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
if (bookDoc?.rendition?.layout === 'pre-paginated') {
view.renderer.setAttribute('zoom', viewSettings.zoomMode);
view.renderer.setAttribute('spread', viewSettings.spreadMode);
} else {
view.renderer.setAttribute('max-column-count', maxColumnCount);
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
}
applyMarginAndGap();
const lastLocation = config.location;
@@ -309,6 +321,7 @@ const FoliateViewer: React.FC<{
const applyMarginAndGap = () => {
const viewSettings = getViewSettings(bookKey)!;
const viewState = getViewState(bookKey);
const viewInsets = getViewInsets(viewSettings);
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
const showDoubleBorderHeader = showDoubleBorder && viewSettings.showHeader;
@@ -316,7 +329,11 @@ const FoliateViewer: React.FC<{
const showTopHeader = viewSettings.showHeader && !viewSettings.vertical;
const showBottomFooter = viewSettings.showFooter && !viewSettings.vertical;
const moreTopInset = showTopHeader ? Math.max(0, 44 - insets.top) : 0;
const moreBottomInset = showBottomFooter ? Math.max(0, 44 - insets.bottom) : 0;
const ttsBarHeight =
viewState?.ttsEnabled && viewSettings.showTTSBar ? 52 + gridInsets.bottom * 0.33 : 0;
const moreBottomInset = showBottomFooter
? Math.max(0, Math.max(ttsBarHeight, 44) - insets.bottom)
: Math.max(0, ttsBarHeight);
const moreRightInset = showDoubleBorderHeader ? 32 : 0;
const moreLeftInset = showDoubleBorderFooter ? 32 : 0;
const topMargin = (showTopHeader ? insets.top : viewInsets.top) + moreTopInset;
@@ -380,6 +397,8 @@ const FoliateViewer: React.FC<{
viewSettings?.doubleBorder,
viewSettings?.showHeader,
viewSettings?.showFooter,
viewSettings?.showTTSBar,
viewState?.ttsEnabled,
]);
return (
@@ -392,11 +411,11 @@ const FoliateViewer: React.FC<{
/>
{!docLoaded.current && loading && <Spinner loading={true} />}
{syncState === 'conflict' && conflictDetails && (
<ConfirmSyncDialog
<KOSyncConflictResolver
details={conflictDetails}
onConfirmLocal={resolveConflictWithLocal}
onConfirmRemote={resolveConflictWithRemote}
onClose={resolveConflictWithLocal}
onResolveWithLocal={resolveWithLocal}
onResolveWithRemote={resolveWithRemote}
onClose={resolveWithLocal}
/>
)}
</>
@@ -183,7 +183,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
/>
<div
className={clsx(
'footer-bar shadow-xs bottom-0 z-50 flex w-full flex-col',
'footer-bar shadow-xs bottom-0 z-30 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
@@ -211,7 +211,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px',
bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px',
}}
>
<div className='flex w-full items-center justify-between gap-x-6'>
@@ -265,7 +265,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px',
bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px',
}}
>
<Slider
@@ -304,10 +304,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
</div>
<div
className={clsx(
'bg-base-200 z-50 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
'bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
)}
style={{
paddingBottom: isMobile ? `${gridInsets.bottom + 16}px` : '0px',
paddingBottom: isMobile ? `${gridInsets.bottom * 0.33 + 16}px` : '0px',
}}
>
<Button
@@ -337,7 +337,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
style={{
bottom: isMobile ? `${gridInsets.bottom / 2}px` : '0px',
bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px',
}}
>
<Button
@@ -392,7 +392,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
/>
</div>
</div>
<TTSControl bookKey={bookKey} />
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
);
};
@@ -148,7 +148,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
!windowButtonVisible && 'max-w-[50%]',
)}
>
{bookTitle} {bookTitle} {bookTitle} {bookTitle}
{bookTitle}
</h2>
</div>
@@ -3,17 +3,17 @@ import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { SyncDetails } from '../hooks/useKOSync';
interface ConfirmSyncDialogProps {
interface KOSyncConflictResolverProps {
details: SyncDetails | null;
onConfirmLocal: () => void;
onConfirmRemote: () => void;
onResolveWithLocal: () => void;
onResolveWithRemote: () => void;
onClose: () => void;
}
const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
const KOSyncConflictResolver: React.FC<KOSyncConflictResolverProps> = ({
details,
onConfirmLocal,
onConfirmRemote,
onResolveWithLocal,
onResolveWithRemote,
onClose,
}) => {
const _ = useTranslation();
@@ -28,7 +28,10 @@ const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
})}
</p>
<div className='mt-4 space-y-4'>
<button className='btn h-auto w-full flex-col items-start py-2' onClick={onConfirmLocal}>
<button
className='btn h-auto w-full flex-col items-start py-2'
onClick={onResolveWithLocal}
>
<span>{_('Local Progress')}</span>
<span className='text-xs font-normal normal-case text-gray-500'>
{details.local.preview}
@@ -36,7 +39,7 @@ const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
</button>
<button
className='btn btn-primary h-auto w-full flex-col items-start py-2'
onClick={onConfirmRemote}
onClick={onResolveWithRemote}
>
<span>{_('Remote Progress')}</span>
<span className='text-xs font-normal normal-case'>{details.remote.preview}</span>
@@ -46,4 +49,4 @@ const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
);
};
export default ConfirmSyncDialog;
export default KOSyncConflictResolver;
@@ -62,7 +62,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
className={clsx(
'progressinfo absolute bottom-0 flex items-center justify-between',
'text-neutral-content font-sans text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'h-[52px] w-full',
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
style={
@@ -78,16 +78,23 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
: {
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.67}px` : 0,
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
}
}
>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
<div
className={clsx(
'flex items-center justify-center',
isVertical ? 'h-full' : 'h-[52px] w-full',
)}
>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
</div>
</div>
);
};
@@ -3,6 +3,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useEffect, Suspense, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
@@ -27,13 +28,35 @@ import { getLocale } from '@/utils/misc';
import { initDayjs } from '@/utils/time';
import ReaderContent from './ReaderContent';
/*
Z-Index Layering Guide:
---------------------------------
99 Window Border (Linux only)
• Ensures the border stays on top of all UI elements.
50 Loading Progress / Toast Notifications / Dialogs
• Includes Settings, About, Updater, and KOSync dialogs.
45 Sidebar / Notebook (Unpinned)
• Floats above the content but below global dialogs.
40 TTS Bar
• Mini controls for TTS playback on top of the TTS Control.
30 Footbar (TTS Control)
• Persistent bottom controls and the TTS icon/panel.
20 Menu / Sidebar / Notebook (Pinned)
• Docked navigation or note views.
10 Headerbar / Ribbon
• Top toolbar and ribbon elements.
0 Base Content
• Main reading area or background content.
*/
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { setLibrary } = useLibraryStore();
const { hoveredBookKey } = useReaderStore();
const { settings, setSettings } = useSettingsStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, systemUIAlwaysHidden, showSystemUI, dismissSystemUI } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -53,8 +76,14 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const handleKeyDown = (event: CustomEvent) => {
if (event.detail.keyName === 'Back') {
setSideBarVisible(false);
setNotebookVisible(false);
if (getIsSideBarVisible()) {
setSideBarVisible(false);
} else if (getIsNotebookVisible()) {
setNotebookVisible(false);
} else {
eventDispatcher.dispatch('close-reader');
router.back();
}
return true;
}
return false;
@@ -62,22 +91,23 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useEffect(() => {
if (!appService?.isAndroidApp) return;
if (isSideBarVisible || isNotebookVisible) {
acquireBackKeyInterception();
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
if (!isSideBarVisible && !isNotebookVisible) {
acquireBackKeyInterception();
return () => {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.isAndroidApp]);
useEffect(() => {
if (!appService?.isAndroidApp) return;
eventDispatcher.onSync('native-key-down', handleKeyDown);
return () => {
if (appService?.isAndroidApp) {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible, isNotebookVisible]);
}, [appService?.isAndroidApp, isSideBarVisible, isNotebookVisible]);
useEffect(() => {
if (isInitiating.current) return;
@@ -67,10 +67,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (index === 0) setSideBarBookKey(key);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const handleShowBookDetails = (event: CustomEvent) => {
const book = event.detail as Book;
setShowDetailsBook(book);
setShowDetailsBook(event.detail as Book);
return true;
};
eventDispatcher.onSync('show-book-details', handleShowBookDetails);
@@ -78,7 +80,6 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
return () => {
eventDispatcher.offSync('show-book-details', handleShowBookDetails);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -94,10 +95,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
window.addEventListener('beforeunload', handleCloseBooks);
eventDispatcher.on('beforereload', handleCloseBooks);
eventDispatcher.on('close-reader', handleCloseBooks);
eventDispatcher.on('quit-app', handleCloseBooks);
return () => {
window.removeEventListener('beforeunload', handleCloseBooks);
eventDispatcher.off('beforereload', handleCloseBooks);
eventDispatcher.off('close-reader', handleCloseBooks);
eventDispatcher.off('quit-app', handleCloseBooks);
unlistenOnCloseWindow?.then((fn) => fn());
};
@@ -6,6 +6,9 @@ import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { MdSync, MdSyncProblem } from 'react-icons/md';
import { IoMdExpand } from 'react-icons/io';
import { TbArrowAutofitWidth } from 'react-icons/tb';
import { TbColumns1, TbColumns2 } from 'react-icons/tb';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
@@ -37,14 +40,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const router = useRouter();
const { user } = useAuth();
const { envConfig, appService } = useEnv();
const { getConfig } = useBookDataStore();
const { getConfig, getBookData } = useBookDataStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const [zoomMode, setZoomMode] = useState(viewSettings!.zoomMode!);
const [spreadMode, setSpreadMode] = useState(viewSettings!.spreadMode!);
const [keepCoverSpread, setKeepCoverSpread] = useState(viewSettings!.keepCoverSpread!);
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
viewSettings!.invertImgColorInDark,
);
@@ -93,6 +100,9 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'zoomLevel', zoomLevel, true, true);
if (bookData.bookDoc?.rendition?.layout === 'pre-paginated') {
getView(bookKey)?.renderer.setAttribute('scale-factor', zoomLevel);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
@@ -102,6 +112,36 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
useEffect(() => {
if (zoomMode === viewSettings.zoomMode) return;
viewSettings.zoomMode = zoomMode;
getView(bookKey)?.renderer.setAttribute('zoom', zoomMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'zoomMode', zoomMode, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomMode]);
useEffect(() => {
if (spreadMode === viewSettings.spreadMode) return;
viewSettings.spreadMode = spreadMode;
getView(bookKey)?.renderer.setAttribute('spread', spreadMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'spreadMode', spreadMode, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spreadMode]);
useEffect(() => {
if (keepCoverSpread === viewSettings.keepCoverSpread) return;
if (!bookData?.bookDoc?.sections?.length) return;
viewSettings.keepCoverSpread = keepCoverSpread;
const coverSide = bookData.bookDoc.dir === 'rtl' ? 'right' : 'left';
bookData.bookDoc.sections[0]!.pageSpread = keepCoverSpread ? '' : coverSide;
getView(bookKey)?.renderer.setAttribute('spread', spreadMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'keepCoverSpread', keepCoverSpread, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keepCoverSpread]);
const lastSyncTime = Math.max(config?.lastSyncedAtConfig || 0, config?.lastSyncedAtNotes || 0);
return (
@@ -145,6 +185,56 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
</button>
</div>
{bookData.bookDoc?.rendition?.layout === 'pre-paginated' && (
<>
<div className={clsx('my-2 flex items-center justify-between rounded-md')}>
<button
onClick={setSpreadMode.bind(null, 'none')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'none' && 'bg-base-300/75',
)}
>
<TbColumns1 />
</button>
<button
onClick={setSpreadMode.bind(null, 'auto')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'auto' && 'bg-base-300/75',
)}
>
<TbColumns2 />
</button>
<div className='bg-base-300 mx-2 h-6 w-[1px]' />
<button
onClick={setZoomMode.bind(null, 'fit-page')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-page' && 'bg-base-300/75',
)}
>
<IoMdExpand />
</button>
<button
onClick={setZoomMode.bind(null, 'fit-width')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-width' && 'bg-base-300/75',
)}
>
<TbArrowAutofitWidth />
</button>
</div>
<MenuItem
label={_('Separate Cover Page')}
Icon={keepCoverSpread ? MdCheck : undefined}
onClick={() => setKeepCoverSpread(!keepCoverSpread)}
disabled={spreadMode === 'none'}
/>
</>
)}
<hr className='border-base-300 my-1' />
<MenuItem label={_('Font & Layout')} shortcut='Shift+F' onClick={openFontLayoutMenu} />
@@ -154,6 +244,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
shortcut='Shift+J'
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
disabled={bookData.bookDoc?.rendition?.layout === 'pre-paginated'}
/>
<hr className='border-base-300 my-1' />
@@ -1,5 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -7,6 +6,8 @@ import { TextSelection } from '@/utils/sel';
import { md5Fingerprint } from '@/utils/md5';
import { BookNote } from '@/types/book';
import useShortcuts from '@/hooks/useShortcuts';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
import TextButton from '@/components/TextButton';
interface NoteEditorProps {
onSave: (selection: TextSelection, note: string) => void;
@@ -23,110 +24,96 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
saveNotebookAnnotationDraft,
getNotebookAnnotationDraft,
} = useNotebookStore();
const editorRef = useRef<HTMLTextAreaElement>(null);
const [note, setNote] = React.useState('');
const editorRef = useRef<TextEditorRef>(null);
const [note, setNote] = useState('');
const separatorWidth = useResponsiveSize(3);
useEffect(() => {
if (editorRef.current) {
editorRef.current.focus();
}
}, [editorRef]);
useEffect(() => {
if (notebookEditAnnotation) {
setNote(notebookEditAnnotation.note);
if (editorRef.current) {
editorRef.current.value = notebookEditAnnotation.note;
editorRef.current.focus();
adjustHeight();
}
const noteText = notebookEditAnnotation.note;
setNote(noteText);
editorRef.current?.setValue(noteText);
editorRef.current?.focus();
} else if (notebookNewAnnotation) {
const noteText = getAnnotationText();
if (noteText) {
const draftNote = getNotebookAnnotationDraft(md5Fingerprint(noteText)) || '';
setNote(draftNote);
if (editorRef.current) {
editorRef.current.value = draftNote;
editorRef.current.focus();
adjustHeight();
}
editorRef.current?.setValue(draftNote);
editorRef.current?.focus();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notebookNewAnnotation, notebookEditAnnotation]);
const adjustHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = 'auto';
editorRef.current.style.height = `${editorRef.current.scrollHeight}px`;
}
};
const getAnnotationText = () => {
return notebookEditAnnotation?.text || notebookNewAnnotation?.text || '';
};
const handleOnChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight();
setNote(e.currentTarget.value);
const handleNoteChange = (value: string) => {
setNote(value);
};
const handleOnBlur = () => {
if (editorRef.current && editorRef.current.value) {
const handleBlur = () => {
const currentValue = editorRef.current?.getValue();
if (currentValue) {
const noteText = getAnnotationText();
if (noteText) {
saveNotebookAnnotationDraft(md5Fingerprint(noteText), editorRef.current.value);
saveNotebookAnnotationDraft(md5Fingerprint(noteText), currentValue);
}
}
};
const handleSaveNote = () => {
if (editorRef.current && notebookNewAnnotation) {
onSave(notebookNewAnnotation, editorRef.current.value);
} else if (editorRef.current && notebookEditAnnotation) {
notebookEditAnnotation.note = editorRef.current.value;
onEdit(notebookEditAnnotation);
const currentValue = editorRef.current?.getValue();
if (currentValue) {
if (notebookNewAnnotation) {
onSave(notebookNewAnnotation, currentValue);
} else if (notebookEditAnnotation) {
notebookEditAnnotation.note = currentValue;
onEdit(notebookEditAnnotation);
}
}
};
const handleEscape = () => {
if (notebookNewAnnotation) {
setNotebookNewAnnotation(null);
}
if (notebookEditAnnotation) {
setNotebookEditAnnotation(null);
}
};
useShortcuts({
onSaveNote: () => {
if (editorRef.current && editorRef.current.value) {
const currentValue = editorRef.current?.getValue();
if (currentValue) {
handleSaveNote();
}
},
onEscape: () => {
if (notebookNewAnnotation) {
setNotebookNewAnnotation(null);
}
if (notebookEditAnnotation) {
setNotebookEditAnnotation(null);
}
},
onEscape: handleEscape,
});
const canSave = Boolean(note.trim());
return (
<div className='content note-editor-container bg-base-100 mt-2 rounded-md p-2'>
<div className='flex w-full justify-between space-x-2'>
<div className='relative w-full'>
<textarea
className={clsx(
'note-editor textarea textarea-ghost min-h-[1em] resize-none !outline-none',
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
'content font-size-sm',
)}
dir='auto'
ref={editorRef}
value={note}
rows={1}
spellCheck={false}
onChange={handleOnChange}
onBlur={handleOnBlur}
placeholder={_('Add your notes here...')}
></textarea>
</div>
<div className='flex w-full'>
<TextEditor
ref={editorRef}
value={note}
onChange={handleNoteChange}
onBlur={handleBlur}
onSave={handleSaveNote}
onEscape={handleEscape}
placeholder={_('Add your notes here...')}
autoFocus={true}
spellCheck={false}
/>
</div>
<div className='flex items-center pt-2'>
<div
className='me-2 mt-0.5 min-h-full self-stretch rounded-xl bg-gray-300'
@@ -138,17 +125,12 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
<span className='content font-size-xs text-gray-500'>{getAnnotationText()}</span>
</div>
</div>
<div className='flex justify-end p-2' dir='ltr'>
<button
className={clsx(
'content btn btn-ghost font-size-sm hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={handleSaveNote}
>
<div className='font-size-sm pr-1 align-bottom text-blue-500'>{_('Save')}</div>
</button>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
<TextButton onClick={handleEscape}>{_('Cancel')}</TextButton>
<TextButton onClick={handleSaveNote} disabled={!canSave}>
{_('Save')}
</TextButton>
</div>
</div>
);
@@ -189,15 +189,15 @@ const Notebook: React.FC = ({}) => {
return isNotebookVisible ? (
<>
{!isNotebookPinned && (
<div className='overlay fixed inset-0 z-10 bg-black/20' onClick={handleClickOverlay} />
<div className='overlay fixed inset-0 z-[45] bg-black/20' onClick={handleClickOverlay} />
)}
<div
className={clsx(
'notebook-container bg-base-200 right-0 z-20 flex min-w-60 select-none flex-col',
'notebook-container bg-base-200 right-0 flex min-w-60 select-none flex-col',
'font-sans text-base font-normal sm:text-sm',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
isNotebookPinned ? 'z-20' : 'z-[45] shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
@@ -7,8 +7,8 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useCustomFontStore } from '@/store/customFontStore';
import { useFileSelector } from '@/hooks/useFileSelector';
import { mountCustomFont } from '@/styles/fonts';
import { parseFontName } from '@/utils/font';
import { CustomFont, mountCustomFont } from '@/styles/fonts';
import { parseFontInfo } from '@/utils/font';
import { getFilename } from '@/utils/path';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
@@ -17,6 +17,11 @@ interface CustomFontsProps {
onBack: () => void;
}
type FontFamily = {
name: string;
fonts: CustomFont[];
};
const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
@@ -62,12 +67,14 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
} else {
continue;
}
const fontName = parseFontName(await fontFile.arrayBuffer(), fontPath);
const fontInfo = parseFontInfo(await fontFile.arrayBuffer(), fontPath);
const customFont = addFont(fontPath, {
name: fontName.name,
family: fontName.family,
style: fontName.style,
name: fontInfo.name,
family: fontInfo.family,
style: fontInfo.style,
weight: fontInfo.weight,
});
console.log('Added custom font:', customFont);
if (customFont && !customFont.error) {
const loadedFont = await loadFont(envConfig, customFont.id);
mountCustomFont(document, loadedFont);
@@ -77,27 +84,25 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
});
};
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 handleDeleteFamily = (family: FontFamily) => {
for (const font of family.fonts) {
if (font) {
if (removeFont(font.id)) {
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 handleSelectFamily = (family: FontFamily) => {
if (currentDefaultFont === 'serif') {
saveViewSettings(envConfig, bookKey, 'serifFont', family.name);
} else {
saveViewSettings(envConfig, bookKey, 'sansSerifFont', family.name);
}
};
@@ -105,10 +110,29 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
setIsDeleteMode(!isDeleteMode);
};
const getAvailableFamilies = (fonts: CustomFont[]): FontFamily[] => {
const familyMap = new Map<string, string[]>();
for (const font of fonts) {
const family = font.family || font.name;
if (!familyMap.has(family)) {
familyMap.set(family, []);
}
familyMap.get(family)!.push(font.id);
}
return Array.from(familyMap.entries()).map(([family, ids]) => ({
name: family,
fonts: ids.map((id) => fonts.find((f) => f.id === id)!).filter((f): f is CustomFont => !!f),
}));
};
const availableFonts = customFonts
.filter((font) => !font.deletedAt)
.sort((a, b) => (b.downloadedAt || 0) - (a.downloadedAt || 0));
const availableFamilies = getAvailableFamilies(availableFonts);
return (
<div className='w-full'>
<div className='mb-6 flex h-8 items-center justify-between'>
@@ -157,31 +181,31 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
</div>
</div>
{availableFonts.map((font) => (
{availableFamilies.map((family) => (
<div
key={font.id}
key={family.name}
className={clsx(
'card h-12 border shadow-sm',
currentFontFamily === font.name
currentFontFamily === family.name
? 'border-primary/50 bg-primary/50'
: `border-base-200 bg-base-200 ${isDeleteMode ? '' : 'cursor-pointer'}`,
)}
onClick={!isDeleteMode ? () => handleSelectFont(font.id) : undefined}
title={font.name}
onClick={!isDeleteMode ? () => handleSelectFamily(family) : undefined}
title={family.fonts.map((f) => f.name).join('\n')}
>
<div className='card-body flex items-center justify-center p-2'>
<div
style={{
fontFamily: font.loaded ? `"${font.name}", sans-serif` : 'sans-serif',
fontFamily: `"${family.name}", sans-serif`,
fontWeight: 400,
}}
className='text-base-content line-clamp-1 break-all'
>
{font.family || font.name}
{family.name}
</div>
{isDeleteMode && (
<button
onClick={() => handleDeleteFont(font.id)}
onClick={() => handleDeleteFamily(family)}
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')}
>
@@ -104,7 +104,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
>
<div className='flex items-center gap-x-1'>
<span
className='line-clamp-1 overflow-visible break-all leading-loose'
className='line-clamp-1 break-all leading-loose'
style={{
fontFamily: onGetFontFamily(selectedOption.option, family ?? ''),
}}
@@ -74,7 +74,7 @@ const FontFace = ({
const _ = useTranslation();
return (
<div className={clsx('config-item', className)}>
<span className='min-w-10'>{label}</span>
<span className='line-clamp-2 min-w-10'>{label}</span>
<FontDropdown
family={family}
options={options.map((option) => ({ option, label: _(option) }))}
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import React from 'react';
import React, { useRef, useState } from 'react';
import { marked } from 'marked';
import { useEnv } from '@/context/EnvContext';
@@ -13,6 +13,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import useScrollToItem from '../../hooks/useScrollToItem';
import TextButton from '@/components/TextButton';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
interface BooknoteItemProps {
bookKey: string;
@@ -26,9 +28,13 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById } = useReaderStore();
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
const separatorWidth = useResponsiveSize(3);
const { text, cfi, note } = item;
const editorRef = useRef<TextEditorRef>(null);
const editorDraftRef = useRef<string>(text || '');
const [inlineEditMode, setInlineEditMode] = useState(false);
const separatorWidth = useResponsiveSize(3);
const progress = getProgress(bookKey);
const { isCurrent, viewRef } = useScrollToItem(cfi, progress);
@@ -65,6 +71,57 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
setNotebookEditAnnotation(note);
};
const editBookmark = () => {
setInlineEditMode(true);
};
const handleSaveBookmark = () => {
setInlineEditMode(false);
const config = getConfig(bookKey);
if (!config || !editorDraftRef.current) return;
const { booknotes: annotations = [] } = config;
const existingIndex = annotations.findIndex((annotation) => item.id === annotation.id);
if (existingIndex === -1) return;
annotations[existingIndex]!.updatedAt = Date.now();
annotations[existingIndex]!.text = editorDraftRef.current;
const updatedConfig = updateBooknotes(bookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
};
if (inlineEditMode) {
return (
<div
className={clsx(
'border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/55 bg-base-100',
'transition-all duration-300 ease-in-out',
)}
>
<div className='flex w-full'>
<TextEditor
className='!leading-normal'
ref={editorRef}
value={editorDraftRef.current}
onChange={(value) => (editorDraftRef.current = value)}
onSave={handleSaveBookmark}
onEscape={() => setInlineEditMode(false)}
autoFocus={true}
spellCheck={false}
/>
</div>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
<TextButton onClick={() => setInlineEditMode(false)}>{_('Cancel')}</TextButton>
<TextButton onClick={handleSaveBookmark} disabled={!editorDraftRef.current}>
{_('Save')}
</TextButton>
</div>
</div>
);
}
return (
<li
ref={viewRef}
@@ -120,7 +177,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
</div>
<div
className={clsx(
'max-h-0 overflow-hidden p-0 text-xs',
'max-h-0 overflow-hidden p-0',
'transition-[max-height] duration-300 ease-in-out',
'group-hover:max-h-8 group-hover:overflow-visible',
)}
@@ -138,46 +195,23 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
</span>
</div>
<div className='flex items-center justify-end space-x-3 p-2' dir='ltr'>
{item.note && (
<div
className={clsx(
'content settings-content cursor-pointer',
'flex items-end p-0 hover:bg-transparent',
)}
onClick={editNote.bind(null, item)}
{(item.note || item.type === 'bookmark') && (
<TextButton
onClick={item.type === 'bookmark' ? editBookmark : editNote.bind(null, item)}
variant='primary'
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
>
<div
className={clsx(
'align-bottom text-blue-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-blue-600',
)}
>
{_('Edit')}
</div>
</div>
{_('Edit')}
</TextButton>
)}
<div
className={clsx(
'content settings-content cursor-pointer',
'flex items-end p-0 hover:bg-transparent',
)}
<TextButton
onClick={deleteNote.bind(null, item)}
variant='danger'
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
>
<div
className={clsx(
'align-bottom text-red-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-red-600',
)}
>
{_('Delete')}
</div>
</div>
{_('Delete')}
</TextButton>
</div>
</div>
</div>
@@ -3,6 +3,8 @@ import React, { useEffect, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
@@ -16,11 +18,14 @@ const SidebarContent: React.FC<{
sideBarBookKey: string;
}> = ({ bookDoc, sideBarBookKey }) => {
const { safeAreaInsets } = useThemeStore();
const { setHoveredBookKey } = useReaderStore();
const { setSideBarVisible } = useSidebarStore();
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
const [fade, setFade] = useState(false);
const [targetTab, setTargetTab] = useState(activeTab);
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
useEffect(() => {
if (!sideBarBookKey) return;
@@ -32,8 +37,13 @@ const SidebarContent: React.FC<{
const handleTabChange = (tab: string) => {
setFade(true);
const timeout = setTimeout(() => {
setFade(false);
if (activeTab === tab && isMobile) {
setHoveredBookKey(sideBarBookKey);
setSideBarVisible(false);
return;
}
setTargetTab(tab);
setFade(false);
setConfig(sideBarBookKey!, config);
clearTimeout(timeout);
}, 300);
@@ -181,13 +181,19 @@ const SideBar: React.FC<{
return isSideBarVisible ? (
<>
{!isSideBarPinned && (
<div
className='overlay fixed inset-0 z-[45] bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
)}
<div
className={clsx(
'sidebar-container bg-base-200 z-20 flex min-w-60 select-none flex-col',
'sidebar-container bg-base-200 flex min-w-60 select-none flex-col',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
'transition-[padding-top] duration-300',
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
isSideBarPinned ? 'z-20' : 'z-[45] shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
@@ -266,12 +272,6 @@ const SideBar: React.FC<{
onTouchStart={handleHorizontalDragStart}
></div>
</div>
{!isSideBarPinned && (
<div
className='overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
)}
</>
) : null;
};
@@ -26,7 +26,7 @@ const TabNavigation: React.FC<{
>
<div
className={clsx(
'bg-base-300 absolute bottom-1.5 start-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'bg-base-300 absolute bottom-1.5 start-1 z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'transform transition-transform duration-300',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
@@ -36,7 +36,7 @@ const TabNavigation: React.FC<{
{tabs.map((tab) => (
<div
key={tab}
className='lg:tooltip lg:tooltip-top z-50 m-1.5 flex-1 cursor-pointer rounded-md p-2'
className='lg:tooltip lg:tooltip-top z-20 m-1.5 flex-1 cursor-pointer rounded-md p-2'
data-tip={
tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')
}
@@ -0,0 +1,100 @@
import clsx from 'clsx';
import React from 'react';
import {
MdPlayArrow,
MdOutlinePause,
MdFastRewind,
MdFastForward,
MdSkipPrevious,
MdSkipNext,
} from 'react-icons/md';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTranslation } from '@/hooks/useTranslation';
type TTSBarProps = {
bookKey: string;
isPlaying: boolean;
onTogglePlay: () => void;
onBackward: (byMark: boolean) => void;
onForward: (byMark: boolean) => void;
gridInsets: Insets;
};
const TTSBar = ({
bookKey,
isPlaying,
onTogglePlay,
onBackward,
onForward,
gridInsets,
}: TTSBarProps) => {
const _ = useTranslation();
const { appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const iconSize32 = useResponsiveSize(30);
const iconSize48 = useResponsiveSize(36);
const isVisible = hoveredBookKey !== bookKey;
return (
<div
className={clsx(
'bg-base-100 absolute bottom-0 z-40',
'inset-x-0 mx-auto flex w-full justify-center sm:w-fit',
'transition-opacity duration-300',
isVisible ? `pointer-events-auto opacity-100` : `pointer-events-none opacity-0`,
)}
style={{ paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0 }}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey('')}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey('')}
>
<div className='text-base-content flex h-[52px] items-center space-x-2 px-2'>
<button
onClick={onBackward.bind(null, false)}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Previous Paragraph')}
aria-label={_('Previous Paragraph')}
>
<MdFastRewind size={iconSize32} />
</button>
<button
onClick={onBackward.bind(null, true)}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Previous Sentence')}
aria-label={_('Previous Sentence')}
>
<MdSkipPrevious size={iconSize32} />
</button>
<button
onClick={onTogglePlay}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={isPlaying ? _('Pause') : _('Play')}
aria-label={isPlaying ? _('Pause') : _('Play')}
>
{isPlaying ? <MdOutlinePause size={iconSize48} /> : <MdPlayArrow size={iconSize48} />}
</button>
<button
onClick={onForward.bind(null, true)}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Next Sentence')}
aria-label={_('Next Sentence')}
>
<MdSkipNext size={iconSize32} />
</button>
<button
onClick={onForward.bind(null, false)}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Next Paragraph')}
aria-label={_('Next Paragraph')}
>
<MdFastForward size={iconSize32} />
</button>
</div>
</div>
);
};
export default TTSBar;
@@ -13,9 +13,11 @@ import { parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { CFI } from '@/libs/document';
import { Insets } from '@/types/misc';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
import TTSBar from './TTSBar';
const POPUP_WIDTH = 282;
const POPUP_HEIGHT = 160;
@@ -23,20 +25,23 @@ const POPUP_PADDING = 10;
interface TTSControlProps {
bookKey: string;
gridInsets: Insets;
}
const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { getBookData } = useBookDataStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showIndicator, setShowIndicator] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [showTTSBar, setShowTTSBar] = useState(() => !!viewSettings?.showTTSBar);
const [panelPosition, setPanelPosition] = useState<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
@@ -44,7 +49,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
const viewSettings = getViewSettings(bookKey);
const popupPadding = useResponsiveSize(POPUP_PADDING);
const maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
@@ -64,11 +68,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
unblockerAudioRef.current.addEventListener('play', () => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = null;
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.setActionHandler('stop', null);
navigator.mediaSession.setActionHandler('seekbackward', null);
navigator.mediaSession.setActionHandler('seekforward', null);
}
});
unblockerAudioRef.current.preload = 'auto';
@@ -123,7 +122,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const mark = (e as CustomEvent<TTSMark>).detail;
if (appService?.isMobileApp && 'mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: mark.text,
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
@@ -240,7 +239,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
}
};
const handleTogglePlay = async () => {
const handleTogglePlay = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
@@ -259,41 +258,54 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
await ttsController.start();
}
}
};
}, [isPlaying, isPaused]);
const handleBackward = async () => {
const handleBackward = useCallback(async (byMark = false) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward();
await ttsController.backward(byMark);
}
};
}, []);
const handleForward = async () => {
const handleForward = useCallback(async (byMark = false) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward();
await ttsController.forward(byMark);
}
};
}, []);
const handleStop = async (bookKey: string) => {
const handlePause = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
setIsPaused(true);
await ttsController.pause();
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (appService?.isMobile) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
};
}, []);
const handleStop = useCallback(
async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (appService?.isMobile) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
// rate range: 0.5 - 3, 1.0 is normal speed
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -363,6 +375,16 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
}
};
const handleToggleTTSBar = () => {
const viewSettings = getViewSettings(bookKey)!;
viewSettings.showTTSBar = !viewSettings.showTTSBar;
setShowTTSBar(viewSettings.showTTSBar);
if (viewSettings.showTTSBar) {
setShowPanel(false);
}
setViewSettings(bookKey, viewSettings);
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
@@ -401,6 +423,30 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
setShowPanel(false);
};
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('pause', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('stop', () => {
handlePause();
});
navigator.mediaSession.setActionHandler('seekforward', () => {
handleForward();
});
navigator.mediaSession.setActionHandler('seekbackward', () => {
handleBackward();
});
}
}, [handleTogglePlay, handlePause, handleForward, handleBackward]);
useEffect(() => {
if (!iconRef.current || !showPanel) return;
const parentElement = iconRef.current.parentElement;
@@ -430,12 +476,13 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
ref={iconRef}
className={clsx(
'absolute h-12 w-12',
viewSettings?.rtl ? 'left-6' : 'right-6',
'transition-transform duration-300',
viewSettings?.rtl ? 'left-8' : 'right-6',
!appService?.hasSafeAreaInset && 'bottom-[70px] sm:bottom-14',
)}
style={{
bottom: appService?.hasSafeAreaInset
? `${(safeAreaInsets?.bottom || 0) + 70}px`
? `${(safeAreaInsets?.bottom || 0) * 0.33 + (hoveredBookKey ? 70 : 52)}px`
: undefined,
}}
>
@@ -464,9 +511,20 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
onSelectTimeout={handleSelectTimeout}
onToogleTTSBar={handleToggleTTSBar}
/>
</Popup>
)}
{showIndicator && showTTSBar && ttsClientsInited && (
<TTSBar
bookKey={bookKey}
isPlaying={isPlaying}
onBackward={handleBackward}
onTogglePlay={handleTogglePlay}
onForward={handleForward}
gridInsets={gridInsets}
/>
)}
</>
);
};
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md';
import { TbChevronCompactDown, TbChevronCompactUp } from 'react-icons/tb';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoicesGroup } from '@/services/tts';
@@ -25,6 +26,7 @@ type TTSPanelProps = {
onSetVoice: (voice: string, lang: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (bookKey: string, value: number) => void;
onToogleTTSBar: () => void;
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
@@ -113,6 +115,7 @@ const TTSPanel = ({
onSetVoice,
onGetVoiceId,
onSelectTimeout,
onToogleTTSBar,
}: TTSPanelProps) => {
const _ = useTranslation();
const { envConfig } = useEnv();
@@ -200,7 +203,7 @@ const TTSPanel = ({
const timeoutOptions = getTTSTimeoutOptions(_);
return (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl px-4 pt-4 sm:gap-1'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
className='range'
@@ -231,23 +234,38 @@ const TTSPanel = ({
</div>
</div>
<div className='flex items-center justify-between space-x-2'>
<button onClick={onBackward} className='rounded-full p-1'>
<button
onClick={onBackward}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Previous Paragraph')}
aria-label={_('Previous Paragraph')}
>
<MdFastRewind size={iconSize32} />
</button>
<button onClick={onTogglePlay} className='rounded-full p-1'>
<button
onClick={onTogglePlay}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={isPlaying ? _('Pause') : _('Play')}
aria-label={isPlaying ? _('Pause') : _('Play')}
>
{isPlaying ? (
<MdPauseCircle size={iconSize48} className='fill-primary' />
) : (
<MdPlayCircle size={iconSize48} className='fill-primary' />
)}
</button>
<button onClick={onForward} className='rounded-full p-1'>
<button
onClick={onForward}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Next Paragraph')}
aria-label={_('Next Paragraph')}
>
<MdFastForward size={iconSize32} />
</button>
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1'
className='flex flex-col items-center justify-center rounded-full p-1 transition-transform duration-200 hover:scale-105'
onClick={(e) => e.currentTarget.focus()}
>
<MdAlarm size={iconSize32} />
@@ -293,7 +311,7 @@ const TTSPanel = ({
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='rounded-full p-1'
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
onClick={(e) => e.currentTarget.focus()}
>
<RiVoiceAiFill size={iconSize32} />
@@ -350,6 +368,15 @@ const TTSPanel = ({
</ul>
</div>
</div>
<div className='flex h-4 items-center justify-center opacity-60 transition-transform duration-200 hover:scale-105 hover:opacity-100'>
<button onClick={onToogleTTSBar} className='p-0'>
{viewSettings?.showTTSBar ? (
<TbChevronCompactUp size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
) : (
<TbChevronCompactDown size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
)}
</button>
</div>
</div>
);
};
@@ -96,12 +96,8 @@ export const useKOSync = (bookKey: string) => {
if (!remote.progress?.startsWith('/body')) return;
try {
const content = view?.renderer.getContents()[0];
const cfi = await getCFIFromXPointer(
remote.progress!,
content?.doc,
content?.index,
bookDoc,
);
const koProgress = normalizeProgressXPointer(remote.progress);
const cfi = await getCFIFromXPointer(koProgress, content?.doc, content?.index, bookDoc);
view?.goTo(cfi);
} catch (error) {
console.error('Failed to convert XPointer to CFI', error);
@@ -242,6 +238,7 @@ export const useKOSync = (bookKey: string) => {
const handlePushProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pushProgress();
pushProgress.flush();
};
const handleFlush = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
@@ -285,14 +282,14 @@ export const useKOSync = (bookKey: string) => {
}
}, [progress, syncState, settings.kosync, pushProgress]);
const resolveConflictWithLocal = () => {
const resolveWithLocal = () => {
pushProgress();
pushProgress.flush();
setSyncState('synced');
setConflictDetails(null);
};
const resolveConflictWithRemote = async () => {
const resolveWithRemote = async () => {
const view = getView(bookKey);
const remote = conflictDetails?.remote;
const book = conflictDetails?.book;
@@ -311,7 +308,7 @@ export const useKOSync = (bookKey: string) => {
errorMessage,
pushProgress,
pullProgress,
resolveConflictWithLocal,
resolveConflictWithRemote,
resolveWithLocal,
resolveWithRemote,
};
};
@@ -116,8 +116,9 @@ export const useProgressSync = (bookKey: string) => {
const view = getView(bookKey);
if (xPointer && view && bookData && bookData.bookDoc) {
const content = view.renderer.getContents()[0];
const koProgress = normalizeProgressXPointer(xPointer);
const candidateCFI = await getCFIFromXPointer(
xPointer,
koProgress,
content?.doc,
content?.index,
bookData.bookDoc,
+6 -2
View File
@@ -57,7 +57,10 @@ const MenuItem: React.FC<MenuItemProps> = ({
{!noIcon && (
<span style={{ minWidth: `${iconSize}px` }}>
{typeof Icon === 'function' ? (
<Icon className='text-base-content' size={iconSize} />
<Icon
className={disabled ? 'text-gray-400' : 'text-base-content'}
size={iconSize}
/>
) : (
Icon
)}
@@ -73,8 +76,9 @@ const MenuItem: React.FC<MenuItemProps> = ({
{shortcut && (
<kbd
className={clsx(
'border-base-300/40 bg-base-300/75 text-neutral-content hidden rounded-md border shadow-sm sm:flex',
'border-base-300/40 bg-base-300/75 hidden rounded-md border shadow-sm sm:flex',
'shrink-0 px-1.5 py-0.5 text-xs font-medium',
disabled ? 'text-gray-400' : 'text-neutral-content',
)}
>
{shortcut}
@@ -0,0 +1,58 @@
import clsx from 'clsx';
import React from 'react';
interface TextButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
variant?: 'primary' | 'secondary' | 'danger' | 'success';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit' | 'reset';
}
const TextButton: React.FC<TextButtonProps> = ({
children,
onClick,
disabled = false,
className,
variant = 'primary',
size = 'sm',
type = 'button',
}) => {
const variantClasses = {
primary: 'text-blue-500 hover:text-blue-600',
secondary: 'text-gray-500 hover:text-gray-600',
danger: 'text-red-500 hover:text-red-600',
success: 'text-green-500 hover:text-green-600',
};
const sizeClasses = {
sm: 'font-size-sm h-[1.3em] min-h-[1.3em]',
md: 'font-size-md h-[1.5em] min-h-[1.5em]',
lg: 'font-size-lg h-[1.8em] min-h-[1.8em]',
};
return (
<button
type={type}
className={clsx(
'content settings-content btn btn-ghost hover:bg-transparent',
'flex items-end p-0',
sizeClasses[size],
disabled ? 'btn-disabled !bg-opacity-0' : '',
className,
)}
onClick={onClick}
disabled={disabled}
>
<div
className={clsx('align-bottom', sizeClasses[size].split(' ')[0], variantClasses[variant])}
>
{children}
</div>
</button>
);
};
export default TextButton;
@@ -0,0 +1,143 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
interface TextEditorProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
onSave?: () => void;
onEscape?: () => void;
placeholder?: string;
className?: string;
autoFocus?: boolean;
spellCheck?: boolean;
disabled?: boolean;
maxRows?: number;
minRows?: number;
}
export interface TextEditorRef {
focus: () => void;
blur: () => void;
getValue: () => string;
setValue: (value: string) => void;
getElement: () => HTMLTextAreaElement | null;
}
const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
(
{
value,
onChange,
onBlur,
onSave,
onEscape,
placeholder,
className,
autoFocus = false,
spellCheck = false,
disabled = false,
maxRows,
minRows = 1,
},
ref,
) => {
const editorRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => ({
focus: () => {
editorRef.current?.focus();
},
blur: () => {
editorRef.current?.blur();
},
getValue: () => {
return editorRef.current?.value || '';
},
setValue: (newValue: string) => {
if (editorRef.current) {
editorRef.current.value = newValue;
adjustHeight();
}
},
getElement: () => editorRef.current,
}));
useEffect(() => {
if (autoFocus && editorRef.current) {
editorRef.current.focus();
}
}, [autoFocus]);
useEffect(() => {
if (editorRef.current) {
editorRef.current.value = value;
adjustHeight();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const adjustHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = 'auto';
const scrollHeight = editorRef.current.scrollHeight;
// Calculate max height based on maxRows
let maxHeight = Infinity;
if (maxRows) {
const lineHeight = parseInt(getComputedStyle(editorRef.current).lineHeight);
maxHeight = lineHeight * maxRows;
}
// Calculate min height based on minRows
const lineHeight = parseInt(getComputedStyle(editorRef.current).lineHeight) || 20;
const minHeight = lineHeight * minRows;
const finalHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
editorRef.current.style.height = `${finalHeight}px`;
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight();
onChange(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && onEscape) {
onEscape();
return;
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onSave) {
e.preventDefault();
onSave();
return;
}
};
return (
<textarea
ref={editorRef}
className={clsx(
'textarea textarea-ghost min-h-[1em] resize-none !outline-none',
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
'content font-size-sm',
className,
)}
dir='auto'
rows={minRows}
spellCheck={spellCheck}
disabled={disabled}
onChange={handleChange}
onBlur={onBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder || ''}
/>
);
},
);
TextEditor.displayName = 'TextEditor';
export default TextEditor;
+21 -12
View File
@@ -1,20 +1,31 @@
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { Insets } from '@/types/misc';
import { getSafeAreaInsets } from '@/utils/bridge';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
export const useSafeAreaInsets = () => {
const { appService } = useEnv();
const [updated, setUpdated] = useState(false);
const [insets, setInsets] = useState({
top: 0,
right: 0,
bottom: 0,
left: 0,
});
const [insets, setInsets] = useState({ top: 0, right: 0, bottom: 0, left: 0 });
const currentInsets = useRef(insets);
const { updateSafeAreaInsets } = useThemeStore();
const updateInsets = (insets: Insets) => {
const { top, right, bottom, left } = currentInsets.current;
if (
insets.top !== top ||
insets.right !== right ||
insets.bottom !== bottom ||
insets.left !== left
) {
currentInsets.current = insets;
setInsets(insets);
updateSafeAreaInsets(insets);
}
};
const onUpdateInsets = useCallback(() => {
if (!appService) return;
@@ -40,10 +51,9 @@ export const useSafeAreaInsets = () => {
bottom: response.bottom,
left: response.left,
};
setInsets(insets);
updateSafeAreaInsets(insets);
setUpdated(true);
updateInsets(insets);
}
setUpdated(true);
});
} else if (hasCustomProperties) {
const insets = {
@@ -52,8 +62,7 @@ export const useSafeAreaInsets = () => {
bottom: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-bottom')) || 0,
left: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-left')) || 0,
};
setInsets(insets);
updateSafeAreaInsets(insets);
updateInsets(insets);
setUpdated(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
+4 -2
View File
@@ -1,7 +1,8 @@
import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { ErrorCodes, getTranslator, getTranslators, TranslatorName } from '@/services/translators';
import { getFromCache, storeInCache, polish, UseTranslatorOptions } from '@/services/translators';
import { getFromCache, storeInCache, UseTranslatorOptions } from '@/services/translators';
import { polish, preprocess } from '@/services/translators';
import { eventDispatcher } from '@/utils/event';
import { useTranslation } from './useTranslation';
@@ -10,6 +11,7 @@ export function useTranslator({
sourceLang = 'AUTO',
targetLang = 'EN',
enablePolishing = true,
enablePreprocessing = true,
}: UseTranslatorOptions = {}) {
const _ = useTranslation();
const { token } = useAuth();
@@ -42,7 +44,7 @@ export function useTranslator({
const sourceLanguage = options?.source || sourceLang;
const targetLanguage = options?.target || targetLang;
const useCache = options?.useCache ?? false;
const textsToTranslate = input;
const textsToTranslate = enablePreprocessing ? preprocess(input) : input;
if (textsToTranslate.length === 0 || textsToTranslate.every((t) => !t?.trim())) {
return textsToTranslate;
+2
View File
@@ -57,6 +57,7 @@ export interface SectionItem {
size: number;
linear: string;
location?: Location;
pageSpread?: 'left' | 'right' | 'center' | '';
createDocument: () => Promise<Document>;
}
@@ -87,6 +88,7 @@ export interface BookDoc {
metadata: BookMetadata;
rendition?: {
layout?: 'pre-paginated' | 'reflowable';
spread?: 'auto' | 'none';
viewport?: { width: number; height: number };
};
dir: string;
+17 -3
View File
@@ -2,7 +2,14 @@ import { v4 as uuidv4 } from 'uuid';
import { SystemSettings } from '@/types/settings';
import { AppPlatform, AppService, OsPlatform } from '@/types/system';
import { FileSystem, BaseDir, DeleteAction } from '@/types/system';
import { Book, BookConfig, BookContent, BookFormat, ViewSettings } from '@/types/book';
import {
Book,
BookConfig,
BookContent,
BookFormat,
FIXED_LAYOUT_FORMATS,
ViewSettings,
} from '@/types/book';
import {
getDir,
getLocalBookFilename,
@@ -35,6 +42,7 @@ import {
DEFAULT_MOBILE_READSETTINGS,
DEFAULT_SCREEN_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS,
} from './constants';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { getOSPlatform, getTargetLang, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
@@ -503,7 +511,10 @@ export abstract class BaseAppService implements AppService {
}
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
const { globalViewSettings } = settings;
const globalViewSettings = {
...settings.globalViewSettings,
...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS : {}),
};
try {
let str = '{}';
if (await this.fs.exists(getConfigFilename(book), 'Books')) {
@@ -532,7 +543,10 @@ export abstract class BaseAppService implements AppService {
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
let serializedConfig: string;
if (settings) {
const { globalViewSettings } = settings;
const globalViewSettings = {
...settings.globalViewSettings,
...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS : {}),
};
serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
} else {
serializedConfig = JSON.stringify(config);
@@ -151,6 +151,10 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
codeLanguage: 'auto-detect',
userStylesheet: '',
userUIStylesheet: '',
zoomMode: 'fit-page',
spreadMode: 'auto',
keepCoverSpread: true,
};
export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
@@ -166,6 +170,10 @@ export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
textIndent: 2,
};
export const DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS: Partial<ViewSettings> = {
overrideColor: true,
};
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
uiLanguage: '',
@@ -187,6 +195,7 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsRate: 1.3,
ttsVoice: '',
ttsLocation: '',
showTTSBar: false,
};
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
@@ -669,6 +678,9 @@ export const TRANSLATED_LANGS = {
vi: 'Tiếng Việt',
th: 'ภาษาไทย',
bo: 'བོད་སྐད་',
bn: 'বাংলা',
ta: 'தமிழ்',
si: 'සිංහල',
'zh-CN': '简体中文',
'zh-TW': '正體中文',
};
@@ -1,4 +1,5 @@
export * from './types';
export * from './cache';
export * from './polish';
export * from './preprocess';
export * from './providers';
@@ -0,0 +1,31 @@
export type Preprocessor = (text: string) => string;
export type TextSubstitutions = Record<string, string>;
const defaultSubstitutions: TextSubstitutions = {
Cover: 'The Cover',
Dedication: 'Dedication Page',
Acknowledgements: 'The Acknowledgements',
};
export const basicSubstitute = (text: string, substitutions: TextSubstitutions): string => {
const allSubstitutions = { ...defaultSubstitutions, ...substitutions };
if (Object.keys(allSubstitutions).length === 0) {
return text;
}
if (allSubstitutions[text]) {
return allSubstitutions[text];
}
return text;
};
export function createPreprocessor(substitutions: TextSubstitutions = {}): Preprocessor {
return (text: string) => basicSubstitute(text, substitutions);
}
export function preprocess(texts: string[], substitutions: TextSubstitutions = {}): string[] {
const preprocessor = createPreprocessor(substitutions);
return texts.map(preprocessor);
}
@@ -23,6 +23,7 @@ export interface UseTranslatorOptions {
sourceLang?: string;
targetLang?: string;
enablePolishing?: boolean;
enablePreprocessing?: boolean;
}
export const ErrorCodes = {
@@ -219,6 +219,7 @@ export class TTSController extends EventTarget {
await this.initViewTTS();
this.#speak(ssml).catch((e) => this.error(e));
this.preloadNextSSML();
this.dispatchSpeakMark();
}
play() {
@@ -270,30 +271,36 @@ export class TTSController extends EventTarget {
this.state = 'stopped';
}
// goto previous paragraph
async backward() {
// goto previous mark/paragraph
async backward(byMark = false) {
await this.initViewTTS();
if (this.state === 'playing') {
await this.stop();
this.#speak(this.view.tts?.prev());
if (byMark) this.#speak(this.view.tts?.prevMark());
else this.#speak(this.view.tts?.prev());
} else {
await this.stop();
this.state = 'backward-paused';
this.view.tts?.prev(true);
if (byMark) this.view.tts?.prevMark(true);
else this.view.tts?.prev(true);
}
}
// goto next paragraph
async forward() {
// goto next mark/paragraph
async forward(byMark = false) {
await this.initViewTTS();
if (this.state === 'playing') {
await this.stop();
this.#speak(this.view.tts?.next());
this.preloadNextSSML();
if (byMark) this.#speak(this.view.tts?.nextMark());
else {
this.#speak(this.view.tts?.next());
this.preloadNextSSML();
}
} else {
await this.stop();
this.state = 'forward-paused';
this.view.tts?.next(true);
if (byMark) this.view.tts?.nextMark(true);
else this.view.tts?.next(true);
}
}
@@ -357,8 +364,8 @@ export class TTSController extends EventTarget {
return this.ttsClient.getSpeakingLang();
}
dispatchSpeakMark(mark: TTSMark) {
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark }));
dispatchSpeakMark(mark?: TTSMark) {
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark || { text: '' } }));
}
error(e: unknown) {
@@ -10,7 +10,7 @@ interface FontStoreState {
setFonts: (fonts: CustomFont[]) => void;
addFont: (
path: string,
options?: { name?: string; family?: string; style?: string },
options?: { name?: string; family?: string; style?: string; weight?: number },
) => CustomFont;
removeFont: (id: string) => boolean;
updateFont: (id: string, updates: Partial<CustomFont>) => boolean;
@@ -240,7 +240,9 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
return get()
.getAvailableFonts()
.filter((font) => font.loaded && !font.error)
.map((font) => font.name);
.map((font) => font.family || font.name)
.filter((value, index, self) => self.indexOf(value) === index)
.sort((a, b) => a.localeCompare(b));
},
getLoadedFonts: () => {
@@ -9,6 +9,7 @@ interface NotebookState {
notebookNewAnnotation: TextSelection | null;
notebookEditAnnotation: BookNote | null;
notebookAnnotationDrafts: { [key: string]: string };
getIsNotebookVisible: () => boolean;
toggleNotebook: () => void;
toggleNotebookPin: () => void;
setNotebookWidth: (width: string) => void;
@@ -27,6 +28,7 @@ export const useNotebookStore = create<NotebookState>((set, get) => ({
notebookNewAnnotation: null,
notebookEditAnnotation: null,
notebookAnnotationDrafts: {},
getIsNotebookVisible: () => get().isNotebookVisible,
setNotebookWidth: (width: string) => set({ notebookWidth: width }),
toggleNotebook: () => set((state) => ({ isNotebookVisible: !state.isNotebookVisible })),
toggleNotebookPin: () => set((state) => ({ isNotebookPinned: !state.isNotebookPinned })),
+2 -1
View File
@@ -327,7 +327,8 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
})),
getGridInsets: (key: string) => get().viewStates[key]?.gridInsets || null,
getGridInsets: (key: string) =>
get().viewStates[key]?.gridInsets || { top: 0, right: 0, bottom: 0, left: 0 },
setGridInsets: (key: string, insets: Insets | null) =>
set((state) => ({
viewStates: {
+3 -1
View File
@@ -5,6 +5,7 @@ interface SidebarState {
sideBarWidth: string;
isSideBarVisible: boolean;
isSideBarPinned: boolean;
getIsSideBarVisible: () => boolean;
setSideBarBookKey: (key: string) => void;
setSideBarWidth: (width: string) => void;
toggleSideBar: () => void;
@@ -13,11 +14,12 @@ interface SidebarState {
setSideBarPin: (pinned: boolean) => void;
}
export const useSidebarStore = create<SidebarState>((set) => ({
export const useSidebarStore = create<SidebarState>((set, get) => ({
sideBarBookKey: null,
sideBarWidth: '',
isSideBarVisible: false,
isSideBarPinned: false,
getIsSideBarVisible: () => get().isSideBarVisible,
setSideBarBookKey: (key: string) => set({ sideBarBookKey: key }),
setSideBarWidth: (width: string) => set({ sideBarWidth: width }),
toggleSideBar: () => set((state) => ({ isSideBarVisible: !state.isSideBarVisible })),
+1 -1
View File
@@ -67,7 +67,7 @@ export const useThemeStore = create<ThemeState>((set, get) => {
systemUIVisible: false,
statusBarHeight: 24,
systemUIAlwaysHidden: false,
safeAreaInsets: null,
safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
showSystemUI: () => set({ systemUIVisible: true }),
dismissSystemUI: () => set({ systemUIVisible: false }),
setStatusBarHeight: (height: number) => set({ statusBarHeight: height }),
+17 -2
View File
@@ -115,11 +115,14 @@ export const mountAdditionalFonts = (document: Document, isCJK = false) => {
});
};
export type FontStyle = 'normal' | 'italic' | 'oblique';
export interface CustomFont {
id: string;
name: string;
family?: string;
style?: string;
weight?: number;
path: string;
downloadedAt?: number;
deletedAt?: number;
@@ -171,7 +174,9 @@ export function createFontFamily(name: string): string {
export function createFontCSS(font: CustomFont): string {
const format = getFontFormat(font.path);
const cssFormat = getCSSFormatString(format);
const fontFamily = createFontFamily(font.name);
const fontFamily = createFontFamily(font.family || font.name);
const fontStyle = font.style || 'normal';
const fontWeight = font.weight || 400;
if (!font.blobUrl) {
throw new Error(`Blob URL not available for font: ${font.name}`);
}
@@ -179,6 +184,8 @@ export function createFontCSS(font: CustomFont): string {
const css = `
@font-face {
font-family: "${fontFamily}";
font-style: ${fontStyle};
font-weight: ${fontWeight};
src: url("${font.blobUrl}") format("${cssFormat}");
font-display: swap;
}
@@ -193,10 +200,18 @@ export function createCustomFont(
name?: string;
family?: string;
style?: string;
weight?: number;
},
): CustomFont {
const name = options?.name || getFontName(path);
return { id: getFontId(name), name, family: options?.family, style: options?.style, path };
return {
id: getFontId(name),
name,
family: options?.family,
style: options?.style,
weight: options?.weight,
path,
};
}
export const mountCustomFont = (document: Document, font: CustomFont) => {
+3 -3
View File
@@ -260,17 +260,17 @@ foliate-view {
.content.font-size-base {
font-size: 1em !important;
line-height: 1.5em !important;
line-height: 1.5em;
}
.content.font-size-sm {
font-size: 0.875em !important;
line-height: 1.25em !important;
line-height: 1.25em;
}
.content.font-size-xs {
font-size: 0.75em !important;
line-height: 1em !important;
line-height: 1em;
}
.writing-vertical-rl {
+6
View File
@@ -126,6 +126,11 @@ export interface BookStyle {
codeLanguage: string;
userStylesheet: string;
userUIStylesheet: string;
// fixed-layout specific
zoomMode: 'fit-page' | 'fit-width' | 'original-size' | 'custom';
spreadMode: 'auto' | 'none';
keepCoverSpread: boolean;
}
export interface BookFont {
@@ -160,6 +165,7 @@ export interface TTSConfig {
ttsRate: number;
ttsVoice: string;
ttsLocation: string;
showTTSBar: boolean;
}
export interface TranslatorConfig {
+117 -7
View File
@@ -1,3 +1,4 @@
import { FontStyle } from '@/styles/fonts';
import { getUserLang } from './misc';
function parseUnicodeString(dataView: DataView, offset: number, length: number): string {
@@ -77,6 +78,74 @@ function getLanguagePriority(platformID: number, languageID: number, userLanguag
return priority;
}
function parseOS2Weight(dataView: DataView, os2TableOffset: number): number {
// OS/2 table usWeightClass is at offset 4
return dataView.getUint16(os2TableOffset + 4, false);
}
function parseOS2Selection(dataView: DataView, os2TableOffset: number): number {
// OS/2 table fsSelection is at offset 62
return dataView.getUint16(os2TableOffset + 62, false);
}
function weightClassToCSSWeight(weightClass: number): number {
// Map OpenType weight class to CSS weight
if (weightClass >= 1 && weightClass <= 100) return 100;
if (weightClass >= 101 && weightClass <= 200) return 200;
if (weightClass >= 201 && weightClass <= 300) return 300;
if (weightClass >= 301 && weightClass <= 400) return 400;
if (weightClass >= 401 && weightClass <= 500) return 500;
if (weightClass >= 501 && weightClass <= 600) return 600;
if (weightClass >= 601 && weightClass <= 700) return 700;
if (weightClass >= 701 && weightClass <= 800) return 800;
if (weightClass >= 801 && weightClass <= 900) return 900;
return 400; // Default to normal weight
}
function inferWeightFromStyleName(styleName: string): number {
const lowerStyle = styleName.toLowerCase();
// Check for specific weight keywords
if (lowerStyle.includes('thin') || lowerStyle.includes('hairline')) return 100;
if (lowerStyle.includes('extralight') || lowerStyle.includes('ultralight')) return 200;
if (
lowerStyle.includes('light') &&
!lowerStyle.includes('extralight') &&
!lowerStyle.includes('ultralight')
)
return 300;
if (lowerStyle.includes('medium')) return 500;
if (lowerStyle.includes('semibold') || lowerStyle.includes('demibold')) return 600;
if (lowerStyle.includes('extrabold') || lowerStyle.includes('ultrabold')) return 800;
if (lowerStyle.includes('black') || lowerStyle.includes('heavy')) return 900;
if (
lowerStyle.includes('bold') &&
!lowerStyle.includes('semibold') &&
!lowerStyle.includes('extrabold') &&
!lowerStyle.includes('ultrabold')
)
return 700;
return 400; // Default to normal weight
}
function inferStyleFromName(
styleName: string,
fsSelection: number,
): 'normal' | 'italic' | 'oblique' {
const lowerStyle = styleName.toLowerCase();
// Check fsSelection flags first (bit 0 = italic, bit 9 = oblique)
if (fsSelection & 0x200) return 'oblique'; // Bit 9
if (fsSelection & 0x1) return 'italic'; // Bit 0
// Fallback to style name analysis
if (lowerStyle.includes('oblique')) return 'oblique';
if (lowerStyle.includes('italic') || lowerStyle.includes('slant')) return 'italic';
return 'normal';
}
type FontNameType = {
name: string;
platformID: number;
@@ -84,7 +153,7 @@ type FontNameType = {
priority: number;
};
export const parseFontName = (fontData: ArrayBuffer, filename: string) => {
export const parseFontInfo = (fontData: ArrayBuffer, filename: string) => {
const fallbackName = filename.replace(/\.[^/.]+$/, '');
try {
const dataView = new DataView(fontData);
@@ -94,6 +163,7 @@ export const parseFontName = (fontData: ArrayBuffer, filename: string) => {
}
const numTables = dataView.getUint16(4, false);
let nameTableOffset = 0;
let os2TableOffset = 0;
for (let i = 0; i < numTables; i++) {
const tableOffset = 12 + i * 16;
const tag = String.fromCharCode(
@@ -105,7 +175,8 @@ export const parseFontName = (fontData: ArrayBuffer, filename: string) => {
if (tag === 'name') {
nameTableOffset = dataView.getUint32(tableOffset + 8, false);
break;
} else if (tag === 'OS/2') {
os2TableOffset = dataView.getUint32(tableOffset + 8, false);
}
}
@@ -119,6 +190,8 @@ export const parseFontName = (fontData: ArrayBuffer, filename: string) => {
const userLanguage = getUserLang();
const fontFamilyNames: Array<FontNameType> = [];
const fontStyleNames: Array<FontNameType> = [];
const preferredFamilyNames: Array<FontNameType> = [];
const preferredStyleNames: Array<FontNameType> = [];
for (let i = 0; i < count; i++) {
const recordOffset = nameTableOffset + 6 + i * 12;
const platformID = dataView.getUint16(recordOffset, false);
@@ -128,7 +201,8 @@ export const parseFontName = (fontData: ArrayBuffer, filename: string) => {
const nameOffsetInTable = dataView.getUint16(recordOffset + 10, false);
// nameID 1 = Font Family name, nameID 2 = Font Subfamily name (style)
if (nameID === 1 || nameID === 2) {
// nameID 16 = Typographic Family name, nameID 17 = Typographic Subfamily name
if (nameID === 1 || nameID === 2 || nameID === 16 || nameID === 17) {
const stringStart = nameTableOffset + stringOffset + nameOffsetInTable;
let fontName = '';
@@ -153,6 +227,10 @@ export const parseFontName = (fontData: ArrayBuffer, filename: string) => {
fontFamilyNames.push(nameEntry);
} else if (nameID === 2) {
fontStyleNames.push(nameEntry);
} else if (nameID === 16) {
preferredFamilyNames.push(nameEntry);
} else if (nameID === 17) {
preferredStyleNames.push(nameEntry);
}
}
}
@@ -162,23 +240,55 @@ export const parseFontName = (fontData: ArrayBuffer, filename: string) => {
}
fontFamilyNames.sort((a, b) => b.priority - a.priority);
fontStyleNames.sort((a, b) => b.priority - a.priority);
const fontStyleName = fontStyleNames[0];
const familyName = fontFamilyNames[0]!.name;
preferredFamilyNames.sort((a, b) => b.priority - a.priority);
preferredStyleNames.sort((a, b) => b.priority - a.priority);
// Prefer typographic names if available
const familyName = (preferredFamilyNames[0] || fontFamilyNames[0])!.name;
const fontStyleName = preferredStyleNames[0] || fontStyleNames[0];
const styleName = fontStyleName?.name || '';
// Parse weight and style information
let fontWeight = 400;
let fontStyle: FontStyle = 'normal';
let fsSelection = 0;
if (os2TableOffset > 0) {
try {
const weightClass = parseOS2Weight(dataView, os2TableOffset);
fontWeight = weightClassToCSSWeight(weightClass);
fsSelection = parseOS2Selection(dataView, os2TableOffset);
} catch {
console.warn('Failed to parse OS/2 table, falling back to style name analysis');
}
}
// If OS/2 table weight is default (400) or unavailable, try to infer from style name
if (fontWeight === 400 && styleName) {
const inferredWeight = inferWeightFromStyleName(styleName);
if (inferredWeight !== 400) {
fontWeight = inferredWeight;
}
}
fontStyle = inferStyleFromName(styleName, fsSelection);
return {
name:
fontStyleName && !NO_STYLE_LANGUAGE_IDS.has(fontStyleName.languageID)
? `${familyName} ${styleName}`
: familyName,
family: familyName,
style: styleName,
weight: fontWeight,
style: fontStyle,
};
} catch (error) {
console.warn(`Failed to parse font: ${error}`);
return {
name: fallbackName,
family: fallbackName,
style: '',
weight: 400,
style: 'normal' as FontStyle,
};
}
};
+3
View File
@@ -43,6 +43,9 @@ export const normalizeToFullLang = (langCode: string): string => {
sl: 'sl-SI',
sk: 'sk-SK',
bo: 'bo-CN',
bn: 'bn-BD',
ta: 'ta-IN',
si: 'si-LK',
zh: 'zh-Hans',
'zh-cn': 'zh-Hans',
'zh-tw': 'zh-Hant',
+1 -1
View File
@@ -81,7 +81,7 @@ export const navigateToLibrary = (
queryParams?: string,
navOptions?: { scroll?: boolean },
) => {
router.push(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions);
router.replace(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions);
};
export const redirectToLibrary = () => {
+29 -18
View File
@@ -13,6 +13,7 @@ interface Chapter {
title: string;
content: string;
text: string;
isVolume: boolean;
}
interface Txt2EpubOptions {
@@ -112,8 +113,8 @@ export class TxtToEpubConverter {
String.raw`(?:^|\n)\s*` +
'(' +
[
String.raw`第[零〇一二三四五六七八九十0-9][零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封])(?:[:、  \(\)0-9]*[^\n-]{0,24})(?!\S)`,
String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论)(?:[:  ][^\n-]{0,24})?(?!\S)`,
String.raw`第[零〇一二三四五六七八九十0-9][零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封本册部话])(?:[:、  \(\)0-9]*[^\n-]{0,24})(?!\S)`,
String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记)(?:[:  ][^\n-]{0,24})?(?!\S)`,
].join('|') +
')',
'gu',
@@ -197,7 +198,7 @@ export class TxtToEpubConverter {
const formattedSegment = formatSegment(chunks.join('\n'));
const title = `${chapters.length + 1}`;
const content = `<h2>${title}</h2><p>${formattedSegment}</p>`;
chapters.push({ title, content, text: chunks.join('\n') });
chapters.push({ title, content, text: chunks.join('\n'), isVolume: false });
}
continue;
}
@@ -208,7 +209,7 @@ export class TxtToEpubConverter {
let isVolume = false;
if (language === 'zh') {
isVolume = /第[零〇一二三四五六七八九十百千万0-9]+/.test(title);
isVolume = /第[零〇一二三四五六七八九十百千万0-9]+(卷|本|册|部)/.test(title);
} else {
isVolume = /\b(Part|Volume|Book)\b/i.test(title);
}
@@ -219,6 +220,7 @@ export class TxtToEpubConverter {
title: escapeXml(title),
content: `${headTitle}<p>${formattedSegment}</p>`,
text: content,
isVolume: isVolume,
});
}
@@ -233,6 +235,7 @@ export class TxtToEpubConverter {
title: escapeXml(segmentTitle),
content: `<h3></h3><p>${formattedSegment}</p>`,
text: initialContent,
isVolume: false,
});
}
chapters.push(...segmentChapters);
@@ -261,20 +264,28 @@ export class TxtToEpubConverter {
await zipWriter.add('META-INF/container.xml', new TextReader(containerXml), zipWriteOptions);
// Create navigation points for TOC
const navPoints = chapters
.map((chapter, index) => {
const id = `chapter${index + 1}`;
const playOrder = index + 1;
return `
<navPoint id="navPoint-${id}" playOrder="${playOrder}">
<navLabel>
<text>${chapter.title}</text>
</navLabel>
<content src="./OEBPS/${id}.xhtml" />
</navPoint>
`.trim();
})
.join('\n');
let isNested = false;
let navPoints = ``;
for (let i = 0; i < chapters.length; i++) {
const id = `chapter${i + 1}`;
const playOrder = i + 1;
if (chapters[i]!.isVolume && isNested) {
navPoints += `</navPoint>\n`;
isNested = !isNested;
}
navPoints +=
`<navPoint id="navPoint-${id}" playOrder="${playOrder}">\n` +
`<navLabel><text>${chapters[i]!.title}</text></navLabel>\n` +
`<content src="./OEBPS/${id}.xhtml" />\n`;
if (chapters[i]!.isVolume && !isNested) {
isNested = !isNested;
} else {
navPoints += `</navPoint>\n`;
}
}
if (isNested) {
navPoints += `</navPoint>`;
}
// Add NCX file (table of contents)
const tocNcx = `<?xml version="1.0" encoding="UTF-8"?>
+5 -1
View File
@@ -30,7 +30,11 @@ export const walkTextNodes = (root: HTMLElement, rejectTags: string[] = []): HTM
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
return true;
}
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === 'SPAN') {
if (
node.nodeType === Node.ELEMENT_NODE &&
(node as HTMLElement).tagName === 'SPAN' &&
node.textContent?.trim()
) {
return true;
}
return false;
+6 -4
View File
@@ -35,6 +35,8 @@ export class XCFI {
// Convert to 0-based: (step - 2) / 2 = 0, 1, 2, 3, ...
return Math.floor((spineStep - 2) / 2);
} else if (cfiOrXPath.startsWith('/body/DocFragment[')) {
// Note that all indices in XPointer/XPath are 1-based
// but the text() offsets are 0-based
const match = cfiOrXPath.match(/DocFragment\[(\d+)\]/);
if (match) {
return parseInt(match[1]!, 10) - 1;
@@ -224,10 +226,10 @@ export class XCFI {
let index: number;
if (segmentWithIndexMatch) {
// Format: tag[index]
// Format: tag[index] (1-based index)
const [, tag, indexStr] = segmentWithIndexMatch;
tagName = tag!;
index = parseInt(indexStr!, 10);
index = Math.max(0, parseInt(indexStr!, 10) - 1);
} else if (segmentWithoutIndexMatch) {
// Format: tag (implicit index 0)
const [, tag] = segmentWithoutIndexMatch;
@@ -379,7 +381,7 @@ export class XCFI {
if (totalSameTagSiblings === 1) {
pathParts.unshift(tagName);
} else {
pathParts.unshift(`${tagName}[${siblingIndex}]`);
pathParts.unshift(`${tagName}[${siblingIndex + 1}]`); // Convert to 1-based index for XPointer
}
current = parent;
}
@@ -545,7 +547,7 @@ export const getXPointerFromCFI = async (
// This has neglectable effect on position accuracy as the XPointer still point to the correct element
// while offset within the text node is usually ignored by pagination.
export const normalizeProgressXPointer = (xpointer: string): string => {
const tailingTextRange = /\/text\(\)\.\d+$/;
const tailingTextRange = /\/text\(\).*$/;
if (xpointer.match(tailingTextRange)) {
xpointer = xpointer.replace(tailingTextRange, '');
}
+1 -1
View File
@@ -5,7 +5,7 @@ compatibility_flags = ["nodejs_compat"]
[observability]
enabled = true
head_sampling_rate = 1
head_sampling_rate = 0.01
[assets]
directory = ".open-next/assets"