├── README.md ├── cpub └── dependencies.txt /README.md: -------------------------------------------------------------------------------- 1 | cpub 2 | ==== 3 | 4 | ncurses based epub reader for the command line. 5 | Based on https://github.com/rupa/epub 6 | 7 | ![GIF](https://thumbs.gfycat.com/OblongSaneChimneyswift-size_restricted.gif) 8 | 9 | Install 10 | ------- 11 | 12 | ``` 13 | usage: cpub 14 | ``` 15 | 16 | Copy the `cpub` binary to any directory which is in `$PATH`, preferably 17 | `$HOME/bin`. 18 | 19 | 20 | Controls 21 | -------- 22 | 23 | - **q** - quit 24 | 25 | ### Table of contents 26 | 27 | - **j/k** - up/down navigation of table of contents 28 | - **o** - open chapter 29 | 30 | ### Chapter 31 | 32 | - **j** - next page in chapter (or next chapter if on last page) 33 | - **k** - prev page in chapter (or prev chapter if on first page) 34 | - **b** - back to table of contents 35 | -------------------------------------------------------------------------------- /cpub: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' 3 | cpub: ncurses based epub reader for the command line 4 | 5 | ''' 6 | from __future__ import print_function 7 | 8 | import curses 9 | import curses.ascii 10 | from html.parser import HTMLParser 11 | import os 12 | import sys 13 | import io 14 | import zipfile 15 | import textwrap 16 | import re 17 | 18 | def eprint(*args, **kwargs): 19 | print(*args, file=sys.stderr, **kwargs) 20 | 21 | from bs4 import BeautifulSoup 22 | 23 | 24 | def parse_chapter(zfile, chfname, ssize): 25 | # eprint("parse chapter", chfname) 26 | class Parser(HTMLParser): 27 | def __init__(self, stream): 28 | HTMLParser.__init__(self) 29 | self.stream = stream 30 | self.style = False 31 | 32 | def handle_data(self, data): 33 | self.stream.write(data) 34 | 35 | def handle_starttag(self, tag, attr): 36 | attr = dict(attr) 37 | if tag == "p": 38 | self.stream.write(' ') 39 | elif tag == "i" or (tag == "span" and 'class' in attr and attr['class'] == "italic"): 40 | self.stream.write('#I') 41 | self.style = True 42 | elif tag == "b" or (tag == "span" and 'class' in attr and attr['class'] == "bold"): 43 | self.stream.write('#B') 44 | self.style = True 45 | elif tag == "br": 46 | self.stream.write('\n') 47 | 48 | def handle_endtag(self, tag): 49 | if tag == "p": 50 | self.stream.write('\n') 51 | elif self.style and (tag == "i" or tag == "b" or tag == "span"): 52 | self.stream.write('#e') 53 | self.style = False 54 | 55 | def handle_startendtag(self, tag, attr): 56 | if tag == "br": 57 | self.stream.write('\n') 58 | 59 | l,c = ssize 60 | nch = l*c 61 | 62 | soup = BeautifulSoup(zfile.read(chfname), 'html5lib') 63 | html_snip = re.sub(r"\n\s*", r" ", str(soup.find('body'))) 64 | re.sub 65 | # eprint("html:", html_snip) 66 | 67 | stream = io.StringIO() 68 | p = Parser(stream) 69 | p.feed(html_snip) 70 | p.close() 71 | 72 | wp = textwrap.TextWrapper(width=c) 73 | al = [] 74 | 75 | for para in stream.getvalue().split('\n'): 76 | pl = wp.wrap(para) 77 | al.extend(pl) 78 | return [al[i:i+l] for i in range(0,len(al),l)] 79 | 80 | def parse_toc(zfile): 81 | soup = BeautifulSoup(zfile.read('META-INF/container.xml'), 'html5lib') 82 | opf = soup.find('rootfile').attrs['full-path'] 83 | 84 | basedir = os.path.dirname(opf) 85 | if basedir: 86 | basedir = '{0}/'.format(basedir) 87 | 88 | soup = BeautifulSoup(zfile.read(opf), 'html5lib') 89 | 90 | toc = [] 91 | title = str(soup.find('dc:title').text) 92 | author = str(soup.find('dc:creator').text) 93 | 94 | flnames = {} 95 | ncx = None 96 | for item in soup.find('manifest').findAll('item'): 97 | flnames[item['id']] = '{0}{1}'.format(basedir, item['href']) 98 | if item['media-type'] == 'application/x-dtbncx+xml': 99 | ncx = '{0}{1}'.format(basedir, item['href']) 100 | 101 | chorder = [] 102 | for item in soup.find('spine').findAll('itemref'): 103 | chorder.append(flnames[item['idref']]) 104 | 105 | chnames = {} 106 | if ncx: 107 | soup = BeautifulSoup(zfile.read(ncx), 'html5lib') 108 | for np in soup.findAll('navpoint'): 109 | k = np.find('content').get('src', None).split('#')[0] 110 | if k: 111 | chnames[k] = np.find('navlabel').text.strip() 112 | # print("Navpoint", k, chnames[k]) 113 | 114 | for i,ch in enumerate(chorder): 115 | if ch in chnames: 116 | toc.append((chnames[ch], ch)) 117 | else: 118 | toc.append(('' % i, ch)) 119 | 120 | return title,author,toc 121 | 122 | class EpubViewer: 123 | def __init__(self, fl): 124 | try: 125 | self.epub = zipfile.ZipFile(fl, 'a') 126 | self.writable = True 127 | except: 128 | self.epub = zipfile.ZipFile(fl, 'r') 129 | self.writable = False 130 | self.title, self.author, self.toc = parse_toc(self.epub) 131 | self.ntoc = len(self.toc) 132 | 133 | # eprint(self.epub.namelist()) 134 | if 'bookmark.cpub' in self.epub.namelist(): 135 | self.mode = "BMK" 136 | bmk = self.epub.read('bookmark.cpub').split(b':') 137 | self.toc_cur = int(bmk[0]) 138 | self.toc_start = max(self.toc_cur - 5, 0) 139 | self.bmk_page, self.bmk_len = int(bmk[2]), int(bmk[1]) 140 | else: 141 | self.mode = "TOC" 142 | 143 | self.toc_start = 0 144 | self.toc_cur = 0 145 | 146 | self.ch_page = 0 147 | self.ch_cur = None 148 | 149 | def __call__(self, screen): 150 | curses.curs_set(0) 151 | while True: 152 | self.l, self.c = screen.getmaxyx() 153 | self.pad = int(self.c/4) 154 | self.w = int(self.c/2) 155 | 156 | self.show_title(screen) 157 | if self.mode == "TOC": 158 | self.show_toc(screen) 159 | elif self.mode == "CH": 160 | self.show_pagenum(screen) 161 | self.show_page(screen) 162 | elif self.mode == "BMK": 163 | self.toc_cur -= 1 164 | self.next_ch() 165 | self.ch_page = (self.bmk_page * len(self.ch_cur)) // self.bmk_len 166 | 167 | self.mode = "CH" 168 | continue 169 | 170 | c = screen.getch() 171 | dirty = True 172 | 173 | # next page keys 174 | if self.mode == "TOC": 175 | if c == ord('j'): 176 | if self.toc_cur < self.ntoc - 1: 177 | self.toc_cur += 1 178 | if self.toc_cur - self.toc_start > 2*self.l/3: 179 | self.toc_start += 1 180 | else: 181 | dirty = False 182 | elif c == ord('k'): 183 | if self.toc_cur > 0: 184 | self.toc_cur -= 1 185 | if self.toc_cur - self.toc_start > self.l/3 and self.toc_start > 0: 186 | self.toc_start -= 1 187 | else: 188 | dirty = False 189 | elif c == ord('o'): 190 | self.toc_cur -= 1 191 | self.next_ch() 192 | self.mode = 'CH' 193 | elif c == ord('q'): 194 | if self.writable: 195 | self.save_bookmark() 196 | return 197 | else: 198 | dirty = False 199 | elif self.mode == "CH": 200 | if c == ord('j'): 201 | if self.ch_page >= len(self.ch_cur)-1: 202 | self.next_ch() 203 | else: 204 | self.ch_page += 1 205 | elif c == ord('k'): 206 | if self.ch_page == 0: 207 | self.prev_ch() 208 | else: 209 | self.ch_page -= 1 210 | elif c == ord('n'): 211 | self.next_ch() 212 | elif c == ord('p'): 213 | self.prev_ch() 214 | elif c == ord('b'): 215 | self.mode = 'TOC' 216 | elif c == ord('q'): 217 | if self.writable: 218 | self.save_bookmark() 219 | return 220 | else: 221 | dirty = False 222 | 223 | if dirty: 224 | screen.clear() 225 | 226 | 227 | def next_ch(self): 228 | if self.toc_cur < self.ntoc - 1: 229 | self.toc_cur += 1 230 | self.ch_page = 0 231 | self.ch_cur = parse_chapter(self.epub, self.toc[self.toc_cur][1], 232 | (self.l, self.w)) 233 | 234 | def prev_ch(self): 235 | if self.toc_cur > 0: 236 | self.toc_cur -= 1 237 | self.ch_page = 0 238 | self.ch_cur = parse_chapter(self.epub, self.toc[self.toc_cur][1], 239 | (self.l, self.w)) 240 | 241 | def show_pagenum(self, screen): 242 | y,x = self.l-1, self.pad + self.w + 5 243 | pageinfo = textwrap.wrap('{0}/{1} - {2}'.format(self.ch_page+1, len(self.ch_cur), self.toc[self.toc_cur][0]), 244 | width=self.c - x) 245 | for l in reversed(pageinfo): 246 | screen.addstr(y, x, l) 247 | y -= 1 248 | 249 | def show_title(self, screen): 250 | tlines = textwrap.wrap(self.title, width=self.pad-5) 251 | for i,ln in enumerate(tlines): 252 | screen.addstr(i,0,ln, curses.A_BOLD) 253 | alines = textwrap.wrap(' - ' + self.author, width=self.pad-5) 254 | for i,ln in enumerate(alines): 255 | screen.addstr(len(tlines)+i,0,ln, curses.A_ITALIC) 256 | 257 | def show_toc(self, screen): 258 | ts = self.toc_start 259 | te = min(ts+self.l,self.ntoc) 260 | 261 | for i,(chname,chfile) in enumerate(self.toc[ts:te]): 262 | screen.addstr(i,self.pad, chname) 263 | screen.clrtoeol() 264 | 265 | screen.chgat(self.toc_cur-ts, self.pad, self.w, curses.A_REVERSE) 266 | 267 | def show_page(self, screen): 268 | # eprint("Show page", self.ch_cur) 269 | if len(self.ch_cur) == 0: 270 | screen.addstr(0, self.pad, "empty", curses.A_ITALIC) 271 | return 272 | 273 | state = 0 274 | for i,line in enumerate(self.ch_cur[self.ch_page]): 275 | if '#' in line: 276 | eprint(self.toc_cur,self.ch_page,i,line) 277 | k = 0 278 | for c in line: 279 | if c == '#': 280 | state = -1 281 | continue 282 | if state < 0: 283 | if c == 'I': 284 | state = curses.A_ITALIC 285 | elif c == 'B': 286 | state = curses.A_BOLD 287 | elif c == 'e': 288 | state = 0 289 | else: 290 | screen.addstr(i, self.pad+k, c, state) 291 | k += 1 292 | else: 293 | screen.addstr(i, self.pad, line) 294 | 295 | def save_bookmark(self): 296 | self.epub.writestr('bookmark.cpub', '{0}:{1}:{2}'.format(self.toc_cur, len(self.ch_cur), self.ch_page)) 297 | 298 | if __name__ == '__main__': 299 | fl = sys.argv[1] 300 | viewer = EpubViewer(fl) 301 | curses.wrapper(viewer) 302 | 303 | -------------------------------------------------------------------------------- /dependencies.txt: -------------------------------------------------------------------------------- 1 | bs4==4.4.1 2 | --------------------------------------------------------------------------------