├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md └── workflows │ ├── deb.yml │ ├── eslint.yml │ └── snap.yml ├── .gitignore ├── .gitmodules ├── COPYING ├── README.md ├── com.github.johnfactotum.Foliate.json ├── data ├── com.github.johnfactotum.Foliate-symbolic.svg ├── com.github.johnfactotum.Foliate.desktop.in ├── com.github.johnfactotum.Foliate.gschema.xml ├── com.github.johnfactotum.Foliate.metainfo.xml.in ├── com.github.johnfactotum.Foliate.svg ├── gschemas.compiled ├── meson.build └── screenshots │ ├── about.png │ ├── annotations.png │ ├── dark.png │ ├── footnote.png │ ├── lookup.png │ ├── screenshot.png │ └── vertical.png ├── debian ├── changelog ├── control ├── copyright ├── lintian-override ├── rules ├── source │ └── format ├── upstream │ └── metadata └── watch ├── docs ├── faq.md └── troubleshooting.md ├── eslint.config.js ├── meson.build ├── meson_options.txt ├── package-lock.json ├── package.json ├── po ├── LINGUAS ├── POTFILES ├── ar.po ├── com.github.johnfactotum.Foliate.pot ├── cs.po ├── de.po ├── el.po ├── es.po ├── eu.po ├── fa_IR.po ├── fr.po ├── gl.po ├── he.po ├── hi.po ├── hr.po ├── hu.po ├── id.po ├── ie.po ├── it.po ├── ja.po ├── ko.po ├── meson.build ├── nb.po ├── nl.po ├── nn.po ├── oc.po ├── pt_BR.po ├── ru.po ├── sr.po ├── sv.po ├── tr.po ├── uk.po ├── zh_CN.po └── zh_TW.po ├── snapcraft.yaml └── src ├── annotations.js ├── app.js ├── book-info.js ├── book-viewer.js ├── common └── widgets.js ├── data.js ├── format.js ├── generate-gresource.js ├── gresource.xml ├── icons └── hicolor │ └── scalable │ └── actions │ ├── bookmark-filled-symbolic.svg │ ├── funnel-symbolic.svg │ ├── library-symbolic.svg │ ├── pan-down-symbolic.svg │ ├── pin-symbolic.svg │ ├── speedometer-symbolic.svg │ ├── stop-sign-symbolic.svg │ ├── tag-symbolic.svg │ └── text-squiggly-symbolic.svg ├── image-viewer.js ├── library.js ├── main.js ├── meson.build ├── navbar.js ├── opds ├── main.html └── main.js ├── reader ├── markup.js ├── reader.html └── reader.js ├── search.js ├── selection-tools.js ├── selection-tools ├── common.css ├── translate.html ├── wikipedia.html └── wiktionary.html ├── speech.js ├── themes.js ├── toc.js ├── tts.js ├── ui ├── annotation-popover.ui ├── annotation-row.ui ├── book-image.ui ├── book-item.ui ├── book-row.ui ├── book-viewer.ui ├── bookmark-row.ui ├── export-dialog.ui ├── image-viewer.ui ├── import-dialog.ui ├── library-view.ui ├── library.ui ├── media-overlay-box.ui ├── navbar.ui ├── selection-popover.ui ├── tts-box.ui └── view-preferences-window.ui ├── utils.js └── webview.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: johnfactotum 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Version:** 27 | - Foliate version: 28 | - OS/Distribution and version: [e.g. Ubuntu 18.04] 29 | - Desktop environment: [e.g. GNOME 3.36] 30 | - Installation method: [e.g. Flatpak] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Question:** 11 | A clear and concise statement of your question. 12 | 13 | **Version:** 14 | - Foliate version: 15 | - OS/Distribution and version: [e.g. Ubuntu 18.04] 16 | - Desktop environment: [e.g. GNOME 3.36] 17 | - Installation method: [e.g. Flatpak] 18 | -------------------------------------------------------------------------------- /.github/workflows/deb.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Build Debian Package 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ gtk4 ] 10 | pull_request: 11 | branches: [ gtk4 ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-24.04 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v2 27 | with: 28 | submodules: 'true' 29 | 30 | - run: sudo apt-get update 31 | - run: sudo apt-get install build-essential debhelper meson gettext pkg-config libglib2.0-dev gjs appstream libgjs-dev desktop-file-utils 32 | - run: dpkg-buildpackage -us -uc -nc 33 | - run: mv ../*.deb . 34 | - uses: actions/upload-artifact@v4 35 | with: 36 | name: Debian Package 37 | path: "*.deb" 38 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # ESLint is a tool for identifying and reporting on patterns 6 | # found in ECMAScript/JavaScript code. 7 | # More details at https://github.com/eslint/eslint 8 | # and https://eslint.org 9 | 10 | name: ESLint 11 | 12 | on: 13 | push: 14 | branches: [ "gtk4" ] 15 | pull_request: 16 | # The branches below must be a subset of the branches above 17 | branches: [ "gtk4" ] 18 | schedule: 19 | - cron: '38 14 * * 6' 20 | 21 | jobs: 22 | eslint: 23 | name: Run eslint scanning 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | security-events: write 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Install ESLint 34 | run: | 35 | npm install 36 | npm install @microsoft/eslint-formatter-sarif@3.0.0 37 | 38 | - name: Run ESLint 39 | run: npx eslint . 40 | --format @microsoft/eslint-formatter-sarif 41 | --output-file eslint-results.sarif 42 | continue-on-error: true 43 | 44 | - name: Upload analysis results to GitHub 45 | uses: github/codeql-action/upload-sarif@v2 46 | with: 47 | sarif_file: eslint-results.sarif 48 | wait-for-processing: true 49 | -------------------------------------------------------------------------------- /.github/workflows/snap.yml: -------------------------------------------------------------------------------- 1 | name: Build test Foliate Snap 2 | on: 3 | push: 4 | branches: 5 | - gtk4 6 | workflow_dispatch: 7 | 8 | jobs: 9 | snap: 10 | name: Build snap 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Change Source 15 | run: yq 'del(.parts.foliate.source-tag) | .parts.foliate.source = "."' snapcraft.yaml 16 | - uses: canonical/action-build@v1 17 | id: snapcraft 18 | - uses: actions/upload-artifact@v4 19 | if: ${{ github.event_name == 'release' }} #uploads the snap only if it's a release 20 | with: 21 | name: amber-snap 22 | path: ${{ steps.snapcraft.outputs.snap }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | build/ 3 | .flatpak-builder/ 4 | .mo 5 | __pycache__/ 6 | node_modules/ 7 | 8 | #snapcraft specific ignores 9 | /parts/ 10 | /stage/ 11 | /prime/ 12 | 13 | *.snap 14 | 15 | .snapcraft 16 | __pycache__ 17 | *.pyc 18 | *_source.tar.bz2 19 | snap/.snapcraft 20 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/foliate-js"] 2 | path = src/foliate-js 3 | url = https://github.com/johnfactotum/foliate-js 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | # Foliate 5 | 6 | Read books in style. 7 | 8 | ![Screenshot](data/screenshots/screenshot.png) 9 | 10 | ## Installation 11 | 12 | ### Run Time Dependencies 13 | 14 | - `gjs` (>= 1.76) 15 | - `gtk4` (>= 4.12) 16 | - `libadwaita` (>= 1.7; `gir1.2-adw-1` in Debian-based distros) 17 | - `webkitgtk-6.0` (`webkitgtk6.0` in Fedora; `gir1.2-webkit-6.0` in Debian-based distros) 18 | 19 | #### Optional Dependencies 20 | 21 | To enable auto-hyphenation, you will need to install hyphenation rules, e.g., `hyphen-en` for English, `hyphen-fr` for French, etc. (which strictly speaking are optional dependencies for WebkitGTK, not Foliate itself). 22 | 23 | For text-to-speech support, install `speech-dispatcher` and output modules such as `espeak-ng`. 24 | 25 | If installed, `tracker` (>= 3; `gir1.2-tracker-3.0` in Debian-based distros) and `tracker-miners` can be used to track the locations of files. 26 | 27 | ### Obtaining the Source 28 | 29 | The repo uses git submodules. Before running or installing, make sure you clone the whole thing with `--recurse-submodules`: 30 | 31 | ``` 32 | git clone --recurse-submodules https://github.com/johnfactotum/foliate.git 33 | ``` 34 | 35 | Or download the tarball (the `.tar.xz` file) from the [Releases](https://github.com/johnfactotum/foliate/releases) page. 36 | 37 | ### Run without Building or Installing 38 | 39 | It's possible to run directly from the source tree without building or installing. Simply run 40 | 41 | ``` 42 | gjs -m src/main.js 43 | ``` 44 | 45 | This can be useful if you just want to quickly try out Foliate or test a change. 46 | 47 | But note that this will run it without using GSettings, so settings will not be saved. To solve this, you can compile the schema by running 48 | 49 | ``` 50 | glib-compile-schemas data 51 | ``` 52 | 53 | Then you can set the schema directory when running the app: 54 | 55 | ``` 56 | GSETTINGS_SCHEMA_DIR=data gjs -m src/main.js 57 | ``` 58 | 59 | ### Building and Installing from Source 60 | 61 | The following dependencies are required for building: 62 | 63 | - `meson` (>= 0.59) 64 | - `pkg-config` 65 | - `gettext` 66 | 67 | To install, run the following commands: 68 | 69 | ``` 70 | meson setup build 71 | sudo ninja -C build install 72 | ``` 73 | 74 | To uninstall, run 75 | 76 | ``` 77 | sudo ninja -C build uninstall 78 | ``` 79 | 80 | #### Installing to a Local Directory 81 | 82 | By default Meson installs to `/usr/local`. You can install without root permissions by choosing a local prefix, such as `$PWD/run`: 83 | 84 | ``` 85 | meson setup build --prefix $PWD/run 86 | ninja -C build install 87 | ``` 88 | 89 | You can then run it with 90 | 91 | ``` 92 | GSETTINGS_SCHEMA_DIR=run/share/glib-2.0/schemas ./run/bin/foliate 93 | ``` 94 | 95 | ### Flatpak 96 | 97 | Foliate is available on [Flathub](https://flathub.org/apps/details/com.github.johnfactotum.Foliate). 98 | 99 | For developement with Flatpak, use [GNOME Builder](https://wiki.gnome.org/Apps/Builder) to open and run the project. 100 | 101 | ### Snap 102 | 103 | Foliate is available on the [Snap Store](https://snapcraft.io/foliate). To install: 104 | 105 | ``` 106 | sudo snap install foliate 107 | ``` 108 | 109 | ## Screenshots 110 | 111 | ![Dark mode](data/screenshots/dark.png) 112 | 113 | ![Wikipedia lookup](data/screenshots/lookup.png) 114 | 115 | ![Book metadata](data/screenshots/about.png) 116 | 117 | ![Annotations](data/screenshots/annotations.png) 118 | 119 | ![Popup footnote](data/screenshots/footnote.png) 120 | 121 | ![Vertical writing](data/screenshots/vertical.png) 122 | 123 | ## License 124 | 125 | This program is free software: you can redistribute it and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 126 | 127 | The following JavaScript libraries are bundled in this software: 128 | 129 | - [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed. 130 | - [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license. 131 | - [fflate](https://github.com/101arrowz/fflate), which is MIT licensed. 132 | - [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0. 133 | 134 | --- 135 | 136 | Buy Me A Coffee 137 | -------------------------------------------------------------------------------- /com.github.johnfactotum.Foliate.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id" : "com.github.johnfactotum.Foliate", 3 | "runtime" : "org.gnome.Sdk", 4 | "runtime-version" : "48", 5 | "sdk" : "org.gnome.Sdk", 6 | "command" : "foliate", 7 | "finish-args" : [ 8 | "--share=network", 9 | "--share=ipc", 10 | "--socket=fallback-x11", 11 | "--device=dri", 12 | "--socket=wayland", 13 | "--filesystem=xdg-run/speech-dispatcher:ro", 14 | "--add-policy=Tracker3.dbus:org.freedesktop.Tracker3.Miner.Files=tracker:Documents" 15 | ], 16 | "modules" : [ 17 | { 18 | "name" : "foliate", 19 | "buildsystem" : "meson", 20 | "sources" : [ 21 | { 22 | "type" : "git", 23 | "branch": "gtk4", 24 | "url": "https://github.com/johnfactotum/foliate.git" 25 | } 26 | ] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /data/com.github.johnfactotum.Foliate-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /data/com.github.johnfactotum.Foliate.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | # Translators: Do NOT translate! The is the application name! 3 | Name=Foliate 4 | GenericName=E-Book Viewer 5 | Comment=Read e-books in style 6 | Categories=Office;Viewer; 7 | MimeType=application/epub+zip;application/x-mobipocket-ebook;application/vnd.amazon.mobi8-ebook;application/x-fictionbook+xml;application/x-zip-compressed-fb2;application/vnd.comicbook+zip;x-scheme-handler/opds; 8 | Exec=foliate %U 9 | Icon=com.github.johnfactotum.Foliate 10 | Terminal=false 11 | Type=Application 12 | # Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 13 | Keywords=Ebook;Book;EPUB;Viewer;Reader; 14 | # Translators: Do NOT translate or transliterate this text (these are enum types)! 15 | X-Purism-FormFactor=Workstation;Mobile; 16 | StartupNotify=true 17 | -------------------------------------------------------------------------------- /data/com.github.johnfactotum.Foliate.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 0 9 | 10 | 11 | 12 | 14 | 15 | 1200 16 | 17 | 18 | 750 19 | 20 | 21 | false 22 | 23 | 24 | false 25 | 26 | 27 | 28 | 30 | 31 | 'grid' 32 | 33 | 34 | true 35 | 36 | 37 | 256 38 | 39 | 40 | 41 | 43 | 44 | 45 | 46 | false 47 | 48 | 49 | 'yellow' 50 | 51 | 52 | '' 53 | 54 | 55 | 56 | 58 | 59 | 1 60 | 61 | 62 | 1.5 63 | 64 | 65 | true 66 | 67 | 68 | true 69 | 70 | 71 | 0.06 72 | 73 | 74 | 720 75 | 76 | 77 | 1440 78 | 79 | 80 | 2 81 | 82 | 83 | false 84 | 85 | 86 | true 87 | 88 | 89 | false 90 | 91 | 92 | 'default' 93 | 94 | 95 | false 96 | 97 | 98 | false 99 | 100 | 101 | 102 | 104 | 105 | 'Serif' 106 | 107 | 108 | 'Sans' 109 | 110 | 111 | 'Monospace' 112 | 113 | 114 | 0 115 | 116 | 117 | 16 118 | 119 | 120 | 0 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /data/com.github.johnfactotum.Foliate.metainfo.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.github.johnfactotum.Foliate 4 | CC0-1.0 5 | GPL-3.0-or-later 6 | Foliate 7 | Read e-books in style 8 | com.github.johnfactotum.Foliate 9 | 10 |

Discover a new chapter in reading with Foliate, the modern e-book reader tailored for GNOME. Immerse yourself in a distraction-free interface, with customization features designed to match your unique preferences.

11 |

Features include:

12 | 22 |
23 | John Factotum 24 | 25 | John Factotum 26 | 27 | com.github.johnfactotum.Foliate.desktop 28 | https://johnfactotum.github.io/foliate/ 29 | https://github.com/johnfactotum/foliate 30 | https://github.com/johnfactotum/foliate/issues 31 | https://github.com/johnfactotum/foliate/tree/gtk4/po 32 | https://github.com/johnfactotum/foliate/blob/gtk4/docs/faq.md 33 | https://www.buymeacoffee.com/johnfactotum 34 | 35 | 36 | https://raw.githubusercontent.com/johnfactotum/foliate/gtk4/data/screenshots/screenshot.png 37 | 38 | 39 | https://raw.githubusercontent.com/johnfactotum/foliate/gtk4/data/screenshots/dark.png 40 | 41 | 42 | https://raw.githubusercontent.com/johnfactotum/foliate/gtk4/data/screenshots/lookup.png 43 | 44 | 45 | https://raw.githubusercontent.com/johnfactotum/foliate/gtk4/data/screenshots/annotations.png 46 | 47 | 48 | https://raw.githubusercontent.com/johnfactotum/foliate/gtk4/data/screenshots/vertical.png 49 | 50 | 51 | 52 | 53 | https://github.com/johnfactotum/foliate/releases/tag/3.3.0 54 | 55 |
    56 |
  • Updated for GNOME 48
  • 57 |
  • Added support for mouse forward/backward buttons
  • 58 |
  • Improved text wrapping in headings
  • 59 |
  • Improved default link style
  • 60 |
61 |
62 |
63 | 64 | https://github.com/johnfactotum/foliate/releases/tag/3.2.1 65 | 66 |
    67 |
  • Fixed tables not displayed in FB2 books
  • 68 |
  • Updated translations
  • 69 |
70 |
71 |
72 | 73 | https://github.com/johnfactotum/foliate/releases/tag/3.2.0 74 | 75 |

Various improvments and fixes, including a revamped translation tool, better focus handling, and better PDF rendering, now faster and no longer blurry.

76 |
77 |
78 | 79 | https://github.com/johnfactotum/foliate/releases/tag/3.1.1 80 | 81 |
    82 |
  • Fixed end of chapter cut off when chapter starts with page break
  • 83 |
  • Fixed incorrect text wrapping in tables
  • 84 |
  • Fixed a performance issue with OPDS catalogs
  • 85 |
86 |
87 |
88 | 89 | https://github.com/johnfactotum/foliate/releases/tag/3.1.0 90 | 91 |
    92 |
  • Added support for OPDS catalogs, now with support for OPDS 2.0
  • 93 |
  • Added option to override publisher font
  • 94 |
  • Added option to reduce animation
  • 95 |
  • Added support for JPEG XL in CBZ
  • 96 |
  • Fixed getting file from Tracker in Flatpak
  • 97 |
  • Fixed parsing of non-year-only first-century and BCE dates
  • 98 |
99 |
100 |
101 | 102 | https://github.com/johnfactotum/foliate/releases/tag/3.0.1 103 | 104 | 105 | https://github.com/johnfactotum/foliate/releases/tag/3.0.0 106 | 107 |

Foliate has been rewritten from scratch with a new e-book rendering library and the latest platform libraries, GTK 4 and Libadwaita, with refreshed UI and improved performance.

108 |
109 |
110 |
111 | 112 | 113 | mobile 114 | 115 | 116 | #7bf1d9 117 | #0b6275 118 | 119 |
120 | -------------------------------------------------------------------------------- /data/gschemas.compiled: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnfactotum/foliate/7dadc21aed46cb6a6cbd594ba0cf55650579ed87/data/gschemas.compiled -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | desktop_file = i18n.merge_file( 2 | input: 'com.github.johnfactotum.Foliate.desktop.in', 3 | output: 'com.github.johnfactotum.Foliate.desktop', 4 | type: 'desktop', 5 | po_dir: '../po', 6 | install: true, 7 | install_dir: join_paths(get_option('datadir'), 'applications') 8 | ) 9 | 10 | desktop_utils = find_program('desktop-file-validate', required: false) 11 | if desktop_utils.found() 12 | test('Validate desktop file', desktop_utils, 13 | args: [desktop_file] 14 | ) 15 | endif 16 | 17 | appstream_file = i18n.merge_file( 18 | input: 'com.github.johnfactotum.Foliate.metainfo.xml.in', 19 | output: 'com.github.johnfactotum.Foliate.metainfo.xml', 20 | po_dir: '../po', 21 | install: true, 22 | install_dir: join_paths(get_option('datadir'), 'metainfo') 23 | ) 24 | 25 | appstreamcli = find_program('appstreamcli', required: false) 26 | if appstreamcli.found() 27 | test('Validate appstream file', appstreamcli, 28 | args: ['validate', '--no-net', appstream_file] 29 | ) 30 | endif 31 | 32 | install_data('com.github.johnfactotum.Foliate.gschema.xml', 33 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 34 | ) 35 | 36 | compile_schemas = find_program('glib-compile-schemas', required: false) 37 | if compile_schemas.found() 38 | test('Validate schema file', compile_schemas, 39 | args: ['--strict', '--dry-run', meson.current_source_dir()] 40 | ) 41 | endif 42 | 43 | install_data('com.github.johnfactotum.Foliate.svg', 44 | install_dir: join_paths(get_option('datadir'), 'icons/hicolor/scalable/apps') 45 | ) 46 | install_data('com.github.johnfactotum.Foliate-symbolic.svg', 47 | install_dir: join_paths(get_option('datadir'), 'icons/hicolor/symbolic/apps') 48 | ) 49 | -------------------------------------------------------------------------------- /data/screenshots/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnfactotum/foliate/7dadc21aed46cb6a6cbd594ba0cf55650579ed87/data/screenshots/about.png -------------------------------------------------------------------------------- /data/screenshots/annotations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnfactotum/foliate/7dadc21aed46cb6a6cbd594ba0cf55650579ed87/data/screenshots/annotations.png -------------------------------------------------------------------------------- /data/screenshots/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnfactotum/foliate/7dadc21aed46cb6a6cbd594ba0cf55650579ed87/data/screenshots/dark.png -------------------------------------------------------------------------------- /data/screenshots/footnote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnfactotum/foliate/7dadc21aed46cb6a6cbd594ba0cf55650579ed87/data/screenshots/footnote.png -------------------------------------------------------------------------------- /data/screenshots/lookup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnfactotum/foliate/7dadc21aed46cb6a6cbd594ba0cf55650579ed87/data/screenshots/lookup.png -------------------------------------------------------------------------------- /data/screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnfactotum/foliate/7dadc21aed46cb6a6cbd594ba0cf55650579ed87/data/screenshots/screenshot.png -------------------------------------------------------------------------------- /data/screenshots/vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnfactotum/foliate/7dadc21aed46cb6a6cbd594ba0cf55650579ed87/data/screenshots/vertical.png -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | foliate (3.3.0) bionic; urgency=medium 2 | 3 | * New version 4 | 5 | -- John Factotum <50942278+johnfactotum@users.noreply.github.com> Sun, 30 Jul 2023 08:08:08 +0800 6 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: foliate 2 | Section: gnome 3 | Priority: optional 4 | Maintainer: John Factotum <50942278+johnfactotum@users.noreply.github.com> 5 | Build-Depends: debhelper-compat(=13), 6 | gettext, 7 | meson (>= 0.59), 8 | pkg-config, 9 | libglib2.0-dev (>= 2.54), 10 | libgjs-dev, 11 | desktop-file-utils, 12 | appstream 13 | Standards-Version: 4.5.0 14 | Rules-Requires-Root: no 15 | Homepage: https://johnfactotum.github.io/foliate/ 16 | Vcs-Browser: https://github.com/johnfactotum/foliate 17 | Vcs-Git: https://github.com/johnfactotum/foliate.git 18 | 19 | Package: foliate 20 | Architecture: all 21 | Depends: ${misc:Depends}, 22 | gjs (>= 1.72), 23 | gir1.2-gtk-4.0 (>= 4.10), 24 | gir1.2-adw-1 (>= 1.7), 25 | gir1.2-webkit-6.0 26 | Suggests: gir1.2-tracker-3.0 27 | Description: Read e-books in style 28 | Supported formats: 29 | EPUB (.epub) 30 | Kindle (.azw, .azw3) and Mobipocket (.mobi) 31 | FictionBook 2 (.fb2, .fb2.zip) 32 | Comic Book Archive (.cbz) 33 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: foliate 3 | Upstream-Contact: John Factotum <50942278+johnfactotum@users.noreply.github.com> 4 | Source: https://github.com/johnfactotum/foliate 5 | 6 | Files: * 7 | Copyright: John Factotum 8 | License: GPL-3.0+ 9 | 10 | Files: src/foliate-js/* 11 | Copyright: John Factotum 12 | License: Expat 13 | 14 | Files: src/foliate-js/fflate.js 15 | Copyright: 2020 Arjun Barrett 16 | License: Expat 17 | 18 | Files: src/foliate-js/zip.js 19 | Copyright: Gildas Lormeau 20 | License: BSD-3-clause 21 | 22 | License: GPL-3.0+ 23 | This program is free software: you can redistribute it and/or modify 24 | it under the terms of the GNU General Public License as published by 25 | the Free Software Foundation, either version 3 of the License, or 26 | (at your option) any later version. 27 | . 28 | This package is distributed in the hope that it will be useful, 29 | but WITHOUT ANY WARRANTY; without even the implied warranty of 30 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 31 | GNU General Public License for more details. 32 | . 33 | You should have received a copy of the GNU General Public License 34 | along with this program. If not, see . 35 | . 36 | On Debian systems, the complete text of the GNU General 37 | Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". 38 | 39 | License: Expat 40 | Permission is hereby granted, free of charge, to any person obtaining 41 | a copy of this software and associated documentation files (the 42 | "Software"), to deal in the Software without restriction, including 43 | without limitation the rights to use, copy, modify, merge, publish, 44 | distribute, sublicense, and/or sell copies of the Software, and to 45 | permit persons to whom the Software is furnished to do so, subject to 46 | the following conditions: 47 | . 48 | The above copyright notice and this permission notice shall be 49 | included in all copies or substantial portions of the Software. 50 | . 51 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 52 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 53 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 54 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 55 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 56 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 57 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 58 | SOFTWARE. 59 | -------------------------------------------------------------------------------- /debian/lintian-override: -------------------------------------------------------------------------------- 1 | foliate: no-manual-page 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | export DEB_BUILD_MAINT_OPTIONS = hardening=+all 4 | 5 | %: 6 | dh $@ 7 | 8 | override_dh_auto_configure: 9 | dh_auto_configure -- -Dcheck_runtime_deps=false 10 | 11 | override_dh_auto_install: 12 | dh_auto_install 13 | find ./debian -type f -name "LICENSE" -delete 14 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /debian/upstream/metadata: -------------------------------------------------------------------------------- 1 | Bug-Database: https://github.com/johnfactotum/foliate/issues 2 | Bug-Submit: https://github.com/johnfactotum/foliate/issues/new 3 | Repository: https://github.com/johnfactotum/foliate.git 4 | Repository-Browse: https://github.com/johnfactotum/foliate 5 | Security-Contact: 50942278+johnfactotum@users.noreply.github.com 6 | -------------------------------------------------------------------------------- /debian/watch: -------------------------------------------------------------------------------- 1 | version=4 2 | opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/foliate-$1\.tar\.gz/ \ 3 | https://github.com/johnfactotum/foliate/tags .*/?(\d\.\d.\d)\.tar\.gz 4 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## General 4 | 5 | ### Something isn't working! What can I do? 6 | 7 | See [troubleshooting](troubleshooting.md). 8 | 9 | ## Reading 10 | 11 | ### What are "locations"? 12 | 13 | In Foliate, a book is divided into locations. Each location is 1500 bytes long. This gives you a rough "page count" that is (mostly) independent from the size of the viewport. 14 | 15 | Locations are not exact. If you want to reference locations in a book, you should use the *identifiers* provided by Foliate, which are standard [EPUB Canonical Fragment Identifiers (CFI)](https://w3c.github.io/epub-specs/epub33/epubcfi/). 16 | 17 | In 1.x and 2.x versions of Foliate, locations were calculated with a entirely different (slower but more precise) method and they are not compatible with the current version. 18 | 19 | ### How are reading time estimates calculated? 20 | 21 | Currently, it simply uses the number of locations — basically, a character count — as a rough estimate. It isn't based on your page turning speed. 22 | 23 | ### How to use text-to-speech? 24 | 25 | Foliate supports text-to-speech with speech-dispatcher, so make sure `speech-dispatcher` and output modules such as `espeak-ng` are installed on your system. 26 | 27 | To use it, click on the Narration button (the one with a headphones icon) on the navbar (which is available by hovering or tapping on the footer area). Note that if the book has embedded audio ([EPUB Media Overlays](https://www.w3.org/TR/epub/#sec-media-overlays)), the Narration button would show controls for the embedded media, and TTS would not be available in that case. 28 | 29 | Alternatively you can select some text and choose Speak from Here from the selection menu. Though you still need to use the Narration button if you want to stop the speech output. 30 | 31 | The default voice may sound somewhat robotic. You can use [Pied](https://pied.mikeasoft.com/) (a frontend for configuring [Piper](https://github.com/rhasspy/piper)) to change that to a more natural sounding voice. See [this](https://askubuntu.com/a/1526192/124466) for more details. 32 | 33 | ### How to use custom themes? 34 | 35 | Themes are defined as JSON files. Here is an example theme: 36 | 37 | ```json 38 | { 39 | "label": "Ghostly Mist", 40 | "light": { 41 | "fg": "#999999", 42 | "bg": "#cccccc", 43 | "link": "#666666" 44 | }, 45 | "dark": { 46 | "fg": "#666666", 47 | "bg": "#333333", 48 | "link": "#777777" 49 | } 50 | } 51 | ``` 52 | 53 | To install themes, you need to put them in `/home/user/.config/com.github.johnfactotum.Foliate/themes/`. 54 | 55 | When using Flatpak, the files should be placed in `~/.var/app/com.github.johnfactotum.Foliate/config/com.github.johnfactotum.Foliate/themes/`. 56 | 57 | When using Snap, the files should be placed in `~/snap/foliate/current/.config/com.github.johnfactotum.Foliate/themes/`. 58 | 59 | ### Can I set my own custom CSS styles? 60 | 61 | You can create a user stylesheet file at `/home/user/.config/com.github.johnfactotum.Foliate/user-stylesheet.css`. If you're using Flatpak, the location should be `~/.var/app/com.github.johnfactotum.Foliate/config/com.github.johnfactotum.Foliate/user-stylesheet.css`. Note that Foliate needs to be restarted for changes to take effect. 62 | 63 | Tip: you can use the [`:lang()`](https://developer.mozilla.org/en-US/docs/Web/CSS/:lang) selector to apply different styles for books in different languages. 64 | 65 | ## Bookmarks & Annotations 66 | 67 | ### How are notes and bookmarks stored? 68 | 69 | Your reading progress, bookmarks, and annotations are saved in `~/.local/share/com.github.johnfactotum.Foliate`. 70 | 71 | When using Flatpak, they are placed in `~/.var/app/com.github.johnfactotum.Foliate/data/com.github.johnfactotum.Foliate`. 72 | 73 | When using Snap, they are placed in `~/snap/foliate/current/.local/share/com.github.johnfactotum.Foliate`. 74 | 75 | The data for each book is stored in a JSON file named after the book's identifier. If you'd like to sync or backup your progress and notes, simply copy these files and everything should just work™. 76 | 77 | Inside the JSON file, the structure looks like this: 78 | 79 | ```javascript 80 | { 81 | "lastLocation": "epubcfi(/6/12!/4/2/2/2/1:0)", // your reading progress 82 | "annotations": [ 83 | { 84 | // EPUB CFI of the highlighted text 85 | "value": "epubcfi(/6/12!/4/2/2/2,/1:0,/1:286)", 86 | // highlight color 87 | "color": "aqua", 88 | // the highlighted text 89 | "text": "Good sense is, of all things among men, the most equally distributed; for every one thinks himself so abundantly provided with it, that those even who are the most difficult to satisfy in everything else, do not usually desire a larger measure of this quality than they already possess.", 90 | // ... and your note 91 | "note": "Very droll, René." 92 | }, 93 | // ... 94 | ], 95 | "bookmarks": [ /* bookmarks are stored here */ ], 96 | "metadata": { /* the book's metadata */ } 97 | } 98 | ``` 99 | 100 | The `epubcfi(...)` parts are [EPUB Canonical Fragment Identifiers (CFI)](https://w3c.github.io/epub-specs/epub33/epubcfi/), which is the "standardized method for referencing arbitrary content within an EPUB® Publication." 101 | 102 | ### How are identifiers generated? 103 | 104 | For formats or books without unique identifiers, Foliate will generate one with the prefix `foliate:`, plus the MD5 hash of the file. To speed things up, it only uses up to the first 10000000 bytes of the file. You can run `head -c 10000000 $YOUR_FILE_HERE | md5sum` to get the same hash. 105 | 106 | ## Security 107 | 108 | ### Is Foliate secure? 109 | 110 | EPUB files are HTML files packaged in a Zip file. They can contain JavaScript and other potentially unsafe content. 111 | 112 | Currently, JavaScript and external resources are blocked in Foliate. For additional safeguard against potential vulnerabilities it is recommended to run Foliate in a sandboxed environment, for example, by using the Flatpak package. 113 | 114 | In 1.x and 2.x versions of Foliate, JavaScript could be optionally enabled. Do NOT do this if you're using these versions as it is highly insecure. 115 | 116 | ### Why does it require these Flatpak permissions? 117 | 118 | - It requires network access (`--share=network`) for online dictionary, encyclopedia, and translation tools. 119 | - It requires `--filesystem=xdg-run/speech-dispatcher:ro` in order to connect to the speech-dispatcher server on the host. 120 | - It requires `--add-policy=Tracker3.dbus:org.freedesktop.Tracker3.Miner.Files=tracker:Documents` in order to access the [Tracker](https://tracker.gnome.org/) database on the host. This allows Foliate to get the locations of files when opening books from the library view. 121 | 122 | The permissions listed above are all optional. If you don't need the functionalities provided by these permissions, you should consider overriding them with the `flatpak` command or with tools like [Flatseal](https://github.com/tchx84/flatseal). 123 | 124 | ## For Publishers and Developers 125 | 126 | ### Developer Tools 127 | 128 | WeKit's Developer Tools can be accessed by going to the primary menu > Inspector, or by pressing F12. It's recommended that you detach the Developer Tools panel to a separate window; otherwise shortcuts set on the viewer window will interfere with key presses in Developer Tools. 129 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## I changed the font, color, and spacing settings, but it's not working for some books 4 | 5 | Foliate tries to respect the publisher's stylesheet. It's hard to strike a balance between user control and publisher control, and overriding the book's styles can result in unexpted breakages. Ultimately, it is up to the publishers to not hardcode styles unecessarily. 6 | 7 | To work around this issue, you can [add your own custom styles](https://github.com/johnfactotum/foliate/blob/gtk4/docs/faq.md#can-i-set-my-own-custom-css-styles). 8 | 9 | ## The dictionary/Wikipedia/translation tool doesn't work 10 | 11 | - These tools require network access to online third party services. 12 | - The language metadata needs to be correctly marked up in the book. 13 | - Dictionary and Wikipedia lookup rely on Wikimedia's REST APIs and they often have trouble extracting content from wikitext (a notoriously difficult task). 14 | 15 | ## Text-to-speech doesn't work 16 | 17 | You need to install `speech-dispatcher` and output modules such as `espeak-ng`. To test your Speech Dispatcher configuration, use the `spd-say` command. 18 | 19 | If you're using Flatpak or Snap, you need to have Speech Dispatcher >= 0.11.4 installed on the host system, and it must be configured with socket activation support . To check this, make sure the file `/usr/lib/systemd/user/speech-dispatcher.socket` exists. Contact the maintainers of your distro if `speech-dispatcher` doesn't support socket activation on your system. 20 | 21 | To work around the issue when Speech Dispatcher doesn't support socket activation, you can have the command `speech-dispatcher --timeout 0` run on startup. This will keep Speech Dispatcher running at all times. 22 | 23 | ## I deleted a book on my disk, but it's still showing up in Foliate's library 24 | 25 | Foliate doesn't keep track of your files. So it can't know whether they are deleted or not. For now, you have to manually remove the book in Foliate after deleting the file. 26 | 27 | Conversely, removing a book in Foliate will not delete the file. 28 | 29 | ## It can't open books. It hangs/crashes/shows a blank page... 30 | 31 | ### ... and I'm using Nvidia GPU 32 | 33 | WebKitGTK, the library Foliate uses to render books is known to have problems with Nvidia. To fix this: 34 | 1. Make sure to your system is up-to-date. 35 | 2. Try setting the environment variable `WEBKIT_DISABLE_DMABUF_RENDERER=1`. This will temporarily fix [bug 261874](https://bugs.webkit.org/show_bug.cgi?id=261874). If you're using Flatpak, you can add environment variables with [Flatseal](https://flathub.org/apps/com.github.tchx84.Flatseal). 36 | 37 | ### ... and I'm using Flatpak 38 | 39 | The issue could be mixed locales, which Flatpak can't handle. To fix this, set the environment variable `LC_ALL=en_US.UTF-8`. You can add environment variables with [Flatseal](https://flathub.org/apps/com.github.tchx84.Flatseal). 40 | 41 | ### ... and I'm using Snap 42 | 43 | The issue could be [#1102](https://github.com/johnfactotum/foliate/issues/1102). To fix this, run the following command: 44 | 45 | ```sh 46 | sudo /usr/lib/snapd/snap-discard-ns foliate 47 | ``` 48 | 49 | ## I'm still having issues 50 | 51 | Please [file a bug report](https://github.com/johnfactotum/foliate/issues/new/choose). Don't be concerned about whether your issue is already reported or not. It's better to have duplicate reports of the same bug than having different bugs in the same issue thread. 52 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | 4 | export default [js.configs.recommended, { ignores: ['src/foliate-js'] }, { 5 | languageOptions: { 6 | globals: { 7 | ...globals.browser, 8 | imports: 'readonly', 9 | pkg: 'readonly', 10 | }, 11 | }, 12 | linterOptions: { 13 | reportUnusedDisableDirectives: true, 14 | }, 15 | rules: { 16 | semi: ['error', 'never'], 17 | indent: ['warn', 4, { flatTernaryExpressions: true, SwitchCase: 1 }], 18 | quotes: ['warn', 'single', { avoidEscape: true }], 19 | 'comma-dangle': ['warn', 'always-multiline'], 20 | 'no-trailing-spaces': 'warn', 21 | 'no-unused-vars': 'warn', 22 | 'no-console': ['warn', { allow: ['debug', 'warn', 'error', 'assert'] }], 23 | 'no-constant-condition': ['error', { checkLoops: false }], 24 | 'no-empty': ['error', { allowEmptyCatch: true }], 25 | }, 26 | }] 27 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('com.github.johnfactotum.Foliate', 2 | version: '3.3.0', 3 | meson_version: '>= 0.59.0', 4 | ) 5 | 6 | gnome = import('gnome') 7 | i18n = import('i18n') 8 | 9 | gjs = dependency('gjs-1.0', version: '>= 1.76') 10 | 11 | if get_option('check_runtime_deps') 12 | dependency('gtk4', version: '>= 4.12') 13 | dependency('libadwaita-1', version: '>= 1.7') 14 | dependency('webkitgtk-6.0', version: '>= 2.40.1') 15 | endif 16 | 17 | subdir('data') 18 | subdir('src') 19 | subdir('po') 20 | 21 | gnome.post_install( 22 | glib_compile_schemas: true, 23 | gtk_update_icon_cache: true, 24 | update_desktop_database: true, 25 | ) 26 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option('check_runtime_deps', type: 'boolean', description: 'Check run-time dependencies') 2 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foliate", 3 | "version": "3.1.1", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "foliate", 9 | "version": "3.1.1", 10 | "license": "GPL-3.0-or-later", 11 | "devDependencies": { 12 | "@eslint/js": "^9.9.1", 13 | "globals": "^15.9.0" 14 | } 15 | }, 16 | "node_modules/@eslint/js": { 17 | "version": "9.10.0", 18 | "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.10.0.tgz", 19 | "integrity": "sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==", 20 | "dev": true, 21 | "license": "MIT", 22 | "engines": { 23 | "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 24 | } 25 | }, 26 | "node_modules/globals": { 27 | "version": "15.9.0", 28 | "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", 29 | "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", 30 | "dev": true, 31 | "license": "MIT", 32 | "engines": { 33 | "node": ">=18" 34 | }, 35 | "funding": { 36 | "url": "https://github.com/sponsors/sindresorhus" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foliate", 3 | "version": "3.1.1", 4 | "description": "Read e-books in style", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/johnfactotum/foliate.git" 8 | }, 9 | "author": "John Factotum", 10 | "license": "GPL-3.0-or-later", 11 | "bugs": { 12 | "url": "https://github.com/johnfactotum/foliate/issues" 13 | }, 14 | "homepage": "https://johnfactotum.github.io/foliate/", 15 | "type": "module", 16 | "devDependencies": { 17 | "@eslint/js": "^9.9.1", 18 | "globals": "^15.9.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | ar 2 | cs 3 | de 4 | el 5 | es 6 | eu 7 | fr 8 | gl 9 | he 10 | hi 11 | hr 12 | hu 13 | id 14 | ie 15 | it 16 | ja 17 | ko 18 | nb 19 | nl 20 | nn 21 | oc 22 | pt_BR 23 | ru 24 | sr 25 | sv 26 | tr 27 | uk 28 | zh_CN 29 | zh_TW 30 | fa_IR 31 | -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | src/annotations.js 2 | src/app.js 3 | src/book-info.js 4 | src/book-viewer.js 5 | src/format.js 6 | src/library.js 7 | src/main.js 8 | src/navbar.js 9 | src/selection-tools.js 10 | src/themes.js 11 | src/tts.js 12 | src/utils.js 13 | src/ui/annotation-popover.ui 14 | src/ui/annotation-row.ui 15 | src/ui/book-image.ui 16 | src/ui/book-item.ui 17 | src/ui/book-row.ui 18 | src/ui/book-viewer.ui 19 | src/ui/bookmark-row.ui 20 | src/ui/export-dialog.ui 21 | src/ui/image-viewer.ui 22 | src/ui/import-dialog.ui 23 | src/ui/library.ui 24 | src/ui/library-view.ui 25 | src/ui/navbar.ui 26 | src/ui/selection-popover.ui 27 | src/ui/tts-box.ui 28 | src/ui/view-preferences-window.ui 29 | data/com.github.johnfactotum.Foliate.desktop.in 30 | data/com.github.johnfactotum.Foliate.metainfo.xml.in 31 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(meson.project_name(), preset: 'glib') 2 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: foliate 2 | grade: stable 3 | adopt-info: foliate 4 | license: GPL-3.0+ 5 | base: core24 6 | confinement: strict 7 | compression: lzo 8 | platforms: 9 | amd64: 10 | arm64: 11 | armhf: 12 | layout: 13 | /usr/lib/$CRAFT_ARCH_TRIPLET/webkitgtk-6.0: 14 | bind: $SNAP/webkitgtk-platform/usr/lib/$CRAFT_ARCH_TRIPLET/webkitgtk-6.0 15 | slots: 16 | foliate: 17 | interface: dbus 18 | bus: session 19 | name: com.github.johnfactotum.Foliate 20 | plugs: 21 | webkitgtk-6-gnome-2404: 22 | interface: content 23 | target: $SNAP/webkitgtk-platform 24 | default-provider: webkitgtk-6-gnome-2404 25 | apps: 26 | foliate: 27 | command: usr/bin/foliate 28 | extensions: 29 | - gnome 30 | plugs: 31 | - home 32 | - removable-media 33 | - network 34 | - network-status 35 | - unity7 36 | - audio-playback 37 | desktop: usr/share/applications/com.github.johnfactotum.Foliate.desktop 38 | common-id: com.github.johnfactotum.Foliate 39 | environment: 40 | SPEECHD_ADDRESS: "unix_socket:/run/user/$SNAP_UID/speech-dispatcher/speechd.sock" 41 | LD_LIBRARY_PATH: $SNAP/webkitgtk-platform/usr/lib:$SNAP/webkitgtk-platform/usr/lib/$CRAFT_ARCH_TRIPLET:$LD_LIBRARY_PATH 42 | GI_TYPELIB_PATH: $SNAP/gnome-platform/usr/lib/$CRAFT_ARCH_TRIPLET/girepository-1.0:$SNAP/gnome-platform/usr/lib/$CRAFT_ARCH_TRIPLET/gjs/girepository-1.0:$SNAP/webkitgtk-platform/usr/lib/$CRAFT_ARCH_TRIPLET/girepository-1.0 43 | parts: 44 | foliate: 45 | plugin: meson 46 | source: https://github.com/johnfactotum/foliate.git 47 | source-tag: '3.2.1' 48 | build-environment: 49 | - PKG_CONFIG_PATH: /snap/webkitgtk-6-gnome-2404-sdk/current/usr/lib/$CRAFT_ARCH_TRIPLET/pkgconfig:$PKG_CONFIG_PATH 50 | meson-parameters: 51 | - --prefix=/snap/foliate/current/usr 52 | override-pull: | 53 | craftctl default 54 | sed -i '1c#!/snap/foliate/current/gnome-platform/usr/bin/gjs -m' src/main.js 55 | build-packages: 56 | - xmlstarlet 57 | build-snaps: 58 | - webkitgtk-6-gnome-2404-sdk/latest/stable 59 | parse-info: 60 | - usr/share/metainfo/com.github.johnfactotum.Foliate.metainfo.xml 61 | organize: 62 | snap/foliate/current/usr: usr 63 | deps: 64 | plugin: nil 65 | stage-packages: 66 | - libspeechd2 # probably necessary, need testing 67 | prime: 68 | - usr/lib/*/libspeechd.so.* 69 | 70 | -------------------------------------------------------------------------------- /src/book-info.js: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk' 2 | import Adw from 'gi://Adw' 3 | import Pango from 'gi://Pango' 4 | import { gettext as _ } from 'gettext' 5 | import * as utils from './utils.js' 6 | import * as format from './format.js' 7 | 8 | export const formatLanguageMap = x => { 9 | if (!x) return '' 10 | if (typeof x === 'string') return x 11 | const keys = Object.keys(x) 12 | return /*x[format.matchLocales(keys)[0]] ??*/ x[keys[0]] 13 | } 14 | 15 | const formatContributors = contributors => Array.isArray(contributors) 16 | ? format.list(contributors.map(contributor => 17 | typeof contributor === 'string' ? contributor 18 | : formatLanguageMap(contributor?.name))) 19 | : typeof contributors === 'string' ? contributors 20 | : formatLanguageMap(contributors?.name) 21 | 22 | export const formatAuthors = metadata => metadata?.author 23 | ? formatContributors(metadata.author) 24 | : metadata?.creator // compat with previous versions 25 | ?? '' 26 | 27 | const makePropertyBox = (title, value) => { 28 | const box = new Gtk.Box({ 29 | orientation: Gtk.Orientation.VERTICAL, 30 | spacing: 3, 31 | }) 32 | box.append(utils.addClass(new Gtk.Label({ 33 | xalign: 0, 34 | wrap: true, 35 | label: title, 36 | }), 'caption-heading')) 37 | box.append(new Gtk.Label({ 38 | margin_bottom: 6, 39 | xalign: 0, 40 | wrap: true, 41 | selectable: true, 42 | label: value, 43 | wrap_mode: Pango.WrapMode.WORD_CHAR, 44 | })) 45 | return box 46 | } 47 | 48 | const makeSubjectBox = subject => { 49 | const box = new Gtk.Box({ spacing: 6 }) 50 | box.append(new Gtk.Image({ 51 | icon_name: 'tag-symbolic', 52 | valign: Gtk.Align.START, 53 | })) 54 | box.append(utils.addClass(new Gtk.Label({ 55 | xalign: 0, 56 | wrap: true, 57 | selectable: true, 58 | label: formatContributors(subject) || subject?.code, 59 | valign: Gtk.Align.START, 60 | }), 'caption')) 61 | return box 62 | } 63 | 64 | const makeBookHeader = (metadata, pixbuf) => { 65 | const box = new Gtk.Box({ 66 | orientation: Gtk.Orientation.VERTICAL, 67 | spacing: 6, 68 | hexpand: true, 69 | }) 70 | 71 | box.append(utils.addClass(new Gtk.Label({ 72 | xalign: 0, 73 | wrap: true, 74 | selectable: true, 75 | label: formatLanguageMap(metadata.title), 76 | }), 'title-2')) 77 | 78 | if (metadata.subtitle) box.append(utils.addClass(new Gtk.Label({ 79 | xalign: 0, 80 | wrap: true, 81 | selectable: true, 82 | label: formatLanguageMap(metadata.subtitle), 83 | }), 'title-4')) 84 | 85 | box.append(new Gtk.Label({ 86 | xalign: 0, 87 | wrap: true, 88 | selectable: true, 89 | label: formatAuthors(metadata), 90 | })) 91 | 92 | if (!pixbuf) return box 93 | 94 | const box2 = new Gtk.Box({ spacing: 18 }) 95 | const frame = new Gtk.Frame() 96 | frame.add_css_class('book-image-frame') 97 | const picture = new Gtk.Picture({ focusable: true, height_request: 180 }) 98 | picture.set_pixbuf(pixbuf) 99 | frame.child = picture 100 | box2.append(frame) 101 | box2.append(box) 102 | return box2 103 | } 104 | 105 | const makeBookInfoBox = (metadata, pixbuf) => { 106 | const box = new Gtk.Box({ 107 | orientation: Gtk.Orientation.VERTICAL, 108 | spacing: 6, 109 | }) 110 | 111 | box.append(makeBookHeader(metadata, pixbuf)) 112 | 113 | if (metadata.description) box.append(new Gtk.Label({ 114 | xalign: 0, 115 | wrap: true, 116 | use_markup: true, 117 | selectable: true, 118 | margin_top: 12, 119 | label: metadata.description, 120 | })) 121 | box.append(new Gtk.Box({ vexpand: true })) 122 | 123 | const flowbox = new Gtk.FlowBox({ 124 | selection_mode: Gtk.SelectionMode.NONE, 125 | row_spacing: 12, 126 | column_spacing: 18, 127 | margin_top: 12, 128 | margin_bottom: 6, 129 | }) 130 | box.append(flowbox) 131 | 132 | for (const [title, value] of [ 133 | [_('Publisher'), formatContributors(metadata.publisher)], 134 | // Translators: this is the heading for the publication date 135 | [_('Published'), format.date(metadata.published)], 136 | // Translators: this is the heading for the modified date 137 | [_('Updated'), format.date(metadata.modified)], 138 | [_('Language'), format.language(metadata.language)], 139 | [_('Translated by'), formatContributors(metadata.translator)], 140 | [_('Edited by'), formatContributors(metadata.editor)], 141 | [_('Narrated by'), formatContributors(metadata.narrator)], 142 | [_('Illustrated by'), formatContributors(metadata.illustrator)], 143 | [_('Produced by'), formatContributors(metadata.producer)], 144 | [_('Artwork by'), formatContributors(metadata.artist)], 145 | [_('Color by'), formatContributors(metadata.colorist)], 146 | [_('Contributors'), formatContributors(metadata.contributor)], 147 | [_('Identifier'), metadata.identifier], 148 | ]) { 149 | if (!value) continue 150 | if (value.length > 30) box.append(makePropertyBox(title, value)) 151 | else flowbox.insert(makePropertyBox(title, value), -1) 152 | } 153 | 154 | if (metadata.subject?.length) { 155 | const subjectsBox = new Gtk.FlowBox({ 156 | selection_mode: Gtk.SelectionMode.NONE, 157 | row_spacing: 3, 158 | column_spacing: 12, 159 | margin_top: 12, 160 | }) 161 | box.append(subjectsBox) 162 | for (const subject of [].concat(metadata.subject)) 163 | subjectsBox.insert(makeSubjectBox(subject), -1) 164 | } 165 | 166 | if (metadata.rights) box.append(utils.addClass(new Gtk.Label({ 167 | margin_top: 12, 168 | xalign: 0, 169 | wrap: true, 170 | selectable: true, 171 | label: metadata.rights, 172 | }), 'caption', 'dim-label')) 173 | return new Adw.Clamp({ child: box }) 174 | } 175 | 176 | export const makeBookInfoWindow = (root, metadata, pixbuf, bigCover) => { 177 | const wide = root.get_width() > 800 178 | const win = new Adw.Window({ 179 | title: _('About This Book'), 180 | width_request: 320, 181 | height_request: 300, 182 | default_width: bigCover && pixbuf ? (wide ? 800 : 320) : 420, 183 | default_height: bigCover && pixbuf ? (wide ? 540 : 640) : pixbuf ? 540 : 420, 184 | modal: true, 185 | transient_for: root, 186 | }) 187 | 188 | const infobox = Object.assign(makeBookInfoBox(metadata, bigCover ? null : pixbuf), { 189 | margin_bottom: 18, 190 | margin_start: 18, 191 | margin_end: 18, 192 | }) 193 | const scrolled = new Gtk.ScrolledWindow({ 194 | hexpand: true, 195 | vexpand: true, 196 | width_request: 320, 197 | }) 198 | const toolbarView = new Adw.ToolbarView({ content: scrolled }) 199 | const headerbar = new Adw.HeaderBar({ show_title: false }) 200 | toolbarView.add_top_bar(headerbar) 201 | 202 | if (bigCover && pixbuf) { 203 | headerbar.add_css_class('overlaid') 204 | toolbarView.extend_content_to_top_edge = true 205 | 206 | const picture = new Gtk.Picture({ 207 | content_fit: Gtk.ContentFit.COVER, 208 | focusable: true, 209 | }) 210 | picture.add_css_class('book-image-full') 211 | picture.set_pixbuf(pixbuf) 212 | 213 | const innerBox = new Gtk.Box({ 214 | orientation: Gtk.Orientation.VERTICAL, 215 | spacing: 18, 216 | }) 217 | innerBox.append(picture) 218 | innerBox.append(infobox) 219 | scrolled.child = innerBox 220 | scrolled.child.vscroll_policy = Gtk.ScrollablePolicy.NATURAL 221 | 222 | const outerBox = new Gtk.Box() 223 | outerBox.append(toolbarView) 224 | 225 | win.content = outerBox 226 | win.add_breakpoint(utils.connect(new Adw.Breakpoint({ 227 | condition: Adw.BreakpointCondition.parse( 228 | 'min-width: 540px and min-aspect-ratio: 5/4'), 229 | }), { 230 | 'apply': () => { 231 | innerBox.remove(picture) 232 | outerBox.prepend(picture) 233 | picture.grab_focus() 234 | headerbar.decoration_layout = ':close' 235 | headerbar.remove_css_class('overlaid') 236 | toolbarView.extend_content_to_top_edge = false 237 | }, 238 | 'unapply': () => { 239 | outerBox.remove(picture) 240 | innerBox.prepend(picture) 241 | headerbar.decoration_layout = null 242 | headerbar.add_css_class('overlaid') 243 | toolbarView.extend_content_to_top_edge = true 244 | }, 245 | })) 246 | } else { 247 | scrolled.child = infobox 248 | win.content = toolbarView 249 | } 250 | 251 | win.add_controller(utils.addShortcuts({ 'Escape|w': () => win.close() })) 252 | win.show() 253 | } 254 | -------------------------------------------------------------------------------- /src/common/widgets.js: -------------------------------------------------------------------------------- 1 | customElements.define('foliate-symbolic', class extends HTMLElement { 2 | static observedAttributes = ['src'] 3 | #root = this.attachShadow({ mode: 'closed' }) 4 | #sheet = new CSSStyleSheet() 5 | #img = document.createElement('img') 6 | constructor() { 7 | super() 8 | this.attachInternals().ariaHidden = 'true' 9 | this.#root.adoptedStyleSheets = [this.#sheet] 10 | this.#root.append(this.#img) 11 | this.#img.style.visibility = 'hidden' 12 | } 13 | attributeChangedCallback(_, __, val) { 14 | this.#img.src = val 15 | this.#sheet.replaceSync(`:host { 16 | display: inline-flex; 17 | background: currentColor; 18 | width: min-content; 19 | height: min-content; 20 | mask: url("${encodeURI(decodeURI(val))}"); 21 | }`) 22 | } 23 | }) 24 | 25 | customElements.define('foliate-scrolled', class extends HTMLElement { 26 | #root = this.attachShadow({ mode: 'closed' }) 27 | constructor() { 28 | super() 29 | const sheet = new CSSStyleSheet() 30 | sheet.replaceSync(':host { overflow: auto }') 31 | this.#root.adoptedStyleSheets = [sheet] 32 | this.#root.append(document.createElement('slot')) 33 | const top = document.createElement('div') 34 | this.#root.prepend(top) 35 | const bottom = document.createElement('div') 36 | this.#root.append(bottom) 37 | const observer = new IntersectionObserver(entries => { 38 | for (const entry of entries) { 39 | if (entry.target === top) { 40 | if (entry.isIntersecting) this.dataset.scrolledToTop = '' 41 | else delete this.dataset.scrolledToTop 42 | } 43 | else { 44 | if (entry.isIntersecting) this.dataset.scrolledToBottom = '' 45 | else delete this.dataset.scrolledToBottom 46 | } 47 | } 48 | this.dispatchEvent(new Event('change')) 49 | }, { root: this }) 50 | observer.observe(top) 51 | observer.observe(bottom) 52 | } 53 | }) 54 | 55 | customElements.define('foliate-center', class extends HTMLElement { 56 | #root = this.attachShadow({ mode: 'closed' }) 57 | constructor() { 58 | super() 59 | const sheet = new CSSStyleSheet() 60 | this.#root.adoptedStyleSheets = [sheet] 61 | sheet.replaceSync(` 62 | :host { 63 | --max-width: 450px; 64 | text-align: center; 65 | display: flex; 66 | width: 100%; 67 | min-height: 100%; 68 | } 69 | :host > div { 70 | margin: auto; 71 | width: min(100%, var(--max-width)); 72 | }`) 73 | const div = document.createElement('div') 74 | div.append(document.createElement('slot')) 75 | this.#root.append(div) 76 | } 77 | }) 78 | 79 | customElements.define('foliate-stack', class extends HTMLElement { 80 | #root = this.attachShadow({ mode: 'closed' }) 81 | constructor() { 82 | super() 83 | const sheet = new CSSStyleSheet() 84 | this.#root.adoptedStyleSheets = [sheet] 85 | sheet.replaceSync(` 86 | ::slotted([hidden]) { 87 | display: none; 88 | visibility: hidden !important; 89 | }`) 90 | const slot = document.createElement('slot') 91 | slot.addEventListener('slotchange', () => this.showChild( 92 | this.querySelector(':scope > :not([hidden])') ?? this.children[0])) 93 | this.#root.append(slot) 94 | } 95 | showChild(child) { 96 | for (const el of this.children) el.hidden = el !== child 97 | } 98 | }) 99 | 100 | customElements.define('foliate-menu', class extends HTMLElement { 101 | #root = this.attachShadow({ mode: 'closed' }) 102 | #internals = this.attachInternals() 103 | #items = [] 104 | constructor() { 105 | super() 106 | this.#internals.role = 'menu' 107 | const slot = document.createElement('slot') 108 | this.#root.append(slot) 109 | slot.addEventListener('slotchange', e => { 110 | const els = e.target.assignedElements() 111 | this.#items = els.filter(el => el.matches('[role^="menuitem"]')) 112 | }) 113 | this.addEventListener('keydown', e => this.#onKeyDown(e)) 114 | } 115 | setFocusPrev(el) { 116 | this.setFocusNext(el, this.#items.slice(0).reverse()) 117 | } 118 | setFocusNext(el, items = this.#items) { 119 | let justFound, found 120 | for (const item of items) { 121 | if (justFound) { 122 | item.tabIndex = 0 123 | item.focus() 124 | found = true 125 | justFound = false 126 | } 127 | else { 128 | item.tabIndex = -1 129 | if (item === el) justFound = true 130 | } 131 | } 132 | if (!found) { 133 | items[0].tabIndex = 0 134 | items[0].focus() 135 | } 136 | } 137 | #onKeyDown(e) { 138 | switch (e.key) { 139 | case 'ArrowUp': 140 | e.preventDefault() 141 | e.stopPropagation() 142 | this.setFocusPrev(e.target) 143 | break 144 | case 'ArrowDown': 145 | e.preventDefault() 146 | e.stopPropagation() 147 | this.setFocusNext(e.target) 148 | break 149 | } 150 | } 151 | }) 152 | 153 | customElements.define('foliate-menubutton', class extends HTMLElement { 154 | #root = this.attachShadow({ mode: 'open' }) 155 | #button 156 | #menu 157 | #ariaExpandedObserver = new MutationObserver(list => { 158 | for (const { target } of list) 159 | target.dispatchEvent(new Event('aria-expanded')) 160 | }) 161 | #onBlur = () => this.#button ? this.#button.ariaExpanded = 'false' : null 162 | #onClick = e => { 163 | if (!this.#button) return 164 | const target = e.composedPath()[0] 165 | if (!this.contains(target) && !this.#button.contains(target) && !this.#menu.contains(target)) { 166 | this.#button.setAttribute('aria-expanded', 'false') 167 | } 168 | } 169 | constructor() { 170 | super() 171 | const sheet = new CSSStyleSheet() 172 | sheet.replaceSync(':host { position: relative }') 173 | this.#root.adoptedStyleSheets = [sheet] 174 | 175 | const slot = document.createElement('slot') 176 | this.#root.append(slot) 177 | slot.addEventListener('slotchange', e => { 178 | this.#button = e.target.assignedElements()[0] 179 | if (!this.#button) return 180 | this.#button.ariaExpanded = 'false' 181 | this.#button.ariaHasPopup = 'menu' 182 | 183 | this.#ariaExpandedObserver.observe(this.#button, 184 | { attributes: true, attributeFilter: ['aria-expanded'] }) 185 | this.#button.addEventListener('click', () => { 186 | this.#button.ariaExpanded = 187 | this.#button.ariaExpanded === 'true' ? 'false' : 'true' 188 | }) 189 | this.#button.addEventListener('aria-expanded', () => { 190 | if (!this.#menu) return 191 | if (this.#button.ariaExpanded === 'true') { 192 | this.#menu.hidden = false 193 | this.#menu.focus() 194 | } else this.#menu.hidden = true 195 | }) 196 | }) 197 | 198 | const menuSlot = document.createElement('slot') 199 | menuSlot.name = 'menu' 200 | this.#root.append(menuSlot) 201 | menuSlot.addEventListener('slotchange', e => { 202 | this.#menu = e.target.assignedElements()[0] 203 | this.#menu.tabIndex = 0 204 | this.#menu.hidden = true 205 | }) 206 | menuSlot.addEventListener('keydown', e => e.key === 'Escape' 207 | ? this.#button.ariaExpanded = 'false' : null) 208 | } 209 | connectedCallback() { 210 | addEventListener('blur', this.#onBlur) 211 | addEventListener('click', this.#onClick) 212 | } 213 | disconnectedCallback() { 214 | removeEventListener('blur', this.#onBlur) 215 | removeEventListener('click', this.#onClick) 216 | } 217 | }) 218 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib' 2 | import Gio from 'gi://Gio' 3 | import GdkPixbuf from 'gi://GdkPixbuf' 4 | import * as utils from './utils.js' 5 | 6 | import { AnnotationModel, BookmarkModel } from './annotations.js' 7 | import { getURIStore, getBookList } from './library.js' 8 | 9 | class BookData { 10 | annotations = utils.connect(new AnnotationModel(), { 11 | 'update-annotation': async (_, annotation) => { 12 | for (const view of this.views) await view.addAnnotation(annotation) 13 | await this.#saveAnnotations() 14 | }, 15 | }) 16 | bookmarks = new BookmarkModel() 17 | constructor(key, views) { 18 | this.key = key 19 | this.views = views 20 | this.storage = utils.connect(new utils.JSONStorage(pkg.datadir, this.key), { 21 | 'externally-modified': () => { 22 | // TODO: the file monitor doesn't seem to work 23 | }, 24 | 'modified': storage => getBookList()?.update(storage.path), 25 | }) 26 | } 27 | async initView(view, init) { 28 | const lastLocation = this.storage.get('lastLocation', null) 29 | await view.init({ lastLocation }) 30 | 31 | if (init) { 32 | const bookmarks = this.storage.get('bookmarks', []) 33 | for (const bookmark of bookmarks) { 34 | try { 35 | const item = await view.getTOCItemOf(bookmark) 36 | this.bookmarks.add(bookmark, item?.label ?? '') 37 | } catch (e) { 38 | console.error(e) 39 | } 40 | } 41 | this.bookmarks.connect('notify::n-items', () => this.#saveBookmarks()) 42 | } 43 | 44 | const annotations = init 45 | ? this.storage.get('annotations', []) 46 | : this.annotations.export() 47 | await this.addAnnotations(annotations, false) 48 | return this 49 | } 50 | async addAnnotation(annotation, save = true) { 51 | try { 52 | const [view, ...views] = this.views 53 | const { index, label } = await view.addAnnotation(annotation) 54 | this.annotations.add(annotation, index, label) 55 | for (const view of views) view.addAnnotation(annotation) 56 | if (save) this.#saveAnnotations() 57 | return annotation 58 | } catch (e) { 59 | console.error(e) 60 | } 61 | } 62 | async addAnnotations(annotations, save = true) { 63 | await Promise.all(annotations.map(x => this.addAnnotation(x, false))) 64 | if (save) this.#saveAnnotations() 65 | } 66 | async deleteAnnotation(annotation) { 67 | try { 68 | const [view, ...views] = this.views 69 | const { index } = await view.deleteAnnotation(annotation) 70 | this.annotations.delete(annotation, index) 71 | for (const view of views) view.deleteAnnotation(annotation) 72 | return this.#saveAnnotations() 73 | } catch (e) { 74 | console.error(e) 75 | } 76 | } 77 | #saveAnnotations() { 78 | this.storage.set('annotations', this.annotations.export()) 79 | } 80 | #saveBookmarks() { 81 | this.storage.set('bookmarks', this.bookmarks.export()) 82 | } 83 | saveCover(cover) { 84 | const settings = utils.settings('library') 85 | if (!(settings?.get_boolean('show-covers') ?? true)) return 86 | const path = pkg.cachepath(`${encodeURIComponent(this.key)}.png`) 87 | if (Gio.File.new_for_path(path).query_exists(null)) return 88 | const width = settings?.get_int('cover-size') ?? 256 89 | const ratio = width / cover.get_width() 90 | const scaled = ratio >= 1 ? cover 91 | : cover.scale_simple(width, Math.round(cover.get_height() * ratio), 92 | GdkPixbuf.InterpType.BILINEAR) 93 | scaled.savev(path, 'png', [], []) 94 | } 95 | saveURI(file) { 96 | const path = file.get_path() 97 | const homeDir = GLib.get_home_dir() 98 | getURIStore().set(this.key, path.startsWith(homeDir) 99 | ? path.replace(homeDir, '~') 100 | : file.get_uri()) 101 | } 102 | } 103 | 104 | class BookDataStore { 105 | #map = new Map() 106 | #views = new Map() 107 | #keys = new WeakMap() 108 | get(key, view) { 109 | const map = this.#map 110 | if (map.has(key)) { 111 | this.#views.get(key).add(view) 112 | this.#keys.set(view, key) 113 | return map.get(key).initView(view) 114 | } 115 | else { 116 | const views = new Set([view]) 117 | const obj = new BookData(key, views) 118 | map.set(key, obj) 119 | this.#views.set(key, views) 120 | this.#keys.set(view, key) 121 | return obj.initView(view, true) 122 | } 123 | } 124 | delete(view) { 125 | const key = this.#keys.get(view) 126 | const views = this.#views.get(key) 127 | views.delete(view) 128 | if (!views.size) { 129 | this.#map.delete(key) 130 | this.#views.delete(key) 131 | } 132 | } 133 | } 134 | 135 | export const dataStore = new BookDataStore() 136 | -------------------------------------------------------------------------------- /src/format.js: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib' 2 | import Gio from 'gi://Gio' 3 | import { gettext as _ } from 'gettext' 4 | 5 | const makeLocale = locale => { 6 | try { return new Intl.Locale(locale) } 7 | catch { return null } 8 | } 9 | 10 | const glibcLocale = str => makeLocale( 11 | str === 'C' ? 'en' : str.split('.')[0].replace('_', '-')) 12 | 13 | const getHourCycle = () => { 14 | try { 15 | const settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }) 16 | return settings.get_string('clock-format') === '24h' ? 'h23' : 'h12' 17 | } catch (e) { 18 | console.debug(e) 19 | } 20 | } 21 | 22 | const hourCycle = getHourCycle() 23 | 24 | export const locales = GLib.get_language_names() 25 | .map(glibcLocale).filter(x => x) 26 | .map(locale => new Intl.Locale(locale, { hourCycle })) 27 | 28 | // very naive, probably bad locale matcher 29 | // replace this with `Intl.LocaleMatcher` once it's available 30 | export const matchLocales = strs => { 31 | const availableLocales = strs.map(makeLocale) 32 | const matches = [] 33 | for (const a of locales) { 34 | for (const [i, b] of availableLocales.entries()) { 35 | if (!b) continue 36 | if (a.language === b.language 37 | && (a.region && b.region ? a.region === b.region : true) 38 | && (a.script && b.script ? a.script === b.script : true)) 39 | matches.push(strs[i]) 40 | } 41 | } 42 | return matches 43 | } 44 | 45 | const numberFormat = new Intl.NumberFormat(locales) 46 | export const number = x => x != null ? numberFormat.format(x) : '' 47 | 48 | const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' }) 49 | export const percent = x => x != null ? percentFormat.format(x) : '' 50 | 51 | const listFormat = new Intl.ListFormat(locales, { style: 'short', type: 'conjunction' }) 52 | export const list = x => x ? listFormat.format(x) : '' 53 | 54 | export const date = (str, showTime = false) => { 55 | if (!str) return '' 56 | const isBCE = str.startsWith('-') 57 | const split = str.split('-').filter(x => x) 58 | const yearOnly = split.length === 1 59 | const yearMonthOnly = split.length === 2 60 | 61 | // years from 0 to 99 treated as 1900 to 1999, and BCE years unsupported, 62 | // unless you use "expanded years", which is `+` or `-` followed by 6 digits 63 | const [year, ...rest] = split 64 | const date = new Date((isBCE ? '-' : '+') 65 | + year.replace(/^0+/, '').padStart(6, '0') 66 | + (rest.length ? '-' + rest.join('-') : '')) 67 | 68 | // fallback when failed to parse date 69 | if (isNaN(date)) return str 70 | 71 | const options = yearOnly 72 | ? { year: 'numeric' } 73 | : yearMonthOnly 74 | ? { year: 'numeric', month: 'long' } 75 | : showTime 76 | ? { year: 'numeric', month: 'long', day: 'numeric', 77 | hour: 'numeric', minute: 'numeric' } 78 | : { year: 'numeric', month: 'long', day: 'numeric' } 79 | 80 | if (isBCE) options.era = 'short' 81 | return new Intl.DateTimeFormat(locales, options).format(date) 82 | } 83 | 84 | const getRegionEmoji = code => { 85 | if (!code || code.length !== 2) return '' 86 | return String.fromCodePoint( 87 | ...Array.from(code.toUpperCase()).map(x => 127397 + x.charCodeAt())) 88 | } 89 | const displayName = new Intl.DisplayNames(locales, { type: 'language' }) 90 | const formatLangauge = code => { 91 | if (!code) return '' 92 | try { 93 | const locale = new Intl.Locale(code) 94 | const { language, region } = locale 95 | const name = displayName.of(language) 96 | if (region) { 97 | const emoji = getRegionEmoji(region) 98 | return `${emoji ? `${emoji} ` : '' }${name}` 99 | } else return name 100 | } catch { 101 | return '' 102 | } 103 | } 104 | export const language = lang => { 105 | if (typeof lang === 'string') return formatLangauge(lang) 106 | if (Array.isArray(lang)) return list(lang.map(formatLangauge)) 107 | return '' 108 | } 109 | 110 | const minuteFormat = new Intl.NumberFormat(locales, { style: 'unit', unit: 'minute' }) 111 | const hourFormat = new Intl.NumberFormat(locales, { style: 'unit', unit: 'hour' }) 112 | export const duration = minutes => minutes < 60 113 | ? minuteFormat.format(Math.round(minutes)) 114 | : hourFormat.format((minutes / 60).toFixed(1)) 115 | 116 | export const mime = mime => mime ? Gio.content_type_get_description(mime) : '' 117 | 118 | export const price = (currency, value) => { 119 | try { 120 | return new Intl.NumberFormat(locales, { style: 'currency', currency }).format(value) 121 | } catch { 122 | return (currency ? currency + ' ' : '') + value 123 | } 124 | } 125 | 126 | export const vprintf = imports.format.vprintf 127 | export const total = n => vprintf(_('of %d'), [n]) 128 | -------------------------------------------------------------------------------- /src/generate-gresource.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { readdir, writeFile } from 'fs/promises' 3 | import { join } from 'path' 4 | 5 | const getPath = file => join(file.parentPath, file.name) 6 | 7 | const getFiles = async (path, filter, compressed) => { 8 | const files = await readdir(path, { withFileTypes: true }) 9 | return files.filter(file => !file.isDirectory()) 10 | .filter(filter ?? (() => true)) 11 | .map(compressed 12 | ? file => `${getPath(file)}` 13 | : file => `${getPath(file)}`) 14 | } 15 | 16 | const getIcons = async () => { 17 | const files = await readdir('icons/hicolor/scalable/actions/', 18 | { withFileTypes: true }) 19 | return files.map(file => 20 | `${getPath(file)}`) 21 | } 22 | 23 | const filter = ({ excludes, endsWith }) => ({ name }) => { 24 | for (const x of excludes) if (name === x) return 25 | for (const x of endsWith) if (name.endsWith(x)) return true 26 | } 27 | 28 | const result = ` 29 | 30 | 31 | ${[ 32 | ...await getFiles('./', filter({ 33 | excludes: ['generate-gresource.js', 'main.js'], 34 | endsWith: ['.js'], 35 | })), 36 | ...await getFiles('ui/'), 37 | ...await getIcons(), 38 | ...await getFiles('foliate-js/', filter({ 39 | excludes: ['reader.js', 'eslint.config.js', 'rollup.config.js'], 40 | endsWith: ['.js'], 41 | })), 42 | ...await getFiles('foliate-js/vendor/'), 43 | ...await getFiles('foliate-js/vendor/pdfjs/'), 44 | ...await getFiles('foliate-js/vendor/pdfjs/cmaps/', null, true), 45 | ...await getFiles('foliate-js/vendor/pdfjs/standard_fonts/', null, true), 46 | ...await getFiles('opds/'), 47 | ...await getFiles('selection-tools/'), 48 | ...await getFiles('common/'), 49 | ...await getFiles('reader/'), 50 | ].map(x => ' ' + x).join('\n')} 51 | 52 | 53 | ` 54 | 55 | await writeFile('gresource.xml', result) 56 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/bookmark-filled-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/funnel-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/library-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/pan-down-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/pin-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/speedometer-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/stop-sign-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/tag-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/icons/hicolor/scalable/actions/text-squiggly-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/image-viewer.js: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk' 2 | import GObject from 'gi://GObject' 3 | import Gdk from 'gi://Gdk' 4 | import GdkPixbuf from 'gi://GdkPixbuf' 5 | import * as utils from './utils.js' 6 | 7 | // TODO: figure out how to use Gdk.Texture 8 | export const ImageViewer = GObject.registerClass({ 9 | GTypeName: 'FoliateImageViewer', 10 | Template: pkg.moduleuri('ui/image-viewer.ui'), 11 | InternalChildren: ['scrolled', 'image'], 12 | Properties: utils.makeParams({ 13 | 'pixbuf': 'object', 14 | }), 15 | Signals: { 16 | 'copy': {}, 17 | 'save-as': {}, 18 | }, 19 | }, class extends Gtk.Box { 20 | #scale = 1 21 | #rotation = 0 22 | actionGroup = utils.addSimpleActions({ 23 | 'zoom-in': () => this.zoom(0.25), 24 | 'zoom-out': () => this.zoom(-0.25), 25 | 'zoom-restore': () => this.zoom(), 26 | 'rotate-left': () => this.rotate(90), 27 | 'rotate-right': () => this.rotate(270), 28 | 'copy': () => this.emit('copy'), 29 | 'save-as': () => this.emit('save-as'), 30 | }) 31 | constructor(params) { 32 | super(params) 33 | this._image.set_pixbuf(this.pixbuf) 34 | this._image.add_controller(utils.connect(new Gtk.GestureDrag(), { 35 | 'drag-begin': () => this._image.cursor = 36 | Gdk.Cursor.new_from_name('move', null), 37 | 'drag-end': () => this._image.cursor = null, 38 | 'drag-update': (_, x, y) => { 39 | const { hadjustment, vadjustment } = this._scrolled 40 | hadjustment.value -= x 41 | vadjustment.value -= y 42 | }, 43 | })) 44 | this.insert_action_group('img', this.actionGroup) 45 | this.add_controller(utils.addShortcuts({ 46 | 'c': 'img.copy', 47 | 's': 'img.save-as', 48 | 'plus|equal|KP_Add|KP_Equal|plus|equal|KP_Add|KP_Equal': 'img.zoom-in', 49 | 'minus|KP_Subtract|minus|KP_Subtract': 'img.zoom-out', 50 | '0|1|KP_0|0|KP_0': 'img.zoom-restore', 51 | 'Left': 'img.rotate-left', 52 | 'Right': 'img.rotate-right', 53 | 'i': 'img.invert', 54 | })) 55 | this.#updateActions() 56 | } 57 | zoom(d) { 58 | if (d == null) this.#scale = 1 59 | else this.#scale += d 60 | this.#update() 61 | } 62 | rotate(degree) { 63 | this.#rotation = (this.#rotation + degree) % 360 64 | this.#update() 65 | } 66 | #update() { 67 | const { pixbuf } = this 68 | const { width, height } = pixbuf 69 | const scale = this.#scale 70 | this._image.set_pixbuf(pixbuf.scale_simple(width * scale, height * scale, 71 | GdkPixbuf.InterpType.BILINEAR).rotate_simple(this.#rotation)) 72 | this.#updateActions() 73 | } 74 | #updateActions() { 75 | const scale = this.#scale 76 | this.actionGroup.lookup_action('zoom-in').enabled = scale < 3 77 | this.actionGroup.lookup_action('zoom-out').enabled = scale > 0.25 78 | this.actionGroup.lookup_action('zoom-restore').enabled = scale !== 1 79 | } 80 | }) 81 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | #!@GJS@ -m 2 | // eslint-disable-next-line no-useless-escape 3 | const MESON = '\@GJS@' !== '@GJS@' // the latter would be replace by Meson 4 | 5 | import Gtk from 'gi://Gtk?version=4.0' 6 | import Gio from 'gi://Gio?version=2.0' 7 | import GLib from 'gi://GLib?version=2.0' 8 | import 'gi://Adw?version=1' 9 | import 'gi://WebKit?version=6.0' 10 | import { programInvocationName, programArgs, exit } from 'system' 11 | import { bindtextdomain, textdomain, gettext as _ } from 'gettext' 12 | import { setConsoleLogDomain } from 'console' 13 | 14 | // mimics (loosely) the `pkg` object set up by GJS if you run `package.init()` 15 | globalThis.pkg = { 16 | name: 'com.github.johnfactotum.Foliate', 17 | version: '3.3.0', 18 | MESON, 19 | } 20 | pkg.userAgent = `Foliate/${pkg.version}` 21 | 22 | GLib.set_prgname(pkg.name) 23 | setConsoleLogDomain(pkg.name) 24 | Gtk.Window.set_default_icon_name(pkg.name) 25 | bindtextdomain(pkg.name, GLib.build_filenamev([MESON ? '@datadir@' : '/usr/share', 'locale'])) 26 | textdomain(pkg.name) 27 | 28 | pkg.localeName = _('Foliate') 29 | GLib.set_application_name(pkg.localeName) 30 | 31 | pkg.datadir = GLib.build_filenamev([GLib.get_user_data_dir(), pkg.name]) 32 | pkg.datapath = path => GLib.build_filenamev([pkg.datadir, path]) 33 | pkg.datafile = path => Gio.File.new_for_path(pkg.datapath(path)) 34 | 35 | pkg.configdir = GLib.build_filenamev([GLib.get_user_config_dir(), pkg.name]) 36 | pkg.configpath = path => GLib.build_filenamev([pkg.configdir, path]) 37 | 38 | pkg.cachedir = GLib.build_filenamev([GLib.get_user_cache_dir(), pkg.name]) 39 | pkg.cachepath = path => GLib.build_filenamev([pkg.cachedir, path]) 40 | 41 | if (MESON) { 42 | // when using Meson, load from compiled GResource binary 43 | Gio.Resource 44 | .load(GLib.build_filenamev(['@datadir@', pkg.name, `${pkg.name}.gresource`])) 45 | ._register() 46 | const moduledir = '/' + pkg.name.replaceAll('.', '/') 47 | pkg.modulepath = path => GLib.build_filenamev([moduledir, path]) 48 | pkg.moduleuri = path => `resource://${pkg.modulepath(path)}` 49 | } 50 | else { 51 | const moduledir = GLib.path_get_dirname(GLib.filename_from_uri(import.meta.url)[0]) 52 | pkg.modulepath = path => GLib.build_filenamev([moduledir, path]) 53 | pkg.moduleuri = path => GLib.filename_to_uri(pkg.modulepath(path), null) 54 | } 55 | pkg.useResource = MESON 56 | 57 | const { Application } = await import(pkg.moduleuri('app.js')) 58 | exit(await new Application().runAsync([programInvocationName, ...programArgs])) 59 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | bin_conf = configuration_data() 2 | bin_conf.set('GJS', gjs.get_variable(pkgconfig: 'gjs_console')) 3 | bin_conf.set('datadir', join_paths(get_option('prefix'), get_option('datadir'))) 4 | 5 | configure_file( 6 | input: 'main.js', 7 | output: 'foliate', 8 | configuration: bin_conf, 9 | install_dir: get_option('bindir'), 10 | ) 11 | 12 | pkgdatadir = join_paths(get_option('datadir'), meson.project_name()) 13 | 14 | src_res = gnome.compile_resources( 15 | meson.project_name(), 16 | 'gresource.xml', 17 | gresource_bundle: true, 18 | install: true, 19 | install_dir: pkgdatadir, 20 | ) 21 | -------------------------------------------------------------------------------- /src/navbar.js: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk' 2 | import GObject from 'gi://GObject' 3 | import Pango from 'gi://Pango' 4 | import * as utils from './utils.js' 5 | import * as format from './format.js' 6 | import './tts.js' 7 | 8 | const ONE_HUNDRED_PERCENT_LENGTH = format.percent(1).length 9 | 10 | const Landmark = utils.makeDataClass('FoliateLandmark', { 11 | 'label': 'string', 12 | 'href': 'string', 13 | }) 14 | 15 | GObject.registerClass({ 16 | GTypeName: 'FoliateLandmarkView', 17 | Signals: { 18 | 'go-to-href': { 19 | param_types: [GObject.TYPE_STRING], 20 | }, 21 | }, 22 | }, class extends Gtk.ListView { 23 | constructor(params) { 24 | super(params) 25 | this.model = new Gtk.NoSelection() 26 | this.connect('activate', (_, pos) => { 27 | const { href } = this.model.model.get_item(pos) ?? {} 28 | if (href) this.emit('go-to-href', href) 29 | }) 30 | this.factory = utils.connect(new Gtk.SignalListItemFactory(), { 31 | 'setup': (_, listItem) => listItem.child = new Gtk.Label({ 32 | xalign: 0, 33 | ellipsize: Pango.EllipsizeMode.END, 34 | }), 35 | 'bind': (_, { child, item }) => { 36 | const label = item.label ?? '' 37 | child.label = label 38 | child.tooltip_text = label 39 | }, 40 | }) 41 | } 42 | load(landmarks) { 43 | this.model.model = landmarks?.length 44 | ? utils.list(landmarks.map(({ label, href }) => 45 | ({ label, href })), Landmark) 46 | : null 47 | } 48 | }) 49 | 50 | GObject.registerClass({ 51 | GTypeName: 'FoliatePageListDropDown', 52 | Signals: { 53 | 'go-to-href': { param_types: [GObject.TYPE_STRING] }, 54 | }, 55 | }, class extends Gtk.DropDown { 56 | #hrefs 57 | #indices 58 | #shouldGo = true 59 | constructor(params) { 60 | super(params) 61 | this.expression = Gtk.PropertyExpression.new(Gtk.StringObject, null, 'string') 62 | this.enable_search = true 63 | this.connect('notify::selected', () => { 64 | if (this.#shouldGo) { 65 | const href = this.#hrefs.get(this.selected) 66 | if (href) this.emit('go-to-href', href) 67 | } 68 | }) 69 | } 70 | load(pageList) { 71 | pageList ??= [] 72 | this.#hrefs = new Map() 73 | this.#indices = new Map() 74 | const list = new Gtk.StringList() 75 | this.model = list 76 | for (const [i, { id, label, href }] of pageList.entries()) { 77 | list.append(label ?? '') 78 | this.#hrefs.set(i, href) 79 | this.#indices.set(id, i) 80 | } 81 | } 82 | update(item) { 83 | this.#shouldGo = false 84 | this.selected = this.#indices?.get?.(item?.id) ?? 0xffffffff 85 | this.#shouldGo = true 86 | } 87 | }) 88 | 89 | GObject.registerClass({ 90 | GTypeName: 'FoliateProgressScale', 91 | Signals: { 92 | 'go-to-fraction': { param_types: [GObject.TYPE_DOUBLE] }, 93 | }, 94 | }, class extends Gtk.Scale { 95 | #shouldUpdate = true 96 | #shouldGo = true 97 | constructor(params) { 98 | super(params) 99 | this.connect('value-changed', scale => { 100 | if (this.#shouldGo) { 101 | this.#shouldUpdate = false 102 | this.emit('go-to-fraction', scale.get_value()) 103 | } 104 | }) 105 | } 106 | loadSectionFractions(fractions) { 107 | this.clear_marks() 108 | for (const fraction of fractions.slice(1, -1)) 109 | this.add_mark(fraction, Gtk.PositionType.TOP, null) 110 | } 111 | update(fraction) { 112 | if (this.#shouldUpdate) { 113 | this.#shouldGo = false 114 | this.set_value(fraction) 115 | this.#shouldGo = true 116 | } else this.#shouldUpdate = true 117 | } 118 | }) 119 | 120 | GObject.registerClass({ 121 | GTypeName: 'FoliateNavBar', 122 | Template: pkg.moduleuri('ui/navbar.ui'), 123 | Children: ['tts-box', 'media-overlay-box'], 124 | InternalChildren: [ 125 | 'prev-image', 'next-image', 'back-image', 'forward-image', 126 | 'progress-box', 'progress-scale', 'location-button', 127 | 'location-popover', 'tts-popover', 'tts-stack', 128 | 'time-book', 'time-section', 129 | 'page-label', 'page-box', 'page-drop-down', 'page-total', 130 | 'loc-entry', 'loc-total', 'cfi-entry', 131 | 'section-entry', 'section-total', 'section-buttons', 132 | 'location-popover-stack', 'landmark-view', 'landmark-toggle', 133 | ], 134 | Signals: { 135 | 'go-to-cfi': { param_types: [GObject.TYPE_STRING] }, 136 | 'go-to-section': { param_types: [GObject.TYPE_UINT] }, 137 | 'go-to-fraction': { param_types: [GObject.TYPE_DOUBLE] }, 138 | 'opened': {}, 139 | 'closed': {}, 140 | }, 141 | }, class extends Gtk.Box { 142 | #locationTotal 143 | constructor(params) { 144 | super(params) 145 | const closed = () => this.emit('closed') 146 | this._location_popover.connect('closed', () => { 147 | this._landmark_toggle.active = false 148 | closed() 149 | }) 150 | this._tts_popover.connect('closed', closed) 151 | this._loc_entry.connect('activate', entry => { 152 | this.emit('go-to-fraction', parseInt(entry.text) / this.#locationTotal) 153 | this._location_popover.popdown() 154 | }) 155 | this._cfi_entry.connect('activate', 156 | entry => this.emit('go-to-cfi', entry.text)) 157 | this._section_entry.connect('activate', 158 | entry => this.emit('go-to-section', parseInt(entry.text) - 1)) 159 | this._progress_scale.connect('go-to-fraction', 160 | (_, value) => this.emit('go-to-fraction', value)) 161 | this._page_drop_down.connect('go-to-href', 162 | (_, href) => this.emit('go-to-cfi', href)) 163 | this._landmark_view.connect('go-to-href', 164 | (_, href) => this.emit('go-to-cfi', href)) 165 | this._landmark_toggle.connect('toggled', toggle => 166 | this._location_popover_stack.visible_child_name = 167 | toggle.active ? 'landmarks' : 'main') 168 | 169 | this.connect('go-to-cfi', () => this._location_popover.popdown()) 170 | this.connect('go-to-section', () => this._location_popover.popdown()) 171 | 172 | const actions = utils.addMethods(this, { 173 | actions: ['copy-cfi', 'paste-cfi', 'toggle-landmarks'], 174 | }) 175 | this.insert_action_group('navbar', actions) 176 | } 177 | get shouldStayVisible() { 178 | return this._location_popover.visible || this._tts_popover.visible 179 | } 180 | update(progress) { 181 | const { fraction, section, location, time, cfi, pageItem } = progress 182 | this._cfi_entry.text = cfi ?? '' 183 | this._progress_scale.update(fraction) 184 | this._location_button.label = format.percent(fraction) 185 | .padStart(ONE_HUNDRED_PERCENT_LENGTH, '\u2007') 186 | this._time_book.label = format.duration(time.total) 187 | this._time_section.label = format.duration(time.section) 188 | this._loc_entry.text = (location.current + 1).toString() 189 | this._loc_total.label = format.total(location.total) 190 | this.#locationTotal = location.total 191 | this._section_entry.text = (section.current + 1).toString() 192 | this._section_total.label = format.total(section.total) 193 | this._page_drop_down.update(pageItem) 194 | } 195 | setDirection(dir) { 196 | const value = utils.getGtkDir(dir) 197 | for (const widget of [this, this._progress_box, this._progress_scale, 198 | this._prev_image, this._next_image, this._back_image, this._forward_image]) 199 | widget.set_direction(value) 200 | utils.setDirection(this._section_buttons, value) 201 | } 202 | loadSectionFractions(fractions) { 203 | this._progress_scale.loadSectionFractions(fractions) 204 | } 205 | loadPageList(pageList, total) { 206 | if (!pageList?.length) { 207 | this._page_box.hide() 208 | this._page_label.hide() 209 | return 210 | } 211 | this._page_box.show() 212 | this._page_label.show() 213 | this._page_drop_down.load(pageList) 214 | this._page_total.label = total ? format.total(total) : '' 215 | } 216 | loadLandmarks(landmarks) { 217 | this._landmark_toggle.sensitive = landmarks?.length ? true : false 218 | this._landmark_view.load(landmarks) 219 | } 220 | copyCfi() { 221 | utils.setClipboardText(this._cfi_entry.text, this.root) 222 | this._location_popover.popdown() 223 | } 224 | pasteCfi() { 225 | utils.getClipboardText() 226 | .then(text => this.emit('go-to-cfi', text)) 227 | .catch(e => console.warn(e)) 228 | } 229 | showLocation() { 230 | this.emit('opened') 231 | this._location_button.popup() 232 | } 233 | setTTSType(name) { 234 | this._tts_stack.visible_child_name = name 235 | this._tts_popover.default_widget = this._tts_stack.visible_child.defaultWidget 236 | } 237 | }) 238 | -------------------------------------------------------------------------------- /src/reader/markup.js: -------------------------------------------------------------------------------- 1 | const unescapeHTML = str => { 2 | const textarea = document.createElement('textarea') 3 | textarea.innerHTML = str 4 | return textarea.value 5 | } 6 | 7 | const usurp = p => { 8 | let last = p 9 | for (let i = p.childNodes.length - 1; i >= 0; i--) { 10 | let e = p.removeChild(p.childNodes[i]) 11 | p.parentNode.insertBefore(e, last) 12 | last = e 13 | } 14 | p.parentNode.removeChild(p) 15 | } 16 | 17 | const pangoTags = ['a', 'b', 'big', 'i', 's', 'sub', 'sup', 'small', 'tt', 'u'] 18 | 19 | export const toPangoMarkup = html => { 20 | if (!html) return '' 21 | const doc = new DOMParser().parseFromString( 22 | html.trim().replace(/\r?\n/g, ' ').replace(/\s{2,}/g, ' '), 'text/html') 23 | Array.from(doc.querySelectorAll('p')) 24 | .forEach(el => el.innerHTML = '\n\n' + el.innerHTML) 25 | Array.from(doc.querySelectorAll('div')) 26 | .forEach(el => el.innerHTML = '\n' + el.innerHTML) 27 | Array.from(doc.querySelectorAll('li')) 28 | .forEach(el => el.innerHTML = '\n • ' + el.innerHTML) 29 | Array.from(doc.querySelectorAll('br')) 30 | .forEach(el => el.innerHTML = '\n') 31 | Array.from(doc.querySelectorAll('em')) 32 | .forEach(el => el.innerHTML = '' + el.innerHTML + '') 33 | Array.from(doc.querySelectorAll('strong')) 34 | .forEach(el => el.innerHTML = '' + el.innerHTML + '') 35 | Array.from(doc.querySelectorAll('code')) 36 | .forEach(el => el.innerHTML = '' + el.innerHTML + '') 37 | Array.from(doc.querySelectorAll('h1, h2, h3, h4, h5, h6')) 38 | .forEach(el => el.innerHTML = '\n\n' + el.innerHTML + '') 39 | Array.from(doc.body.querySelectorAll('*')).forEach(el => { 40 | const nodeName = el.nodeName.toLowerCase() 41 | if (pangoTags.indexOf(nodeName) === -1) usurp(el) 42 | else Array.from(el.attributes).forEach(attr => { 43 | if (attr.name !== 'href') el.removeAttribute(attr.name) 44 | }) 45 | if (nodeName === 'a' && !el.hasAttribute('href')) usurp(el) 46 | }) 47 | return unescapeHTML(doc.body.innerHTML.trim() 48 | .replace(/\n{3,}/g, '\n\n') 49 | .replace(/&(?=lt;|gt;|amp;)/g, '&')) 50 | .replace(/&/g, '&') 51 | } 52 | -------------------------------------------------------------------------------- /src/reader/reader.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 127 | 128 |
129 | 130 |
131 |
132 |
133 |
134 | 135 | 136 | 137 |
138 |
139 |
140 | 141 | -------------------------------------------------------------------------------- /src/search.js: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk' 2 | import GObject from 'gi://GObject' 3 | import GLib from 'gi://GLib' 4 | import Pango from 'gi://Pango' 5 | import * as utils from './utils.js' 6 | 7 | const SearchSettings = utils.makeDataClass('FoliateSearchSettings', { 8 | 'scope': 'string', 9 | 'match-case': 'boolean', 10 | 'match-diacritics': 'boolean', 11 | 'match-whole-words': 'boolean', 12 | }) 13 | 14 | const SearchResult = utils.makeDataClass('FoliateSearchResult', { 15 | 'label': 'string', 16 | 'cfi': 'string', 17 | 'subitems': 'object', 18 | }) 19 | 20 | const formatExcerpt = ({ pre, match, post }) => { 21 | const [a, b, c] = [pre, match, post].map(x => GLib.markup_escape_text(x, -1)) 22 | return `${a}${b}${c}` 23 | } 24 | 25 | GObject.registerClass({ 26 | GTypeName: 'FoliateSearchView', 27 | Properties: utils.makeParams({ 28 | 'entry': 'object', 29 | 'settings': 'object', 30 | 'dir': 'string', 31 | }), 32 | Signals: { 33 | 'show-results': {}, 34 | 'no-results': {}, 35 | 'clear-results': {}, 36 | 'show-cfi': { 37 | param_types: [GObject.TYPE_STRING], 38 | }, 39 | }, 40 | }, class extends Gtk.ListView { 41 | generator = null 42 | getGenerator 43 | doSearch = () => this.search().catch(e => console.error(e)) 44 | constructor(params) { 45 | super(params) 46 | this.settings = new SearchSettings({ scope: 'book' }) 47 | this.settings.connectAll(this.doSearch) 48 | this.model = new Gtk.SingleSelection({ autoselect: false }) 49 | this.actionGroup = utils.addSimpleActions({ 50 | 'prev': () => this.cycle(-1), 51 | 'next': () => this.cycle(1), 52 | }) 53 | utils.addPropertyActions(this.settings, this.settings.keys, this.actionGroup) 54 | this.model.connect('selection-changed', sel => { 55 | this.scroll_to(sel.selected, Gtk.ListScrollFlags.NONE, null) 56 | const { cfi } = sel.selected_item?.item ?? {} 57 | if (cfi) this.emit('show-cfi', cfi) 58 | }) 59 | this.connect('activate', (_, pos) => { 60 | const { cfi } = this.model.model.get_item(pos).item ?? {} 61 | if (cfi) this.emit('show-cfi', cfi) 62 | }) 63 | this.factory = utils.connect(new Gtk.SignalListItemFactory(), { 64 | 'setup': (_, listItem) => { 65 | listItem.child = new Gtk.TreeExpander({ indent_for_icon: false }) 66 | listItem.child.child = new Gtk.Label({ 67 | xalign: 0, 68 | margin_top: 6, 69 | margin_bottom: 6, 70 | wrap_mode: Pango.WrapMode.WORD_CHAR, 71 | }) 72 | }, 73 | 'bind': (_, listItem) => { 74 | const widget = listItem.child.child 75 | listItem.child.list_row = listItem.item 76 | const { label, subitems } = listItem.item.item 77 | Object.assign(widget, { 78 | label, 79 | ellipsize: subitems ? Pango.EllipsizeMode.END : Pango.EllipsizeMode.NONE, 80 | wrap: !subitems, 81 | use_markup: !subitems, 82 | }) 83 | const ctx = listItem.child.get_style_context() 84 | if (subitems) { 85 | ctx.add_class('caption') 86 | ctx.add_class('dim-label') 87 | } else { 88 | ctx.remove_class('caption') 89 | ctx.remove_class('dim-label') 90 | } 91 | utils.setDirection(listItem.child, this.dir) 92 | }, 93 | }) 94 | } 95 | async reset() { 96 | await this.generator?.return() 97 | this.generator = null 98 | this.model.model = null 99 | this.entry.progress_fraction = null 100 | this.emit('clear-results') 101 | } 102 | async search() { 103 | const query = this.entry.text.trim() 104 | if (!query) return 105 | await this.reset() 106 | this.model.model = utils.tree([]) 107 | this.emit('show-results') 108 | 109 | const opts = this.settings.toCamel() 110 | const index = opts.scope === 'section' ? this.index : null 111 | this.generator = await this.getGenerator({ ...opts, query, index }) 112 | 113 | for await (const result of this.generator) { 114 | if (result === 'done') { 115 | this.entry.progress_fraction = null 116 | if (!this.model.model.get_n_items()) this.emit('no-results') 117 | } 118 | else if ('progress' in result) 119 | this.entry.progress_fraction = result.progress 120 | else { 121 | const { label, cfi, excerpt, subitems } = result 122 | const { model } = this.model 123 | if (!model) return 124 | model.model.append(subitems ? new SearchResult({ 125 | label: label ?? '', 126 | cfi: cfi ?? '', 127 | subitems: utils.list(subitems.map(item => ({ 128 | label: formatExcerpt(item.excerpt), 129 | cfi: item.cfi, 130 | })), SearchResult), 131 | }) : new SearchResult({ label: formatExcerpt(excerpt), cfi })) 132 | } 133 | } 134 | } 135 | cycle(dir) { 136 | const { model } = this 137 | while (true) { 138 | if (!model.get_n_items()) break 139 | const position = model.selected 140 | if (position + dir < 0) model.selected = model.get_n_items() - 1 141 | else model.selected = position + dir 142 | if (model.selected_item?.item?.cfi) break 143 | } 144 | } 145 | }) 146 | -------------------------------------------------------------------------------- /src/selection-tools.js: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk' 2 | import Gio from 'gi://Gio' 3 | import GObject from 'gi://GObject' 4 | import WebKit from 'gi://WebKit' 5 | import Gdk from 'gi://Gdk' 6 | import { gettext as _ } from 'gettext' 7 | 8 | import * as utils from './utils.js' 9 | import { WebView } from './webview.js' 10 | import { locales, matchLocales } from './format.js' 11 | 12 | const getLanguage = lang => { 13 | try { 14 | return new Intl.Locale(lang).language 15 | } catch (e) { 16 | console.warn(e) 17 | return 'en' 18 | } 19 | } 20 | 21 | const getGoogleTranslateLanguages = utils.memoize(() => { 22 | // list of languages supported by Google Translate 23 | // generated by running the following on https://cloud.google.com/translate/docs/languages 24 | // [...document.querySelector('table').querySelectorAll('tr')].map(tr => tr.querySelector('code')?.innerText).filter(x => x).map(x => `'${x}'`).join(', ') 25 | const displayName = new Intl.DisplayNames(locales, { type: 'language' }) 26 | const langs = ['af', 'sq', 'am', 'ar', 'hy', 'as', 'ay', 'az', 'bm', 'eu', 'be', 'bn', 'bho', 'bs', 'bg', 'ca', 'ceb', 'zh-CN', 'zh-TW', 'co', 'hr', 'cs', 'da', 'dv', 'doi', 'nl', 'en', 'eo', 'et', 'ee', 'fil', 'fi', 'fr', 'fy', 'gl', 'ka', 'de', 'el', 'gn', 'gu', 'ht', 'ha', 'haw', 'he', 'hi', 'hmn', 'hu', 'is', 'ig', 'ilo', 'id', 'ga', 'it', 'ja', 'jv', 'kn', 'kk', 'km', 'rw', 'gom', 'ko', 'kri', 'ku', 'ckb', 'ky', 'lo', 'la', 'lv', 'ln', 'lt', 'lg', 'lb', 'mk', 'mai', 'mg', 'ms', 'ml', 'mt', 'mi', 'mr', 'mni-Mtei', 'lus', 'mn', 'my', 'ne', 'no', 'ny', 'or', 'om', 'ps', 'fa', 'pl', 'pt', 'pa', 'qu', 'ro', 'ru', 'sm', 'sa', 'gd', 'nso', 'sr', 'st', 'sn', 'sd', 'si', 'sk', 'sl', 'so', 'es', 'su', 'sw', 'sv', 'tl', 'tg', 'ta', 'tt', 'te', 'th', 'ti', 'ts', 'tr', 'tk', 'ak', 'uk', 'ur', 'ug', 'uz', 'vi', 'cy', 'xh', 'yi', 'yo', 'zu'] 27 | const defaultLang = matchLocales(langs)[0] ?? 'en' 28 | return [langs.map(lang => [lang, displayName.of(lang)]), defaultLang] 29 | }) 30 | 31 | const tools = { 32 | 'dictionary': { 33 | label: _('Dictionary'), 34 | uri: 'foliate-selection-tool:///selection-tools/wiktionary.html', 35 | run: (__, { text, lang }) => ({ 36 | msg: { 37 | footer: _('From Wiktionary, released under the CC BY-SA License.'), 38 | error: _('No Definitions Found'), 39 | errorAction: _('Search on Wiktionary'), 40 | }, 41 | text, 42 | lang: getLanguage(lang), 43 | }), 44 | }, 45 | 'wikipedia': { 46 | label: _('Wikipedia'), 47 | uri: 'foliate-selection-tool:///selection-tools/wikipedia.html', 48 | run: (__, { text, lang }) => ({ 49 | msg: { 50 | footer: _('From Wikipedia, released under the CC BY-SA License.'), 51 | error: _('No Definitions Found'), 52 | errorAction: _('Search on Wikipedia'), 53 | }, 54 | text, 55 | lang: getLanguage(lang), 56 | }), 57 | }, 58 | 'translate': { 59 | label: _('Translate'), 60 | uri: 'foliate-selection-tool:///selection-tools/translate.html', 61 | run: (popover, { text }) => { 62 | const [langs, defaultLang] = getGoogleTranslateLanguages() 63 | return { 64 | msg: { 65 | footer: _('Translation by Google Translate'), 66 | error: _('Cannot retrieve translation'), 67 | search: _('Search…'), 68 | langs, 69 | }, 70 | text, 71 | lang: popover.translate_target_language || defaultLang, 72 | } 73 | }, 74 | }, 75 | } 76 | 77 | const SelectionToolPopover = GObject.registerClass({ 78 | GTypeName: 'FoliateSelectionToolPopover', 79 | Properties: utils.makeParams({ 80 | 'translate-target-language': 'string', 81 | }), 82 | }, class extends Gtk.Popover { 83 | #webView = utils.connect(new WebView({ 84 | settings: new WebKit.Settings({ 85 | enable_write_console_messages_to_stdout: true, 86 | enable_back_forward_navigation_gestures: false, 87 | enable_hyperlink_auditing: false, 88 | enable_html5_database: false, 89 | enable_html5_local_storage: false, 90 | }), 91 | }), { 92 | 'decide-policy': (_, decision, type) => { 93 | switch (type) { 94 | case WebKit.PolicyDecisionType.NAVIGATION_ACTION: 95 | case WebKit.PolicyDecisionType.NEW_WINDOW_ACTION: { 96 | const { uri } = decision.navigation_action.get_request() 97 | if (!uri.startsWith('foliate-selection-tool:')) { 98 | decision.ignore() 99 | new Gtk.UriLauncher({ uri }).launch(this.root, null, null) 100 | return true 101 | } 102 | } 103 | } 104 | }, 105 | }) 106 | constructor(params) { 107 | super(params) 108 | utils.bindSettings('viewer', this, ['translate-target-language']) 109 | Object.assign(this, { 110 | width_request: 300, 111 | height_request: 300, 112 | }) 113 | this.child = this.#webView 114 | this.#webView.set_background_color(new Gdk.RGBA()) 115 | this.#webView.registerHandler('settings', payload => { 116 | if (payload.key === 'translate-target-language') 117 | this.translate_target_language = payload.value 118 | }) 119 | } 120 | loadTool(tool, init) { 121 | this.#webView.loadURI(tool.uri) 122 | .then(() => this.#webView.opacity = 1) 123 | .then(() => this.#webView.exec('init', init)) 124 | .catch(e => console.error(e)) 125 | } 126 | }) 127 | 128 | const getSelectionToolPopover = utils.memoize(() => new SelectionToolPopover()) 129 | 130 | export const SelectionPopover = GObject.registerClass({ 131 | GTypeName: 'FoliateSelectionPopover', 132 | Template: pkg.moduleuri('ui/selection-popover.ui'), 133 | Signals: { 134 | 'show-popover': { param_types: [Gtk.Popover.$gtype] }, 135 | 'run-tool': { return_type: GObject.TYPE_JSOBJECT }, 136 | }, 137 | }, class extends Gtk.PopoverMenu { 138 | constructor(params) { 139 | super(params) 140 | const model = this.menu_model 141 | const section = new Gio.Menu() 142 | model.insert_section(1, null, section) 143 | 144 | const group = new Gio.SimpleActionGroup() 145 | this.insert_action_group('selection-tools', group) 146 | 147 | for (const [name, tool] of Object.entries(tools)) { 148 | const action = new Gio.SimpleAction({ name }) 149 | action.connect('activate', () => { 150 | const popover = getSelectionToolPopover() 151 | Promise.resolve(tool.run(popover, this.emit('run-tool'))) 152 | .then(x => popover.loadTool(tool, x)) 153 | .catch(e => console.error(e)) 154 | this.emit('show-popover', popover) 155 | }) 156 | group.add_action(action) 157 | section.append(tool.label, `selection-tools.${name}`) 158 | } 159 | } 160 | }) 161 | -------------------------------------------------------------------------------- /src/selection-tools/common.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | html, body { 5 | color-scheme: light dark; 6 | font: menu; 7 | } 8 | h1 { 9 | font-size: larger; 10 | } 11 | h2 { 12 | font-size: smaller; 13 | } 14 | a:any-link { 15 | color: highlight; 16 | } 17 | ul, ol { 18 | padding-inline-start: 2em; 19 | } 20 | footer { 21 | font-size: smaller; 22 | opacity: .6; 23 | } 24 | :is([data-state="loading"], [data-state="error"]) footer { 25 | display: none; 26 | } 27 | [data-state="loaded"] footer { 28 | display: block; 29 | } 30 | [data-state="error"] main { 31 | display: flex; 32 | position: absolute; 33 | inset: 0; 34 | width: 100%; 35 | height: 100%; 36 | text-align: center; 37 | justify-content: center; 38 | align-items: center; 39 | } 40 | -------------------------------------------------------------------------------- /src/selection-tools/translate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 82 |
83 | 87 |
88 |
89 |
asdf
90 | 91 | 92 |
93 |
94 | 95 | 218 | -------------------------------------------------------------------------------- /src/selection-tools/wikipedia.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 20 |
21 |

22 | 62 | -------------------------------------------------------------------------------- /src/selection-tools/wiktionary.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 24 |
25 |

26 | 115 | -------------------------------------------------------------------------------- /src/speech.js: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio' 2 | import GLib from 'gi://GLib' 3 | 4 | class SSIPConnection { 5 | #connection 6 | #inputStream 7 | #outputStream 8 | #onResponse 9 | #eventData = [] 10 | constructor(onEvent) { 11 | this.onEvent = onEvent 12 | } 13 | spawn() { 14 | const flags = Gio.SubprocessFlags.NONE 15 | const launcher = new Gio.SubprocessLauncher({ flags }) 16 | const cmd = GLib.getenv('SPEECHD_CMD') ?? 'speech-dispatcher' 17 | const proc = launcher.spawnv([cmd, '--spawn']) 18 | return new Promise(resolve => proc.wait_check_async(null, () => resolve())) 19 | } 20 | connect() { 21 | const path = GLib.getenv('SPEECHD_ADDRESS')?.split(':')?.[1] 22 | ?? GLib.build_filenamev([GLib.get_user_runtime_dir(), 23 | 'speech-dispatcher/speechd.sock']) 24 | try { 25 | const address = Gio.UnixSocketAddress.new(path) 26 | this.#connection = new Gio.SocketClient().connect(address, null) 27 | } catch (e){ 28 | throw new Error(`Error connecting to ${path}: ${e}`) 29 | } 30 | this.#outputStream = Gio.DataOutputStream.new(this.#connection.get_output_stream()) 31 | this.#inputStream = Gio.DataInputStream.new(this.#connection.get_input_stream()) 32 | this.#inputStream.newline_type = Gio.DataStreamNewlineType.TYPE_CR_LF 33 | this.#receive() 34 | } 35 | #receive() { 36 | this.#inputStream.read_line_async(0, null, (stream, res) => { 37 | const [line/*, length*/] = stream.read_line_finish_utf8(res) 38 | const code = line.slice(0, 3) 39 | const end = line.slice(3, 4) === ' ' 40 | const text = line.slice(4, -1) 41 | if (code.startsWith('7')) this.#onEvent(code, end, text) 42 | else this.#onResponse(code, end, text) 43 | this.#receive() 44 | }) 45 | } 46 | #onEvent(code, end, message) { 47 | if (!end) return this.#eventData.push(message) 48 | else { 49 | const [msgID,, mark] = this.#eventData 50 | this.onEvent?.(msgID, { code, message, mark }) 51 | this.#eventData = [] 52 | } 53 | } 54 | send(command) { 55 | return new Promise((resolve, reject) => { 56 | if (!this.#connection.is_connected()) reject() 57 | this.#outputStream.put_string(command + '\r\n', null) 58 | const data = [] 59 | this.#onResponse = (code, end, message) => { 60 | if (!end) return data.push(message) 61 | if (code.startsWith('2')) 62 | resolve(Object.assign(data, { code, message })) 63 | else reject(new Error(code + ' ' + message)) 64 | } 65 | }) 66 | } 67 | } 68 | 69 | export class SSIPClient { 70 | #initialized 71 | #promises = new Map() 72 | #connection = new SSIPConnection((msgID, result) => 73 | this.#promises.get(msgID)?.resolve?.(result)) 74 | async init() { 75 | if (this.#initialized) return 76 | this.#initialized = true 77 | try { 78 | await this.#connection.spawn() 79 | } catch (e) { 80 | console.debug(e) 81 | } 82 | try { 83 | this.#connection.connect() 84 | const clientName = `${GLib.get_user_name()}:foliate:tts` 85 | await this.#connection.send('SET SELF CLIENT_NAME ' + clientName) 86 | await this.#connection.send('SET SELF SSML_MODE on') 87 | await this.#connection.send('SET SELF NOTIFICATION ALL on') 88 | } catch (e) { 89 | this.#initialized = false 90 | throw e 91 | } 92 | } 93 | #makePromise(msgID){ 94 | return new Promise((resolve, reject) => this.#promises.set(msgID, { 95 | resolve: value => (resolve(value), this.#promises.delete(msgID)), 96 | reject: value => (reject(value), this.#promises.delete(msgID)), 97 | })) 98 | } 99 | #makeIter(msgID) { 100 | let promise = this.#makePromise(msgID) 101 | return { 102 | next: async () => { 103 | const data = await promise 104 | promise = this.#makePromise(msgID) 105 | return data 106 | }, 107 | return: () => { 108 | promise = null 109 | this.#promises.delete(msgID) 110 | }, 111 | } 112 | } 113 | async send(command) { 114 | await this.init() 115 | return this.#connection.send(command) 116 | } 117 | async speak(str) { 118 | await this.send('SPEAK') 119 | const text = str.replace('\r\n.', '\r\n..') + '\r\n.' 120 | const [msgID] = await this.send(text) 121 | const iter = this.#makeIter(msgID) 122 | let done = false 123 | const next = async () => { 124 | if (done) return { done } 125 | const value = await iter.next() 126 | const { code } = value 127 | if (code === '702' || code === '703') { 128 | iter.return() 129 | done = true 130 | return { value, done: false } 131 | } 132 | return { value, done } 133 | } 134 | return { 135 | next, 136 | [Symbol.asyncIterator]: () => ({ next }), 137 | } 138 | } 139 | pause() { 140 | return this.send('PAUSE self') 141 | } 142 | resume() { 143 | return this.send('RESUME self') 144 | } 145 | stop() { 146 | return this.send('STOP self') 147 | } 148 | setRate(rate) { 149 | return this.send(`SET self RATE ${rate}`) 150 | } 151 | setPitch(rate) { 152 | return this.send(`SET self PITCH ${rate}`) 153 | } 154 | async listSynthesisVoices() { 155 | const data = await this.send('LIST SYNTHESIS_VOICES') 156 | return data.map(row => { 157 | const [name, lang, variant] = row.split('\t') 158 | return { name, lang, variant } 159 | }) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/themes.js: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk' 2 | import GLib from 'gi://GLib' 3 | import { gettext as _ } from 'gettext' 4 | import * as utils from './utils.js' 5 | 6 | export const themes = [ 7 | { 8 | name: 'default', label: _('Default'), 9 | light: { fg: '#000000', bg: '#ffffff', link: '#0066cc' }, 10 | dark: { fg: '#e0e0e0', bg: '#222222', link: '#77bbee' }, 11 | }, 12 | { 13 | name: 'gray', label: _('Gray'), 14 | light: { fg: '#222222', bg: '#e0e0e0', link: '#4488cc' }, 15 | dark: { fg: '#c6c6c6', bg: '#444444', link: '#88ccee' }, 16 | }, 17 | { 18 | name: 'sepia', label: _('Sepia'), 19 | light: { fg: '#5b4636', bg: '#f1e8d0', link: '#008b8b' }, 20 | dark: { fg: '#ffd595', bg: '#342e25', link: '#48d1cc' }, 21 | }, 22 | { 23 | name: 'grass', label: _('Grass'), 24 | light: { fg: '#232c16', bg: '#d7dbbd', link: '#177b4d' }, 25 | dark: { fg: '#d8deba', bg: '#333627', link: '#a6d608' }, 26 | }, 27 | { 28 | name: 'cherry', label: _('Cherry'), 29 | light: { fg: '#4e1609', bg: '#f0d1d5', link: '#de3838' }, 30 | dark: { fg: '#e5c4c8', bg: '#462f32', link: '#ff646e' }, 31 | }, 32 | { 33 | name: 'sky', label: _('Sky'), 34 | light: { fg: '#262d48', bg: '#cedef5', link: '#2d53e5' }, 35 | dark: { fg: '#babee1', bg: '#282e47', link: '#ff646e' }, 36 | }, 37 | { 38 | name: 'solarized', label: _('Solarized'), 39 | light: { fg: '#586e75', bg: '#fdf6e3', link: '#268bd2' }, 40 | dark: { fg: '#93a1a1', bg: '#002b36', link: '#268bd2' }, 41 | }, 42 | { 43 | name: 'gruvbox', label: _('Gruvbox'), 44 | light: { fg: '#3c3836', bg: '#fbf1c7', link: '#076678' }, 45 | dark: { fg: '#ebdbb2', bg: '#282828', link: '#83a598' }, 46 | }, 47 | { 48 | name: 'nord', label: _('Nord'), 49 | light: { fg: '#2e3440', bg: '#eceff4', link: '#5e81ac' }, 50 | dark: { fg: '#d8dee9', bg: '#2e3440', link: '#88c0d0' }, 51 | }, 52 | ] 53 | 54 | for (const { file, name } of utils.listDir(pkg.configpath('themes'))) try { 55 | if (!/\.json$/.test(name)) continue 56 | const theme = utils.readJSONFile(file) 57 | themes.push({ 58 | name, 59 | label: theme.label ?? name.replace(/\.json$/, ''), 60 | light: { 61 | fg: theme.light.fg, 62 | bg: theme.light.bg, 63 | link: theme.light.link, 64 | }, 65 | dark: { 66 | fg: theme.dark.fg, 67 | bg: theme.dark.bg, 68 | link: theme.dark.link, 69 | }, 70 | }) 71 | } catch (e) { 72 | console.error(e) 73 | } 74 | 75 | export const themeCssProvider = new Gtk.CssProvider() 76 | themeCssProvider.load_from_data(` 77 | .theme-container .card { 78 | padding: 9px; 79 | } 80 | ` + themes.map(theme => { 81 | const id = `theme-${GLib.uuid_string_random()}` 82 | theme.id = id 83 | return ` 84 | .${id}, .sidebar-${id}:not(.background) { 85 | color: ${theme.light.fg}; 86 | background: ${theme.light.bg}; 87 | } 88 | .sidebar-${id}:not(.background) toolbarview { 89 | background: rgba(0, 0, 0, .08); 90 | } 91 | .is-dark .${id}, .is-dark .sidebar-${id}:not(.background) { 92 | color: ${theme.dark.fg}; 93 | background: ${theme.dark.bg}; 94 | } 95 | .is-dark .sidebar-${id}:not(.background) toolbarview { 96 | background: rgba(255, 255, 255, .05); 97 | } 98 | .${id} highlight { 99 | background: ${theme.light.link}; 100 | } 101 | .is-dark .${id} highlight { 102 | background: ${theme.dark.link}; 103 | } 104 | .${id} popover highlight, .is-dark .${id} popover highlight { 105 | background: @accent_bg_color; 106 | } 107 | ` 108 | }).join(''), -1) 109 | 110 | export const invertTheme = ({ light, dark }) => ({ light, dark, inverted: { 111 | fg: utils.invertColor(dark.fg), 112 | link: utils.invertColor(dark.link), 113 | } }) 114 | -------------------------------------------------------------------------------- /src/toc.js: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk' 2 | import GObject from 'gi://GObject' 3 | import Pango from 'gi://Pango' 4 | import * as utils from './utils.js' 5 | 6 | const TOCItem = utils.makeDataClass('FoliateTOCItem', { 7 | 'id': 'uint', 8 | 'label': 'string', 9 | 'href': 'string', 10 | 'subitems': 'object', 11 | }) 12 | 13 | GObject.registerClass({ 14 | GTypeName: 'FoliateTOCView', 15 | Properties: utils.makeParams({ 16 | 'dir': 'string', 17 | }), 18 | Signals: { 19 | 'go-to-href': { 20 | param_types: [GObject.TYPE_STRING], 21 | }, 22 | }, 23 | }, class extends Gtk.ListView { 24 | #shouldGoToTocItem = true 25 | #map = new Map() 26 | #parentMap = new Map() 27 | constructor(params) { 28 | super(params) 29 | this.model = new Gtk.SingleSelection({ autoselect: false, can_unselect: true }) 30 | this.model.connect('selection-changed', sel => { 31 | if (!this.#shouldGoToTocItem) return 32 | const href = sel.selected_item?.item?.href 33 | if (href) this.emit('go-to-href', href) 34 | }) 35 | this.connect('activate', (_, pos) => { 36 | const { href } = this.model.model.get_item(pos).item ?? {} 37 | if (href) this.emit('go-to-href', href) 38 | }) 39 | this.factory = utils.connect(new Gtk.SignalListItemFactory(), { 40 | 'setup': (_, listItem) => { 41 | listItem.child = new Gtk.TreeExpander() 42 | listItem.child.child = new Gtk.Label({ 43 | xalign: 0, 44 | ellipsize: Pango.EllipsizeMode.END, 45 | }) 46 | }, 47 | 'bind': (_, listItem) => { 48 | const widget = listItem.child.child 49 | listItem.child.list_row = listItem.item 50 | const { label, href } = listItem.item.item 51 | Object.assign(widget, { label, tooltip_text: label }) 52 | const ctx = widget.get_style_context() 53 | if (href) ctx.remove_class('dim-label') 54 | else ctx.add_class('dim-label') 55 | utils.setDirection(listItem.child, this.dir) 56 | }, 57 | }) 58 | } 59 | load(toc) { 60 | toc ??= [] 61 | this.model.model = utils.tree(toc, TOCItem, false) 62 | // save parent for each item in a map 63 | const f = item => { 64 | this.#map.set(item.id, item) 65 | if (!item.subitems?.length) return 66 | for (const subitem of item.subitems) { 67 | this.#parentMap.set(subitem, item) 68 | f(subitem) 69 | } 70 | } 71 | for (const item of toc) f(item) 72 | } 73 | getParents(id) { 74 | const results = [] 75 | let item = this.#map.get(id) 76 | while (item) { 77 | results.push(item.id) 78 | item = this.#parentMap.get(item) 79 | } 80 | return results.reverse() 81 | } 82 | setCurrent(id) { 83 | if (id == null) { 84 | this.model.unselect_item(this.model.selected) 85 | return 86 | } 87 | const { model } = this 88 | let index 89 | let iStart = 0 90 | // child rows are added to the tree dynamically 91 | // so have to expand every ancestors from the top 92 | for (const parent of this.getParents(id)) { 93 | const length = model.get_n_items() 94 | for (let i = iStart; i < length; i++) { 95 | const row = model.get_item(i) 96 | if (row.get_item().id === parent) { 97 | row.expanded = true 98 | index = i 99 | // start next search from i + 1 100 | // as children must come after the parent 101 | iStart = i + 1 102 | break 103 | } 104 | } 105 | } 106 | this.#shouldGoToTocItem = false 107 | this.scroll_to(index, Gtk.ListScrollFlags.SELECT, null) 108 | this.#shouldGoToTocItem = true 109 | } 110 | }) 111 | -------------------------------------------------------------------------------- /src/tts.js: -------------------------------------------------------------------------------- 1 | import Gtk from 'gi://Gtk' 2 | import GObject from 'gi://GObject' 3 | import { gettext as _ } from 'gettext' 4 | 5 | import * as utils from './utils.js' 6 | import { SSIPClient } from './speech.js' 7 | 8 | const ssip = new SSIPClient() 9 | 10 | GObject.registerClass({ 11 | GTypeName: 'FoliateTTSBox', 12 | Template: pkg.moduleuri('ui/tts-box.ui'), 13 | Signals: { 14 | 'init': { return_type: GObject.TYPE_JSOBJECT }, 15 | 'start': { return_type: GObject.TYPE_JSOBJECT }, 16 | 'resume': { return_type: GObject.TYPE_JSOBJECT }, 17 | 'backward': { return_type: GObject.TYPE_JSOBJECT }, 18 | 'forward': { return_type: GObject.TYPE_JSOBJECT }, 19 | 'backward-paused': {}, 20 | 'forward-paused': {}, 21 | 'highlight': { 22 | param_types: [GObject.TYPE_STRING], 23 | return_type: GObject.TYPE_JSOBJECT, 24 | }, 25 | 'next-section': { return_type: GObject.TYPE_JSOBJECT }, 26 | }, 27 | InternalChildren: [ 28 | 'tts-rate-scale', 'tts-pitch-scale', 29 | 'media-buttons', 'play-button', 30 | ], 31 | }, class extends Gtk.Box { 32 | #state = 'stopped' 33 | defaultWidget = this._play_button 34 | constructor(params) { 35 | super(params) 36 | this.insert_action_group('tts', utils.addMethods(this, { 37 | actions: ['play', 'backward', 'forward', 'stop'], 38 | })) 39 | utils.setDirection(this._media_buttons, Gtk.TextDirection.LTR) 40 | 41 | this.#connectScale(this._tts_rate_scale, ssip.setRate.bind(ssip)) 42 | this.#connectScale(this._tts_pitch_scale, ssip.setPitch.bind(ssip)) 43 | } 44 | #connectScale(scale, f) { 45 | scale.connect('value-changed', scale => { 46 | const shouldResume = this.state === 'playing' 47 | this.state = 'paused' 48 | ssip.stop() 49 | .then(() => f(Math.trunc(scale.get_value()))) 50 | .then(() => shouldResume ? this.start() : null) 51 | .catch(e => this.error(e)) 52 | }) 53 | } 54 | get state() { 55 | return this.#state 56 | } 57 | set state(state) { 58 | this.#state = state 59 | this._play_button.icon_name = state === 'playing' 60 | ? 'media-playback-pause-symbolic' 61 | : 'media-playback-start-symbolic' 62 | } 63 | #init() { 64 | return ssip.stop().then(() => this.emit('init')) 65 | } 66 | async #speak(ssml) { 67 | this.state = 'playing' 68 | ssml = await ssml 69 | if (!ssml && await this.emit('next-section')) return this.forward() 70 | const iter = await ssip.speak(ssml) 71 | let state 72 | for await (const { mark, message } of iter) { 73 | if (mark) await this.emit('highlight', mark) 74 | else state = message 75 | } 76 | if (state === 'END') this.forward() 77 | } 78 | speak(ssml) { 79 | this.#init().then(() => this.#speak(ssml)).catch(e => this.error(e)) 80 | } 81 | play() { 82 | if (this.#state !== 'playing') this.start() 83 | else this.pause() 84 | } 85 | start() { 86 | this.#init() 87 | .then(() => this.#speak(this.state === 'paused' 88 | ? this.emit('resume') 89 | : this.emit('start'))) 90 | .catch(e => this.error(e)) 91 | } 92 | pause() { 93 | this.state = 'paused' 94 | ssip.stop().catch(e => this.error(e)) 95 | } 96 | stop() { 97 | this.state = 'stopped' 98 | ssip.stop().catch(e => this.error(e)) 99 | } 100 | backward() { 101 | this.#init() 102 | .then(() => this.state === 'playing' 103 | ? this.#speak(this.emit('backward')) 104 | : (this.state = 'paused', this.emit('backward-paused'))) 105 | .catch(e => this.error(e)) 106 | } 107 | forward() { 108 | this.#init() 109 | .then(() => this.state === 'playing' 110 | ? this.#speak(this.emit('forward')) 111 | : (this.state = 'paused', this.emit('forward-paused'))) 112 | .catch(e => this.error(e)) 113 | } 114 | error(e) { 115 | this.state = 'stopped' 116 | console.error(e) 117 | this.root.error(_('Text-to-Speech Error'), 118 | _('Make sure Speech Dispatcher is installed and working on your system')) 119 | } 120 | kill() { 121 | this.emit = () => {} 122 | if (this.state === 'playing') ssip.stop().catch(e => console.error(e)) 123 | } 124 | }) 125 | 126 | 127 | GObject.registerClass({ 128 | GTypeName: 'FoliateMediaOverlayBox', 129 | Template: pkg.moduleuri('ui/media-overlay-box.ui'), 130 | Properties: utils.makeParams({ 131 | 'rate': 'double', 132 | 'volume': 'double', 133 | }), 134 | Signals: { 135 | 'start': {}, 136 | 'pause': {}, 137 | 'resume': {}, 138 | 'stop': {}, 139 | 'backward': {}, 140 | 'forward': {}, 141 | }, 142 | InternalChildren: [ 143 | 'volume-scale', 144 | 'media-buttons', 'play-button', 145 | ], 146 | }, class extends Gtk.Box { 147 | #state = 'stopped' 148 | defaultWidget = this._play_button 149 | constructor(params) { 150 | super(params) 151 | this.set_property('rate', 1) 152 | const actionGroup = utils.addMethods(this, { 153 | actions: ['play', 'backward', 'forward', 'stop'], 154 | }) 155 | utils.addPropertyActions(this, ['rate'], actionGroup) 156 | this.insert_action_group('media-overlay', actionGroup) 157 | 158 | utils.setDirection(this._media_buttons, Gtk.TextDirection.LTR) 159 | 160 | // GtkScale, y u no implement GtkActionable? 161 | this._volume_scale.connect('value-changed', scale => 162 | this.set_property('volume', scale.get_value())) 163 | } 164 | get state() { 165 | return this.#state 166 | } 167 | set state(state) { 168 | this.#state = state 169 | this._play_button.icon_name = state === 'playing' 170 | ? 'media-playback-pause-symbolic' 171 | : 'media-playback-start-symbolic' 172 | } 173 | play() { 174 | if (this.#state !== 'playing') this.start() 175 | else this.pause() 176 | } 177 | start() { 178 | if (this.state === 'paused') this.emit('resume') 179 | else this.emit('start') 180 | this.state = 'playing' 181 | } 182 | pause() { 183 | this.state = 'paused' 184 | this.emit('pause') 185 | } 186 | stop() { 187 | this.state = 'stopped' 188 | this.emit('stop') 189 | } 190 | backward() { 191 | if (this.state === 'stopped') this.state = 'playing' 192 | this.emit('backward') 193 | } 194 | forward() { 195 | if (this.state === 'stopped') this.state = 'playing' 196 | this.emit('forward') 197 | } 198 | }) 199 | -------------------------------------------------------------------------------- /src/ui/annotation-popover.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 71 | 72 | -------------------------------------------------------------------------------- /src/ui/annotation-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 70 | 71 | -------------------------------------------------------------------------------- /src/ui/book-image.ui: -------------------------------------------------------------------------------- 1 | 2 | 35 | 36 | -------------------------------------------------------------------------------- /src/ui/book-item.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | Open in New Window 6 | book-item.open-new-window 7 | 8 | 9 | Open with External App 10 | book-item.open-external-app 11 | 12 |
13 |
14 | 15 | About This Book 16 | book-item.info 17 | 18 | 19 | Export Annotations… 20 | book-item.export 21 | 22 |
23 |
24 | 25 | Remove 26 | book-item.remove 27 | 28 |
29 |
30 | 73 |
74 | -------------------------------------------------------------------------------- /src/ui/book-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | Open in New Window 6 | book-item.open-new-window 7 | 8 | 9 | Open with External App 10 | book-item.open-external-app 11 | 12 |
13 |
14 | 15 | About This Book 16 | book-item.info 17 | 18 | 19 | Export Annotations… 20 | book-item.export 21 | 22 |
23 |
24 | 25 | Remove 26 | book-item.remove 27 | 28 |
29 |
30 | 86 |
87 | -------------------------------------------------------------------------------- /src/ui/bookmark-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 38 | 39 | -------------------------------------------------------------------------------- /src/ui/export-dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | Export Annotations 6 | 7 | 8 | false 9 | 10 | 11 | Cancel 12 | 13 | 14 | 15 | 16 | Export 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Format 29 | Choose “JSON” if you plan on importing annotations back to Foliate 30 | 31 | 32 | 33 | JSON 34 | HTML 35 | Markdown 36 | Org Mode 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/ui/image-viewer.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | Copy 7 | img.copy 8 | 9 | 10 | Save As… 11 | img.save-as 12 | 13 |
14 |
15 | 94 |
95 | -------------------------------------------------------------------------------- /src/ui/import-dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 52 | 53 | -------------------------------------------------------------------------------- /src/ui/library-view.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49 | 50 | -------------------------------------------------------------------------------- /src/ui/media-overlay-box.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 143 | 144 | -------------------------------------------------------------------------------- /src/ui/selection-popover.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | horizontal-buttons 6 | 7 | Copy 8 | selection.copy 9 | edit-copy-symbolic 10 | 11 | 12 | Highlight 13 | selection.highlight 14 | document-edit-symbolic 15 | 16 | 17 | Find 18 | selection.search 19 | edit-find-symbolic 20 | 21 |
22 |
23 | 29 | 30 | Speak from Here 31 | selection.speak-from-here 32 | 33 |
34 |
35 | 36 | Copy with Citation 37 | selection.copy-citation 38 | 39 | 40 | Copy Identifier 41 | selection.copy-cfi 42 | 43 | 44 | Print Selection… 45 | selection.print 46 | 47 |
48 |
49 | 52 |
53 | -------------------------------------------------------------------------------- /src/ui/tts-box.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 149 | 150 | -------------------------------------------------------------------------------- /src/webview.js: -------------------------------------------------------------------------------- 1 | import GObject from 'gi://GObject' 2 | import GLib from 'gi://GLib' 3 | import Gio from 'gi://Gio' 4 | import WebKit from 'gi://WebKit' 5 | 6 | const registerScheme = (name, callback) => 7 | WebKit.WebContext.get_default().register_uri_scheme(name, req => { 8 | try { 9 | callback(req) 10 | } catch (e) { 11 | console.error(e) 12 | req.finish_error(new GLib.Error( 13 | Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND, 'Not found')) 14 | } 15 | }) 16 | 17 | const registerPaths = (name, dirs) => registerScheme(name, req => { 18 | const path = pkg.MESON 19 | ? req.get_path().replace(/(?<=\/icons)\/hicolor(?=\/scalable\/)/, '') 20 | : req.get_path() 21 | if (dirs.every(dir => !path.startsWith(dir))) throw new Error() 22 | const mime = path.endsWith('.js') || path.endsWith('.mjs') ? 'application/javascript' 23 | : path.endsWith('.svg') ? 'image/svg+xml' : 'text/html' 24 | const file = Gio.File.new_for_uri(pkg.moduleuri(path)) 25 | req.finish(file.read(null), -1, mime) 26 | }) 27 | 28 | registerPaths('foliate', ['/reader/', '/foliate-js/']) 29 | registerPaths('foliate-opds', ['/opds/', '/foliate-js/', '/icons/', '/common/']) 30 | registerPaths('foliate-selection-tool', ['/selection-tools/', '/icons/', '/common/']) 31 | 32 | /* 33 | `.run_javascript()` is hard to use if you're running an async function. You have 34 | to use messaging which is quite cumbersome. 35 | 36 | So the idea is that we create a promise that can be resolved from the outside 37 | whenever we're calling an async function inside the webview. 38 | 39 | Then, after the function inside the webview resolves, we send a back a message 40 | that will be used to resolve the aforementioned promise. 41 | */ 42 | 43 | const makeToken = () => Math.random().toString() 44 | 45 | class PromiseStore { 46 | #promises = new Map() 47 | make(token) { 48 | return new Promise((resolve, reject) => this.#promises.set(token, { 49 | resolve: value => (resolve(value), this.#promises.delete(token)), 50 | reject: value => (reject(value), this.#promises.delete(token)), 51 | })) 52 | } 53 | resolve(token, ok, value) { 54 | const promise = this.#promises.get(token) 55 | if (ok) promise.resolve(value) 56 | else promise.reject(value) 57 | } 58 | } 59 | 60 | const pass = obj => typeof obj === 'undefined' ? '' 61 | : `JSON.parse(decodeURI("${encodeURI(JSON.stringify(obj))}"))` 62 | 63 | const makeHandlerStr = name => `globalThis.webkit.messageHandlers["${name}"]` 64 | 65 | export const WebView = GObject.registerClass({ 66 | GTypeName: 'FoliateWebView', 67 | }, class extends WebKit.WebView { 68 | #promises = new PromiseStore() 69 | #handlerName = pkg.name + '.' + makeToken() 70 | #handler = makeHandlerStr(this.#handlerName) 71 | constructor(params) { 72 | super(params) 73 | this.registerHandler(this.#handlerName, ({ token, ok, payload }) => 74 | this.#promises.resolve(token, ok, payload)) 75 | 76 | this.connect('web-process-terminated', (_, reason) => { 77 | switch (reason) { 78 | case WebKit.WebProcessTerminationReason.CRASHED: 79 | console.error('My name is Oh-No-WebKit-Crashed, bug of bugs!') 80 | console.error('Look on this line, Developer -- despair!') 81 | break 82 | case WebKit.WebProcessTerminationReason.EXCEEDED_MEMORY_LIMIT: 83 | console.error('Memory, all alone in the moonlight') 84 | console.error('I can dream of the old days') 85 | console.error('Life was beautiful then') 86 | console.error('I remember the time I knew what happiness was') 87 | console.error('Let the memory live again') 88 | break 89 | } 90 | }) 91 | } 92 | // execute arbitrary js without returning anything 93 | run(script) { 94 | return new Promise(resolve => 95 | this.evaluate_javascript(script, -1, null, null, null, () => resolve())) 96 | } 97 | eval(exp) { 98 | return new Promise((resolve, reject) => 99 | this.evaluate_javascript(`JSON.stringify(${exp})`, -1, null, null, null, (_, result) => { 100 | try { 101 | const jscValue = this.evaluate_javascript_finish(result) 102 | const str = jscValue.to_string() 103 | const value = str != null ? JSON.parse(str) : null 104 | resolve(value) 105 | } catch (e) { 106 | reject(e) 107 | } 108 | })) 109 | } 110 | // call async function with a parameter object 111 | exec(func, params) { 112 | const token = makeToken() 113 | const script = `(async () => await ${func}(${pass(params)}))() 114 | .then(payload => ${this.#handler}.postMessage( 115 | JSON.stringify({ token: "${token}", ok: true, payload }))) 116 | .catch(e => ${this.#handler}.postMessage( 117 | JSON.stringify({ token: "${token}", ok: false, payload: 118 | e?.message + '\\n' + e?.stack + '\\n' + \`${func}\` })))` 119 | const promise = this.#promises.make(token) 120 | this.evaluate_javascript(script, -1, null, null, null, () => {}) 121 | return promise 122 | } 123 | // call generator, get async generator object 124 | async iter(func, params) { 125 | const name = makeToken() 126 | const instance = `globalThis["${this.#handlerName}"]["${name}"]` 127 | const script = `globalThis["${this.#handlerName}"] ??= {} 128 | ${instance} = ${func}(${pass(params)})` 129 | await this.run(script) 130 | const next = async args => { 131 | const result = await this.exec(`${instance}.next`, args) 132 | if (result.done) await this.run(`delete ${instance}`) 133 | return result 134 | } 135 | return { 136 | next, 137 | [Symbol.asyncIterator]: () => ({ next }), 138 | // technically these should return `IteratorResult`, but do not 139 | return: async () => this.run(`${instance}?.return?.()`), 140 | throw: async () => this.run(`${instance}?.throw?.()`), 141 | } 142 | } 143 | // the revserse of the `exec` method 144 | // scripts in the webview can get response from GJS as a promise 145 | provide(name, callback) { 146 | const handlerName = this.#handlerName + '.' + name 147 | const handler = makeHandlerStr(handlerName) 148 | this.registerHandler(handlerName, ({ token, payload }) => { 149 | Promise.resolve(callback(payload)) 150 | .then(value => this.run( 151 | `globalThis.${name}.resolve("${token}", true, ${pass(value)})`)) 152 | .catch(e => { 153 | console.error(e) 154 | this.run(`globalThis.${name}.resolve("${token}", false)`) 155 | }) 156 | }) 157 | return () => { 158 | const script = `globalThis["${name}"] = (() => { 159 | const makeToken = () => Math.random().toString() 160 | ${PromiseStore.toString()} 161 | const promises = new PromiseStore() 162 | const func = params => { 163 | const token = makeToken() 164 | const promise = promises.make(token) 165 | ${handler}.postMessage(JSON.stringify({ token, payload: params })) 166 | return promise 167 | } 168 | func.resolve = promises.resolve.bind(promises) 169 | return func 170 | })()` 171 | this.run(script) 172 | } 173 | } 174 | registerHandler(name, callback) { 175 | const manager = this.get_user_content_manager() 176 | manager.connect(`script-message-received::${name}`, (_, result) => { 177 | try { 178 | callback(JSON.parse(result.to_string())) 179 | } catch (e) { 180 | console.error(e) 181 | } 182 | }) 183 | const success = manager.register_script_message_handler(name, null) 184 | if (!success) throw new Error('Failed to register script message handler') 185 | } 186 | #load(func, ...args) { 187 | return new Promise((resolve, reject) => { 188 | const changed = this.connect('load-changed', (_, event) => { 189 | if (event === WebKit.LoadEvent.FINISHED) { 190 | this.disconnect(changed) 191 | resolve() 192 | } 193 | }) 194 | const failed = this.connect('load-failed', () => { 195 | this.disconnect(failed) 196 | reject() 197 | }) 198 | func(...args) 199 | }) 200 | } 201 | loadURI(uri) { 202 | return this.#load(this.load_uri.bind(this), uri) 203 | } 204 | loadHTML(html, base = null) { 205 | return this.#load(this.load_html.bind(this), html, base) 206 | } 207 | }) 208 | --------------------------------------------------------------------------------