#!/usr/bin/env python3 """\ Usages: epy read last epub epy EPUBFILE read EPUBFILE epy STRINGS read matched STRINGS from history epy NUMBER read file from history with associated NUMBER Options: -r print reading history -d dump epub -h, --help print short, long help """ __version__ = "2020.4.18" __license__ = "MIT" __author__ = "Benawi Adha" __url__ = "https://github.com/wustho/epy" import base64 import curses import zipfile import sys import re import os import textwrap import json import tempfile import shutil import subprocess import xml.etree.ElementTree as ET from urllib.parse import unquote from html import unescape # from subprocess import run from html.parser import HTMLParser from difflib import SequenceMatcher as SM from functools import wraps # -1 is default terminal fg/bg colors CFG = { "DefaultViewer": "auto", "DictionaryClient": "auto", "EnableProgressIndicator": True, "DarkColorFG": 252, "DarkColorBG": 235, "LightColorFG": 238, "LightColorBG": 253, "Keys": { "ScrollUp": "k", "ScrollDown": "j", "PageUp": "h", "PageDown": "l", "NextChapter": "n", "PrevChapter": "p", "BeginningOfCh": "g", "EndOfCh": "G", "Shrink": "-", "Enlarge": "+", "SetWidth": "=", "Metadata": "M", "DefineWord": "d", "ToC": "t", "Follow": "f", "OpenImage": "o", "RegexSearch": "/", "ShowHideProgress": "s", "MarkPosition": "m", "JumpToPosition": "`", "AddBookmark": "b", "ShowBookmarks": "B", "Quit": "q", "Help": "?", "SwitchColor": "c" } } STATE = { "LastRead": "", "States": {} } # default keys K = { "ScrollUp": {curses.KEY_UP}, "ScrollDown": {curses.KEY_DOWN}, "PageUp": {curses.KEY_PPAGE, curses.KEY_LEFT}, "PageDown": {curses.KEY_NPAGE, ord(" "), curses.KEY_RIGHT}, "BeginningOfCh": {curses.KEY_HOME}, "EndOfCh": {curses.KEY_END}, "ToC": {9, ord("\t")}, "Follow": {10}, "Quit": {3, 27, 304} } WINKEYS = set() CFGFILE = "" STATEFILE = "" COLORSUPPORT = False LINEPRSRV = 0 # 2 SEARCHPATTERN = None VWR = None DICT = None SCREEN = None PERCENTAGE = [] JUMPLIST = {} SHOWPROGRESS = CFG["EnableProgressIndicator"] class Epub: NS = { "DAISY": "http://www.daisy.org/z3986/2005/ncx/", "OPF": "http://www.idpf.org/2007/opf", "CONT": "urn:oasis:names:tc:opendocument:xmlns:container", "XHTML": "http://www.w3.org/1999/xhtml", "EPUB": "http://www.idpf.org/2007/ops" } def __init__(self, fileepub): self.path = os.path.abspath(fileepub) self.file = zipfile.ZipFile(fileepub, "r") cont = ET.parse(self.file.open("META-INF/container.xml")) self.rootfile = cont.find( "CONT:rootfiles/CONT:rootfile", self.NS ).attrib["full-path"] self.rootdir = os.path.dirname(self.rootfile)\ + "/" if os.path.dirname(self.rootfile) != "" else "" cont = ET.parse(self.file.open(self.rootfile)) # EPUB3 self.version = cont.getroot().get("version") if self.version == "2.0": # "OPF:manifest/*[@id='ncx']" self.toc = self.rootdir\ + cont.find( "OPF:manifest/*[@media-type='application/x-dtbncx+xml']", self.NS ).get("href") elif self.version == "3.0": self.toc = self.rootdir\ + cont.find( "OPF:manifest/*[@properties='nav']", self.NS ).get("href") self.contents = [] self.toc_entries = [[], [], []] def get_meta(self): meta = [] # why self.file.read(self.rootfile) problematic cont = ET.fromstring(self.file.open(self.rootfile).read()) for i in cont.findall("OPF:metadata/*", self.NS): if i.text is not None: meta.append([re.sub("{.*?}", "", i.tag), i.text]) return meta def initialize(self): cont = ET.parse(self.file.open(self.rootfile)).getroot() manifest = [] for i in cont.findall("OPF:manifest/*", self.NS): # EPUB3 # if i.get("id") != "ncx" and i.get("properties") != "nav": if i.get("media-type") != "application/x-dtbncx+xml"\ and i.get("properties") != "nav": manifest.append([ i.get("id"), i.get("href") ]) spine, contents = [], [] for i in cont.findall("OPF:spine/*", self.NS): spine.append(i.get("idref")) for i in spine: for j in manifest: if i == j[0]: self.contents.append(self.rootdir+unquote(j[1])) contents.append(unquote(j[1])) manifest.remove(j) # TODO: test is break necessary break toc = ET.parse(self.file.open(self.toc)).getroot() # EPUB3 if self.version == "2.0": navPoints = toc.findall("DAISY:navMap//DAISY:navPoint", self.NS) elif self.version == "3.0": navPoints = toc.findall( "XHTML:body//XHTML:nav[@EPUB:type='toc']//XHTML:a", self.NS ) for i in navPoints: if self.version == "2.0": src = i.find("DAISY:content", self.NS).get("src") name = i.find("DAISY:navLabel/DAISY:text", self.NS).text elif self.version == "3.0": src = i.get("href") name = "".join(list(i.itertext())) src = src.split("#") idx = contents.index(unquote(src[0])) self.toc_entries[0].append(name) self.toc_entries[1].append(idx) if len(src) == 2: self.toc_entries[2].append(src[1]) elif len(src) == 1: self.toc_entries[2].append("") def get_raw_text(self, chpath): content = self.file.open(chpath).read() return content.decode("utf-8") def get_img_bytestr(self, impath): return impath, self.file.read(impath) class FictionBook: NS = { "FB2": "http://www.gribuser.ru/xml/fictionbook/2.0" } def __init__(self, filefb): self.path = os.path.abspath(filefb) cont = ET.parse(filefb) self.root = cont.getroot() self.contents = [] self.toc_entries = [[], [], []] def get_meta(self): desc = self.root.find("FB2:description", self.NS) alltags = desc.findall("*/*") return [[re.sub("{.*?}", "", i.tag), " ".join(i.itertext())] for i in alltags] def initialize(self): self.contents = self.root.findall("FB2:body/*", self.NS) # TODO for n, i in enumerate(self.contents): title = i.find("FB2:title", self.NS) if title is not None: self.toc_entries[0].append("".join(title.itertext())) self.toc_entries[1].append(n) self.toc_entries[2].append("") def get_raw_text(self, node): ET.register_namespace("", "http://www.gribuser.ru/xml/fictionbook/2.0") # sys.exit(ET.tostring(node, encoding="utf8", method="html").decode("utf-8").replace("ns1:","")) return ET.tostring(node, encoding="utf8", method="html").decode("utf-8").replace("ns1:","") def get_img_bytestr(self, imgid): imgid = imgid.replace("#", "") img = self.root.find("*[@id='{}']".format(imgid)) imgtype = img.get("content-type").split("/")[1] return imgid+"."+imgtype, base64.b64decode(img.text) class HTMLtoLines(HTMLParser): para = {"p", "div"} inde = {"q", "dt", "dd", "blockquote"} pref = {"pre"} bull = {"li"} hide = {"script", "style", "head"} # hide = {"script", "style", "head", ", "sub} def __init__(self, sects={""}): HTMLParser.__init__(self) self.text = [""] self.imgs = [] self.ishead = False self.isinde = False self.isbull = False self.ispref = False self.ishidden = False self.idhead = set() self.idinde = set() self.idbull = set() self.idpref = set() self.sects = sects def handle_starttag(self, tag, attrs): if re.match("h[1-6]", tag) is not None: self.ishead = True elif tag in self.inde: self.isinde = True elif tag in self.pref: self.ispref = True elif tag in self.bull: self.isbull = True elif tag in self.hide: self.ishidden = True elif tag == "sup": self.text[-1] += "^{" elif tag == "sub": self.text[-1] += "_{" elif tag == "image": for i in attrs: # if i[0] == "xlink:href": if i[0].endswith("href"): self.text.append("[IMG:{}]".format(len(self.imgs))) self.imgs.append(unquote(i[1])) if self.sects != {""}: for i in attrs: if i[1] in self.sects: self.text[-1] += " (#" + i[1] + ") " def handle_startendtag(self, tag, attrs): if tag == "br": self.text += [""] elif tag in {"img", "image"}: for i in attrs: if (tag == "img" and i[0] == "src")\ or (tag == "image" and i[0] == "xlink:href"): self.text.append("[IMG:{}]".format(len(self.imgs))) self.imgs.append(unquote(i[1])) self.text.append("") def handle_endtag(self, tag): if re.match("h[1-6]", tag) is not None: self.text.append("") self.text.append("") self.ishead = False elif tag in self.para: self.text.append("") elif tag in self.hide: self.ishidden = False elif tag in self.inde: if self.text[-1] != "": self.text.append("") self.isinde = False elif tag in self.pref: if self.text[-1] != "": self.text.append("") self.ispref = False elif tag in self.bull: if self.text[-1] != "": self.text.append("") self.isbull = False elif tag in {"sub", "sup"}: self.text[-1] += "}" elif tag == "image": self.text.append("") def handle_data(self, raw): if raw and not self.ishidden: if self.text[-1] == "": tmp = raw.lstrip() else: tmp = raw if self.ispref: line = unescape(tmp) else: line = unescape(re.sub(r"\s+", " ", tmp)) self.text[-1] += line if self.ishead: self.idhead.add(len(self.text)-1) elif self.isbull: self.idbull.add(len(self.text)-1) elif self.isinde: self.idinde.add(len(self.text)-1) elif self.ispref: self.idpref.add(len(self.text)-1) def get_lines(self, width=0): text, sect = [], {} if width == 0: return self.text for n, i in enumerate(self.text): findsect = re.search(r"(?<= \(#).*?(?=\) )", i) if findsect is not None and findsect.group() in self.sects: i = i.replace(" (#" + findsect.group() + ") ", "") sect[findsect.group()] = len(text) if n in self.idhead: text += [i.rjust(width//2 + len(i)//2)] + [""] elif n in self.idinde: text += [ " "+j for j in textwrap.wrap(i, width - 3) ] + [""] elif n in self.idbull: tmp = textwrap.wrap(i, width - 3) text += [ " - "+j if j == tmp[0] else " "+j for j in tmp ] + [""] elif n in self.idpref: tmp = i.splitlines() wraptmp = [] for line in tmp: wraptmp += [j for j in textwrap.wrap(line, width - 6)] text += [" "+j for j in wraptmp] + [""] else: text += textwrap.wrap(i, width) + [""] return text, self.imgs, sect def text_win(textfunc): @wraps(textfunc) def wrapper(*args, **kwargs): rows, cols = SCREEN.getmaxyx() hi, wi = rows - 4, cols - 4 Y, X = 2, 2 textw = curses.newwin(hi, wi, Y, X) if COLORSUPPORT: textw.bkgd(SCREEN.getbkgd()) title, raw_texts, key = textfunc(*args, **kwargs) if len(title) > cols-8: title = title[:cols-8] texts = [] for i in raw_texts.splitlines(): texts += textwrap.wrap(i, wi - 6, drop_whitespace=False) textw.box() textw.keypad(True) textw.addstr(1, 2, title) textw.addstr(2, 2, "-"*len(title)) key_textw = 0 totlines = len(texts) pad = curses.newpad(totlines, wi - 2) if COLORSUPPORT: pad.bkgd(SCREEN.getbkgd()) pad.keypad(True) for n, i in enumerate(texts): pad.addstr(n, 0, i) y = 0 textw.refresh() pad.refresh(y, 0, Y+4, X+4, rows - 5, cols - 6) padhi = rows - 8 - Y while key_textw not in K["Quit"]|key: if key_textw in K["ScrollUp"] and y > 0: y -= 1 elif key_textw in K["ScrollDown"] and y < totlines - hi + 6: y += 1 elif key_textw in K["PageUp"]: y = pgup(y, padhi) elif key_textw in K["PageDown"]: y = pgdn(y, totlines, padhi) elif key_textw in K["BeginningOfCh"]: y = 0 elif key_textw in K["EndOfCh"]: y = pgend(totlines, padhi) elif key_textw in WINKEYS - key: textw.clear() textw.refresh() return key_textw pad.refresh(y, 0, 6, 5, rows - 5, cols - 5) key_textw = textw.getch() textw.clear() textw.refresh() return return wrapper def choice_win(allowdel=False): def inner_f(listgen): @wraps(listgen) def wrapper(*args, **kwargs): rows, cols = SCREEN.getmaxyx() hi, wi = rows - 4, cols - 4 Y, X = 2, 2 chwin = curses.newwin(hi, wi, Y, X) if COLORSUPPORT: chwin.bkgd(SCREEN.getbkgd()) title, ch_list, index, key = listgen(*args, **kwargs) if len(title) > cols-8: title = title[:cols-8] chwin.box() chwin.keypad(True) chwin.addstr(1, 2, title) chwin.addstr(2, 2, "-"*len(title)) if allowdel: chwin.addstr(3, 2, "HINT: Press 'd' to delete.") key_chwin = 0 totlines = len(ch_list) chwin.refresh() pad = curses.newpad(totlines, wi - 2) if COLORSUPPORT: pad.bkgd(SCREEN.getbkgd()) pad.keypad(True) padhi = rows - 5 - Y - 4 + 1 - (1 if allowdel else 0) # padhi = rows - 5 - Y - 4 + 1 - 1 y = 0 if index in range(padhi//2, totlines - padhi//2): y = index - padhi//2 + 1 span = [] for n, i in enumerate(ch_list): # strs = " " + str(n+1).rjust(d) + " " + i[0] strs = " " + i strs = strs[0:wi-3] pad.addstr(n, 0, strs) span.append(len(strs)) countstring = "" while key_chwin not in K["Quit"]|key: if countstring == "": count = 1 else: count = int(countstring) if key_chwin in range(48, 58): # i.e., k is a numeral countstring = countstring + chr(key_chwin) else: if key_chwin in K["ScrollUp"] or key_chwin in K["PageUp"]: index -= count if index < 0: index = 0 elif key_chwin in K["ScrollDown"] or key_chwin in K["PageDown"]: index += count if index + 1 >= totlines: index = totlines - 1 elif key_chwin in K["Follow"]: chwin.clear() chwin.refresh() return index, None # elif key_chwin in K["PageUp"]: # index -= 3 # if index < 0: # index = 0 # elif key_chwin in K["PageDown"]: # index += 3 # if index >= totlines: # index = totlines - 1 elif key_chwin in K["BeginningOfCh"]: index = 0 elif key_chwin in K["EndOfCh"]: index = totlines - 1 elif key_chwin == ord("D") and allowdel: return (0 if index == 0 else index-1), index # chwin.redrawwin() # chwin.refresh() elif key_chwin == ord("d") and allowdel: resp, _ = choice_win()( lambda: ("Delete '{}'?".format( ch_list[index] ), ["(Y)es", "(N)o"], 0, {ord("n")}) )() if resp == 0: return (0 if index == 0 else index-1), index chwin.redrawwin() chwin.refresh() elif key_chwin in {ord(i) for i in ["Y", "y", "N", "n"]}\ and ch_list == ["(Y)es", "(N)o"]: if key_chwin in {ord("Y"), ord("y")}: return 0, None else: return 1, None elif key_chwin in WINKEYS - key: chwin.clear() chwin.refresh() return key_chwin, None countstring = "" while index not in range(y, y+padhi): if index < y: y -= 1 else: y += 1 for n in range(totlines): att = curses.A_REVERSE if index == n else curses.A_NORMAL pre = ">>" if index == n else " " pad.addstr(n, 0, pre) pad.chgat(n, 0, span[n], pad.getbkgd() | att) pad.refresh(y, 0, Y+4+(1 if allowdel else 0), X+4, rows - 5, cols - 6) # pad.refresh(y, 0, Y+5, X+4, rows - 5, cols - 6) key_chwin = chwin.getch() chwin.clear() chwin.refresh() return None, None return wrapper return inner_f def loadstate(): global CFG, STATE, CFGFILE, STATEFILE prefix = "" if os.getenv("HOME") is not None: homedir = os.getenv("HOME") if os.path.isdir(os.path.join(homedir, ".config")): prefix = os.path.join(homedir, ".config", "epy") else: prefix = os.path.join(homedir, ".epy") elif os.getenv("USERPROFILE") is not None: prefix = os.path.join(os.getenv("USERPROFILE"), ".epy") else: CFGFILE = os.devnull STATEFILE = os.devnull os.makedirs(prefix, exist_ok=True) CFGFILE = os.path.join(prefix, "config.json") STATEFILE = os.path.join(prefix, "state.json") try: with open(CFGFILE) as f: CFG = json.load(f) with open(STATEFILE) as f: STATE = json.load(f) except FileNotFoundError: pass def parse_keys(): global WINKEYS for i in CFG["Keys"].keys(): try: K[i].add(ord(CFG["Keys"][i])) except KeyError: K[i] = {ord(CFG["Keys"][i])} WINKEYS = {curses.KEY_RESIZE}|K["Metadata"]|K["Help"]|\ K["ToC"]|K["ShowBookmarks"] def savestate(file, index, width, pos, pctg): with open(CFGFILE, "w") as f: json.dump(CFG, f, indent=2) STATE["LastRead"] = file STATE["States"][file]["index"] = index STATE["States"][file]["width"] = width STATE["States"][file]["pos"] = pos STATE["States"][file]["pctg"] = pctg with open(STATEFILE, "w") as f: json.dump(STATE, f, indent=4) def pgup(pos, winhi, preservedline=0, c=1): if pos >= (winhi - preservedline) * c: return pos - (winhi + preservedline) * c else: return 0 def pgdn(pos, tot, winhi, preservedline=0, c=1): if pos + (winhi * c) <= tot - winhi: return pos + (winhi * c) else: pos = tot - winhi if pos < 0: return 0 return pos def pgend(tot, winhi): if tot - winhi >= 0: return tot - winhi else: return 0 @choice_win() def toc(src, index): return "Table of Contents", src, index, K["ToC"] @text_win def meta(ebook): mdata = "[File Info]\nPATH: {}\nSIZE: {} MB\n \n[Book Info]\n".format( ebook.path, round(os.path.getsize(ebook.path)/1024**2, 2) ) for i in ebook.get_meta(): data = re.sub("<[^>]*>", "", i[1]) mdata += i[0].upper() + ": " + data + "\n" data = re.sub("\t", "", data) # mdata += textwrap.wrap(i[0].upper() + ": " + data, wi - 6) return "Metadata", mdata, K["Metadata"] @text_win def help(): # src = re.search("Key Bind(\n|.)*", __doc__).group() src = "Key Bindings\n" for i in CFG["Keys"].keys(): src += " " + i + ": " + CFG["Keys"][i] + "\n" return "Help", src, K["Help"] @text_win def errmsg(title, msg, key): return title, msg, key def bookmarks(ebookpath): idx = 0 while True: bmarkslist = [ i[0] for i in STATE["States"][ebookpath]["bmarks"] ] if bmarkslist == []: return list(K["ShowBookmarks"])[0] idx, todel = choice_win(True)(lambda: ("Bookmarks", bmarkslist, idx, {ord("B")}) )() if todel is not None: del STATE["States"][ebookpath]["bmarks"][todel] else: return idx def input_prompt(prompt): # prevent pad hole when prompting for input while # other window is active # pad.refresh(y, 0, 0, x, rows-2, x+width) rows, cols = SCREEN.getmaxyx() stat = curses.newwin(1, cols, rows-1, 0) if COLORSUPPORT: stat.bkgd(SCREEN.getbkgd()) stat.keypad(True) curses.echo(1) curses.curs_set(1) init_text = "" stat.addstr(0, 0, prompt, curses.A_REVERSE) stat.addstr(0, len(prompt), init_text) stat.refresh() try: while True: ipt = stat.getch() if ipt == 27: stat.clear() stat.refresh() curses.echo(0) curses.curs_set(0) return elif ipt == 10: stat.clear() stat.refresh() curses.echo(0) curses.curs_set(0) return init_text elif ipt in {8, curses.KEY_BACKSPACE}: init_text = init_text[:-1] elif ipt == curses.KEY_RESIZE: stat.clear() stat.refresh() curses.echo(0) curses.curs_set(0) return curses.KEY_RESIZE # elif len(init_text) <= maxlen: else: init_text += chr(ipt) stat.clear() stat.addstr(0, 0, prompt, curses.A_REVERSE) stat.addstr( 0, len(prompt), init_text if len(prompt+init_text) < cols else "..."+init_text[len(prompt)-cols+4:] ) stat.refresh() except KeyboardInterrupt: stat.clear() stat.refresh() curses.echo(0) curses.curs_set(0) return def det_ebook_cls(file): filext = os.path.splitext(file)[1] if filext == ".epub": return Epub(file) elif filext == ".fb2": return FictionBook(file) else: sys.exit("ERR: Format not supported. (Supported: epub, fb2)") def dots_path(curr, tofi): candir = curr.split("/") tofi = tofi.split("/") alld = tofi.count("..") t = len(candir) candir = candir[0:t-alld-1] try: while True: tofi.remove("..") except ValueError: pass return "/".join(candir+tofi) def find_dict_client(): global DICT if shutil.which(CFG["DictionaryClient"].split()[0]) is not None: DICT = CFG["DictionaryClient"] else: DICT_LIST = [ "sdcv", "dict" ] for i in DICT_LIST: if shutil.which(i) is not None: DICT = i break if DICT in {"sdcv"}: DICT += " -n" def find_media_viewer(): global VWR if shutil.which(CFG["DefaultViewer"].split()[0]) is not None: VWR = CFG["DefaultViewer"] elif sys.platform == "win32": VWR = "start" elif sys.platform == "darwin": VWR = "open" else: VWR_LIST = [ "feh", "gio", "gnome-open", "gvfs-open", "xdg-open", "kde-open", "firefox" ] for i in VWR_LIST: if shutil.which(i) is not None: VWR = i break if VWR in {"gio"}: VWR += " open" def open_media(scr, name, bstr): sfx = os.path.splitext(name)[1] fd, path = tempfile.mkstemp(suffix=sfx) try: with os.fdopen(fd, "wb") as tmp: # tmp.write(epub.file.read(src)) tmp.write(bstr) # run(VWR + " " + path, shell=True) subprocess.call( VWR + " " + path, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) k = scr.getch() finally: os.remove(path) return k @text_win def define_word(word): rows, cols = SCREEN.getmaxyx() hi, wi = 5, 16 Y, X = (rows - hi)//2, (cols - wi)//2 p = subprocess.Popen( "{} {}".format(DICT, word), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True ) dictwin = curses.newwin(hi, wi, Y, X) dictwin.box() dictwin.addstr((hi-1)//2, (wi-10)//2, "Loading...") dictwin.refresh() out, err = p.communicate() dictwin.clear() dictwin.refresh() if err == b"": return "Definition: " + word.upper(), out.decode(), K["DefineWord"] else: return "Error: " + DICT, err.decode(), K["DefineWord"] def searching(pad, src, width, y, ch, tot): global SEARCHPATTERN rows, cols = SCREEN.getmaxyx() x = (cols - width) // 2 if SEARCHPATTERN is None: candtext = input_prompt(" Regex:") if candtext is None: return y elif isinstance(candtext, str): SEARCHPATTERN = "/" + candtext elif candtext == curses.KEY_RESIZE: return candtext if SEARCHPATTERN in {"?", "/"}: SEARCHPATTERN = None return y found = [] try: pattern = re.compile(SEARCHPATTERN[1:], re.IGNORECASE) except re.error as reerrmsg: SEARCHPATTERN = None tmpk = errmsg("!Regex Error", str(reerrmsg), set()) return tmpk for n, i in enumerate(src): for j in pattern.finditer(i): found.append([n, j.span()[0], j.span()[1] - j.span()[0]]) if found == []: if SEARCHPATTERN[0] == "/" and ch + 1 < tot: return 1 elif SEARCHPATTERN[0] == "?" and ch > 0: return -1 else: s = 0 while True: if s in K["Quit"]: SEARCHPATTERN = None SCREEN.clear() SCREEN.refresh() return y elif s == ord("n") and ch == 0: SEARCHPATTERN = "/"+SEARCHPATTERN[1:] return 1 elif s == ord("N") and ch + 1 == tot: SEARCHPATTERN = "?"+SEARCHPATTERN[1:] return -1 SCREEN.clear() SCREEN.addstr( rows-1, 0, " Finished searching: " + SEARCHPATTERN[1:cols-22] + " ", curses.A_REVERSE ) SCREEN.refresh() pad.refresh(y, 0, 0, x, rows-2, x+width) s = pad.getch() sidx = len(found) - 1 if SEARCHPATTERN[0] == "/": if y > found[-1][0]: return 1 for n, i in enumerate(found): if i[0] >= y: sidx = n break s = 0 msg = " Searching: "\ + SEARCHPATTERN[1:]\ + " --- Res {}/{} Ch {}/{} ".format( sidx + 1, len(found), ch+1, tot ) while True: if s in K["Quit"]: SEARCHPATTERN = None for i in found: pad.chgat(i[0], i[1], i[2], pad.getbkgd()) SCREEN.clear() SCREEN.refresh() return y elif s == ord("n"): SEARCHPATTERN = "/"+SEARCHPATTERN[1:] if sidx == len(found) - 1: if ch + 1 < tot: return 1 else: s = 0 msg = " Finished searching: " + SEARCHPATTERN[1:] + " " continue else: sidx += 1 msg = " Searching: "\ + SEARCHPATTERN[1:]\ + " --- Res {}/{} Ch {}/{} ".format( sidx + 1, len(found), ch+1, tot ) elif s == ord("N"): SEARCHPATTERN = "?"+SEARCHPATTERN[1:] if sidx == 0: if ch > 0: return -1 else: s = 0 msg = " Finished searching: " + SEARCHPATTERN[1:] + " " continue else: sidx -= 1 msg = " Searching: "\ + SEARCHPATTERN[1:]\ + " --- Res {}/{} Ch {}/{} ".format( sidx + 1, len(found), ch+1, tot ) elif s == curses.KEY_RESIZE: return s while found[sidx][0] not in list(range(y, y+rows-1)): if found[sidx][0] > y: y += rows - 1 else: y -= rows - 1 if y < 0: y = 0 for n, i in enumerate(found): attr = curses.A_REVERSE if n == sidx else curses.A_NORMAL pad.chgat(i[0], i[1], i[2], pad.getbkgd() | attr) SCREEN.clear() SCREEN.addstr(rows-1, 0, msg, curses.A_REVERSE) SCREEN.refresh() pad.refresh(y, 0, 0, x, rows-2, x+width) s = pad.getch() def find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y): ntoc = 0 for n, (i, j) in enumerate(zip(toc_idx, toc_sect)): if i <= index: if y >= toc_secid.get(j, 0): ntoc = n else: break return ntoc def reader(ebook, index, width, y, pctg, sect): global SHOWPROGRESS k = 0 if SEARCHPATTERN is None else ord("/") rows, cols = SCREEN.getmaxyx() x = (cols - width) // 2 contents = ebook.contents toc_name = ebook.toc_entries[0] toc_idx = ebook.toc_entries[1] toc_sect = ebook.toc_entries[2] toc_secid = {} chpath = contents[index] content = ebook.get_raw_text(chpath) parser = HTMLtoLines(set(toc_sect)) # parser = HTMLtoLines() # try: parser.feed(content) parser.close() # except: # pass src_lines, imgs, toc_secid = parser.get_lines(width) totlines = len(src_lines) + 1 # 1 extra line for suffix if y < 0 and totlines <= rows: y = 0 elif pctg is not None: y = round(pctg*totlines) else: y = y % totlines pad = curses.newpad(totlines, width + 2) # + 2 unnecessary if COLORSUPPORT: pad.bkgd(SCREEN.getbkgd()) pad.keypad(True) LOCALPCTG = [] for n, i in enumerate(src_lines): if re.search("\\[IMG:[0-9]+\\]", i): pad.addstr(n, width//2 - len(i)//2 + 1, i, curses.A_REVERSE) else: pad.addstr(n, 0, i) if CFG["EnableProgressIndicator"]: LOCALPCTG.append(len(re.sub("\s", "", i))) # chapter suffix ch_suffix = "***" # "\u3064\u3065\u304f" つづく try: pad.addstr(n+1, (width - len(ch_suffix))//2 + 1, ch_suffix) except curses.error: pass if CFG["EnableProgressIndicator"]: TOTALPCTG = sum(PERCENTAGE) TOTALLOCALPCTG = sum(PERCENTAGE[:index]) SCREEN.clear() SCREEN.refresh() # try except to be more flexible on terminal resize try: pad.refresh(y, 0, 0, x, rows-1, x+width) except curses.error: pass if sect != "": y = toc_secid.get(sect, 0) countstring = "" try: while True: if countstring == "": count = 1 else: count = int(countstring) if k in range(48, 58): # i.e., k is a numeral countstring = countstring + chr(k) else: if k in K["Quit"]: if k == 27 and countstring != "": countstring = "" else: savestate(ebook.path, index, width, y, y/totlines) sys.exit() elif k in K["ScrollUp"]: if y >= count: y -= count elif index != 0: return -1, width, -rows, None, "" elif k in K["PageUp"]: if y == 0 and index != 0: return -1, width, -rows, None, "" else: y = pgup(y, rows, LINEPRSRV, count) elif k in K["ScrollDown"]: if y + count <= totlines - rows: y += count elif index != len(contents)-1: return 1, width, 0, None, "" elif k in K["PageDown"]: if totlines - y - LINEPRSRV > rows: y += rows - LINEPRSRV # SCREEN.clear() # SCREEN.refresh() elif index != len(contents)-1: return 1, width, 0, None, "" elif k in K["NextChapter"]: ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y) if ntoc < len(toc_idx) - 1: if index == toc_idx[ntoc+1]: try: y = toc_secid[toc_sect[ntoc+1]] except KeyError: pass else: return toc_idx[ntoc+1]-index, width, 0, None, toc_sect[ntoc+1] elif k in K["PrevChapter"]: ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y) if ntoc > 0: if index == toc_idx[ntoc-1]: y = toc_secid.get(toc_sect[ntoc-1], 0) else: return toc_idx[ntoc-1]-index, width, 0, None, toc_sect[ntoc-1] elif k in K["BeginningOfCh"]: ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y) try: y = toc_secid[toc_sect[ntoc]] except (KeyError, IndexError): y = 0 elif k in K["EndOfCh"]: ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y) try: if toc_secid[toc_sect[ntoc+1]] - rows >= 0: y = toc_secid[toc_sect[ntoc+1]] - rows else: y = toc_secid[toc_sect[ntoc]] except (KeyError, IndexError): y = pgend(totlines, rows) elif k in K["ToC"]: if ebook.toc_entries == [[], [], []]: k = errmsg( "Table of Contents", "N/A: ToC is unavailable for this book.", K["ToC"] ) continue ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y) fllwd, _ = toc(toc_name, ntoc) if fllwd is not None: if fllwd in WINKEYS: k = fllwd continue if index == toc_idx[fllwd]: try: y = toc_secid[toc_sect[fllwd]] except KeyError: y = 0 else: return toc_idx[fllwd] - index, width, 0, None, toc_sect[fllwd] elif k in K["Metadata"]: k = meta(ebook) if k in WINKEYS: continue elif k in K["Help"]: k = help() if k in WINKEYS: continue elif k in K["Enlarge"] and (width + count) < cols - 4: width += count return 0, width, 0, y/totlines, "" elif k in K["Shrink"] and width >= 22: width -= count return 0, width, 0, y/totlines, "" elif k in K["SetWidth"]: if countstring == "": # if called without a count, toggle between 80 cols and full width if width != 80 and cols - 4 >= 80: return 0, 80, 0, y/totlines, "" else: return 0, cols - 4, 0, y/totlines, "" else: width = count if width < 20: width = 20 elif width >= cols - 4: width = cols - 4 return 0, width, 0, y/totlines, "" # elif k == ord("0"): # if width != 80 and cols - 2 >= 80: # return 0, 80, 0, y/totlines, "" # else: # return 0, cols - 2, 0, y/totlines, "" elif k in K["RegexSearch"]: fs = searching( pad, src_lines, width, y, index, len(contents) ) if fs in WINKEYS or fs is None: k = fs continue elif SEARCHPATTERN is not None: return fs, width, 0, None, "" else: y = fs elif k in K["OpenImage"] and VWR is not None: gambar, idx = [], [] for n, i in enumerate(src_lines[y:y+rows]): img = re.search("(?<=\\[IMG:)[0-9]+(?=\\])", i) if img is not None: gambar.append(img.group()) idx.append(n) impath = "" if len(gambar) == 1: impath = imgs[int(gambar[0])] elif len(gambar) > 1: p, i = 0, 0 while p not in K["Quit"] and p not in K["Follow"]: SCREEN.move(idx[i], x + width//2 + len(gambar[i]) + 1) SCREEN.refresh() curses.curs_set(1) p = pad.getch() if p in K["ScrollDown"]: i += 1 elif p in K["ScrollUp"]: i -= 1 i = i % len(gambar) curses.curs_set(0) if p in K["Follow"]: impath = imgs[int(gambar[i])] if impath != "": if ebook.__class__.__name__ == "Epub": impath = dots_path(chpath, impath) imgnm, imgbstr = ebook.get_img_bytestr(impath) k = open_media(pad, imgnm, imgbstr) continue elif k in K["SwitchColor"] and COLORSUPPORT and countstring in {"", "0", "1", "2"}: if countstring == "": count_color = curses.pair_number(SCREEN.getbkgd()) if count_color not in {2, 3}: count_color = 1 count_color = count_color % 3 else: count_color = count SCREEN.bkgd(curses.color_pair(count_color+1)) return 0, width, y, None, "" elif k in K["AddBookmark"]: defbmname_suffix = 1 defbmname = "Bookmark " + str(defbmname_suffix) occupiedbmnames = [i[0] for i in STATE["States"][ebook.path]["bmarks"]] while defbmname in occupiedbmnames: defbmname_suffix += 1 defbmname = "Bookmark " + str(defbmname_suffix) bmname = input_prompt(" Add bookmark ({}):".format(defbmname)) if bmname is not None: if bmname.strip() == "": bmname = defbmname STATE["States"][ebook.path]["bmarks"].append( [bmname, index, y, y/totlines] ) elif k in K["ShowBookmarks"]: if STATE["States"][ebook.path]["bmarks"] == []: k = text_win(lambda: ( "Bookmarks", "N/A: Bookmarks are not found in this book.", {ord("B")} ))() continue else: idxchoice = bookmarks(ebook.path) if idxchoice in WINKEYS: k = idxchoice continue elif idxchoice is not None: bmtojump = STATE["States"][ebook.path]["bmarks"][idxchoice] return bmtojump[1]-index, width, bmtojump[2], bmtojump[3], "" elif k in K["DefineWord"] and DICT is not None: word = input_prompt(" Define:") if word is not None: defin = define_word(word) if defin in WINKEYS: k = defin continue elif k in K["MarkPosition"]: jumnum = pad.getch() if jumnum in range(49, 58): JUMPLIST[chr(jumnum)] = [index, width, y, y/totlines] else: k = jumnum continue elif k in K["JumpToPosition"]: jumnum = pad.getch() if jumnum in range(49, 58) and chr(jumnum) in JUMPLIST.keys(): tojumpidxdiff = JUMPLIST[chr(jumnum)][0]-index tojumpy = JUMPLIST[chr(jumnum)][2] tojumpctg = None if JUMPLIST[chr(jumnum)][1] == width else JUMPLIST[chr(jumnum)][3] return tojumpidxdiff, width, tojumpy, tojumpctg, "" else: k = jumnum continue elif k in K["ShowHideProgress"] and CFG["EnableProgressIndicator"]: SHOWPROGRESS = not SHOWPROGRESS elif k == curses.KEY_RESIZE: savestate(ebook.path, index, width, y, y/totlines) # stated in pypi windows-curses page: # to call resize_term right after KEY_RESIZE if sys.platform == "win32": curses.resize_term(rows, cols) rows, cols = SCREEN.getmaxyx() else: rows, cols = SCREEN.getmaxyx() curses.resize_term(rows, cols) if cols < 22 or rows < 12: sys.exit("ERR: Screen was too small.") if cols <= width + 4: return 0, cols - 4, 0, y/totlines, "" else: return 0, width, y, None, "" countstring = "" if CFG["EnableProgressIndicator"]: PROGRESS = (TOTALLOCALPCTG + sum(LOCALPCTG[:y+rows-1])) / TOTALPCTG PROGRESSTR = "{}%".format(int(PROGRESS*100)) try: SCREEN.clear() SCREEN.addstr(0, 0, countstring) if SHOWPROGRESS and (cols-width-2)//2 > 3: SCREEN.addstr(0, cols-len(PROGRESSTR), PROGRESSTR) SCREEN.refresh() if totlines - y < rows: pad.refresh(y, 0, 0, x, totlines-y, x+width) else: pad.refresh(y, 0, 0, x, rows-1, x+width) except curses.error: pass k = pad.getch() except KeyboardInterrupt: savestate(ebook.path, index, width, y, y/totlines) sys.exit() def preread(stdscr, file): global COLORSUPPORT, SHOWPROGRESS, PERCENTAGE, SCREEN curses.use_default_colors() try: curses.init_pair(1, -1, -1) curses.init_pair(2, CFG["DarkColorFG"], CFG["DarkColorBG"]) curses.init_pair(3, CFG["LightColorFG"], CFG["LightColorBG"]) COLORSUPPORT = True except: COLORSUPPORT = False SCREEN = stdscr SCREEN.keypad(True) curses.curs_set(0) SCREEN.clear() rows, cols = SCREEN.getmaxyx() SCREEN.addstr(rows-1, 0, "Loading...") SCREEN.refresh() ebook = det_ebook_cls(file) if ebook.path in STATE["States"]: idx = STATE["States"][ebook.path]["index"] width = STATE["States"][ebook.path]["width"] y = STATE["States"][ebook.path]["pos"] else: STATE["States"][ebook.path] = {} STATE["States"][ebook.path]["bmarks"] = [] idx = 0 y = 0 width = 80 pctg = None if cols <= width + 4: width = cols - 4 pctg = STATE["States"][ebook.path].get("pctg", None) ebook.initialize() find_media_viewer() find_dict_client() parse_keys() SHOWPROGRESS = CFG["EnableProgressIndicator"] if SHOWPROGRESS: for i in ebook.contents: content = ebook.get_raw_text(i) parser = HTMLtoLines() # try: parser.feed(content) parser.close() # except: # pass src_lines = parser.get_lines() PERCENTAGE.append(sum([len(re.sub("\s", "", j)) for j in src_lines])) sec = "" while True: incr, width, y, pctg, sec = reader( ebook, idx, width, y, pctg, sec ) idx += incr def main(): args = [] if sys.argv[1:] != []: args += sys.argv[1:] if len({"-h", "--help"} & set(args)) != 0: print(__doc__.rstrip()) sys.exit() loadstate() if len({"-v", "--version", "-V"} & set(args)) != 0: print("Startup file loaded:") print(CFGFILE) print(STATEFILE) print() print("v" + __version__) print(__license__, "License") print("Copyright (c) 2019", __author__) print(__url__) sys.exit() if len({"-d"} & set(args)) != 0: args.remove("-d") dump = True else: dump = False if args == []: file = STATE["LastRead"] if not os.path.isfile(file): # print(__doc__) sys.exit("ERROR: Found no last read file.") elif os.path.isfile(args[0]): file = args[0] else: file = None todel = [] xitmsg = 0 val = 0 for i in STATE["States"].keys(): if not os.path.exists(i): todel.append(i) else: match_val = sum([ j.size for j in SM( None, i.lower(), " ".join(args).lower() ).get_matching_blocks() ]) if match_val >= val: val = match_val file = i if val == 0: xitmsg = "\nERROR: No matching file found in history." for i in todel: del STATE["States"][i] with open(STATEFILE, "w") as f: json.dump(STATE, f, indent=4) if len(args) == 1 and re.match(r"[0-9]+", args[0]) is not None: try: file = list(STATE["States"].keys())[int(args[0])-1] xitmsg = 0 except IndexError: xitmsg = "\nERROR: No matching file found in history." if xitmsg != 0 or "-r" in args: print("Reading history:") dig = len(str(len(STATE["States"].keys())+1)) for n, i in enumerate(STATE["States"].keys()): print(str(n+1).rjust(dig) + ("* " if STATE["LastRead"] == i else " ") + i) sys.exit(xitmsg) if dump: ebook = det_ebook_cls(file) ebook.initialize() for i in ebook.contents: content = ebook.get_raw_text(i) parser = HTMLtoLines() # try: parser.feed(content) parser.close() # except: # pass src_lines = parser.get_lines() # sys.stdout.reconfigure(encoding="utf-8") # Python>=3.7 for j in src_lines: sys.stdout.buffer.write((j+"\n\n").encode("utf-8")) sys.exit() else: curses.wrapper(preread, file) if __name__ == "__main__": main()