#!/usr/bin/python3 # vim:fileencoding=utf-8 """\ Usage: termpdf.py [options] example.pdf Options: -p n, --page-number n : open to page n -f n, --first-page n : set logical page number for page 1 to n --citekey key : bibtex citekey --nvim-listen-address path : path to nvim msgpack server -v, --version -h, --help """ __version__ = "0.1.1" __license__ = "MIT" __copyright__ = "Copyright (c) 2019" __author__ = "David Sanson" __url__ = "https://github.com/dsanson/termpdf.py" __viewer_shortcuts__ = """\ Keys: j, down, space: forward [count] pages k, up: back [count] pages l, right: forward [count] sections h, left: back [count] sections gg: go to beginning of document G: go to end of document [count]G: go to page [count] b: cycle through open documents s: visual mode t: table of contents M: show metadata f: show links on page r: rotate [count] quarter turns clockwise R: rotate [count] quarter turns counterclockwise c: toggle autocropping of margins a: toggle alpha transparency i: invert colors d: darken using TINT_COLOR [count]P: Set logical page number of current page to count -: zoom out (reflowable only) +: zoom in (reflowable only) ctrl-r: refresh q: quit """ import re import array import curses import fcntl import fitz import os import sys import termios import subprocess import zlib import shutil import select import hashlib import string import json import roman import pyperclip from time import sleep, monotonic from base64 import standard_b64encode from operator import attrgetter from collections import namedtuple from math import ceil from tempfile import NamedTemporaryFile # Class Definitions class Config: def __init__(self): self.BIBTEX = '' self.KITTYCMD = 'kitty --single-instance --instance-group=1' # open notes in a new OS window # self.KITTYCMD = 'kitty @ new-window' # open notes in split kitty window self.TINT_COLOR = 'antiquewhite2' self.URL_BROWSER_LIST = [ 'gnome-open', 'gvfs-open', 'xdg-open', 'kde-open', 'firefox', 'w3m', 'elinks', 'lynx' ] self.URL_BROWSER = None self.GUI_VIEWER = 'preview' self.NOTE_PATH = os.path.join(os.getenv("HOME"), 'inbox.org') def browser_detect(self): if sys.platform == 'darwin': self.URL_BROWSER = 'open' else: for i in self.URL_BROWSER_LIST: if shutil.which(i) is not None: self.URL_BROWSER = i break def load_config_file(self): config_file = os.path.join(os.getenv('HOME'), '.config', 'termpdf.py', 'config') if os.path.exists(config_file): with open(config_file, 'r') as f: prefs = json.load(f) for key in prefs: setattr(self, key, prefs[key]) class Buffers: def __init__(self): self.docs = [] self.current = 0 def goto_buffer(self,n): l = len(self.docs) - 1 if n > l: n = l elif n < 0: n = 0 self.current = n def cycle(self, count): l = len(self.docs) - 1 c = self.current + count if c > l: c = 0 self.current = c def close_buffer(self,n): del self.docs[n] if self.current == n: self.current = max(0,n-1) if len(self.docs) == 0: clean_exit() class Screen: def __init__(self): self.rows = 0 self.cols = 0 self.width = 0 self.height = 0 self.cell_width = 0 self.cell_height = 0 self.stdscr = None def get_size(self): fd = sys.stdout buf = array.array('H', [0, 0, 0, 0]) fcntl.ioctl(fd, termios.TIOCGWINSZ, buf) r,c,w,h = tuple(buf) cw = w // (c or 1) ch = h // (r or 1) self.rows = r self.cols = c self.width = w self.height = h self.cell_width = cw self.cell_height = ch def init_curses(self): os.environ.setdefault('ESCDELAY', '25') self.stdscr = curses.initscr() self.stdscr.clear() curses.noecho() curses.curs_set(0) curses.mousemask(curses.REPORT_MOUSE_POSITION | curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED | curses.BUTTON2_PRESSED | curses.BUTTON2_RELEASED | curses.BUTTON3_PRESSED | curses.BUTTON3_RELEASED | curses.BUTTON4_PRESSED | curses.BUTTON4_RELEASED | curses.BUTTON1_CLICKED | curses.BUTTON3_CLICKED | curses.BUTTON1_DOUBLE_CLICKED | curses.BUTTON1_TRIPLE_CLICKED | curses.BUTTON2_DOUBLE_CLICKED | curses.BUTTON2_TRIPLE_CLICKED | curses.BUTTON3_DOUBLE_CLICKED | curses.BUTTON3_TRIPLE_CLICKED | curses.BUTTON4_DOUBLE_CLICKED | curses.BUTTON4_TRIPLE_CLICKED | curses.BUTTON_SHIFT | curses.BUTTON_ALT | curses.BUTTON_CTRL) self.stdscr.keypad(True) # Handle our own escape codes for now # The first call to getch seems to clobber the statusbar. # So we make a dummy first call. self.stdscr.nodelay(True) self.stdscr.getch() self.stdscr.nodelay(False) def create_text_win(self, length, header): # calculate dimensions w = max(self.cols - 4, 60) h = self.rows - 2 x = int(self.cols / 2 - w / 2) y = 1 win = curses.newwin(h,w,y,x) win.box() win.addstr(1,2, '{:^{l}}'.format(header, l=(w-3))) self.stdscr.clear() self.stdscr.refresh() win.refresh() pad = curses.newpad(length,1000) pad.keypad(True) return win, pad def swallow_keys(self): self.stdscr.nodelay(True) k = self.stdscr.getch() end = monotonic() + 0.1 while monotonic() < end: self.stdscr.getch() self.stdscr.nodelay(False) def clear(self): sys.stdout.buffer.write('\033[2J'.encode('ascii')) def set_cursor(self,c,r): if c > self.cols: c = self.cols elif c < 0: c = 0 if r > self.rows: r = self.rows elif r < 0: r = 0 sys.stdout.buffer.write('\033[{};{}f'.format(r, c).encode('ascii')) def place_string(self,c,r,string): self.set_cursor(c,r) sys.stdout.write(string) sys.stdout.flush() def get_filehash(path): blocksize = 65536 hasher = hashlib.md5() with open(path, 'rb') as afile: buf = afile.read(blocksize) while len(buf) > 0: hasher.update(buf) buf = afile.read(blocksize) return hasher.hexdigest() def get_cachefile(path): filehash = get_filehash(path) cachedir = os.path.join(os.getenv("HOME"), '.config', 'termpdf.py', 'cache') os.makedirs(cachedir, exist_ok=True) cachefile = os.path.join(cachedir, filehash) return cachefile class Document(fitz.Document): """ An extension of the fitz.Document class, with extra attributes """ def __init__(self, filename=None, filetype=None, rect=None, width=0, height=0, fontsize=12): fitz.Document.__init__(self, filename, None, filetype, rect, width, height, fontsize) self.filename = filename self.citekey = None self.papersize = 3 self.layout(rect=fitz.PaperRect('A6'),fontsize=fontsize) self.page = 0 self.logicalpage = 1 self.prevpage = 0 self.pages = self.pageCount - 1 self.first_page_offset = 1 self.logical_pages = list(range(0 + self.first_page_offset, self.pages + self.first_page_offset)) self.chapter = 0 self.rotation = 0 self.fontsize = fontsize self.width = width self.height = height self.autocrop = False self.alpha = False self.invert = False self.tint = False self.tint_color = config.TINT_COLOR self.nvim = None self.nvim_listen_address = '/tmp/termpdf_nvim_bridge' self.page_states = [ Page_State(i) for i in range(0,self.pages + 1) ] def write_state(self): cachefile = get_cachefile(self.filename) state = {'citekey': self.citekey, 'papersize': self.papersize, 'page': self.page, 'logicalpage': self.logicalpage, 'first_page_offset': self.first_page_offset, 'chapter': self.chapter, 'rotation': self.rotation, 'autocrop': self.autocrop, 'alpha': self.alpha, 'invert': self.invert, 'tint': self.tint} with open(cachefile, 'w') as f: json.dump(state, f) def goto_page(self, p): # store prevpage self.prevpage = self.page # delete prevpage # self.clear_page(self.prevpage) # set new page if p > self.pages: self.page = self.pages elif p < 0: self.page = 0 else: self.page = p self.logicalpage = self.page_to_logical(self.page) def goto_logical_page(self, p): p = self.logical_to_page(p) self.goto_page(p) def next_page(self, count=1): self.goto_page(self.page + count) def prev_page(self, count=1): self.goto_page(self.page - count) def goto_chap(self, n): toc = self.getToC() if n > len(toc): n = len(toc) elif n < 0: n = 0 self.chapter = n try: self.goto_page(toc[n][2] - 1) except: self.goto_page(0) def current_chap(self): toc = self.getToC() p = self.page for i,ch in enumerate(toc): cp = ch[2] - 1 if cp > p: return i - 1 return len(toc) def next_chap(self, count=1): self.goto_chap(self.chapter + count) def prev_chap(self, count=1): self.goto_chap(self.chapter - count) def parse_pagelabels(self): if self.isPDF: from pdfrw import PdfReader from pagelabels import PageLabels, PageLabelScheme try: reader = PdfReader(self.filename) labels = PageLabels.from_pdf(reader) labels = sorted(labels, key=attrgetter('startpage')) except: labels = [] else: labels = [] return labels def set_pagelabel(self,count,style="arabic"): if self.isPDF: from pdfrw import PdfReader, PdfWriter from pagelabels import PageLabels, PageLabelScheme reader = PdfReader(self.filename) labels = PageLabels.from_pdf(reader) newlabels = PageLabels() for label in labels: if label.startpage != self.page: newlabels.append(label) newlabel = PageLabelScheme(startpage=self.page, style=style, prefix="", firstpagenum=count) newlabels.append(newlabel) newlabels.write(reader) writer = PdfWriter() writer.trailer = reader #print("writing new pagelabels...") writer.write(self.filename) # unused; using pdfrw instead def parse_pagelabels_pure(self): cat = self._getPDFroot() cat_str = self._getXrefString(cat) lines = cat_str.split('\n') labels = [] for line in lines: match = re.search('/PageLabels',line) if re.match(r'.*/PageLabels.*', line): labels += [line] print(labels) raise SystemExit def pages_to_logical_pages(self): labels = self.parse_pagelabels() self.logical_pages = list(range(0,self.pages + 1)) def divmod_alphabetic(n): a, b = divmod(n, 26) if b == 0: return a - 1, b + 26 return a, b def to_alphabetic(n): chars = [] while n > 0: n, d = divmod_alphabetic(n) chars.append(string.ascii_uppercase[d - 1]) return ''.join(reversed(chars)) if labels == []: for p in range(0,self.pages + 1): self.logical_pages[p] = str(p + self.first_page_offset) else: for p in range(0,self.pages + 1): for label in labels: if p >= label.startpage: lp = (p - label.startpage) + label.firstpagenum style = label.style prefix = label.prefix if style == 'roman uppercase': lp = prefix + roman.toRoman(lp) lp = lp.upper() elif style == 'roman lowercase': lp = prefix + roman.toRoman(lp) lp = lp.lower() elif style == 'alphabetic uppercase': lp = prefix + to_alphabetic(lp) elif style == 'alphabetic lowercase': lp = prefix + to_alphabetic(lp) lp = lp.lower() else: lp = prefix + str(lp) self.logical_pages[p] = lp def page_to_logical(self, p=None): if not p: p = self.page return self.logical_pages[p] def logical_to_page(self, lp=None): if not lp: lp = self.logicalpage try: p = self.logical_pages.index(str(lp)) except: # no such logical page in document p = 0 return p def make_link(self): p = self.page_to_logical(self.page) if self.citekey: return '[@{}, {}]'.format(self.citekey, p) else: return '({}, {}, {})'.format(self.metadata['author'],self.metadata['title'], p) def find_target(self, target, target_text): # since our pct calculation is at best an estimate # of the correct target page, we search for the first # few words of the original page on the surrounding pages # until we find a match for i in [0,1,-1,2,-2,3,-3,4,-4,5,-5,6,-6]: f = target + i match_text = self[f].getText().split() match_text = ' '.join(match_text) if target_text in match_text: return f return target def set_layout(self,papersize, adjustpage=True): # save a snippet of text from current page target_text = self[self.page].getText().split() if len(target_text) > 6: target_text = ' '.join(target_text[:6]) elif len(target_text) > 0: target_text = ' '.join(target_text) else: target_text = '' pct = (self.page + 1) / (self.pages + 1) sizes = ['a7','c7','b7','a6','c6','b6','a5','c5','b5','a4'] if papersize > len(sizes) - 1: papersize = len(sizes) - 1 elif papersize < 0: papersize = 0 p = sizes[papersize] self.layout(fitz.PaperRect(p)) self.pages = self.pageCount - 1 if adjustpage: target = int((self.pages + 1) * pct) - 1 target = self.find_target(target, target_text) self.goto_page(target) self.papersize = papersize self.pages_to_logical_pages() def mark_all_pages_stale(self): self.page_states = [ Page_State(i) for i in range(0,self.pages + 1) ] def clear_page(self, p): cmd = {'a': 'd', 'd': 'a', 'i': p + 1} write_gr_cmd(cmd) def cells_to_pixels(self, *coords): factor = self.page_states[self.page].factor l,t,_,_ = self.page_states[self.page].place pix_coords = [] for coord in coords: col = coord[0] row = coord[1] x = (col - l) * scr.cell_width / factor y = (row - t) * scr.cell_height / factor pix_coords.append((x,y)) return pix_coords def pixels_to_cells(self, *coords): factor = self.page_states[self.page].factor l,t,_,_ = self.page_states[self.page].place cell_coords = [] for coord in coords: x = coord[0] y = coord[1] col = (x * factor + l * scr.cell_width) / scr.cell_width row = (y * factor + t * scr.cell_height) / scr.cell_height col = int(col) row = int(row) cell_coords.append((col,row)) return cell_coords # get text that is inside a Rect def get_text_in_Rect(self, rect): from operator import itemgetter from itertools import groupby page = self.loadPage(self.page) words = page.getTextWords() mywords = [w for w in words if fitz.Rect(w[:4]) in rect] mywords.sort(key=itemgetter(3, 0)) # sort by y1, x0 of the word rect group = groupby(mywords, key=itemgetter(3)) text = [] for y1, gwords in group: text = text + [" ".join(w[4] for w in gwords)] return text # get text that intersects a Rect def get_text_intersecting_Rect(self, rect): from operator import itemgetter from itertools import groupby page = self.loadPage(self.page) words = page.getTextWords() mywords = [w for w in words if fitz.Rect(w[:4]).intersects(rect)] mywords.sort(key=itemgetter(3, 0)) # sort by y1, x0 of the word rect group = groupby(mywords, key=itemgetter(3)) text = [] for y1, gwords in group: text = text + [" ".join(w[4] for w in gwords)] return text def search_text(self,string): for p in range(self.page,self.pages): page_text = self.getPageText(p, 'text') if re.search(string,page_text): self.goto_page(p) return "match on page" return "no matches" def auto_crop(self,page): blocks = page.getTextBlocks() if len(blocks) > 0: crop = fitz.Rect(blocks[0][:4]) else: # don't try to crop empty pages crop = fitz.Rect(0,0,0,0) for block in blocks: b = fitz.Rect(block[:4]) crop = crop | b return crop def display_page(self, bar, p, display=True): page = self.loadPage(p) page_state = self.page_states[p] if self.autocrop and self.isPDF: page.setCropBox(page.MediaBox) crop = self.auto_crop(page) page.setCropBox(crop) elif self.isPDF: page.setCropBox(page.MediaBox) dw = scr.width dh = scr.height - scr.cell_height if self.rotation in [0,180]: pw = page.bound().width ph = page.bound().height else: pw = page.bound().height ph = page.bound().width # calculate zoom factor fx = dw / pw fy = dh / ph factor = min(fx,fy) self.page_states[p].factor = factor # calculate zoomed dimensions zw = factor * pw zh = factor * ph # calculate place in pixels, convert to cells pix_x = (dw / 2) - (zw / 2) pix_y = (dh / 2) - (zh / 2) l_col = int(pix_x / scr.cell_width) + 1 t_row = int(pix_y / scr.cell_height) r_col = l_col + int(zw / scr.cell_width) b_row = t_row + int(zh / scr.cell_height) place = (l_col, t_row, r_col, b_row) self.page_states[p].place = place # move cursor to place scr.set_cursor(l_col,t_row) # clear previous page # display image cmd = {'a': 'p', 'i': p + 1, 'z': -1} if page_state.stale: #or (display and not write_gr_cmd_with_response(cmd)): # get zoomed and rotated pixmap mat = fitz.Matrix(factor, factor) mat = mat.preRotate(self.rotation) pix = page.getPixmap(matrix = mat, alpha=self.alpha) if self.invert: pix.invertIRect() if self.tint: tint = fitz.utils.getColor(self.tint_color) red = int(tint[0] * 256) blue = int(tint[1] * 256) green = int(tint[2] * 256) pix.tintWith(red,blue,green) # build cmd to send to kitty cmd = {'i': p + 1, 't': 'd', 's': pix.width, 'v': pix.height} if self.alpha: cmd['f'] = 32 else: cmd['f'] = 24 # transfer the image write_chunked(cmd, pix.samples) if display: # clear prevpage self.clear_page(self.prevpage) # display the image cmd = {'a': 'p', 'i': p + 1, 'z': -1} success = write_gr_cmd_with_response(cmd) if not success: self.page_states[p].stale = True bar.message = 'failed to load page ' + str(p+1) bar.update(self) self.page_states[p].stale = False scr.swallow_keys() def show_toc(self, bar): toc = self.getToC() if not toc: bar.message = "No ToC available" return self.page_states[self.page ].stale = True self.clear_page(self.page) scr.clear() def init_pad(toc): win, pad = scr.create_text_win(len(toc), 'Table of Contents') y,x = win.getbegyx() h,w = win.getmaxyx() span = [] for i, ch in enumerate(toc): text = '{}{}'.format(' ' * (ch[0] - 1), ch[1]) pad.addstr(i,0,text) span.append(len(text)) return win,pad,y,x,h,w,span win,pad,y,x,h,w,span = init_pad(toc) keys = shortcuts() index = self.current_chap() j = 0 while True: for i, ch in enumerate(toc): attr = curses.A_REVERSE if index == i else curses.A_NORMAL pad.chgat(i, 0, span[i], attr) pad.refresh(j, 0, y + 3, x + 2, y + h - 2, x + w - 3) key = scr.stdscr.getch() if key in keys.REFRESH: scr.clear() scr.get_size() scr.init_curses() self.set_layout(self.papersize) self.mark_all_pages_stale() init_pad(toc) elif key in keys.QUIT: clean_exit() elif key == 27 or key in keys.SHOW_TOC: scr.clear() return elif key in keys.NEXT_PAGE: index = min(len(toc) - 1, index + 1) elif key in keys.PREV_PAGE: index = max(0, index - 1) elif key in keys.OPEN: scr.clear() self.goto_page(toc[index][2] - 1) return if index > j + (h - 5): j += 1 if index < j: j -= 1 def update_metadata_from_bibtex(self): if not self.citekey: return bib = bib_from_key([self.citekey]) bib_entry = bib.entries[self.citekey] metadata = self.metadata title = bib_entry.fields['title'] title = title.replace('{','') title = title.replace('}','') metadata['title'] = title authors = [author for author in bib_entry.persons['author']] if len(authors) == 0: authors = [author for author in bib_entry.persons['editor']] authorNames = '' for author in authors: if authorNames != '': authorNames += ' & ' if author.first_names: authorNames += ' '.join(author.first_names) + ' ' if author.last_names: authorNames += ' '.join(author.last_names) metadata['author'] = authorNames if 'Keywords' in bib_entry.fields: metadata['keywords'] = bib_entry.fields['Keywords'] self.setMetadata(metadata) try: self.saveIncr() except: pass def show_meta(self, bar): meta = self.metadata if not meta: bar.message = "No metadata available" return self.page_states[self.page].stale = True self.clear_page(self.page) scr.clear() def init_pad(metadata): win, pad = scr.create_text_win(len(meta), 'Metadata') y,x = win.getbegyx() h,w = win.getmaxyx() span = [] for i, mkey in enumerate(meta): text = '{}: {}'.format(mkey,meta[mkey]) pad.addstr(i,0,text) span.append(len(text)) return win,pad,y,x,h,w,span win,pad,y,x,h,w,span = init_pad(meta) keys = shortcuts() index = 0 j = 0 while True: for i, mkey in enumerate(meta): attr = curses.A_REVERSE if index == i else curses.A_NORMAL pad.chgat(i, 0, span[i], attr) pad.refresh(j, 0, y + 3, x + 2, y + h - 2, x + w - 3) key = scr.stdscr.getch() if key in keys.REFRESH: scr.clear() scr.get_size() scr.init_curses() self.set_layout(self.papersize) self.mark_all_pages_stale() init_pad(meta) elif key in keys.QUIT: clean_exit() elif key == 27 or key in keys.SHOW_META: scr.clear() return elif key in keys.NEXT_PAGE: index = min(len(meta) - 1, index + 1) elif key in keys.PREV_PAGE: index = max(0, index - 1) elif key in keys.UPDATE_FROM_BIB: self.update_metadata_from_bibtex() meta = self.metadata win,pad,y,x,h,w,span = init_pad(meta) elif key in keys.OPEN: # TODO edit metadata pass if index > j + (h - 5): j += 1 if index < j: j -= 1 def goto_link(self,link): kind = link['kind'] # 0 == no destination # 1 == internal link # 2 == uri # 3 == launch link # 5 == external pdf link if kind == 0: pass elif kind == 1: self.goto_page(link['page']) elif kind == 2: subprocess.run([config.URL_BROWSER, link['uri']], check=True) elif kind == 3: # not sure what these are pass elif kind == 5: path = link['fileSpec'] opts = {'page': link['page']} #load_doc(path,opts) pass def show_links(self, bar): links = self[self.page].getLinks() urls = [link for link in links if 0 < link['kind'] < 3] if not urls: bar.message = "No links on page" return self.page_states[self.page].stale = True self.clear_page(self.page) scr.clear() def init_pad(urls): win, pad = scr.create_text_win(len(urls), 'URLs') y,x = win.getbegyx() h,w = win.getmaxyx() span = [] for i, url in enumerate(urls): anchor_text = self.get_text_intersecting_Rect(url['from']) if len(anchor_text) > 0: anchor_text = anchor_text[0] else: anchor_text = '' if url['kind'] == 2: link_text = url['uri'] else: link_text = url['page'] text = '{}: {}'.format(anchor_text, link_text) pad.addstr(i,0,text) span.append(len(text)) return win,pad,y,x,h,w,span win,pad,y,x,h,w,span = init_pad(urls) keys = shortcuts() index = 0 j = 0 while True: for i, url in enumerate(urls): attr = curses.A_REVERSE if index == i else curses.A_NORMAL pad.chgat(i, 0, span[i], attr) pad.refresh(j, 0, y + 3, x + 2, y + h - 2, x + w - 3) key = scr.stdscr.getch() if key in keys.REFRESH: scr.clear() scr.get_size() scr.init_curses() self.set_layout(self.papersize) self.mark_all_pages_stale() init_pad(urls) elif key in keys.QUIT: clean_exit() elif key == 27 or key in keys.SHOW_LINKS: scr.clear() return elif key in keys.NEXT_PAGE: index = min(len(urls) - 1, index + 1) elif key in keys.PREV_PAGE: index = max(0, index - 1) elif key in keys.OPEN: self.goto_link(urls[index]) scr.clear() return if index > j + (h - 5): j += 1 if index < j: j -= 1 def view_text(self): pass def init_neovim_bridge(self): try: from pynvim import attach except: raise SystemExit('pynvim unavailable') try: self.nvim = attach('socket', path=self.nvim_listen_address) except: ncmd = 'env NVIM_LISTEN_ADDRESS={} nvim {}'.format(self.nvim_listen_address, config.NOTE_PATH) try: os.system('{} {}'.format(config.KITTYCMD,ncmd)) except: raise SystemExit('unable to open new kitty window') end = monotonic() + 5 # 5 second time out while monotonic() < end: try: self.nvim = attach('socket', path=self.nvim_listen_address) break except: # keep trying every tenth of a second sleep(0.1) def send_to_neovim(self,text,append=False): try: self.nvim.api.strwidth('testing') except: self.init_neovim_bridge() if not self.nvim: return if append: line = self.nvim.funcs.line('$') self.nvim.funcs.append(line, text) self.nvim.funcs.cursor(self.nvim.funcs.line('$'),0) else: line = self.nvim.funcs.line('.') self.nvim.funcs.append(line, text) self.nvim.funcs.cursor(line + len(text), 0) class Page_State: def __init__(self, p): self.number = p self.stale = True self.factor = (1,1) self.place = (0,0,40,40) self.crop = None class status_bar: def __init__(self): self.cols = 40 self.rows = 1 self.cmd = ' ' self.message = ' ' self.counter = ' ' self.format = '{} {:^{me_w}} {}' self.bar = '' def update(self, doc): p = doc.page_to_logical() pc = doc.page_to_logical(doc.pages) self.counter = '[{}/{}]'.format(p, pc) w = self.cols = scr.cols cm_w = len(self.cmd) co_w = len(self.counter) me_w = w - cm_w - co_w - 2 if len(self.message) > me_w: self.message = self.message[:me_w - 1] + '…' self.bar = self.format.format(self.cmd, self.message, self.counter, me_w=me_w) scr.place_string(1,scr.rows,self.bar) class shortcuts: def __init__(self): self.GOTO_PAGE = {ord('G')} self.GOTO = {ord('g')} self.NEXT_PAGE = {ord('j'), curses.KEY_DOWN, ord(' ')} self.PREV_PAGE = {ord('k'), curses.KEY_UP} self.GO_BACK = {ord('p')} self.NEXT_CHAP = {ord('l'), curses.KEY_RIGHT} self.PREV_CHAP = {ord('h'), curses.KEY_LEFT} self.BUFFER_CYCLE = {ord('b')} self.BUFFER_CYCLE_REV = {ord('B')} self.HINTS = {ord('f')} self.OPEN = {curses.KEY_ENTER, curses.KEY_RIGHT, 10} self.SHOW_TOC = {ord('t')} self.SHOW_META = {ord('M')} self.UPDATE_FROM_BIB = {ord('b')} self.SHOW_LINKS = {ord('f')} self.TOGGLE_TEXT_MODE = {ord('T')} self.ROTATE_CW = {ord('r')} self.ROTATE_CCW = {ord('R')} self.VISUAL_MODE = {ord('s')} self.SELECT = {ord('v')} self.YANK = {ord('y')} self.INSERT_NOTE = {ord('n')} self.APPEND_NOTE = {ord('a')} self.TOGGLE_AUTOCROP = {ord('c')} self.TOGGLE_ALPHA = {ord('A')} self.TOGGLE_INVERT = {ord('i')} self.TOGGLE_TINT = {ord('d')} self.SET_PAGE_LABEL = {ord('P')} self.SET_PAGE_ALT = {ord('I')} self.INC_FONT = {ord('=')} self.DEC_FONT = {ord('-')} self.OPEN_GUI = {ord('X')} self.REFRESH = {18, curses.KEY_RESIZE} # CTRL-R self.QUIT = {3, ord('q')} self.DEBUG = {ord('D')} # Kitty graphics functions def detect_support(): return write_gr_cmd_with_response(dict(a='q', s=1, v=1, i=1), standard_b64encode(b'abcd')) def serialize_gr_command(cmd, payload=None): cmd = ','.join('{}={}'.format(k, v) for k, v in cmd.items()) ans = [] w = ans.append w(b'\033_G'), w(cmd.encode('ascii')) if payload: w(b';') w(payload) w(b'\033\\') return b''.join(ans) def write_gr_cmd(cmd, payload=None): sys.stdout.buffer.write(serialize_gr_command(cmd, payload)) sys.stdout.flush() def write_gr_cmd_with_response(cmd, payload=None): # rewrite using swallow keys to be nonblocking write_gr_cmd(cmd, payload) resp = b'' while resp[-2:] != b'\033\\': resp += sys.stdin.buffer.read(1) if b'OK' in resp: return True else: return False def write_chunked(cmd, data): if cmd['f'] != 100: data = zlib.compress(data) cmd['o'] = 'z' data = standard_b64encode(data) while data: chunk, data = data[:4096], data[4096:] m = 1 if data else 0 cmd['m'] = m write_gr_cmd(cmd, chunk) cmd.clear() # bibtex functions def bib_from_field(field,regex): if shutil.which('bibtool') is not None: from pybtex.database import parse_string select = "select {" + field + " " select = select + '\"{}\"'.format(regex) select = select + "}" text = subprocess.run(["bibtool", "-r", "biblatex", "--", select, config.BIBTEX], stdout=subprocess.PIPE, universal_newlines = True) if text.returncode != 0: return None bib = parse_string(text.stdout,'bibtex') if len(bib.entries) == 0: return None else: from pybtex.database import parse_file bib = parse_file(config.BIBTEX,'bibtex') return bib def bib_from_key(citekeys): field = '$key' regex = '\|'.join(citekeys) regex = '^' + regex + '$' return bib_from_field(field,regex) def citekey_from_path(path): path = os.path.basename(path) bib = bib_from_field('File',path) if bib and len(bib.entries) == 1: citekey = list(bib.entries)[0] return citekey def path_from_citekey(citekey): bib = bib_from_key([citekey]) if bib == None: raise SystemExit('Cannot find file associated with ' + citekey) if len(bib.entries) == 1: try: paths = bib.entries[citekey].fields["File"] except: raise SystemExit('No file for ' + citekey) paths = paths.split(';') exts = ['.pdf', '.xps', '.cbz', '.fb2' ] extsf = ['.epub', '.oxps'] extsl = ['.html'] best = [path for path in paths if path[-4:] in exts] okay = [path for path in paths if path[-5:] in extsf] worst = [path for path in paths if path[-5:] in extsl] if len(best) != 0: return best[0] elif len(okay) != 0: return okay[0] elif len(worst) != 0: return worst[0] return None # Command line helper functions def print_version(): print(__version__) print(__license__, 'License') print(__copyright__, __author__) print(__url__) raise SystemExit def print_help(): print(__doc__.rstrip()) print() print(__viewer_shortcuts__) raise SystemExit() def parse_args(args): files = [] opts = {} if len(args) == 1: args = args + ['-h'] args = args[1:] if len({'-h', '--help'} & set(args)) != 0: print_help() elif len({'-v', '--version'} & set(args)) != 0: print_version() skip = False for i,arg in enumerate(args): if skip: skip = not skip elif arg in {'-p', '--page-number'}: try: opts['logicalpage'] = int(args[i + 1]) skip = True except: raise SystemExit('No valid page number specified') elif arg in {'-f', '--first-page'}: try: opts['first_page_offset'] = int(args[i + 1]) skip = True except: raise SystemExit('No valid first page specified') elif arg in {'--nvim-listen-address'}: try: opts['nvim_listen_address'] = args[i + 1] skip = True except: raise SystemExit('No address specified') elif arg in {'--citekey'}: try: opts['citekey'] = args[i + 1] skip = True except: raise SystemExit('No citekey specified') elif arg in {'-o', '--open'}: try: citekey = args[i+1] except: raise SystemExit('No citekey specified') opts['citekey'] = citekey path = path_from_citekey(citekey) if path: if path[-5:] == '.html': subprocess.run([config.URL_BROWSER, path], check=True) print("Opening html file in browser") elif path[-5:] == '.docx': # TODO: support for docx files raise SystemExit('Cannot open ' + path) else: files += [path] else: raise SystemExit('No file for ' + citekey) skip = True elif os.path.isfile(arg): files = files + [arg] elif os.path.isfile(arg.strip('\"')): files = files + [arg.strip('\"')] elif os.path.isfile(arg.strip('\'')): files = files + [arg.strip('\'')] elif re.match('^-', arg): raise SystemExit('Unknown option: ' + arg) else: raise SystemExit('Can\'t open file: ' + arg) if len(files) == 0: raise SystemExit('No file to open') return files, opts def clean_exit(message=''): for doc in bufs.docs: # save current state doc.write_state() # close the document doc.close() # close curses scr.stdscr.keypad(False) curses.echo() curses.curs_set(1) curses.endwin() raise SystemExit(message) def get_text_in_rows(doc,left,right, selection): l,t,r,b = doc.page_states[doc.page].place top = (l + left,t + selection[0] - 1) bottom = (l + right,t + selection[1]) top_pix, bottom_pix = doc.cells_to_pixels(top,bottom) rect = fitz.Rect(top_pix, bottom_pix) select_text = doc.get_text_in_Rect(rect) link = doc.make_link() select_text = select_text + [link] return (' '.join(select_text)) # Viewer functions def visual_mode(doc,bar): l,t,r,b = doc.page_states[doc.page].place width = (r - l) + 1 def highlight_row(row,left,right, fill='▒', color='yellow'): if color == 'yellow': cc = 33 elif color == 'blue': cc = 34 elif color == 'none': cc = 0 fill = fill[0] * (right - left) scr.set_cursor(l + left,row) sys.stdout.buffer.write('\033[{}m'.format(cc).encode('ascii')) #sys.stdout.buffer.write('\033[{}m'.format(cc + 10).encode('ascii')) sys.stdout.write(fill) sys.stdout.flush() sys.stdout.buffer.write(b'\033[0m') sys.stdout.flush() def unhighlight_row(row): # scr.set_cursor(l,row) # sys.stdout.write(' ' * width) # sys.stdout.flush() highlight_row(row,0,width,fill=' ',color='none') def highlight_selection(selection,left,right, fill='▒', color='blue'): a = min(selection) b = max(selection) for r in range(a,b+1): highlight_row(r,left,right,fill,color) def unhighlight_selection(selection): highlight_selection(selection,0,width,fill=' ',color='none') current_row = t left = 0 right = width select = False selection = [current_row,current_row] count_string = '' while True: bar.cmd = count_string bar.update(doc) unhighlight_selection([t,b]) if select: highlight_selection(selection,left,right,color='blue') else: highlight_selection(selection,left,right,color='yellow') if count_string == '': count = 1 else: count = int(count_string) keys = shortcuts() key = scr.stdscr.getch() if key in range(48,58): #numerals count_string = count_string + chr(key) elif key in keys.QUIT: clean_exit() elif key == 27 or key in keys.VISUAL_MODE: unhighlight_selection([t,b]) return elif key in keys.SELECT: if select: select = False else: select = True selection = [current_row, current_row] count_string = '' elif key in keys.NEXT_PAGE: current_row += count current_row = min(current_row,b) if select: selection[1] = current_row else: selection = [current_row,current_row] count_string = '' elif key in keys.PREV_PAGE: current_row -= count current_row = max(current_row,t) if select: selection[1] = current_row else: selection = [current_row,current_row] count_string = '' elif key in keys.NEXT_CHAP: right = min(width,right + count) count_string = '' elif key in { ord('L'), curses.KEY_SRIGHT }: right = max(left + 1,right - count) count_string = '' elif key in keys.PREV_CHAP: left = max(0,left - count) count_string = '' elif key in { ord('H'), curses.KEY_SLEFT }: left = min(left + count,right - 1) count_string = '' elif key in keys.GOTO_PAGE: current_row = b if select: selection[1] = current_row else: selection = [current_row,current_row] count_string = '' elif key in keys.GOTO: current_row = t if select: selection[1] = current_row else: selection = [current_row,current_row] count_string = '' elif key in keys.YANK: if selection == [None,None]: selection = [current_row, current_row] selection.sort() select_text = get_text_in_rows(doc,left,right,selection) select_text = '> ' + select_text pyperclip.copy(select_text) unhighlight_selection([t,b]) bar.message = 'copied' return elif key in keys.INSERT_NOTE: if selection == [None,None]: selection = [current_row, current_row] selection.sort() select_text = [''] select_text = ['#+BEGIN_QUOTE'] select_text += [get_text_in_rows(doc,left,right,selection)] select_text += ['#+END_QUOTE'] select_text += [''] doc.send_to_neovim(select_text, append=False) unhighlight_selection([t,b]) return elif key in keys.APPEND_NOTE: if selection == [None,None]: selection = [current_row, current_row] selection.sort() note_header = ' Notes on {}, {}'.format(doc.metadata['author'], doc.metadata['title']) if doc.citekey: note_header = doc.citekey + note_header select_text = ['** ' + note_header] select_text += [''] select_text = ['#+BEGIN_QUOTE'] select_text += [get_text_in_rows(doc,left,right,selection)] select_text += ['#+END_QUOTE'] select_text += [''] doc.send_to_neovim(select_text,append=True) unhighlight_selection([t,b]) return def view(doc): scr.get_size() scr.init_curses() if not detect_support(): raise SystemExit( 'Terminal does not support kitty graphics protocol' ) scr.swallow_keys() bar = status_bar() if doc.citekey: bar.message = doc.citekey count_string = "" stack = [0] keys = shortcuts() while True: bar.cmd = ''.join(map(chr,stack[::-1])) bar.update(doc ) doc.display_page(bar,doc.page) if count_string == "": count = 1 else: count = int(count_string) key = scr.stdscr.getch() if key == -1: pass elif key in keys.REFRESH: scr.clear() scr.get_size() scr.init_curses() doc.set_layout(doc.papersize) doc.mark_all_pages_stale() elif key == 27: # quash stray escape codes scr.swallow_keys() count_string = "" stack = [0] elif stack[0] in keys.BUFFER_CYCLE and key in range(48,58): bufs.goto_buffer(int(chr(key)) - 1) doc = bufs.docs[bufs.current] doc.goto_logical_page(doc.logicalpage) doc.set_layout(doc.papersize,adjustpage=False) doc.mark_all_pages_stale() if doc.citekey: bar.message = doc.citekey count_string = "" stack = [0] elif stack[0] in keys.BUFFER_CYCLE and key == ord('d'): bufs.close_buffer(bufs.current) doc = bufs.docs[bufs.current] doc.goto_logical_page(doc.logicalpage) doc.set_layout(doc.papersize,adjustpage=False) doc.mark_all_pages_stale() if doc.citekey: bar.message = doc.citekey count_string = "" stack = [0] elif stack[0] in keys.BUFFER_CYCLE and key in keys.BUFFER_CYCLE: bufs.cycle(count) doc = bufs.docs[bufs.current] doc.goto_logical_page(doc.logicalpage) doc.set_layout(doc.papersize,adjustpage=False) doc.mark_all_pages_stale() if doc.citekey: bar.message = doc.citekey count_string = "" stack = [0] elif key in keys.BUFFER_CYCLE_REV: bufs.cycle(-count) doc = bufs.docs[bufs.current] doc.goto_logical_page(doc.logicalpage) doc.set_layout(doc.papersize,adjustpage=False) doc.mark_all_pages_stale() if doc.citekey: bar.message = doc.citekey count_string = "" stack = [0] elif key in range(48,58): #numerals stack = [key] + stack count_string = count_string + chr(key) elif key in keys.QUIT: clean_exit() elif key in keys.GOTO_PAGE: if count_string == "": p = doc.page_to_logical(doc.pages) else: p = count doc.goto_logical_page(p) count_string = "" stack = [0] elif key in keys.NEXT_PAGE: doc.next_page(count) count_string = "" stack = [0] elif key in keys.PREV_PAGE: doc.prev_page(count) count_string = "" stack = [0] elif key in keys.GO_BACK: doc.goto_page(doc.prevpage) count_string = "" stack = [0] elif key in keys.NEXT_CHAP: doc.next_chap(count) count_string = "" stack = [0] elif key in keys.PREV_CHAP: doc.prev_chap(count) count_string = "" stack = [0] elif stack[0] in keys.GOTO and key in keys.GOTO: doc.goto_page(0) count_string = "" stack = [0] elif key in keys.ROTATE_CW: doc.rotation = (doc.rotation + 90 * count) % 360 doc.mark_all_pages_stale() count_string = '' stack = [0] elif key in keys.ROTATE_CCW: doc.rotation = (doc.rotation - 90 * count) % 360 doc.mark_all_pages_stale() count_string = "" stack = [0] elif key in keys.TOGGLE_AUTOCROP: doc.autocrop = not doc.autocrop doc.mark_all_pages_stale() count_string = "" stack = [0] elif key in keys.TOGGLE_ALPHA: doc.alpha = not doc.alpha doc.mark_all_pages_stale() count_string = "" stack = [0] elif key in keys.TOGGLE_INVERT: doc.invert = not doc.invert doc.mark_all_pages_stale() count_string = "" stack = [0] elif key in keys.TOGGLE_TINT: doc.tint = not doc.tint doc.mark_all_pages_stale() count_string = "" stack = [0] elif key in keys.SHOW_TOC: doc.show_toc(bar) count_string = "" stack = [0] elif key in keys.SHOW_META: doc.show_meta(bar) count_string = "" stack = [0] elif key in keys.SHOW_LINKS: doc.show_links(bar) count_string = "" stack = [0] elif key in keys.TOGGLE_TEXT_MODE: doc.view_text() count_string = "" stack = [0] elif key in keys.INC_FONT: doc.set_layout(doc.papersize - count) doc.mark_all_pages_stale() count_string = "" stack = [0] elif key in keys.DEC_FONT: doc.set_layout(doc.papersize + count) doc.mark_all_pages_stale() count_string = "" stack = [0] elif key in keys.VISUAL_MODE: visual_mode(doc,bar) count_string = "" stack = [0] elif key in keys.INSERT_NOTE: text = doc.make_link() doc.send_to_neovim(text,append=False) count_string = "" stack = [0] elif key in keys.APPEND_NOTE: text = doc.make_link() doc.send_to_neovim(text,append=True) count_string = "" stack = [0] elif key in keys.SET_PAGE_LABEL: if doc.isPDF: doc.set_pagelabel(count,'arabic') else: doc.first_page_offset = count - doc.page doc.pages_to_logical_pages() count_string = "" stack = [0] elif key in keys.SET_PAGE_ALT: if doc.isPDF: doc.set_pagelabel(count,'roman lowercase') else: doc.first_page_offset = count - doc.page doc.pages_to_logical_pages() count_string = "" elif key == ord('/'): scr.place_string(1,scr.rows,"/") curses.echo() scr.set_cursor(2,scr.rows) s = scr.stdscr.getstr() search_text = s.decode('utf-8') curses.noecho() bar.message = doc.search_text(search_text) elif key in keys.OPEN_GUI: subprocess.run([config.GUI_VIEWER, doc.filename], check=True) elif key in keys.DEBUG: pass elif key in range(48,257): #printable characters stack = [key] + stack # config is global config = Config() config.load_config_file() if not config.URL_BROWSER: config.browser_detect() # buffers list is global bufs = Buffers() # screen is global scr = Screen() def main(args=sys.argv): if not sys.stdin.isatty(): raise SystemExit('Not an interactive tty') scr.get_size() if scr.width == 0: raise SystemExit( 'Terminal does not support reporting screen sizes via the TIOCGWINSZ ioctl' ) paths, opts = parse_args(args) for path in paths: try: doc = Document(path) except: raise SystemExit('Unable to open ' + files[0]) # load saved file state cachefile = get_cachefile(doc.filename) if os.path.exists(cachefile): with open(cachefile, 'r') as f: state = json.load(f) for key in state: setattr(doc, key, state[key]) bufs.docs += [doc] for doc in bufs.docs: if not doc.citekey: doc.citekey = citekey_from_path(doc.filename) doc = bufs.docs[bufs.current] # load cli settings for key in opts: setattr(doc, key, opts[key]) # generate logical pages doc.pages_to_logical_pages() # normalize page number doc.goto_logical_page(doc.logicalpage) # apply layout settings doc.set_layout(doc.papersize,adjustpage=False) view(doc) if __name__ == '__main__': main()