├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE.md ├── README.md ├── Screenshot.png ├── entrypoint.py ├── requirements.txt ├── setup.py └── tinyPub ├── __init__.py ├── __main__.py ├── addnotation.py ├── htmlParser ├── __init__.py ├── default.py ├── notes.md ├── renderer.py ├── styles.py └── textFormater.py └── navParser.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | # Repository specific 127 | *.epub 128 | 129 | .directory 130 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: Current File", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/entrypoint.py", 9 | "console": "integratedTerminal", 10 | "args": ["${workspaceFolder}/tests/pan.epub"], 11 | "justMyCode": true 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/usr/bin/python" 3 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2019 Grzegorz Koperwas 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tinyPub.py 2 | 3 | ![This thing in action](Screenshot.png) 4 | 5 | A simple, [py_cui](https://github.com/jwlodek/py_cui) based, terminal ebook reader for .epub files. 6 | 7 | ``` 8 | pip install tinypub-HakierGrzonzo 9 | ``` 10 | and then you should be able to use it with: 11 | ``` 12 | tinypub 13 | ``` 14 | 15 | Features: 16 | 17 | - Your cursor should be exactly where you left it, as your progress is tracked in ~/.tinypub.json file 18 | - You can create short notes, and quickly jump to them. 19 | - Somewhat scalable interface. I mean, it works on startup… 20 | - In house parser for *HTML* that can use css to format text. 21 | 22 | ## How to use: 23 | 24 | Run it with your ebook's epub file as an argument. 25 | 26 | On the bottom of your screen you will see help messages. 27 | 28 | You can use cursor keys to move the cursor, you can use "k" and "j" keys to jump to next paragraph break. 29 | 30 | You can use "h" and "l" keys to switch between chapters. 31 | 32 | Pressing "t" opens up Table of Contents. 33 | 34 | Pressing "a" allows you to add a note at cursor. 35 | -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HakierGrzonzo/tinyPub/077f39b666c2ee02755eccf7dfa684df301d35b7/Screenshot.png -------------------------------------------------------------------------------- /entrypoint.py: -------------------------------------------------------------------------------- 1 | """A quick script to launch tinyPub in testing""" 2 | 3 | from tinyPub import main 4 | main() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | EbookLib==0.17.1 2 | py_cui==0.0.3 3 | cssutils==1.0.2 4 | beautifulsoup4==4.9.1 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open('README.md', 'r') as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name = "tinypub-HakierGrzonzo", 8 | version = "2.1.0", 9 | author = "Grzegorz Koperwas", 10 | author_email = "Grzegorz.Koperwas.dev@gmail.com", 11 | description = "A console based epub ebook reader.", 12 | long_description = long_description, 13 | ong_description_content_type="text/markdown", 14 | url='https://github.com/HakierGrzonzo/tinyPub', 15 | packages=setuptools.find_packages(), 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: POSIX :: Linux", 20 | "Development Status :: 4 - Beta", 21 | "Intended Audience :: End Users/Desktop" 22 | ], 23 | entry_points={ 24 | 'console_scripts': [ 25 | 'tinypub=tinyPub.__main__:main' 26 | ] 27 | }, 28 | python_requires='>=3.6.1' 29 | ) 30 | -------------------------------------------------------------------------------- /tinyPub/__init__.py: -------------------------------------------------------------------------------- 1 | from tinyPub.__main__ import main 2 | -------------------------------------------------------------------------------- /tinyPub/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | A python .epub ebook reader 4 | Used to be based on prompt_toolkit, but since version 1.9 is based on py_cui. 5 | """ 6 | from .htmlParser import Chapter, StyleHandler 7 | import py_cui as pycui 8 | from ebooklib import epub 9 | import json, os, ebooklib, argparse 10 | from multiprocessing import Pool, cpu_count 11 | from .navParser import parse_navigation 12 | from .addnotation import Addnotation, Adnotation_menu 13 | 14 | __version__ = '2.0.1' 15 | # Debug functions 16 | log = list() 17 | 18 | def pp(arg): 19 | print(json.dumps(arg, indent = 4)) 20 | 21 | 22 | class impPYCUI(pycui.PyCUI): 23 | """py_cui.PyCUI but with add methods for my classes""" 24 | def __init__(self, arg1, arg2): 25 | super(impPYCUI, self).__init__(arg1, arg2) 26 | 27 | def add_Displayer(self, title, row, column, row_span = 1, column_span = 1, padx = 1, pady = 0, book = None): 28 | id = 'Widget{}'.format(len(self.widgets.keys())) 29 | new_displayer = Displayer(id, title, self.grid, row, column, row_span, column_span, padx, pady, book) 30 | self.widgets[id] = new_displayer 31 | if self.selected_widget is None: 32 | self.set_selected_widget(id) 33 | return new_displayer 34 | 35 | def add_Adnotation_menu(self, title, row, column, row_span = 1, column_span = 1, padx = 1, pady = 0): 36 | id = 'Widget{}'.format(len(self.widgets.keys())) 37 | new_adnotation_menu = Adnotation_menu(id, title, self.grid, row, column, row_span, column_span, padx, pady) 38 | self.widgets[id] = new_adnotation_menu 39 | if self.selected_widget is None: 40 | self.set_selected_widget(id) 41 | return new_adnotation_menu 42 | 43 | 44 | class Displayer(pycui.widgets.ScrollTextBlock): 45 | """basicly py_cui Text_block that is read only and has some glue for Book()""" 46 | def __init__(self, id, title, grid, row, column, row_span, column_span, padx, pady, book): 47 | global config 48 | self.book = book 49 | # set title according to book's chapter title 50 | text, self.breaks, self.org_title = self.book.chapter_returner() 51 | # pass all parameters and chapter's text to parent class 52 | super(Displayer, self).__init__(id, self.org_title, grid, row, column, row_span, column_span, padx, pady, text) 53 | # get keybindings from config, I know it is not supposed to work like that, but it works 54 | self.keybinds = config.get('keybindings', {'h': 'prev', 'j': 'down', 'k': 'up', 'l': 'next'}) 55 | self.addnotationDisplayer = None 56 | # Set help text 57 | reverseKeybinds = dict() 58 | for k, v in self.keybinds.items(): 59 | reverseKeybinds[v] = k 60 | self.set_focus_text('t - Table of contents | ' + reverseKeybinds['prev'] + ', ' + reverseKeybinds['next'] + ' - change chapter | a - add note on cursor | ' + reverseKeybinds['up'] + ', ' + reverseKeybinds['down'] + ' - fast scroll | Esc - select widget') 61 | # set initial cursor position according to saved entry in config 62 | last = None 63 | while self.cursor_text_pos_y < self.book.XcursorPos: 64 | last = self.cursor_text_pos_y 65 | self.move_down() 66 | if last == self.cursor_text_pos_y: 67 | # if cursor position is not changing, well, you tried 68 | break 69 | while self.cursor_text_pos_x < self.book.YcursorPos: 70 | last = self.cursor_text_pos_x 71 | self.move_right() 72 | if last == self.cursor_text_pos_x: 73 | break 74 | self.append_progress_to_title() 75 | 76 | def insert_char(self, key_pressed): 77 | """hijack insert_char to handle some keybindings""" 78 | action = self.keybinds.get(chr(key_pressed)) 79 | if action == 'prev': 80 | # switch chapter to previous one 81 | text, self.breaks, self.org_title = self.book.select_prev_chapter() 82 | self.set_text(text) 83 | self.load_adnotations() 84 | self.append_progress_to_title() 85 | if action == 'next': 86 | # switch chapter to next one 87 | text, self.breaks, self.org_title = self.book.select_next_chapter() 88 | self.set_text(text) 89 | self.load_adnotations() 90 | self.append_progress_to_title() 91 | # jump to paragraph break 92 | if action == 'down': 93 | for x in self.breaks: 94 | if x > self.cursor_text_pos_y: 95 | self.jump_to_line(x) 96 | break 97 | self.append_progress_to_title() 98 | if action == 'up': 99 | for x in self.breaks[::-1]: 100 | if x < self.cursor_text_pos_y: 101 | self.jump_to_line(x) 102 | break 103 | self.append_progress_to_title() 104 | 105 | def handle_delete(self): 106 | pass 107 | 108 | def handle_backspace(self): 109 | pass 110 | 111 | def handle_newline(self): 112 | pass 113 | 114 | def load_adnotations(self): 115 | """load notifications after self.addnotationDisplayer is set and after chapter change""" 116 | if self.addnotationDisplayer == None: 117 | return 118 | self.addnotationDisplayer.clear() 119 | for x in self.book.addnotations: 120 | # add notifications from entry in config 121 | self.addnotationDisplayer.add_adnotation(x) 122 | 123 | def add_adnotation_on_cursor(self, text): 124 | """adds addnotation in paragraph where cursor is located""" 125 | adnotation = self.book.add_adnotation(text, self.cursor_text_pos_y, self.breaks) 126 | if not self.addnotationDisplayer == None: 127 | self.addnotationDisplayer.add_adnotation(adnotation) 128 | 129 | def getTOC(self): 130 | """generates dict: TOC entry -> chapterIndex for Book()""" 131 | res = dict() 132 | i = 1 133 | for item in self.book.chapters: 134 | res[str(i) + '. ' + item['title']] = i 135 | i += 1 136 | return res 137 | 138 | def force_chapter(self, identifier): 139 | """change chapter to identifier""" 140 | try: 141 | num = self.getTOC()[identifier] - 1 142 | text, self.breaks, self.org_title = self.book.select_chapter(num) 143 | self.set_text(text) 144 | self.load_adnotations() 145 | self.append_progress_to_title() 146 | except Exception as e: 147 | global log 148 | log.append('force_chapter: ' + str(e)) 149 | 150 | def append_progress_to_title(self): 151 | self.title = self.org_title + ' '+ str(max(round(100*self.cursor_text_pos_y / len(self.text_lines)), 0)) + '%' 152 | 153 | def jump_to_line(self, line): 154 | """change cursor_text_pos_y to line""" 155 | if self.cursor_text_pos_y > line: 156 | last = self.cursor_text_pos_y 157 | while line < self.cursor_text_pos_y: 158 | last = self.cursor_text_pos_y 159 | self.move_up() 160 | if last == self.cursor_text_pos_y: 161 | # if position has not changed, abort 162 | break 163 | elif self.cursor_text_pos_y < line: 164 | last = self.cursor_text_pos_y 165 | while self.cursor_text_pos_y < line: 166 | last = self.cursor_text_pos_y 167 | self.move_down() 168 | if last == self.cursor_text_pos_y: 169 | # if position has not changed, abort 170 | break 171 | 172 | def move_up(self): 173 | super().move_up() 174 | self.append_progress_to_title() 175 | 176 | def move_down(self): 177 | super().move_down() 178 | self.append_progress_to_title 179 | 180 | 181 | class Book(object): 182 | """A wrapper class that represents ebook with its htmlParser.chapter-s and addnotations 183 | 184 | Parameters 185 | ---------- 186 | pathToFile: str 187 | path to .epub file""" 188 | def __init__(self, pathToFile): 189 | global config 190 | super(Book, self).__init__() 191 | # try to load epub from pathToFile 192 | try: 193 | self.book = epub.read_epub(pathToFile) 194 | except Exception as e: 195 | raise ValueError('Exception while importing epub:', str(e)) 196 | # process styles 197 | styles = dict() 198 | for x in self.book.get_items_of_type(ebooklib.ITEM_STYLE): 199 | styles[x.file_name] = StyleHandler(x.get_content()) 200 | # read spine 201 | spine_ids = [x[0] for x in self.book.spine] 202 | chapters = list() 203 | # make dict href → id 204 | self.id_to_href = dict() 205 | for item in self.book.get_items(): 206 | self.id_to_href[item.get_id()] = item.get_name() 207 | for id in spine_ids: 208 | doc = self.book.get_item_with_id(id) 209 | chapter = Chapter(doc.content, 210 | ['sup'], 211 | lineLength = config.get('lineLength', 78), 212 | css_data = styles, 213 | force_justify_on_p_tags= config.get('force_justify_on_p_tags', False) 214 | ) 215 | chapters.append((chapter, id)) 216 | # get toc 217 | self.chapters = list() 218 | try: 219 | navigation = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION))[0] 220 | self.navigation = parse_navigation(navigation.content) 221 | self.navigation.sort(key= lambda item: item["playOrder"]) 222 | href_to_title = dict() 223 | for item in self.navigation: 224 | href_to_title[item['content']] = item['title'] 225 | for chapter in chapters: 226 | item = { 227 | 'chapter' : chapter[0], 228 | 'title' : href_to_title.get(self.id_to_href[chapter[1]], chapter[0].title()) 229 | } 230 | self.chapters.append(item) 231 | except Exception as e: 232 | raise e 233 | for chapter in chapters: 234 | x = { 235 | 'chapter' : chapter[0], 236 | 'title': chapter[0].title() 237 | } 238 | self.chapters.append(x) 239 | # set inital chapter and cursor position according to config entry 240 | self.configEntry = config.get('books').get(self.title(), dict()) 241 | self.chapterIndex = int(self.configEntry.get('chapter', 1)) 242 | self.XcursorPos = self.configEntry.get('cursorX', 0) 243 | self.YcursorPos = self.configEntry.get('cursorY', 0) 244 | 245 | def chapter_returner(self): 246 | """returns text, paragraph breaks and title of current chapter. Also initializes 247 | addnotations for current chapter""" 248 | text, breaks = self.chapters[self.chapterIndex]['chapter'].text() 249 | addnotations = self.configEntry.get('addnotations', dict()).get(str(self.chapterIndex), list()) 250 | self.addnotations = [Addnotation(breaks, dict = x) for x in addnotations] 251 | return text, breaks, str(self.chapters[self.chapterIndex]['title']) + ' ({}/{})'.format(self.chapterIndex + 1, len(self.chapters)) 252 | 253 | def title(self): 254 | """returns book's title""" 255 | return self.book.get_metadata('DC', 'title')[0][0] 256 | 257 | def select_chapter(self, new_chapter_num): 258 | """selects chapter number new_chapter_num, then exceutes chapter_returner() and returns its 259 | results""" 260 | if new_chapter_num < 0 or new_chapter_num > len(self.chapters) - 1: 261 | raise ValueError('Chapter does not exist') 262 | try: 263 | self.configEntry['addnotations'][str(self.chapterIndex)] = [x.dump() for x in self.addnotations] 264 | except: 265 | self.configEntry['addnotations'] = dict() 266 | self.configEntry['addnotations'][str(self.chapterIndex)] = [x.dump() for x in self.addnotations] 267 | self.chapterIndex = new_chapter_num 268 | self.XcursorPos = 0 269 | self.YcursorPos = 0 270 | return self.chapter_returner() 271 | 272 | def select_next_chapter(self): 273 | """tries to select next chapter""" 274 | try: 275 | return self.select_chapter(self.chapterIndex + 1) 276 | except Exception as e: 277 | return self.chapter_returner() 278 | 279 | def select_prev_chapter(self): 280 | """tries to select previous chapter""" 281 | try: 282 | return self.select_chapter(self.chapterIndex - 1) 283 | except Exception as e: 284 | return self.chapter_returner() 285 | 286 | def add_adnotation(self, text, y_pos, breaks): 287 | """adds addnotation created from arguments to config entry and 288 | returns coresponding Addnotation()""" 289 | res = Addnotation(breaks, text = text, position = y_pos) 290 | self.addnotations.append(res) 291 | return res 292 | 293 | def dump_to_config(self, YcursorPos, XcursorPos): 294 | """commits changes to its config entry""" 295 | global config 296 | try: 297 | self.configEntry['addnotations'][str(self.chapterIndex)] = [x.dump() for x in self.addnotations] 298 | except: 299 | self.configEntry['addnotations'] = dict() 300 | self.configEntry['addnotations'][str(self.chapterIndex)] = [x.dump() for x in self.addnotations] 301 | self.configEntry = { 302 | 'chapter': self.chapterIndex, 303 | 'cursorX': XcursorPos, 304 | 'cursorY': YcursorPos, 305 | 'addnotations': self.configEntry.get('addnotations', dict()) 306 | } 307 | config['books'][self.title()] = self.configEntry 308 | 309 | 310 | class Interface: 311 | """base interface class, initializes interface with book""" 312 | def __init__(self, master, book): 313 | self.master = master 314 | self.book = book 315 | self.master.toggle_unicode_borders() 316 | # set application title to book's title 317 | self.master.set_title(self.book.title()) 318 | self.master.grid.offset_x = 0 319 | self.master.grid.offset_y = 0 320 | # calculate width for given lineLength in config for Displayer() 321 | lineLength = config.get('lineLength', 78) 322 | min_columns = (lineLength + 15) // self.master.grid.column_width + 1 323 | try: 324 | self.textDisplayer = self.master.add_Displayer('' ,row = 0, column = 0, row_span = 5, column_span = min_columns, book = self.book) 325 | except: 326 | self.textDisplayer = self.master.add_Displayer('' ,row = 0, column = 0, row_span = 5, column_span = self.master.grid.num_columns, book = self.book) 327 | if min_columns + 2 < self.master.grid.num_columns: 328 | self.addnotationsAllowed = True 329 | self.addnotationsMenu = self.master.add_Adnotation_menu('Notes', row = 0, column = min_columns, row_span = 5, column_span = self.master.grid.num_columns - min_columns) 330 | self.textDisplayer.add_key_command(pycui.keys.KEY_A_LOWER, self.add_adnotation) 331 | self.textDisplayer.addnotationDisplayer = self.addnotationsMenu 332 | self.addnotationsMenu.Displayer = self.textDisplayer 333 | self.addnotationsMenu.add_key_command(pycui.keys.KEY_ENTER, self.handle_adnotation_menu_enter) 334 | self.textDisplayer.load_adnotations() 335 | else: 336 | self.addnotationsAllowed = False 337 | self.textDisplayer.add_key_command(pycui.keys.KEY_T_LOWER, self.showTOC) 338 | self.master.run_on_exit(self.exit) 339 | self.master.move_focus(self.textDisplayer) 340 | 341 | def showTOC(self): 342 | # show table of contents 343 | tocList = [k for k, v in self.textDisplayer.getTOC().items()] 344 | self.master.show_menu_popup('Table of contents', tocList, self.handleTOC) 345 | 346 | def handleTOC(self, arg): 347 | # select from table of contents 348 | self.textDisplayer.force_chapter(arg) 349 | self.master.move_focus(self.textDisplayer) 350 | 351 | def add_adnotation(self): 352 | # show popup for adnotations 353 | self.master.show_text_box_popup('Text for adnotation:', self.adnotation_handle) 354 | 355 | def adnotation_handle(self, arg): 356 | # add adnotation after popup 357 | self.textDisplayer.add_adnotation_on_cursor(arg) 358 | self.master.move_focus(self.textDisplayer) 359 | 360 | def exit(self): 361 | self.textDisplayer.book.dump_to_config(self.textDisplayer.cursor_text_pos_x, self.textDisplayer.cursor_text_pos_y) 362 | 363 | def handle_adnotation_menu_enter(self): 364 | # jump to selected addnotation 365 | self.addnotationsMenu.handle_adnotation() 366 | self.master.move_focus(self.textDisplayer) 367 | 368 | 369 | def main(): 370 | global config 371 | # set default path for config 372 | configPath = os.path.expanduser('~/.tinypub.json') 373 | parser = argparse.ArgumentParser(description = 'A simple ebook reader with console interface by Grzegorz Koperwas', prog = 'tinyPub', epilog = 'Configuration and reading progress is stored in ~/.tinypub.json', allow_abbrev = True) 374 | parser.add_argument('file', type = str, help = 'Path to .epub file.', metavar = 'file_path') 375 | parser.add_argument('--config', dest='config', type = str, help = 'Path to alternative config file', default = configPath, metavar = 'PATH') 376 | parser.add_argument('--version', action='version', version = __version__) 377 | parser.add_argument('--cat', const=True, default=False, action="store_const") 378 | args = parser.parse_args() 379 | # read config, on failure, assume defaults 380 | try: 381 | with open(args.config) as f: 382 | config = json.loads(f.read()) 383 | except FileNotFoundError as e: 384 | config = { 385 | 'name': 'tinypub v.2', 386 | 'books': {}, 387 | 'lineLength': 78, 388 | 'keybinds': { 389 | 'h': 'prev', 390 | 'j': 'down', 391 | 'k': 'up', 392 | 'l': 'next' 393 | }, 394 | 'force_justify_on_p_tags': False 395 | } 396 | # load ebook 397 | book = Book(args.file) 398 | # if --cat → print epub 399 | if args.cat: 400 | for chapter in book.chapters: 401 | text, _ = chapter["chapter"].text() 402 | print(text) 403 | 404 | 405 | else: 406 | # start ui initialization 407 | root = impPYCUI(5, 30) 408 | # show error if terminal is to small 409 | try: 410 | s = Interface(root, book) 411 | root.start() 412 | except pycui.errors.PyCUIOutOfBoundsError: 413 | print('Your terminal is too small, try decreasing lineLength in ~/.tinypub.json') 414 | input() 415 | # After exit write to config file 416 | with open(args.config, 'w+') as f: 417 | f.write(json.dumps(config, indent = 4)) 418 | print('done!') 419 | 420 | 421 | config = None 422 | if __name__ == '__main__': 423 | main() 424 | -------------------------------------------------------------------------------- /tinyPub/addnotation.py: -------------------------------------------------------------------------------- 1 | import py_cui as pycui 2 | class Addnotation(): 3 | """Represents adnotation 4 | 5 | Parameters 6 | ---------- 7 | breaks: list 8 | list containing paragraph breaks, so each adnotation is bound to paragraph 9 | text: str 10 | text for addnotation 11 | position: int 12 | cursor_text_pos_y form Displayer() 13 | dict: dict 14 | can be used instead of text&position, used for loading from config 15 | """ 16 | def __init__(self, breaks, text = str(), position = -1, dict = dict()): 17 | """constructor for Addnotation()""" 18 | self.text = dict.get('text', text) 19 | pos = 0 20 | if position != -1: 21 | i = 1 22 | while True: 23 | if breaks[i] > position: 24 | pos = i - 1 25 | break 26 | i += 1 27 | self.pos = dict.get('position', pos) 28 | self.breaks = breaks 29 | def raw_pos(self): 30 | """converts relative pos to cursor_text_pos_y for use in Displayer()""" 31 | return self.breaks[self.pos] 32 | def dump(self): 33 | """converts addnotation to dict for storage""" 34 | return {'position': self.pos, 'text': self.text} 35 | 36 | class Adnotation_menu(pycui.widgets.ScrollMenu): 37 | """ScrollMenu but with methods to return objects form dict""" 38 | def __init__(self, id, title, grid, row, column, row_span, column_span, padx, pady): 39 | super(Adnotation_menu, self).__init__(id, title, grid, row, column, row_span, column_span, padx, pady) 40 | # Dict for: entry in ScrollMenu -> object 41 | self.decoding_dict = dict() 42 | self.Displayer = None 43 | self.set_focus_text('Press enter to jump to selected item.') 44 | def add_adnotation(self, adnotation): 45 | """Adds Addnotation() to menu, sorts addnotations based on position""" 46 | adnotations = list([self.decoding_dict[v] for v in self.get_item_list()]) 47 | adnotations.append(adnotation) 48 | adnotations.sort(key = lambda item: item.pos) 49 | self.decoding_dict = dict() 50 | res = list() 51 | for a in adnotations: 52 | text = str(len(res) + 1) + '. ' + a.text 53 | self.decoding_dict[text] = a 54 | res.append(text) 55 | self.clear() 56 | self.add_item_list(res) 57 | def handle_adnotation(self): 58 | """Returns selected Addnotation()""" 59 | adnotation = self.decoding_dict[self.get()] 60 | self.Displayer.jump_to_line(adnotation.raw_pos()) 61 | -------------------------------------------------------------------------------- /tinyPub/htmlParser/__init__.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup, Tag 2 | from .styles import StyleHandler, baseStyle, ppStyle 3 | from .renderer import render, debug_printer 4 | from ebooklib import epub 5 | import ebooklib, json 6 | from string import whitespace 7 | 8 | def space_count(string): 9 | if string.strip() == str(): 10 | return 999 11 | i = 0 12 | for char in string: 13 | if char in whitespace: 14 | i += 1 15 | else: 16 | break 17 | return i 18 | 19 | class Chapter(object): 20 | """Parses chapter html to raw text. 21 | 22 | Parameters 23 | ========== 24 | raw_: bytes 25 | html in bytes (utf-8) 26 | ignored_tags_: list of str 27 | tags that will be ignored with their children 28 | lineLength: int 29 | target max lineLength 30 | css_data: list of Style 31 | list with style objects to be used 32 | force_justify_on_p_tags: bool 33 | forces 'text-align: justify' for all

tags 34 | """ 35 | 36 | def __init__(self, 37 | raw_, 38 | ignored_tags_ = [], 39 | lineLength = 78, 40 | css_data = dict(), 41 | force_justify_on_p_tags = False): 42 | super(Chapter, self).__init__() 43 | self.ignored_tags = ignored_tags_ 44 | self.soup = BeautifulSoup(raw_.decode('utf-8'), features='lxml') 45 | self.width = lineLength 46 | self.css = baseStyle.copy() 47 | self.css.force_justify_on_p_tags = force_justify_on_p_tags 48 | for link in self.soup.find_all('link'): 49 | self.css.overwrite(css_data.get(link.get('href'))) 50 | def title(self): 51 | return self.soup.find('title').text.strip() 52 | def hasBody(self): 53 | body = self.soup.find('body') 54 | if body == None: 55 | return False 56 | if len(body.get_text().strip()) > 0: 57 | return True 58 | return False 59 | def contents(self): 60 | return self.soup.find('body') 61 | def render(self): 62 | return render(self.contents(), self.css, self.width, ignored_tags=self.ignored_tags) 63 | def text(self): 64 | """returns text from render() and generates breaks""" 65 | text = self.render() 66 | lines = text.split('\n') 67 | breaks = list() 68 | rising_edge = True 69 | for i in range(len(lines)): 70 | try: 71 | if rising_edge: 72 | if space_count(lines[i]) > space_count(lines[i+1]): 73 | rising_edge = False 74 | breaks.append(i) 75 | i += 1 76 | else: 77 | if space_count(lines[i]) < space_count(lines[i+1]): 78 | rising_edge = True 79 | i += 1 80 | except IndexError: 81 | break 82 | breaks.append(len(lines) - 1) 83 | return text, breaks 84 | 85 | 86 | 87 | 88 | if __name__ == '__main__': 89 | import sys 90 | book = epub.read_epub(sys.argv[1]) 91 | styles = dict() 92 | for x in book.get_items_of_type(ebooklib.ITEM_STYLE): 93 | styles[x.file_name] = StyleHandler(x.get_content()) 94 | for x in book.get_items_of_type(ebooklib.ITEM_DOCUMENT): 95 | chapter = Chapter(x.content, css_data = styles, lineLength=78, ignored_tags_ = ['sup']) 96 | chapter.text() 97 | -------------------------------------------------------------------------------- /tinyPub/htmlParser/default.py: -------------------------------------------------------------------------------- 1 | # string with default css based on firefox stylesheet 2 | default = """ 3 | /* This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 | 7 | /* parts were cut out as there ware unecessary for tinypub to function */ 8 | 9 | /* blocks */ 10 | 11 | article, 12 | aside, 13 | details, 14 | div, 15 | dt, 16 | figcaption, 17 | footer, 18 | form, 19 | header, 20 | hgroup, 21 | html, 22 | main, 23 | nav, 24 | section, 25 | summary { 26 | display: block; 27 | } 28 | 29 | body { 30 | display: block; 31 | margin-left: 1ex; 32 | } 33 | 34 | p, dl, multicol { 35 | display: block; 36 | margin-block-start: 1em; 37 | margin-block-end: 1em; 38 | text-align: justify; 39 | } 40 | 41 | dd { 42 | display: block; 43 | margin-inline-start: 40px; 44 | } 45 | 46 | blockquote, figure { 47 | display: block; 48 | margin-block-start: 2em; 49 | margin-block-end: 1em; 50 | margin-inline-start: 2em; 51 | margin-inline-end: 40px; 52 | } 53 | 54 | address { 55 | display: block; 56 | font-style: italic; 57 | } 58 | 59 | center { 60 | display: block; 61 | text-align: center; 62 | } 63 | 64 | h1 { 65 | display: block; 66 | font-size: 2em; 67 | font-weight: bold; 68 | margin-top: 1em; 69 | margin-bottom: 0.5em; 70 | } 71 | 72 | h2 { 73 | display: block; 74 | font-size: 1.5em; 75 | font-weight: bold; 76 | margin-top: 1em; 77 | margin-bottom: 0.5em; 78 | } 79 | 80 | h3 { 81 | display: block; 82 | font-size: 1.17em; 83 | font-weight: bold; 84 | margin-top: 0.5em; 85 | margin-bottom: 0.5em; 86 | } 87 | 88 | h4 { 89 | display: block; 90 | font-size: 1.00em; 91 | font-weight: bold; 92 | margin-top: 0.5em; 93 | margin-bottom: 0.5em; 94 | } 95 | 96 | h5 { 97 | display: block; 98 | font-size: 0.83em; 99 | font-weight: bold; 100 | margin-top: 0.5em; 101 | margin-bottom: 0.5em; 102 | } 103 | 104 | h6 { 105 | display: block; 106 | font-size: 0.67em; 107 | font-weight: bold; 108 | margin-top: 0.5em; 109 | margin-bottom: 0.5em; 110 | } 111 | 112 | listing { 113 | display: block; 114 | margin-block-start: 1em; 115 | margin-block-end: 1em; 116 | } 117 | 118 | xmp, pre, plaintext { 119 | display: block; 120 | margin-block-start: 1em; 121 | margin-block-end: 1em; 122 | } 123 | 124 | /* tables */ 125 | 126 | table { 127 | display: table; 128 | border-spacing: 2px; 129 | border-collapse: separate; 130 | /* XXXldb do we want this if we're border-collapse:collapse ? */ 131 | box-sizing: border-box; 132 | text-indent: 0; 133 | } 134 | 135 | /* caption inherits from table not table-outer */ 136 | caption { 137 | display: table-caption; 138 | text-align: center; 139 | } 140 | 141 | tr { 142 | display: table-row; 143 | vertical-align: inherit; 144 | } 145 | 146 | col { 147 | display: table-column; 148 | } 149 | 150 | colgroup { 151 | display: table-column-group; 152 | } 153 | 154 | tbody { 155 | display: table-row-group; 156 | vertical-align: middle; 157 | } 158 | 159 | thead { 160 | display: table-header-group; 161 | vertical-align: middle; 162 | } 163 | 164 | tfoot { 165 | display: table-footer-group; 166 | vertical-align: middle; 167 | } 168 | 169 | /* for XHTML tables without tbody */ 170 | 171 | td { 172 | display: table-cell; 173 | vertical-align: inherit; 174 | text-align: unset; 175 | padding: 1px; 176 | } 177 | 178 | th { 179 | display: table-cell; 180 | vertical-align: inherit; 181 | font-weight: bold; 182 | padding: 1px; 183 | } 184 | 185 | b, strong { 186 | font-weight: bolder; 187 | } 188 | 189 | i, cite, em, var, dfn { 190 | font-style: italic; 191 | } 192 | 193 | tt, code, kbd, samp { 194 | font-family: -moz-fixed; 195 | } 196 | 197 | u, ins { 198 | text-decoration: underline; 199 | } 200 | 201 | s, strike, del { 202 | text-decoration: line-through; 203 | } 204 | 205 | big { 206 | font-size: larger; 207 | } 208 | 209 | small { 210 | font-size: smaller; 211 | } 212 | 213 | sub { 214 | vertical-align: sub; 215 | font-size: smaller; 216 | } 217 | 218 | sup { 219 | vertical-align: super; 220 | font-size: smaller; 221 | } 222 | 223 | nobr { 224 | white-space: nowrap; 225 | } 226 | 227 | mark { 228 | background: yellow; 229 | color: black; 230 | } 231 | 232 | 233 | ul, menu, dir { 234 | display: block; 235 | list-style-type: disc; 236 | margin-block-start: 1em; 237 | margin-block-end: 1em; 238 | padding-inline-start: 40px; 239 | } 240 | 241 | ul, ol, menu { 242 | counter-reset: list-item; 243 | -moz-list-reversed: false; 244 | } 245 | 246 | ol { 247 | display: block; 248 | list-style-type: decimal; 249 | margin-block-start: 1em; 250 | margin-block-end: 1em; 251 | padding-inline-start: 40px; 252 | } 253 | 254 | li { 255 | display: list-item; 256 | text-align: match-parent; 257 | } 258 | 259 | /* leafs */ 260 | 261 | /*


noshade and color attributes are handled completely by 262 | * the nsHTMLHRElement attribute mapping code 263 | */ 264 | hr { 265 | display: block; 266 | border: 1px inset; 267 | margin-block-start: 0.5em; 268 | margin-block-end: 0.5em; 269 | margin-inline-start: auto; 270 | margin-inline-end: auto; 271 | color: gray; 272 | -moz-float-edge: margin-box; 273 | box-sizing: content-box; 274 | } 275 | 276 | frameset { 277 | display: block ! important; 278 | overflow: -moz-hidden-unscrollable; 279 | position: static ! important; 280 | float: none ! important; 281 | border: none ! important; 282 | } 283 | 284 | link { 285 | display: none; 286 | } 287 | 288 | frame { 289 | border-radius: 0 ! important; 290 | } 291 | 292 | iframe { 293 | border: 2px inset; 294 | } 295 | 296 | noframes { 297 | display: none; 298 | } 299 | 300 | spacer { 301 | position: static ! important; 302 | float: none ! important; 303 | } 304 | 305 | canvas { 306 | user-select: none; 307 | } 308 | 309 | /* hidden elements */ 310 | base, basefont, datalist, head, meta, script, style, title, 311 | noembed, param, template { 312 | display: none; 313 | } 314 | 315 | area { 316 | /* Don't give it frames other than its imageframe */ 317 | display: none ! important; 318 | } 319 | """ 320 | -------------------------------------------------------------------------------- /tinyPub/htmlParser/notes.md: -------------------------------------------------------------------------------- 1 | # Style handling: 2 | 3 | ## Style priority: 4 | 5 | 1. `style` property on tag 6 | 2. linked stylesheet 7 | 3. `