├── .all-contributorsrc ├── .deepsource.toml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── settings.json └── tasks.json ├── .yamllint.yaml ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── __builtins__.pyi ├── common.py ├── container.py ├── conversion ├── input_config.py ├── input_init.py ├── kepub_input.py ├── kepub_output.py ├── output_config.py └── output_init.py ├── conversion_in_init.py ├── conversion_out_init.py ├── css ├── hyphenation.css.tmpl ├── no-hyphens.css └── style-hacks.css ├── device ├── __init__.py ├── driver.py └── koboextended_config.py ├── device_init.py ├── md_reader_init.py ├── md_writer_init.py ├── metadata ├── __init__.py ├── reader.py └── writer.py ├── pyproject.toml ├── requirements.txt ├── scripts ├── build.sh └── update-calibre.py ├── test_init.py ├── tests ├── __init__.py ├── assertions.py ├── reference_book │ ├── META-INF │ │ └── container.xml │ ├── OEBPS │ │ ├── part 002.xhtml │ │ ├── part001.xhtml │ │ └── part_(003).xhtml │ ├── content.opf │ ├── cover.jpg │ └── toc.ncx ├── test_common.py ├── test_container.py ├── test_device.py └── test_files │ ├── page_dirty_markup.html │ ├── page_github_106.html │ ├── page_github_136.html │ ├── page_github_90.html │ ├── page_needs_cleanup.html │ ├── page_with_kobo_spans.html │ ├── page_without_spans.html │ ├── page_without_spans_with_comments.html │ ├── test.css │ └── test.js └── translations ├── de.po ├── en.po ├── en_CA.po ├── en_US.po ├── es.po ├── fr.po ├── it.po ├── messages.pot ├── nl.po ├── pt.po └── pt_BR.po /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "contributorsSortAlphabetically": true, 3 | "files": [ 4 | "CONTRIBUTORS.md", 5 | "README.md" 6 | ], 7 | "projectName": "calibre-kobo-driver", 8 | "projectOwner": "jgoguen", 9 | "repoType": "github", 10 | "repoHost": "https://github.com", 11 | "skipCi": true, 12 | "contributors": [ 13 | { 14 | "login": "hub2git", 15 | "name": "hub2git", 16 | "avatar_url": "https://avatars3.githubusercontent.com/u/7141051?v=4", 17 | "profile": "https://github.com/hub2git", 18 | "contributions": [ 19 | "doc" 20 | ] 21 | }, 22 | { 23 | "login": "dchawisher", 24 | "name": "dchawisher", 25 | "avatar_url": "https://avatars0.githubusercontent.com/u/22660616?v=4", 26 | "profile": "https://github.com/dchawisher", 27 | "contributions": [ 28 | "bug" 29 | ] 30 | }, 31 | { 32 | "login": "Byte6d65", 33 | "name": "Byte6d65", 34 | "avatar_url": "https://avatars3.githubusercontent.com/u/66903648?v=4", 35 | "profile": "https://github.com/Byte6d65", 36 | "contributions": [ 37 | "bug" 38 | ] 39 | }, 40 | { 41 | "login": "NiLuJe", 42 | "name": "NiLuJe", 43 | "avatar_url": "https://avatars3.githubusercontent.com/u/111974?v=4", 44 | "profile": "https://github.com/NiLuJe", 45 | "contributions": [ 46 | "code" 47 | ] 48 | }, 49 | { 50 | "login": "davidfor", 51 | "name": "David", 52 | "avatar_url": "https://avatars0.githubusercontent.com/u/4010598?v=4", 53 | "profile": "https://github.com/davidfor", 54 | "contributions": [ 55 | "code" 56 | ] 57 | } 58 | ], 59 | "contributorsPerLine": 7 60 | } 61 | -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "shell" 5 | 6 | [[analyzers]] 7 | name = "python" 8 | 9 | [analyzers.meta] 10 | runtime_version = "3.x.x" 11 | additional_builtins = ["_", "ngettext"] 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | issuehunt: jgoguen 3 | ko_fi: jgoguen 4 | liberapay: jgoguen 5 | custom: ["https://paypal.me/jtgoguen", "https://www.buymeacoffee.com/jgoguen"] 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an error in the current functionality of a plugin from this repository 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | # Bug Checklist 11 | 12 | These items are mandatory. If you need help finding this information submit the 13 | bug report with as much completed as you can and ask for help finding the rest. 14 | 15 | - [ ] I am using the latest version of calibre to report this bug, which is: 16 | - [ ] I am using an official calibre release, not one from a third party (e.g. 17 | your Linux distro, Flatpak, Chocolatey package, Homebrew, etc.) 18 | - [ ] I am using the latest version of this plugin, which is: 19 | - [ ] My operating system is (e.g. Windows 10, Windows 8.1, Windows 8, macOS 20 | 10.15.5, Fedora 32, Arch Linux, etc.): 21 | - [ ] I have included the full, complete, unmodified debug log from calibre 22 | - Directions for getting the debug log are under the "Logs" header below. 23 | - [ ] I have translated the text in any screenshots and logs to English, or all 24 | screenshots and logs included are in English. 25 | 26 | These items are optional. Fill in as much of them as possible. If something is 27 | not applicable to your bug report, note that. 28 | 29 | - [ ] I have installed the Scramble Epub plugin (see 30 | https://www.mobileread.com/forums/showthread.php?t=267998) and will attach 31 | a **scrambled** copy of the book I'm having problems with (attach a file by 32 | dragging and dropping onto the Github editor). 33 | - [ ] If this is a conversion bug, I will also attach a **scrambled** copy of 34 | the converted book. 35 | - [ ] The path to my calibre library or to a book in my calibre library has 36 | non-ASCII characters: yes/no 37 | - [ ] If I am using Windows 10, I (have/have not) enabled Windows' beta support 38 | for Unicode (see 39 | https://www.mobileread.com/forums/showpost.php?p=3988195&postcount=2052) 40 | - [ ] If I am using Windows 10, does this bug happens with beta Unicode support 41 | both enabled and disabled, only when enabled, or only when disabled? 42 | 43 | # Describe the bug 44 | 45 | A clear and concise description of what the bug is. 46 | 47 | ## Steps to Reproduce 48 | 49 | Steps to reproduce the behavior (as detailed as you can): 50 | 51 | 1. Go to '...' 52 | 1. Click on '....' 53 | 1. Scroll down to '....' 54 | 1. See error 55 | 56 | ## Expected behavior 57 | 58 | A clear and concise description of what you expected to happen. 59 | 60 | ## Actual behaviour 61 | 62 | A clear and concise description of the actual behaviour you observe. This may be a summary of the bug description. 63 | 64 | ## Screenshots 65 | 66 | If applicable, add screenshots to help explain your problem. If you are using 67 | calibre in any language other than English, please either provide a translation 68 | of any relevant text to English or switch calibre to use English first. 69 | 70 | ## Logs 71 | 72 | Restart calibre in debug mode. Paste the full calibre debug log here. To get the 73 | debug log: 74 | 75 | 1. Find the `Preferences` button in the calibre toolbar. 76 | 1. Click the arrow to the right of `Preferences`. 77 | 1. Select the `Restart in debug mode` menu item. 78 | 1. This will shut down and restart calibre immediately. Calibre will 79 | automatically restart, but it may take a few seconds. 80 | 1. You will see a notification informing you that you have started calibre 81 | in debug mode. Click `OK`. 82 | 1. Do the minimum possible steps to reproduce the bug. 83 | 1. Close calibre. 84 | 1. This will automatically display the debug log. Copy and paste the entire 85 | log in the blank space here (between `` ```text `` and `` ``` ``) 86 | 87 | ```text 88 | 89 | ``` 90 | 91 | ## Additional context 92 | 93 | Add any other information you think might be helpful. 94 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature Request/Enhancement" 3 | about: Request a new feature or a change/enhancement to an existing feature 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | # Description 11 | 12 | Describe your requested new feature, or feature enhancement, in as much detail 13 | as you can. If your proposed change involves book conversion and you can show 14 | where in a book it would be useful, install the Scramble Epub plugin (see 15 | https://www.mobileread.com/forums/showthread.php?t=267998), attach a scrambled 16 | KePub book, and describe where in the book I should look and what you expect 17 | the result should be. 18 | 19 | ## Which plugins 20 | 21 | Which plugin(s) does this apply to? 22 | 23 | ## Workarounds 24 | 25 | If this feature request will make some part of your workflow with calibre and 26 | KePub books easier, what are you doing right now to achieve the same end result? 27 | 28 | ## How useful could this be 29 | 30 | Make your best guess at how useful this might be to other people using these 31 | plugins, and if you think it might be useful to relatively few people or to a 32 | lot of people and why. 33 | 34 | ## Additional context 35 | 36 | Add any other information you think might be helpful. 37 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request: (short title summary) 2 | 3 | ## Description of change 4 | 5 | A complete description of what you are changing. Include the issue number which 6 | this PR will close (create an issue first if you have not already done so). The 7 | issue should describe the problem or proposed new feature, the pull request 8 | should describe in detail what is being changed. There may be sometimes 9 | significant overlap between what you write in the issue and what you write here; 10 | that's OK! 11 | 12 | ## Test results 13 | 14 | Changes to code need to come with updates to unit tests as much as is feasible. 15 | Include the results of your testing (on macOS and Linux, the output of running 16 | `make test`). If you are not changing code, just delete this section. 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vim: fileencoding=UTF-8:expandtab:autoindent:ts=2:sts=2:sw=2:filetype=yaml 3 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow 4 | name: CI 5 | 6 | # Controls when the action will run. Workflow runs when manually triggered 7 | # using the UI or API. 8 | "on": 9 | push: 10 | pull_request: 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or 13 | # in parallel 14 | jobs: 15 | test: 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: 21 | - "ubuntu-latest" 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 1 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.11" 32 | 33 | - name: Install dependencies 34 | run: | 35 | PLATFORM=$(uname) 36 | if [ "${PLATFORM}" = "Linux" ]; then 37 | sudo apt-get update 38 | sudo apt-get install -y zsh gettext libegl1 libopengl0 libglx0 libxkbcommon0 39 | fi 40 | 41 | # yamllint disable rule:line-length 42 | - name: Set environment variables 43 | run: | 44 | CALIBRE_DIR="$(mktemp -d XXXXXXXX)" 45 | printf 'CALIBRE_DIR="%s"\n' "${CALIBRE_DIR}" >>"${GITHUB_ENV}" 46 | printf 'CALIBRE_CONFIG_DIRECTORY="%s"\n' "${CALIBRE_DIR}/config" >>"${GITHUB_ENV}" 47 | printf 'CALIBRE_TEMP_DIR="%s"\n' "${CALIBRE_DIR}/tmp" >>"${GITHUB_ENV}" 48 | # yamllint enable rule:line-length 49 | - name: Create calibre directories 50 | run: | 51 | mkdir -p "${CALIBRE_CONFIG_DIRECTORY}" "${CALIBRE_TEMP_DIR}" 52 | 53 | - name: Install calibre from upstream 54 | run: ./scripts/update-calibre.py 55 | 56 | - name: Build plugin ZIP files 57 | run: ./scripts/build.sh 58 | 59 | - name: Install Python test dependencies 60 | run: | 61 | set -x 62 | python3 -m pip install --upgrade pip 63 | if [ -f requirements.txt ]; then 64 | python3 -m pip install -r requirements.txt 65 | fi 66 | if [ -f scripts/requirements.txt ]; then 67 | python3 -m pip install -r scripts/requirements.txt 68 | fi 69 | if [ -f tests/requirements.txt ]; then 70 | python3 -m pip install -r tests/requirements.txt 71 | fi 72 | 73 | - name: Run tests 74 | run: ./scripts/build.sh test 75 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vim: fileencoding=UTF-8:expandtab:autoindent:ts=2:sts=2:sw=2:filetype=yaml 3 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow 4 | name: Lint 5 | 6 | "on": 7 | push: 8 | branches: 9 | - "*" 10 | pull_request: 11 | 12 | jobs: 13 | ruff: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 1 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.11" 25 | 26 | - name: Install Ruff 27 | run: pip install ruff 28 | 29 | - name: Lint Python 30 | run: | 31 | ruff check --output-format github 32 | ruff format --diff 33 | 34 | shellcheck: 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 1 41 | 42 | - name: Install ShellCheck 43 | run: | 44 | sudo apt-get update 45 | sudo apt-get install -y zsh shellcheck 46 | 47 | - name: Run ShellCheck 48 | run: shellcheck --severity=error --shell=sh **/*.sh 49 | 50 | yamllint: 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | with: 56 | fetch-depth: 1 57 | 58 | - name: Set up Python 59 | uses: actions/setup-python@v5 60 | with: 61 | python-version: "3.11" 62 | 63 | - name: Install yamllint 64 | run: pip install yamllint 65 | 66 | - name: Run yamllint 67 | run: yamllint -f colored ./.github/workflows/ 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vim: fileencoding=UTF-8:expandtab:autoindent:ts=2:sts=2:sw=2:filetype=yaml 3 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow 4 | name: Populate Release 5 | 6 | "on": 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | jobs: 12 | upload-assets: 13 | runs-on: "ubuntu-latest" 14 | 15 | steps: 16 | - name: Install repo dependencies 17 | run: | 18 | sudo apt-get update 19 | sudo apt-get install -y gettext 20 | 21 | - name: Check out release tag 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 1 25 | 26 | - name: Set up Python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.11" 30 | 31 | - name: Fetch calibre 32 | run: ./scripts/update-calibre.py 33 | 34 | - name: Build ZIP files 35 | run: ./scripts/build.sh build 36 | 37 | - name: Create GitHub release 38 | if: runner.environment == 'github-hosted' 39 | uses: https://github.com/softprops/action-gh-release@v2 40 | with: 41 | files: "release/*.zip" 42 | make_latest: true 43 | generate_release_notes: true 44 | 45 | - name: Create Forgejo release 46 | if: runner.environment != 'github-hosted' 47 | uses: https://code.forgejo.org/actions/forgejo-release@v2 48 | with: 49 | direction: upload 50 | token: ${{ secrets.TOKEN }} 51 | tag: ${{ github.ref_name }} 52 | release-dir: release 53 | release-notes-assistant: true 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.pyd 4 | *.pyw 5 | calibre 6 | calibre-py[23] 7 | 8 | # IDE files 9 | .settings 10 | .includepath 11 | .classpath 12 | *.xcodeproj 13 | *.kpf 14 | *.komodoproject 15 | .komodotools 16 | 17 | # CTags 18 | .tags 19 | .tags_sorted_by_file 20 | 21 | test-books/ 22 | /*.zip 23 | TODO 24 | .checkstyle 25 | /__init__.py 26 | .DS_Store 27 | translations/*.mo 28 | venv 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | default_language_version: 3 | python: python3 4 | minimum_pre_commit_version: "2.7.1" 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: check-case-conflict 10 | - id: mixed-line-ending 11 | args: 12 | - "--fix=lf" 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | rev: v0.8.4 15 | hooks: 16 | # Linter 17 | - id: ruff 18 | args: 19 | - "--fix" 20 | - "--show-fixes" 21 | # Formatter 22 | - id: ruff-format 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.autoComplete.extraPaths": [ 3 | "${workspaceFolder:calibre}/src" 4 | ], 5 | "python.pythonPath": "/usr/bin/python3", 6 | "python.analysis.extraPaths": [ 7 | "${workspaceFolder:calibre}/src" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Build", 8 | "type": "process", 9 | "command": "/usr/bin/make", 10 | "args": [ 11 | "-f", 12 | { 13 | "quoting": "strong", 14 | "value": "${workspaceFolder}/Makefile" 15 | }, 16 | "build" 17 | ], 18 | "options": { 19 | "cwd": "${workspaceFolder}" 20 | }, 21 | "presentation": { 22 | "clear": true, 23 | "panel": "dedicated" 24 | }, 25 | "group": { 26 | "kind": "build", 27 | "isDefault": true 28 | } 29 | }, 30 | { 31 | "label": "Run Python 2 Unit Tests", 32 | "type": "process", 33 | "command": "/usr/bin/make", 34 | "args": [ 35 | "-f", 36 | { 37 | "quoting": "strong", 38 | "value": "${workspaceFolder}/Makefile" 39 | }, 40 | "build", 41 | "test_py2" 42 | ], 43 | "options": { 44 | "cwd": "${workspaceFolder}", 45 | "env": { 46 | "PYTHONDONTWRITEBYTECODE": "true" 47 | } 48 | }, 49 | "presentation": { 50 | "clear": true, 51 | "panel": "dedicated" 52 | }, 53 | "group": "test" 54 | }, 55 | { 56 | "label": "Run Python 3 Unit Tests", 57 | "type": "process", 58 | "command": "/usr/bin/make", 59 | "args": [ 60 | "-f", 61 | { 62 | "quoting": "strong", 63 | "value": "${workspaceFolder}/Makefile" 64 | }, 65 | "build", 66 | "test_py3" 67 | ], 68 | "options": { 69 | "cwd": "${workspaceFolder}", 70 | "env": { 71 | "PYTHONDONTWRITEBYTECODE": "true" 72 | } 73 | }, 74 | "presentation": { 75 | "clear": true, 76 | "panel": "dedicated" 77 | }, 78 | "group": "test" 79 | }, 80 | { 81 | "label": "Run Unit Tests", 82 | "type": "process", 83 | "command": "/usr/bin/make", 84 | "args": [ 85 | "-f", 86 | { 87 | "quoting": "strong", 88 | "value": "${workspaceFolder}/Makefile" 89 | }, 90 | "test" 91 | ], 92 | "presentation": { 93 | "clear": true, 94 | "panel": "dedicated" 95 | }, 96 | "problemMatcher": [], 97 | "group": { 98 | "kind": "test", 99 | "isDefault": true 100 | } 101 | } 102 | ] 103 | } 104 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | line-length: 4 | max: 120 5 | allow-non-breakable-words: true 6 | allow-non-breakable-inline-mappings: true 7 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

hub2git

📖

dchawisher

🐛

Byte6d65

🐛

NiLuJe

💻

David

💻
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /__builtins__.pyi: -------------------------------------------------------------------------------- 1 | def _(s: str) -> str: ... 2 | def ngettext(single: str, plural: str, count: int) -> str: ... 3 | def get_resources(name: str) -> bytes: ... 4 | def load_translations() -> None: ... 5 | -------------------------------------------------------------------------------- /common.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Common functions and variables needed by more than one plugin.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2013, Joel Goguen " 7 | __docformat__ = "markdown en" 8 | 9 | # Be careful editing this! This file has to work in multiple packages at once, 10 | # so don't import anything from calibre_plugins 11 | 12 | import os 13 | import re 14 | import sys 15 | import time 16 | import traceback 17 | from functools import partial 18 | 19 | from calibre import prints 20 | from calibre.constants import config_dir 21 | from calibre.constants import preferred_encoding 22 | from calibre.ebooks.metadata.book.base import Metadata 23 | from calibre.ebooks.metadata.book.base import NULL_VALUES 24 | from calibre.ebooks.oeb.polish.container import EpubContainer 25 | from calibre.ebooks.oeb.polish.container import OPF_NAMESPACES 26 | from calibre.ptempfile import PersistentTemporaryFile 27 | from calibre.utils.logging import ANSIStream 28 | from polyglot.builtins import is_py3 29 | from polyglot.io import PolyglotStringIO 30 | 31 | # lxml isn't great, but I don't have access to defusedxml 32 | from lxml.etree import _Element # skipcq: BAN-B410 33 | 34 | if is_py3: 35 | from typing import Dict 36 | from typing import List 37 | from typing import Optional 38 | from typing import Union 39 | 40 | KOBO_JS_RE = re.compile(r".*/?kobo.*?\.js$", re.IGNORECASE) 41 | XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" 42 | CONFIGDIR = os.path.join(config_dir, "plugins") 43 | REFERENCE_KEPUB = os.path.join(CONFIGDIR, "reference.kepub.epub") 44 | PLUGIN_VERSION = (3, 7, 2) 45 | PLUGIN_MINIMUM_CALIBRE_VERSION = (5, 0, 0) 46 | 47 | 48 | class Logger: 49 | LEVELS = {"DEBUG": 0, "INFO": 1, "WARN": 2, "ERROR": 3} 50 | 51 | def __init__(self) -> None: 52 | self.log_level = "INFO" 53 | if ( 54 | "CALIBRE_DEVELOP_FROM" in os.environ 55 | or "CALIBRE_DEBUG" in os.environ 56 | or "calibre-debug" in sys.argv[0] 57 | ): 58 | self.log_level = "DEBUG" 59 | 60 | # According to Kovid, calibre always uses UTF-8 for the Python 3 version 61 | self.preferred_encoding = "UTF-8" if is_py3 else preferred_encoding 62 | self.outputs = [ANSIStream()] 63 | 64 | self.debug = partial(self.print_formatted_log, "DEBUG") 65 | self.info = partial(self.print_formatted_log, "INFO") 66 | self.warn = self.warning = partial(self.print_formatted_log, "WARN") 67 | self.error = partial(self.print_formatted_log, "ERROR") 68 | 69 | def __call__(self, logmsg) -> None: 70 | self.info(logmsg) 71 | 72 | @staticmethod 73 | def _tag_args(level: str, *args: str) -> List[str]: 74 | now = time.localtime() 75 | buf = PolyglotStringIO() 76 | tagged_args: List[str] = [] 77 | for arg in args: 78 | prints(time.strftime("%Y-%m-%d %H:%M:%S", now), file=buf, end=" ") 79 | buf.write("[") 80 | prints(level, file=buf, end="") 81 | buf.write("] ") 82 | prints(arg, file=buf, end="") 83 | 84 | tagged_args.append(buf.getvalue()) 85 | buf.truncate(0) 86 | 87 | return tagged_args 88 | 89 | def _prints(self, level: str, *args, **kwargs) -> None: 90 | for o in self.outputs: 91 | o.prints(self.LEVELS[level], *args, **kwargs) 92 | if hasattr(o, "flush"): 93 | o.flush() 94 | 95 | def print_formatted_log(self, level: str, *args, **kwargs) -> None: 96 | tagged_args = self._tag_args(level, *args) 97 | self._prints(level, *tagged_args, **kwargs) 98 | 99 | def exception(self, *args, **kwargs) -> None: 100 | limit = kwargs.pop("limit", None) 101 | tagged_args = self._tag_args("ERROR", *args) 102 | self._prints("ERROR", *tagged_args, **kwargs) 103 | self._prints("ERROR", traceback.format_exc(limit)) 104 | 105 | 106 | log = Logger() 107 | 108 | 109 | # The logic here to detect a cover image is mostly duplicated from 110 | # metadata/writer.py. Updates to the logic here probably need an accompanying 111 | # update over there. 112 | def modify_epub( 113 | container: EpubContainer, 114 | filename: str, 115 | metadata: Optional[Metadata] = None, 116 | opts: Optional[Dict[str, Union[str, bool]]] = None, 117 | ) -> None: 118 | """Modify the ePub file to make it KePub-compliant.""" 119 | _modify_start = time.time() 120 | opts = opts or {} 121 | 122 | # Search for the ePub cover 123 | # TODO: Refactor out cover detection logic so it can be directly used in 124 | # metadata/writer.py 125 | found_cover = False 126 | opf: _Element = container.opf 127 | cover_meta_node_list: List[_Element] = opf.xpath( 128 | './opf:metadata/opf:meta[@name="cover"]', namespaces=OPF_NAMESPACES 129 | ) 130 | 131 | if len(cover_meta_node_list) > 0: 132 | cover_meta_node: _Element = cover_meta_node_list[0] 133 | cover_id = cover_meta_node.attrib.get("content", None) 134 | 135 | log.debug("Found meta node with name=cover") 136 | 137 | if cover_id: 138 | log.info(f"Found cover image ID '{cover_id}'") 139 | 140 | cover_node_list: _Element = opf.xpath( 141 | f'./opf:manifest/opf:item[@id="{cover_id}"]', 142 | namespaces=OPF_NAMESPACES, 143 | ) 144 | if len(cover_node_list) > 0: 145 | cover_node: _Element = cover_node_list[0] 146 | 147 | log.debug("Found an item node with cover ID") 148 | 149 | if cover_node.attrib.get("properties", "") != "cover-image": 150 | log.info("Setting cover-image property") 151 | cover_node.set("properties", "cover-image") 152 | container.dirty(container.opf_name) 153 | else: 154 | log.warning("Item node is already set as cover-image") 155 | found_cover = True 156 | 157 | # It's possible that the cover image can't be detected this way. Try 158 | # looking for the cover image ID in the OPF manifest. 159 | if not found_cover: 160 | log.debug("Looking for cover image in OPF manifest") 161 | 162 | node_list: List[_Element] = opf.xpath( 163 | "./opf:manifest/opf:item[(translate(@id, " 164 | + "'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')" 165 | + '="cover" or starts-with(translate(@id, ' 166 | + "'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')" 167 | + ', "cover")) and starts-with(@media-type, "image")]', 168 | namespaces=OPF_NAMESPACES, 169 | ) 170 | if len(node_list) > 0: 171 | log.info( 172 | f"Found {len(node_list)} nodes, assuming the first is the right node" 173 | ) 174 | 175 | node: _Element = node_list[0] 176 | if node.attrib.get("properties", "") != "cover-image": 177 | log.info("Setting cover-image property") 178 | node.set("properties", "cover-image") 179 | container.dirty(container.opf_name) 180 | else: 181 | log.warning("Item node is already set as cover-image") 182 | found_cover = True 183 | 184 | # Hyphenate files? 185 | if opts.get("no-hyphens", False): 186 | nohyphen_css = PersistentTemporaryFile(suffix="_nohyphen", prefix="kepub_") 187 | nohyphen_css.write(get_resources("css/no-hyphens.css")) 188 | nohyphen_css.close() 189 | 190 | css_path = os.path.basename( 191 | container.copy_file_to_container( 192 | nohyphen_css.name, name="kte-css/no-hyphens.css" 193 | ) 194 | ) 195 | container.add_content_file_reference(f"kte-css/{css_path}") 196 | os.unlink(nohyphen_css.name) 197 | elif opts.get("hyphenate", False) and int(opts.get("hyphen_min_chars", 6)) > 0: 198 | if metadata and metadata.language == NULL_VALUES["language"]: 199 | log.warning( 200 | "Hyphenation is enabled but not overriding content file " 201 | + "language. Hyphenation may use the wrong dictionary." 202 | ) 203 | hyphen_css = PersistentTemporaryFile(suffix="_hyphenate", prefix="kepub_") 204 | css_template = get_resources("css/hyphenation.css.tmpl").decode() 205 | hyphen_limit_lines = opts.get("hyphen_limit_lines", 2) 206 | if hyphen_limit_lines == 0: 207 | hyphen_limit_lines = "no-limit" 208 | hyphen_css.write( 209 | css_template.format( 210 | hyphen_min_chars=opts.get("hyphen_min_chars"), 211 | hyphen_min_chars_before=opts.get("hyphen_min_chars_before", 3), 212 | hyphen_min_chars_after=opts.get("hyphen_min_chars_after", 3), 213 | hyphen_limit_lines=hyphen_limit_lines, 214 | ).encode() 215 | ) 216 | hyphen_css.close() 217 | 218 | css_path = os.path.basename( 219 | container.copy_file_to_container( 220 | hyphen_css.name, name="kte-css/hyphenation.css" 221 | ) 222 | ) 223 | container.add_content_file_reference(f"kte-css/{css_path}") 224 | os.unlink(hyphen_css.name) 225 | 226 | # Now smarten punctuation 227 | if opts.get("smarten_punctuation", False): 228 | container.smarten_punctuation() 229 | 230 | if opts.get("extended_kepub_features", True): 231 | if metadata is not None: 232 | log.info( 233 | f"Adding extended Kobo features to {metadata.title} by " 234 | + " and ".join(metadata.authors) 235 | ) 236 | 237 | # Add the Kobo span and div tags 238 | container.convert() 239 | 240 | # Check to see if there's already a kobo*.js in the ePub 241 | skip_js = False 242 | for name in container.name_path_map: 243 | if KOBO_JS_RE.match(name): 244 | skip_js = True 245 | break 246 | 247 | if os.path.isfile(REFERENCE_KEPUB) and not skip_js: 248 | reference_container = EpubContainer(REFERENCE_KEPUB, log) 249 | for name in reference_container.name_path_map: 250 | if KOBO_JS_RE.match(name): 251 | jsname = container.copy_file_to_container( 252 | os.path.join(reference_container.root, name), name="kobo.js" 253 | ) 254 | container.add_content_file_reference(jsname) 255 | break 256 | 257 | # Add the Kobo style hacks 258 | stylehacks_css = PersistentTemporaryFile(suffix="_stylehacks", prefix="kepub_") 259 | stylehacks_css.write(get_resources("css/style-hacks.css")) 260 | stylehacks_css.close() 261 | 262 | css_path = os.path.basename( 263 | container.copy_file_to_container( 264 | stylehacks_css.name, name="kte-css/stylehacks.css" 265 | ) 266 | ) 267 | container.add_content_file_reference(f"kte-css/{css_path}") 268 | os.unlink(filename) 269 | container.commit(filename) 270 | 271 | _modify_time = time.time() - _modify_start 272 | log.info(f"modify_epub took {_modify_time:0.2f} seconds") 273 | 274 | 275 | def intValueChanged(widget, singular, plural, *args, **kwargs): 276 | from PyQt5 import QtWidgets 277 | 278 | if isinstance(widget, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox)): 279 | widget.setSuffix(" " + ngettext(singular, plural, widget.value())) 280 | -------------------------------------------------------------------------------- /conversion/input_config.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Configuration for reading KePub files.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2015, David Forrester " 7 | __docformat__ = "markdown en" 8 | 9 | from calibre.ebooks.conversion.config import OPTIONS 10 | from calibre.gui2.convert import Widget 11 | from calibre.gui2.convert.epub_output_ui import Ui_Form as EPUBUIForm 12 | from calibre.gui2.preferences.conversion import OutputOptions as BaseOutputOptions 13 | 14 | 15 | try: 16 | from PyQt5.Qt import QIcon 17 | from PyQt5 import Qt as QtGui 18 | from PyQt5 import QtCore 19 | except ImportError: 20 | from PyQt4.Qt import QIcon 21 | from PyQt4 import QtCore, QtGui 22 | 23 | try: 24 | load_translations() 25 | except NameError: 26 | pass 27 | 28 | 29 | class PluginWidget(Widget, EPUBUIForm): 30 | """Configuration widget for KePub input parser.""" 31 | 32 | TITLE = "KePub Input" 33 | COMMIT_NAME = "kepub_input" 34 | ICON = I("mimetypes/epub.png") # noqa: F821 - defined by calibre 35 | HELP = _("Options specific to KePub input.") # noqa: F821 - calibre 36 | 37 | def __init__(self, parent, get_option, get_help, db=None, book_id=None): 38 | """Initialize KePub input configuration.""" 39 | Widget.__init__( 40 | self, 41 | parent, 42 | OPTIONS["input"].get("epub", ()) + ("strip_kobo_spans",), 43 | ) 44 | 45 | if book_id: 46 | self._icon = QIcon(I("forward.png")) # noqa: F821 - calibre 47 | self.initialize_options(get_option, get_help, db, book_id) 48 | 49 | def setupUi(self, Form): # noqa: N802, N803 50 | """Set up configuration UI.""" 51 | super(PluginWidget, self).setupUi(Form) 52 | 53 | rows = self.gridLayout.rowCount() - 1 54 | 55 | spacer = self.gridLayout.itemAtPosition(rows, 0) 56 | self.gridLayout.removeItem(spacer) 57 | 58 | self.opt_strip_kobo_spans = QtGui.QCheckBox(Form) # skipcq: PYL-W0201 59 | self.opt_strip_kobo_spans.setObjectName("opt_strip_kobo_spans") 60 | self.opt_strip_kobo_spans.setText(_("Strip Kobo spans")) # noqa: F821 61 | self.gridLayout.addWidget(self.opt_strip_kobo_spans, rows, 0, 1, 1) 62 | rows = rows + 1 63 | 64 | # Next options here 65 | self.gridLayout.addItem(spacer, rows, 0, 1, 1) 66 | 67 | # Copy from calibre.gui2.convert.epub_output_ui.Ui_Form to make the 68 | # new additions work 69 | QtCore.QMetaObject.connectSlotsByName(Form) 70 | 71 | 72 | class OutputOptions(BaseOutputOptions): 73 | """This allows adding our options to the input process.""" 74 | 75 | def load_conversion_widgets(self): 76 | """Add our configuration to the input processing.""" 77 | super(OutputOptions, self).load_conversion_widgets() 78 | self.conversion_widgets.append(PluginWidget) 79 | self.conversion_widgets = sorted( # skipcq: PYL-W0201 80 | self.conversion_widgets, key=lambda x: x.TITLE 81 | ) 82 | -------------------------------------------------------------------------------- /conversion/input_init.py: -------------------------------------------------------------------------------- 1 | from calibre.customize.builtins import plugins 2 | 3 | for plugin in plugins: 4 | if plugin.name == "Input Options": 5 | plugin.config_widget = ( 6 | "calibre_plugins.kepubin.conversion.input_config:InputOptions" 7 | ) 8 | break 9 | -------------------------------------------------------------------------------- /conversion/kepub_input.py: -------------------------------------------------------------------------------- 1 | """Input processing of KePub files.""" 2 | 3 | __license__ = "GPL v3" 4 | __copyright__ = "2015, David Forrester " 5 | __docformat__ = "markdown en" 6 | 7 | import os 8 | from typing import Optional 9 | from typing import Set 10 | from typing import Tuple 11 | 12 | from calibre.customize.conversion import OptionRecommendation 13 | from calibre.ebooks.conversion.plugins.epub_input import EPUBInput 14 | 15 | from calibre_plugins.kepubin import common 16 | 17 | # Support load_translations() without forcing calibre 1.9+ 18 | try: 19 | load_translations() 20 | except NameError: 21 | pass 22 | 23 | 24 | class KEPUBInput(EPUBInput): 25 | """Extension of calibre's EPUBInput to understand KePub format books.""" 26 | 27 | name = "KePub Input" 28 | description = "Convert KEPUB files (.kepub) to HTML" 29 | author = "David Forrester" 30 | file_types = {"kepub"} 31 | version = common.PLUGIN_VERSION 32 | minimum_calibre_version = (0, 1, 0) 33 | 34 | kepub_options = { 35 | OptionRecommendation( 36 | name="strip_kobo_spans", 37 | recommended_value=True, 38 | help=_( 39 | "Kepubs have spans wrapping each sentence. These are used by " 40 | + "the ereader for the reading location and bookmark location. " 41 | + "They are not used by an ePub reader but are valid code and " 42 | + "can be safely be left in the ePub. If you plan to edit the " 43 | + "ePub, it is recommended that you remove the spans." 44 | ), 45 | ) 46 | } 47 | 48 | kepub_recommendations: Set[Tuple[str, bool, int]] = { 49 | ("strip_kobo_spans", True, OptionRecommendation.LOW) 50 | } 51 | 52 | def __init__(self, *args, **kwargs): 53 | self.removed_cover: Optional[str] = None 54 | super(KEPUBInput, self).__init__(*args, **kwargs) 55 | self.options = self.options.union(self.kepub_options) 56 | self.recommendations: Set[Tuple[str, bool, int]] = self.recommendations.union( 57 | self.kepub_recommendations 58 | ) 59 | 60 | @staticmethod 61 | def gui_configuration_widget( 62 | parent, get_option_by_name, get_option_help, db, book_id=None 63 | ): 64 | """Set up the input processor's configuration widget.""" 65 | from calibre_plugins.kepubin.conversion.input_config import PluginWidget 66 | 67 | return PluginWidget(parent, get_option_by_name, get_option_help, db, book_id) 68 | 69 | def convert(self, stream, _options, _file_ext, log, _accelerators): 70 | """Convert a KePub file into a structure calibre can process.""" 71 | log("KEPUBInput::convert - start") 72 | from calibre.utils.zipfile import ZipFile 73 | from calibre import walk 74 | from calibre.ebooks import DRMError 75 | from calibre.ebooks.metadata.opf2 import OPF 76 | 77 | try: 78 | zf = ZipFile(stream) 79 | cwd = os.getcwd() 80 | zf.extractall(cwd) 81 | except Exception: 82 | log.exception( 83 | "KEPUB appears to be invalid ZIP file, trying a " 84 | + "more forgiving ZIP parser" 85 | ) 86 | from calibre.utils.localunzip import extractall 87 | 88 | stream.seek(0) 89 | extractall(stream) 90 | opf = self.find_opf() 91 | if opf is None: 92 | for f in walk("."): 93 | if ( 94 | f.lower().endswith(".opf") 95 | and "__MACOSX" not in f 96 | and not os.path.basename(f).startswith(".") 97 | ): 98 | opf = os.path.abspath(f) 99 | break 100 | path = getattr(stream, "name", "stream") 101 | 102 | if opf is None: 103 | raise ValueError( 104 | _( # noqa: F821 105 | "{0} is not a valid KEPUB file (could not find opf)" 106 | ).format(path) 107 | ) 108 | 109 | encfile = os.path.abspath("rights.xml") 110 | if os.path.exists(encfile): 111 | raise DRMError(os.path.basename(path)) 112 | 113 | cwd = os.getcwd() 114 | opf = os.path.relpath(opf, cwd) 115 | parts = os.path.split(opf) 116 | opf = OPF(opf, os.path.dirname(os.path.abspath(opf))) 117 | 118 | if len(parts) > 1 and parts[0]: 119 | delta = "/".join(parts[:-1]) + "/" 120 | for elem in opf.itermanifest(): 121 | elem.set("href", delta + elem.get("href")) 122 | for elem in opf.iterguide(): 123 | elem.set("href", delta + elem.get("href")) 124 | 125 | f = ( 126 | self.rationalize_cover3 127 | if opf.package_version >= 3.0 128 | else self.rationalize_cover2 129 | ) 130 | self.removed_cover = f(opf, log) 131 | 132 | for x in opf.itermanifest(): 133 | if x.get("media-type", "") == "application/x-dtbook+xml": 134 | raise ValueError( 135 | _("EPUB files with DTBook markup are not supported") # noqa: F821 136 | ) 137 | 138 | not_for_spine = set() 139 | for y in opf.itermanifest(): 140 | id_ = y.get("id", None) 141 | if id_ and y.get("media-type", None) in { 142 | "application/vnd.adobe-page-template+xml", 143 | "application/vnd.adobe.page-template+xml", 144 | "application/adobe-page-template+xml", 145 | "application/adobe.page-template+xml", 146 | "application/text", 147 | }: 148 | not_for_spine.add(id_) 149 | 150 | seen = set() 151 | for x in list(opf.iterspine()): 152 | ref = x.get("idref", None) 153 | if not ref or ref in not_for_spine or ref in seen: 154 | x.getparent().remove(x) 155 | continue 156 | seen.add(ref) 157 | 158 | if len(list(opf.iterspine())) == 0: 159 | raise ValueError( 160 | _("No valid entries in the spine of this EPUB") # noqa: F821 161 | ) 162 | 163 | with open("content.opf", "wb") as nopf: 164 | nopf.write(opf.render()) 165 | 166 | return os.path.abspath("content.opf") 167 | 168 | def postprocess_book(self, oeb, opts, log): 169 | """Perform any needed post-input processing on the book.""" 170 | log("KEPUBInput::postprocess_book - start") 171 | from calibre.ebooks.oeb.base import XHTML_NS 172 | 173 | # The Kobo spans wrap each sentence. Remove them and add their text to 174 | # the parent tag. 175 | def refactor_span(a): 176 | p = a.getparent() 177 | idx = p.index(a) - 1 178 | p.remove(a) 179 | 180 | if idx < 0: 181 | if p.text is None: 182 | p.text = "" 183 | p.text += a.text if a.text else "" 184 | p.text += a.tail if a.tail else "" 185 | else: 186 | if p[idx].tail is None: 187 | p[idx].tail = "" 188 | p[idx].tail += a.text if a.text else "" 189 | p[idx].tail += a.tail if a.tail else "" 190 | 191 | super(KEPUBInput, self).postprocess_book(oeb, opts, log) 192 | 193 | if not opts.strip_kobo_spans: 194 | log("KEPUBInput::postprocess_book - not stripping kobo spans") 195 | return 196 | 197 | for item in oeb.spine: 198 | log("item.__class__.__name__", item.__class__.__name__) 199 | if not hasattr(item.data, "xpath"): 200 | continue 201 | 202 | for a in item.data.xpath( 203 | '//h:span[@class="koboSpan"]', namespaces={"h": XHTML_NS} 204 | ): 205 | refactor_span(a) 206 | 207 | log("KEPUBInput::postprocess_book - end") 208 | -------------------------------------------------------------------------------- /conversion/kepub_output.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Output processing for KePub files.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2013, Joel Goguen " 7 | __docformat__ = "markdown en" 8 | 9 | import json 10 | import os 11 | from datetime import datetime 12 | from typing import Any 13 | from typing import Set 14 | from typing import Tuple 15 | 16 | from calibre.constants import config_dir 17 | from calibre.customize.conversion import OptionRecommendation 18 | from calibre.customize.conversion import OutputFormatPlugin 19 | from calibre.ebooks.conversion.plugins.epub_output import EPUBOutput 20 | from calibre.ebooks.metadata.book.base import Metadata 21 | from calibre.ebooks.metadata.book.base import NULL_VALUES 22 | 23 | from calibre_plugins.kepubout import common 24 | from calibre_plugins.kepubout.container import KEPubContainer 25 | 26 | 27 | # Support load_translations() without forcing calibre 1.9+ 28 | try: 29 | load_translations() 30 | except NameError: 31 | pass 32 | 33 | 34 | class KEPubOutput(OutputFormatPlugin): 35 | """Allows calibre to convert any known source format to a KePub file.""" 36 | 37 | name = "KePub Output" 38 | author = "Joel Goguen" 39 | file_type = "kepub" 40 | version = common.PLUGIN_VERSION 41 | minimum_calibre_version = common.PLUGIN_MINIMUM_CALIBRE_VERSION 42 | 43 | epub_output_plugin = None 44 | configdir = os.path.join(config_dir, "plugins") 45 | reference_kepub = os.path.join(configdir, "reference.kepub.epub") 46 | kepub_options: Set[OptionRecommendation] = { 47 | OptionRecommendation( 48 | name="kepub_hyphenate", 49 | recommended_value=True, 50 | help=" ".join( 51 | [ 52 | _( # noqa: F821 53 | "Select this to add a CSS file which enables hyphenation." 54 | ), 55 | _( # noqa: F821 56 | "The language used will be the language defined for the " 57 | + "book in calibre." 58 | ), 59 | _( # noqa: F821 60 | "Please see the README file for directions on updating " 61 | + "hyphenation dictionaries." 62 | ), 63 | ] 64 | ), 65 | ), 66 | OptionRecommendation( 67 | name="kepub_disable_hyphenation", 68 | recommended_value=False, 69 | help=" ".join( 70 | [ 71 | _( # noqa: F821 72 | "Select this to disable all hyphenation in a book." 73 | ), 74 | _( # noqa: F821 75 | "This takes precedence over the hyphenation option." 76 | ), 77 | ] 78 | ), 79 | ), 80 | OptionRecommendation( 81 | name="kepub_clean_markup", 82 | recommended_value=True, 83 | help=_("Select this to clean up the internal ePub markup."), # noqa: F821 84 | ), 85 | OptionRecommendation( 86 | name="kepub_hyphenate_chars", 87 | recommended_value=6, 88 | help=_( # noqa: F821 89 | "Sets the minimum word length, in characters, for hyphenation " 90 | + "to be allowed." 91 | ), 92 | ), 93 | OptionRecommendation( 94 | name="kepub_hyphenate_chars_before", 95 | recommended_value=3, 96 | help=_( # noqa: F821 97 | "Sets the minimum number of characters which must appear before " 98 | + "a hyphen" 99 | ), 100 | ), 101 | OptionRecommendation( 102 | name="kepub_hyphenate_chars_after", 103 | recommended_value=3, 104 | help=_( # noqa: F821 105 | "Sets the minimum number of characters which must appear after a hyphen" 106 | ), 107 | ), 108 | OptionRecommendation( 109 | name="kepub_hyphenate_limit_lines", 110 | recommended_value=2, 111 | help=" ".join( 112 | [ 113 | _( # noqa: F821 114 | "Sets the maximum number of consecutive lines that may be " 115 | + "hyphenated." 116 | ), 117 | _("Set this to 0 to disable limiting."), # noqa: F821 118 | ] 119 | ), 120 | ), 121 | } 122 | kepub_recommendations: Set[Tuple[str, Any, int]] = { 123 | ("epub_version", "3", OptionRecommendation.LOW) 124 | } 125 | 126 | def __init__(self, *args, **kwargs): 127 | """Initialize the KePub output converter.""" 128 | self.epub_output_plugin = EPUBOutput(*args, **kwargs) 129 | self.options = self.epub_output_plugin.options.union(self.kepub_options) 130 | self.recommendations: Set[Tuple[str, Any, int]] = ( 131 | self.epub_output_plugin.recommendations.union(self.kepub_recommendations) 132 | ) 133 | OutputFormatPlugin.__init__(self, *args, **kwargs) 134 | 135 | @staticmethod 136 | def gui_configuration_widget( 137 | parent, get_option_by_name, get_option_help, db, book_id=None 138 | ): 139 | """Set up the plugin configuration widget.""" 140 | from calibre_plugins.kepubout.conversion.output_config import PluginWidget 141 | 142 | return PluginWidget(parent, get_option_by_name, get_option_help, db, book_id) 143 | 144 | def convert(self, oeb_book, output, input_plugin, opts, _): 145 | """Convert from calibre's internal format to KePub.""" 146 | common.log.debug("Running ePub conversion") 147 | self.epub_output_plugin.convert( 148 | oeb_book, output, input_plugin, opts, common.log 149 | ) 150 | common.log.debug("Done ePub conversion") 151 | container = KEPubContainer( 152 | output, common.log, do_cleanup=opts.kepub_clean_markup 153 | ) 154 | 155 | if container.is_drm_encumbered: 156 | common.log.error("DRM-encumbered container, skipping conversion") 157 | return 158 | 159 | # Write the details file 160 | o = { 161 | "kepub_output_version": ".".join([str(n) for n in self.version]), 162 | "kepub_output_currenttime": datetime.utcnow().ctime(), 163 | } 164 | kte_data_file = self.temporary_file("_KePubOutputPluginInfo") 165 | kte_data_file.write(json.dumps(o).encode("UTF-8")) 166 | kte_data_file.close() 167 | container.copy_file_to_container( 168 | kte_data_file.name, name="plugininfo.kte", mt="application/json" 169 | ) 170 | 171 | title = container.opf_xpath("./opf:metadata/dc:title/text()") 172 | if len(title) > 0: 173 | title = title[0] 174 | else: 175 | title = NULL_VALUES["title"] 176 | authors = container.opf_xpath( 177 | './opf:metadata/dc:creator[@opf:role="aut"]/text()' 178 | ) 179 | if len(authors) < 1: 180 | authors = NULL_VALUES["authors"] 181 | mi = Metadata(title, authors) 182 | language = container.opf_xpath("./opf:metadata/dc:language/text()") 183 | if len(language) > 0: 184 | mi.languages = language 185 | language = language[0] 186 | else: 187 | mi.languages = NULL_VALUES["languages"] 188 | language = NULL_VALUES["language"] 189 | 190 | try: 191 | common.modify_epub( 192 | container, 193 | output, 194 | metadata=mi, 195 | opts={ 196 | "hyphenate": opts.kepub_hyphenate, 197 | "hyphen_min_chars": opts.kepub_hyphenate_chars, 198 | "hyphen_min_chars_before": opts.kepub_hyphenate_chars_before, 199 | "hyphen_min_chars_after": opts.kepub_hyphenate_chars_after, 200 | "hyphen_limit_lines": opts.kepub_hyphenate_limit_lines, 201 | "no-hyphens": opts.kepub_disable_hyphenation, 202 | "smarten_punctuation": False, 203 | "extended_kepub_features": True, 204 | }, 205 | ) 206 | except Exception: 207 | common.log.exception("Failed converting!") 208 | raise 209 | -------------------------------------------------------------------------------- /conversion/output_config.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Configuration for exporting KePub files.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2013, Joel Goguen " 7 | __docformat__ = "markdown en" 8 | 9 | import functools 10 | 11 | from calibre.ebooks.conversion.config import OPTIONS 12 | from calibre.gui2.convert import Widget 13 | from calibre.gui2.convert.epub_output import PluginWidget as EPUBPluginWidget 14 | from calibre.gui2.convert.epub_output_ui import Ui_Form as EPUBUIForm 15 | from calibre.gui2.preferences.conversion import OutputOptions as BaseOutputOptions 16 | 17 | from calibre_plugins.kepubout import common 18 | 19 | # Support load_translations() without forcing calibre 1.9+ 20 | try: 21 | load_translations() 22 | except NameError: 23 | pass 24 | 25 | 26 | class PluginWidget(EPUBPluginWidget, EPUBUIForm): 27 | """The plugin configuration widget for a KePub output plugin.""" 28 | 29 | TITLE = "KePub Output" 30 | HELP = _("Options specific to KePub output") # noqa: F821 31 | COMMIT_NAME = "kepub_output" 32 | 33 | # A near copy of calibre.gui2.convert.epub_output.PluginWidget#__init__ 34 | # If something seems wrong, start by checking for changes there. 35 | # We copy that instead of calling super().__init__() because the super __init__ 36 | # calls Widget.__init__() with ePub options and there's no easy way to add and link 37 | # new UI elements once that's been done. 38 | def __init__(self, parent, get_option, get_help, db=None, book_id=None): 39 | """Initialize the KePub output configuration widget.""" 40 | Widget.__init__( 41 | self, 42 | parent, 43 | OPTIONS["output"].get("epub", ()) 44 | + ( 45 | "kepub_hyphenate", 46 | "kepub_clean_markup", 47 | "kepub_disable_hyphenation", 48 | "kepub_hyphenate_chars", 49 | "kepub_hyphenate_chars_before", 50 | "kepub_hyphenate_chars_after", 51 | "kepub_hyphenate_limit_lines", 52 | ), 53 | ) 54 | self.opt_no_svg_cover.toggle() 55 | self.opt_no_svg_cover.toggle() 56 | ev = get_option("epub_version") 57 | self.opt_epub_version.addItems(list(ev.option.choices)) 58 | self.db, self.book_id = db, book_id 59 | self.initialize_options(get_option, get_help, db, book_id) 60 | 61 | def setupUi(self, Form): # noqa: N802, N803 62 | """Set up the plugin widget UI.""" 63 | super(PluginWidget, self).setupUi(Form) 64 | 65 | from PyQt5 import QtWidgets 66 | from PyQt5 import QtCore 67 | 68 | rows = self.gridLayout.rowCount() - 1 69 | 70 | spacer = self.gridLayout.itemAtPosition(rows, 0) 71 | self.gridLayout.removeItem(spacer) 72 | 73 | self.opt_kepub_hyphenate = QtWidgets.QCheckBox(Form) # skipcq: PYL-W0201 74 | self.opt_kepub_hyphenate.setObjectName("opt_kepub_hyphenate") # noqa: F821 75 | self.opt_kepub_hyphenate.setText(_("Hyphenate Files")) # noqa: F821 76 | self.gridLayout.addWidget(self.opt_kepub_hyphenate, rows, 0, 1, 1) 77 | 78 | self.opt_kepub_disable_hyphenation = QtWidgets.QCheckBox( # skipcq: PYL-W0201 79 | Form 80 | ) 81 | self.opt_kepub_disable_hyphenation.setObjectName( 82 | "opt_kepub_disable_hyphenation" # noqa: F821 83 | ) 84 | self.opt_kepub_disable_hyphenation.setText( 85 | _("Disable hyphenation") # noqa: F821 86 | ) 87 | self.gridLayout.addWidget(self.opt_kepub_disable_hyphenation, rows, 1, 1, 1) 88 | 89 | rows += 1 90 | 91 | self.opt_kepub_hyphenate_chars_label = QtWidgets.QLabel( # skipcq: PYL-W0201 92 | _("Minimum word length to hyphenate") + ":" # noqa: F821 93 | ) 94 | self.gridLayout.addWidget(self.opt_kepub_hyphenate_chars_label, rows, 0, 1, 1) 95 | 96 | self.opt_kepub_hyphenate_chars = QtWidgets.QSpinBox(Form) # skipcq: PYL-W0201 97 | self.opt_kepub_hyphenate_chars_label.setBuddy(self.opt_kepub_hyphenate_chars) 98 | self.opt_kepub_hyphenate_chars.setObjectName("opt_kepub_hyphenate_chars") 99 | self.opt_kepub_hyphenate_chars.setSpecialValueText(_("Disabled")) # noqa: F821 100 | self.opt_kepub_hyphenate_chars.valueChanged.connect( 101 | functools.partial( 102 | common.intValueChanged, 103 | self.opt_kepub_hyphenate_chars, 104 | _("character"), # noqa: F821 105 | _("characters"), # noqa: F821 106 | ) 107 | ) 108 | self.gridLayout.addWidget(self.opt_kepub_hyphenate_chars, rows, 1, 1, 1) 109 | 110 | rows += 1 111 | 112 | self.opt_kepub_hyphenate_chars_before_label = ( # skipcq: PYL-W0201 113 | QtWidgets.QLabel(_("Minimum characters before hyphens") + ":") # noqa: F821 114 | ) 115 | self.gridLayout.addWidget( 116 | self.opt_kepub_hyphenate_chars_before_label, rows, 0, 1, 1 117 | ) 118 | 119 | self.opt_kepub_hyphenate_chars_before = QtWidgets.QSpinBox( # skipcq: PYL-W0201 120 | Form 121 | ) 122 | self.opt_kepub_hyphenate_chars_before_label.setBuddy( 123 | self.opt_kepub_hyphenate_chars_before 124 | ) 125 | self.opt_kepub_hyphenate_chars_before.setObjectName( 126 | "opt_kepub_hyphenate_chars_before" 127 | ) 128 | self.opt_kepub_hyphenate_chars_before.valueChanged.connect( 129 | functools.partial( 130 | common.intValueChanged, 131 | self.opt_kepub_hyphenate_chars_before, 132 | _("character"), # noqa: F821 133 | _("characters"), # noqa: F821 134 | ) 135 | ) 136 | self.opt_kepub_hyphenate_chars_before.setMinimum(2) 137 | self.gridLayout.addWidget(self.opt_kepub_hyphenate_chars_before, rows, 1, 1, 1) 138 | 139 | rows += 1 140 | 141 | self.opt_kepub_hyphenate_chars_after_label = ( # skipcq: PYL-W0201 142 | QtWidgets.QLabel(_("Minimum characters after hyphens") + ":") # noqa: F821 143 | ) 144 | self.gridLayout.addWidget( 145 | self.opt_kepub_hyphenate_chars_after_label, rows, 0, 1, 1 146 | ) 147 | 148 | self.opt_kepub_hyphenate_chars_after = QtWidgets.QSpinBox( # skipcq: PYL-W0201 149 | Form 150 | ) 151 | self.opt_kepub_hyphenate_chars_after_label.setBuddy( 152 | self.opt_kepub_hyphenate_chars_after 153 | ) 154 | self.opt_kepub_hyphenate_chars_after.setObjectName( 155 | "opt_kepub_hyphenate_chars_after" 156 | ) 157 | self.opt_kepub_hyphenate_chars_after.valueChanged.connect( 158 | functools.partial( 159 | common.intValueChanged, 160 | self.opt_kepub_hyphenate_chars_after, 161 | _("character"), # noqa: F821 162 | _("characters"), # noqa: F821 163 | ) 164 | ) 165 | self.opt_kepub_hyphenate_chars_after.setMinimum(2) 166 | self.gridLayout.addWidget(self.opt_kepub_hyphenate_chars_after, rows, 1, 1, 1) 167 | 168 | rows += 1 169 | 170 | self.opt_kepub_hyphenate_limit_lines_label = ( # skipcq: PYL-W0201 171 | QtWidgets.QLabel( 172 | _("Maximum consecutive hyphenated lines") + ":" # noqa: F821 173 | ) 174 | ) 175 | self.gridLayout.addWidget( 176 | self.opt_kepub_hyphenate_limit_lines_label, rows, 0, 1, 1 177 | ) 178 | 179 | self.opt_kepub_hyphenate_limit_lines = QtWidgets.QSpinBox( # skipcq: PYL-W0201 180 | Form 181 | ) 182 | self.opt_kepub_hyphenate_limit_lines_label.setBuddy( 183 | self.opt_kepub_hyphenate_limit_lines 184 | ) 185 | self.opt_kepub_hyphenate_limit_lines.setObjectName( 186 | "opt_kepub_hyphenate_limit_lines" 187 | ) 188 | self.opt_kepub_hyphenate_limit_lines.setSpecialValueText( 189 | _("Disabled") # noqa: F821 190 | ) 191 | self.opt_kepub_hyphenate_limit_lines.valueChanged.connect( 192 | functools.partial( 193 | common.intValueChanged, 194 | self.opt_kepub_hyphenate_limit_lines, 195 | _("line"), # noqa: F821 196 | _("lines"), # noqa: F821 197 | ) 198 | ) 199 | self.gridLayout.addWidget(self.opt_kepub_hyphenate_limit_lines, rows, 1, 1, 1) 200 | 201 | rows += 1 202 | 203 | self.opt_kepub_clean_markup = QtWidgets.QCheckBox(Form) # skipcq: PYL-W0201 204 | self.opt_kepub_clean_markup.setObjectName( 205 | "opt_kepub_clean_markup" # noqa: F821 206 | ) 207 | self.opt_kepub_clean_markup.setText(_("Clean up ePub markup")) # noqa: F821 208 | self.gridLayout.addWidget(self.opt_kepub_clean_markup, rows, 0, 1, 1) 209 | 210 | # Next options here 211 | 212 | rows += 1 213 | 214 | self.gridLayout.addItem(spacer, rows, 0, 1, 1) 215 | 216 | # Copy from calibre.gui2.convert.epub_output_ui.Ui_Form to make the 217 | # new additions work 218 | QtCore.QMetaObject.connectSlotsByName(Form) 219 | 220 | 221 | class OutputOptions(BaseOutputOptions): 222 | """This allows adding our options to the output process.""" 223 | 224 | def load_conversion_widgets(self): 225 | """Add our configuration to the output process.""" 226 | super(OutputOptions, self).load_conversion_widgets() 227 | self.conversion_widgets.append(PluginWidget) 228 | self.conversion_widgets = sorted( # skipcq: PYL-W0201 229 | self.conversion_widgets, key=lambda x: x.TITLE 230 | ) 231 | -------------------------------------------------------------------------------- /conversion/output_init.py: -------------------------------------------------------------------------------- 1 | from calibre.customize.builtins import plugins 2 | 3 | for plugin in plugins: 4 | if plugin.name == "Output Options": 5 | plugin.config_widget = ( 6 | "calibre_plugins.kepubout.conversion.output_config:OutputOptions" 7 | ) 8 | break 9 | -------------------------------------------------------------------------------- /conversion_in_init.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Init KePub input plugin.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2015, Joel Goguen " 7 | __docformat__ = "markdown en" 8 | 9 | from calibre_plugins.kepubin.conversion.kepub_input import KEPUBInput 10 | -------------------------------------------------------------------------------- /conversion_out_init.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Init KePub output plugin.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2013, Joel Goguen " 7 | __docformat__ = "markdown en" 8 | 9 | from calibre_plugins.kepubout.conversion.kepub_output import KEPubOutput 10 | -------------------------------------------------------------------------------- /css/hyphenation.css.tmpl: -------------------------------------------------------------------------------- 1 | * {{ 2 | /* Vendor-prefixed CSS properties for hyphenation. Keep -webkit last since 3 | * some user agents also recognize -webkit properties and will apply them. 4 | */ 5 | -webkit-hyphens: auto; 6 | -webkit-hyphenate-limit-after: {hyphen_min_chars_after}; 7 | -webkit-hyphenate-limit-before: {hyphen_min_chars_before}; 8 | -webkit-hyphenate-limit-chars: {hyphen_min_chars} {hyphen_min_chars_before} {hyphen_min_chars_after}; 9 | -webkit-hyphenate-limit-lines: {hyphen_limit_lines}; 10 | 11 | /* CSS4 standard properties for hyphenation. If a property isn't represented 12 | * in the standard, don't put a vendor-prefixed property for it above. 13 | */ 14 | hyphens: auto; 15 | hyphenate-limit-chars: {hyphen_min_chars} {hyphen_min_chars_before} {hyphen_min_chars_after}; 16 | hyphenate-limit-lines: {hyphen_limit_lines}; 17 | hyphenate-limit-last: page; 18 | }} 19 | 20 | h1, h2, h3, h4, h5, h6, td {{ 21 | -webkit-hyphens: none !important; 22 | hyphens: none !important; 23 | }} 24 | -------------------------------------------------------------------------------- /css/no-hyphens.css: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-hyphens: none !important; 3 | hyphens: none !important; 4 | } 5 | -------------------------------------------------------------------------------- /css/style-hacks.css: -------------------------------------------------------------------------------- 1 | div#book-inner { 2 | margin-top: 0; 3 | margin-bottom: 0; 4 | } 5 | -------------------------------------------------------------------------------- /device/__init__.py: -------------------------------------------------------------------------------- 1 | """KoboTouchExtended device driver.""" 2 | -------------------------------------------------------------------------------- /device_init.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Init KoboTouchExtended driver plugin.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2013, Joel Goguen " 7 | __docformat__ = "markdown en" 8 | 9 | from calibre_plugins.kobotouch_extended.device.driver import KOBOTOUCHEXTENDED 10 | -------------------------------------------------------------------------------- /md_reader_init.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Init KePub metadata reader plugin.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2015, David Forrester " 7 | __docformat__ = "markdown en" 8 | 9 | from calibre_plugins.kepubmdreader.metadata.reader import KEPUBMetadataReader 10 | -------------------------------------------------------------------------------- /md_writer_init.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """Init KePub metadata writer plugin.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2015, David Forrester " 7 | __docformat__ = "markdown en" 8 | 9 | from calibre_plugins.kepubmdwriter.metadata.writer import KEPUBMetadataWriter 10 | -------------------------------------------------------------------------------- /metadata/__init__.py: -------------------------------------------------------------------------------- 1 | """KePub metadata handling.""" 2 | -------------------------------------------------------------------------------- /metadata/reader.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """KePub metadata reader.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2015, David Forrester " 7 | __docformat__ = "markdown en" 8 | 9 | from calibre.customize.builtins import EPUBMetadataReader 10 | 11 | from calibre_plugins.kepubmdreader import common 12 | 13 | # Support load_translations() without forcing calibre 1.9+ 14 | try: 15 | load_translations() 16 | except NameError: 17 | pass 18 | 19 | 20 | class KEPUBMetadataReader(EPUBMetadataReader): 21 | """KePub metadata is identical to ePub, we just need to tell calibre.""" 22 | 23 | name = "KePub Metadata Reader" 24 | author = "David Forrester" 25 | description = _("Read metadata from Kobo KePub files") # noqa: F821 26 | file_types = {"kepub"} 27 | version = common.PLUGIN_VERSION 28 | minimum_calibre_version = common.PLUGIN_MINIMUM_CALIBRE_VERSION 29 | -------------------------------------------------------------------------------- /metadata/writer.py: -------------------------------------------------------------------------------- 1 | # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 2 | 3 | """KePub metadata writer.""" 4 | 5 | __license__ = "GPL v3" 6 | __copyright__ = "2015, David Forrester " 7 | __docformat__ = "markdown en" 8 | 9 | import os 10 | 11 | from calibre.customize.builtins import EPUBMetadataWriter 12 | from calibre.ebooks.metadata.epub import get_zip_reader 13 | from calibre.ebooks.metadata.opf2 import OPF 14 | from calibre.utils.localunzip import LocalZipFile 15 | from calibre.utils.zipfile import safe_replace 16 | 17 | from calibre_plugins.kepubmdwriter import common 18 | 19 | # Support load_translations() without forcing calibre 1.9+ 20 | try: 21 | load_translations() 22 | except NameError: 23 | pass 24 | 25 | 26 | class KEPUBMetadataWriter(EPUBMetadataWriter): 27 | """Setting KePub metadata. 28 | 29 | KePub metadata is almost identical to ePub. The sole difference is when 30 | writing out metadata, KePub files are stricter about how to identify the 31 | cover image. 32 | """ 33 | 34 | name = "KePub Metadata Writer" 35 | author = "David Forrester" 36 | description = _("Set metadata in Kobo KePub files") # noqa: F821 37 | file_types = {"kepub"} 38 | version = common.PLUGIN_VERSION 39 | minimum_calibre_version = common.PLUGIN_MINIMUM_CALIBRE_VERSION 40 | 41 | # The logic in here to detect a cover image is mostly duplicated from 42 | # modify_epub() in common.py. Updates to the logic here probably need an 43 | # accompanying update there. 44 | def set_metadata(self, stream, mi, filetype): 45 | """Set standard ePub metadata then properly set the cover image.""" 46 | common.log.debug( 47 | f"KEPUBMetadataWriter::set_metadata - self.__class__={self.__class__}" 48 | ) 49 | super(KEPUBMetadataWriter, self).set_metadata(stream, mi, filetype) 50 | 51 | stream.seek(0) 52 | reader = get_zip_reader(stream, root=os.getcwd()) 53 | 54 | found_cover = False 55 | covers = reader.opf.raster_cover_path(reader.opf.metadata) 56 | if len(covers) > 0: 57 | common.log.debug( 58 | f"KEPUBMetadataWriter::set_metadata - covers={', '.join(covers)}" 59 | ) 60 | cover_id = covers[0].get("content") 61 | common.log.debug(f"KEPUBMetadataWriter::set_metadata - cover_id={cover_id}") 62 | for item in reader.opf.itermanifest(): 63 | if item.get("id", None) == cover_id: 64 | mt = item.get("media-type", "") 65 | if mt and mt.startswith("image/"): 66 | common.log.debug( 67 | "KEPUBMetadataWriter::set_metadata - found cover" 68 | ) 69 | item.set("properties", "cover-image") 70 | found_cover = True 71 | break 72 | if not found_cover: 73 | common.log.debug( 74 | "KEPUBMetadataWriter::set_metadata - looking for cover " 75 | + "using href" 76 | ) 77 | for item in reader.opf.itermanifest(): 78 | if item.get("href", None) == cover_id: 79 | mt = item.get("media-type", "") 80 | if mt and mt.startswith("image/"): 81 | common.log("KEPUBMetadataWriter::set_metadata -found cover") 82 | item.set("properties", "cover-image") 83 | found_cover = True 84 | 85 | if found_cover: 86 | opfbytes = reader.read_bytes(reader.opf_path) 87 | if isinstance(reader.archive, LocalZipFile): 88 | reader.archive.safe_replace( 89 | reader.container[OPF.MIMETYPE], opfbytes 90 | ) 91 | else: 92 | safe_replace(stream, reader.container[OPF.MIMETYPE], opfbytes) 93 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "calibre-kobo-driver" 3 | dynamic = ["version"] 4 | readme = "README.md" 5 | license = { file = "LICENSE" } 6 | requires-python = "== 3.11" 7 | 8 | [tool.basedpyright] 9 | exclude = ["**/__pycache__", "calibre-py3/**", "venv/**"] 10 | extraPaths = ["../calibre", "../calibre/src"] 11 | pythonVersion = "3.11" 12 | reportAssertAlwaysTrue = "warning" 13 | reportDuplicateImport = "error" 14 | reportFunctionMemberAccess = "warning" 15 | reportImplicitOverride = false 16 | reportImplicitStringConcatenation = "warning" 17 | reportImportCycles = "warning" 18 | reportMissingImports = false 19 | reportMissingTypeStubs = false 20 | reportOptionalCall = "warning" 21 | reportOptionalIterable = "warning" 22 | reportOptionalMemberAccess = "warning" 23 | reportOptionalSubscript = "warning" 24 | reportPrivateUsage = "warning" 25 | reportUndefinedVariable = "none" 26 | reportUnnecessaryCast = "warning" 27 | reportUnnecessaryIsInstance = "warning" 28 | reportUnusedCallResult = false 29 | reportUnusedFunction = "error" 30 | reportUnusedVariable = "warning" 31 | strictDictionaryInference = true 32 | strictListInference = true 33 | strictParameterNoneValue = true 34 | 35 | [tool.ruff] 36 | builtins = ["_", "ngettext", "get_resources", "load_translations"] 37 | exclude = ["**/__pycache__", "calibre-py3/**", "venv/**"] 38 | line-length = 88 39 | target-version = "py311" 40 | 41 | [tool.ruff.format] 42 | docstring-code-format = true 43 | exclude = ["*.pyi"] 44 | line-ending = "lf" 45 | quote-style = "double" 46 | skip-magic-trailing-comma = false 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Python requirememnts for plugins or scripts or tests 2 | lxml 3 | lxml-stubs 4 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This is needed to force shellcheck to actually run, and bash and zsh are 3 | # typically close enough that it shouldn't cause a problem. 4 | # vim: set noexpandtab sw=2 ts=2 sts=2 nospell: 5 | 6 | set -eux 7 | 8 | if [ -x /usr/bin/uname ]; then 9 | UNAME_BIN="/usr/bin/uname" 10 | elif [ -x /bin/uname ]; then 11 | UNAME_BIN="/bin/uname" 12 | else 13 | printf 'Could not find uname\n' >&2 14 | return 1 15 | fi 16 | PLATFORM="$("${UNAME_BIN}" | /usr/bin/tr '[:upper:]' '[:lower:]')" 17 | 18 | # Find the repo root dir 19 | cd "$(/usr/bin/dirname "${0}")" 20 | while [ "${PWD}" != "/" ]; do 21 | if [ -d "${PWD}/.git" ] || [ -d "${PWD}/.hg" ]; then 22 | break 23 | fi 24 | cd ../ 25 | done 26 | if [ "${PWD}" = "/" ]; then 27 | printf 'Could not find repository starting from %s\n' "$(/usr/bin/dirname "${0}")" >&2 28 | return 1 29 | fi 30 | 31 | # Creates a POT file suitable for translators to use for generating localization 32 | # translations. The output file is always './translations/messages.pot'. 33 | make_pot() { 34 | if [ ! -d "./translations" ]; then 35 | mkdir ./translations 36 | fi 37 | 38 | # We explicitly do not want to quote the subcommand here since it prints 39 | # each file surrounded by quotes already. 40 | XGETTEXT_BIN="$(command -v xgettext)" 41 | # shellcheck disable=SC2046 42 | "${XGETTEXT_BIN}" -o ./translations/messages.pot --no-wrap \ 43 | --copyright-holder="Joel Goguen" --package-name="calibre-kobo-driver" \ 44 | $(/usr/bin/find . -type f -name '*.py' -not \( -name 'test_*.py' -or -path '*/calibre-*' \)) 45 | } 46 | 47 | # Equivalent of `make clean` 48 | clean() { 49 | /bin/rm -f -- *.zip ./__init__.py ./conversion/__init__.py ./translations/*.mo plugin-import-name-*.txt 50 | } 51 | 52 | # Finds all test files 53 | __all_tests() { 54 | /usr/bin/find ./tests -maxdepth 1 -type f -name 'test_*.py' 55 | } 56 | 57 | # Finds all translation files 58 | __all_translations() { 59 | /usr/bin/find ./translations -type f -name '*.mo' -exec printf '%s ' '{}' \; 60 | } 61 | 62 | # Finds all CSS files 63 | __all_css() { 64 | /usr/bin/find ./css -type f -exec printf '%s ' '{}' \; 65 | } 66 | 67 | # Prints the names of files common to all plugins 68 | __common_files() { 69 | touch "plugin-import-name-${1}.txt" 70 | printf './common.py ./__init__.py ./plugin-import-name-%s.txt %s' "${1}" "$(__all_translations)" 71 | } 72 | 73 | compile_translations() { 74 | MSGFMT_BIN="$(command -v msgfmt)" 75 | 76 | while IFS=$'\n' read -r po_file; do 77 | "${MSGFMT_BIN}" -o "${po_file%%po}mo" "${po_file}" 78 | done < <(/usr/bin/find translations -type f -name '*.po') 79 | } 80 | 81 | # This builds the KoboTouchExtended.zip plugin archive. 82 | build_kte() { 83 | /bin/cp -f ./device_init.py ./__init__.py 84 | 85 | if [ -r ./release/KoboTouchExtended.zip ]; then 86 | zip_args="-u" 87 | else 88 | zip_args="" 89 | fi 90 | # We explicitly do not want to quote the subcommands here since they all 91 | # print the file names surrounded in quotes already or refer to a set of 92 | # CLI flags. 93 | # shellcheck disable=SC2046,SC2086 94 | /usr/bin/zip ${zip_args} ./release/KoboTouchExtended.zip $(__common_files "kobotouch_extended") \ 95 | $(__all_css) ./container.py ./device/*.py 96 | 97 | /bin/rm -f ./__init__.py plugin-import-name-*.txt 98 | } 99 | 100 | # This builds the "KePub Output.zip" plugin archive. 101 | build_kepub_output() { 102 | /bin/cp -f ./conversion_out_init.py ./__init__.py 103 | /bin/cp -f ./conversion/output_init.py ./conversion/__init__.py 104 | 105 | if [ -r "./release/KePub Output.zip" ]; then 106 | zip_args="-u" 107 | else 108 | zip_args="" 109 | fi 110 | # We explicitly do not want to quote things here since they all print the 111 | # file names surrounded in quotes already or refer to a set of CLI flags. 112 | # shellcheck disable=SC2046,SC2086 113 | /usr/bin/zip ${zip_args} "./release/KePub Output.zip" $(__common_files "kepubout") \ 114 | $(__all_css) ./conversion/__init__.py ./container.py \ 115 | ./conversion/kepub_output.py ./conversion/output_config.py 116 | 117 | /bin/rm -f ./__init__.py ./conversion/__init__.py plugin-import-name-*.txt 118 | } 119 | 120 | # This builds the "KePub Input.zip" plugin archive. 121 | build_kepub_input() { 122 | /bin/cp -f ./conversion_in_init.py ./__init__.py 123 | /bin/cp -f ./conversion/input_init.py ./conversion/__init__.py 124 | 125 | if [ -r "./release/KePub Input.zip" ]; then 126 | zip_args="-u" 127 | else 128 | zip_args="" 129 | fi 130 | # We explicitly do not want to quote things here since they all print the 131 | # file names surrounded in quotes already or refer to a set of CLI flags. 132 | # shellcheck disable=SC2046,SC2086 133 | /usr/bin/zip ${zip_args} "./release/KePub Input.zip" $(__common_files "kepubin") \ 134 | ./conversion/__init__.py ./container.py ./conversion/kepub_input.py \ 135 | ./conversion/input_config.py 136 | 137 | /bin/rm -f ./__init__.py ./conversion/__init__.py plugin-import-name-*.txt 138 | } 139 | 140 | # This builds the "KePub Metadata Reader.zip" plugin archive. 141 | build_kepub_md_reader() { 142 | /bin/cp -f ./md_reader_init.py ./__init__.py 143 | 144 | if [ -r "./release/KePub Metadata Reader.zip" ]; then 145 | zip_args="-u" 146 | else 147 | zip_args="" 148 | fi 149 | # We explicitly do not want to quote things here since they all print the 150 | # file names surrounded in quotes already or refer to a set of CLI flags. 151 | # shellcheck disable=SC2046,SC2086 152 | /usr/bin/zip ${zip_args} "./release/KePub Metadata Reader.zip" $(__common_files "kepubmdreader") \ 153 | ./metadata/__init__.py ./metadata/reader.py 154 | 155 | /bin/rm -f ./__init__.py plugin-import-name-*.txt 156 | } 157 | 158 | # This builds the "KePub Metadata Writer.zip" plugin archive. 159 | build_kepub_md_writer() { 160 | /bin/cp -f ./md_writer_init.py ./__init__.py 161 | 162 | if [ -r "./release/KePub Metadata Writer.zip" ]; then 163 | zip_args="-u" 164 | else 165 | zip_args="" 166 | fi 167 | # We explicitly do not want to quote things here since they all print the 168 | # file names surrounded in quotes already or refer to a set of CLI flags. 169 | # shellcheck disable=SC2046,SC2086 170 | /usr/bin/zip ${zip_args} "./release/KePub Metadata Writer.zip" $(__common_files "kepubmdwriter") \ 171 | ./metadata/__init__.py ./metadata/writer.py 172 | 173 | /bin/rm -f ./__init__.py plugin-import-name-*.txt 174 | } 175 | 176 | # Build all plugin ZIP files 177 | build() { 178 | if [ -d ./release ]; then 179 | rm -r ./release 180 | fi 181 | mkdir ./release 182 | build_kte 183 | build_kepub_output 184 | build_kepub_input 185 | build_kepub_md_reader 186 | build_kepub_md_writer 187 | } 188 | 189 | cleanup_dir() { 190 | dname="${1}" 191 | 192 | if [ -n "${dname}" ] && [ -d "${dname}" ]; then 193 | /bin/rm -rf "${dname}" 194 | fi 195 | } 196 | 197 | # Run tests 198 | # WARNING: You MUST call `build` before running tests! 199 | run_tests() { 200 | CALIBRE_BIN_BASE="${PWD}/calibre-py3" 201 | if [ "${PLATFORM}" = "darwin" ]; then 202 | CALIBRE_BIN_BASE="${CALIBRE_BIN_BASE}/Contents/MacOS" 203 | fi 204 | 205 | touch ./__init__.py 206 | 207 | if [ -z "${GITHUB_WORKFLOW:-""}" ]; then 208 | # Not running in repo actions, create calibre directories 209 | # Of course, macOS and Linux need different options to mktemp... 210 | if [ -f /etc/os-release ]; then 211 | CALIBRE_DIR=$(mktemp --tmpdir -d XXXXXXXXXXX) 212 | else 213 | CALIBRE_DIR="$(mktemp -d XXXXXXXXXXX)" 214 | fi 215 | /bin/mkdir -p "${CALIBRE_DIR}/config" "${CALIBRE_DIR}/tmp" 216 | export CALIBRE_CONFIG_DIRECTORY="${CALIBRE_DIR}/config" 217 | export CALIBRE_TEMP_DIR="${CALIBRE_DIR}/tmp" 218 | fi 219 | 220 | # Disable quote warning, I want to expand this now since it isn't assured to be defined when 221 | # called elsewhere, especially at EXIT. 222 | # shellcheck disable=SC2064 223 | trap "cleanup_dir ${CALIBRE_DIR:-''}" EXIT INT TERM PIPE 224 | 225 | while IFS=$'\n' read -r plugin; do 226 | printf 'Installing plugin file "%s" to "%s"\n' "${plugin}" "${CALIBRE_CONFIG_DIRECTORY}" 227 | "${CALIBRE_BIN_BASE}/calibre-customize" -a "${plugin}" 228 | "${CALIBRE_BIN_BASE}/calibre-customize" --enable-plugin "$(basename "${plugin%.zip}")" 229 | done < <(/usr/bin/find release -type f -maxdepth 1 -type f -name '*.zip') 230 | 231 | while IFS=$'\n' read -r test_file; do 232 | printf 'Executing test: %s\n' "${test_file}" 233 | PYTHONDONTWRITEBYTECODE="true" "${CALIBRE_BIN_BASE}/calibre-debug" "${test_file}" 234 | done < <(__all_tests) 235 | 236 | /bin/rm -f ./__init__.py 237 | } 238 | 239 | # Check run mode; default if no arguments are given is 'build' 240 | if [ "$#" -eq 0 ]; then 241 | compile_translations 242 | build 243 | else 244 | while [ "$#" -gt 0 ]; do 245 | OPT="${1}" 246 | shift 247 | case "${OPT}" in 248 | build) 249 | compile_translations 250 | build 251 | ;; 252 | test) 253 | build 254 | run_tests 255 | ;; 256 | pot | translations) 257 | make_pot 258 | ;; 259 | clean) 260 | clean 261 | ;; 262 | esac 263 | done 264 | fi 265 | -------------------------------------------------------------------------------- /scripts/update-calibre.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # vim: ft=python:syntax=python:expandtab:autoindent:ts=4:sts=4 3 | 4 | import json 5 | import os 6 | import platform 7 | import re 8 | import shutil 9 | import subprocess 10 | import sys 11 | import tempfile 12 | 13 | from urllib.request import Request 14 | from urllib.request import urlopen 15 | 16 | 17 | CALIBRE_VERSION_RE = re.compile(r"^.+\(.+ ([\d\.]+)\)$") 18 | CALIBRE_REMOTE_VERSION_RE = re.compile(r"^calibre\-([\d\.]+)[\.\-]") 19 | PLATFORM = platform.system().lower() 20 | repo_dir = os.path.realpath(sys.argv[0]) 21 | while repo_dir != os.path.realpath("/"): 22 | if os.path.isdir(os.path.join(repo_dir, ".git")) or os.path.isdir( 23 | os.path.join(repo_dir, ".hg") 24 | ): 25 | break 26 | repo_dir = os.path.dirname(repo_dir) 27 | if repo_dir == os.path.realpath("/"): 28 | raise Exception("Could not find repository root") 29 | if PLATFORM == "darwin": 30 | pkg_ext = ".dmg" 31 | else: 32 | ARCH = "x86_64" if platform.architecture()[0] == "64bit" else "i686" 33 | pkg_ext = f"-{ARCH}.txz" 34 | 35 | 36 | def extract_calibre_pkg(pkg_path: str) -> None: 37 | calibre_dir = os.path.join(repo_dir, "calibre-py3") 38 | if os.path.isdir(calibre_dir): 39 | shutil.rmtree(calibre_dir) 40 | os.makedirs(calibre_dir) 41 | print(f"Ready to extract to {calibre_dir}") 42 | 43 | if PLATFORM == "darwin": 44 | dmg_name = os.path.basename(pkg_path).rpartition(".")[0] 45 | cmd_list = [ 46 | ["/usr/bin/hdiutil", "attach", pkg_path], 47 | [ 48 | "/usr/bin/rsync", 49 | "--archive", 50 | "--partial", 51 | "--progress", 52 | "--delete", 53 | f"{os.path.join('/Volumes', dmg_name, 'calibre.app')}/", 54 | f"{calibre_dir}/", 55 | ], 56 | ["/usr/bin/hdiutil", "unmount", os.path.join("/Volumes", dmg_name)], 57 | ] 58 | for cmd in cmd_list: 59 | print(f"Running macOS extraction command: {cmd}") 60 | subprocess.run( 61 | cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True 62 | ) 63 | else: 64 | # Thanks Debian! 65 | tar_path = None 66 | paths = [ 67 | "/usr/bin/tar", 68 | "/bin/tar", 69 | ] 70 | for p in paths: 71 | if os.path.exists(p): 72 | tar_path = p 73 | break 74 | if tar_path is None: 75 | raise RuntimeError("tar not found!") 76 | 77 | print(f"Extracting Linux calibre package from {pkg_path} to {calibre_dir}") 78 | try: 79 | subprocess.run( 80 | [ 81 | tar_path, 82 | "--extract", 83 | "--xz", 84 | "--file", 85 | pkg_path, 86 | "--directory", 87 | calibre_dir, 88 | ], 89 | capture_output=True, 90 | check=True, 91 | ) 92 | except subprocess.CalledProcessError as ce: 93 | print(f"cmd: {ce.cmd}", file=sys.stderr) 94 | print(f"stdout: {ce.stdout.decode()}", file=sys.stderr) 95 | print(f"stderr: {ce.stderr.decode()}", file=sys.stderr) 96 | raise 97 | 98 | 99 | def get_calibre() -> None: 100 | # skipcq: BAN-B310 101 | api_resp = urlopen( 102 | "https://api.github.com/repos/kovidgoyal/calibre/releases/latest" 103 | ) 104 | if api_resp.status != 200: 105 | raise Exception( 106 | f"Github API request returned HTTP{api_resp.status} {api_resp.reason}" 107 | ) 108 | 109 | print(f"Got HTTP{api_resp.status} {api_resp.reason} from Github API") 110 | 111 | release_data = json.load(api_resp) 112 | print("Loaded Github release data") 113 | 114 | for asset in release_data["assets"]: 115 | if asset["name"].endswith(pkg_ext): 116 | print(f"Found desired asset name: {asset['name']}") 117 | if asset["browser_download_url"].lower().startswith("http"): 118 | pkg_req = Request(asset["browser_download_url"]) 119 | else: 120 | raise ValueError( 121 | "Browser download URL does not begin with http/https: " 122 | + asset["browser_download_url"] 123 | ) 124 | 125 | with urlopen(pkg_req) as pkg_resp: 126 | if pkg_resp.status != 200: 127 | raise Exception( 128 | "Calibre download returned " 129 | + f"HTTP{pkg_resp.status} {pkg_resp.reason}" 130 | ) 131 | 132 | with tempfile.TemporaryDirectory() as tmpdir: 133 | pkg_file = os.path.join(tmpdir, asset["name"]) 134 | with open(pkg_file, "wb") as f: 135 | f.write(pkg_resp.read()) 136 | print(f"Downloaded calibre package {pkg_file}") 137 | extract_calibre_pkg(pkg_file) 138 | 139 | return None 140 | 141 | raise Exception("No calibre package could be found") 142 | 143 | 144 | if __name__ == "__main__": 145 | get_calibre() 146 | -------------------------------------------------------------------------------- /test_init.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgoguen/calibre-kobo-driver/1a222ca2424c39c89339abc8b05255f6c3491a16/test_init.py -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgoguen/calibre-kobo-driver/1a222ca2424c39c89339abc8b05255f6c3491a16/tests/__init__.py -------------------------------------------------------------------------------- /tests/assertions.py: -------------------------------------------------------------------------------- 1 | # vim: fileencoding=UTF-8:expandtab:autoindent:ts=4:sw=4:sts=4 2 | 3 | import sys 4 | import unittest 5 | 6 | from typing import Any 7 | from typing import Optional 8 | 9 | 10 | # The unittest framework doesn't print tracebacks from any frame with __unittest set to 11 | # True in their globals. 12 | __unittest = True 13 | 14 | 15 | class TestAssertions(unittest.TestCase): # skipcq: PTC-W0046 16 | """Additional useful unittest assertion functions. 17 | 18 | Note that because this class subclasses unittest.TestCase, any class using this 19 | must not subclass TestCase directly. 20 | """ 21 | 22 | @staticmethod 23 | def __is_any_string_type(value: object) -> bool: 24 | if isinstance(value, str): 25 | return True 26 | 27 | if sys.version_info.major < 3: 28 | # Skip flake8 here, basestring is only defined in Python 2 but linting is 29 | # done for Python 2 and 3. 30 | if isinstance(value, basestring): # noqa: F821 31 | return True 32 | 33 | return False 34 | 35 | def assertIsNotNoneOrEmptyString(self, value: Optional[Any]): 36 | try: 37 | self.assertIsNoneOrEmptyString(value) 38 | except AssertionError: 39 | # But it must still be a string type 40 | if not self.__is_any_string_type(value): 41 | self.fail("value must be a string type") 42 | else: 43 | self.fail("value must not be None and must not be empty string") 44 | 45 | def assertIsNoneOrEmptyString(self, value: Optional[Any]): 46 | if value is not None: 47 | if not self.__is_any_string_type(value): 48 | # value is not None and is not any string type 49 | self.fail("value must be None or a string type") 50 | 51 | # value is not None and is a string type 52 | if value.strip() != "": 53 | # value is not empty 54 | self.fail(f"expected empty string, got: {value}") 55 | -------------------------------------------------------------------------------- /tests/reference_book/META-INF/container.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/reference_book/OEBPS/part 002.xhtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | part002 5 | 6 | 7 |

Filename With Space

8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/reference_book/OEBPS/part001.xhtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | part001 5 | 6 | 7 |

Filename With No Special Characters

8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/reference_book/OEBPS/part_(003).xhtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | part003 5 | 6 | 7 |

Filename With Parenthesis

8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/reference_book/content.opf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | J. Smith 5 | Copyright J. Smith 6 | Reference Book 7 | 2020-01-01T00:00:00+00:00 8 | 053f900d-23dc-4851-b078-9f8c78dde49e 9 | en 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/reference_book/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgoguen/calibre-kobo-driver/1a222ca2424c39c89339abc8b05255f6c3491a16/tests/reference_book/cover.jpg -------------------------------------------------------------------------------- /tests/reference_book/toc.ncx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Reference Book 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/test_common.py: -------------------------------------------------------------------------------- 1 | # vim: fileencoding=UTF-8:expandtab:autoindent:ts=4:sw=4:sts=4 2 | 3 | # To import from calibre, some things need to be added to `sys` first. Do not import 4 | # anything from calibre or the plugins yet. 5 | import glob 6 | import os 7 | import sys 8 | import unittest 9 | 10 | from typing import Dict 11 | from typing import List 12 | from typing import Set 13 | 14 | test_dir = os.path.dirname(os.path.abspath(__file__)) 15 | src_dir = os.path.dirname(test_dir) 16 | test_libdir = os.path.join(src_dir, "pylib", f"python{sys.version_info.major}") 17 | sys.path += glob.glob(os.path.join(test_libdir, "*.zip")) 18 | 19 | from unittest import mock 20 | 21 | from calibre_plugins.kobotouch_extended import common 22 | from polyglot.builtins import unicode_type 23 | 24 | 25 | LANGUAGES = ("en_CA", "fr_CA", "fr_FR", "de_DE", "ar_EG", "ru_RU") 26 | TEST_STRINGS: List[Dict[str, Set[str]]] = [ 27 | { 28 | "encodings": {"UTF-8", "CP1252"}, 29 | "test_strings": { 30 | unicode_type(s) for s in ["Hello, World!", "J'ai trouvé mon livre préféré"] 31 | }, 32 | }, 33 | { 34 | "encodings": {"UTF-8", "CP1256"}, 35 | "test_strings": {unicode_type(s) for s in ["مرحبا بالعالم"]}, 36 | }, 37 | { 38 | "encodings": {"UTF-8", "CP1251"}, 39 | "test_strings": {unicode_type(s) for s in ["Привет мир"]}, 40 | }, 41 | { 42 | "encodings": {"UTF-8", "CP932"}, 43 | "test_strings": {unicode_type(s) for s in ["こんにちは世界"]}, 44 | }, 45 | ] 46 | TEST_TIME = "2020-04-01 01:02:03" 47 | 48 | 49 | def gen_lang_code(): 50 | encodings = set() 51 | for o in TEST_STRINGS: 52 | encodings |= o["encodings"] 53 | 54 | for enc in encodings: 55 | yield enc 56 | 57 | 58 | class TestCommon(unittest.TestCase): 59 | orig_lang = "" 60 | 61 | def setUp(self): # type: () -> None 62 | self.orig_lang = os.environ.get("LANG") 63 | 64 | def tearDown(self): # type: () -> None 65 | if not self.orig_lang: 66 | if "LANG" in os.environ: 67 | del os.environ["LANG"] 68 | else: 69 | os.environ["LANG"] = self.orig_lang 70 | self.orig_lang = "" 71 | 72 | def test_logger_log_level(self): # type: () -> None 73 | for envvar in ("CALIBRE_DEVELOP_FROM", "CALIBRE_DEBUG"): 74 | if envvar in os.environ: 75 | del os.environ[envvar] 76 | logger = common.Logger() 77 | self.assertEqual(logger.log_level, "INFO") 78 | 79 | os.environ["CALIBRE_DEVELOP_FROM"] = "true" 80 | logger = common.Logger() 81 | self.assertEqual(logger.log_level, "DEBUG") 82 | del os.environ["CALIBRE_DEVELOP_FROM"] 83 | 84 | os.environ["CALIBRE_DEBUG"] = "1" 85 | logger = common.Logger() 86 | self.assertEqual(logger.log_level, "DEBUG") 87 | del os.environ["CALIBRE_DEBUG"] 88 | 89 | def test_logger_ensure_unicode_from_bytes(self) -> None: 90 | for o in TEST_STRINGS: 91 | for enc in o["encodings"]: 92 | with mock.patch( 93 | "calibre_plugins.kobotouch_extended.common.preferred_encoding", enc 94 | ), mock.patch( 95 | "calibre_plugins.kobotouch_extended.common.time.strftime", 96 | mock.MagicMock(return_value=TEST_TIME), 97 | ): 98 | logger = common.Logger() 99 | 100 | for msg in o["test_strings"]: 101 | test_tagged = logger._tag_args("DEBUG", msg) 102 | self.assertListEqual( 103 | test_tagged, 104 | [f"{TEST_TIME} [DEBUG] {msg}"], 105 | ) 106 | 107 | @mock.patch( 108 | "calibre_plugins.kobotouch_extended.common.Logger.print_formatted_log", 109 | mock.MagicMock(), 110 | ) 111 | @mock.patch( 112 | "calibre_plugins.kobotouch_extended.common.Logger._prints", 113 | mock.MagicMock(), 114 | ) 115 | @mock.patch( 116 | "calibre_plugins.kobotouch_extended.common.Logger._tag_args", 117 | mock.MagicMock(return_value="Goodbye, World"), 118 | ) 119 | def test_logger_logs(self): 120 | logger = common.Logger() 121 | 122 | logger.debug("Hello, World") 123 | logger.print_formatted_log.assert_called_with("DEBUG", "Hello, World") 124 | 125 | logger("Hello, World") 126 | logger.print_formatted_log.assert_called_with("INFO", "Hello, World") 127 | 128 | logger.print_formatted_log.reset_mock() 129 | logger._prints.reset_mock() 130 | logger._tag_args.reset_mock() 131 | 132 | logger.exception("Oh noes!") 133 | logger._tag_args.assert_called_with("ERROR", "Oh noes!") 134 | self.assertEqual(logger._prints.call_count, 2) 135 | 136 | 137 | if __name__ == "__main__": 138 | unittest.main(module="test_common", verbosity=2) 139 | -------------------------------------------------------------------------------- /tests/test_device.py: -------------------------------------------------------------------------------- 1 | # vim: fileencoding=UTF-8:expandtab:autoindent:ts=4:sw=4:sts=4 2 | 3 | # To import from calibre, some things need to be added to `sys` first. Do not import 4 | # anything from calibre or the plugins yet. 5 | import glob 6 | import os 7 | import shutil 8 | import sys 9 | import tempfile 10 | import unittest 11 | import uuid 12 | import warnings 13 | 14 | test_dir = os.path.dirname(os.path.abspath(__file__)) 15 | src_dir = os.path.dirname(test_dir) 16 | test_libdir = os.path.join(src_dir, "pylib", f"python{sys.version_info.major}") 17 | sys.path = [src_dir] + glob.glob(os.path.join(test_libdir, "*.zip")) + sys.path 18 | 19 | from unittest import mock 20 | 21 | from calibre_plugins.kobotouch_extended import container 22 | from calibre_plugins.kobotouch_extended.device import driver 23 | 24 | 25 | class MockPropertyTrue: 26 | def __call__(self, *args, **kwargs): 27 | return True 28 | 29 | 30 | class MockPropertyFalse: 31 | def __call__(self, *args, **kwargs): 32 | return False 33 | 34 | 35 | class MockKePubContainer(mock.MagicMock): 36 | @staticmethod 37 | def is_drm_encumbered() -> bool: 38 | return False 39 | 40 | 41 | class DeviceTestBase(unittest.TestCase): # skipcq: PTC-W0046 42 | log = mock.Mock() 43 | 44 | def __init__(self, *args, **kwargs): 45 | super(DeviceTestBase, self).__init__(*args, **kwargs) 46 | self.reference_book = os.path.join(test_dir, "reference_book") 47 | 48 | self.mi = mock.MagicMock() 49 | self.mi.title = "Test Book" 50 | self.mi.authors = ["John Q. Public", "Suzie Q. Public"] 51 | self.mi.uuid = uuid.uuid4() 52 | 53 | @classmethod 54 | def setUpClass(cls): 55 | driver.common.log = mock.MagicMock() 56 | 57 | def setUp(self): 58 | self.device = driver.KOBOTOUCHEXTENDED( 59 | os.path.join(src_dir, "KoboTouchExtended.zip") 60 | ) 61 | self.device.startup() 62 | self.device.initialize() 63 | self.device._main_prefix = "/mnt/kobo" 64 | 65 | self.basedir = tempfile.mkdtemp(prefix="kte-", suffix="-test", dir=test_dir) 66 | self.epub_dir = os.path.join(self.basedir, "kepub") 67 | self.tmpdir = os.path.join(self.basedir, "tmp") 68 | 69 | shutil.copytree(self.reference_book, self.epub_dir) 70 | os.mkdir(self.tmpdir) 71 | 72 | self.container = container.KEPubContainer( 73 | self.epub_dir, self.log, tdir=self.tmpdir 74 | ) 75 | 76 | if sys.version_info >= (3, 2): 77 | warnings.simplefilter("ignore", category=ResourceWarning) 78 | 79 | def tearDown(self): 80 | if self.device: 81 | self.device.shutdown() 82 | self.device = None 83 | 84 | self.log.reset_mock() 85 | 86 | if self.basedir and os.path.isdir(self.basedir): 87 | shutil.rmtree(self.basedir, ignore_errors=True) 88 | 89 | 90 | @mock.patch.object(driver.KOBOTOUCHEXTENDED, "extra_features", True) 91 | class TestDeviceWithExtendedFeatures(DeviceTestBase): 92 | def test_filename_callback_not_skipped(self): 93 | assert self.device is not None 94 | 95 | mi = mock.MagicMock() 96 | mi.uuid = uuid.uuid4() 97 | 98 | for ext in ("kepub", "epub"): 99 | cb_name = self.device.filename_callback(f"reference.{ext}", mi) 100 | self.assertEqual(cb_name, "reference.kepub.epub") 101 | 102 | cb_name = self.device.filename_callback("reference.mobi", mi) 103 | self.assertEqual(cb_name, "reference.mobi") 104 | 105 | def test_filename_callback_skipped(self): 106 | assert self.device is not None 107 | 108 | mi = mock.MagicMock() 109 | mi.uuid = uuid.uuid4() 110 | self.device.skip_renaming_files.add(mi.uuid) 111 | 112 | for ext in ("mobi", "epub"): 113 | cb_name = self.device.filename_callback(f"reference.{ext}", mi) 114 | self.assertEqual(cb_name, f"reference.{ext}") 115 | 116 | cb_name = self.device.filename_callback("reference.kepub", mi) 117 | self.assertEqual(cb_name, "reference.kepub.epub") 118 | 119 | def test_sanitize_filename_components(self): 120 | # Make sure test_components and expected_components stay in the right order! 121 | assert self.device is not None 122 | 123 | test_components = [ 124 | "home", 125 | "Calibre Library", 126 | r"test*path?component%with:lots|of$bad!characters/", 127 | ] 128 | expected_components = [ 129 | "home", 130 | "Calibre Library", 131 | "test_path_component_with_lots_of_bad_characters_", 132 | ] 133 | sanitized_components = self.device.sanitize_path_components(test_components) 134 | self.assertListEqual(sanitized_components, expected_components) 135 | 136 | @mock.patch.object( 137 | driver.common, "modify_epub", side_effect=ValueError("Testing exception") 138 | ) 139 | def test_modify_epub_exception_fails(self, _modify_epub): 140 | assert self.device is not None 141 | 142 | self.assertFalse(self.device.skip_failed) 143 | 144 | with self.assertRaises(ValueError): 145 | self.device._modify_epub("test.epub", self.mi, self.container) 146 | 147 | self.assertNotIn(self.mi.uuid, self.device.skip_renaming_files) 148 | 149 | 150 | @mock.patch.object(driver.KOBOTOUCHEXTENDED, "extra_features", False) 151 | @mock.patch.object(driver.KOBOTOUCHEXTENDED, "use_template", False) 152 | class TestDeviceWithoutExtendedFeatures(DeviceTestBase): 153 | def test_filename_callback_skipped(self): 154 | assert self.device is not None 155 | 156 | mi = mock.MagicMock() 157 | mi.uuid = uuid.uuid4() 158 | self.assertFalse(self.device.extra_features) 159 | 160 | for ext in ("epub", "mobi"): 161 | cb_name = self.device.filename_callback(f"reference.{ext}", mi) 162 | self.assertEqual(cb_name, f"reference.{ext}") 163 | 164 | 165 | @mock.patch.object(driver.KOBOTOUCHEXTENDED, "extra_features", True) 166 | @mock.patch.object(driver.KOBOTOUCHEXTENDED, "skip_failed", True) 167 | class TestDeviceSkippingErrors(DeviceTestBase): 168 | @mock.patch.object( 169 | driver.common, "modify_epub", side_effect=ValueError("Testing exception") 170 | ) 171 | @mock.patch.object(driver.KOBOTOUCH, "_modify_epub") 172 | def test_modify_epub_skip_exceptions( 173 | self, _extended_modify_epub, _base_modify_epub 174 | ): 175 | assert self.device is not None 176 | 177 | self.assertTrue(self.device.skip_failed) 178 | 179 | self.device._modify_epub("test.epub", self.mi, self.container) 180 | 181 | self.assertIn(self.mi.uuid, self.device.skip_renaming_files) 182 | 183 | 184 | if __name__ == "__main__": 185 | unittest.main(module="test_device", verbosity=2) 186 | -------------------------------------------------------------------------------- /tests/test_files/page_dirty_markup.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | Reference Book 8 | 9 | 10 | 11 | 12 |

Copyright

13 |

14 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/test_files/page_github_106.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 8 | 9 | 10 | Github PR 106 Test 11 | 12 | 13 | 14 | 15 |

16 | Neque porro quisquam est qui dolorem ipsum quia dolor 17 | sit amet, consectetur, adipisci velit 18 |

19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/test_files/page_github_136.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | assassina-5 8 | 9 | 10 | 11 | 12 |
13 |

Capitolo 1

14 | 15 |

 

16 | 17 |

2014

18 | 19 |

 

20 | 21 |

Sto ripulendo la cucina dagli avanzi della colazione quando il mio telefono di 22 | lavoro squilla, facendomi fermare di scatto. Vedo il nome della mia assistente lampeggiare sullo schermo.

23 | 24 |

«Ciao Jennifer, che succede?»

25 | 26 |

«C’è questa ragazza». La sua voce giunge trafelata. «Finirà massacrata dall’accusa 27 | se non accetti di occuparti del suo caso».

28 | 29 |

Stringendo il telefono tra l’orecchio e la spalla, sciacquo via i Coco Pops da una 30 | scodella di cereali. Non c’è tempo da perdere, sono già in ritardo. «Okay, ti ascolto».

31 | 32 |

«Sono arrivata presto per depositare i documenti del caso Preston. Stavo 33 | aspettando l’arrivo dell’impiegato quando ho sentito Sarah, l’avvocato d’ufficio, parlare di questo caso».

34 | 35 |

Jennifer si interrompe per respirare.

36 | 37 |

«Quindi, di cosa si tratta?», la incalzo.

38 | 39 |

«C’è questa ragazza, Chloe. Ha quindici anni e l’accusano di tentato omicidio». 40 |

41 | 42 |

«Che cosa ha fatto?». Spostando il telefono all’altro orecchio continuo a pulire, 43 | e intanto la incito mentalmente a raccontarmi la storia per intero invece di darmi brandelli di informazioni.

44 | 45 |

«Ha investito un tizio ed è scappata». La sua voce è pervasa di eccitazione.

46 | 47 |

«Un momento, che cosa ci faceva al volante? Non hai detto che ha solo quindici 48 | anni?»

49 | 50 |

«Sì, è così. È salita in auto e l’ha investito in retromarcia».

51 | 52 |

«Come ha ottenuto le chiavi? Gliele ha rubate?»

53 | 54 |

«Non ne sono sicura…». La voce di Jennifer si spegne.

55 | 56 |

«D’accordo, possiamo scoprirlo più avanti. Lui è ferito?»

57 | 58 |

«Oh sì». Di colpo si rianima. «È ancora in ospedale. Ha entrambe le gambe rotte, 59 | un paio di costole fratturate, un polmone perforato e una grave emorragia interna. I dottori non sanno se 60 | riprenderà mai a camminare».

61 | 62 |

«Ahia». Faccio una smorfia e rabbrividisco, cercando di togliermi dalla testa 63 | l’immagine del corpo ferito di questo sconosciuto.

64 | 65 |

«Sarah ha suggerito di parlarti del caso, vedere se avevi tempo di occupartene», 66 | prosegue Jennifer.

67 | 68 |

Inspirando a fondo, rifletto sulla mia attuale mole di lavoro. «Non lo so. Sai 69 | quanto sono impegnata al momento».

70 | 71 |

«Sì, ma cerchi sempre di aiutare le giovani donne, le ragazze che non hanno nessun 72 | altro a cui rivolgersi. Ed è qualche mese che non prendi un caso pro bono».

73 | 74 |

Jennifer ha ragione. I casi in cui l’accusata ha una storia difficile, quelli da 75 | cui gli altri fuggirebbero a gambe levate, mi toccano sempre e mi stimolano a fare del mio meglio.

76 | 77 |

«Sei ancora lì?»

78 | 79 |

«Sì, sì», rispondo in fretta, tornando alla realtà. «Non lo so. È un’omissione di 80 | soccorso. Vale la pena?»

81 | 82 |

«Be’, non è il tipico caso di cui tendi a occuparti. Ma il fatto che non sia una 83 | vittima di violenza non significa che non meriti una difesa solida. E sai quanto sono impegnati gli avvocati 84 | d’ufficio», insiste. «Sarah sta seguendo altri diciotto processi. Non avrebbe tempo di difenderla come si deve. La 85 | ragazza è spacciata».

86 | 87 |

C’è qualcosa che non torna in questa descrizione del caso. Dentro di me una vocina 88 | consiglia di non perdere tempo, di passare oltre. «I suoi genitori non possono trovarle un buon legale?»

89 | 90 |

«Non lo so, ma se è stata mandata da un difensore d’ufficio probabilmente non ha 91 | altra scelta. Presumo».

92 | 93 |

Nonostante le mie riserve, sono intrigata. «Puoi chiedere a Sarah il fascicolo del 94 | caso?»

95 | 96 |

«Te ne ho già preso una copia. La troverai sulla scrivania appena arrivi». Sul mio 97 | volto si allarga un sorriso. Gli straordinari talenti organizzativi di Jennifer mi permettono di concentrarmi su 98 | ciò che conta davvero: difendere i clienti.

99 | 100 |

Jennifer riattacca e io continuo a pulire la cucina. Questa mattina c’è stato il 101 | solito delirio mentre vestivo i miei due bambini, preparavo loro il pranzo e controllavo che finissero la 102 | colazione. Come ogni giorno mio marito è uscito per primo, lasciandomi ad affrontare il disastro della nostra 103 | convulsa routine mattutina. «Devo andare», ha detto. «Voglio evitare il traffico».

104 | 105 |

«Lo dici tutti i giorni e rimani sempre imbottigliato», ho risposto, scuotendo la 106 | testa mentre guardavo il disordine.

107 | 108 |

«Questa è la volta buona. Me lo sento», ha ribattuto, baciandomi sulla guancia 109 | prima di sfrecciare fuori. Il suo ottimismo è ammirevole, ma sarebbe bello se ogni tanto mi desse una mano a 110 | pulire. Ancora non riesco a capire perché insiste per andare al lavoro in auto invece di prendere la metro. Gli 111 | farebbe senz’altro risparmiare del tempo, per non parlare dello stress di restare bloccato nel traffico. Ma del 112 | resto, anche io mi rifiuto di fare come buona parte dei londinesi, che utilizzano i trasporti pubblici. Guidare mi 113 | dà il tempo di pensare, la possibilità di saltare in auto e andarmene ogni volta che ne ho bisogno. E odio stare a 114 | stretto contatto con tutta quella gente, pigiata contro il lato della vettura durante l’ora di punta.

115 | 116 |

La lavastoviglie borbotta, indicando l’inizio del ciclo di lavaggio. Mentre mi 117 | volto per uscire, la vivace tazza di Peppa Pig di mia figlia attira la mia attenzione. Sporgendomi sull’isola 118 | della cucina ne afferro il beccuccio rosa ma, quando lo faccio, il coperchio si stacca e rovescia succo di 119 | mirtillo rosso sulla superficie bianca.

120 | 121 |

Un grido soffocato mi sfugge prima che possa fermarlo e in fretta chiudo gli 122 | occhi, il corpo scosso dai tremiti. Posando il coperchio della tazza, mi aggrappo forte al piano per farmi forza. 123 | Odio il colore rosso. Lo detesto così tanto che sono capace di tutto pur di non vederlo. Per anni ho evitato 124 | qualunque cibo rosso. La mia dieta non contemplava fragole, barbabietole o pomodori. E la carne doveva essere ben 125 | cotta, le bistecche strinate fino ad asciugare ogni goccia di sangue. Era l’unico modo in cui potessi mangiarle. 126 | Mio marito sa che questa avversione è legata alla mia paura del sangue, ma ho lasciato che lui, come chiunque 127 | altro venga a conoscenza del mio odio, la ritenga una conseguenza di quando, da ragazzina, ho visto un cane morire 128 | dopo essere stato investito da un’auto fuori dalla scuola. Non possono certo immaginare che sia stato un altro 129 | episodio cruento, soltanto un paio di anni più tardi, a consolidare l’avversione per qualunque cosa mi ricordi il 130 | sangue.

131 | 132 |

 

133 | 134 |

 

135 |
136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /tests/test_files/page_github_90.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 8 | 9 | 10 | Github Issue 90 Test 11 | 12 | 13 | 14 | 15 |

16 | Hail Mary, full of grace,
17 | the Lord is with thee.
18 | Blessed art thou among women,
19 | and blessed is the fruit of thy womb, Jesus. 20 |

21 |

22 | Holy Mary, Mother of God,
23 | pray for us sinners,
24 | now and at the hour of our death. 25 |

26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/test_files/page_needs_cleanup.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | Reference Book 7 | 8 | 9 | 10 |