1779 lines
54 KiB
Python
1779 lines
54 KiB
Python
|
#!/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()
|
||
|
|