#!/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__ = "2021.8.14" __license__ = "GPL-3.0" __author__ = "Benawi Adha" __email__ = "benawiadha@gmail.com" __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 multiprocessing import xml.etree.ElementTree as ET from urllib.parse import unquote from html import unescape from html.parser import HTMLParser from difflib import SequenceMatcher as SM from functools import wraps try: import mobi MOBISUPPORT = True except ModuleNotFoundError: MOBISUPPORT = False if shutil.which("pico2wave") is None\ or shutil.which("play") is None: TTSSUPPORT = False else: TTSSUPPORT = True SPEAKING = False # -1 is default terminal fg/bg colors CFG = { "DefaultViewer": "auto", "DictionaryClient": "auto", "ShowProgressIndicator": True, "PageScrollAnimation": True, "MouseSupport": False, "StartWithDoubleSpread": False, "TTSSpeed": 1, "DarkColorFG": 252, "DarkColorBG": 235, "LightColorFG": 238, "LightColorBG": 253, "Keys": { "ScrollUp": "k", "ScrollDown": "j", "PageUp": "h", "PageDown": "l", "HalfScreenUp": "^u", "HalfScreenDown": "C-d", "NextChapter": "n", "PrevChapter": "p", "BeginningOfCh": "g", "EndOfCh": "G", "Shrink": "-", "Enlarge": "+", "SetWidth": "=", "Metadata": "M", "DefineWord": "d", "TableOfContents": "t", "Follow": "f", "OpenImage": "o", "RegexSearch": "/", "ShowHideProgress": "s", "MarkPosition": "m", "JumpToPosition": "`", "AddBookmark": "b", "ShowBookmarks": "B", "Quit": "q", "Help": "?", "SwitchColor": "c", "TTSToggle": "!", "DoubleSpreadToggle": "D" } } 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}, "TableOfContents": {9, ord("\t")}, "Follow": {10}, "Quit": {3, 27, 304} } WINKEYS = set() CFGFILE = "" STATEFILE = "" COLORSUPPORT = False SEARCHPATTERN = None VWR = None DICT = None SCREEN = None JUMPLIST = {} SHOWPROGRESS = CFG["ShowProgressIndicator"] MULTIPROC = False if multiprocessing.cpu_count() == 1 else True ALLPREVLETTERS = [] SUMALLLETTERS = 0 PROC_COUNTLETTERS = None ANIMATE = None SPREAD = 1 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") 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("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 = [[], [], []] # 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 try: 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("#") try: idx = contents.index(unquote(src[0])) except ValueError: continue 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("") except AttributeError: pass def get_raw_text(self, chpath): # using try-except block to catch # zlib.error: Error -3 while decompressing data: invalid distance too far back # caused by forking PROC_COUNTLETTERS while True: try: content = self.file.open(chpath).read() break except: continue return content.decode("utf-8") def get_img_bytestr(self, impath): return impath, self.file.read(impath) def cleanup(self): return class Mobi(Epub): def __init__(self, filemobi): self.path = os.path.abspath(filemobi) self.file, _ = mobi.extract(filemobi) def get_meta(self): meta = [] # why self.file.read(self.rootfile) problematic with open(os.path.join(self.rootdir, "content.opf")) as f: cont = ET.parse(f).getroot() 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): self.rootdir = os.path.join(self.file, "mobi7") self.toc = os.path.join(self.rootdir, "toc.ncx") self.version = "2.0" self.contents = [] self.toc_entries = [[], [], []] with open(os.path.join(self.rootdir, "content.opf")) as f: cont = ET.parse(f).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(os.path.join(self.rootdir, unquote(j[1]))) contents.append(unquote(j[1])) manifest.remove(j) # TODO: test is break necessary break with open(self.toc) as f: toc = ET.parse(f).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("#") try: idx = contents.index(unquote(src[0])) except ValueError: continue 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): # using try-except block to catch # zlib.error: Error -3 while decompressing data: invalid distance too far back # caused by forking PROC_COUNTLETTERS while True: try: with open(chpath) as f: content = f.read() break except: continue # return content.decode("utf-8") return content def get_img_bytestr(self, impath): # TODO: test on windows # if impath "Images/asdf.png" is problematic with open(os.path.join(self.rootdir, impath), "rb") as f: src = f.read() return impath, src def cleanup(self): shutil.rmtree(self.file) return class Azw3(Epub): def __init__(self, fileepub): self.path = os.path.abspath(fileepub) self.tmpdir, self.tmpepub = mobi.extract(fileepub) self.file = zipfile.ZipFile(self.tmpepub, "r") def cleanup(self): shutil.rmtree(self.tmpdir) return class FictionBook: NS = { "FB2": "http://www.gribuser.ru/xml/fictionbook/2.0" } def __init__(self, filefb): self.path = os.path.abspath(filefb) self.file = filefb 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): cont = ET.parse(self.file) self.root = cont.getroot() self.contents = [] self.toc_entries = [[], [], []] 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) def cleanup(self): return class HTMLtoLines(HTMLParser): para = {"p", "div"} inde = {"q", "dt", "dd", "blockquote"} pref = {"pre"} bull = {"li"} hide = {"script", "style", "head"} ital = {"i", "em"} bold = {"b", "strong"} # 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 self.sectsindex = {} self.initital = [] self.initbold = [] 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] += "_{" # NOTE: "img" and "image" # In HTML, both are startendtag (no need endtag) # but in XHTML both need endtag elif tag in {"img", "image"}: for i in attrs: if (tag == "img" and i[0] == "src")\ or (tag == "image" and i[0].endswith("href")): self.text.append("[IMG:{}]".format(len(self.imgs))) self.imgs.append(unquote(i[1])) # formatting elif tag in self.ital: if len(self.initital) == 0 or len(self.initital[-1]) == 4: self.initital.append([len(self.text)-1, len(self.text[-1])]) elif tag in self.bold: if len(self.initbold) == 0 or len(self.initbold[-1]) == 4: self.initbold.append([len(self.text)-1, len(self.text[-1])]) if self.sects != {""}: for i in attrs: if i[0] == "id" and i[1] in self.sects: # self.text[-1] += " (#" + i[1] + ") " # self.sectsindex.append([len(self.text), i[1]]) self.sectsindex[len(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"): if (tag == "img" and i[0] == "src")\ or (tag == "image" and i[0].endswith("href")): self.text.append("[IMG:{}]".format(len(self.imgs))) self.imgs.append(unquote(i[1])) self.text.append("") # sometimes attribute "id" is inside "startendtag" # especially html from mobi module (kindleunpack fork) if self.sects != {""}: for i in attrs: if i[0] == "id" and i[1] in self.sects: # self.text[-1] += " (#" + i[1] + ") " self.sectsindex[len(self.text)-1] = i[1] 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 in {"img", "image"}: self.text.append("") # formatting elif tag in self.ital: if len(self.initital[-1]) == 2: self.initital[-1] += [len(self.text)-1, len(self.text[-1])] elif len(self.initital[-1]) == 4: self.initital[-1][2:4] = [len(self.text)-1, len(self.text[-1])] elif tag in self.bold: if len(self.initbold[-1]) == 2: self.initbold[-1] += [len(self.text)-1, len(self.text[-1])] elif len(self.initbold[-1]) == 4: self.initbold[-1][2:4] = [len(self.text)-1, len(self.text[-1])] 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 = [], {} formatting = { "italic": [], "bold": [] } tmpital = [] for i in self.initital: # handle uneven markup # like but no if len(i) == 4: if i[0] == i[2]: tmpital.append([i[0], i[1], i[3]-i[1]]) elif i[0] == i[2]-1: tmpital.append([i[0], i[1], len(self.text[i[0]])-i[1]]) tmpital.append([i[2], 0, i[3]]) elif i[2]-i[0] > 1: tmpital.append([i[0], i[1], len(self.text[i[0]])-i[1]]) for j in range(i[0]+1, i[2]): tmpital.append([j, 0, len(self.text[j])]) tmpital.append([i[2], 0, i[3]]) tmpbold = [] for i in self.initbold: if len(i) == 4: if i[0] == i[2]: tmpbold.append([i[0], i[1], i[3]-i[1]]) elif i[0] == i[2]-1: tmpbold.append([i[0], i[1], len(self.text[i[0]])-i[1]]) tmpbold.append([i[2], 0, i[3]]) elif i[2]-i[0] > 1: tmpbold.append([i[0], i[1], len(self.text[i[0]])-i[1]]) for j in range(i[0]+1, i[2]): tmpbold.append([j, 0, len(self.text[j])]) tmpbold.append([i[2], 0, i[3]]) if width == 0: return self.text for n, i in enumerate(self.text): startline = len(text) # findsect = re.search(r"(?<= \(#).*?(?=\) )", i) # if findsect is not None and findsect.group() in self.sects: # i = i.replace(" (#" + findsect.group() + ") ", "") # # i = i.replace(" (#" + findsect.group() + ") ", " "*(5+len(findsect.group()))) # sect[findsect.group()] = len(text) if n in self.sectsindex.keys(): sect[self.sectsindex[n]] = len(text) if n in self.idhead: text += [i.rjust(width//2 + len(i)//2)] + [""] formatting["bold"] += [[j, 0, len(text[j])] for j in range(startline, len(text))] 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) + [""] # TODO: inline formats for indents endline = len(text) # -1 tmp_filtered = [j for j in tmpital if j[0] == n] for j in tmp_filtered: tmp_count = 0 # for k in text[startline:endline]: for k in range(startline, endline): if n in self.idbull|self.idinde: if tmp_count <= j[1]: tmp_start = [k, j[1]-tmp_count+3] if tmp_count <= j[1]+j[2]: tmp_end = [k, j[1]+j[2]-tmp_count+3] tmp_count += len(text[k]) - 2 else: if tmp_count <= j[1]: tmp_start = [k, j[1]-tmp_count] if tmp_count <= j[1]+j[2]: tmp_end = [k, j[1]+j[2]-tmp_count] tmp_count += len(text[k]) + 1 if tmp_start[0] == tmp_end[0]: formatting["italic"].append(tmp_start + [tmp_end[1]-tmp_start[1]]) elif tmp_start[0] == tmp_end[0]-1: formatting["italic"].append(tmp_start + [len(text[tmp_start[0]])-tmp_start[1]+1]) formatting["italic"].append([tmp_end[0], 0, tmp_end[1]]) # elif tmp_start[0]-tmp_end[1] > 1: else: formatting["italic"].append(tmp_start + [len(text[tmp_start[0]])-tmp_start[1]+1]) for l in range(tmp_start[0]+1, tmp_end[0]): formatting["italic"].append([l, 0, len(text[l])]) formatting["italic"].append([tmp_end[0], 0, tmp_end[1]]) tmp_filtered = [j for j in tmpbold if j[0] == n] for j in tmp_filtered: tmp_count = 0 # for k in text[startline:endline]: for k in range(startline, endline): if n in self.idbull|self.idinde: if tmp_count <= j[1]: tmp_start = [k, j[1]-tmp_count+3] if tmp_count <= j[1]+j[2]: tmp_end = [k, j[1]+j[2]-tmp_count+3] tmp_count += len(text[k]) - 2 else: if tmp_count <= j[1]: tmp_start = [k, j[1]-tmp_count] if tmp_count <= j[1]+j[2]: tmp_end = [k, j[1]+j[2]-tmp_count] tmp_count += len(text[k]) + 1 if tmp_start[0] == tmp_end[0]: formatting["bold"].append(tmp_start + [tmp_end[1]-tmp_start[1]]) elif tmp_start[0] == tmp_end[0]-1: formatting["bold"].append(tmp_start + [len(text[tmp_start[0]])-tmp_start[1]+1]) formatting["bold"].append([tmp_end[0], 0, tmp_end[1]]) # elif tmp_start[0]-tmp_end[1] > 1: else: formatting["bold"].append(tmp_start + [len(text[tmp_start[0]])-tmp_start[1]+1]) for l in range(tmp_start[0]+1, tmp_end[0]): formatting["bold"].append([l, 0, len(text[l])]) formatting["bold"].append([tmp_end[0], 0, tmp_end[1]]) return text, self.imgs, sect, formatting class Board: MAXCHUNKS = 32000-2 # lines def __init__(self, totlines, width): self.chunks = [self.MAXCHUNKS*(i+1)-1 for i in range(totlines // self.MAXCHUNKS)] self.chunks += [] if totlines % self.MAXCHUNKS == 0 else [totlines % self.MAXCHUNKS + (0 if self.chunks == [] else self.chunks[-1])] # -1 self.pad = curses.newpad(min([self.MAXCHUNKS+2, totlines]), width) self.pad.keypad(True) # self.current_chunk = 0 self.y = 0 self.width = width def feed(self, textlist): self.text = textlist def feed_format(self, formatting): self.formatting = formatting def format(self): chunkidx = self.find_chunkidx(self.y) start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx-1]+1 end_chunk = self.chunks[chunkidx] # if y in range(start_chunk, end_chunk+1): for i in [j for j in self.formatting["italic"] if start_chunk <= j[0] and j[0] <= end_chunk]: try: self.pad.chgat(i[0] % self.MAXCHUNKS, i[1], i[2], SCREEN.getbkgd()|curses.A_ITALIC) except: pass for i in [j for j in self.formatting["bold"] if start_chunk <= j[0] and j[0] <= end_chunk]: try: self.pad.chgat(i[0] % self.MAXCHUNKS, i[1], i[2], SCREEN.getbkgd()|curses.A_BOLD) except: pass def getch(self): return self.pad.getch() def bkgd(self, bg): self.pad.bkgd(SCREEN.getbkgd()) def find_chunkidx(self, y): for n, i in enumerate(self.chunks): if y <= i: return n def paint_text(self, chunkidx=0): self.pad.clear() start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx-1]+1 end_chunk = self.chunks[chunkidx] for n, i in enumerate(self.text[start_chunk:end_chunk+1]): if re.search("\\[IMG:[0-9]+\\]", i): self.pad.addstr(n, self.width//2 - len(i)//2 + 1, i, curses.A_REVERSE) else: self.pad.addstr(n, 0, i) # chapter suffix ch_suffix = "***" # "\u3064\u3065\u304f" つづく try: self.pad.addstr(n+1, (self.width - len(ch_suffix))//2 + 1, ch_suffix) except curses.error: pass # if chunkidx < len(self.chunks)-1: # try: # self.pad.addstr(self.MAXCHUNKS+1, (self.width - len(ch_suffix))//2 + 1, ch_suffix) # except curses.error: # pass def chgat(self, y, x, n, attr): chunkidx = self.find_chunkidx(y) start_chunk = 0 if chunkidx == 0 else self.chunks[chunkidx-1]+1 end_chunk = self.chunks[chunkidx] if y in range(start_chunk, end_chunk+1): self.pad.chgat(y % self.MAXCHUNKS, x, n, attr) def getbkgd(self): return self.pad.getbkgd() def refresh(self, y, b, c, d, e, f): chunkidx = self.find_chunkidx(y) if chunkidx != self.find_chunkidx(self.y): self.paint_text(chunkidx) self.y = y self.format() # TODO: not modulo by self.MAXCHUNKS but self.pad.height self.pad.refresh(y % self.MAXCHUNKS, b, c, d, e, f) self.y = y 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 None, 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 None, (0 if index == 0 else index-1), index # chwin.redrawwin() # chwin.refresh() elif key_chwin == ord("d") and allowdel: resk, resp, _ = choice_win()( lambda: ("Delete '{}'?".format( ch_list[index] ), ["(Y)es", "(N)o"], 0, {ord("n")}) )() if resk is not None: key_chwin = resk continue elif resp == 0: return None, (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 None, 0, None else: return None, 1, None elif key_chwin in WINKEYS - key: chwin.clear() chwin.refresh() return key_chwin, index, 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() if key_chwin == curses.KEY_MOUSE: mouse_event = curses.getmouse() if mouse_event[4] == curses.BUTTON4_PRESSED: key_chwin = list(K["ScrollUp"])[0] elif mouse_event[4] == 2097152: key_chwin = list(K["ScrollDown"])[0] elif mouse_event[4] == curses.BUTTON1_DOUBLE_CLICKED: if mouse_event[2] >= 6 and mouse_event[2] < rows-4\ and mouse_event[2] < 6+totlines: index = mouse_event[2]-6+y key_chwin = list(K["Follow"])[0] elif mouse_event[4] == curses.BUTTON1_CLICKED\ and mouse_event[2] >= 6 and mouse_event[2] < rows-4\ and mouse_event[2] < 6+totlines: if index == mouse_event[2]-6+y: key_chwin = list(K["Follow"])[0] continue index = mouse_event[2]-6+y elif mouse_event[4] == curses.BUTTON3_CLICKED: key_chwin = list(K["Quit"])[0] chwin.clear() chwin.refresh() return None, None, None return wrapper return inner_f def show_loader(scr): scr.clear() rows, cols = scr.getmaxyx() scr.addstr((rows-1)//2, (cols-1)//2, "\u231B") # scr.addstr(((rows-2)//2)+1, (cols-len(msg))//2, msg) scr.refresh() 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: cfg_tmp = CFG with open(CFGFILE) as f: cfg = json.load(f) for i in cfg_tmp: if i != "Keys" and i in cfg: cfg_tmp[i] = cfg[i] cfg_tmp["Keys"].update(cfg["Keys"]) CFG = cfg_tmp with open(STATEFILE) as f: STATE = json.load(f) except FileNotFoundError: pass if sys.platform == "win32": CFG["PageScrollAnimation"] = False def parse_keys(): global WINKEYS for i in CFG["Keys"].keys(): parsedk = CFG["Keys"][i] if len(parsedk) == 1: parsedk = ord(parsedk) elif parsedk[:-1] in {"^", "C-"}: parsedk = ord(parsedk[-1]) - 96 # Reference: ASCII chars else: sys.exit("ERROR: Keybindings {}".format(i)) try: K[i].add(parsedk) except KeyError: K[i] = {parsedk} WINKEYS = {curses.KEY_RESIZE}|K["Metadata"]|K["Help"]|\ K["TableOfContents"]|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) if MULTIPROC: # PROC_COUNTLETTERS.terminate() # PROC_COUNTLETTERS.kill() # PROC_COUNTLETTERS.join() try: PROC_COUNTLETTERS.kill() except AttributeError: PROC_COUNTLETTERS.terminate() 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["TableOfContents"] @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 = "Key Bindings:\n" dig = max([len(i) for i in CFG["Keys"].values()]) + 2 for i in CFG["Keys"].keys(): src += "{} {}\n".format( CFG["Keys"][i].rjust(dig), " ".join(re.findall("[A-Z][^A-Z]*", i)) ) 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], None retk, idx, todel = choice_win(True)(lambda: ("Bookmarks", bmarkslist, idx, {ord("B")}) )() if todel is not None: del STATE["States"][ebookpath]["bmarks"][todel] else: return retk, idx def truncate(teks, subte, maxlen, startsub=0): if startsub > maxlen: raise ValueError("Var startsub cannot be bigger than maxlen.") elif len(teks) <= maxlen: return teks else: lensu = len(subte) beg = teks[:startsub] mid = subte if lensu <= maxlen - startsub else subte[:maxlen-startsub] end = teks[startsub+lensu-maxlen:] if lensu < maxlen - startsub else "" return beg+mid+end def safe_curs_set(state): try: curses.curs_set(state) except: return 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) safe_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: # NOTE: getch() only handles ascii # to handle wide char like: é, use get_wch() # ipt = stat.getch() ipt = stat.get_wch() # get_wch() return ambiguous type # str for string input but int for function or special keys if type(ipt) == str: ipt = ord(ipt) if ipt == 27: stat.clear() stat.refresh() curses.echo(0) safe_curs_set(0) return elif ipt == 10: stat.clear() stat.refresh() curses.echo(0) safe_curs_set(0) return init_text elif ipt in {8, 127, curses.KEY_BACKSPACE}: init_text = init_text[:-1] elif ipt == curses.KEY_RESIZE: stat.clear() stat.refresh() curses.echo(0) safe_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) safe_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) elif MOBISUPPORT and filext == ".mobi": return Mobi(file) elif MOBISUPPORT and filext == ".azw3": return Azw3(file) elif not MOBISUPPORT and filext in {".mobi", ".azw3"}: sys.exit("""ERROR: Format not supported. (Supported: epub, fb2). To get mobi and azw3 support, install mobi module from pip. $ pip install mobi""") else: sys.exit("ERROR: 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() if SPREAD == 2: width = (cols-7)//2 x = (cols - width) // 2 if SPREAD == 1: x = (cols - width) // 2 else: x = 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) if SPREAD == 2: if y+rows < len(src): pad.refresh(y+rows-1, 0, 0, cols-2-width, rows-2, cols-2) 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()) pad.format() 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 # TODO if y+rows-1 > pad.chunks[pad.find_chunkidx(y)]: y = pad.chunks[pad.find_chunkidx(y)] + 1 while found[sidx][0] not in list(range(y, y+(rows-1)*SPREAD )): if found[sidx][0] > y: y += (rows - 1)*SPREAD else: y -= (rows - 1)*SPREAD 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) if SPREAD == 2: if y+rows < len(src): pad.refresh(y+rows-1, 0, 0, cols-2-width, rows-2, cols-2) 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 count_pct_async(ebook, allprev, sumlet): perch = [] for n, i in enumerate(ebook.contents): content = ebook.get_raw_text(i) parser = HTMLtoLines() # try: parser.feed(content) parser.close() # except: # pass src_lines = parser.get_lines() allprev[n] = sum(perch) perch.append(sum([len(re.sub("\s", "", j)) for j in src_lines])) sumlet.value = sum(perch) def count_pct(ebook): perch = [] allprev = [] 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() allprev.append(sum(perch)) perch.append(sum([len(re.sub("\s", "", j)) for j in src_lines])) sumlet = sum(perch) return allprev, sumlet def count_max_reading_pg(ebook): global ALLPREVLETTERS, SUMALLLETTERS, PROC_COUNTLETTERS, MULTIPROC if MULTIPROC: try: ALLPREVLETTERS = multiprocessing.Array("i", len(ebook.contents)) SUMALLLETTERS = multiprocessing.Value("i", 0) PROC_COUNTLETTERS = multiprocessing.Process( target=count_pct_async, args=( ebook, ALLPREVLETTERS, SUMALLLETTERS ) ) # forking PROC_COUNTLETTERS will raise # zlib.error: Error -3 while decompressing data: invalid distance too far back PROC_COUNTLETTERS.start() except: MULTIPROC = False if not MULTIPROC: ALLPREVLETTERS, SUMALLLETTERS = count_pct(ebook) def speaking(text): global SPEAKING SPEAKING = True rows, _ = SCREEN.getmaxyx() SCREEN.addstr(rows-1, 0, ' Speaking! ', curses.A_REVERSE) SCREEN.refresh() SCREEN.timeout(1) try: _, path = tempfile.mkstemp(suffix=".wav") subprocess.call( [ "pico2wave", "-w", path, text], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) SPEAKER = subprocess.Popen( [ "play", path, "tempo", str(CFG["TTSSpeed"])], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) while True: if SPEAKER.poll() is not None: k = ord("l") break k = SCREEN.getch() if k == curses.KEY_MOUSE: mouse_event = curses.getmouse() if mouse_event[4] == curses.BUTTON2_CLICKED: k = list(K["Quit"])[0] elif mouse_event[4] == curses.BUTTON1_CLICKED: if mouse_event[1] < SCREEN.getmaxyx()[1]//2: k = list(K["PageUp"])[0] else: k = list(K["PageDown"])[0] elif mouse_event[4] == curses.BUTTON4_PRESSED: k = list(K["ScrollUp"])[0] elif mouse_event[4] == 2097152: k = list(K["ScrollDown"])[0] # if k != -1: if k in K["Quit"]|K["PageUp"]|K["PageDown"]|K["ScrollUp"]|K["ScrollDown"]|{curses.KEY_RESIZE}: SPEAKER.terminate() # SPEAKER.kill() break finally: SCREEN.timeout(-1) os.remove(path) if k in K["Quit"]: SPEAKING = False k = None return k def reader(ebook, index, width, y, pctg, sect): global SHOWPROGRESS, SPEAKING, ANIMATE, SPREAD k = 0 if SEARCHPATTERN is None else ord("/") rows, cols = SCREEN.getmaxyx() mincols_doublespr = 2 + 22 + 3 + 22 + 2 if cols < mincols_doublespr: SPREAD = 1 if SPREAD == 2: width = (cols-7)//2 x = (cols - width) // 2 if SPREAD == 1: x = (cols - width) // 2 else: x = 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, formatting = parser.get_lines(width) totlines = len(src_lines) + 1 # 1 extra line for suffix if y < 0 and totlines <= rows*SPREAD: y = 0 elif pctg is not None: y = round(pctg*totlines) else: y = y % totlines pad = Board(totlines, width) pad.feed(src_lines) pad.feed_format(formatting) # this make curses.A_REVERSE not working # put before paint_text if COLORSUPPORT: pad.bkgd(SCREEN.getbkgd()) pad.paint_text(0) pad.format() LOCALPCTG = [] for i in src_lines: LOCALPCTG.append(len(re.sub("\s", "", i))) 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 = "" svline = "dontsave" 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["TTSToggle"] and TTSSUPPORT: # tospeak = "\n".join(src_lines[y:y+rows-1]) tospeak = "" for i in src_lines[y:y+(rows*SPREAD)]: if re.match(r"^\s*$", i) is not None: tospeak += "\n. \n" else: tospeak += re.sub(r"\[IMG:[0-9]+\]", "Image", i) + " " k = speaking(tospeak) if totlines-y <= rows and index == len(contents)-1: SPEAKING = False continue elif k in K["DoubleSpreadToggle"]: if cols < mincols_doublespr: k = text_win(lambda: ( "Screen is too small", "Min: {} cols x {} rows".format(mincols_doublespr, 12), {ord("D")} ))() SPREAD = (SPREAD % 2) + 1 return 0, width, 0, y/totlines, "" elif k in K["ScrollUp"]: if SPREAD == 2: k = list(K["PageUp"])[0] continue if count > 1: svline = y - 1 if y >= count: y -= count elif y == 0 and index != 0: ANIMATE = "prev" return -1, width, -rows, None, "" else: y = 0 elif k in K["PageUp"]: if y == 0 and index != 0: ANIMATE = "prev" tmp_parser = HTMLtoLines() tmp_parser.feed(ebook.get_raw_text(contents[index-1])) tmp_parser.close() return -1, width, rows*SPREAD*(len(tmp_parser.get_lines(width)[0])//(rows*SPREAD)), None, "" else: if y >= rows*SPREAD*count: ANIMATE = "prev" y -= rows*SPREAD*count else: y = 0 elif k in K["ScrollDown"]: if SPREAD == 2: k = list(K["PageDown"])[0] continue if count > 1: svline = y + rows - 1 if y + count <= totlines - rows: y += count elif y >= totlines - rows and index != len(contents)-1: ANIMATE = "next" return 1, width, 0, None, "" else: y = totlines - rows elif k in K["PageDown"]: if totlines - y > rows*SPREAD: ANIMATE = "next" if y+(rows*SPREAD) > pad.chunks[pad.find_chunkidx(y)]: y = pad.chunks[pad.find_chunkidx(y)] + 1 else: y += rows*SPREAD # SCREEN.clear() # SCREEN.refresh() elif index != len(contents)-1: ANIMATE = "next" return 1, width, 0, None, "" elif k in K["HalfScreenUp"]|K["HalfScreenDown"]: countstring = str(rows//2) k = list(K["ScrollUp" if k in K["HalfScreenUp"] else "ScrollDown"])[0] continue 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["TableOfContents"]: if ebook.toc_entries == [[], [], []]: k = errmsg( "Table of Contents", "N/A: TableOfContents is unavailable for this book.", K["TableOfContents"] ) continue ntoc = find_curr_toc_id(toc_idx, toc_sect, toc_secid, index, y) rettock, fllwd, _ = toc(toc_name, ntoc) if rettock is not None: # and rettock in WINKEYS: k = rettock continue elif fllwd is not None: 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 and SPREAD == 1: width += count return 0, width, 0, y/totlines, "" elif k in K["Shrink"] and width >= 22 and SPREAD == 1: width -= count return 0, width, 0, y/totlines, "" elif k in K["SetWidth"] and SPREAD == 1: 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*SPREAD)]): 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] % rows, (x if idx[i]//rows==0 else cols-2-width) + width//2 + len(gambar[i]) + 1) SCREEN.refresh() safe_curs_set(1) p = pad.getch() if p in K["ScrollDown"]: i += 1 elif p in K["ScrollUp"]: i -= 1 i = i % len(gambar) safe_curs_set(0) if p in K["Follow"]: impath = imgs[int(gambar[i])] if impath != "": try: if ebook.__class__.__name__ in {"Epub", "Azw3"}: impath = dots_path(chpath, impath) imgnm, imgbstr = ebook.get_img_bytestr(impath) k = open_media(pad, imgnm, imgbstr) continue except Exception as e: errmsg("Error Opening Image", str(e), set()) 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)) pad.format() 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: retk, idxchoice = bookmarks(ebook.path) if retk is not None: k = retk 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 == curses.KEY_RESIZE: k = word continue elif 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"]: 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("ERROR: Screen was too small (min 22cols x 12rows).") if cols <= width + 4: return 0, cols - 4, 0, y/totlines, "" else: return 0, width, y, None, "" countstring = "" if svline != "dontsave": pad.chgat(svline, 0, width, SCREEN.getbkgd()|curses.A_UNDERLINE) try: # NOTE: clear() will delete everything but doesnt need refresh() # while refresh() id necessary whenever a char added to scr SCREEN.clear() SCREEN.addstr(0, 0, countstring) SCREEN.refresh() if CFG["PageScrollAnimation"] and ANIMATE is not None: for i in range(width+1): curses.napms(1) # to optimize performance if i == width: # to cleanup screen from animation residue # actually only problematic for "next" animation # but just to be safe SCREEN.clear() SCREEN.refresh() if ANIMATE == "next": pad.refresh(y, 0, 0, x+width-i, rows-1, x+width) if SPREAD == 2 and y+rows < totlines: pad.refresh(y+rows, 0, 0, cols-2-i, rows-1, cols-2) elif ANIMATE == "prev": pad.refresh(y, width-i-1, 0, x, rows-1, x+i) if SPREAD == 2 and y+rows < totlines: pad.refresh(y+rows, width-i-1, 0, cols-2-width, rows-1, cols-2-width+i) else: pad.refresh(y, 0, 0, x, rows-1, x+width) if SPREAD == 2 and y+rows < totlines: pad.refresh(y+rows, 0, 0, cols-2-width, rows-1, cols-2) ANIMATE = None LOCALSUMALLL = SUMALLLETTERS.value if MULTIPROC else SUMALLLETTERS if SHOWPROGRESS and (cols-width-2)//2 > 3 and LOCALSUMALLL != 0: PROGRESS = (ALLPREVLETTERS[index] + sum(LOCALPCTG[:y+rows-1])) / LOCALSUMALLL PROGRESSTR = "{}%".format(int(PROGRESS*100)) SCREEN.addstr(0, cols-len(PROGRESSTR), PROGRESSTR) SCREEN.refresh() except curses.error: pass if SPEAKING: k = list(K["TTSToggle"])[0] continue k = pad.getch() if k == curses.KEY_MOUSE: mouse_event = curses.getmouse() if mouse_event[4] == curses.BUTTON1_CLICKED: if mouse_event[1] < cols//2: k = list(K["PageUp"])[0] else: k = list(K["PageDown"])[0] elif mouse_event[4] == curses.BUTTON3_CLICKED: k = list(K["TableOfContents"])[0] elif mouse_event[4] == curses.BUTTON4_PRESSED: k = list(K["ScrollUp"])[0] elif mouse_event[4] == 2097152: k = list(K["ScrollDown"])[0] elif mouse_event[4] == curses.BUTTON4_PRESSED+curses.BUTTON_CTRL: k = list(K["Enlarge"])[0] elif mouse_event[4] == 2097152+curses.BUTTON_CTRL: k = list(K["Shrink"])[0] elif mouse_event[4] == curses.BUTTON2_CLICKED: k = list(K["TTSToggle"])[0] if svline != "dontsave": pad.chgat(svline, 0, width, SCREEN.getbkgd()|curses.A_NORMAL) svline = "dontsave" except KeyboardInterrupt: savestate(ebook.path, index, width, y, y/totlines) sys.exit() def preread(stdscr, file): global COLORSUPPORT, SHOWPROGRESS, SCREEN, SPREAD try: curses.use_default_colors() 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) safe_curs_set(0) if CFG["MouseSupport"]: curses.mousemask(-1) # curses.mouseinterval(0) SCREEN.clear() _, cols = SCREEN.getmaxyx() show_loader(SCREEN) ebook = det_ebook_cls(file) try: 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) try: ebook.initialize() except Exception as e: sys.exit("ERROR: Badly-structured ebook.\n"+str(e)) find_media_viewer() find_dict_client() parse_keys() SHOWPROGRESS = CFG["ShowProgressIndicator"] SPREAD = 2 if CFG["StartWithDoubleSpread"] else 1 count_max_reading_pg(ebook) sec = "" while True: incr, width, y, pctg, sec = reader( ebook, idx, width, y, pctg, sec ) idx += incr show_loader(SCREEN) finally: ebook.cleanup() def main(): termc, termr = shutil.get_terminal_size() 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 = "ERROR: No matching file found in history." if xitmsg != 0 or "-r" in args: print("Reading history:") dig = len(str(len(STATE["States"].keys())+1)) tcols = termc - dig - 2 for n, i in enumerate(STATE["States"].keys()): p = i.replace(os.getenv("HOME"), "~") print("{}{} {}".format( str(n+1).rjust(dig), "*" if i == STATE["LastRead"] else " ", truncate(p, "...", tcols, 7) )) sys.exit(xitmsg) if dump: ebook = det_ebook_cls(file) try: try: ebook.initialize() except Exception as e: sys.exit("ERROR: Badly-structured ebook.\n"+str(e)) 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")) finally: ebook.cleanup() sys.exit() else: if termc < 22 or termr < 12: sys.exit("ERROR: Screen was too small (min 22cols x 12rows).") curses.wrapper(preread, file) if __name__ == "__main__": main()